Author SHA1 Message Date
taodengandClaude <claude-opus-4-8> <noreply@anthropic.com> 2963547a98 docs(providers): fix review-noted comment nits (dup token + OCP line-ref 783-784)
Independent review (Iron Rule 10) findings 1+2, both non-blocking comment-only.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-05-31 17:25:08 +10:00
taodengandClaude <claude-sonnet-4-6> <noreply@anthropic.com> 422512d2b2 fix(server): add process-level crash handlers unhandledRejection/uncaughtException/clientError (P1)
Registers three crash-prevention handlers at module level so they are
installed before the first request arrives. These are no-ops on the
happy-path /v1/chat/completions request cycle.

  process.on('unhandledRejection', ...)  — logs and stays alive
  process.on('uncaughtException', ...)   — logs and stays alive
  server.on('clientError', ...)          — destroys the broken socket

process.exit is intentionally NOT called in unhandledRejection /
uncaughtException — the daemon logs the event and continues serving. The
clientError handler destroys the socket to release the file descriptor; if
left open, broken sockets from mid-upload client aborts or malformed HTTP
headers accumulate and exhaust fd limits.

server.on('clientError') is attached inside createOlpServer() (the factory
function) so it is present on every server instance, including test servers
created via the factory — enabling the 45f listener-count assertion.

Authority (ALIGNMENT.md Rule 1 + Rule 5):
  Entry-surface scope — OpenAI Chat Completions API contract unchanged.
  https://platform.openai.com/docs/api-reference/chat
  happy-path /v1/chat/completions is unaffected; these handlers only fire on
  low-level TCP/parse errors or unhandled promises outside the request cycle.
  Port source: OCP server.mjs:2134-2146.

Tests (Suite 45):
  45a–45c: Task A — stdin EPIPE guard fires and logs stdin_write_error in
    anthropic, codex, and mistral providers (mock spawn with EventEmitter
    stdin that emits EPIPE).
  45d: process.listenerCount('unhandledRejection') >= 1
  45e: process.listenerCount('uncaughtException') >= 1
  45f: server created by createOlpServer() has a clientError listener >= 1

Total test count: 819 pass / 0 fail (813 pre-existing + 6 new).

CHANGELOG.md: adds Unreleased entry for both P1 crash-hardening fixes per
release_kit phase_rolling_mode policy (Phase 7 closed at v0.7.0; D-day
commits land under Unreleased until Phase 8 close).

Co-Authored-By: Claude <claude-sonnet-4-6> <noreply@anthropic.com>
2026-05-31 17:18:15 +10:00
taodengandClaude <claude-sonnet-4-6> <noreply@anthropic.com> a503441ea3 fix(providers): guard stdin EPIPE on fast-failing CLI spawn (P1 crash-hardening)
Adds `proc.stdin.on('error', ...)` guard in anthropic.mjs, codex.mjs, and
mistral.mjs before any stdin.write() / stdin.end() call. Prevents a
fast-failing CLI (auth error, bad model, oversized prompt) from crashing the
single-process daemon via an unhandled EPIPE on the Node stdin Writable.

The ChildProcess 'error' event fires on the spawned process itself; it does
NOT catch errors on the stdin Writable. The stdin Writable emits its own
'error' event which, without a listener, propagates as an unhandled exception
and crashes the daemon.

Authority (ALIGNMENT.md Rule 1 + Rule 5):
  - anthropic: claude CLI v2.1.104 observed behaviour — fast-failing spawn
    (e.g. auth error, bad model) closes stdin before OLP finishes writing;
    Node stdin Writable surfaces EPIPE. ALIGNMENT.md Rule 2: no CLI operation
    changed; guard is a Node stream safety net only.
    Port source: OCP server.mjs:777-784.
  - codex: Codex CLI reference https://developers.openai.com/codex/cli/reference
    — same mechanism; stdin path via `-` positional documented in reference
    § "Use '-' to pipe the prompt from stdin." ALIGNMENT.md Rule 2 applies.
  - mistral: Mistral Vibe CLI https://docs.mistral.ai/mistral-vibe/terminal/quickstart
    — vibe --prompt reads from args not stdin, but stdin.end() can still
    surface EPIPE if the process exits before the fd is flushed.
    ALIGNMENT.md Rule 2: no CLI operation changed.

A `typeof proc.stdin.on === 'function'` guard is included to protect
test-injected mock stdin objects (plain objects without .on()) from
breaking the EventEmitter call.

