mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-22 05:25:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc3a1be06 |
@@ -677,8 +677,4 @@ 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 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 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."
|
- **`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.
|
- **`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,468 @@
|
|||||||
|
# ADR 0015 — Session-NAT Layer: Per-OLP-key Stable Session_id for IDE-like Anthropic Billing Classification
|
||||||
|
|
||||||
|
**Status:** Draft (Phase 8 candidate, NOT YET ACCEPTED)
|
||||||
|
**Date:** 2026-05-29
|
||||||
|
**Phase:** Phase 8 (not yet started)
|
||||||
|
**Authors:** project maintainer (with AI drafting assistance)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- **ADR 0001** (Project Founding) — § Non-mission says "OLP is a pure stateless proxy. Memory and continuity are client-side concerns." This ADR proposes carrying a small piece of state (`session_id` per OLP key) and requires an **ADR 0001 amendment co-merge** to redraw the state boundary.
|
||||||
|
- **ADR 0007** (Multi-key Auth) — provides the per-OLP-key isolation primitive (`keyId`) that this ADR's NAT-style mapping uses as its left-hand side.
|
||||||
|
- **ADR 0009 Amendment 1** (Phase 6c stream-json transport) — established `--no-session-persistence` as part of the spawn args. This ADR proposes **dropping that flag**; requires an ADR 0009 Amendment co-merge.
|
||||||
|
- **ADR 0014 Amendment 1** (Phase 7 Solution 1) — ephemeral `$HOME` + symlinked credentials per spawn. This ADR layers Session-NAT on top: each OLP key gets a stable session file outside the ephemeral home, symlinked in at spawn time.
|
||||||
|
- **Anthropic billing announcement** (2026-05-14, effective 2026-06-15) — the policy event that motivates this ADR. Splits `claude -p` / third-party-app traffic out of subscription pool into Agent SDK Credit pool.
|
||||||
|
- **cc-mem `incident_2026_05_27_spawn_cli_security.md` § 9** — bridge-value caveat for the Phase 6c spawn-mode change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
### 1.1 The user's NAT framing
|
||||||
|
|
||||||
|
The architectural insight that motivates this ADR is a network-address-translation analogy. In a home router:
|
||||||
|
|
||||||
|
| NAT concept | Implementation |
|
||||||
|
|---|---|
|
||||||
|
| Many internal IPs | All devices behind the router |
|
||||||
|
| One external IP | The ISP-assigned WAN address |
|
||||||
|
| Port number (multiplexing tag) | TCP/UDP source port the router rewrites |
|
||||||
|
| Conntrack table | Router-side map of (internal IP, internal port) ↔ (external IP, external port) |
|
||||||
|
|
||||||
|
The external network sees one IP with many distinct connections distinguished by port. **The downstream (ISP) does not need to know that NAT is happening to bill correctly** — they bill the one external IP for the aggregate traffic.
|
||||||
|
|
||||||
|
The OLP-side analogue:
|
||||||
|
|
||||||
|
| NAT concept | OLP-NAT mapping |
|
||||||
|
|---|---|
|
||||||
|
| Many internal IPs | Many OLP keys (one per family member / device / use-case) |
|
||||||
|
| One external IP | One Anthropic OAuth credential (pooled subscription) |
|
||||||
|
| Port number | claude CLI `session_id` (a UUID identifying a conversation) |
|
||||||
|
| Conntrack table | OLP-side `Map<olp_key_id, stable_session_id>` persisted to disk under `~/.olp/sessions/<key-id>/` |
|
||||||
|
|
||||||
|
The Anthropic API server sees one OAuth with N concurrent conversations, each identified by a stable `session_id` that persists across requests. This is structurally indistinguishable from one power-user who has N project windows open in Claude Code.
|
||||||
|
|
||||||
|
### 1.2 The signal OLP currently emits
|
||||||
|
|
||||||
|
Post-Phase-7, OLP spawns claude with `--no-session-persistence` and `--system-prompt <OLP wrapper>`. Every `/v1/chat/completions` request produces a fresh `session_id` that claude never reuses. The Anthropic-side observable pattern is:
|
||||||
|
|
||||||
|
- One OAuth token
|
||||||
|
- Infinite never-repeated `session_id` values
|
||||||
|
- Session lifetime ≈ duration of the HTTP request
|
||||||
|
- Cross-`session_id` correlation: zero
|
||||||
|
|
||||||
|
This pattern is **mechanically distinguishable from any plausible IDE user** — IDE users keep session IDs alive for hours, reuse them across many requests, and rarely emit > 5–10 distinct sessions per OAuth. The current OLP spawn shape is therefore an unintentional but reliable fingerprint that Anthropic's billing classifier can use to flag OLP traffic as "third-party app authenticating via Agent SDK" — exactly the bucket that moves to the $100/mo Agent SDK Credit pool on 2026-06-15.
|
||||||
|
|
||||||
|
### 1.3 What this ADR is, and is not, trying to do
|
||||||
|
|
||||||
|
This ADR **is** trying to:
|
||||||
|
- Reshape OLP's observable Anthropic-side traffic so that it falls inside the statistical envelope of "one power user with multiple project windows" rather than "third-party API client churning ephemeral sessions"
|
||||||
|
- Inherit any cost benefit Anthropic's prompt-cache regime gives to long-lived sessions
|
||||||
|
- Preserve the four orthogonal values of Phase 6c (cost reduction, hallucination fix, NDJSON observability, possible bridge value) — none of those are sacrificed
|
||||||
|
- Compose cleanly with Phase 7 Solution 1 ephemeral-home + symlink + per-provider ISOLATION
|
||||||
|
|
||||||
|
This ADR is **not** trying to:
|
||||||
|
- Permanently evade Anthropic's billing classification. Anthropic can, and probably will, add additional signals beyond `session_id` count (per-OAuth request rate, prompt-content fingerprinting, etc.). Session-NAT addresses *one* signal; durability comes from the multi-provider fallback chain (ADR 0001).
|
||||||
|
- Hide that OLP is OLP. ALIGNMENT.md Anti-Fingerprinting clause is respected: this ADR uses **claude CLI's documented native flags** (`--session-id`, `--resume`) and does not patch the binary, MITM the HTTPS transport, or fabricate HTTP headers.
|
||||||
|
- Provide conversational memory to OLP clients as a user-visible feature. The session_id state is for routing / billing-classification purposes only; clients still send full conversation history in their OpenAI-format requests, and OLP still relays it. The model gets the same input it would without this ADR; what changes is what `session_id` claude tags the API call with.
|
||||||
|
|
||||||
|
This last distinction is what makes the ADR 0001 amendment claim defensible: OLP holds the *identifier* of a session, never the *content* of one.
|
||||||
|
|
||||||
|
### 1.4 Why this is Phase 8, not Phase 7
|
||||||
|
|
||||||
|
Phase 7 closed at v0.7.0 on 2026-05-29 with the Solution 1 isolation layer. Session-NAT is an independent architectural decision that:
|
||||||
|
- Depends on Phase 7 being in place (uses ephemeral-home + symlink primitives)
|
||||||
|
- Is not on the critical path for any current OLP behaviour (ratelimit + session-pool are quality-of-service features, not correctness features)
|
||||||
|
- Requires spike work to verify claude CLI `--session-id` + cwd-encoding behaviour before implementation
|
||||||
|
- Has an open ADR 0001 amendment that needs maintainer-level signoff
|
||||||
|
|
||||||
|
Treating it as a Phase 8 candidate gives the maintainer time to observe what Anthropic actually does to OLP traffic post-2026-06-15 before committing to the engineering work. If Anthropic does not in fact reclassify OLP traffic (or the reclassification is benign), this ADR can be abandoned without cost.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Decision
|
||||||
|
|
||||||
|
Adopt the **Session-NAT layer**: a per-OLP-key stable claude `session_id` pool maintained by OLP, symlinked into the Phase 7 ephemeral spawn home so that each `/v1/chat/completions` request to the anthropic provider invokes claude with a session that persists across requests for that OLP key.
|
||||||
|
|
||||||
|
Co-merge requirements:
|
||||||
|
- **ADR 0001 amendment** clarifying that `session_id` is a routing identifier held by OLP and is not "conversation state" in the ADR-0001 sense (which excluded prompt content, memory continuity, and IDE-side context — none of which OLP starts holding under this ADR).
|
||||||
|
- **ADR 0009 amendment** narrowing the Phase 6c spawn-arg specification: `--no-session-persistence` is replaced by `--session-id <stable_uuid>` (computed by OLP per key + lifecycle policy). All other Phase 6c args (stream-json + verbose + --system-prompt) are unchanged.
|
||||||
|
- **ADR 0014 Amendment** (optional, possibly Amendment 2 to 0014) noting that the Phase 7 ephemeral-home `requiredHomePaths` list is extended for the anthropic provider to include the session-mount target dir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
### 3.1 Per-key session state filesystem layout
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.olp/
|
||||||
|
sessions/
|
||||||
|
<olp-key-id>/ <-- chmod 0700, per ADR 0007 § 3
|
||||||
|
session_state.json <-- chmod 0600
|
||||||
|
conversation.jsonl <-- chmod 0600; claude session log
|
||||||
|
session_state.json.tmp.<pid>.<counter> <-- transient, atomic-replace target
|
||||||
|
```
|
||||||
|
|
||||||
|
`session_state.json` schema (v1):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"current_session_id": "<uuid-v4>",
|
||||||
|
"created_at": "<ISO-8601 UTC>",
|
||||||
|
"last_used_at": "<ISO-8601 UTC>",
|
||||||
|
"turn_count": 0,
|
||||||
|
"rotation_reason_history": [
|
||||||
|
{ "reason": "lifetime_expired" | "turn_limit_reached" | "conversation_boundary" | "initial", "at": "<ISO-8601 UTC>" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field semantics:
|
||||||
|
- `current_session_id`: the UUID currently in use for this OLP key's claude spawns. Pre-generated as UUID-v4 (per `crypto.randomUUID()`); fed to claude via `--session-id <uuid>`. Rotated per the policy in § 3.2.
|
||||||
|
- `turn_count`: incremented after every successful `/v1/chat/completions` spawn that reaches the `result` event (failed spawns don't increment).
|
||||||
|
- `rotation_reason_history`: bounded ring buffer of last N rotations for debugging / audit. N = 10.
|
||||||
|
|
||||||
|
`conversation.jsonl` is claude CLI's native session log format. **OLP does not parse, read, or modify this file**. It exists on disk solely so claude can read+write its own session state across spawns; OLP's only interaction with it is the symlink in § 3.4.
|
||||||
|
|
||||||
|
This boundary is the ADR 0001 amendment's load-bearing claim: OLP holds the file path and inode reference (via `session_state.json`'s `current_session_id` and the symlink layout), but the conversation content lives in a file format OLP does not consume.
|
||||||
|
|
||||||
|
### 3.2 Session lifecycle
|
||||||
|
|
||||||
|
A session is rotated (new UUID generated, old `conversation.jsonl` archived or pruned) when any of the following triggers fire:
|
||||||
|
|
||||||
|
1. **Lifetime expired** — `now() - created_at > session_lifetime_hours` (default: 8h, configurable via `~/.olp/config.json security.session_nat.lifetime_hours`)
|
||||||
|
2. **Turn limit reached** — `turn_count >= session_max_turns` (default: 200, configurable)
|
||||||
|
3. **Conversation boundary detected** — see § 3.3
|
||||||
|
4. **Operator-forced** — `olp keys rotate-session --key <id>` admin command (Phase 8.x deliverable)
|
||||||
|
5. **Initial** — first spawn for an OLP key that has no `session_state.json` yet
|
||||||
|
|
||||||
|
When a session rotates, the old `conversation.jsonl` is **renamed** to `conversation-<rotated_at>.jsonl.old` and the new `conversation.jsonl` is fresh-empty. The old log is retained on disk for N days (default 7) then garbage-collected by a background sweep. This retention is non-load-bearing — OLP does not query it — but supports operator debugging.
|
||||||
|
|
||||||
|
**Why two upper bounds, not one**: the lifetime cap matches a typical IDE work-day rhythm (and matches what Anthropic's classifier most likely models as "session timeout"). The turn cap protects against unbounded prompt-history growth for high-frequency OLP keys. A power user with light usage stays in one session all day; a heavy user rotates by turn count.
|
||||||
|
|
||||||
|
### 3.3 Conversation-boundary detection (heuristic)
|
||||||
|
|
||||||
|
Anthropic's IDE-user pattern includes session rotation when the human "starts a new conversation" (clears the chat, opens a new pane, etc.). OLP can't observe a user click "new chat", but can detect a *probable* conversation boundary using OpenAI-format request inspection:
|
||||||
|
|
||||||
|
- **Heuristic A**: the incoming request's `messages[0]` differs structurally from the prior request's `messages[0]` (different system prompt OR first user turn is different).
|
||||||
|
- **Heuristic B**: the incoming request's `messages` array length is 1 (a user starting fresh from scratch), and the prior request had > 1 message.
|
||||||
|
- **Heuristic C**: the time gap between this request and `last_used_at` exceeds a configurable idle threshold (default 2h).
|
||||||
|
|
||||||
|
Any heuristic firing triggers a session rotation. The heuristics are **conservative-bias** — false positives (rotating when the user is actually continuing) are mild (small token-cost hit from losing prompt-cache continuity); false negatives (not rotating when they actually started a new conversation) are mild (slightly worse fingerprint match to IDE behaviour, but not catastrophic).
|
||||||
|
|
||||||
|
Heuristics A and B are stateless and cheap; C requires a single `Date` comparison. None of the three reads `messages[i].content` beyond shape inspection, preserving the OLP-doesn't-hold-conversation-content invariant.
|
||||||
|
|
||||||
|
### 3.4 Integration with Phase 7 ephemeral home
|
||||||
|
|
||||||
|
Phase 7 Solution 1 creates an ephemeral `$HOME` at `/tmp/olp-spawn/<keyId>/<reqId>/home/` per request, with credentials symlinked in. Session-NAT adds one more symlink:
|
||||||
|
|
||||||
|
```
|
||||||
|
/tmp/olp-spawn/<keyId>/<reqId>/home/
|
||||||
|
├── .claude/
|
||||||
|
│ ├── .credentials.json <-- symlink to ~/.claude/.credentials.json (existing Phase 7)
|
||||||
|
│ └── projects/
|
||||||
|
│ └── <ephemeral_cwd_encoded>/
|
||||||
|
│ └── <session_id>.jsonl <-- symlink to ~/.olp/sessions/<keyId>/conversation.jsonl (NEW)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `<ephemeral_cwd_encoded>` segment is claude's session-file-path encoding of the spawn cwd. From the Phase 7 spike on PI231, claude encodes cwd by replacing `/` with `-`; the spawn cwd is `/tmp/olp-spawn/<keyId>/<reqId>/work` (or whatever cwd OLP sets), so the encoded form is `-tmp-olp-spawn-<keyId>-<reqId>-work`.
|
||||||
|
|
||||||
|
**The crucial invariant**: claude's session storage path is `$HOME/.claude/projects/<cwd-encoded>/<session-id>.jsonl`. To make session state persist across spawns (which is the whole point), this path must resolve (via the symlink) to a single per-OLP-key file outside the ephemeral home.
|
||||||
|
|
||||||
|
**Open issue**: `<cwd-encoded>` depends on the spawn cwd, which under Phase 7 is per-request unique. This means each spawn's `<cwd-encoded>` is *different*, so each spawn's `<session_id>.jsonl` lookup path is different. **Even with `--session-id <stable_uuid>`, claude will not find prior session state because the lookup path includes per-request cwd**.
|
||||||
|
|
||||||
|
This is a real architectural problem that the implementation spike must resolve. Three candidate fixes, in increasing order of cleanliness:
|
||||||
|
|
||||||
|
a) **Stable spawn cwd per OLP key** — OLP changes the spawn cwd from `/tmp/olp-spawn/<keyId>/<reqId>/work` to `/tmp/olp-spawn/<keyId>/work` (drop the reqId segment). Cwd becomes per-key not per-request. Loses per-request cwd isolation. Acceptable if cwd-level isolation isn't load-bearing (the per-key ephemeral home still isolates files; cwd is just a label).
|
||||||
|
|
||||||
|
b) **Synthetic stable cwd via `cwd:` spawn option** — OLP passes `cwd: /home/olp/.claude-cwd/<keyId>` (a stable per-key directory) to `child_process.spawn`. The cwd directory exists on disk but is empty; claude only uses it for label-encoding purposes. The actual filesystem reads/writes go through the ephemeral home's `.claude/projects/<encoded>/` symlink.
|
||||||
|
|
||||||
|
c) **Two-symlink approach** — symlink the parent `projects/<encoded>/` directory itself (not just the session file inside) so any `<session_id>.jsonl` inside resolves to the per-key persistent location. Then changing cwd between spawns doesn't break the resolve because all encoded paths point to the same backing dir.
|
||||||
|
|
||||||
|
Approach (b) is cleanest from a layering standpoint (Phase 7 ephemeral home stays untouched, cwd is the only knob); approach (c) is most robust to claude CLI changes in cwd-encoding. The spike picks one.
|
||||||
|
|
||||||
|
### 3.5 Same-key concurrent requests — session pool
|
||||||
|
|
||||||
|
A single OLP key can receive parallel `/v1/chat/completions` requests (e.g., a family member's laptop fires two browser tabs at once). claude's session file is **not safe for concurrent append/edit** — two spawns writing the same `conversation.jsonl` would corrupt it.
|
||||||
|
|
||||||
|
The session-NAT design needs to handle this gracefully without forcing client-side serialisation. Three options:
|
||||||
|
|
||||||
|
1. **Per-key serial lock**: second concurrent request waits for the first to release. Bad UX — every parallel request gets latency from the slower predecessor.
|
||||||
|
|
||||||
|
2. **Per-key session pool**: each OLP key owns a small fixed number of session slots (default 3, configurable). Concurrent requests cycle through available slots; if all are busy, the request queues briefly then proceeds to a fresh ephemeral session (degraded mode — that request loses session continuity but completes). Acceptable parallelism; bounded growth in session count.
|
||||||
|
|
||||||
|
3. **One session per concurrent conversation thread**: dynamically create sessions on demand, garbage-collect after timeout. Most flexible; highest session count per OAuth.
|
||||||
|
|
||||||
|
Recommended: option 2 with `session_pool_size: 3` default. This gives each OLP key a small bounded session set — `(N OLP keys) × 3` total session count on the Anthropic API side per OLP deployment. For a family-scale deployment of 5 keys, that's 15 maximum concurrent session_ids on one OAuth, which is still well within "power-user with multiple project windows" plausibility.
|
||||||
|
|
||||||
|
The pool slot in use for each spawn is recorded in `session_state.json` (extended schema):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"pool": [
|
||||||
|
{ "session_id": "<uuid>", "in_use": false, "last_used_at": "...", "turn_count": 12 },
|
||||||
|
{ "session_id": "<uuid>", "in_use": true, "last_used_at": "...", "turn_count": 87 },
|
||||||
|
{ "session_id": "<uuid>", "in_use": false, "last_used_at": "...", "turn_count": 4 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Lock acquisition: atomic-rename pattern on session_state.json (the same atomic-write discipline ADR 0007 § 6.1 codified for key manifests). A request holds its slot's `in_use=true` for the duration of the claude spawn, releases on spawn exit (happy or error path) via the existing Phase 7 cleanup mechanism extended for session lifecycle.
|
||||||
|
|
||||||
|
### 3.6 claude CLI flags used (verified empirically)
|
||||||
|
|
||||||
|
Empirical verification of `claude --help` on PI231 v2.1.154 (2026-05-29) shows the following flags available:
|
||||||
|
|
||||||
|
- `--session-id <uuid>` — "Use a specific session ID for the conversation (must be a valid UUID)". This is the canonical Session-NAT primitive. When passed with a UUID claude has seen before in the same session-storage path, claude resumes that session; with a new UUID it creates fresh.
|
||||||
|
- `-r, --resume [value]` — "Resume a conversation by session ID, or open interactive picker with optional search term". Alternative to `--session-id` for resume-only semantics. Less flexible (no fresh-create); may not coexist with `--session-id` cleanly. Spike verifies whether `--session-id` alone gives resume-or-create-as-needed behaviour (preferred for Session-NAT).
|
||||||
|
- `-c, --continue` — "Continue the most recent conversation in the current directory". Cwd-dependent; not used by Session-NAT (we want explicit session control via UUID).
|
||||||
|
- `--fork-session` — "When resuming, create a new session ID instead of reusing the original". Not used; we want session reuse, which is exactly what the flag inhibits.
|
||||||
|
- `--no-session-persistence` — currently passed by OLP per Phase 6c. **This ADR proposes removing it** (ADR 0009 amendment co-merge).
|
||||||
|
|
||||||
|
Flag combination proposed for Session-NAT spawn:
|
||||||
|
|
||||||
|
```
|
||||||
|
claude \
|
||||||
|
--session-id <stable_uuid_from_pool> \
|
||||||
|
--output-format stream-json \
|
||||||
|
--verbose \
|
||||||
|
--system-prompt <OLP_SYSTEM_PROMPT_WRAPPER> \
|
||||||
|
--model <model_from_request>
|
||||||
|
# Note: --no-session-persistence removed
|
||||||
|
# Note: --resume / --continue NOT added (--session-id covers both new-create and resume semantics per spike validation)
|
||||||
|
```
|
||||||
|
|
||||||
|
The spike must confirm:
|
||||||
|
- Whether `--session-id <new_uuid>` creates a fresh session when no prior session file exists at the expected path.
|
||||||
|
- Whether `--session-id <existing_uuid>` resumes successfully when the session file exists at the expected path (under stable cwd from § 3.4 fix).
|
||||||
|
- Whether `--no-session-persistence` is required to be *absent* (its presence might override `--session-id` and force ephemeral).
|
||||||
|
- Whether the spawn behaves correctly when the session file is a symlink rather than a real file (Phase 7 symlinked credentials work fine; assume yes but verify).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ADR 0001 Amendment Co-merge — state framing
|
||||||
|
|
||||||
|
**Current ADR 0001 § Non-mission text** (relevant excerpt):
|
||||||
|
|
||||||
|
> "OLP is a pure stateless proxy. Memory and continuity are client-side concerns (Memory Continuity, Hermes equivalents, IDE-side context). OLP does not retain it."
|
||||||
|
|
||||||
|
**Proposed amendment** (additive paragraph):
|
||||||
|
|
||||||
|
> ### Amendment N — Session-NAT routing-identifier carveout (Phase 8, ADR 0015 co-merge, 2026-05-29)
|
||||||
|
>
|
||||||
|
> The "stateless proxy" framing in § Non-mission carves out a distinction between **routing/identification state** and **conversation/content state**:
|
||||||
|
>
|
||||||
|
> - **Routing/identification state** that OLP holds: per-OLP-key key manifest (ADR 0007 § 4), per-key cache namespace (ADR 0005 D1), and — under ADR 0015 — a per-OLP-key claude `session_id` (a UUID identifier with no conversation content). All of these are bounded-size identifiers used to route requests to the correct downstream slot.
|
||||||
|
>
|
||||||
|
> - **Conversation/content state** that OLP does NOT hold: prompt content of in-flight or past requests, response content beyond what flows through OLP's per-request cache (ADR 0005), memory or continuity that survives request boundaries, IDE-side editor / cursor / project context, or any other data that constitutes "what was talked about". Under ADR 0015, claude CLI's session log file `conversation.jsonl` lives on the OLP host's disk because the spawn architecture requires it, but OLP does not read, parse, or transmit its content beyond claude's own consumption.
|
||||||
|
>
|
||||||
|
> The thesis "OLP is a pure stateless proxy" is preserved in the content sense — the dimension on which it actually matters for ADR 0001's reasons (privacy, simplicity, no migration concerns, no conversation-history liability). The carveout above codifies that OLP has always held a small amount of identifier state (the key manifest); ADR 0015 widens this by one UUID per key.
|
||||||
|
>
|
||||||
|
> Operationally: an OLP shutdown loses all in-flight request state but preserves session_id identifiers across restarts. A user wiping `~/.olp/sessions/<key-id>/` loses claude's conversation continuity for that key but does not affect anything else about that key (audit trail, cache, manifest all intact).
|
||||||
|
|
||||||
|
This amendment is **co-merge required** with ADR 0015 acceptance. ADR 0015 acceptance without ADR 0001 amendment leaves the constitution self-contradictory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ADR 0009 Amendment 1 amendment Co-merge — drop `--no-session-persistence`
|
||||||
|
|
||||||
|
**Current ADR 0009 Amendment 1 spawn-arg specification** (Phase 6c):
|
||||||
|
|
||||||
|
> `claude --output-format stream-json --verbose --no-session-persistence --system-prompt <OLP_SYSTEM_PROMPT_WRAPPER>`
|
||||||
|
|
||||||
|
**Proposed change** (ADR 0009 Amendment 2):
|
||||||
|
|
||||||
|
> ### Amendment 2 — Session-NAT layer (Phase 8, ADR 0015 co-merge, 2026-05-29)
|
||||||
|
>
|
||||||
|
> The `--no-session-persistence` flag is removed from the Phase 6c spawn args specification. The flag's original purpose (per-spawn statelessness, no session continuity) is superseded by the Session-NAT layer (ADR 0015), which deliberately introduces controlled per-OLP-key session continuity to match IDE-user fingerprint patterns. All other Phase 6c flags (`--output-format stream-json`, `--verbose`, `--system-prompt <OLP wrapper>`) are unchanged — their cost-reduction and hallucination-suppression and observability values are preserved.
|
||||||
|
>
|
||||||
|
> The new flag set adds `--session-id <stable_uuid_from_pool>` per-spawn. The UUID is computed by OLP per the ADR 0015 § 3.2 lifecycle policy and § 3.5 pool selection.
|
||||||
|
>
|
||||||
|
> If the implementation spike (ADR 0015 § 10 open question 1) reveals that `--no-session-persistence` is required *to be present* for `--output-format stream-json` to work without `--print`, this amendment is **rejected** and Session-NAT is re-scoped to use `--print` mode (with the associated cost regression). In that case ADR 0015 is also re-scoped — see ADR 0015 § 10 open question 1's contingency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Cost analysis
|
||||||
|
|
||||||
|
### 6.1 Anthropic prompt cache interaction
|
||||||
|
|
||||||
|
Anthropic's API supports prompt caching: identical prefix tokens in subsequent requests hit a 90%-off price (cache-read) instead of full price (cache-creation). Long-lived sessions naturally accumulate identical prefixes (system prompt + early conversation turns are constant), so persistent sessions should have higher prompt-cache hit rates than ephemeral ones.
|
||||||
|
|
||||||
|
Current OLP (post-Phase-7, ephemeral session) observed cost per Sonnet 4.6 request: $0.0078 average (from Phase 6c measurement). Under Session-NAT, two competing forces:
|
||||||
|
|
||||||
|
- **Prompt-cache hit rate increase** (cost-down): ~30-50% of input tokens were cache_creation in the post-Phase-6c measurement. Under Session-NAT, these become cache_read on subsequent turns within the session → ~70% input-token-cost reduction on the cached portion → net 20-35% cost reduction.
|
||||||
|
|
||||||
|
- **Conversation-history growth** (cost-up): each turn N's request prompt includes turns 1..N-1's content (claude prepends prior conversation as context). After 50 turns the input token count is N× higher than turn 1. Even with 90% cache-read discount, the total cost trends upward with conversation length.
|
||||||
|
|
||||||
|
Net effect depends on the turn-count distribution and conversation-content size. For a family deployment with light usage (~50 turns per session before rotation), prompt-cache benefits dominate and net cost decreases. For heavy usage (hitting the 200-turn rotation cap), conversation growth dominates and net cost increases vs Phase 7 baseline.
|
||||||
|
|
||||||
|
**Predicted range** (un-spiked): -20% to +50% per-request cost vs Phase 7 baseline, depending on usage pattern. Spike § 10 open question 4 measures actual.
|
||||||
|
|
||||||
|
### 6.2 Cache layer (ADR 0005) interaction
|
||||||
|
|
||||||
|
OLP's own cache layer hashes (provider, model, IR-request) and stores response — orthogonal to claude's session cache. Under Session-NAT, two interactions:
|
||||||
|
|
||||||
|
- **OLP-cache key stability**: same IR-request still hashes to the same OLP cache key regardless of session_id, so OLP cache hits are unaffected. Good.
|
||||||
|
- **OLP-cache hit-rate vs claude-cache hit-rate**: when OLP cache hits, no claude spawn fires, so claude prompt-cache doesn't matter. OLP-cache misses go to claude with possible prompt-cache hit. The two layers compose; no negative interaction expected.
|
||||||
|
|
||||||
|
### 6.3 Storage growth
|
||||||
|
|
||||||
|
Session log files (`conversation.jsonl`) grow per turn. Rough estimate: a 10-turn conversation in claude's session log is ~20-50 KB. Per OLP key, 200 turns × 3 pool slots = ~30 MB max before rotation. Family-scale deployment of 5 keys: ~150 MB session storage. Acceptable.
|
||||||
|
|
||||||
|
Rotated log retention (default 7 days) adds another factor: ~150 MB × 7 = ~1 GB worst case. Operator can tune `session_nat.retention_days` lower if disk is constrained.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Provider contract impact
|
||||||
|
|
||||||
|
The Provider `ISOLATION` contract (ADR 0002 Amendment 9) currently has no session-related field. Session-NAT touches only the anthropic provider's spawn path; codex and mistral are unaffected (codex has its own session model via `~/.codex/sessions/`; mistral pending).
|
||||||
|
|
||||||
|
Proposed `ISOLATION` extension (optional, additive):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export const ISOLATION = {
|
||||||
|
// ... existing fields per ADR 0002 Amendment 9 ...
|
||||||
|
|
||||||
|
// ADR 0015 (Session-NAT) extension (OPTIONAL — providers without
|
||||||
|
// sessionLayer fall back to ephemeral-per-spawn behaviour).
|
||||||
|
sessionLayer: {
|
||||||
|
enabled: true,
|
||||||
|
persistDir: ({ keyId }) => `~/.olp/sessions/${keyId}`,
|
||||||
|
// Function returning the spawn args that activate the session for
|
||||||
|
// a given session_id. Allows provider-specific flag composition.
|
||||||
|
sessionFlagsForSpawn: ({ sessionId }) => ['--session-id', sessionId],
|
||||||
|
// Pool configuration; orchestrator selects an available slot.
|
||||||
|
poolSize: 3,
|
||||||
|
lifetimeHours: 8,
|
||||||
|
maxTurns: 200,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
For anthropic this is populated as above; for codex and mistral the field is omitted (or `enabled: false`), and the orchestrator's pre-spawn step skips session work for those providers — equivalent to current Phase 7 behaviour.
|
||||||
|
|
||||||
|
This is a forward-compatible additive change to ADR 0002 Amendment 9. A separate ADR 0002 Amendment 10 co-merge captures it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Detection-resistance analysis (honest assessment)
|
||||||
|
|
||||||
|
What signals does Session-NAT dampen, and which does it not?
|
||||||
|
|
||||||
|
| Signal | Current Phase 7 OLP | Session-NAT |
|
||||||
|
|---|---|---|
|
||||||
|
| Active session_ids per OAuth | ♾ ephemeral (very bot-like) | 5–15 stable (IDE-power-user-like) |
|
||||||
|
| Session reuse over time | Never | Yes (hours to days per session) |
|
||||||
|
| Session lifetime | < 1 second | Hours |
|
||||||
|
| Cross-session_id correlation | None | None (preserved) |
|
||||||
|
| Per-OAuth request rate | Aggregated, can spike | **Unchanged** — needs separate ratelimit ADR |
|
||||||
|
| Per-session request rate | n/a (sessions don't survive a single request) | Bounded by usage pattern; comparable to human-paced IDE rate |
|
||||||
|
| `--system-prompt` customization | OLP-specific wrapper, not Claude Code default | **Unchanged** — same fingerprint |
|
||||||
|
| User-agent / HTTP headers | claude CLI default | **Unchanged** — same fingerprint |
|
||||||
|
| Concurrent active sessions per OAuth (same time-window) | ♾ | Bounded by pool_size × key count |
|
||||||
|
|
||||||
|
**The honest conclusion**: Session-NAT shifts OLP's traffic into the "one power user with several persistent IDE projects" envelope across **5 of 9 observable signals**. The remaining 4 signals (per-OAuth request rate, `--system-prompt` customization, HTTP headers, concurrent-session burst) are addressed by orthogonal mechanisms:
|
||||||
|
|
||||||
|
- Per-OAuth request rate: covered by the Phase 8 ratelimit ADR (see § 1.3 — separate work).
|
||||||
|
- `--system-prompt` customization: a known fingerprint we accept the cost of (Phase 6c benefits outweigh).
|
||||||
|
- HTTP headers: claude CLI controls these; OLP doesn't touch (and shouldn't, per AGENTS.md).
|
||||||
|
- Concurrent burst across pool slots: ratelimit pool layer caps this.
|
||||||
|
|
||||||
|
Session-NAT is therefore best understood as **one of 2-3 layers** that together approximate IDE-user-pattern matching. It is not sufficient alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Authority citations
|
||||||
|
|
||||||
|
Per ALIGNMENT.md Rule 1:
|
||||||
|
|
||||||
|
- **claude CLI v2.1.154 `--help` capture** (PI231, 2026-05-29 11:33 UTC):
|
||||||
|
- `--session-id <uuid>` — "Use a specific session ID for the conversation (must be a valid UUID)"
|
||||||
|
- `--resume [value]` — "Resume a conversation by session ID, or open interactive picker with optional search term"
|
||||||
|
- `--continue` — "Continue the most recent conversation in the current directory"
|
||||||
|
- `--fork-session` — "When resuming, create a new session ID instead of reusing the original"
|
||||||
|
- `--no-session-persistence` — "Disable session persistence — sessions will not be saved to disk and cannot be resumed (only works with --print)"
|
||||||
|
- Full capture stored in `docs/spikes/2026-05-29-claude-session-flags.md` (to be created at spike time)
|
||||||
|
|
||||||
|
- **Anthropic billing announcement 2026-05-14, effective 2026-06-15** — original public announcement URL (placeholder; verify at impl time): the Pro/Max subscription pool excludes `claude -p` / Agent SDK traffic post-effective-date. Source: cc-mem `~/.cc-rules/memory/learnings/anthropic_claude_code_billing_split_2026_06_15.md`.
|
||||||
|
|
||||||
|
- **ADR 0001 § Mission and § Non-mission** — the "no conversation state" framing this ADR amends.
|
||||||
|
|
||||||
|
- **ADR 0007 § 3 (filesystem layout) + § 6 (atomic-write discipline)** — the per-OLP-key isolation primitive and the atomic-write pattern reused for `session_state.json`.
|
||||||
|
|
||||||
|
- **ADR 0009 Amendment 1** — the Phase 6c spawn-arg specification this ADR proposes amending.
|
||||||
|
|
||||||
|
- **ADR 0014 Amendment 1** (Phase 7 Solution 1) + cc-mem `incident_2026_05_27_spawn_cli_security.md` § 6 — the ephemeral home + spawn architecture this ADR layers on top.
|
||||||
|
|
||||||
|
- **Phase 7 PI231 spike** (`docs/spikes/2026-05-29-ephemeral-home.md`) — established that claude v2.1.152 honours `HOME` env override and writes session-derived state under `$HOME/.claude/projects/<cwd-encoded>/`. This ADR's § 3.4 fix builds on that observation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Open questions (spike-required before implementation)
|
||||||
|
|
||||||
|
These must be resolved by a PI231 (or comparable) spike before ADR 0015 transitions from Draft to Accepted. Each is a blocking issue.
|
||||||
|
|
||||||
|
1. **`--no-session-persistence` and `stream-json` interaction**. claude CLI `--help` documents that `--no-session-persistence` "only works with --print", and separately documents that `--output-format stream-json` works "only with --print". The Phase 6c spike empirically discovered that stream-json works without `--print` on v2.1.104-v2.1.154 despite the help text. The open question for Session-NAT: does *removing* `--no-session-persistence` work in the no-`--print` stream-json mode? Spike: spawn claude with stream-json + `--session-id <uuid>` and no `--print`, verify session state is written to disk and a second spawn with same session-id resumes correctly.
|
||||||
|
|
||||||
|
2. **cwd-encoding stability across claude versions**. claude encodes cwd into the session file path by replacing `/` with `-` (observed empirically on v2.1.152, Phase 7 spike). Is this stable across the v2.1.100-v2.1.154 window? Future versions? Session-NAT's symlink layout in § 3.4 depends on the encoding being predictable. Spike: test cwd-encoding on at least 3 claude versions; document the formula; fall back to approach (c) two-symlink design if the encoding ever changes.
|
||||||
|
|
||||||
|
3. **Session file format compatibility across versions**. claude CLI may evolve `conversation.jsonl` format. If a session created on v2.1.154 is resumed on v2.1.158 with a different format, does claude reject it gracefully or corrupt? Spike: cross-version resume test; if any version rejects, add a session-rotation-on-claude-version-change policy.
|
||||||
|
|
||||||
|
4. **Cost prediction validation**. § 6.1 predicts -20% to +50% per-request cost vs Phase 7 baseline. Spike: measure 50 sequential requests in a single OLP key with Session-NAT vs Phase 7 baseline; report actual cost delta. If cost is > +30%, re-evaluate the architecture (perhaps lower turn cap, or accept it).
|
||||||
|
|
||||||
|
5. **Concurrent same-key pool exhaustion behaviour**. § 3.5 specifies pool slots with `in_use=true` flag and queueing. The current Phase 7 cleanup path runs on spawn exit. Verify the slot-release timing under (a) happy path, (b) spawn error path, (c) HTTP client disconnect mid-stream. Spike: spawn pool exhaustion under load test; verify no permanent slot leaks.
|
||||||
|
|
||||||
|
6. **OAuth-side observability for Session-NAT validation**. Once Session-NAT is shipped, how do we verify Anthropic is in fact treating OLP traffic as IDE-pattern? Options: (a) Anthropic dashboard quota probe (ADR 0008 Amendment 2 quota_v2) — does the `representative_claim` or `status_5h` field change semantically? (b) Per-OAuth bill at end of month — does it land in subscription pool or Agent SDK Credit pool? Spike: define a measurable success criterion **before** shipping, not after.
|
||||||
|
|
||||||
|
7. **Operator UX for forced-rotate**. § 3.2 trigger 4 ("Operator-forced") needs a CLI surface. Probably `olp keys rotate-session --key <id>` (or all-keys variant). Out-of-band from main spike but should be specified before implementation begins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- **Anthropic-side fingerprint approaches power-user-IDE pattern** for the dominant `session_id` signal. Reduces probability of OLP traffic being unilaterally reclassified into the Agent SDK Credit pool on 2026-06-15.
|
||||||
|
- **Prompt-cache hit-rate improvement** for repeat-prefix queries within a session. Net cost direction depends on usage pattern but expected to be at worst neutral, possibly positive (§ 6.1).
|
||||||
|
- **Conversation continuity as an emergent feature for OLP clients** — each OLP key now has a claude-side memory across requests. Clients that benefit (e.g., a single user reusing one key for related questions) get an improved UX. Clients that don't (stateless API clients) are unaffected since they always send full conversation history anyway.
|
||||||
|
- **Architectural cleanness via the NAT framing** — the conntrack-table-style stable mapping is well-understood, debug-friendly, and analytics-clean (session-NAT log queries directly map to per-OLP-key billing).
|
||||||
|
- **Composes with Phase 7 ephemeral home** (additive symlink only; no changes to Phase 7 logic).
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- **ADR 0001 amendment required** to redraw the state boundary. The amendment is defensible (routing state vs content state) but is constitutional surgery that future readers will scrutinize.
|
||||||
|
- **ADR 0009 amendment required** to drop `--no-session-persistence`. Reversal of a Phase 6c decision that shipped only weeks earlier. If the spike (§ 10 open question 1) reveals that flag is required for stream-json mode, ADR 0015 is rescoped to use `--print` mode at the cost of regressing Phase 6c's NDJSON observability.
|
||||||
|
- **Bounded but real complexity increase**: session pool, lifecycle, rotation, cwd-fix layering, ADR-0014 § requiredHomePaths extension. Phase 7 added a lot of orchestration; Phase 8 Session-NAT adds more.
|
||||||
|
- **Cost direction not guaranteed positive** (§ 6.1). Worst-case scenario: heavy users see +50% per-request cost vs Phase 7 baseline due to conversation-history growth.
|
||||||
|
- **Anthropic can still classify out** of "power user" pattern via other signals (per-OAuth request rate, system-prompt customization, HTTP headers). Session-NAT is *one* layer; full IDE-user pattern matching requires several.
|
||||||
|
- **Bridge value still uncertain** per cc-mem § 9: Anthropic can update classification rules without a technical change. Session-NAT extends the bridge but does not make it durable.
|
||||||
|
|
||||||
|
### Reversibility
|
||||||
|
|
||||||
|
- **Fully reversible** by removing `--session-id` from the spawn args (revert ADR 0009 amendment) and deleting the `~/.olp/sessions/` directory. The OLP code refactor to remove the session-pool layer is ~300 LOC delete.
|
||||||
|
- **No state migration** required for revert: if Session-NAT is in production and the maintainer reverts, the next request goes back to ephemeral session_id; in-flight conversation continuity is lost but no other state corrupts.
|
||||||
|
- **Per-OLP-key opt-out** possible at implementation time: `~/.olp/keys/<key-id>/manifest.json` could gain a `session_nat: false` field that disables Session-NAT for that key only. Useful for testing / debug / rollback per-tenant. Out of ADR 0015 scope but trivially additive.
|
||||||
|
|
||||||
|
### Mission boundary check
|
||||||
|
|
||||||
|
ADR 0001 § Non-mission excludes (1) commercial multi-tenant SaaS, (2) generic enterprise gateway, (3) model-capability router, (4) conversation-state store. Item (4) is the one this ADR brushes against; the proposed § 4 amendment redraws the line precisely. Items 1-3 are unaffected.
|
||||||
|
|
||||||
|
The "personal- and family-scale" mission frame is preserved: Session-NAT is a quality-of-service feature that family deployments benefit from (per-key continuity + cost stability + billing-classification softening). Commercial multi-tenant trust isolation is not introduced; the `recommendedDeploymentTier` advisory (ADR 0014 Amendment 1 § A1.3) is unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Status transitions
|
||||||
|
|
||||||
|
- **2026-05-29** — Drafted. Status: Draft (Phase 8 candidate). Spike work in § 10 required before transition to Proposed.
|
||||||
|
- **(TBD)** — Spike complete, open questions resolved → Proposed. Reviewer assigned per Iron Rule 10.
|
||||||
|
- **(TBD)** — Reviewer APPROVE, ADR 0001 amendment + ADR 0009 Amendment 2 + ADR 0002 Amendment 10 drafted as co-merge → Accepted.
|
||||||
|
- **(TBD)** — Implementation begins (estimated 2-3 weeks based on Phase 7 baseline).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Authors:** project maintainer (with AI drafting assistance, 2026-05-29). This ADR is a forward-looking design proposal triggered by user-side architectural insight (NAT-style framing) and the upcoming Anthropic billing-pool split (2026-06-15). It is **not** ratified by any spike-validated empirical work; § 10 open questions block transition to Proposed.
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
# TUI-mode — Deployment-A Implementation Plan (PR-0 … PR-3)
|
|
||||||
|
|
||||||
- **Date:** 2026-05-30
|
|
||||||
- **Status:** Implementation plan (pre-code). Derived verbatim from the final design spec
|
|
||||||
`docs/superpowers/specs/2026-05-30-tui-mode-production-design.md` (3 review passes + spikes S1/S2/S3 + pre-code gates T1/T3/T6). **Decisions in the spec are NOT re-litigated here.**
|
|
||||||
- **Scope:** **Deployment A only** (single-user / OCP canary). Deployment B (multi-tenant) is DEFERRED behind spikes **T2** (body-capture `tools:[]`) + **T4** (concurrency). B's gating hooks (`--tools ""`, `--strict-mcp-config`, `--disallowedTools "mcp__*"`, per-spawn MCP-disable verification) are **wired in PR-2 but B is not enabled** — no per-key guest path ships in this plan.
|
|
||||||
- **Authority of record (to be created in PR-3):** ADR 0016 (or ADR 0009 Amendment 2) — see PR-3.
|
|
||||||
- **Iron Rules in force:** 10 (independent reviewer), 11 (minimum reviewable unit — one PR per layer), 12 (prior-art search done = the spikes). `ALIGNMENT.md` Rule 1 (cite authority) + Rule 2 (no inventing CLI behavior) + Rule 5 (release-kit).
|
|
||||||
- **Author credit (binding, §13):** every implementing commit carries `Co-Authored-By: jaekwon-park <…>` (pull the real email/handle from OCP PR #101 before committing — do NOT invent). ADR 0016 names PR #101 + jaekwon-park in its acknowledgment section. Add jaekwon-park to CONTRIBUTORS and notify on PR #101 at ship time.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Ground-truth code anchors (verified against the real tree)
|
|
||||||
|
|
||||||
Everything below cites the exact function/line the change hooks into. Re-verify line numbers at edit time (the files churn).
|
|
||||||
|
|
||||||
| Surface | Location (verified) | Role in TUI-mode |
|
|
||||||
|---|---|---|
|
|
||||||
| `spawn(irRequest, authContext, isolationCtx)` (public contract) | `lib/providers/anthropic.mjs:1164` → delegates to `_spawnAndStream` | **PR-3** branches here on `CLAUDE_TUI_MODE`. Default falls through to `_spawnAndStream` (stream-json) UNCHANGED. |
|
|
||||||
| `_spawnAndStream(irRequest, authContext, spawnImpl, isolationCtx)` | `anthropic.mjs:872` | The default transport. **Not modified** by TUI-mode (PR-3 adds a sibling branch in the public `spawn`, it does not touch `_spawnAndStream`). |
|
|
||||||
| `buildCliArgs(model, systemPrompt)` | `anthropic.mjs:834` (returns `--model … --output-format stream-json --verbose --no-session-persistence --system-prompt …`) | TUI driver builds its **own** argv (no `-p`, no `--output-format`); it does NOT reuse `buildCliArgs`. Cited as the contrast surface. |
|
|
||||||
| `extractSystemPrompt(irRequest)` | `anthropic.mjs:123` (always prefixes `OLP_SYSTEM_PROMPT_WRAPPER` `:109`) | **REUSED unchanged** by the TUI driver to compute the `--system-prompt` value. |
|
|
||||||
| `irToAnthropic(irRequest)` | `anthropic.mjs:601` (serializes user/assistant/tool; skips `system`) | **REUSED unchanged** — produces the prompt body text the TUI driver writes to the prompt file (§6 recipe). |
|
|
||||||
| `ISOLATION` named export | `anthropic.mjs:1667` (`ephemeralEnvOverrides`→`{HOME}`, `credentialMounts`, `requiredHomePaths:['.claude']`, `hasInnerSandbox:false`) | **PR-0** EXTENDS with a TUI-only seed hook. |
|
|
||||||
| `prepareIsolatedEnvironment({provider,keyId,reqId})` | `lib/sandbox/manager.mjs:203` → returns `{ephemeralRoot, envOverrides, hardenedArgs, wrapForLayer3, cleanup}` | **PR-0** consumes the new seed step; **PR-2** driver calls it to get `ephemeralRoot`. Note the **test bypass at `:223`** (returns `_legacyShape()` under `test-features.mjs` unless `globalThis.__OLP_FORCE_ISOLATION_IN_TEST`). |
|
|
||||||
| Buffered spawn call site | `server.mjs:1347` (`prepareIsolatedEnvironment`) → `:1355` (`for await … hopProviderPlugin.spawn(...)`) inside `collectAllChunks()` (`:1299`); result cached via `cacheStore.getOrCompute(keyId, hopCacheKey, collectAllChunks)` at `:1445` | **computeFn returns an ARRAY of IR chunks.** TUI transport must yield `[{type:'delta',role:'assistant',content},{type:'stop',finish_reason:'stop'}]` so this path is unchanged. |
|
|
||||||
| Streaming spawn call site | `server.mjs:1564` (`prepareIsolatedEnvironment`) → `:1570` (`for await … streamPlugin.spawn(...)`) inside `sourceWithRelease()`; coordinated via `cacheStore.getOrComputeStreaming(keyId, streamCacheKey, sourceFactory, …)` at `:1587` | **sourceFactory returns an ASYNC GENERATOR of IR chunks.** TUI transport yields the same 2-chunk shape → SSE replay (`irChunkToOpenAISSE` at `server.mjs:1764`) is byte-identical to the stream-json path. This is the §3.1 single-buffered-then-replay mechanism. |
|
|
||||||
| `irChunkToOpenAISSE`, `SSE_DONE` | imported `server.mjs:38`; used `:1764`, `:1772` | **REUSED unchanged** for `stream:true` replay. |
|
|
||||||
| `max_tokens` parse | `lib/ir/openai-to-ir.mjs:182` (sets `ir.max_tokens`) | Accepted into IR, **dropped at CLI boundary** (§4.5). Same for `temperature` `:190`, `top_p` `:198`, `stop` `:206` (§4.6). |
|
|
||||||
| `validateKey` / `owner_tier` / `providers_enabled` | `lib/keys.mjs:414`; tiers `'owner'|'guest'|'anonymous'` (`:428`,`:463`) | **REUSED unchanged.** A's canary runs owner-tier. B's guest gating is wired but inert. |
|
|
||||||
|
|
||||||
**Cache contract crux (load-bearing for PR-1).** `server.mjs` does NOT expect a string from the transport. It expects **IR chunks** — an array (buffered, `getOrCompute`) or an async generator (streaming, `getOrComputeStreaming`). The TUI transcript reader (PR-1) resolves a **single string**; the TUI driver/provider-branch (PR-2/PR-3) is responsible for the thin adapter `string → [delta, stop]` so both existing cache paths consume it with **zero modification**. This is the concrete meaning of spec §3.2 "returns a resolved response string adapted to the getOrCompute/singleflight cache contract."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cross-cutting contracts (define these FIRST; every PR conforms)
|
|
||||||
|
|
||||||
### C1. Transport interface (so node-pty can slot later — spec §8)
|
|
||||||
|
|
||||||
A single interface in `lib/tui/session.mjs`; tmux is the only implementation in this plan; node-pty is a stubbed adapter behind the same interface.
|
|
||||||
|
|
||||||
```
|
|
||||||
interface TuiTransport {
|
|
||||||
// create the session bound to ephemeralRoot, spawn `claude` interactive, settle to input box
|
|
||||||
open({ bin, args, env, cwd, ephemeralRoot, reqId }): Promise<SessionHandle>
|
|
||||||
// submit one prompt body (T3 recipe: file → send-keys -- "$(cat f)" → separate Enter)
|
|
||||||
submit(handle, promptText): Promise<void>
|
|
||||||
// teardown: kill session + nothing else (ephemeral root rm is the manager.cleanup's job, but
|
|
||||||
// the driver MUST also kill the session in a trap/finally — §8)
|
|
||||||
close(handle): Promise<void>
|
|
||||||
// startup-time orphan reaper (kill restart-surviving sessions) — §5.5
|
|
||||||
reapOrphans(): Promise<{ killed: string[] }>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`tmuxTransport` implements all four. `nodePtyTransport` is a stub that throws `NOT_IMPLEMENTED` (present so the interface boundary is real and reviewable). The transcript reader (C2) and IR mapping never import the transport — they only consume the deterministic transcript path, so swapping transports later touches nothing else.
|
|
||||||
|
|
||||||
### C2. Transcript-reader interface (PR-1 owns it; transport-agnostic)
|
|
||||||
|
|
||||||
```
|
|
||||||
computeTranscriptPath({ ephemeralRoot, cwd, sessionId }): string // §4.1 formula, pure
|
|
||||||
readTurnResult({ transcriptPath, sinceUserContent, wallClockCapMs, pollMs }):
|
|
||||||
Promise<{ text: string, durationMs?: number, messageCount?: number }> // resolves the assistant text
|
|
||||||
// throws TuiCompletionError on guard-(B) terminal conditions (tool_use / wall-clock cap) — §4.4
|
|
||||||
```
|
|
||||||
|
|
||||||
`readTurnResult` is the **dual-signal** completion engine. It never imports tmux/node-pty. It is unit-tested entirely against captured JSONL fixtures.
|
|
||||||
|
|
||||||
### C3. `CLAUDE_TUI_MODE` flag semantics (binding)
|
|
||||||
|
|
||||||
- **Unset / not `"1"`** → default path. **Byte-for-byte unchanged** from today: `_spawnAndStream` (stream-json), `ISOLATION` with NO seed, no `.claude.json` written, no new on-disk sensitive data. This is a **hard requirement** (spec §7.1) and is the regression invariant (C4).
|
|
||||||
- **`CLAUDE_TUI_MODE=1`** → TUI transport: ephemeral home seeded (PR-0), tmux interactive `claude` (PR-2), transcript-read completion (PR-1), provider branch (PR-3).
|
|
||||||
- The flag is read **once** in the provider `spawn()` branch (PR-3) — `process.env.CLAUDE_TUI_MODE === '1'`. It is the ONLY toggle. No config-file alternative in this plan.
|
|
||||||
- Sub-flags (A-only, all default-off, all gated under `CLAUDE_TUI_MODE=1`): `CLAUDE_TUI_WARM_POOL` (§7.2 — **out of scope for this plan; not implemented, only namespace-reserved**).
|
|
||||||
|
|
||||||
### C4. Default-path-unchanged invariant + how to test it
|
|
||||||
|
|
||||||
- **Invariant:** with `CLAUDE_TUI_MODE` unset, no code path added by PR-0..PR-3 executes. `ISOLATION` returns the same shape, `_spawnAndStream` is the only transport, no `.claude.json` is seeded.
|
|
||||||
- **Test (regression guard, runs in every PR):** the full existing `test-features.mjs` suite stays green. Additionally PR-0 adds an explicit assertion: `prepareIsolatedEnvironment` for the anthropic provider with `CLAUDE_TUI_MODE` unset produces an ephemeral root containing **no** `.claude.json` (only the symlinked `.credentials.json` + `.claude/` dir, as today). PR-3 adds: `spawn()` with the flag unset calls `_spawnAndStream` (assert via the existing `__setSpawnImpl` seam — the mock spawn is invoked, the TUI driver is NOT).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PR-0 — ISOLATION extend (TUI-only `.claude.json` seed)
|
|
||||||
|
|
||||||
### 1. Goal
|
|
||||||
Seed a minimal `.claude.json` (onboarding/trust/bypass markers ONLY) into the ephemeral home **only when `CLAUDE_TUI_MODE` is active**, so a fresh-home interactive `claude` drops straight to the input box instead of hanging on first-run onboarding — while the default stream-json path's bootstrap stays byte-for-byte unchanged.
|
|
||||||
|
|
||||||
### 2. Files touched
|
|
||||||
- `lib/providers/anthropic.mjs` — extend the `ISOLATION` block (`:1667`).
|
|
||||||
- `lib/sandbox/manager.mjs` — add the opt-in seed step to `prepareIsolatedEnvironment` (`:203`), gated so it is a no-op unless the caller requests it.
|
|
||||||
- `test-features.mjs` — new suite (seed-on / seed-off / permissions).
|
|
||||||
- *(no new file in PR-0)*
|
|
||||||
|
|
||||||
### 3. Concrete changes
|
|
||||||
|
|
||||||
**3a. `ISOLATION` gains a seed descriptor (NOT a function that reads the real home unconditionally).** Add to the anthropic `ISOLATION` object an OPTIONAL field describing the TUI seed, e.g.:
|
|
||||||
|
|
||||||
```
|
|
||||||
// anthropic.mjs ISOLATION (extend, after requiredHomePaths)
|
|
||||||
tuiSeed: { // consumed ONLY when prepareIsolatedEnvironment is called with { tui:true }
|
|
||||||
relPath: '.claude.json', // written under ephemeralRoot
|
|
||||||
mode: 0o600, // §5.5 — same care as the bearer
|
|
||||||
// builder is pure-ish: it reads the real ~/.claude.json ONCE to copy oauthAccount/userID,
|
|
||||||
// strips `projects`, and stamps onboarding/trust/bypass markers + a pre-trusted cwd.
|
|
||||||
build: ({ cwd }) => ({ /* hasCompletedOnboarding:true, oauthAccount, userID,
|
|
||||||
bypassPermissionsModeAccepted:true,
|
|
||||||
projects: { [cwd]: { hasTrustDialogAccepted:true, … } } */ }),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Authority/contract note:** ADR 0002 Amendment 9's `credentialMounts` is deliberately a static list (not a function) for auditability; the seed is a NEW optional field, so PR-0 must add a one-paragraph Amendment-9 note (in ADR 0002, co-merged or referenced) stating the seed reads the real `~/.claude.json` exactly once to copy `oauthAccount`/`userID`, writes mode-600, and carries **no MCP-disable weight** (T6 negative control, spec §5.2 / §7.1). The seed is onboarding/trust/bypass ONLY.
|
|
||||||
- **The seed does NOT disable managed MCP** (T6 negative control). PR-0 must NOT add `claudeAiMcpEverConnected` manipulation or any MCP field. A code comment cites spec §5.2 + T6.
|
|
||||||
|
|
||||||
**3b. `prepareIsolatedEnvironment` gains a `tui` opt-in param.** Change the signature to `prepareIsolatedEnvironment({ provider, keyId, reqId, tui = false })` (`manager.mjs:203`). After the existing Layer-2 symlink loop (`:318`), add a guarded block:
|
|
||||||
|
|
||||||
```
|
|
||||||
if (tui && isolation?.tuiSeed) {
|
|
||||||
// chmod 700 the ephemeralRoot (§5.5), write isolation.tuiSeed.build({cwd}) JSON
|
|
||||||
// at join(ephemeralRoot, tuiSeed.relPath) with { mode: tuiSeed.mode }, never log contents.
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Default path is untouched:** existing call sites at `server.mjs:1347` and `:1564` pass NO `tui` flag → `tui=false` → seed block is skipped → identity behavior. This satisfies C4. The TUI driver (PR-2) is the ONLY caller that passes `tui:true`.
|
|
||||||
- **`chmod 700` the ephemeral root** (§5.5) is applied **inside the `tui` block** so the default path's permission semantics are also unchanged. (The default path created the root via `mkdirSync` at `:250`; PR-0 does not alter that.)
|
|
||||||
- **Per-`keyId` isolation** is already structurally given by the `/tmp/olp-spawn/<safeKeyId>/<safeReqId>/home` path (`manager.mjs:247`). PR-0 adds an assertion/comment that the parent `<safeKeyId>` dir is not world-traversable (chmod 700 on the chain) — §5.5.
|
|
||||||
- **Test bypass interaction (`manager.mjs:223`):** the existing test-runner bypass returns `_legacyShape()`. PR-0's seed tests MUST set `globalThis.__OLP_FORCE_ISOLATION_IN_TEST = true` to exercise the real path, then unset it in `finally` (this seam already exists).
|
|
||||||
|
|
||||||
### 4. Unit tests + fixtures (`test-features.mjs`)
|
|
||||||
- [ ] **seed-off (default-path invariant, C4):** call `prepareIsolatedEnvironment({provider:anthropic, keyId, reqId})` (no `tui`) under `__OLP_FORCE_ISOLATION_IN_TEST` → assert ephemeralRoot has `.claude/.credentials.json` symlink + `.claude/` dir and **NO `.claude.json`**.
|
|
||||||
- [ ] **seed-on:** call with `{ tui:true }` → assert `.claude.json` exists, is mode `600`, parses as JSON, contains `hasCompletedOnboarding:true` + `bypassPermissionsModeAccepted:true` + a pre-trusted `projects[cwd]`, and contains **NO** `mcpServers`/`claudeAiMcpEverConnected` field (negative assertion — T6).
|
|
||||||
- [ ] **root permissions:** assert ephemeralRoot is mode `700` on the `tui:true` path.
|
|
||||||
- [ ] **no-real-home-mutation:** assert the real `~/.claude.json` is not written/modified (read-only copy).
|
|
||||||
- [ ] Fixture: a minimal fake `~/.claude.json` (via a temp HOME or an injected reader seam) carrying a dummy `oauthAccount`/`userID` so the test never touches the operator's real account file.
|
|
||||||
- [ ] Full existing suite stays green (regression).
|
|
||||||
|
|
||||||
### 5. PI231 integration checkpoint (maintainer-supervised; /tmp scratch only)
|
|
||||||
Run on PI231 scratch (prod OLP on :4567 untouched):
|
|
||||||
- [ ] Drive `prepareIsolatedEnvironment({tui:true})` against a scratch keyId/reqId; `ls -la` the ephemeral root.
|
|
||||||
- [ ] **Pass criteria:** `.claude.json` present, mode `600`; root mode `700`; symlinked `.credentials.json` present; `cat` the seed shows onboarding/trust/bypass markers and **no MCP fields**; the real `~/.claude.json` mtime unchanged.
|
|
||||||
- [ ] Launch interactive `claude` by hand bound to that ephemeral HOME and confirm it **does not** hang on onboarding (drops to input box). (This is the load-bearing reason PR-0 exists.)
|
|
||||||
|
|
||||||
### 6. Acceptance criteria (binding, testable)
|
|
||||||
- With `tui` unset, ephemeral home is byte-identical to today (no `.claude.json`). ✔ regression test + PI231.
|
|
||||||
- With `tui:true`, seed is written mode-600, root mode-700, onboarding/trust/bypass present, MCP fields absent.
|
|
||||||
- No change to default stream-json spawn behavior; full suite green.
|
|
||||||
|
|
||||||
### 7. Reviewer (Iron Rule 10)
|
|
||||||
Fresh-context reviewer opens **spec §7.1 + §5.2 (T6 negative control) + ADR 0002 Amendment 9** and confirms: (a) the seed is gated on the opt-in `tui` param so the default path is unchanged; (b) the seed carries NO MCP-disable field (T6); (c) mode-600 seed + mode-700 root + per-keyId isolation per §5.5; (d) the Amendment-9 note documenting the new `tuiSeed` field is present. A review that does not name the §5.2 negative control is not a valid approval.
|
|
||||||
|
|
||||||
### 8. Authority citation (commit + PR body)
|
|
||||||
`claude` CLI v2.1.158 § first-run onboarding (theme/login pickers) + `$HOME`-redirect behavior (ADR 0002 Amendment 9 anthropic ISOLATION pin); ADR 0002 Amendment 9 (ISOLATION contract); spec §7.1 + §5.2; PI231 ephemeral-home spike `docs/spikes/2026-05-29-ephemeral-home.md`. State explicitly: **the seed does NOT disable managed MCP — that is the spawn-argv flag in PR-2 (T6).**
|
|
||||||
|
|
||||||
### Risk / rollback
|
|
||||||
Independently revertable (revert reinstates the pre-seed ISOLATION; default path was never touched). Default-off: nothing reaches users — the seed only fires when a caller passes `tui:true`, and no caller does until PR-2/PR-3.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PR-1 — Transcript reader (`lib/tui/transcript.mjs`)
|
|
||||||
|
|
||||||
### 1. Goal
|
|
||||||
A transport-agnostic reader that computes the deterministic transcript path, polls for lazy file creation, detects turn completion via the **mandatory dual-signal guard** (turn_duration OR tool_use OR wall-clock cap; NO quiescence in v1), extracts the assistant text, and resolves a single response string.
|
|
||||||
|
|
||||||
### 2. Files touched
|
|
||||||
- **NEW** `lib/tui/transcript.mjs`.
|
|
||||||
- `test-features.mjs` — new transcript-reader suite.
|
|
||||||
- Fixtures dir (NEW) `docs/spikes/fixtures/tui/` — captured real JSONL (see §4).
|
|
||||||
|
|
||||||
### 3. Concrete changes (exports + signatures)
|
|
||||||
|
|
||||||
- `export function computeTranscriptPath({ ephemeralRoot, cwd, sessionId })` — **pure.** Implements §4.1: `<ephemeralRoot>/.claude/projects/<CWD_ENCODED>/<sessionId>.jsonl` where `CWD_ENCODED` = `cwd` with **every** `/` → `-` **including the leading slash** (`/tmp/x` → `-tmp-x`). No filesystem access. (OLP generates `sessionId` and `cwd`, so the path is known before spawn.)
|
|
||||||
- `export async function readTurnResult({ transcriptPath, sinceUserContent, wallClockCapMs = 120_000, pollMs = 500, toolUseIsTerminal = true })`:
|
|
||||||
- **Lazy-create poll:** the file is created on first message, not at spawn (§4.1). Tolerate ENOENT; poll every `pollMs` until the file exists or `wallClockCapMs` elapses (then throw `TuiCompletionError('completion-marker timeout')`).
|
|
||||||
- **Dual-signal completion (§4.4, MANDATORY):**
|
|
||||||
- **(A) happy path:** a line `{"type":"system","subtype":"turn_duration"}` for this turn appears → done. Carries `durationMs` + `messageCount`. Do NOT rely on file-tail byte ordering (§4.3 trap): re-scan the file, find the matching `user` line for `sinceUserContent`, collect all subsequent `assistant`/`text` blocks.
|
|
||||||
- **(B) co-equal terminal guard (mandatory, never-hang):** if the last assistant message carries `stop_reason:"tool_use"` → throw `TuiCompletionError('tool-use turn unsupported in TUI-mode')` (maps to clean 502). If `wallClockCapMs` fires → throw `TuiCompletionError('completion-marker timeout')`.
|
|
||||||
- **NO quiescence cut in v1** (§4.4 ⚠️): do NOT abort on "file size-stable for N seconds" — a long Opus/extended-thinking turn legitimately produces no growth. Quiescence is added only after spike T5. (Comment cites §4.4 explicitly so a future contributor does not "helpfully" add it.)
|
|
||||||
- Do NOT key off `stop_reason:"end_turn"` alone (§4.3 trap — appears on both `thinking` and `text` blocks).
|
|
||||||
- **Assistant-text extraction (§4.2):** `JSON.parse` per line (native log → escaping-clean). Response = concatenation of `text`-type content blocks from `assistant` messages emitted **since the matching `user` line**. Return `{ text, durationMs, messageCount }`.
|
|
||||||
- **Trailing-newline normalization (§3.2 / §6 caveat):** the input box strips the source's single trailing newline. `sinceUserContent` matching MUST normalize the trailing newline before comparing source-prompt vs the transcript `user` line, or the "matching user line" lookup (and any cache-key reasoning) sees a spurious mismatch.
|
|
||||||
- **Cache-contract adapter note (does NOT live in PR-1, but PR-1's return shape is designed for it):** `readTurnResult` resolves a string; the PR-2/PR-3 layer wraps it as `[{type:'delta',role:'assistant',content:text},{type:'stop',finish_reason:'stop'}]`. PR-1's JSDoc states this adapter contract and points at `server.mjs:1299` (buffered array) + `server.mjs:1558` (streaming generator) so the reviewer sees the two consumers. **max_tokens/sampling graceful-drop boundary** (§4.5/§4.6): PR-1 documents that these IR fields never reach this layer (interactive `claude` has no flag); nothing to do — they are dropped at the CLI-args boundary in PR-2/PR-3. PR-1 adds a comment asserting `finish_reason` is always `'stop'` (no `length` mapping, since max_tokens is not enforced).
|
|
||||||
|
|
||||||
### 4. Unit tests + fixtures
|
|
||||||
**Fixtures (capture REAL JSONL on PI231 — do not hand-fabricate the shapes):**
|
|
||||||
- [ ] `text-turn.jsonl` — a normal `end_turn` text answer ending in a `turn_duration` line.
|
|
||||||
- [ ] `refusal-turn.jsonl` — a refusal that still emits `turn_duration` (T1: `durationMs≈3221`).
|
|
||||||
- [ ] `tool-use-no-marker.jsonl` — **MANDATORY** (T1): a `tool_use` turn whose last assistant line is `stop_reason:"tool_use"` with **NO** `turn_duration` line. This is the hang case guard (B) must catch.
|
|
||||||
- [ ] `out-of-order.jsonl` — a text block flushed by byte-position AFTER `turn_duration` though `turn_duration` has the later timestamp (§4.3 trap) — proves the reader does not rely on file-tail ordering.
|
|
||||||
- [ ] `multiturn.jsonl` — two user lines so `sinceUserContent` selection is exercised (a `toolUseResult:true` user line within a turn must NOT be mistaken for a new submit — §6 step 5).
|
|
||||||
|
|
||||||
**Tests:**
|
|
||||||
- [ ] `computeTranscriptPath` exact-string equality incl. leading-slash encoding.
|
|
||||||
- [ ] happy path returns concatenated text + `durationMs`/`messageCount`.
|
|
||||||
- [ ] refusal path returns refusal text (still completes).
|
|
||||||
- [ ] **tool-use fixture → throws `TuiCompletionError` (never hangs)** — assert with a short `wallClockCapMs` that the throw is the tool_use detection, not the timeout (distinguish the two error messages).
|
|
||||||
- [ ] wall-clock cap fires on a never-completing fixture (truncated file with no marker) → throws within cap.
|
|
||||||
- [ ] out-of-order fixture → correct text (no reliance on last byte).
|
|
||||||
- [ ] trailing-newline normalization: `sinceUserContent` with trailing `\n` still matches the transcript user line.
|
|
||||||
- [ ] **no-quiescence assertion:** a fixture that is size-stable for > pollMs but has not completed does NOT abort before the wall-clock cap (proves quiescence is excluded).
|
|
||||||
- [ ] Full existing suite stays green (PR-1 adds a new module + new tests only; touches no existing path).
|
|
||||||
|
|
||||||
### 5. PI231 integration checkpoint (maintainer-supervised)
|
|
||||||
- [ ] Capture the 5 fixtures above from real `claude` v2.1.158 runs on PI231 scratch (this is also how the fixtures are sourced). Commit them under `docs/spikes/fixtures/tui/`.
|
|
||||||
- [ ] **Pass criteria:** `readTurnResult` against each freshly-captured fixture returns the same text a human reads in the transcript; the tool-use capture throws `TuiCompletionError` and never blocks; cap fires deterministically on a manually-truncated fixture.
|
|
||||||
|
|
||||||
### 6. Acceptance criteria (binding)
|
|
||||||
- Deterministic path matches §4.1 exactly.
|
|
||||||
- Dual-signal completion: completes on `turn_duration`; **never hangs** on tool-use or a missing marker (guard B); **no quiescence cut**.
|
|
||||||
- Escaping-clean text extraction; trailing-newline normalized.
|
|
||||||
- Pure reader: zero tmux/node-pty import; fully fixture-testable.
|
|
||||||
|
|
||||||
### 7. Reviewer (Iron Rule 10)
|
|
||||||
Fresh-context reviewer opens **spec §4.1–§4.4 (and §3.2 cache-contract / trailing-newline)** and confirms: (a) the path formula incl. leading-slash; (b) the dual-signal guard is present AND quiescence is explicitly excluded with a §4.4 citation; (c) the tool-use-no-marker fixture exists and the test proves a non-hanging terminal throw; (d) the resolved-string return is documented against the `getOrCompute`/`getOrComputeStreaming` consumers. A review missing the tool-use-no-marker check is not valid.
|
|
||||||
|
|
||||||
### 8. Authority citation
|
|
||||||
`claude` CLI v2.1.158 § native session transcript JSONL (`turn_duration` is an undocumented internal-log behavior — pin to v2.1.158, re-verify per CLI/Ink bump); spec §4 (S2 PASS) + §4.4 (T1 PARTIAL); fixtures captured PI231 2026-05-30. No OpenAI-spec surface (reader is internal). ALIGNMENT Rule 2: the reader consumes a behavior `claude` actually emits — no invented format.
|
|
||||||
|
|
||||||
### Risk / rollback
|
|
||||||
New file + new tests only; revert deletes the module and tests, default path untouched. Riskiest sub-step is the dual-signal guard's tool-use detection (the hang vector) — fully covered by the mandatory fixture.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PR-2 — Session driver (`lib/tui/session.mjs`)
|
|
||||||
|
|
||||||
### 1. Goal
|
|
||||||
A tmux-backed interactive-`claude` driver behind the transport interface (C1): spawn with the T6 flag set, submit via the T3 recipe, auto-answer dialogs, run the per-spawn MCP-disable verification gate, guarantee teardown via trap/finally, and reap orphan sessions on startup — producing a single buffered response (via PR-1's reader) adapted to IR chunks for both cache paths.
|
|
||||||
|
|
||||||
### 2. Files touched
|
|
||||||
- **NEW** `lib/tui/session.mjs` (tmux transport + node-pty stub + the driver `runTuiTurn`).
|
|
||||||
- `lib/sandbox/manager.mjs` — driver calls `prepareIsolatedEnvironment({…, tui:true})` (the param added in PR-0).
|
|
||||||
- `test-features.mjs` — driver suite (with a mock transport — no real tmux/claude in unit tests).
|
|
||||||
- *(server wiring is PR-3, NOT here)*
|
|
||||||
|
|
||||||
### 3. Concrete changes
|
|
||||||
|
|
||||||
**3a. Transport interface + tmux implementation (C1).**
|
|
||||||
- `export const tmuxTransport` implementing `open/submit/close/reapOrphans`.
|
|
||||||
- `export const nodePtyTransport` — stub throwing `NOT_IMPLEMENTED` (interface placeholder, §8 decision: tmux first).
|
|
||||||
- Session naming: `olp-tui-<keyId>-<reqId>` so `reapOrphans` can pattern-match.
|
|
||||||
|
|
||||||
**3b. Spawn argv (T6 flag set, §5.2) — the driver builds its OWN args (NOT `buildCliArgs`).**
|
|
||||||
```
|
|
||||||
claude --model <m> --session-id <uuid> --system-prompt "<extractSystemPrompt(ir)>"
|
|
||||||
--strict-mcp-config // T6 load-bearing: 0 managed-MCP (no --mcp-config supplied)
|
|
||||||
--disallowedTools "mcp__*" // deny MCP-namespaced tools
|
|
||||||
[--tools "" ] // B-only built-in lockdown — WIRED, gated off for A (see 3g)
|
|
||||||
// NO -p, NO --output-format → real TTY → cc_entrypoint=cli
|
|
||||||
env: CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1 // defense-in-depth
|
|
||||||
+ carry-forward: CLAUDE_CODE_DISABLE_CLAUDE_MDS=1, unset ANTHROPIC_* (reuse buildSpawnEnv semantics)
|
|
||||||
+ HOME=<ephemeralRoot> (from prepareIsolatedEnvironment envOverrides)
|
|
||||||
```
|
|
||||||
- `--system-prompt` value comes from **`extractSystemPrompt(ir)` (`anthropic.mjs:123`) — REUSED.** Prompt body comes from **`irToAnthropic(ir)` (`anthropic.mjs:601`) — REUSED** (written to the prompt file, 3d).
|
|
||||||
- **`--bare` is forbidden** (§5.2 — breaks OAuth). Comment cites it.
|
|
||||||
- `--model` from `ir.model`; `--session-id` is the OLP-generated UUID also fed to `computeTranscriptPath`.
|
|
||||||
|
|
||||||
**3c. Per-spawn MCP-disable verification gate (§5.2 (4) preflight semantics).** After `open()` settles, assert **0** dirs matching `$HOME/.cache/claude-cli-nodejs/*/mcp-logs-claude-ai-*` under the ephemeral root. **Do NOT run `/mcp` inside the serving session** (§5.2: it writes a transcript line, consumes a turn, corrupts the reader's matching-user-line semantics). For A's canary the cache-dir assertion is the in-band check; the `/mcp`-empty assertion belongs to a **separate preflight session at startup / CLI upgrade** (wire the preflight hook here but it is owner-tier advisory for A; it becomes a hard gate for B). On assertion failure: tear down + clean 502.
|
|
||||||
|
|
||||||
**3d. Submit recipe (T3 PASS — binding for acceptance, §6).**
|
|
||||||
1. Write `irToAnthropic(ir)` to a file under the ephemeral root (NEVER interpolate into a shell line — backticks/`$()`/`&&`/quotes get mangled by the shell, §6 step 1).
|
|
||||||
2. `tmux send-keys -t <S> -- "$(cat promptfile)"` — the leading `--` end-of-options guard is **required** (prompt starting with `-`). Embedded `\n` are soft line-breaks; do NOT submit. Do NOT use `send-keys -l` for the body (§6 step 2).
|
|
||||||
3. Settle ~1.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.
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
# TUI-mode — Production Design Spec
|
|
||||||
|
|
||||||
- **Date:** 2026-05-30
|
|
||||||
- **Status:** Draft (design spec; pre-implementation). Supersedes the "Option 1 / stream-json adapter" lane of ADR 0009 Amendment 1 for the *billing-pool* concern, and proposes a new ADR (0009 Amendment 2 or a fresh ADR 0016) as the authority of record before any code lands.
|
|
||||||
- **Authors:** project maintainer (with AI drafting assistance).
|
|
||||||
- **Builds on community work:** `dtzp555-max/ocp` **PR #101 by jaekwon-park** (tmux + interactive-`claude` prototype). See § "Author credit plan".
|
|
||||||
- **Validated by:** PI231 spikes S1 (billing + no-tool property), S2 (JSONL transcript output), S3 (submission reliability), plus pre-code gate spikes **T1** (completion detection on non-`end_turn` stop reasons — PARTIAL), **T3** (multiline/special-char submission — PASS), **T6** (marketplace + managed-MCP disable — PASS), `claude` v2.1.158, `tmux` 3.3a, model `claude-haiku-4-5-20251001`, arm64 Debian. Spike JSON retained in session record.
|
|
||||||
|
|
||||||
> **Honesty banner.** TUI-mode is a *grey-area bridge*, not a durable architecture. It automates `claude`'s genuinely-interactive mode (`cc_entrypoint=cli`) to serve programmatic proxy requests so traffic bills against the Anthropic subscription pool instead of the post-2026-06-15 Agent SDK credit pool. The interactivity is real (not forged), but it is automated. It is OPT-IN (`CLAUDE_TUI_MODE`). Spike-confirmed facts and the remaining gaps govern everything below: (a) `--system-prompt` keeps `cc_entrypoint=cli` — the TTY path carries the **genuine interactive-use signal**; whether that *bills* to the subscription pool is an **inference pending post-2026-06-15 validation** (S1 proved the entrypoint signal, not the billed pool — the split has not yet taken effect, so no spike can prove the billed pool today); (b) the native JSONL transcript is a clean, escaping-free output channel — **output mechanism is sound** (S2 PASS); (c) `--system-prompt` suppresses tool *text* but does **not structurally strip** account-attached managed MCP servers — **the no-tool property is model restraint, not enforcement** (S1 PARTIAL); (d) the load-bearing structural MCP-disable mechanism is now **found and verified** — `--strict-mcp-config` (with no `--mcp-config`) yields 0 managed-MCP attachment (T6 PASS); (e) `turn_duration` completion detection is **reliable for text/refusal turns but ABSENT on tool-use turns**, which would hang the reader — a co-equal wall-clock/quiescence guard is now mandatory, not optional (T1 PARTIAL); (f) multiline/special-char prompt submission is **byte-for-byte reliable** via `send-keys -- "$(cat file)"` + separate Enter token (T3 PASS). (c)+(e) remain the load-bearing risks; (c) is now mitigable structurally via (d) and gates multi-tenant (Deployment B) rollout together with the still-open body-capture verification (T2) and concurrency (T4).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Context & motivation
|
|
||||||
|
|
||||||
### 1.1 The billing trigger
|
|
||||||
|
|
||||||
Anthropic's 2026-06-15 billing split moves `claude -p`, the Agent SDK, and "third-party apps that authenticate with your Claude subscription through the Agent SDK" into a separate ~$100/month Agent SDK *credit* pool. The subscription pool (Pro/Max) covers "Claude Code in the terminal or your IDE in **interactive mode**." OLP's anthropic provider currently spawns `claude` non-interactively (`--output-format stream-json --verbose --no-session-persistence`, ADR 0009 Amendment 1). Post-split, that path's billing classification is at best uncertain and at worst routes to the credit pool — which exhausts in ~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,15 +1726,4 @@ export const ISOLATION = {
|
|||||||
// 1). No additional CLI flags are needed at the orchestrator level.
|
// 1). No additional CLI flags are needed at the orchestrator level.
|
||||||
// Per ADR 0002 Amendment 9 § 7: absence means the orchestrator passes args
|
// Per ADR 0002 Amendment 9 § 7: absence means the orchestrator passes args
|
||||||
// through unchanged from spawn().
|
// 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).
|
|
||||||
};
|
};
|
||||||
|
|||||||
+5
-107
@@ -47,7 +47,7 @@
|
|||||||
* __resetSandboxManagerForTests() — test seam: reset module state
|
* __resetSandboxManagerForTests() — test seam: reset module state
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, symlinkSync } from 'node:fs';
|
||||||
import { rm } from 'node:fs/promises';
|
import { rm } from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
@@ -189,21 +189,18 @@ export function isSandboxActive() {
|
|||||||
* returns the legacy unsandboxed shape (identity env, identity hooks, no cleanup).
|
* returns the legacy unsandboxed shape (identity env, identity hooks, no cleanup).
|
||||||
*
|
*
|
||||||
* @param {object} params
|
* @param {object} params
|
||||||
* @param {object} params.provider — provider plugin object (may have .ISOLATION)
|
* @param {object} params.provider — provider plugin object (may have .ISOLATION)
|
||||||
* @param {string} params.keyId — OLP key identity driving this request
|
* @param {string} params.keyId — OLP key identity driving this request
|
||||||
* @param {string} params.reqId — per-request UUID
|
* @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<{
|
* @returns {Promise<{
|
||||||
* ephemeralRoot: string|null,
|
* ephemeralRoot: string|null,
|
||||||
* reqId: string,
|
|
||||||
* envOverrides: Record<string, string>,
|
* envOverrides: Record<string, string>,
|
||||||
* hardenedArgs: (args: string[]) => string[],
|
* hardenedArgs: (args: string[]) => string[],
|
||||||
* wrapForLayer3: (command: string) => Promise<string>,
|
* wrapForLayer3: (command: string) => Promise<string>,
|
||||||
* cleanup: () => Promise<void>,
|
* cleanup: () => Promise<void>,
|
||||||
* }>}
|
* }>}
|
||||||
*/
|
*/
|
||||||
export async function prepareIsolatedEnvironment({ provider, keyId, reqId, tui = false, tuiSeedSource }) {
|
export async function prepareIsolatedEnvironment({ provider, keyId, reqId }) {
|
||||||
const isolation = provider?.ISOLATION;
|
const isolation = provider?.ISOLATION;
|
||||||
|
|
||||||
// ── Test-context bypass ──────────────────────────────────────────────────
|
// ── Test-context bypass ──────────────────────────────────────────────────
|
||||||
@@ -320,46 +317,6 @@ export async function prepareIsolatedEnvironment({ provider, keyId, reqId, tui =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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) ────────────────────────────────
|
// ── Compose envOverrides (Layer 1 output) ────────────────────────────────
|
||||||
let envOverrides = {};
|
let envOverrides = {};
|
||||||
if (typeof isolation.ephemeralEnvOverrides === 'function') {
|
if (typeof isolation.ephemeralEnvOverrides === 'function') {
|
||||||
@@ -477,7 +434,6 @@ export async function prepareIsolatedEnvironment({ provider, keyId, reqId, tui =
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ephemeralRoot,
|
ephemeralRoot,
|
||||||
reqId: safeReqId,
|
|
||||||
envOverrides,
|
envOverrides,
|
||||||
hardenedArgs,
|
hardenedArgs,
|
||||||
wrapForLayer3,
|
wrapForLayer3,
|
||||||
@@ -485,64 +441,6 @@ export async function prepareIsolatedEnvironment({ provider, keyId, reqId, tui =
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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 ──────────────────────────────────────────────
|
// ── Legacy unsandboxed shape ──────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18318,209 +18318,3 @@ describe('Suite 44 — sandbox Layer 3 E2E test (PI231 only) [SKIP: awaiting Tas
|
|||||||
assert.ok(true, 'placeholder — real test lands in Task #9');
|
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