mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
Two TUI-mode findings from the 2026-05-31 audit (ADR 0007):
1. The fail-loud LAN gate refused TUI boot only when CLAUDE_BIND === "0.0.0.0",
but binding to a concrete LAN IP, a Tailscale 100.x address, or IPv6 :: is
equally network-exposed and slipped through — letting any reachable peer drive
the operator's full-filesystem claude session. New isLoopbackBind() treats only
127.0.0.0/8 / ::1 / localhost / ::ffff:127.0.0.1 as safe; the gate now trips on
any non-loopback bind (OCP_TUI_ALLOW_LAN=1 escape hatch retained).
2. verifyEntrypoint() was implemented and unit-tested but never wired: readTuiTranscript
discarded the events and returned only text, so a silent degrade to the metered
sdk-cli (Agent SDK) billing pool — which still returns text but costs money — went
undetected. readTuiTranscript now returns { text, entrypoint }; runTuiTurn passes it
through; callClaudeTui logs a "tui_entrypoint_mismatch" warning when configured cli
mode yields a non-cli entrypoint (including null/unverified). callClaudeTui still
returns Promise<string>, so singleflight/cache/completion downstream is unchanged.
ALIGNMENT.md: TUI is a proxy-side execution-mode bridge (ADR 0007); this adds a local
bind classifier (startup gate) + a non-fatal log assertion of an existing classification
— no Anthropic operation forwarded, so a cli.js citation is N/A under Rule 2. No
blacklisted tokens or port literals introduced; alignment.yml passes.
Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10) — verified
isLoopbackBind via truth table (no exposed address misclassified as loopback — the
dangerous direction), the full string→{text,entrypoint} contract ripple across all
callers (Promise<string> preserved), the mismatch logic (warns on sdk-cli AND null in
cli mode, silent for auto/off, non-fatal), and ALIGNMENT N/A via a fetch/header grep.
Minors are process-only (this commit body; a future lib/ extraction of the test-mirrored
helper). npm test → 181 passed, 0 failed.
Closes #115.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
135 lines
6.4 KiB
JavaScript
135 lines
6.4 KiB
JavaScript
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
|
|
// and returns the latest assistant turn's text once the turn is terminal.
|
|
//
|
|
// Authority: claude CLI v2.1.157 — interactive session transcript at
|
|
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
|
|
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
|
|
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
|
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
// Project-dir encoding: claude replaces every "/" AND every "." with "-".
|
|
// Verified live (claude v2.1.158): cwd /home/u/.ocp-tui/work is stored under
|
|
// projects/-home-u--ocp-tui-work/ (the "." in ".ocp-tui" becomes "-", yielding
|
|
// the double dash). The earlier "/"-only rule was wrong for dotted paths; the
|
|
// fixture cwd /tmp/tui-test happened to have no dots so it never surfaced.
|
|
// NOTE: prefer findTranscriptPath() (glob by session-id) for resolution — it is
|
|
// immune to the exact encoding rule. This helper is kept for the known-path case.
|
|
export function encodeCwd(cwd) {
|
|
return cwd.replace(/[/.]/g, "-");
|
|
}
|
|
|
|
export function transcriptPath(home, cwd, sessionId) {
|
|
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
|
|
}
|
|
|
|
// Locate a session's transcript by its UUID across every projects subdir, without
|
|
// reconstructing the encoded cwd. Robust to whatever encoding claude applies.
|
|
// Returns the path, or null if not present yet (it appears once the turn starts).
|
|
export function findTranscriptPath(home, sessionId) {
|
|
if (!home || !sessionId) return null;
|
|
const root = `${home}/.claude/projects`;
|
|
let dirs;
|
|
try { dirs = readdirSync(root); } catch { return null; }
|
|
for (const d of dirs) {
|
|
const candidate = `${root}/${d}/${sessionId}.jsonl`;
|
|
if (existsSync(candidate)) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
|
|
// (the live transcript is read mid-write, so the last line may be incomplete).
|
|
export function parseTranscriptLines(text) {
|
|
const out = [];
|
|
for (const line of text.split("\n")) {
|
|
const t = line.trim();
|
|
if (!t) continue;
|
|
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// A line marks the assistant turn complete when it is the turn_duration system
|
|
// 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;
|
|
return obj.type === "system" && obj.subtype === "turn_duration";
|
|
}
|
|
|
|
// Text of the LAST assistant turn: concatenate its text content blocks
|
|
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
|
|
// Fixture-confirmed shape: top-level type:"assistant", message.content[] array.
|
|
//
|
|
// Scoping: this returns the FINAL text-bearing assistant entry in the whole file,
|
|
// not "text since the matching user line" (spec §4.2). Those are equivalent ONLY
|
|
// under OCP's one-session-per-request model (a fresh --session-id => a fresh
|
|
// transcript holding one logical exchange). If a future warm-pool ever reuses a
|
|
// session WITHOUT a fresh session-id / clear, earlier-turn text could leak — that
|
|
// author must add user-line scoping here. See spec §7.2.
|
|
export function extractLatestAssistantText(events) {
|
|
let text = "";
|
|
for (const ev of events) {
|
|
if (!ev || ev.type !== "assistant") continue;
|
|
const content = ev.message && ev.message.content;
|
|
if (!Array.isArray(content)) continue;
|
|
const parts = content
|
|
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
.map((b) => b.text);
|
|
if (parts.length) text = parts.join("");
|
|
}
|
|
return text;
|
|
}
|
|
|
|
// Returns the entrypoint string from the turn_duration line (e.g. "cli"),
|
|
// or null if absent. Lets callers assert the subscription-classified path.
|
|
// Fixture-confirmed: entrypoint field lives directly on the turn_duration line.
|
|
export function verifyEntrypoint(events) {
|
|
for (const ev of events) {
|
|
if (ev && ev.type === "system" && ev.subtype === "turn_duration") {
|
|
return ev.entrypoint != null ? ev.entrypoint : null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// 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 { 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).
|
|
// Resolution: pass an explicit `transcriptPath` (used by unit tests), OR pass
|
|
// `home` + `sessionId` to resolve by glob each poll (production) — the transcript
|
|
// file does not exist until the turn starts, so resolution happens inside the loop.
|
|
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;
|
|
const ep = verifyEntrypoint(events);
|
|
if (ep != null) lastEntrypoint = ep;
|
|
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint };
|
|
}
|
|
await sleep(pollMs);
|
|
}
|
|
if (lastText) return { text: lastText, entrypoint: lastEntrypoint };
|
|
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
|
}
|