mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-22 13:35:10 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
066cc21eba | ||
|
|
1fd27e1c11 | ||
|
|
0e334f9cac | ||
|
|
349d35557b | ||
|
|
67bf7fa692 |
@@ -677,4 +677,8 @@ The full test list is captured in ADR 0014 Amendment 1's PR-B-revised test suite
|
||||
- **`ALIGNMENT.md` Rule 1 (Cite First)** — every per-field design choice is cited above. Every per-provider concrete instance is cited to the underlying CLI authority.
|
||||
- **`ALIGNMENT.md` Rule 2 (No Invention)** — no invented env vars, no invented CLI flags. The mistral `crossTenantReadProtection: 'none'` declaration is the explicit honest acknowledgment that no protection regime has been established, rather than invention of one.
|
||||
- **`ALIGNMENT.md` Rule 4 (Unalignable Plugins / Fields Are Deleted)** — see § Rule 4 compliance above for the explicit reasoning that OPTIONAL `ISOLATION` is not "feature-flagging" but rather "honestly transitional."
|
||||
|
||||
#### tuiSeed extension note (PR-0 co-merge, 2026-05-30)
|
||||
|
||||
`lib/sandbox/manager.mjs` adds a `tui` opt-in param to `prepareIsolatedEnvironment`. When `tui:true`, the orchestrator (a) chmod 700s the per-reqId dir and ephemeralRoot (spec §5.5 credential-wall), and (b) calls the module-private helper `_seedTuiClaudeJson(ephemeralRoot, seedSource)`. The helper reads `~/.claude.json` (or the override `tuiSeedSource`) once, copies `oauthAccount` and `userID` via object spread, strips `projects` entirely to avoid leaking the operator's real project history (spec §7.1), stamps `hasCompletedOnboarding:true` and `bypassPermissionsModeAccepted:true`, and writes the result to `join(ephemeralRoot, '.claude.json')` at mode 0o600. The seed carries NO MCP-disable weight — it does not set `claudeAiMcpEverConnected` or `mcpServers`; that is the spawn-argv flag `--strict-mcp-config` landed in PR-2 (spec §5.2 T6 negative control). The `projects` field is stripped unconditionally and is NOT re-populated with a pre-trusted `cwd` entry. **Correction to the original plan framing:** `bypassPermissionsModeAccepted:true` suppresses the bypass-permissions acceptance dialog only — it does **NOT** suppress the per-directory **trust-folder** dialog ("Is this a project you trust?"), which still appears for a fresh ephemeral `$HOME`. The pre-code spikes confirmed this (their playbook answers the trust dialog by sending "1"). PR-0 does not pre-trust because the spawn `cwd` is a PR-2 session-driver concern (not known at seed time) and the exact trust-field key is unverified; therefore **PR-2's session driver MUST answer the trust-folder dialog** (the spike-validated approach). Pre-trusting `projects[cwd]` in the seed remains an optional future optimization. Authority: claude CLI v2.1.158 first-run onboarding behavior (spec §7.1); the default (stream-json) code path is byte-for-byte unchanged.
|
||||
- **`ALIGNMENT.md` Amendment Procedure** — this section (Amendment 9) is the PR-required citation of evidence (the 2026-05-27 incident memory, the ADR 0014 PoC spike report at `/tmp/sandbox-spike/report.md` on PI231) and the structural amendment of the Provider contract documented in this ADR's § Decision.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# TUI-mode implementation — session handoff (resume at PR-1)
|
||||
|
||||
**Written:** 2026-05-30. **Goal:** a tested, working A-path TUI-mode before 2026-06-15 (the Anthropic billing split), so that even if 6/15 blocks the proxy, there is working code + data to adjust from.
|
||||
|
||||
---
|
||||
|
||||
## 0. What TUI-mode is (one paragraph)
|
||||
|
||||
OLP/OCP are OpenAI-compatible HTTP proxies that spawn the `claude` CLI. Anthropic's 2026-06-15 billing split routes traffic by the `cc_entrypoint` field the CLI stamps on every request: **`cli` (genuine interactive terminal/IDE) → subscription pool; `sdk-cli` (`-p`, `--output-format`, any non-TTY invocation) → the separate Agent SDK credit pool** (Pro plans get only ~$20 of credit there → useless). TUI-mode drives `claude` in a **real interactive tmux session** (no `-p`, no `--output-format`) so it stamps `cc_entrypoint=cli`, and **reads the response from claude's native JSONL session transcript** (not a hook file). This keeps the proxy usable for Pro subscribers post-6/15. It is OPT-IN (`CLAUDE_TUI_MODE`), default stays stream-json. It is an honest grey-area bridge (genuinely interactive, but automated) — see spec §10.
|
||||
|
||||
---
|
||||
|
||||
## 1. READ THESE FIRST (in order)
|
||||
|
||||
All on branch **`feat/tui-mode-pr0-isolation-seed`** (which is stacked on `docs/tui-mode-design-spec`, so it contains the spec + plan + PR-0):
|
||||
|
||||
1. `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md` — the design spec (final; 3 independent reviews + 6 spikes folded in). **Authoritative.** Do not re-litigate its decisions.
|
||||
2. `docs/superpowers/plans/2026-05-30-tui-mode-A-path-implementation-plan.md` — the implementation plan (PR-0..PR-3) + the **"Maintainer decisions"** appendix at the bottom (resolves P1–P5 + open questions). **Read the appendix carefully.**
|
||||
3. The committed PR-0 code (this branch's HEAD `1fd27e1`): `lib/sandbox/manager.mjs` (the `tui` param + `_seedTuiClaudeJson`), `test-features.mjs` Suite 45, `docs/adr/0002-plugin-architecture.md` § tuiSeed extension note.
|
||||
|
||||
---
|
||||
|
||||
## 2. WHERE WE ARE
|
||||
|
||||
```
|
||||
✅ PR-0 TUI-only ISOLATION .claude.json seed (committed 1fd27e1, pushed, 816 tests pass)
|
||||
⬜ PR-1 transcript reader lib/tui/transcript.mjs ← START HERE
|
||||
⬜ PR-2 session driver lib/tui/session.mjs (needs PI231 integration testing)
|
||||
⬜ PR-3 provider wiring CLAUDE_TUI_MODE branch + ADR 0016 + README
|
||||
then: OCP single-tenant canary (Deployment A) post-2026-06-15 to measure REAL billing
|
||||
```
|
||||
|
||||
Deployment **B** (family/team multi-tenant) is DEFERRED behind spikes **T2** (body-capture proof the wire `/v1/messages` carries `tools:[]`) + **T4** (concurrency) — these are verification, not open research (T6 already found the disable mechanism). Do NOT build B in this pass.
|
||||
|
||||
---
|
||||
|
||||
## 3. VALIDATED FACTS (do NOT re-spike — already proven on PI231, claude v2.1.158)
|
||||
|
||||
- **S1 (PARTIAL):** `--system-prompt` keeps `cc_entrypoint=cli`. BUT account-attached managed MCP servers (the owner's Gmail/Calendar/Drive) auto-attach over the network even with empty local config — the "no-tool" property is model restraint, not enforcement. (Gates B; mitigated by T6.)
|
||||
- **S2 (PASS):** read the native JSONL transcript at `<EHOME>/.claude/projects/<CWD_ENCODED>/<SESSION_ID>.jsonl` where `CWD_ENCODED` = the spawn cwd with **every `/` → `-` (including the leading slash)**, and `SESSION_ID` = the UUID you pass via `--session-id` (so YOU compute the path before spawn). The file is created lazily on first message — poll for it. Assistant text extracts escaping-clean (one `JSON.parse` per line). **Completion marker = a `{"type":"system","subtype":"turn_duration"}` line** (last line of the turn by timestamp; carries messageCount + durationMs).
|
||||
- **S3 (PASS):** submission is 100% reliable IF Enter is a tmux **key token**, not a literal `\n` in text (Ink bug anthropics/claude-code#15553).
|
||||
- **T1 (PARTIAL):** `turn_duration` fires for `end_turn` text turns AND refusals, but is **ABSENT on tool-use turns** (which would hang a marker-only reader).
|
||||
- **T3 (PASS):** multiline/special-char prompts submit byte-for-byte via the file→send-keys recipe (§ below).
|
||||
- **T6 (PASS):** `--strict-mcp-config` (with NO `--mcp-config`) gives **0** managed-MCP attachment. **Editing/seeding `.claude.json` does NOT disable MCP** (account/server-driven — negative control). Defense-in-depth: env `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1` + `--disallowedTools "mcp__*"` + (for B) `--tools ""`. Do NOT use `--bare` (breaks OAuth).
|
||||
|
||||
**Billing caveat (honesty):** S1 proved the `cc_entrypoint=cli` SIGNAL, NOT the actual billed pool — the split hasn't happened. The OCP canary post-6/15 is the first real billing measurement.
|
||||
|
||||
---
|
||||
|
||||
## 4. CRITICAL GOTCHAS (things that already bit us — a fresh session WILL miss these)
|
||||
|
||||
1. **TWO different startup dialogs — do not conflate them:**
|
||||
- **bypass-permissions** dialog ("you accept all responsibility… 1.No 2.Yes") → suppressed by `bypassPermissionsModeAccepted:true` in the seeded `.claude.json` (PR-0 does this). Its default cursor is "1.No,exit" — a naive Enter EXITS.
|
||||
- **trust-folder** dialog ("Is this a project you trust? 1.Yes 2.No") → **NOT suppressed by the seed.** Still appears for a fresh ephemeral `$HOME` (projects stripped). **PR-2's session driver MUST answer it by sending "1".** (A review-fix agent wrongly claimed bypassPermissionsModeAccepted suppresses the trust dialog — it does NOT. This was caught + corrected; do not reintroduce that claim.)
|
||||
2. **Submission recipe (exact):** write the prompt body to a FILE (never interpolate into a shell command line — `$()`/backticks/`;` get mangled by the shell). Then: `tmux send-keys -t <S> -- "$(cat promptfile)"` (the leading `--` end-of-options guard is required), sleep ~1.5–2s to let Ink settle, then submit with a SEPARATE key token: `tmux send-keys -t <S> Enter`. Embedded `\n` in the body are delivered as soft line-breaks and do NOT submit. Verify submission via capture-pane (spinner / "esc to interrupt" / response bullet `●`); retry Enter (key token) up to ~4× as a defensive guard.
|
||||
3. **Completion = DUAL SIGNAL (no quiescence in v1):** terminal when EITHER a `turn_duration` line appears (happy path) OR `stop_reason:"tool_use"` is seen OR a wall-clock cap fires → return a clean error, never hang. **Do NOT use "file size-stable for N seconds" as a v1 terminal signal** — a long Opus extended-thinking turn legitimately stalls transcript growth and quiescence would falsely abort it. Quiescence is a T5 follow-up only. Read all assistant `text` blocks since the matching `user` line; do NOT rely on file-tail byte ordering (write-order can differ from timestamp-order). Wall-clock cap = env `CLAUDE_TUI_WALLCLOCK_MS` (default 120000), config not constant.
|
||||
4. **No `--dangerously-skip-permissions`** — we read the transcript, no tool writes, so it's not needed (this is the whole point vs PR #101's hook approach). No `--bare` (breaks OAuth).
|
||||
5. **Streaming = BUFFERED.** The transcript is read AFTER the turn completes, so there is NO true token-streaming. For `stream:true`, replay the completed response as SSE chunks (one burst after the turn). The maintainer accepted this; document it in README. Do NOT try to parse the live terminal pane for token-streaming — that's the fragile path the maintainer explicitly rejected ("不做易坏的功能").
|
||||
6. **max_tokens + sampling params (temperature/top_p/stop/etc.)** — interactive claude has NO flags for these; they're parsed into IR (`lib/ir/openai-to-ir.mjs:182`) and dropped at the CLI boundary today already. Graceful-drop + document; no regression.
|
||||
|
||||
---
|
||||
|
||||
## 5. PI231 (the test machine) — playbook
|
||||
|
||||
- SSH: `tlab@172.16.2.231` (RPi4 arm64 Debian). `claude` v2.1.158 at `~/.npm-global/bin/claude` (`export PATH="$HOME/.npm-global/bin:$PATH"`). tmux 3.3a. uuidgen / python3 available.
|
||||
- **⚠️ PI231 runs PROD OLP on :4567 — DO NOT TOUCH IT.** All TUI testing is `/tmp` scratch + tmux sessions with unique names. Clean up: kill tmux sessions, `rm -rf` scratch dirs, **`shred -u` any `--debug-file`** (debug logs contain the OAuth bearer — never `cat` a full debug log; only grep specific tokens).
|
||||
- Ephemeral session recipe (what PR-0's seed enables): make an ephemeral `$HOME`, symlink `~/.claude/.credentials.json` into it, seed `.claude.json` (PR-0's `_seedTuiClaudeJson` does this: strip projects, set hasCompletedOnboarding + bypassPermissionsModeAccepted), run `claude --model <m> --session-id <uuid> [--system-prompt …] [--strict-mcp-config …]` in a tmux session with cwd = ephemeral home. Answer the **trust** dialog with "1". cc_entrypoint capture: add `--debug api --debug-file <path>`, grep `cc_entrypoint=`, shred.
|
||||
- The maintainer (you, with the human) handles PI231 integration testing directly — subagents are unreliable at the delicate tmux/interactive timing (it bit us; the human's accumulated context catches subagent errors).
|
||||
|
||||
---
|
||||
|
||||
## 6. THE REMAINING PRs (concrete)
|
||||
|
||||
Use the **plan** (`docs/superpowers/plans/...`) PR sections + Maintainer-decisions appendix as the source of truth. Summary:
|
||||
|
||||
### PR-1 — `lib/tui/transcript.mjs` (transcript reader) — START HERE
|
||||
- Pure module, no existing-path touch. Exports a reader that, given `{ ephemeralRoot, sessionId, cwd }`, computes the transcript path (§ CWD_ENCODED formula above), polls for lazy file creation, detects completion via the **dual-signal guard** (turn_duration OR tool_use OR wall-clock cap from `CLAUDE_TUI_WALLCLOCK_MS`), extracts assistant `text` blocks since the matching `user` line, and returns a **resolved string** (the adapter to IR `[{delta},{stop}]` chunks lives in PR-3's provider branch, not here — keep PR-1 transport-agnostic).
|
||||
- **Needs real JSONL fixtures captured from PI231**, especially a **`tool-use-no-marker.jsonl`** (a turn with `stop_reason:"tool_use"` and NO `turn_duration`) + an `out-of-order.jsonl` (text block flushed after turn_duration by byte). The maintainer captures these on PI231. Unit-test the reader against the fixtures; assert the non-hang throw is distinguishable from the timeout.
|
||||
|
||||
### PR-2 — `lib/tui/session.mjs` (session driver) — needs PI231 integration testing
|
||||
- tmux transport behind a transport interface (so node-pty can slot later — but ship tmux only; node-pty stubbed). `runTuiTurn(isolationCtx, irRequest, opts)` — takes `isolationCtx` (has `ephemeralRoot` + `reqId` from PR-0), NOT raw keyId/reqId.
|
||||
- Spawn flags: `--session-id <uuid>` + `--system-prompt <OLP wrapper>` + (T6) `--strict-mcp-config` + `--disallowedTools "mcp__*"` + env `CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1`. (A also uses --strict-mcp-config per decision P3.)
|
||||
- **Answer the trust-folder dialog (send "1")** — the seed already handles bypass. (See gotcha #1.)
|
||||
- Submit via the file→`send-keys -- "$(cat file)"`→separate-Enter recipe (gotcha #2).
|
||||
- Call the PR-1 reader for the response; map to single-buffered + SSE-replay for stream:true.
|
||||
- **trap-guaranteed cleanup** (`finally`, not best-effort) + an **orphan-tmux-session reaper on server startup** wired into the `isMain` boot block at `server.mjs:2417` (decision P2) — tmux sessions survive restart and hold the owner OAuth.
|
||||
- Preflight `/mcp`-empty verification: a SEPARATE preflight session at startup (not in a serving turn — it would corrupt the reader's matching-user-line). For A: advisory (log warn). For B: hard gate (decision P3).
|
||||
|
||||
### PR-3 — provider wiring
|
||||
- `CLAUDE_TUI_MODE` branch in `lib/providers/anthropic.mjs` `spawn()` (~line 1164 → `_spawnAndStream`); default falls through to the untouched stream-json path. The string→IR-chunks adapter (`[{type:'delta',role:'assistant',content},{type:'stop',finish_reason:'stop'}]`) lives HERE — it fits BOTH cache consumers: `getOrCompute` (array, `server.mjs:1445`, buffered path `collectAllChunks:1299`) and `getOrComputeStreaming` (`:1587`, `sourceWithRelease:1558`) with ZERO server edits.
|
||||
- New **ADR 0016** (OLP) as authority of record (interactive-mode billing lane; supersedes ADR 0009 Amd 1's billing premise). Acknowledge **PR #101 + jaekwon-park**.
|
||||
- README: new env var + Troubleshooting (onboarding/OAuth-login requirement, **NO token-streaming** = buffered, max_tokens/sampling not honored) + honest grey-area framing.
|
||||
|
||||
---
|
||||
|
||||
## 7. WORKING METHOD + CONVENTIONS
|
||||
|
||||
- **Hybrid:** run a small workflow per PR — `implement (sonnet) → review (opus, fresh-context) → fix (sonnet) → npm test must pass`. THEN the maintainer commits + supervises PI231 integration. The maintainer's PI231 context catches subagent errors (it already caught a fabricated trust-dialog claim).
|
||||
- **Branch stacking:** PR-1 branches off `feat/tui-mode-pr0-isolation-seed` (which has spec+plan+PR-0). Stack PR-2 off PR-1, etc. OR merge PR-0 to main first then branch — maintainer's call. No OLP TUI PRs are opened on GitHub yet (branches pushed only).
|
||||
- **Co-author on every implementing commit:** `Co-Authored-By: jaekwon-park <insainty21@gmail.com>` (work traces to OCP PR #101) + `Co-Authored-By: Claude <model> <noreply@anthropic.com>`.
|
||||
- **ALIGNMENT discipline:** provider-plugin changes cite claude CLI version (v2.1.158) + spec/ADR. Independent reviewer per Iron Rule 10. Minimum reviewable unit per Iron Rule 11.
|
||||
- **Default-path-unchanged is sacred:** `CLAUDE_TUI_MODE` default off; stream-json path byte-for-byte unchanged until explicitly enabled. Reviewer must verify this for each PR.
|
||||
- **Don't break `npm test`** (currently 816 pass). The `__OLP_FORCE_ISOLATION_IN_TEST` global is the seam to exercise real ISOLATION in tests (see Suite 43f / 45).
|
||||
|
||||
---
|
||||
|
||||
## 8. KEY CODE ANCHORS (verified)
|
||||
|
||||
- `lib/sandbox/manager.mjs`: `prepareIsolatedEnvironment` (now has `tui`/`tuiSeedSource` params + `reqId` in return + `_seedTuiClaudeJson` helper).
|
||||
- `lib/providers/anthropic.mjs`: `spawn`:1164 → `_spawnAndStream`; `ISOLATION`:1667 (has tui doc comment); `buildCliArgs`:834 (passes only --model + --system-prompt today); `extractSystemPrompt`:123; `irToAnthropic`:601; `OLP_SYSTEM_PROMPT_WRAPPER`.
|
||||
- `server.mjs`: spawn sites `:1347` (buffered `collectAllChunks:1299` → `getOrCompute:1445`) + `:1564` (streaming `sourceWithRelease:1558` → `getOrComputeStreaming:1587`); `irChunkToOpenAISSE`; `isMain` boot block `:2417` (reaper hook target).
|
||||
- `lib/ir/openai-to-ir.mjs:182` (max_tokens parsed, dropped at CLI).
|
||||
- `lib/keys.mjs`: `validateKey:414`; `owner_tier ∈ {owner, guest}` (`:143`), 'anonymous' is a runtime fallback identity (`:439`).
|
||||
|
||||
---
|
||||
|
||||
## 9. SUGGESTED FIRST MOVE in the new session
|
||||
|
||||
1. `git checkout feat/tui-mode-pr0-isolation-seed && git pull` (or confirm you're on it).
|
||||
2. Read this handoff + the spec + the plan's Maintainer-decisions appendix.
|
||||
3. Capture the PR-1 JSONL fixtures on PI231 (esp. `tool-use-no-marker.jsonl`) — drive a tool-inviting prompt under `--system-prompt` and a normal prompt; save the transcripts as test fixtures.
|
||||
4. Branch `feat/tui-mode-pr1-transcript-reader` off PR-0; run the PR-1 implement→review→fix workflow against the fixtures.
|
||||
|
||||
**Deadline:** working A-path + PI231 integration test before 2026-06-15. ~16 days.
|
||||
@@ -0,0 +1,435 @@
|
||||
# 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.5–2s 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 P1–P5 + 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.
|
||||
@@ -0,0 +1,399 @@
|
||||
# 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 ~20–50 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.4–3.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.5–2s** 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 ~3–4s 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 ~30–60 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.5–4.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.5–4.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).
|
||||
@@ -1726,4 +1726,15 @@ export const ISOLATION = {
|
||||
// 1). No additional CLI flags are needed at the orchestrator level.
|
||||
// Per ADR 0002 Amendment 9 § 7: absence means the orchestrator passes args
|
||||
// through unchanged from spawn().
|
||||
|
||||
// TUI-mode reuses this ISOLATION shape unchanged for Layers 1+2.
|
||||
// The .claude.json seed extension (onboarding/trust/bypass markers) is an
|
||||
// orchestrator-side opt-in: the session driver (PR-2/PR-3) calls
|
||||
// prepareIsolatedEnvironment({ ..., tui:true }) to seed the ephemeral home.
|
||||
// The seed logic lives in lib/sandbox/manager.mjs _seedTuiClaudeJson(),
|
||||
// gated on the tui param so the default stream-json path is unchanged.
|
||||
//
|
||||
// NOTE (T6 negative control): the seed does NOT disable managed MCP — that
|
||||
// is the spawn-argv flag --strict-mcp-config (PR-2). See spec §5.2 + §7.1.
|
||||
// Authority: claude CLI v2.1.158 + ADR 0002 Amendment 9 (tuiSeed extension).
|
||||
};
|
||||
|
||||
+104
-2
@@ -47,7 +47,7 @@
|
||||
* __resetSandboxManagerForTests() — test seam: reset module state
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, symlinkSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
@@ -192,15 +192,18 @@ export function isSandboxActive() {
|
||||
* @param {object} params.provider — provider plugin object (may have .ISOLATION)
|
||||
* @param {string} params.keyId — OLP key identity driving this request
|
||||
* @param {string} params.reqId — per-request UUID
|
||||
* @param {boolean} [params.tui=false] — TUI-mode opt-in: seed .claude.json + chmod 700 (spec §7.1)
|
||||
* @param {string} [params.tuiSeedSource] — override path for the seed source (default: ~/.claude.json)
|
||||
* @returns {Promise<{
|
||||
* ephemeralRoot: string|null,
|
||||
* reqId: string,
|
||||
* envOverrides: Record<string, string>,
|
||||
* hardenedArgs: (args: string[]) => string[],
|
||||
* wrapForLayer3: (command: string) => Promise<string>,
|
||||
* cleanup: () => Promise<void>,
|
||||
* }>}
|
||||
*/
|
||||
export async function prepareIsolatedEnvironment({ provider, keyId, reqId }) {
|
||||
export async function prepareIsolatedEnvironment({ provider, keyId, reqId, tui = false, tuiSeedSource }) {
|
||||
const isolation = provider?.ISOLATION;
|
||||
|
||||
// ── Test-context bypass ──────────────────────────────────────────────────
|
||||
@@ -317,6 +320,46 @@ export async function prepareIsolatedEnvironment({ provider, keyId, reqId }) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── TUI-mode: chmod 700 + seed .claude.json (gated on tui=true) ─────────
|
||||
// Spec §7.1 + §5.5: runs ONLY when the caller opts in with tui:true.
|
||||
// The default (stream-json) path is byte-for-byte unchanged — this block
|
||||
// does not execute when tui is falsy.
|
||||
//
|
||||
// NOTE: the seed does NOT disable managed MCP. That is the spawn-argv flag
|
||||
// --strict-mcp-config (PR-2). See spec §5.2 + T6 negative control.
|
||||
if (tui) {
|
||||
// chmod 700 the per-reqId dir and ephemeralRoot so sibling spawns cannot
|
||||
// traverse into each other's ephemeral homes (§5.5 credential-wall).
|
||||
const reqIdDir = join(SPAWN_BASE_DIR, safeKeyId, safeReqId);
|
||||
chmodSync(reqIdDir, 0o700);
|
||||
chmodSync(ephemeralRoot, 0o700);
|
||||
|
||||
// Seed .claude.json with onboarding + bypass markers.
|
||||
//
|
||||
// PR-0 seeds `hasCompletedOnboarding` + `bypassPermissionsModeAccepted` only.
|
||||
// It deliberately does NOT pre-trust the spawn cwd, and the per-directory
|
||||
// **trust-folder dialog is therefore NOT suppressed** by this seed.
|
||||
//
|
||||
// Important correction to the original plan §4/§7.1 framing: there are TWO
|
||||
// distinct dialogs. `bypassPermissionsModeAccepted:true` suppresses the
|
||||
// bypass-permissions acceptance dialog ("you accept all responsibility…
|
||||
// 1.No 2.Yes") — verified on claude v2.1.158. It does NOT suppress the
|
||||
// per-directory trust-folder dialog ("Is this a project you trust? 1.Yes
|
||||
// 2.No"), which still appears for a fresh ephemeral $HOME (projects is
|
||||
// stripped). The pre-code spikes confirmed this — their playbook answers
|
||||
// the trust dialog by sending "1".
|
||||
//
|
||||
// PR-0 does not pre-trust because (a) the spawn cwd is chosen by PR-2's
|
||||
// session driver, not known here, and (b) the exact trust-field key in the
|
||||
// projects map is unverified. Therefore **PR-2's session driver MUST answer
|
||||
// the trust-folder dialog (send "1")** — the spike-validated approach.
|
||||
// Pre-trusting projects[cwd] in the seed is an optional future optimization
|
||||
// (PR-2 could thread cwd back), but the dialog-answer is the proven default.
|
||||
// See ADR 0002 Amendment 9 § tuiSeed extension note.
|
||||
const resolvedSeedSource = tuiSeedSource ?? join(homedir(), '.claude.json');
|
||||
_seedTuiClaudeJson(ephemeralRoot, resolvedSeedSource);
|
||||
}
|
||||
|
||||
// ── Compose envOverrides (Layer 1 output) ────────────────────────────────
|
||||
let envOverrides = {};
|
||||
if (typeof isolation.ephemeralEnvOverrides === 'function') {
|
||||
@@ -434,6 +477,7 @@ export async function prepareIsolatedEnvironment({ provider, keyId, reqId }) {
|
||||
|
||||
return {
|
||||
ephemeralRoot,
|
||||
reqId: safeReqId,
|
||||
envOverrides,
|
||||
hardenedArgs,
|
||||
wrapForLayer3,
|
||||
@@ -441,6 +485,64 @@ export async function prepareIsolatedEnvironment({ provider, keyId, reqId }) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── TUI-mode helper: seed ephemeral .claude.json ─────────────────────────
|
||||
|
||||
/**
|
||||
* Reads the operator's ~/.claude.json (or tuiSeedSource), strips `projects`,
|
||||
* stamps onboarding/trust/bypass markers, and writes the result to
|
||||
* join(ephemeralRoot, '.claude.json') mode 0o600.
|
||||
*
|
||||
* Called ONLY from prepareIsolatedEnvironment when tui=true. Never called on
|
||||
* the default (stream-json) path.
|
||||
*
|
||||
* Authority: claude CLI v2.1.158 first-run onboarding behavior (spec §7.1);
|
||||
* seed does NOT disable managed MCP (T6 negative control, spec §5.2).
|
||||
*
|
||||
* @param {string} ephemeralRoot — the per-request ephemeral $HOME
|
||||
* @param {string} seedSource — path to read oauthAccount/userID from
|
||||
*/
|
||||
function _seedTuiClaudeJson(ephemeralRoot, seedSource) {
|
||||
const destPath = join(ephemeralRoot, '.claude.json');
|
||||
|
||||
let seedObj;
|
||||
if (existsSync(seedSource)) {
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(seedSource, 'utf8'));
|
||||
} catch (e) {
|
||||
throw new Error(`[sandbox/manager] Failed to parse TUI seed source ${seedSource}: ${e?.message ?? e}`);
|
||||
}
|
||||
// Strip projects to avoid leaking owner's real project history (§7.1).
|
||||
// Copy everything else (oauthAccount, userID, etc.) and stamp markers.
|
||||
const { projects: _dropped, ...rest } = raw;
|
||||
seedObj = {
|
||||
...rest,
|
||||
hasCompletedOnboarding: true,
|
||||
bypassPermissionsModeAccepted: true,
|
||||
};
|
||||
} else {
|
||||
// Source absent: write minimal seed so the session can still start.
|
||||
// OAuth may fail without a real oauthAccount; operator must supply one.
|
||||
console.warn(
|
||||
`[sandbox/manager] [WARN][tui] TUI seed source not found: ${seedSource}. ` +
|
||||
`Writing minimal seed — interactive claude may fail OAuth without a real ~/.claude.json.`,
|
||||
);
|
||||
seedObj = {
|
||||
hasCompletedOnboarding: true,
|
||||
bypassPermissionsModeAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(destPath, JSON.stringify(seedObj), { mode: 0o600 });
|
||||
} catch (e) {
|
||||
throw new Error(`[sandbox/manager] Failed to write TUI seed to ${destPath}: ${e?.message ?? e}`);
|
||||
}
|
||||
|
||||
// NEVER log the seed contents — carries oauthAccount/userID (§5.5, §7.1).
|
||||
console.info('[tui] seeded ephemeral .claude.json');
|
||||
}
|
||||
|
||||
// ── Legacy unsandboxed shape ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -18318,3 +18318,209 @@ 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 — PR-0 TUI ISOLATION seed ────────────────────────────────────
|
||||
//
|
||||
// Tests for the tui opt-in param of prepareIsolatedEnvironment:
|
||||
// T-a default-path-unchanged (tui omitted / false)
|
||||
// T-b tui-seed (tui:true, valid fixture source)
|
||||
// T-c missing-source (tui:true, non-existent source → minimal seed)
|
||||
//
|
||||
// Authority:
|
||||
// spec §7.1 + §5.2 (T6 negative control) — seed does NOT disable managed MCP
|
||||
// spec §5.5 — chmod 700 + mode-600 seed
|
||||
// ADR 0002 Amendment 9 (ISOLATION contract, tuiSeed extension)
|
||||
// claude CLI v2.1.158 first-run onboarding behavior
|
||||
|
||||
import { ISOLATION as _ISOLATION_PR0 } from './lib/providers/anthropic.mjs';
|
||||
import { mkdtempSync as _mkdtemp45, rmSync as _rmSync45, statSync as _statSync45, writeFileSync as _wfs45, existsSync as _exists45, readFileSync as _rfs45 } from 'node:fs';
|
||||
import { join as _join45 } from 'node:path';
|
||||
import { tmpdir as _tmpdir45 } from 'node:os';
|
||||
|
||||
describe('Suite 45 — PR-0 TUI ISOLATION seed', () => {
|
||||
|
||||
// ── Shared: build a mock provider that has the real anthropic ISOLATION shape
|
||||
// (credentialMounts is overridden to [] so we don't symlink ~/.claude/.credentials.json).
|
||||
function _mockAnthropicProvider() {
|
||||
return {
|
||||
name: 'mock-anthropic-pr0',
|
||||
ISOLATION: {
|
||||
..._ISOLATION_PR0,
|
||||
// Override credentialMounts to avoid accessing the real home during tests.
|
||||
credentialMounts: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── T-a: default-path-unchanged ──────────────────────────────────────────
|
||||
// With tui omitted (or false), prepareIsolatedEnvironment must NOT write
|
||||
// .claude.json in the ephemeralRoot. The return shape must equal the
|
||||
// existing shape PLUS the additive reqId field.
|
||||
|
||||
it('45a: default-path-unchanged — no .claude.json written when tui is falsy', async () => {
|
||||
await _resetSandboxMgr();
|
||||
globalThis.__OLP_FORCE_ISOLATION_IN_TEST = true;
|
||||
const provider = _mockAnthropicProvider();
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await prepareIsolatedEnvironment({
|
||||
provider,
|
||||
keyId: 'test-key-45a',
|
||||
reqId: 'test-req-45a',
|
||||
// tui intentionally omitted (defaults to false)
|
||||
});
|
||||
|
||||
// ephemeralRoot must exist and be under /tmp/olp-spawn/
|
||||
assert.ok(typeof ctx.ephemeralRoot === 'string' && ctx.ephemeralRoot.length > 0,
|
||||
'ephemeralRoot must be a non-empty string');
|
||||
assert.ok(ctx.ephemeralRoot.startsWith('/tmp/olp-spawn/'),
|
||||
`ephemeralRoot must be under /tmp/olp-spawn/; got ${ctx.ephemeralRoot}`);
|
||||
|
||||
// NO .claude.json must be present
|
||||
const claudeJsonPath = _join45(ctx.ephemeralRoot, '.claude.json');
|
||||
assert.equal(_exists45(claudeJsonPath), false,
|
||||
'.claude.json MUST NOT be present on the default (non-tui) path');
|
||||
|
||||
// reqId is present (additive — does not break existing callers who ignore it)
|
||||
assert.ok('reqId' in ctx, 'reqId must be present in the returned ctx');
|
||||
assert.equal(typeof ctx.reqId, 'string', 'reqId must be a string');
|
||||
|
||||
// Return shape has all original fields
|
||||
assert.equal(typeof ctx.envOverrides, 'object', 'envOverrides must be present');
|
||||
assert.equal(typeof ctx.hardenedArgs, 'function', 'hardenedArgs must be a function');
|
||||
assert.equal(typeof ctx.wrapForLayer3, 'function', 'wrapForLayer3 must be a function');
|
||||
assert.equal(typeof ctx.cleanup, 'function', 'cleanup must be a function');
|
||||
|
||||
await ctx.cleanup();
|
||||
} finally {
|
||||
delete globalThis.__OLP_FORCE_ISOLATION_IN_TEST;
|
||||
if (ctx?.cleanup) { try { await ctx.cleanup(); } catch { /* already cleaned */ } }
|
||||
await _resetSandboxMgr();
|
||||
}
|
||||
});
|
||||
|
||||
// ── T-b: tui-seed ────────────────────────────────────────────────────────
|
||||
// With tui:true + a fixture tuiSeedSource: .claude.json must be written
|
||||
// mode 600, projects stripped, markers present, oauthAccount/userID present.
|
||||
// The <reqId> dir and ephemeralRoot must be mode 700.
|
||||
|
||||
it('45b: tui-seed — .claude.json written mode 600, markers present, projects stripped, dirs mode 700', async () => {
|
||||
await _resetSandboxMgr();
|
||||
globalThis.__OLP_FORCE_ISOLATION_IN_TEST = true;
|
||||
|
||||
// Write a fixture .claude.json to a temp dir (never touches real ~/.claude.json).
|
||||
const fixtureDir = _mkdtemp45(_join45(_tmpdir45(), 'olp-pr0-fixture-'));
|
||||
const fixtureSeedPath = _join45(fixtureDir, 'fixture-claude.json');
|
||||
const fixtureSource = {
|
||||
oauthAccount: { emailAddress: 'test@example.com', organizationUuid: 'org-uuid-fake' },
|
||||
userID: 'user-id-fake',
|
||||
projects: { '/some/real/project': { hasTrustDialogAccepted: true } },
|
||||
someOtherField: 'keep-me',
|
||||
};
|
||||
_wfs45(fixtureSeedPath, JSON.stringify(fixtureSource), 'utf8');
|
||||
|
||||
const provider = _mockAnthropicProvider();
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await prepareIsolatedEnvironment({
|
||||
provider,
|
||||
keyId: 'test-key-45b',
|
||||
reqId: 'test-req-45b',
|
||||
tui: true,
|
||||
tuiSeedSource: fixtureSeedPath,
|
||||
});
|
||||
|
||||
// .claude.json must exist
|
||||
const claudeJsonPath = _join45(ctx.ephemeralRoot, '.claude.json');
|
||||
assert.equal(_exists45(claudeJsonPath), true,
|
||||
'.claude.json must be present when tui:true');
|
||||
|
||||
// mode 600
|
||||
const seedMode = _statSync45(claudeJsonPath).mode & 0o777;
|
||||
assert.equal(seedMode, 0o600,
|
||||
`.claude.json must have mode 0o600; got ${seedMode.toString(8)}`);
|
||||
|
||||
// Parse and check content
|
||||
const seedContent = JSON.parse(_rfs45(claudeJsonPath, 'utf8'));
|
||||
assert.equal(seedContent.hasCompletedOnboarding, true,
|
||||
'hasCompletedOnboarding must be true');
|
||||
assert.equal(seedContent.bypassPermissionsModeAccepted, true,
|
||||
'bypassPermissionsModeAccepted must be true');
|
||||
assert.equal('projects' in seedContent, false,
|
||||
'projects must NOT be present in the seed (stripped to avoid leaking owner history)');
|
||||
assert.deepEqual(seedContent.oauthAccount, fixtureSource.oauthAccount,
|
||||
'oauthAccount must be copied from source');
|
||||
assert.equal(seedContent.userID, fixtureSource.userID,
|
||||
'userID must be copied from source');
|
||||
assert.equal(seedContent.someOtherField, 'keep-me',
|
||||
'other fields must be preserved');
|
||||
|
||||
// Negative assertion: no MCP-disable fields (T6 negative control)
|
||||
assert.equal('claudeAiMcpEverConnected' in seedContent, false,
|
||||
'seed must NOT contain claudeAiMcpEverConnected (MCP disable is spawn-argv, not seed)');
|
||||
assert.equal('mcpServers' in seedContent, false,
|
||||
'seed must NOT contain mcpServers');
|
||||
|
||||
// reqId present
|
||||
assert.ok('reqId' in ctx, 'reqId must be present');
|
||||
assert.equal(ctx.reqId, 'test-req-45b', 'reqId must match the sanitized input');
|
||||
|
||||
// <reqId> dir and ephemeralRoot must be mode 700 (§5.5)
|
||||
const reqIdDir = _join45('/tmp/olp-spawn', 'test-key-45b', 'test-req-45b');
|
||||
const reqIdMode = _statSync45(reqIdDir).mode & 0o777;
|
||||
assert.equal(reqIdMode, 0o700,
|
||||
`<reqId> dir must have mode 0o700; got ${reqIdMode.toString(8)}`);
|
||||
const rootMode = _statSync45(ctx.ephemeralRoot).mode & 0o777;
|
||||
assert.equal(rootMode, 0o700,
|
||||
`ephemeralRoot must have mode 0o700; got ${rootMode.toString(8)}`);
|
||||
|
||||
await ctx.cleanup();
|
||||
} finally {
|
||||
delete globalThis.__OLP_FORCE_ISOLATION_IN_TEST;
|
||||
if (ctx?.cleanup) { try { await ctx.cleanup(); } catch { /* already cleaned */ } }
|
||||
try { _rmSync45(fixtureDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
await _resetSandboxMgr();
|
||||
}
|
||||
});
|
||||
|
||||
// ── T-c: missing-source ──────────────────────────────────────────────────
|
||||
// With tui:true and a non-existent tuiSeedSource, _seedTuiClaudeJson should
|
||||
// write a minimal seed (markers only, no oauthAccount) and NOT throw.
|
||||
|
||||
it('45c: missing-source — minimal seed written when tuiSeedSource does not exist', async () => {
|
||||
await _resetSandboxMgr();
|
||||
globalThis.__OLP_FORCE_ISOLATION_IN_TEST = true;
|
||||
|
||||
const provider = _mockAnthropicProvider();
|
||||
const nonExistentSource = _join45(_tmpdir45(), 'olp-pr0-nonexistent-' + Date.now() + '.json');
|
||||
let ctx;
|
||||
try {
|
||||
// Must not throw even though source is absent
|
||||
ctx = await prepareIsolatedEnvironment({
|
||||
provider,
|
||||
keyId: 'test-key-45c',
|
||||
reqId: 'test-req-45c',
|
||||
tui: true,
|
||||
tuiSeedSource: nonExistentSource,
|
||||
});
|
||||
|
||||
const claudeJsonPath = _join45(ctx.ephemeralRoot, '.claude.json');
|
||||
assert.equal(_exists45(claudeJsonPath), true,
|
||||
'minimal seed must be written even when source is absent');
|
||||
|
||||
const seedContent = JSON.parse(_rfs45(claudeJsonPath, 'utf8'));
|
||||
assert.equal(seedContent.hasCompletedOnboarding, true,
|
||||
'minimal seed must have hasCompletedOnboarding:true');
|
||||
assert.equal(seedContent.bypassPermissionsModeAccepted, true,
|
||||
'minimal seed must have bypassPermissionsModeAccepted:true');
|
||||
assert.equal('oauthAccount' in seedContent, false,
|
||||
'minimal seed must NOT have oauthAccount (source was absent)');
|
||||
|
||||
await ctx.cleanup();
|
||||
} finally {
|
||||
delete globalThis.__OLP_FORCE_ISOLATION_IN_TEST;
|
||||
if (ctx?.cleanup) { try { await ctx.cleanup(); } catch { /* already cleaned */ } }
|
||||
await _resetSandboxMgr();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user