mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe615cb0d3 | ||
|
|
60930f0ba4 | ||
|
|
c86e3d014f | ||
|
|
3322d7bdae | ||
|
|
79c1d61e1d | ||
|
|
a37ff713d9 | ||
|
|
6d4751f983 | ||
|
|
0dced52215 | ||
|
|
d291331998 | ||
|
|
9568411bcb | ||
|
|
1f577c075f | ||
|
|
6dff36959a | ||
|
|
1b02f181fa | ||
|
|
0000926358 | ||
|
|
aa1c65beb1 | ||
|
|
879b40fe93 | ||
|
|
68d58e7df4 | ||
|
|
4a7d79c330 | ||
|
|
c3b1f32c86 | ||
|
|
4458490caa | ||
|
|
36be723198 | ||
|
|
7b065600aa | ||
|
|
1b5a742711 | ||
|
|
05a984df89 | ||
|
|
a30b20978c | ||
|
|
cd98b51b96 | ||
|
|
74260d7f6f | ||
|
|
885f62addf | ||
|
|
1dd6fb9440 | ||
|
|
9e25160527 | ||
|
|
49c6d32e3b | ||
|
|
7766fa0868 | ||
|
|
70faeff067 | ||
|
|
7a69d72886 | ||
|
|
a8601a6d30 | ||
|
|
cd6ec2a212 | ||
|
|
ab03c13332 | ||
|
|
55c576bbb1 | ||
|
|
750b25ba77 | ||
|
|
fd6e875bd7 | ||
|
|
8c0b97f3ae | ||
|
|
68acf15373 | ||
|
|
a71c939bf8 | ||
|
|
d245c62df7 | ||
|
|
047750e642 | ||
|
|
3bdeb50ed5 | ||
|
|
fbbf3b6c7c | ||
|
|
d760d7fcce | ||
|
|
5e2effd05b | ||
|
|
fb2d1d3feb | ||
|
|
12b09c236e | ||
|
|
c0f2d3ab20 | ||
|
|
0d61da5153 | ||
|
|
49baffe2da | ||
|
|
4b01d4e768 | ||
|
|
36fa81d1e6 | ||
|
|
cce0110253 | ||
|
|
40391791a1 | ||
|
|
342a0a44f5 | ||
|
|
9494fd6c69 | ||
|
|
5ff30ac9b6 | ||
|
|
16eeb66557 | ||
|
|
c998d21a4f | ||
|
|
5be369ed68 | ||
|
|
3d52ffc152 | ||
|
|
68b0838074 | ||
|
|
313cb13a78 | ||
|
|
e4b010af5e | ||
|
|
0752f666fb | ||
|
|
ae4a829904 | ||
|
|
51e908e145 | ||
|
|
d99534dc35 | ||
|
|
39ca20536e | ||
|
|
8b3f50912e | ||
|
|
780462c763 | ||
|
|
733a2ed4c2 | ||
|
|
ca2d23d230 | ||
|
|
b871b72b6b | ||
|
|
cff06439fa | ||
|
|
1c29f4867f | ||
|
|
cdd6b41261 | ||
|
|
9facd8307a | ||
|
|
2ecc4945ad | ||
|
|
497ff1dcd2 | ||
|
|
2e634f708b | ||
|
|
820abc4b89 | ||
|
|
ab9e0c656b | ||
|
|
5ef163aa95 | ||
|
|
c6f7850e89 | ||
|
|
43cd7712e6 | ||
|
|
ba273aaf06 |
@@ -0,0 +1,259 @@
|
||||
---
|
||||
name: speckit-analyze
|
||||
description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation.
|
||||
argument-hint: "Optional focus areas for analysis"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: github-spec-kit
|
||||
source: claude:templates/commands/analyze.md
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before analysis)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_analyze` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Goal.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Goal
|
||||
|
||||
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
|
||||
|
||||
**Constitution Authority**: The project constitution (`/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Initialize Analysis Context
|
||||
|
||||
Run `{SCRIPT}` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
|
||||
|
||||
- SPEC = FEATURE_DIR/spec.md
|
||||
- PLAN = FEATURE_DIR/plan.md
|
||||
- TASKS = FEATURE_DIR/tasks.md
|
||||
|
||||
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
|
||||
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
### 2. Load Artifacts (Progressive Disclosure)
|
||||
|
||||
Load only the minimal necessary context from each artifact:
|
||||
|
||||
**From spec.md:**
|
||||
|
||||
- Overview/Context
|
||||
- Functional Requirements
|
||||
- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact)
|
||||
- User Stories
|
||||
- Edge Cases (if present)
|
||||
|
||||
**From plan.md:**
|
||||
|
||||
- Architecture/stack choices
|
||||
- Data Model references
|
||||
- Phases
|
||||
- Technical constraints
|
||||
|
||||
**From tasks.md:**
|
||||
|
||||
- Task IDs
|
||||
- Descriptions
|
||||
- Phase grouping
|
||||
- Parallel markers [P]
|
||||
- Referenced file paths
|
||||
|
||||
**From constitution:**
|
||||
|
||||
- Load `/memory/constitution.md` for principle validation
|
||||
|
||||
### 3. Build Semantic Models
|
||||
|
||||
Create internal representations (do not include raw artifacts in output):
|
||||
|
||||
- **Requirements inventory**: For each Functional Requirement (FR-###) and Success Criterion (SC-###), record a stable key. Use the explicit FR-/SC- identifier as the primary key when present, and optionally also derive an imperative-phrase slug for readability (e.g., "User can upload file" → `user-can-upload-file`). Include only Success Criteria items that require buildable work (e.g., load-testing infrastructure, security audit tooling), and exclude post-launch outcome metrics and business KPIs (e.g., "Reduce support tickets by 50%").
|
||||
- **User story/action inventory**: Discrete user actions with acceptance criteria
|
||||
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
|
||||
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
|
||||
|
||||
### 4. Detection Passes (Token-Efficient Analysis)
|
||||
|
||||
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
|
||||
|
||||
#### A. Duplication Detection
|
||||
|
||||
- Identify near-duplicate requirements
|
||||
- Mark lower-quality phrasing for consolidation
|
||||
|
||||
#### B. Ambiguity Detection
|
||||
|
||||
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
|
||||
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
|
||||
|
||||
#### C. Underspecification
|
||||
|
||||
- Requirements with verbs but missing object or measurable outcome
|
||||
- User stories missing acceptance criteria alignment
|
||||
- Tasks referencing files or components not defined in spec/plan
|
||||
|
||||
#### D. Constitution Alignment
|
||||
|
||||
- Any requirement or plan element conflicting with a MUST principle
|
||||
- Missing mandated sections or quality gates from constitution
|
||||
|
||||
#### E. Coverage Gaps
|
||||
|
||||
- Requirements with zero associated tasks
|
||||
- Tasks with no mapped requirement/story
|
||||
- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks
|
||||
|
||||
#### F. Inconsistency
|
||||
|
||||
- Terminology drift (same concept named differently across files)
|
||||
- Data entities referenced in plan but absent in spec (or vice versa)
|
||||
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
|
||||
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
|
||||
|
||||
### 5. Severity Assignment
|
||||
|
||||
Use this heuristic to prioritize findings:
|
||||
|
||||
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
|
||||
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
|
||||
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
|
||||
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
|
||||
|
||||
### 6. Produce Compact Analysis Report
|
||||
|
||||
Output a Markdown report (no file writes) with the following structure:
|
||||
|
||||
## Specification Analysis Report
|
||||
|
||||
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|
||||
|----|----------|----------|-------------|---------|----------------|
|
||||
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
|
||||
|
||||
(Add one row per finding; generate stable IDs prefixed by category initial.)
|
||||
|
||||
**Coverage Summary Table:**
|
||||
|
||||
| Requirement Key | Has Task? | Task IDs | Notes |
|
||||
|-----------------|-----------|----------|-------|
|
||||
|
||||
**Constitution Alignment Issues:** (if any)
|
||||
|
||||
**Unmapped Tasks:** (if any)
|
||||
|
||||
**Metrics:**
|
||||
|
||||
- Total Requirements
|
||||
- Total Tasks
|
||||
- Coverage % (requirements with >=1 task)
|
||||
- Ambiguity Count
|
||||
- Duplication Count
|
||||
- Critical Issues Count
|
||||
|
||||
### 7. Provide Next Actions
|
||||
|
||||
At end of report, output a concise Next Actions block:
|
||||
|
||||
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
|
||||
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
||||
- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
|
||||
|
||||
### 8. Offer Remediation
|
||||
|
||||
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
|
||||
|
||||
### 9. Check for extension hooks
|
||||
|
||||
After reporting, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_analyze` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Operating Principles
|
||||
|
||||
### Context Efficiency
|
||||
|
||||
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
|
||||
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
|
||||
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
|
||||
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
|
||||
|
||||
### Analysis Guidelines
|
||||
|
||||
- **NEVER modify files** (this is read-only analysis)
|
||||
- **NEVER hallucinate missing sections** (if absent, report them accurately)
|
||||
- **Prioritize constitution violations** (these are always CRITICAL)
|
||||
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
|
||||
- **Report zero issues gracefully** (emit success report with coverage statistics)
|
||||
|
||||
## Context
|
||||
|
||||
{ARGS}
|
||||
@@ -0,0 +1,371 @@
|
||||
---
|
||||
name: speckit-checklist
|
||||
description: Generate a custom checklist for the current feature based on user requirements.
|
||||
argument-hint: "Domain or focus area for the checklist"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: github-spec-kit
|
||||
source: claude:templates/commands/checklist.md
|
||||
---
|
||||
|
||||
## Checklist Purpose: "Unit Tests for English"
|
||||
|
||||
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
|
||||
|
||||
**NOT for verification/testing**:
|
||||
|
||||
- ❌ NOT "Verify the button clicks correctly"
|
||||
- ❌ NOT "Test error handling works"
|
||||
- ❌ NOT "Confirm the API returns 200"
|
||||
- ❌ NOT checking if code/implementation matches the spec
|
||||
|
||||
**FOR requirements quality validation**:
|
||||
|
||||
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
|
||||
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
|
||||
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
|
||||
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
|
||||
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
|
||||
|
||||
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before checklist generation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_checklist` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Execution Steps.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
|
||||
- All file paths must be absolute.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||
- Only ask about information that materially changes checklist content
|
||||
- Be skipped individually if already unambiguous in `$ARGUMENTS`
|
||||
- Prefer precision over breadth
|
||||
|
||||
Generation algorithm:
|
||||
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
|
||||
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
|
||||
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
|
||||
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
|
||||
5. Formulate questions chosen from these archetypes:
|
||||
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
|
||||
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
|
||||
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
|
||||
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
|
||||
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
|
||||
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
|
||||
|
||||
Question formatting rules:
|
||||
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
|
||||
- Limit to A–E options maximum; omit table if a free-form answer is clearer
|
||||
- Never ask the user to restate what they already said
|
||||
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
|
||||
|
||||
Defaults when interaction impossible:
|
||||
- Depth: Standard
|
||||
- Audience: Reviewer (PR) if code-related; Author otherwise
|
||||
- Focus: Top 2 relevance clusters
|
||||
|
||||
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
|
||||
|
||||
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
- Derive checklist theme (e.g., security, review, deploy, ux)
|
||||
- Consolidate explicit must-have items mentioned by user
|
||||
- Map focus selections to category scaffolding
|
||||
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
|
||||
|
||||
4. **Load feature context**: Read from FEATURE_DIR:
|
||||
- spec.md: Feature requirements and scope
|
||||
- plan.md (if exists): Technical details, dependencies
|
||||
- tasks.md (if exists): Implementation tasks
|
||||
|
||||
**Context Loading Strategy**:
|
||||
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
|
||||
- Prefer summarizing long sections into concise scenario/requirement bullets
|
||||
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||
|
||||
5. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||
- Generate unique checklist filename:
|
||||
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||
- Format: `[domain].md`
|
||||
- File handling behavior:
|
||||
- If file does NOT exist: Create new file and number items starting from CHK001
|
||||
- If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016)
|
||||
- Never delete or replace existing checklist content - always preserve and append
|
||||
|
||||
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
|
||||
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
|
||||
- **Completeness**: Are all necessary requirements present?
|
||||
- **Clarity**: Are requirements unambiguous and specific?
|
||||
- **Consistency**: Do requirements align with each other?
|
||||
- **Measurability**: Can requirements be objectively verified?
|
||||
- **Coverage**: Are all scenarios/edge cases addressed?
|
||||
|
||||
**Category Structure** - Group items by requirement quality dimensions:
|
||||
- **Requirement Completeness** (Are all necessary requirements documented?)
|
||||
- **Requirement Clarity** (Are requirements specific and unambiguous?)
|
||||
- **Requirement Consistency** (Do requirements align without conflicts?)
|
||||
- **Acceptance Criteria Quality** (Are success criteria measurable?)
|
||||
- **Scenario Coverage** (Are all flows/cases addressed?)
|
||||
- **Edge Case Coverage** (Are boundary conditions defined?)
|
||||
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
|
||||
- **Dependencies & Assumptions** (Are they documented and validated?)
|
||||
- **Ambiguities & Conflicts** (What needs clarification?)
|
||||
|
||||
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
|
||||
|
||||
❌ **WRONG** (Testing implementation):
|
||||
- "Verify landing page displays 3 episode cards"
|
||||
- "Test hover states work on desktop"
|
||||
- "Confirm logo click navigates home"
|
||||
|
||||
✅ **CORRECT** (Testing requirements quality):
|
||||
- "Are the exact number and layout of featured episodes specified?" [Completeness]
|
||||
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
|
||||
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
|
||||
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
|
||||
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
|
||||
- "Are loading states defined for asynchronous episode data?" [Completeness]
|
||||
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
|
||||
|
||||
**ITEM STRUCTURE**:
|
||||
Each item should follow this pattern:
|
||||
- Question format asking about requirement quality
|
||||
- Focus on what's WRITTEN (or not written) in the spec/plan
|
||||
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
|
||||
- Reference spec section `[Spec §X.Y]` when checking existing requirements
|
||||
- Use `[Gap]` marker when checking for missing requirements
|
||||
|
||||
**EXAMPLES BY QUALITY DIMENSION**:
|
||||
|
||||
Completeness:
|
||||
- "Are error handling requirements defined for all API failure modes? [Gap]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
|
||||
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
|
||||
|
||||
Clarity:
|
||||
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
|
||||
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
|
||||
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
|
||||
|
||||
Consistency:
|
||||
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
|
||||
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
|
||||
|
||||
Coverage:
|
||||
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
|
||||
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
|
||||
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
|
||||
|
||||
Measurability:
|
||||
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
|
||||
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
|
||||
|
||||
**Scenario Classification & Coverage** (Requirements Quality Focus):
|
||||
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
|
||||
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
|
||||
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
|
||||
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
|
||||
|
||||
**Traceability Requirements**:
|
||||
- MINIMUM: ≥80% of items MUST include at least one traceability reference
|
||||
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
|
||||
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
|
||||
|
||||
**Surface & Resolve Issues** (Requirements Quality Problems):
|
||||
Ask questions about the requirements themselves:
|
||||
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
|
||||
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
|
||||
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
|
||||
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
|
||||
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
|
||||
|
||||
**Content Consolidation**:
|
||||
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
|
||||
- Merge near-duplicates checking the same requirement aspect
|
||||
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
|
||||
|
||||
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
|
||||
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
|
||||
- ❌ References to code execution, user actions, system behavior
|
||||
- ❌ "Displays correctly", "works properly", "functions as expected"
|
||||
- ❌ "Click", "navigate", "render", "load", "execute"
|
||||
- ❌ Test cases, test plans, QA procedures
|
||||
- ❌ Implementation details (frameworks, APIs, algorithms)
|
||||
|
||||
**✅ REQUIRED PATTERNS** - These test requirements quality:
|
||||
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
|
||||
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
|
||||
- ✅ "Are requirements consistent between [section A] and [section B]?"
|
||||
- ✅ "Can [requirement] be objectively measured/verified?"
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
6. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
|
||||
7. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
|
||||
- Focus areas selected
|
||||
- Depth level
|
||||
- Actor/timing
|
||||
- Any explicit user-specified must-have items incorporated
|
||||
|
||||
**Important**: Each `/speckit.checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
|
||||
|
||||
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
||||
- Simple, memorable filenames that indicate checklist purpose
|
||||
- Easy identification and navigation in the `checklists/` folder
|
||||
|
||||
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
|
||||
|
||||
## Example Checklist Types & Sample Items
|
||||
|
||||
**UX Requirements Quality:** `ux.md`
|
||||
|
||||
Sample items (testing the requirements, NOT the implementation):
|
||||
|
||||
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
|
||||
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
|
||||
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
|
||||
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
|
||||
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
|
||||
|
||||
**API Requirements Quality:** `api.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are error response formats specified for all failure scenarios? [Completeness]"
|
||||
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
|
||||
- "Are authentication requirements consistent across all endpoints? [Consistency]"
|
||||
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
|
||||
- "Is versioning strategy documented in requirements? [Gap]"
|
||||
|
||||
**Performance Requirements Quality:** `performance.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are performance requirements quantified with specific metrics? [Clarity]"
|
||||
- "Are performance targets defined for all critical user journeys? [Coverage]"
|
||||
- "Are performance requirements under different load conditions specified? [Completeness]"
|
||||
- "Can performance requirements be objectively measured? [Measurability]"
|
||||
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
|
||||
|
||||
**Security Requirements Quality:** `security.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are authentication requirements specified for all protected resources? [Coverage]"
|
||||
- "Are data protection requirements defined for sensitive information? [Completeness]"
|
||||
- "Is the threat model documented and requirements aligned to it? [Traceability]"
|
||||
- "Are security requirements consistent with compliance obligations? [Consistency]"
|
||||
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
|
||||
|
||||
## Anti-Examples: What NOT To Do
|
||||
|
||||
**❌ WRONG - These test implementation, not requirements:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
|
||||
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
|
||||
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
|
||||
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
|
||||
```
|
||||
|
||||
**✅ CORRECT - These test requirements quality:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
|
||||
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
|
||||
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
|
||||
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
|
||||
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
|
||||
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
|
||||
```
|
||||
|
||||
**Key Differences:**
|
||||
|
||||
- Wrong: Tests if the system works correctly
|
||||
- Correct: Tests if the requirements are written correctly
|
||||
- Wrong: Verification of behavior
|
||||
- Correct: Validation of requirement quality
|
||||
- Wrong: "Does it do X?"
|
||||
- Correct: "Is X clearly specified?"
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after checklist generation)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_checklist` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
@@ -0,0 +1,253 @@
|
||||
---
|
||||
name: speckit-clarify
|
||||
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
|
||||
argument-hint: "Optional areas to clarify in the spec"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: github-spec-kit
|
||||
source: claude:templates/commands/clarify.md
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before clarification)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_clarify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
||||
|
||||
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
|
||||
|
||||
Execution steps:
|
||||
|
||||
1. Run `{SCRIPT}` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
|
||||
- `FEATURE_DIR`
|
||||
- `FEATURE_SPEC`
|
||||
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
||||
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
|
||||
Functional Scope & Behavior:
|
||||
- Core user goals & success criteria
|
||||
- Explicit out-of-scope declarations
|
||||
- User roles / personas differentiation
|
||||
|
||||
Domain & Data Model:
|
||||
- Entities, attributes, relationships
|
||||
- Identity & uniqueness rules
|
||||
- Lifecycle/state transitions
|
||||
- Data volume / scale assumptions
|
||||
|
||||
Interaction & UX Flow:
|
||||
- Critical user journeys / sequences
|
||||
- Error/empty/loading states
|
||||
- Accessibility or localization notes
|
||||
|
||||
Non-Functional Quality Attributes:
|
||||
- Performance (latency, throughput targets)
|
||||
- Scalability (horizontal/vertical, limits)
|
||||
- Reliability & availability (uptime, recovery expectations)
|
||||
- Observability (logging, metrics, tracing signals)
|
||||
- Security & privacy (authN/Z, data protection, threat assumptions)
|
||||
- Compliance / regulatory constraints (if any)
|
||||
|
||||
Integration & External Dependencies:
|
||||
- External services/APIs and failure modes
|
||||
- Data import/export formats
|
||||
- Protocol/versioning assumptions
|
||||
|
||||
Edge Cases & Failure Handling:
|
||||
- Negative scenarios
|
||||
- Rate limiting / throttling
|
||||
- Conflict resolution (e.g., concurrent edits)
|
||||
|
||||
Constraints & Tradeoffs:
|
||||
- Technical constraints (language, storage, hosting)
|
||||
- Explicit tradeoffs or rejected alternatives
|
||||
|
||||
Terminology & Consistency:
|
||||
- Canonical glossary terms
|
||||
- Avoided synonyms / deprecated terms
|
||||
|
||||
Completion Signals:
|
||||
- Acceptance criteria testability
|
||||
- Measurable Definition of Done style indicators
|
||||
|
||||
Misc / Placeholders:
|
||||
- TODO markers / unresolved decisions
|
||||
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
|
||||
|
||||
For each category with Partial or Missing status, add a candidate question opportunity unless:
|
||||
- Clarification would not materially change implementation or validation strategy
|
||||
- Information is better deferred to planning phase (note internally)
|
||||
|
||||
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
- Maximum of 5 total questions across the whole session.
|
||||
- Each question must be answerable with EITHER:
|
||||
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||
- A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words").
|
||||
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
|
||||
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
|
||||
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
|
||||
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
|
||||
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
|
||||
|
||||
4. Sequential questioning loop (interactive):
|
||||
- Present EXACTLY ONE question at a time.
|
||||
- For multiple‑choice questions:
|
||||
- **Analyze all options** and determine the **most suitable option** based on:
|
||||
- Best practices for the project type
|
||||
- Common patterns in similar implementations
|
||||
- Risk reduction (security, performance, maintainability)
|
||||
- Alignment with any explicit project goals or constraints visible in the spec
|
||||
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
|
||||
- Format as: `**Recommended:** Option [X] - <reasoning>`
|
||||
- Then render all options as a Markdown table:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| A | <Option A description> |
|
||||
| B | <Option B description> |
|
||||
| C | <Option C description> (add D/E as needed up to 5) |
|
||||
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
|
||||
|
||||
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
|
||||
- For short‑answer style (no meaningful discrete options):
|
||||
- Provide your **suggested answer** based on best practices and context.
|
||||
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
|
||||
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
|
||||
- After the user answers:
|
||||
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
|
||||
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
|
||||
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
|
||||
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
|
||||
- Stop asking further questions when:
|
||||
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
|
||||
- User signals completion ("done", "good", "no more"), OR
|
||||
- You reach 5 asked questions.
|
||||
- Never reveal future queued questions in advance.
|
||||
- If no valid questions exist at start, immediately report no critical ambiguities.
|
||||
|
||||
5. Integration after EACH accepted answer (incremental update approach):
|
||||
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
|
||||
- For the first integrated answer in this session:
|
||||
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
|
||||
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
|
||||
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
|
||||
- Then immediately apply the clarification to the most appropriate section(s):
|
||||
- Functional ambiguity → Update or add a bullet in Functional Requirements.
|
||||
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
|
||||
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
|
||||
- Non-functional constraint → Add/modify measurable criteria in Success Criteria > Measurable Outcomes (convert vague adjective to metric or explicit target).
|
||||
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
|
||||
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
|
||||
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
|
||||
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
|
||||
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
|
||||
- Keep each inserted clarification minimal and testable (avoid narrative drift).
|
||||
|
||||
6. Validation (performed after EACH write plus final pass):
|
||||
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
|
||||
- Total asked (accepted) questions ≤ 5.
|
||||
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
|
||||
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
|
||||
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
|
||||
- Terminology consistency: same canonical term used across all updated sections.
|
||||
|
||||
7. Write the updated spec back to `FEATURE_SPEC`.
|
||||
|
||||
8. Report completion (after questioning loop ends or early termination):
|
||||
- Number of questions asked & answered.
|
||||
- Path to updated spec.
|
||||
- Sections touched (list names).
|
||||
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
|
||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.
|
||||
- Suggested next command.
|
||||
|
||||
Behavior rules:
|
||||
|
||||
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
|
||||
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).
|
||||
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
|
||||
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
||||
- Respect user early termination signals ("stop", "done", "proceed").
|
||||
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
|
||||
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
|
||||
|
||||
Context for prioritization: {ARGS}
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after clarification)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_clarify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: speckit-constitution
|
||||
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
|
||||
argument-hint: "Principles or values for the project constitution"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: github-spec-kit
|
||||
source: claude:templates/commands/constitution.md
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before constitution update)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_constitution` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
|
||||
|
||||
**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.
|
||||
|
||||
Follow this execution flow:
|
||||
|
||||
1. Load the existing constitution at `.specify/memory/constitution.md`.
|
||||
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
|
||||
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
|
||||
|
||||
2. Collect/derive values for placeholders:
|
||||
- If user input (conversation) supplies a value, use it.
|
||||
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
|
||||
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
|
||||
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
|
||||
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
|
||||
- MINOR: New principle/section added or materially expanded guidance.
|
||||
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
|
||||
- If version bump type ambiguous, propose reasoning before finalizing.
|
||||
|
||||
3. Draft the updated constitution content:
|
||||
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
|
||||
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
|
||||
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious.
|
||||
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
|
||||
|
||||
4. Consistency propagation checklist (convert prior checklist into active validations):
|
||||
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
|
||||
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
|
||||
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
|
||||
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
|
||||
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
|
||||
|
||||
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
|
||||
- Version change: old → new
|
||||
- List of modified principles (old title → new title if renamed)
|
||||
- Added sections
|
||||
- Removed sections
|
||||
- Templates requiring updates (✅ updated / ⚠ pending) with file paths
|
||||
- Follow-up TODOs if any placeholders intentionally deferred.
|
||||
|
||||
6. Validation before final output:
|
||||
- No remaining unexplained bracket tokens.
|
||||
- Version line matches report.
|
||||
- Dates ISO format YYYY-MM-DD.
|
||||
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
|
||||
|
||||
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
|
||||
|
||||
8. Output a final summary to the user with:
|
||||
- New version and bump rationale.
|
||||
- Any files flagged for manual follow-up.
|
||||
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
|
||||
|
||||
Formatting & Style Requirements:
|
||||
|
||||
- Use Markdown headings exactly as in the template (do not demote/promote levels).
|
||||
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
|
||||
- Keep a single blank line between sections.
|
||||
- Avoid trailing whitespace.
|
||||
|
||||
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
|
||||
|
||||
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
|
||||
|
||||
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after constitution update)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_constitution` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
@@ -0,0 +1,208 @@
|
||||
---
|
||||
name: speckit-implement
|
||||
description: Execute the implementation plan by processing and executing all tasks defined in tasks.md
|
||||
argument-hint: "Optional implementation guidance or task filter"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: github-spec-kit
|
||||
source: claude:templates/commands/implement.md
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before implementation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_implement` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
|
||||
- Scan all checklist files in the checklists/ directory
|
||||
- For each checklist, count:
|
||||
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
|
||||
- Completed items: Lines matching `- [X]` or `- [x]`
|
||||
- Incomplete items: Lines matching `- [ ]`
|
||||
- Create a status table:
|
||||
|
||||
```text
|
||||
| Checklist | Total | Completed | Incomplete | Status |
|
||||
|-----------|-------|-----------|------------|--------|
|
||||
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||
```
|
||||
|
||||
- Calculate overall status:
|
||||
- **PASS**: All checklists have 0 incomplete items
|
||||
- **FAIL**: One or more checklists have incomplete items
|
||||
|
||||
- **If any checklist is incomplete**:
|
||||
- Display the table with incomplete item counts
|
||||
- **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)"
|
||||
- Wait for user response before continuing
|
||||
- If user says "no" or "wait" or "stop", halt execution
|
||||
- If user says "yes" or "proceed" or "continue", proceed to step 3
|
||||
|
||||
- **If all checklists are complete**:
|
||||
- Display the table showing all checklists passed
|
||||
- Automatically proceed to step 3
|
||||
|
||||
3. Load and analyze the implementation context:
|
||||
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
|
||||
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||
|
||||
4. **Project Setup Verification**:
|
||||
- **REQUIRED**: Create/verify ignore files based on actual project setup:
|
||||
|
||||
**Detection & Creation Logic**:
|
||||
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
|
||||
|
||||
```sh
|
||||
git rev-parse --git-dir 2>/dev/null
|
||||
```
|
||||
|
||||
- Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore
|
||||
- Check if .eslintrc* exists → create/verify .eslintignore
|
||||
- Check if eslint.config.* exists → ensure the config's `ignores` entries cover required patterns
|
||||
- Check if .prettierrc* exists → create/verify .prettierignore
|
||||
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
|
||||
- Check if terraform files (*.tf) exist → create/verify .terraformignore
|
||||
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
|
||||
|
||||
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
|
||||
**If ignore file missing**: Create with full pattern set for detected technology
|
||||
|
||||
**Common Patterns by Technology** (from plan.md tech stack):
|
||||
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
|
||||
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
|
||||
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
|
||||
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
|
||||
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
|
||||
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
|
||||
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
|
||||
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
|
||||
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
|
||||
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
|
||||
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `*.dll`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*`
|
||||
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
|
||||
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
|
||||
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
|
||||
|
||||
**Tool-Specific Patterns**:
|
||||
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
|
||||
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
|
||||
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
|
||||
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
|
||||
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
|
||||
|
||||
5. Parse tasks.md structure and extract:
|
||||
- **Task phases**: Setup, Tests, Core, Integration, Polish
|
||||
- **Task dependencies**: Sequential vs parallel execution rules
|
||||
- **Task details**: ID, description, file paths, parallel markers [P]
|
||||
- **Execution flow**: Order and dependency requirements
|
||||
|
||||
6. Execute implementation following the task plan:
|
||||
- **Phase-by-phase execution**: Complete each phase before moving to the next
|
||||
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
||||
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
|
||||
- **File-based coordination**: Tasks affecting the same files must run sequentially
|
||||
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||
|
||||
7. Implementation execution rules:
|
||||
- **Setup first**: Initialize project structure, dependencies, configuration
|
||||
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
||||
- **Core development**: Implement models, services, CLI commands, endpoints
|
||||
- **Integration work**: Database connections, middleware, logging, external services
|
||||
- **Polish and validation**: Unit tests, performance optimization, documentation
|
||||
|
||||
8. Progress tracking and error handling:
|
||||
- Report progress after each completed task
|
||||
- Halt execution if any non-parallel task fails
|
||||
- For parallel tasks [P], continue with successful tasks, report failed ones
|
||||
- Provide clear error messages with context for debugging
|
||||
- Suggest next steps if implementation cannot proceed
|
||||
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
|
||||
|
||||
9. Completion validation:
|
||||
- Verify all required tasks are completed
|
||||
- Check that implemented features match the original specification
|
||||
- Validate that tests pass and coverage meets requirements
|
||||
- Confirm the implementation follows the technical plan
|
||||
- Report final status with summary of completed work
|
||||
|
||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.
|
||||
|
||||
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_implement` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: speckit-plan
|
||||
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
|
||||
argument-hint: "Optional guidance for the planning phase"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: github-spec-kit
|
||||
source: claude:templates/commands/plan.md
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before planning)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_plan` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load context**: Read FEATURE_SPEC and `/memory/constitution.md`. Load IMPL_PLAN template (already copied).
|
||||
|
||||
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
||||
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
||||
- Fill Constitution Check section from constitution
|
||||
- Evaluate gates (ERROR if violations unjustified)
|
||||
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
|
||||
- Phase 1: Generate data-model.md, contracts/, quickstart.md
|
||||
- Phase 1: Update agent context by running the agent script
|
||||
- Re-evaluate Constitution Check post-design
|
||||
|
||||
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
|
||||
5. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_plan` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0: Outline & Research
|
||||
|
||||
1. **Extract unknowns from Technical Context** above:
|
||||
- For each NEEDS CLARIFICATION → research task
|
||||
- For each dependency → best practices task
|
||||
- For each integration → patterns task
|
||||
|
||||
2. **Generate and dispatch research agents**:
|
||||
|
||||
```text
|
||||
For each unknown in Technical Context:
|
||||
Task: "Research {unknown} for {feature context}"
|
||||
For each technology choice:
|
||||
Task: "Find best practices for {tech} in {domain}"
|
||||
```
|
||||
|
||||
3. **Consolidate findings** in `research.md` using format:
|
||||
- Decision: [what was chosen]
|
||||
- Rationale: [why chosen]
|
||||
- Alternatives considered: [what else evaluated]
|
||||
|
||||
**Output**: research.md with all NEEDS CLARIFICATION resolved
|
||||
|
||||
### Phase 1: Design & Contracts
|
||||
|
||||
**Prerequisites:** `research.md` complete
|
||||
|
||||
1. **Extract entities from feature spec** → `data-model.md`:
|
||||
- Entity name, fields, relationships
|
||||
- Validation rules from requirements
|
||||
- State transitions if applicable
|
||||
|
||||
2. **Define interface contracts** (if project has external interfaces) → `/contracts/`:
|
||||
- Identify what interfaces the project exposes to users or other systems
|
||||
- Document the contract format appropriate for the project type
|
||||
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
|
||||
- Skip if project is purely internal (build scripts, one-off tools, etc.)
|
||||
|
||||
3. **Agent context update**:
|
||||
- Update the plan reference between the `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` markers in `__CONTEXT_FILE__` to point to the plan file created in step 1 (the IMPL_PLAN path)
|
||||
|
||||
**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file
|
||||
|
||||
## Key rules
|
||||
|
||||
- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files
|
||||
- ERROR on gate failures or unresolved clarifications
|
||||
@@ -0,0 +1,329 @@
|
||||
---
|
||||
name: speckit-specify
|
||||
description: Create or update the feature specification from a natural language feature description.
|
||||
argument-hint: "Describe the feature you want to specify"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: github-spec-kit
|
||||
source: claude:templates/commands/specify.md
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before specification)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_specify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
||||
|
||||
Given that feature description, do this:
|
||||
|
||||
1. **Generate a concise short name** (2-4 words) for the feature:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Create a 2-4 word short name that captures the essence of the feature
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||
- Keep it concise but descriptive enough to understand the feature at a glance
|
||||
- Examples:
|
||||
- "I want to add user authentication" → "user-auth"
|
||||
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
|
||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||
|
||||
2. **Branch creation** (optional, via hook):
|
||||
|
||||
If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name.
|
||||
|
||||
If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation).
|
||||
|
||||
3. **Create the spec feature directory**:
|
||||
|
||||
Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`.
|
||||
|
||||
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
|
||||
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
|
||||
2. Otherwise, auto-generate it under `specs/`:
|
||||
- Check `.specify/init-options.json` for `branch_numbering`
|
||||
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
|
||||
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
|
||||
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
|
||||
|
||||
**Create the directory and spec file**:
|
||||
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
|
||||
- Copy `templates/spec-template.md` to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
|
||||
- Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md`
|
||||
- Persist the resolved path to `.specify/feature.json`:
|
||||
```json
|
||||
{
|
||||
"feature_directory": "<resolved feature dir>"
|
||||
}
|
||||
```
|
||||
Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
|
||||
This allows downstream commands (`/speckit.plan`, `/speckit.tasks`, etc.) to locate the feature directory without relying on git branch name conventions.
|
||||
|
||||
**IMPORTANT**:
|
||||
- You must only create one feature per `/speckit.specify` invocation
|
||||
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
|
||||
- The spec directory and file are always created by this command, never by the hook
|
||||
|
||||
4. Load `templates/spec-template.md` to understand required sections.
|
||||
|
||||
5. Follow this execution flow:
|
||||
1. Parse user description from arguments
|
||||
If empty: ERROR "No feature description provided"
|
||||
2. Extract key concepts from description
|
||||
Identify: actors, actions, data, constraints
|
||||
3. For unclear aspects:
|
||||
- Make informed guesses based on context and industry standards
|
||||
- Only mark with [NEEDS CLARIFICATION: specific question] if:
|
||||
- The choice significantly impacts feature scope or user experience
|
||||
- Multiple reasonable interpretations exist with different implications
|
||||
- No reasonable default exists
|
||||
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
|
||||
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
|
||||
4. Fill User Scenarios & Testing section
|
||||
If no clear user flow: ERROR "Cannot determine user scenarios"
|
||||
5. Generate Functional Requirements
|
||||
Each requirement must be testable
|
||||
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
|
||||
6. Define Success Criteria
|
||||
Create measurable, technology-agnostic outcomes
|
||||
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
|
||||
Each criterion must be verifiable without implementation details
|
||||
7. Identify Key Entities (if data involved)
|
||||
8. Return: SUCCESS (spec ready for planning)
|
||||
|
||||
6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||
|
||||
7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
|
||||
|
||||
a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:
|
||||
|
||||
```markdown
|
||||
# Specification Quality Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md]
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [ ] No implementation details (languages, frameworks, APIs)
|
||||
- [ ] Focused on user value and business needs
|
||||
- [ ] Written for non-technical stakeholders
|
||||
- [ ] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [ ] No [NEEDS CLARIFICATION] markers remain
|
||||
- [ ] Requirements are testable and unambiguous
|
||||
- [ ] Success criteria are measurable
|
||||
- [ ] Success criteria are technology-agnostic (no implementation details)
|
||||
- [ ] All acceptance scenarios are defined
|
||||
- [ ] Edge cases are identified
|
||||
- [ ] Scope is clearly bounded
|
||||
- [ ] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [ ] All functional requirements have clear acceptance criteria
|
||||
- [ ] User scenarios cover primary flows
|
||||
- [ ] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [ ] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
```
|
||||
|
||||
b. **Run Validation Check**: Review the spec against each checklist item:
|
||||
- For each item, determine if it passes or fails
|
||||
- Document specific issues found (quote relevant spec sections)
|
||||
|
||||
c. **Handle Validation Results**:
|
||||
|
||||
- **If all items pass**: Mark checklist complete and proceed to step 7
|
||||
|
||||
- **If items fail (excluding [NEEDS CLARIFICATION])**:
|
||||
1. List the failing items and specific issues
|
||||
2. Update the spec to address each issue
|
||||
3. Re-run validation until all items pass (max 3 iterations)
|
||||
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
|
||||
|
||||
- **If [NEEDS CLARIFICATION] markers remain**:
|
||||
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
|
||||
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
|
||||
3. For each clarification needed (max 3), present options to user in this format:
|
||||
|
||||
```markdown
|
||||
## Question [N]: [Topic]
|
||||
|
||||
**Context**: [Quote relevant spec section]
|
||||
|
||||
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
|
||||
|
||||
**Suggested Answers**:
|
||||
|
||||
| Option | Answer | Implications |
|
||||
|--------|--------|--------------|
|
||||
| A | [First suggested answer] | [What this means for the feature] |
|
||||
| B | [Second suggested answer] | [What this means for the feature] |
|
||||
| C | [Third suggested answer] | [What this means for the feature] |
|
||||
| Custom | Provide your own answer | [Explain how to provide custom input] |
|
||||
|
||||
**Your choice**: _[Wait for user response]_
|
||||
```
|
||||
|
||||
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
|
||||
- Use consistent spacing with pipes aligned
|
||||
- Each cell should have spaces around content: `| Content |` not `|Content|`
|
||||
- Header separator must have at least 3 dashes: `|--------|`
|
||||
- Test that the table renders correctly in markdown preview
|
||||
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
|
||||
6. Present all questions together before waiting for responses
|
||||
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
|
||||
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
|
||||
9. Re-run validation after all clarifications are resolved
|
||||
|
||||
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
|
||||
|
||||
8. **Report completion** to the user with:
|
||||
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
|
||||
- `SPEC_FILE` — the spec file path
|
||||
- Checklist results summary
|
||||
- Readiness for the next phase (`/speckit.clarify` or `/speckit.plan`)
|
||||
|
||||
9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_specify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command.
|
||||
|
||||
## Quick Guidelines
|
||||
|
||||
- Focus on **WHAT** users need and **WHY**.
|
||||
- Avoid HOW to implement (no tech stack, APIs, code structure).
|
||||
- Written for business stakeholders, not developers.
|
||||
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
|
||||
|
||||
### Section Requirements
|
||||
|
||||
- **Mandatory sections**: Must be completed for every feature
|
||||
- **Optional sections**: Include only when relevant to the feature
|
||||
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
|
||||
|
||||
### For AI Generation
|
||||
|
||||
When creating this spec from a user prompt:
|
||||
|
||||
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
|
||||
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
|
||||
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
|
||||
- Significantly impact feature scope or user experience
|
||||
- Have multiple reasonable interpretations with different implications
|
||||
- Lack any reasonable default
|
||||
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
|
||||
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
|
||||
6. **Common areas needing clarification** (only if no reasonable default exists):
|
||||
- Feature scope and boundaries (include/exclude specific use cases)
|
||||
- User types and permissions (if multiple conflicting interpretations possible)
|
||||
- Security/compliance requirements (when legally/financially significant)
|
||||
|
||||
**Examples of reasonable defaults** (don't ask about these):
|
||||
|
||||
- Data retention: Industry-standard practices for the domain
|
||||
- Performance targets: Standard web/mobile app expectations unless specified
|
||||
- Error handling: User-friendly messages with appropriate fallbacks
|
||||
- Authentication method: Standard session-based or OAuth2 for web apps
|
||||
- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.)
|
||||
|
||||
### Success Criteria Guidelines
|
||||
|
||||
Success criteria must be:
|
||||
|
||||
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
|
||||
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
|
||||
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
|
||||
4. **Verifiable**: Can be tested/validated without knowing implementation details
|
||||
|
||||
**Good examples**:
|
||||
|
||||
- "Users can complete checkout in under 3 minutes"
|
||||
- "System supports 10,000 concurrent users"
|
||||
- "95% of searches return results in under 1 second"
|
||||
- "Task completion rate improves by 40%"
|
||||
|
||||
**Bad examples** (implementation-focused):
|
||||
|
||||
- "API response time is under 200ms" (too technical, use "Users see results instantly")
|
||||
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
|
||||
- "React components render efficiently" (framework-specific)
|
||||
- "Redis cache hit rate above 80%" (technology-specific)
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
name: speckit-tasks
|
||||
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
|
||||
argument-hint: "Optional task generation constraints"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: github-spec-kit
|
||||
source: claude:templates/commands/tasks.md
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before tasks generation)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_tasks` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load design documents**: Read from FEATURE_DIR:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
|
||||
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
|
||||
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
||||
|
||||
3. **Execute task generation workflow**:
|
||||
- Load plan.md and extract tech stack, libraries, project structure
|
||||
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
|
||||
- If data-model.md exists: Extract entities and map to user stories
|
||||
- If contracts/ exists: Map interface contracts to user stories
|
||||
- If research.md exists: Extract decisions for setup tasks
|
||||
- Generate tasks organized by user story (see Task Generation Rules below)
|
||||
- Generate dependency graph showing user story completion order
|
||||
- Create parallel execution examples per user story
|
||||
- Validate task completeness (each user story has all needed tasks, independently testable)
|
||||
|
||||
4. **Generate tasks.md**: Use `templates/tasks-template.md` as structure, fill with:
|
||||
- Correct feature name from plan.md
|
||||
- Phase 1: Setup tasks (project initialization)
|
||||
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
|
||||
- Phase 3+: One phase per user story (in priority order from spec.md)
|
||||
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
|
||||
- Final Phase: Polish & cross-cutting concerns
|
||||
- All tasks must follow the strict checklist format (see Task Generation Rules below)
|
||||
- Clear file paths for each task
|
||||
- Dependencies section showing story completion order
|
||||
- Parallel execution examples per story
|
||||
- Implementation strategy section (MVP first, incremental delivery)
|
||||
|
||||
5. **Report**: Output path to generated tasks.md and summary:
|
||||
- Total task count
|
||||
- Task count per user story
|
||||
- Parallel opportunities identified
|
||||
- Independent test criteria for each story
|
||||
- Suggested MVP scope (typically just User Story 1)
|
||||
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
|
||||
|
||||
6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_tasks` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
Context for task generation: {ARGS}
|
||||
|
||||
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
|
||||
|
||||
## Task Generation Rules
|
||||
|
||||
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
|
||||
|
||||
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
|
||||
|
||||
### Checklist Format (REQUIRED)
|
||||
|
||||
Every task MUST strictly follow this format:
|
||||
|
||||
```text
|
||||
- [ ] [TaskID] [P?] [Story?] Description with file path
|
||||
```
|
||||
|
||||
**Format Components**:
|
||||
|
||||
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
|
||||
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
|
||||
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
|
||||
4. **[Story] label**: REQUIRED for user story phase tasks only
|
||||
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
|
||||
- Setup phase: NO story label
|
||||
- Foundational phase: NO story label
|
||||
- User Story phases: MUST have story label
|
||||
- Polish phase: NO story label
|
||||
5. **Description**: Clear action with exact file path
|
||||
|
||||
**Examples**:
|
||||
|
||||
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
|
||||
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
|
||||
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
|
||||
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
|
||||
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
|
||||
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
|
||||
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
|
||||
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
|
||||
|
||||
### Task Organization
|
||||
|
||||
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
|
||||
- Each user story (P1, P2, P3...) gets its own phase
|
||||
- Map all related components to their story:
|
||||
- Models needed for that story
|
||||
- Services needed for that story
|
||||
- Interfaces/UI needed for that story
|
||||
- If tests requested: Tests specific to that story
|
||||
- Mark story dependencies (most stories should be independent)
|
||||
|
||||
2. **From Contracts**:
|
||||
- Map each interface contract → to the user story it serves
|
||||
- If tests requested: Each interface contract → contract test task [P] before implementation in that story's phase
|
||||
|
||||
3. **From Data Model**:
|
||||
- Map each entity to the user story(ies) that need it
|
||||
- If entity serves multiple stories: Put in earliest story or Setup phase
|
||||
- Relationships → service layer tasks in appropriate story phase
|
||||
|
||||
4. **From Setup/Infrastructure**:
|
||||
- Shared infrastructure → Setup phase (Phase 1)
|
||||
- Foundational/blocking tasks → Foundational phase (Phase 2)
|
||||
- Story-specific setup → within that story's phase
|
||||
|
||||
### Phase Structure
|
||||
|
||||
- **Phase 1**: Setup (project initialization)
|
||||
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
|
||||
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
|
||||
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
|
||||
- Each phase should be a complete, independently testable increment
|
||||
- **Final Phase**: Polish & Cross-Cutting Concerns
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: speckit-taskstoissues
|
||||
description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts.
|
||||
argument-hint: "Optional filter or label for GitHub issues"
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||
metadata:
|
||||
author: github-spec-kit
|
||||
source: claude:templates/commands/taskstoissues.md
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Pre-Execution Checks
|
||||
|
||||
**Check for extension hooks (before tasks-to-issues conversion)**:
|
||||
- Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Pre-Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Pre-Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
|
||||
1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
1. From the executed script, extract the path to **tasks**.
|
||||
1. Get the Git remote by running:
|
||||
|
||||
```bash
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
|
||||
|
||||
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote.
|
||||
|
||||
> [!CAUTION]
|
||||
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
**Check for extension hooks (after tasks-to-issues conversion)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` -> `/speckit-git-commit`.
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
@@ -1,8 +0,0 @@
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
node_modules
|
||||
.env
|
||||
.env.*
|
||||
scripts/
|
||||
start.sh
|
||||
@@ -0,0 +1,9 @@
|
||||
# GitHub recognizes this file and shows a "Sponsor" button on the repo page.
|
||||
# Add other platforms here as they get set up. Empty / commented-out entries
|
||||
# are skipped silently.
|
||||
|
||||
buy_me_a_coffee: dtzp555
|
||||
|
||||
# github: [dtzp555-max] # uncomment after GitHub Sponsors enrollment is approved
|
||||
# ko_fi: dtzp555
|
||||
# custom: ["https://example.com/donate"]
|
||||
@@ -4,22 +4,46 @@
|
||||
|
||||
<!-- One or two sentences describing the change and why it is in scope for OCP. -->
|
||||
|
||||
## Endpoint Class (REQUIRED)
|
||||
|
||||
Per `ALIGNMENT.md` and ADR 0006, every PR that touches a network-facing endpoint must declare its class. Pick the most specific applicable class (Hybrid covers PRs that touch both A and B):
|
||||
|
||||
- [ ] **Class A** — forwards a `cli.js` operation (e.g., `/v1/messages`, `/api/oauth/*`, or the Anthropic-side wire call inside `/usage`)
|
||||
- [ ] **Class B** — extends an OCP-owned compatibility endpoint (per ADR 0006). Sub-bucket:
|
||||
- [ ] B.1 — OpenAI-compatibility surface (`/v1/chat/completions`, `/v1/models`)
|
||||
- [ ] B.2 — OCP-administrative surface (`/health`, `/dashboard`, `/sessions`, `/logs`, `/status`, `/settings`, `/api/keys*`, `/api/usage`, `/cache*`)
|
||||
- [ ] **Hybrid** — touches both classes (e.g., `/usage` if the PR modifies both the Anthropic wire call AND the local synthesis layer). Both evidence sections below must be filled.
|
||||
- [ ] **Not endpoint-touching** — refactor / docs / tooling that does not modify any request handler. Skip both evidence sections; explain in Summary.
|
||||
|
||||
## Claude Code Alignment Evidence (REQUIRED)
|
||||
|
||||
Per `ALIGNMENT.md`, every PR that touches `server.mjs` or any network-facing surface must fill out this section. PRs with this section blank or unchecked will receive a `request changes` review and cannot be merged.
|
||||
PRs with the relevant evidence section blank or unchecked will receive a `request changes` review and cannot be merged.
|
||||
|
||||
### If Class A
|
||||
|
||||
- [ ] **Corresponding `cli.js` reference.** I have identified the `cli.js` function and line range that performs the operation this PR forwards. Citation (format `cli.js:NNNN` or `cli.js vE4 <functionName>`):
|
||||
<!-- e.g. cli.js:18423-18467 (function: sendUserMessage) -->
|
||||
|
||||
- [ ] **If `cli.js` does not perform this operation**, I have stated this explicitly below and justified the scope under `ALIGNMENT.md` Rule 2. (Note: in almost all cases this means the PR should be closed, not merged. Proxy layers do not invent endpoints.)
|
||||
- [ ] **If `cli.js` does not perform this operation**, I have stated this explicitly below and justified the scope under `ALIGNMENT.md` Rule 2. (Note: in almost all cases this means the PR should be closed, not merged. Proxy layers do not invent endpoints. If the endpoint is in fact Class B, switch the class above and use the Class B section instead.)
|
||||
<!-- Justification, if applicable. Empty is fine when cli.js does perform the operation. -->
|
||||
|
||||
- [ ] **Commit message citations.** Every "Claude Code uses X" or "cli.js uses X" assertion in every commit of this PR is immediately followed by a `cli.js:NNNN` or `cli.js vE4 <functionName>` citation. I have verified this by rereading each commit message.
|
||||
|
||||
### If Class B
|
||||
|
||||
- [ ] **Authorizing ADR.** Cite the ADR number that authorizes the endpoint this PR modifies (e.g., "ADR 0006 — OpenAI shim scope"). For B.1 endpoints (`/v1/chat/completions`, `/v1/models`), this is ADR 0006. For grandfathered B.2 endpoints, this is "ADR 0006 (grandfathered as of v3.16.4)." For new B.2 endpoints, cite the endpoint's own authorizing ADR; if none exists, the PR cannot proceed — the authorizing ADR must be drafted and merged first.
|
||||
<!-- e.g., ADR 0006 -->
|
||||
|
||||
- [ ] **Specification citation.** For B.1 endpoints, link to the relevant section of OpenAI's `/v1/chat/completions` specification (https://platform.openai.com/docs/api-reference/chat/create), including the specific field or behaviour being implemented. For B.2 endpoints with their own ADR, cite the ADR section that specifies the behaviour. For grandfathered B.2 endpoints, the PR must be a behaviour-preserving refactor — link the existing handler code being modified.
|
||||
<!-- B.1 example: OpenAI chat/completions, `response_format` parameter, https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format -->
|
||||
<!-- B.2 example: ADR 00NN § "Behaviour" -->
|
||||
|
||||
- [ ] **No invention beyond the specification.** I confirm this PR does not introduce any field or behaviour not present in OpenAI's spec for the endpoint (B.1) or beyond the scope of the authorizing ADR (B.2). For grandfathered B.2 endpoints, I confirm the change is behaviour-preserving (no contract drift). If something the user actually wants is not in the spec, the right answer is to close this PR and propose an upstream spec change or a new ADR.
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix (alignment with existing `cli.js` behavior)
|
||||
- [ ] Feature (new `cli.js` behavior now surfaced through OCP)
|
||||
- [ ] Bug fix (alignment with existing `cli.js` behavior, or with the cited spec / ADR for Class B)
|
||||
- [ ] Feature (new `cli.js` behavior now surfaced through OCP, or new field already in OpenAI's spec for Class B)
|
||||
- [ ] Refactor (no wire-level behavior change)
|
||||
- [ ] Deletion (unalignable feature removal per `ALIGNMENT.md` Unalignable Policy)
|
||||
- [ ] Documentation / governance
|
||||
@@ -28,13 +52,31 @@ Per `ALIGNMENT.md`, every PR that touches `server.mjs` or any network-facing sur
|
||||
|
||||
Reviewers: this section is for you, not the author. Do not approve until every box is checked.
|
||||
|
||||
- [ ] I opened `cli.js` at the cited line range and confirmed the operation matches.
|
||||
- [ ] If Class A, I opened `cli.js` at the cited line range and confirmed the operation matches. If Class B, I opened the OpenAI spec at the cited section (B.1) or the authorizing ADR (B.2) and confirmed the behaviour described in this PR matches the cited reference.
|
||||
- [ ] I ran (or confirmed CI ran) `.github/workflows/alignment.yml` and it passed.
|
||||
- [ ] I am not the commit author of any commit in this PR (Iron Rule 10).
|
||||
- [ ] If the PR asserts scope without a `cli.js` citation, I confirmed the justification is sound per `ALIGNMENT.md` Rule 2.
|
||||
- [ ] If the PR asserts scope without a `cli.js` citation (Class A) or without an ADR (Class B), I confirmed the justification is sound per `ALIGNMENT.md` Rule 2 and ADR 0006.
|
||||
- [ ] If the PR is Class B and adds a new endpoint or new method, I confirmed the authorizing ADR lands in the same merge or before this PR.
|
||||
|
||||
## Related
|
||||
|
||||
- `ALIGNMENT.md` Rule(s) invoked: <!-- e.g. Rule 3 -->
|
||||
- `ALIGNMENT.md` Rule(s) invoked: <!-- e.g. Rule 3, or Rule 3 (Class B mapping) -->
|
||||
- Authorizing ADR (Class B only): <!-- e.g. ADR 0006 -->
|
||||
- Related issue / prior PR: <!-- #NNN -->
|
||||
- Historical lesson reference (if relevant): <!-- e.g. 2026-04-11 drift, b87992f -->
|
||||
|
||||
### User-visible change self-check (铁律第五律 5.3)
|
||||
|
||||
- [ ] This PR has user-visible changes → README has corresponding documentation (paste diff link or line range)
|
||||
- [ ] This PR has no user-visible changes → stated "no user-visible change" in summary above
|
||||
|
||||
Reviewers: if "user-visible" is checked but README diff is empty, block merge (per 5.3 reviewer gate).
|
||||
|
||||
### Privacy self-check (for PUBLIC repos) — Iron Rule adjacent
|
||||
|
||||
- [ ] This PR does not introduce real names, nicknames, or handles that identify specific individuals. All references use role-based terms (`project maintainer`, `contributor`, `user`, `reviewer`).
|
||||
- [ ] This PR does not introduce literal personal paths (`/Users/<username>/`, `/home/<username>/`). Uses `$HOME/` or `~/` instead.
|
||||
- [ ] This PR does not introduce personal machine hostnames. Uses role-based names or generic descriptors.
|
||||
- [ ] This PR does not introduce personal email addresses beyond automated placeholders like `noreply@<vendor>.com`.
|
||||
|
||||
Reviewers: if any of the above is violated and the repo is PUBLIC, block merge and request scrub.
|
||||
|
||||
@@ -4,6 +4,11 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'server.mjs'
|
||||
- 'setup.mjs'
|
||||
- 'scripts/**'
|
||||
- 'lib/**'
|
||||
- 'ocp'
|
||||
- 'ocp-connect'
|
||||
- '.github/workflows/alignment.yml'
|
||||
|
||||
jobs:
|
||||
@@ -24,10 +29,14 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Known-hallucinated tokens. Extend only via an ALIGNMENT.md amendment PR.
|
||||
# Each token is matched as a fixed string against server.mjs only.
|
||||
# Blacklisted tokens — two kinds (see ALIGNMENT.md "OAuth token-host verification"):
|
||||
# (1) known LLM hallucinations (e.g. the 2026-04-11 /api/oauth/usage drift), and
|
||||
# (2) pinned wrong-host variants of a VERIFIED Class A endpoint (a hit means a
|
||||
# drift to a known-wrong host, not necessarily a hallucination).
|
||||
# Extend only via an ALIGNMENT.md amendment PR. Matched as fixed strings vs server.mjs.
|
||||
BLACKLIST=(
|
||||
"api.anthropic.com/api/oauth/usage"
|
||||
"console.anthropic.com/v1/oauth/token"
|
||||
)
|
||||
|
||||
FAIL=0
|
||||
@@ -46,8 +55,8 @@ jobs:
|
||||
============================================================
|
||||
server.mjs contains a token on the OCP alignment blacklist.
|
||||
|
||||
These tokens were introduced by LLM hallucinations and do
|
||||
not appear in cli.js at any shipped Claude Code version.
|
||||
These tokens are either LLM hallucinations that never appeared in cli.js,
|
||||
or pinned wrong-host variants of a verified Class A endpoint (a drift).
|
||||
See ALIGNMENT.md -> "Historical Lesson: The 2026-04-11 Drift"
|
||||
(commit b87992f) for the full incident record.
|
||||
|
||||
@@ -66,6 +75,80 @@ jobs:
|
||||
|
||||
echo "Blacklist scan clean."
|
||||
|
||||
port-spot:
|
||||
name: port literal SPOT (hard fail)
|
||||
# Background: from 2026-05-08 (PR #71 dogfood accident) through 2026-05-13
|
||||
# a hardcoded "3478" in scripts/upgrade.mjs + scripts/doctor.mjs cascaded
|
||||
# into wrong baseUrl writes for the OpenClaw "claude-local" provider,
|
||||
# taking out the "大内总管" Telegram agent.
|
||||
#
|
||||
# Rule: the only places allowed to write a literal port number in source
|
||||
# are (a) lib/constants.mjs (the SPOT), (b) bash scripts ocp / ocp-connect
|
||||
# (which can't import .mjs and must keep the literal in sync — flagged
|
||||
# with a `// keep in sync with lib/constants.mjs` style comment), and
|
||||
# (c) test-features.mjs (intentionally pins historical ports for plist /
|
||||
# systemd parser tests). Everything else MUST import from lib/constants.mjs.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Scan for hardcoded port literals outside SPOT
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Files/paths exempt from the SPOT requirement.
|
||||
EXEMPT_REGEX='^(lib/constants\.mjs|test-features\.mjs|ocp|ocp-connect|CHANGELOG\.md|README\.md|docs/|\.github/workflows/alignment\.yml)'
|
||||
|
||||
# Hardcoded port literals to forbid in non-exempt source.
|
||||
FORBIDDEN_PORTS=("3478" "3456")
|
||||
|
||||
FAIL=0
|
||||
for port in "${FORBIDDEN_PORTS[@]}"; do
|
||||
HITS="$(git ls-files | grep -E '\.(mjs|js|ts|json)$' \
|
||||
| xargs grep -n -E "[^0-9]${port}[^0-9]" 2>/dev/null \
|
||||
| grep -v -E "${EXEMPT_REGEX}" \
|
||||
|| true)"
|
||||
if [ -n "$HITS" ]; then
|
||||
echo "::error::Hardcoded port literal '${port}' found outside lib/constants.mjs:"
|
||||
echo "$HITS"
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FAIL" -ne 0 ]; then
|
||||
cat <<'EOF'
|
||||
|
||||
============================================================
|
||||
PORT LITERAL SPOT VIOLATION
|
||||
============================================================
|
||||
A hardcoded TCP port literal was found in a source file
|
||||
that should import from lib/constants.mjs instead.
|
||||
|
||||
Background: this rule exists because between 2026-05-08 and
|
||||
2026-05-13 a stray hardcoded "3478" in scripts/upgrade.mjs
|
||||
and scripts/doctor.mjs cascaded into downstream OpenClaw
|
||||
config writes, taking out the OpenClaw Telegram agent.
|
||||
See v3.16.3 CHANGELOG and lib/constants.mjs header comment.
|
||||
|
||||
Required action:
|
||||
1. Import DEFAULT_PORT (or related constant) from
|
||||
lib/constants.mjs instead of hardcoding the literal.
|
||||
2. If the file genuinely cannot import .mjs (e.g. bash
|
||||
script), add it to EXEMPT_REGEX in this workflow and
|
||||
add a `keep in sync with lib/constants.mjs` comment
|
||||
at the reference.
|
||||
3. For test files that intentionally pin historical ports
|
||||
(test-features.mjs), the regex already exempts them.
|
||||
|
||||
============================================================
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Port SPOT scan clean."
|
||||
|
||||
commit-citation:
|
||||
name: commit message citation (soft check)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: gitleaks
|
||||
|
||||
# Secret scanning gate. Runs on every PR (any branch) and every push to main.
|
||||
# Configuration is read from the repo-root `.gitleaks.toml` automatically.
|
||||
# Hard-fails the build on any detected leak — public repo, no tolerance.
|
||||
#
|
||||
# To extend the allowlist (e.g. a new known-safe placeholder), edit
|
||||
# `.gitleaks.toml`. Do not add `continue-on-error` here without an explicit
|
||||
# governance decision.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: gitleaks scan (hard fail)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout (full history)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run gitleaks
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Auto Release on Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Extract version from tag
|
||||
id: ver
|
||||
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
- name: Extract CHANGELOG section
|
||||
id: notes
|
||||
run: |
|
||||
VERSION="${{ steps.ver.outputs.version }}"
|
||||
# Extract section for this version from CHANGELOG.md
|
||||
# Pattern: "## v${VERSION}" through the next "## " or EOF
|
||||
if [ ! -f CHANGELOG.md ]; then
|
||||
echo "No CHANGELOG.md found; using minimal release notes"
|
||||
echo "notes=Release v${VERSION}" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
awk -v ver="v${VERSION}" '
|
||||
$0 ~ "^## " ver { found=1; print; next }
|
||||
found && /^## v/ { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md > /tmp/release-notes.md
|
||||
if [ ! -s /tmp/release-notes.md ]; then
|
||||
echo "No matching section in CHANGELOG for v${VERSION}; using minimal notes"
|
||||
echo "Release v${VERSION}" > /tmp/release-notes.md
|
||||
fi
|
||||
echo "notes_file=/tmp/release-notes.md" >> $GITHUB_OUTPUT
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if gh release view "v${{ steps.ver.outputs.version }}" >/dev/null 2>&1; then
|
||||
echo "Release v${{ steps.ver.outputs.version }} already exists — skipping"
|
||||
exit 0
|
||||
fi
|
||||
gh release create "v${{ steps.ver.outputs.version }}" \
|
||||
--title "v${{ steps.ver.outputs.version }}" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
--latest
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test-features:
|
||||
name: test-features.mjs (smoke)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# `test-features.mjs` is self-contained — it runs assertions against the
|
||||
# `keys.mjs` DB layer using a throwaway test database. It does NOT need a
|
||||
# live claude CLI or a running OCP server. So this job runs as a hard
|
||||
# check on every push / PR.
|
||||
#
|
||||
# If a future expansion of the suite adds tests that DO require a live
|
||||
# claude CLI or a running OCP server, mark those steps `continue-on-error:
|
||||
# true` (or split them into a separate job) — CI must not be flaky on
|
||||
# things outside the contributor's machine.
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
# Node 24 ships `node:sqlite` as stable. The test imports keys.mjs,
|
||||
# which uses `import { DatabaseSync } from "node:sqlite"`.
|
||||
# Node 22 also works with `--experimental-sqlite`, but we run on 24
|
||||
# to keep the CI step simple and to match what released OCP runs on.
|
||||
node-version: '24'
|
||||
|
||||
# OCP has zero runtime npm dependencies (package-lock.json shows only
|
||||
# the project's own package and zero external entries). No install
|
||||
# step needed — `node:*` modules are built into Node 24.
|
||||
|
||||
- name: Run test-features.mjs
|
||||
run: npm test
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Runtime artifacts
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Editor / OS scratch
|
||||
.DS_Store
|
||||
*.swp
|
||||
*~
|
||||
@@ -0,0 +1,14 @@
|
||||
title = "OCP gitleaks configuration"
|
||||
|
||||
[allowlist]
|
||||
description = "OCP known-safe allowlist"
|
||||
regexes = [
|
||||
# Public OAuth client ID for Claude Code PKCE flow (constant, not a secret)
|
||||
'''9d1c250a-e61b-44d9-88ed-5944d1962f5e''',
|
||||
# OCP README API key placeholder example
|
||||
'''ocp_(example|XXX+|xxxx+)''',
|
||||
]
|
||||
paths = [
|
||||
# Old plan doc with test-admin-123 placeholders
|
||||
'''docs/superpowers/plans/2026-04-10-lan-mode\.md''',
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# [CHECKLIST TYPE] Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: [Brief description of what this checklist covers]
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md or relevant documentation]
|
||||
|
||||
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
|
||||
|
||||
<!--
|
||||
============================================================================
|
||||
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
|
||||
|
||||
The /speckit.checklist command MUST replace these with actual items based on:
|
||||
- User's specific checklist request
|
||||
- Feature requirements from spec.md
|
||||
- Technical context from plan.md
|
||||
- Implementation details from tasks.md
|
||||
|
||||
DO NOT keep these sample items in the generated checklist file.
|
||||
============================================================================
|
||||
-->
|
||||
|
||||
## [Category 1]
|
||||
|
||||
- [ ] CHK001 First checklist item with clear action
|
||||
- [ ] CHK002 Second checklist item
|
||||
- [ ] CHK003 Third checklist item
|
||||
|
||||
## [Category 2]
|
||||
|
||||
- [ ] CHK004 Another category item
|
||||
- [ ] CHK005 Item with specific criteria
|
||||
- [ ] CHK006 Final item in this category
|
||||
|
||||
## Notes
|
||||
|
||||
- Check items off as completed: `[x]`
|
||||
- Add comments or findings inline
|
||||
- Link to relevant resources or documentation
|
||||
- Items are numbered sequentially for easy reference
|
||||
@@ -0,0 +1,50 @@
|
||||
# [PROJECT_NAME] Constitution
|
||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
||||
|
||||
## Core Principles
|
||||
|
||||
### [PRINCIPLE_1_NAME]
|
||||
<!-- Example: I. Library-First -->
|
||||
[PRINCIPLE_1_DESCRIPTION]
|
||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
||||
|
||||
### [PRINCIPLE_2_NAME]
|
||||
<!-- Example: II. CLI Interface -->
|
||||
[PRINCIPLE_2_DESCRIPTION]
|
||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
||||
|
||||
### [PRINCIPLE_3_NAME]
|
||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
||||
[PRINCIPLE_3_DESCRIPTION]
|
||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
||||
|
||||
### [PRINCIPLE_4_NAME]
|
||||
<!-- Example: IV. Integration Testing -->
|
||||
[PRINCIPLE_4_DESCRIPTION]
|
||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
||||
|
||||
### [PRINCIPLE_5_NAME]
|
||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
||||
[PRINCIPLE_5_DESCRIPTION]
|
||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
||||
|
||||
## [SECTION_2_NAME]
|
||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
||||
|
||||
[SECTION_2_CONTENT]
|
||||
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
||||
|
||||
## [SECTION_3_NAME]
|
||||
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
||||
|
||||
[SECTION_3_CONTENT]
|
||||
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
||||
|
||||
## Governance
|
||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
||||
|
||||
[GOVERNANCE_RULES]
|
||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
||||
|
||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
||||
@@ -0,0 +1,104 @@
|
||||
# Implementation Plan: [FEATURE]
|
||||
|
||||
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
||||
|
||||
## Summary
|
||||
|
||||
[Extract from feature spec: primary requirement + technical approach from research]
|
||||
|
||||
## Technical Context
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||
for the project. The structure here is presented in advisory capacity to guide
|
||||
the iteration process.
|
||||
-->
|
||||
|
||||
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
|
||||
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
|
||||
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
|
||||
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
|
||||
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
|
||||
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
|
||||
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
|
||||
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
[Gates determined based on constitution file]
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/[###-feature]/
|
||||
├── plan.md # This file (/speckit.plan command output)
|
||||
├── research.md # Phase 0 output (/speckit.plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||
for this feature. Delete unused options and expand the chosen structure with
|
||||
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||
not include Option labels.
|
||||
-->
|
||||
|
||||
```text
|
||||
# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT)
|
||||
src/
|
||||
├── models/
|
||||
├── services/
|
||||
├── cli/
|
||||
└── lib/
|
||||
|
||||
tests/
|
||||
├── contract/
|
||||
├── integration/
|
||||
└── unit/
|
||||
|
||||
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected)
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── models/
|
||||
│ ├── services/
|
||||
│ └── api/
|
||||
└── tests/
|
||||
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
│ └── services/
|
||||
└── tests/
|
||||
|
||||
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
||||
api/
|
||||
└── [same as backend above]
|
||||
|
||||
ios/ or android/
|
||||
└── [platform-specific structure: feature modules, UI flows, platform tests]
|
||||
```
|
||||
|
||||
**Structure Decision**: [Document the selected structure and reference the real
|
||||
directories captured above]
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||
@@ -0,0 +1,128 @@
|
||||
# Feature Specification: [FEATURE NAME]
|
||||
|
||||
**Feature Branch**: `[###-feature-name]`
|
||||
**Created**: [DATE]
|
||||
**Status**: Draft
|
||||
**Input**: User description: "$ARGUMENTS"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
<!--
|
||||
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||
|
||||
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||
Think of each story as a standalone slice of functionality that can be:
|
||||
- Developed independently
|
||||
- Tested independently
|
||||
- Deployed independently
|
||||
- Demonstrated to users independently
|
||||
-->
|
||||
|
||||
### User Story 1 - [Brief Title] (Priority: P1)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
2. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - [Brief Title] (Priority: P2)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - [Brief Title] (Priority: P3)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
[Add more user stories as needed, each with an assigned priority]
|
||||
|
||||
### Edge Cases
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right edge cases.
|
||||
-->
|
||||
|
||||
- What happens when [boundary condition]?
|
||||
- How does system handle [error scenario]?
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right functional requirements.
|
||||
-->
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
|
||||
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
|
||||
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
|
||||
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
|
||||
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
|
||||
|
||||
*Example of marking unclear requirements:*
|
||||
|
||||
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
|
||||
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **[Entity 1]**: [What it represents, key attributes without implementation]
|
||||
- **[Entity 2]**: [What it represents, relationships to other entities]
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Define measurable success criteria.
|
||||
These must be technology-agnostic and measurable.
|
||||
-->
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"]
|
||||
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
|
||||
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
|
||||
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
|
||||
|
||||
## Assumptions
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right assumptions based on reasonable defaults
|
||||
chosen when the feature description did not specify certain details.
|
||||
-->
|
||||
|
||||
- [Assumption about target users, e.g., "Users have stable internet connectivity"]
|
||||
- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"]
|
||||
- [Assumption about data/environment, e.g., "Existing authentication system will be reused"]
|
||||
- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"]
|
||||
@@ -0,0 +1,251 @@
|
||||
---
|
||||
|
||||
description: "Task list template for feature implementation"
|
||||
---
|
||||
|
||||
# Tasks: [FEATURE NAME]
|
||||
|
||||
**Input**: Design documents from `/specs/[###-feature-name]/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
## Path Conventions
|
||||
|
||||
- **Single project**: `src/`, `tests/` at repository root
|
||||
- **Web app**: `backend/src/`, `frontend/src/`
|
||||
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
|
||||
- Paths shown below assume single project - adjust based on plan.md structure
|
||||
|
||||
<!--
|
||||
============================================================================
|
||||
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
|
||||
|
||||
The /speckit.tasks command MUST replace these with actual tasks based on:
|
||||
- User stories from spec.md (with their priorities P1, P2, P3...)
|
||||
- Feature requirements from plan.md
|
||||
- Entities from data-model.md
|
||||
- Endpoints from contracts/
|
||||
|
||||
Tasks MUST be organized by user story so each story can be:
|
||||
- Implemented independently
|
||||
- Tested independently
|
||||
- Delivered as an MVP increment
|
||||
|
||||
DO NOT keep these sample tasks in the generated tasks.md file.
|
||||
============================================================================
|
||||
-->
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Project initialization and basic structure
|
||||
|
||||
- [ ] T001 Create project structure per implementation plan
|
||||
- [ ] T002 Initialize [language] project with [framework] dependencies
|
||||
- [ ] T003 [P] Configure linting and formatting tools
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
Examples of foundational tasks (adjust based on your project):
|
||||
|
||||
- [ ] T004 Setup database schema and migrations framework
|
||||
- [ ] T005 [P] Implement authentication/authorization framework
|
||||
- [ ] T006 [P] Setup API routing and middleware structure
|
||||
- [ ] T007 Create base models/entities that all stories depend on
|
||||
- [ ] T008 Configure error handling and logging infrastructure
|
||||
- [ ] T009 Setup environment configuration management
|
||||
|
||||
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: [Brief description of what this story delivers]
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️
|
||||
|
||||
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||
|
||||
- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||
- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py
|
||||
- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py
|
||||
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
|
||||
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
- [ ] T016 [US1] Add validation and error handling
|
||||
- [ ] T017 [US1] Add logging for user story 1 operations
|
||||
|
||||
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - [Title] (Priority: P2)
|
||||
|
||||
**Goal**: [Brief description of what this story delivers]
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️
|
||||
|
||||
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py
|
||||
- [ ] T021 [US2] Implement [Service] in src/services/[service].py
|
||||
- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
- [ ] T023 [US2] Integrate with User Story 1 components (if needed)
|
||||
|
||||
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - [Title] (Priority: P3)
|
||||
|
||||
**Goal**: [Brief description of what this story delivers]
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️
|
||||
|
||||
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py
|
||||
- [ ] T027 [US3] Implement [Service] in src/services/[service].py
|
||||
- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
|
||||
**Checkpoint**: All user stories should now be independently functional
|
||||
|
||||
---
|
||||
|
||||
[Add more user story phases as needed, following the same pattern]
|
||||
|
||||
---
|
||||
|
||||
## Phase N: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Improvements that affect multiple user stories
|
||||
|
||||
- [ ] TXXX [P] Documentation updates in docs/
|
||||
- [ ] TXXX Code cleanup and refactoring
|
||||
- [ ] TXXX Performance optimization across all stories
|
||||
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||
- [ ] TXXX Security hardening
|
||||
- [ ] TXXX Run quickstart.md validation
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies - can start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
|
||||
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
|
||||
- User stories can then proceed in parallel (if staffed)
|
||||
- Or sequentially in priority order (P1 → P2 → P3)
|
||||
- **Polish (Final Phase)**: Depends on all desired user stories being complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
|
||||
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable
|
||||
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests (if included) MUST be written and FAIL before implementation
|
||||
- Models before services
|
||||
- Services before endpoints
|
||||
- Core implementation before integration
|
||||
- Story complete before moving to next priority
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- All Setup tasks marked [P] can run in parallel
|
||||
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
|
||||
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)
|
||||
- All tests for a user story marked [P] can run in parallel
|
||||
- Models within a story marked [P] can run in parallel
|
||||
- Different user stories can be worked on in parallel by different team members
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch all tests for User Story 1 together (if tests requested):
|
||||
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
|
||||
Task: "Integration test for [user journey] in tests/integration/test_[name].py"
|
||||
|
||||
# Launch all models for User Story 1 together:
|
||||
Task: "Create [Entity1] model in src/models/[entity1].py"
|
||||
Task: "Create [Entity2] model in src/models/[entity2].py"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup
|
||||
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
|
||||
3. Complete Phase 3: User Story 1
|
||||
4. **STOP and VALIDATE**: Test User Story 1 independently
|
||||
5. Deploy/demo if ready
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Setup + Foundational → Foundation ready
|
||||
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
|
||||
3. Add User Story 2 → Test independently → Deploy/Demo
|
||||
4. Add User Story 3 → Test independently → Deploy/Demo
|
||||
5. Each story adds value without breaking previous stories
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple developers:
|
||||
|
||||
1. Team completes Setup + Foundational together
|
||||
2. Once Foundational is done:
|
||||
- Developer A: User Story 1
|
||||
- Developer B: User Story 2
|
||||
- Developer C: User Story 3
|
||||
3. Stories complete and integrate independently
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- Each user story should be independently completable and testable
|
||||
- Verify tests fail before implementing
|
||||
- Commit after each task or logical group
|
||||
- Stop at any checkpoint to validate story independently
|
||||
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence
|
||||
@@ -0,0 +1,74 @@
|
||||
Inherits: @~/.cc-rules/AGENTS.md
|
||||
|
||||
# OCP — Open Claude Proxy — Agent Guidelines
|
||||
|
||||
**Scope**: the `dtzp555-max/ocp` repository.
|
||||
**Audience**: any AI coding agent (Claude Code / Cursor / OpenCode / Copilot / Codex / Gemini) touching OCP source.
|
||||
|
||||
---
|
||||
|
||||
## What this project is
|
||||
|
||||
OCP (Open Claude Proxy) is an open-source HTTP gateway that sits between the Claude Code CLI (`cli.js`) and Anthropic's public API. It forwards, observes, and multiplexes traffic that `cli.js` already emits — it is explicitly **not** an extension layer. A secondary role: registering OCP as a local provider inside OpenClaw (a sibling IDE-agnostic tool), so that users running OpenClaw against OCP see the same model list as native Claude Code.
|
||||
|
||||
Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mjs` is the single executable entrypoint; `ocp` and `ocp-connect` are CLI wrappers.
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
- Node.js >=18, native ESM modules
|
||||
- `http`/`https` built-ins for the proxy core (no Express, no Fastify)
|
||||
- `models.json` as the single source of truth for model metadata
|
||||
- GitHub Actions for CI (`alignment.yml`, `release.yml`)
|
||||
- `gh` CLI assumed for PR creation and release automation
|
||||
- No TypeScript. No test framework beyond `test-features.mjs` (run via `npm test`; CI workflow `.github/workflows/test.yml`). Keep dependencies minimal.
|
||||
|
||||
---
|
||||
|
||||
## Key files to know
|
||||
|
||||
- `server.mjs` — the proxy itself; every request path lives here. Governed by `ALIGNMENT.md`.
|
||||
- `models.json` — single source of truth for model IDs, aliases, and context windows. See ADR 0003.
|
||||
- `setup.mjs` — first-time installer; reads `models.json` to derive bootstrap config.
|
||||
- `scripts/sync-openclaw.mjs` — idempotent OpenClaw registry sync invoked by `ocp update`. See ADR 0004.
|
||||
- `ocp` — user-facing CLI (install, update, start, stop, status, logs, etc.).
|
||||
- `ALIGNMENT.md` — the constitution. Binding for any `server.mjs` change. See ADR 0002.
|
||||
- `.github/workflows/alignment.yml` — CI blacklist grep; fails the build on known-hallucinated tokens.
|
||||
- `CLAUDE.md` — Claude-Code-specific session instructions + release_kit overlay (Iron Rule 5.5).
|
||||
- `docs/adr/` — Architecture Decision Records. Read these before proposing governance or SPOT changes. See `docs/adr/README.md` for the index.
|
||||
- `docs/superpowers/plans/` — active spec-kit plans. `docs/superpowers/plans/shipped/` archives plans that have been delivered (don't propose changes against shipped plans — they're history). `docs/superpowers/specs/` holds long-lived design documents that other code references (e.g., the SSE heartbeat design referenced from `server.mjs`).
|
||||
- `memory/constitution.md` — spec-kit's project constitution (its standard `memory/` location). Distinct from `~/.cc-rules/memory/` (cross-machine personal memory) and from this repo's `ALIGNMENT.md` (the OCP code-level constitution).
|
||||
|
||||
---
|
||||
|
||||
## Project-specific constraints
|
||||
|
||||
- **`ALIGNMENT.md` is binding.** Any PR touching `server.mjs` must cite `cli.js:NNNN` (or `cli.js vE4 <functionName>`) in the commit body and PR description. See `CLAUDE.md` § "Hard requirements for `server.mjs` changes" and ADR 0002.
|
||||
- **Alignment CI is not suppressible.** The `alignment.yml` workflow greps `server.mjs` for known-hallucinated tokens (currently blocking `api.anthropic.com/api/oauth/usage`). Adding new tokens is done via PR amendment to `alignment.yml`; removing entries requires an `ALIGNMENT.md` amendment PR.
|
||||
- **No self-approval.** Implementation author cannot merge their own PR (Iron Rule 10). A fresh-context reviewer must open `cli.js` at the cited lines and confirm in the review comment.
|
||||
- **`models.json` is the only place to add/edit models.** Do not touch `MODEL_MAP` or `MODELS` arrays directly in `server.mjs` or `setup.mjs`. See ADR 0003.
|
||||
- **OpenClaw boundary.** `scripts/sync-openclaw.mjs` only writes `models.providers["claude-local"].models` and `agents.defaults.models["claude-local/*"]` in `~/.openclaw/openclaw.json`. Do not expand scope. See ADR 0004.
|
||||
|
||||
---
|
||||
|
||||
## Release protocol
|
||||
|
||||
OCP follows the machine-readable `release_kit:` overlay in `CLAUDE.md` (Iron Rule 5.5). Before any version bump or tag push, re-read that YAML block and walk every item in `new_feature_doc_expectations` and `bootstrap_quirk_policy`. Tag push triggers `.github/workflows/release.yml`, which creates the GitHub Release automatically — do not create the release manually.
|
||||
|
||||
Version is sourced from `package.json`; changelog from `CHANGELOG.md`; user-facing docs from `README.md`.
|
||||
|
||||
---
|
||||
|
||||
## Handoff expectations
|
||||
|
||||
A fresh session picking up OCP work should read, in order:
|
||||
|
||||
1. This file (`AGENTS.md`).
|
||||
2. `ALIGNMENT.md` — constitution; non-optional.
|
||||
3. `CLAUDE.md` — tool-specific instructions and release_kit overlay.
|
||||
4. `docs/adr/` — most recent ADRs first; they explain why the current structure exists.
|
||||
5. Any active plan under `docs/superpowers/plans/` (excluding `shipped/` which is the archive).
|
||||
6. `~/.cc-rules/memory/auto/MEMORY.md` — cross-machine memory index.
|
||||
|
||||
Only after these should the session touch code.
|
||||
+87
-5
@@ -8,10 +8,14 @@
|
||||
|
||||
OCP (Open Claude Proxy) is a **proxy layer** for the Claude Code CLI. It forwards, observes, and multiplexes the traffic that `cli.js` already emits. It is **not** an extension layer. If `cli.js` does not perform a given operation, or performs it differently, OCP does not invent one.
|
||||
|
||||
This Core Principle applies in full to **Class A** endpoints (the `cli.js`-mirror surface). A second class of endpoint — **Class B**, the OCP-owned compatibility surface — has its own scope discipline anchored to its own specification authority. See "Scope Clarification: OCP-Owned Compatibility Endpoints (Class B)" below and ADR 0006.
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
The following Rules apply to **Class A operations** (the `cli.js`-mirror surface — the inbound `/v1/messages` forwarding route, the outbound `/v1/messages` wire call used by `handleUsage()` for rate-limit-header extraction, the OAuth bearer machinery, and any future operations OCP forwards from `cli.js` to Anthropic). For the Class B mapping of each rule, see the Class B section below.
|
||||
|
||||
1. **Rule 1 (Grep First).** Before adding, renaming, or changing any endpoint, header, parameter, or response shape, the author must `grep` the reference `cli.js` and record the exact line numbers in the commit message and PR body. An absent grep hit is itself a finding and must be declared.
|
||||
|
||||
2. **Rule 2 (No Invention).** OCP must not introduce endpoints, headers, request fields, or response fields that are not present in `cli.js`. Speculative "Claude Code probably uses X" statements are prohibited. If the behavior is not observable in `cli.js`, the feature is out of scope.
|
||||
@@ -44,10 +48,30 @@ OCP (Open Claude Proxy) is a **proxy layer** for the Claude Code CLI. It forward
|
||||
- **Claude Code version under audit:** `2.1.89`
|
||||
- **`cli.js` SHA-256:** `a9950ef6407fdc750bddb673852485500387e524a99d42385cb81e7d17128e01`
|
||||
- **Audit date:** `2026-04-20`
|
||||
- **Auditor:** `Tao Deng`
|
||||
- **Auditor:** `project maintainer`
|
||||
|
||||
The audit pin is updated once per year (see Annual Alignment Audit) and whenever a drift incident forces a re-verification.
|
||||
|
||||
### OAuth token-host verification (2026-05-31)
|
||||
|
||||
Motivating evidence: the 2026-05-31 code audit (issues #112 / #119 / #123). The OAuth bearer
|
||||
machinery is a Class A surface (Rules 1–5). Because `cli.js` now ships as a
|
||||
compiled binary, the token-refresh host was re-verified against `claude.exe` (Claude Code
|
||||
`2.1.154`) on 2026-05-31 using the compiled-binary protocol — `strings` on the Mach-O, **no
|
||||
live OAuth probe** (a `refresh_token` grant would rotate the operator's real credentials):
|
||||
|
||||
- **Verified host:** `https://platform.claude.com/v1/oauth/token` — present in the binary
|
||||
byte-for-byte, paired with `OAUTH_CLIENT_ID` in the same `prod` config object (matches
|
||||
`server.mjs` `OAUTH_TOKEN_URL` / `OAUTH_CLIENT_ID`). The legacy `console.anthropic.com/v1/oauth`
|
||||
host is absent (0 hits).
|
||||
- **Pinned wrong-host variant:** `console.anthropic.com/v1/oauth/token` is added to the
|
||||
`alignment.yml` blacklist so a future accidental revert to the legacy host hard-fails CI.
|
||||
|
||||
The blacklist therefore now holds two kinds of token: (1) known hallucinations (e.g.
|
||||
`api.anthropic.com/api/oauth/usage`, the 2026-04-11 drift), and (2) pinned wrong-host variants
|
||||
of a *verified* Class A endpoint. A blacklist hit means either a re-introduced hallucination
|
||||
**or** a drift to a known-wrong host — both are alignment failures under Rules 2 and 3.
|
||||
|
||||
---
|
||||
|
||||
## Historical Lesson: The 2026-04-11 Drift
|
||||
@@ -68,20 +92,78 @@ On 2026-04-11, commit `b87992f` ("fix: use dedicated /api/oauth/usage endpoint f
|
||||
|
||||
## Unalignable Policy
|
||||
|
||||
A feature is **unalignable** if, after a good-faith search, it cannot be mapped to a specific `cli.js` line range or function.
|
||||
A feature is **unalignable** if, after a good-faith search, it cannot be mapped to a specific `cli.js` line range or function (Class A) or to a specific OpenAI specification section AND an authorizing ADR (Class B).
|
||||
|
||||
- Unalignable features are **deleted**, not disabled, not feature-flagged, not deprecated.
|
||||
- Deletion is the default outcome of an alignment audit finding. The burden of proof is on the feature, not on the auditor.
|
||||
- A deletion PR does not require user-facing deprecation notice, because the feature was never legitimately in scope.
|
||||
- If a user workflow depended on an unalignable feature, the correct remediation is to upstream the behavior into `cli.js` or to move it out of OCP into a separate tool. OCP does not retain it.
|
||||
- If a user workflow depended on an unalignable feature, the correct remediation is to upstream the behavior into `cli.js` (Class A) or into OpenAI's spec (Class B) or to move it out of OCP into a separate tool. OCP does not retain it.
|
||||
|
||||
---
|
||||
|
||||
## Scope Clarification: OCP-Owned Compatibility Endpoints (Class B)
|
||||
|
||||
OCP has two classes of endpoint. Rules 1–5 above were drafted in the aftermath of the 2026-04-11 forwarding drift and are written in the language of a one-to-one proxy; they apply verbatim to **Class A** endpoints. **Class B** endpoints — the OCP-owned compatibility surface where `cli.js` is not the wire authority — have their own scope discipline, anchored to their own specification authority. The full rationale lives in **ADR 0006 (OpenAI Shim Scope)**.
|
||||
|
||||
**Class A** — `cli.js`-mirror endpoints. The endpoint exists because `cli.js` performs the equivalent operation and OCP forwards, observes, or multiplexes that operation. Rules 1–5 above apply verbatim. Citation format: `cli.js:NNNN` or `cli.js vE4 <functionName>`.
|
||||
|
||||
**Class B** — OCP-owned compatibility endpoints. The endpoint exists because OCP itself surfaces it, with no `cli.js` analogue. Two sub-buckets: **B.1** (OpenAI-compatibility surface — protocol authority is OpenAI's `/v1/chat/completions` specification) and **B.2** (OCP-administrative surface — authority is the ADR that authorized the endpoint's existence).
|
||||
|
||||
### Grandfather provision for existing B.2 inventory
|
||||
|
||||
ADR 0006 retroactively authorizes the B.2 endpoints listed in the inventory table below, **frozen at their current behaviour as of v3.16.4**. This is a one-time provision; it does not extend to new B.2 endpoints or to B.1 endpoints. Any change to the contract (request shape, response shape, semantics) of a grandfathered B.2 endpoint is treated as a new authorization request and requires either a behaviour-preserving refactor PR or its own ADR. Any new B.2 endpoint, or any new method on a grandfathered B.2 endpoint, requires its own ADR before merge.
|
||||
|
||||
### Current Class B inventory
|
||||
|
||||
| Endpoint | Method | Sub-bucket | Authorizing ADR |
|
||||
|---|---|---|---|
|
||||
| `/v1/chat/completions` | POST | B.1 (OpenAI-compat) | ADR 0006 |
|
||||
| `/v1/models` | GET | B.1 (OpenAI-compat) | ADR 0006; content sourced from `models.json` per ADR 0003 |
|
||||
| `/health` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/dashboard` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/sessions` | GET, DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/logs` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/status` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/settings` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/api/keys` | GET, POST | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/api/keys/:id` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/api/keys/:id/quota` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/api/usage` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/cache/stats` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/cache` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
|
||||
**Hybrid note.** `/usage` is a hybrid endpoint: the underlying call to `api.anthropic.com/v1/messages` (used to extract `anthropic-ratelimit-unified-*` headers, per the in-file comment block at `server.mjs` line 845–849) is Class A and requires the standard `cli.js` citation; the local synthesis layer that adds `proxy:` stats and `models:` snapshot is Class B and is authorized by ADR 0006. A PR touching only the wire-call layer is Class A; a PR touching only the synthesis layer is Class B; a PR touching both must satisfy both citation requirements.
|
||||
|
||||
### Class B citation requirement
|
||||
|
||||
Class B PRs cite **the relevant specification section + the authorizing ADR**, in place of `cli.js:NNNN`. Examples:
|
||||
|
||||
- B.1: "OpenAI `chat/completions` API, `response_format` parameter (https://platform.openai.com/docs/api-reference/chat/create), authorized by ADR 0006."
|
||||
- B.2 (grandfathered): "Authorized by ADR 0006 (grandfathered as of v3.16.4)."
|
||||
- B.2 (with its own ADR): "Authorized by ADR 00NN (the ADR that originally authorized the endpoint)."
|
||||
|
||||
### Rule mapping for Class B
|
||||
|
||||
| Class A rule | Class B mapping |
|
||||
|---|---|
|
||||
| Rule 1 (Grep First) | Read the cited OpenAI spec section (B.1) or the authorizing ADR (B.2) before writing code. Record the spec URL and ADR number in the PR body. |
|
||||
| Rule 2 (No Invention) | OCP must not introduce fields or behaviour not present in OpenAI's spec for the endpoint (B.1) or outside the scope of the authorizing ADR (B.2). For grandfathered B.2 endpoints, "scope" is the v3.16.4 behaviour snapshot. |
|
||||
| Rule 3 (Match the Implementation) | Match OpenAI's spec wire-format (B.1) or the ADR's specified behaviour (B.2). |
|
||||
| Rule 4 (Unalignable Features Are Deleted) | A Class B endpoint that maps to nothing in OpenAI's spec **and** lacks an authorizing ADR (including not being in the grandfather inventory) is unalignable and is deleted on the same terms as a Class A unalignable feature. |
|
||||
| Rule 5 (Cite Line Numbers in Commits) | Cite the OpenAI spec section URL + authorizing ADR number in the commit body (B.1) or the authorizing ADR number alone (B.2). |
|
||||
|
||||
### New Class B endpoint procedure
|
||||
|
||||
Any new Class B endpoint, or any new method on an existing Class B endpoint (including grandfathered ones), requires its own ADR before merge. An "ADR-less" new Class B endpoint is itself an alignment finding under Rule 4.
|
||||
|
||||
---
|
||||
|
||||
## Annual Alignment Audit
|
||||
|
||||
- **Date:** 11 April each year (the anniversary of the `b87992f` drift).
|
||||
- **Scope:** Diff the current `cli.js` against the pinned SHA-256 in the Golden Reference section. For every network call in `server.mjs`, re-verify that the corresponding `cli.js` reference still exists at the cited line numbers (adjust citations if line numbers shifted across Claude Code versions).
|
||||
- **Output:** A signed audit note committed to `docs/alignment-audits/YYYY-04-11.md`, updating the pin.
|
||||
- **Scope (Class A):** Diff the current `cli.js` against the pinned SHA-256 in the Golden Reference section. For every network call in `server.mjs`, re-verify that the corresponding `cli.js` reference still exists at the cited line numbers (adjust citations if line numbers shifted across Claude Code versions).
|
||||
- **Scope (Class B):** Audit B.1 endpoints against OpenAI's current `/v1/chat/completions` specification snapshot. Audit B.2 endpoints against their authorizing ADR — for grandfathered endpoints, verify the endpoint behaviour still matches its v3.16.4 snapshot; for ADR-specific endpoints, verify behaviour still matches the ADR. The B.1 specification pin lives in `docs/openai-compat-pin.md` (created alongside the first B.1 audit; not required for ADR 0006 to land).
|
||||
- **Output:** A signed audit note committed to `docs/alignment-audits/YYYY-04-11.md`, updating the Class A pin and (once `docs/openai-compat-pin.md` exists) the B.1 pin.
|
||||
- **Failure mode:** Any audit finding that cannot be reconciled triggers an immediate deletion PR per the Unalignable Policy.
|
||||
|
||||
---
|
||||
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
# Changelog
|
||||
|
||||
## v3.20.1 — 2026-06-13
|
||||
|
||||
TUI-mode auth hardening: fixes the recurring `Please run /login · API Error: 401` (the PI231 incident) and reaps leaked defunct `claude` sessions. ([#141](https://github.com/dtzp555-max/ocp/pull/141))
|
||||
|
||||
### Fixed
|
||||
|
||||
- **TUI 401 / credential corruption (#141)** — interactive `claude` prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var (unlike `-p` mode, where the env token wins). OCP TUI's per-request spawn + `kill-session` cycle raced claude's single-use refresh-token rotation, corrupting the refresh token to an empty string → permanent 401 that `claude /login` couldn't fix (each new spawn re-corrupted it). This bit Linux/file-based hosts specifically (macOS reads credentials from the Keychain, so Mac mini was immune). **Fix:** when `CLAUDE_CODE_OAUTH_TOKEN` is set, the TUI claude now runs in a credential-free scratch HOME (`<HOME>/.ocp-tui/home`, overridable by `OCP_TUI_HOME`) seeded with onboarding + cwd-trust but **no `.credentials.json`**, so the env token is the only credential and claude never runs the refresh path. Recurrence-proof — a later `claude login` can no longer break TUI. Also: `buildTuiCmd` passes `CLAUDE_CODE_OAUTH_TOKEN` to the spawn, and `reapStaleTuiSessions` reaps defunct `claude` sessions (tmux-server-owned zombies) via `kill-server` when no foreign session remains, plus a 15-min idle-gated periodic reap. When the env token is unset, behaviour is byte-for-byte unchanged (real-home + credentials.json). Two independent fresh-context reviewers (Iron Rule 10) + a live PI231 portability test (works with a corrupt credentials.json present). Authorized by the ADR 0007 PR-D amendment (Class B).
|
||||
|
||||
### Environment variables
|
||||
|
||||
- `CLAUDE_CODE_OAUTH_TOKEN` — when set on a TUI host, TUI authenticates via this long-lived token in a credential-isolated home (recommended; immune to credentials.json corruption).
|
||||
- `OCP_TUI_HOME` — overrides the TUI scratch home; if you previously pointed it at your real home, unset it to get the credential-isolated default.
|
||||
|
||||
## v3.20.0 — 2026-06-10
|
||||
|
||||
TUI-mode billing-safety hardening for the 2026-06-15 Anthropic billing split. A 5-dimension multi-agent audit (adversarial verification + live tests on all three hosts — PI231 / Oracle / Mac mini, claude 2.1.104 / 2.1.114 / 2.1.170) found the TUI subscription-pool path could silently bill the metered Agent SDK pool or poison the cache under realistic failure modes. Three PRs, each with a fresh-context reviewer (Iron Rule 10) and CI; the default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
|
||||
|
||||
### TUI — honesty & cache correctness (#137)
|
||||
|
||||
- **C-1** — `callClaudeTui` now throws on a claude-CLI auth-failure banner (e.g. `Please run /login · API Error: 401 …`, `Failed to authenticate. API Error: 401 …`) instead of returning it as a real answer, so it is never cached, singleflight-shared, or counted as a model success. Conservative detector (whole trimmed text ≤100 chars + `API Error: 4xx` + auth keyword + no code/quote char); overridable via `CLAUDE_TUI_ERROR_PATTERNS`. Live-reproduced on PI231.
|
||||
- **C-2** — `readTuiTranscript` distinguishes a complete turn from a wallclock-truncated partial (`truncated` flag); `callClaudeTui` throws `tui_wallclock_truncated` so a partial is never cached or counted as success.
|
||||
- **C-3** — `verifyEntrypoint` reads the `entrypoint` field from any transcript line, not just `{system, turn_duration}` — some claude builds emit zero turn_duration lines (live-confirmed on Oracle's claude 2.1.114), which previously left the billing-drift assertion blind on those builds.
|
||||
- **C-4 (paste)** — short prompts (e.g. `hi`) could never pass paste-landing detection; threshold lowered. Live-reproduced on PI231.
|
||||
|
||||
### TUI — concurrency & observability (#139)
|
||||
|
||||
- **Concurrency** — `OCP_TUI_MAX_CONCURRENT` (default 2) bounds concurrent interactive `claude` boots via a queuing semaphore (`lib/tui/semaphore.mjs`); the slot is released on throw so honesty-gate / spawn failures never leak it; bounded wait-queue → `tui_queue_full` (503). Independent of the global `MAX_CONCURRENT` (8) — a TUI turn is a heavy per-request cold-boot of tmux+claude + up to 120s wallclock.
|
||||
- **Observability** — additive `/health` `tui` block (`enabled` / `entrypointMode` / `lastEntrypoint` / `entrypointMismatches` / `inflight` / `maxConcurrent`) so an operator can poll for a silent `sdk-cli` metered-pool drift (the audit's top risk) instead of grepping journald. Authorized by the ADR 0007 PR-B amendment under the ALIGNMENT grandfather provision (additive, behaviour-preserving — every pre-existing `/health` field unchanged).
|
||||
|
||||
### Operations (#138)
|
||||
|
||||
- `docs/runbooks/615-canary.md` — the 2026-06-15 credit-balance canary: quiesce, read the Agent SDK credit balance (manual — no programmatic API exists for that pool; OCP's `/usage` headers are subscription rate-limit data, not the credit pool), one TUI canary turn, confirm `entrypoint:cli` in the transcript, green/red decision tree, periodic auto-mode self-classification mini-canary.
|
||||
- `docs/runbooks/tui-flip-rollback.md` — flip/rollback per deployment (systemd `daemon-reload`; launchd `bootout`/`bootstrap`, not `kickstart -k`).
|
||||
- `setup.mjs` auth quick-test gated behind `OCP_SKIP_AUTH_TEST=1` (the `claude -p` probe draws from the metered Agent SDK pool after 6/15).
|
||||
|
||||
### New environment variables
|
||||
|
||||
- `OCP_TUI_MAX_CONCURRENT` — max concurrent interactive TUI turns (default 2) (#139).
|
||||
- `OCP_SKIP_AUTH_TEST` — skip the `claude -p` auth probe in `setup.mjs` (default off) (#138).
|
||||
|
||||
## v3.19.0 — 2026-06-02
|
||||
|
||||
TUI-mode reliability + proxy-purity release. Two fixes diagnosed and verified live on both test hosts (PI231 / Oracle, claude 2.1.104 / 2.1.114), each its own PR with a fresh-context reviewer (Iron Rule 10), then an adversarial multi-host test battery (0 hangs / 0 crashes / 0 injection / 0 leaks). The default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
|
||||
|
||||
### TUI
|
||||
|
||||
- **#130** — Fixed the "stuck typing" hang on large multi-line prompts. Three root causes: (1) terminal-turn detection only recognized `{system, turn_duration}`, which older claude builds (e.g. 2.1.114) don't emit → the reader ran to the wallclock and returned partial text; now also accepts an `assistant` line with a final `stop_reason` (`end_turn`/`stop_sequence`/`max_tokens`), while `tool_use` stays non-terminal. (2) Large prompts pasted via `send-keys -l` delivered embedded newlines as separate Enter events → the prompt never landed; now uses `tmux load-buffer` + `paste-buffer -p` (bracketed paste, atomic). (3) The paste-landed check false-positived on claude's empty curly-quote placeholder → Enter fired into an empty box; now positive-signal-only (`[Pasted text]` / prompt text) with a readiness/paste-verify poll + fast-fail (deterministic ~5s error instead of a 120s wallclock hang).
|
||||
- **#4** — TUI-mode never injects the host's `CLAUDE.md` / auto-memory into proxied turns. OCP is a proxy: the proxied client (OpenClaw / an IDE) owns its own context and memory. `buildTuiCmd` now always sets `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY` (unconditional — proxy purity is not an opt-in). Verified live with a marker `CLAUDE.md`: obeyed by the proxied turn before the fix, blocked after, on both hosts. Residual host-context vectors (managed-policy / `settings.json` / output-styles) tracked in #133. The env is delivered via an `env`-prefix on the tmux pane command (tmux does not forward the spawning process's environment, and `new-session -e` requires tmux ≥3.2 while the cloud host runs 2.7).
|
||||
|
||||
## v3.18.0 — 2026-06-01
|
||||
|
||||
Hardening release from a multi-agent code audit (1 P0 + 14 P2 + 2 P3 findings, each adversarially verified and independently reviewed) plus three follow-ups (#123–#125). Every change shipped as its own PR with a fresh-context reviewer (Iron Rule 10). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical **except** the `/health` change in #109.
|
||||
|
||||
### Security
|
||||
|
||||
- **#109 (P0)** — `/health` no longer advertises `PROXY_ANONYMOUS_KEY` to remote callers by default. The `anonymousKey` field is gated behind a new `PROXY_ADVERTISE_ANON_KEY=1` opt-in env var; localhost callers are always exempt. Prevents any LAN-reachable device from harvesting a working, quota-spending bearer credential from the unauthenticated `/health` endpoint. **Behavior change:** `ocp-connect` zero-config Path A now requires the server to set `PROXY_ADVERTISE_ANON_KEY=1`; otherwise pass `--key` or use anonymous access.
|
||||
- **#114** — Dashboard escapes all DB-sourced strings (key names, usage rows) before `innerHTML`; the revoke button uses a `data-` attribute + listener instead of an inline `onclick` a quote could break out of; `POST /api/keys` validates key names server-side (`[A-Za-z0-9 ._-]{1,64}`).
|
||||
- **#124** — Dashboard status/plan summary cards escaped too (uniform defense-in-depth over all `innerHTML` sinks).
|
||||
- **#111** — Streaming error paths strip filesystem paths from claude error text / stderr before sending them to clients (`sanitizeError`), matching the non-streaming path.
|
||||
|
||||
### Reliability / correctness
|
||||
|
||||
- **#110** — Non-array `messages` is rejected with a 400 (was silently hanging the connection until socket timeout); OpenAI array `content` is flattened into the prompt instead of dumped as raw JSON; a streamed upstream error now emits an SSE `error` frame instead of a success-looking `finish_reason:"stop"`.
|
||||
- **#111** — `res.on("close")` escalates SIGTERM→SIGKILL on client disconnect (closes a narrow re-occurrence of the #37 concurrency-slot leak on the hottest exit path); `overallTimer` is cleared on semantic completion so a slow-exiting child can't record a spurious post-success timeout; per-key quota is documented as best-effort (bounded overshoot ≤ `MAX_CONCURRENT`, cache hits uncounted).
|
||||
- **#113** — CLI/installer hardening: `ocp-plugin` restart uses the live uid + `dev.ocp.proxy`/`ocp-proxy` labels and drops the unsafe `pkill` fallback; `ocp-connect` quotes + `chmod 600`s the persisted key; `setup.mjs` XML-escapes and newline-validates injected service-unit secrets.
|
||||
|
||||
### Alignment / governance
|
||||
|
||||
- **#112** — OAuth token-refresh host (`platform.claude.com/v1/oauth/token`) re-verified against the compiled cli.js v2.1.154 (`strings`, no live probe) and recorded in `ALIGNMENT.md`; usage-probe and default request model now derive from `models.json` (ADR 0003 SPOT) instead of hardcoded IDs.
|
||||
- **#123** — The legacy `console.anthropic.com/v1/oauth/token` host is pinned in the `alignment.yml` blacklist so a future OAuth-host drift hard-fails CI; the blacklist now documents its dual purpose (known hallucinations + pinned wrong-host variants of a verified Class A endpoint).
|
||||
|
||||
### TUI
|
||||
|
||||
- **#115** — The TUI LAN gate refuses any non-loopback bind (not just literal `0.0.0.0`); the achieved `cc_entrypoint` is asserted each turn and a `tui_entrypoint_mismatch` warning is logged on a silent degrade to the metered sdk-cli pool.
|
||||
|
||||
### Refactor
|
||||
|
||||
- **#125** — `isLoopbackBind` extracted to `lib/net.mjs`, shared by `server.mjs` and the test suite (was duplicated via a copy-paste mirror).
|
||||
|
||||
### New environment variables
|
||||
|
||||
- `PROXY_ADVERTISE_ANON_KEY` — opt-in (default off); advertise `PROXY_ANONYMOUS_KEY` on the public `/health` body for remote zero-config discovery (#109).
|
||||
|
||||
## v3.17.1 — 2026-05-31
|
||||
|
||||
### Fix — code-audit P1/P2 hardening
|
||||
|
||||
Fixes from a multi-agent code audit (3 P1 + 5 P2, adversarially verified). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical.
|
||||
|
||||
**Availability / correctness (P1):**
|
||||
- Guard `proc.stdin` against EPIPE — a fast-failing spawned `claude` (auth error, bad model, large prompt) no longer crashes the single-process daemon.
|
||||
- Add `unhandledRejection`/`uncaughtException`/`clientError` safety nets + wrap all request-body read loops — a client aborting mid-upload no longer crashes the daemon.
|
||||
- TUI transcript reader: only `turn_duration` is terminal (was also `tool_use`), which silently truncated any TUI turn that used a built-in tool.
|
||||
|
||||
**Security gates / cache integrity (P2):**
|
||||
- `AUTH_MODE=multi`: the default spawn now passes `--disallowedTools` (Bash/Read/Write/Edit/…) so a guest prompt cannot drive operator-filesystem tools. Single-user path unchanged.
|
||||
- `/sessions` (DELETE), `/settings` (PATCH), `/logs`, `/usage`, `/status` are now admin-gated (were dispatched before the admin check).
|
||||
- Streaming path no longer caches an `is_error` response as success (cache-poisoning fix).
|
||||
- TUI fail-loud guard extended to `none`+`0.0.0.0` (unless `OCP_TUI_ALLOW_LAN=1`) and `+ PROXY_ANONYMOUS_KEY`.
|
||||
- TUI `send-keys` paste uses `-l` (literal) so a prompt equal to a tmux key token (e.g. `C-c`) is typed, not interpreted.
|
||||
|
||||
---
|
||||
|
||||
## v3.17.0 — 2026-05-31
|
||||
|
||||
### Provider — default claude invocation ported to stream-json + `--system-prompt` (Phase 6c)
|
||||
|
||||
OCP's default (non-TUI) claude spawn moves from `claude -p --output-format text` to `claude --output-format stream-json --verbose --no-session-persistence --system-prompt <wrapper>` (no `-p`). The NDJSON event stream is parsed into the assembled response. Benefits: ~64% per-request cost reduction and anti-hallucination via `--system-prompt` tool-use suppression. Clients see no API change — the OpenAI-compatible request/response shapes are identical. Faithful port of OLP's production-verified implementation; covered by 17 new stream-json parser tests.
|
||||
|
||||
⚠️ **Billing note:** from 2026-06-15 this default path carries `cc_entrypoint=sdk-cli` and bills against the Agent SDK credit pool. Use the new opt-in `CLAUDE_TUI_MODE` (below) to keep traffic on the Pro/Max subscription pool.
|
||||
|
||||
---
|
||||
|
||||
### feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool), single-user only; default stream-json path unchanged
|
||||
|
||||
From 2026-06-15 Anthropic routes `claude -p` / `--output-format` invocations to the Agent SDK credit pool (`cc_entrypoint=sdk-cli`). This feature adds an opt-in bridge: when `CLAUDE_TUI_MODE=true`, OCP serves each request via a real interactive `claude` session (no `-p`, no `--output-format`) so it carries `cc_entrypoint=cli` and bills against the Pro/Max subscription.
|
||||
|
||||
The complete string response is read from claude's native JSONL session transcript and replayed to callers as a normal OpenAI completion or chunked SSE. Clients see no API change. The default stream-json path is byte-for-byte unchanged when `CLAUDE_TUI_MODE` is unset.
|
||||
|
||||
**Security:** single-user / single-operator only. Never enable on a multi-user OCP. See ADR 0007 and README § "Subscription-pool (TUI) mode".
|
||||
|
||||
New env vars: `CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD`, `OCP_TUI_HOME`.
|
||||
New ADR: `docs/adr/0007-tui-interactive-mode.md`.
|
||||
New modules: `lib/tui/transcript.mjs`, `lib/tui/session.mjs` (shipped in preceding commits on this branch).
|
||||
|
||||
---
|
||||
|
||||
### Model — add claude-opus-4-8
|
||||
|
||||
Add `claude-opus-4-8` as the newest Opus to `models.json` (index 0, newest first). Repoint `aliases.opus` from `claude-opus-4-7` to `claude-opus-4-8`. `claude-opus-4-7` remains in the list callable by literal id. `legacyAliases.claude-opus-4` left pointing at `claude-opus-4-7` (no change — legacy alias tracks the prior generation). README Available Models table and model-count references updated accordingly.
|
||||
|
||||
---
|
||||
|
||||
## v3.16.4 — 2026-05-13
|
||||
|
||||
### Refactor — port-literal SPOT + CI guardrail
|
||||
|
||||
Closes the structural side of the port-drift cascade addressed by v3.16.2
|
||||
and v3.16.3. Those two releases reverted plist / plugin / scripts back to
|
||||
3456 line-by-line, but the underlying invitation to drift — a hardcoded
|
||||
port literal scattered across six source files — was still intact.
|
||||
|
||||
Changes:
|
||||
|
||||
- **New `lib/constants.mjs`** — single source of truth for shared literals.
|
||||
Exports `DEFAULT_PORT = 3456`, `LOCAL_HOST = "127.0.0.1"`,
|
||||
`OPENAI_API_BASE = "/v1"`, `LOCAL_PROXY_URL`.
|
||||
- **`server.mjs:127`, `setup.mjs:36`, `scripts/upgrade.mjs:137`,
|
||||
`scripts/doctor.mjs:84` + `:205`, `scripts/sync-openclaw.mjs:73`** —
|
||||
all replaced with imports from `lib/constants.mjs`. Behavior is
|
||||
identical; the literal `3456` now exists in exactly one place per
|
||||
language (`lib/constants.mjs` for `.mjs`, `ocp` + `ocp-connect` for
|
||||
bash, `test-features.mjs` for pinned historical-port tests).
|
||||
- **`.github/workflows/alignment.yml`** — extended the path filter to
|
||||
`setup.mjs`, `scripts/**`, `lib/**`, `ocp`, `ocp-connect`. Added a new
|
||||
`port-spot` hard-fail job that greps for any hardcoded `3478` or `3456`
|
||||
literal in `.mjs/.js/.ts/.json` outside the EXEMPT_REGEX (which lists
|
||||
`lib/constants.mjs`, `test-features.mjs`, the bash CLIs, docs, and the
|
||||
workflow itself). Any future PR re-introducing a hardcoded port
|
||||
literal will be blocked at CI before it can cascade.
|
||||
- Doc comments in `server.mjs` env-var summary and `setup.mjs` usage
|
||||
banner reworded so the literal `3456` no longer appears as
|
||||
documentation text (CI grep is intentionally aggressive — it does not
|
||||
parse comments — so doc strings reference `DEFAULT_PORT from
|
||||
lib/constants.mjs` instead).
|
||||
|
||||
No behavior change for any user. `CLAUDE_PROXY_PORT` env var remains
|
||||
the runtime override; the only difference is the unset-env fallback
|
||||
now flows through one shared constant.
|
||||
|
||||
ALIGNMENT.md hard-requirements: this PR modifies `server.mjs` (one-line
|
||||
import + one literal swap, mechanical). No cli.js operation changed;
|
||||
the citation requirement does not apply. SPOT principle (Rule 2 spirit)
|
||||
is the entire motivation.
|
||||
|
||||
## v3.16.3 — 2026-05-13
|
||||
|
||||
### Fixes — completes v3.16.2 port-drift revert
|
||||
|
||||
v3.16.2 reverted the plugin / `openclaw.plugin.json` / README / Mac mini
|
||||
plist back to `3456` (the historical source default since `593d0dc`), but
|
||||
missed three places in `scripts/` that still defaulted to `3478`. Those
|
||||
three lines were the residual cascade source: every time `ocp doctor` or
|
||||
`ocp upgrade` ran without `CLAUDE_PROXY_PORT` in the env, they probed
|
||||
`3478`, reported "OCP not responding" against a healthy 3456 instance,
|
||||
and (in the case of OpenClaw sync follow-ups on the maintainer's host)
|
||||
re-introduced 3478 into downstream config.
|
||||
|
||||
Changes:
|
||||
|
||||
- `scripts/upgrade.mjs:137` — default port `3478` → `3456`.
|
||||
- `scripts/doctor.mjs:84` — default port `3478` → `3456`.
|
||||
- `scripts/doctor.mjs:205` — default port `3478` → `3456`.
|
||||
|
||||
No behavior change for users who set `CLAUDE_PROXY_PORT` explicitly; env
|
||||
still takes precedence. The fix only affects the unset-env fallback,
|
||||
which now matches `server.mjs:126` and the rest of the codebase.
|
||||
|
||||
Test plan: existing `test-features.mjs` cases that pin
|
||||
`CLAUDE_PROXY_PORT=3478` continue to pass — they use the env path, not
|
||||
the default.
|
||||
|
||||
## v3.16.2 — 2026-05-12
|
||||
|
||||
### Fixes — corrects v3.16.1
|
||||
|
||||
The v3.16.1 fix was directionally correct (plugin now reads env first, falls back to a hardcoded default) but **the narrative and the hardcoded default were both wrong**.
|
||||
|
||||
What v3.16.1 said: "OCP server moved to 3478 default in v3.14+; plugin lagged at 3456."
|
||||
What is actually true:
|
||||
- **OCP server source default has been `3456` since `593d0dc` (initial release) and has never changed.** Every line in `server.mjs`, `setup.mjs`, and the `ocp` CLI still uses `3456` as the documented and code-level default.
|
||||
- The single OCP installation observed on `3478` is the maintainer's Mac mini, whose plist was rewritten with `--port 3478` during a PR #71 dogfood smoke-test accident on 2026-05-08 (see `~/.cc-rules/memory/learnings/subagent_setup_mjs_prod_host_collision.md`). The plist drift was never reconciled back to source default, and v3.16.1 incorrectly canonised the post-accident value as if it had been a release decision.
|
||||
|
||||
This release:
|
||||
- Restores the plugin fallback to `http://127.0.0.1:3456` to match server source default.
|
||||
- Updates `openclaw.plugin.json` `configSchema.proxyUrl.default` back to `3456`.
|
||||
- Restores README §"Environment Variables" `CLAUDE_PROXY_PORT` default to `3456`.
|
||||
- Plugin reads `OCP_PROXY_URL` env (full URL) first, then `CLAUDE_PROXY_PORT` env (port only), then falls back to `3456`. Hosts whose OCP plist injects a non-default port must also inject the same `CLAUDE_PROXY_PORT` into the OpenClaw plist for the plugin to follow.
|
||||
- Maintainer's Mac mini plist was reverted from `3478` to `3456` as part of this release deploy (no source change reflects this; it was a one-host correction).
|
||||
|
||||
### Governance
|
||||
|
||||
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
||||
|
||||
## v3.16.1 — 2026-05-12 (superseded — narrative incorrect; see v3.16.2 erratum)
|
||||
|
||||
### Fixes (as shipped — note erratum above)
|
||||
|
||||
- **OCP plugin port lag** — `ocp-plugin/index.js` hard-coded `http://127.0.0.1:3456`. ~~While OCP server moved to 3478 in v3.14+,~~ **(corrected v3.16.2: no such move ever happened.)** The Mac mini's plist was on `3478` only as residue from a dogfood accident. Result: `/ocp` slash commands from the home Telegram bot returned "OCP error: fetch failed". v3.16.1 changed the plugin default to `3478` (wrong direction; v3.16.2 reverts to `3456`).
|
||||
|
||||
### Governance
|
||||
|
||||
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
||||
|
||||
## v3.16.0 — 2026-05-10
|
||||
|
||||
### Features
|
||||
|
||||
- **`ocp doctor --check oauth`** (PR #93) — fast path that runs only the OAuth check, skipping
|
||||
version detection / from-version / git operations / models endpoint. ~50ms vs. full doctor's
|
||||
~200-500ms. Use cases: AI agent repair loops, post-`claude auth login` verify, quick health
|
||||
gates. Help text in `cmd_doctor_help` now reflects working behaviour.
|
||||
- **`ocp update --rollback --gc`** — manually garbage-collect old upgrade snapshots.
|
||||
Retention policy: keep last 5 snapshots OR snapshots newer than 30 days OR the single most
|
||||
recent (always-keep safety net). `--dry-run` previews. Successful `ocp update` runs auto-GC
|
||||
at the end of the full path; light path does not (no snapshot created there).
|
||||
|
||||
### Behavior changes
|
||||
|
||||
- After a successful cross-minor `ocp update`, the auto-GC emits `[gc] removed N old snapshots`
|
||||
to stderr if any were collected. Safe to ignore; manual gc is `ocp update --rollback --gc`.
|
||||
|
||||
### Governance
|
||||
|
||||
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
||||
- PR #93 (--check oauth) merged separately; this release bundles it with the GC feature.
|
||||
|
||||
## v3.15.1 — 2026-05-10
|
||||
|
||||
### Fixes
|
||||
|
||||
- **doctor: dynamic `latest_version` from `origin/main:package.json`** — v3.15.0 doctor used a hard-coded `latest = "v3.14.0"` fallback, which made any v3.15.0+ install report `kind = upgrade` (against a stale value). `ocp update` would then attempt `git checkout v3.14.0` — a downgrade. Doctor now fetches `git -C ~/ocp show origin/main:package.json` to determine the actual latest version; on failure (offline, fresh clone with no remote), falls back to `currentVersion` so `kind = noop` instead of recommending a downgrade.
|
||||
|
||||
## v3.15.0 — 2026-05-10
|
||||
|
||||
### Features
|
||||
|
||||
- **`ocp doctor`** — health & upgrade-readiness check; primary entry for AI-driven debugging.
|
||||
`--json` mode emits a `next_action` with `ai_executable[]` for agents to run verbatim
|
||||
and `human_required[]` for steps requiring the user (typically only OAuth).
|
||||
- **`ocp update` cross-version path** — for cross-minor jumps (e.g. v3.10 → v3.14),
|
||||
`ocp update` now runs doctor → snapshot → `setup.mjs` (with the plist env-merge from
|
||||
PR #90) → service restart → post-flight `/health` + `/v1/models` verification.
|
||||
Same-patch updates retain the existing light path; users see no change for routine
|
||||
patch bumps.
|
||||
- **`ocp update --rollback`** — restore the most recent (or specified) upgrade snapshot.
|
||||
Snapshots are saved to `~/.ocp/upgrade-snapshot-<ISO-ts>/` and never auto-deleted.
|
||||
- **Fresh-install routing** — `ocp update` on installations < v3.4.0 routes to a fresh-install
|
||||
flow (with `--yes` to skip confirmation; AI agents pass this). OAuth survives via Claude
|
||||
Code's credential store; users do not re-OAuth unless their token was independently broken.
|
||||
- **AI prompt blocks in README** — §Installation, §Upgrading, and §Troubleshooting each
|
||||
start with a copy-paste prompt for Claude Code / Cursor / Copilot, so users can drive
|
||||
install / setup / upgrade through their existing AI assistant.
|
||||
|
||||
### Behavior changes
|
||||
|
||||
- `ocp update` may take 10–30s longer when a cross-minor jump triggers the full path
|
||||
(snapshot + post-flight). Patch bumps are unchanged.
|
||||
- Pre-v3.4.0 installs are routed to fresh-install rather than failing silently or
|
||||
half-migrating.
|
||||
|
||||
### Governance
|
||||
|
||||
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
||||
- Depends on PR #90 (plist env merge bug fix; merged before this release).
|
||||
|
||||
## v3.14.0 — 2026-05-10
|
||||
|
||||
### Features (security hardening)
|
||||
|
||||
- **Per-key session isolation** (PR #86, S1) — the `sessions` Map in `server.mjs` is now keyed by `${keyName}|${conversationId}` instead of bare `conversationId`. Before this fix, two clients using distinct API keys but the same `session_id` value (e.g. both defaulting to `"default"`) would share the same `cli.js` subprocess and conversation history, creating a cross-tenant leak path. Post-fix each (key, session) pair is isolated end-to-end, extending the per-key cache isolation shipped in v3.13.0 D1 to the session layer.
|
||||
- **On-disk credential file modes 0700/0600** (PR #87, S2) — `setup.mjs` now creates `~/.ocp` at mode 0700 and both `admin-key` and `ocp.db` at mode 0600. An idempotent `reconcileFileModes()` call in `server.mjs` startup tightens any existing installation to these modes automatically on every launch, so existing prod boxes fix themselves without manual `chmod`. Before this fix, all three files were created at the process's default umask (typically world-readable 0644 / 0755), leaving plaintext credentials readable by other local users.
|
||||
- **`/api/usage` default scope = self; admin all-keys requires `?all=true`** (PR #88, S3) — the usage endpoint now applies a least-privilege default: anonymous callers receive only their own rows, non-admin authenticated callers receive only their own rows, and admin callers receive only their own rows unless they explicitly pass `?all=true`. When `?all=true` is used, an audit log line is emitted. Before this fix, any admin-token holder could silently enumerate usage data for every key on the server.
|
||||
|
||||
### Behavior changes
|
||||
|
||||
- **Breaking change for admin tooling**: `/api/usage` no longer returns all-keys data by default. Existing cron jobs, dashboards, or scripts that rely on the admin token seeing all-keys output must add `?all=true` to their request URL after upgrading to v3.14.0.
|
||||
- **File mode reconcile at server startup** logs a one-line notice per path when mode is tightened (e.g. `[security] tightened ~/.ocp/ocp.db → 0600`). No action is required from the operator; the reconcile is idempotent and silent when modes are already correct.
|
||||
- **`sessions` Map key is now `${keyName}|${conversationId}` internally.** No client-visible wire change — the `session_id` field in request/response is unchanged.
|
||||
|
||||
### Verification
|
||||
|
||||
- Stress-test pass: 11/11 phases including S1/S2/S3 security regression checks (Phase E, I, J). 35-minute sustained run, 60 calls, 0 errors, 0 timeouts. RSS dropped 51→47 MB across the window. Per-key cache isolation, singleflight, cache_control bypass, quota enforcement, file-mode reconcile, and scope guard against escalation all verified against running code.
|
||||
|
||||
### Governance
|
||||
|
||||
- All three PRs (#86, #87, #88) include the explicit `cli.js`-citation-not-applicable disclaimer (per PR #75 pattern) since they are OCP-internal access-control, session-state, and file-permission changes with no corresponding `cli.js` operation to cite.
|
||||
|
||||
### No new env vars / no public API surface change beyond the documented breaking change
|
||||
|
||||
This release adds no new env vars or endpoints. The only externally visible change is the `/api/usage` scope guard (breaking for admin all-keys consumers; see Behavior changes above).
|
||||
|
||||
## v3.13.0 — 2026-05-07
|
||||
|
||||
### Features (cache layer hardening)
|
||||
|
||||
- **Per-key cache isolation** (D1) — the cache key now includes the API key id, so distinct keys never share cache entries. Anonymous/unauthenticated callers share one `anon` pool. Hash format upgraded to `v2`; legacy v1-format rows orphan and are reaped by the existing TTL cleanup interval (no migration script).
|
||||
- **`cache_control` bypass** (D2) — when a request carries an Anthropic `cache_control` annotation (top-level or nested in a content array), OCP skips its own cache entirely. The caller is using Anthropic-side prompt caching deliberately, and OCP must not interfere. A `cache_skipped{reason: cache_control_present}` log line is emitted on bypass.
|
||||
- **Chunked stream replay** (D3) — when a streaming request hits the cache, the cached content is now emitted as multiple SSE chunks (80 codepoints/chunk, codepoint-safe via `Array.from()`) instead of a single large delta. Multibyte characters (CJK / emoji) stay intact.
|
||||
- **Singleflight stampede protection** (D4) — concurrent identical cache-miss requests now share one upstream `cli.js` spawn instead of spawning N processes. Followers receive byte-identical responses to what the leader returns. All-or-nothing failure semantics: if the leader errors, all followers receive the same error. Streaming-path singleflight is explicitly out of scope (TODO left for follow-up).
|
||||
|
||||
### Behavior changes
|
||||
|
||||
- `/cache/stats` response now includes additive fields `inflight` and `requesters` (current in-flight singleflight entries and total waiting callers). Existing fields `entries`, `totalHits`, `sizeBytes` are preserved unchanged.
|
||||
|
||||
### Governance
|
||||
|
||||
- New ADR [`docs/adr/0005-no-multi-provider.md`](docs/adr/0005-no-multi-provider.md): OCP stays single-provider (Anthropic via `cli.js` spawn). Multi-provider gateway refactor explicitly out of scope; cache improvements are explicitly in scope.
|
||||
- Design spec for this release: [`docs/superpowers/specs/2026-05-07-cache-upgrade-design.md`](docs/superpowers/specs/2026-05-07-cache-upgrade-design.md).
|
||||
|
||||
### No new env vars / no public API surface change
|
||||
|
||||
This release adds no new env vars or endpoints. All four improvements are internal correctness/concurrency upgrades to the existing `CLAUDE_CACHE_TTL`-gated cache layer. No client-observable wire shape change.
|
||||
|
||||
## v3.12.0 — 2026-04-25
|
||||
|
||||
### Features
|
||||
|
||||
- **Streaming heartbeat** — opt-in SSE comment frame (`: keepalive\n\n`) emitted during silent windows on the streaming response. Controlled by `CLAUDE_HEARTBEAT_INTERVAL` env var (ms; `0` = disabled, default). Covers both pre-first-byte and mid-stream tool-use pauses. Addresses #47. See [design doc](docs/superpowers/specs/2026-04-25-47-sse-heartbeat-design.md).
|
||||
- **`X-Accel-Buffering: no`** response header added to SSE responses so heartbeats survive nginx/Cloudflare default buffering.
|
||||
|
||||
### Behavior changes
|
||||
|
||||
- SSE headers are now sent immediately after the claude CLI spawns successfully, not on first stdout byte. The rare "spawn succeeded but subprocess died before any byte" path now closes the SSE stream cleanly rather than returning a JSON error.
|
||||
|
||||
### Config additions
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `CLAUDE_HEARTBEAT_INTERVAL` | `0` (disabled) | Interval in ms for SSE keepalive comment frames on streaming path. Resets on every real frame. |
|
||||
|
||||
## v3.11.1 — 2026-04-21
|
||||
|
||||
### Fixes
|
||||
- Concurrency slot leak on subprocess timeout (#37). The request-timeout handler called `proc.kill("SIGTERM")` without decrementing `stats.activeRequests`. A subprocess stuck in a syscall that ignored SIGTERM would hold its slot until (or beyond) the 5s SIGKILL escalation actually reaped it. Slot release is now wired to `proc.once("exit", cleanup)` so every termination path — normal close, error, SIGTERM, SIGKILL — releases the slot exactly once.
|
||||
|
||||
## v3.11.0 — 2026-04-20
|
||||
|
||||
### Features
|
||||
- `ocp update` now automatically syncs OpenClaw's registry with the latest models (scripts/sync-openclaw.mjs)
|
||||
- Server logs warn if OpenClaw registry drifts from models.json
|
||||
|
||||
### Refactor
|
||||
- models.json is now the single source of truth for model list
|
||||
- server.mjs and setup.mjs derive MODEL_MAP/MODELS from models.json
|
||||
- Adding a new model is now a one-file edit
|
||||
|
||||
### Fixes
|
||||
- OpenClaw's model dropdown now shows all 4 current models (opus-4-7, opus-4-6, sonnet-4-6, haiku-4.5) on existing installs after `ocp update`. Previously setup.mjs only wrote the registry at install time.
|
||||
@@ -1,3 +1,6 @@
|
||||
@AGENTS.md
|
||||
@~/.cc-rules/AGENTS.md
|
||||
|
||||
# OCP Project Session Instructions
|
||||
|
||||
> **WARNING — READ BEFORE WRITING ANY CODE IN THIS REPO**
|
||||
@@ -19,7 +22,7 @@
|
||||
Every PR that modifies `server.mjs` must satisfy all three of the following. A PR missing any one of them is blocked from merge.
|
||||
|
||||
1. **`cli.js` citation.** The commit message and PR body declare the corresponding `cli.js` function name and line number range, using the format `cli.js:NNNN` or `cli.js vE4 <functionName>`. If `cli.js` does not perform the operation, the PR must state this explicitly and justify scope under `ALIGNMENT.md` Rule 2 (in practice, this almost always means the PR should be closed).
|
||||
2. **CI blacklist pass.** The `alignment.yml` workflow must pass. The workflow greps `server.mjs` for known-hallucinated tokens (including `api/oauth/usage` and `api/usage`) and fails the build on any hit. Do not suppress the workflow. Do not add allowlist entries without an amendment PR to `ALIGNMENT.md`.
|
||||
2. **CI blacklist pass.** The `alignment.yml` workflow must pass. The workflow greps `server.mjs` for known-hallucinated tokens (currently blocking `api.anthropic.com/api/oauth/usage`) and fails the build on any hit. New tokens are added via PR amendment to `alignment.yml`; removing entries requires an `ALIGNMENT.md` amendment PR. Do not suppress the workflow.
|
||||
3. **Independent reviewer (Iron Rule 10).** The implementation author may not self-approve. A separate reviewer — human or a subagent spawned with a fresh context — must read the diff, verify the `cli.js` citation by opening `cli.js` at the cited lines, and explicitly approve. A review comment that does not confirm the `cli.js` citation was checked is not a valid approval.
|
||||
|
||||
---
|
||||
@@ -32,7 +35,7 @@ This repo operates under the CC Development Iron Rules (CC 开发铁律) v1.3. T
|
||||
- **Iron Rule 11 (Incremental Diff Review).** Non-trivial work is split into the minimum reviewable unit — one PR per layer per severity. `ALIGNMENT.md`, `CLAUDE.md`, the PR template, and the CI workflow are therefore shipped as the same constitutional PR (they are one layer: governance), but any subsequent `server.mjs` remediation lands as its own PR.
|
||||
- **Iron Rule 12 (Pre-Brainstorm Prior-Art Search).** Before proposing any new endpoint or header, search GitHub, Anthropic docs, and the `cli.js` bundle. For OCP specifically, the `cli.js` grep is the decisive search: if it does not hit, Rule 2 of the constitution applies.
|
||||
|
||||
The full iron rules are at `~/.claude/CC_DEV_IRON_RULES.md` (symlinked from the cc-rules repo on Tao's workstations). Load them into session context with `/cc-rules` when needed.
|
||||
The full iron rules are at `~/.claude/CC_DEV_IRON_RULES.md` (symlinked from the cc-rules repo on the maintainer's workstations). Load them into session context with `/cc-rules` when needed.
|
||||
|
||||
---
|
||||
|
||||
@@ -55,4 +58,37 @@ The full iron rules are at `~/.claude/CC_DEV_IRON_RULES.md` (symlinked from the
|
||||
|
||||
## Project-level escalation
|
||||
|
||||
If a design decision cannot be resolved by reference to `cli.js` and `ALIGNMENT.md`, escalate to Tao (老大) via `/cc-chat` rather than guessing. Silent guessing is what produced the 2026-04-11 drift.
|
||||
If a design decision cannot be resolved by reference to `cli.js` and `ALIGNMENT.md`, escalate to the project maintainer via `/cc-chat` rather than guessing. Silent guessing is what produced the 2026-04-11 drift.
|
||||
|
||||
---
|
||||
|
||||
## Release kit overlay (CC 开发铁律 第五律 5.5)
|
||||
|
||||
This project's overlay per iron rule v1.4's 5.5. Machine-checkable declaration.
|
||||
|
||||
```yaml
|
||||
release_kit:
|
||||
version_source: package.json
|
||||
changelog: CHANGELOG.md
|
||||
release_channel:
|
||||
type: github-release
|
||||
tag_format: v{semver}
|
||||
auto_create_on_tag_push: true # via .github/workflows/release.yml
|
||||
docs_source: README.md
|
||||
resource_lists:
|
||||
- name: Available Models table
|
||||
location: README.md § "Available Models"
|
||||
source_of_truth: models.json
|
||||
- name: API Endpoints table
|
||||
location: README.md § "API Endpoints"
|
||||
- name: Environment Variables table
|
||||
location: README.md § "Environment Variables"
|
||||
new_feature_doc_expectations:
|
||||
- new CLI subcommand → README § "All Commands" + usage example
|
||||
- new env var → README § "Environment Variables" table
|
||||
- new auto-sync / hook → dedicated §, must document trigger + manual invocation + opt-out + any bootstrap quirk
|
||||
- new endpoint → README § "API Endpoints" table + any relevant Config/Troubleshooting §
|
||||
- new file / SPOT / schema → Architecture or contributor § with link
|
||||
bootstrap_quirk_policy:
|
||||
- any one-time migration quirk → README § "Troubleshooting"
|
||||
```
|
||||
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY server.mjs ./
|
||||
COPY setup.mjs ./
|
||||
COPY package.json ./
|
||||
|
||||
ENV CLAUDE_SESSION_TOKEN="" \
|
||||
CLAUDE_COOKIES=""
|
||||
|
||||
EXPOSE 3456
|
||||
|
||||
CMD ["node", "server.mjs"]
|
||||
@@ -1,7 +1,13 @@
|
||||
# OCP — Open Claude Proxy
|
||||
|
||||
[](LICENSE) [](https://github.com/dtzp555-max/ocp/releases) [](https://buymeacoffee.com/dtzp555)
|
||||
|
||||
> **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.**
|
||||
|
||||
*Open source from day one, used daily by my family, maintained on nights and weekends. If OCP saves you money too, you can [☕ buy me a coffee](https://buymeacoffee.com/dtzp555) — [full story below](#support-ocp).*
|
||||
|
||||
*If OCP saves you a setup, a ⭐ helps other folks discover it. Issue reports are even more useful — that's the highest-quality feedback this project gets.*
|
||||
|
||||
OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing.
|
||||
|
||||
```
|
||||
@@ -14,6 +20,42 @@ OpenClaw ───┘
|
||||
|
||||
One proxy. Multiple IDEs. All models. **$0 API cost.**
|
||||
|
||||
## Why OCP?
|
||||
|
||||
There are several Claude proxy projects. OCP picks a specific lane: **align tightly with what `cli.js` actually does, observe + multiplex what's already there, don't extend the protocol.** What you get:
|
||||
|
||||
- **LAN multi-user keys** (v3.7.0) — share one Claude Pro/Max subscription with family, friends, or your own devices. Each user gets a per-key API token (no OAuth session leak), with independent usage tracking and one-line revocation.
|
||||
- **`ocp-connect` one-shot IDE setup** — one command on the client machine detects and configures Claude Code, Cursor, Cline, Continue.dev, OpenCode, and OpenClaw. No pasting `OPENAI_BASE_URL` six times.
|
||||
- **Response cache with per-key isolation + singleflight** (v3.13.0). Optional SHA-256 prompt cache, isolated per API key (cross-user pollution is impossible by hash construction, not by application logic), with stampede protection on concurrent identical prompts. Off by default. ([PR #65](https://github.com/dtzp555-max/ocp/pull/65), [PR #66](https://github.com/dtzp555-max/ocp/pull/66))
|
||||
- **Per-key request quotas** (v3.8.0). Daily / weekly / monthly limits per key — set a kid's iPad to 20/day, a partner's laptop to 100/week. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18))
|
||||
- **SSE heartbeat for long reasoning** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in). If you've ever watched your IDE die at the 60s idle mark during a long Claude tool-use pause — that's nginx/Cloudflare default behavior. OCP emits an SSE comment frame to keep the connection alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49))
|
||||
- **`cli.js` alignment + CI guardrail.** LLM-assisted code drifts easily — it's tempting to invent plausible-looking endpoints that `cli.js` doesn't actually use. [`ALIGNMENT.md`](./ALIGNMENT.md) is binding: every endpoint OCP exposes must cite a `cli.js` line. The [`alignment.yml`](./.github/workflows/alignment.yml) CI workflow blocks PRs that introduce known-hallucinated tokens. The payoff is boring: your setup keeps working when `cli.js` ships its next minor.
|
||||
- **`models.json` single source of truth** (v3.11.0). Adding a model is one file edit; both `/v1/models` and the OpenClaw bootstrap derive from it. ([PR #30](https://github.com/dtzp555-max/ocp/pull/30))
|
||||
|
||||
### Comparison
|
||||
|
||||
OCP and the alternatives serve adjacent but distinct needs. Pick the one that fits your use case:
|
||||
|
||||
| Feature | OCP | claude-code-router | anthropic-proxy |
|
||||
|---|---|---|---|
|
||||
| Forwards Claude Code subscription as OpenAI API | yes | yes | yes |
|
||||
| Routes to multiple model backends (OpenAI, Gemini, etc.) | no | yes | partial |
|
||||
| SSE heartbeat for long reasoning | yes (opt-in) | no | no |
|
||||
| Per-key quota + LAN multi-user keys | yes | no | no |
|
||||
| Response cache | yes (opt-in) | no | no |
|
||||
| OpenClaw / IDE auto-config | yes | no | no |
|
||||
| Model-routing rules / model-switching | no | yes | no |
|
||||
| GitHub stars / ecosystem size | small | large | mid |
|
||||
| Governance discipline (CI-enforced alignment with cli.js) | yes | n/a | n/a |
|
||||
|
||||
**Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to share one Claude Pro/Max subscription across IDEs, devices, and people, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
|
||||
|
||||
### Related: OLP — Open LLM Proxy
|
||||
|
||||
OCP is Claude-only by design. If you want to spread across **multiple LLM providers** (not just Claude), see the sibling project **[OLP — Open LLM Proxy](https://github.com/dtzp555-max/olp)**: the same spawn-the-provider-CLI approach, but across several provider CLIs behind one OpenAI-compatible endpoint, with intelligent fallback chains. It grew out of OCP in response to Anthropic's 2026-06-15 billing split — the idea being to spread subscription/quota risk across more than one provider. OCP remains the focused, Claude-only option; OLP is the multi-provider one.
|
||||
|
||||
OCP is single-maintainer + LLM-assisted, currently pre-1.0. It runs the maintainer's daily Claude Code workflow. If something breaks, [open an issue](https://github.com/dtzp555-max/ocp/issues).
|
||||
|
||||
## Supported Tools
|
||||
|
||||
Any tool that accepts `OPENAI_BASE_URL` works with OCP:
|
||||
@@ -24,11 +66,27 @@ Any tool that accepts `OPENAI_BASE_URL` works with OCP:
|
||||
| **OpenCode** | `OPENAI_BASE_URL=http://127.0.0.1:3456/v1` |
|
||||
| **Aider** | `aider --openai-api-base http://127.0.0.1:3456/v1` |
|
||||
| **Continue.dev** | config.json → `apiBase: "http://127.0.0.1:3456/v1"` |
|
||||
| **OpenClaw** | `setup.mjs` auto-configures |
|
||||
| **OpenClaw** [^openclaw] | `setup.mjs` auto-configures |
|
||||
| **Any OpenAI client** | Set base URL to `http://127.0.0.1:3456/v1` |
|
||||
|
||||
[^openclaw]: **OpenClaw** is an IDE-agnostic AI coding agent (sibling project to OCP). When OCP runs on the same machine, OpenClaw can use it as a local provider — see `scripts/sync-openclaw.mjs` and ADR 0004.
|
||||
|
||||
## Installation
|
||||
|
||||
The simplest path: ask your AI.
|
||||
|
||||
Paste this prompt to Claude Code / Cursor / Copilot:
|
||||
|
||||
```
|
||||
Install OCP for me. Read README §Manual Installation and follow it.
|
||||
Tell me when I need to run `claude auth login`.
|
||||
```
|
||||
|
||||
The AI will run `git clone`, `npm install`, `node setup.mjs`, and tell you
|
||||
when to OAuth.
|
||||
|
||||
### Manual Installation
|
||||
|
||||
OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client** (connects to a server, zero dependencies).
|
||||
|
||||
```
|
||||
@@ -45,13 +103,96 @@ OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client**
|
||||
|
||||
---
|
||||
|
||||
### Quick install with AI assistance
|
||||
|
||||
If you've got Claude Code, Cursor, or any other AI coding assistant on this machine, you can copy-paste one of these prompts and let the AI walk through the install for you. Each prompt pins the AI to the right README section, names the verification step, and forbids silent retries — so you stay in the loop.
|
||||
|
||||
**Single-machine use** — install OCP for IDEs on this same machine only:
|
||||
|
||||
```text
|
||||
I want to install OCP on this machine to use my Claude Pro/Max subscription
|
||||
as an OpenAI-compatible API for local IDEs.
|
||||
|
||||
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||
"Server Setup" → "Single-machine use" path:
|
||||
|
||||
1. Verify prerequisites: macOS or Linux, Node.js 22.5+, git, Claude CLI
|
||||
installed and logged in (`claude auth status`). Install missing pieces
|
||||
using my system's package manager.
|
||||
2. git clone the repo, cd in, and run `node setup.mjs`.
|
||||
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 5 models).
|
||||
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
|
||||
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
|
||||
|
||||
Before each step, tell me what you'll run and wait for confirmation.
|
||||
On any error, diagnose first — don't auto-retry.
|
||||
```
|
||||
|
||||
**LAN mode (server)** — install OCP as a server so your family or multiple devices can share it:
|
||||
|
||||
```text
|
||||
I want to install OCP on this device as a LAN server so my family and other
|
||||
devices on the network can share my Claude Pro/Max subscription.
|
||||
|
||||
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||
"Server Setup" → "LAN mode" path:
|
||||
|
||||
1. Verify prerequisites: macOS or Linux (Windows not supported), Node.js
|
||||
22.5+, git, Claude CLI installed and authenticated.
|
||||
2. Generate a strong admin key with `openssl rand -base64 32`. Save it —
|
||||
I'll need it to manage per-user keys later.
|
||||
3. git clone https://github.com/dtzp555-max/ocp.git && cd ocp
|
||||
4. Run `node setup.mjs --bind 0.0.0.0 --auth-mode multi`.
|
||||
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
|
||||
6. Run `ocp lan` to show me the LAN IP and connect command.
|
||||
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
|
||||
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 5 models.
|
||||
|
||||
Tell me each step before running it. On error, diagnose before retrying.
|
||||
```
|
||||
|
||||
**Client connect** — configure this device to use an existing OCP server on your LAN:
|
||||
|
||||
```text
|
||||
There's an OCP server at <SERVER_IP> on my LAN. Configure this machine to
|
||||
use it for any local IDEs (Cursor, Cline, Continue.dev, OpenCode, Claude
|
||||
Code, OpenClaw).
|
||||
|
||||
Server IP: <SERVER_IP>
|
||||
API key (leave blank if the server has anonymous mode enabled): <OPTIONAL_KEY>
|
||||
|
||||
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||
"Client Setup" path:
|
||||
|
||||
1. Download ocp-connect:
|
||||
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
|
||||
chmod +x ocp-connect
|
||||
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
|
||||
3. Follow any IDE-specific manual hints it prints.
|
||||
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 5 models.
|
||||
5. Tell me to reload my shell + restart any IDE that was already running.
|
||||
|
||||
Don't auto-retry on error. Tell me the failure mode first.
|
||||
```
|
||||
|
||||
> If you'd rather do everything manually, the **Server Setup** and **Client Setup** sections below have the same steps in handbook form.
|
||||
|
||||
---
|
||||
|
||||
### Server Setup
|
||||
|
||||
> **Recommended:** Install OCP on a device that stays powered on — Mac mini, NAS, Raspberry Pi, or a desktop that doesn't sleep. This ensures all clients always have access.
|
||||
|
||||
**Prerequisites:**
|
||||
- Node.js 18+
|
||||
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) installed and authenticated (`claude auth login`)
|
||||
- macOS or Linux (Windows is not supported — `setup.mjs` installs launchd / systemd auto-start)
|
||||
- Node.js 22.5+ (Node 23+ recommended — `node:sqlite` is fully stable without flags from 23.0; on 22.5–22.x it works behind `--experimental-sqlite`)
|
||||
- `git`
|
||||
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) — install and authenticate:
|
||||
```bash
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
claude auth login # prints a URL + code — open URL on any browser, sign in, paste code back
|
||||
```
|
||||
Headless servers (Pi / NAS / VPS without a desktop browser): see [Headless install notes](#headless-install-notes) below.
|
||||
|
||||
```bash
|
||||
# 1. Clone and run setup
|
||||
@@ -64,7 +205,8 @@ The setup script will:
|
||||
1. Verify Claude CLI is installed and authenticated
|
||||
2. Start the proxy on port 3456
|
||||
3. Install auto-start (launchd on macOS, systemd on Linux)
|
||||
4. Symlink `ocp` to `/usr/local/bin` for CLI access
|
||||
|
||||
After install the `ocp` CLI lives at `~/ocp/ocp`. To put it on your PATH, either symlink it manually (`ln -sf ~/ocp/ocp ~/.local/bin/ocp` if `~/.local/bin` is on your PATH, or `sudo ln -sf ~/ocp/ocp /usr/local/bin/ocp` for a system-wide symlink) or add an alias (`alias ocp=~/ocp/ocp`). Otherwise invoke it as `~/ocp/ocp <subcommand>`. The rest of this README assumes `ocp` is on your PATH.
|
||||
|
||||
**Single-machine use** — just set your IDE to use the proxy:
|
||||
```bash
|
||||
@@ -79,11 +221,13 @@ node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||
|
||||
Then create API keys for each person/device:
|
||||
```bash
|
||||
export OCP_ADMIN_KEY=your-secret-admin-key
|
||||
# Generate a strong admin key (one-time — save it for later key management):
|
||||
export OCP_ADMIN_KEY=$(openssl rand -base64 32)
|
||||
# Add the same export line to ~/.zshrc or ~/.bashrc so it persists.
|
||||
|
||||
ocp keys add wife-laptop
|
||||
# ✓ Key created for "wife-laptop"
|
||||
# API Key: ocp_xDYzOB9ZKYzn...
|
||||
# API Key: ocp_example12345abcde...
|
||||
# Copy this key now — you won't see it again.
|
||||
|
||||
ocp keys add son-ipad
|
||||
@@ -95,14 +239,43 @@ Run `ocp lan` to see your IP and ready-to-share instructions.
|
||||
**Verify:**
|
||||
```bash
|
||||
curl http://127.0.0.1:3456/v1/models
|
||||
# Returns: claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4
|
||||
# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
||||
```
|
||||
|
||||
#### Headless install notes
|
||||
|
||||
OCP is designed for always-on devices that often don't have a desktop browser — Mac mini, NAS, Raspberry Pi, cloud VPS. The Claude CLI auth flow still works headless:
|
||||
|
||||
**Option 1 — interactive OAuth over SSH (one-shot).** `claude auth login` prints a URL + 8-digit code. Open the URL on **any** device with a browser (your laptop, phone), sign in to your Anthropic account, and paste the code back into the SSH session. No browser needed on the server itself.
|
||||
|
||||
**Option 2 — long-lived token (auth once, no re-prompts).**
|
||||
|
||||
```bash
|
||||
claude setup-token # subscription-backed long-lived token
|
||||
```
|
||||
|
||||
Same Claude subscription as Option 1; the token is stored in Claude CLI's normal config location. Useful when you'd rather not redo the OAuth flow when sessions expire.
|
||||
|
||||
If `claude auth login` errors out with something like `cannot open browser`, you've hit the same case — fall back to either option above.
|
||||
|
||||
---
|
||||
|
||||
### Uninstall
|
||||
|
||||
```bash
|
||||
# From the cloned repo
|
||||
node uninstall.mjs
|
||||
```
|
||||
|
||||
Removes the launchd (macOS) or systemd (Linux) auto-start entry. Handles both legacy (`ai.openclaw.proxy` / `openclaw-proxy`) and current (`dev.ocp.proxy` / `ocp-proxy`) service names. Does not delete `~/.openclaw/`, `~/.ocp/`, or the cloned repo — remove those manually if desired.
|
||||
|
||||
---
|
||||
|
||||
### Client Setup
|
||||
|
||||
> Clients do **not** need to install Node.js, Claude CLI, or the OCP repo. Only `curl` and `python3` are required (pre-installed on most Linux/Mac systems).
|
||||
>
|
||||
> **Find the server's LAN IP** by running `ocp lan` on the server machine — it prints both the IP and a ready-to-share connect command.
|
||||
|
||||
**One-command setup** — download the lightweight `ocp-connect` script:
|
||||
|
||||
@@ -112,7 +285,7 @@ chmod +x ocp-connect
|
||||
./ocp-connect <server-ip>
|
||||
```
|
||||
|
||||
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically:
|
||||
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` *and* opted in with `PROXY_ADVERTISE_ANON_KEY=1` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically. Without the opt-in, `/health` does not expose the key (issue #109); pass `--key` or rely on anonymous access instead:
|
||||
|
||||
```bash
|
||||
./ocp-connect <server-ip>
|
||||
@@ -139,13 +312,13 @@ OCP Connect v1.3.0
|
||||
Checking connectivity...
|
||||
✓ Connected
|
||||
|
||||
Remote OCP v3.9.0 (auth: multi)
|
||||
Remote OCP v3.11.0 (auth: multi)
|
||||
|
||||
ⓘ Using server-advertised anonymous key: ocp_publ...n_v1
|
||||
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
||||
|
||||
Testing API access...
|
||||
✓ API accessible (3 models available)
|
||||
✓ API accessible (5 models available)
|
||||
|
||||
Shell config:
|
||||
✓ .bashrc
|
||||
@@ -175,6 +348,8 @@ OCP Connect v1.3.0
|
||||
✓ OpenClaw configured
|
||||
Provider: ocp
|
||||
Models:
|
||||
• ocp/claude-opus-4-8
|
||||
• ocp/claude-opus-4-7
|
||||
• ocp/claude-opus-4-6
|
||||
• ocp/claude-sonnet-4-6
|
||||
• ocp/claude-haiku-4-5-20251001
|
||||
@@ -195,7 +370,7 @@ OCP Connect v1.3.0
|
||||
The script automatically:
|
||||
- Writes env vars to all relevant shell rc files (`.bashrc`, `.zshrc`)
|
||||
- Sets system-level env vars (`launchctl setenv` on macOS, `environment.d` on Linux)
|
||||
- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.9.0+)
|
||||
- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.10.0+; server must also set `PROXY_ADVERTISE_ANON_KEY=1` — see [Anonymous Access](#anonymous-access-optional))
|
||||
- Configures OpenClaw automatically (including per-agent `auth-profiles.json` for multi-agent setups)
|
||||
- Detects Cline, Continue.dev, Cursor, and opencode, and prints setup hints (manual configuration required for these IDEs)
|
||||
|
||||
@@ -234,7 +409,21 @@ ocp keys revoke son-ipad # Revoke a key
|
||||
|------|-----|----------|
|
||||
| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed |
|
||||
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
|
||||
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys with usage tracking (recommended) |
|
||||
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys for usage tracking + quotas (trusted users only — see Deployment model below) |
|
||||
|
||||
> **Usage scope (v3.14.0+):** `/api/usage` returns the caller's own rows by default. Admin callers must pass `?all=true` to retrieve data for all keys; doing so emits an audit log line.
|
||||
|
||||
### Deployment model & security (read this)
|
||||
|
||||
**What OCP is built for today: single-user, multi-IDE.** Run OCP as a server on one machine and point all of *your own* IDEs/devices at it — one Claude Pro/Max subscription, used everywhere. This is the primary, solid use case.
|
||||
|
||||
**Sharing with family / a team — honest limits.** You *can* share OCP on a LAN, but be clear about what the auth modes do and don't give you:
|
||||
|
||||
- The per-key modes (`shared` / `multi`) give per-key **usage tracking, quotas, and cache separation** — useful for seeing who used what and capping budgets.
|
||||
- They do **not** give a **security isolation boundary**. The spawned `claude` runs with the **operator's filesystem access** and is *not* sandboxed per key. **Only share with people you fully trust, on a trusted network.**
|
||||
- For simple trusted family sharing, the easiest setup is a single shared **anonymous key** (see [Anonymous Access](#anonymous-access-optional)) — no per-person separation, same trust assumption.
|
||||
|
||||
**Real per-user isolation (sandboxed, multi-tenant-safe) is planned for after 2026-06-15** — per-key ephemeral home + tool lockdown + an OS sandbox. Until then, treat a multi-user OCP as a *trusted-group convenience*, not a security boundary. (This is also why `CLAUDE_TUI_MODE` is single-user-only — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode).)
|
||||
|
||||
### Anonymous Access (optional)
|
||||
|
||||
@@ -242,12 +431,16 @@ In `multi` mode, the admin can designate a single well-known "anonymous" key tha
|
||||
|
||||
**Enable**:
|
||||
|
||||
The anonymous key is wired into the service unit (launchd plist on macOS, systemd unit on Linux) at install time. Export `PROXY_ANONYMOUS_KEY` in your shell before running `setup.mjs`, and `setup.mjs` will write it into the service unit env so the auto-started proxy picks it up:
|
||||
|
||||
```bash
|
||||
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
|
||||
ocp start # or however you start the server
|
||||
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||
```
|
||||
|
||||
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set). Clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
|
||||
If OCP is already installed without it, re-export the env var and re-run `node setup.mjs` (the installer is idempotent — it refreshes the service unit). Then `ocp restart` so the running proxy picks up the new env. Setting `PROXY_ANONYMOUS_KEY` only in your interactive shell **does not** affect the auto-started proxy — the service unit is the source of truth for its environment.
|
||||
|
||||
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set) **only to localhost callers** or when the admin has also set `PROXY_ADVERTISE_ANON_KEY=1` (default off — see issue #109). With that opt-in, clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
|
||||
|
||||
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
|
||||
|
||||
@@ -293,6 +486,8 @@ When a key exceeds its quota, OCP returns HTTP 429 with a structured error:
|
||||
- Admin and anonymous users are never subject to quotas
|
||||
- PATCH is a partial update — omitted fields are left unchanged
|
||||
|
||||
> **Note:** quotas are best-effort. Under concurrent bursts a key can exceed its cap by up to the server's max-concurrency (default 8), and cache hits are not counted toward quota. They cap budgets for cooperative family use, not adversarial abuse.
|
||||
|
||||
### Important Notes
|
||||
|
||||
- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly)
|
||||
@@ -300,6 +495,7 @@ When a key exceeds its quota, OCP returns HTTP 429 with a structured error:
|
||||
- Keys are stored in `~/.ocp/ocp.db` (SQLite, zero external dependencies)
|
||||
- Admin key is required for key management API endpoints
|
||||
- The dashboard (`/dashboard`) and health check (`/health`) are always public
|
||||
- File modes for `~/.ocp` (0700), `admin-key` + `ocp.db` (0600) are auto-tightened at server startup as of v3.14.0
|
||||
|
||||
## Built-in Usage Monitoring
|
||||
|
||||
@@ -338,6 +534,7 @@ ocp keys List all API keys (multi mode)
|
||||
ocp keys add <name> Create a new API key
|
||||
ocp keys revoke <name> Revoke an API key
|
||||
ocp connect <ip> One-command LAN client setup
|
||||
ocp doctor Health & upgrade-readiness check; primary entry for AI-driven debugging. --json produces a next_action for AI agents.
|
||||
ocp lan Show LAN connection info & IP
|
||||
ocp settings View tunable settings
|
||||
ocp settings <k> <v> Update a setting at runtime
|
||||
@@ -364,16 +561,84 @@ ocp --help
|
||||
|
||||
> **Cloud/Linux servers:** If `ocp: command not found`, the binary isn't in PATH. Full path: `~/.openclaw/projects/ocp/ocp`
|
||||
|
||||
### Self-Update
|
||||
## Upgrading
|
||||
|
||||
The simplest path: ask your AI.
|
||||
|
||||
Paste this prompt:
|
||||
|
||||
```
|
||||
Upgrade my OCP. Run `ocp update` and follow whatever it says.
|
||||
If it tells me to run `claude auth login`, I'll do that.
|
||||
```
|
||||
|
||||
What `ocp update` does:
|
||||
|
||||
- **Patch bump** (e.g. `v3.14.0 → v3.14.1`):
|
||||
light path (git pull + npm install + restart).
|
||||
- **Cross-minor** (e.g. `v3.10 → v3.14`):
|
||||
full path: pre-flight check, snapshot, `setup.mjs` (with plist env-merge),
|
||||
service restart, post-flight `/health` and `/v1/models` verification.
|
||||
- **Old version** (< v3.4.0):
|
||||
fresh-install. Pre-v3.4 lacked admin-key/usage-db, so there is nothing to
|
||||
migrate. Your OAuth token (managed by the Claude Code CLI, not OCP) is
|
||||
preserved; you do not need to re-OAuth unless your token expired
|
||||
separately.
|
||||
|
||||
Snapshots are saved to `~/.ocp/upgrade-snapshot-<ISO-ts>/` and never
|
||||
auto-deleted. Clean old ones with `rm -rf ~/.ocp/upgrade-snapshot-*` once
|
||||
you're confident the upgrade is stable.
|
||||
|
||||
### Manual upgrade — same command, no AI
|
||||
|
||||
```bash
|
||||
# Check if a new version is available
|
||||
ocp update --check
|
||||
|
||||
# Pull latest, sync plugin, restart proxy — one command
|
||||
ocp update
|
||||
ocp update # smart-pick path
|
||||
ocp update --check # show available updates, don't apply
|
||||
ocp update --dry-run # preview plan
|
||||
ocp update --target v3.13.0 # pin a specific version
|
||||
ocp update --rollback --yes # restore most recent snapshot (--yes confirms)
|
||||
ocp update --rollback --list # list snapshots, no mutation
|
||||
ocp update --rollback --dry-run # preview rollback plan
|
||||
```
|
||||
|
||||
### When upgrade fails
|
||||
|
||||
`ocp update` prints a recovery line on failure. To restore from the snapshot:
|
||||
|
||||
```bash
|
||||
ocp update --rollback --yes # --yes confirms the destructive restore
|
||||
ocp doctor
|
||||
```
|
||||
|
||||
If `ocp doctor` still reports problems after rollback, open a GitHub issue
|
||||
with the snapshot path and the doctor JSON output (`ocp doctor --json`).
|
||||
|
||||
### OpenClaw Auto-Sync (v3.11.0+)
|
||||
|
||||
Whenever the model list in [`models.json`](./models.json) changes, `ocp update` automatically reconciles your OpenClaw config so the model dropdown stays in sync — no more "I upgraded OCP but my Telegram bot still shows the old models" surprises.
|
||||
|
||||
**What gets synced** (and only this — all other config keys are preserved):
|
||||
- `models.providers."claude-local".models` in `~/.openclaw/openclaw.json`
|
||||
- `agents.defaults.models["claude-local/*"]` aliases
|
||||
|
||||
**Safety**:
|
||||
- Timestamped backup written before every change: `~/.openclaw/openclaw.json.bak.<ms>`
|
||||
- Idempotent — already-in-sync runs are a no-op (no backup, no rewrite)
|
||||
- Non-fatal — sync failure does NOT abort `ocp update`; `/v1/models` still works
|
||||
- Skips silently if OpenClaw is not installed (`~/.openclaw/openclaw.json` missing)
|
||||
|
||||
**Manual trigger** (e.g. after fixing a hand-edited config, or for the one-time v3.10.0→v3.11.0 bootstrap quirk):
|
||||
```bash
|
||||
node ~/ocp/scripts/sync-openclaw.mjs
|
||||
node ~/ocp/scripts/sync-openclaw.mjs --quiet # silent unless changes
|
||||
```
|
||||
|
||||
**Opt-out**: `ocp update` only invokes the sync if `node` and `scripts/sync-openclaw.mjs` are both present. Removing the script disables auto-sync; the rest of `ocp update` still works.
|
||||
|
||||
**One-time bootstrap caveat (v3.10.0 → v3.11.0 only)**: the first `ocp update` to v3.11.0 runs the *old* `cmd_update` already loaded into your shell, so the new sync hook does NOT fire on this single jump. Run `node ~/ocp/scripts/sync-openclaw.mjs` once manually. Every future update from v3.11.0+ syncs automatically.
|
||||
|
||||
**Other IDEs** (Cline / Aider / Cursor / opencode) query `/v1/models` live, so they pick up new models on the next request — no sync needed. Continue.dev users edit their own `config.json` model id manually.
|
||||
|
||||
### Runtime Settings (No Restart Needed)
|
||||
|
||||
```
|
||||
@@ -399,17 +664,20 @@ ocp settings cacheTTL 300000
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- Cache key = SHA-256 of `model` + `messages` + `temperature` + `max_tokens` + `top_p`
|
||||
- Cache key = SHA-256 of `v2|<keyId or "anon">|model + messages + temperature + max_tokens + top_p`
|
||||
- **Per-key isolation** — different API keys never share cache entries; anonymous callers share one `anon` pool
|
||||
- Cache hits return instantly — no Claude CLI process spawned
|
||||
- Works for both streaming and non-streaming requests
|
||||
- **Streaming hits** are replayed as multiple SSE chunks (80 codepoints each), not one large delta — incremental render preserved
|
||||
- **`cache_control` bypass** — if a request carries an Anthropic `cache_control` annotation (top-level or nested in `content[]`), OCP skips its own cache entirely so it doesn't interfere with Anthropic-side prompt caching
|
||||
- **Singleflight stampede protection** — concurrent identical cache-miss requests share one upstream `cli.js` spawn; followers receive byte-identical responses to the leader's call. Non-streaming path only (streaming-path singleflight is a known TODO)
|
||||
- Multi-turn conversations (with `session_id`) are never cached
|
||||
- Expired entries are cleaned up automatically every 10 minutes
|
||||
|
||||
**Management:**
|
||||
```bash
|
||||
# View cache stats
|
||||
# View cache stats (now includes singleflight in-flight counts)
|
||||
curl http://127.0.0.1:3456/cache/stats
|
||||
# → { "entries": 42, "totalHits": 156, "sizeBytes": 284000 }
|
||||
# → { "entries": 42, "totalHits": 156, "sizeBytes": 284000, "inflight": 0, "requesters": 0 }
|
||||
|
||||
# Clear all cached responses
|
||||
curl -X DELETE http://127.0.0.1:3456/cache
|
||||
@@ -420,21 +688,36 @@ ocp settings cacheTTL 0
|
||||
|
||||
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`.
|
||||
|
||||
**Hash format upgrade in v3.13.0:** legacy `v1` cache rows from earlier versions don't match new `v2`-format lookups; they orphan and are reaped by the TTL cleanup interval within one window. No migration script required.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Your IDE → OCP (localhost:3456) → claude -p CLI → Anthropic (via subscription)
|
||||
Your IDE → OCP (localhost:3456) → claude --output-format stream-json CLI → Anthropic (via subscription)
|
||||
```
|
||||
|
||||
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
|
||||
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude --output-format stream-json` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
|
||||
|
||||
## Available Models
|
||||
|
||||
| Model ID | Notes |
|
||||
|----------|-------|
|
||||
| `claude-opus-4-6` | Most capable, slower |
|
||||
| `claude-sonnet-4-6` | Good balance of speed/quality |
|
||||
| `claude-haiku-4-5-20251001` | Fastest, lightweight |
|
||||
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
|
||||
| `claude-opus-4-7` | Previous Opus, retained for pinning |
|
||||
| `claude-opus-4-6` | Older Opus, retained for pinning |
|
||||
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
|
||||
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
||||
|
||||
The canonical list lives in [`models.json`](./models.json) — the single source of truth as of v3.11.0. Both `server.mjs` (the `/v1/models` endpoint) and `setup.mjs` (the OpenClaw registration) derive from it. Adding a new model is now a one-file edit:
|
||||
|
||||
```bash
|
||||
# 1. Edit models.json — add an entry
|
||||
# 2. Bump version, commit, tag, push
|
||||
# 3. Users get it on next `ocp update`:
|
||||
# - OpenClaw: auto-synced via scripts/sync-openclaw.mjs
|
||||
# - Cline / Aider / Cursor / opencode: live /v1/models, picks up immediately
|
||||
# - Continue.dev: user edits their own config.json
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
@@ -442,7 +725,7 @@ OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p
|
||||
|----------|--------|-------------|
|
||||
| `/v1/models` | GET | List available models |
|
||||
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
|
||||
| `/health` | GET | Comprehensive health check |
|
||||
| `/health` | GET | Comprehensive health check (includes a `tui` block for TUI-mode drift/concurrency monitoring) |
|
||||
| `/usage` | GET | Plan usage limits + per-model stats |
|
||||
| `/status` | GET | Combined overview (usage + health) |
|
||||
| `/settings` | GET/PATCH | View or update settings at runtime |
|
||||
@@ -452,7 +735,7 @@ OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p
|
||||
| `/api/keys` | GET/POST | List or create API keys (admin only) |
|
||||
| `/api/keys/:id` | DELETE | Revoke an API key (admin only) |
|
||||
| `/api/keys/:id/quota` | GET/PATCH | View or set per-key quota (admin only) |
|
||||
| `/api/usage` | GET | Per-key usage stats (`?since=&until=&hours=&limit=`) |
|
||||
| `/api/usage` | GET | Per-key usage stats (`?since=&until=&hours=&limit=`); returns self only by default — pass `?all=true` (admin only) for all-keys data |
|
||||
| `/cache/stats` | GET | Cache statistics (admin only) |
|
||||
| `/cache` | DELETE | Clear response cache (admin only) |
|
||||
|
||||
@@ -460,7 +743,8 @@ OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p
|
||||
|
||||
OCP was originally built for [OpenClaw](https://github.com/openclaw/openclaw) and includes deep integration:
|
||||
|
||||
- **`setup.mjs`** auto-configures the `claude-local` provider in `openclaw.json`
|
||||
- **`setup.mjs`** auto-configures the `claude-local` provider in `openclaw.json` at install time
|
||||
- **`ocp update`** auto-syncs the `claude-local` model registry from `models.json` (v3.11.0+) — no more stale model dropdowns after upgrades
|
||||
- **Gateway plugin** registers `/ocp` as a native slash command in Telegram/Discord
|
||||
- **Multi-agent** — 8 concurrent requests sharing one subscription
|
||||
- **No conflicts** — uses neutral service names (`dev.ocp.proxy` / `ocp-proxy`) that don't trigger OpenClaw's gateway-like service detection
|
||||
@@ -501,6 +785,52 @@ After installing the gateway plugin, use `/ocp` slash commands in your chat:
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
The simplest path: ask your AI.
|
||||
|
||||
Paste this prompt:
|
||||
|
||||
```
|
||||
Run `ocp doctor` and follow its `next_action`. Tell me if you hit
|
||||
anything that needs human input.
|
||||
```
|
||||
|
||||
The doctor produces a JSON `next_action` with `ai_executable[]` (commands
|
||||
the agent runs verbatim) and `human_required[]` (steps that need you,
|
||||
typically just OAuth).
|
||||
|
||||
### Manual debugging
|
||||
|
||||
### Setup fails with "claude: command not found"
|
||||
|
||||
`setup.mjs` requires the Claude CLI to be on `PATH`. Install it via the [official guide](https://docs.anthropic.com/en/docs/claude-cli), confirm with `which claude`, then run `claude auth login` before re-running `node setup.mjs`.
|
||||
|
||||
### Setup fails with "EADDRINUSE: port 3456 already in use"
|
||||
|
||||
Something else is already bound to port 3456 — usually an old OCP instance. Check what:
|
||||
|
||||
```bash
|
||||
lsof -nP -iTCP:3456 -sTCP:LISTEN
|
||||
```
|
||||
|
||||
If it's an old OCP process, stop it before re-running setup:
|
||||
|
||||
```bash
|
||||
ocp stop # if the CLI is on PATH
|
||||
launchctl bootout gui/$(id -u)/dev.ocp.proxy # macOS launchd fallback
|
||||
sudo systemctl stop ocp-proxy # Linux systemd fallback
|
||||
```
|
||||
|
||||
### Setup fails with "node: command not found" or version error
|
||||
|
||||
OCP requires Node.js 22.5+. Install:
|
||||
|
||||
```bash
|
||||
brew install node # macOS
|
||||
# Linux: see https://nodejs.org/en/download for current install commands
|
||||
```
|
||||
|
||||
Confirm with `node --version` (should be ≥ v22.5).
|
||||
|
||||
### Requests fail or agents stuck
|
||||
|
||||
```bash
|
||||
@@ -520,16 +850,57 @@ claude auth login
|
||||
ocp restart
|
||||
```
|
||||
|
||||
### Startup log warns "OpenClaw registry out of sync"
|
||||
|
||||
On boot, OCP compares OpenClaw's registered models against [`models.json`](./models.json) and warns if they drift. Cause: someone (or an OpenClaw upgrade) modified `~/.openclaw/openclaw.json` and removed entries OCP expects. Fix:
|
||||
|
||||
```bash
|
||||
node ~/ocp/scripts/sync-openclaw.mjs
|
||||
```
|
||||
|
||||
This is read-only at startup; the warning never blocks the gateway from running.
|
||||
|
||||
### OpenClaw shows old models after `ocp update` (v3.10→v3.11 only)
|
||||
|
||||
One-time bootstrap quirk for the v3.10.0 → v3.11.0 jump only — the running shell had the old `cmd_update` cached. Run once manually:
|
||||
|
||||
```bash
|
||||
node ~/ocp/scripts/sync-openclaw.mjs
|
||||
openclaw gateway restart # so OpenClaw re-reads the config
|
||||
```
|
||||
|
||||
Future `ocp update` invocations sync automatically.
|
||||
|
||||
### TUI-mode returns `Please run /login · API Error: 401` (re-login doesn't stick)
|
||||
|
||||
A long-running TUI-mode host can get stuck returning a permanent 401 that re-login cannot fix.
|
||||
|
||||
**Root cause (two layers):** interactive `claude` **prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var** (this is *unlike* the `-p` path, where the env token wins). So (a) a stale/corrupt `credentials.json` **shadows** the env token — passing the token is not enough on its own; and (b) when claude does use `credentials.json`, its single-use OAuth refresh token can be corrupted (ending up an empty string) by the per-request spawn + `kill-session` teardown racing claude's token rotation. Re-login writes a fresh token, but the next spawn re-corrupts it. Proven live on PI231: *env token passed + broken `credentials.json` present → 401; env token passed + `credentials.json` moved aside → works.*
|
||||
|
||||
**Fix:** set `CLAUDE_CODE_OAUTH_TOKEN` on the OCP host and leave `OCP_TUI_HOME` **unset**. OCP then runs the TUI `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** at all, so the env token is the only credential (authoritative — nothing shadows it) and claude never runs the refresh path (so the single-use token can't be corrupted). Then restart — on systemd `daemon-reload`, on launchd `bootout`+`bootstrap`; `kickstart -k` does **not** reload env. Verify the env reached the process and the boot log shows the isolated home:
|
||||
|
||||
```bash
|
||||
# Linux (systemd): confirm the token is in the service env
|
||||
tr '\0' '\n' < /proc/$(pgrep -f server.mjs | head -1)/environ | grep CLAUDE_CODE_OAUTH_TOKEN
|
||||
# Boot log should read: TUI-mode: ON home=$HOME/.ocp-tui/home ... auth=env-token (credential-isolated home — no credentials.json)
|
||||
```
|
||||
|
||||
> If you previously set `OCP_TUI_HOME` to the real home (or any home that contains a `credentials.json`), **unset it** so the credential-isolated default takes effect — otherwise the shadowing `credentials.json` remains in play.
|
||||
|
||||
See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-C / PR-D amendments.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port (server-side). Also consumed by the OpenClaw `ocp-plugin` to dial the local proxy. |
|
||||
| `OCP_PROXY_URL` | *(unset)* | Plugin-side full URL override (e.g. `http://10.0.0.5:3456`). Wins over `CLAUDE_PROXY_PORT` when both are set. Read by `ocp-plugin/index.js` only — server ignores it. |
|
||||
| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) |
|
||||
| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` |
|
||||
| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) |
|
||||
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
||||
| `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
|
||||
| `CLAUDE_HEARTBEAT_INTERVAL` | `0` | Streaming SSE keepalive interval (ms). `0` = disabled. See "Streaming heartbeat" section. |
|
||||
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
|
||||
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
|
||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
|
||||
@@ -538,7 +909,153 @@ ocp restart
|
||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
||||
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
|
||||
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
|
||||
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` so clients auto-discover. See [Anonymous Access](#anonymous-access-optional). |
|
||||
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). |
|
||||
| `PROXY_ADVERTISE_ANON_KEY` | *(unset)* | When `=1`, advertise `PROXY_ANONYMOUS_KEY` in the public `/health` body for remote zero-config discovery. Default off — `/health` is unauthenticated, so this exposes the shared key to any LAN-reachable device (issue #109). Localhost always sees it regardless. |
|
||||
| `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. |
|
||||
| `CLAUDE_CODE_OAUTH_TOKEN` | *(unset)* | OAuth bearer token (highest-precedence credential source for the `-p` path). **Recommended for TUI-mode hosts:** when set (and `OCP_TUI_HOME` unset), OCP runs the interactive `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`, no `credentials.json`) so this long-lived token is the only credential and is authoritative — interactive `claude` otherwise *prefers* `~/.claude/.credentials.json` over the env var, so a stale one shadows the token and its single-use refresh token gets corrupted by the spawn/teardown cycle (the permanent `Please run /login` 401 — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-D). The token appears in the pane command (ps-visible) — acceptable for the single-user A-path; the multi-user B-path is refused at boot. |
|
||||
| `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. |
|
||||
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
|
||||
| `OCP_TUI_HOME` | *(auto)* | (TUI-mode) `HOME` claude runs under. **When unset, OCP picks it for you:** if `CLAUDE_CODE_OAUTH_TOKEN` is set → a **credential-isolated** scratch home `$HOME/.ocp-tui/home` (no `credentials.json`, env-token auth — **recommended**); if no env token → the operator's real home (legacy shared `credentials.json`). Setting this to an **explicit** path overrides the auto-default. The credential handling at that path still follows the env token: **with** the env token it is credential-free (env-token auth, no `credentials.json` written); **without** the env token (and the path ≠ real home) it uses the legacy symlinked-credentials scratch mode, which carries the credential-fork caveat — see ADR 0007. |
|
||||
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. |
|
||||
| `OCP_TUI_MAX_CONCURRENT` | `2` | (TUI-mode) Max concurrent interactive TUI turns. **Independent** of `CLAUDE_MAX_CONCURRENT` (which bounds the `-p`/stream-json path; TUI never uses it). A TUI turn is heavy (per-request cold-boot of tmux+claude + up to `CLAUDE_TUI_WALLCLOCK_MS` wallclock), so the default is low to keep small hosts (e.g. a Pi 4) alive under a burst. Excess turns **queue** (bounded); a full queue yields a 503. See ADR 0007 PR-B amendment. |
|
||||
| `OCP_SKIP_AUTH_TEST` | *(unset)* | When `=1`, skip the `claude -p` auth probe during `setup.mjs`. After 2026-06-15 this probe draws from the Agent SDK credit pool; set this to avoid burning a metered credit on re-installs or `ocp update` runs. Auth is validated at the first real request. |
|
||||
| `OCP_TUI_FULL_TOOLS` | *(unset)* | (TUI-mode, **single-user only**) When `=1`, grant the interactive session the **same tool surface as the `-p` path** — `--allowedTools` (+ optional `--mcp-config` / `--dangerously-skip-permissions`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG` / `CLAUDE_SKIP_PERMISSIONS`) — instead of the default MCP-walled, built-in-tools-only set. Lets a trusted single-operator TUI deployment run a **tool-using / MCP agent** (e.g. an OpenClaw assistant) on the subscription pool. Safe because TUI **refuses to boot under `AUTH_MODE=multi`** (hard exit) — no guest key can ever reach the TUI path, so this gate cannot expose tools to an untrusted caller. (Under `AUTH_MODE=shared` + `OCP_TUI_ALLOW_LAN=1`, anyone holding the single shared key reaches it — that is the existing TUI trust model, unchanged.) See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007. |
|
||||
|
||||
### Streaming heartbeat
|
||||
|
||||
When `CLAUDE_HEARTBEAT_INTERVAL` is set to a positive integer (milliseconds), OCP emits an SSE comment frame (`: keepalive\n\n`) on streaming responses whenever the stream has been idle for that duration. The timer resets on every real chunk, so heartbeats only fire during genuine silent windows (for example, Claude CLI tool-use pauses of 30s–5min, or a long "processing large contexts" delay before the first token).
|
||||
|
||||
Use cases: downstream HTTP clients or load balancers with idle-connection timeouts that would otherwise abort a slow-but-alive request. `CLAUDE_HEARTBEAT_INTERVAL=30000` (30s) is a reasonable starting value if your downstream has a 60s idle timeout.
|
||||
|
||||
Heartbeats are inert SSE comment lines — conforming SSE clients ignore them. If your downstream client's SSE parser crashes on comment frames, leave this disabled (the default) and file an issue so we can consider an alternate frame format.
|
||||
|
||||
OCP also sends `X-Accel-Buffering: no` on SSE responses so nginx-default proxy buffering does not hold heartbeats in an upstream buffer.
|
||||
|
||||
## Subscription-pool (TUI) mode
|
||||
|
||||
> **SECURITY — read before enabling.**
|
||||
> TUI-mode is **single-user / single-operator only**. `claude` runs with the OCP process owner's filesystem access regardless of `HOME` setting. If OCP serves multiple users or guest API keys, a guest prompt could exfiltrate files or exhaust the subscription. **Never enable `CLAUDE_TUI_MODE=true` on a multi-user OCP.**
|
||||
|
||||
### What it is and why
|
||||
|
||||
From 2026-06-15 Anthropic routes `claude` invocations by `cc_entrypoint`:
|
||||
|
||||
| Launch method | `cc_entrypoint` | Billing pool |
|
||||
|---------------|-----------------|-------------|
|
||||
| `claude -p` / `--output-format` (OCP default) | `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro) |
|
||||
| Interactive `claude` (no flags) | `cli` | Pro/Max subscription pool |
|
||||
|
||||
TUI-mode lets OCP serve requests via the interactive path so they bill against the subscription pool. The response is read from claude's native JSONL session transcript once the turn is complete, then replayed to the caller as a normal OpenAI completion or chunked SSE response.
|
||||
|
||||
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`)
|
||||
|
||||
`OCP_TUI_ENTRYPOINT` (default `cli`) controls how `CLAUDE_CODE_ENTRYPOINT` is set on the spawn
|
||||
environment. The default (`cli`) pins the value deterministically — immune to a stray inherited
|
||||
env var or a future stdout-redirect bug silently flipping it to `sdk-cli`. This label is honest
|
||||
**only** when the spawn is a genuine interactive PTY (tmux pane, no `-p`, stdout not redirected,
|
||||
and `tmux new-session` verified to succeed). If you need to observe the raw TTY-derived value, set
|
||||
`OCP_TUI_ENTRYPOINT=auto`. See ADR 0007 for the full rationale and governing rule.
|
||||
|
||||
### Enabling TUI-mode (opt-in)
|
||||
|
||||
```bash
|
||||
# Prerequisites
|
||||
mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup
|
||||
# tmux must be installed: brew install tmux / apt install tmux
|
||||
|
||||
# Enable
|
||||
export CLAUDE_TUI_MODE=true
|
||||
# STRONGLY RECOMMENDED on a TUI host — authenticate via the long-lived OAuth token.
|
||||
# With this set (and OCP_TUI_HOME left UNSET), OCP runs the interactive claude in a
|
||||
# credential-isolated home ($HOME/.ocp-tui/home, no credentials.json), so the env token
|
||||
# is the only credential and is authoritative. This both stops a stale credentials.json
|
||||
# from shadowing the token AND ends the refresh-token corruption that caused a permanent
|
||||
# "Please run /login" 401 (no credentials file → claude never runs the refresh path).
|
||||
# See the auth note below + ADR 0007 PR-D.
|
||||
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
|
||||
# Optionally tune:
|
||||
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns
|
||||
export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed
|
||||
export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value
|
||||
# Do NOT set OCP_TUI_HOME for the recommended setup — leaving it unset is what enables
|
||||
# the credential-isolated home. Set it only to opt into the legacy symlinked-creds mode.
|
||||
```
|
||||
|
||||
Then restart OCP. At boot you will see (with the env token set, isolated home auto-selected):
|
||||
|
||||
```
|
||||
⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ...
|
||||
TUI-mode: ON home=/home/user/.ocp-tui/home cwd=/home/user/.ocp-tui/work auth=env-token (credential-isolated home — no credentials.json) wallclock=120000ms maxConcurrent=2
|
||||
```
|
||||
|
||||
### What changes / what doesn't
|
||||
|
||||
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
||||
- **No real token streaming.** TUI-mode buffers the full response then replays it as chunked SSE. You will see a delay then the complete response rather than real-time tokens.
|
||||
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
||||
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled.
|
||||
- **Authenticate via `CLAUDE_CODE_OAUTH_TOKEN` in a credential-isolated home (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. But passing the token is **not enough on its own**: interactive `claude` *prefers* `~/.claude/.credentials.json` over the env var (unlike the `-p` path), so a stale `credentials.json` would shadow the token. With the env token set and `OCP_TUI_HOME` unset, OCP therefore runs claude in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** — so the env token is the only credential and is authoritative, and claude never runs the token-refresh path (so the single-use refresh token can't be corrupted by the spawn/teardown cycle). On a long-running host the credentials.json path produced a permanent `Please run /login · API Error: 401` that re-login could not fix (the next spawn re-corrupted it); the isolated home ends that at the root. Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home's `credentials.json` (byte-for-byte the previous behaviour). (The token is visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C / PR-D amendments.
|
||||
- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment.
|
||||
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
||||
- **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once.
|
||||
|
||||
### Monitoring drift via `/health`
|
||||
|
||||
`GET /health` includes a `tui` block so you can poll for a silent billing-pool drift (the top risk after the 6/15 flip — a lost TTY flipping `cc_entrypoint` from `cli` to the metered `sdk-cli` pool would still return answers but burn metered credits). The block is **always present** (with `enabled:false` when TUI-mode is off):
|
||||
|
||||
```jsonc
|
||||
"tui": {
|
||||
"enabled": true, // CLAUDE_TUI_MODE === "true"
|
||||
"entrypointMode": "cli", // OCP_TUI_ENTRYPOINT (cli | auto | off)
|
||||
"lastEntrypoint": "cli", // last cc_entrypoint observed in a transcript, or null
|
||||
"entrypointMismatches": 0, // count of cli-expected-but-got-other turns — ALERT if this climbs
|
||||
"inflight": 1, // TUI turns running right now
|
||||
"queued": 0, // TUI turns waiting for a concurrency slot
|
||||
"maxConcurrent": 2 // OCP_TUI_MAX_CONCURRENT
|
||||
}
|
||||
```
|
||||
|
||||
Alert on `entrypointMismatches > 0` (or `lastEntrypoint !== "cli"`): it means a turn drew from the metered Agent SDK pool instead of the subscription. `inflight` / `queued` show how close the TUI path is to its concurrency cap.
|
||||
|
||||
### Kill-switch
|
||||
|
||||
```bash
|
||||
unset CLAUDE_TUI_MODE
|
||||
# restart OCP
|
||||
```
|
||||
|
||||
The stream-json path is restored immediately. No other change is needed.
|
||||
|
||||
### 2026-06-15 operator checklist
|
||||
|
||||
Every host serving traffic must be flipped to TUI-mode **and** canary-verified before 2026-06-15, or it will bill the metered Agent SDK credit pool instead of the subscription.
|
||||
|
||||
- **[Flip/rollback runbook](docs/runbooks/tui-flip-rollback.md)** — how to set `CLAUDE_TUI_MODE=true` on systemd (Linux) and launchd (macOS) hosts. Covers the `daemon-reload` requirement (systemd) and the `bootout`+`bootstrap` cycle requirement (launchd — `launchctl kickstart -k` does not reload plist env).
|
||||
- **[615-canary runbook](docs/runbooks/615-canary.md)** — after each flip, run one quiesced request and compare the Agent SDK credit balance before and after. `entrypoint:cli` in the transcript (the `cc_entrypoint` billing classifier) is necessary but not sufficient — only a stable credit balance confirms the subscription pool is being used. Balance check is a manual step (no known programmatic API for the Agent SDK credit pool balance).
|
||||
|
||||
### Architecture and design decisions
|
||||
|
||||
See [`docs/adr/0007-tui-interactive-mode.md`](docs/adr/0007-tui-interactive-mode.md) for the full rationale, home-strategy options, MCP-disable mechanism, coexistence rules, and the B-path (multi-tenant isolation) roadmap.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
Top-level files a contributor or operator may need to know:
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `server.mjs` | The proxy itself; every request path lives here. Governed by `ALIGNMENT.md`. |
|
||||
| `setup.mjs` | First-time installer — verifies Claude CLI, patches OpenClaw config, installs auto-start. |
|
||||
| `uninstall.mjs` | Reverses the launchd / systemd auto-start install. |
|
||||
| `keys.mjs` | API-key management module (multi-mode auth: create/list/revoke, quotas, usage tracking). |
|
||||
| `models.json` | Single source of truth for model IDs, aliases, context windows. See ADR 0003. |
|
||||
| `ocp` / `ocp-connect` | User-facing CLI wrappers (server-side / client-side respectively). |
|
||||
| `dashboard.html` | Static dashboard served from `/dashboard`. |
|
||||
| `scripts/sync-openclaw.mjs` | Idempotent OpenClaw registry sync invoked by `ocp update`. See ADR 0004. |
|
||||
| `.claude/skills/` | Project-specific Claude Code skills. |
|
||||
| `ocp-plugin/` | OpenClaw gateway plugin (optional installation). |
|
||||
| `docs/adr/` | Architecture Decision Records. Read these before proposing governance or SPOT changes — see [`docs/adr/README.md`](docs/adr/README.md). |
|
||||
| `ALIGNMENT.md` | The constitution. Binding for any `server.mjs` change. |
|
||||
| `AGENTS.md` / `CLAUDE.md` | Agent and Claude-Code-specific session instructions. |
|
||||
|
||||
## Security
|
||||
|
||||
@@ -551,6 +1068,36 @@ ocp restart
|
||||
- **Keys stored locally** — `~/.ocp/ocp.db` (SQLite), never sent to external services
|
||||
- **Auto-start** — launchd (macOS) / systemd (Linux)
|
||||
|
||||
## Governance
|
||||
|
||||
OCP runs under a small set of binding documents so contributions stay aligned with what `cli.js` actually does, not what an LLM thinks it does:
|
||||
|
||||
- **[`ALIGNMENT.md`](./ALIGNMENT.md)** — the constitution. Every endpoint OCP exposes must correspond to something `cli.js` actually does, with a line-number citation. Background in [ADR 0002](./docs/adr/0002-alignment-constitution.md).
|
||||
- **[`.github/workflows/alignment.yml`](./.github/workflows/alignment.yml)** — CI guardrail. Greps `server.mjs` for known-hallucinated tokens and fails the build on any hit. Not suppressible without an `ALIGNMENT.md` amendment PR.
|
||||
- **[`AGENTS.md`](./AGENTS.md)** — guidelines any AI coding agent (Claude Code / Cursor / Copilot / Codex / Gemini) should read before touching this repo.
|
||||
- **[`models.json`](./models.json)** — single source of truth for the model registry. See [ADR 0003](./docs/adr/0003-models-json-spot.md).
|
||||
- **[`docs/adr/`](./docs/adr/)** — architecture decision records explaining why current structure exists.
|
||||
|
||||
If you want to contribute: read `ALIGNMENT.md` first, search `cli.js` for the operation you're proposing, and cite the line number in your PR.
|
||||
|
||||
## Support OCP
|
||||
|
||||
OCP has been **open source from day one** — not a freemium tool, not a commercial product turned open, just open. It will stay that way forever. No paid tiers, no premium features, no "Pro" version locked behind a paywall.
|
||||
|
||||
I built it because my family and I needed it. We use OCP every day across our own machines and IDEs — keeping one Claude Pro/Max subscription powering everything, saving the per-token API cost we'd otherwise pay. It's been quietly heartwarming to hear from users online who say OCP has saved them money the same way it saves ours. That's the whole point.
|
||||
|
||||
Behind every version are hundreds of hours that don't show up in commits: building it from scratch, adding new features as the Claude Code ecosystem evolves, debugging across Mac / Windows / Linux machines, validating against half a dozen IDEs (Claude Code, Cursor, Cline, OpenCode, Aider, Continue.dev, OpenClaw), tracking down `cli.js` drift, OAuth refresh edge cases, SSE streaming quirks, concurrency leaks, and the occasional incident that turns into a multi-day investigation (the [2026-04-11 alignment drift](./docs/adr/0002-alignment-constitution.md), the [v3.11.1 concurrency leak](./CHANGELOG.md), the v3.12 SSE replay regression).
|
||||
|
||||
**The commitment**: this project will keep being updated, keep getting new features, and will stay open source as long as I'm able to maintain it.
|
||||
|
||||
**Please try it.** If something breaks or could be better, [open an issue](https://github.com/dtzp555-max/ocp/issues) — feedback is genuinely what keeps the project moving.
|
||||
|
||||
And if OCP saves you (or your team, or your family) real money and you'd like to chip in toward the next debugging session:
|
||||
|
||||
- ☕ **[Buy me a coffee](https://buymeacoffee.com/dtzp555)**
|
||||
|
||||
Donations directly fund the time it takes to keep OCP saving the community money.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT — see [`LICENSE`](LICENSE).
|
||||
|
||||
+43
-17
@@ -55,7 +55,13 @@
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Usage by Key</h2>
|
||||
<div class="flex" style="justify-content: space-between; align-items: center;">
|
||||
<h2 style="margin: 0;">Usage by Key</h2>
|
||||
<label id="usage-scope-toggle" class="flex" style="display:none; gap: 0.4rem; font-size: 0.8rem; color: #94a3b8; cursor: pointer;">
|
||||
<input type="checkbox" id="usage-show-all" style="cursor: pointer;">
|
||||
<span>Show all keys</span>
|
||||
</label>
|
||||
</div>
|
||||
<table id="key-usage-table">
|
||||
<thead><tr><th>Key</th><th>Requests</th><th>OK</th><th>Err</th><th>Avg Time</th><th>Last Request</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
@@ -126,6 +132,10 @@ function fmtChars(n) {
|
||||
return n > 1000 ? (n/1000).toFixed(0) + "K" : String(n);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
function barColor(pct) {
|
||||
if (pct >= 80) return "bar-red";
|
||||
if (pct >= 50) return "bar-amber";
|
||||
@@ -138,8 +148,8 @@ async function refreshStatus() {
|
||||
const r = data.requests || {};
|
||||
|
||||
document.getElementById("status-cards").innerHTML = `
|
||||
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${p.status || '?'}</span></div><div class="sub">v${p.version || '?'}</div></div>
|
||||
<div class="card"><div class="label">Uptime</div><div class="value">${p.uptime || '?'}</div></div>
|
||||
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${escapeHtml(p.status || '?')}</span></div><div class="sub">v${escapeHtml(p.version || '?')}</div></div>
|
||||
<div class="card"><div class="label">Uptime</div><div class="value">${escapeHtml(p.uptime || '?')}</div></div>
|
||||
<div class="card"><div class="label">Requests</div><div class="value">${r.total || 0}</div><div class="sub">${r.active || 0} active</div></div>
|
||||
<div class="card"><div class="label">Errors</div><div class="value">${r.errors || 0}</div><div class="sub">${r.timeouts || 0} timeouts</div></div>
|
||||
<div class="card"><div class="label">Sessions</div><div class="value">${p.activeSessions || 0}</div></div>
|
||||
@@ -154,15 +164,15 @@ async function refreshStatus() {
|
||||
document.getElementById("plan-cards").innerHTML = `
|
||||
<div class="card">
|
||||
<div class="label">Session (5h)</div>
|
||||
<div class="value">${s.percent || '?'}</div>
|
||||
<div class="value">${escapeHtml(s.percent || '?')}</div>
|
||||
<div class="bar-bg"><div class="bar-fill ${barColor(sPct)}" style="width:${sPct}%"></div></div>
|
||||
<div class="sub">Resets in ${s.resetsIn || '?'}</div>
|
||||
<div class="sub">Resets in ${escapeHtml(s.resetsIn || '?')}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Weekly (7d)</div>
|
||||
<div class="value">${w.percent || '?'}</div>
|
||||
<div class="value">${escapeHtml(w.percent || '?')}</div>
|
||||
<div class="bar-bg"><div class="bar-fill ${barColor(wPct)}" style="width:${wPct}%"></div></div>
|
||||
<div class="sub">Resets in ${w.resetsIn || '?'}</div>
|
||||
<div class="sub">Resets in ${escapeHtml(w.resetsIn || '?')}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -170,25 +180,26 @@ async function refreshStatus() {
|
||||
|
||||
async function refreshUsage() {
|
||||
try {
|
||||
const data = await api("/api/usage");
|
||||
const showAll = localStorage.getItem("ocp_usage_show_all") === "1";
|
||||
const data = await api(showAll ? "/api/usage?all=true" : "/api/usage");
|
||||
const tbody = document.querySelector("#key-usage-table tbody");
|
||||
tbody.innerHTML = (data.byKey || []).map(k => `
|
||||
<tr>
|
||||
<td>${k.key_name}</td>
|
||||
<td>${escapeHtml(k.key_name)}</td>
|
||||
<td>${k.requests}</td>
|
||||
<td>${k.successes}</td>
|
||||
<td>${k.errors}</td>
|
||||
<td>${fmtTime(k.avg_elapsed_ms)}</td>
|
||||
<td class="mono">${k.last_request || '-'}</td>
|
||||
<td class="mono">${escapeHtml(k.last_request || '-')}</td>
|
||||
</tr>
|
||||
`).join("") || '<tr><td colspan="6" style="color:#475569">No usage data yet</td></tr>';
|
||||
|
||||
const rtbody = document.querySelector("#recent-table tbody");
|
||||
rtbody.innerHTML = (data.recent || []).slice(0, 20).map(r => `
|
||||
<tr>
|
||||
<td class="mono">${r.created_at?.slice(11, 19) || '?'}</td>
|
||||
<td>${r.key_name}</td>
|
||||
<td>${r.model}</td>
|
||||
<td class="mono">${escapeHtml(r.created_at?.slice(11, 19) || '?')}</td>
|
||||
<td>${escapeHtml(r.key_name)}</td>
|
||||
<td>${escapeHtml(r.model)}</td>
|
||||
<td>${fmtChars(r.prompt_chars)}</td>
|
||||
<td>${fmtChars(r.response_chars)}</td>
|
||||
<td>${fmtTime(r.elapsed_ms)}</td>
|
||||
@@ -204,16 +215,21 @@ async function refreshKeys() {
|
||||
try {
|
||||
const data = await api("/api/keys");
|
||||
document.getElementById("key-mgmt-section").style.display = "";
|
||||
// Admin-only "Show all keys" toggle for /api/usage scope.
|
||||
document.getElementById("usage-scope-toggle").style.display = "flex";
|
||||
const tbody = document.querySelector("#keys-table tbody");
|
||||
tbody.innerHTML = (data.keys || []).map(k => `
|
||||
<tr>
|
||||
<td>${k.name}</td>
|
||||
<td class="mono">${k.keyPreview}</td>
|
||||
<td class="mono">${k.created_at}</td>
|
||||
<td>${escapeHtml(k.name)}</td>
|
||||
<td class="mono">${escapeHtml(k.keyPreview)}</td>
|
||||
<td class="mono">${escapeHtml(k.created_at)}</td>
|
||||
<td><span class="tag ${k.revoked ? 'tag-err' : 'tag-ok'}">${k.revoked ? 'revoked' : 'active'}</span></td>
|
||||
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" onclick="revokeKeyUI('${k.name}')">Revoke</button>`}</td>
|
||||
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" data-revoke="${escapeHtml(k.name)}">Revoke</button>`}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
tbody.querySelectorAll("button[data-revoke]").forEach(btn =>
|
||||
btn.addEventListener("click", () => revokeKeyUI(btn.getAttribute("data-revoke")))
|
||||
);
|
||||
} catch(e) { /* not admin */ }
|
||||
}
|
||||
|
||||
@@ -240,6 +256,16 @@ async function refreshAll() {
|
||||
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
// Wire "Show all keys" toggle (visibility gated to admin via refreshKeys()).
|
||||
(function setupUsageScopeToggle() {
|
||||
const cb = document.getElementById("usage-show-all");
|
||||
cb.checked = localStorage.getItem("ocp_usage_show_all") === "1";
|
||||
cb.addEventListener("change", () => {
|
||||
localStorage.setItem("ocp_usage_show_all", cb.checked ? "1" : "0");
|
||||
refreshUsage();
|
||||
});
|
||||
})();
|
||||
|
||||
refreshAll();
|
||||
setInterval(refreshAll, 30000);
|
||||
</script>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
services:
|
||||
claude-proxy:
|
||||
build: .
|
||||
ports:
|
||||
- "3456:3456"
|
||||
env_file: .env
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,70 @@
|
||||
# 0002 — Alignment Constitution
|
||||
|
||||
- **Date**: 2026-04-20
|
||||
- **Status**: Accepted
|
||||
- **Authors**: project maintainer (with AI drafting assistance)
|
||||
- **Related**: PR #20, commit 2853088; supersedes implicit "keep the proxy honest" discipline
|
||||
|
||||
## Context
|
||||
|
||||
On 2026-04-11 an OCP commit (`b87992f`, "fix: use dedicated `/api/oauth/usage` endpoint for reliable plan data") was merged. The commit asserted that `/api/oauth/usage` was "the dedicated usage endpoint that Claude Code CLI uses." The assertion was false: the string `/api/oauth/usage` does not appear anywhere in `cli.js`. The endpoint was fabricated by an LLM-assisted authoring pass generalizing from adjacent OAuth paths, without anyone running `grep` against `cli.js`.
|
||||
|
||||
The hallucination was not an isolated slip. It persisted across nine days and two additional commits of compensation:
|
||||
|
||||
- `cb6c2a8` extended the stale cache to 15 minutes and added a fallback path on HTTP 429 — a workaround that masked the fabricated endpoint's 4xx failures rather than investigating them.
|
||||
- The dashboard `/usage` progress bar was broken for the entire window (2026-04-11 through 2026-04-20).
|
||||
|
||||
Root cause analysis identified three structural gaps:
|
||||
|
||||
1. No binding rule that OCP must mirror `cli.js` behavior exactly. "Proxy-only" was aspirational, not enforced.
|
||||
2. No CI check that would fail builds containing known-hallucinated tokens.
|
||||
3. No reviewer gate that required the reviewer to verify the `cli.js` citation before approving.
|
||||
|
||||
Without all three, the same class of drift was re-occurrence-probable rather than preventable.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt `ALIGNMENT.md` as the project constitution. It encodes five binding Rules:
|
||||
|
||||
1. **Grep First** — before changing any endpoint/header/parameter/response shape, the author must `grep` `cli.js` and record the line numbers.
|
||||
2. **No Invention** — OCP must not introduce surface area not present in `cli.js`. Speculative "Claude Code probably uses X" statements are prohibited.
|
||||
3. **Match the Implementation** — where `cli.js` does perform the operation, OCP matches it byte-for-byte on the wire.
|
||||
4. **Unalignable Features Are Deleted** — features that cannot be traced to a `cli.js` reference are removed, not deprecated, not feature-flagged.
|
||||
5. **Cite Line Numbers in Commits** — every `server.mjs`-touching commit references `cli.js:NNNN` or `cli.js vE4 <functionName>`.
|
||||
|
||||
Supporting mechanisms:
|
||||
|
||||
- `CLAUDE.md` enshrines hard requirements for `server.mjs` PRs: `cli.js` citation, CI blacklist pass, independent reviewer who opens `cli.js` at the cited lines.
|
||||
- `.github/workflows/alignment.yml` greps `server.mjs` on every PR for the known-hallucinated token set (`api/oauth/usage`, `api/usage`, et al.) and fails the build on any hit.
|
||||
- `.github/PULL_REQUEST_TEMPLATE.md` makes the `cli.js` citation and the reviewer's cli.js-opened confirmation mandatory fields.
|
||||
- A bootstrap audit pin: Claude Code `2.1.89`, `cli.js` SHA-256 `a9950ef6407fdc750bddb673852485500387e524a99d42385cb81e7d17128e01`, auditor: project maintainer, date 2026-04-20. The pin is refreshed annually on 11 April (the drift anniversary) or on any re-verification event.
|
||||
- A documented Historical Lesson section in `ALIGNMENT.md` that names the drift commits by SHA, so the incident cannot be rewritten or quietly forgotten.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Every `server.mjs` change is now provably aligned to a specific `cli.js` line range before merge.
|
||||
- CI hard-fails any reintroduction of the specific hallucinated tokens; the failure mode is loud and immediate, not silent-and-cached.
|
||||
- The reviewer gate makes self-approval a policy violation, which structurally prevents the "fix my own hallucination" cycle that produced `cb6c2a8`.
|
||||
- The constitution becomes the foundation that later governance (iron rule v1.4, per-project release kit, cross-device dev system) builds on.
|
||||
|
||||
**Negative**
|
||||
|
||||
- `server.mjs` changes are meaningfully slower: the `grep` step and the reviewer's cli.js verification are real costs on every PR.
|
||||
- New contributors face a steeper ramp — they must read `ALIGNMENT.md` fully before their first server-side PR will pass review.
|
||||
- The CI blacklist is a moving target; as future drift patterns are discovered, the list grows, and each addition is governance work.
|
||||
|
||||
**Follow-ons**
|
||||
|
||||
- ADR 0003 (models.json SPOT) and ADR 0004 (OpenClaw auto-sync) both lean on the constitution's "one reviewable layer" structure.
|
||||
- Annual audit on 11 April is a recurring calendar obligation; failure to perform it is itself an alignment violation.
|
||||
- The `cli.js` bundle became opaque at v2.1.90 (binary packaging). Future audits require a different verification strategy — see Alternatives (b) below.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**(a) Pure human discipline — no CI, no template, no ADR.** the maintainer would simply commit to grepping `cli.js` on every change, and reviewers would commit to verifying. Rejected: the 2026-04-11 drift already happened under exactly this regime. the maintainer is meticulous, and the drift still shipped. Social discipline alone cannot prevent LLM hallucination from slipping through, especially when the LLM's output is superficially plausible.
|
||||
|
||||
**(b) Automatic `cli.js` diff on every PR.** A CI step that diffs `server.mjs`'s network surface against a parsed `cli.js` AST, blocking on any mismatch. Rejected as too fragile: `cli.js` v2.1.90+ ships as a minified/obfuscated binary, making AST-level grep invalid without an unofficial unbundling step. Any such pipeline becomes a maintenance burden on Anthropic's release cadence, and would routinely false-positive. The blacklist approach is lower-precision but dramatically more robust.
|
||||
|
||||
**(c) Freeze OCP and fork a new `ocp-v2` from scratch.** Start over with alignment baked in from day one. Rejected: the existing user base depends on OCP, and the drift affected one endpoint, not the architecture. Retrofitting a constitution onto the existing repo is cheaper and preserves user trust.
|
||||
@@ -0,0 +1,65 @@
|
||||
# 0003 — `models.json` as Single Source of Truth
|
||||
|
||||
- **Date**: 2026-04-20
|
||||
- **Status**: Accepted
|
||||
- **Authors**: project maintainer (with AI drafting assistance)
|
||||
- **Related**: PR #30, commit c6f7850; precursor to ADR 0004
|
||||
|
||||
## Context
|
||||
|
||||
OCP's model catalog (the mapping from short aliases like `sonnet` and `opus` to full model IDs with context-window metadata) had organically drifted into three independent locations:
|
||||
|
||||
1. `server.mjs` — `MODEL_MAP` and `MODELS` arrays, hardcoded at the top of the file. This was the runtime authority for `/v1/models` responses and alias resolution.
|
||||
2. `setup.mjs` — a separate `MODELS` constant, unchanged since the v3.0 era. Used only at first-install time to seed user config; by v3.10 it was stale and listed no Claude 4.x models at all.
|
||||
3. `~/.openclaw/openclaw.json` (on user machines) — written exactly once by `setup.mjs` during initial OCP install and never refreshed. A user who installed OCP in v3.0 and ran `ocp update` faithfully through v3.10 still had their OpenClaw config listing only three pre-Claude-4 models.
|
||||
|
||||
By the v3.10.0 release, Opus 4.7 was correctly present in location (1) and absent from (2) and (3). The symptom reaching users: native Claude Code saw the new model immediately (because it queries `/v1/models` live from server.mjs), but OpenClaw users saw nothing new, and new-installers via `setup.mjs` got an incomplete initial config. Three distinct bug reports in the two weeks following v3.10.0.
|
||||
|
||||
The drift was structural, not a bug in any one file. The files disagreed because there was no mechanism requiring them to agree.
|
||||
|
||||
## Decision
|
||||
|
||||
Extract all model metadata into `models.json` at the repo root. `server.mjs` and `setup.mjs` both read this file and derive their in-memory `MODEL_MAP`/`MODELS` structures from it. The file is committed to the repo; it is neither generated nor cached.
|
||||
|
||||
Shape (summarized):
|
||||
|
||||
- `models` — array of entries, each with `id` (full model ID), `alias` (short name), `context_window`, and flags where relevant.
|
||||
- `default_alias` — which alias resolves when the client sends an unknown or empty model.
|
||||
|
||||
Migration approach:
|
||||
|
||||
1. Hand-populate `models.json` from the v3.10.0 `server.mjs` `MODEL_MAP` values.
|
||||
2. Rewrite `server.mjs` to load and index `models.json` at startup.
|
||||
3. Rewrite `setup.mjs` to derive its `MODELS` constant from the same file.
|
||||
4. Verify byte-equivalence: the derived `MODEL_MAP` in v3.11.0 must be a byte-identical superset of the v3.10.0 hardcoded `MODEL_MAP`. This is checked by a one-shot comparison script during the refactor PR; no regression is permitted.
|
||||
|
||||
Post-refactor, the contract for adding a model is: edit `models.json`, open a PR, reviewer sanity-checks the `id` string against Anthropic's model announcement, merge. No other file changes.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Single edit point eliminates the "updated one place, forgot the other" failure mode structurally.
|
||||
- `setup.mjs`'s latent staleness is repaired as a side effect — new-installers now get a fresh model list.
|
||||
- Opens the door to ADR 0004 (OpenClaw auto-sync), which requires a file-based SPOT to sync from.
|
||||
- The `models.json` format is stable, Markdown-friendly JSON, easy to diff in code review.
|
||||
|
||||
**Negative**
|
||||
|
||||
- One additional file to load at server startup (negligible cost, but now a startup dependency).
|
||||
- Schema drift risk: if anyone adds a new field to `models.json` that `server.mjs` or `setup.mjs` doesn't know about, the field is silently ignored. A future schema version tag may be warranted if the format grows.
|
||||
- `models.json` parse failure is now a fatal startup error; previously, bad model config required editing source. Consider this a feature, not a regression.
|
||||
|
||||
**Follow-ons**
|
||||
|
||||
- ADR 0004 (OpenClaw auto-sync) consumes `models.json` directly in `scripts/sync-openclaw.mjs`.
|
||||
- Future additions (per-model pricing, per-model capability flags, etc.) belong in `models.json`, not scattered back across `server.mjs`.
|
||||
- The README "Available Models" table is now derived documentation and its source of truth should be pinned to `models.json` in the release_kit overlay.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**(a) Keep the three locations, enforce sync by manual review discipline.** A reviewer checklist item: "did you update all three places?" Rejected: the drift had already demonstrated that manual discipline is insufficient when the three files are in unrelated sections of the diff. Human reviewers routinely miss the third file. The 2026-04-11 alignment drift had already taught the project that discipline-only approaches fail.
|
||||
|
||||
**(b) YAML with SOPS field-level encryption.** Some projects prefer YAML for multi-line string readability and use SOPS to encrypt sensitive fields. Rejected: OCP's model catalog contains no secrets — model IDs, aliases, and context windows are all public information published by Anthropic. YAML adds a parser dependency and SOPS adds a decryption step at startup, both for zero benefit. JSON is already native to Node, and `models.json` is easy to diff line-by-line in GitHub review UI.
|
||||
|
||||
**(c) Fetch the model list live from Anthropic at server start.** Rejected: `cli.js` does not perform this operation, so per `ALIGNMENT.md` Rule 2 it is out of scope for OCP. Additionally, a live fetch introduces a startup-time network dependency and an availability coupling to Anthropic that OCP is explicitly designed to avoid (OCP is the gateway, not another consumer).
|
||||
@@ -0,0 +1,67 @@
|
||||
# 0004 — OpenClaw Auto-Sync on `ocp update`
|
||||
|
||||
- **Date**: 2026-04-20
|
||||
- **Status**: Accepted
|
||||
- **Authors**: project maintainer (with AI drafting assistance)
|
||||
- **Related**: PR #31, commit 5ef163a; builds on ADR 0003
|
||||
|
||||
## Context
|
||||
|
||||
v3.10.0 added Claude Opus 4.7 to OCP's `server.mjs` `MODEL_MAP`. Native Claude Code users and other IDE consumers (Cline, Aider, Cursor) saw the new model immediately, because every one of those clients queries `/v1/models` live at session start.
|
||||
|
||||
OpenClaw is different. OpenClaw caches its provider/model list in `~/.openclaw/openclaw.json`, written exactly once during OCP's `setup.mjs` run, then treated as immutable until the user manually edits it. An OpenClaw user who installed OCP in, say, v3.7 and diligently ran `ocp update` through v3.10 still saw only the pre-Claude-4 model list. From their perspective, `ocp update` "did not do what it said."
|
||||
|
||||
Within two weeks of v3.10.0, three separate bug reports surfaced, all with the same root cause: OpenClaw's cache was stale. Users tried the obvious workarounds (reinstall OpenClaw, edit the JSON by hand) and reported those as additional bugs when they misformatted the file.
|
||||
|
||||
The underlying asymmetry: every other IDE integration is pull-based (asks OCP for models on demand); OpenClaw is push-based (was told once, caches forever). OCP had no mechanism for a subsequent push.
|
||||
|
||||
Additionally, ADR 0003 had just landed `models.json` as the single source of truth — meaning the data a sync mechanism would need was now available in a machine-readable file rather than scattered across `server.mjs`.
|
||||
|
||||
## Decision
|
||||
|
||||
Add `scripts/sync-openclaw.mjs`, invoked automatically at the end of `ocp update`, plus a passive drift self-check in `server.mjs` startup. Design constraints:
|
||||
|
||||
1. **Strictly scoped.** The script only touches two sub-trees of `~/.openclaw/openclaw.json`:
|
||||
- `models.providers["claude-local"].models` — the provider's model list.
|
||||
- `agents.defaults.models["claude-local/*"]` — per-agent defaults that reference claude-local models.
|
||||
All other OpenClaw config (user-defined agents, non-claude-local providers, UI preferences) is left untouched.
|
||||
|
||||
2. **Idempotent.** Running the script twice with the same `models.json` produces the same file both times — byte-identical. The script diffs before writing and no-ops if there is nothing to change.
|
||||
|
||||
3. **Safe.** Before any write, the script creates a timestamped backup at `~/.openclaw/openclaw.json.bak.<ISO8601>`. The user can always roll back.
|
||||
|
||||
4. **Non-fatal.** If `~/.openclaw/openclaw.json` is missing (OpenClaw not installed), malformed, or otherwise unwriteable, the script logs a single-line warning and exits 0. `ocp update` never fails because of sync.
|
||||
|
||||
5. **Manually invocable.** `node scripts/sync-openclaw.mjs` runs the sync as a standalone operation, for users who want to trigger it without a full `ocp update`.
|
||||
|
||||
6. **Passive drift self-check.** On server startup, `server.mjs` reads the `claude-local` model list from `openclaw.json` (if present) and compares against the models derived from `models.json`. Mismatches produce a single WARN log line — enough to alert the user without taking action. This is the "we noticed" signal; the fix is to run `ocp update`.
|
||||
|
||||
Implementation source: the sync script reads the SPOT (`models.json`), produces the canonical claude-local model list, merges it into the OpenClaw config in the two scoped locations, writes atomically (write-to-temp then rename), and logs the diff.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Users get new models on the next `ocp update` with no manual action. The invariant OCP's update flow was advertising is now actually true.
|
||||
- Manual invocation remains available for users who want to sync without updating OCP itself (edge case, but cheap to support).
|
||||
- Passive self-check means even users who somehow skip `ocp update` receive a runtime heads-up instead of silent drift.
|
||||
- The script is short (under 150 lines) and testable in isolation.
|
||||
|
||||
**Negative**
|
||||
|
||||
- One-time bootstrap quirk: users upgrading from v3.10 → v3.11 have a cached `cmd_update` in their existing installation that does not yet invoke the new script. The first `ocp update` to v3.11 still misses the sync; the second `ocp update` (now running v3.11's code) performs it. This is documented in README § "Troubleshooting" per the release_kit `bootstrap_quirk_policy`.
|
||||
- A new script to maintain. If OpenClaw's config schema changes, this script needs updating. The strict-scope constraint bounds the maintenance surface.
|
||||
- Non-fatal-on-error means a broken `openclaw.json` silently stays broken from OCP's perspective. Accepted trade-off: `ocp update` failing because of a sibling tool's config would be worse.
|
||||
|
||||
**Follow-ons**
|
||||
|
||||
- If OpenClaw ever adopts live `/v1/models` polling upstream, this script becomes redundant and can be deleted per ADR 0002's Rule 4 (unalignable-to-upstream features are deleted).
|
||||
- Similar sync needs for future sibling tools would follow this pattern: separate script, strictly scoped, idempotent, non-fatal, invoked by `ocp update`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**(a) Modify OpenClaw itself to poll `/v1/models` live.** The "correct" fix at the architecture level. Rejected: OCP is a tenant in OpenClaw's plugin model, not its owner. Opening an upstream PR creates a cross-repo coordination dependency (review timeline, release timeline, version matrix) that leaves current OCP users broken for weeks or months. The sync script is something OCP can ship unilaterally and remove later if the upstream change lands.
|
||||
|
||||
**(b) Re-run `setup.mjs` in full.** `setup.mjs` already knows how to write `openclaw.json` from scratch. Rejected: `setup.mjs` has many side effects beyond OpenClaw registration — it rewrites user shell rc files, regenerates systemd units, touches credential storage. It is explicitly not idempotent, and running it a second time on an already-configured system produces duplicate entries or regressions. The sync script's strict scope is the whole point; re-running `setup.mjs` would blow past it.
|
||||
|
||||
**(c) Do nothing — tell users to manually edit `~/.openclaw/openclaw.json`.** Rejected for two reasons. First, UX: OCP's value proposition includes "`ocp update` keeps your toolchain current," and asking users to hand-edit a third party's JSON breaks that promise. Second, error rate: the three bug reports that motivated this ADR included two malformed-JSON follow-ups from users who tried the manual approach. A machine-written file is strictly safer than a hand-edited one.
|
||||
@@ -0,0 +1,79 @@
|
||||
# 0005 — OCP Stays Single-Provider; No Multi-Provider Refactor
|
||||
|
||||
- **Date**: 2026-05-06
|
||||
- **Status**: Accepted
|
||||
- **Authors**: project maintainer (with AI advisory drafting)
|
||||
- **Related**: ADR 0002 (Alignment Constitution), ADR 0003 (`models.json` SPOT)
|
||||
|
||||
## Context
|
||||
|
||||
OCP's `server.mjs` reached 1667 lines and now provides response cache, per-key quota, session tracking, model-level stats, and SSE heartbeat — all targeting a single backend path: `spawn` the locally installed `cli.js` and let it transact with `api.anthropic.com`. This architecture is the source of OCP's only real differentiator: **`cli.js` behavior-level alignment** (session create-vs-resume semantics, tool_use id reuse, SSE quirks, etc.) — none of which a generic LLM gateway has, because none of them speak this protocol.
|
||||
|
||||
The maintainer evaluated extending OCP to support OpenAI / Gemini / OpenRouter / Together / Groq / Ollama — i.e., turning OCP into a multi-provider gateway resembling Helicone, LiteLLM, OpenRouter, or Portkey. The motivation for that extension: reduce dependency on Anthropic, broaden OCP's commercial surface, and stop being grayscale-positioned (the `cli.js` spawn pattern depends on the local Pro/Max subscription, which Anthropic could fingerprint and disable).
|
||||
|
||||
The honest engineering estimate for that extension:
|
||||
|
||||
| Phase | Net New LOC | Calendar Time (part-time) |
|
||||
|---|---|---|
|
||||
| Provider abstraction + OpenAI | ~1230 + schema migration | 2 weeks |
|
||||
| Add Gemini | ~550 | +1.5 weeks |
|
||||
| OpenAI-compatible family (OpenRouter / Together / Groq) | ~300 | +1 week |
|
||||
| Tests, docs, hardening | — | +1.5 weeks |
|
||||
| **Multi-provider v1** | ~2080 | **~7 weeks focused** |
|
||||
|
||||
That number is not the real cost. The real cost is **strategic**:
|
||||
|
||||
1. **Loss of unique value.** `cli.js` behavior alignment is meaningless for OpenAI / Gemini / Ollama traffic. Going multi-provider means OCP's only moat applies to ~30% of its surface; the other 70% is generic gateway code already done better by Helicone / LiteLLM.
|
||||
|
||||
2. **Hybrid architecture awkwardness.** A multi-provider OCP would have two paths: `spawn(cli.js)` for Claude (still grayscale, depends on Pro subscription), and direct API call for everyone else (clean, BYOK). Customers asking "what is OCP?" would hear two different answers depending on which model they pick. This is worse than either pure path.
|
||||
|
||||
3. **Direct competition with funded incumbents.** Helicone (~$5M raised, YC W23), OpenRouter (~$1B valuation), LiteLLM (significant enterprise revenue), Portkey, Langfuse, Cloudflare AI Gateway — all already do multi-provider gateway with mature dashboards, audit logs, SOC2, and team features. OCP would enter that market 2+ years late with one engineer.
|
||||
|
||||
4. **The grayscale problem isn't solved by adding providers.** As long as OCP keeps the `cli.js` spawn path for Anthropic, it remains grayscale for that path; adding OpenAI alongside doesn't make the Anthropic path any less dependent on a Pro/Max subscription that wasn't licensed for proxying.
|
||||
|
||||
The maintainer's separate decision (recorded in personal notes, not this repo) is that **OCP itself will not be commercialized**; it will remain a personal power tool plus open-source contribution. Any commercial gateway work, if pursued, will start from a clean codebase with BYOK from day one — not from OCP.
|
||||
|
||||
Given that, the multi-provider extension would buy OCP nothing: not a moat, not commercial readiness, not even meaningfully better personal utility (the maintainer overwhelmingly uses Claude).
|
||||
|
||||
## Decision
|
||||
|
||||
OCP stays single-provider. Specifically:
|
||||
|
||||
1. **No new providers added to `server.mjs`.** The dispatch path remains `spawn(cli.js) → api.anthropic.com`. Pull requests that introduce a `providers/` directory or a model-to-provider router are declined on the basis of this ADR.
|
||||
|
||||
2. **`models.json` schema stays Anthropic-only.** No `provider` field, no per-model cost/capability metadata that anticipates other providers. If non-Anthropic models ever need to be referenced (e.g., for OpenClaw provider list completeness), they live in a separate file or in OpenClaw's own config — not in OCP's SPOT.
|
||||
|
||||
3. **Cache improvements are in scope.** The existing response cache (in `keys.mjs`: `cacheHash` / `getCachedResponse` / `setCachedResponse` / `clearCache`) is acceptable to upgrade with stream replay, stampede protection (singleflight), per-key isolation, and Anthropic `cache_control` awareness. These reinforce the single-provider position; they do not create provider-extension surface area.
|
||||
|
||||
4. **Anthropic alignment work continues to be encouraged.** Anything that deepens `cli.js` behavior alignment — session lifetime, tool_use id semantics, SSE behavior, multi-account routing, model-tier observability — is the project's actual value and should be prioritized over generic-gateway features.
|
||||
|
||||
5. **Commercial work, if pursued, starts elsewhere.** A separate repository, separate name, BYOK from day one, no `cli.js` spawn. That repo is out of scope for OCP and is not bound by this ADR.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Project scope stays bounded. The maintainer can keep evolving OCP at part-time pace without the multi-provider maintenance burden (every provider's API breaks at some point and demands attention).
|
||||
- The unique value (`cli.js` alignment) is preserved and continues to compound — every new alignment fix increases OCP's distance from generic gateways.
|
||||
- Future contributors reading the code see one architecture, not a hybrid; debugging stays tractable.
|
||||
- Decisions about commercialization are decoupled from OCP's technical evolution. OCP can stay grayscale-personal-tool indefinitely without that being a blocker for any future commercial product.
|
||||
|
||||
**Negative**
|
||||
|
||||
- OCP cannot serve any user who needs OpenAI / Gemini / local LLM access. Those users must route through a different gateway (Helicone, LiteLLM, OpenRouter) or call providers directly.
|
||||
- If Anthropic substantially changes `cli.js` (e.g., adds client attestation, removes the spawn-and-forward pattern, or migrates `claude` to a non-CLI form factor), OCP's core architecture breaks and there is no second backend to fall back to.
|
||||
- The maintainer must resist a recurring temptation: "while I'm in here, let me just add OpenAI." The whole point of this ADR is to make that temptation cost a documented amendment, not a quiet PR.
|
||||
|
||||
**Neutral**
|
||||
|
||||
- This ADR records a non-decision in code: nothing in `server.mjs` changes today. Its purpose is to make future contributors (including the maintainer) explain themselves before going against it. Per the project's PR template and Iron Rule 11, an amendment to this ADR is the gating step before any provider-extension PR.
|
||||
|
||||
## Trigger conditions for revisiting this ADR
|
||||
|
||||
This ADR should be revisited (and possibly amended or superseded) if any of the following occur:
|
||||
|
||||
1. Anthropic ships a feature that breaks the `cli.js` spawn pattern OCP depends on, and the maintainer wants to keep OCP useful.
|
||||
2. The maintainer makes a deliberate decision to commercialize OCP (rather than start a separate codebase). This requires explicit re-scoping; "let me try" is not enough.
|
||||
3. A genuine user need emerges — e.g., the maintainer themselves starts using OpenAI / Gemini frequently from Claude Code workflows — that single-provider OCP cannot serve.
|
||||
|
||||
In all three cases, the response is **first amend this ADR**, then write code. Order is not optional.
|
||||
@@ -0,0 +1,132 @@
|
||||
# 0006 — OpenAI Shim Scope: Class A vs Class B Endpoints
|
||||
|
||||
- **Date**: 2026-05-20
|
||||
- **Status**: Proposed — owner reviewing
|
||||
- **Authors**: project maintainer (with AI drafting assistance)
|
||||
- **Related**: `ALIGNMENT.md` (the constitution); ADR 0002 (Alignment Constitution provenance, PR #20, commit 2853088); PR #99 by external contributor (triggering incident — OpenAI `response_format` honoring on `/v1/chat/completions`)
|
||||
|
||||
## Context
|
||||
|
||||
`ALIGNMENT.md` was drafted in the aftermath of the 2026-04-11 drift (commit `b87992f` — fabricated `/api/oauth/usage` endpoint) and ratified in PR #20 / commit 2853088. Its five Rules are written in the language of a one-to-one proxy: Rule 1 (Grep First), Rule 2 (No Invention), Rule 3 (Match the Implementation), Rule 4 (Unalignable Features Are Deleted), Rule 5 (Cite Line Numbers in Commits). All five anchor explicitly on `cli.js` as the golden reference. This is correct and binding for the endpoints OCP was originally designed to forward — `/v1/messages`, `/api/oauth/*`, and the rate-limit-header extraction path that backs `/usage` — because for those, `cli.js` is the literal wire authority and any deviation is a drift risk.
|
||||
|
||||
OCP also exposes a second class of endpoint that the constitution does not currently distinguish: **OpenAI-compatible surface** that exists so non-Claude-Code clients (Honcho, OpenWebUI, OpenAI SDK consumers, BYO scripts) can talk to claude via OCP. The flagship is `/v1/chat/completions`, which translates between OpenAI's request/response schema and `cli.js`'s native protocol. `cli.js` never speaks OpenAI's wire format — by construction it cannot, because OpenAI and Anthropic are different vendors with different protocols. There is no `cli.js:NNNN` to cite for OpenAI's `messages[].role` field handling, OpenAI's streaming `delta` shape, OpenAI's `stop` event names, or OpenAI's `response_format` parameter. The protocol authority for these is OpenAI's published specification, not `cli.js`.
|
||||
|
||||
The structural gap surfaced when PR #99 (external contributor `jaekwon-park`) added support for the OpenAI `response_format` request field on `/v1/chat/completions`. A strict reading of Rule 2 ("OCP must not introduce request fields that are not present in `cli.js`") blocks the PR. But the same strict reading also blocks the existence of `/v1/chat/completions` itself — every OpenAI-shaped field on that endpoint is, by definition, not in `cli.js`. The endpoint has been in OCP since before the constitution was written and is used by real downstream consumers. The constitution and the endpoint cannot both be correct under the current reading.
|
||||
|
||||
The 2026-04-11 drift remains the cautionary tale that drove the constitution and remains binding. The drift was not "OCP exposed an endpoint that wasn't in `cli.js`" — it was specifically "OCP claimed to forward `cli.js`'s `/api/oauth/usage` call when no such call exists in `cli.js`." That is a Class A failure mode: a forwarding endpoint that lied about what it was forwarding. The fix to that failure mode (Rules 1, 2, 3, 5; CI blacklist; reviewer gate) was correct then and is correct now. This ADR does not relitigate that decision and does not soften Rules 1–5 for the class of endpoint they were designed to discipline.
|
||||
|
||||
What this ADR does is acknowledge that OCP has two classes of endpoint, and that the discipline that fits Class A does not fit Class B without distortion. Class B needs its own anchor (OpenAI's specification) and its own authorization gate (an ADR per endpoint), so contributors know exactly which rule set applies to their PR and so Class B never becomes a backdoor for "OCP can do anything OpenAI-shaped."
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce an explicit two-class taxonomy of OCP endpoints:
|
||||
|
||||
- **Class A — `cli.js`-mirror endpoints.** Endpoints that exist because `cli.js` performs the equivalent operation and OCP forwards, observes, or multiplexes that operation. Rules 1–5 of `ALIGNMENT.md` apply verbatim. The citation requirement is `cli.js:NNNN` (or `cli.js vE4 <functionName>`).
|
||||
|
||||
- **Class B — OCP-owned compatibility endpoints.** Endpoints that exist because OCP itself surfaces them, with no `cli.js` analogue. They fall into two sub-buckets:
|
||||
- **B.1 — OpenAI-compatibility surface.** Endpoints implementing OpenAI's published API contract so non-Anthropic clients can use OCP. The protocol authority is OpenAI's specification.
|
||||
- **B.2 — OCP-administrative surface.** Endpoints that exist purely to operate the proxy itself (health, dashboard, key management, cache control). The authority for these is the ADR that authorized the endpoint's existence.
|
||||
|
||||
For Class B endpoints, the citation requirement shifts from `cli.js:NNNN` to **(a)** the relevant specification section (OpenAI spec section for B.1, or the authorizing ADR for B.2) **and (b)** the ADR that authorized the endpoint's existence in the first place.
|
||||
|
||||
### Grandfather provision for existing B.2 inventory
|
||||
|
||||
ADR 0006 retroactively authorizes the existing B.2 endpoints listed in the inventory table below, **frozen at their current behaviour as of v3.16.4**. This is a one-time grandfather provision intended to avoid a 12-ADR back-fill burden for endpoints that have existed in OCP since before any constitutional governance was written.
|
||||
|
||||
The grandfather provision is narrowly scoped:
|
||||
|
||||
- It covers only the B.2 endpoints enumerated in the inventory table as of this ADR's merge date.
|
||||
- It freezes those endpoints at their **current behaviour**. Any change to the request shape, response shape, or semantics of a grandfathered B.2 endpoint is treated as a new authorization request and requires either (a) a behaviour-preserving refactor PR with no contract change, or (b) its own ADR.
|
||||
- It does **not** authorize new B.2 endpoints. Any new B.2 endpoint, or any new method on a grandfathered B.2 endpoint, requires its own ADR before merge.
|
||||
- It does **not** extend to B.1 (OpenAI-compat) endpoints. B.1 endpoints are bounded by OpenAI's published specification, not by a behaviour snapshot — there is no grandfather equivalent for them.
|
||||
|
||||
The structural intent is: take the one-time hit of declaring "current B.2 surface is authorized" cleanly, then make every future addition pay the ADR-per-endpoint cost. This prevents Class B from becoming a backdoor for general OCP-owned-surface invention while not blocking the present ADR on twelve back-fill PRs.
|
||||
|
||||
### Current Class B inventory (enumerated from `server.mjs`)
|
||||
|
||||
The following endpoints exist today in `server.mjs` and are Class B (no `cli.js` analogue):
|
||||
|
||||
| Endpoint | Method | Sub-bucket | Authorizing ADR |
|
||||
|---|---|---|---|
|
||||
| `/v1/chat/completions` | POST | B.1 (OpenAI-compat) | ADR 0006 |
|
||||
| `/v1/models` | GET | B.1 (OpenAI-compat) | ADR 0006; content sourced from `models.json` per ADR 0003 |
|
||||
| `/health` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/dashboard` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/sessions` | GET, DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/logs` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/status` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/settings` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/api/keys` | GET, POST | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/api/keys/:id` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/api/keys/:id/quota` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/api/usage` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/cache/stats` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
| `/cache` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||
|
||||
For Class A reference, the current Class A inventory is `/v1/messages` (forwarded directly to `api.anthropic.com/v1/messages`) and the OAuth bearer / rate-limit-header machinery used by `handleUsage()` (which calls `https://api.anthropic.com/v1/messages` to extract `anthropic-ratelimit-unified-*` headers, per the in-file comment at line 845–849). The `GET /usage` endpoint surface itself is Class B (administrative augmentation: it adds `proxy:` and `models:` blocks not present in any upstream API), but the data fetch underlying it is Class A — see "Hybrid endpoints" below.
|
||||
|
||||
### Hybrid endpoints
|
||||
|
||||
`/usage` is a hybrid: the wire call out to `api.anthropic.com/v1/messages` is Class A and must continue to cite `cli.js`; the local synthesis on top (`proxy:` stats block, `models:` snapshot, response shape) is Class B and is authorized by this ADR. Any future change strictly to the wire-call layer is Class A; any change strictly to the synthesis layer is Class B. A PR touching both must satisfy both citation requirements.
|
||||
|
||||
## What does NOT change
|
||||
|
||||
The following continue to apply verbatim and are not weakened by this ADR:
|
||||
|
||||
- **Rules 1, 2, 3, 4, 5 of `ALIGNMENT.md`** for all Class A endpoints. The 2026-04-11 drift discipline is unchanged. Class A PRs still require `cli.js:NNNN` citations, still must match `cli.js`'s wire format byte-for-byte, and still face the Unalignable Policy if the citation cannot be produced.
|
||||
- **CI blacklist** (`.github/workflows/alignment.yml`). The known-hallucinated token list (currently `api.anthropic.com/api/oauth/usage`) continues to be greppable-and-failable on every PR.
|
||||
- **Reviewer gate** (CLAUDE.md hard requirements + Iron Rule 10). Implementation author may not self-approve; a fresh-context reviewer opens `cli.js` at the cited lines for Class A PRs.
|
||||
- **Annual Alignment Audit** on 11 April. The Class A audit (re-verify each `server.mjs` Class A reference against the pinned `cli.js` SHA-256) continues unchanged.
|
||||
- **Unalignable Policy.** A Class A endpoint that cannot be traced to a `cli.js` reference is still deleted, not deprecated.
|
||||
- **Historical Lesson section in `ALIGNMENT.md`.** The 2026-04-11 drift remains the named cautionary incident, with commit SHAs intact.
|
||||
|
||||
## What additionally applies to Class B
|
||||
|
||||
The following are new and apply only to Class B endpoints:
|
||||
|
||||
1. **OpenAI specification as protocol authority (B.1).** The OpenAI compatibility surface follows OpenAI's published `/v1/chat/completions` specification (https://platform.openai.com/docs/api-reference/chat/create) — not OCP imagination, not "OpenAI probably does X," not generalization from adjacent OpenAI endpoints. The same anti-invention discipline that Rule 2 imposes for `cli.js` applies, with OpenAI's spec substituted as the reference.
|
||||
|
||||
2. **ADR-authorized endpoint existence.** Any new Class B endpoint, or any new Class B endpoint method, requires its own ADR before merge. The grandfather provision above covers existing B.2 inventory only. An "ADR-less" Class B endpoint added after this ADR merges is itself an alignment finding and is subject to deletion under a Class B equivalent of the Unalignable Policy (see Rule 4 mapping in `ALIGNMENT.md`'s new section).
|
||||
|
||||
3. **Class B citation format.** Class B PRs cite (a) the relevant specification section and (b) the authorizing ADR. Example for B.1: "OpenAI `chat/completions` API, `response_format` parameter (https://platform.openai.com/docs/api-reference/chat/create), authorized by ADR 0006." Example for B.2: "Authorized by ADR 0006 (grandfathered)" for grandfathered endpoints, or "Authorized by ADR 00NN" for endpoints with their own ADR.
|
||||
|
||||
4. **Class B audit cadence.** Class B endpoints are audited annually alongside the Class A audit. B.1 endpoints are audited against OpenAI's current `/v1/chat/completions` specification snapshot. B.2 endpoints (grandfathered or ADR-specific) are audited against their authorizing ADR — for grandfathered endpoints, the audit verifies the endpoint behaviour still matches its v3.16.4 snapshot; for ADR-specific endpoints, the audit verifies behaviour still matches the ADR. The B.1 specification pin lives in `docs/openai-compat-pin.md` (to be created alongside the first B.1 audit; not a prerequisite for this ADR to land).
|
||||
|
||||
5. **Reviewer expectation.** The fresh-context reviewer for a Class B PR opens the cited OpenAI spec section (B.1) or the authorizing ADR (B.2) instead of opening `cli.js`. The "I am not the commit author" rule and the "explicit approval comment naming the verified reference" rule continue.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- PR #99 becomes mergeable with a one-line scope declaration ("Class B — extends `/v1/chat/completions` per ADR 0006") plus the existing alignment-evidence section adapted to Class B citation format. The structural ambiguity that blocked it is removed.
|
||||
- Future Class B contributors have a clear template and a defensible scope: "extend `/v1/chat/completions` for an OpenAI-spec field that's already in OpenAI's spec" is a well-formed PR; "add a new OCP-invented field that looks OpenAI-shaped" is not, and the same anti-invention discipline that protects Class A protects Class B.
|
||||
- The Class A surface is structurally unchanged. Reviewers reading the new `ALIGNMENT.md` see a clean Class A regime with all five Rules intact, plus an enumerated and explicitly scoped Class B carve-out.
|
||||
- The administrative endpoint surface (B.2) is no longer in a "is this even allowed under the constitution?" limbo. The grandfather provision cleanly authorizes the current inventory; new B.2 endpoints must earn their ADR.
|
||||
|
||||
**Negative**
|
||||
|
||||
- OCP now maintains a second alignment surface. OpenAI's `/v1/chat/completions` specification is also a moving target (OpenAI ships changes more than once per year, including breaking ones), so the B.1 audit has real work attached.
|
||||
- The grandfather provision freezes the current B.2 behaviour. If a grandfathered B.2 endpoint has a latent bug or undesirable behaviour, "fixing" it is a contract change and now requires an ADR (or a behaviour-preserving refactor). This is intentional friction to prevent silent contract drift.
|
||||
- Contributors must now choose Class A or Class B on every PR. Some will misclassify. The PR template's required Class A/B radio (see PR template update) and the reviewer's spec-or-cli verification step are the structural counter-measures.
|
||||
|
||||
**Mitigations**
|
||||
|
||||
- The Class B inventory is small (currently 14 endpoints) and is enumerated explicitly in `ALIGNMENT.md`. New entries require an ADR per item 2 above, so the inventory cannot grow silently.
|
||||
- Anthropic-side change frequency (which drives Class A audit cost) is structurally higher than OpenAI's `chat/completions` shape, which has been stable across multiple OpenAI API versions. The marginal B.1 audit cost is low. The grandfathered B.2 audit cost is also low — most of those endpoints have not changed in months.
|
||||
- The B.1 specification pin in `docs/openai-compat-pin.md` lets the audit anchor on a specific OpenAI spec snapshot, the same way the Class A pin anchors on a specific `cli.js` SHA-256. Drift detection then works the same way for both classes.
|
||||
|
||||
## Historical Lesson — explicit non-relitigation
|
||||
|
||||
This ADR does not relitigate the 2026-04-11 drift. The drift commit `b87992f` was Class A — it claimed `cli.js` forwarded a call that `cli.js` did not in fact make. The fix (constitution + CI blacklist + reviewer gate) was correct and remains binding for Class A. This ADR carves out Class B because the discipline that fits Class A does not fit a class of endpoint where `cli.js` is not the wire authority — not because the discipline was wrong, and not because the drift lesson is any less load-bearing.
|
||||
|
||||
A reviewer or future maintainer reading this ADR should not infer: "OCP relaxed its alignment rules." The Class A regime is structurally identical to the version that shipped in PR #20. What changed is that the constitution now names the scope of that regime precisely (the class of endpoint for which `cli.js` is the wire authority) instead of implicitly applying it to every endpoint, including ones the regime was never designed for.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**(a) Refuse Class B as a category; close PR #99 and delete `/v1/chat/completions`.** This would resolve the structural ambiguity by enforcing Rule 2 maximally — if `cli.js` doesn't speak OpenAI's protocol, neither does OCP. Rejected: there is an existing user base on the OpenAI-compat surface, the surface is genuinely useful (it is OCP's bridge to non-Claude-Code agents), and deletion would be a load-bearing user-facing breakage in service of a doctrinal point that the constitution was never designed to make. The constitution was a response to the 2026-04-11 forwarding drift, not a charter against any OCP-owned surface.
|
||||
|
||||
**(b) Soften Rule 2 to "OCP must not introduce surface area not present in `cli.js` OR not authorized by an ADR."** This is the obvious diff and would unblock PR #99 with the smallest possible textual change. Rejected because it loses the precision that Class A needs. A combined Rule 2 means a Class A reviewer has to read the PR description twice to figure out which authority applies. The Class A/B split makes the question explicit at the PR-template level (the author picks the class) and at the reviewer level (the reviewer opens the appropriate reference). The cost of the split is one new section in `ALIGNMENT.md`; the benefit is no ambiguity in either class.
|
||||
|
||||
**(c) Move `/v1/chat/completions` and all OpenAI-compat surface out of OCP into a separate "ocp-openai-shim" repository.** This would cleanly resolve the scope question by moving Class B out of OCP entirely. Rejected as premature: the maintainer is one person, the OpenAI-compat surface today is a single endpoint plus its support, and the operational cost of two repositories (separate releases, separate CI, separate version coordination) exceeds the cost of one constitution with two named classes. If the OpenAI-compat surface ever grows to the size where a separate repo is justified, ADR 0006 is the natural pivot point — at that future date, the carve-out becomes a separation.
|
||||
|
||||
**(d) Twelve-ADR back-fill for the existing B.2 inventory before this ADR can merge.** Considered and rejected on cost grounds. Each back-fill ADR would be a short paragraph explaining what an existing endpoint does and why it's allowed; the educational value is low and the merge friction is high (12 PRs through the reviewer gate). The grandfather provision above achieves the same authorization outcome in one paragraph, while still requiring an ADR for any future B.2 endpoint. The trade-off: grandfathered endpoints are not individually documented to ADR-depth. Mitigation: the inventory table in `ALIGNMENT.md` lists every grandfathered endpoint by path and method, so the audit surface remains explicit.
|
||||
@@ -0,0 +1,338 @@
|
||||
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
|
||||
|
||||
**Date:** 2026-05-31
|
||||
**Status:** Accepted — amended by PR-4 (entrypoint hardening), PR-B (observability + concurrency), PR-C (env-token auth + defunct-reaping), PR-D (credential-isolated home — corrects PR-C)
|
||||
**Deciders:** project maintainer
|
||||
**Authority:** claude CLI v2.1.158 interactive mode — verified live on the test host that sessions launched without `-p` / `--output-format` carry `cc_entrypoint=cli` (subscription pool), not `cc_entrypoint=sdk-cli` (Agent SDK credit pool). Mechanism verified on cli.js v2.1.104; live-confirmed on v2.1.158.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
On 2026-05-14 Anthropic announced (effective 2026-06-15) a billing split that routes requests by `cc_entrypoint`:
|
||||
|
||||
| `cc_entrypoint` value | Billing pool |
|
||||
|-----------------------|-------------|
|
||||
| `cli` | Pro/Max subscription pool |
|
||||
| `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro = easily exhausted) |
|
||||
|
||||
OCP's existing path (`claude --output-format stream-json -p`) sets `cc_entrypoint=sdk-cli`. After 2026-06-15 every OCP request will draw from the Agent SDK pool rather than the subscription.
|
||||
|
||||
The structural response: add an opt-in mode that drives a real **interactive** `claude` session (no `-p`, no `--output-format`), which carries `cc_entrypoint=cli` and therefore bills against the subscription. The response text is read from claude's native JSONL transcript instead of from `stdout`.
|
||||
|
||||
This is a personal-use A-path feature (single-user, single-subscription host). It is **not** a multi-tenant isolation layer.
|
||||
|
||||
### Source-verified entrypoint mechanism (PR-4 amendment)
|
||||
|
||||
Claude CLI's `main()` calls a startup function (`t$A` in the compiled bundle) that sets
|
||||
`process.env.CLAUDE_CODE_ENTRYPOINT` **only if unset** to:
|
||||
|
||||
```
|
||||
(argv has -p/--print/--init-only/--sdk-url OR !process.stdout.isTTY) ? "sdk-cli" : "cli"
|
||||
```
|
||||
|
||||
The billing header reads `cc_entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "unknown"`.
|
||||
The `"unknown"` branch is dead code for any real `main()` spawn — the startup function always
|
||||
sets a value on unset env. The **real risk** is not `"unknown"`: it is a **lost TTY** (e.g. stdout
|
||||
redirected or a non-PTY spawn) silently flipping the self-classification to `"sdk-cli"` and
|
||||
drawing from the metered pool.
|
||||
|
||||
`cc_entrypoint` is one of ~6 upstream run-mode signals. The **dominant discriminator** is the
|
||||
system-prompt identity block ("official CLI" vs "Claude Agent SDK"), which is driven by genuine
|
||||
interactivity (no `-p`, no `--output-format`, real PTY) and is overridable by no env var. This
|
||||
is the real reason the tmux/no-`-p` approach works: the spawn is genuinely interactive, not just
|
||||
labelled as such.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Add `CLAUDE_TUI_MODE=true` as an opt-in flag in `server.mjs`.
|
||||
|
||||
### How it works
|
||||
|
||||
1. Each request spawns a fresh tmux session running `claude --model <M> --session-id <UUID> --strict-mcp-config --disallowedTools 'mcp__*'` (no `-p`, no `--output-format`).
|
||||
2. The spawn result is checked immediately: if `tmux new-session` returns a non-zero exit status (or a falsy result), the request is aborted with `tui_spawn_failed: tmux session not created` **before** the boot sleep. This is the spawn/PTY gate — OCP must not issue a billing request without a verified interactive session.
|
||||
3. The serialized prompt (from `messagesToPrompt`) is pasted via `tmux send-keys … "$(cat file)"` + a separate `Enter` key event.
|
||||
4. The answer is read from claude's native JSONL transcript at `<HOME>/.claude/projects/<encoded-cwd>/<session-id>.jsonl`, polling until a `turn_duration` system event or the wall-clock cap (`CLAUDE_TUI_WALLCLOCK_MS`, default 120 s).
|
||||
5. The string answer is returned to OCP's existing downstream (singleflight → cache write-back → `completionResponse` / `streamStringAsSSE`) — **same contract as `callClaude`**.
|
||||
6. Streaming requests are buffered then replayed as chunked SSE (no real token streaming — deliberate; "don't build fragile features").
|
||||
|
||||
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`, PR-4)
|
||||
|
||||
`CLAUDE_CODE_ENTRYPOINT` on the spawn env is managed by `resolveTuiEntrypointEnv(env, mode)`
|
||||
(exported from `lib/tui/session.mjs`, pure, testable). The function **always deletes any
|
||||
inherited value first** so a stray env var from OCP's own parent process can never leak in and
|
||||
mislabel the billing header. Then:
|
||||
|
||||
| `OCP_TUI_ENTRYPOINT` | Behaviour |
|
||||
|----------------------|-----------|
|
||||
| `cli` (default) | Sets `CLAUDE_CODE_ENTRYPOINT=cli` deterministically — subscription-pool classification. **Honest only because the spawn is a genuine interactive PTY** (tmux pane, no `-p`, stdout not redirected, `new-session` verified). |
|
||||
| `auto` | Deletes the key → claude self-classifies via `t$A` (TTY → `cli`). Use to observe/diagnose the real TTY-derived value. |
|
||||
| `off` | Leaves the env exactly as inherited — diagnostics / honesty audit only. |
|
||||
|
||||
**Governing rule (verbatim):** *OCP may make a true value deterministic; it may never assert a
|
||||
value the spawn's real state contradicts. When it cannot make the claim true (e.g. cannot
|
||||
guarantee a PTY), it fails/drops the request — it does not force the signal.*
|
||||
|
||||
This is why the spawn/PTY gate (step 2 above) is load-bearing for `mode="cli"`: if `new-session`
|
||||
fails, there is no PTY, so asserting `cli` would be dishonest. Abort rather than lie.
|
||||
|
||||
OCP never suppresses the billing header (anti-fingerprinting: we do not mask the spawn).
|
||||
|
||||
### 2026-06-15 verification protocol
|
||||
|
||||
Run one quiesced canary request in TUI-mode and watch the **Agent SDK credit balance** (not the
|
||||
request header). If the balance drops, the subscription pool is unreachable via spawn. Per the
|
||||
constitution (`ALIGNMENT.md`), the response is to **drop the Anthropic provider** rather than
|
||||
escalate spoofing.
|
||||
|
||||
Version caveat: mechanism verified on cli.js v2.1.104 + live on v2.1.158. Re-verify after any
|
||||
major cli.js upgrade.
|
||||
|
||||
### Default behaviour is unchanged
|
||||
|
||||
When `CLAUDE_TUI_MODE` is unset (the default), no code path touches `callClaudeTui` or `runTuiTurn`. `upstreamCall === callClaude` and streaming uses `callClaudeStreaming` — byte-for-byte identical to the pre-TUI code path.
|
||||
|
||||
### Kill-switch
|
||||
|
||||
Unset `CLAUDE_TUI_MODE` (or set it to any value other than `"true"`) → stream-json path restored immediately on next restart.
|
||||
|
||||
### Home strategy
|
||||
|
||||
> **Superseded by the PR-D amendment below for the env-token case.** As of PR-D, `TUI_HOME`
|
||||
> is computed by `resolveTuiHome()`: when `CLAUDE_CODE_OAUTH_TOKEN` is set (and `OCP_TUI_HOME`
|
||||
> is unset) the default is a **credential-free scratch home**, not the real home. The
|
||||
> descriptions below remain accurate for the **no-env-token** case and the **explicit
|
||||
> `OCP_TUI_HOME` override** case.
|
||||
|
||||
- **Real-home (default when NO env token, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write).
|
||||
- **Scratch-home opt-in (`OCP_TUI_HOME=<path>`, no env token):** a dedicated `HOME` that symlinks `~/.claude/.credentials.json` from the real home (token is never copied) and seeds a stripped `~/.claude.json` (no project history, trusts only the scratch cwd). **Caveat:** claude rewrites `.credentials.json` on OAuth token refresh, replacing the symlink with a regular file — this forks the credentials. Use this legacy symlink mode only with a dedicated OAuth or for ephemeral testing. (The PR-D env-token mode avoids this caveat entirely — no credentials file to fork.)
|
||||
|
||||
### Working directory
|
||||
|
||||
`TUI_CWD = OCP_TUI_CWD || $HOME/.ocp-tui/work` (dedicated scratch cwd). Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/` — a stable, single location separate from the operator's real project histories. The directory is created automatically on first request.
|
||||
|
||||
### MCP hard-disable
|
||||
|
||||
`--strict-mcp-config` (no `--mcp-config` argument) prevents account-attached managed MCP servers from connecting. Belt-and-braces: `--disallowedTools 'mcp__*'` blocks any MCP tool invocation even if a server were somehow loaded. Built-in tools (Bash, Read, etc.) are left enabled on the A-path (single-user, acceptable).
|
||||
|
||||
### Session namespace
|
||||
|
||||
All tmux sessions use the prefix `ocp-tui-`. The prefix-scoped reaper (`reapStaleTuiSessions`) kills only `ocp-tui-*` sessions, never `olp-tui-*` or any other prefix. A stale-session cleanup runs once at OCP boot when `TUI_MODE` is on.
|
||||
|
||||
---
|
||||
|
||||
## SECURITY — PROMINENT WARNING
|
||||
|
||||
**TUI-mode is SINGLE-USER / SINGLE-OPERATOR ONLY.**
|
||||
|
||||
`claude` runs as the OCP process owner with full filesystem access regardless of `HOME` setting. Home selection is **not** user isolation. If OCP is serving multiple users or guest API keys:
|
||||
|
||||
- A guest prompt would run `claude` with the **operator's** filesystem access.
|
||||
- An adversarial prompt could exfiltrate files, run shell commands, or exhaust the subscription.
|
||||
|
||||
**Never enable `CLAUDE_TUI_MODE=true` on an OCP instance that serves untrusted callers or multiple users.**
|
||||
|
||||
The B-path (multi-tenant isolation) requires:
|
||||
1. `--tools ""` (no built-in tools)
|
||||
2. Per-key ephemeral `HOME` (isolated credentials + no cross-key project pollution)
|
||||
3. Sandbox runtime (e.g. `@anthropic-ai/sandbox-runtime`)
|
||||
|
||||
B-path is **deferred** and is not implemented in this ADR. Until B-path lands, TUI-mode must only be enabled on a personal single-user OCP.
|
||||
|
||||
---
|
||||
|
||||
## Observability and concurrency (PR-B amendment)
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Accepted — amends ADR 0007.
|
||||
**Motivation:** the post-PR-A code audit, findings C-4 (P1) and C-5 (P1).
|
||||
|
||||
### C-4 — independent concurrency bound for the TUI path
|
||||
|
||||
The global `MAX_CONCURRENT` gate lives in `spawnClaudeProcess()` (the `-p` / stream-json
|
||||
path). `callClaudeTui()` never calls `spawnClaudeProcess` — it calls `runTuiTurn()`, which
|
||||
cold-boots a full interactive `claude` inside a fresh tmux session. So the TUI path had **no**
|
||||
concurrency bound: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude
|
||||
processes. On a small host (e.g. a Pi 4 serving a family) a burst of ~5 is an OOM risk and
|
||||
also multiplies subscription rate-limit pressure.
|
||||
|
||||
PR-B adds an **independent** limiter for the TUI path (`lib/tui/semaphore.mjs`,
|
||||
`TuiSemaphore`):
|
||||
|
||||
- **`OCP_TUI_MAX_CONCURRENT`, default `2`.** Rationale: a TUI turn is heavy — a per-request
|
||||
cold-boot of tmux+claude plus up to `CLAUDE_TUI_WALLCLOCK_MS` (120 s) of wallclock — so a
|
||||
small host cannot run many at once. `2` is the conservative default that keeps a Pi-class
|
||||
host alive under a family burst while still allowing some overlap. It is deliberately **not**
|
||||
the same knob as `MAX_CONCURRENT` (default 8): the two pools have different shapes (a
|
||||
stream-json spawn is cheap and fast; a TUI turn is a heavy cold-boot + long wallclock), so
|
||||
coupling them would mis-size one of the two paths.
|
||||
- **Queue, don't reject.** The limiter **queues** (awaits a slot), mirroring the spirit of
|
||||
`MAX_CONCURRENT` — requests are not dropped on contention. To bound memory against a runaway
|
||||
client, the wait queue itself is capped (`maxQueue`, default 32× the limit); when the queue
|
||||
is full `run()` rejects with `tui_queue_full`, surfaced as a 503 — deterministic backpressure
|
||||
rather than silent OOM.
|
||||
- **Slot released in a `finally`.** `TuiSemaphore.run(fn)` releases the slot in a `finally`, so
|
||||
any throw — PR-A's honesty gates (`tui_wallclock_truncated`, `tui_upstream_error`), a
|
||||
`tui_paste_not_landed`, or a `tui_spawn_failed` — can never leak a slot.
|
||||
|
||||
This limiter has **zero effect when `TUI_MODE` is off**: `callClaudeTui` is never reached, so
|
||||
the semaphore is never entered. The default stream-json path is untouched.
|
||||
|
||||
### C-5 — operator-visible drift surface on `/health` (additive)
|
||||
|
||||
The `tui_entrypoint_mismatch` warning only reached journald. After the 2026-06-15 flip, a
|
||||
silent `sdk-cli` drift (the documented top risk in this ADR — a lost TTY flipping the
|
||||
self-classification to the metered Agent SDK pool) would drain metered credits **invisibly**.
|
||||
PR-B adds a `tui` block to the `/health` JSON response so an operator can poll it:
|
||||
|
||||
```
|
||||
tui: {
|
||||
enabled: <TUI_MODE>,
|
||||
entrypointMode: <OCP_TUI_ENTRYPOINT>, // cli | auto | off
|
||||
lastEntrypoint: <last observed cc_entrypoint, e.g. "cli", or null>,
|
||||
entrypointMismatches: <count of cli-expected-but-got-other turns>,
|
||||
inflight: <current concurrent TUI turns>,
|
||||
queued: <turns waiting for a slot>,
|
||||
maxConcurrent: <OCP_TUI_MAX_CONCURRENT>
|
||||
}
|
||||
```
|
||||
|
||||
`lastEntrypoint` is recorded and `entrypointMismatches` incremented inside `callClaudeTui` in
|
||||
the same mismatch branch that already emits the journald warning (via `recordTuiEntrypoint`).
|
||||
`inflight` / `queued` / `maxConcurrent` come from the C-4 semaphore. When `TUI_MODE` is off the
|
||||
block still appears with `enabled:false` (cheap, harmless) so the response shape is stable for
|
||||
consumers regardless of mode.
|
||||
|
||||
### ALIGNMENT authorization for the `/health` change
|
||||
|
||||
`/health` is a **grandfathered B.2 endpoint** under ADR 0006, frozen at its v3.16.4 behaviour.
|
||||
`ALIGNMENT.md`'s grandfather provision states: *"Any change to the contract (request shape,
|
||||
response shape, semantics) of a grandfathered B.2 endpoint is treated as a new authorization
|
||||
request and requires either a behaviour-preserving refactor PR or its own ADR."*
|
||||
|
||||
This amendment **is** that authorization. The argument:
|
||||
|
||||
- The change is **additive**: it adds one new top-level field (`tui`) containing only new
|
||||
sub-fields. **No existing `/health` field is changed, renamed, removed, or re-typed**, and no
|
||||
existing semantics change. Existing `/health` consumers (the dashboard, `ocp-connect`,
|
||||
monitoring) read the fields they already read and are unaffected — the change is
|
||||
**behaviour-preserving** for them, which is exactly the bar the grandfather provision sets for
|
||||
a non-ADR contract change.
|
||||
- The TUI observability surface is an **intrinsic part of the TUI feature** whose authorizing
|
||||
authority is **this ADR (0007)**, not a brand-new B.2 endpoint. We are not adding a new B.2
|
||||
endpoint or a new method (which would each require their own fresh ADR under the New Class B
|
||||
endpoint procedure) — we are extending the response of an existing grandfathered endpoint with
|
||||
fields that report state owned by an ADR-0007 feature. ADR 0007 is the natural home for that
|
||||
authority, and this amendment records it explicitly.
|
||||
- `cli.js` does not perform this operation — `/health` is OCP-owned (Class B), so no `cli.js`
|
||||
citation applies; the citation is this ADR + ADR 0006 (grandfathered B.2) per
|
||||
`ALIGNMENT.md`'s Class B citation requirement.
|
||||
|
||||
### `OCP_TUI_MAX_CONCURRENT` summary
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `OCP_TUI_MAX_CONCURRENT` | `2` | Max concurrent interactive TUI turns. Independent of `CLAUDE_MAX_CONCURRENT` (the stream-json path). Excess turns queue (bounded); a full queue yields a 503. |
|
||||
|
||||
---
|
||||
|
||||
## Authentication + defunct-reaping (PR-C amendment)
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Status:** Accepted — amends ADR 0007.
|
||||
**Motivation:** the PI231 production incident — TUI-mode returned `Please run /login · API Error: 401` for days; re-login never stuck.
|
||||
|
||||
### How the TUI `claude` authenticates
|
||||
|
||||
The spawned interactive `claude` obtains its OAuth bearer in one of two ways, in this order of preference:
|
||||
|
||||
1. **`CLAUDE_CODE_OAUTH_TOKEN` in env (PREFERRED).** If the env var is set on the OCP process, `buildTuiCmd` adds `CLAUDE_CODE_OAUTH_TOKEN=<shq-escaped token>` to the pane command's `env` prefix. claude then authenticates via this long-lived token and **never touches the credentials-refresh path**. This is the stable mode — it is exactly how the oracle and Mac-mini hosts already run (and how `server.mjs`'s own `getOAuthCredentials()` takes the same env at highest precedence). cli.js is **not** the authority here: this is a Class B, OCP-owned TUI spawn — see the Class B citation below.
|
||||
2. **`<HOME>/.claude/.credentials.json` (FALLBACK).** When the env var is unset, claude falls back to the credentials file and its short-lived access token, renewing via the single-use refresh token.
|
||||
|
||||
The token MUST be set explicitly in `buildTuiCmd` because **tmux does not forward the parent process's environment to the pane** (verified live 2026-06-01 — the same reason the whole env is delivered as an `env` prefix). A token sitting in the OCP process env is invisible to the pane unless `buildTuiCmd` re-emits it.
|
||||
|
||||
### Why the fallback path corrupts (the PI231 incident)
|
||||
|
||||
When the env token is absent, every per-request spawn drives claude through the credentials.json refresh path. OAuth refresh tokens are **single-use / rotating**: a refresh consumes the old refresh token and writes a new one. The per-request `kill-session` teardown can race / interrupt claude mid-rotation, and over many spawn+kill cycles the refresh token ended up an **empty string** — at which point renewal is impossible and the host returns a permanent 401. Re-login writes a fresh token, but the next spawn re-corrupts it. **Proof the env-token fix works:** on the broken PI231 host, `CLAUDE_CODE_OAUTH_TOKEN=<oat01 token> claude -p ...` returned a real answer *despite* the corrupt credentials.json (control without the env token = 401).
|
||||
|
||||
**Operator guidance:** set `CLAUDE_CODE_OAUTH_TOKEN` on any TUI-mode host. The credentials.json fallback is retained only for hosts that intentionally rely on it; it is not recommended for a long-running TUI deployment.
|
||||
|
||||
**Security note:** with the token in the pane command, it is visible in `ps`. This is acceptable for the **single-user A-path** (it mirrors the existing plaintext-token practice for `server.mjs`), and the **multi-user B-path is already refused at boot** (`CLAUDE_TUI_MODE=true` + `AUTH_MODE=multi` is a hard FATAL), so a guest can never reach this spawn.
|
||||
|
||||
### Defunct `<claude>` reaping
|
||||
|
||||
The connected leak: the pane's `claude` process is a child of the long-lived **tmux server** daemon, not of the OCP node process (`tmux new-session -d` returns the instant the server forks the pane). Node can therefore never `waitpid()`/reap it — a SIGKILL still needs the *parent* (the tmux server) to reap. `kill-session` destroys the session but leaves the pane's `claude` (and its grandchildren) as `<defunct>` zombies that only the server reaps; over 30 days on PI231 this accumulated to **25 defunct `<claude>`** (a live `tmux kill-server` dropped it 25→3).
|
||||
|
||||
The node-reachable action that *actually reaps* — rather than merely re-signalling — is to stop the tmux server: on server exit the kernel reparents survivors to init (PID 1), which reaps them. `reapStaleTuiSessions` therefore, after killing our own `ocp-tui-*` sessions, issues `kill-server` **only when no foreign session of any prefix remains** (coexistence: never disrupt a co-hosted `olp-tui-*` instance). This runs at boot (existing) and now on a 15-min periodic interval gated on TUI-mode and on the TUI path being idle (`inflight === 0 && queued === 0`) so a live turn's pane is never torn down. Residual: a request whose pane is created in the narrow window between the idle-check and `kill-server` would fail cleanly via the existing honesty gates (rare; documented in the server comment).
|
||||
|
||||
### ALIGNMENT authorization (Class B)
|
||||
|
||||
Both changes are **Class B** (OCP-owned TUI spawn). `cli.js` does not perform either operation — there is no `cli.js` analogue for "how the TUI pane authenticates" or "reaping tmux-server-owned zombies"; this surface is authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. No Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
|
||||
|
||||
---
|
||||
|
||||
## Credential-isolated home for env-token auth (PR-D amendment)
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Status:** Accepted — amends ADR 0007. **Corrects** the PR-C rationale and the original "Home strategy" section's scratch-home caveat.
|
||||
**Motivation:** PR-C's env-token passing alone did **not** fix the PI231 401. Decisive live evidence (claude 2.1.104, PI231):
|
||||
|
||||
| Condition | Result |
|
||||
|---|---|
|
||||
| env token passed + a broken `~/.claude/.credentials.json` present | **401** (`Please run /login · API Error: 401`) |
|
||||
| env token passed + `credentials.json` moved aside | **works** (real answer) |
|
||||
|
||||
### Corrected root cause
|
||||
|
||||
**Interactive `claude` PREFERS `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var.** A stale/corrupt `credentials.json` therefore **shadows** the env token. (This is *unlike* `-p` mode, where the env token wins — which is why `server.mjs`'s own `getOAuthCredentials()` is unaffected and why PR-C's premise looked sufficient.) So passing the token (PR-C, `buildTuiCmd`) is **necessary but insufficient**: the TUI `claude` must additionally run in a HOME that has **no `credentials.json`**, so the env token is the only credential and is authoritative.
|
||||
|
||||
This also fixes the original incident at the **root**, more completely than PR-C claimed: with no `credentials.json` in the home, claude never runs the token-refresh path at all, so the single-use refresh token can never be rotated — and therefore never corrupted — by the spawn+`kill-session` cycle. The 25-zombie / empty-refresh-token failure mode becomes structurally impossible, not merely avoided.
|
||||
|
||||
### Decision
|
||||
|
||||
When `CLAUDE_CODE_OAUTH_TOKEN` is set, the TUI `claude` runs in a **credential-free scratch home** by default:
|
||||
|
||||
- `resolveTuiHome({ realHome, configuredHome, envTokenSet })` (exported from `lib/tui/session.mjs`, pure) decides the home:
|
||||
- **`OCP_TUI_HOME` set** → that path (explicit override, back-compat — an operator who configured it keeps exactly that home).
|
||||
- **else env token set** → `<realHome>/.ocp-tui/home` — a dedicated scratch home seeded with a minimal `.claude.json` (`hasCompletedOnboarding=true` + trust **only** the scratch cwd) and its own `projects/` dir, and **deliberately NO `.credentials.json`** (no symlink, no copy).
|
||||
- **else (no env token)** → the operator's real home — **byte-for-byte the pre-fix behaviour** for hosts that intentionally rely on `credentials.json`.
|
||||
- `prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode })` gates the credential handling: in `envTokenMode` it creates the scratch `projects/` dir and seeds the minimal trusted `.claude.json` but **never** creates the credentials symlink. `runTuiTurn` sets `envTokenMode = !!CLAUDE_CODE_OAUTH_TOKEN && ehome !== realHome`.
|
||||
- `readTuiTranscript` reads from the **same** home claude runs under (`ehome`), so transcripts land under `<scratch home>/.claude/projects/` and `findTranscriptPath` globs them there — the home is threaded through consistently. (We chose scratch-`HOME` over `CLAUDE_CONFIG_DIR`: the binary supports `CLAUDE_CONFIG_DIR`, but it relocates the transcript root to `<CONFIG_DIR>/projects/` rather than `<HOME>/.claude/projects/`, which would fork the transcript-resolution rule across modes for no benefit. The scratch-HOME lever reuses the existing, tested `prepareTuiHome`/`ehome` plumbing.)
|
||||
|
||||
### This RESOLVES — not reintroduces — the scratch-home caveat
|
||||
|
||||
The original "Home strategy" section and PR-C's `prepareTuiHome` comment warned that scratch-home is unsafe because *claude rewrites a **symlinked** `.credentials.json` on token refresh → forks/corrupts the OAuth credentials*. **That caveat does not apply to env-token mode**: there is no `credentials.json` in the home to fork, and claude never refreshes (it uses the long-lived env token), so there is no rotation and no corruption. The fork risk was inherent to the *symlink* approach; removing the credentials file entirely removes the risk. The legacy symlink mode is retained **only** for an operator who explicitly sets `OCP_TUI_HOME` without an env token, and its caveat is preserved for exactly that path.
|
||||
|
||||
### ALIGNMENT authorization (Class B)
|
||||
|
||||
**Class B** (OCP-owned TUI spawn). `cli.js` has no analogue for the TUI pane's auth/home strategy; authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. `server.mjs` is touched only to compute `TUI_HOME` via `resolveTuiHome()` (TUI wiring) and to surface the auth mode in the boot log — no Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- After 2026-06-15, requests in TUI-mode bill against the Pro/Max subscription pool (`cc_entrypoint=cli`) rather than the Agent SDK credit pool.
|
||||
- Kill-switch is immediate (unset env var + restart); zero code change required.
|
||||
- Default stream-json path is untouched — no regression risk for existing deployments.
|
||||
|
||||
### Negative / trade-offs
|
||||
|
||||
- **No token streaming:** responses are buffered then replayed as chunked SSE. Clients see a delay then the full response arrives; real-time token streaming is not available in TUI-mode.
|
||||
- **Billing unmeasurable until 2026-06-15:** the `cc_entrypoint=cli` signal is verified, but the credit deduction from the correct pool cannot be confirmed until the billing split activates.
|
||||
- **tmux dependency:** the host must have `tmux` installed. CI / Docker images that lack tmux cannot use TUI-mode (the default stream-json path is unaffected).
|
||||
- **Wall-clock cap:** long Opus thinking turns may hit the 120 s cap. Increase `CLAUDE_TUI_WALLCLOCK_MS` if needed (no quiescence heuristic — the reader polls until terminal marker or cap).
|
||||
- **Grey-area usage:** running an interactive `claude` session headlessly to serve HTTP requests is not an officially documented use case. If Anthropic policy changes to block this pattern, OCP must fall back to the stream-json path (unset `CLAUDE_TUI_MODE`).
|
||||
|
||||
### Coexistence
|
||||
|
||||
- tmux prefix `ocp-tui-` is registered. Any co-hosted OLP test instance must use `olp-tui-`. Never run two TUI proxies on the same OAuth concurrently — stop one instance during integration testing.
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
TUI-mode originated in a prototype contributed via PR #101 (see the PR for author attribution). The productionization design is in `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md`. Spikes S1–S6 / T1–T6 were validated live on the test host against `claude v2.1.158`.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
This directory holds the OCP Architecture Decision Records (ADRs) — short documents that capture the **why** behind structural choices.
|
||||
|
||||
Read these before proposing governance, SPOT (single-source-of-truth), or process changes.
|
||||
|
||||
## Numbering
|
||||
|
||||
ADRs start at `0002`. The first one (`0001`) was reserved for an early
|
||||
internal proposal that was superseded before publication; `0002` is
|
||||
deliberately the first published record so the archived `0001` slot
|
||||
remains a placeholder rather than being silently renumbered.
|
||||
|
||||
New ADRs increment from the highest existing number. Filenames are
|
||||
`NNNN-<short-slug>.md`.
|
||||
|
||||
## Index
|
||||
|
||||
| ADR | Title | What it covers |
|
||||
|---|---|---|
|
||||
| [0002](0002-alignment-constitution.md) | Alignment Constitution | The `ALIGNMENT.md` constitution: why every `server.mjs` change requires `cli.js` citation + independent reviewer + CI blacklist pass. Background: the 2026-04-11 drift incident. |
|
||||
| [0003](0003-models-json-spot.md) | `models.json` as SPOT | Why model IDs / aliases / context windows live in a single JSON file (not duplicated in `server.mjs` and `setup.mjs` arrays). v3.11.0 refactor. |
|
||||
| [0004](0004-openclaw-auto-sync.md) | OpenClaw Auto-Sync | Why `scripts/sync-openclaw.mjs` runs on `ocp update`, what its scope boundary is (writes only `models.providers["claude-local"].models` and `agents.defaults.models["claude-local/*"]`), and the idempotency contract. |
|
||||
| [0005](0005-no-multi-provider.md) | No Multi-Provider | Why OCP stays single-provider (Anthropic-via-cli.js) and does not extend to OpenAI / Gemini / OpenRouter. Cost estimate: ~7 weeks for a v1 that buys neither moat nor commercial readiness. Separate commercial work starts in a separate repo. |
|
||||
| [0006](0006-openai-shim-scope.md) | OpenAI Shim Scope | The Class A / Class B taxonomy. Class A endpoints (`cli.js`-mirror) keep Rules 1–5 verbatim; Class B endpoints (OCP-owned compatibility surface — `/v1/chat/completions`, `/v1/models`, admin endpoints) are anchored to OpenAI's spec (B.1) or to an authorizing ADR (B.2). Triggered by PR #99 (external `response_format` honoring). Grandfathers the existing B.2 inventory at v3.16.4. |
|
||||
|
||||
## When to write a new ADR
|
||||
|
||||
Open one whenever:
|
||||
|
||||
- A structural rule is being added or changed (e.g., new SPOT, new boundary, new CI guardrail).
|
||||
- A decision encodes a lesson from an incident or drift.
|
||||
- A future contributor reading the code alone could plausibly undo or re-litigate the choice.
|
||||
|
||||
Skip ADRs for routine implementation choices (algorithm pick, naming) — those belong in commit messages.
|
||||
|
||||
## Format
|
||||
|
||||
Keep ADRs short — Context / Decision / Consequences is the standard skeleton. Cite incidents, PRs, or commits where useful.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 366 KiB |
@@ -0,0 +1,11 @@
|
||||
# OpenAI Compatibility Pin (Class B.1)
|
||||
|
||||
**Status:** Placeholder — populated at first B.1 audit per ADR 0006 §"Class B audit cadence".
|
||||
|
||||
This file is the Class B.1 counterpart to the Class A `cli.js` audit pin in `ALIGNMENT.md` §"Golden Reference". When the first annual alignment audit covers Class B (per `ALIGNMENT.md` §"Annual Alignment Audit"), this file will be populated with:
|
||||
|
||||
- The OpenAI `/v1/chat/completions` specification snapshot date being audited against (and the source URL the snapshot was taken from).
|
||||
- The list of B.1 endpoints (currently `/v1/chat/completions`, `/v1/models`) and, for each one, the specific OpenAI spec fields and behaviours it honors.
|
||||
- Drift detection notes for any OpenAI spec changes since the previous audit, and any OCP code changes required to track those changes.
|
||||
|
||||
Until populated, this file's existence is only a forward reference so that the link in `ALIGNMENT.md` does not 404. The actual audit procedure is defined in `ALIGNMENT.md` §"Annual Alignment Audit" (Class B scope) and ADR 0006 §"Class B audit cadence".
|
||||
@@ -0,0 +1,268 @@
|
||||
# OCP Anthropic-Only Sandbox Strategy — Handoff Document
|
||||
|
||||
**Status:** Forward-looking planning doc (not yet a decision)
|
||||
**Date:** 2026-05-29
|
||||
**Audience:** future OCP maintainer / session picking up multi-tenant security work
|
||||
**Provenance:** authored during OLP Phase 7 PR-B re-evaluation; OLP's parallel analysis (multi-provider) lives at `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` Amendment 1 (pending). This OCP-side doc strips the multi-LLM generalization and keeps only what applies to OCP's single-provider (anthropic) deployment.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this doc exists
|
||||
|
||||
OCP is in maintenance mode (per OLP ADR 0001 supersession of OCP ADR 0005). It is not under active development for new features. However, two things may eventually drive sandbox work in OCP:
|
||||
|
||||
1. **Multi-key OCP deployments.** `OCP_OWNER_TOKEN` + per-key cache namespace already shipped (OCP `lib/keys.mjs`). If multiple human users share an OCP instance, the same multi-tenant filesystem-isolation gap that motivated OLP Phase 7 also exists here.
|
||||
2. **Cloud or shared-host OCP deployments.** Any deployment beyond "single user on their own machine" inherits the threat surface.
|
||||
|
||||
If/when that work starts, this doc is the prior-art capture so the maintainer doesn't repeat OLP's PR-B path (which has a documented dead-end — see § 3.2 below).
|
||||
|
||||
This doc is anthropic-only by design — codex/mistral/etc. multi-LLM concerns are out of scope per OCP ADR 0005.
|
||||
|
||||
---
|
||||
|
||||
## 2. The multi-tenant gap (OCP-specific)
|
||||
|
||||
OCP spawns `claude -p` as the OCP-process user. Every spawned claude instance runs with the OCP user's filesystem permissions. Consequences for a multi-key OCP deployment:
|
||||
|
||||
1. **Cross-key lateral read.** A prompt-injected `cat ~/.ocp/keys/<other-key>.json` reads any other key's manifest (token hash, owner_tier, providers_enabled — not catastrophic since it's only the *hash*, but still identity-attribution surface).
|
||||
2. **OAuth credential exposure.** `~/.claude/.credentials.json` is the Anthropic OAuth refresh token. A prompt-injected read of this file = stealing the subscription that OCP exists to pool.
|
||||
3. **SSH identity exposure.** `~/.ssh/id_*` reachable for lateral movement to other hosts the OCP user can reach.
|
||||
4. **Other host secrets.** Anything else under the OCP user's home is reachable.
|
||||
|
||||
OCP's `ALIGNMENT.md` Class A/B endpoint discipline does not address this — that discipline is wire-level honesty (`cli.js` mirror), not host-level isolation.
|
||||
|
||||
The threat model assumes prompt-injection capability — any caller with a valid OCP key + ability to craft a prompt that elicits a tool call. Default `claude -p` mode includes Read/Bash/etc. tool descriptions in the system prompt; the model is **eager** to use them.
|
||||
|
||||
---
|
||||
|
||||
## 3. Why OLP Phase 7 PR-B is the wrong path to copy
|
||||
|
||||
OLP attempted to wrap `claude -p` spawn in `@anthropic-ai/sandbox-runtime` (outer bubblewrap on Linux, sandbox-exec on macOS). This produced four binding problems documented during OLP's re-evaluation:
|
||||
|
||||
### 3.1 Anthropic's design doesn't expect external sandboxing
|
||||
|
||||
Per Anthropic's [engineering blog on Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing), `sandbox-runtime` is designed to be invoked **by claude code itself** to sandbox **its own** Bash tool / MCP servers / spawn children. It is **not** designed to sandbox claude code as an externally-wrapped process.
|
||||
|
||||
Concretely: claude CLI assumes it can freely read+write its own `$HOME`-derived paths (`~/.claude.json`, `~/.claude/.credentials.json`, `~/.config/claude/`, future state files). When wrapped in `bwrap --ro-bind / /`, those writes hit `EROFS` and claude silently exits with no stdout.
|
||||
|
||||
### 3.2 `~/.claude.json` upstream status is "closed not planned"
|
||||
|
||||
claude CLI writes `~/.claude.json` non-atomically at startup. Upstream issues #28842, #29162, #29217, #28837, #29051, #29250, #7243 all document this. **#29250 is closed as "not planned / duplicate"** — Anthropic is not going to make this file atomic-write because their mental model is that claude runs in an environment that can write its `$HOME`.
|
||||
|
||||
For OCP, this means: any outer-sandbox approach that uses `--ro-bind` on `$HOME` will be a **permanent maintenance treadmill** — every new claude CLI version that adds a state file outside the patched mount paths breaks OCP. OLP's PR-B fold-in tried to patch this by promoting `~/.claude/` to rw, which was insufficient (the actual file is `~/.claude.json` at $HOME root, not inside `~/.claude/`).
|
||||
|
||||
### 3.3 The threat model doesn't justify the cost
|
||||
|
||||
OCP is, per ADR 0005, a personal-and-family-scale tool. The realistic threat surface is misbehaving prompts from family members or self-injected via dependent agents, not adversarial external attackers. The blast radius of a successful cross-key read is bounded (token *hash*, OAuth that's pooled-by-design across all OCP keys).
|
||||
|
||||
A maintenance-mode project investing weeks into outer-sandboxing for a hypothetical threat is a poor cost/benefit. There are cheaper architectures (§ 4 below) that get most of the protection.
|
||||
|
||||
### 3.4 OLP-specific reason that does NOT apply to OCP
|
||||
|
||||
OLP also hit a multi-provider conflict: codex CLI has its own inner bubblewrap that breaks when wrapped in an outer bwrap (openai/codex#16018). **This is not an OCP concern** — OCP only spawns claude. So the multi-provider forcing function for OLP doesn't apply here. The other three reasons (§ 3.1–3.3) are sufficient on their own.
|
||||
|
||||
---
|
||||
|
||||
## 4. Three viable approaches for OCP
|
||||
|
||||
Ranked by "engineering cost vs isolation strength" — pick by deployment context.
|
||||
|
||||
### 4.1 Approach A — Ephemeral `$HOME` via env var (recommended starting point)
|
||||
|
||||
Per-spawn setup:
|
||||
|
||||
```
|
||||
ephemeralRoot=/tmp/ocp-spawn/<keyId>/<reqId>/home
|
||||
mkdir -p $ephemeralRoot/.claude
|
||||
ln -s ~/.claude/.credentials.json $ephemeralRoot/.claude/.credentials.json
|
||||
HOME=$ephemeralRoot claude -p --output-format stream-json ...
|
||||
```
|
||||
|
||||
Mechanics:
|
||||
- claude CLI uses Node's `os.homedir()` which reads `$HOME` env first.
|
||||
- `~/.claude.json` written by claude on startup → lands in `/tmp/ocp-spawn/<keyId>/<reqId>/home/.claude.json` (tmpfs, discarded after spawn).
|
||||
- `~/.claude/.credentials.json` is the OAuth file claude needs — symlinked in read-only from the real one.
|
||||
- Any new state file claude CLI introduces in a future version → also lands in the ephemeral home, no patch needed.
|
||||
|
||||
Threat coverage:
|
||||
- ✅ Solves EROFS upgrade tax permanently — any claude state-file location works because they all land in tmpfs.
|
||||
- ✅ Cross-key OAuth credential isolation — keyA's ephemeral home has only keyA's symlink, but here the symlink target is the SAME real file because OCP shares OAuth (this is fine: shared OAuth is OCP's design, the symlink just keeps the file inaccessible via `cat ~/.claude/.credentials.json` from a different keyId's ephemeral root).
|
||||
- ❌ Does NOT solve cross-key lateral filesystem read via absolute paths. A prompt-injected `cat /home/<ocp-user>/.ocp/keys/<otherKey>.json` still works — `os.homedir()` override doesn't affect absolute-path reads.
|
||||
|
||||
5-minute spike before adopting:
|
||||
|
||||
```bash
|
||||
HOME=/tmp/fake-home-spike claude --print "echo PONG" --no-session-persistence 2>&1
|
||||
ls -la /tmp/fake-home-spike # expect: .claude.json + .claude/ created here
|
||||
find ~/.claude ~/.claude.json -newer /tmp/spike-marker 2>/dev/null # expect: empty
|
||||
```
|
||||
|
||||
If claude falls back to `os.userInfo().homedir` (uses getpwuid_r, ignores HOME env), this approach degrades — fall back to Approach B.
|
||||
|
||||
**Engineering cost:** ~50 LOC in OCP's spawn pipeline (mkdir + symlink + env merge + cleanup-on-exit). No new dependencies.
|
||||
|
||||
### 4.2 Approach B — Outer bubblewrap with `--tmpfs $HOME` + `--ro-bind` credentials
|
||||
|
||||
```
|
||||
bwrap \
|
||||
--ro-bind / / \
|
||||
--tmpfs /home/<ocp-user> \
|
||||
--ro-bind /home/<ocp-user>/.claude/.credentials.json /home/<ocp-user>/.claude/.credentials.json \
|
||||
--ro-bind /home/<ocp-user>/.ocp/keys/<thisKeyId>.json /home/<ocp-user>/.ocp/keys/<thisKeyId>.json \
|
||||
--dev /dev --proc /proc --tmpfs /tmp \
|
||||
claude -p ...
|
||||
```
|
||||
|
||||
This is the canonical bwrap pattern (Flatpak uses exactly this for every sandboxed app — see [Bubblewrap ArchWiki Examples](https://wiki.archlinux.org/title/Bubblewrap/Examples)).
|
||||
|
||||
Threat coverage:
|
||||
- ✅ Solves EROFS upgrade tax (tmpfs accepts any write path).
|
||||
- ✅ Cross-key lateral read prevention — only the current key's manifest is bind-mounted in, others are simply absent from the sandbox view.
|
||||
- ✅ `~/.ssh` and similar identity material absent from sandbox.
|
||||
|
||||
Trade-offs:
|
||||
- bwrap dependency: install `bubblewrap` apt package on host.
|
||||
- Bypasses `@anthropic-ai/sandbox-runtime` library — direct bwrap arg composition. Worth it because sandbox-runtime's outer-wrap design is for short-lived claude-internal subprocesses, not long-running claude CLI itself (per § 3.1).
|
||||
- macOS: not supported by bwrap (macOS would need separate `sandbox-exec` profile, ~50-100 LOC additional work). OCP cross-machine maintainer deploys mostly on Mac mini + Oracle ARM VM — both Linux on the cloud side, Mac mini side may remain unsandboxed if family-trust-zone.
|
||||
|
||||
**Engineering cost:** ~150 LOC for the spawn wrapper + deployment doc updates to require `apt install bubblewrap`. macOS support is a separate ~100 LOC if/when needed.
|
||||
|
||||
### 4.3 Approach C — OverlayFS lowerdir (read-only) + tmpfs upperdir (writable)
|
||||
|
||||
```
|
||||
mount -t overlay overlay \
|
||||
-o lowerdir=/home/<ocp-user>/.claude,upperdir=/tmp/ocp-spawn/<reqId>/upper,workdir=/tmp/ocp-spawn/<reqId>/work \
|
||||
/tmp/ocp-spawn/<reqId>/merged-claude
|
||||
HOME=/tmp/ocp-spawn/<reqId>/home claude -p ...
|
||||
# After spawn: umount + rm -rf
|
||||
```
|
||||
|
||||
Most elegant — claude sees a view identical to its real `~/.claude/`, all writes go to tmpfs upperdir, real `~/.claude/` is never touched.
|
||||
|
||||
Trade-offs:
|
||||
- Requires `CAP_SYS_ADMIN` or rootless-overlayfs (kernel ≥5.11 + user-ns enabled). OCP currently runs as the maintainer's user — no SYS_ADMIN — so this would require either running OCP as root (bad) or rootless-overlayfs setup.
|
||||
- More moving parts (mount/umount per spawn, work-dir lifetime, cleanup-on-crash).
|
||||
|
||||
Better fit if OCP ever moves to a dedicated `ocp` system user with `CAP_SYS_ADMIN` capability via systemd.
|
||||
|
||||
**Engineering cost:** ~120 LOC + kernel/permission preflight check.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-key isolation orthogonal layer
|
||||
|
||||
The three approaches above all solve `~/.claude.json` EROFS + state-write isolation. None of them alone solve **cross-key lateral filesystem read via absolute paths** (e.g. prompt-injected `cat /home/<user>/.ocp/keys/<otherKey>.json`).
|
||||
|
||||
For that, two options compose with any of A/B/C:
|
||||
|
||||
### 5.1 Per-spawn `sandbox-runtime` customConfig with `denyRead`
|
||||
|
||||
`@anthropic-ai/sandbox-runtime`'s `wrapWithSandbox(command, binShell?, customConfig?, abortSignal?)` accepts per-call override:
|
||||
|
||||
```
|
||||
const otherKeysWorkspaces = listAllKeyManifestsExcept(thisKeyId)
|
||||
const wrapped = await SandboxManager.wrapWithSandbox(claudeCommand, undefined, {
|
||||
filesystem: {
|
||||
denyRead: [
|
||||
...otherKeysWorkspaces, // all keys except current
|
||||
'/home/<ocp-user>/.ssh',
|
||||
'/home/<ocp-user>/.gnupg',
|
||||
'/home/<ocp-user>/.aws',
|
||||
],
|
||||
allowWrite: [ephemeralRoot, '/tmp'],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
This adds bwrap deny-paths per-spawn (after sandbox-runtime singleton init). Works in combination with Approach A (the `HOME` env-var override is independent of sandbox-runtime's restrictions).
|
||||
|
||||
Caveat: this re-introduces the outer-bwrap concern from § 3.1 — claude CLI is now wrapped after all. Mitigation: use this only for **cross-key isolation**, not for `$HOME` restriction. The `denyRead` paths are all outside `$HOME`, so claude's `~/.claude.json` write is unaffected.
|
||||
|
||||
### 5.2 Per-OS-user OCP spawning
|
||||
|
||||
Each OCP key gets a dedicated Linux user (`ocp-<keyId>`). Spawn claude as that user via `runuser` or `sudo -u`. OAuth credential shared via Linux group permissions or bind-mount.
|
||||
|
||||
True kernel-level uid isolation. Most robust answer for OCP-as-shared-host scenarios.
|
||||
|
||||
Trade-offs:
|
||||
- Setup script complexity (one-time per key).
|
||||
- Linux-only.
|
||||
- Doesn't fit Mac mini deployment.
|
||||
|
||||
Best fit for a cloud OCP deployment where per-tenant trust isolation matters.
|
||||
|
||||
---
|
||||
|
||||
## 6. Trust model framing
|
||||
|
||||
OCP's authentication layer (`lib/keys.mjs`) provides **attribution** (per-key audit, per-key cache namespace). It does NOT, by itself, provide **isolation** (per-key trust boundary against prompt-injection lateral reads).
|
||||
|
||||
This distinction is worth making explicit in OCP's README "Security" section (it currently isn't). The three tiers:
|
||||
|
||||
| Tier | Trust Model | Sandbox requirement |
|
||||
|---|---|---|
|
||||
| **Single-user** | maintainer's own machine, single OCP token | None — system-user permissions are sufficient |
|
||||
| **Family-trust-zone** | maintainer + family members on shared OCP instance, all parties trusted not to attack each other | Optional — Approach A (ephemeral $HOME) gives cleanup hygiene without changing trust assumptions |
|
||||
| **Shared-host / cloud / external callers** | OCP keys handed to potentially-adversarial callers (CI runners, third-party agents, public demo) | Required — Approach B or C + § 5 cross-key isolation |
|
||||
|
||||
The current OCP deployment fits tier 1 or 2. The work in this doc applies only when promoting to tier 3.
|
||||
|
||||
---
|
||||
|
||||
## 7. Recommendation if/when this work starts
|
||||
|
||||
**Phase 1 — Approach A (ephemeral `$HOME`) only.**
|
||||
- ~50 LOC, no apt deps, works on Mac mini + Linux
|
||||
- Solves the EROFS upgrade tax structurally
|
||||
- Closes cross-key OAuth-credential-file lateral read
|
||||
- Cost-effective hygiene improvement
|
||||
|
||||
**Phase 2 — Approach B (outer bwrap) gated by deployment config.**
|
||||
- Add `~/.ocp/config.json` field `security.sandbox: 'off' | 'tmpfs-home'`
|
||||
- Default off (preserves Mac mini family deployment)
|
||||
- Operator opts in on Linux cloud deployments
|
||||
- Apt prereq documented in deployment guide
|
||||
|
||||
**Phase 3 — § 5 cross-key isolation (only if tier 3 deployment is planned).**
|
||||
- Layer per-spawn customConfig denyRead OR per-OS-user spawning
|
||||
- Treat as separate ADR amendment with its own threat-model evidence
|
||||
|
||||
**Skip Approach C** unless a future requirement forces overlay (low likelihood for OCP scope).
|
||||
|
||||
---
|
||||
|
||||
## 8. Authority citations
|
||||
|
||||
This doc claims findings about claude CLI / `@anthropic-ai/sandbox-runtime` behavior. Sources for verification:
|
||||
|
||||
- [Anthropic engineering — Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing) (sandbox-runtime design intent)
|
||||
- [Anthropic sandbox-runtime GitHub](https://github.com/anthropic-experimental/sandbox-runtime) (wrapWithSandbox API + customConfig per-call signature)
|
||||
- [claude-code#29250 — `.claude.json` non-atomic-write closed-not-planned](https://github.com/anthropics/claude-code/issues/29250)
|
||||
- [claude-code#29162 — read-only `~/.claude.json` startup hang](https://github.com/anthropics/claude-code/issues/29162)
|
||||
- [claude-code#29217 — concurrent-write corruption](https://github.com/anthropics/claude-code/issues/29217)
|
||||
- [claude-code#28842 — Windows startup race](https://github.com/anthropics/claude-code/issues/28842)
|
||||
- [claude-code#7243 — "the .claude.json elephant in the room"](https://github.com/anthropics/claude-code/issues/7243)
|
||||
- [Bubblewrap README](https://github.com/containers/bubblewrap)
|
||||
- [Bubblewrap ArchWiki — Examples section, --tmpfs HOME pattern](https://wiki.archlinux.org/title/Bubblewrap/Examples)
|
||||
- [Sandboxing CLI tools with Bubblewrap — botmonster](https://botmonster.com/self-hosting/sandbox-linux-apps-cli-tools-bubblewrap/)
|
||||
- [OverlayFS kernel documentation](https://docs.kernel.org/filesystems/overlayfs.html)
|
||||
- [OverlayFS ArchWiki](https://wiki.archlinux.org/title/Overlay_filesystem)
|
||||
|
||||
OLP's parallel work (multi-provider generalization of this strategy, including the codex inner-bwrap conflict that does not apply to OCP):
|
||||
|
||||
- `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` (PR-B as-shipped) + Amendment 1 (pending — Solution 1 architecture)
|
||||
- `dtzp555-max/olp` `docs/plans/cloud-deployment-family.md` § 5 (deployment-side trust tier mapping)
|
||||
- archive branch `dtzp555-max/olp:phase-7-pr-b-outer-bwrap-snapshot` captures the outer-bwrap approach as snapshot if anyone wants to revisit it
|
||||
|
||||
---
|
||||
|
||||
## 9. What this doc is NOT
|
||||
|
||||
- Not an ADR. ADRs are decisions; this is a forward-facing strategy doc that becomes an ADR only when work starts and a decision is made.
|
||||
- Not a binding spec. The three approaches are alternatives; the recommendation in § 7 is the maintainer's lean from prior-art analysis, not a constitution.
|
||||
- Not authority for any code change. OCP `ALIGNMENT.md` still requires citation per Class A/B; no sandbox code lands without proper authority pinning when the work eventually starts.
|
||||
- Not a security audit. The threat model is informal — based on prior-art search + incident memory from OLP's parallel session. A real cloud deployment should commission an independent threat model.
|
||||
|
||||
---
|
||||
|
||||
**Authors:** project maintainer (handoff prepared with AI drafting assistance during OLP Phase 7 PR-B re-evaluation, 2026-05-29).
|
||||
@@ -0,0 +1,151 @@
|
||||
# 2026-06-15 Canary Runbook
|
||||
|
||||
**Purpose:** Confirm that a TUI-mode turn is billed to the **Pro/Max subscription pool** (not the Agent SDK credit pool) after Anthropic's 2026-06-15 billing split activates.
|
||||
|
||||
The billing classifier reading `cli` is **necessary but NOT sufficient** proof. (Note the naming: the value is stored in the JSONL transcript under the field name `entrypoint`, and sent to Anthropic on the wire as the `cc_entrypoint` header — they carry the same value after claude's startup classification. The commands below grep the transcript, so they match `entrypoint`.) A `cli` label tells you OCP sent the right classification; it does not tell you Anthropic billed the right pool. The only authoritative test is to observe whether the **Agent SDK credit balance** moves or not before and after the canary turn.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `CLAUDE_TUI_MODE=true` already set and OCP restarted (see [TUI-mode setup in README](../../README.md#enabling-tui-mode-opt-in))
|
||||
- `tmux` installed on the host
|
||||
- No other OCP traffic during the canary (quiesce — see below)
|
||||
- Access to your Anthropic account billing page (manual step — see below)
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Quiesce the host
|
||||
|
||||
Stop any IDE or client that is actively sending requests through this OCP instance.
|
||||
|
||||
Confirm the proxy is idle:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep activeRequests
|
||||
# Expected: "activeRequests": 0
|
||||
```
|
||||
|
||||
Wait until `activeRequests` is `0` before proceeding. If you cannot quiesce (e.g. family members are actively using it), run the canary on a separate OCP instance or during a quiet window.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Read the Agent SDK credit balance BEFORE the canary
|
||||
|
||||
> **Manual step — no programmatic API available.**
|
||||
>
|
||||
> OCP's `/usage` endpoint reads `anthropic-ratelimit-unified-*` response headers from the Pro/Max plan quota (5-hour and 7-day subscription windows). These headers report **subscription usage**, not the Agent SDK credit pool balance. There is no known programmatic API to query the Agent SDK credit pool balance from outside the Anthropic web app.
|
||||
|
||||
To read the balance:
|
||||
|
||||
1. Open [https://claude.ai/settings/billing](https://claude.ai/settings/billing) (or your Anthropic Console billing page) in a browser.
|
||||
2. Find the **Agent SDK Credits** section (sometimes labeled "API Credits" or "Agent SDK usage").
|
||||
3. Note the current balance (e.g. `$18.43 remaining of $20.00`).
|
||||
|
||||
Write the value down — you will compare it after the canary turn.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Send the canary turn
|
||||
|
||||
With TUI-mode on and the host quiesced, send exactly one small request:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:3456/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"messages": [{"role": "user", "content": "Reply with the single word: pong"}],
|
||||
"max_tokens": 10
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
Use Haiku (the cheapest model) to minimize any hypothetical impact if the canary turns red.
|
||||
|
||||
Wait for the response to arrive completely (TUI-mode buffers the full response before returning — you will see a delay of several seconds, then the full reply).
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Confirm the transcript shows `entrypoint:"cli"`
|
||||
|
||||
After the canary turn completes, inspect the most recent JSONL transcript for the billing-classifier label:
|
||||
|
||||
```bash
|
||||
# The canary was run quiesced (Step 1), so the most recent JSONL across ALL project
|
||||
# dirs IS the canary turn. We glob every projects subdir instead of recomputing
|
||||
# claude's cwd-encoding rule (it maps every "/" AND "." to "-", e.g. ~/.ocp-tui/work
|
||||
# => projects/-home-<user>--ocp-tui-work/; see lib/tui/transcript.mjs encodeCwd) —
|
||||
# a glob is robust even if that encoding changes in a future claude build.
|
||||
LATEST=$(ls -t "$HOME"/.claude/projects/*/*.jsonl 2>/dev/null | head -1)
|
||||
echo "Transcript: $LATEST"
|
||||
grep -o '"entrypoint":"[^"]*"' "$LATEST" | tail -1
|
||||
# Expected: "entrypoint":"cli"
|
||||
```
|
||||
|
||||
If the output shows `"entrypoint":"cli"`, the billing-classifier label is correct. If it shows `"entrypoint":"sdk-cli"`, the spawn did not get a real PTY — stop immediately and do not re-enable TUI-mode without investigation. Check `tmux new-session` manually and review ADR 0007 § spawn/PTY gate. (If the grep returns nothing, the transcript may not yet be flushed — re-run after a second, or confirm the turn completed.)
|
||||
|
||||
**Reminder: an `entrypoint:cli` label (the `cc_entrypoint=cli` wire header) is necessary but not sufficient.** It tells you OCP sent the right label to Anthropic. You must still check the credit balance in Step 5.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Re-read the Agent SDK credit balance AFTER the canary
|
||||
|
||||
Return to [https://claude.ai/settings/billing](https://claude.ai/settings/billing) and reload the page. Note the current balance again.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Green/Red decision
|
||||
|
||||
### Green (balance unchanged)
|
||||
|
||||
The Agent SDK credit balance did not decrease. The turn billed against the Pro/Max subscription pool as expected. TUI-mode is working correctly.
|
||||
|
||||
**Actions:**
|
||||
- Keep `CLAUDE_TUI_MODE=true` on this host.
|
||||
- Monitor the balance periodically for the first week to catch any delayed attribution.
|
||||
- Resume normal traffic.
|
||||
|
||||
### Red (Agent SDK credit balance decreased)
|
||||
|
||||
The Agent SDK credit balance decreased. The subscription pool is not being used for TUI-mode turns on this host, despite `cc_entrypoint=cli` being set. This may indicate a backend routing change on Anthropic's side, a TTY detection failure, or a policy change.
|
||||
|
||||
**Actions — immediate:**
|
||||
1. Unset `CLAUDE_TUI_MODE` (or set to any value other than `"true"`) in the service unit:
|
||||
- systemd: edit `/etc/ocp/ocp.env` (or the unit's `Environment=` line), then `sudo systemctl daemon-reload && sudo systemctl restart ocp.service`
|
||||
- launchd: edit the plist `EnvironmentVariables` section, then `launchctl bootout gui/$(id -u)/dev.ocp.proxy && launchctl bootstrap gui/$(id -u) <plist-path>`
|
||||
2. Restart OCP and confirm the `/health` response no longer shows TUI-mode active.
|
||||
3. If you share this OCP with family or other Max users: freeze their access temporarily until you understand the billing impact.
|
||||
4. Consider pivoting to OLP multi-provider (see [OLP](https://github.com/dtzp555-max/olp)) which can spread load across other providers to avoid the Agent SDK credit drain.
|
||||
|
||||
Per ALIGNMENT.md Rule 2 / ADR 0007 § Kill-switch: "Per the constitution, the response is to drop the Anthropic provider rather than escalate spoofing."
|
||||
|
||||
---
|
||||
|
||||
## Ongoing monitoring — self-classification mini-canary
|
||||
|
||||
To detect future drift (e.g. a claude CLI upgrade that changes TTY-detection behavior), you can run a periodic one-liner that sends a tiny TUI turn with `OCP_TUI_ENTRYPOINT=auto` (so claude self-classifies rather than having OCP pin the value) and alerts if the transcript self-classification is not `cli`:
|
||||
|
||||
```bash
|
||||
# Run with OCP temporarily configured OCP_TUI_ENTRYPOINT=auto
|
||||
# Then check the most recent transcript:
|
||||
# Glob the most recent transcript across all project dirs (robust to claude's
|
||||
# cwd-encoding rule; run this right after the auto-mode mini-canary turn).
|
||||
LATEST=$(ls -t "$HOME"/.claude/projects/*/*.jsonl 2>/dev/null | head -1)
|
||||
RESULT=$(grep -o '"entrypoint":"[^"]*"' "$LATEST" | tail -1)
|
||||
echo "Self-classified entrypoint: $RESULT"
|
||||
if echo "$RESULT" | grep -q '"entrypoint":"cli"'; then
|
||||
echo "OK — subscription pool"
|
||||
else
|
||||
echo "ALERT — not cli; check TTY and billing"
|
||||
fi
|
||||
```
|
||||
|
||||
Run this after any major `claude` CLI upgrade. The `auto` mode lets the CLI's own `t$A` startup function determine the value from the actual TTY state (see ADR 0007 § Billing-classifier labeling).
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Flip/rollback runbook](./tui-flip-rollback.md) — how to set and unset `CLAUDE_TUI_MODE` on systemd and launchd hosts
|
||||
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture and governing rules
|
||||
- README § [Subscription-pool (TUI) mode](../../README.md#subscription-pool-tui-mode)
|
||||
@@ -0,0 +1,180 @@
|
||||
# TUI-Mode Flip and Rollback Runbook
|
||||
|
||||
**Purpose:** Step-by-step instructions for enabling (`CLAUDE_TUI_MODE=true`) or disabling TUI-mode on real OCP deployments managed by **systemd** (Linux) or **launchd** (macOS).
|
||||
|
||||
Run the [615-canary](./615-canary.md) runbook after any flip to confirm billing pool routing is correct.
|
||||
|
||||
---
|
||||
|
||||
## Critical pitfalls — read first
|
||||
|
||||
### systemd: `daemon-reload` is required after editing the unit
|
||||
|
||||
Editing the unit file (or EnvironmentFile) and then doing `systemctl restart ocp.service` **without** `daemon-reload` will restart the process with the **old** environment from the cached unit. Always run `daemon-reload` after editing any unit file.
|
||||
|
||||
### launchd: `launchctl kickstart -k` does NOT reload plist env
|
||||
|
||||
`launchctl kickstart -k gui/$(id -u)/dev.ocp.proxy` kills the running process and re-launches it, but it **re-uses the launchd-cached environment** — not the current plist file. If you edited the plist's `EnvironmentVariables` section, you must do a full `bootout` + `bootstrap` cycle for the change to take effect. `kickstart` is not sufficient.
|
||||
|
||||
---
|
||||
|
||||
## Flip — enable TUI-mode
|
||||
|
||||
### systemd (Linux, e.g. Raspberry Pi, VPS)
|
||||
|
||||
**Option A — EnvironmentFile (recommended for clean separation)**
|
||||
|
||||
If your unit uses `EnvironmentFile=/etc/ocp/ocp.env` (or similar):
|
||||
|
||||
```bash
|
||||
# 1. Edit the environment file
|
||||
sudo nano /etc/ocp/ocp.env
|
||||
# Add or update:
|
||||
# CLAUDE_TUI_MODE=true
|
||||
#
|
||||
# If OCP binds to 0.0.0.0 AND you trust the network:
|
||||
# OCP_TUI_ALLOW_LAN=1
|
||||
# (WARNING: TUI-mode is single-user only — only enable OCP_TUI_ALLOW_LAN=1
|
||||
# if you fully trust every caller that can reach the OCP port on your network)
|
||||
|
||||
# 2. Reload the unit definition and restart
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ocp.service
|
||||
|
||||
# 3. Verify
|
||||
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep -E "tui|version"
|
||||
# Expected: "tuiMode": true (or similar TUI indicator in the health response)
|
||||
```
|
||||
|
||||
**Option B — inline Environment= in the unit file**
|
||||
|
||||
```bash
|
||||
# 1. Edit the unit file
|
||||
sudo systemctl edit --full ocp.service
|
||||
# Add or update in the [Service] section:
|
||||
# Environment=CLAUDE_TUI_MODE=true
|
||||
|
||||
# 2. Reload and restart
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ocp.service
|
||||
|
||||
# 3. Verify
|
||||
systemctl show ocp.service --property=Environment
|
||||
# Expected: Environment=CLAUDE_TUI_MODE=true ...
|
||||
```
|
||||
|
||||
### launchd (macOS)
|
||||
|
||||
Locate the OCP plist. The standard label is `dev.ocp.proxy`:
|
||||
|
||||
```bash
|
||||
# Find the plist path
|
||||
ls ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||
```
|
||||
|
||||
**Edit the plist:**
|
||||
|
||||
```bash
|
||||
# 1. Stop the service first (bootout)
|
||||
launchctl bootout gui/$(id -u)/dev.ocp.proxy
|
||||
|
||||
# 2. Edit the plist — add CLAUDE_TUI_MODE to EnvironmentVariables
|
||||
# Use your editor of choice:
|
||||
nano ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||
```
|
||||
|
||||
Inside the plist, in the `<key>EnvironmentVariables</key>` `<dict>` block, add:
|
||||
|
||||
```xml
|
||||
<key>CLAUDE_TUI_MODE</key>
|
||||
<string>true</string>
|
||||
```
|
||||
|
||||
If `OCP_TUI_ALLOW_LAN=1` is also needed (only if OCP binds to `0.0.0.0` and you trust the network):
|
||||
|
||||
```xml
|
||||
<key>OCP_TUI_ALLOW_LAN</key>
|
||||
<string>1</string>
|
||||
```
|
||||
|
||||
```bash
|
||||
# 3. Bootstrap (reload from disk + start)
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||
|
||||
# 4. Verify
|
||||
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep -E "tui|version"
|
||||
```
|
||||
|
||||
**Confirm env was actually loaded** (not just set in your shell):
|
||||
|
||||
```bash
|
||||
ps aux | grep server.mjs | grep -v grep
|
||||
# Get the PID, then:
|
||||
# macOS: ps -E -p <PID> | tr ' ' '\n' | grep CLAUDE_TUI_MODE
|
||||
# Expected: CLAUDE_TUI_MODE=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback — disable TUI-mode
|
||||
|
||||
Rollback is the same procedure as flip, but you **remove** `CLAUDE_TUI_MODE` or set it to any value other than `"true"` (e.g. `false`, or simply omit it).
|
||||
|
||||
After rollback, OCP returns to the default `callClaude` / `callClaudeStreaming` stream-json path — byte-for-byte identical to the pre-TUI code path. No other change is required.
|
||||
|
||||
### systemd rollback
|
||||
|
||||
```bash
|
||||
# Option A — EnvironmentFile
|
||||
sudo nano /etc/ocp/ocp.env
|
||||
# Remove or comment out:
|
||||
# CLAUDE_TUI_MODE=true
|
||||
# OCP_TUI_ALLOW_LAN=1 (if set)
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ocp.service
|
||||
|
||||
# Verify
|
||||
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep tui
|
||||
# Expected: "tuiMode": false (or the field absent)
|
||||
```
|
||||
|
||||
### launchd rollback
|
||||
|
||||
```bash
|
||||
# 1. Stop
|
||||
launchctl bootout gui/$(id -u)/dev.ocp.proxy
|
||||
|
||||
# 2. Edit plist — remove the CLAUDE_TUI_MODE and OCP_TUI_ALLOW_LAN entries from EnvironmentVariables
|
||||
|
||||
# 3. Bootstrap
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||
|
||||
# 4. Verify
|
||||
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep tui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Billing impact of staying on the default (non-TUI) path after 2026-06-15
|
||||
|
||||
If you do NOT flip to TUI-mode and keep `CLAUDE_TUI_MODE` unset (the default), OCP continues using `claude -p --output-format stream-json`, which sets `cc_entrypoint=sdk-cli`. After 2026-06-15, every OCP request on the default path will draw from the Agent SDK credit pool (approximately $20/month on a Pro plan, or $100/month on a Max plan) rather than the Pro/Max subscription. The subscription pool usage (5-hour and 7-day windows) will be unaffected, but the Agent SDK credit balance will drain with each request.
|
||||
|
||||
If you want to continue using OCP without TUI-mode after 2026-06-15, budget for the Agent SDK credit cost accordingly — or switch to [OLP](https://github.com/dtzp555-max/olp) for multi-provider fallback.
|
||||
|
||||
---
|
||||
|
||||
## Verify after any flip
|
||||
|
||||
1. Check `/health` shows the expected `tuiMode` state.
|
||||
2. Run the [615-canary](./615-canary.md) to confirm billing pool routing.
|
||||
3. If TUI-mode is ON: check `ocp logs 10` for any TUI spawn errors (`tui_spawn_failed`, tmux errors).
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [615-canary runbook](./615-canary.md) — how to verify billing pool routing after a flip
|
||||
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture; Kill-switch section
|
||||
- README § [Subscription-pool (TUI) mode](../../README.md#subscription-pool-tui-mode)
|
||||
- README § [Environment Variables](../../README.md#environment-variables) — `CLAUDE_TUI_MODE`, `OCP_TUI_ALLOW_LAN=1`
|
||||
@@ -0,0 +1,737 @@
|
||||
# TUI-mode (OCP-first) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add an opt-in `CLAUDE_TUI_MODE` to OCP that serves `/v1/chat/completions` by driving a *real interactive* `claude` session (no `-p`, no `--output-format`) so the request bills as `cc_entrypoint=cli` (subscription pool), reading the answer from claude's native JSONL transcript — while the default stream-json path stays byte-for-byte unchanged.
|
||||
|
||||
**Architecture:** Two new pure-ish modules under `lib/tui/` — a transcript **reader** (`transcript.mjs`, provider-agnostic, the shareable core) and a tmux **session driver** (`session.mjs`, OCP-specific). `server.mjs` gains a `callClaudeTui()` that returns `Promise<string>` and is gated into the existing dispatch by a single env flag; because OCP's entire downstream (singleflight → `setCachedResponse` → `completionResponse` / chunked-SSE-replay → `recordUsage`) already consumes a string from `callClaude`, TUI-mode is a drop-in. Streaming is buffered then replayed as chunked SSE (no token streaming — deliberately, "don't build fragile features").
|
||||
|
||||
**Tech Stack:** Node.js ESM (`.mjs`), `tmux` (interactive PTY host), `child_process` (`spawnSync`), `node:fs` polling (no `fs.watch`, no terminal-screen parsing). Test harness: `node test-features.mjs`.
|
||||
|
||||
**Source of truth for the TUI mechanism:** the OLP design spec `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md` (CLI-level, applies to both projects) + its 6 validation spikes (S1–S6, T1–T6) run on PI231 against `claude v2.1.158`. This plan is the OCP-grounded execution of that spec.
|
||||
|
||||
---
|
||||
|
||||
## Why OCP-first / scope decisions (read before coding)
|
||||
|
||||
- **OCP-first** because OCP has the users and its compute path is `callClaude → Promise<string>`, a near-perfect impedance match for a reader that also returns a string. OLP would additionally need a string→IR-chunk-array adapter. OLP-sync is **deferred entirely until the post-2026-06-15 fork decision** — do not spend cycles keeping OLP's TUI in lockstep.
|
||||
- **A-path only.** Single-user / multi-device on one subscription. No per-key ephemeral isolation, no multi-tenant. (That is the OLP B-path, deferred.)
|
||||
- **A-path isolation = real `$HOME` + dedicated scratch cwd + `--strict-mcp-config`.** OCP has *no* ISOLATION contract and we do not build one. We run interactive `claude` in the operator's real home (OAuth + onboarding already valid) but in a **dedicated scratch working directory** (`OCP_TUI_CWD`, default `$HOME/.ocp-tui/work`) so transcripts land under one stable `projects/<cwd>` folder instead of polluting the operator's genuine project histories, and the trust-folder dialog is granted once.
|
||||
- **One `claude` session per request.** OCP is stateless (full conversation re-serialized each request via `messagesToPrompt`). TUI-mode mirrors this: per request, start a fresh interactive session with a fresh `--session-id`, submit one serialized prompt, await turn completion, read the transcript, extract the latest assistant text, tear the session down. Warm-pool / large-paste optimizations are explicitly out of v1 scope.
|
||||
- **Billing is unmeasurable until 2026-06-15.** Spike S1 proved the `cc_entrypoint=cli` *signal*, not the billed pool. The pre-6/15 deliverable is "a tested, working transport that emits `cli`"; 6/16 we flip the flag and measure with a documented kill-switch.
|
||||
- **Coexistence rule (PI231 runs an OLP test instance too).** All tmux sessions use the prefix `ocp-tui-`; the reaper kills **only** `ocp-tui-*`, never `olp-tui-*`. Never run two TUI proxies on the same OAuth concurrently — stop the OLP test instance during OCP integration.
|
||||
- **Provenance.** TUI-mode originated in OCP PR #101 (author courtesy: jaekwon-park <insainty21@gmail.com>). The PR #101 author should be credited + notified on the shipping PR.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | New/Modified |
|
||||
|------|----------------|--------------|
|
||||
| `lib/tui/transcript.mjs` | Pure transcript parsing + the polling reader. Returns the latest assistant text once the turn is terminal or the wall-clock cap elapses. Provider-agnostic — the shareable core. | **Create** |
|
||||
| `lib/tui/session.mjs` | tmux session lifecycle: boot interactive `claude`, answer the trust dialog, submit the prompt (file → `"$(cat)"` paste → separate Enter), await the reader, tear down. Plus the prefix-scoped reaper. OCP-specific. | **Create** |
|
||||
| `lib/tui/fixtures/` | Real transcript JSONL harvested from PI231 + a few hand-crafted edge cases, for the reader's unit tests. | **Create** |
|
||||
| `server.mjs` | `callClaudeTui()` (`Promise<string>`); `streamStringAsSSE()` helper (DRY refactor of the cache-replay block); single-flag dispatch gates; reaper hook at boot; env consts. | **Modify** (`:258` env consts, `:1018`–`:1023` helpers, `:1467` dispatch, boot block) |
|
||||
| `test-features.mjs` | Suite for the reader (fixtures, runs in CI) + a live-only guarded suite for the driver (`OCP_TUI_LIVE=1`, skipped in CI). | **Modify** |
|
||||
| `docs/adr/0007-tui-interactive-mode.md` | OCP ADR 0007 (OCP's next number) — TUI mode rationale, billing-signal authority, scope, kill-switch. | **Create** |
|
||||
| `README.md` | New env vars (`CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD`), a "Subscription-pool (TUI) mode" section, troubleshooting + kill-switch. | **Modify** |
|
||||
| `CHANGELOG.md` | Unreleased entry. | **Modify** |
|
||||
|
||||
---
|
||||
|
||||
## PR-1 — Transcript reader (`lib/tui/transcript.mjs`)
|
||||
|
||||
The shareable core. Pure functions + a polling reader. Fully unit-testable from committed fixtures; needs PI231 only once, to harvest realistic fixtures.
|
||||
|
||||
### Task 0: Harvest real fixtures from PI231
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/tui/fixtures/complete-haiku.jsonl` (real, has `turn_duration`)
|
||||
- Create: `lib/tui/fixtures/complete-sonnet-multiblock.jsonl` (real, multi content-block answer)
|
||||
|
||||
- [ ] **Step 1: Drive one real interactive turn on PI231 and copy its transcript**
|
||||
|
||||
On PI231 (the only box with an authenticated interactive `claude`), run a single interactive turn in a scratch cwd, then locate its transcript:
|
||||
|
||||
Run (on PI231):
|
||||
```bash
|
||||
SID=$(uuidgen)
|
||||
mkdir -p ~/.ocp-tui/work
|
||||
# drive one turn by hand in tmux OR reuse a transcript already produced by the S-spikes:
|
||||
ls -t ~/.claude/projects/-home-*-.ocp-tui-work/*.jsonl 2>/dev/null | head
|
||||
# pick one complete transcript (must contain a line with "subtype":"turn_duration")
|
||||
```
|
||||
Expected: at least one `.jsonl` file whose tail contains `{"type":"system","subtype":"turn_duration",...}`.
|
||||
|
||||
- [ ] **Step 2: Copy 2 real transcripts into the repo as fixtures, scrubbed**
|
||||
|
||||
Run (from the workstation):
|
||||
```bash
|
||||
scp pi231:'~/.claude/projects/<encoded-cwd>/<sid>.jsonl' lib/tui/fixtures/complete-haiku.jsonl
|
||||
# Scrub: the transcript may contain the prompt/answer text only (no OAuth token — tokens
|
||||
# live in ~/.claude/.credentials.json, NOT in projects/*.jsonl). Confirm no credential
|
||||
# material before committing:
|
||||
grep -iE "sk-ant|oat01|bearer|authorization" lib/tui/fixtures/*.jsonl && echo "STOP: scrub" || echo "clean"
|
||||
```
|
||||
Expected: `clean`. (Transcripts hold conversation content + metadata, never the bearer token. If a fixture's prompt text is sensitive, replace it with a benign hand-edited turn that keeps the JSON shape.)
|
||||
|
||||
- [ ] **Step 3: Commit the fixtures**
|
||||
|
||||
```bash
|
||||
git add lib/tui/fixtures/complete-haiku.jsonl lib/tui/fixtures/complete-sonnet-multiblock.jsonl
|
||||
git commit -m "test(tui): real claude transcript fixtures harvested from PI231 (v2.1.158)"
|
||||
```
|
||||
|
||||
### Task 1: `encodeCwd` + `transcriptPath` (the path formula)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/tui/transcript.mjs`
|
||||
- Test: `test-features.mjs` (new Suite "TUI transcript")
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `test-features.mjs`:
|
||||
```js
|
||||
// ── Suite: TUI transcript reader ────────────────────────────────────────
|
||||
import { encodeCwd, transcriptPath } from "./lib/tui/transcript.mjs";
|
||||
|
||||
test("encodeCwd replaces every slash incl. leading", () => {
|
||||
assertEqual(encodeCwd("/home/u/.ocp-tui/work"), "-home-u-.ocp-tui-work");
|
||||
});
|
||||
test("transcriptPath composes EHOME/.claude/projects/<enc>/<sid>.jsonl", () => {
|
||||
assertEqual(
|
||||
transcriptPath("/home/u", "/home/u/.ocp-tui/work", "abc-123"),
|
||||
"/home/u/.claude/projects/-home-u-.ocp-tui-work/abc-123.jsonl"
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `node test-features.mjs 2>&1 | grep -i "tui transcript\|Cannot find module"`
|
||||
Expected: FAIL — `Cannot find module './lib/tui/transcript.mjs'`.
|
||||
|
||||
- [ ] **Step 3: Minimal implementation**
|
||||
|
||||
Create `lib/tui/transcript.mjs`:
|
||||
```js
|
||||
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
|
||||
// and returns the latest assistant turn's text once the turn is terminal.
|
||||
//
|
||||
// Authority: claude CLI v2.1.158 — interactive session transcript at
|
||||
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
|
||||
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
|
||||
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Project-dir encoding: every "/" -> "-" (including the leading slash).
|
||||
export function encodeCwd(cwd) {
|
||||
return cwd.replace(/\//g, "-");
|
||||
}
|
||||
|
||||
export function transcriptPath(home, cwd, sessionId) {
|
||||
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `node test-features.mjs 2>&1 | grep -i "tui transcript"`
|
||||
Expected: PASS for both cases.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/tui/transcript.mjs test-features.mjs
|
||||
git commit -m "feat(tui): transcript path formula (encodeCwd + transcriptPath)"
|
||||
```
|
||||
|
||||
### Task 2: `parseTranscriptLines` + `isTerminalLine` + `extractLatestAssistantText`
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/tui/transcript.mjs`
|
||||
- Test: `test-features.mjs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```js
|
||||
import { parseTranscriptLines, isTerminalLine, extractLatestAssistantText } from "./lib/tui/transcript.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("parseTranscriptLines skips blank + malformed/partial lines", () => {
|
||||
const evs = parseTranscriptLines('{"a":1}\n\n{bad json\n{"b":2}\n');
|
||||
assertEqual(evs.length, 2);
|
||||
assertEqual(evs[1].b, 2);
|
||||
});
|
||||
test("isTerminalLine true on turn_duration", () => {
|
||||
assertEqual(isTerminalLine({ type: "system", subtype: "turn_duration" }), true);
|
||||
});
|
||||
test("isTerminalLine true on stop_reason tool_use (message-wrapped + flat)", () => {
|
||||
assertEqual(isTerminalLine({ type: "assistant", message: { stop_reason: "tool_use" } }), true);
|
||||
assertEqual(isTerminalLine({ stop_reason: "tool_use" }), true);
|
||||
});
|
||||
test("isTerminalLine false on ordinary assistant/text lines", () => {
|
||||
assertEqual(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
|
||||
});
|
||||
test("extractLatestAssistantText concatenates text blocks of the LAST assistant turn", () => {
|
||||
const evs = [
|
||||
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
|
||||
{ type: "user", message: { content: "..." } },
|
||||
{ type: "assistant", message: { content: [{ type: "text", text: "A" }, { type: "thinking", thinking: "x" }, { type: "text", text: "B" }] } },
|
||||
];
|
||||
assertEqual(extractLatestAssistantText(evs), "AB");
|
||||
});
|
||||
test("real complete fixture yields non-empty text and is terminal", () => {
|
||||
const evs = parseTranscriptLines(readFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||
assert(evs.some(isTerminalLine), "fixture must contain a terminal line");
|
||||
assert(extractLatestAssistantText(evs).length > 0, "fixture must yield assistant text");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `node test-features.mjs 2>&1 | grep -i "parseTranscript\|isTerminal\|extractLatest\|real complete fixture"`
|
||||
Expected: FAIL — exports not defined.
|
||||
|
||||
- [ ] **Step 3: Minimal implementation** (append to `lib/tui/transcript.mjs`)
|
||||
|
||||
```js
|
||||
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
|
||||
// (the live transcript is read mid-write, so the last line may be incomplete).
|
||||
export function parseTranscriptLines(text) {
|
||||
const out = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A line marks the assistant turn complete when it is the turn_duration system
|
||||
// event, or an assistant message that stopped to hand off to a tool.
|
||||
export function isTerminalLine(obj) {
|
||||
if (!obj || typeof obj !== "object") return false;
|
||||
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
|
||||
const sr = (obj.message && obj.message.stop_reason) || obj.stop_reason;
|
||||
return sr === "tool_use";
|
||||
}
|
||||
|
||||
// Text of the LAST assistant turn: concatenate its text content blocks
|
||||
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
|
||||
export function extractLatestAssistantText(events) {
|
||||
let text = "";
|
||||
for (const ev of events) {
|
||||
if (!ev || ev.type !== "assistant") continue;
|
||||
const content = ev.message && ev.message.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
const parts = content
|
||||
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
||||
.map((b) => b.text);
|
||||
if (parts.length) text = parts.join("");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `node test-features.mjs 2>&1 | grep -iE "parseTranscript|isTerminal|extractLatest|real complete fixture"`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/tui/transcript.mjs test-features.mjs
|
||||
git commit -m "feat(tui): transcript parsing + terminal detection + assistant-text extraction"
|
||||
```
|
||||
|
||||
### Task 3: `readTuiTranscript` (the polling reader with wall-clock cap)
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/tui/transcript.mjs`
|
||||
- Test: `test-features.mjs`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```js
|
||||
import { readTuiTranscript } from "./lib/tui/transcript.mjs";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
test("readTuiTranscript returns assistant text when terminal marker present", async () => {
|
||||
const dir = mkdtempSync(`${tmpdir()}/tui-`);
|
||||
const p = `${dir}/s.jsonl`;
|
||||
writeFileSync(p, [
|
||||
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello world" }] } }),
|
||||
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200 }),
|
||||
].join("\n") + "\n");
|
||||
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 });
|
||||
assertEqual(out, "hello world");
|
||||
});
|
||||
test("readTuiTranscript honours wall-clock cap and returns partial text", async () => {
|
||||
const dir = mkdtempSync(`${tmpdir()}/tui-`);
|
||||
const p = `${dir}/s.jsonl`;
|
||||
writeFileSync(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
|
||||
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 }); // never terminal
|
||||
assertEqual(out, "partial");
|
||||
});
|
||||
test("readTuiTranscript throws when no text and cap elapses", async () => {
|
||||
const dir = mkdtempSync(`${tmpdir()}/tui-`);
|
||||
const p = `${dir}/missing.jsonl`; // file never appears
|
||||
let threw = false;
|
||||
try { await readTuiTranscript({ transcriptPath: p, wallclockMs: 200, pollMs: 50 }); }
|
||||
catch { threw = true; }
|
||||
assert(threw, "must throw on empty timeout");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `node test-features.mjs 2>&1 | grep -i "readTuiTranscript"`
|
||||
Expected: FAIL — export not defined.
|
||||
|
||||
- [ ] **Step 3: Minimal implementation** (append)
|
||||
|
||||
```js
|
||||
// Block until the session transcript is terminal (turn_duration / tool_use) or
|
||||
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
|
||||
// editors). Returns the latest assistant text. On cap with text, returns the
|
||||
// partial text; on cap with no text at all, throws.
|
||||
//
|
||||
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
|
||||
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
|
||||
export async function readTuiTranscript({ transcriptPath: p, wallclockMs = 120000, pollMs = 250 }) {
|
||||
const deadline = Date.now() + wallclockMs;
|
||||
let lastText = "";
|
||||
while (Date.now() < deadline) {
|
||||
if (existsSync(p)) {
|
||||
const events = parseTranscriptLines(readFileSync(p, "utf8"));
|
||||
lastText = extractLatestAssistantText(events) || lastText;
|
||||
if (events.some(isTerminalLine)) return lastText;
|
||||
}
|
||||
await sleep(pollMs);
|
||||
}
|
||||
if (lastText) return lastText;
|
||||
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `node test-features.mjs 2>&1 | grep -i "readTuiTranscript"`
|
||||
Expected: all 3 PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/tui/transcript.mjs test-features.mjs
|
||||
git commit -m "feat(tui): polling transcript reader with wall-clock cap (no quiescence)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PR-2 — Session driver (`lib/tui/session.mjs`)
|
||||
|
||||
tmux lifecycle + the validated submission recipe. Cannot be unit-tested without a live authenticated `claude`; tested by a live-only guarded suite that runs on PI231.
|
||||
|
||||
### Task 4: `reapStaleTuiSessions` (prefix-scoped reaper)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/tui/session.mjs`
|
||||
- Test: `test-features.mjs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (pure — no live claude; inject a fake tmux runner)
|
||||
|
||||
```js
|
||||
import { reapStaleTuiSessions, SESSION_PREFIX } from "./lib/tui/session.mjs";
|
||||
|
||||
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
|
||||
const killed = [];
|
||||
const fakeTmux = (args) => {
|
||||
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\nmisc\nocp-tui-cccc\n" };
|
||||
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
|
||||
return { status: 0, stdout: "" };
|
||||
};
|
||||
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
||||
assertEqual(SESSION_PREFIX, "ocp-tui-");
|
||||
assertEqual(n, 2);
|
||||
assertEqual(killed.join(","), "ocp-tui-aaaa,ocp-tui-cccc");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `node test-features.mjs 2>&1 | grep -i "reaper kills"`
|
||||
Expected: FAIL — module/export missing.
|
||||
|
||||
- [ ] **Step 3: Minimal implementation**
|
||||
|
||||
Create `lib/tui/session.mjs`:
|
||||
```js
|
||||
// TUI-mode session driver: hosts an interactive `claude` in tmux, submits one
|
||||
// serialized prompt, awaits the transcript reader, tears down. OCP-specific.
|
||||
//
|
||||
// Authority: claude CLI v2.1.158 interactive mode (no -p / no --output-format
|
||||
// => cc_entrypoint=cli). Submission recipe + dialog handling validated by spikes
|
||||
// T3/T6 on PI231. See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { transcriptPath, readTuiTranscript } from "./transcript.mjs";
|
||||
|
||||
export const SESSION_PREFIX = "ocp-tui-"; // per-proxy namespace (coexistence rule)
|
||||
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const defaultTmux = (args, opts = {}) => spawnSync(TMUX, args, { encoding: "utf8", ...opts });
|
||||
|
||||
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
|
||||
// OLP test instance's `olp-tui-*` sessions are never touched.
|
||||
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
|
||||
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
|
||||
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
|
||||
let killed = 0;
|
||||
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) {
|
||||
if (name.startsWith(SESSION_PREFIX)) { tmux(["kill-session", "-t", name]); killed++; }
|
||||
}
|
||||
return killed;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `node test-features.mjs 2>&1 | grep -i "reaper kills"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/tui/session.mjs test-features.mjs
|
||||
git commit -m "feat(tui): prefix-scoped session reaper (ocp-tui-* only)"
|
||||
```
|
||||
|
||||
### Task 5: `runTuiTurn` (boot → trust dialog → paste → Enter → read → teardown)
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/tui/session.mjs`
|
||||
- Test: `test-features.mjs` (live-only, guarded by `OCP_TUI_LIVE=1`)
|
||||
|
||||
- [ ] **Step 1: Write the live-only guarded test** (skipped in CI; run on PI231)
|
||||
|
||||
```js
|
||||
// Live-only: requires an authenticated interactive `claude`. Skipped unless OCP_TUI_LIVE=1.
|
||||
if (process.env.OCP_TUI_LIVE === "1") {
|
||||
test("runTuiTurn drives a real interactive turn and returns text", async () => {
|
||||
const { runTuiTurn } = await import("./lib/tui/session.mjs");
|
||||
const out = await runTuiTurn({
|
||||
prompt: "Reply with exactly the word PONG and nothing else.",
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
claudeBin: process.env.OCP_TUI_CLAUDE_BIN || "claude",
|
||||
home: process.env.HOME,
|
||||
cwd: `${process.env.HOME}/.ocp-tui/work`,
|
||||
wallclockMs: 120000,
|
||||
});
|
||||
assert(/PONG/i.test(out), `expected PONG, got: ${out.slice(0, 200)}`);
|
||||
});
|
||||
} else {
|
||||
test("runTuiTurn (live) — SKIPPED (set OCP_TUI_LIVE=1 on PI231 to run)", () => { assert(true); });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails** (on a box, with the flag)
|
||||
|
||||
Run (PI231): `OCP_TUI_LIVE=1 node test-features.mjs 2>&1 | grep -i "runTuiTurn"`
|
||||
Expected: FAIL — `runTuiTurn` not exported yet.
|
||||
|
||||
- [ ] **Step 3: Implementation** (append to `lib/tui/session.mjs`)
|
||||
|
||||
```js
|
||||
// Boot wait + dialog timing. Conservative defaults validated on PI231; env-tunable.
|
||||
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "3500", 10);
|
||||
const DIALOG_MS = parseInt(process.env.OCP_TUI_DIALOG_MS || "1200", 10);
|
||||
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10);
|
||||
|
||||
const shq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`; // single-quote for sh -c
|
||||
|
||||
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
|
||||
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
|
||||
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
|
||||
// belt-and-braces with --disallowedTools "mcp__*".
|
||||
function buildTuiCmd(claudeBin, model, sessionId) {
|
||||
return [
|
||||
shq(claudeBin),
|
||||
"--model", shq(model),
|
||||
"--session-id", sessionId,
|
||||
"--strict-mcp-config",
|
||||
"--disallowedTools", shq("mcp__*"),
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export async function runTuiTurn({
|
||||
prompt, model, claudeBin, home, cwd,
|
||||
wallclockMs = 120000, tmux = defaultTmux,
|
||||
}) {
|
||||
const sessionId = randomUUID();
|
||||
const tmuxName = SESSION_PREFIX + sessionId.slice(0, 8);
|
||||
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
||||
|
||||
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||
const promptFile = `${tmpDir}/prompt.txt`;
|
||||
writeFileSync(promptFile, prompt, { mode: 0o600 });
|
||||
|
||||
const env = { ...process.env, CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1" };
|
||||
delete env.CLAUDECODE; delete env.ANTHROPIC_API_KEY; delete env.ANTHROPIC_BASE_URL; delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
if (home) env.HOME = home;
|
||||
|
||||
try {
|
||||
// 1. Boot the interactive session inside tmux, in the dedicated scratch cwd.
|
||||
tmux(["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||
buildTuiCmd(claudeBin, model, sessionId)], { env });
|
||||
await sleep(BOOT_MS);
|
||||
|
||||
// 2. Answer the trust-folder dialog defensively. The seeded bypass flag (if any)
|
||||
// suppresses the *bypass-permissions* dialog but NOT the trust-folder dialog;
|
||||
// "1" = "Yes, proceed". Harmless if the dialog is absent (cwd already trusted).
|
||||
tmux(["send-keys", "-t", tmuxName, "1"]);
|
||||
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||
await sleep(DIALOG_MS);
|
||||
|
||||
// 3. Submit the prompt. Body is pasted via `"$(cat file)"` so the content never
|
||||
// touches the command line (no shell injection from prompt text), then a
|
||||
// SEPARATE Enter key event submits it (Ink #15553: literal "\n" in a paste
|
||||
// does not submit; the Enter key event does).
|
||||
spawnSync("sh", ["-c",
|
||||
`${shq(TMUX)} send-keys -t ${shq(tmuxName)} -- "$(cat ${shq(promptFile)})"`],
|
||||
{ env, encoding: "utf8" });
|
||||
await sleep(PASTE_SETTLE_MS);
|
||||
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||
|
||||
// 4. Read the answer from the native transcript.
|
||||
const tpath = transcriptPath(home || process.env.HOME, cwd, sessionId);
|
||||
return await readTuiTranscript({ transcriptPath: tpath, wallclockMs });
|
||||
} finally {
|
||||
// 5. Teardown — always. Kill the session, remove the temp prompt dir.
|
||||
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes** (PI231, live)
|
||||
|
||||
Run (PI231): `OCP_TUI_LIVE=1 node test-features.mjs 2>&1 | grep -i "runTuiTurn"`
|
||||
Expected: PASS — output contains `PONG`. Also confirm no orphan sessions: `tmux ls 2>/dev/null | grep ocp-tui- || echo "clean"` → `clean`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/tui/session.mjs test-features.mjs
|
||||
git commit -m "feat(tui): runTuiTurn — interactive session driver (boot/trust/paste/Enter/read/teardown)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PR-3 — Wiring into `server.mjs`
|
||||
|
||||
Gate TUI-mode behind one env flag. Default path (`CLAUDE_TUI_MODE` unset) stays byte-for-byte identical.
|
||||
|
||||
### Task 6: env consts + `streamStringAsSSE` DRY refactor
|
||||
|
||||
**Files:**
|
||||
- Modify: `server.mjs` (env consts near `:275`; refactor cache-replay block `:1524`–`:1539` into a helper near `:1023`)
|
||||
|
||||
- [ ] **Step 1: Add TUI env consts + import** (near the other `const ... = process.env...` at `server.mjs:258`–`:275`)
|
||||
|
||||
```js
|
||||
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
|
||||
|
||||
// TUI-mode (subscription-pool bridge). Opt-in; default OFF keeps stream-json path.
|
||||
// Authority: docs/adr/0007-tui-interactive-mode.md.
|
||||
const TUI_MODE = process.env.CLAUDE_TUI_MODE === "true";
|
||||
const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000", 10);
|
||||
const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extract the chunked-SSE-replay into a reusable helper** (near `completionResponse` at `:1023`)
|
||||
|
||||
```js
|
||||
// Replay a complete string as a chunked SSE stream (80 codepoints/chunk).
|
||||
// Extracted from the cache-hit replay block so TUI-mode streaming reuses it.
|
||||
function streamStringAsSSE(res, id, model, content) {
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
|
||||
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
|
||||
const CHUNK = 80;
|
||||
const codepoints = Array.from(content);
|
||||
for (let i = 0; i < codepoints.length; i += CHUNK) {
|
||||
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: codepoints.slice(i, i + CHUNK).join("") }, finish_reason: null }] });
|
||||
}
|
||||
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Point the cache-hit streaming replay (`:1524`–`:1539`) at the helper** (DRY — behavior identical)
|
||||
|
||||
Replace the inline block inside `if (stream) { ... }` of the cache hit with:
|
||||
```js
|
||||
if (stream) {
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
streamStringAsSSE(res, id, model, cached.response);
|
||||
return;
|
||||
} else {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the full suite to verify no regression**
|
||||
|
||||
Run: `node test-features.mjs 2>&1 | tail -3`
|
||||
Expected: all existing tests PASS (the refactor is behavior-preserving; cache-replay covered by existing D3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server.mjs
|
||||
git commit -m "refactor(server): extract streamStringAsSSE helper + add TUI env consts"
|
||||
```
|
||||
|
||||
### Task 7: `callClaudeTui` + dispatch gates
|
||||
|
||||
**Files:**
|
||||
- Modify: `server.mjs` (new `callClaudeTui` near `callClaude:735`; gates at the buffered dispatch `:1563`/`:1594` and streaming dispatch `:1551`)
|
||||
|
||||
- [ ] **Step 1: Add `callClaudeTui`** (near `callClaude`, after `:800`)
|
||||
|
||||
```js
|
||||
// TUI-mode upstream: drive an interactive claude session, return the assistant
|
||||
// text as a string — same contract as callClaude(), so all downstream
|
||||
// (singleflight, cache write-back, completionResponse) is unchanged.
|
||||
// System messages are rendered inline as [System] blocks by messagesToPrompt;
|
||||
// we deliberately do NOT pass --system-prompt in interactive mode to avoid any
|
||||
// flag that could perturb cc_entrypoint classification.
|
||||
function callClaudeTui(model, messages, conversationId, keyName) {
|
||||
const cliModel = MODEL_MAP[model] || model;
|
||||
const prompt = messagesToPrompt(messages); // includes system as [System] inline
|
||||
recordModelRequest(cliModel, prompt.length);
|
||||
return runTuiTurn({
|
||||
prompt, model: cliModel, claudeBin: CLAUDE,
|
||||
home: process.env.HOME, cwd: TUI_CWD, wallclockMs: TUI_WALLCLOCK_MS,
|
||||
}).then((text) => {
|
||||
recordModelSuccess(cliModel, 0);
|
||||
return text;
|
||||
}).catch((err) => {
|
||||
recordModelError(cliModel, false);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Gate the buffered dispatch** — at `server.mjs:1563`–`:1597`, replace the two `callClaude(...)` call sites (inside the singleflight closure and the cache-disabled fallback) with a selected upstream:
|
||||
|
||||
Add once, just before the `if (CACHE_TTL > 0 && req._cacheHash)` block (~`:1563`):
|
||||
```js
|
||||
const upstreamCall = TUI_MODE ? callClaudeTui : callClaude;
|
||||
```
|
||||
Then change `await callClaude(model, messages, conversationId, req._authKeyName)` → `await upstreamCall(model, messages, conversationId, req._authKeyName)` at **both** sites (`:1572` and `:1594`).
|
||||
|
||||
- [ ] **Step 3: Gate the streaming dispatch** — at `server.mjs:1551`–`:1553`, branch TUI streaming to buffer-then-replay:
|
||||
|
||||
```js
|
||||
if (stream) {
|
||||
if (TUI_MODE) {
|
||||
// TUI has no token stream; buffer the turn, write-back to cache, replay as chunked SSE.
|
||||
const t0Usage = Date.now();
|
||||
try {
|
||||
const content = await callClaudeTui(model, messages, conversationId, req._authKeyName);
|
||||
if (CACHE_TTL > 0 && req._cacheHash) {
|
||||
try { setCachedResponse(req._cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||
}
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
streamStringAsSSE(res, id, model, content);
|
||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch {}
|
||||
return;
|
||||
} catch (err) {
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {}; return; }
|
||||
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
||||
}
|
||||
}
|
||||
// Default: real stream-json streaming, unchanged.
|
||||
return callClaudeStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName, cacheHash: req._cacheHash });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify default path is untouched + TUI path selected only by flag**
|
||||
|
||||
Run: `CLAUDE_TUI_MODE= node -e "process.env.CLAUDE_TUI_MODE; import('./server.mjs')" 2>&1 | head -1 || true`
|
||||
Then the regression suite: `node test-features.mjs 2>&1 | tail -3`
|
||||
Expected: all PASS (no test sets `CLAUDE_TUI_MODE`, so `upstreamCall === callClaude` and streaming uses `callClaudeStreaming` — identical to today).
|
||||
|
||||
Live end-to-end (PI231, after Task 8 setup): with `CLAUDE_TUI_MODE=true` start OCP and `curl` both `stream:false` and `stream:true`:
|
||||
```bash
|
||||
curl -s localhost:3456/v1/chat/completions -H "Authorization: Bearer <key>" \
|
||||
-d '{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":"say PONG"}]}' | head
|
||||
```
|
||||
Expected: a normal OpenAI completion whose content contains `PONG`. Cross-check on PI231 that the spawned `claude` had no `-p`/`--output-format` (`ps -ef | grep claude`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server.mjs
|
||||
git commit -m "feat(tui): gate interactive TUI upstream behind CLAUDE_TUI_MODE (buffered + streaming)"
|
||||
```
|
||||
|
||||
### Task 8: reaper hook at boot + ADR + README + CHANGELOG
|
||||
|
||||
**Files:**
|
||||
- Modify: `server.mjs` (boot block — call `reapStaleTuiSessions()` once on startup when `TUI_MODE`)
|
||||
- Create: `docs/adr/0007-tui-interactive-mode.md`
|
||||
- Modify: `README.md`, `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1: Reaper on boot** (in the server start/`listen` block)
|
||||
|
||||
```js
|
||||
if (TUI_MODE) {
|
||||
try { const n = reapStaleTuiSessions(); if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n }); } catch {}
|
||||
console.log(` TUI-mode: ON (interactive claude → cc_entrypoint=cli). cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write ADR 0007** — `docs/adr/0007-tui-interactive-mode.md`
|
||||
|
||||
Context: 2026-06-15 billing split routes by `cc_entrypoint`; `-p`/`--output-format` ⇒ `sdk-cli` (Agent SDK credit pool, ~$20 on Pro = unusable). Decision: opt-in interactive driver ⇒ `cli` (subscription pool). Authority: spec §1/§4, claude v2.1.158. Scope: A-path single-user; MCP hard-disabled via `--strict-mcp-config`. Kill-switch: unset `CLAUDE_TUI_MODE` → stream-json path restored. Consequences: no token streaming (buffered+replayed); grey-area, billing unmeasurable until 6/15; reaper + tmux-prefix coexistence rules.
|
||||
|
||||
- [ ] **Step 3: README** — add `CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD` to the env-var table; add a "Subscription-pool (TUI) mode" section (what it is, opt-in, the 6/15 rationale, no-streaming caveat, the one-time `mkdir -p ~/.ocp-tui/work` + tmux dependency, and the `CLAUDE_TUI_MODE` unset kill-switch).
|
||||
|
||||
- [ ] **Step 4: CHANGELOG** — Unreleased: `feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool); default stream-json path unchanged.`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server.mjs docs/adr/0007-tui-interactive-mode.md README.md CHANGELOG.md
|
||||
git commit -m "feat(tui): boot reaper + ADR 0007 + README + CHANGELOG (TUI-mode docs)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration & canary (post-implementation, on PI231)
|
||||
|
||||
1. Stop the OLP test instance (`:4567`) — clean shared OAuth + no tmux collision.
|
||||
2. `git clone`/checkout this branch on PI231, `mkdir -p ~/.ocp-tui/work`, start OCP on `:3456` with `CLAUDE_TUI_MODE=true`.
|
||||
3. Run the live driver suite: `OCP_TUI_LIVE=1 node test-features.mjs`.
|
||||
4. End-to-end `curl` (buffered + streaming) through OCP; confirm spawned `claude` carries no `-p`/`--output-format`.
|
||||
5. **Pre-6/15 deliverable = here.** Billing measurement waits for 6/15; document the kill-switch (unset `CLAUDE_TUI_MODE`).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (against spec + the OCP-first execution review)
|
||||
|
||||
- **Spec coverage:** transcript path formula (§4 → Task 1), parsing/terminal/extract (§4 → Task 2), polling reader + wall-clock cap + no-quiescence (§4.3 → Task 3), submission recipe file→paste→Enter (§5/T3 → Task 5), trust-dialog handling (§5.2 → Task 5), MCP disable `--strict-mcp-config` (§5.2/T6 → Tasks 5 & buildTuiCmd), string-contract drop-in (→ Tasks 6–7), kill-switch + default-path-sacred (→ Task 7 Step 4), coexistence prefix + reaper (→ Tasks 4 & 8). ✅
|
||||
- **Review findings folded:** OCP-first string match (Task 7); no ephemeral-home, real-home + scratch cwd (scope §); reader-only sharing, driver forked (file table); tmux prefix + scoped reaper + never-both-on-OAuth (Task 4, Integration §1); `TIMEOUT=600000 > 120s` cap verified (no SIGKILL-mid-turn); `--strict-mcp-config` added (Task 5); provenance jaekwon-park (Why §). OLP-sync deferred. ✅
|
||||
- **Placeholder scan:** none — every code step carries real code; every run step an exact command + expected output. ✅
|
||||
- **Type consistency:** `runTuiTurn`/`reapStaleTuiSessions`/`SESSION_PREFIX` exported in Task 4–5 match imports in Task 6–8; `streamStringAsSSE(res, id, model, content)` defined Task 6, used Tasks 6–7; `callClaudeTui(model, messages, conversationId, keyName)` mirrors `callClaude`'s signature. ✅
|
||||
- **Open item for integration:** confirm on PI231 that the seeded `~/.claude.json` is unnecessary for real-home A (onboarding already complete); if a bypass-permissions dialog *does* appear in real home, add a one-line seed step (`bypassPermissionsModeAccepted:true`) — but the driver already answers the trust dialog defensively, so the turn still completes.
|
||||
+2
-2
@@ -31,7 +31,7 @@
|
||||
- [ ] **Step 1: Install better-sqlite3**
|
||||
|
||||
```bash
|
||||
cd /Users/taodeng/.openclaw/projects/claude-proxy
|
||||
cd $HOME/.openclaw/projects/claude-proxy
|
||||
npm install better-sqlite3
|
||||
```
|
||||
|
||||
@@ -1085,7 +1085,7 @@ git commit -m "docs: add LAN mode documentation and family sharing guide"
|
||||
- [ ] **Step 1: Start OCP in LAN mode with multi-key auth**
|
||||
|
||||
```bash
|
||||
cd /Users/taodeng/.openclaw/projects/claude-proxy
|
||||
cd $HOME/.openclaw/projects/claude-proxy
|
||||
CLAUDE_BIND=0.0.0.0 CLAUDE_AUTH_MODE=multi OCP_ADMIN_KEY=test-admin-123 node server.mjs &
|
||||
sleep 3
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,193 @@
|
||||
# Design: SSE Heartbeat on Streaming Path
|
||||
|
||||
**Issue:** [#47](https://github.com/dtzp555-max/ocp/issues/47)
|
||||
**Date:** 2026-04-25
|
||||
**Status:** Draft (awaiting maintainer approval)
|
||||
**Target version:** v3.12.0 (minor — new opt-in feature + env var)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Add an opt-in idle-watchdog heartbeat to OCP's streaming response. When enabled, OCP emits an SSE comment frame (`: keepalive\n\n`) whenever the stream has been idle for a configurable interval. Timer resets on every real frame. Covers both pre-first-byte and mid-stream silent windows. Default **disabled**. Zero behavior change for existing deployments on upgrade.
|
||||
|
||||
Companion tweak: `X-Accel-Buffering: no` response header added to both SSE header sites so heartbeats survive nginx-default proxy buffering.
|
||||
|
||||
## Motivation
|
||||
|
||||
Per [#47](https://github.com/dtzp555-max/ocp/issues/47): when `claude -p` takes a long time to respond (processing large contexts, or executing long tool calls that pause the token stream for 30s–5min), OCP emits no bytes to the downstream client for up to 600s. The caller cannot distinguish "slow but alive" from "hung." A recent incident reported 15 consecutive 600s silent waits cascading into a 2-hour downstream gateway outage.
|
||||
|
||||
SSE heartbeats at the application layer let a caller observe liveness without OCP introducing any new client-killing timer.
|
||||
|
||||
## Key decisions (with rationale)
|
||||
|
||||
Six decisions were fixed during brainstorming. Each is presented as "decision → rationale" so future readers can judge whether a decision is still load-bearing.
|
||||
|
||||
### D1. Coverage: whole-stream with idle-watchdog reset-on-byte
|
||||
|
||||
A per-request timer starts when SSE headers are written and resets on every real `sendSSE()` call. Heartbeat fires only during genuine idle windows — never during healthy token bursts.
|
||||
|
||||
**Rationale.** The `server.mjs:8-10` comment documents Claude tool-use pauses as "30s-5min pauses in the token stream." This means silent windows happen both pre-first-byte AND mid-stream. Covering only one of the two windows means re-opening this issue in three months when a different user reports the uncovered case. The reset-on-byte discipline is a ~2-LOC discipline (`clearTimeout` + `setTimeout` inside `sendSSE`) and is the standard "idle watchdog" pattern.
|
||||
|
||||
### D2. Frame format: SSE comment (`: keepalive\n\n`)
|
||||
|
||||
**Rationale.** Per SSE spec / MDN, lines starting with `:` are comments and MUST be ignored by conforming parsers. This is the maximally inert shape we can emit. Alternatives considered:
|
||||
- `event: ping` named event — Anthropic's own Messages API uses this, but on an OpenAI-compatible surface, downstream clients don't recognize that event name, so risk of client confusion is higher.
|
||||
- Empty-delta JSON chunk — parser-safe on OpenAI-compatible clients but burns an event id and is less observably "a heartbeat" in logs.
|
||||
|
||||
The known risk with SSE comments is that some SDKs crash on empty comment frames (`openai-go` issue #556). The default-disabled posture (D3) mitigates this: users who opt in can also verify their client tolerates comments. If the comment format turns out to be broken in the wild for common OCP callers, we can add a second format behind `CLAUDE_HEARTBEAT_FORMAT` in a follow-up — **not in this PR**.
|
||||
|
||||
### D3. Default disabled
|
||||
|
||||
`CLAUDE_HEARTBEAT_INTERVAL=0` (meaning disabled) is the default when the env var is unset. Any positive integer enables at that ms interval.
|
||||
|
||||
**Rationale.** Existing deployments see zero byte-shape change on upgrade. Users who have pingvvino's problem set the env var and get the fix. This is a reversible posture: once field evidence shows comment frames are safe across current OCP callers, a future minor can flip the default to `30000`.
|
||||
|
||||
### D4. Header relocation: `ensureHeaders()` moves earlier
|
||||
|
||||
Currently `ensureHeaders()` is invoked inside the `proc.stdout` handler, so SSE headers are written only on first byte from claude CLI. This PR moves the `ensureHeaders()` call to immediately after successful `spawn()` return.
|
||||
|
||||
**Rationale.** You cannot emit SSE frames before sending SSE headers. For heartbeats to cover the pre-first-byte silent window (pingvvino's "processing large contexts" case), headers must be sent earlier. Behavioral consequence: the narrow "spawn succeeded but subprocess erroneously died before any byte" branch — currently a JSON error response — becomes an SSE error event + `[DONE]` + `res.end()`. Pre-spawn errors (before `spawn()`) still return JSON, unchanged. The affected path is rare (claude CLI either spawns or it doesn't).
|
||||
|
||||
### D5. Transport-layer buffering hint: `X-Accel-Buffering: no`
|
||||
|
||||
Added to both SSE response header sites (real-streaming `ensureHeaders()` and the cache-hit simulated-streaming header write).
|
||||
|
||||
**Rationale.** nginx (and many LBs / Cloudflare) default to buffering proxied responses. Without this header, heartbeat bytes may accumulate in an upstream buffer and never reach the client, defeating the feature silently. This header is a nginx-specific hint; other stacks ignore it. 1 line per site, 2 sites total, indistinguishable-from-no-op for stacks that don't use it.
|
||||
|
||||
### D6. Observability: single log line per affected request
|
||||
|
||||
On the first heartbeat fire within a request, emit a structured log entry (`logEvent("info", "heartbeat_active", { session, intervalMs })`). No log spam for subsequent fires in the same request.
|
||||
|
||||
**Rationale.** For a first-mover feature the question "did the heartbeat actually work for that hung request?" needs to be answerable from the existing `/logs` endpoint alone, without external tooling. One line per affected request gives proof-of-life without polluting the log stream during healthy traffic (where the timer resets and never fires).
|
||||
|
||||
## Architecture
|
||||
|
||||
Single-file change in `server.mjs`. One new helper plus small patches to existing sites.
|
||||
|
||||
```
|
||||
startHeartbeat(res, intervalMs, sessionId) → { reset(), stop() }
|
||||
if intervalMs <= 0: return no-op handle
|
||||
internal: handle = setTimeout(intervalMs, onFire)
|
||||
onFire():
|
||||
res.write(": keepalive\n\n")
|
||||
if !hasFired: logEvent("info", "heartbeat_active", { session, intervalMs }); hasFired = true
|
||||
handle = setTimeout(intervalMs, onFire)
|
||||
reset(): clearTimeout(handle); handle = setTimeout(intervalMs, onFire)
|
||||
stop(): clearTimeout(handle); handle = null
|
||||
```
|
||||
|
||||
Handle created once per streaming request. `sendSSE()` calls `heartbeat.reset()` before its `res.write()`. All exit paths call `heartbeat.stop()`.
|
||||
|
||||
## Components and LOC budget
|
||||
|
||||
| Location | Change | Est LOC |
|
||||
|---|---|---|
|
||||
| `server.mjs` env block | Parse `CLAUDE_HEARTBEAT_INTERVAL` | 1 |
|
||||
| `server.mjs` new `startHeartbeat()` function | Per spec above | 12 |
|
||||
| `server.mjs:565-579` `ensureHeaders()` | Add `"X-Accel-Buffering": "no"` to header object | 1 |
|
||||
| `server.mjs:~548-554` streaming entry | Move `ensureHeaders()` call to post-spawn; create heartbeat handle | 3 (net) |
|
||||
| `server.mjs:669` `sendSSE()` | Accept optional `hb` param; call `hb?.reset()` before `res.write()` | 2 |
|
||||
| `server.mjs` streaming exit hooks (proc 'close', proc 'error', req 'close') | Call `hb.stop()` | 3 |
|
||||
| `server.mjs:~610-611` pre-first-byte error branch | If headers sent, SSE error + `[DONE]` + `res.end()` instead of JSON | 3 |
|
||||
| `server.mjs:1171` cache-hit header write | Add `"X-Accel-Buffering": "no"` | 1 |
|
||||
| `README.md` env var table | One new row | 1 |
|
||||
| `README.md` new short section | "Streaming heartbeat" paragraph + nginx note | 5 |
|
||||
| `CHANGELOG.md` new v3.12.0 entry | Features + Config additions | 4 |
|
||||
| `package.json` + `ocp-plugin/package.json` + `ocp-plugin/openclaw.plugin.json` | Version bump 3.11.1 → 3.12.0 | 3 |
|
||||
|
||||
**Estimated total: ~40 lines.** This is 5–10 lines over the 25–35 budget set in brainstorming. The overshoot is accounted for by D4 (header relocation + SSE error branch) and D5 (X-Accel-Buffering at two sites). Reviewer may reject if actual code lands materially larger than ~45 lines of server.mjs code excluding docs and version files.
|
||||
|
||||
## Data flow (streaming request, heartbeat enabled)
|
||||
|
||||
1. Client sends `POST /v1/chat/completions` with `stream=true`.
|
||||
2. Cache miss → `callClaudeStreaming()` invoked.
|
||||
3. `spawn()` claude subprocess succeeds → `ensureHeaders(res)` writes SSE headers including `X-Accel-Buffering: no`.
|
||||
4. `const hb = startHeartbeat(res, HEARTBEAT_INTERVAL, sessionId)` arms the watchdog (no-op if interval is 0).
|
||||
5. Watchdog ticks after `HEARTBEAT_INTERVAL` ms of idle. On fire: `: keepalive\n\n` out; first-fire logs once; re-arm.
|
||||
6. Every real `sendSSE()` write calls `hb.reset()` — cancels and re-arms timer.
|
||||
7. Healthy token bursts → heartbeat never fires.
|
||||
8. Tool-use pause → timer elapses → heartbeat fires → client stays alive → re-arm → repeat until next chunk arrives.
|
||||
9. On proc 'close' (success / `[DONE]`) / proc 'error' / req 'close' / `CLAUDE_TIMEOUT` kill → `hb.stop()`.
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Client disconnect mid-heartbeat.** `req.on('close')` fires → `hb.stop()`. Any in-flight write becomes a no-op / emits `'error'` on `res`; existing code already tolerates this.
|
||||
- **`CLAUDE_TIMEOUT` (600s) fires mid-request.** Existing timeout handler SIGTERM's subprocess → proc 'close' → `hb.stop()`. This PR does **not** fix the separate issue that the current timeout path does not `res.end()` or emit an SSE error frame; that is documented as a separate issue.
|
||||
- **`spawn()` throws synchronously.** Heartbeat never started. Existing JSON error response unchanged.
|
||||
- **`spawn()` succeeds, subprocess errors before first byte.** Headers have already been written (per D4). Branch emits an SSE error event + `[DONE]` + `res.end()` instead of a JSON error. Documented behavior change.
|
||||
|
||||
## Testing plan
|
||||
|
||||
OCP has no unit test framework beyond `test-features.mjs`. Verification is manual + cloud-backed.
|
||||
|
||||
### Manual local smoke test
|
||||
|
||||
1. Set `CLAUDE_HEARTBEAT_INTERVAL=5000` (5s for easy observation) and start OCP.
|
||||
2. Issue a streaming completion with a prompt that triggers a tool-use pause (e.g., ask for a large file read or long reasoning):
|
||||
```
|
||||
curl -N http://localhost:3456/v1/chat/completions \
|
||||
-H "Authorization: Bearer $OCP_KEY" \
|
||||
-d '{"model":"claude-opus-4-7","stream":true,
|
||||
"messages":[{"role":"user","content":"read the attached 200KB text and summarize"}]}'
|
||||
```
|
||||
3. Confirm `: keepalive` comment lines appear in the raw response during the pause, at ~5s cadence.
|
||||
4. Confirm `/logs` shows exactly one `heartbeat_active` entry for the request.
|
||||
5. With `CLAUDE_HEARTBEAT_INTERVAL=0` (default), confirm no heartbeats and no log line.
|
||||
|
||||
### Cloud-backed test run (pre-push, required)
|
||||
|
||||
Per project feedback, tests must pass before any push to the public repo. Options, in preference order:
|
||||
|
||||
1. **GitHub Actions** — add a temporary smoke workflow (or piggyback on an existing one) that runs `test-features.mjs` against a sandboxed claude mock. Not viable if `test-features.mjs` requires a real claude CLI auth.
|
||||
2. **Remote Linux test host via cc-chat handoff** — push feature branch, instruct a cloud machine with claude CLI installed to run the manual steps above, capture output, return verdict via cc-chat.
|
||||
3. **Docker/compose locally** — if the maintainer has Docker available, `docker-compose.yml` is present and can be extended.
|
||||
|
||||
The implementation subagent and the reviewer subagent MUST include the chosen verification evidence in the PR body (command + output excerpt, sanitized of any identifiers) before the PR is opened for merge review.
|
||||
|
||||
### Downstream-parser compatibility
|
||||
|
||||
Before merging, verify at least one real downstream client (OCP's own `ocp-connect`, plus — if feasible — the current OpenClaw gateway) does not crash on comment frames. If a target client crashes, either (a) adjust the default to 0 and document the incompatibility, or (b) scope a follow-up PR for `CLAUDE_HEARTBEAT_FORMAT=empty-delta` as an alternative.
|
||||
|
||||
## Privacy preflight (for public-repo push)
|
||||
|
||||
Before `gh pr create` or any `git push` to `dtzp555-max/ocp`, run the following scan on the full diff (`git diff origin/main...HEAD`):
|
||||
|
||||
1. **Use OCP's PR template privacy self-check** — the `.github/PULL_REQUEST_TEMPLATE.md` Privacy self-check section is the canonical list. Fill every checkbox.
|
||||
2. **Run `.gitleaks.toml` via gitleaks if available.**
|
||||
3. **Manual grep on the diff** for these patterns (sanitize any hits before commit):
|
||||
- Personal names in any language (check commit-author trailers especially — `Co-Authored-By` lines have leaked names before).
|
||||
- Email addresses beyond automated placeholders (`noreply@*`).
|
||||
- Local paths like `/Users/<name>/`, `/home/<name>/`, `C:\Users\<name>\` — replace with `$HOME/` or `~/`.
|
||||
- Machine hostnames — use role-based names or generic descriptors.
|
||||
- IPs, internal URLs, tailnet names.
|
||||
4. **Log samples and test output** pasted into spec / README / CHANGELOG / PR body must be sanitized pre-paste, not pre-push. This spec doc itself was drafted under this discipline.
|
||||
|
||||
Historical reference: PR #43 / postmortem #44 (2026-04-22) scrubbed a prior leak and established the current apparatus. The user has explicitly flagged this as a scar to avoid re-treading for this PR.
|
||||
|
||||
## Scope lock (out of scope)
|
||||
|
||||
- `server.mjs:480-489` `CLAUDE_TIMEOUT` dangling-client behavior (no `res.end()` / no SSE error frame on timeout kill) — **will be filed as a separate issue** before this PR opens.
|
||||
- Issue #41 (handleSessionFailure deletes on resume only) — separate.
|
||||
- Issue #42 (SESSION_TTL and `lastUsed` interaction) — separate.
|
||||
- Any new first-byte / idle / adaptive-tier timeout logic — explicitly forbidden per the v3.3 lesson (`server.mjs:8-11`, commit 3843ec8).
|
||||
- Non-streaming chat path — no HTTP-level fix possible per [#47](https://github.com/dtzp555-max/ocp/issues/47)'s own conclusion.
|
||||
- Named-event format (`event: ping`) or empty-delta JSON chunk variants — possible follow-up PR if field evidence shows comment frames break real clients.
|
||||
- Changes to `CLAUDE_TIMEOUT` default — unchanged (stays 600s).
|
||||
- Circuit breaker revival — explicitly forbidden.
|
||||
|
||||
## ALIGNMENT.md disposition
|
||||
|
||||
`cli.js` does not itself emit SSE heartbeat frames — claude CLI speaks newline-delimited JSON to stdout, not SSE. SSE is an OCP-owned translation layer. Per `ALIGNMENT.md` Rule 2 / `AGENTS.md` ("OCP forwards, observes, and multiplexes traffic that cli.js already emits"): heartbeats are a translation-layer response-shaping concern, not a new endpoint and not a behavior mimicry. The PR body will state this explicitly in the `cli.js` citation checkbox and reference this design doc.
|
||||
|
||||
## IDR (Iron Rule 11) disposition
|
||||
|
||||
Single PR. Scope is one feature (SSE heartbeat) × one layer (streaming response formatting) × one severity (minor opt-in addition). Release-kit companion files (version bump, CHANGELOG, README) are bundled with the code change per the explicit Iron Rule 11 example ("版本 bump 相关的小改动 + README + CHANGELOG 可以同 PR"). The separate filings for the dangling-client bug (`server.mjs:480-489`) and any follow-up heartbeat format variants are IDR-compliant — each lands as its own PR.
|
||||
|
||||
## Related
|
||||
|
||||
- Issue: [#47](https://github.com/dtzp555-max/ocp/issues/47)
|
||||
- Prior timeout scar: commit 3843ec8 (v3.3.0 "simplify timeout to single CLAUDE_TIMEOUT")
|
||||
- Prior privacy scar: PR [#43](https://github.com/dtzp555-max/ocp/pull/43) / postmortem [#44](https://github.com/dtzp555-max/ocp/issues/44)
|
||||
- Constitution: `ALIGNMENT.md`
|
||||
- Project instructions: `AGENTS.md`, `CLAUDE.md`
|
||||
@@ -0,0 +1,147 @@
|
||||
# Design: Response Cache Upgrade (Per-Key Isolation, cache_control Bypass, Chunked Stream Replay, Singleflight)
|
||||
|
||||
**Date:** 2026-05-07
|
||||
**Status:** Draft (awaiting maintainer approval)
|
||||
**Target version:** v3.13.0 (minor — internal correctness/concurrency improvements; no new public env vars or endpoints)
|
||||
**Driving ADR:** [ADR 0005 — No Multi-Provider](../../adr/0005-no-multi-provider.md), decision §3 ("Cache improvements are in scope")
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
OCP already has a response cache (`keys.mjs:296` `cacheHash` / `keys.mjs:311` `getCachedResponse` / `keys.mjs:324` `setCachedResponse`), wired into the proxy core at `server.mjs:1220` (non-streaming path read), `server.mjs:1227` (cache-hit-on-streaming-request replay), and `server.mjs:683` (streaming write-back). Today it has four functional gaps. This PR pair closes all four, in two minimum-reviewable units, **without changing the public API surface**.
|
||||
|
||||
| Gap | Impact today | Fix lands in |
|
||||
|---|---|---|
|
||||
| All keys share one cache pool | Key A's cache hit can leak Key B's prompt response | PR-A |
|
||||
| Anthropic `cache_control` markers not detected | OCP cache may interfere with Anthropic prompt caching that the user explicitly requested | PR-A |
|
||||
| Stream cache hit replays whole content in one SSE chunk | Downstream renders all-at-once; some SDKs misbehave on huge single deltas | PR-A |
|
||||
| Concurrent identical cache misses all spawn `cli.js` independently | Cache stampede: N requests → N spawns → N billable calls | PR-B |
|
||||
|
||||
---
|
||||
|
||||
## Constitutional alignment (ALIGNMENT.md)
|
||||
|
||||
**`cli.js` does not perform response caching at the proxy layer.** The OCP response cache is a value-add operation that exists only inside OCP, between the wire (clients ↔ OCP) and the spawn (OCP ↔ `cli.js`). It does not introduce, rename, or alter any endpoint, header, request field, or response field that `cli.js` emits or expects. Cache hits return content byte-identical to what `cli.js` returned on the original miss, with the same `chat.completion` / `chat.completion.chunk` shape — **no client-observable wire shape change**.
|
||||
|
||||
This PR pair extends the existing cache (introduced in earlier commits) without expanding its surface. No new endpoints. No new headers. No new env vars exposed publicly (we add internal counters readable via the existing `/cache/stats` endpoint, but the response shape only gains numeric fields, not new structural fields).
|
||||
|
||||
Per Rule 1 / Rule 5: every commit body in this PR pair will state the absence of `cli.js` reference explicitly and justify scope under Rule 2's value-add carve-out for non-wire-affecting proxy operations.
|
||||
|
||||
---
|
||||
|
||||
## Key decisions (with rationale)
|
||||
|
||||
### D1. Per-key isolation via hash input, not schema column
|
||||
|
||||
`cacheHash` gains an optional `keyId` input. Distinct `keyId` values produce distinct hashes for the same prompt, so SQLite-level isolation falls out for free without a schema change.
|
||||
|
||||
**Rationale.** Adding a `key_id` column to `response_cache` requires either (a) dropping the existing `hash UNIQUE` index and replacing with a composite `(hash, key_id) UNIQUE`, which SQLite cannot do via plain `ALTER TABLE` and would require a table-rebuild migration, or (b) tolerating duplicate `hash` rows, which contradicts the existing schema comment and breaks `setCachedResponse`'s `ON CONFLICT(hash)` upsert clause.
|
||||
|
||||
The hash-input approach is reversible (we can switch to a schema column later if analytics across keys becomes a real need) and zero-risk on the SQL plane. The trade-off — losing the ability to query "which keys have cached this prompt?" — has no current consumer.
|
||||
|
||||
**Hash input format.** `cacheHash` prepends a version tag and key tag before the existing inputs:
|
||||
|
||||
```
|
||||
v2|k:<keyId or "anon">|<model>|...rest as today
|
||||
```
|
||||
|
||||
The `v2` prefix means existing v1-format rows in the cache table no longer hash-match any new request. They are abandoned, not deleted; the existing TTL-based `clearCache(CACHE_TTL)` cleanup interval at `server.mjs:185` reaps them within one TTL window. **No migration step is needed.** This is acceptable because the cache is by definition ephemeral and best-effort.
|
||||
|
||||
**Anonymous fallback.** When the request has no authenticated key (`req._authKeyId === undefined`), `keyId` is `"anon"`. Anonymous-mode users (PROXY_ANONYMOUS_KEY or no auth) share one anonymous pool, which preserves the only legitimate today-multi-user use case (a household running OCP without per-user keys). If this becomes a problem we can add per-IP scoping later, but anonymous-pool sharing is acceptable for v1 because anonymous mode is fundamentally a trust-everyone-on-LAN posture.
|
||||
|
||||
### D2. `cache_control` bypass: detect anywhere, skip OCP cache entirely
|
||||
|
||||
If any element in `messages` (top-level or nested in `content` arrays) carries a `cache_control` field, OCP sets `req._cacheHash = null` and skips both lookup and write-back.
|
||||
|
||||
**Rationale.** Anthropic's [prompt caching](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching) is opt-in by client-side annotation. A user who annotates `cache_control: { type: "ephemeral" }` is explicitly requesting that *Anthropic's* cache serve the call (and is paying the reduced cache-read pricing). Layering OCP's response cache on top in this case is wrong on two counts:
|
||||
|
||||
1. The user's intent is "cache at provider, not at proxy." OCP overruling that intent silently is the same drift family as the 2026-04-11 incident — proxy invents behavior the upstream surface doesn't request.
|
||||
2. OCP cache hits would make `usage.cache_read_input_tokens` (the client-observable signal that prompt caching worked) appear inconsistent — sometimes present, sometimes absent — depending on whether OCP cached.
|
||||
|
||||
Detection is purely structural: walk `messages`, for each `m` check `m.cache_control` (rare top-level form) and if `m.content` is an array, check each part. No semantic interpretation; if the field is present, we bypass.
|
||||
|
||||
**Implementation site.** A small helper `hasCacheControl(messages)` exported from `keys.mjs`, called in `handleChatCompletions` immediately before the existing `cacheHash` call. If it returns true, we skip the cache-lookup branch entirely.
|
||||
|
||||
### D3. Chunked stream replay (80 chars/chunk, no artificial delay)
|
||||
|
||||
Today's cache-hit-on-streaming-request branch (`server.mjs:1227–1237`) sends the entire cached content in a single `delta.content` chunk. This works for spec-compliant SSE clients but visibly degrades the UX (no incremental render) and has tripped at least one buggy SDK in the wild that assumes deltas are small.
|
||||
|
||||
The fix splits cached content into ~80-character substrings, each sent as a separate `chat.completion.chunk` SSE event. **No artificial delay between chunks** — they ship as fast as `res.write` accepts. This preserves OCP's "ship as fast as possible" disposition; we are simulating *the chunk shape* of streaming, not the *latency*.
|
||||
|
||||
**Why 80 chars?** Compromise: small enough that even a multi-paragraph cached response yields >5 chunks (visible incremental render), large enough that even a 4 KB response only produces 50 chunks (not 4000 single-char events). Tunable later via internal constant; not exposed as env var per scope-creep avoidance.
|
||||
|
||||
**Boundary safety.** UTF-8 multibyte characters: we slice by `Array.from(content)` (so each iteration step is a full code point) and group every 80 code points. This avoids producing invalid UTF-8 mid-character.
|
||||
|
||||
### D4. Singleflight stampede protection: in-process Map, all-or-nothing failure
|
||||
|
||||
`keys.mjs` exports `singleflight(hash, fn)`. An in-memory `Map<hash, Promise>` deduplicates concurrent identical cache-miss flows. The first request executes `fn()`; concurrent requests with the same hash receive the same promise. When the promise settles (resolve or reject), the map entry is deleted.
|
||||
|
||||
**Rationale (single-process scope).** OCP runs as a single Node.js process per host. A `Map` is sufficient. Adding Redis or another shared store would be the start of a multi-instance evolution, which is out of scope per ADR 0005 (OCP is a personal power tool, not a horizontally-scaled SaaS).
|
||||
|
||||
**All-or-nothing failure semantics.** When the leader's `fn()` rejects, all followers receive the same rejection. The alternative — letting followers retry independently after a leader failure — risks N retries of an already-broken upstream, which is exactly what stampede protection was meant to prevent. Followers can retry at the *next* request, with idle backoff handled by the client. This matches Go's `golang.org/x/sync/singleflight` reference behavior.
|
||||
|
||||
**Streaming caveat.** Singleflight wraps the *non-streaming* code path only in PR-B. For streaming, deduplicating concurrent identical streaming requests is materially harder (we'd need to fan out one upstream stream to N downstream connections in real time, with backpressure). It's also a less common case (cache stampedes typically come from non-streaming batch jobs hitting the proxy in parallel). Streaming dedup is **explicitly out of scope** for this PR pair; leave a TODO comment in `callClaudeStreaming` for a future ticket.
|
||||
|
||||
**Map size unboundedness.** In normal operation the map is empty most of the time (entries delete on Promise settlement). Pathological case: an upstream call that hangs forever leaks one Map entry per stuck request. The existing `TIMEOUT` guard on `callClaude` (server.mjs spawn timeout) bounds this — the Promise will reject (timeout) within `TIMEOUT` ms, and the entry clears. No additional sweep needed.
|
||||
|
||||
---
|
||||
|
||||
## PR boundaries
|
||||
|
||||
### PR-A — Foundation (D1 + D2 + D3)
|
||||
|
||||
**Files touched:**
|
||||
- `keys.mjs`: extend `cacheHash` with optional `keyId`/version prefix; add `hasCacheControl(messages)` helper
|
||||
- `server.mjs`: pass `req._authKeyId` to `cacheHash`; check `hasCacheControl` and bypass; chunk cache-hit replay at line 1227–1237
|
||||
- `test-features.mjs`: add cases for keyId isolation, cache_control bypass, chunked replay shape
|
||||
|
||||
**LOC budget:** ~80 production + ~50 test
|
||||
**Risk:** Low — all changes are additive or guard-clause; existing cache behavior preserved when `keyId` defaults to "anon" and no `cache_control` present.
|
||||
**Backward compat:** v1-format hashes naturally orphan; TTL cleanup reaps within one window; no migration script.
|
||||
|
||||
### PR-B — Concurrency (D4)
|
||||
|
||||
**Files touched:**
|
||||
- `keys.mjs`: add `singleflight(hash, fn)` and `getInflightStats()` exports
|
||||
- `server.mjs`: wrap non-streaming cache-miss path through `singleflight`; add inflight count to `/cache/stats` response
|
||||
- `test-features.mjs`: add concurrent-request test that asserts only 1 spawn occurs for N=10 simultaneous identical requests
|
||||
|
||||
**LOC budget:** ~70 production + ~40 test
|
||||
**Risk:** Medium — concurrency code is harder to reason about; mitigation is an explicit test case for the dedup behavior.
|
||||
**Streaming explicitly out of scope:** TODO comment placed in `callClaudeStreaming` for follow-up ticket.
|
||||
|
||||
---
|
||||
|
||||
## Testing strategy
|
||||
|
||||
**Unit-ish (in `test-features.mjs`):**
|
||||
1. `cacheHash` with two different `keyId` values → different hashes
|
||||
2. `cacheHash` v2 prefix present in output (sanity check)
|
||||
3. `hasCacheControl` returns true for top-level `cache_control` and for nested in `content[]`
|
||||
4. `hasCacheControl` returns false for benign messages
|
||||
5. Chunked replay: cached "abcdefgh..." (160 chars) produces 2 deltas
|
||||
|
||||
**Integration (manual smoke before merge):**
|
||||
1. Set `CLAUDE_CACHE_TTL=60000`; create key A and key B; identical prompt from each → both spawn fresh; second-call from same key → cache hit
|
||||
2. Send a message with `cache_control` annotation → OCP logs `cache_skipped: cache_control_present`; no cache write
|
||||
3. Streaming cache hit visibly produces multiple SSE deltas (`curl -N | grep "data: "` shows >1 lines)
|
||||
|
||||
**Concurrent (PR-B only):**
|
||||
1. Spawn 10 simultaneous identical non-streaming requests; assert (via `/cache/stats` inflight peak or via a process spawn counter) only 1 `cli.js` spawn occurred
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (deliberately deferred)
|
||||
|
||||
- **Streaming singleflight** — see D4 streaming caveat. TODO in code.
|
||||
- **Semantic cache** (embedding-based near-match) — needs an embedding provider + vector index. Punt to v3.14+ if there's user demand.
|
||||
- **Cross-process cache** (Redis backend) — violates ADR 0005's "personal power tool" posture.
|
||||
- **Cache versioning by model ID hash** — model upgrades currently invalidate cache organically because model is in the hash; if Anthropic ever silently changes a model's behavior without a model ID bump, that's a separate alignment problem.
|
||||
- **Per-key cache TTL override** — single global TTL (existing `CLAUDE_CACHE_TTL`) is fine; per-key TTL is a knob no one has asked for.
|
||||
|
||||
---
|
||||
|
||||
## Rollback plan
|
||||
|
||||
If either PR introduces a regression, the rollback is a clean git revert. The cache layer is opt-in (default `CLAUDE_CACHE_TTL=0` = disabled), so users who never enabled the cache are unaffected by any cache-layer regression. Users who *had* enabled the cache lose only ephemeral state on revert. No persistent on-disk state is reshaped by this PR pair (we explicitly avoid schema migrations per D1 rationale).
|
||||
@@ -3,11 +3,13 @@
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { mkdirSync, chmodSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const OCP_DIR = join(homedir(), ".ocp");
|
||||
mkdirSync(OCP_DIR, { recursive: true });
|
||||
mkdirSync(OCP_DIR, { recursive: true, mode: 0o700 });
|
||||
// Tighten the directory mode in case it already existed with broader permissions.
|
||||
try { chmodSync(OCP_DIR, 0o700); } catch { /* ignore EPERM on pre-existing dirs */ }
|
||||
const DB_PATH = join(OCP_DIR, "ocp.db");
|
||||
|
||||
let db;
|
||||
@@ -18,6 +20,8 @@ export function getDb() {
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
initSchema();
|
||||
// Tighten mode on the DB file (0600) after creation / first open.
|
||||
try { chmodSync(DB_PATH, 0o600); } catch { /* ignore — same-user access still works */ }
|
||||
}
|
||||
return db;
|
||||
}
|
||||
@@ -292,9 +296,13 @@ export function getKeyQuota(keyId) {
|
||||
|
||||
// ── Response cache ──
|
||||
|
||||
// Generate a cache key from model + messages + request params that affect output
|
||||
// Generate a cache key from model + messages + request params that affect output.
|
||||
// opts.keyId isolates per-API-key cache pools (v2 hash format).
|
||||
// When keyId is absent/null/empty, falls back to "anon" (shared anonymous pool).
|
||||
export function cacheHash(model, messages, opts = {}) {
|
||||
const keyId = opts.keyId || "anon";
|
||||
const h = createHash("sha256");
|
||||
h.update(`v2|k:${keyId}|`);
|
||||
h.update(model);
|
||||
if (opts.temperature != null) h.update(`t:${opts.temperature}`);
|
||||
if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`);
|
||||
@@ -306,6 +314,22 @@ export function cacheHash(model, messages, opts = {}) {
|
||||
return h.digest("hex");
|
||||
}
|
||||
|
||||
// Check whether any message (or content part) carries an Anthropic cache_control field.
|
||||
// If true, OCP should skip its own cache to avoid interfering with prompt-caching intent.
|
||||
export function hasCacheControl(messages) {
|
||||
for (const m of messages || []) {
|
||||
if (m && typeof m === "object") {
|
||||
if (m.cache_control) return true;
|
||||
if (Array.isArray(m.content)) {
|
||||
for (const part of m.content) {
|
||||
if (part && typeof part === "object" && part.cache_control) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Look up a cached response. Returns { response, hits } or null.
|
||||
// Also updates last_hit_at and increments hits counter on hit.
|
||||
export function getCachedResponse(hash, ttlMs) {
|
||||
@@ -351,6 +375,36 @@ export function getCacheStats() {
|
||||
return { entries: total, totalHits, sizeBytes };
|
||||
}
|
||||
|
||||
// ── Singleflight stampede protection ──
|
||||
|
||||
// In-memory singleflight Map: hash → { promise, requesters }
|
||||
// Deduplicates concurrent identical cache-miss flows so only one upstream call runs.
|
||||
// Per ADR 0005 / spec D4: in-process scope only (single Node process per host).
|
||||
const inflightMap = new Map();
|
||||
|
||||
export function singleflight(hash, fn) {
|
||||
const existing = inflightMap.get(hash);
|
||||
if (existing) {
|
||||
existing.requesters++;
|
||||
return existing.promise;
|
||||
}
|
||||
// Wrap fn() in Promise.resolve().then() so synchronous throws don't escape.
|
||||
const promise = Promise.resolve().then(fn).finally(() => {
|
||||
inflightMap.delete(hash);
|
||||
});
|
||||
inflightMap.set(hash, { promise, requesters: 1 });
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function getInflightStats() {
|
||||
let totalRequesters = 0;
|
||||
for (const entry of inflightMap.values()) totalRequesters += entry.requesters;
|
||||
return {
|
||||
inflight: inflightMap.size,
|
||||
requesters: totalRequesters,
|
||||
};
|
||||
}
|
||||
|
||||
// Find a key by id or name (returns { id, name } or null)
|
||||
export function findKey(idOrName) {
|
||||
const d = getDb();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* OCP shared constants — single source of truth.
|
||||
*
|
||||
* Any literal that appears in more than one place across server.mjs, setup.mjs,
|
||||
* scripts/* belongs here so port-drift / URL-drift cascades cannot recur.
|
||||
*
|
||||
* Background: from 2026-05-08 (PR #71 dogfood accident) through 2026-05-13
|
||||
* (v3.16.3) a single hardcoded "3478" in scripts/upgrade.mjs + scripts/doctor.mjs
|
||||
* cascaded into every downstream config write, ultimately taking out the
|
||||
* OpenClaw "大内总管" Telegram agent. See CHANGELOG v3.16.2 and v3.16.3.
|
||||
*
|
||||
* Adding a new constant: prefer ALL_CAPS_SNAKE_CASE. Document the consumers.
|
||||
* If a literal is referenced from a shell script (ocp, ocp-connect, setup.sh)
|
||||
* that can't import .mjs, add a `// keep in sync with lib/constants.mjs` note
|
||||
* at the shell-script reference; CI grep prevents drift.
|
||||
*/
|
||||
|
||||
// Default TCP port the OCP HTTP proxy listens on. Set by env CLAUDE_PROXY_PORT
|
||||
// at runtime; this is the fallback when env is unset.
|
||||
// Consumers: server.mjs, setup.mjs, scripts/upgrade.mjs, scripts/doctor.mjs,
|
||||
// scripts/sync-openclaw.mjs. Shell scripts ocp / ocp-connect keep the literal
|
||||
// "3456" in sync with this value (see CI gate in .github/workflows/alignment.yml).
|
||||
export const DEFAULT_PORT = 3456;
|
||||
|
||||
// Localhost bind for client-side fetches (curl, health checks).
|
||||
export const LOCAL_HOST = "127.0.0.1";
|
||||
|
||||
// OpenAI-compatible API base path appended to the proxy URL.
|
||||
export const OPENAI_API_BASE = "/v1";
|
||||
|
||||
// Convenience: full local URL the OCP proxy listens on by default.
|
||||
// scripts that want to probe locally can use this directly.
|
||||
export const LOCAL_PROXY_URL = `http://${LOCAL_HOST}:${DEFAULT_PORT}`;
|
||||
@@ -0,0 +1,9 @@
|
||||
// OCP network helpers — shared so server.mjs and tests use one definition. (issue #125)
|
||||
|
||||
// A bind address is "loopback" only if it cannot be reached from another host.
|
||||
// Any other address (0.0.0.0, ::, a concrete LAN/Tailscale IP, etc.) is
|
||||
// network-exposed and must trigger the TUI LAN gate.
|
||||
export function isLoopbackBind(addr) {
|
||||
return addr === "127.0.0.1" || addr === "::1" || addr === "localhost" ||
|
||||
addr === "::ffff:127.0.0.1" || /^127\./.test(addr);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb2222-3333-4444-5555-666677778888","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"What is 2 + 2?"}]}}
|
||||
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb2222-3333-4444-5555-666677778888","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"Failed to authenticate. API Error: 401 Invalid authentication credentials"}]}}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"aaaa1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"What is 2 + 2?"}]}}
|
||||
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"aaaa1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"Please run /login · API Error: 401 Invalid authentication credentials"}]}}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"Say PONG and nothing else."}]}}
|
||||
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"PONG"}]}}
|
||||
@@ -0,0 +1,102 @@
|
||||
// TUI-path concurrency limiter (audit finding C-4).
|
||||
//
|
||||
// WHY THIS EXISTS, SEPARATE FROM server.mjs's MAX_CONCURRENT:
|
||||
// The global MAX_CONCURRENT gate lives in spawnClaudeProcess() (the -p / stream-json
|
||||
// path). callClaudeTui() NEVER calls spawnClaudeProcess — it calls runTuiTurn(), which
|
||||
// boots a full interactive `claude` inside a fresh tmux session. So nothing bounded the
|
||||
// TUI path: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude
|
||||
// processes. On a small host (a Pi 4 serving a family) a burst of ~5 is an OOM risk, and
|
||||
// it also multiplies subscription rate-limit pressure. This is an INDEPENDENT limiter for
|
||||
// the TUI path that mirrors MAX_CONCURRENT's intent without coupling to it (the two pools
|
||||
// are different shapes: a stream-json spawn is cheap and fast; a TUI turn is a heavy
|
||||
// cold-boot + up to 120s wallclock).
|
||||
//
|
||||
// QUEUE vs REJECT: we QUEUE (await a slot), mirroring the spirit of MAX_CONCURRENT's
|
||||
// intent not to drop requests, rather than rejecting immediately. To avoid unbounded
|
||||
// memory growth from a runaway client, the wait queue itself is bounded by maxQueue
|
||||
// (default: a generous multiple of the concurrency limit). When the queue is full, run()
|
||||
// rejects with a tui_queue_full error (the caller surfaces it as a 503) — a deterministic
|
||||
// backpressure signal rather than silent OOM.
|
||||
//
|
||||
// Pure + importable so test-features.mjs can assert the bound directly (no server boot).
|
||||
|
||||
export class TuiSemaphore {
|
||||
// limit: max concurrent slots. maxQueue: max waiters before run() rejects with backpressure.
|
||||
constructor(limit, { maxQueue } = {}) {
|
||||
this.limit = Math.max(1, parseInt(limit, 10) || 1);
|
||||
// Default queue cap: 32× the limit. Large enough that real family-burst traffic never
|
||||
// hits it, small enough that a pathological flood can't grow the queue without bound.
|
||||
this.maxQueue = Number.isFinite(maxQueue) ? maxQueue : this.limit * 32;
|
||||
this._inflight = 0;
|
||||
this._waiters = []; // FIFO queue of resolve callbacks waiting for a slot
|
||||
}
|
||||
|
||||
get inflight() { return this._inflight; }
|
||||
get queued() { return this._waiters.length; }
|
||||
|
||||
// Acquire a slot. Resolves once a slot is free (immediately if under the limit, otherwise
|
||||
// when an in-flight task releases). Rejects synchronously-ish if the wait queue is full.
|
||||
acquire() {
|
||||
if (this._inflight < this.limit) {
|
||||
this._inflight++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this._waiters.length >= this.maxQueue) {
|
||||
return Promise.reject(new Error(
|
||||
`tui_queue_full: TUI concurrency limit (${this.limit}) reached and wait queue ` +
|
||||
`(${this.maxQueue}) is full`));
|
||||
}
|
||||
return new Promise((resolve) => { this._waiters.push(resolve); });
|
||||
}
|
||||
|
||||
// Release a slot. If a waiter is queued, hand the slot directly to it (inflight stays
|
||||
// constant across the handoff); otherwise decrement.
|
||||
release() {
|
||||
const next = this._waiters.shift();
|
||||
if (next) {
|
||||
next(); // the woken waiter already "owns" the slot — inflight unchanged
|
||||
} else if (this._inflight > 0) {
|
||||
this._inflight--;
|
||||
}
|
||||
}
|
||||
|
||||
// Run fn() under one slot. Releases in a finally so a throw (PR-A's honesty gates,
|
||||
// wallclock truncation, paste-not-landed, tmux spawn failure) NEVER leaks a slot.
|
||||
async run(fn) {
|
||||
await this.acquire();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TUI drift observability (audit C-5) — pure helpers, importable for testing ──
|
||||
|
||||
// Record an observed cc_entrypoint into the (mutable) tuiStats counter. Sets lastEntrypoint
|
||||
// unconditionally and increments entrypointMismatches when the spawn was supposed to be
|
||||
// subscription-pool ("cli") but the transcript reported something else (a silent drift to
|
||||
// the metered Agent SDK pool — the audit's top risk after the 6/15 billing flip).
|
||||
// Returns true iff this observation was a mismatch (so the caller can also emit a log).
|
||||
export function recordTuiEntrypoint(tuiStats, observed, expectedMode = "cli") {
|
||||
tuiStats.lastEntrypoint = observed ?? null;
|
||||
const mismatch = expectedMode === "cli" && observed !== "cli";
|
||||
if (mismatch) tuiStats.entrypointMismatches++;
|
||||
return mismatch;
|
||||
}
|
||||
|
||||
// Build the additive /health `tui` block (ADR 0007 PR-B amendment). Pure: given the
|
||||
// config + live counters, returns the exact object embedded in /health. New fields only —
|
||||
// behaviour-preserving for existing /health consumers (grandfathered B.2 under ADR 0006).
|
||||
export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent }, tuiStats, semaphore) {
|
||||
return {
|
||||
enabled,
|
||||
entrypointMode, // cli | auto | off
|
||||
lastEntrypoint: tuiStats.lastEntrypoint, // last observed cc_entrypoint, or null
|
||||
entrypointMismatches: tuiStats.entrypointMismatches,
|
||||
inflight: semaphore.inflight, // current concurrent TUI turns
|
||||
queued: semaphore.queued, // turns waiting for a slot
|
||||
maxConcurrent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
// TUI-mode session driver: hosts an interactive `claude` in tmux, submits one
|
||||
// serialized prompt, awaits the transcript reader, tears down. OCP-specific.
|
||||
//
|
||||
// Authority: claude CLI v2.1.158 interactive mode (no -p / no --output-format
|
||||
// => cc_entrypoint=cli). Submission recipe validated by spikes T3/T6 on PI231.
|
||||
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md.
|
||||
//
|
||||
// Trust handling: rather than answer the trust-folder dialog interactively (which
|
||||
// only appears on a cwd's FIRST encounter — sending a defensive "1" to an already
|
||||
// trusted cwd would inject a stray prompt turn), we PRE-TRUST the scratch cwd by
|
||||
// seeding <home>/.claude.json. Every turn then boots dialog-free and identical.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, statSync, renameSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readTuiTranscript } from "./transcript.mjs";
|
||||
|
||||
export const SESSION_PREFIX = "ocp-tui-"; // per-proxy namespace (coexistence rule)
|
||||
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
|
||||
|
||||
const defaultTmux = (args, opts = {}) =>
|
||||
spawnSync(TMUX, args, { encoding: "utf8", ...opts });
|
||||
|
||||
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
|
||||
// OLP test instance's `olp-tui-*` sessions are never touched.
|
||||
//
|
||||
// Defunct-reaping (PI231 incident): the pane's `claude` process is a child of the
|
||||
// long-lived tmux SERVER daemon, NOT of the OCP node process — `tmux new-session -d`
|
||||
// returns the instant the server forks the pane, so node never becomes its parent and
|
||||
// therefore can NEVER waitpid()/reap it (a SIGKILL still needs the *parent* to reap, and
|
||||
// here that parent is the tmux server). `kill-session` destroys the session but the server
|
||||
// can leave the pane's `claude` (and any grandchildren claude spawned) as `<defunct>`
|
||||
// zombies that only the server can reap. Over many per-request spawn+teardown cycles these
|
||||
// accumulate (live evidence on PI231: 25 defunct `<claude>` over 30 days; `tmux kill-server`
|
||||
// dropped it 25→3). The only node-reachable action that ACTUALLY reaps them — rather than
|
||||
// merely re-signalling — is to stop the tmux server: when the server exits, the kernel
|
||||
// reparents its surviving children to init (PID 1), which reaps them immediately.
|
||||
//
|
||||
// So after killing our own sessions, if the server has NO sessions left of ANY prefix
|
||||
// (i.e. nothing we could disrupt — no co-hosted `olp-tui-*` or other instance), we
|
||||
// `kill-server` to flush the defunct backlog. If ANY non-ocp session remains we leave the
|
||||
// server running (coexistence rule, ADR 0007) and let the next boot/periodic sweep retry
|
||||
// once the server is otherwise idle.
|
||||
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
|
||||
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
|
||||
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
|
||||
const names = String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
let killed = 0;
|
||||
let othersRemain = false;
|
||||
for (const name of names) {
|
||||
if (name.startsWith(SESSION_PREFIX)) {
|
||||
tmux(["kill-session", "-t", name]);
|
||||
killed++;
|
||||
} else {
|
||||
othersRemain = true; // a session we do NOT own (e.g. olp-tui-*) — never kill-server
|
||||
}
|
||||
}
|
||||
// Reap defunct `claude` zombies: safe ONLY when the server is now ours-only/empty.
|
||||
// kill-server is what actually reaps (server exit reparents survivors to init); a
|
||||
// per-session kill cannot, since node is not the zombies' parent.
|
||||
if (!othersRemain) {
|
||||
tmux(["kill-server"]);
|
||||
}
|
||||
return killed;
|
||||
}
|
||||
|
||||
// ── Task 5: runTuiTurn ───────────────────────────────────────────────────
|
||||
|
||||
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
|
||||
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); // max wait for input-ready
|
||||
const READY_POLL_MS = parseInt(process.env.OCP_TUI_READY_POLL_MS || "400", 10); // readiness / paste-verify poll interval
|
||||
const PASTE_VERIFY_MS = parseInt(process.env.OCP_TUI_PASTE_VERIFY_MS || "5000", 10); // max wait for pasted prompt to render
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Capture the visible tmux pane as plain text (for readiness / paste verification).
|
||||
function tuiCapturePane(tmux, tmuxName) {
|
||||
const r = tmux(["capture-pane", "-p", "-t", tmuxName]);
|
||||
return (r && typeof r.stdout === "string") ? r.stdout : "";
|
||||
}
|
||||
|
||||
// True once claude's input bar is rendered and ready for keystrokes.
|
||||
function tuiInputReady(pane) {
|
||||
return /\? for shortcuts/.test(pane);
|
||||
}
|
||||
|
||||
// True once the pasted prompt has POSITIVELY landed in the input box. We only trust
|
||||
// affirmative signals — NOT "the placeholder is gone", which is unreliable (claude's
|
||||
// placeholder uses a curly quote `"`, randomized example text, and renders the big paste
|
||||
// a beat after paste-buffer returns; a "placeholder-gone" heuristic false-positived on the
|
||||
// still-empty box and made us submit Enter into nothing → issue #130 hang). Landed iff:
|
||||
// (a) the bracketed-paste indicator "[Pasted text" is present (large/multi-line paste), OR
|
||||
// (b) the prompt's own leading text appears in the pane (short/literal paste).
|
||||
function tuiPromptLanded(pane, prompt) {
|
||||
const flatPane = pane.replace(/\s+/g, " ");
|
||||
if (flatPane.includes("[Pasted text")) return true;
|
||||
const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || "";
|
||||
const needle = firstLine.replace(/\s+/g, " ").slice(0, 24);
|
||||
// C-4/#133: threshold lowered 3 → 2. A prompt whose first non-blank line is 1–2
|
||||
// chars ("hi", "ok") previously NEVER matched (needle.length >= 3) and never
|
||||
// surfaced "[Pasted text", so EVERY short prompt 5s-failed with tui_paste_not_landed
|
||||
// (live-reproduced: "hi"). The input box starts EMPTY (the curly-quote placeholder
|
||||
// is excluded by the affirmative-signal design above), so a >=2-char needle present
|
||||
// in the pane is the pasted prompt, not placeholder noise — false-positive risk is
|
||||
// low. We keep >=2 rather than >=1 because a single visible char is more likely to
|
||||
// collide with incidental glyphs in claude's chrome (borders, the "❯" prompt mark);
|
||||
// 2 chars is the floor that lands real prompts while staying conservative.
|
||||
return needle.length >= 2 && flatPane.includes(needle);
|
||||
}
|
||||
|
||||
async function pollUntil(fn, { timeoutMs, intervalMs }) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try { if (fn()) return true; } catch { /* ignore, keep polling */ }
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Single-quote escaper for sh -c arguments.
|
||||
function shq(s) {
|
||||
return `'${String(s).replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
// Pre-trust the scratch cwd by seeding the trust record in <home>/.claude.json so
|
||||
// the trust-folder dialog never appears. Verified-live trust shape:
|
||||
// projects["<cwd>"] = { hasTrustDialogAccepted: true, allowedTools: [], ... }
|
||||
// Idempotent + best-effort: a missing/unreadable .claude.json must not abort a
|
||||
// turn (a fresh cwd would then show the dialog once; the boot wait tolerates it).
|
||||
// Must run BEFORE the session boots so claude reads the trusted record at startup.
|
||||
export function ensureTuiCwdTrusted(home, cwd) {
|
||||
if (!home || !cwd) return;
|
||||
const path = `${home}/.claude.json`;
|
||||
let j, mode;
|
||||
try {
|
||||
j = JSON.parse(readFileSync(path, "utf8"));
|
||||
mode = statSync(path).mode & 0o777;
|
||||
} catch { return; }
|
||||
j.projects = j.projects || {};
|
||||
const entry = j.projects[cwd] || {};
|
||||
if (entry.hasTrustDialogAccepted === true) return; // already trusted, no rewrite
|
||||
entry.hasTrustDialogAccepted = true;
|
||||
if (!Array.isArray(entry.allowedTools)) entry.allowedTools = [];
|
||||
j.projects[cwd] = entry;
|
||||
// Atomic write (temp + rename on the same fs), preserving mode, so a crash
|
||||
// mid-write can never truncate the user's real ~/.claude.json. We seed ONLY the
|
||||
// per-project trust flag — NOT bypassPermissionsModeAccepted: the driver never
|
||||
// passes --dangerously-skip-permissions, so the bypass dialog cannot appear, and
|
||||
// onboarding completion is an A-path precondition (the host already runs claude).
|
||||
// NOTE: when the A-path moves to a dedicated scratch HOME (task #26), this writes
|
||||
// a file we fully own, removing the real-config-mutation concern entirely.
|
||||
try {
|
||||
const tmp = `${path}.ocp-tui.${process.pid}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(j, null, 2), { mode });
|
||||
renameSync(tmp, path);
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// Resolve the HOME the TUI `claude` runs under. Three intents, decided by the env
|
||||
// token + an explicit OCP_TUI_HOME override:
|
||||
//
|
||||
// - ENV-TOKEN MODE (default when CLAUDE_CODE_OAUTH_TOKEN is set AND OCP_TUI_HOME is
|
||||
// unset): a CREDENTIAL-FREE scratch home at `<realHome>/.ocp-tui/home`. There is
|
||||
// deliberately NO .credentials.json (no symlink, no copy), so the only credential
|
||||
// claude can find is the long-lived env token (passed by buildTuiCmd). This is what
|
||||
// actually FORCES env-token auth — see the prepareTuiHome comment for why passing
|
||||
// the token alone is insufficient.
|
||||
// - EXPLICIT OVERRIDE: whatever OCP_TUI_HOME names (back-compat; an operator who set it
|
||||
// keeps exactly that home).
|
||||
// - REAL-HOME (default when the env token is unset): the operator's real home, shared
|
||||
// credentials.json — byte-for-byte the pre-fix behaviour for credentials.json hosts.
|
||||
//
|
||||
// Pure + deterministic so server.mjs and the tests share one decision. `configuredHome`
|
||||
// is the raw OCP_TUI_HOME value (undefined/empty => unset).
|
||||
export const DEFAULT_TUI_SCRATCH_HOME = (realHome) => `${realHome}/.ocp-tui/home`;
|
||||
export function resolveTuiHome({ realHome, configuredHome, envTokenSet }) {
|
||||
if (configuredHome) return configuredHome; // explicit override wins (back-compat)
|
||||
if (envTokenSet) return DEFAULT_TUI_SCRATCH_HOME(realHome); // credential-free scratch
|
||||
return realHome; // legacy real-home default
|
||||
}
|
||||
|
||||
// Prepare the HOME claude runs under. Three modes:
|
||||
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
|
||||
// in the real ~/.claude.json. The legacy default when no env token is set.
|
||||
// - ENV-TOKEN scratch-home (envTokenMode === true): a dedicated HOME with a seeded
|
||||
// .claude.json (onboarded + trusts only the scratch cwd) and its own projects/ dir,
|
||||
// and DELIBERATELY NO .credentials.json (no symlink, no copy). claude then has no
|
||||
// credentials file to read, so it authenticates via CLAUDE_CODE_OAUTH_TOKEN (passed
|
||||
// by buildTuiCmd) — which is authoritative precisely because nothing shadows it.
|
||||
// - legacy scratch-home (envTokenMode falsy, tuiHome !== realHome): the historical
|
||||
// mode that SYMLINKS the real .credentials.json. Retained only for an operator who
|
||||
// explicitly set OCP_TUI_HOME without an env token; see the caveat below.
|
||||
//
|
||||
// WHY ENV-TOKEN MODE IS THE FIX (proven live on PI231, claude 2.1.104):
|
||||
// env token passed + a broken ~/.claude/.credentials.json present → 401.
|
||||
// env token passed + credentials.json moved aside → real answer.
|
||||
// Interactive `claude` PREFERS .credentials.json over the env var (unlike `-p`, where the
|
||||
// env token wins), so a stale/corrupt credentials.json SHADOWS the env token. Passing the
|
||||
// token is necessary but insufficient; the TUI claude must run in a HOME with NO
|
||||
// credentials.json so the env token is the only credential. This ALSO ends the refresh-
|
||||
// corruption incident at the root: with no credentials file, claude never runs the token-
|
||||
// refresh path, so the single-use refresh token can never be rotated (and corrupted) by the
|
||||
// spawn+kill cycle. (This RESOLVES — not reintroduces — the ADR 0007 scratch-home concern:
|
||||
// the old caveat was about a SYMLINKED credentials.json being forked on refresh; here there
|
||||
// is no credentials file to fork and no refresh ever happens.)
|
||||
//
|
||||
// ⚠️ LEGACY SCRATCH-HOME CAVEAT (envTokenMode falsy, symlink path): claude rewrites
|
||||
// .credentials.json on token refresh, REPLACING the symlink with a regular-file copy → the
|
||||
// scratch home FORKS the OAuth credentials and a refresh can invalidate the real-home token.
|
||||
// That path is therefore safe only with a DEDICATED OAuth or for ephemeral use. The env-token
|
||||
// mode above avoids this entirely.
|
||||
//
|
||||
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never corrupts.
|
||||
// Run BEFORE the session boots.
|
||||
export function prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode = false } = {}) {
|
||||
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
|
||||
try {
|
||||
const claudeDir = `${tuiHome}/.claude`;
|
||||
mkdirSync(`${claudeDir}/projects`, { recursive: true });
|
||||
if (!envTokenMode) {
|
||||
// Legacy mode ONLY: symlink the real credentials (never copy the token); refresh if
|
||||
// missing. Env-token mode deliberately skips this — no credentials file at all.
|
||||
const link = `${claudeDir}/.credentials.json`;
|
||||
if (!existsSync(link)) {
|
||||
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
// Seed .claude.json ONCE (if absent): onboarded + trust ONLY the scratch cwd.
|
||||
// In env-token mode start from a MINIMAL config (do NOT copy the real ~/.claude.json —
|
||||
// a credential-isolated home should not inherit the operator's account/config state);
|
||||
// in legacy mode carry the onboarded real config minus the user's project history.
|
||||
const seedPath = `${tuiHome}/.claude.json`;
|
||||
if (!existsSync(seedPath)) {
|
||||
let base = {};
|
||||
if (!envTokenMode) {
|
||||
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
|
||||
}
|
||||
base.hasCompletedOnboarding = true;
|
||||
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
|
||||
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
// Ensure the cwd is trusted in the scratch config (idempotent; atomic).
|
||||
ensureTuiCwdTrusted(tuiHome, cwd);
|
||||
}
|
||||
|
||||
// ── Billing-classifier labeling ─────────────────────────────────────────
|
||||
// Resolve CLAUDE_CODE_ENTRYPOINT on the spawn env per mode. ALWAYS deletes any
|
||||
// inherited value first (so a stray entrypoint from OCP's own parent env can never
|
||||
// leak into / mislabel the billing header). Then:
|
||||
// "cli" (default) → set "cli": deterministic subscription-pool classification.
|
||||
// HONEST ONLY because OCP's spawn is a genuine interactive PTY (tmux pane,
|
||||
// no -p, stdout not redirected). Never set "cli" on a non-interactive spawn.
|
||||
// "auto" → leave unset → claude self-classifies via its t$A (TTY → cli). Use to
|
||||
// observe/diagnose the real TTY-derived value.
|
||||
// "off" → leave the env exactly as inherited (diagnostics / honesty audit).
|
||||
export function resolveTuiEntrypointEnv(env, mode = "cli") {
|
||||
if (mode === "off") return env;
|
||||
delete env.CLAUDE_CODE_ENTRYPOINT;
|
||||
if (mode === "cli") env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
||||
return env;
|
||||
}
|
||||
|
||||
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
|
||||
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
|
||||
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
|
||||
// belt-and-braces with --disallowedTools "mcp__*".
|
||||
// A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B
|
||||
// (guest keys) MUST additionally pass --tools "" per spec §5.2(2) as the credential
|
||||
// wall before this argv is reachable for owner_tier=guest — guard that in PR-3 wiring.
|
||||
export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode) {
|
||||
// Deliver claude's env via an `env` prefix on the PANE COMMAND — tmux does NOT forward the
|
||||
// spawning process's environment to the pane, and `new-session -e` needs tmux ≥3.2 (the cloud
|
||||
// host runs 2.7), so this is the only portable, reliable mechanism (verified live 2026-06-01:
|
||||
// passing {env} to spawnSync left the pane with only HOME). DISABLE_AUTOUPDATER pins the version
|
||||
// (no "What's new" splash that delayed input-readiness); CLAUDE_CODE_ENTRYPOINT labels the
|
||||
// billing pool (set below per entrypointMode).
|
||||
//
|
||||
// CLAUDE_CODE_DISABLE_CLAUDE_MDS + DISABLE_AUTO_MEMORY: OCP is a PROXY, not a Claude Code
|
||||
// session. The proxied client (OpenClaw / an IDE) owns its own context and memory; the HOST's
|
||||
// CLAUDE.md and auto-memory must NEVER leak into the agent OCP runs on the user's behalf.
|
||||
// Without these, claude loads the host's project/user CLAUDE.md + memory into every proxied
|
||||
// turn — verified live 2026-06-02: a cwd CLAUDE.md ("end every reply with QUACKMARKER_42") was
|
||||
// obeyed by the proxied turn until these flags were set, after which it was not. Unconditional
|
||||
// by design (not gated): proxy purity is not an opt-in. Harmless on hosts with no CLAUDE.md
|
||||
// (the common case — they suppress nothing). Mirrors the -p path's CLAUDE_NO_CONTEXT vars.
|
||||
const sets = [
|
||||
`HOME=${shq(ehome)}`,
|
||||
"DISABLE_AUTOUPDATER=1",
|
||||
"CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1",
|
||||
"CLAUDE_CODE_DISABLE_CLAUDE_MDS=1",
|
||||
"CLAUDE_CODE_DISABLE_AUTO_MEMORY=1",
|
||||
];
|
||||
// CLAUDE_CODE_OAUTH_TOKEN: tmux does NOT forward the parent process's env to the pane (the
|
||||
// same reason the whole env is delivered as an `env` prefix above — verified live 2026-06-01),
|
||||
// so the token MUST be set explicitly here or the spawned `claude` never sees it. Without it,
|
||||
// the TUI claude falls back to authenticating via <HOME>/.claude/.credentials.json, whose
|
||||
// single-use refresh token gets corrupted by the per-request spawn + `kill-session` teardown
|
||||
// racing claude's token-rotation write (the PI231 incident: refresh token ended up an empty
|
||||
// string → permanent 401 "Please run /login", re-login re-corrupted on the next spawn). With
|
||||
// the long-lived OAuth token in env, claude authenticates via the token and never touches the
|
||||
// credentials.json refresh path — matching how the stable oracle / Mac-mini hosts already run.
|
||||
//
|
||||
// SECURITY: the token appears in the pane command (ps-visible). This is acceptable for the
|
||||
// single-user A-path — it mirrors the existing plaintext-token practice (server.mjs reads the
|
||||
// same CLAUDE_CODE_OAUTH_TOKEN env at getOAuthCredentials()), and the multi-user B-path is
|
||||
// already refused at boot (TUI + AUTH_MODE=multi is a hard FATAL). Read from process.env here,
|
||||
// consistent with how buildTuiCmd already reads OCP_TUI_FULL_TOOLS / CLAUDE_ALLOWED_TOOLS below.
|
||||
//
|
||||
// When the env is unset (e.g. a host that intentionally relies on credentials.json), no token
|
||||
// is added — behaviour is byte-for-byte unchanged from before this fix.
|
||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
sets.push(`CLAUDE_CODE_OAUTH_TOKEN=${shq(process.env.CLAUDE_CODE_OAUTH_TOKEN)}`);
|
||||
}
|
||||
const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
|
||||
if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli");
|
||||
else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY
|
||||
const envPrefix = ["env", ...unset.map((u) => `-u ${u}`), ...sets].join(" ");
|
||||
|
||||
// Tool surface.
|
||||
// DEFAULT (safe): hard-disable MCP (--strict-mcp-config + --disallowedTools mcp__*);
|
||||
// built-in tools stay on, acceptable for single-user A-path.
|
||||
// OCP_TUI_FULL_TOOLS=1: grant the SAME tool surface as the -p A-path
|
||||
// (--allowedTools [+ --mcp-config] [+ --dangerously-skip-permissions]), so a
|
||||
// SINGLE-USER / trusted TUI deployment can run a tool-using agent (e.g. an OpenClaw
|
||||
// assistant that needs Bash/Read/Write/MCP) on the subscription pool. This mirrors
|
||||
// buildCliArgs() in server.mjs. Safe to gate ON only because TUI is hard-incompatible
|
||||
// with AUTH_MODE=multi (server.mjs refuses to boot), so it can never widen a guest's
|
||||
// surface. Env mirrors server.mjs's CLAUDE_ALLOWED_TOOLS / _SKIP_PERMISSIONS / _MCP_CONFIG.
|
||||
let toolArgs;
|
||||
if (process.env.OCP_TUI_FULL_TOOLS === "1") {
|
||||
toolArgs = [];
|
||||
if (process.env.CLAUDE_SKIP_PERMISSIONS === "true") {
|
||||
toolArgs.push("--dangerously-skip-permissions");
|
||||
} else {
|
||||
const allowed = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
||||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
// shq EACH token: buildTuiCmd returns a SHELL STRING (run by tmux via sh -c), unlike
|
||||
// buildCliArgs which returns an argv array to spawn(). claude accepts scoped specifiers
|
||||
// like "Bash(npm run test:*)" / "Read(~/**)" whose ( ) * ~ would break/inject the shell
|
||||
// command if pasted bare. (operator-self-injection only — guests can't reach TUI.)
|
||||
if (allowed.length) toolArgs.push("--allowedTools", ...allowed.map(shq));
|
||||
}
|
||||
if (process.env.CLAUDE_MCP_CONFIG) toolArgs.push("--mcp-config", shq(process.env.CLAUDE_MCP_CONFIG));
|
||||
} else {
|
||||
toolArgs = ["--strict-mcp-config", "--disallowedTools", shq("mcp__*")];
|
||||
}
|
||||
return [
|
||||
envPrefix,
|
||||
shq(claudeBin),
|
||||
"--model", shq(model),
|
||||
"--session-id", sessionId,
|
||||
...toolArgs,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
// Full per-request TUI lifecycle:
|
||||
// 1. Pre-trust the scratch cwd (no trust dialog will appear).
|
||||
// 2. Write prompt to a 0600 temp file (no shell injection from prompt content).
|
||||
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd; poll
|
||||
// capture-pane until the `? for shortcuts` input bar appears (readiness-poll
|
||||
// replaces the old blind boot sleep). BOOT_MS is the max wait, not a fixed delay.
|
||||
// 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) —
|
||||
// reliable for large multi-line prompts where send-keys -l is not (issue #130).
|
||||
// Poll-verify the prompt landed in the input (placeholder gone / [Pasted text]);
|
||||
// fast-fail with tui_paste_not_landed if it never lands (prevents the 120s
|
||||
// wallclock "stuck typing" hang). Then submit with a SEPARATE Enter key event.
|
||||
// 5. Block on the native JSONL transcript (located by session-id) until terminal
|
||||
// marker or wall-clock cap.
|
||||
// 6. Always teardown: kill session + rm temp dir (even on throw).
|
||||
// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
|
||||
// classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
|
||||
export async function runTuiTurn({
|
||||
prompt,
|
||||
model,
|
||||
claudeBin,
|
||||
home,
|
||||
realHome,
|
||||
cwd,
|
||||
wallclockMs = 120000,
|
||||
entrypointMode = "cli",
|
||||
tmux = defaultTmux,
|
||||
}) {
|
||||
const sessionId = randomUUID();
|
||||
const tmuxName = SESSION_PREFIX + sessionId.slice(0, 8);
|
||||
const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real)
|
||||
const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source)
|
||||
|
||||
// Env-token-only mode: the env token is set AND claude runs in an isolated home
|
||||
// (ehome !== rhome). In that case the scratch home must be CREDENTIAL-FREE (no
|
||||
// .credentials.json) so the env token — passed by buildTuiCmd — is the only credential
|
||||
// and is therefore authoritative (interactive claude otherwise PREFERS a credentials.json,
|
||||
// shadowing the env token; proven live on PI231). server.mjs derives TUI_HOME via
|
||||
// resolveTuiHome() so this isolated home is the DEFAULT once CLAUDE_CODE_OAUTH_TOKEN is set.
|
||||
const envTokenMode = !!process.env.CLAUDE_CODE_OAUTH_TOKEN && ehome !== rhome;
|
||||
|
||||
// Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the
|
||||
// cwd — before claude boots.
|
||||
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
||||
prepareTuiHome(rhome, ehome, cwd, { envTokenMode });
|
||||
|
||||
// Write prompt to a temp file (mode 0600) so the content never touches argv.
|
||||
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||
const promptFile = `${tmpDir}/prompt.txt`;
|
||||
writeFileSync(promptFile, prompt, { mode: 0o600 });
|
||||
|
||||
// Build the env: disable marketplace auto-install, strip any Anthropic / CC
|
||||
// env vars that might interfere with interactive-mode classification.
|
||||
const env = { ...process.env, CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1" };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
delete env.ANTHROPIC_BASE_URL;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
env.HOME = ehome; // claude reads credentials + writes the transcript under this HOME
|
||||
resolveTuiEntrypointEnv(env, entrypointMode);
|
||||
|
||||
try {
|
||||
// 1. Boot the interactive session inside tmux, rooted at the scratch cwd.
|
||||
// Capture the result: if tmux new-session fails (status !== 0) there is no
|
||||
// PTY, no interactive spawn — abort BEFORE the boot sleep rather than paste
|
||||
// into a non-existent session or issue a billing request without a verified
|
||||
// interactive context. The finally teardown is still harmless (kill-session
|
||||
// is a no-op when the session never existed).
|
||||
const spawnResult = tmux(
|
||||
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||
buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)],
|
||||
{ env },
|
||||
);
|
||||
if (!spawnResult || spawnResult.status !== 0) {
|
||||
throw new Error("tui_spawn_failed: tmux session not created");
|
||||
}
|
||||
|
||||
// 2. Wait until claude's input bar is actually ready (was: blind sleep(BOOT_MS)).
|
||||
// BOOT_MS is now the MAX readiness wait, not a fixed delay.
|
||||
const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
|
||||
{ timeoutMs: BOOT_MS, intervalMs: READY_POLL_MS });
|
||||
if (!ready) {
|
||||
// (readiness timed out; relying on paste-verify)
|
||||
console.error("[tui] input_not_ready", tmuxName);
|
||||
}
|
||||
|
||||
// 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT
|
||||
// `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the
|
||||
// embedded newlines arrive as separate key events (effectively repeated Enter),
|
||||
// so a big OpenClaw-style prompt never lands and the turn hangs to the wallclock
|
||||
// (issue #130 — reproduced at ~300 lines; fixed by bracketed paste). load-buffer
|
||||
// reads the file directly (no shell arg limit, no `"$(cat)"`), and paste-buffer -p
|
||||
// wraps it in bracketed-paste markers so claude ingests it atomically as ONE paste
|
||||
// ("[Pasted text #N +M lines]"). -d deletes the buffer afterward. Buffer name is the
|
||||
// per-session tmuxName, so concurrent turns never collide.
|
||||
tmux(["load-buffer", "-b", tmuxName, promptFile]);
|
||||
tmux(["paste-buffer", "-b", tmuxName, "-t", tmuxName, "-p", "-d"]);
|
||||
|
||||
// Verify the prompt POSITIVELY landed before submitting; poll (a large bracketed paste
|
||||
// takes a beat to render the "[Pasted text]" indicator). This is load-bearing: firing
|
||||
// Enter before the paste renders submits an empty box → the turn hangs to the wallclock
|
||||
// (issue #130). Fast-fail if it never lands → deterministic error in seconds.
|
||||
const landed = await pollUntil(() => tuiPromptLanded(tuiCapturePane(tmux, tmuxName), prompt),
|
||||
{ timeoutMs: PASTE_VERIFY_MS, intervalMs: READY_POLL_MS });
|
||||
if (!landed) {
|
||||
throw new Error("tui_paste_not_landed: prompt did not reach claude's input within " + PASTE_VERIFY_MS + "ms");
|
||||
}
|
||||
|
||||
// Submit (separate Enter key event).
|
||||
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||
|
||||
// 4. Block on the native transcript (resolved by session-id) until terminal.
|
||||
// Returns { text, entrypoint } from readTuiTranscript.
|
||||
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
|
||||
} finally {
|
||||
// 5. Teardown — always, even on throw.
|
||||
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
|
||||
// and returns the latest assistant turn's text once the turn is terminal.
|
||||
//
|
||||
// Authority: claude CLI v2.1.157 — interactive session transcript at
|
||||
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
|
||||
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
|
||||
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
|
||||
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Project-dir encoding: claude replaces every "/" AND every "." with "-".
|
||||
// Verified live (claude v2.1.158): cwd /home/u/.ocp-tui/work is stored under
|
||||
// projects/-home-u--ocp-tui-work/ (the "." in ".ocp-tui" becomes "-", yielding
|
||||
// the double dash). The earlier "/"-only rule was wrong for dotted paths; the
|
||||
// fixture cwd /tmp/tui-test happened to have no dots so it never surfaced.
|
||||
// NOTE: prefer findTranscriptPath() (glob by session-id) for resolution — it is
|
||||
// immune to the exact encoding rule. This helper is kept for the known-path case.
|
||||
export function encodeCwd(cwd) {
|
||||
return cwd.replace(/[/.]/g, "-");
|
||||
}
|
||||
|
||||
export function transcriptPath(home, cwd, sessionId) {
|
||||
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
|
||||
}
|
||||
|
||||
// Locate a session's transcript by its UUID across every projects subdir, without
|
||||
// reconstructing the encoded cwd. Robust to whatever encoding claude applies.
|
||||
// Returns the path, or null if not present yet (it appears once the turn starts).
|
||||
export function findTranscriptPath(home, sessionId) {
|
||||
if (!home || !sessionId) return null;
|
||||
const root = `${home}/.claude/projects`;
|
||||
let dirs;
|
||||
try { dirs = readdirSync(root); } catch { return null; }
|
||||
for (const d of dirs) {
|
||||
const candidate = `${root}/${d}/${sessionId}.jsonl`;
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
|
||||
// (the live transcript is read mid-write, so the last line may be incomplete).
|
||||
export function parseTranscriptLines(text) {
|
||||
const out = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A line marks the assistant turn complete when EITHER:
|
||||
// (a) {type:"system", subtype:"turn_duration"} — emitted by newer claude builds
|
||||
// (e.g. 2.1.159), OR
|
||||
// (b) {type:"assistant"} whose message.stop_reason is a FINAL reason
|
||||
// ("end_turn" / "stop_sequence" / "max_tokens"). This is the API-level
|
||||
// end-of-turn signal, present across claude builds whose transcripts do NOT
|
||||
// emit turn_duration (e.g. 2.1.114 — verified live on the cloud host). Without
|
||||
// it OCP can't detect completion on those builds and hangs to the wallclock,
|
||||
// then returns only partial text (issue #130, cloud/server-side symptom).
|
||||
//
|
||||
// stop_reason "tool_use" is deliberately NOT terminal: the model is mid-turn (it will
|
||||
// run a tool and continue with a later assistant entry). Matching on a FINAL
|
||||
// stop_reason — not on the mere presence of a tool_use — keeps tool-using turns intact.
|
||||
// (The v3.17.1 narrowing dropped a buggy "tool_use is terminal" rule; this restores
|
||||
// cross-version completion detection without bringing that bug back.)
|
||||
const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"]);
|
||||
export function isTerminalLine(obj) {
|
||||
if (!obj || typeof obj !== "object") return false;
|
||||
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
|
||||
if (obj.type === "assistant" && obj.message && typeof obj.message === "object") {
|
||||
return TERMINAL_STOP_REASONS.has(obj.message.stop_reason);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Text of the LAST assistant turn: concatenate its text content blocks
|
||||
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
|
||||
// Fixture-confirmed shape: top-level type:"assistant", message.content[] array.
|
||||
//
|
||||
// Scoping: this returns the FINAL text-bearing assistant entry in the whole file,
|
||||
// not "text since the matching user line" (spec §4.2). Those are equivalent ONLY
|
||||
// under OCP's one-session-per-request model (a fresh --session-id => a fresh
|
||||
// transcript holding one logical exchange). If a future warm-pool ever reuses a
|
||||
// session WITHOUT a fresh session-id / clear, earlier-turn text could leak — that
|
||||
// author must add user-line scoping here. See spec §7.2.
|
||||
export function extractLatestAssistantText(events) {
|
||||
let text = "";
|
||||
for (const ev of events) {
|
||||
if (!ev || ev.type !== "assistant") continue;
|
||||
const content = ev.message && ev.message.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
const parts = content
|
||||
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
||||
.map((b) => b.text);
|
||||
if (parts.length) text = parts.join("");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// Returns the entrypoint string (e.g. "cli") used for the billing-pool assertion,
|
||||
// or null if absent. Lets callers assert the subscription-classified path.
|
||||
//
|
||||
// Resolution order (C-3, issue #133):
|
||||
// 1. PREFER the turn_duration system line's `entrypoint` — the authoritative
|
||||
// end-of-turn classifier emitted by builds that produce turn_duration
|
||||
// (e.g. claude-2.1.104/2.1.157 on PI231).
|
||||
// 2. FALL BACK to the `entrypoint` field on ANY ordinary transcript line
|
||||
// (assistant / user / attachment / system) — present on BOTH emitting and
|
||||
// non-emitting builds. Some claude builds (e.g. certain Mac mini transcripts)
|
||||
// do NOT emit a turn_duration line at all; reading ONLY turn_duration made the
|
||||
// caller's tui_entrypoint_mismatch assertion (server.mjs) get got:null every
|
||||
// turn and go blind. The entrypoint value is identical across line types within
|
||||
// a single interactive session (fixture-confirmed: every line in
|
||||
// complete-haiku.jsonl carrying `entrypoint` reads "cli"), so the fallback
|
||||
// yields the same classifier. Last-writer-wins on the fallback.
|
||||
export function verifyEntrypoint(events) {
|
||||
let fallback = null;
|
||||
for (const ev of events) {
|
||||
if (!ev || typeof ev !== "object") continue;
|
||||
if (ev.type === "system" && ev.subtype === "turn_duration" && ev.entrypoint != null) {
|
||||
return ev.entrypoint; // authoritative — short-circuit
|
||||
}
|
||||
if (ev.entrypoint != null) fallback = ev.entrypoint;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// ── C-1: honest AUTH-FAILURE banner detection (issue #133) ───────────────
|
||||
// When the interactive `claude` CLI hits an in-session error it does NOT crash —
|
||||
// it renders the error as ordinary assistant text in the transcript. The specific
|
||||
// failure C-1 exists to catch is R-1: EXPIRED / INVALID credentials, where every
|
||||
// turn comes back as the same one-line auth-failure banner and OCP, none the wiser,
|
||||
// caches that banner (server.mjs setCachedResponse), shares it via singleflight, and
|
||||
// records a model SUCCESS — so a hard auth error is silently served (and cached for
|
||||
// the 5-min TTL) as a real answer. The two live-reproduced banners on PI231
|
||||
// (2026-06-10) are:
|
||||
// "Please run /login · API Error: 401 Invalid authentication credentials" (69 chars)
|
||||
// "Failed to authenticate. API Error: 401 Invalid authentication credentials" (73 chars)
|
||||
//
|
||||
// WHY THE SCOPE IS NARROW (conservatism — the load-bearing design choice).
|
||||
// An earlier generalised rule (^<short-prefix>?API Error:\s*\d{3}\b.*$) was TOO
|
||||
// BROAD: its unbounded `.*` tail let any short prefix + "API Error: NNN" + an
|
||||
// arbitrarily long sentence match, so it KILLED legitimate long answers that merely
|
||||
// DISCUSS an API error (e.g. "API Error: 500 happened because the server was
|
||||
// overloaded. To fix this, retry with exponential backoff …"). That is the worst
|
||||
// outcome: a false-positive costs the user a missing answer AND a double-burn retry,
|
||||
// whereas the rare false-negative (caching one transient error for the 5-min TTL) is
|
||||
// cheap and self-healing. So C-1 is reframed from "detect ANY API error" to "detect
|
||||
// a claude-CLI AUTHENTICATION-FAILURE banner", and when unsure it PASSES (does not
|
||||
// kill). Transient 5xx server errors are deliberately NOT detected — they are not the
|
||||
// R-1 case and the conservative choice is to let them through.
|
||||
//
|
||||
// THE SIGNAL — a turn is an auth-failure banner only if ALL of these hold over the
|
||||
// WHOLE trimmed assistant text (a conjunction; any one failing => PASS):
|
||||
// 1. SHORT whole-message. Real banners are one short line (the two live samples are
|
||||
// 69 and 73 chars). Cap = TUI_ERR_MAX_LEN (100) — headroom over 73 for a
|
||||
// slightly longer future banner, while still rejecting multi-sentence prose. A
|
||||
// long answer that happens to discuss auth (no code chars, e.g. 226 chars) is
|
||||
// rejected on length alone.
|
||||
// 2. Contains "API Error: 4\d{2}" — auth failures are 4xx (401/403). This rejects
|
||||
// transient 5xx ("API Error: 500/503 …") and bare "HTTP 401 means unauthorized."
|
||||
// (no "API Error:" core).
|
||||
// 3. Contains an auth KEYWORD — authenticat | /login | credential (case-insensitive).
|
||||
// This rejects answers that quote a 4xx but are not auth banners, e.g.
|
||||
// "To debug a 401: the server returns API Error: 401 Unauthorized …"
|
||||
// ("Unauthorized" is authoriz-, not authenticat-; no /login, no credential).
|
||||
// 4. Contains NO backtick or quote char (` ' "). A real CLI banner is plain text;
|
||||
// backticked/quoted text signals an answer that is QUOTING the error rather than
|
||||
// being the banner, e.g. "You'll see `API Error: 401` … run /login to fix it."
|
||||
// (75 chars — passes 1-3 but is excluded here). This is the conservative tie-
|
||||
// breaker for short instructional answers.
|
||||
//
|
||||
// Worked matrix (all required cases pass — see test-features.mjs C-1 block):
|
||||
// KILL: "Please run /login · API Error: 401 Invalid authentication credentials"
|
||||
// KILL: "Failed to authenticate. API Error: 401 Invalid authentication credentials"
|
||||
// PASS: "API Error: 500 happened because the server was overloaded. …" (not 4xx)
|
||||
// PASS: "Failed to parse the config. Here are the API Error: 401 details …" (too long + no auth-kw)
|
||||
// PASS: "To debug a 401: … API Error: 401 Unauthorized, then you refresh …" (no auth-kw)
|
||||
// PASS: "Here is the handler … It logs the string API Error: 503 …" (not 4xx)
|
||||
// PASS: "You'll see `API Error: 401` … run /login to fix it." (has backtick)
|
||||
// PASS: "HTTP 401 means unauthorized." (no API Error core)
|
||||
// PASS: "The capital of France is Paris." (nothing matches)
|
||||
//
|
||||
// OPERATOR OVERRIDE (unchanged): CLAUDE_TUI_ERROR_PATTERNS lets an operator REPLACE
|
||||
// the default auth-banner detector with their own newline- or `||`-separated JS regex
|
||||
// source strings (each auto-anchored ^…$ over the trimmed text, case-insensitive). A
|
||||
// non-empty override uses ONLY those regexes (the narrowed default is bypassed); an
|
||||
// empty / whitespace-only override DISABLES detection entirely (escape hatch).
|
||||
|
||||
// Whole-message length cap for the default auth-banner detector. Real banners are
|
||||
// 69/73 chars; 100 gives headroom while still rejecting multi-sentence prose.
|
||||
const TUI_ERR_MAX_LEN = 100;
|
||||
// 4xx "API Error:" core — auth failures are 4xx (401/403), never 5xx.
|
||||
const TUI_ERR_4XX = /API Error:\s*4\d{2}\b/i;
|
||||
// Auth keyword — the message must be about authentication, not just quote a 4xx.
|
||||
const TUI_ERR_AUTH_KW = /authenticat|\/login|credential/i;
|
||||
// Code/quote chars — their presence signals prose QUOTING an error, not the banner.
|
||||
const TUI_ERR_CODE_CHAR = /[`'"]/;
|
||||
|
||||
// Default detector: returns true iff `trimmed` IS a claude-CLI auth-failure banner
|
||||
// (all four signals above). Conservative — any signal failing => false (PASS).
|
||||
function isDefaultAuthFailureBanner(trimmed) {
|
||||
if (trimmed.length > TUI_ERR_MAX_LEN) return false; // 1. short whole-message
|
||||
if (!TUI_ERR_4XX.test(trimmed)) return false; // 2. 4xx API Error core
|
||||
if (!TUI_ERR_AUTH_KW.test(trimmed)) return false; // 3. auth keyword
|
||||
if (TUI_ERR_CODE_CHAR.test(trimmed)) return false; // 4. no code/quote chars
|
||||
return true;
|
||||
}
|
||||
|
||||
// Compile an OPERATOR-SUPPLIED pattern set (override path only). Each source is
|
||||
// anchored ^…$ over the trimmed text and matched case-insensitively (`s` so `.` spans
|
||||
// a multi-line banner). A pattern that fails to compile is skipped (never throws into
|
||||
// the request path).
|
||||
function compileTuiErrorPatterns(raw) {
|
||||
const sources = String(raw).split(/\r?\n|\|\|/).map((s) => s.trim()).filter(Boolean);
|
||||
const out = [];
|
||||
for (const src of sources) {
|
||||
try { out.push(new RegExp(`^(?:${src})$`, "is")); } catch { /* skip bad pattern */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Returns the matched banner text (the trimmed assistant text) if `text` IS a claude-
|
||||
// CLI auth-failure banner in its entirety, else null. `patternsRaw` defaults to
|
||||
// process.env.CLAUDE_TUI_ERROR_PATTERNS:
|
||||
// - undefined → narrowed default auth-banner detector (isDefaultAuthFailureBanner).
|
||||
// - non-empty → operator regex override REPLACES the default.
|
||||
// - empty/ws → detection disabled (escape hatch).
|
||||
export function detectTuiUpstreamError(text, patternsRaw = process.env.CLAUDE_TUI_ERROR_PATTERNS) {
|
||||
if (typeof text !== "string") return null;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
if (patternsRaw == null) {
|
||||
return isDefaultAuthFailureBanner(trimmed) ? trimmed : null;
|
||||
}
|
||||
// Operator override path: empty/whitespace disables; otherwise use only their regexes.
|
||||
const patterns = compileTuiErrorPatterns(patternsRaw);
|
||||
if (patterns.length === 0) return null;
|
||||
for (const re of patterns) {
|
||||
if (re.test(trimmed)) return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Block until the session transcript is terminal (turn_duration / final
|
||||
// stop_reason) or the wall-clock cap elapses, polling the file (no fs.watch —
|
||||
// robust over NFS / editors). Returns { text, entrypoint, truncated }:
|
||||
// - text: latest assistant text.
|
||||
// - entrypoint: billing-pool classifier (see verifyEntrypoint), or null.
|
||||
// - truncated: FALSE when a terminal marker was reached (the turn completed);
|
||||
// TRUE when the wall-clock cap was hit with partial text but NO
|
||||
// terminal marker (the turn is INCOMPLETE — what we have is a
|
||||
// cut-off prefix). (C-2, issue #133.)
|
||||
//
|
||||
// Why `truncated` matters: previously the terminal-marker path and the
|
||||
// cap-with-partial-text path BOTH returned `{text, entrypoint}` identically, so
|
||||
// callClaudeTui could not tell a complete answer from a truncated one and cached +
|
||||
// returned the partial as finish_reason:stop (silent success). The caller now
|
||||
// throws on `truncated` so a cut-off turn is neither cached nor counted as success.
|
||||
// The field is additive — existing call sites that ignore it keep working.
|
||||
//
|
||||
// On cap with NO text at all, still throws (unchanged) — there is nothing to return.
|
||||
//
|
||||
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
|
||||
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
|
||||
// Resolution: pass an explicit `transcriptPath` (used by unit tests), OR pass
|
||||
// `home` + `sessionId` to resolve by glob each poll (production) — the transcript
|
||||
// file does not exist until the turn starts, so resolution happens inside the loop.
|
||||
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
|
||||
const deadline = Date.now() + wallclockMs;
|
||||
let lastText = "";
|
||||
let lastEntrypoint = null;
|
||||
while (Date.now() < deadline) {
|
||||
const resolved = p || findTranscriptPath(home, sessionId);
|
||||
if (resolved && existsSync(resolved)) {
|
||||
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
|
||||
lastText = extractLatestAssistantText(events) || lastText;
|
||||
const ep = verifyEntrypoint(events);
|
||||
if (ep != null) lastEntrypoint = ep;
|
||||
// Terminal marker reached → the turn is COMPLETE.
|
||||
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint, truncated: false };
|
||||
}
|
||||
await sleep(pollMs);
|
||||
}
|
||||
// Cap elapsed with no terminal marker. If we have partial text, flag it truncated
|
||||
// so the caller rejects it (don't cache / don't count as success). No text at all
|
||||
// → throw (nothing to return).
|
||||
if (lastText) return { text: lastText, entrypoint: lastEntrypoint, truncated: true };
|
||||
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# OCP Constitution
|
||||
<!-- Created by spec-kit integration (github/spec-kit v0.7.3) -->
|
||||
<!--
|
||||
NOTE TO CLAUDE SESSIONS: This file is the spec-kit constitution for the OCP repo.
|
||||
It coexists with — and is subordinate to — the project-specific constraints in
|
||||
~/ocp/AGENTS.md. Always read AGENTS.md first for memory policies, protected files,
|
||||
and development discipline rules. This constitution covers spec-driven development
|
||||
principles for new features.
|
||||
-->
|
||||
|
||||
## Core Principles
|
||||
|
||||
### I. Spec-First Development
|
||||
All non-trivial features begin with a specification in `specs/` before any code is written.
|
||||
Use `/speckit-specify` to create the spec, `/speckit-plan` for the implementation plan,
|
||||
`/speckit-tasks` to generate the task list, then `/speckit-implement` to execute.
|
||||
|
||||
### II. Server Integrity (NON-NEGOTIABLE)
|
||||
`server.mjs`, `models.json`, `package.json`, and `keys.mjs` are protected files.
|
||||
No spec-driven workflow may modify them without explicit approval from the project
|
||||
maintainer. If a spec calls for changes to these files, halt and escalate.
|
||||
|
||||
### III. Test-First
|
||||
Implementation tasks include tests before code. The `/speckit-implement` command
|
||||
must follow TDD for any logic touching the proxy or model-routing paths.
|
||||
|
||||
### IV. Additive by Default
|
||||
Features should be additive — new endpoints, new model entries, new config flags —
|
||||
not modifications to existing contracts unless the spec explicitly justifies the
|
||||
breaking change and the plan documents a migration path.
|
||||
|
||||
### V. Cross-Device Compatibility
|
||||
OCP runs on multiple machines. Specs and plans committed to `specs/` serve as the
|
||||
cross-device handoff artifact. Keep `specs/NNN/tasks.md` updated as the canonical
|
||||
work-state file.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Follow CC 开发铁律 v1.3 (Iron Rules) as loaded via `/cc-rules` in CLAUDE.md.
|
||||
- One PR per reviewable unit (Iron Rule 11 IDR).
|
||||
- Independent review required before merge (Iron Rule 10).
|
||||
- Pre-brainstorm prior-art search required (Iron Rule 12).
|
||||
|
||||
## Governance
|
||||
|
||||
This constitution is subordinate to `AGENTS.md` for project-specific memory policy
|
||||
and to CC 开发铁律 for development discipline. Amendments require a PR reviewed by
|
||||
the project maintainer.
|
||||
|
||||
**Version**: 1.0.0 | **Ratified**: 2026-04-21 | **Last Amended**: 2026-04-21
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"$schema": "./models.schema.json",
|
||||
"version": 1,
|
||||
"models": [
|
||||
{
|
||||
"id": "claude-opus-4-8",
|
||||
"displayName": "Claude Opus 4.8",
|
||||
"openclawName": "Claude Opus 4.8 (via CLI)",
|
||||
"reasoning": true,
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 16384
|
||||
},
|
||||
{
|
||||
"id": "claude-opus-4-7",
|
||||
"displayName": "Claude Opus 4.7",
|
||||
"openclawName": "Claude Opus 4.7 (via CLI)",
|
||||
"reasoning": true,
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 16384
|
||||
},
|
||||
{
|
||||
"id": "claude-opus-4-6",
|
||||
"displayName": "Claude Opus 4.6",
|
||||
"openclawName": "Claude Opus 4.6 (via CLI)",
|
||||
"reasoning": true,
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 16384
|
||||
},
|
||||
{
|
||||
"id": "claude-sonnet-4-6",
|
||||
"displayName": "Claude Sonnet 4.6",
|
||||
"openclawName": "Claude Sonnet 4.6 (via CLI)",
|
||||
"reasoning": true,
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 16384
|
||||
},
|
||||
{
|
||||
"id": "claude-haiku-4-5-20251001",
|
||||
"displayName": "Claude Haiku 4.5",
|
||||
"openclawName": "Claude Haiku 4.5 (via CLI)",
|
||||
"reasoning": false,
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 8192
|
||||
}
|
||||
],
|
||||
"aliases": {
|
||||
"opus": "claude-opus-4-8",
|
||||
"sonnet": "claude-sonnet-4-6",
|
||||
"haiku": "claude-haiku-4-5-20251001"
|
||||
},
|
||||
"legacyAliases": {
|
||||
"claude-opus-4": "claude-opus-4-7",
|
||||
"claude-haiku-4": "claude-haiku-4-5-20251001",
|
||||
"claude-haiku-4-5": "claude-haiku-4-5-20251001"
|
||||
}
|
||||
}
|
||||
@@ -8,21 +8,18 @@ set -euo pipefail
|
||||
|
||||
PROXY="http://127.0.0.1:3456"
|
||||
|
||||
# Auth header for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
||||
_AUTH_HEADER=""
|
||||
# Auth args for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
||||
# Using a bash array preserves word boundaries — no eval needed.
|
||||
_AUTH_ARGS=()
|
||||
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
|
||||
_AUTH_HEADER="-H \"Authorization: Bearer $OCP_ADMIN_KEY\""
|
||||
_AUTH_ARGS=(-H "Authorization: Bearer $OCP_ADMIN_KEY")
|
||||
elif [[ -f "$HOME/.ocp/admin-key" ]]; then
|
||||
_AUTH_HEADER="-H \"Authorization: Bearer $(cat "$HOME/.ocp/admin-key")\""
|
||||
_AUTH_ARGS=(-H "Authorization: Bearer $(cat "$HOME/.ocp/admin-key")")
|
||||
fi
|
||||
|
||||
# Wrapper: curl with optional auth
|
||||
_curl() {
|
||||
if [[ -n "$_AUTH_HEADER" ]]; then
|
||||
eval curl "$_AUTH_HEADER" "$@"
|
||||
else
|
||||
curl "$@"
|
||||
fi
|
||||
curl "${_AUTH_ARGS[@]}" "$@"
|
||||
}
|
||||
|
||||
_json() { python3 -m json.tool 2>/dev/null || cat; }
|
||||
@@ -604,7 +601,7 @@ cmd_restart() {
|
||||
self_r="${BASH_SOURCE[0]}"
|
||||
while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done
|
||||
script_dir="$(cd "$(dirname "$self_r")" && pwd)"
|
||||
nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
|
||||
DISABLE_AUTOUPDATER=1 nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
|
||||
fi
|
||||
sleep 3
|
||||
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
|
||||
@@ -695,25 +692,35 @@ for e in d.get('errors', []):
|
||||
# ── update ──────────────────────────────────────────────────────────────
|
||||
cmd_update_help() {
|
||||
cat <<'EOF'
|
||||
ocp update — Update OCP to the latest version
|
||||
ocp update — Smart upgrade dispatcher
|
||||
|
||||
Pulls the latest code from GitHub, restarts the proxy service,
|
||||
and optionally syncs the plugin to the OpenClaw extensions directory.
|
||||
Runs `ocp doctor` internally to choose the right path:
|
||||
• Patch bump (same minor): light path (git pull + npm install + restart)
|
||||
• Cross-minor (e.g. v3.10→v3.14): full path with snapshot + post-flight
|
||||
• Old version (< v3.4.0): fresh-install (asks first; AI passes --yes)
|
||||
|
||||
Usage:
|
||||
ocp update Pull latest and restart
|
||||
ocp update --check Check for updates without applying
|
||||
ocp update Smart auto-pick path
|
||||
ocp update --check Show available updates, don't apply
|
||||
ocp update --dry-run Preview the plan, don't mutate
|
||||
ocp update --target v3.13.0 Pin a specific version
|
||||
ocp update --yes Skip y/N prompts (AI agents pass this)
|
||||
ocp update --rollback Restore the most recent upgrade snapshot
|
||||
ocp update --rollback --list List available snapshots
|
||||
ocp update --rollback <path> Restore a specific snapshot
|
||||
ocp update --rollback --dry-run Preview rollback plan
|
||||
ocp update --rollback --gc Delete old snapshots (keep last 5, or <30 days)
|
||||
ocp update --rollback --gc --dry-run Preview what would be deleted
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_update() {
|
||||
local script_dir self
|
||||
self="${BASH_SOURCE[0]}"
|
||||
# Resolve symlinks (e.g. ~/.local/bin/ocp → real location)
|
||||
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
|
||||
script_dir="$(cd "$(dirname "$self")" && pwd)"
|
||||
|
||||
# Check-only mode
|
||||
# Pass through --check fast path (existing behaviour)
|
||||
if [[ "${1:-}" == "--check" ]]; then
|
||||
cd "$script_dir"
|
||||
git fetch origin main --quiet 2>/dev/null || true
|
||||
@@ -730,62 +737,104 @@ cmd_update() {
|
||||
echo " Status: ✓ Up to date"
|
||||
else
|
||||
echo " Status: $behind commit(s) behind"
|
||||
echo ""
|
||||
echo " Run 'ocp update' to apply."
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Updating OCP..."
|
||||
echo ""
|
||||
# Rollback path
|
||||
if [[ "${1:-}" == "--rollback" ]]; then
|
||||
shift
|
||||
exec node "$script_dir/scripts/upgrade.mjs" --rollback "$@"
|
||||
fi
|
||||
|
||||
# 1. Pull latest
|
||||
# Doctor-driven path selection
|
||||
local kind
|
||||
kind=$(node "$script_dir/scripts/doctor.mjs" --json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['next_action']['kind'])" 2>/dev/null || echo "unknown")
|
||||
|
||||
case "$kind" in
|
||||
noop)
|
||||
echo "Already at latest. Nothing to do."
|
||||
return 0
|
||||
;;
|
||||
update)
|
||||
_cmd_update_light "$script_dir"
|
||||
;;
|
||||
upgrade|fresh_install)
|
||||
exec node "$script_dir/scripts/upgrade.mjs" "$@"
|
||||
;;
|
||||
fix_oauth|fix_service)
|
||||
echo "Pre-upgrade check failed: $kind"
|
||||
echo "Run \`ocp doctor\` for details and ai_executable steps."
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
echo "Unknown doctor kind: $kind. Run \`ocp doctor --json\` to inspect."
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Existing light-path body extracted into a helper so cmd_update can call it conditionally.
|
||||
_cmd_update_light() {
|
||||
local script_dir="$1"
|
||||
echo "Updating OCP (light path)..."
|
||||
cd "$script_dir"
|
||||
local old_ver
|
||||
local old_ver new_ver
|
||||
old_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
||||
|
||||
echo " Pulling latest from GitHub..."
|
||||
if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then
|
||||
echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local new_ver
|
||||
new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
||||
|
||||
if [[ "$old_ver" == "$new_ver" ]]; then
|
||||
echo " ✓ Already at latest (v$new_ver)"
|
||||
else
|
||||
echo " ✓ Updated v$old_ver → v$new_ver"
|
||||
fi
|
||||
|
||||
# 2. Sync plugin to extensions dir
|
||||
# Sync plugin (existing logic preserved)
|
||||
local ext_dir="$HOME/.openclaw/extensions/ocp"
|
||||
if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then
|
||||
echo ""
|
||||
echo " Syncing OCP plugin..."
|
||||
cp "$script_dir/ocp-plugin/index.js" "$ext_dir/index.js" 2>/dev/null
|
||||
cp "$script_dir/ocp-plugin/package.json" "$ext_dir/package.json" 2>/dev/null
|
||||
cp "$script_dir/ocp-plugin/openclaw.plugin.json" "$ext_dir/openclaw.plugin.json" 2>/dev/null
|
||||
echo " ✓ Plugin synced to $ext_dir"
|
||||
echo " ✓ Plugin synced"
|
||||
fi
|
||||
|
||||
if command -v node >/dev/null 2>&1 && [[ -f "$script_dir/scripts/sync-openclaw.mjs" ]]; then
|
||||
echo " Syncing OpenClaw registry..."
|
||||
node "$script_dir/scripts/sync-openclaw.mjs" 2>&1 | sed 's/^/ /' || echo " ⚠ OpenClaw sync failed (non-fatal)"
|
||||
fi
|
||||
|
||||
# 3. Restart proxy
|
||||
echo ""
|
||||
echo " Restarting proxy..."
|
||||
cmd_restart > /dev/null 2>&1
|
||||
sleep 2
|
||||
}
|
||||
|
||||
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
|
||||
local running_ver
|
||||
running_ver=$(curl -sf --max-time 5 "$PROXY/health" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['version'])" 2>/dev/null || echo "?")
|
||||
echo " ✓ Proxy running (v$running_ver)"
|
||||
else
|
||||
echo " ⚠ Proxy not responding — check: ocp health"
|
||||
fi
|
||||
cmd_doctor_help() {
|
||||
cat <<'EOF'
|
||||
ocp doctor — Health & upgrade-readiness check
|
||||
|
||||
echo ""
|
||||
echo "Done."
|
||||
Runs a series of checks (Node version, git state, service health,
|
||||
OAuth token, plist customisation, OpenClaw provider) and emits either
|
||||
human-readable PASS/WARN/FAIL output or a JSON next_action that
|
||||
AI agents can execute.
|
||||
|
||||
Usage:
|
||||
ocp doctor Human-readable output
|
||||
ocp doctor --json JSON for AI agents and ocp update internal use
|
||||
ocp doctor --check oauth Fast path: OAuth check only
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_doctor() {
|
||||
local script_dir self
|
||||
self="${BASH_SOURCE[0]}"
|
||||
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
|
||||
script_dir="$(cd "$(dirname "$self")" && pwd)"
|
||||
exec node "$script_dir/scripts/doctor.mjs" "$@"
|
||||
}
|
||||
|
||||
# ── help ─────────────────────────────────────────────────────────────────
|
||||
@@ -853,6 +902,7 @@ case "$subcmd" in
|
||||
lan) cmd_lan ;;
|
||||
connect) cmd_connect "$@" ;;
|
||||
restart) cmd_restart "${1:-}" ;;
|
||||
update) cmd_update "${1:-}" ;;
|
||||
doctor) cmd_doctor "$@" ;;
|
||||
update) cmd_update "$@" ;;
|
||||
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
|
||||
esac
|
||||
|
||||
+22
-8
@@ -506,9 +506,11 @@ main() {
|
||||
echo ""
|
||||
|
||||
# Step 2.5: auto-discover anonymous key from /health (issue #12 §14 Path A).
|
||||
# When the OCP admin set PROXY_ANONYMOUS_KEY, the server advertises it via
|
||||
# /health.anonymousKey. If the user didn't pass --key, use it automatically so
|
||||
# `ocp-connect <host>` works zero-config for OpenClaw multi-agent setups.
|
||||
# The server advertises anonymousKey in /health ONLY when the admin has set
|
||||
# PROXY_ADVERTISE_ANON_KEY=1 (default off — /health is unauthenticated, so
|
||||
# advertising exposes the shared key to any LAN-reachable device; issue #109).
|
||||
# Localhost callers always receive it regardless. When the field is absent,
|
||||
# ocp-connect falls back to anonymous access / interactive --key (step 3 below).
|
||||
if [[ -z "$key" ]]; then
|
||||
local anon_key
|
||||
anon_key=$(echo "$health_json" | python3 -c "
|
||||
@@ -582,11 +584,19 @@ print(k if k else '')
|
||||
if [[ "${SHELL:-}" == */fish ]]; then
|
||||
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
||||
rc_files+=("$HOME/.bashrc")
|
||||
elif $is_mac; then
|
||||
# macOS: default shell since Catalina (2019) is zsh.
|
||||
# Always write ~/.zshrc (create if absent — zsh tolerates an empty file).
|
||||
# Only write ~/.bashrc if it already exists (don't surprise users with new files).
|
||||
[[ -f "$HOME/.bashrc" ]] && rc_files+=("$HOME/.bashrc")
|
||||
# zshrc: always include on macOS; create the file if it doesn't exist yet
|
||||
[[ -f "$HOME/.zshrc" ]] || touch "$HOME/.zshrc"
|
||||
rc_files+=("$HOME/.zshrc")
|
||||
else
|
||||
# Always write both on macOS (default shell is zsh but some tools source bashrc)
|
||||
# Linux / other: write to whichever rc files already exist or match current shell
|
||||
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
|
||||
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
|
||||
# If neither exists, create for current shell
|
||||
# If neither exists, fall back to creating one for the current shell
|
||||
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
||||
fi
|
||||
|
||||
@@ -624,11 +634,12 @@ PYEOF
|
||||
{
|
||||
echo ""
|
||||
echo "# OCP LAN (added by ocp connect)"
|
||||
echo "export OPENAI_BASE_URL=$base_url/v1"
|
||||
echo "export OPENAI_BASE_URL='$base_url/v1'"
|
||||
if [[ -n "$key" ]]; then
|
||||
echo "export OPENAI_API_KEY=$key"
|
||||
echo "export OPENAI_API_KEY='$key'"
|
||||
fi
|
||||
} >> "$rc_file"
|
||||
chmod 600 "$rc_file" 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo " Shell config:"
|
||||
@@ -661,6 +672,7 @@ PYEOF
|
||||
echo "OPENAI_API_KEY=$key"
|
||||
fi
|
||||
} > "$env_dir/ocp.conf"
|
||||
chmod 600 "$env_dir/ocp.conf" 2>/dev/null || true
|
||||
echo ""
|
||||
echo " System-level (systemd):"
|
||||
echo " ✓ $env_dir/ocp.conf"
|
||||
@@ -705,7 +717,9 @@ PYEOF
|
||||
|
||||
echo ""
|
||||
echo " Done. Reload your shell to apply:"
|
||||
echo " source $rc_file"
|
||||
for rc_file in "${rc_files[@]}"; do
|
||||
echo " source $rc_file"
|
||||
done
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+23
-10
@@ -1,9 +1,19 @@
|
||||
/**
|
||||
* OCP Plugin — registers /ocp as a native slash command in OpenClaw gateway.
|
||||
* Calls the local claude-proxy at http://127.0.0.1:3456 and formats the response.
|
||||
* Calls the local claude-proxy and formats the response.
|
||||
*
|
||||
* Port resolution (in priority order):
|
||||
* 1. OCP_PROXY_URL env (full URL, e.g. http://10.0.0.5:3456)
|
||||
* 2. CLAUDE_PROXY_PORT env (port only; localhost assumed)
|
||||
* 3. Fallback: http://127.0.0.1:3456 (OCP server source default since v1.0)
|
||||
*
|
||||
* If a particular host's OCP plist injects a non-default CLAUDE_PROXY_PORT,
|
||||
* the OpenClaw launchd plist for that host must also inject the same
|
||||
* CLAUDE_PROXY_PORT into the plugin's env, or the plugin will fall back to
|
||||
* 3456 and miss the server.
|
||||
*/
|
||||
|
||||
const PROXY = "http://127.0.0.1:3456";
|
||||
const PROXY = process.env.OCP_PROXY_URL
|
||||
|| (process.env.CLAUDE_PROXY_PORT ? `http://127.0.0.1:${process.env.CLAUDE_PROXY_PORT}` : "http://127.0.0.1:3456");
|
||||
|
||||
// Wrap output in monospace code block for Telegram/Discord alignment
|
||||
function mono(text) { return "```\n" + text + "\n```"; }
|
||||
@@ -198,31 +208,34 @@ async function cmdTest() {
|
||||
async function cmdRestart(args) {
|
||||
const target = (args || "").trim().toLowerCase();
|
||||
const { execSync } = await import("node:child_process");
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
|
||||
const macProxy = `launchctl kickstart -k gui/${uid}/dev.ocp.proxy`;
|
||||
const macGateway = `launchctl kickstart -k gui/${uid}/ai.openclaw.gateway`;
|
||||
try {
|
||||
if (target === "gateway") {
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
|
||||
execSync(macGateway, { timeout: 15000 });
|
||||
return "✓ Gateway restarted";
|
||||
} else if (target === "all") {
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
|
||||
execSync(macProxy, { timeout: 15000 });
|
||||
// Gateway restart will kill this plugin too, so do it last
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
|
||||
execSync(macGateway, { timeout: 15000 });
|
||||
return "✓ Proxy + Gateway restarted";
|
||||
} else {
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
|
||||
execSync(macProxy, { timeout: 15000 });
|
||||
return "✓ Proxy restarted";
|
||||
}
|
||||
} catch (e) {
|
||||
// Try systemd for Linux
|
||||
// Linux: systemd user services
|
||||
try {
|
||||
if (target === "gateway") {
|
||||
execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 });
|
||||
return "✓ Gateway restarted";
|
||||
} else {
|
||||
execSync("systemctl --user restart openclaw-proxy 2>/dev/null || pkill -f 'node.*server.mjs' && sleep 2 && cd ~/.openclaw/projects/*/; node server.mjs &", { timeout: 15000, shell: true });
|
||||
execSync("systemctl --user restart ocp-proxy", { timeout: 15000 });
|
||||
return "✓ Proxy restarted";
|
||||
}
|
||||
} catch (e2) {
|
||||
return `✗ Restart failed: ${e2.message?.slice(0, 100)}`;
|
||||
return `✗ Restart failed: ${e2.message?.slice(0, 100)}. Run \`ocp restart\` on the server host manually.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "ocp",
|
||||
"name": "OCP Commands",
|
||||
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
|
||||
"version": "3.3.1",
|
||||
"version": "3.16.2",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@@ -10,7 +10,7 @@
|
||||
"proxyUrl": {
|
||||
"type": "string",
|
||||
"default": "http://127.0.0.1:3456",
|
||||
"description": "URL of the Claude proxy"
|
||||
"description": "URL of the Claude proxy. Overridable via OCP_PROXY_URL or CLAUDE_PROXY_PORT env."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ocp",
|
||||
"version": "3.3.1",
|
||||
"version": "3.12.0",
|
||||
"description": "Slash commands for the OpenClaw Proxy",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
# openclaw-claude-proxy v2.3.0
|
||||
|
||||
Use your **Claude Pro / Max** subscription as an **OpenAI-compatible local endpoint**.
|
||||
|
||||
`openclaw-claude-proxy` accepts OpenAI-style chat completion requests, then runs them through the local `claude` CLI. That means tools which only know how to talk to an OpenAI API can still use Claude models through a local base URL.
|
||||
|
||||
## Why v2 matters
|
||||
|
||||
v2 is not just a bugfix release. It changes the runtime model:
|
||||
|
||||
- **On-demand spawning** instead of fragile warm pools
|
||||
- **Session resume** support for multi-turn conversations
|
||||
- **Faster fallback** with first-byte timeout + lower default request timeout
|
||||
- **Full tool access** via configurable allowed tools
|
||||
- **MCP config + system prompt pass-through**
|
||||
- **Health / sessions / diagnostics endpoints**
|
||||
- **Safe coexistence with Claude Code channel / interactive mode**
|
||||
|
||||
## The short pitch
|
||||
|
||||
If Claude's new channel workflow feels useful, OCP v2 now covers the same practical ground for many local agent/tooling setups:
|
||||
|
||||
- multi-turn continuity
|
||||
- tool-enabled Claude runs
|
||||
- local orchestration
|
||||
- stable process isolation
|
||||
- coexistence with your normal Claude Code workflow
|
||||
|
||||
And it adds a few advantages that channel users usually still want:
|
||||
|
||||
- **OpenAI-compatible HTTP API** for existing tools
|
||||
- **Works with OpenClaw, Cursor, Continue, Open WebUI, LangChain, and anything with custom base URL support**
|
||||
- **Explicit health checks and diagnostics**
|
||||
- **Model/provider failover can happen outside Claude itself**
|
||||
- **No lock-in to a single client UX**
|
||||
|
||||
## Coexistence with Claude Code channel
|
||||
|
||||
This is the important part: **OCP v2 does not replace Claude Code channel, and it does not need to. They can coexist on the same machine.**
|
||||
|
||||
### Claude Code channel / interactive mode
|
||||
- persistent interactive workflow
|
||||
- MCP protocol / in-process experience
|
||||
- great when you are directly driving Claude Code
|
||||
|
||||
### OCP v2
|
||||
- local HTTP server on `localhost`
|
||||
- OpenAI-compatible API surface
|
||||
- per-request `claude -p` execution with session resume when you want continuity
|
||||
- ideal for external tools, routers, orchestrators, OpenClaw providers, and local automation
|
||||
|
||||
### Practical takeaway
|
||||
Use both:
|
||||
- use **Claude Code channel** when you want Claude's native interactive workflow
|
||||
- use **OCP v2** when another app expects an OpenAI-style API but you still want to use Claude
|
||||
|
||||
They solve adjacent problems, not identical ones.
|
||||
|
||||
## Unique advantages of OCP v2
|
||||
|
||||
1. **API compatibility**
|
||||
- Drop into tools that already support OpenAI-compatible endpoints.
|
||||
- No need to wait for each tool to add native Claude channel support.
|
||||
|
||||
2. **Routing freedom**
|
||||
- Put OCP behind OpenClaw or another router.
|
||||
- Mix Claude with fallback providers outside the Claude client itself.
|
||||
|
||||
3. **Operational visibility**
|
||||
- `/health`, `/sessions`, recent errors, auth state, resolved binary path, timeout config.
|
||||
- Much easier to debug than a black-box local integration.
|
||||
|
||||
4. **Safer runtime model**
|
||||
- v2 removes the old pre-spawn pool crash loop.
|
||||
- No stale workers, no degraded warm pool states, fewer hidden failure modes.
|
||||
|
||||
5. **Configurable tools and behavior**
|
||||
- allowed tools
|
||||
- skip permissions mode
|
||||
- system prompt append
|
||||
- MCP config passthrough
|
||||
- session TTL
|
||||
- concurrency limits
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dtzp555-max/openclaw-claude-proxy
|
||||
cd openclaw-claude-proxy
|
||||
npm install
|
||||
node server.mjs
|
||||
```
|
||||
|
||||
Default base URL:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3456/v1
|
||||
```
|
||||
|
||||
## Quick OpenAI-compatible config
|
||||
|
||||
```json
|
||||
{
|
||||
"baseURL": "http://127.0.0.1:3456/v1",
|
||||
"apiKey": "anything"
|
||||
}
|
||||
```
|
||||
|
||||
If `PROXY_API_KEY` is unset, auth is disabled. If you set it, pass it as a Bearer token.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---:|---|
|
||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
||||
| `CLAUDE_BIN` | auto-detect | Claude CLI binary path |
|
||||
| `CLAUDE_TIMEOUT` | `120000` | Overall per-request timeout |
|
||||
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `30000` | Abort if Claude produces no stdout quickly |
|
||||
| `CLAUDE_ALLOWED_TOOLS` | expanded set | Comma-separated allowed tools |
|
||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass permission checks |
|
||||
| `CLAUDE_SYSTEM_PROMPT` | unset | Append a system prompt to every request |
|
||||
| `CLAUDE_MCP_CONFIG` | unset | Path to MCP config JSON |
|
||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session TTL |
|
||||
| `CLAUDE_MAX_CONCURRENT` | `5` | Max concurrent Claude processes |
|
||||
| `PROXY_API_KEY` | unset | Optional Bearer token auth |
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /health`
|
||||
- `GET /v1/models`
|
||||
- `POST /v1/chat/completions`
|
||||
- `GET /sessions`
|
||||
- `DELETE /sessions`
|
||||
|
||||
## Example health response highlights
|
||||
|
||||
`/health` reports useful operational state such as:
|
||||
|
||||
- resolved Claude binary path
|
||||
- whether the binary is executable
|
||||
- auth status
|
||||
- timeouts
|
||||
- current sessions
|
||||
- recent errors
|
||||
- basic request stats
|
||||
|
||||
## Version highlights
|
||||
|
||||
### v2.3.0
|
||||
- clarified v2 positioning and coexistence story in docs
|
||||
- officially documents faster fallback defaults
|
||||
- recommends OCP v2 as the API bridge layer for Claude-powered tools
|
||||
|
||||
### v2.2.0
|
||||
- first-byte timeout
|
||||
- reduced default timeout for faster fallback
|
||||
|
||||
### v2.0.0
|
||||
- on-demand architecture
|
||||
- session management
|
||||
- full tool access
|
||||
- MCP + system prompt passthrough
|
||||
- concurrency control
|
||||
- coexistence with Claude Code interactive mode
|
||||
|
||||
## When to use OCP v2 vs Claude channel
|
||||
|
||||
Choose **OCP v2** when:
|
||||
- your app only supports OpenAI-compatible endpoints
|
||||
- you want routing / failover outside Claude
|
||||
- you want explicit health checks and local diagnostics
|
||||
- you want Claude available to multiple local tools through one endpoint
|
||||
|
||||
Choose **Claude channel** when:
|
||||
- you are primarily living inside Claude Code itself
|
||||
- you want Claude's native interactive workflow directly
|
||||
|
||||
Use **both together** when you want the best of both worlds.
|
||||
|
||||
---
|
||||
|
||||
If you already pay for Claude Pro or Max, OCP v2 turns that subscription into a practical local API bridge for the rest of your tooling stack.
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "openclaw-claude-proxy",
|
||||
"version": "2.4.0",
|
||||
"description": "OpenAI-compatible proxy for Claude CLI v2 — per-model circuit breaker, adaptive first-byte timeout, structured logging",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"openclaw-claude-proxy": "./server.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
"setup": "node setup.mjs"
|
||||
},
|
||||
"keywords": [
|
||||
"openclaw",
|
||||
"claude",
|
||||
"proxy",
|
||||
"openai",
|
||||
"anthropic"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dtzp555-max/openclaw-claude-proxy"
|
||||
}
|
||||
}
|
||||
@@ -1,643 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* openclaw-claude-proxy v2.4.0 — OpenAI-compatible proxy for Claude CLI
|
||||
*
|
||||
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
|
||||
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
|
||||
*
|
||||
* v2.4.0:
|
||||
* - Per-model circuit breaker: consecutive timeouts temporarily mark a model as degraded
|
||||
* - Adaptive first-byte timeout: scales by model tier + prompt size
|
||||
* - Structured JSON logging for key events (easier to parse/alert on)
|
||||
* - On-demand spawning (no pool), session management, full tool access
|
||||
*
|
||||
* Env vars:
|
||||
* CLAUDE_PROXY_PORT — listen port (default: 3456)
|
||||
* CLAUDE_BIN — path to claude binary (default: auto-detect)
|
||||
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000)
|
||||
* CLAUDE_FIRST_BYTE_TIMEOUT — base first-byte timeout in ms (default: 45000)
|
||||
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
||||
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
|
||||
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
||||
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
||||
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
||||
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 5)
|
||||
* CLAUDE_BREAKER_THRESHOLD — consecutive timeouts before circuit opens (default: 3)
|
||||
* CLAUDE_BREAKER_COOLDOWN — ms to wait before retrying after circuit opens (default: 60000)
|
||||
* PROXY_API_KEY — Bearer token for API auth (optional)
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync, accessSync, constants } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||
|
||||
// ── Resolve claude binary ───────────────────────────────────────────────
|
||||
// Priority: CLAUDE_BIN env > well-known paths > which lookup
|
||||
// Fail-fast if not found — never start with an unresolvable binary.
|
||||
function resolveClaude() {
|
||||
if (process.env.CLAUDE_BIN) {
|
||||
try {
|
||||
accessSync(process.env.CLAUDE_BIN, constants.X_OK);
|
||||
return process.env.CLAUDE_BIN;
|
||||
} catch {
|
||||
console.error(`FATAL: CLAUDE_BIN="${process.env.CLAUDE_BIN}" is set but not executable.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
"/opt/homebrew/bin/claude",
|
||||
"/usr/local/bin/claude",
|
||||
"/usr/bin/claude",
|
||||
join(process.env.HOME || "", ".local/bin/claude"),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim();
|
||||
if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; }
|
||||
} catch {}
|
||||
|
||||
console.error(
|
||||
"FATAL: claude binary not found.\n" +
|
||||
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
|
||||
" Checked: " + candidates.join(", ")
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Configuration ───────────────────────────────────────────────────────
|
||||
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
||||
const CLAUDE = resolveClaude();
|
||||
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10);
|
||||
const BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "45000", 10);
|
||||
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
|
||||
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
|
||||
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
||||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent"
|
||||
).split(",").map(s => s.trim()).filter(Boolean);
|
||||
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
||||
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
||||
const SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
||||
const MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "5", 10);
|
||||
const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "3", 10);
|
||||
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "60000", 10);
|
||||
|
||||
const VERSION = _pkg.version;
|
||||
const START_TIME = Date.now();
|
||||
|
||||
// ── Structured logging helper ───────────────────────────────────────────
|
||||
function logEvent(level, event, data = {}) {
|
||||
const entry = { ts: new Date().toISOString(), level, event, ...data };
|
||||
if (level === "error" || level === "warn") {
|
||||
console.error(JSON.stringify(entry));
|
||||
} else {
|
||||
console.log(JSON.stringify(entry));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-model circuit breaker ───────────────────────────────────────────
|
||||
// Tracks consecutive timeouts per model. When threshold is reached, the
|
||||
// model is marked "open" (degraded) for BREAKER_COOLDOWN ms. During that
|
||||
// window, requests for this model fail fast with a clear error instead of
|
||||
// waiting for yet another timeout that would block the gateway.
|
||||
const breakers = new Map(); // cliModel → { failures, state, openedAt }
|
||||
|
||||
function getBreakerState(cliModel) {
|
||||
if (!breakers.has(cliModel)) {
|
||||
breakers.set(cliModel, { failures: 0, state: "closed", openedAt: 0 });
|
||||
}
|
||||
const b = breakers.get(cliModel);
|
||||
|
||||
// Auto-recover: if cooldown has elapsed, transition to half-open
|
||||
if (b.state === "open" && Date.now() - b.openedAt >= BREAKER_COOLDOWN) {
|
||||
b.state = "half-open";
|
||||
logEvent("info", "breaker_half_open", { model: cliModel, cooldownMs: BREAKER_COOLDOWN });
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
function breakerRecordSuccess(cliModel) {
|
||||
const b = getBreakerState(cliModel);
|
||||
if (b.failures > 0 || b.state !== "closed") {
|
||||
logEvent("info", "breaker_reset", { model: cliModel, previousFailures: b.failures, previousState: b.state });
|
||||
}
|
||||
b.failures = 0;
|
||||
b.state = "closed";
|
||||
b.openedAt = 0;
|
||||
}
|
||||
|
||||
function breakerRecordTimeout(cliModel) {
|
||||
const b = getBreakerState(cliModel);
|
||||
b.failures++;
|
||||
logEvent("warn", "breaker_failure", { model: cliModel, consecutiveFailures: b.failures, threshold: BREAKER_THRESHOLD });
|
||||
|
||||
if (b.failures >= BREAKER_THRESHOLD && b.state !== "open") {
|
||||
b.state = "open";
|
||||
b.openedAt = Date.now();
|
||||
logEvent("error", "breaker_open", { model: cliModel, failures: b.failures, cooldownMs: BREAKER_COOLDOWN });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model mapping ───────────────────────────────────────────────────────
|
||||
// Maps request model IDs and aliases to canonical claude CLI model IDs.
|
||||
const MODEL_MAP = {
|
||||
"claude-opus-4-6": "claude-opus-4-6",
|
||||
"claude-sonnet-4-6": "claude-sonnet-4-6",
|
||||
"claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
|
||||
"claude-opus-4": "claude-opus-4-6",
|
||||
"claude-haiku-4": "claude-haiku-4-5-20251001",
|
||||
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
|
||||
"opus": "claude-opus-4-6",
|
||||
"sonnet": "claude-sonnet-4-6",
|
||||
"haiku": "claude-haiku-4-5-20251001",
|
||||
};
|
||||
|
||||
const MODELS = [
|
||||
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||
];
|
||||
|
||||
// ── Session management ──────────────────────────────────────────────────
|
||||
// Maps conversation IDs (from caller) to Claude CLI session UUIDs.
|
||||
// Enables --resume for multi-turn conversations, reducing token waste.
|
||||
const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, s] of sessions) {
|
||||
if (now - s.lastUsed > SESSION_TTL) {
|
||||
sessions.delete(id);
|
||||
console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round((now - s.lastUsed) / 60000)}m)`);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// ── Stats & diagnostics ─────────────────────────────────────────────────
|
||||
const stats = {
|
||||
totalRequests: 0,
|
||||
activeRequests: 0,
|
||||
errors: 0,
|
||||
timeouts: 0,
|
||||
sessionHits: 0,
|
||||
sessionMisses: 0,
|
||||
oneOffRequests: 0,
|
||||
};
|
||||
const recentErrors = []; // last 20 errors
|
||||
|
||||
function trackError(msg) {
|
||||
stats.errors++;
|
||||
recentErrors.push({ time: new Date().toISOString(), message: String(msg).slice(0, 200) });
|
||||
if (recentErrors.length > 20) recentErrors.shift();
|
||||
}
|
||||
|
||||
// ── Auth health check ───────────────────────────────────────────────────
|
||||
let authStatus = { ok: null, lastCheck: 0, message: "" };
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
delete env.ANTHROPIC_BASE_URL;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
execFileSync(CLAUDE, ["auth", "status"], { encoding: "utf8", timeout: 10000, env });
|
||||
authStatus = { ok: true, lastCheck: Date.now(), message: "authenticated" };
|
||||
} catch (e) {
|
||||
const msg = (e.stderr || e.message || "").slice(0, 200);
|
||||
authStatus = { ok: false, lastCheck: Date.now(), message: msg };
|
||||
console.error(`[auth] check failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check auth on start and every 10 minutes
|
||||
checkAuth();
|
||||
setInterval(checkAuth, 600000);
|
||||
|
||||
// ── Build CLI arguments ─────────────────────────────────────────────────
|
||||
function buildCliArgs(cliModel, sessionInfo) {
|
||||
const args = ["-p", "--model", cliModel, "--output-format", "text"];
|
||||
|
||||
// Session handling
|
||||
if (sessionInfo?.resume) {
|
||||
args.push("--resume", sessionInfo.uuid);
|
||||
} else if (sessionInfo?.uuid) {
|
||||
args.push("--session-id", sessionInfo.uuid);
|
||||
} else {
|
||||
args.push("--no-session-persistence");
|
||||
}
|
||||
|
||||
// Permissions
|
||||
if (SKIP_PERMISSIONS) {
|
||||
args.push("--dangerously-skip-permissions");
|
||||
} else if (ALLOWED_TOOLS.length > 0) {
|
||||
args.push("--allowedTools", ...ALLOWED_TOOLS);
|
||||
}
|
||||
|
||||
// System prompt
|
||||
if (SYSTEM_PROMPT) {
|
||||
args.push("--append-system-prompt", SYSTEM_PROMPT);
|
||||
}
|
||||
|
||||
// MCP config
|
||||
if (MCP_CONFIG) {
|
||||
args.push("--mcp-config", MCP_CONFIG);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
// ── Format messages to prompt text ──────────────────────────────────────
|
||||
function messagesToPrompt(messages) {
|
||||
return messages.map((m) => {
|
||||
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
||||
if (m.role === "system") return `[System] ${text}`;
|
||||
if (m.role === "assistant") return `[Assistant] ${text}`;
|
||||
return text;
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
// Model tier multipliers for first-byte timeout.
|
||||
// Opus is much slower to produce first token, especially with large contexts.
|
||||
const MODEL_TIMEOUT_TIERS = {
|
||||
"opus": { base: 60000, perPromptChar: 0.00015 }, // 60s base + ~15s per 100k chars
|
||||
"sonnet": { base: 45000, perPromptChar: 0.00008 }, // 45s base + ~8s per 100k chars
|
||||
"haiku": { base: 30000, perPromptChar: 0.00005 }, // 30s base + ~5s per 100k chars
|
||||
};
|
||||
|
||||
function getModelTier(cliModel) {
|
||||
if (cliModel.includes("opus")) return "opus";
|
||||
if (cliModel.includes("haiku")) return "haiku";
|
||||
return "sonnet";
|
||||
}
|
||||
|
||||
function computeFirstByteTimeout(cliModel, promptLength) {
|
||||
const tier = MODEL_TIMEOUT_TIERS[getModelTier(cliModel)];
|
||||
const timeout = tier.base + Math.floor(promptLength * tier.perPromptChar);
|
||||
return Math.min(timeout, Math.max(TIMEOUT - 5000, 10000));
|
||||
}
|
||||
|
||||
// ── Call claude CLI ─────────────────────────────────────────────────────
|
||||
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
||||
// No pool = no crash loops, no stale workers, no degraded states.
|
||||
// Stdin is written immediately so there's no 3s stdin timeout issue.
|
||||
function callClaude(model, messages, conversationId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||
return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`));
|
||||
}
|
||||
|
||||
const cliModel = MODEL_MAP[model] || model;
|
||||
|
||||
// Circuit breaker check: fail fast if model is in open state
|
||||
const breaker = getBreakerState(cliModel);
|
||||
if (breaker.state === "open") {
|
||||
const remainingMs = BREAKER_COOLDOWN - (Date.now() - breaker.openedAt);
|
||||
logEvent("warn", "breaker_rejected", { model: cliModel, remainingCooldownMs: remainingMs });
|
||||
return reject(new Error(`circuit breaker open for ${cliModel}: ${breaker.failures} consecutive timeouts, retry in ${Math.ceil(remainingMs / 1000)}s`));
|
||||
}
|
||||
|
||||
stats.activeRequests++;
|
||||
stats.totalRequests++;
|
||||
|
||||
let sessionInfo = null;
|
||||
let prompt;
|
||||
|
||||
// ── Session logic ──
|
||||
if (conversationId && sessions.has(conversationId)) {
|
||||
// Resume existing session: only send the latest user message
|
||||
const session = sessions.get(conversationId);
|
||||
session.lastUsed = Date.now();
|
||||
sessionInfo = { uuid: session.uuid, resume: true };
|
||||
stats.sessionHits++;
|
||||
|
||||
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
||||
prompt = lastUserMsg
|
||||
? (typeof lastUserMsg.content === "string" ? lastUserMsg.content : JSON.stringify(lastUserMsg.content))
|
||||
: "";
|
||||
session.messageCount = messages.length;
|
||||
|
||||
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
|
||||
|
||||
} else if (conversationId) {
|
||||
// New session: send all messages, persist session for future --resume
|
||||
const uuid = randomUUID();
|
||||
sessions.set(conversationId, { uuid, messageCount: messages.length, lastUsed: Date.now(), model: cliModel });
|
||||
sessionInfo = { uuid, resume: false };
|
||||
stats.sessionMisses++;
|
||||
prompt = messagesToPrompt(messages);
|
||||
|
||||
console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
|
||||
|
||||
} else {
|
||||
// One-off request, no session
|
||||
stats.oneOffRequests++;
|
||||
prompt = messagesToPrompt(messages);
|
||||
}
|
||||
|
||||
const cliArgs = buildCliArgs(cliModel, sessionInfo);
|
||||
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
delete env.ANTHROPIC_BASE_URL;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
|
||||
const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const t0 = Date.now();
|
||||
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
|
||||
let settled = false;
|
||||
let gotFirstByte = false;
|
||||
|
||||
function settle(err, result) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
clearTimeout(firstByteTimer);
|
||||
stats.activeRequests--;
|
||||
|
||||
if (err) {
|
||||
trackError(err.message || String(err));
|
||||
|
||||
// If session resume failed, remove session so next request starts fresh
|
||||
if (sessionInfo?.resume && conversationId) {
|
||||
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
|
||||
sessions.delete(conversationId);
|
||||
}
|
||||
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
}
|
||||
|
||||
proc.stdout.on("data", (d) => {
|
||||
if (!gotFirstByte) {
|
||||
gotFirstByte = true;
|
||||
clearTimeout(firstByteTimer);
|
||||
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
|
||||
}
|
||||
stdout += d;
|
||||
});
|
||||
proc.stderr.on("data", (d) => (stderr += d));
|
||||
|
||||
proc.on("close", (code, signal) => {
|
||||
const elapsed = Date.now() - t0;
|
||||
if (settled) {
|
||||
logEvent("warn", "late_close", { model: cliModel, code, signal: signal || "none", elapsed });
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
||||
settle(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
|
||||
} else {
|
||||
breakerRecordSuccess(cliModel);
|
||||
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||
settle(null, stdout.trim());
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
console.error(`[claude] spawn error: ${err.message}`);
|
||||
settle(err);
|
||||
});
|
||||
|
||||
// Write prompt to stdin immediately — no idle timeout issue
|
||||
proc.stdin.write(prompt);
|
||||
proc.stdin.end();
|
||||
|
||||
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||
|
||||
// First-byte timeout: abort early if Claude CLI produces no output
|
||||
const firstByteTimer = setTimeout(() => {
|
||||
if (!gotFirstByte && !settled) {
|
||||
stats.timeouts++;
|
||||
breakerRecordTimeout(cliModel);
|
||||
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||
settle(new Error(`first-byte timeout after ${firstByteTimeoutMs}ms`));
|
||||
}
|
||||
}, firstByteTimeoutMs);
|
||||
|
||||
// Overall request timeout with graceful kill
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
stats.timeouts++;
|
||||
breakerRecordTimeout(cliModel);
|
||||
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||
settle(new Error(`timeout after ${TIMEOUT}ms`));
|
||||
}, TIMEOUT);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Response helpers ────────────────────────────────────────────────────
|
||||
function jsonResponse(res, status, data) {
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function sendSSE(res, data) {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
function streamResponse(res, id, model, content) {
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
});
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
});
|
||||
for (let i = 0; i < content.length; i += 500) {
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: { content: content.slice(i, i + 500) }, finish_reason: null }],
|
||||
});
|
||||
}
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
});
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
|
||||
function completionResponse(res, id, model, content) {
|
||||
jsonResponse(res, 200, {
|
||||
id, object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Handle chat completions ─────────────────────────────────────────────
|
||||
async function handleChatCompletions(req, res) {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
||||
|
||||
const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }];
|
||||
const model = parsed.model || "claude-sonnet-4-6";
|
||||
const stream = parsed.stream;
|
||||
|
||||
// Session ID: from request body, header, or null (one-off)
|
||||
const conversationId = parsed.session_id || parsed.conversation_id || req.headers["x-session-id"] || req.headers["x-conversation-id"] || null;
|
||||
|
||||
if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
|
||||
|
||||
try {
|
||||
const content = await callClaude(model, messages, conversationId);
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
|
||||
if (stream) {
|
||||
streamResponse(res, id, model, content);
|
||||
} else {
|
||||
completionResponse(res, id, model, content);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[proxy] error: ${err.message}`);
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) {
|
||||
try { res.end(); } catch {}
|
||||
return;
|
||||
}
|
||||
jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP server ─────────────────────────────────────────────────────────
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id");
|
||||
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
||||
|
||||
// Bearer token auth (skip for /health and when PROXY_API_KEY is not set)
|
||||
if (PROXY_API_KEY && req.url !== "/health") {
|
||||
const auth = req.headers["authorization"] || "";
|
||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
||||
if (token !== PROXY_API_KEY) {
|
||||
return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /v1/models
|
||||
if (req.url === "/v1/models" && req.method === "GET") {
|
||||
return jsonResponse(res, 200, {
|
||||
object: "list",
|
||||
data: MODELS.map((m) => ({
|
||||
id: m.id, object: "model", owned_by: "anthropic",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// POST /v1/chat/completions
|
||||
if (req.url === "/v1/chat/completions" && req.method === "POST") {
|
||||
return handleChatCompletions(req, res);
|
||||
}
|
||||
|
||||
// GET /health — comprehensive diagnostics
|
||||
if (req.url === "/health") {
|
||||
let binaryOk = false;
|
||||
try { accessSync(CLAUDE, constants.X_OK); binaryOk = true; } catch {}
|
||||
|
||||
const uptimeMs = Date.now() - START_TIME;
|
||||
const sessionList = [];
|
||||
for (const [id, s] of sessions) {
|
||||
sessionList.push({
|
||||
id: id.slice(0, 12) + "...",
|
||||
model: s.model,
|
||||
messages: s.messageCount,
|
||||
idleMs: Date.now() - s.lastUsed,
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResponse(res, 200, {
|
||||
status: binaryOk && authStatus.ok !== false ? "ok" : "degraded",
|
||||
version: VERSION,
|
||||
architecture: "on-demand (v2)",
|
||||
uptime: uptimeMs,
|
||||
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
|
||||
claudeBinary: CLAUDE,
|
||||
claudeBinaryOk: binaryOk,
|
||||
auth: authStatus,
|
||||
config: {
|
||||
timeout: TIMEOUT,
|
||||
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
|
||||
maxConcurrent: MAX_CONCURRENT,
|
||||
sessionTTL: SESSION_TTL,
|
||||
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
|
||||
systemPrompt: SYSTEM_PROMPT ? `${SYSTEM_PROMPT.slice(0, 50)}...` : "(none)",
|
||||
mcpConfig: MCP_CONFIG || "(none)",
|
||||
},
|
||||
stats,
|
||||
sessions: sessionList,
|
||||
recentErrors: recentErrors.slice(-5),
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE /sessions — clear all sessions
|
||||
if (req.url === "/sessions" && req.method === "DELETE") {
|
||||
const count = sessions.size;
|
||||
sessions.clear();
|
||||
return jsonResponse(res, 200, { cleared: count });
|
||||
}
|
||||
|
||||
// GET /sessions — list active sessions
|
||||
if (req.url === "/sessions" && req.method === "GET") {
|
||||
const list = [];
|
||||
for (const [id, s] of sessions) {
|
||||
list.push({ id, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
|
||||
}
|
||||
return jsonResponse(res, 200, { sessions: list });
|
||||
}
|
||||
|
||||
// Catch-all POST
|
||||
if (req.method === "POST") {
|
||||
return handleChatCompletions(req, res);
|
||||
}
|
||||
|
||||
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET|DELETE /sessions" });
|
||||
});
|
||||
|
||||
// ── Start ───────────────────────────────────────────────────────────────
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`openclaw-claude-proxy v${VERSION} listening on http://0.0.0.0:${PORT}`);
|
||||
console.log(`Architecture: on-demand spawning (no pool)`);
|
||||
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
||||
console.log(`Claude binary: ${CLAUDE}`);
|
||||
console.log(`Timeout: ${TIMEOUT}ms (base first-byte: ${BASE_FIRST_BYTE_TIMEOUT}ms, adaptive by model/prompt) | Max concurrent: ${MAX_CONCURRENT}`);
|
||||
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
||||
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
||||
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
|
||||
if (MCP_CONFIG) console.log(`MCP config: ${MCP_CONFIG}`);
|
||||
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
|
||||
console.log(`---`);
|
||||
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
|
||||
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`);
|
||||
console.log(` CC uses: MCP protocol (in-process) → persistent session`);
|
||||
console.log(` Both can run simultaneously on the same machine.`);
|
||||
});
|
||||
Generated
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "openclaw-claude-proxy",
|
||||
"version": "3.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openclaw-claude-proxy",
|
||||
"version": "3.4.0",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"ocp": "ocp",
|
||||
"openclaw-claude-proxy": "server.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw-claude-proxy",
|
||||
"version": "3.9.0",
|
||||
"name": "open-claude-proxy",
|
||||
"version": "3.20.1",
|
||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -9,7 +9,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
"setup": "node setup.mjs"
|
||||
"setup": "node setup.mjs",
|
||||
"test": "node test-features.mjs"
|
||||
},
|
||||
"keywords": [
|
||||
"openclaw",
|
||||
@@ -20,7 +21,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22.5"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/doctor.mjs — OCP health & upgrade-readiness check.
|
||||
*
|
||||
* Usage:
|
||||
* ocp doctor human-readable PASS/WARN/FAIL
|
||||
* ocp doctor --json machine-readable JSON for AI agents + ocp update
|
||||
* ocp doctor --check oauth fast path: only OAuth check
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 all PASS or WARN-only
|
||||
* 1 any FAIL
|
||||
*/
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
||||
|
||||
const SCHEMA_VERSION = "1";
|
||||
|
||||
function semverParts(v) {
|
||||
const m = String(v).replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||
if (!m) return null;
|
||||
return { major: +m[1], minor: +m[2], patch: +m[3] };
|
||||
}
|
||||
|
||||
function semverCompare(a, b) {
|
||||
const A = semverParts(a), B = semverParts(b);
|
||||
if (!A || !B) return 0;
|
||||
if (A.major !== B.major) return A.major - B.major;
|
||||
if (A.minor !== B.minor) return A.minor - B.minor;
|
||||
return A.patch - B.patch;
|
||||
}
|
||||
|
||||
export async function runDoctor(opts = {}) {
|
||||
const checks = [];
|
||||
const push = (id, level, message, extra = {}) =>
|
||||
checks.push({ id, level, message, ...extra });
|
||||
|
||||
// --- fast path: --check oauth ---
|
||||
if (opts.checkOnly === "oauth") {
|
||||
return runOauthOnly(opts, checks, push);
|
||||
}
|
||||
|
||||
// --- version detection ---
|
||||
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
||||
let currentVersion = opts.mockVersion;
|
||||
if (!currentVersion) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(ocpDir, "package.json"), "utf8"));
|
||||
currentVersion = `v${pkg.version}`;
|
||||
} catch {
|
||||
currentVersion = "unknown";
|
||||
}
|
||||
}
|
||||
// Resolve latest from origin/main (cheap: `git show origin/main:package.json`).
|
||||
// Falls back to current_version when network/git unavailable, so kind = noop instead
|
||||
// of recommending a downgrade against a stale hardcoded value.
|
||||
let latestVersion = opts.mockLatest;
|
||||
if (!latestVersion) {
|
||||
try {
|
||||
const out = execSync(`git -C ${ocpDir} show origin/main:package.json 2>/dev/null`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
||||
const remotePkg = JSON.parse(out);
|
||||
latestVersion = `v${remotePkg.version}`;
|
||||
} catch {
|
||||
latestVersion = currentVersion;
|
||||
}
|
||||
}
|
||||
push("current_version", "PASS", `current=${currentVersion}`);
|
||||
|
||||
// --- from-version supported? ---
|
||||
const fromSupported = !!semverParts(currentVersion) && semverCompare(currentVersion, "v3.4.0") >= 0;
|
||||
push("from_version_supported", fromSupported ? "PASS" : "FAIL",
|
||||
fromSupported ? "≥ v3.4.0" : `${currentVersion} < v3.4.0; in-place upgrade not supported`);
|
||||
|
||||
// --- service health check (mockable) ---
|
||||
let healthOk = true, oauthOk = true;
|
||||
if (!opts.skipNetwork) {
|
||||
let health;
|
||||
if (opts.mockHealth !== undefined) {
|
||||
health = opts.mockHealth;
|
||||
} else {
|
||||
try {
|
||||
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
||||
const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
||||
health = { status: 200, body: JSON.parse(out) };
|
||||
} catch (e) {
|
||||
health = { error: String(e.message || e) };
|
||||
}
|
||||
}
|
||||
if (health.error || health.status !== 200) {
|
||||
healthOk = false;
|
||||
push("service_running", "FAIL", `service unreachable: ${health.error || `status ${health.status}`}`);
|
||||
} else if (!health.body || typeof health.body !== "object") {
|
||||
healthOk = false;
|
||||
push("service_running", "FAIL", "service /health returned 200 but empty/non-JSON body");
|
||||
} else {
|
||||
push("service_running", "PASS", "service responding on /health");
|
||||
const authOk = health.body?.auth?.ok;
|
||||
if (!authOk) {
|
||||
oauthOk = false;
|
||||
push("oauth_ok", "FAIL", `auth.ok=false: ${health.body?.auth?.message || "unknown"}`);
|
||||
} else {
|
||||
push("oauth_ok", "PASS", "OAuth token valid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- determine next_action.kind (priority: fresh_install > fix_service > fix_oauth > noop > update > upgrade) ---
|
||||
let kind;
|
||||
if (!fromSupported) {
|
||||
kind = "fresh_install";
|
||||
} else if (!opts.skipNetwork && !healthOk) {
|
||||
kind = "fix_service";
|
||||
} else if (!opts.skipNetwork && !oauthOk) {
|
||||
kind = "fix_oauth";
|
||||
} else {
|
||||
const cur = semverParts(currentVersion), lat = semverParts(latestVersion);
|
||||
if (!cur) {
|
||||
kind = "fresh_install";
|
||||
} else if (semverCompare(currentVersion, latestVersion) === 0) {
|
||||
kind = "noop";
|
||||
} else if (lat && cur.major === lat.major && cur.minor === lat.minor) {
|
||||
kind = "update";
|
||||
} else {
|
||||
kind = "upgrade";
|
||||
}
|
||||
}
|
||||
|
||||
// --- next_action shape ---
|
||||
let next_action;
|
||||
if (kind === "fresh_install") {
|
||||
next_action = {
|
||||
kind,
|
||||
human_required: ["claude auth login (only if OAuth becomes invalid after reinstall)"],
|
||||
ai_executable: [
|
||||
`launchctl bootout gui/$(id -u)/ai.openclaw.proxy 2>/dev/null || true`,
|
||||
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
||||
`mv ${join(homedir(), ".ocp")} ${join(homedir(), ".ocp.backup-")}$(date +%s) 2>/dev/null || true`,
|
||||
`rm -rf ${ocpDir}`,
|
||||
`git clone https://github.com/dtzp555-max/ocp ${ocpDir}`,
|
||||
`cd ${ocpDir} && npm install --no-audit --no-fund && node setup.mjs`,
|
||||
`${ocpDir}/ocp doctor`
|
||||
],
|
||||
verify: "ocp doctor expects PASS on all checks"
|
||||
};
|
||||
} else if (kind === "noop") {
|
||||
next_action = { kind, human_required: [], ai_executable: [], verify: "already at latest" };
|
||||
} else if (kind === "fix_oauth") {
|
||||
next_action = {
|
||||
kind,
|
||||
human_required: [],
|
||||
ai_executable: [
|
||||
`cd "$(npm root -g)/@anthropic-ai/claude-code" && node install.cjs`,
|
||||
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
||||
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
||||
`${ocpDir}/ocp doctor`
|
||||
],
|
||||
verify: "ocp doctor expects oauth_ok=PASS",
|
||||
reference: "~/.cc-rules/memory/learnings/ocp_claude_native_binary_postinstall.md"
|
||||
};
|
||||
} else if (kind === "fix_service") {
|
||||
next_action = {
|
||||
kind,
|
||||
human_required: [],
|
||||
ai_executable: [
|
||||
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
||||
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
||||
`${ocpDir}/ocp doctor`
|
||||
],
|
||||
verify: "ocp doctor expects service_running=PASS"
|
||||
};
|
||||
} else {
|
||||
next_action = {
|
||||
kind,
|
||||
human_required: [],
|
||||
ai_executable: [`${ocpDir}/ocp update --yes`],
|
||||
verify: "ocp doctor expects PASS on all checks"
|
||||
};
|
||||
}
|
||||
|
||||
const fail_count = checks.filter(c => c.level === "FAIL").length;
|
||||
const warn_count = checks.filter(c => c.level === "WARN").length;
|
||||
return {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
timestamp: new Date().toISOString(),
|
||||
ready_to_upgrade: fail_count === 0,
|
||||
current_version: currentVersion,
|
||||
latest_version: latestVersion,
|
||||
from_version_supported: fromSupported,
|
||||
fail_count,
|
||||
warn_count,
|
||||
checks,
|
||||
next_action
|
||||
};
|
||||
}
|
||||
|
||||
function runOauthOnly(opts, checks, push) {
|
||||
let healthOk = true, oauthOk = true;
|
||||
let health;
|
||||
if (opts.mockHealth !== undefined) {
|
||||
health = opts.mockHealth;
|
||||
} else {
|
||||
try {
|
||||
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
||||
const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
||||
health = { status: 200, body: JSON.parse(out) };
|
||||
} catch (e) {
|
||||
health = { error: String(e.message || e) };
|
||||
}
|
||||
}
|
||||
|
||||
if (health.error || health.status !== 200) {
|
||||
healthOk = false;
|
||||
push("oauth_ok", "FAIL", `service unreachable: ${health.error || `status ${health.status}`}`);
|
||||
} else if (!health.body || typeof health.body !== "object") {
|
||||
healthOk = false;
|
||||
push("oauth_ok", "FAIL", "service /health returned 200 but empty/non-JSON body");
|
||||
} else if (!health.body?.auth?.ok) {
|
||||
oauthOk = false;
|
||||
push("oauth_ok", "FAIL", `auth.ok=false: ${health.body?.auth?.message || "unknown"}`);
|
||||
} else {
|
||||
push("oauth_ok", "PASS", "OAuth token valid");
|
||||
}
|
||||
|
||||
const kind = !healthOk ? "fix_service" : !oauthOk ? "fix_oauth" : "noop";
|
||||
|
||||
let next_action;
|
||||
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
||||
if (kind === "noop") {
|
||||
next_action = { kind, human_required: [], ai_executable: [], verify: "OAuth healthy" };
|
||||
} else if (kind === "fix_oauth") {
|
||||
next_action = {
|
||||
kind,
|
||||
human_required: [],
|
||||
ai_executable: [
|
||||
`cd "$(npm root -g)/@anthropic-ai/claude-code" && node install.cjs`,
|
||||
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
||||
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
||||
`${ocpDir}/ocp doctor --check oauth`
|
||||
],
|
||||
verify: "ocp doctor --check oauth expects PASS",
|
||||
reference: "~/.cc-rules/memory/learnings/ocp_claude_native_binary_postinstall.md"
|
||||
};
|
||||
} else {
|
||||
next_action = {
|
||||
kind,
|
||||
human_required: [],
|
||||
ai_executable: [
|
||||
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
||||
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
||||
`${ocpDir}/ocp doctor --check oauth`
|
||||
],
|
||||
verify: "ocp doctor --check oauth expects service_running=PASS"
|
||||
};
|
||||
}
|
||||
|
||||
const fail_count = checks.filter(c => c.level === "FAIL").length;
|
||||
// "skipped" = --check oauth fast path intentionally omits version detection.
|
||||
// AI agents should NOT semver-compare against current_version/latest_version when
|
||||
// either equals "skipped"; the full path provides those fields when needed.
|
||||
return {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
timestamp: new Date().toISOString(),
|
||||
ready_to_upgrade: fail_count === 0,
|
||||
current_version: opts.mockVersion || "skipped",
|
||||
latest_version: opts.mockLatest || "skipped",
|
||||
from_version_supported: true,
|
||||
fail_count,
|
||||
warn_count: 0,
|
||||
checks,
|
||||
next_action
|
||||
};
|
||||
}
|
||||
|
||||
// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths
|
||||
// (e.g. /tmp/ → /private/tmp/ on macOS would otherwise miss the guard).
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { realpathSync } from "node:fs";
|
||||
function _isMain() {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
||||
} catch { return false; }
|
||||
}
|
||||
if (_isMain()) {
|
||||
const wantJson = process.argv.includes("--json");
|
||||
const checkIdx = process.argv.indexOf("--check");
|
||||
const checkOnly = checkIdx !== -1 ? process.argv[checkIdx + 1] : undefined;
|
||||
const result = await runDoctor({ checkOnly });
|
||||
if (wantJson) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`OCP doctor — ${result.current_version} → ${result.latest_version}`);
|
||||
for (const c of result.checks) console.log(` [${c.level}] ${c.id}: ${c.message}`);
|
||||
console.log(`\nSummary: ${result.fail_count} FAIL, ${result.warn_count} WARN`);
|
||||
console.log(`Next action: ${result.next_action.kind}`);
|
||||
}
|
||||
process.exit(result.fail_count === 0 ? 0 : 1);
|
||||
}
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/bin/bash
|
||||
# One-shot field-evidence gatherer for OCP v3.12.0 SSE heartbeat.
|
||||
# Scheduled by ~/Library/LaunchAgents/dev.ocp.heartbeat-check.plist to fire
|
||||
# once at 2026-05-02 09:00 Australia/Brisbane. Gathers evidence from local
|
||||
# OCP logs + GitHub issue #47 + repo issue search, posts a summary comment
|
||||
# on #47, and exits. Does NOT open PRs or change code — the maintainer
|
||||
# decides after reading the summary.
|
||||
#
|
||||
# Dry-run: ./heartbeat-field-check.sh --dry-run (prints summary, skips post)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="dtzp555-max/ocp"
|
||||
SHIP_DATE="2026-04-25"
|
||||
# Baseline captured at script-install time so internal testing entries from
|
||||
# Phase 3 verification (~5 entries from 2026-04-25T00:00–00:48Z) don't get
|
||||
# counted as field evidence. Any heartbeat_active log entry with ts >= this
|
||||
# timestamp is treated as a real opt-in.
|
||||
BASELINE_TS="2026-04-25T01:00:00Z"
|
||||
PROXY_LOG="$HOME/ocp/logs/proxy.log"
|
||||
OUT_DIR="$HOME/ocp/logs"
|
||||
SELF_LOG="$OUT_DIR/heartbeat-field-check-$(date +%Y-%m-%d).log"
|
||||
DRY_RUN=0
|
||||
[ "${1:-}" = "--dry-run" ] && DRY_RUN=1
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
exec > >(tee -a "$SELF_LOG") 2>&1
|
||||
|
||||
echo "=== heartbeat field-evidence check: $(date -u +%Y-%m-%dT%H:%M:%SZ) (dry_run=$DRY_RUN) ==="
|
||||
|
||||
# ── signal 1: local proxy log ─────────────────────────────────────────────
|
||||
if [ -r "$PROXY_LOG" ]; then
|
||||
# Only count entries with ts >= BASELINE_TS (string sort works on RFC3339)
|
||||
HEARTBEAT_COUNT=$(grep '"event":"heartbeat_active"' "$PROXY_LOG" 2>/dev/null \
|
||||
| awk -v base="$BASELINE_TS" '
|
||||
match($0, /"ts":"[^"]+"/) {
|
||||
ts = substr($0, RSTART+6, RLENGTH-7);
|
||||
if (ts >= base) c++
|
||||
}
|
||||
END { print c+0 }')
|
||||
else
|
||||
HEARTBEAT_COUNT=0
|
||||
fi
|
||||
echo "signal 1 — heartbeat_active log entries since $BASELINE_TS: $HEARTBEAT_COUNT"
|
||||
|
||||
# ── signal 2: comments on #47 since ship ──────────────────────────────────
|
||||
NEW_47_JSON="/tmp/ocp-47-new-comments-$$.json"
|
||||
gh issue view 47 --repo "$REPO" --json comments \
|
||||
--jq '[.comments[] | select(.createdAt >= "'"$SHIP_DATE"'T00:00:00Z")]' \
|
||||
> "$NEW_47_JSON" 2>/dev/null || echo "[]" > "$NEW_47_JSON"
|
||||
NEW_COMMENTS=$(jq 'length' "$NEW_47_JSON")
|
||||
echo "signal 2 — new comments on #47 since $SHIP_DATE: $NEW_COMMENTS"
|
||||
|
||||
# Build a compact, human-readable excerpt for the summary body
|
||||
NEW_47_EXCERPT=""
|
||||
if [ "$NEW_COMMENTS" -gt 0 ]; then
|
||||
NEW_47_EXCERPT=$(jq -r '.[] | "- **@\(.author.login)** (\(.createdAt)): " + (.body | gsub("\r"; "") | split("\n")[0])[:180]' "$NEW_47_JSON")
|
||||
fi
|
||||
|
||||
# ── signal 3: other heartbeat-related issues since ship ──────────────────
|
||||
OTHER_ISSUES_JSON="/tmp/ocp-heartbeat-issues-$$.json"
|
||||
gh search issues "repo:$REPO heartbeat" --json number,title,state,createdAt --limit 30 \
|
||||
--jq '[.[] | select(.createdAt >= "'"$SHIP_DATE"'T00:00:00Z" and .number != 47 and .number != 48)]' \
|
||||
> "$OTHER_ISSUES_JSON" 2>/dev/null || echo "[]" > "$OTHER_ISSUES_JSON"
|
||||
OTHER_ISSUES=$(jq 'length' "$OTHER_ISSUES_JSON")
|
||||
echo "signal 3 — other heartbeat-related issues since ship: $OTHER_ISSUES"
|
||||
|
||||
OTHER_ISSUES_EXCERPT=""
|
||||
if [ "$OTHER_ISSUES" -gt 0 ]; then
|
||||
OTHER_ISSUES_EXCERPT=$(jq -r '.[] | "- #\(.number) [\(.state)] \(.title)"' "$OTHER_ISSUES_JSON")
|
||||
fi
|
||||
|
||||
# ── compose summary ──────────────────────────────────────────────────────
|
||||
BODY_FILE="/tmp/ocp-47-summary-$$.md"
|
||||
{
|
||||
echo "### Automated 7-day field-evidence check (v3.12.0)"
|
||||
echo
|
||||
echo "_Triggered by a local launchd scheduled task on the maintainer's rig at $(date -u +%Y-%m-%dT%H:%M:%SZ)._"
|
||||
echo
|
||||
echo "| Signal | Count |"
|
||||
echo "|---|---|"
|
||||
echo "| \`heartbeat_active\` log entries on prod rig (since baseline $BASELINE_TS) | $HEARTBEAT_COUNT |"
|
||||
echo "| New comments on #47 since $SHIP_DATE | $NEW_COMMENTS |"
|
||||
echo "| Other heartbeat-related issues filed since $SHIP_DATE | $OTHER_ISSUES |"
|
||||
echo
|
||||
if [ -n "$NEW_47_EXCERPT" ]; then
|
||||
echo "**New #47 comments (first line each):**"
|
||||
echo
|
||||
echo "$NEW_47_EXCERPT"
|
||||
echo
|
||||
fi
|
||||
if [ -n "$OTHER_ISSUES_EXCERPT" ]; then
|
||||
echo "**Other heartbeat-related issues:**"
|
||||
echo
|
||||
echo "$OTHER_ISSUES_EXCERPT"
|
||||
echo
|
||||
fi
|
||||
echo "**Decision guidance for maintainer (manual):**"
|
||||
echo
|
||||
echo "- If any of the above indicate a **crash report** on \`: keepalive\` comment frames → leave default at \`0\` and file a \`CLAUDE_HEARTBEAT_FORMAT=empty-delta\` follow-up issue (spec \`§D2\` fallback plan)."
|
||||
echo "- If there is at least one **opt-in confirmation** (a user reports \`CLAUDE_HEARTBEAT_INTERVAL\` fixed their timeout issue) and no crash reports → consider opening a PR for v3.13.0 flipping the default to \`30000\`, following the same ALIGNMENT + independent-reviewer + release-kit discipline as PR #49."
|
||||
echo "- If all three signals are zero → extend the soak window or close this follow-up as \"no field evidence.\""
|
||||
echo
|
||||
echo "This bot does not open PRs or change code. The maintainer reviews and acts."
|
||||
} > "$BODY_FILE"
|
||||
|
||||
echo "--- summary preview ---"
|
||||
cat "$BODY_FILE"
|
||||
echo "--- end preview ---"
|
||||
|
||||
# ── post (unless dry-run) ────────────────────────────────────────────────
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "DRY RUN — skipping gh issue comment"
|
||||
else
|
||||
gh issue comment 47 --repo "$REPO" --body-file "$BODY_FILE" && echo "comment posted on #47"
|
||||
fi
|
||||
|
||||
# ── cleanup + self-disable so the plist doesn't linger loaded forever ────
|
||||
rm -f "$NEW_47_JSON" "$OTHER_ISSUES_JSON" "$BODY_FILE"
|
||||
|
||||
if [ "$DRY_RUN" -eq 0 ]; then
|
||||
# Unload + remove the plist so this never fires again
|
||||
PLIST="$HOME/Library/LaunchAgents/dev.ocp.heartbeat-check.plist"
|
||||
if [ -f "$PLIST" ]; then
|
||||
launchctl bootout "gui/$(id -u)" "$PLIST" 2>/dev/null || launchctl unload "$PLIST" 2>/dev/null || true
|
||||
rm -f "$PLIST"
|
||||
echo "self-disabled: removed $PLIST"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== done ==="
|
||||
@@ -0,0 +1,88 @@
|
||||
// scripts/lib/plist-merge.mjs
|
||||
//
|
||||
// Preserves user-customised env vars when setup.mjs rewrites the unit file.
|
||||
//
|
||||
// Rule:
|
||||
// - keys present in NEW template → template value wins (template is source of truth)
|
||||
// - keys ONLY in EXISTING (not in template) → preserved verbatim
|
||||
//
|
||||
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
||||
// is stable enough for our hand-written templates in setup.mjs.
|
||||
|
||||
// Note: setup.mjs XML-escapes all injected values before writing (via xmlEscape()),
|
||||
// so raw `<` / `>` / `&` never appear in plist <string> bodies — the [^<]* regex below is safe.
|
||||
const PLIST_KV_RE = /<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g;
|
||||
|
||||
export function parsePlistEnv(plistContent) {
|
||||
if (!plistContent) return {};
|
||||
if (Buffer.isBuffer(plistContent)) plistContent = plistContent.toString("utf8");
|
||||
// Restrict to the EnvironmentVariables dict to avoid catching Label, etc.
|
||||
const envBlock = plistContent.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/);
|
||||
if (!envBlock) return {};
|
||||
const out = {};
|
||||
let m;
|
||||
PLIST_KV_RE.lastIndex = 0;
|
||||
while ((m = PLIST_KV_RE.exec(envBlock[1])) !== null) {
|
||||
out[m[1]] = m[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function mergePlistEnv(existing, template) {
|
||||
if (!existing) return template;
|
||||
const existingEnv = parsePlistEnv(existing);
|
||||
const templateEnv = parsePlistEnv(template);
|
||||
const KNOWN = new Set(Object.keys(templateEnv));
|
||||
|
||||
const preserved = {};
|
||||
for (const [k, v] of Object.entries(existingEnv)) {
|
||||
if (!KNOWN.has(k)) preserved[k] = v;
|
||||
}
|
||||
if (Object.keys(preserved).length === 0) return template;
|
||||
|
||||
const lines = Object.entries(preserved)
|
||||
.map(([k, v]) => ` <key>${k}</key>\n <string>${v}</string>`)
|
||||
.join("\n");
|
||||
|
||||
// Inject before the closing </dict> of EnvironmentVariables
|
||||
return template.replace(
|
||||
/(<key>EnvironmentVariables<\/key>\s*<dict>[\s\S]*?)(\n\s*<\/dict>)/,
|
||||
`$1\n${lines}$2`
|
||||
);
|
||||
}
|
||||
|
||||
const SYSTEMD_KV_RE = /^Environment=([^=]+)=(.*)$/gm;
|
||||
|
||||
export function parseSystemdEnv(serviceContent) {
|
||||
if (!serviceContent) return {};
|
||||
if (Buffer.isBuffer(serviceContent)) serviceContent = serviceContent.toString("utf8");
|
||||
const out = {};
|
||||
let m;
|
||||
SYSTEMD_KV_RE.lastIndex = 0;
|
||||
while ((m = SYSTEMD_KV_RE.exec(serviceContent)) !== null) {
|
||||
out[m[1]] = m[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function mergeSystemdEnv(existing, template) {
|
||||
if (!existing) return template;
|
||||
const existingEnv = parseSystemdEnv(existing);
|
||||
const templateEnv = parseSystemdEnv(template);
|
||||
const KNOWN = new Set(Object.keys(templateEnv));
|
||||
|
||||
const preservedLines = Object.entries(existingEnv)
|
||||
.filter(([k]) => !KNOWN.has(k))
|
||||
.map(([k, v]) => `Environment=${k}=${v}`);
|
||||
if (preservedLines.length === 0) return template;
|
||||
|
||||
// Guard: if template has no Environment= anchor, cannot inject — return template as-is.
|
||||
// (In practice the OCP systemd template always has Environment= lines.)
|
||||
if (!/^Environment=/m.test(template)) return template;
|
||||
|
||||
// Inject after the last existing Environment= line in the template
|
||||
return template.replace(
|
||||
/(^Environment=[^\n]+\n)((?!Environment=).*$)/ms,
|
||||
`$1${preservedLines.join("\n")}\n$2`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
|
||||
const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
||||
const root = join(homeDir, ".ocp", `upgrade-snapshot-${ts}`);
|
||||
mkdirSync(root, { recursive: true });
|
||||
|
||||
// Standard manifest files
|
||||
writeFileSync(join(root, "from-commit.txt"), fromCommit + "\n");
|
||||
writeFileSync(join(root, "from-version.txt"), fromVersion + "\n");
|
||||
writeFileSync(join(root, "to-version.txt"), toVersion + "\n");
|
||||
|
||||
// Optional captures (best-effort, never fatal)
|
||||
const tryCopy = (src, dst) => {
|
||||
try {
|
||||
if (existsSync(src)) copyFileSync(src, dst);
|
||||
} catch (err) {
|
||||
console.error(`[snapshot] warn: could not copy ${src} (${err.code || err.message})`);
|
||||
}
|
||||
};
|
||||
tryCopy(join(homeDir, "Library", "LaunchAgents", "dev.ocp.proxy.plist"), join(root, "plist"));
|
||||
tryCopy(join(homeDir, ".config", "systemd", "user", "ocp-proxy.service"), join(root, "service"));
|
||||
tryCopy(join(homeDir, ".ocp", "ocp.db"), join(root, "db.bak"));
|
||||
tryCopy(join(homeDir, ".ocp", "admin-key"), join(root, "admin-key"));
|
||||
tryCopy(join(homeDir, ".openclaw", "openclaw.json"), join(root, "openclaw.json"));
|
||||
|
||||
for (const { src, name } of extraFiles) tryCopy(src, join(root, name));
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
export function readSnapshot(snapshotPath) {
|
||||
const read = (n) => {
|
||||
try { return readFileSync(join(snapshotPath, n), "utf8").trim(); } catch { return null; }
|
||||
};
|
||||
return {
|
||||
path: snapshotPath,
|
||||
fromCommit: read("from-commit.txt"),
|
||||
fromVersion: read("from-version.txt"),
|
||||
toVersion: read("to-version.txt")
|
||||
};
|
||||
}
|
||||
|
||||
export function listSnapshots(homeDir) {
|
||||
const root = join(homeDir, ".ocp");
|
||||
if (!existsSync(root)) return [];
|
||||
return readdirSync(root)
|
||||
.filter(name => name.startsWith("upgrade-snapshot-"))
|
||||
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Garbage-collect old upgrade snapshots.
|
||||
*
|
||||
* Retention rule (a snapshot is KEPT if any of these is true):
|
||||
* - It is among the last `keepCount` snapshots (sorted oldest→newest)
|
||||
* - Its timestamp is within `keepDays` of `now`
|
||||
* - It is the single most-recent snapshot (always-keep safety net)
|
||||
*
|
||||
* @param {string} homeDir - Root containing ~/.ocp/
|
||||
* @param {object} opts
|
||||
* @param {number} [opts.keepCount=5] - Minimum count to keep
|
||||
* @param {number} [opts.keepDays=30] - Keep snapshots newer than N days
|
||||
* @param {boolean} [opts.dryRun=false] - If true, report plan but don't delete
|
||||
* @param {Date} [opts.now=new Date()] - Override clock for testing
|
||||
* @returns {{kept: Array, removed: Array, dryRun: boolean}}
|
||||
*/
|
||||
export function gcSnapshots(homeDir, opts = {}) {
|
||||
const keepCount = opts.keepCount ?? 5;
|
||||
const keepDays = opts.keepDays ?? 30;
|
||||
const dryRun = !!opts.dryRun;
|
||||
const now = opts.now || new Date();
|
||||
|
||||
const all = listSnapshots(homeDir); // sorted oldest→newest
|
||||
if (all.length === 0) return { kept: [], removed: [], dryRun };
|
||||
if (all.length === 1) return { kept: all, removed: [], dryRun }; // always keep most recent
|
||||
|
||||
const cutoffMs = now.getTime() - keepDays * 24 * 60 * 60 * 1000;
|
||||
const lastN = new Set(all.slice(-keepCount).map(s => s.path));
|
||||
|
||||
const kept = [], removed = [];
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const s = all[i];
|
||||
const isMostRecent = i === all.length - 1;
|
||||
const isInLastN = lastN.has(s.path);
|
||||
const isWithinDays = parseSnapshotTimestamp(s.name) >= cutoffMs;
|
||||
if (isMostRecent || isInLastN || isWithinDays) {
|
||||
kept.push(s);
|
||||
} else {
|
||||
removed.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
for (const s of removed) {
|
||||
try {
|
||||
rmSync(s.path, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
console.error(`[snapshot] warn: could not remove ${s.path} (${err.code || err.message})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { kept, removed, dryRun };
|
||||
}
|
||||
|
||||
function parseSnapshotTimestamp(name) {
|
||||
// upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms
|
||||
const m = name.match(/upgrade-snapshot-(.+)$/);
|
||||
if (!m) return 0;
|
||||
const t = Date.parse(m[1]);
|
||||
return Number.isFinite(t) ? t : 0;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
// Idempotently sync OCP's claude-local provider models into OpenClaw's registry.
|
||||
// Only touches:
|
||||
// - config.models.providers["claude-local"].models
|
||||
// - config.agents.defaults.models["claude-local/*"] keys
|
||||
// All other fields and providers are preserved.
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, copyFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { homedir } from "node:os";
|
||||
import { DEFAULT_PORT, LOCAL_HOST, OPENAI_API_BASE } from "../lib/constants.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = join(__dirname, "..");
|
||||
const OPENCLAW_CONFIG = join(homedir(), ".openclaw", "openclaw.json");
|
||||
const PROVIDER_NAME = "claude-local";
|
||||
const QUIET = process.argv.includes("--quiet");
|
||||
|
||||
function log(msg) { if (!QUIET) console.log(` ✓ ${msg}`); }
|
||||
function warn(msg) { console.warn(` ⚠ ${msg}`); }
|
||||
|
||||
if (!existsSync(OPENCLAW_CONFIG)) {
|
||||
log(`OpenClaw not installed at ${OPENCLAW_CONFIG} — skipping (this is fine for non-OpenClaw users)`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const modelsConfig = JSON.parse(readFileSync(join(REPO_ROOT, "models.json"), "utf-8"));
|
||||
const desiredModels = modelsConfig.models.map(m => ({
|
||||
id: m.id,
|
||||
name: m.openclawName,
|
||||
reasoning: m.reasoning,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: m.contextWindow,
|
||||
maxTokens: m.maxTokens,
|
||||
}));
|
||||
const desiredAliases = Object.fromEntries(
|
||||
modelsConfig.models.map(m => [`${PROVIDER_NAME}/${m.id}`, { alias: m.displayName }])
|
||||
);
|
||||
|
||||
const config = JSON.parse(readFileSync(OPENCLAW_CONFIG, "utf-8"));
|
||||
|
||||
// Compute diff before writing
|
||||
const existingModels = config?.models?.providers?.[PROVIDER_NAME]?.models ?? [];
|
||||
const existingIds = new Set(existingModels.map(m => m.id));
|
||||
const desiredIds = new Set(desiredModels.map(m => m.id));
|
||||
const added = [...desiredIds].filter(id => !existingIds.has(id));
|
||||
const removed = [...existingIds].filter(id => !desiredIds.has(id));
|
||||
|
||||
if (added.length === 0 && removed.length === 0 && existingModels.length === desiredModels.length) {
|
||||
// Check deep equality too in case names/maxTokens changed
|
||||
const changed = desiredModels.some((d) => {
|
||||
const e = existingModels.find(x => x.id === d.id);
|
||||
return !e || e.name !== d.name || e.maxTokens !== d.maxTokens;
|
||||
});
|
||||
if (!changed) {
|
||||
log("OpenClaw registry already in sync");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Backup
|
||||
const backupPath = `${OPENCLAW_CONFIG}.bak.${Date.now()}`;
|
||||
copyFileSync(OPENCLAW_CONFIG, backupPath);
|
||||
log(`Backed up to ${backupPath}`);
|
||||
|
||||
// Surgical patch: only touch claude-local provider and claude-local/* aliases
|
||||
if (!config.models) config.models = {};
|
||||
if (!config.models.providers) config.models.providers = {};
|
||||
if (!config.models.providers[PROVIDER_NAME]) {
|
||||
// First-time registration
|
||||
config.models.providers[PROVIDER_NAME] = {
|
||||
baseUrl: `http://${LOCAL_HOST}:${DEFAULT_PORT}${OPENAI_API_BASE}`,
|
||||
api: "openai-completions",
|
||||
authHeader: false,
|
||||
models: desiredModels,
|
||||
};
|
||||
} else {
|
||||
// Update only the models array; leave baseUrl/api/authHeader untouched (user may have customized port)
|
||||
config.models.providers[PROVIDER_NAME].models = desiredModels;
|
||||
}
|
||||
|
||||
if (!config.agents) config.agents = {};
|
||||
if (!config.agents.defaults) config.agents.defaults = {};
|
||||
if (!config.agents.defaults.models) config.agents.defaults.models = {};
|
||||
|
||||
// Remove stale claude-local/* aliases, then add desired ones
|
||||
for (const key of Object.keys(config.agents.defaults.models)) {
|
||||
if (key.startsWith(`${PROVIDER_NAME}/`)) delete config.agents.defaults.models[key];
|
||||
}
|
||||
Object.assign(config.agents.defaults.models, desiredAliases);
|
||||
|
||||
writeFileSync(OPENCLAW_CONFIG, JSON.stringify(config, null, 2) + "\n");
|
||||
|
||||
if (added.length > 0) log(`Added: ${added.join(", ")}`);
|
||||
if (removed.length > 0) log(`Removed (no longer in models.json): ${removed.join(", ")}`);
|
||||
log(`OpenClaw registry synced: ${desiredModels.length} models registered`);
|
||||
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/upgrade.mjs — OCP unified upgrade dispatcher.
|
||||
*
|
||||
* Paths:
|
||||
* noop current == latest, exit 0
|
||||
* light same major.minor, patch bump only (existing fast path; delegated to bash)
|
||||
* full cross-minor (snapshot + setup.mjs + post-flight)
|
||||
* fresh_install from-version < v3.4.0 (--yes required for non-interactive)
|
||||
* rollback restore from snapshot
|
||||
*/
|
||||
import { runDoctor } from "./doctor.mjs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { existsSync, copyFileSync } from "node:fs";
|
||||
import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs";
|
||||
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
||||
|
||||
export async function runUpgrade(opts = {}) {
|
||||
const dryRun = !!opts.dryRun;
|
||||
const yes = !!opts.yes;
|
||||
// yes is reserved for Bundle 3 (fresh-install / rollback interactive gate); not used in upgrade-path here.
|
||||
const plan = [];
|
||||
|
||||
// --- rollback path (no doctor needed; snapshot is authoritative) ---
|
||||
if (opts.rollback) {
|
||||
return await runRollback(opts);
|
||||
}
|
||||
|
||||
// --- doctor pre-flight ---
|
||||
const doctor = opts.mockDoctor || await runDoctor();
|
||||
if (!doctor.ready_to_upgrade && doctor.next_action.kind !== "fresh_install") {
|
||||
throw new Error(`doctor FAIL: ${doctor.next_action.kind} (run "ocp doctor" for details)`);
|
||||
}
|
||||
|
||||
const kind = doctor.next_action.kind;
|
||||
plan.push(`[doctor] from=${doctor.current_version} to=${doctor.latest_version} kind=${kind}`);
|
||||
|
||||
// --- noop ---
|
||||
if (kind === "noop") {
|
||||
plan.push(`[noop] already at latest (${doctor.latest_version})`);
|
||||
return { path: "noop", executed: true, changed: false, plan };
|
||||
}
|
||||
|
||||
// --- dry-run early exit ---
|
||||
if (dryRun) {
|
||||
plan.push(`[plan] would proceed with ${kind} path`);
|
||||
if (kind === "upgrade") {
|
||||
plan.push(`[plan] phase 1: snapshot to ~/.ocp/upgrade-snapshot-<ts>/`);
|
||||
plan.push(`[plan] phase 2: git checkout ${doctor.latest_version} && npm install`);
|
||||
plan.push(`[plan] phase 3: node setup.mjs`);
|
||||
plan.push(`[plan] phase 4: launchctl bootout/bootstrap`);
|
||||
plan.push(`[plan] phase 5: post-flight /health + /v1/models`);
|
||||
} else if (kind === "update") {
|
||||
plan.push(`[plan] light path: git pull + npm install + restart`);
|
||||
} else if (kind === "fresh_install") {
|
||||
plan.push(`[plan] fresh-install ai_executable[]:`);
|
||||
for (const cmd of doctor.next_action.ai_executable) plan.push(` - ${cmd}`);
|
||||
}
|
||||
return { path: kind, executed: false, plan };
|
||||
}
|
||||
|
||||
// --- non-dry-run paths ---
|
||||
if (kind === "update") {
|
||||
return { path: "update", executed: true, changed: true, plan: [...plan, "[light] delegated to bash cmd_update existing logic"] };
|
||||
}
|
||||
|
||||
if (kind === "upgrade") {
|
||||
return await runFullUpgrade({ doctor, opts });
|
||||
}
|
||||
|
||||
if (kind === "fresh_install") {
|
||||
return await runFreshInstall({ doctor, opts });
|
||||
}
|
||||
|
||||
throw new Error(`path ${kind} not yet implemented`);
|
||||
}
|
||||
|
||||
async function runFullUpgrade({ doctor, opts }) {
|
||||
const phases = [];
|
||||
let snapshotPath = null;
|
||||
const exec = (cmd, label) => {
|
||||
if (opts.mockExec) {
|
||||
phases.push({ name: label, cmd, status: "skipped-mock" });
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const out = execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
||||
phases.push({ name: label, cmd, status: "ok" });
|
||||
return out;
|
||||
} catch (err) {
|
||||
const detail = err.stderr?.toString().trim();
|
||||
phases.push({ name: label, cmd, status: "fail", stderr: detail });
|
||||
throw Object.assign(
|
||||
new Error(`phase ${label} failed: ${detail || err.message}`),
|
||||
{ phases, cmd }
|
||||
);
|
||||
}
|
||||
};
|
||||
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
||||
|
||||
try {
|
||||
// phase 1: pre-flight (doctor already passed; just record)
|
||||
phases.push({ name: "pre-flight", status: "ok", note: `kind=upgrade from=${doctor.current_version} to=${doctor.latest_version}` });
|
||||
|
||||
// phase 2: snapshot
|
||||
const fromCommit = opts.mockExec
|
||||
? "mock-commit"
|
||||
: execSync(`git -C ${ocpDir} rev-parse HEAD`).toString().trim();
|
||||
snapshotPath = opts.mockExec
|
||||
? "/tmp/mock-snapshot"
|
||||
: writeSnapshot({ homeDir: homedir(), fromCommit, fromVersion: doctor.current_version, toVersion: doctor.latest_version });
|
||||
phases.push({ name: "snapshot", path: snapshotPath, status: "ok" });
|
||||
|
||||
// phase 3: fetch + install
|
||||
exec(`git -C ${ocpDir} fetch --tags --quiet`, "fetch+install");
|
||||
exec(`git -C ${ocpDir} checkout ${doctor.latest_version}`, "fetch+install");
|
||||
exec(`npm --prefix ${ocpDir} install --no-audit --no-fund`, "fetch+install");
|
||||
|
||||
// phase 4: reconfigure
|
||||
exec(`node ${ocpDir}/setup.mjs`, "reconfigure");
|
||||
|
||||
// phase 5: restart (heads-up note printed before invoking)
|
||||
if (!opts.mockExec) {
|
||||
console.error(`[heads-up] restarting OCP service in 3s — expect ~5–10s blip on requests in flight.`);
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
exec(`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, "restart");
|
||||
exec(`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, "restart");
|
||||
} else {
|
||||
exec(`systemctl --user restart ocp-proxy.service`, "restart");
|
||||
}
|
||||
|
||||
// phase 6: post-flight (10s budget; skipped under mockExec)
|
||||
if (!opts.mockExec) {
|
||||
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
||||
let ok = false;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
const out = execSync(`curl -sf --max-time 2 http://127.0.0.1:${port}/health`).toString();
|
||||
const body = JSON.parse(out);
|
||||
if (body.auth?.ok === true) { ok = true; break; }
|
||||
} catch { /* retry */ }
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
if (!ok) {
|
||||
phases.push({ name: "post-flight", status: "fail", message: "health did not return auth.ok=true within 10s" });
|
||||
throw new Error("post-flight failed");
|
||||
}
|
||||
execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/v1/models > /dev/null`);
|
||||
phases.push({ name: "post-flight", status: "ok" });
|
||||
} else {
|
||||
phases.push({ name: "post-flight", status: "skipped-mock" });
|
||||
}
|
||||
|
||||
// Auto-GC old snapshots after successful upgrade (best-effort, never throws).
|
||||
try {
|
||||
const gc = gcSnapshots(homedir(), { keepCount: 5, keepDays: 30 });
|
||||
if (gc.removed.length > 0) {
|
||||
console.error(`[gc] removed ${gc.removed.length} old snapshots; kept ${gc.kept.length}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[gc] warn: snapshot GC failed: ${e.message}`);
|
||||
}
|
||||
|
||||
return { path: "upgrade", executed: true, changed: true, snapshotPath, phases };
|
||||
} catch (err) {
|
||||
if (snapshotPath && !err.snapshotPath) {
|
||||
Object.assign(err, {
|
||||
snapshotPath,
|
||||
phases,
|
||||
hint: "Working tree may be at new version. Run `ocp update --rollback` to restore from snapshot."
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function runFreshInstall({ doctor, opts }) {
|
||||
if (!opts.yes) {
|
||||
throw new Error("fresh_install requires --yes for non-interactive execution (or run interactively and answer y)");
|
||||
}
|
||||
const steps = [];
|
||||
for (const cmd of doctor.next_action.ai_executable) {
|
||||
if (opts.mockExec) {
|
||||
steps.push({ cmd, status: "skipped-mock" });
|
||||
} else {
|
||||
try {
|
||||
execSync(cmd, { stdio: "inherit" });
|
||||
steps.push({ cmd, status: "ok" });
|
||||
} catch (e) {
|
||||
const detail = e.stderr?.toString().trim() || e.message;
|
||||
steps.push({ cmd, status: "fail", error: String(detail) });
|
||||
throw Object.assign(new Error(`fresh_install step failed: ${cmd} — ${detail}`), { steps });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { path: "fresh_install", executed: true, changed: true, steps };
|
||||
}
|
||||
|
||||
async function runRollback(opts) {
|
||||
const homeDir = opts.homeDir || homedir();
|
||||
const snapshots = opts.mockSnapshots ?? listSnapshots(homeDir);
|
||||
|
||||
if (opts.gc) {
|
||||
const result = gcSnapshots(homeDir, { dryRun: opts.dryRun });
|
||||
return { path: opts.dryRun ? "rollback-gc-dry-run" : "rollback-gc", ...result };
|
||||
}
|
||||
|
||||
if (opts.list) {
|
||||
return { path: "rollback-list", snapshots };
|
||||
}
|
||||
if (snapshots.length === 0) {
|
||||
throw new Error("no upgrade snapshots found in ~/.ocp/upgrade-snapshot-*");
|
||||
}
|
||||
|
||||
const target = opts.snapshotPath
|
||||
? snapshots.find(s => s.path === opts.snapshotPath)
|
||||
: snapshots[snapshots.length - 1];
|
||||
if (!target) throw new Error(`snapshot not found: ${opts.snapshotPath} (must be inside ~/.ocp/upgrade-snapshot-*)`);
|
||||
|
||||
const meta = opts.mockSnapshotMeta ?? readSnapshot(target.path);
|
||||
if (!meta.fromCommit) throw new Error(`snapshot ${target.path} has no from-commit.txt`);
|
||||
|
||||
const phases = [];
|
||||
if (opts.dryRun) {
|
||||
return {
|
||||
path: "rollback-dry-run",
|
||||
executed: false,
|
||||
target: target.path,
|
||||
plan: [
|
||||
`git checkout ${meta.fromCommit}`,
|
||||
`cp ${target.path}/plist ~/Library/LaunchAgents/dev.ocp.proxy.plist`,
|
||||
`cp ${target.path}/db.bak ~/.ocp/ocp.db`,
|
||||
`launchctl bootout/bootstrap`,
|
||||
`ocp doctor`
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (!opts.yes) throw new Error("rollback requires --yes for non-interactive execution");
|
||||
|
||||
const exec = (cmd, label) => {
|
||||
if (opts.mockExec) {
|
||||
phases.push({ name: label, cmd, status: "skipped-mock" });
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
phases.push({ name: label, cmd, status: "ok" });
|
||||
} catch (err) {
|
||||
const detail = err.stderr?.toString().trim();
|
||||
phases.push({ name: label, cmd, status: "fail", stderr: detail });
|
||||
throw Object.assign(
|
||||
new Error(`rollback phase ${label} failed: ${detail || err.message}`),
|
||||
{ phases, target: target.path }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
||||
exec(`git -C ${ocpDir} checkout ${meta.fromCommit}`, "git-checkout");
|
||||
|
||||
if (!opts.mockExec) {
|
||||
const tryCopy = (src, dst) => {
|
||||
try {
|
||||
if (existsSync(src)) copyFileSync(src, dst);
|
||||
} catch (err) {
|
||||
console.error(`[rollback] warn: could not restore ${src} → ${dst} (${err.code || err.message})`);
|
||||
}
|
||||
};
|
||||
tryCopy(join(target.path, "plist"), join(homeDir, "Library", "LaunchAgents", "dev.ocp.proxy.plist"));
|
||||
tryCopy(join(target.path, "service"), join(homeDir, ".config", "systemd", "user", "ocp-proxy.service"));
|
||||
tryCopy(join(target.path, "db.bak"), join(homeDir, ".ocp", "ocp.db"));
|
||||
tryCopy(join(target.path, "admin-key"), join(homeDir, ".ocp", "admin-key"));
|
||||
phases.push({ name: "restore-files", status: "ok" });
|
||||
} else {
|
||||
phases.push({ name: "restore-files", status: "skipped-mock" });
|
||||
}
|
||||
|
||||
exec(`npm --prefix ${ocpDir} install --no-audit --no-fund`, "npm-install");
|
||||
|
||||
if (!opts.mockExec) {
|
||||
console.error(`[heads-up] restarting OCP service in 3s — expect ~5–10s blip on requests in flight.`);
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
exec(`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, "restart");
|
||||
exec(`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, "restart");
|
||||
} else {
|
||||
exec(`systemctl --user restart ocp-proxy.service`, "restart");
|
||||
}
|
||||
|
||||
return { path: "rollback", executed: true, changed: true, target: target.path, phases };
|
||||
}
|
||||
|
||||
// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths.
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { realpathSync } from "node:fs";
|
||||
function _isMain() {
|
||||
if (!process.argv[1]) return false;
|
||||
try {
|
||||
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
||||
} catch { return false; }
|
||||
}
|
||||
if (_isMain()) {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const yes = args.includes("--yes");
|
||||
const rollback = args.includes("--rollback");
|
||||
const list = args.includes("--list");
|
||||
const gc = args.includes("--gc");
|
||||
const targetIdx = args.indexOf("--target");
|
||||
const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined;
|
||||
// First non-flag positional after --rollback is the snapshot path
|
||||
let snapshotPath;
|
||||
if (rollback) {
|
||||
const rb = args.indexOf("--rollback");
|
||||
const cand = args[rb + 1];
|
||||
if (cand && !cand.startsWith("--")) snapshotPath = cand;
|
||||
}
|
||||
try {
|
||||
const result = await runUpgrade({ dryRun, yes, rollback, list, gc, snapshotPath, target });
|
||||
if (result.plan) for (const line of result.plan) console.log(line);
|
||||
if (result.phases) for (const p of result.phases) console.log(`[${p.name}] ${p.status}${p.cmd ? `: ${p.cmd}` : ""}`);
|
||||
if (result.steps) for (const s of result.steps) console.log(` ${s.status === "ok" ? "✓" : s.status === "skipped-mock" ? "·" : "✗"} ${s.cmd}`);
|
||||
if (result.snapshots) {
|
||||
console.log(`Found ${result.snapshots.length} snapshots:`);
|
||||
for (const s of result.snapshots) console.log(` ${s.name}`);
|
||||
}
|
||||
if (result.removed && result.kept) {
|
||||
console.log(`Snapshots: kept ${result.kept.length}, ${result.dryRun ? "would remove" : "removed"} ${result.removed.length}`);
|
||||
for (const s of result.removed) console.log(` - ${s.name}`);
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error(`✗ ${e.message}`);
|
||||
if (e.snapshotPath) console.error(` snapshot: ${e.snapshotPath}`);
|
||||
if (e.target) console.error(` target: ${e.target}`);
|
||||
if (e.hint) console.error(` hint: ${e.hint}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
+1015
-190
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* openclaw-claude-proxy setup
|
||||
* OCP (Open Claude Proxy) setup
|
||||
*
|
||||
* Automatically configures OpenClaw to use Claude CLI as a model provider.
|
||||
* Run: node setup.mjs [--port 3456] [--default-model opus|sonnet|haiku] [--dry-run]
|
||||
* Run: node setup.mjs [--port N] [--default-model opus|sonnet|haiku] [--dry-run]
|
||||
* (default port = DEFAULT_PORT from lib/constants.mjs)
|
||||
*
|
||||
* What it does:
|
||||
* 1. Verifies claude CLI is installed and authenticated
|
||||
@@ -12,11 +13,13 @@
|
||||
* 4. Creates start.sh for easy launch
|
||||
* 5. Optionally starts the proxy
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync, chmodSync } from "node:fs";
|
||||
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const HOME = homedir();
|
||||
@@ -31,7 +34,7 @@ const opt = (name, fallback) => {
|
||||
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
||||
};
|
||||
|
||||
const PORT = parseInt(opt("port", "3456"), 10);
|
||||
const PORT = parseInt(opt("port", String(DEFAULT_PORT)), 10);
|
||||
const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku
|
||||
const DRY_RUN = flag("dry-run");
|
||||
const SKIP_START = flag("no-start");
|
||||
@@ -39,49 +42,70 @@ const PROVIDER_NAME = opt("provider-name", "claude-local");
|
||||
const BIND_ADDRESS = opt("bind", "127.0.0.1");
|
||||
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
|
||||
|
||||
const MODEL_ID_MAP = {
|
||||
opus: "claude-opus-4-6",
|
||||
sonnet: "claude-sonnet-4-6",
|
||||
haiku: "claude-haiku-4",
|
||||
};
|
||||
// ── Service-env injection: CLAUDE_BIN, OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY ──
|
||||
// These are read from the user's shell env at install time and written into
|
||||
// the service unit (plist / systemd) so the daemon picks them up on boot.
|
||||
|
||||
// CLAUDE_BIN — detect at install time; omit if not found (server.mjs fallback)
|
||||
let CLAUDE_BIN_INJECT = null;
|
||||
if (process.env.CLAUDE_BIN) {
|
||||
CLAUDE_BIN_INJECT = process.env.CLAUDE_BIN;
|
||||
} else {
|
||||
try {
|
||||
const detected = execSync("which claude 2>/dev/null", { encoding: "utf-8" }).trim();
|
||||
if (detected && existsSync(detected)) {
|
||||
CLAUDE_BIN_INJECT = detected;
|
||||
}
|
||||
} catch { /* which not available or claude not on PATH — omit */ }
|
||||
}
|
||||
|
||||
// OCP_ADMIN_KEY — omit entirely when empty/unset; don't write empty string
|
||||
const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null;
|
||||
|
||||
// PROXY_ANONYMOUS_KEY — same pattern
|
||||
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
|
||||
|
||||
// ── Inject-value helpers ─────────────────────────────────────────────────
|
||||
// Escape a value for safe inclusion in a plist <string>…</string> body.
|
||||
function xmlEscape(v) {
|
||||
return String(v).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
// Validate an injected service value: no control chars (a newline would inject a
|
||||
// rogue systemd Environment= directive; other control chars corrupt the unit/plist).
|
||||
// Spaces are allowed — filesystem paths (CLAUDE_BIN) may legitimately contain them.
|
||||
function assertSafeInjectValue(name, v) {
|
||||
if (v == null) return v;
|
||||
if (/[\x00-\x1f]/.test(String(v))) {
|
||||
console.error(`FATAL: ${name} contains a newline or control character — refusing to write it into the service unit.`);
|
||||
process.exit(1);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// Validate all three INJECT values before they are written into any service unit.
|
||||
assertSafeInjectValue("CLAUDE_BIN", CLAUDE_BIN_INJECT);
|
||||
assertSafeInjectValue("OCP_ADMIN_KEY", OCP_ADMIN_KEY_INJECT);
|
||||
assertSafeInjectValue("PROXY_ANONYMOUS_KEY", PROXY_ANON_KEY_INJECT);
|
||||
|
||||
// ── Models: derived from models.json (single source of truth) ──────────
|
||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
||||
|
||||
const MODEL_ID_MAP = modelsConfig.aliases;
|
||||
const DEFAULT_MODEL_ID = MODEL_ID_MAP[DEFAULT_MODEL] || MODEL_ID_MAP.opus;
|
||||
|
||||
// ── Models to register ──────────────────────────────────────────────────
|
||||
const MODELS = [
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6 (via CLI)",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 16384,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6 (via CLI)",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 16384,
|
||||
},
|
||||
{
|
||||
id: "claude-haiku-4",
|
||||
name: "Claude Haiku 4 (via CLI)",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
];
|
||||
const MODELS = modelsConfig.models.map(m => ({
|
||||
id: m.id,
|
||||
name: m.openclawName,
|
||||
reasoning: m.reasoning,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: m.contextWindow,
|
||||
maxTokens: m.maxTokens,
|
||||
}));
|
||||
|
||||
const MODEL_ALIASES = {
|
||||
[`${PROVIDER_NAME}/claude-opus-4-6`]: { alias: "Claude Opus 4.6" },
|
||||
[`${PROVIDER_NAME}/claude-sonnet-4-6`]: { alias: "Claude Sonnet 4.6" },
|
||||
[`${PROVIDER_NAME}/claude-haiku-4`]: { alias: "Claude Haiku 4" },
|
||||
};
|
||||
const MODEL_ALIASES = Object.fromEntries(
|
||||
modelsConfig.models.map(m => [`${PROVIDER_NAME}/${m.id}`, { alias: m.displayName }])
|
||||
);
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
function log(msg) { console.log(` ✓ ${msg}`); }
|
||||
@@ -117,120 +141,134 @@ try {
|
||||
}
|
||||
|
||||
// Check claude auth (quick test)
|
||||
try {
|
||||
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
|
||||
encoding: "utf-8",
|
||||
timeout: 30000,
|
||||
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
|
||||
}).trim();
|
||||
if (out.length > 0) {
|
||||
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
|
||||
// NOTE: This probe uses `claude -p` (sdk-cli spawn). After the 2026-06-15 Anthropic billing
|
||||
// split, every `claude -p` call draws from the Agent SDK credit pool rather than the
|
||||
// Pro/Max subscription. Re-running setup after 6/15 will consume one metered credit.
|
||||
// Set OCP_SKIP_AUTH_TEST=1 to skip this probe (auth is still validated at first real request).
|
||||
if (process.env.OCP_SKIP_AUTH_TEST === "1") {
|
||||
warn("OCP_SKIP_AUTH_TEST=1 — skipping claude auth probe (will be validated at first request).");
|
||||
} else {
|
||||
try {
|
||||
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
|
||||
encoding: "utf-8",
|
||||
timeout: 30000,
|
||||
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
|
||||
}).trim();
|
||||
if (out.length > 0) {
|
||||
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
|
||||
}
|
||||
} catch (e) {
|
||||
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
|
||||
warn("Make sure you're logged in: claude login");
|
||||
}
|
||||
} catch (e) {
|
||||
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
|
||||
warn("Make sure you're logged in: claude login");
|
||||
}
|
||||
|
||||
// Check openclaw config
|
||||
if (!existsSync(CONFIG_PATH)) fail(`OpenClaw config not found at ${CONFIG_PATH}`);
|
||||
log(`OpenClaw config: ${CONFIG_PATH}`);
|
||||
// Check openclaw config (optional — OCP runs standalone without OpenClaw)
|
||||
const OPENCLAW_PRESENT = existsSync(CONFIG_PATH);
|
||||
if (OPENCLAW_PRESENT) {
|
||||
log(`OpenClaw config: ${CONFIG_PATH}`);
|
||||
} else {
|
||||
warn(`OpenClaw not detected at ${CONFIG_PATH} — skipping OpenClaw integration.`);
|
||||
warn(`To register OCP with OpenClaw later, install OpenClaw and re-run \`node setup.mjs\`,`);
|
||||
warn(`or run \`ocp update\` if OpenClaw is installed afterward.`);
|
||||
}
|
||||
|
||||
// ── Step 2: Patch openclaw.json ─────────────────────────────────────────
|
||||
console.log("\n📝 Configuring OpenClaw...\n");
|
||||
if (OPENCLAW_PRESENT) {
|
||||
console.log("\n📝 Configuring OpenClaw...\n");
|
||||
|
||||
const config = readJSON(CONFIG_PATH);
|
||||
const config = readJSON(CONFIG_PATH);
|
||||
|
||||
// Ensure models.providers exists
|
||||
if (!config.models) config.models = {};
|
||||
if (!config.models.providers) config.models.providers = {};
|
||||
// Ensure models.providers exists
|
||||
if (!config.models) config.models = {};
|
||||
if (!config.models.providers) config.models.providers = {};
|
||||
|
||||
// Add/update claude-local provider
|
||||
config.models.providers[PROVIDER_NAME] = {
|
||||
baseUrl: `http://127.0.0.1:${PORT}/v1`,
|
||||
api: "openai-completions",
|
||||
authHeader: false,
|
||||
models: MODELS,
|
||||
};
|
||||
log(`Provider "${PROVIDER_NAME}" → http://127.0.0.1:${PORT}/v1`);
|
||||
// Add/update claude-local provider
|
||||
config.models.providers[PROVIDER_NAME] = {
|
||||
baseUrl: `http://127.0.0.1:${PORT}/v1`,
|
||||
api: "openai-completions",
|
||||
authHeader: false,
|
||||
models: MODELS,
|
||||
};
|
||||
log(`Provider "${PROVIDER_NAME}" → http://127.0.0.1:${PORT}/v1`);
|
||||
|
||||
// Ensure auth profile in config
|
||||
if (!config.auth) config.auth = {};
|
||||
if (!config.auth.profiles) config.auth.profiles = {};
|
||||
config.auth.profiles[`${PROVIDER_NAME}:default`] = {
|
||||
provider: PROVIDER_NAME,
|
||||
mode: "api_key",
|
||||
};
|
||||
log(`Auth profile "${PROVIDER_NAME}:default" registered`);
|
||||
// Ensure auth profile in config
|
||||
if (!config.auth) config.auth = {};
|
||||
if (!config.auth.profiles) config.auth.profiles = {};
|
||||
config.auth.profiles[`${PROVIDER_NAME}:default`] = {
|
||||
provider: PROVIDER_NAME,
|
||||
mode: "api_key",
|
||||
};
|
||||
log(`Auth profile "${PROVIDER_NAME}:default" registered`);
|
||||
|
||||
// Add models to agents.defaults.models
|
||||
if (!config.agents) config.agents = {};
|
||||
if (!config.agents.defaults) config.agents.defaults = {};
|
||||
if (!config.agents.defaults.models) config.agents.defaults.models = {};
|
||||
for (const [key, val] of Object.entries(MODEL_ALIASES)) {
|
||||
config.agents.defaults.models[key] = val;
|
||||
}
|
||||
log(`Model aliases added to agents.defaults.models`);
|
||||
|
||||
// Set idleTimeoutSeconds to 0 — critical for Claude tool-use.
|
||||
// When Claude calls tools (Bash, Read, etc.), the token stream pauses for 30-120s.
|
||||
// OpenClaw's default idleTimeoutSeconds (60s) kills the connection mid-tool-call,
|
||||
// causing exit 143 (SIGTERM) and stuck sessions. Setting to 0 disables the idle timer.
|
||||
if (!config.agents.defaults.llm) config.agents.defaults.llm = {};
|
||||
if (config.agents.defaults.llm.idleTimeoutSeconds === undefined ||
|
||||
config.agents.defaults.llm.idleTimeoutSeconds > 0) {
|
||||
config.agents.defaults.llm.idleTimeoutSeconds = 0;
|
||||
log(`Set agents.defaults.llm.idleTimeoutSeconds = 0 (prevents tool-call timeouts)`);
|
||||
} else {
|
||||
log(`idleTimeoutSeconds already configured: ${config.agents.defaults.llm.idleTimeoutSeconds}`);
|
||||
}
|
||||
|
||||
writeJSON(CONFIG_PATH, config);
|
||||
log(`Config saved`);
|
||||
|
||||
// ── Step 3: Patch auth-profiles.json ────────────────────────────────────
|
||||
console.log("\n🔑 Configuring auth profiles...\n");
|
||||
|
||||
// Find all agent auth-profiles.json files
|
||||
const agentsDir = join(OPENCLAW_DIR, "agents");
|
||||
const agentDirs = existsSync(agentsDir)
|
||||
? readdirSync(agentsDir).filter((d) => {
|
||||
const ap = join(agentsDir, d, "agent", "auth-profiles.json");
|
||||
return existsSync(ap);
|
||||
})
|
||||
: [];
|
||||
|
||||
import { readdirSync } from "node:fs";
|
||||
|
||||
for (const agentId of agentDirs) {
|
||||
const apPath = join(agentsDir, agentId, "agent", "auth-profiles.json");
|
||||
try {
|
||||
const ap = readJSON(apPath);
|
||||
if (!ap.profiles) ap.profiles = {};
|
||||
|
||||
// Add claude-local profile if missing
|
||||
if (!ap.profiles[`${PROVIDER_NAME}:default`]) {
|
||||
ap.profiles[`${PROVIDER_NAME}:default`] = {
|
||||
type: "api_key",
|
||||
provider: PROVIDER_NAME,
|
||||
key: "local-proxy-no-auth",
|
||||
};
|
||||
}
|
||||
|
||||
// Add to lastGood if missing
|
||||
if (!ap.lastGood) ap.lastGood = {};
|
||||
if (!ap.lastGood[PROVIDER_NAME]) {
|
||||
ap.lastGood[PROVIDER_NAME] = `${PROVIDER_NAME}:default`;
|
||||
}
|
||||
|
||||
writeJSON(apPath, ap);
|
||||
log(`Agent "${agentId}" auth profile updated`);
|
||||
} catch (e) {
|
||||
warn(`Skipped agent "${agentId}": ${e.message}`);
|
||||
// Add models to agents.defaults.models
|
||||
if (!config.agents) config.agents = {};
|
||||
if (!config.agents.defaults) config.agents.defaults = {};
|
||||
if (!config.agents.defaults.models) config.agents.defaults.models = {};
|
||||
for (const [key, val] of Object.entries(MODEL_ALIASES)) {
|
||||
config.agents.defaults.models[key] = val;
|
||||
}
|
||||
}
|
||||
log(`Model aliases added to agents.defaults.models`);
|
||||
|
||||
if (agentDirs.length === 0) {
|
||||
warn("No agent auth-profiles.json found — you may need to restart the gateway first");
|
||||
// Set idleTimeoutSeconds to 0 — critical for Claude tool-use.
|
||||
// When Claude calls tools (Bash, Read, etc.), the token stream pauses for 30-120s.
|
||||
// OpenClaw's default idleTimeoutSeconds (60s) kills the connection mid-tool-call,
|
||||
// causing exit 143 (SIGTERM) and stuck sessions. Setting to 0 disables the idle timer.
|
||||
if (!config.agents.defaults.llm) config.agents.defaults.llm = {};
|
||||
if (config.agents.defaults.llm.idleTimeoutSeconds === undefined ||
|
||||
config.agents.defaults.llm.idleTimeoutSeconds > 0) {
|
||||
config.agents.defaults.llm.idleTimeoutSeconds = 0;
|
||||
log(`Set agents.defaults.llm.idleTimeoutSeconds = 0 (prevents tool-call timeouts)`);
|
||||
} else {
|
||||
log(`idleTimeoutSeconds already configured: ${config.agents.defaults.llm.idleTimeoutSeconds}`);
|
||||
}
|
||||
|
||||
writeJSON(CONFIG_PATH, config);
|
||||
log(`Config saved`);
|
||||
|
||||
// ── Step 3: Patch auth-profiles.json ────────────────────────────────────
|
||||
console.log("\n🔑 Configuring auth profiles...\n");
|
||||
|
||||
// Find all agent auth-profiles.json files
|
||||
const agentsDir = join(OPENCLAW_DIR, "agents");
|
||||
const agentDirs = existsSync(agentsDir)
|
||||
? readdirSync(agentsDir).filter((d) => {
|
||||
const ap = join(agentsDir, d, "agent", "auth-profiles.json");
|
||||
return existsSync(ap);
|
||||
})
|
||||
: [];
|
||||
|
||||
for (const agentId of agentDirs) {
|
||||
const apPath = join(agentsDir, agentId, "agent", "auth-profiles.json");
|
||||
try {
|
||||
const ap = readJSON(apPath);
|
||||
if (!ap.profiles) ap.profiles = {};
|
||||
|
||||
// Add claude-local profile if missing
|
||||
if (!ap.profiles[`${PROVIDER_NAME}:default`]) {
|
||||
ap.profiles[`${PROVIDER_NAME}:default`] = {
|
||||
type: "api_key",
|
||||
provider: PROVIDER_NAME,
|
||||
key: "local-proxy-no-auth",
|
||||
};
|
||||
}
|
||||
|
||||
// Add to lastGood if missing
|
||||
if (!ap.lastGood) ap.lastGood = {};
|
||||
if (!ap.lastGood[PROVIDER_NAME]) {
|
||||
ap.lastGood[PROVIDER_NAME] = `${PROVIDER_NAME}:default`;
|
||||
}
|
||||
|
||||
writeJSON(apPath, ap);
|
||||
log(`Agent "${agentId}" auth profile updated`);
|
||||
} catch (e) {
|
||||
warn(`Skipped agent "${agentId}": ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (agentDirs.length === 0) {
|
||||
warn("No agent auth-profiles.json found — you may need to restart the gateway first");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 4: Create start.sh ─────────────────────────────────────────────
|
||||
@@ -241,7 +279,7 @@ const logDir = join(OPENCLAW_DIR, "logs");
|
||||
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
||||
|
||||
const startSh = `#!/bin/bash
|
||||
# Start openclaw-claude-proxy if not already running
|
||||
# Start OCP (Open Claude Proxy) if not already running
|
||||
PORT=\${CLAUDE_PROXY_PORT:-${PORT}}
|
||||
if ! lsof -i :\$PORT -sTCP:LISTEN &>/dev/null; then
|
||||
unset CLAUDECODE
|
||||
@@ -262,40 +300,71 @@ if (!DRY_RUN) {
|
||||
log(`Launcher: ${startPath}`);
|
||||
|
||||
// ── Step 5: Summary ─────────────────────────────────────────────────────
|
||||
console.log(`
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ Setup complete! ║
|
||||
╠══════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ Provider: ${PROVIDER_NAME.padEnd(44)}║
|
||||
║ Port: ${String(PORT).padEnd(44)}║
|
||||
║ Models: claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4║
|
||||
║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}║
|
||||
║ ║
|
||||
║ Start proxy: ║
|
||||
║ bash ${startPath.replace(HOME, "~").padEnd(50)}║
|
||||
║ ║
|
||||
║ Or directly: ║
|
||||
║ node ${serverPath.replace(HOME, "~").padEnd(49)}║
|
||||
║ ║
|
||||
║ Set as default model in openclaw.json: ║
|
||||
║ agents.defaults.model.primary = ║
|
||||
║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}║
|
||||
║ ║
|
||||
║ Then restart gateway: ║
|
||||
║ openclaw gateway restart ║
|
||||
║ ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
|
||||
// ── Step 6: Optionally start ────────────────────────────────────────────
|
||||
if (!SKIP_START && !DRY_RUN) {
|
||||
try {
|
||||
execSync(`bash "${startPath}"`, { stdio: "inherit" });
|
||||
} catch { /* ignore */ }
|
||||
const banner = [
|
||||
`╔══════════════════════════════════════════════════════════════╗`,
|
||||
`║ Setup complete! ║`,
|
||||
`╠══════════════════════════════════════════════════════════════╣`,
|
||||
`║ ║`,
|
||||
`║ Provider: ${PROVIDER_NAME.padEnd(44)}║`,
|
||||
`║ Port: ${String(PORT).padEnd(44)}║`,
|
||||
`║ Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}║`,
|
||||
`║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}║`,
|
||||
`║ ║`,
|
||||
`║ Start proxy: ║`,
|
||||
`║ bash ${startPath.replace(HOME, "~").padEnd(50)}║`,
|
||||
`║ ║`,
|
||||
`║ Or directly: ║`,
|
||||
`║ node ${serverPath.replace(HOME, "~").padEnd(49)}║`,
|
||||
`║ ║`,
|
||||
];
|
||||
if (OPENCLAW_PRESENT) {
|
||||
banner.push(
|
||||
`║ Set as default model in openclaw.json: ║`,
|
||||
`║ agents.defaults.model.primary = ║`,
|
||||
`║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}║`,
|
||||
`║ ║`,
|
||||
`║ Then restart gateway: ║`,
|
||||
`║ openclaw gateway restart ║`,
|
||||
`║ ║`,
|
||||
);
|
||||
} else {
|
||||
banner.push(
|
||||
`║ OpenClaw not detected — running in standalone mode. ║`,
|
||||
`║ Point your IDE (Cline / Cursor / Continue / OpenCode / ║`,
|
||||
`║ Aider / OpenClaw) at: ║`,
|
||||
`║ http://${BIND_ADDRESS}:${String(PORT)}/v1${" ".repeat(Math.max(0, 47 - BIND_ADDRESS.length - String(PORT).length))}║`,
|
||||
`║ ║`,
|
||||
`║ See README § "Client Setup" for per-IDE instructions. ║`,
|
||||
`║ ║`,
|
||||
);
|
||||
}
|
||||
banner.push(`╚══════════════════════════════════════════════════════════════╝`);
|
||||
console.log("\n" + banner.join("\n") + "\n");
|
||||
|
||||
// ── Step 7: Install auto-start on boot ──────────────────────────────────
|
||||
|
||||
// Log service-env injection plan (shown in both dry-run and live mode)
|
||||
console.log("\n🔧 Service unit env vars to inject:\n");
|
||||
if (CLAUDE_BIN_INJECT) {
|
||||
log(`CLAUDE_BIN: ${CLAUDE_BIN_INJECT}`);
|
||||
} else {
|
||||
log(`CLAUDE_BIN: (not found — server.mjs will auto-detect at runtime)`);
|
||||
}
|
||||
if (OCP_ADMIN_KEY_INJECT) {
|
||||
log(`OCP_ADMIN_KEY: injected (length: ${OCP_ADMIN_KEY_INJECT.length})`);
|
||||
} else {
|
||||
log(`OCP_ADMIN_KEY: (unset — admin endpoints disabled)`);
|
||||
}
|
||||
if (PROXY_ANON_KEY_INJECT) {
|
||||
log(`PROXY_ANONYMOUS_KEY: injected (set)`);
|
||||
} else {
|
||||
log(`PROXY_ANONYMOUS_KEY: (unset — anonymous access disabled)`);
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log("\n [dry-run] would write service unit with above env vars\n");
|
||||
}
|
||||
|
||||
if (!DRY_RUN) {
|
||||
console.log("\n🔄 Installing auto-start on login...\n");
|
||||
|
||||
@@ -364,11 +433,17 @@ if (!DRY_RUN) {
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>CLAUDE_PROXY_PORT</key>
|
||||
<string>${PORT}</string>
|
||||
<string>${xmlEscape(PORT)}</string>
|
||||
<key>CLAUDE_BIND</key>
|
||||
<string>${BIND_ADDRESS}</string>
|
||||
<string>${xmlEscape(BIND_ADDRESS)}</string>
|
||||
<key>CLAUDE_AUTH_MODE</key>
|
||||
<string>${AUTH_MODE_CONFIG}</string>
|
||||
<string>${xmlEscape(AUTH_MODE_CONFIG)}</string>${CLAUDE_BIN_INJECT ? `
|
||||
<key>CLAUDE_BIN</key>
|
||||
<string>${xmlEscape(CLAUDE_BIN_INJECT)}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
|
||||
<key>OCP_ADMIN_KEY</key>
|
||||
<string>${xmlEscape(OCP_ADMIN_KEY_INJECT)}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
|
||||
<key>PROXY_ANONYMOUS_KEY</key>
|
||||
<string>${xmlEscape(PROXY_ANON_KEY_INJECT)}</string>` : ""}
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
@@ -382,8 +457,15 @@ if (!DRY_RUN) {
|
||||
</plist>
|
||||
`;
|
||||
|
||||
writeFileSync(plistPath, plistXml);
|
||||
log(`Plist written: ${plistPath}`);
|
||||
const existingPlist = existsSync(plistPath) ? readFileSync(plistPath, "utf8") : null;
|
||||
const finalPlistXml = mergePlistEnv(existingPlist, plistXml);
|
||||
writeFileSync(plistPath, finalPlistXml);
|
||||
chmodSync(plistPath, 0o600);
|
||||
if (existingPlist && finalPlistXml !== plistXml) {
|
||||
log(`Plist written: ${plistPath} (mode 600, preserved user env vars)`);
|
||||
} else {
|
||||
log(`Plist written: ${plistPath} (mode 600)`);
|
||||
}
|
||||
|
||||
// Bootout first (in case it was already loaded) then bootstrap
|
||||
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
||||
@@ -406,7 +488,7 @@ After=network.target
|
||||
ExecStart=${nodeBin} ${serverPath}
|
||||
Environment=CLAUDE_PROXY_PORT=${PORT}
|
||||
Environment=CLAUDE_BIND=${BIND_ADDRESS}
|
||||
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}
|
||||
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}${CLAUDE_BIN_INJECT ? `\nEnvironment=CLAUDE_BIN=${CLAUDE_BIN_INJECT}` : ""}${OCP_ADMIN_KEY_INJECT ? `\nEnvironment=OCP_ADMIN_KEY=${OCP_ADMIN_KEY_INJECT}` : ""}${PROXY_ANON_KEY_INJECT ? `\nEnvironment=PROXY_ANONYMOUS_KEY=${PROXY_ANON_KEY_INJECT}` : ""}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:${logPath}
|
||||
@@ -416,8 +498,15 @@ StandardError=append:${logPath}
|
||||
WantedBy=default.target
|
||||
`;
|
||||
|
||||
writeFileSync(servicePath, serviceUnit);
|
||||
log(`Service file written: ${servicePath}`);
|
||||
const existingService = existsSync(servicePath) ? readFileSync(servicePath, "utf8") : null;
|
||||
const finalServiceUnit = mergeSystemdEnv(existingService, serviceUnit);
|
||||
writeFileSync(servicePath, finalServiceUnit);
|
||||
chmodSync(servicePath, 0o600);
|
||||
if (existingService && finalServiceUnit !== serviceUnit) {
|
||||
log(`Service file written: ${servicePath} (mode 600, preserved user env vars)`);
|
||||
} else {
|
||||
log(`Service file written: ${servicePath} (mode 600)`);
|
||||
}
|
||||
|
||||
execSync(`systemctl --user daemon-reload`);
|
||||
execSync(`systemctl --user enable ocp-proxy`);
|
||||
@@ -429,4 +518,52 @@ WantedBy=default.target
|
||||
}
|
||||
|
||||
console.log("\n✅ Auto-start installed — proxy will start automatically on login\n");
|
||||
|
||||
// ── Step 8: Post-install health verification ───────────────────────────
|
||||
if (!SKIP_START) {
|
||||
console.log("⏳ Waiting for server to bind...\n");
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
const healthUrl = `http://127.0.0.1:${PORT}/health`;
|
||||
let verified = false;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
const res = await fetch(healthUrl, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
|
||||
if (res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
console.log(` ✓ Health check passed (${healthUrl})`);
|
||||
console.log(` version: ${body.version ?? "unknown"}`);
|
||||
console.log(` authMode: ${body.authMode ?? "unknown"}`);
|
||||
|
||||
// Verify bind socket
|
||||
try {
|
||||
const bindCheck = process.platform === "linux"
|
||||
? execSync(`ss -tlnp 2>/dev/null | grep ':${PORT}'`, { encoding: "utf-8" }).trim()
|
||||
: execSync(`lsof -nP -iTCP:${PORT} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" }).trim();
|
||||
if (bindCheck) {
|
||||
console.log(` bind: ${bindCheck.split("\n")[0]}`);
|
||||
}
|
||||
} catch { /* bind check is best-effort */ }
|
||||
|
||||
verified = true;
|
||||
} else {
|
||||
warn(`Health check returned HTTP ${res.status} — service may not have started cleanly`);
|
||||
}
|
||||
} catch (e) {
|
||||
const isTimeout = e.name === "AbortError" || (e.cause && e.cause.code === "UND_ERR_CONNECT_TIMEOUT");
|
||||
warn(`Health check failed: ${isTimeout ? "timeout (5s)" : e.message}`);
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
const logHint = process.platform === "linux"
|
||||
? "journalctl --user -u ocp-proxy -n 50"
|
||||
: `tail -n 100 ~/.ocp/logs/proxy.log`;
|
||||
console.error(`\n ✗ Server did not respond on port ${PORT} within 5 seconds.`);
|
||||
console.error(` Check service logs:\n ${logHint}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Start openclaw-claude-proxy if not already running
|
||||
PORT=${CLAUDE_PROXY_PORT:-3456}
|
||||
if ! lsof -i :$PORT -sTCP:LISTEN &>/dev/null; then
|
||||
unset CLAUDECODE
|
||||
nohup node "/Users/taodeng/.openclaw/projects/claude-proxy/server.mjs" \
|
||||
>> "/Users/taodeng/.openclaw/logs/claude-proxy.log" \
|
||||
2>> "/Users/taodeng/.openclaw/logs/claude-proxy.err.log" &
|
||||
echo "claude-proxy started on port $PORT (pid $!)"
|
||||
else
|
||||
echo "claude-proxy already running on port $PORT"
|
||||
fi
|
||||
+2174
-1
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* openclaw-claude-proxy uninstaller
|
||||
* OCP (Open Claude Proxy) uninstaller
|
||||
*
|
||||
* Stops and removes the launchd (macOS) or systemd (Linux) auto-start entry.
|
||||
* Handles both legacy (ai.openclaw.proxy / openclaw-proxy) and current
|
||||
|
||||
Reference in New Issue
Block a user