Co-Authored-By: Claude <claude-sonnet-4-6> <noreply@anthropic.com>
2026-05-31 17:17:54 +10:00
8 changed files with 331 additions and 837 deletions
+14 -1
View File
@@ -4,7 +4,20 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
## Unreleased
(no in-flight changes)
### P1 daemon-crash hardening (fix/olp-p1-crash-hardening)
- **fix(providers): guard stdin EPIPE on fast-failing CLI spawn** — added
`proc.stdin.on('error', ...)` guard in `lib/providers/anthropic.mjs`,
`lib/providers/codex.mjs`, and `lib/providers/mistral.mjs` before any
`stdin.write()` / `stdin.end()` call. Prevents a fast-failing CLI (auth
error, bad model, oversized prompt) from crashing the daemon via an
unhandled EPIPE on the Node stdin Writable. Port of OCP server.mjs:780.
- **fix(server): process-level crash handlers unhandledRejection / uncaughtException / clientError** —
registered `process.on('unhandledRejection', ...)`,
`process.on('uncaughtException', ...)`, and `server.on('clientError', ...)`
at module level. The daemon logs the event and stays alive; `process.exit`
is intentionally not called. Port of OCP server.mjs:2134-2146.
## v0.7.0 — 2026-05-29 — Phase 7 close: Solution 1 isolation + opus 4.8
@@ -1,435 +0,0 @@
# TUI-mode — Deployment-A Implementation Plan (PR-0 … PR-3)
- **Date:** 2026-05-30
- **Status:** Implementation plan (pre-code). Derived verbatim from the final design spec
`docs/superpowers/specs/2026-05-30-tui-mode-production-design.md` (3 review passes + spikes S1/S2/S3 + pre-code gates T1/T3/T6). **Decisions in the spec are NOT re-litigated here.**
- **Scope:** **Deployment A only** (single-user / OCP canary). Deployment B (multi-tenant) is DEFERRED behind spikes **T2** (body-capture `tools:[]`) + **T4** (concurrency). B's gating hooks (`--tools ""`, `--strict-mcp-config`, `--disallowedTools "mcp__*"`, per-spawn MCP-disable verification) are **wired in PR-2 but B is not enabled** — no per-key guest path ships in this plan.
- **Authority of record (to be created in PR-3):** ADR 0016 (or ADR 0009 Amendment 2) — see PR-3.
- **Iron Rules in force:** 10 (independent reviewer), 11 (minimum reviewable unit — one PR per layer), 12 (prior-art search done = the spikes). `ALIGNMENT.md` Rule 1 (cite authority) + Rule 2 (no inventing CLI behavior) + Rule 5 (release-kit).
- **Author credit (binding, §13):** every implementing commit carries `Co-Authored-By: jaekwon-park <…>` (pull the real email/handle from OCP PR #101 before committing — do NOT invent). ADR 0016 names PR #101 + jaekwon-park in its acknowledgment section. Add jaekwon-park to CONTRIBUTORS and notify on PR #101 at ship time.
---
## 0. Ground-truth code anchors (verified against the real tree)
Everything below cites the exact function/line the change hooks into. Re-verify line numbers at edit time (the files churn).
| Surface | Location (verified) | Role in TUI-mode |
|---|---|---|
| `spawn(irRequest, authContext, isolationCtx)` (public contract) | `lib/providers/anthropic.mjs:1164` → delegates to `_spawnAndStream` | **PR-3** branches here on `CLAUDE_TUI_MODE`. Default falls through to `_spawnAndStream` (stream-json) UNCHANGED. |
| `_spawnAndStream(irRequest, authContext, spawnImpl, isolationCtx)` | `anthropic.mjs:872` | The default transport. **Not modified** by TUI-mode (PR-3 adds a sibling branch in the public `spawn`, it does not touch `_spawnAndStream`). |
| `buildCliArgs(model, systemPrompt)` | `anthropic.mjs:834` (returns `--model … --output-format stream-json --verbose --no-session-persistence --system-prompt …`) | TUI driver builds its **own** argv (no `-p`, no `--output-format`); it does NOT reuse `buildCliArgs`. Cited as the contrast surface. |
| `extractSystemPrompt(irRequest)` | `anthropic.mjs:123` (always prefixes `OLP_SYSTEM_PROMPT_WRAPPER` `:109`) | **REUSED unchanged** by the TUI driver to compute the `--system-prompt` value. |
| `irToAnthropic(irRequest)` | `anthropic.mjs:601` (serializes user/assistant/tool; skips `system`) | **REUSED unchanged** — produces the prompt body text the TUI driver writes to the prompt file (§6 recipe). |
| `ISOLATION` named export | `anthropic.mjs:1667` (`ephemeralEnvOverrides``{HOME}`, `credentialMounts`, `requiredHomePaths:['.claude']`, `hasInnerSandbox:false`) | **PR-0** EXTENDS with a TUI-only seed hook. |
| `prepareIsolatedEnvironment({provider,keyId,reqId})` | `lib/sandbox/manager.mjs:203` → returns `{ephemeralRoot, envOverrides, hardenedArgs, wrapForLayer3, cleanup}` | **PR-0** consumes the new seed step; **PR-2** driver calls it to get `ephemeralRoot`. Note the **test bypass at `:223`** (returns `_legacyShape()` under `test-features.mjs` unless `globalThis.__OLP_FORCE_ISOLATION_IN_TEST`). |
| Buffered spawn call site | `server.mjs:1347` (`prepareIsolatedEnvironment`) → `:1355` (`for await … hopProviderPlugin.spawn(...)`) inside `collectAllChunks()` (`:1299`); result cached via `cacheStore.getOrCompute(keyId, hopCacheKey, collectAllChunks)` at `:1445` | **computeFn returns an ARRAY of IR chunks.** TUI transport must yield `[{type:'delta',role:'assistant',content},{type:'stop',finish_reason:'stop'}]` so this path is unchanged. |
| Streaming spawn call site | `server.mjs:1564` (`prepareIsolatedEnvironment`) → `:1570` (`for await … streamPlugin.spawn(...)`) inside `sourceWithRelease()`; coordinated via `cacheStore.getOrComputeStreaming(keyId, streamCacheKey, sourceFactory, …)` at `:1587` | **sourceFactory returns an ASYNC GENERATOR of IR chunks.** TUI transport yields the same 2-chunk shape → SSE replay (`irChunkToOpenAISSE` at `server.mjs:1764`) is byte-identical to the stream-json path. This is the §3.1 single-buffered-then-replay mechanism. |
| `irChunkToOpenAISSE`, `SSE_DONE` | imported `server.mjs:38`; used `:1764`, `:1772` | **REUSED unchanged** for `stream:true` replay. |
| `max_tokens` parse | `lib/ir/openai-to-ir.mjs:182` (sets `ir.max_tokens`) | Accepted into IR, **dropped at CLI boundary** (§4.5). Same for `temperature` `:190`, `top_p` `:198`, `stop` `:206` (§4.6). |
| `validateKey` / `owner_tier` / `providers_enabled` | `lib/keys.mjs:414`; tiers `'owner'|'guest'|'anonymous'` (`:428`,`:463`) | **REUSED unchanged.** A's canary runs owner-tier. B's guest gating is wired but inert. |
**Cache contract crux (load-bearing for PR-1).** `server.mjs` does NOT expect a string from the transport. It expects **IR chunks** — an array (buffered, `getOrCompute`) or an async generator (streaming, `getOrComputeStreaming`). The TUI transcript reader (PR-1) resolves a **single string**; the TUI driver/provider-branch (PR-2/PR-3) is responsible for the thin adapter `string → [delta, stop]` so both existing cache paths consume it with **zero modification**. This is the concrete meaning of spec §3.2 "returns a resolved response string adapted to the getOrCompute/singleflight cache contract."
---
## Cross-cutting contracts (define these FIRST; every PR conforms)
### C1. Transport interface (so node-pty can slot later — spec §8)
A single interface in `lib/tui/session.mjs`; tmux is the only implementation in this plan; node-pty is a stubbed adapter behind the same interface.
```
interface TuiTransport {
// create the session bound to ephemeralRoot, spawn `claude` interactive, settle to input box
open({ bin, args, env, cwd, ephemeralRoot, reqId }): Promise<SessionHandle>
// submit one prompt body (T3 recipe: file → send-keys -- "$(cat f)" → separate Enter)
submit(handle, promptText): Promise<void>
// teardown: kill session + nothing else (ephemeral root rm is the manager.cleanup's job, but
// the driver MUST also kill the session in a trap/finally — §8)
close(handle): Promise<void>
// startup-time orphan reaper (kill restart-surviving sessions) — §5.5
reapOrphans(): Promise<{ killed: string[] }>
}
```
`tmuxTransport` implements all four. `nodePtyTransport` is a stub that throws `NOT_IMPLEMENTED` (present so the interface boundary is real and reviewable). The transcript reader (C2) and IR mapping never import the transport — they only consume the deterministic transcript path, so swapping transports later touches nothing else.
### C2. Transcript-reader interface (PR-1 owns it; transport-agnostic)
```
computeTranscriptPath({ ephemeralRoot, cwd, sessionId }): string // §4.1 formula, pure
readTurnResult({ transcriptPath, sinceUserContent, wallClockCapMs, pollMs }):
Promise<{ text: string, durationMs?: number, messageCount?: number }> // resolves the assistant text
// throws TuiCompletionError on guard-(B) terminal conditions (tool_use / wall-clock cap) — §4.4
```
`readTurnResult` is the **dual-signal** completion engine. It never imports tmux/node-pty. It is unit-tested entirely against captured JSONL fixtures.
### C3. `CLAUDE_TUI_MODE` flag semantics (binding)
- **Unset / not `"1"`** → default path. **Byte-for-byte unchanged** from today: `_spawnAndStream` (stream-json), `ISOLATION` with NO seed, no `.claude.json` written, no new on-disk sensitive data. This is a **hard requirement** (spec §7.1) and is the regression invariant (C4).
- **`CLAUDE_TUI_MODE=1`** → TUI transport: ephemeral home seeded (PR-0), tmux interactive `claude` (PR-2), transcript-read completion (PR-1), provider branch (PR-3).
- The flag is read **once** in the provider `spawn()` branch (PR-3) — `process.env.CLAUDE_TUI_MODE === '1'`. It is the ONLY toggle. No config-file alternative in this plan.
- Sub-flags (A-only, all default-off, all gated under `CLAUDE_TUI_MODE=1`): `CLAUDE_TUI_WARM_POOL` (§7.2 — **out of scope for this plan; not implemented, only namespace-reserved**).
### C4. Default-path-unchanged invariant + how to test it
- **Invariant:** with `CLAUDE_TUI_MODE` unset, no code path added by PR-0..PR-3 executes. `ISOLATION` returns the same shape, `_spawnAndStream` is the only transport, no `.claude.json` is seeded.
- **Test (regression guard, runs in every PR):** the full existing `test-features.mjs` suite stays green. Additionally PR-0 adds an explicit assertion: `prepareIsolatedEnvironment` for the anthropic provider with `CLAUDE_TUI_MODE` unset produces an ephemeral root containing **no** `.claude.json` (only the symlinked `.credentials.json` + `.claude/` dir, as today). PR-3 adds: `spawn()` with the flag unset calls `_spawnAndStream` (assert via the existing `__setSpawnImpl` seam — the mock spawn is invoked, the TUI driver is NOT).
---
## PR-0 — ISOLATION extend (TUI-only `.claude.json` seed)
### 1. Goal
Seed a minimal `.claude.json` (onboarding/trust/bypass markers ONLY) into the ephemeral home **only when `CLAUDE_TUI_MODE` is active**, so a fresh-home interactive `claude` drops straight to the input box instead of hanging on first-run onboarding — while the default stream-json path's bootstrap stays byte-for-byte unchanged.
### 2. Files touched
- `lib/providers/anthropic.mjs` — extend the `ISOLATION` block (`:1667`).
- `lib/sandbox/manager.mjs` — add the opt-in seed step to `prepareIsolatedEnvironment` (`:203`), gated so it is a no-op unless the caller requests it.
- `test-features.mjs` — new suite (seed-on / seed-off / permissions).
- *(no new file in PR-0)*
### 3. Concrete changes
**3a. `ISOLATION` gains a seed descriptor (NOT a function that reads the real home unconditionally).** Add to the anthropic `ISOLATION` object an OPTIONAL field describing the TUI seed, e.g.:
```
// anthropic.mjs ISOLATION (extend, after requiredHomePaths)
tuiSeed: { // consumed ONLY when prepareIsolatedEnvironment is called with { tui:true }
relPath: '.claude.json', // written under ephemeralRoot
mode: 0o600, // §5.5 — same care as the bearer
// builder is pure-ish: it reads the real ~/.claude.json ONCE to copy oauthAccount/userID,
// strips `projects`, and stamps onboarding/trust/bypass markers + a pre-trusted cwd.
build: ({ cwd }) => ({ /* hasCompletedOnboarding:true, oauthAccount, userID,
bypassPermissionsModeAccepted:true,
projects: { [cwd]: { hasTrustDialogAccepted:true, … } } */ }),
}
```
- **Authority/contract note:** ADR 0002 Amendment 9's `credentialMounts` is deliberately a static list (not a function) for auditability; the seed is a NEW optional field, so PR-0 must add a one-paragraph Amendment-9 note (in ADR 0002, co-merged or referenced) stating the seed reads the real `~/.claude.json` exactly once to copy `oauthAccount`/`userID`, writes mode-600, and carries **no MCP-disable weight** (T6 negative control, spec §5.2 / §7.1). The seed is onboarding/trust/bypass ONLY.
- **The seed does NOT disable managed MCP** (T6 negative control). PR-0 must NOT add `claudeAiMcpEverConnected` manipulation or any MCP field. A code comment cites spec §5.2 + T6.
**3b. `prepareIsolatedEnvironment` gains a `tui` opt-in param.** Change the signature to `prepareIsolatedEnvironment({ provider, keyId, reqId, tui = false })` (`manager.mjs:203`). After the existing Layer-2 symlink loop (`:318`), add a guarded block:
```
if (tui && isolation?.tuiSeed) {
// chmod 700 the ephemeralRoot (§5.5), write isolation.tuiSeed.build({cwd}) JSON
// at join(ephemeralRoot, tuiSeed.relPath) with { mode: tuiSeed.mode }, never log contents.
}
```
- **Default path is untouched:** existing call sites at `server.mjs:1347` and `:1564` pass NO `tui` flag → `tui=false` → seed block is skipped → identity behavior. This satisfies C4. The TUI driver (PR-2) is the ONLY caller that passes `tui:true`.
- **`chmod 700` the ephemeral root** (§5.5) is applied **inside the `tui` block** so the default path's permission semantics are also unchanged. (The default path created the root via `mkdirSync` at `:250`; PR-0 does not alter that.)
- **Per-`keyId` isolation** is already structurally given by the `/tmp/olp-spawn/<safeKeyId>/<safeReqId>/home` path (`manager.mjs:247`). PR-0 adds an assertion/comment that the parent `<safeKeyId>` dir is not world-traversable (chmod 700 on the chain) — §5.5.
- **Test bypass interaction (`manager.mjs:223`):** the existing test-runner bypass returns `_legacyShape()`. PR-0's seed tests MUST set `globalThis.__OLP_FORCE_ISOLATION_IN_TEST = true` to exercise the real path, then unset it in `finally` (this seam already exists).
### 4. Unit tests + fixtures (`test-features.mjs`)
- [ ] **seed-off (default-path invariant, C4):** call `prepareIsolatedEnvironment({provider:anthropic, keyId, reqId})` (no `tui`) under `__OLP_FORCE_ISOLATION_IN_TEST` → assert ephemeralRoot has `.claude/.credentials.json` symlink + `.claude/` dir and **NO `.claude.json`**.
- [ ] **seed-on:** call with `{ tui:true }` → assert `.claude.json` exists, is mode `600`, parses as JSON, contains `hasCompletedOnboarding:true` + `bypassPermissionsModeAccepted:true` + a pre-trusted `projects[cwd]`, and contains **NO** `mcpServers`/`claudeAiMcpEverConnected` field (negative assertion — T6).
- [ ] **root permissions:** assert ephemeralRoot is mode `700` on the `tui:true` path.
- [ ] **no-real-home-mutation:** assert the real `~/.claude.json` is not written/modified (read-only copy).
- [ ] Fixture: a minimal fake `~/.claude.json` (via a temp HOME or an injected reader seam) carrying a dummy `oauthAccount`/`userID` so the test never touches the operator's real account file.
- [ ] Full existing suite stays green (regression).
### 5. PI231 integration checkpoint (maintainer-supervised; /tmp scratch only)
Run on PI231 scratch (prod OLP on :4567 untouched):
- [ ] Drive `prepareIsolatedEnvironment({tui:true})` against a scratch keyId/reqId; `ls -la` the ephemeral root.
- [ ] **Pass criteria:** `.claude.json` present, mode `600`; root mode `700`; symlinked `.credentials.json` present; `cat` the seed shows onboarding/trust/bypass markers and **no MCP fields**; the real `~/.claude.json` mtime unchanged.
- [ ] Launch interactive `claude` by hand bound to that ephemeral HOME and confirm it **does not** hang on onboarding (drops to input box). (This is the load-bearing reason PR-0 exists.)
### 6. Acceptance criteria (binding, testable)
- With `tui` unset, ephemeral home is byte-identical to today (no `.claude.json`). ✔ regression test + PI231.
- With `tui:true`, seed is written mode-600, root mode-700, onboarding/trust/bypass present, MCP fields absent.
- No change to default stream-json spawn behavior; full suite green.
### 7. Reviewer (Iron Rule 10)
Fresh-context reviewer opens **spec §7.1 + §5.2 (T6 negative control) + ADR 0002 Amendment 9** and confirms: (a) the seed is gated on the opt-in `tui` param so the default path is unchanged; (b) the seed carries NO MCP-disable field (T6); (c) mode-600 seed + mode-700 root + per-keyId isolation per §5.5; (d) the Amendment-9 note documenting the new `tuiSeed` field is present. A review that does not name the §5.2 negative control is not a valid approval.
### 8. Authority citation (commit + PR body)
`claude` CLI v2.1.158 § first-run onboarding (theme/login pickers) + `$HOME`-redirect behavior (ADR 0002 Amendment 9 anthropic ISOLATION pin); ADR 0002 Amendment 9 (ISOLATION contract); spec §7.1 + §5.2; PI231 ephemeral-home spike `docs/spikes/2026-05-29-ephemeral-home.md`. State explicitly: **the seed does NOT disable managed MCP — that is the spawn-argv flag in PR-2 (T6).**
### Risk / rollback
Independently revertable (revert reinstates the pre-seed ISOLATION; default path was never touched). Default-off: nothing reaches users — the seed only fires when a caller passes `tui:true`, and no caller does until PR-2/PR-3.
---
## PR-1 — Transcript reader (`lib/tui/transcript.mjs`)
### 1. Goal
A transport-agnostic reader that computes the deterministic transcript path, polls for lazy file creation, detects turn completion via the **mandatory dual-signal guard** (turn_duration OR tool_use OR wall-clock cap; NO quiescence in v1), extracts the assistant text, and resolves a single response string.
### 2. Files touched
- **NEW** `lib/tui/transcript.mjs`.
- `test-features.mjs` — new transcript-reader suite.
- Fixtures dir (NEW) `docs/spikes/fixtures/tui/` — captured real JSONL (see §4).
### 3. Concrete changes (exports + signatures)
- `export function computeTranscriptPath({ ephemeralRoot, cwd, sessionId })`**pure.** Implements §4.1: `<ephemeralRoot>/.claude/projects/<CWD_ENCODED>/<sessionId>.jsonl` where `CWD_ENCODED` = `cwd` with **every** `/``-` **including the leading slash** (`/tmp/x``-tmp-x`). No filesystem access. (OLP generates `sessionId` and `cwd`, so the path is known before spawn.)
- `export async function readTurnResult({ transcriptPath, sinceUserContent, wallClockCapMs = 120_000, pollMs = 500, toolUseIsTerminal = true })`:
- **Lazy-create poll:** the file is created on first message, not at spawn (§4.1). Tolerate ENOENT; poll every `pollMs` until the file exists or `wallClockCapMs` elapses (then throw `TuiCompletionError('completion-marker timeout')`).
- **Dual-signal completion (§4.4, MANDATORY):**
- **(A) happy path:** a line `{"type":"system","subtype":"turn_duration"}` for this turn appears → done. Carries `durationMs` + `messageCount`. Do NOT rely on file-tail byte ordering (§4.3 trap): re-scan the file, find the matching `user` line for `sinceUserContent`, collect all subsequent `assistant`/`text` blocks.
- **(B) co-equal terminal guard (mandatory, never-hang):** if the last assistant message carries `stop_reason:"tool_use"` → throw `TuiCompletionError('tool-use turn unsupported in TUI-mode')` (maps to clean 502). If `wallClockCapMs` fires → throw `TuiCompletionError('completion-marker timeout')`.
- **NO quiescence cut in v1** (§4.4 ⚠️): do NOT abort on "file size-stable for N seconds" — a long Opus/extended-thinking turn legitimately produces no growth. Quiescence is added only after spike T5. (Comment cites §4.4 explicitly so a future contributor does not "helpfully" add it.)
- Do NOT key off `stop_reason:"end_turn"` alone (§4.3 trap — appears on both `thinking` and `text` blocks).
- **Assistant-text extraction (§4.2):** `JSON.parse` per line (native log → escaping-clean). Response = concatenation of `text`-type content blocks from `assistant` messages emitted **since the matching `user` line**. Return `{ text, durationMs, messageCount }`.
- **Trailing-newline normalization (§3.2 / §6 caveat):** the input box strips the source's single trailing newline. `sinceUserContent` matching MUST normalize the trailing newline before comparing source-prompt vs the transcript `user` line, or the "matching user line" lookup (and any cache-key reasoning) sees a spurious mismatch.
- **Cache-contract adapter note (does NOT live in PR-1, but PR-1's return shape is designed for it):** `readTurnResult` resolves a string; the PR-2/PR-3 layer wraps it as `[{type:'delta',role:'assistant',content:text},{type:'stop',finish_reason:'stop'}]`. PR-1's JSDoc states this adapter contract and points at `server.mjs:1299` (buffered array) + `server.mjs:1558` (streaming generator) so the reviewer sees the two consumers. **max_tokens/sampling graceful-drop boundary** (§4.5/§4.6): PR-1 documents that these IR fields never reach this layer (interactive `claude` has no flag); nothing to do — they are dropped at the CLI-args boundary in PR-2/PR-3. PR-1 adds a comment asserting `finish_reason` is always `'stop'` (no `length` mapping, since max_tokens is not enforced).
### 4. Unit tests + fixtures
**Fixtures (capture REAL JSONL on PI231 — do not hand-fabricate the shapes):**
- [ ] `text-turn.jsonl` — a normal `end_turn` text answer ending in a `turn_duration` line.
- [ ] `refusal-turn.jsonl` — a refusal that still emits `turn_duration` (T1: `durationMs≈3221`).
- [ ] `tool-use-no-marker.jsonl`**MANDATORY** (T1): a `tool_use` turn whose last assistant line is `stop_reason:"tool_use"` with **NO** `turn_duration` line. This is the hang case guard (B) must catch.
- [ ] `out-of-order.jsonl` — a text block flushed by byte-position AFTER `turn_duration` though `turn_duration` has the later timestamp (§4.3 trap) — proves the reader does not rely on file-tail ordering.
- [ ] `multiturn.jsonl` — two user lines so `sinceUserContent` selection is exercised (a `toolUseResult:true` user line within a turn must NOT be mistaken for a new submit — §6 step 5).
**Tests:**
- [ ] `computeTranscriptPath` exact-string equality incl. leading-slash encoding.
- [ ] happy path returns concatenated text + `durationMs`/`messageCount`.
- [ ] refusal path returns refusal text (still completes).
- [ ] **tool-use fixture → throws `TuiCompletionError` (never hangs)** — assert with a short `wallClockCapMs` that the throw is the tool_use detection, not the timeout (distinguish the two error messages).
- [ ] wall-clock cap fires on a never-completing fixture (truncated file with no marker) → throws within cap.
- [ ] out-of-order fixture → correct text (no reliance on last byte).
- [ ] trailing-newline normalization: `sinceUserContent` with trailing `\n` still matches the transcript user line.
- [ ] **no-quiescence assertion:** a fixture that is size-stable for > pollMs but has not completed does NOT abort before the wall-clock cap (proves quiescence is excluded).
- [ ] Full existing suite stays green (PR-1 adds a new module + new tests only; touches no existing path).
### 5. PI231 integration checkpoint (maintainer-supervised)
- [ ] Capture the 5 fixtures above from real `claude` v2.1.158 runs on PI231 scratch (this is also how the fixtures are sourced). Commit them under `docs/spikes/fixtures/tui/`.
- [ ] **Pass criteria:** `readTurnResult` against each freshly-captured fixture returns the same text a human reads in the transcript; the tool-use capture throws `TuiCompletionError` and never blocks; cap fires deterministically on a manually-truncated fixture.
### 6. Acceptance criteria (binding)
- Deterministic path matches §4.1 exactly.
- Dual-signal completion: completes on `turn_duration`; **never hangs** on tool-use or a missing marker (guard B); **no quiescence cut**.
- Escaping-clean text extraction; trailing-newline normalized.
- Pure reader: zero tmux/node-pty import; fully fixture-testable.
### 7. Reviewer (Iron Rule 10)
Fresh-context reviewer opens **spec §4.1–§4.4 (and §3.2 cache-contract / trailing-newline)** and confirms: (a) the path formula incl. leading-slash; (b) the dual-signal guard is present AND quiescence is explicitly excluded with a §4.4 citation; (c) the tool-use-no-marker fixture exists and the test proves a non-hanging terminal throw; (d) the resolved-string return is documented against the `getOrCompute`/`getOrComputeStreaming` consumers. A review missing the tool-use-no-marker check is not valid.
### 8. Authority citation
`claude` CLI v2.1.158 § native session transcript JSONL (`turn_duration` is an undocumented internal-log behavior — pin to v2.1.158, re-verify per CLI/Ink bump); spec §4 (S2 PASS) + §4.4 (T1 PARTIAL); fixtures captured PI231 2026-05-30. No OpenAI-spec surface (reader is internal). ALIGNMENT Rule 2: the reader consumes a behavior `claude` actually emits — no invented format.
### Risk / rollback
New file + new tests only; revert deletes the module and tests, default path untouched. Riskiest sub-step is the dual-signal guard's tool-use detection (the hang vector) — fully covered by the mandatory fixture.
---
## PR-2 — Session driver (`lib/tui/session.mjs`)
### 1. Goal
A tmux-backed interactive-`claude` driver behind the transport interface (C1): spawn with the T6 flag set, submit via the T3 recipe, auto-answer dialogs, run the per-spawn MCP-disable verification gate, guarantee teardown via trap/finally, and reap orphan sessions on startup — producing a single buffered response (via PR-1's reader) adapted to IR chunks for both cache paths.
### 2. Files touched
- **NEW** `lib/tui/session.mjs` (tmux transport + node-pty stub + the driver `runTuiTurn`).
- `lib/sandbox/manager.mjs` — driver calls `prepareIsolatedEnvironment({…, tui:true})` (the param added in PR-0).
- `test-features.mjs` — driver suite (with a mock transport — no real tmux/claude in unit tests).
- *(server wiring is PR-3, NOT here)*
### 3. Concrete changes
**3a. Transport interface + tmux implementation (C1).**
- `export const tmuxTransport` implementing `open/submit/close/reapOrphans`.
- `export const nodePtyTransport` — stub throwing `NOT_IMPLEMENTED` (interface placeholder, §8 decision: tmux first).
- Session naming: `olp-tui-<keyId>-<reqId>` so `reapOrphans` can pattern-match.
**3b. Spawn argv (T6 flag set, §5.2) — the driver builds its OWN args (NOT `buildCliArgs`).**
```
claude --model <m> --session-id <uuid> --system-prompt "<extractSystemPrompt(ir)>"
--strict-mcp-config // T6 load-bearing: 0 managed-MCP (no --mcp-config supplied)
--disallowedTools "mcp__*" // deny MCP-namespaced tools
[--tools "" ] // B-only built-in lockdown — WIRED, gated off for A (see 3g)
// NO -p, NO --output-format → real TTY → cc_entrypoint=cli
env: CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1 // defense-in-depth
+ carry-forward: CLAUDE_CODE_DISABLE_CLAUDE_MDS=1, unset ANTHROPIC_* (reuse buildSpawnEnv semantics)
+ HOME=<ephemeralRoot> (from prepareIsolatedEnvironment envOverrides)
```
- `--system-prompt` value comes from **`extractSystemPrompt(ir)` (`anthropic.mjs:123`) — REUSED.** Prompt body comes from **`irToAnthropic(ir)` (`anthropic.mjs:601`) — REUSED** (written to the prompt file, 3d).
- **`--bare` is forbidden** (§5.2 — breaks OAuth). Comment cites it.
- `--model` from `ir.model`; `--session-id` is the OLP-generated UUID also fed to `computeTranscriptPath`.
**3c. Per-spawn MCP-disable verification gate (§5.2 (4) preflight semantics).** After `open()` settles, assert **0** dirs matching `$HOME/.cache/claude-cli-nodejs/*/mcp-logs-claude-ai-*` under the ephemeral root. **Do NOT run `/mcp` inside the serving session** (§5.2: it writes a transcript line, consumes a turn, corrupts the reader's matching-user-line semantics). For A's canary the cache-dir assertion is the in-band check; the `/mcp`-empty assertion belongs to a **separate preflight session at startup / CLI upgrade** (wire the preflight hook here but it is owner-tier advisory for A; it becomes a hard gate for B). On assertion failure: tear down + clean 502.
**3d. Submit recipe (T3 PASS — binding for acceptance, §6).**
1. Write `irToAnthropic(ir)` to a file under the ephemeral root (NEVER interpolate into a shell line — backticks/`$()`/`&&`/quotes get mangled by the shell, §6 step 1).
2. `tmux send-keys -t <S> -- "$(cat promptfile)"` — the leading `--` end-of-options guard is **required** (prompt starting with `-`). Embedded `\n` are soft line-breaks; do NOT submit. Do NOT use `send-keys -l` for the body (§6 step 2).
3. Settle ~1.52s for Ink render / paste-collapse (§6 step 3). (Production: poll the pane for input-box-ready / paste-collapse before Enter, or scale settle to payload size — §6 caveat.)
4. **Submit Enter as a SEPARATE tmux KEY TOKEN:** `tmux send-keys -t <S> Enter` — never a literal `"\n"` appended to text (Ink #15553, §6 step 4).
5. **Verify via TRANSCRIPT** (not `capture-pane`): exactly one `user`-role line whose content equals source minus its single trailing newline (a second `user` line with `toolUseResult:true` is in-turn tool output, not a second submit — §6 step 5). Large pastes collapse to a `[Pasted text …]` placeholder so pane-scraping is impossible — transcript-read is mandatory.
6. **Retry** Enter (key token) up to ~4× as a defensive guard (§6 step 6).
**3e. Dialog auto-answer (S3 footgun, §6).** With the PR-0 seed (trust + bypass pre-seeded) neither dialog should appear. Defensive handling if they do: trust-folder defaults to "1. Yes, I trust" → bare Enter confirms; the **bypass-permissions dialog defaults cursor to "1. No, exit"** — a naive Enter **kills the session** → must send **Down then Enter** to land on "2. Yes, I accept". Prefer the pre-seed; keep the Down+Enter recipe as fallback.
**3f. Teardown (trap-guaranteed, §8) + orphan reaper (§5.5).**
- `close()` + ephemeral-root cleanup MUST run in a `finally` (NOT best-effort) — S3 noted best-effort `rm` left empty `home_*` dirs with stray cred symlinks. The driver wraps the whole turn in `try { … } finally { await transport.close(handle); await isolationCtx.cleanup(); }`.
- `tmuxTransport.reapOrphans()` runs at **server startup** (called from PR-3's boot path): list `olp-tui-*` tmux sessions surviving a restart, kill each + `rm -rf` its ephemeral root (these still hold the owner OAuth via the mounted ephemeral home — §5.5). This is the restart-time backstop complementing the steady-state finally.
**3g. B-gate hooks wired but inert (scope discipline).** `--tools ""` (built-in lockdown) and the `/mcp`-empty hard gate are **present in the code path but only activated for `owner_tier === 'guest'`**, which no A/canary request is. A comment + the ADR state: **B does not launch until T2 (body-capture `tools:[]`) passes; serialized after T2; concurrent only after T4** (§5.2 gate semantics). PR-2 ships the flags; PR-3/B-enablement flips them on. No guest key is provisioned in this plan.
**3h. Single buffered response + SSE replay (§3.1).** The driver's public entry, e.g. `export async function runTuiTurn({ ir, authContext, keyId, reqId, transport = tmuxTransport })`, returns the **resolved string** from PR-1's `readTurnResult`. The IR-chunk adapter `string → [{type:'delta',role:'assistant',content},{type:'stop',finish_reason:'stop'}]` is applied by PR-3's provider branch so both `getOrCompute` (buffered array) and `getOrComputeStreaming` (async generator) consume it unchanged — for `stream:true` the existing `irChunkToOpenAISSE` replay (`server.mjs:1764`) emits the completed text as one burst of delta(s) + `[DONE]` AFTER the turn finishes. **True token streaming is NOT possible** (§3.1) — `capture-pane` partial-text tapping is explicitly rejected (large pastes collapse to `[Pasted text …]`).
### 4. Unit tests + fixtures
- [ ] **mock transport** (no real tmux/claude): assert the driver builds the exact T6 argv set (`--strict-mcp-config`, `--disallowedTools "mcp__*"`, no `-p`, no `--output-format`, no `--bare`, env `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1`).
- [ ] submit recipe shape: prompt written to a file; `send-keys -- "$(cat …)"` issued; Enter is a SEPARATE token; on a simulated missed-Enter the retry fires ≤4×.
- [ ] dialog fallback: simulated bypass dialog → driver sends Down+Enter (not bare Enter).
- [ ] teardown: assert `close` + `cleanup` fire in `finally` on both happy and thrown paths (inject a throw mid-turn).
- [ ] reaper: seed fake `olp-tui-*` session records into the mock transport → `reapOrphans` kills them + rms roots.
- [ ] guest-gating: with `owner_tier:'guest'` the argv gains `--tools ""`; with `'owner'` it does not (B-hook wired-but-inert proof).
- [ ] string→IR-chunk adapter produces `[delta, stop]` with `finish_reason:'stop'`.
- [ ] T3 regression negative control (documented, runs on PI231 not in unit): a newline-as-text submit silently fails (Ink #15553) — guards against a future refactor reintroducing `-l`.
- [ ] Full existing suite green; default path (flag-unset) never reaches this module.
### 5. PI231 integration checkpoint (maintainer-supervised; /tmp scratch + tmux only)
- [ ] Real multiline-code request (fenced code block + shell-special chars, ~50 lines per T3) through `runTuiTurn` against real `claude` v2.1.158 on PI231 scratch.
- [ ] **Pass criteria:** response text is correct and byte-for-byte intact; exactly ONE `user` submit in the transcript; **`cc_entrypoint=cli` verified** (transcript `turn_duration` line `entrypoint=cli` / `--debug` metadata); MCP-disable gate passes (0 `mcp-logs-claude-ai-*` dirs); the real `~/.claude` is **untouched** (mtime check on `~/.claude.json` + `~/.claude/projects`); session is killed + ephemeral root removed on completion (no stray `home_*`); reaper kills a deliberately-orphaned session on the next startup.
- [ ] Re-run the T3 negative control (newline-as-text fails to submit) to confirm the Ink #15553 control still holds on this CLI version.
### 6. Acceptance criteria (binding)
- T6 flag set applied; MCP-disable gate asserts 0 managed-MCP (cache-dir evidence) per spawn.
- T3 submit: multiline/shell-special payload submits byte-for-byte, exactly one submit, transcript-verified.
- Trap-guaranteed teardown (no stray ephemeral roots / cred symlinks) + startup orphan reaper.
- Single buffered response; `stream:true` is SSE-replay (no token streaming). B hooks wired but inert.
- `cc_entrypoint=cli` confirmed; real `~/.claude` untouched.
### 7. Reviewer (Iron Rule 10)
Fresh-context reviewer opens **spec §5.2 (T6) + §6 (T3) + §3.1 + §5.5 + §8** and confirms: (a) `--strict-mcp-config` with NO `--mcp-config` is the disable mechanism (not seed-editing); (b) `--bare` is NOT used; (c) the T3 recipe is file→`send-keys -- "$(cat)"`→separate Enter (not `-l`, not literal `\n`); (d) teardown is finally-based + a startup reaper exists; (e) B hooks (`--tools ""`, `/mcp` hard gate) are present but gated to guest and B is documented as blocked on T2/T4; (f) response is single-buffered with SSE replay, no token streaming. A review that does not open the live `claude --help` for `--strict-mcp-config`/`--disallowedTools` on v2.1.158 is not valid.
### 8. Authority citation
`claude` CLI v2.1.158 § `--strict-mcp-config`, § `--disallowedTools`, § `--system-prompt`, § `--session-id`, § `--model` (live `--help` on PI231); env `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1` (binary-confirmed); `tmux` 3.3a § `send-keys`/`send-keys -l`/key-tokens (Ink #15553 control); spec §5.2 (T6 PASS), §6 (T3 PASS), §3.1, §5.5, §8. ALIGNMENT Rule 2: every flag is one `claude` accepts — no invented flag.
### Risk / rollback
Riskiest PR. Independently revertable (deletes the module + the `tui:true` caller; PR-0/PR-1 inert without it). Default-off: no server path invokes `runTuiTurn` until PR-3, and even then only under `CLAUDE_TUI_MODE=1`.
---
## PR-3 — Provider wiring + ADR + README
### 1. Goal
Add the `CLAUDE_TUI_MODE` branch in the anthropic provider `spawn()` so a flagged request routes to the TUI driver and yields IR chunks; default stays stream-json. Land ADR 0016 as authority of record and the README docs (quirks + non-honored params + grey-area framing).
### 2. Files touched
- `lib/providers/anthropic.mjs` — branch in public `spawn()` (`:1164`); call `reapOrphans` from a boot hook (or export an init the server calls).
- `server.mjs` — call the orphan reaper at startup (near `bootstrapSandbox`, `:82`/boot path); pass `tui:true` to `prepareIsolatedEnvironment` ONLY on the TUI branch (the branch lives in the provider, so the simplest wiring is: the provider's TUI branch calls `prepareIsolatedEnvironment({…, tui:true})` itself; if the existing architecture composes isolation in `server.mjs` before `spawn`, PR-3 adds a flag-gated `tui` pass-through there — decide per the under-spec note below).
- `docs/adr/0016-tui-mode.md` — NEW (or ADR 0009 Amendment 2).
- `README.md` — env-var table, Troubleshooting, API/Configuration notes.
- `CHANGELOG.md` — Unreleased entry (no version bump mid-Phase per CLAUDE.md `phase_rolling_mode`).
- `CONTRIBUTORS` — add jaekwon-park.
- `test-features.mjs` — branch-selection tests.
### 3. Concrete changes
**3a. `spawn()` branch (`anthropic.mjs:1164`).**
```
export async function* spawn(irRequest, authContext, isolationCtx) {
if (process.env.CLAUDE_TUI_MODE === '1') {
// import { runTuiTurn } from '../tui/session.mjs'
const text = await runTuiTurn({ ir: irRequest, authContext, keyId, reqId, … });
yield { type: 'delta', role: 'assistant', content: text };
yield { type: 'stop', finish_reason: 'stop' };
return;
}
yield* _spawnAndStream(irRequest, authContext, _spawnImpl, isolationCtx); // UNCHANGED default
}
```
- The default branch (`_spawnAndStream`) is **byte-for-byte unchanged**. C4 invariant holds.
- The 2-chunk yield is exactly what `collectAllChunks` (`server.mjs:1299`) buffers into an array for `getOrCompute`, and what `sourceWithRelease` (`server.mjs:1558`) yields for `getOrComputeStreaming` → SSE replay. No server change to the cache paths.
- **keyId/reqId access:** the provider `spawn()` currently receives `(irRequest, authContext, isolationCtx)` — it does NOT receive `keyId/reqId`. The TUI driver needs them (for ephemeral root + session name). **Under-spec — see §"Open implementation questions".** Options: (i) thread `keyId/reqId` into the TUI branch via `isolationCtx` (the manager already has `safeKeyId/safeReqId` and `ephemeralRoot`), so the driver reuses `isolationCtx.ephemeralRoot` rather than re-preparing; (ii) pass a `tui:true` to `prepareIsolatedEnvironment` at the server call site (flag-gated) and let the driver consume the returned `ephemeralRoot`. **Recommended: (i)** — the provider's TUI branch reads `isolationCtx.ephemeralRoot` + a reqId carried on `isolationCtx`, and PR-0's seed runs because the server passes `tui: (process.env.CLAUDE_TUI_MODE==='1')` to `prepareIsolatedEnvironment` at `server.mjs:1347` and `:1564`. This keeps the seed/ephemeral-root creation in the manager (one owner) and the tmux drive in the provider. Maintainer to confirm the threading before PR-2 finalizes its `runTuiTurn` signature.
**3b. Orphan reaper at startup.** Call `tmuxTransport.reapOrphans()` from the server boot path (alongside `bootstrapSandbox`, `server.mjs:82` import region / router init at `:2334`+), gated on `CLAUDE_TUI_MODE==='1'` so default deployments incur zero tmux dependency.
**3c. max_tokens / sampling graceful-drop (§4.5/§4.6) — already the behavior; just assert + document.** The TUI argv carries no `--max-tokens`/`--temperature`/etc. (interactive `claude` has none). `ir.max_tokens` (`openai-to-ir.mjs:182`), `temperature`, `top_p`, `stop` are accepted into IR and silently dropped at the argv boundary — same posture as the stream-json path. No error. Document in README (3e).
**3d. ADR 0016 (authority of record).** New ADR: Context (2026-06-15 billing split + ADR 0009 Amd 1 premise), Decision (TTY-backed TUI transport behind `CLAUDE_TUI_MODE`, default stays stream-json), the spike record (S1/S2/S3 + T1/T3/T6 results; T2/T4/T5 open), the §5.2 security model + §5.5 credential coupling, the §3.1 no-token-streaming decision, the §4.5/§4.6 dropped-param decision, A-vs-B gate semantics (no B before T2; serialized after T2; concurrent after T4). **Acknowledgment section names OCP PR #101 + jaekwon-park** (adopted: interactive-TUI-for-subscription idea; redesigned: transcript-read not hook-file, no `--dangerously-skip-permissions`, structural tool-stripping for B). Supersede note on ADR 0009 Amendment 1's billing-pool lane (§Status of the spec).
**3e. README.** Per CLAUDE.md `release_kit.new_feature_doc_expectations`:
- **Environment Variables table:** `CLAUDE_TUI_MODE` (default unset/off; opt-in TTY path; grey-area, billing-favorable, post-2026-06-15-inference), `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL` (set by TUI driver). Reserve-note `CLAUDE_TUI_WARM_POOL` as A-only future.
- **Troubleshooting / TUI-mode §:** onboarding-hang quirk (fresh ephemeral home → seed required, PR-0); **OAuth-login requirement** (one `claude login` on the host; member keys hold OLP keys not OAuth); **NO true token-streaming** (§3.1 — `stream:true` is replay-after-completion, one burst); **`max_tokens`/sampling params not honored** (§4.5–§4.6); honest grey-area framing (§10.2 — opt-in, no anti-fingerprinting, drop-on-ban).
- Do NOT hand-edit the Supported Providers table (sourced from `models-registry.json`).
### 4. Unit tests + fixtures
- [ ] `CLAUDE_TUI_MODE` unset → `spawn()` invokes `_spawnAndStream` (assert via `__setSpawnImpl` mock spawn is called; `runTuiTurn` is NOT). **C4 regression.**
- [ ] `CLAUDE_TUI_MODE='1'``spawn()` invokes `runTuiTurn` (inject a mock driver returning a fixed string) and yields `[delta, stop]` with `finish_reason:'stop'`.
- [ ] buffered path: a flagged request through the (mocked) provider produces a well-formed OpenAI JSON body (drive `getOrCompute`'s array consumer).
- [ ] streaming path: a flagged `stream:true` request replays as SSE delta(s) + `[DONE]` (drive `irChunkToOpenAISSE`).
- [ ] dropped-param: a request with `max_tokens`/`temperature` succeeds and ignores them (no error).
- [ ] reaper boot hook is a no-op when flag unset.
- [ ] Full existing suite green.
### 5. PI231 integration checkpoint (maintainer-supervised)
- [ ] On PI231 scratch (prod :4567 untouched), run a real flagged request end-to-end through OLP scratch instance with `CLAUDE_TUI_MODE=1`: buffered `stream:false` returns correct JSON; `stream:true` returns valid SSE (one burst); flag-unset run is identical to today's stream-json.
- [ ] **Pass criteria:** flagged path returns correct text via tmux/transcript; `cc_entrypoint=cli`; default path unchanged (diff a flag-unset response against current prod behavior); reaper runs clean at startup; real `~/.claude` untouched.
### 6. Acceptance criteria (binding)
- Flag unset → identical to current stream-json (C4). Flag set → TUI path, correct buffered + SSE-replay responses.
- ADR 0016 merged as authority of record, names PR #101 + jaekwon-park.
- README documents the env var, onboarding-hang, OAuth-login req, no-token-streaming, dropped params, grey-area framing.
- CHANGELOG Unreleased entry; CONTRIBUTORS updated; no mid-Phase version bump.
### 7. Reviewer (Iron Rule 10)
Fresh-context reviewer opens **spec §3.1, §4.5–§4.6, §10.2, §12 (PR-3), §13 + ADR 0016** and confirms: (a) the default branch is unchanged and the flag is the sole toggle (C4); (b) the 2-chunk adapter slots into both cache paths without server cache-layer edits; (c) dropped params documented, no silent failure; (d) ADR 0016 acknowledges PR #101/jaekwon-park and the co-author trailer is on the commits; (e) README quirks present. A review that does not open ADR 0016 + confirm the author-credit obligation is not valid.
### 8. Authority citation
OpenAI `/v1/chat/completions` spec (entry surface is unchanged; `stream`, `max_tokens`, `temperature`, `top_p`, `stop` fields — document non-honored set) — cite the OpenAI spec URL for the entry-surface PR portion; `claude` CLI v2.1.158 (provider branch); ADR 0016 (new authority of record) + ADR 0009 Amendment 1 (superseded billing lane) + ADR 0002 Amendment 9 (ISOLATION) + ADR 0014 (sandbox). spec §§3.1/4.5/4.6/10.2/12/13. Co-author trailer `jaekwon-park` on every commit (§13).
### Risk / rollback
Independently revertable (revert removes the branch; provider returns to pure stream-json). **Default-off is the kill switch:** until an operator sets `CLAUDE_TUI_MODE=1`, nothing about TUI-mode executes. The OCP single-tenant canary (post-2026-06-15) is the first real enablement.
---
## Parallel B-gate spike track (does NOT block A)
These run independently of PR-0..PR-3 and gate Deployment B only. One paragraph each.
- **T2 — body-capture `tools:[]` (security + credential-safety gate, §5.2(4)/§5.5).** Stand up a body-logging channel for the outbound `/v1/messages` from an interactive `claude` spawn (a local MITM proxy with a trusted cert in the ephemeral home, or a body-capturing forward proxy via `HTTPS_PROXY`). `--debug api` is insufficient (metadata only). Method: run a TUI turn under the full §5.2 flag set (`--strict-mcp-config` + `--disallowedTools "mcp__*"` + `--tools ""`), capture the wire request body, assert it carries `tools:[]` or no tools array. PASS is the hard gate that lets B launch (serialized). Per §5.5 this is a **credential-safety** gate, not mere MCP hygiene.
- **T4 — concurrency (§7.3).** Run K concurrent TUI turns sharing one owner OAuth, each with its own ephemeral `$HOME` + `--session-id` + cwd. Method: fire K parallel `runTuiTurn` calls; assert transcript isolation (no cross-session lines), billing entrypoint stays `cli` on all, no OAuth auth contention/refresh thrash, and that one credential tolerates K concurrent interactive sessions. Until PASS, B serializes (concurrency=1). This lifts B's concurrency limit only.
- **T5 — cold-start latency + inotify (§4.4 sizing / non-blocking).** Method: measure submit→transcript-available cold-start end-to-end (currently unmeasured); compare `inotifywait` vs 0.5s poll under load; measure Opus-class long-stream `turn_duration` ordering to size the §4.4 wall-clock cap (recommend ≥120s, tune here). Non-blocking for A; informs the cap constant and a possible future quiescence window (which §4.4 forbids in v1).
---
## Test strategy on PI231 without breaking prod
- **PI231 runs prod OLP on :4567.** It must stay untouched throughout. All TUI testing is **/tmp scratch + tmux**: a scratch OLP instance on a different port (or direct `node` invocation of the new modules), ephemeral homes under `/tmp/olp-spawn/*`, scratch tmux sessions `olp-tui-*`.
- **Never** point a TUI test at the prod `~/.claude` — the ephemeral-home seed + symlink keep the real home read-only; every PI231 checkpoint asserts `~/.claude.json` + `~/.claude/projects` mtime unchanged.
- **The canary is OCP single-tenant post-6/15** (spec §12.6): OCP is one user, no cross-tenant boundary, and is where PR #101 originated. Enable `CLAUDE_TUI_MODE=1` there first; watch billing entrypoint stays `cli`, cap behavior, completion reliability over real usage — before any OLP Deployment-B exposure.
- Mac mini is NEVER a test target (cc-mem rule). MacBook/PI231-scratch only.
---
## Author credit (binding, §13) — checklist applied to every PR
- [ ] Co-author trailer `Co-Authored-By: jaekwon-park <…>` on every implementing commit (pull real email/handle from OCP PR #101 first — do not invent).
- [ ] ADR 0016 names PR #101 + jaekwon-park (adopted idea vs redesigned implementation).
- [ ] Add jaekwon-park to CONTRIBUTORS.
- [ ] Notify on OCP PR #101 (comment linking the shipping PR) at ship time.
---
## Open implementation questions (maintainer decides BEFORE code)
1. **keyId/reqId into the TUI driver.** The provider `spawn(irRequest, authContext, isolationCtx)` does not receive `keyId/reqId` today. The driver needs them for the ephemeral root + tmux session name. Recommended: have the server pass `tui:(CLAUDE_TUI_MODE==='1')` to `prepareIsolatedEnvironment` at `server.mjs:1347`/`:1564` (so the seed + chmod fire in the manager), and thread `ephemeralRoot` (+ a reqId field) to the provider via `isolationCtx`; the TUI branch then reuses `isolationCtx.ephemeralRoot` rather than re-preparing. Confirm this threading before PR-2 fixes `runTuiTurn`'s signature. **(Spec §3.2 implies the reuse but does not specify the parameter plumbing.)**
2. **Warm pool (§7.2) is namespace-reserved, not built.** Confirm A's canary runs ephemeral-per-request (no warm pool) for this plan — the spec allows warm pool for A but it adds cross-request-context-leak risk and is out of the PR-0..PR-3 scope.
3. **Wall-clock cap constant.** Spec recommends ≥120s pending T5. Confirm the v1 value to bake into `readTurnResult` (PR-1) — or read it from config so T5 can tune it without a code change.
4. **Large-paste path (§6 caveat).** T3 validated ≤50 lines / 1.2 KB. Coding-proxy traffic carries multi-KB pastes. Decide whether PR-2 ships `send-keys` only (with the documented ≤50-line validation) or also wires the `paste-buffer`/`load-buffer` fallback for large bodies now (recommended as a fast-follow, non-blocking for A).
5. **Preflight MCP-disable session for A.** §5.2 makes the separate-preflight `/mcp`-empty assertion a hard gate for B. Confirm whether A's canary runs it as advisory-at-startup (recommended) or skips it (relying on the per-spawn cache-dir assertion alone).
---
## Maintainer decisions — plan-review fixes + open questions RESOLVED (2026-05-30)
Plan-review verdict was **ready-with-fixes**. All anchors verified accurate. Decisions below resolve P1P5 + the open implementation questions; the plan is now ready to implement.
| Ref | Decision |
|---|---|
| **P1 / OQ#1 — keyId/reqId plumbing** | **Reuse `isolationCtx.ephemeralRoot` + reqId.** The two existing spawn call sites (`server.mjs:1347`, `:1564`) already call `prepareIsolatedEnvironment` and pass `isolationCtx` into `spawn()`. PR-0 adds `ephemeralRoot` + `reqId` to the returned `isolationCtx`; the TUI branch reads them from there — **no new edits to the default-path call sites**, preserving the byte-for-byte-unchanged invariant. `runTuiTurn(isolationCtx, irRequest, opts)` takes `isolationCtx`, not raw keyId/reqId. |
| **P2 — reaper boot anchor** | Wire `reapOrphans()` into the real boot region: the `isMain` block at **`server.mjs:2417`** (NOT `:2334`, which is wrong; `:82` is the import). Co-locate with the existing `await bootstrapSandbox()` call. |
| **P3 / OQ#5 — A preflight `/mcp`** | **A also spawns with `--strict-mcp-config` + `--disallowedTools "mcp__*"`** (defense-in-depth — even the owner does not want a prompt-injected client reaching the owner's own Gmail/Drive). The separate preflight `/mcp`-empty session is **advisory-at-startup for A** (log a warning if managed MCP still attaches; do NOT block), and a **hard gate for B**. Decided line item for PR-2, no longer open. |
| **P4 — tier citation** | Cite accurately: manifest `owner_tier ∈ {'owner','guest'}` (`keys.mjs:143`); `'anonymous'` is a runtime fallback identity (`:439`), not a manifest tier. Cosmetic; correct the anchor table. |
| **P5 / OQ#3 — wall-clock cap** | **Config, not constant.** Read from env `CLAUDE_TUI_WALLCLOCK_MS` (default `120000`) so T5 can tune it without a code change. Baked into `readTurnResult` (PR-1). |
| **OQ#2 / OQ#5 — warm pool** | **Out of PR-0..PR-3 scope.** Initial A = per-request ephemeral session (cleanest, matches B). Warm pool is a later opt-in optimization (`CLAUDE_TUI_WARM_POOL`), process-reuse-not-context per spec §7.2, tracked separately. |
| **OQ#4 — large-paste (>50 lines)** | **Defer to fast-follow.** PR-2 ships the `send-keys -- "$(cat file)"` recipe with the documented ≤50-line / multi-KB validation from T3; the `paste-buffer`/`load-buffer` path for very large bodies is a non-blocking follow-up PR. Document the current bound in the README. |
**Net:** P1 (the one true PR-2-interface blocker) is decided = reuse `isolationCtx`. P2/P4 are anchor corrections. P3/P5 are decided line items. Warm-pool + large-paste are explicitly scoped out of the initial A deliverable. Implementation may proceed PR-0 → PR-1 → PR-2 → PR-3.
@@ -1,399 +0,0 @@
# TUI-mode — Production Design Spec
- **Date:** 2026-05-30
- **Status:** Draft (design spec; pre-implementation). Supersedes the "Option 1 / stream-json adapter" lane of ADR 0009 Amendment 1 for the *billing-pool* concern, and proposes a new ADR (0009 Amendment 2 or a fresh ADR 0016) as the authority of record before any code lands.
- **Authors:** project maintainer (with AI drafting assistance).
- **Builds on community work:** `dtzp555-max/ocp` **PR #101 by jaekwon-park** (tmux + interactive-`claude` prototype). See § "Author credit plan".
- **Validated by:** PI231 spikes S1 (billing + no-tool property), S2 (JSONL transcript output), S3 (submission reliability), plus pre-code gate spikes **T1** (completion detection on non-`end_turn` stop reasons — PARTIAL), **T3** (multiline/special-char submission — PASS), **T6** (marketplace + managed-MCP disable — PASS), `claude` v2.1.158, `tmux` 3.3a, model `claude-haiku-4-5-20251001`, arm64 Debian. Spike JSON retained in session record.
> **Honesty banner.** TUI-mode is a *grey-area bridge*, not a durable architecture. It automates `claude`'s genuinely-interactive mode (`cc_entrypoint=cli`) to serve programmatic proxy requests so traffic bills against the Anthropic subscription pool instead of the post-2026-06-15 Agent SDK credit pool. The interactivity is real (not forged), but it is automated. It is OPT-IN (`CLAUDE_TUI_MODE`). Spike-confirmed facts and the remaining gaps govern everything below: (a) `--system-prompt` keeps `cc_entrypoint=cli` — the TTY path carries the **genuine interactive-use signal**; whether that *bills* to the subscription pool is an **inference pending post-2026-06-15 validation** (S1 proved the entrypoint signal, not the billed pool — the split has not yet taken effect, so no spike can prove the billed pool today); (b) the native JSONL transcript is a clean, escaping-free output channel — **output mechanism is sound** (S2 PASS); (c) `--system-prompt` suppresses tool *text* but does **not structurally strip** account-attached managed MCP servers — **the no-tool property is model restraint, not enforcement** (S1 PARTIAL); (d) the load-bearing structural MCP-disable mechanism is now **found and verified** — `--strict-mcp-config` (with no `--mcp-config`) yields 0 managed-MCP attachment (T6 PASS); (e) `turn_duration` completion detection is **reliable for text/refusal turns but ABSENT on tool-use turns**, which would hang the reader — a co-equal wall-clock/quiescence guard is now mandatory, not optional (T1 PARTIAL); (f) multiline/special-char prompt submission is **byte-for-byte reliable** via `send-keys -- "$(cat file)"` + separate Enter token (T3 PASS). (c)+(e) remain the load-bearing risks; (c) is now mitigable structurally via (d) and gates multi-tenant (Deployment B) rollout together with the still-open body-capture verification (T2) and concurrency (T4).
---
## 1. Context & motivation
### 1.1 The billing trigger
Anthropic's 2026-06-15 billing split moves `claude -p`, the Agent SDK, and "third-party apps that authenticate with your Claude subscription through the Agent SDK" into a separate ~$100/month Agent SDK *credit* pool. The subscription pool (Pro/Max) covers "Claude Code in the terminal or your IDE in **interactive mode**." OLP's anthropic provider currently spawns `claude` non-interactively (`--output-format stream-json --verbose --no-session-persistence`, ADR 0009 Amendment 1). Post-split, that path's billing classification is at best uncertain and at worst routes to the credit pool — which exhausts in ~2050 heavy sessions/month and makes OLP unusable for a Pro subscriber pooling to family/team.
TUI-mode is the bridge: drive `claude` in genuine interactive mode (no `-p`, no `--output-format`; a real PTY/tmux session) so the User-Agent carries `cc_entrypoint=cli`, which S1 confirmed holds even with `--system-prompt`. That signal matches genuine interactive use; **actual subscription-pool billing is an inference to be validated only after the 2026-06-15 split takes effect** — S1 cannot prove the billed pool pre-split, and the OCP canary (§ 12) is the first real billing measurement.
### 1.2 What changed since ADR 0009 Amendment 1
ADR 0009 Amendment 1 locked "Option 1 — stream-json, no `-p`" on the premise that stream-json-without-`-p` emits NDJSON *and* (implicitly) bills as interactive. The unverified premise in ADR 0009 § 1.3 was exactly the TTY-detection risk: **Anthropic may use `isTTY` as the billing signal, not the `-p` flag.** If that premise holds, the current stream-json (piped stdio, non-TTY) path bills as `sdk-cli`/credit-pool. TUI-mode resolves this by using a **real TTY** (PTY/tmux), which S1 confirmed produces `cc_entrypoint=cli` across all 5 `/v1/messages` requests in a turn (main + auxiliary). This spec therefore **does not replace** the stream-json path; it adds a *TTY-backed* execution mode selectable per the `CLAUDE_TUI_MODE` flag, keeping stream-json as the default. Note the default's billing is **uncertain, not safe-credit-pool-guaranteed**: per ADR 0009 § 1.3 the non-TTY piped-stdio default may itself bill to the credit pool if Anthropic keys on `isTTY` — its merit is the conservative ToS posture, not a billing guarantee (§ 10.2).
### 1.3 Orthogonal value (so the work earns its keep even if the bridge dies)
Per ADR 0009 Amendment 1 § "Value re-anchoring": even if Anthropic reclassifies third-party apps to the credit pool on 2026-06-15 — killing the billing bridge — the `--system-prompt` tool-suppression already delivers the hallucination fix (env-block / cwd injection) and a measured ~30% input-token / ~64% per-request cost reduction. TUI-mode inherits those. The transcript-read channel (S2) additionally exposes per-turn `turn_duration` (messageCount + durationMs) for observability.
---
## 2. Deployment models
TUI-mode must serve two shapes. **B is the superset; A is B with exactly one key.** Build for B; A falls out.
### 2.1 Model A — single-user / multi-device
One subscription, one OLP server instance, many of the *user's own* client IDEs/devices. All traffic is the same human. Privacy *between clients* is not a hard requirement (it's all one person), so A **may** opt into a warm session pool for latency (§ 8) — but a warm pool MUST reuse the *process* only, **not** conversation context: each request resets to a fresh turn (new `--session-id`, or `/clear` between requests) so it never inherits a prior request's implicit context. Otherwise the proxy violates OpenAI chat-completions **stateless** semantics (a later request would see an earlier one's hidden context, dirtying cache + reproducibility) even for a single user. One `claude login` on the host.
### 2.2 Model B — family / team share
One **owner** subscription pooled to N members via OLP per-key auth. Members do **not** do their own OAuth — they hold an OLP key; the host holds the single owner OAuth. Hard requirements:
- **Per-member privacy.** Member A cannot see the owner's or member B's history. Transcripts must never co-mingle and must never land in the owner's real `~/.claude/projects/`.
- **Per-key cache + audit isolation.** Reuse the existing OLP/OCP multi-key namespacing (`lib/keys.mjs`: `owner_tier`, `providers_enabled`, per-key cache/audit). No new isolation primitive is invented for cache/audit.
- **Shared 5-hour cap.** One pooled OAuth → the subscription's rolling 5-hour usage cap is shared across all B members. This is an inherent limit of pooling one subscription (§ 9).
- **Structural tool stripping is mandatory** (not optional as in A), because a member's prompt reaching an un-stripped tool surface could touch the *owner's* Gmail/Calendar/Drive via account-attached MCP (S1 caveat). See § 5.
---
## 3. Architecture (the layers)
TUI-mode is a new **execution transport** under the existing anthropic provider, selected when `CLAUDE_TUI_MODE` is set. It reuses the IR boundary, the `--system-prompt` wrapper (Phase 6c), the ephemeral-home isolation (Phase 7), and multi-key auth unchanged. New surface is the session driver + transcript reader.
```
OpenAI-compat entry (/v1/chat/completions) [REUSE — unchanged]
│ validateKey → keyId, owner_tier, providers_enabled [REUSE lib/keys.mjs]
IR request ────────────────────────────────────── [REUSE lib/ir]
│ irToAnthropic: role:system → --system-prompt; user/assistant → prompt text
anthropic provider .spawn() [BRANCH on CLAUDE_TUI_MODE]
├─ default (flag unset): stream-json --verbose --no-session-persistence
│ (ADR 0009 Amd 1; uncertain-billing / safe ToS posture —
│ per ADR 0009 §1.3 the default itself MAY bill to the
│ credit pool because Anthropic may key on the isTTY signal)
└─ CLAUDE_TUI_MODE=1: ── TUI transport ──────────────────────────────┐
┌──────────────────────────────────────────────────────────────────────── ▼ ───┐
│ 1. prepareIsolatedEnvironment({ provider, keyId, reqId }) [REUSE Phase 7] │
│ Layer 1: ephemeral $HOME = /tmp/olp-spawn/<keyId>/<reqId>/home (chmod 700)│
│ Layer 2: symlink real ~/.claude/.credentials.json → ephemeralRoot │
│ + NEW: seed ephemeral .claude.json (onboarding/trust/bypass; mode 600) │
│ (NOTE: seed does NOT disable managed-MCP — T6 negative control; that is │
│ the spawn-flag --strict-mcp-config in step 2, not the seed) │
│ 2. spawn interactive `claude` in a PTY/tmux session bound to ephemeralRoot │
│ args: --system-prompt "<OLP wrapper>" --model <m> --session-id <uuid> │
│ --strict-mcp-config (no --mcp-config) --disallowedTools "mcp__*" │
│ [--tools "" | --allowedTools "…"] (NO -p, NO --output-format) │
│ env: CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1 │
│ → real TTY → cc_entrypoint=cli ; 0 managed-MCP (T6-verified) │
│ 3. submit prompt (T3): write body to file → send-keys -- "$(cat file)" → │
│ settle ~1.5-2s → send Enter as a SEPARATE tmux KEY TOKEN │
│ verify via TRANSCRIPT (exactly 1 user line == source); retry Enter ≤4x │
│ 4. read response from NATIVE JSONL transcript at the computed deterministic │
│ path; completion (T1 dual-signal): {"type":"system","subtype": │
│ "turn_duration"} line OR terminal guard (stop_reason:tool_use / │
│ size-stable ≥10s / wall-clock cap ≥120s → clean 502, never hang) │
│ 5. map transcript assistant text blocks → ONE buffered IR response → OpenAI │
│ JSON, or (stream:true) replay the completed text as SSE chunks AFTER the │
│ turn finishes — NOT incremental tokens (see § 3.1 streaming semantics) │
│ 6. cleanup(): kill session, rm -rf ephemeralRoot (trap-guaranteed) │
└────────────────────────────────────────────────────────────────────────────── ┘
```
### 3.1 Streaming semantics — single buffered response, NOT token streaming (DECISION)
TUI-mode reads the native transcript JSONL **after the turn completes** (the `turn_duration` marker / quiescence guard, § 4.3–§ 4.4). The transport therefore produces a **single, fully-buffered response string** — there is no per-token channel to tap, because the transcript is only authoritative once the turn is done. **True incremental token-streaming is NOT possible in TUI-mode.** (Tapping the live `capture-pane` for partial text is explicitly rejected: § 6/T3 showed large pastes collapse to a `[Pasted text …]` placeholder and pane text is cosmetic, not authoritative.)
**Decision (maintainer default):** for `stream:true` requests, **replay the completed response as SSE chunks** — chunk the buffered string and emit it as standard OpenAI `delta` events followed by `[DONE]`. The wire format is valid SSE, but the data arrives as **one burst after the turn finishes**, not incrementally as the model generates. This limitation is documented in the README (Troubleshooting / TUI-mode § "no true streaming") and surfaced to operators. Clients that depend on early-token latency (e.g. live typing UIs) get a correct-but-non-incremental experience under TUI-mode; this is an accepted trade of the bridge.
### 3.2 Cache contract integration (REUSE getOrCompute / singleflight)
The TUI transport is, from `server.mjs`'s perspective, a function that returns a **resolved response string** for a `(keyId, prompt)` pair — the same shape the existing cache layer expects. It plugs into the established `getOrCompute` / singleflight contract in `server.mjs` unchanged: the cache key is composed exactly as today (content-addressed over the normalized prompt), and the TUI transport is invoked only on a cache miss as the compute function whose resolved string is then stored and replayed (including chunked SSE replay for `stream:true`, identical to how the stream-json path's buffered result is cached). **Cache-key note (from T3):** the interactive input box strips the prompt's single trailing newline on submit; any prompt-in vs prompt-on-wire hashing MUST normalize the trailing newline or it will see a spurious cache-key mismatch. No new cache primitive is introduced; per-key isolation and singleflight are REUSE (§ 9).
Layer responsibilities:
| Layer | Owner | Reuse / New |
|---|---|---|
| Entry surface, IR, key auth | server.mjs, lib/ir, lib/keys.mjs | REUSE |
| System-prompt wrapper (`OLP_SYSTEM_PROMPT_WRAPPER`) | lib/providers/anthropic.mjs | REUSE (Phase 6c) |
| Ephemeral home + credential mount + cleanup | lib/sandbox/manager.mjs `prepareIsolatedEnvironment` + anthropic `ISOLATION` | REUSE + EXTEND (seed `.claude.json`, pin plugins) |
| Session driver (PTY/tmux spawn, submit, dialog auto-answer) | **NEW** lib/providers/anthropic-tui.mjs (or lib/tui/session.mjs) | NEW |
| Transcript reader (path compute, poll/inotify, completion detect, text extract) | **NEW** lib/tui/transcript.mjs | NEW |
| Cache + audit per-key | lib/cache, lib/audit | REUSE |
---
## 4. Output mechanism — native JSONL transcript read (S2 PASS)
**Decision: read `claude`'s native session transcript JSONL. Do NOT use a hook→result.json contract, and do NOT rely on `--output-format`.** S2 proved this end-to-end and it is strictly better than the PR #101 hook-file approach: it eliminates JSON double-escaping (the exact failure that broke hook→result.json) and removes any need for `--dangerously-skip-permissions` (§ 5.4).
### 4.1 Transcript path formula (S2-confirmed, exact)
```
<EHOME>/.claude/projects/<CWD_ENCODED>/<SESSION_ID>.jsonl
```
- `EHOME` = the ephemeral `$HOME` from `prepareIsolatedEnvironment`.
- `CWD_ENCODED` = the spawn `cwd` with **every** `/` replaced by `-`, **including the leading slash** (so `/tmp/x``-tmp-x`). Verified against pre-existing dirs and against the spike's own run.
- `SESSION_ID` = the UUID OLP passes via `--session-id`. OLP generates it, so OLP computes the path *before* spawn. File is created lazily on first message, not at spawn — the reader must tolerate "file not yet present" and poll for creation.
### 4.2 Assistant text extraction (escaping-clean — the load-bearing win)
The final assistant message is `type:"assistant"` with a content block `type:"text"`. Because this is `claude`'s *native* log, one `JSON.parse()` per line yields the text with real newlines, real double-quotes, and **zero** `\\n` / `\\"` double-escaping artifacts (S2 char-level checks: double-quote present, real newline present, literal-backslash-n bug-indicator absent). Response text = concatenation of `text` blocks from `assistant` messages emitted **since the matching `user` line** for this turn.
### 4.3 Completion detection (S2-confirmed, with the trap)
- **Positive marker = a line `{"type":"system","subtype":"turn_duration"}`.** It is the last line of the turn by timestamp and carries `messageCount` + `durationMs` (and `entrypoint=cli`). Poll the file (or `inotifywait`); when a `turn_duration` line for this turn appears, the turn is done. **T1 confirmed** this fires for both text turns (a 2128-word / 19991-char near-cap answer, `durationMs=33135`) **and** refusal turns (`durationMs=3221`).
- **TRAP — do NOT key off `stop_reason:"end_turn"` alone.** It appears on BOTH the `thinking` block AND the `text` block, so "first `end_turn`" fires before the visible text is complete.
- **TRAP — do NOT assume `turn_duration` is the literal last *byte* in the file.** S2 saw write-order momentarily differ from timestamp-order (a text block flushed after `turn_duration` by byte position while `turn_duration` had the later timestamp). Robust rule: "a `turn_duration` line for this turn has appeared" → then read all assistant `text` since the `user` line. Do not rely on file-tail ordering.
- **TRAP (NEW, T1) — `turn_duration` is ABSENT on tool-use turns.** When the model issues a `tool_use` block, the last assistant line carries `stop_reason:"tool_use"` and **no `turn_duration` line is ever written** — even after the (interactive) tool-permission dialog is rejected. A marker-only reader would hang indefinitely. `turn_duration` MUST NOT be the sole completion signal (see § 4.4).
- **Latency:** S2 measured submit→transcript-available ≈ 3.43.6s wall for a tiny haiku turn (~300 output tokens); a 0.5s poll added <0.5s detection lag. `inotifywait` would make detection lag near-zero. T1's longest legitimate text turn was `durationMs=33135` (~33s server-side, ~20s detection wall) — this is the realistic worst case for a long single-stream answer and bounds the quiescence/wall-clock sizing in § 4.4.
### 4.4 Completion robustness — T1 RESOLVED (partial): dual-signal guard is MANDATORY
**T1 verdict: PARTIAL.** `turn_duration` is RELIABLE for text-only turns (both near-cap long answers and refusals emit it) but is **ABSENT on tool-use turns**, which would hang a marker-only reader. Verified on PI231, `claude` v2.1.158, model `claude-haiku-4-5`, against the § 4.3/§ 4.4 contract. (A true API `max_tokens` truncation could not be forced — interactive `claude` exposes no max-tokens flag, so the "long" path exercised `claude`'s own default-length stop, which is `end_turn`-with-`turn_duration`; see § 4.5 and the concern below.)
**Production rule (now binding, not a gate).** The reader MUST treat completion as a **dual signal**:
- **(A) Happy path** — a `{"type":"system","subtype":"turn_duration"}` line for this turn appears (fires for `end_turn` text turns and refusal turns). Then read all assistant `text` blocks since the matching `user` line (§ 4.2; do not rely on file-tail byte ordering).
- **(B) Co-equal terminal-NON-HANG guard (mandatory)** — detect either: the transcript's last assistant message has `stop_reason:"tool_use"`, **or** an absolute wall-clock cap fires. Either is a **terminal** condition: abort the turn and return a clean error (e.g. `502` "tool-use turn unsupported in TUI-mode" / "completion-marker timeout"). **Never block forever.**
⚠️ **Quiescence ("file size-stable for N seconds") is deliberately EXCLUDED from the v1 terminal set.** A long Opus extended-thinking turn or a slow-network turn can legitimately produce **no transcript growth for >10s**, so a quiescence cut would falsely abort valid long turns (this corrects the T1 spike's own co-equal-quiescence suggestion). Quiescence may be added **only after spike T5** establishes a safe window AND only gated behind "assistant/tool output has already begun." v1 relies on `turn_duration` (happy path) + `tool_use` detection + a generous wall-clock cap alone.
**Sizing (from T1, tune via T5):** longest legitimate text turn measured was `durationMs=33135` (~33s server-side, ~20s detection wall). Set the absolute wall-clock cap **generously above expected Opus-class long-stream latency (recommend ≥ 120s, tune via spike T5)** so a slow-but-valid long turn is not aborted prematurely.
**Why guard (B) cannot be dropped under the structural tool-strip.** S1 already showed — and T1 re-confirmed at the model's own words ("The tools are available in the function schema, but… I won't invoke tools") — that `--system-prompt` suppresses tool *use* via model restraint, NOT tool *availability*. Under the production `--system-prompt` wrapper, three separate tool-inviting prompts all resolved to `end_turn`+`turn_duration` with zero `tool_use` — but **restraint is not enforcement**, so a `tool_use` turn (and its hang) remains reachable in production whenever model restraint does not hold. The structural tool-removal required for guest/member keys (§ 5.2, now mechanizable via T6) reduces this for multi-tenant traffic, but does **not** eliminate the need for guard (B) on **owner-tier / canary traffic where tools remain attached.** Guard (B) is unconditional.
**Second hang vector — interactive tool-permission dialog.** When a `tool_use` does occur, `claude` blocks on an interactive tool-PERMISSION dialog in the TUI (`Do you want to create …? 1.Yes 2.Yes-allow-all 3.No`) with `stop_reason:"tool_use"` and no `turn_duration` — the session is frozen awaiting a keypress, a distinct hang from the missing-marker case. Production TUI-mode MUST either pre-grant/auto-deny tool permissions (a permission-mode that auto-rejects) **or** have guard (B) detect-and-tear-down a session stuck on a permission prompt. The cleanest combination is the structural disable of § 5.2/T6 (no MCP tools to invoke) *plus* a built-in-tool lockdown (`--tools ""` / explicit `--allowedTools` subset) so no `tool_use` is reachable at all on member keys.
**Re-run cadence:** `turn_duration` emission is an undocumented internal-log behavior pinned to `claude` v2.1.158. Re-run T1 on every `claude`/Ink version bump.
### 4.5 max_tokens handling (DECISION — ignore + document)
GROUND TRUTH (verified against the current code path): `buildCliArgs` passes only `--model` + `--system-prompt`; a client `max_tokens` is parsed into the IR (`lib/ir/openai-to-ir.mjs:182`) but **never reaches the CLI** today — interactive `claude` exposes **no max-tokens flag**, and the existing stream-json path does not forward it either. T1 also could not force a true API `max_tokens` truncation for the same reason; the "long" path tested `claude`'s own default-length stop (`end_turn`-with-`turn_duration`), which is the realistic worst case for length.
**Decision (maintainer default): ignore `max_tokens` and document the limitation.** This matches current stream-json behavior, so TUI-mode introduces no regression. The IR field is accepted and dropped silently at the CLI boundary (no error). README documents that `max_tokens` is not honored under either anthropic path. **Future option (not in initial scope):** soft-inject a "limit your response to roughly N tokens" instruction into the prompt body for a best-effort approximation — this is a prompt-level hint, not a hard API cap, and would be a separate ADR-tracked change. Note the formally-unverified corner: if the proxy ever maps client `max_tokens` to a *real* truncation, the `turn_duration` behavior on a hard `max_tokens` stop is untested (though `end_turn`-with-`turn_duration` is the observed behavior for the longest turns `claude` produces on its own).
### 4.6 Other OpenAI sampling params — graceful drop (DECISION)
Interactive `claude` (`cc_entrypoint=cli`) exposes **no flags** for `stop`, `temperature`, `top_p`, `presence_penalty`, `frequency_penalty`, `logit_bias`, `n`, or `seed` — the interactive session uses the account/model defaults and there is no per-request override surface. **Decision (maintainer default): accept these params into the IR and drop them gracefully at the CLI boundary** (no error, same posture as `max_tokens` § 4.5 and consistent with the existing stream-json path, which also cannot forward them). README documents the non-honored set so clients are not surprised when, e.g., a low `temperature` does not deterministically constrain output under TUI-mode. No silent failure mode is introduced — the request still succeeds, it just ignores the unsupported knobs.
---
## 5. Security model — the no-tool property
### 5.1 What S1 actually proved (and did not)
S1 confirmed *behaviorally*: with `--system-prompt`, for a coding-style prompt, the model answered conversationally and emitted **zero** `tool_use`/`tool_call`/`tool_result` tokens, and the entrypoint stayed `cli`. **But** during startup the CLI auto-fetched the Anthropic official plugin marketplace and established live MCP connections (claude.ai Gmail / Google Calendar / Google Drive) — account-attached managed MCP servers delivered **over the network** (each connects via `https://mcp-proxy.anthropic.com/v1/mcp/<mcpsrv_id>`), present **even with empty local `mcpServers` config**. `--system-prompt` replaces the system-prompt *text* (suppressing default tool-usage instructions) but does **not** strip tool/MCP *availability* from the request. The no-`tool_use` outcome was **model restraint, not structural enforcement.** **T6 re-confirmed** this directly: even under the production `--system-prompt` wrapper, the model stated the tools are present in its function schema ("The tools are available in the function schema, but… I won't invoke tools") — so structural stripping (§ 5.2) is required and is **orthogonal to** `--system-prompt`. Additionally, `--debug api` logs metadata only (not bodies), so the spike could not prove the outbound `/v1/messages` carried `tools:[]` — only that no `tool_use` came back (still open as T2 body-capture).
### 5.2 The structural requirement (binding for Deployment B) — T6 RESOLVED the disable mechanism
A multi-tenant proxy MUST **structurally** remove the tool surface, not rely on the model declining. **T6 (PASS) found and verified the load-bearing mechanism**: the `--strict-mcp-config` CLI flag (with **no** `--mcp-config` supplied) yields **ZERO** managed-MCP attachment in an ephemeral interactive session — 0 `mcp-logs-claude-ai-*` cache dirs and `/mcp` reports "No MCP servers configured" (vs. a baseline of 3 servers / 28 tools). It keeps OAuth subscription auth intact. This converts requirement (1) below from "find a mechanism" (formerly spike T6) into **"apply the verified mechanism + assert the verification gate."**
**Critical NEGATIVE control (binding):** T6 proved that **stripping/seeding the ephemeral `.claude.json` is NOT a mitigation.** Removing the local cache key `claudeAiMcpEverConnected` from the seed did **not** prevent attachment (the 3 servers still connected, 28 tools) — the managed-MCP fetch is **account/server-driven**, not gated by any local `.claude.json` field. **Do NOT rely on editing the seeded home to disable MCP.** The CLI flag is required; the seed-edit approach (an earlier § 7.1 / PR-0 assumption) is downgraded to onboarding/trust convenience only and carries **no** security weight for MCP.
Concretely, before TUI-mode is allowed for any **owner_tier=guest** (member) key, the ephemeral spawn MUST:
1. **Pass `--strict-mcp-config` and pass NO `--mcp-config`** (mandatory, load-bearing — the ONLY mechanism that prevents the account-attached claude.ai managed MCP from connecting over the network). T6-validated spawn template (PI231): `claude --model <m> --session-id <uuid> --strict-mcp-config --disallowedTools "mcp__*" [--tools "" | --allowedTools "…"]`.
2. **Lock tools down explicitly**`--disallowedTools "mcp__*"` (deny any MCP-namespaced tool even if config changes), plus built-in lockdown. **For initial Deployment B the lockdown MUST be `--tools ""` (ZERO built-in tools) — NOT an `--allowedTools` subset.** Rationale (credential-wall coupling, § 5.5): any tool in an `--allowedTools` subset that can read files / run commands / reach the network **voids both the T2 `tools:[]` proof and the owner-bearer credential wall**. Any non-empty `--allowedTools` subset for B is **out of initial scope** and requires its own ADR + security proof. Note `--strict-mcp-config` removes MCP tools but does **NOT** lock built-in tools (Bash/Read/etc.) — the `--tools ""` pairing is required for multi-tenant.
3. **Disable the official-marketplace plugin auto-install (defense-in-depth)** — set env `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1` (binary-confirmed env var; 0 plugin/marketplace dirs in T6 worst-case test). `--strict-mcp-config` affects MCP only; the marketplace is a separate surface. In a fresh ephemeral home no marketplace was present, but the env var is cheap insurance against the autoinstall firing on a flag-stripped home.
4. **Verify with a body-level capture** (proxy MITM or a body-logging channel) that the outbound `/v1/messages` actually carries `tools:[]` (or no tools array). `--debug api` is insufficient — it does not log bodies. **This is the one remaining hard gate (spike T2)** on Deployment B: T6 proved the MCP servers do not *connect* (cache-dir + `/mcp` + transcript-token evidence), but body-capture of the wire request is still needed to assert the request carries no tools array.
**Verification gate (preflight / upgrade-time — NEVER inside a serving turn):** Running `/mcp` (or the cache-dir assertion) **inside a session that also serves a user request would itself write a transcript line, consume a turn, and corrupt the reader's "matching user line" semantics (§ 4.2).** So the gate MUST run as a **separate preflight session** — at server startup and on every `claude` CLI upgrade — whose transcript is discarded and which never serves a user turn. The preflight asserts **0** dirs matching `$HOME/.cache/claude-cli-nodejs/*/mcp-logs-claude-ai-*` **and** that `/mcp` reports "No MCP servers configured." Findings are pinned to `claude` v2.1.158 and the managed-MCP fetch is account/server-driven, so a future CLI/server change could alter behavior — re-run the preflight on every upgrade (and optionally on a periodic timer), not once.
Carry-forward env from the existing isolation: keep `CLAUDE_CODE_DISABLE_CLAUDE_MDS=1` and unset `ANTHROPIC_*`. **Do NOT use `--bare`** — it strips managed MCP too but forces `ANTHROPIC_API_KEY`/`apiKeyHelper`-only auth, which breaks the OAuth/Max subscription spawn model (the whole point of the bridge). (A settings.json route — `suppressedClaudeAiConnectors` / `allowAllClaudeAiMcps` — exists in the binary but was deliberately **not** chosen: an argv-level flag cannot be overridden by a tenant-writable settings file; spike separately only if a settings approach is ever preferred.)
**Deployment B gate semantics (binding, two-stage — resolves the prior T2-only-vs-T2+T4 ambiguity):**
- **(i) Security gate = T2.** Until § 5.2 (1)+(2)+(3) are applied AND (4) is verified by body-capture, **B does not launch at all.** The disable mechanism itself (T6) is resolved; T2 is proving it on the wire.
- **(ii) Concurrency gate = T4.** Once T2 passes, **B launches SERIALIZED (concurrency = 1).** Concurrent multi-member service is a **separate** gate on T4 (§ 7.3) — per-session isolation under parallel load + one-OAuth-concurrent-session tolerance — and is NOT lifted until T4 passes.
- Net: **no B before T2; serialized B after T2; concurrent B only after T4.**
Deployment A (single user, all traffic is the owner) may proceed on the behavioral property because there is no cross-tenant boundary to breach — but the structural hardening should still ship, because an un-stripped surface means a prompt-injected client could reach the owner's own Gmail/Drive, which is undesirable even single-user.
### 5.3 A vs B isolation summary
| Concern | Model A | Model B |
|---|---|---|
| Cross-tenant history leakage | N/A (one human) | **Hard** — ephemeral $HOME per request; transcripts in `/tmp`, rm'd; never owner's real `~/.claude/projects/` |
| Tool/MCP surface | Should-strip (defense-in-depth) | **Must-strip structurally** (§ 5.2); B blocked until verified |
| Cache/audit namespacing | single key | per-key (REUSE `lib/keys.mjs`) |
| OAuth | one owner login | one owner login, pooled (members hold OLP keys, not OAuth) |
### 5.4 No `--dangerously-skip-permissions` needed
Because OLP reads the transcript (§ 4) instead of asking `claude` to *write a result file*, there is no tool invocation to permission, so `--dangerously-skip-permissions` is **not required** for the output path. (S3 used `--dangerously-skip-permissions` in its harness for spawn convenience, and S1/S2 used a pre-seeded `bypassPermissionsModeAccepted` flag — but the *architecture* does not need the dangerous flag because no file-writing tool runs.) If a future requirement forces tool execution, that flag and its full multi-tenant security implications must be re-examined in a new ADR — it is explicitly out of scope here.
### 5.5 Credential-leak coupling — B's safety DEPENDS on T2+T6 (binding)
State this plainly: in Deployment B the **owner's OAuth bearer is symlinked into every member's ephemeral `$HOME`** (`.credentials.json`, § 7.1). It is therefore **readable by every member spawn**, and is protected **ONLY** by the (unenforced) no-tool property. There is no second wall. This means **Deployment B's credential safety is not independent of the tool surface — it is coupled to it.** If a member's prompt can reach a tool that reads files (a built-in `Read`/`Bash`, or a slipped-through MCP), it can exfiltrate the owner's bearer.
Consequences (all binding for B):
- The structural tool-strip (§ 5.2: `--strict-mcp-config` + `--disallowedTools "mcp__*"` + built-in lockdown `--tools ""`/explicit `--allowedTools`) is **the credential wall**, not merely a privacy-of-data measure. T6 (disable mechanism) and T2 (body-capture proof) are therefore **credential-safety gates**, not just MCP-hygiene gates — link them: **B credential safety ⇐ T2 ∧ T6.**
- The ephemeral root MUST be `chmod 700` and **per-`keyId` isolated** (no shared parent that another member can traverse).
- The seed (`.claude.json` with `oauthAccount`/`userID`) MUST be written **mode 600**; the symlinked `.credentials.json` target's permissions are the owner's real file (never copied), and the symlink lives only inside the 700 root.
- **Orphan-tmux-session reaper (NEW, mandatory).** tmux sessions survive an OLP server restart and continue to hold the owner OAuth (via the still-mounted ephemeral home / live process). On server startup OLP MUST reap orphaned TUI tmux sessions (kill session + `rm -rf` its ephemeral root) before serving, so a crashed/restarted server does not leave owner-credential-bearing sessions live and unowned. This compounds with the § 8 trap-guaranteed teardown (steady-state cleanup) — the reaper is the restart-time backstop.
---
## 6. Submission technique (S3 PASS — 15/15 first-attempt; T3 PASS — multiline/special-char now verified)
**Decision: write the prompt body to a FILE, feed it in one shot with `tmux send-keys -- "$(cat file)"`, then send Enter as a tmux/PTY KEY TOKEN — never as a literal `\n`/`\r` in the text payload.** S3 proved 100% first-attempt submission for short prompts, and a negative control proved the Ink #15553 bug *does* reproduce here when a newline is sent as text (`send-keys -l "...\n"` silently fails to submit). **T3 (PASS)** extended this to realistic multiline + shell-special payloads (fenced code blocks, backticks, `$`, `${VAR}`, `$(…)`, `;`, `&&`, `|`, `&`, quotes, braces, literal mid-prompt newlines, up to ~50 lines / 1.2 KB): each produced **exactly ONE** user submit with the content arriving **byte-for-byte intact** in the transcript, zero premature submit on embedded newlines, zero corruption.
Production recipe (T3-validated, binding for PR-2 acceptance):
1. **Write the prompt body to a file. NEVER interpolate it into a shell command line** — that is where backticks/`$()`/`&&`/quotes get mangled by the shell (not by `claude`). T3 verified the file-then-`cat` path delivers all shell-special chars intact.
2. **Feed it in ONE shot** with `tmux send-keys -t <S> -- "$(cat promptfile)"`. The leading `--` end-of-options guard is **required** so a prompt starting with `-` is not parsed as a flag. Embedded `\n` bytes are delivered as **soft line-breaks** in the Ink input box and do NOT submit. Do **NOT** use `send-keys -l` for the body in this version — the default (non-literal) mode already passes newlines through correctly and `-l` is unnecessary.
3. **Settle ~1.52s** to let the Ink input box render (and, for large pastes, to let the paste-collapse UI render — a 50-line block collapses to ` [Pasted text #1 +46 lines]`; cosmetic only, buffer is complete).
4. **Submit with a SEPARATE Enter KEY TOKEN:** `tmux send-keys -t <S> Enter`. Enter must be a key token, never a literal `"\n"` appended to the text (Ink #15553).
5. **Verify** submission by reading the **transcript JSONL** (not `capture-pane`): exactly one `user`-role line whose `message.content` equals the source minus its single trailing newline. (A second `user`-role line carrying `toolUseResult:true` is `claude`'s tool output **within the same turn**, not a second submit.) For large prompts, transcript-read is **mandatory** — the paste-collapse placeholder defeats pane-scraping verification.
6. **Retry** Enter (key token) up to ~4× as a defensive guard. S3 never needed it (net-zero cost) but it protects against rare Ink races on upgrade.
`paste-buffer` / `load-buffer` (bracketed, streams from a file) is an acceptable alternative and is the **recommended fallback at very large sizes** (see caveat below); it offered no advantage for the tested ≤50-line cases and was not needed.
**Dialog automation (calibrated, S3 — a real footgun):** trust-folder dialog defaults to "1. Yes, I trust" → bare Enter confirms. The bypass-permissions dialog defaults cursor to **"1. No, exit"** — a naive Enter here **EXITS and kills the session**; must send **Down then Enter** to land on "2. Yes, I accept". Better: pre-seed the trust + bypass markers in `.claude.json` (§ 7) so neither dialog appears.
⚠️ T3 caveats to carry:
- **Only tested up to ~50 lines / 1.2 KB.** Coding-proxy traffic can carry much larger pastes (whole files, multi-KB diffs). A follow-up spike should confirm `send-keys` behavior at e.g. 500+ lines / tens of KB, where tmux `send-keys` argv length or input-box buffering limits could surface; `paste-buffer`/`load-buffer` (streams from a file) is the more robust path at very large sizes and is the recommended next validation.
- **Enter timing.** A fixed settle delay was used; under load or for very large pastes the input box may still be rendering when Enter fires. Production should either poll the pane for the input-box-ready / paste-collapse state before sending Enter, or scale the settle delay to payload size.
- **Trailing-newline stripping.** The input box trims the source's single trailing newline on submit. Harmless for prompts, but any cache-key hashing of prompt-in vs prompt-on-wire MUST normalize the trailing newline (see § 3.2) or it will see a mismatch.
- Results are pinned to `claude` v2.1.158 + tmux 3.3a on arm64; an Ink-version bump could change #15553 / paste-collapse behavior — re-run the T3 negative control on every `claude` upgrade.
---
## 7. Session lifecycle
### 7.1 Ephemeral default (cleanest privacy — Deployment B default)
Default = **per-request ephemeral session.** Each request gets its own ephemeral `$HOME` + `--session-id` UUID via `prepareIsolatedEnvironment` (REUSE Phase 7). The transcript lands in `/tmp/olp-spawn/<keyId>/<reqId>/home/.claude/projects/...` and is rm'd on cleanup. This is what guarantees Deployment-B per-member privacy: no two members ever share a `$HOME`, and nothing touches the owner's real `~/.claude`.
**NEW bootstrap requirement (S1+S2 gap vs current ISOLATION) — TUI-ONLY, must NOT touch the default path.** ⚠️ The seed + tightened-permissions bootstrap below runs **only when `CLAUDE_TUI_MODE` is active.** The default (stream-json) anthropic path keeps the current `ISOLATION` behavior **unchanged** — no `.claude.json` seed, no private account fields (`oauthAccount`/`userID`) written to disk, no behavior change before the feature flag. Gating the seed on the flag is mandatory: otherwise PR-0 would alter existing default-path behavior and expand the sensitive-data-on-disk surface ahead of any opt-in. (Implementation: the `ISOLATION` extend exposes the seed as an opt-in step the session driver invokes only on the TUI branch; `prepareIsolatedEnvironment` does not seed unconditionally.) The current anthropic `ISOLATION` block only symlinks `.credentials.json` and mkdir's `.claude/`. A *fresh* ephemeral `$HOME` triggers `claude`'s first-run onboarding (theme picker → login-method picker → OAuth browser-open, which **hangs**). Under TUI-mode, the bootstrap MUST additionally seed a minimal `.claude.json` carrying `hasCompletedOnboarding:true` + `oauthAccount` + `userID` (copied from the real `~/.claude.json`, `projects` stripped) + `bypassPermissionsModeAccepted:true`, and pre-trust the cwd in the seeded `projects` map to skip the trust dialog. With that seed, the session drops straight to the ready input box.
⚠️ **The seed does NOT disable managed MCP (T6 negative control).** An earlier draft assumed pinning the seeded `.claude.json` (e.g. removing `claudeAiMcpEverConnected`) would suppress managed-MCP attachment. **T6 disproved this** — the fetch is account/server-driven and ignores the local cache key. The seed's role is **onboarding/trust/bypass convenience only** and carries **no security weight for MCP**; the structural MCP disable is the `--strict-mcp-config` flag (§ 5.2), applied at spawn argv. PR-0 (§ 12) must reflect this: the ISOLATION extend seeds onboarding markers, but the MCP/marketplace disable is a spawn-flag/env concern owned by the session driver, not the seed.
⚠️ **Privacy + credential handling of the seed (see § 5.5).** `oauthAccount` + `userID` are private account fields. Treat the seed file with the same care as the bearer token: never log it, never commit it, write it **mode 600** only into the `/tmp` ephemeral root, and ensure cleanup rm's it. The ephemeral root MUST be **`chmod 700` and per-`keyId` isolated.** (The OAuth bearer itself stays only in the symlinked `.credentials.json`, never copied — but note § 5.5: that symlink is readable by every member spawn and is protected ONLY by the unenforced no-tool property, so B's credential safety is coupled to T2+T6.) An **orphan-tmux-session reaper** must run on server startup to kill restart-surviving sessions that still hold the owner OAuth (§ 5.5).
### 7.2 Warm-pool option (Deployment A only, opt-in `CLAUDE_TUI_WARM_POOL`)
Single-user A may keep N warm interactive sessions to amortize the ~34s cold submit→response latency. **A-only** because a warm pool reuses one `$HOME` across requests, which violates B's per-member privacy. Warm-pool entries must still be the *same single owner*. **Critical: the warm pool reuses the PROCESS, not conversation state.** A warm session reused across turns would accumulate conversation context in its transcript — which breaks OpenAI chat-completions **stateless** semantics (a later request would inherit an earlier one's hidden context, dirtying cache + reproducibility) even for a single user (§ 2.1). So each request MUST reset to a clean turn: a fresh `--session-id` per request (preferred — keeps transcript-path computation deterministic) or `/clear` between turns. Cross-request context accumulation is **forbidden for A and B alike** — the only thing A's warm pool saves is process/onboarding cold-start, never context. Pool concerns (crash recovery, idle eviction, max-age recycle) are why tmux is favored over node-pty (§ 8).
### 7.3 Concurrency — UNPROVEN, gates B
All three spikes ran **sequentially**. Concurrent multi-session isolation (N parallel requests) is **unproven**. The likely-correct answer is "one ephemeral `$HOME` per session, distinct `--session-id` + cwd" (which the ephemeral default already gives), but it must be spiked under real parallel load before Deployment B serves concurrent members, including whether one OAuth credential tolerates concurrent interactive sessions (§ 11, spike T4). Until then, B runs with a concurrency limit of 1 (serialize), or stays in canary.
---
## 8. tmux vs node-pty
**Recommendation: tmux as the primary transport; keep a node-pty adapter behind an interface as a fallback/option.**
| Dimension | tmux | node-pty |
|---|---|---|
| Crash recovery | **System-level** — session survives an OLP server restart; can re-attach + capture-pane to recover state | In-process — server crash kills the PTY and loses the turn |
| Weight | External binary dependency; one process per session | In-process, lighter; native addon (engines-bump + CI matrix per ADR 0009 § 6 discipline) |
| Spike coverage | **All of S1/S2/S3 used tmux** — the validated path | Unvalidated for OLP's flow |
| Submission control | `send-keys` key-token vs `-l` text is the exact, S3-calibrated #15553 control | Would need its own submission-reliability re-validation |
| Observability/debug | `capture-pane` gives a human-inspectable pane for ops | Buffer only |
Rationale: every passing spike used tmux, so tmux is the de-risked choice and the one this spec is written against. tmux's system-level crash recovery is especially valuable for the warm-pool (§ 7.2) and for ops debuggability (`tmux attach` to a stuck session). node-pty's in-process lightness is attractive for a pure-Node server, but it adds a native-addon dependency (CI matrix + engines bump) and **has zero spike coverage** — adopting it now would re-open submission and completion-detection risk that tmux has already closed. **Decision:** ship tmux first; define the session driver behind a transport interface (`lib/tui/session.mjs`) so a node-pty adapter can be added later without touching the transcript reader or IR mapping. ⚠️ tmux teardown must be **trap-guaranteed** — S3 noted the driver's best-effort `rm` left empty ephemeral `home_*` dirs with stray cred symlinks; production cleanup must be a `trap`/`finally`, not best-effort, or scratch homes (and cred symlinks) accumulate.
---
## 9. Reuse map
| Need | Reused asset | Status |
|---|---|---|
| Entry surface (`/v1/chat/completions`, key auth, owner gating) | `server.mjs`, `lib/keys.mjs` (`owner_tier`, `providers_enabled`, `__env_owner__`) | REUSE unchanged |
| IR ↔ anthropic shape; `role:system``--system-prompt` | `lib/ir`, `lib/providers/anthropic.mjs` `irToAnthropic` / `extractSystemPrompt` | REUSE |
| Tool-suppression + hallucination fix + cost reduction | `OLP_SYSTEM_PROMPT_WRAPPER` (Phase 6c) | REUSE |
| Per-request ephemeral `$HOME`, credential symlink, cleanup | `lib/sandbox/manager.mjs` `prepareIsolatedEnvironment` + anthropic `ISOLATION` (ADR 0002 Amd 9) | REUSE + **EXTEND**: seed `.claude.json` (onboarding/trust/bypass only), `chmod 700` root + mode-600 seed + per-`keyId` isolation (§ 5.5). **NOTE:** the MCP/marketplace disable is NOT in the seed (T6 negative control) — it is the spawn-argv `--strict-mcp-config` + `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1`, owned by the session driver |
| Per-key cache + audit isolation | `lib/cache`, `lib/audit` | REUSE |
| Optional OS-level sandbox (Layer 3) | sandbox-runtime `wrapForLayer3` (ADR 0014) | REUSE if active; orthogonal to TUI |
| Session driver (PTY/tmux, submit, dialogs) | — | **NEW** `lib/tui/session.mjs` |
| Transcript reader (path, completion, extract) | — | **NEW** `lib/tui/transcript.mjs` |
The EXTEND to `ISOLATION` (seed `.claude.json` for onboarding/trust/bypass; tighten root/seed permissions per § 5.5) is the only change to a Phase 7 *bootstrap* surface; it should land as its own reviewable PR (PR-0, Iron Rule 11) with ADR 0002 Amendment 9 cited, because it changes the per-spawn bootstrap contract. The managed-MCP/marketplace disable is **not** part of this EXTEND — T6 proved it is account/server-driven and cannot be controlled via the seeded home; it is enforced at spawn-argv time (`--strict-mcp-config`) by the session driver (PR-2) and gated by the per-spawn verification check.
---
## 10. Risks & opt-in framing
### 10.1 Precarious loophole (document honestly)
- **Anthropic can close it.** Parent-process verification, device fingerprinting, request-cadence/timing-pattern detection, or simply reclassifying "any third-party app" to the credit pool would kill the billing bridge. The bridge is estimated viable ~3060 days post-2026-06-15 — a spike judgment, **not** Anthropic-confirmed. Per ADR 0009 Amd 1, the implementation must keep working (minus the billing benefit) if the bridge dies, because the cost/hallucination/observability values are orthogonal.
- **Shared 5-hour cap.** One pooled owner OAuth → the subscription's rolling 5-hour cap is shared across all B members. A heavy member can exhaust the window for everyone. Rate-modeling must account for the auxiliary calls too: S1 saw **5× `/v1/messages` per single user turn** (main + prompt_suggestion forked agent + title/topic gen), all `cc_entrypoint=cli` — extra quota draw and extra cap pressure.
- **Requires `claude login` once on the host.** No member OAuth; the owner runs it once. If the OAuth expires/revokes, all of B is down until re-login.
### 10.2 ToS-intent grey area (frame honestly, not as forgery)
TUI-mode runs a *genuinely interactive* `cc_entrypoint=cli` session — it is **not forging** the entrypoint header. But it **automates** that interactive mode to serve programmatic requests, which is against the spirit of "interactive mode = a human at a terminal." OLP states this plainly rather than hiding it. Mitigation = **opt-in**: `CLAUDE_TUI_MODE` lets the operator consciously choose:
- **flag set** → TTY path (grey-area, billing-favorable — `cc_entrypoint=cli`, the genuine interactive-use signal S1 confirmed; **actual subscription-pool billing is a post-2026-06-15 inference, not S1-proven** — § 1.2, measured first by the OCP canary § 12.6);
- **flag unset (default)** → stream-json path (**safe ToS posture, uncertain billing**). Per ADR 0009 § 1.3 the default itself **may** bill to the Agent SDK credit pool because Anthropic may key on the `isTTY` signal rather than the `-p` flag — the piped-stdio default is non-TTY. Do **not** describe the default as a guaranteed credit-pool *or* subscription path; its billing classification is uncertain. Its value is the conservative ToS posture, not a billing guarantee.
No anti-fingerprinting is added (AGENTS.md: "No anti-fingerprinting"). If Anthropic detects and bans the spawn pattern, the documented response is to drop/disable TUI-mode (fall back to the default path or other providers), **not** to mask the spawn.
### 10.3 Reliability gates (be honest where spikes were thin)
- **Completion detection** — **T1 RESOLVED (partial)**: `turn_duration` is reliable for `end_turn` text turns and refusals but **ABSENT on tool-use turns** (would hang). The dual-signal guard (turn_duration **OR** co-equal quiescence/wall-clock/`stop_reason:tool_use` teardown) is now **mandatory and built into PR-1** (§ 4.4), not a deferred fold-in. A true `max_tokens` truncation remains formally unverified (no CLI flag to force it; § 4.5).
- **Submission** — **T3 RESOLVED (PASS)**: multiline + shell-special payloads submit byte-for-byte intact via file → `send-keys -- "$(cat file)"` → separate Enter (§ 6). Gates PR-2 acceptance. Open follow-up: very large pastes (500+ lines / tens of KB) — validate `paste-buffer`/`load-buffer` next (non-blocking for initial A rollout).
- **MCP/marketplace disable** — **T6 RESOLVED (PASS)**: `--strict-mcp-config` (no `--mcp-config`) gives 0 managed-MCP attachment; seed-editing does NOT (account/server-driven). § 5.2.
- **Concurrency** is entirely **unproven** — gates Deployment B (§ 7.3, spike T4).
- **Security (no-tool structural body proof)** — the disable *mechanism* is resolved (T6); the **body-level capture** that the wire `/v1/messages` carries `tools:[]` is the one remaining structural gate (§ 5.2 (4), spike T2) — and per § 5.5 it is a **credential-safety** gate for B, not just MCP hygiene.
---
## 11. Open questions & spike-gated items
No item below blocks the *architecture*; each gates a specific rollout step. **T1, T3, T6 are now spiked** (pre-code gate set, § 12); T2, T4, T5 remain open.
| ID | Status | Question | Gates | Method / Result |
|---|---|---|---|---|
| **T1** | ✅ **PARTIAL** | Is `turn_duration` emitted on `max_tokens`, tool-use, and refusal turns? | Completion-detect reliability (all rollout) — now built into PR-1 | **RESULT:** reliable for `end_turn` text (`durationMs=33135` near-cap) **and** refusal (`durationMs=3221`); **ABSENT on tool-use** (`stop_reason:tool_use`, no marker → hang). True `max_tokens` truncation unforceable (no CLI flag). → dual-signal guard (§ 4.4) is MANDATORY; re-run per CLI/Ink bump |
| **T2** | 🔴 **OPEN** | Can the outbound `/v1/messages` be **proven** (body capture) to carry `tools:[]`? | **Deployment B** (multi-tenant security + credential safety, § 5.5) | Disable mechanism RESOLVED by T6 (`--strict-mcp-config`); remaining: body-capture (MITM/body-log) the wire request; assert no tools array. `--debug api` is insufficient (no bodies) |
| **T3** | ✅ **PASS** | Long/multiline prompts and prompts with tmux-special chars — submit reliably without premature submit? | Real prompt traffic — gates PR-2 acceptance | **RESULT:** 3/3 realistic payloads (fenced code, heavy shell-special, ~50-line block) submitted byte-for-byte, exactly 1 user submit each, 0 premature submit. Recipe: file → `send-keys -- "$(cat file)"` → separate Enter (§ 6). Open follow-up: 500+ lines / tens of KB via `paste-buffer` (non-blocking) |
| **T4** | 🔴 **OPEN** | Under N parallel requests sharing one owner OAuth, does per-session ephemeral `$HOME`+`session-id` give clean isolation, and does one OAuth tolerate concurrent interactive sessions? | **Deployment B concurrency** | Run K concurrent sessions; check transcript isolation, billing entrypoint stays `cli`, no auth contention; until passed, B serializes (concurrency=1) |
| **T5** | 🔴 **OPEN** | inotify vs poll for completion at scale; Opus-class long-streaming latency; cold-start end-to-end latency (unmeasured) | Performance tuning (non-blocking) + sizing the § 4.4 wall-clock cap | `inotifywait` vs 0.5s poll under load; measure long-response `turn_duration` ordering; **measure cold-start end-to-end latency before B** |
| **T6** | ✅ **PASS** | Exact flag/settings combination that disables marketplace auto-fetch + managed-MCP attach | Feeds T2 + is the § 5.2 disable mechanism + § 5.5 credential wall | **RESULT:** `--strict-mcp-config` (no `--mcp-config`) → 0 `mcp-logs-claude-ai-*` dirs, `/mcp` empty (vs baseline 3 servers/28 tools). **NEGATIVE control:** seed-editing (`claudeAiMcpEverConnected`) does NOT disable (account/server-driven). Defense-in-depth: `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1` + `--disallowedTools "mcp__*"` + `--tools ""`/`--allowedTools`. NOT `--bare` (breaks OAuth). Verification gate: assert 0 mcp-logs dirs + `/mcp` empty per spawn |
---
## 12. Rollout
Sequenced to honor Iron Rule 11 (minimum reviewable unit per layer) and to validate billing/security before any multi-tenant exposure.
**Pre-code gate set (DONE — resolved before any PR lands).** Per the two design reviews, T1 must be resolved *with* the transcript reader, not folded in after; and T3/T6 likewise feed the driver/security layers they gate. These three are now **pre-code gates, completed before PR-0/PR-1/PR-2:**
- **T1 (✅ PARTIAL)** — completion detection on non-`end_turn` stop reasons. Result forces the **dual-signal guard** into PR-1's design (§ 4.4), not a later fold-in. Resolved before PR-1.
- **T3 (✅ PASS)** — multiline/special-char submission. Result defines and **gates PR-2 acceptance** (§ 6 recipe). Resolved before PR-2.
- **T6 (✅ PASS)** — marketplace + managed-MCP disable mechanism (`--strict-mcp-config`). Result defines PR-0/PR-2's spawn-flag set (§ 5.2) and is the § 5.5 credential wall. Resolved before PR-0/PR-2.
**PR sequence:**
1. **PR-0 — ISOLATION extend (TUI-ONLY — default path unchanged).** Seed `.claude.json` (onboarding/trust/bypass **only** — NOT an MCP control; § 7.1 + T6 negative control) in the anthropic `ISOLATION` block + `prepareIsolatedEnvironment`, **invoked only on the `CLAUDE_TUI_MODE` branch** so the default stream-json path's bootstrap + on-disk sensitive-data surface are unchanged (§ 7.1). Ephemeral root `chmod 700`, seed mode 600, per-`keyId` isolation (§ 5.5). Cite ADR 0002 Amendment 9. Independent reviewer (Iron Rule 10). Lands first because every TUI spawn depends on it. (The MCP/marketplace disable is spawn-argv/env — `--strict-mcp-config` + `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1` — owned by PR-2's session driver, per T6.)
2. **PR-1 — transcript reader** (`lib/tui/transcript.mjs`): path compute, lazy-create poll, **dual-signal completion (T1): `turn_duration` OR co-equal quiescence/wall-clock/`stop_reason:tool_use` terminal-teardown (§ 4.4)** — designed in from the start, not added later. Assistant-text extraction. `max_tokens`/other-param graceful-drop boundary (§ 4.54.6). Returns a **resolved response string** adapted to the `getOrCompute`/singleflight cache contract (§ 3.2). Unit-tested against captured fixtures incl. a tool-use-no-marker fixture.
3. **PR-2 — session driver** (`lib/tui/session.mjs`): tmux spawn with the T6 flag set (`--strict-mcp-config` + `--disallowedTools "mcp__*"` + built-in lockdown; env `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1`) + post-spawn MCP-disable verification gate (§ 5.2). **T3 submit recipe (file → `send-keys -- "$(cat file)"` → separate Enter) + transcript-read verify/retry — T3 PASS gates acceptance.** Dialog auto-answer, trap-guaranteed cleanup, **orphan-tmux-session reaper on startup (§ 5.5)**. tmux transport behind the interface; node-pty stubbed. Single-buffered response + SSE-replay for `stream:true` (§ 3.1).
4. **PR-3 — provider wiring**: `CLAUDE_TUI_MODE` branch in anthropic `.spawn()`; default stays stream-json (uncertain-billing / safe ToS posture, § 10.2). New ADR (0009 Amd 2 / 0016) as authority of record. README: new env var + Troubleshooting (onboarding-hang quirk, OAuth-login requirement, **no true token-streaming** § 3.1, **`max_tokens`/sampling params not honored** § 4.54.6) + honest grey-area framing.
5. **Measure cold-start end-to-end latency** (currently unmeasured — fold into T5) before enabling B; informs the § 4.4 wall-clock cap sizing.
6. **OCP canary first.** Enable `CLAUDE_TUI_MODE` on **OCP** (single-tenant, the maintainer's own subscription, Deployment A) post-2026-06-15. OCP is the natural canary: single user, no cross-tenant boundary, and it is where PR #101 originated. Watch billing entrypoint stays `cli`, cap behavior, completion reliability over real usage.
7. **Spike T2 + T4** (security body-capture + concurrency) — **hard gate** before B. T2 is a **credential-safety** gate per § 5.5.
8. **OLP Deployment B** (family/team) only after T2 + T4 pass: enable per-key, members on guest keys (full § 5.2 flag set + per-spawn MCP-disable verification gate), concurrency limit lifted only when T4 passes. Until then B runs serialized or stays in canary.
The version bump + tag fires at the Phase close per CLAUDE.md `release_kit.phase_rolling_mode` (explicit maintainer action), not per D-day push.
---
## 13. Author credit plan (binding — community-PR provenance)
TUI-mode adopts the core idea from **`dtzp555-max/ocp` PR #101 by jaekwon-park** (interactive-`claude` via tmux to keep traffic on the subscription pool). OCP rejected PR #101's *specific implementation* (hook-file polling + `--dangerously-skip-permissions`) on alignment + security grounds, but the *idea* is the seed of this spec. The author MUST be credited and notified:
- **Co-author trailer** on the implementing commits: `Co-Authored-By: jaekwon-park <…>` (use the email/handle from PR #101; do not invent one — pull it from the PR before committing).
- **ADR acknowledgment**: the authority-of-record ADR (0009 Amd 2 / 0016) names PR #101 + jaekwon-park in its "Builds on" / acknowledgment section, noting what was adopted (the interactive-TUI-for-subscription-billing idea) and what was redesigned (transcript-read instead of hook-file; no `--dangerously-skip-permissions`; structural tool-stripping for multi-tenant).
- **CONTRIBUTORS / notification**: add jaekwon-park to CONTRIBUTORS (or equivalent) and **notify them on PR #101** (a comment on the original PR) that the idea was adopted into OLP/OCP TUI-mode, with a link to the shipping PR. This is a courtesy + provenance obligation, not optional.
---
## 14. Authority citations
- **Billing classification** — Anthropic 2026-06-15 split; `~/.cc-rules/memory/learnings/anthropic_claude_code_billing_split_2026_06_15.md`; published docs (`code.claude.com/docs/en/headless`, `support.claude.com/en/articles/15036540`, `support.claude.com/en/articles/11145838`) per ADR 0009 Amd 1 § "Additional spike findings".
- **`--system-prompt` tool suppression + cost/hallucination value** — ADR 0009 Amendment 1; `lib/providers/anthropic.mjs` `OLP_SYSTEM_PROMPT_WRAPPER`; claude CLI v2.1.104+ `--help` § `--system-prompt`.
- **Ephemeral-home isolation contract** — ADR 0014 (sandbox-runtime integration) + ADR 0002 Amendment 9 (Provider ISOLATION contract); `lib/sandbox/manager.mjs` `prepareIsolatedEnvironment`; 2026-05-29 PI231 ephemeral-home spike.
- **Multi-key auth** — ADR 0007; `lib/keys.mjs`.
- **Interactive-mode lineage** — ADR 0009 (placeholder + Amendment 1); OCP ADR 0007; **OCP PR #101 (jaekwon-park)**.
- **Spike evidence** — S1 (billing + no-tool, PARTIAL), S2 (transcript output, PASS), S3 (submission reliability, PASS), **T1 (completion on non-`end_turn` stop reasons, PARTIAL — `turn_duration` reliable for text/refusal, ABSENT on tool-use)**, **T3 (multiline/special-char submission, PASS)**, **T6 (marketplace + managed-MCP disable, PASS — `--strict-mcp-config` load-bearing; seed-edit is NOT a mitigation)**, `claude` v2.1.158, model `claude-haiku-4-5-20251001`, tmux 3.3a, PI231 (ephemeral HOME with seeded creds; PROD :4567 confirmed untouched; scratch + cred symlink removed in finally). Spike JSON retained in session record.
- **CLI version pin** — validated on `claude` v2.1.158; ADR 0009 Amd 1 § "CLI version pin guidance" — emit a log warning if `claude --version` falls outside the validated range; re-run the S3/T3 submission negative control **and** the T1 `turn_duration` + T6 MCP-disable spikes on every `claude` upgrade (Ink-version + undocumented-internal-log + account/server-driven-MCP sensitivity).
+14 -1
View File
@@ -967,7 +967,20 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl, isolationCtx)
stdio: ['pipe', 'pipe', 'pipe'],
});
// OCP server.mjs:586-587: proc.stdin.write(prompt); proc.stdin.end();
// Guard stdin writes against EPIPE (child may close stdin before we finish
// writing — e.g. on auth error, bad model, or oversized prompt).
// Port of OCP server.mjs:780.
// Authority: claude CLI v2.1.104 observed behaviour — fast-failing spawn closes
// stdin before OLP finishes writing; Node stdin Writable surfaces EPIPE.
// ALIGNMENT.md Rule 2: no CLI operation changed; guard is a Node stream safety net.
// The typeof guard protects test-injected mock stdin objects that are plain
// objects (not EventEmitters) and do not implement .on().
if (typeof proc.stdin.on === 'function') {
proc.stdin.on('error', (e) => {
console.error(JSON.stringify({ ts: new Date().toISOString(), level: 'warn', event: 'stdin_write_error', error: e.message }));
});
}
// OCP server.mjs:783-784: proc.stdin.write(prompt); proc.stdin.end();
proc.stdin.write(prompt);
proc.stdin.end();
+13
View File
@@ -505,6 +505,19 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl, isolationCtx)
const proc = spawnImpl(finalBin, finalArgs, { env: finalEnv, stdio: ['pipe', 'pipe', 'pipe'] });
// Guard stdin writes against EPIPE (child may close stdin before we finish
// writing — e.g. on auth error, bad model, or oversized prompt).
// Port of OCP server.mjs:780.
// Authority: Codex CLI reference (https://developers.openai.com/codex/cli/reference)
// observed behaviour — fast-failing spawn closes stdin before OLP finishes writing.
// ALIGNMENT.md Rule 2: no CLI operation changed; guard is a Node stream safety net.
// The typeof guard protects test-injected mock stdin objects that are plain
// objects (not EventEmitters) and do not implement .on().
if (typeof proc.stdin.on === 'function') {
proc.stdin.on('error', (e) => {
console.error(JSON.stringify({ ts: new Date().toISOString(), level: 'warn', event: 'stdin_write_error', error: e.message }));
});
}
// Write prompt via stdin for multi-line prompts (D6 assumption A1)
if (useStdin) {
proc.stdin.write(prompt);
+14
View File
@@ -500,6 +500,20 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
const proc = spawnImpl(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
// Guard stdin writes against EPIPE (child may close stdin before we finish
// writing — vibe --prompt reads from CLI args, but stdin.end() can still
// surface EPIPE if the process exits before the fd is flushed).
// Port of OCP server.mjs:780.
// Authority: Mistral Vibe CLI https://docs.mistral.ai/mistral-vibe/terminal/quickstart
// observed behaviour — fast-failing spawn closes stdin; Node Writable surfaces EPIPE.
// ALIGNMENT.md Rule 2: no CLI operation changed; guard is a Node stream safety net.
// The typeof guard protects test-injected mock stdin objects that are plain
// objects (not EventEmitters) and do not implement .on().
if (typeof proc.stdin.on === 'function') {
proc.stdin.on('error', (e) => {
console.error(JSON.stringify({ ts: new Date().toISOString(), level: 'warn', event: 'stdin_write_error', error: e.message }));
});
}
// Close stdin immediately — vibe --prompt reads from args, not stdin.
proc.stdin.end();
+26 -1
View File
@@ -2383,6 +2383,23 @@ async function router(req, res) {
}
}
// ── Process-level crash handlers ─────────────────────────────────────────
// Prevent unhandled async rejections and synchronous exceptions from crashing
// the daemon. Registered once at module level so they are installed before
// the first request arrives. These are no-ops on the happy path.
// Port of OCP server.mjs:2134-2146.
// Authority: OpenAI Chat Completions entry-surface contract unchanged
// (https://platform.openai.com/docs/api-reference/chat); happy-path
// /v1/chat/completions is unaffected — these handlers only fire on
// low-level TCP/parse errors or unhandled promises outside the request cycle.
// Note: process.exit is intentionally NOT called — log only, daemon stays alive.
process.on('unhandledRejection', (reason) =>
logEvent('error', 'unhandled_rejection', { reason: String(reason) })
);
process.on('uncaughtException', (err) =>
logEvent('error', 'uncaught_exception', { error: err && err.message, stack: err && err.stack })
);
// ── Server factory + main guard ───────────────────────────────────────────
//
// Factory pattern: `createOlpServer()` returns an http.Server bound to the
@@ -2392,7 +2409,15 @@ async function router(req, res) {
// import-time side effects when tests pull in server.mjs.
export function createOlpServer() {
return createServer(router);
const srv = createServer(router);
// Destroy the socket on low-level HTTP parse errors (client abort mid-upload,
// malformed HTTP header, etc.) so broken connections don't crash the daemon.
// Port of OCP server.mjs:2144-2146.
// Authority: OpenAI Chat Completions entry-surface contract unchanged
// (https://platform.openai.com/docs/api-reference/chat); happy-path /v1/chat/completions
// is unaffected — clientError only fires on pre-request TCP-level failures.
srv.on('clientError', (err, socket) => { try { socket.destroy(); } catch {} });
return srv;
}
export { router, loadedProviders, VERSION };
+250
View File
@@ -18318,3 +18318,253 @@ describe('Suite 44 — sandbox Layer 3 E2E test (PI231 only) [SKIP: awaiting Tas
assert.ok(true, 'placeholder — real test lands in Task #9');
});
});
// ── Suite 45 — P1 daemon-crash hardening (fix/olp-p1-crash-hardening) ────
//
// Two groups of tests:
// 45a45c Task A: stdin EPIPE guard in provider plugins
// 45d45f Task B: process-level crash handlers in server.mjs
//
// Authority citations (Task A):
// claude CLI v2.1.104 observed behaviour — fast-failing spawn closes stdin
// before OLP finishes writing; Node stdin Writable surfaces EPIPE.
// Port of OCP server.mjs:780.
// ALIGNMENT.md Rule 2: no CLI operation changed; guard is a Node stream
// safety net only.
//
// Authority citations (Task B):
// OpenAI Chat Completions entry-surface contract unchanged
// (https://platform.openai.com/docs/api-reference/chat); happy-path
// /v1/chat/completions is unaffected.
// Port of OCP server.mjs:2134-2146.
describe('Suite 45 — P1 daemon-crash hardening: stdin EPIPE guard + process crash handlers', () => {
// ── 45a: anthropic.mjs stdin EPIPE guard ──────────────────────────────
// Injects a mock spawn where proc.stdin is an EventEmitter that emits an
// EPIPE error immediately after the spawn call. Asserts the guard catches
// the error (console.error called with 'stdin_write_error'), the generator
// exits cleanly, and no unhandled exception is thrown.
it('45a: anthropic stdin-error guard catches EPIPE and logs stdin_write_error without throwing', async () => {
const errors = [];
const origConsoleError = console.error;
console.error = (...args) => errors.push(args.join(' '));
// Build a mock spawn where stdin is an EventEmitter that fires EPIPE
// synchronously after the outer function returns (via setImmediate).
// The generator will call proc.stdin.on('error', handler) before write/end,
// so the handler is installed when the error event fires.
const stdinEE = new EventEmitter();
stdinEE.write = () => {};
stdinEE.end = () => {
// Simulate: child closes stdin immediately (auth error, bad model, etc.)
setImmediate(() => stdinEE.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })));
};
let spawnCalled = false;
const mockSpawn = (_bin, _args, _opts) => {
spawnCalled = true;
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.stdin = stdinEE;
proc.killed = false;
proc.kill = () => {};
// Close the process cleanly so the generator can finish
stdinEE.end = () => {
setImmediate(() => {
stdinEE.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }));
proc.stdout.emit('end');
proc.stderr.emit('end');
proc.emit('close', 1, null);
});
};
return proc;
};
__setSpawnImpl(mockSpawn);
try {
const ir = {
irVersion: 1,
model: 'claude-sonnet-4-6',
stream: false,
messages: [{ role: 'user', content: 'hi' }],
};
// Consume the generator; it will hit EPIPE + throw ProviderError on exit 1.
// The EPIPE error must NOT propagate as unhandled — the guard absorbs it.
const gen = anthropic.spawn(ir, { accessToken: 'tok' }, null);
try {
// eslint-disable-next-line no-unused-vars
for await (const _chunk of gen) { /* drain */ }
} catch {
// Provider may throw ProviderError on exit code 1 — that is expected and OK.
// What must NOT happen is an unhandled EPIPE propagation.
}
} finally {
__resetSpawnImpl();
console.error = origConsoleError;
}
assert.ok(spawnCalled, 'mock spawn must have been called');
// At least one console.error call must contain 'stdin_write_error'
const stdinErrorLogs = errors.filter(e => e.includes('stdin_write_error'));
assert.ok(
stdinErrorLogs.length >= 1,
`Expected at least one log containing "stdin_write_error"; got: ${JSON.stringify(errors)}`,
);
// Verify the log is structured JSON with the expected fields
const parsed = JSON.parse(stdinErrorLogs[0]);
assert.equal(parsed.level, 'warn', 'log level must be warn');
assert.equal(parsed.event, 'stdin_write_error', 'log event must be stdin_write_error');
assert.ok(typeof parsed.error === 'string', 'log must include error string');
});
// ── 45b: codex.mjs stdin EPIPE guard ──────────────────────────────────
it('45b: codex stdin-error guard catches EPIPE and logs stdin_write_error without throwing', async () => {
const errors = [];
const origConsoleError = console.error;
console.error = (...args) => errors.push(args.join(' '));
const stdinEE = new EventEmitter();
stdinEE.write = () => {};
const mockSpawn = (_bin, _args, _opts) => {
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.stdin = stdinEE;
proc.killed = false;
proc.kill = () => {};
stdinEE.end = () => {
setImmediate(() => {
stdinEE.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }));
proc.stdout.emit('end');
proc.stderr.emit('end');
proc.emit('close', 1, null);
});
};
return proc;
};
codexSetSpawnImpl(mockSpawn);
try {
const ir = {
irVersion: 1,
model: 'codex-mini-latest',
stream: false,
// Multi-line prompt triggers useStdin=true path in codex.mjs
messages: [{ role: 'user', content: 'line1\nline2' }],
};
const gen = codex.spawn(ir, { accessToken: 'test-key' }, null);
try {
for await (const _chunk of gen) { /* drain */ }
} catch {
// ProviderError on exit 1 — expected; EPIPE must be absorbed by guard.
}
} finally {
codexResetSpawnImpl();
console.error = origConsoleError;
}
const stdinErrorLogs = errors.filter(e => e.includes('stdin_write_error'));
assert.ok(
stdinErrorLogs.length >= 1,
`Expected at least one log containing "stdin_write_error"; got: ${JSON.stringify(errors)}`,
);
const parsed = JSON.parse(stdinErrorLogs[0]);
assert.equal(parsed.level, 'warn');
assert.equal(parsed.event, 'stdin_write_error');
assert.ok(typeof parsed.error === 'string');
});
// ── 45c: mistral.mjs stdin EPIPE guard ────────────────────────────────
it('45c: mistral stdin-error guard catches EPIPE and logs stdin_write_error without throwing', async () => {
const errors = [];
const origConsoleError = console.error;
console.error = (...args) => errors.push(args.join(' '));
const stdinEE = new EventEmitter();
stdinEE.write = () => {};
const mockSpawn = (_bin, _args, _opts) => {
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.stdin = stdinEE;
proc.killed = false;
proc.kill = () => {};
// mistral only calls proc.stdin.end() — trigger EPIPE on end
stdinEE.end = () => {
setImmediate(() => {
stdinEE.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }));
proc.stdout.emit('end');
proc.stderr.emit('end');
proc.emit('close', 1, null);
});
};
return proc;
};
mistralSetSpawnImpl(mockSpawn);
try {
const ir = {
irVersion: 1,
model: 'mistral-medium-latest',
stream: false,
messages: [{ role: 'user', content: 'hi' }],
};
const gen = mistral.spawn(ir, { apiKey: 'test-key' }, null);
try {
for await (const _chunk of gen) { /* drain */ }
} catch {
// ProviderError on exit 1 — expected; EPIPE must be absorbed by guard.
}
} finally {
mistralResetSpawnImpl();
console.error = origConsoleError;
}
const stdinErrorLogs = errors.filter(e => e.includes('stdin_write_error'));
assert.ok(
stdinErrorLogs.length >= 1,
`Expected at least one log containing "stdin_write_error"; got: ${JSON.stringify(errors)}`,
);
const parsed = JSON.parse(stdinErrorLogs[0]);
assert.equal(parsed.level, 'warn');
assert.equal(parsed.event, 'stdin_write_error');
assert.ok(typeof parsed.error === 'string');
});
// ── 45d: process-level unhandledRejection handler is registered ────────
it('45d: process.on(unhandledRejection) handler is registered (at least 1 listener)', () => {
// The handler is registered at server.mjs module load time (import-time side
// effect). Since server.mjs is already imported at the top of this test file
// (via createOlpServer), the listener must already be present.
const count = process.listenerCount('unhandledRejection');
assert.ok(
count >= 1,
`Expected at least 1 unhandledRejection listener; got ${count}`,
);
});
// ── 45e: process-level uncaughtException handler is registered ─────────
it('45e: process.on(uncaughtException) handler is registered (at least 1 listener)', () => {
const count = process.listenerCount('uncaughtException');
assert.ok(
count >= 1,
`Expected at least 1 uncaughtException listener; got ${count}`,
);
});
// ── 45f: server created by createOlpServer() has a clientError listener ──
it('45f: server returned by createOlpServer() has a clientError listener', async () => {
const { createOlpServer: createSrv45 } = await import('./server.mjs');
const srv = createSrv45();
const count = srv.listenerCount('clientError');
srv.close();
assert.ok(
count >= 1,
`Expected at least 1 clientError listener on server; got ${count}`,
);
});
});