Author SHA1 Message Date
taodengandClaude Opus 4.8 066cc21eba docs(handoff): TUI-mode session handoff for PR-1 onward
Self-contained resume doc: context, validated spike facts (S1/S2/S3/
T1/T3/T6), the critical gotchas (trust-vs-bypass dialog distinction,
submission key-token recipe, dual-signal-no-quiescence completion,
buffered-not-streaming), PI231 playbook (prod :4567 untouched), the
remaining PRs (PR-1 reader → PR-2 driver → PR-3 wiring), working method
(hybrid workflow + maintainer-supervised PI231), code anchors, conventions
(jaekwon-park co-author, default-path-unchanged sacred), and first move.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:35:02 +10:00
1fd27e1c11 feat(tui): PR-0 — TUI-only ISOLATION .claude.json seed
First PR of the TUI-mode A-path (interactive claude → cc_entrypoint=cli →
subscription pool, so the proxy stays usable for Pro subscribers post-6/15).

Adds a `tui` opt-in param to prepareIsolatedEnvironment (lib/sandbox/manager.mjs).
When tui:true (and only then):
- chmod 700 the per-reqId dir + ephemeralRoot (credential-wall, spec §5.5)
- seed ephemeral .claude.json via _seedTuiClaudeJson: read ~/.claude.json
  (or tuiSeedSource), STRIP projects (no owner history leak), stamp
  hasCompletedOnboarding + bypassPermissionsModeAccepted, preserve
  oauthAccount/userID, write mode 0o600, never log contents
- return reqId in isolationCtx (PR-2 driver derives tmux session name —
  maintainer decision P1: reuse isolationCtx, no server.mjs call-site edits)

DEFAULT PATH BYTE-FOR-BYTE UNCHANGED: when tui falsy, none of the above runs;
the only return-shape change is the additive reqId field existing callers
ignore. Reviewer verified this invariant airtight (server.mjs:1347/:1564 pass
no tui arg).

Seed carries NO MCP-disable weight (T6 negative control: --strict-mcp-config
in PR-2 is the mechanism, not the seed). The seed also does NOT suppress the
per-directory trust-folder dialog — corrected a review-fix overclaim that
attributed trust-dialog suppression to bypassPermissionsModeAccepted (that
flag only suppresses the bypass-permissions dialog; the trust dialog still
appears and PR-2's driver MUST answer it "1", per the spikes).

Tests: Suite 45 (45a default-path-unchanged, 45b tui-seed perms/markers/
strip, 45c missing-source minimal seed). 816 pass / 0 fail.

ADR 0002 Amendment 9 § tuiSeed extension note added.

Authority: design spec docs/superpowers/specs/2026-05-30-tui-mode-production-design.md
§7.1/§5.5/§12.1; implementation plan + maintainer decisions; ADR 0002 Amd 9;
claude CLI v2.1.158.

Co-Authored-By: jaekwon-park <insainty21@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:09:12 +10:00
5 changed files with 452 additions and 5 deletions
+4
View File
@@ -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 P1P5 + 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.52s 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.
+11
View File
@@ -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
View File
@@ -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 ──────────────────────────────────────────────
/**
+206
View File
@@ -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();
}
});
});