Compare commits

..
Author SHA1 Message Date
taodengandClaude <claude-opus-4-8> <noreply@anthropic.com> 2ac3e7918c docs: restructure README (1205 → 497 lines) — ops-manual content moves to docs/
Maintainer-approved P2 restructure. Principle: what a new user needs in the first
10 minutes stays in README; the operations manual moves to docs/. Content MOVED,
not rewritten — an independent verifier swept all 20 original sections, 60+
distinctive facts, and all table rows against the new corpus: zero content loss.

New files (verbatim moves + two mandated dedup merges):
- docs/lan-mode.md    (396) — LAN setup, key management, quotas, anonymous access,
                              deployment/security model + honest limits, client connect
- docs/tui-mode.md    (196) — full TUI section + the four giant env-cell essays as
                              prose subsections; opens with the single-user SECURITY
                              warning + the PAUSED billing-split status banner
- docs/troubleshooting.md (136) — full troubleshooting; canonical 401/credential-
                              isolation explanation (union of the 4 prior copies)
- docs/upgrading.md   (79)  — upgrade paths, snapshots, rollback, auto-sync

README keeps: pitch (byte-identical), new TOC, 62-line Quickstart, How It Works
verbatim (incl. #171 billing-status note + workload fit), the three release_kit-
pinned tables (Available Models / API Endpoints / all 37 Environment Variables
rows — 4 giant TUI cells now one-line pointers), All Commands, slim Troubleshooting
(bootstrap quirks retained per release_kit bootstrap_quirk_policy), summary stubs
linking each moved doc, Repository Layout (+4 doc rows), Governance, Support.

Dedup (canonical copies): sdk-cli vs subscription-pool table → docs/tui-mode.md;
credential-isolated-home / permanent-401 → docs/troubleshooting.md#tui-401.
Link retargets: docs/runbooks/615-canary.md, docs/runbooks/tui-flip-rollback.md,
setup.mjs (one banner string; node --check clean). 126 links across the touched
files verified resolving; repo-wide grep shows no reference to a removed anchor.

npm test: 332 passed / 0 failed.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 14:28:08 +10:00
4 changed files with 22 additions and 139 deletions
-10
View File
@@ -59,16 +59,6 @@ export async function runDoctor(opts = {}) {
// of recommending a downgrade against a stale hardcoded value. // of recommending a downgrade against a stale hardcoded value.
let latestVersion = opts.mockLatest; let latestVersion = opts.mockLatest;
if (!latestVersion) { if (!latestVersion) {
// Issue #173: `git show origin/main:...` reads the LOCALLY CACHED remote ref. Without a
// fetch first, a machine that hasn't pulled since the last release sees latest == current
// and reports noop — new releases were invisible everywhere except the machine that cut
// the tag (live repro: Oracle VM, 2026-07-17). Fetch before comparing; on failure
// (offline, auth, timeout) fall through to the cached ref — the pre-existing behavior.
if (!opts.skipNetwork) {
try {
execSync(`git -C ${ocpDir} fetch --tags --quiet`, { stdio: ["pipe", "pipe", "pipe"], timeout: 15000 });
} catch { /* offline → compare against cached origin/main, as before */ }
}
try { try {
const out = execSync(`git -C ${ocpDir} show origin/main:package.json 2>/dev/null`, { stdio: ["pipe", "pipe", "pipe"] }).toString(); const out = execSync(`git -C ${ocpDir} show origin/main:package.json 2>/dev/null`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
const remotePkg = JSON.parse(out); const remotePkg = JSON.parse(out);
+4 -19
View File
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readd
import { join } from "node:path"; import { join } from "node:path";
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) { export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
const ts = formatSnapshotTimestamp(new Date()); const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z");
const root = join(homeDir, ".ocp", `upgrade-snapshot-${ts}`); const root = join(homeDir, ".ocp", `upgrade-snapshot-${ts}`);
mkdirSync(root, { recursive: true }); mkdirSync(root, { recursive: true });
@@ -48,10 +48,7 @@ export function listSnapshots(homeDir) {
return readdirSync(root) return readdirSync(root)
.filter(name => name.startsWith("upgrade-snapshot-")) .filter(name => name.startsWith("upgrade-snapshot-"))
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs })) .map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
.sort((a, b) => { .sort((a, b) => a.name.localeCompare(b.name));
const chronological = parseSnapshotTimestamp(a.name) - parseSnapshotTimestamp(b.name);
return chronological || a.name.localeCompare(b.name);
});
} }
/** /**
@@ -110,21 +107,9 @@ export function gcSnapshots(homeDir, opts = {}) {
} }
function parseSnapshotTimestamp(name) { function parseSnapshotTimestamp(name) {
// Both legacy ISO names and Windows-safe names are supported.
// upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms // upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms
// upgrade-snapshot-2026-05-11T08-30-00Z → epoch ms
const m = name.match(/upgrade-snapshot-(.+)$/); const m = name.match(/upgrade-snapshot-(.+)$/);
if (!m) return 0; if (!m) return 0;
const raw = m[1]; const t = Date.parse(m[1]);
const t = Date.parse(raw); return Number.isFinite(t) ? t : 0;
if (Number.isFinite(t)) return t;
const iso = raw.replace(/(T\d{2})-(\d{2})-(\d{2})Z$/, "$1:$2:$3Z");
const portable = Date.parse(iso);
return Number.isFinite(portable) ? portable : 0;
}
function formatSnapshotTimestamp(date) {
// Windows forbids ':' in directory names. Replacing only the time separators
// preserves chronological lexical order and keeps the timestamp readable.
return date.toISOString().replace(/\.\d+Z$/, "Z").replace(/:/g, "-");
} }
+2 -22
View File
@@ -17,20 +17,6 @@ import { existsSync, copyFileSync } from "node:fs";
import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs"; import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs";
import { DEFAULT_PORT } from "../lib/constants.mjs"; import { DEFAULT_PORT } from "../lib/constants.mjs";
// Post-flight acceptance predicate (issue #173). A health probe passes ONLY when the server
// is authed AND actually serving the TARGET version. auth.ok alone is not enough: a stale
// process holding the port answers auth.ok=true while still running the OLD code — exactly
// what a nohup-fallback orphan did on 2026-07-17 (upgrade "succeeded", /health kept serving
// 3.21.1). Comparing /health.version to the checkout target catches orphan-holds-port,
// restart-didn't-take, and wrong-unit-restarted alike. `target` tolerates a leading "v"
// (doctor reports "v3.22.1"; /health reports "3.22.1"); an empty/unknown target degrades to
// the old auth-only check rather than blocking an otherwise-good upgrade.
export function postFlightOk(body, target) {
if (body?.auth?.ok !== true) return false;
const want = String(target || "").replace(/^v/, "");
return !want || body?.version === want;
}
export async function runUpgrade(opts = {}) { export async function runUpgrade(opts = {}) {
const dryRun = !!opts.dryRun; const dryRun = !!opts.dryRun;
const yes = !!opts.yes; const yes = !!opts.yes;
@@ -151,22 +137,16 @@ async function runFullUpgrade({ doctor, opts }) {
if (!opts.mockExec) { if (!opts.mockExec) {
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT); const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
let ok = false; let ok = false;
let lastSeen = null;
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
try { try {
const out = execSync(`curl -sf --max-time 2 http://127.0.0.1:${port}/health`).toString(); const out = execSync(`curl -sf --max-time 2 http://127.0.0.1:${port}/health`).toString();
const body = JSON.parse(out); const body = JSON.parse(out);
lastSeen = body.version; if (body.auth?.ok === true) { ok = true; break; }
if (postFlightOk(body, doctor.latest_version)) { ok = true; break; }
} catch { /* retry */ } } catch { /* retry */ }
await new Promise(r => setTimeout(r, 1000)); await new Promise(r => setTimeout(r, 1000));
} }
if (!ok) { if (!ok) {
phases.push({ phases.push({ name: "post-flight", status: "fail", message: "health did not return auth.ok=true within 10s" });
name: "post-flight", status: "fail",
message: `health did not return auth.ok=true AND version=${doctor.latest_version} within 10s`
+ (lastSeen ? ` (last saw version=${lastSeen} — a stale process may still hold the port; check \`ss -ltnp\` / \`lsof -i\`)` : ""),
});
throw new Error("post-flight failed"); throw new Error("post-flight failed");
} }
execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/v1/models > /dev/null`); execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/v1/models > /dev/null`);
+16 -88
View File
@@ -822,34 +822,10 @@ test("doctor falls back to currentVersion when origin/main unreachable (no stale
}); });
// ── Upgrade Tests ── // ── Upgrade Tests ──
import { runUpgrade, postFlightOk } from "./scripts/upgrade.mjs"; import { runUpgrade } from "./scripts/upgrade.mjs";
console.log("\nUpgrade:"); console.log("\nUpgrade:");
// ── postFlightOk (issue #173) — the acceptance predicate for phase 6 ─────────
// Mutation-proof: revert the version comparison to auth-only and the "stale process
// still holds the port" test below goes green-to-red (that case is the 2026-07-17
// Oracle incident: orphan answered auth.ok=true while serving the OLD version).
test("postFlightOk: rejects a healthy-looking probe that serves the WRONG version (orphan case)", () => {
assert.equal(postFlightOk({ auth: { ok: true }, version: "3.21.1" }, "v3.22.1"), false);
});
test("postFlightOk: accepts auth.ok + exact target version, tolerating the leading v", () => {
assert.equal(postFlightOk({ auth: { ok: true }, version: "3.22.1" }, "v3.22.1"), true);
assert.equal(postFlightOk({ auth: { ok: true }, version: "3.22.1" }, "3.22.1"), true);
});
test("postFlightOk: auth failure rejects regardless of version", () => {
assert.equal(postFlightOk({ auth: { ok: false }, version: "3.22.1" }, "v3.22.1"), false);
assert.equal(postFlightOk({ version: "3.22.1" }, "v3.22.1"), false);
assert.equal(postFlightOk(null, "v3.22.1"), false);
});
test("postFlightOk: unknown/empty target degrades to the auth-only check (never blocks)", () => {
assert.equal(postFlightOk({ auth: { ok: true }, version: "3.22.1" }, ""), true);
assert.equal(postFlightOk({ auth: { ok: true }, version: "3.22.1" }, undefined), true);
});
test("upgrade --dry-run prints plan, no side effects", async () => { test("upgrade --dry-run prints plan, no side effects", async () => {
const result = await runUpgrade({ const result = await runUpgrade({
dryRun: true, dryRun: true,
@@ -904,41 +880,6 @@ import { join as testJoin } from "node:path";
console.log("\nSnapshot:"); console.log("\nSnapshot:");
const portableSnapshotName = (isoTimestamp) => `upgrade-snapshot-${isoTimestamp.replace(/:/g, "-")}`;
const legacyMixedSnapshot = "upgrade-snapshot-2026-05-11T09:05:00Z";
const portableMixedSnapshot = "upgrade-snapshot-2026-05-11T09-47-00Z";
function runMixedSnapshotScenario() {
// NTFS rejects the legacy ':' name, so exercise the real exported functions
// in an isolated process whose built-in fs bindings expose both formats.
const moduleUrl = new URL("./scripts/lib/snapshot.mjs", import.meta.url).href;
const script = `
import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
const names = ${JSON.stringify([legacyMixedSnapshot, portableMixedSnapshot])};
const deleted = [];
fs.existsSync = () => true;
fs.readdirSync = () => [...names];
fs.statSync = () => ({ mtimeMs: 0 });
fs.rmSync = (path) => { deleted.push(path); };
syncBuiltinESMExports();
const { listSnapshots, gcSnapshots } = await import(${JSON.stringify(moduleUrl)});
const listed = listSnapshots("/virtual-home").map(snapshot => snapshot.name);
const gc = gcSnapshots("/virtual-home", {
keepCount: 1,
keepDays: 0,
now: new Date("2026-05-12T00:00:00Z")
});
process.stdout.write(JSON.stringify({
listed,
kept: gc.kept.map(snapshot => snapshot.name),
removed: gc.removed.map(snapshot => snapshot.name),
deleted
}));
`;
return JSON.parse(execFileSync(process.execPath, ["--input-type=module", "--eval", script], { encoding: "utf8" }));
}
test("writeSnapshot creates dir + manifest files", () => { test("writeSnapshot creates dir + manifest files", () => {
const root = mkdtempSync(testJoin(tmpdir(), "ocp-snap-test-")); const root = mkdtempSync(testJoin(tmpdir(), "ocp-snap-test-"));
const dotOcp = testJoin(root, ".ocp"); const dotOcp = testJoin(root, ".ocp");
@@ -963,7 +904,7 @@ test("listSnapshots returns sorted by ISO timestamp", () => {
const dotOcp = testJoin(root, ".ocp"); const dotOcp = testJoin(root, ".ocp");
tMkdirSync(dotOcp, { recursive: true }); tMkdirSync(dotOcp, { recursive: true });
for (const ts of ["2026-05-01T10:00:00Z", "2026-05-02T10:00:00Z", "2026-05-03T10:00:00Z"]) { for (const ts of ["2026-05-01T10:00:00Z", "2026-05-02T10:00:00Z", "2026-05-03T10:00:00Z"]) {
tMkdirSync(testJoin(dotOcp, portableSnapshotName(ts))); tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
} }
const list = listSnapshots(root); const list = listSnapshots(root);
assert.equal(list.length, 3); assert.equal(list.length, 3);
@@ -972,19 +913,6 @@ test("listSnapshots returns sorted by ISO timestamp", () => {
rmSync(root, { recursive: true, force: true }); rmSync(root, { recursive: true, force: true });
}); });
test("listSnapshots sorts mixed legacy and Windows-safe names chronologically", () => {
const result = runMixedSnapshotScenario();
assert.deepEqual(result.listed, [legacyMixedSnapshot, portableMixedSnapshot]);
});
test("gcSnapshots keeps the newer Windows-safe snapshot across the format boundary", () => {
const result = runMixedSnapshotScenario();
assert.deepEqual(result.kept, [portableMixedSnapshot]);
assert.deepEqual(result.removed, [legacyMixedSnapshot]);
assert.equal(result.deleted.length, 1);
assert.ok(result.deleted[0].endsWith(legacyMixedSnapshot));
});
test("upgrade error after snapshot carries snapshotPath + hint", async () => { test("upgrade error after snapshot carries snapshotPath + hint", async () => {
// Use mockExec=true so no real commands are run. // Use mockExec=true so no real commands are run.
// Verify the success path returns a snapshotPath (Fix B regression guard). // Verify the success path returns a snapshotPath (Fix B regression guard).
@@ -1074,7 +1002,7 @@ test("gcSnapshots keeps last N regardless of age", () => {
const dotOcp = testJoin(root, ".ocp"); const dotOcp = testJoin(root, ".ocp");
tMkdirSync(dotOcp, { recursive: true }); tMkdirSync(dotOcp, { recursive: true });
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) { for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
tMkdirSync(testJoin(dotOcp, portableSnapshotName(ts))); tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
} }
const result = gcSnapshots(root, { keepCount: 3, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") }); const result = gcSnapshots(root, { keepCount: 3, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
assert.equal(result.kept.length, 3); assert.equal(result.kept.length, 3);
@@ -1157,7 +1085,7 @@ test("gcSnapshots keeps snapshots newer than keepDays regardless of count", () =
const dotOcp = testJoin(root, ".ocp"); const dotOcp = testJoin(root, ".ocp");
tMkdirSync(dotOcp, { recursive: true }); tMkdirSync(dotOcp, { recursive: true });
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) { for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
tMkdirSync(testJoin(dotOcp, portableSnapshotName(ts))); tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
} }
// keepCount=1 but keepDays=15 means anything from after 2026-04-26 is kept too // keepCount=1 but keepDays=15 means anything from after 2026-04-26 is kept too
const result = gcSnapshots(root, { keepCount: 1, keepDays: 15, now: new Date("2026-05-11T00:00:00Z") }); const result = gcSnapshots(root, { keepCount: 1, keepDays: 15, now: new Date("2026-05-11T00:00:00Z") });
@@ -1171,7 +1099,7 @@ test("gcSnapshots never deletes the most recent snapshot", () => {
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-recent-")); const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-recent-"));
const dotOcp = testJoin(root, ".ocp"); const dotOcp = testJoin(root, ".ocp");
tMkdirSync(dotOcp, { recursive: true }); tMkdirSync(dotOcp, { recursive: true });
tMkdirSync(testJoin(dotOcp, portableSnapshotName("2026-01-01T10:00:00Z"))); tMkdirSync(testJoin(dotOcp, "upgrade-snapshot-2026-01-01T10:00:00Z"));
// Even with keepCount=0 and keepDays=0, the most recent must survive // Even with keepCount=0 and keepDays=0, the most recent must survive
const result = gcSnapshots(root, { keepCount: 0, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") }); const result = gcSnapshots(root, { keepCount: 0, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
assert.equal(result.kept.length, 1); assert.equal(result.kept.length, 1);
@@ -1184,13 +1112,13 @@ test("gcSnapshots --dry-run reports plan without deleting", () => {
const dotOcp = testJoin(root, ".ocp"); const dotOcp = testJoin(root, ".ocp");
tMkdirSync(dotOcp, { recursive: true }); tMkdirSync(dotOcp, { recursive: true });
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-05-10T10:00:00Z"]) { for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-05-10T10:00:00Z"]) {
tMkdirSync(testJoin(dotOcp, portableSnapshotName(ts))); tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
} }
const result = gcSnapshots(root, { keepCount: 1, keepDays: 0, dryRun: true, now: new Date("2026-05-11T00:00:00Z") }); const result = gcSnapshots(root, { keepCount: 1, keepDays: 0, dryRun: true, now: new Date("2026-05-11T00:00:00Z") });
assert.equal(result.dryRun, true); assert.equal(result.dryRun, true);
assert.equal(result.removed.length, 2); assert.equal(result.removed.length, 2);
// Files still exist // Files still exist
assert.ok(testExistsSync(testJoin(dotOcp, portableSnapshotName("2026-04-01T10:00:00Z")))); assert.ok(testExistsSync(testJoin(dotOcp, "upgrade-snapshot-2026-04-01T10:00:00Z")));
rmSync(root, { recursive: true, force: true }); rmSync(root, { recursive: true, force: true });
}); });
@@ -2685,21 +2613,21 @@ import { tmpdir as hTmp } from "node:os";
console.log("\nTUI home preparation:"); console.log("\nTUI home preparation:");
test("prepareTuiHome scratch mode: symlinks creds, seeds onboarded config, trusts cwd, strips history", () => { test("prepareTuiHome scratch mode: symlinks creds, seeds onboarded config, trusts cwd, strips history", () => {
const realHome = hMkdtemp(testJoin(hTmp(), "real-")); const realHome = hMkdtemp(`${hTmp()}/real-`);
hMkdir(testJoin(realHome, ".claude"), { recursive: true }); hMkdir(`${realHome}/.claude`, { recursive: true });
hWrite(testJoin(realHome, ".claude", ".credentials.json"), '{"token":"x"}'); hWrite(`${realHome}/.claude/.credentials.json`, '{"token":"x"}');
hWrite(testJoin(realHome, ".claude.json"), JSON.stringify({ theme: "dark", projects: { "/old/secret/project": { hasTrustDialogAccepted: true } } })); hWrite(`${realHome}/.claude.json`, JSON.stringify({ theme: "dark", projects: { "/old/secret/project": { hasTrustDialogAccepted: true } } }));
const tuiHome = hMkdtemp(testJoin(hTmp(), "tui-")); const tuiHome = hMkdtemp(`${hTmp()}/tui-`);
const cwd = testJoin(tuiHome, "work"); const cwd = `${tuiHome}/work`;
prepareTuiHome(realHome, tuiHome, cwd); prepareTuiHome(realHome, tuiHome, cwd);
// credentials symlinked (token never copied) // credentials symlinked (token never copied)
assert.equal(hReadlink(testJoin(tuiHome, ".claude", ".credentials.json")), testJoin(realHome, ".claude", ".credentials.json")); assert.equal(hReadlink(`${tuiHome}/.claude/.credentials.json`), `${realHome}/.claude/.credentials.json`);
const seed = JSON.parse(hRead(testJoin(tuiHome, ".claude.json"), "utf8")); const seed = JSON.parse(hRead(`${tuiHome}/.claude.json`, "utf8"));
assert.equal(seed.hasCompletedOnboarding, true); assert.equal(seed.hasCompletedOnboarding, true);
assert.equal(seed.theme, "dark"); // onboarded config carried over assert.equal(seed.theme, "dark"); // onboarded config carried over
assert.equal(seed.projects[cwd].hasTrustDialogAccepted, true); // scratch cwd trusted assert.equal(seed.projects[cwd].hasTrustDialogAccepted, true); // scratch cwd trusted
assert.equal(seed.projects["/old/secret/project"], undefined); // user project history stripped assert.equal(seed.projects["/old/secret/project"], undefined); // user project history stripped
assert.ok(hExists(testJoin(tuiHome, ".claude", "projects"))); // own projects dir assert.ok(hExists(`${tuiHome}/.claude/projects`)); // own projects dir
}); });
test("prepareTuiHome real mode (tuiHome===realHome): no symlink, just trusts cwd in real config", () => { test("prepareTuiHome real mode (tuiHome===realHome): no symlink, just trusts cwd in real config", () => {