diff --git a/components/model-catalog.ts b/components/model-catalog.ts index 6a97320..20724d2 100644 --- a/components/model-catalog.ts +++ b/components/model-catalog.ts @@ -39,7 +39,7 @@ export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; catalogReaders.add(read); if (first) { stopCatalogSignals = onSettingsSaved((section) => { - if (section === "keys") { + if (section === "keys" || section === "claudeCode") { catalogGeneration++; catalogInflight = undefined; catalogCache = undefined; fetchCatalog(); } else if (section === "models") fetchCatalog(true); diff --git a/components/settings-signal.ts b/components/settings-signal.ts index 47f885d..da4dddf 100644 --- a/components/settings-signal.ts +++ b/components/settings-signal.ts @@ -20,7 +20,7 @@ export const SETTINGS_SAVED_EVENT = "lh:settings-saved"; // "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section // dispatches it after save AND delete — deletions used to be invisible to // the zero-keys pill and the setup dialog's auto-advance. -export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction" | "dictation"; +export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction" | "dictation" | "claudeCode"; /** Writers call this after the PUT resolves ok — never before (a failed * write notifies nothing; the record is unchanged). */ diff --git a/components/settings/claude-code-status.tsx b/components/settings/claude-code-status.tsx index 65c600a..3626c2d 100644 --- a/components/settings/claude-code-status.tsx +++ b/components/settings/claude-code-status.tsx @@ -1,7 +1,9 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "@/src/core"; -import { ConnectionRow } from "./connection-controls.tsx"; +import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus, type UserSettings } from "@/src/core"; +import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; +import { Button } from "@/components/ui/button"; +import { Toggle } from "./controls"; export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string { switch (status?.state) { @@ -13,10 +15,65 @@ export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string { } export function ClaudeCodeInstallationStatus() { + const [enabled, setEnabled] = useState(false); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); const [status, setStatus] = useState(null); - const [busy, setBusy] = useState(true); + const [busy, setBusy] = useState(false); const request = useRef(null); + const settingsRead = useRef(null); + const settingsWrite = useRef(null); + + const read = useCallback(async () => { + settingsRead.current?.abort(); + const controller = new AbortController(); + settingsRead.current = controller; + setLoaded(false); + try { + const response = await fetch("/api/settings", { cache: "no-store", signal: controller.signal }); + if (!response.ok) throw new Error("settings unavailable"); + const body = await response.json() as { settings: UserSettings | null }; + if (!controller.signal.aborted) { + setEnabled(body.settings?.claudeCode?.enabled === true); + setLoaded(true); + setError(null); + } + } catch { + if (!controller.signal.aborted) setError("Could not load Claude Code settings."); + } + }, []); + useEffect(() => { + void read(); + return () => { settingsRead.current?.abort(); settingsWrite.current?.abort(); }; + }, [read]); + useSettingsSaved((section) => { if (section === "claudeCode") void read(); }); + + const save = async (next: boolean) => { + const controller = new AbortController(); + settingsWrite.current = controller; + settingsRead.current?.abort(); + setSaving(true); + setError(null); + try { + const response = await fetch("/api/settings/claudeCode", { method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: next }), signal: controller.signal }); + if (!response.ok) throw new Error("settings write failed"); + const body = await response.json() as { settings: UserSettings }; + if (typeof body.settings?.claudeCode?.enabled !== "boolean") throw new Error("invalid settings response"); + if (!controller.signal.aborted) { + setEnabled(body.settings.claudeCode.enabled); + notifySettingsSaved("claudeCode"); + } + } catch { + if (!controller.signal.aborted) setError("Could not save Claude Code settings."); + } finally { + if (!controller.signal.aborted) setSaving(false); + } + }; + const refresh = useCallback(async () => { + if (!enabled) return; request.current?.abort(); const controller = new AbortController(); request.current = controller; @@ -34,16 +91,27 @@ export function ClaudeCodeInstallationStatus() { } finally { if (!controller.signal.aborted) setBusy(false); } - }, []); + }, [enabled]); useEffect(() => { - void refresh(); + if (enabled) void refresh(); + else { request.current?.abort(); setStatus(null); setBusy(false); } return () => request.current?.abort(); - }, [refresh]); + }, [enabled, refresh]); + return
- void refresh()} actionTestid="claude-code-check-again"> - {busy ? "Checking…" : claudeCodeStatusLabel(status)} - -

Install and authenticate in Claude Code outside LearnHarness. Authentication is not checked. Your Claude Code configuration determines billing.

-

Minimum version: {CLAUDE_CODE_MINIMUM_VERSION}. Meeting this minimum does not guarantee MCP compatibility, model access, or available quota. Claude Code setup ↗

+
+ Claude Code + void save(next)} /> +
+ {error &&

{error}

} + {enabled && <> +
+ {busy ? "Checking…" : claudeCodeStatusLabel(status)} + +
+

Install and authenticate Claude Code in your terminal, then select a Claude Code model here. LearnHarness starts it when needed—no background process required.

+

Checks installation/version only—not authentication, model access, or quota. Claude Code controls billing. Requires v{CLAUDE_CODE_MINIMUM_VERSION}+. Setup ↗

+

Verify AI claims independently—they may be inaccurate, incomplete, misleading, or outdated.

+ }
; } diff --git a/src/core/settings.ts b/src/core/settings.ts index f7966f1..2e8d3ae 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -29,6 +29,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts"; +import { CLAUDE_CODE_PROVIDER } from "./contracts/claude-code.ts"; import type { ModelRole } from "../../config.ts"; import type { ChatSettings } from "./contracts/chat.ts"; import type { SearchEngineId } from "./search.ts"; @@ -156,10 +157,11 @@ export interface ProviderKeyMissing { /** The refusal wording's invariant tail — the matcher below keys on it * (the label prefix varies by provider). */ const PROVIDER_KEY_MISSING_TAIL = "key is set — add one in Settings → Keys"; +const CLAUDE_CODE_UNAVAILABLE = "Claude Code is unavailable. Enable and set it up in Settings → Providers."; export function providerKeyMissing(provider: string, slots: ModelSlotName[]): ProviderKeyMissing { const label = AUTH_PROVIDER_KEYS.find((p) => p.id === provider)?.label ?? provider; return { - error: `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`, + error: provider === CLAUDE_CODE_PROVIDER ? CLAUDE_CODE_UNAVAILABLE : `No ${label} ${PROVIDER_KEY_MISSING_TAIL}`, code: PROVIDER_KEY_MISSING_CODE, provider, slots, @@ -170,7 +172,7 @@ export function providerKeyMissing(provider: string, slots: ModelSlotName[]): Pr * scary-error) — the envelope's home module owns the string so the * writer and the matcher can never drift. */ export function isProviderKeyRefusal(error: string | undefined): boolean { - return !!error && error.endsWith(PROVIDER_KEY_MISSING_TAIL); + return !!error && (error.endsWith(PROVIDER_KEY_MISSING_TAIL) || error === CLAUDE_CODE_UNAVAILABLE); } /** The typed throw for in-process seams (extraction pipeline) — the * ProviderKeyMissing sibling of ModelsNotConfiguredError: callers catch @@ -368,6 +370,8 @@ export interface UserSettings { version: number; general?: GeneralSettings; dictation?: DictationSettings; + /** Explicit app-wide opt-in; absent/invalid is off, including older records. */ + claudeCode?: { enabled?: boolean }; agent?: AgentSettings; /** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */ models?: ModelsSettings; @@ -631,6 +635,12 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null { ? { language: (raw as DictationSettings).language } : {}; } + if (r.claudeCode !== undefined) { + const raw = r.claudeCode; + out.claudeCode = raw !== null && typeof raw === "object" && !Array.isArray(raw) + && typeof (raw as Record).enabled === "boolean" + ? { enabled: (raw as { enabled: boolean }).enabled } : {}; + } if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) { out.agent = sanitizeAgent(r.agent as Record); } diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index b0eef2a..21ccc7e 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -7,7 +7,7 @@ import { type TeachingAgent, } from "../agent/teacher-agent.ts"; import { piEngineForSpec, type PiEngine } from "../agent/models.ts"; -import { connectionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts"; +import { connectionBackgroundAllowed as subscriptionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts"; import { nativeSubscriptionError } from "../agent/subscription-stream.ts"; import { chatGPTStreamError } from "../agent/chatgpt-stream.ts"; import { SubscriptionRequestCycle, type SubscriptionDispatchLifecycle } from "../agent/subscription-request-cycle.ts"; @@ -33,6 +33,7 @@ import { providerEventNote } from "../agent/provider-events.ts"; import { effectiveSettings, stores } from "./stores.ts"; import { searchAvailable, searchProvider } from "./search.ts"; import { providerKeyPresent } from "./keys.ts"; +import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts"; import { extractionEnabled, graphToolsEnabled, @@ -80,6 +81,12 @@ import type {CallUsage} from "../core/contracts.ts"; * for write-ahead persistence, turn liveness, and the turn services. */ +/** Fresh check only at new background-run admission; never cancel a run + * already in progress when the opt-in is switched off. */ +function connectionBackgroundAllowed(provider: string): boolean { + return subscriptionBackgroundAllowed(provider) && (provider !== CLAUDE_CODE_PROVIDER || providerKeyPresent(provider)); +} + function settlementErrorText(error: unknown): string { return error instanceof TurnPersistenceError ? error.message : "An error occurred."; } diff --git a/src/server/claude-code-config.ts b/src/server/claude-code-config.ts new file mode 100644 index 0000000..9deffc2 --- /dev/null +++ b/src/server/claude-code-config.ts @@ -0,0 +1,13 @@ +import { getClaudeBinary } from "../agent/claude-code/binary.ts"; +import { settingsStore } from "../store/settings.ts"; + +/** Fresh admission state, not a process-wide cache. Existing runs retain + * their engines; only new chat/job/extraction/compaction admissions consult it. */ +export function claudeCodeEnabled(): boolean { + return settingsStore.readSettings()?.claudeCode?.enabled === true; +} + +/** Installation/opt-in availability only, never authenticated readiness. */ +export function claudeCodeAvailable(): boolean { + return claudeCodeEnabled() && getClaudeBinary() !== null; +} diff --git a/src/server/claude-code.ts b/src/server/claude-code.ts index bd40ebe..c6b0c3b 100644 --- a/src/server/claude-code.ts +++ b/src/server/claude-code.ts @@ -1,6 +1,7 @@ import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts"; import { initAuthEnv } from "./keys.ts"; import { subscriptionRequestOrigin } from "./web-subscription-http.ts"; +import { claudeCodeEnabled } from "./claude-code-config.ts"; /** The Settings status seam: initialize provenance before any child env is copied. */ export async function claudeCodeInstallationStatus() { @@ -13,6 +14,7 @@ export async function claudeCodeInstallationStatus() { export async function claudeCodeInstallationResponse( request: Request, inspect = claudeCodeInstallationStatus, + enabled = claudeCodeEnabled, ): Promise { const origin = subscriptionRequestOrigin(request); const supplied = request.headers.get("origin"); @@ -23,5 +25,6 @@ export async function claudeCodeInstallationResponse( || (supplied !== null && supplied !== origin)) { return Response.json({ error: "Claude Code checks require a same-origin LearnHarness request." }, { status: 403, headers }); } + if (!enabled()) return Response.json({ error: "Claude Code is disabled." }, { status: 403, headers }); return Response.json(await inspect(), { headers }); } diff --git a/src/server/keys.ts b/src/server/keys.ts index 03b8d4c..502bce2 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -53,7 +53,7 @@ import { } from "../core/auth-keys.ts"; import { authStore } from "../store/auth.ts"; import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts"; -import { getClaudeBinary } from "../agent/claude-code/binary.ts"; +import { claudeCodeAvailable } from "./claude-code-config.ts"; import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; // Shared HMR-stable provenance lets external CLIs exclude file-only injections @@ -189,8 +189,9 @@ export function keyValue(id: string): string | undefined { * (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take * no key at all: nothing to gate, present by definition. */ export function providerKeyPresent(provider: string): boolean { - // Presence only, not authentication readiness. Launch performs the version gate. - if (provider === CLAUDE_CODE_PROVIDER) return getClaudeBinary() !== null; + // Explicit opt-in + installation only, never authentication readiness. + // Consulted at admission, not inside already-running agent loops. + if (provider === CLAUDE_CODE_PROVIDER) return claudeCodeAvailable(); if (isSubscriptionProvider(provider)) return subscriptionConnected(provider); const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider); return info ? keyValue(info.envVar) !== undefined : true; diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 6605788..f2fc362 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -31,6 +31,7 @@ export async function refreshConnectionCatalog(provider?: string): Promise ]); } function connectionModel(provider: string, id: string): Model | undefined { + if (provider === CLAUDE_CODE_PROVIDER && !claudeCodeAvailable()) return undefined; return provider === "chatgpt" ? chatGPTModel(id) : nativeSubscriptionId(provider) ? nativeSubscriptionModel(provider, id) : piModels().getModel(provider, id); } function connectionProviderIds(): string[] { return [...piModels().getProviders().map((p) => p.id), "chatgpt", ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]; } @@ -48,7 +49,7 @@ import type { } from "../core/contracts/models.ts"; import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts"; import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.ts"; -import { getClaudeBinary } from "../agent/claude-code/binary.ts"; +import { claudeCodeAvailable } from "./claude-code-config.ts"; import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; import { BACKGROUND_MODEL_ROLES, MODEL_SLOTS, type BackgroundModelRole, type ModelSlotName, type ModelsSettings } from "../core/settings.ts"; @@ -143,7 +144,7 @@ export function catalogProviders(): CatalogProvider[] { keyPresent: keyValue(p.envVar) !== undefined, })).concat([ { id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }, - { id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: getClaudeBinary() !== null }, + { id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() }, ...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })), ]).sort((a, b) => a.id.localeCompare(b.id)); } diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index 9b1a173..81ae0f2 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -91,6 +91,15 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult { + const raw = asRecord(body); + if (!raw) return { ok: false, error: "Invalid JSON body" }; + if (raw.enabled === undefined) return { ok: true, settings: {} }; + if (typeof raw.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" }; + return { ok: true, settings: { enabled: raw.enabled } }; +} + /** agent.models: only the six known background roles; each override's * fields mirror ChatSettings (model pair + independent thinkingLevel). */ function parseRoleOverride(value: unknown, role: string): { ok: true; override: RoleModelOverride } | { ok: false; error: string } { @@ -397,6 +406,7 @@ export const SETTINGS_SECTIONS = { compaction: parseCompactionSettingsBody, models: parseModelsSettingsBody, dictation: parseDictationSettingsBody, + claudeCode: parseClaudeCodeSettingsBody, } as const; export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS; diff --git a/tests/claude-code-harness.test.ts b/tests/claude-code-harness.test.ts index 78bedb2..aa6d735 100644 --- a/tests/claude-code-harness.test.ts +++ b/tests/claude-code-harness.test.ts @@ -15,19 +15,26 @@ import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; -import test, { type TestContext } from "node:test"; +import test, { after, type TestContext } from "node:test"; -import { PATHS } from "../config.ts"; -import { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } from "../src/agent/claude-code/live-processes.ts"; -import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts"; -import { createClaudeCodeStreamFunction, type RunClaude } from "../src/agent/claude-code/stream.ts"; +import type { RunClaude } from "../src/agent/claude-code/stream.ts"; import type { PiEngine } from "../src/agent/models.ts"; -import { createTeacherAgent } from "../src/agent/teacher-agent.ts"; -import { cliContextWindowOf, danglingQuestionOf } from "../src/core/native-messages.ts"; -import { handleChatRequest } from "../src/server/chat-handler.ts"; -import { isTurnRunning } from "../src/server/turn-liveness.ts"; -import { runningTurn } from "../src/server/turn-runs.ts"; -import { chatStore } from "../src/store/chats.ts"; + +// Opt-in writes must not race another test process's settings record. +const testData = mkdtempSync(join(tmpdir(), "lh-cli-harness-data-")); +process.env.LH_DATA_DIR = testData; +after(() => rmSync(testData, { recursive: true, force: true })); +const { PATHS, SETTINGS_FILE } = await import("../config.ts"); +const { readSettings, writeSettings } = await import("../src/store/settings.ts"); +const { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } = await import("../src/agent/claude-code/live-processes.ts"); +const { CLAUDE_CODE_MODELS } = await import("../src/agent/claude-code/provider.ts"); +const { createClaudeCodeStreamFunction } = await import("../src/agent/claude-code/stream.ts"); +const { createTeacherAgent } = await import("../src/agent/teacher-agent.ts"); +const { cliContextWindowOf, danglingQuestionOf } = await import("../src/core/native-messages.ts"); +const { handleChatRequest } = await import("../src/server/chat-handler.ts"); +const { isTurnRunning } = await import("../src/server/turn-liveness.ts"); +const { runningTurn } = await import("../src/server/turn-runs.ts"); +const { chatStore } = await import("../src/store/chats.ts"); const FAKE_CLAUDE = fileURLToPath(new URL("./fixtures/claude-code/fake-claude.mjs", import.meta.url)); const TEXT_FIXTURE = fileURLToPath(new URL("./fixtures/claude-code/text.jsonl", import.meta.url)); @@ -61,6 +68,9 @@ function toAskScenario(): string[] { } function createFakeRun(t: TestContext, scenarios: string[][]): FakeRun { + const settings = readSettings(); + writeSettings({ ...(settings ?? { version: 1 }), claudeCode: { enabled: true } }); + t.after(() => { if (settings) writeSettings(settings); else rmSync(SETTINGS_FILE, { force: true }); }); const dir = mkdtempSync(join(tmpdir(), "claude-code-harness-")); t.after(() => rmSync(dir, { recursive: true, force: true })); // Satisfy the real presence gate; this marker is NEVER executed. All work @@ -155,6 +165,47 @@ async function waitForExit(child: ChildProcessWithoutNullStreams): Promise await once(child, "exit", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }); } +test("disabled opt-in refuses a saved CLI chat before write-ahead, liveness, or agent creation", async (t) => { + const fakeRun = createFakeRun(t, [toAskScenario()]); + const chatId = createClaudeCodeChat(t); + writeSettings({ ...(readSettings() ?? { version: 1 }), claudeCode: { enabled: false } }); + const before = chatStore.getChat(chatId)!.messages; + const response = await handleChatRequest(new Request("http://test/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chatId, input: { type: "text", text: "must not start" } }) }), () => assert.fail("disabled work must not build an agent")); + assert.equal(response.status, 503); + assert.match((await response.json()).error, /Enable and set it up in Settings/); + assert.equal(isTurnRunning(chatId), false); assert.equal(fakeRun.children.length, 0); + assert.deepEqual(chatStore.getChat(chatId)!.messages, before); + assert.deepEqual(chatStore.getChat(chatId)!.settings?.model, { provider: "claude-code", id: "sonnet" }); +}); + +test("switching off blocks future requests but does not interrupt an already admitted CLI turn", async (t) => { + const scenario = [JSON.stringify({ fake: "checkpoint" }), JSON.stringify({ fake: "wait-for-message" }), ...readFileSync(TEXT_FIXTURE, "utf8").trim().split("\n")]; + const fakeRun = createFakeRun(t, [scenario]); + const run = fakeRun.runClaude; + let ready!: (child: ChildProcessWithoutNullStreams) => void; + let failed!: (error: unknown) => void; + const checkpoint = new Promise((resolve, reject) => { ready = resolve; failed = reject; }); + fakeRun.runClaude = async (args, options) => { + const child = await run(args, options); assert(child); + void once(child, "message", { signal: AbortSignal.timeout(TURN_TIMEOUT_MS) }).then(() => ready(child), failed); + return child; + }; + const chatId = createClaudeCodeChat(t); + const response = await handleChatRequest(new Request("http://test/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chatId, input: { type: "text", text: "complete this admitted fixture" } }) }), createAgentFactory(fakeRun)); + assert.equal(response.status, 200); + const child = await checkpoint; + const handle = runningTurn(chatId); assert(handle); + const finished = new Response(handle.subscribe()).text(); + writeSettings({ ...(readSettings() ?? { version: 1 }), claudeCode: { enabled: false } }); + assert.equal(child.signalCode, null, "opt-out must not kill an already running process"); + child.send("continue"); await finished; + assert(getLastAssistantText(chatId).length > 0, "the admitted turn completes normally"); + assert.equal(readSettings()?.claudeCode?.enabled, false); + await waitForExit(child); +}); + test("a question answered before the idle timeout continues the waiting CLI process with the answer", async (t) => { const fakeRun = createFakeRun(t, [toAskScenario()]); const run = fakeRun.runClaude; diff --git a/tests/claude-code-opt-in.test.ts b/tests/claude-code-opt-in.test.ts new file mode 100644 index 0000000..b0e0026 --- /dev/null +++ b/tests/claude-code-opt-in.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import test, { after, type TestContext } from "node:test"; + +const testData = mkdtempSync(join(tmpdir(), "lh-cli-opt-in-data-")); +process.env.LH_DATA_DIR = testData; +after(() => rmSync(testData, { recursive: true, force: true })); +const { SETTINGS_FILE } = await import("../config.ts"); +const { PUT } = await import("../app/api/settings/[section]/route.ts"); +const { sanitizeUserSettings, providerKeyMissing } = await import("../src/core/settings.ts"); +const { parseClaudeCodeSettingsBody } = await import("../src/server/settings-body.ts"); +const { claudeCodeAvailable, claudeCodeEnabled } = await import("../src/server/claude-code-config.ts"); +const { claudeCodeInstallationResponse } = await import("../src/server/claude-code.ts"); +const { modelCatalog, validateModelsSettings } = await import("../src/server/model-catalog.ts"); +const { providerKeyPresent } = await import("../src/server/keys.ts"); +const { readSettings, writeSettings } = await import("../src/store/settings.ts"); +const { chatStore } = await import("../src/store/chats.ts"); +const { jobStore } = await import("../src/store/jobs.ts"); +const { createJobRunner } = await import("../src/agent/subagents/runner.ts"); +const { CLAUDE_CODE_MODELS } = await import("../src/agent/claude-code/provider.ts"); + +function setup(t: TestContext) { + const previous = readSettings(); + const path = process.env.PATH; + const dir = mkdtempSync(join(tmpdir(), "claude-opt-in-")); + // Never executed: only installation discovery observes this marker. + writeFileSync(join(dir, process.platform === "win32" ? "claude.exe" : "claude"), "", { mode: 0o755 }); + process.env.PATH = `${dir}${delimiter}${path ?? ""}`; + t.after(() => { + if (previous) writeSettings(previous); else rmSync(SETTINGS_FILE, { force: true }); + if (path === undefined) delete process.env.PATH; else process.env.PATH = path; + rmSync(dir, { recursive: true, force: true }); + }); + writeSettings({ version: 1 }); +} +async function save(body: unknown) { + return PUT(new Request("http://test/api/settings/claudeCode", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), + { params: Promise.resolve({ section: "claudeCode" }) }); +} + +test("opt-in parses strictly and malformed stored values never enable the CLI", () => { + assert.deepEqual(parseClaudeCodeSettingsBody({}), { ok: true, settings: {} }); + assert.deepEqual(parseClaudeCodeSettingsBody({ enabled: true, ignored: true }), { ok: true, settings: { enabled: true } }); + assert.deepEqual(parseClaudeCodeSettingsBody({ enabled: false }), { ok: true, settings: { enabled: false } }); + for (const raw of [true, null, [], "on", { enabled: "true" }, { enabled: 1 }]) assert.equal(parseClaudeCodeSettingsBody(raw).ok, false); + for (const raw of [null, [], true, "on", { enabled: "true" }, { enabled: 1 }]) { + assert.notEqual(sanitizeUserSettings({ version: 1, claudeCode: raw })?.claudeCode?.enabled, true); + } + assert.equal(sanitizeUserSettings({ version: 1 })?.claudeCode, undefined); +}); + +test("default off even with an installed binary; section writes toggle availability but preserve saved model pins and unrelated settings", async (t) => { + setup(t); + const models = { strong: { model: { provider: "claude-code", id: "sonnet" } }, cheap: { model: { provider: "claude-code", id: "haiku" } } }; + writeSettings({ version: 1, models, graph: { enabled: false }, futureSection: { keep: 1 } }); + assert.equal(claudeCodeEnabled(), false); assert.equal(claudeCodeAvailable(), false); + assert.equal(providerKeyPresent("claude-code"), false); + assert(!modelCatalog().models.some((m) => m.provider === "claude-code")); + assert.equal((await save({ enabled: true })).status, 200); + assert.equal(claudeCodeEnabled(), true); assert.equal(claudeCodeAvailable(), true); + assert.equal(modelCatalog().models.filter((m) => m.provider === "claude-code").length, 3); + assert.equal((await save({ enabled: false })).status, 200); + assert.equal(claudeCodeAvailable(), false); + assert(!modelCatalog().models.some((m) => m.provider === "claude-code")); + assert.deepEqual(readSettings()?.models, models); + assert.deepEqual(readSettings()?.graph, { enabled: false }); + assert.deepEqual(readSettings()?.futureSection, { keep: 1 }); + assert.equal(validateModelsSettings(models, models).ok, true, "saved unavailable pins remain repairable, not silently removed"); + assert.equal((await save({ enabled: "true" })).status, 400); assert.equal(claudeCodeEnabled(), false); + assert.equal((await save({})).status, 200); assert.equal(claudeCodeEnabled(), false); +}); + +test("off cannot launch a version probe even through an otherwise authorized HTTP request", async (t) => { + setup(t); + const request = new Request("http://test/api/claude-code", { headers: { "x-learnharness-claude-code": "1" } }); + const response = await claudeCodeInstallationResponse(request, async () => assert.fail("disabled CLI must not execute")); + assert.equal(response.status, 403); +}); + +test("a saved CLI background job is refused before model execution while opt-in is off", async (t) => { + setup(t); + const chat = chatStore.createChat("CLI opt-in job fixture"); + t.after(() => { jobStore.deleteJobsForChat(chat.id); chatStore.deleteChat(chat.id); }); + const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "cli-off-job" }); + let calls = 0; + const runner = createJobRunner({ chatStore, jobStore, deliver: async () => {}, + engineFor: () => ({ model: CLAUDE_CODE_MODELS[0]!, streamFn: () => { calls++; assert.fail("must not run a disabled CLI model"); } }), + providerKeyMissing: (provider, tier) => providerKeyPresent(provider) ? null : providerKeyMissing(provider, [tier]), + }); + await runner.runJob(job.id, { question: "Synthetic fixture only" }); + assert.equal(calls, 0); + const refused = jobStore.getJob(job.id)!; + assert.equal(refused.status, "error"); + assert.match(JSON.stringify(refused.result), /Enable and set it up in Settings/); + assert(!JSON.stringify(refused.result).includes("add one in Settings → Keys")); +}); diff --git a/tests/claude-code-status-http.test.ts b/tests/claude-code-status-http.test.ts index 1c8f746..b9bed2d 100644 --- a/tests/claude-code-status-http.test.ts +++ b/tests/claude-code-status-http.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { claudeCodeInstallationResponse } from "../src/server/claude-code.ts"; +import { claudeCodeInstallationResponse as respond } from "../src/server/claude-code.ts"; +const claudeCodeInstallationResponse: typeof respond = (request, inspect) => respond(request, inspect, () => true); function request(headers: Record = {}) { return new Request("http://localhost:3000/api/claude-code", { headers: { "x-learnharness-claude-code": "1", ...headers } }); @@ -20,6 +21,12 @@ for (const headers of [ assert.equal(response.headers.get("access-control-allow-origin"), null); }); +test("disabled opt-in never starts even a version probe", async () => { + let probes = 0; + const response = await respond(request(), async () => { probes++; return status; }, () => false); + assert.equal(response.status, 403); assert.equal(probes, 0); +}); + test("ordinary image/subresource GET without intent header cannot start a probe", async () => { const response = await claudeCodeInstallationResponse(new Request("http://localhost:3000/api/claude-code"), async () => assert.fail("must not probe")); assert.equal(response.status, 403); diff --git a/tests/claude-code-status-ui.test.tsx b/tests/claude-code-status-ui.test.tsx index 1afbf0d..ee92d29 100644 --- a/tests/claude-code-status-ui.test.tsx +++ b/tests/claude-code-status-ui.test.tsx @@ -7,45 +7,81 @@ import { renderToStaticMarkup } from "react-dom/server"; import { parseHTML } from "linkedom"; import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx"; import { ConnectionRow } from "../components/settings/connection-controls.tsx"; +import { useModelCatalog } from "../components/model-catalog.ts"; import type { ClaudeCodeStatus } from "../src/core/contracts/claude-code.ts"; const dom = parseHTML(""); -Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); +Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, + CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true }); const originalFetch = globalThis.fetch; let root: Root | undefined, host: HTMLDivElement | undefined; const status = (state: ClaudeCodeStatus["state"], version: string | null = null): ClaudeCodeStatus => ({ state, version, minimumVersion: "2.1.259" }); const button = () => document.querySelector('[data-testid="claude-code-check-again"]')!; +const toggle = () => document.querySelector('[role="switch"]')!; const label = () => document.querySelector('[role="status"]')?.textContent; -async function mount() { +function fixture(probe: (init?: RequestInit) => Promise, enabled = true, failSave = false) { + const calls: string[] = []; + let on = enabled; + globalThis.fetch = (async (url, init) => { + calls.push(`${init?.method ?? "GET"} ${url}`); + if (url === "/api/settings") return Response.json({ settings: { version: 1, claudeCode: { enabled: on } } }); + if (url === "/api/settings/claudeCode") { + assert.equal(init?.method, "PUT"); assert.equal(new Headers(init.headers).get("Content-Type"), "application/json"); + if (failSave) return new Response(null, { status: 500 }); + on = JSON.parse(String(init.body)).enabled; + return Response.json({ settings: { version: 1, claudeCode: { enabled: on } } }); + } + if (url === "/api/models") return Response.json({ models: on ? [{ provider: "claude-code", id: "sonnet" }] : [] }); + assert.equal(url, "/api/claude-code"); + assert.equal(init?.cache, "no-store"); + assert.equal(new Headers(init?.headers).get("x-learnharness-claude-code"), "1"); + return probe(init); + }) as typeof fetch; + return calls; +} +async function mount(extra?: React.ReactNode) { host = document.createElement("div"); document.body.append(host); root = createRoot(host); - await act(async () => root!.render()); + await act(async () => root!.render(<>{extra})); } afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; globalThis.fetch = originalFetch; }); -test("row is informational, disclaims auth/billing readiness, and links only to official setup", () => { +test("initial render is collapsed and off, with no setup prose or version probe action", () => { const html = renderToStaticMarkup(); - assert.match(html, /Authentication is not checked/); - assert.match(html, /configuration determines billing/); - assert.match(html, /does not guarantee MCP compatibility/); - assert.match(html, /https:\/\/code.claude.com\/docs\/en\/setup/); + assert.match(html, /Claude Code/); assert.match(html, /aria-checked="false"/); assert.equal((html.match(/")); - assert.match(html, /data-variant="outline"/); - assert.match(html, /data-size="xs"/); + assert(!html.includes("Check again")); assert(!html.includes("Authenticate")); assert(!html.includes("Setup ↗")); }); -test("recheck matches the existing Connect button styling without restyling Manage actions", () => { - const row = (action: string) => renderToStaticMarkup( {}} />); - const connect = parseHTML(row("Connect")).document.querySelector("button")!; - const manage = parseHTML(row("Manage")).document.querySelector("button")!; - const check = parseHTML(renderToStaticMarkup()).document.querySelector("button")!; - assert.equal(check.getAttribute("class"), connect.getAttribute("class")); - assert.equal(check.getAttribute("data-variant"), "outline"); - assert.equal(manage.getAttribute("data-variant"), "ghost"); +test("off by default; enabling persists before probing and reveals the version/action row below the toggle", async () => { + const calls = fixture(async () => Response.json(status("not-found")), false); + await mount(); + assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button()); + assert.deepEqual(calls, ["GET /api/settings"]); + await act(async () => toggle().click()); + assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.equal(label(), "Not found"); + assert(calls.indexOf("PUT /api/settings/claudeCode") < calls.indexOf("GET /api/claude-code")); + assert.notEqual(toggle().parentElement, button().parentElement); + assert.equal(document.querySelector('[role="status"]')?.parentElement, button().parentElement); + assert.match(document.body.textContent ?? "", /no background process required/); + assert.match(document.body.textContent ?? "", /not authentication, model access, or quota/); + assert.match(document.body.textContent ?? "", /Claude Code controls billing/); + assert.match(document.body.textContent ?? "", /Verify AI claims independently—they may be inaccurate, incomplete, misleading, or outdated\./); + assert(document.querySelector("strong")?.textContent?.includes("Install and authenticate")); + assert.equal(document.querySelector("a")?.getAttribute("href"), "https://code.claude.com/docs/en/setup"); + await act(async () => toggle().click()); + assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button()); assert.equal(label(), undefined); + assert.equal(calls.filter((call) => call === "GET /api/claude-code").length, 1); +}); + +test("recheck matches Connect styling; Manage remains ghost", async () => { + fixture(async () => Response.json(status("not-found"))); await mount(); + const row = (action: string) => parseHTML(renderToStaticMarkup( {}} />)).document.querySelector("button")!; + assert.equal(button().getAttribute("class"), row("Connect").getAttribute("class")); + assert.equal(button().getAttribute("data-variant"), "outline"); + assert.equal(row("Manage").getAttribute("data-variant"), "ghost"); }); for (const [value, text] of [ @@ -55,41 +91,51 @@ for (const [value, text] of [ [status("version-unchecked"), "Version could not be checked"], ] as const) test(`installation display: ${text}`, () => assert.equal(claudeCodeStatusLabel(value), text)); -test("mount checks once, Check again refreshes, and neither request asks for authentication", async () => { - const calls: string[] = []; - globalThis.fetch = (async (url, init) => { - calls.push(String(url)); - assert.equal(init?.cache, "no-store"); - assert.equal(new Headers(init?.headers).get("x-learnharness-claude-code"), "1"); - return Response.json(calls.length === 1 ? status("not-found") : status("meets-minimum", "2.1.310")); - }) as typeof fetch; - await mount(); - assert.equal(label(), "Not found"); - await act(async () => button().click()); - assert.equal(label(), "Version 2.1.310 · meets minimum"); - assert.deepEqual(calls, ["/api/claude-code", "/api/claude-code"]); +test("enabled mount checks once; outlined Check again refreshes without probing authentication", async () => { + let probes = 0; + const calls = fixture(async () => Response.json(++probes === 1 ? status("not-found") : status("meets-minimum", "2.1.310"))); + await mount(); assert.equal(label(), "Not found"); + await act(async () => button().click()); assert.equal(label(), "Version 2.1.310 · meets minimum"); + assert.equal(calls.filter((call) => call === "GET /api/claude-code").length, 2); }); -test("slow checks disable recheck and unmount aborts outstanding work", async () => { +test("failed opt-in save stays collapsed/off and never probes", async () => { + const calls = fixture(async () => assert.fail("must not probe"), false, true); + await mount(); await act(async () => toggle().click()); + assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button()); + assert.match(document.querySelector('[role="alert"]')?.textContent ?? "", /Could not save/); + assert(!calls.includes("GET /api/claude-code")); +}); + +test("slow checks disable recheck; switching off aborts pending version work without starting another probe", async () => { let signal: AbortSignal | null | undefined; let resolve!: (response: Response) => void; - globalThis.fetch = ((_url, init) => { signal = init?.signal; return new Promise((done) => { resolve = done; }); }) as typeof fetch; - await mount(); - assert.equal(label(), "Checking…"); assert(button().disabled); - await act(async () => { root!.unmount(); root = undefined; }); - assert(signal?.aborted); - await act(async () => resolve(Response.json(status("not-found")))); - assert.equal(document.querySelector('[role="status"]'), null); + fixture((init) => { signal = init?.signal; return new Promise((done) => { resolve = done; }); }); + await mount(); assert.equal(label(), "Checking…"); assert(button().disabled); assert(!toggle().disabled); + await act(async () => toggle().click()); assert(signal?.aborted); assert(!button()); + await act(async () => resolve(Response.json(status("meets-minimum", "2.1.310")))); assert.equal(label(), undefined); }); for (const failure of ["network", "http", "payload"] as const) test(`${failure} failure never retains a stale meets-minimum status`, async () => { let calls = 0; - globalThis.fetch = (async () => { + fixture(async () => { if (++calls === 1) return Response.json(status("meets-minimum", "2.1.310")); if (failure === "network") throw new Error("offline"); return failure === "http" ? new Response(null, { status: 503 }) : Response.json({ state: "authenticated" }); - }) as typeof fetch; + }); await mount(); assert.equal(label(), "Version 2.1.310 · meets minimum"); - await act(async () => button().click()); - assert.equal(label(), "Version could not be checked"); assert(!button().disabled); + await act(async () => button().click()); assert.equal(label(), "Version could not be checked"); assert(!button().disabled); +}); + +function CatalogReader() { + const { catalog } = useModelCatalog(); + return {catalog?.models.map((m) => `${m.provider}:${m.id}`).join(",") ?? "loading"}; +} +test("persisted toggle updates invalidate shared model availability in both directions", async () => { + fixture(async () => Response.json(status("not-found")), false); + await mount(); + const models = () => document.querySelector('[data-testid="cli-catalog-models"]')?.textContent; + assert.equal(models(), ""); + await act(async () => toggle().click()); assert.equal(models(), "claude-code:sonnet"); + await act(async () => toggle().click()); assert.equal(models(), ""); }); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index 9af9c7d..da2242c 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -1,9 +1,12 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { describe, it } from "node:test"; +import { after, describe, it } from "node:test"; import assert from "node:assert/strict"; -import { +const testData = fs.mkdtempSync(path.join(os.tmpdir(), "lh-cli-catalog-data-")); +process.env.LH_DATA_DIR = testData; +after(() => fs.rmSync(testData, { recursive: true, force: true })); +const { catalogContextLimits, catalogDefaultsFor, catalogProviders, @@ -16,17 +19,18 @@ import { toModelDto, validateChatSettings, validateModelsSettings, -} from "../src/server/model-catalog.ts"; +} = await import("../src/server/model-catalog.ts"); import type { EffectiveSlot } from "../src/core/settings-resolution.ts"; import type { SettingsValidation } from "../src/server/model-catalog.ts"; import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { DEFAULT_THINKING_ORDER } from "../src/core/settings.ts"; -import { piEngineForSpec, piModels } from "../src/agent/models.ts"; +const { piEngineForSpec, piModels } = await import("../src/agent/models.ts"); import { BACKGROUND_MODEL_ROLES } from "../src/core/settings.ts"; import { resolveSettings } from "../src/core/settings-resolution.ts"; -import { readSettings } from "../src/store/settings.ts"; -import { effectiveSettings } from "../src/server/stores.ts"; -import { providerKeyPresent } from "../src/server/keys.ts"; +const { readSettings, writeSettings } = await import("../src/store/settings.ts"); +const { SETTINGS_FILE } = await import("../config.ts"); +const { effectiveSettings } = await import("../src/server/stores.ts"); +const { providerKeyPresent } = await import("../src/server/keys.ts"); // The model-control wave (2026-09-12): pi-ai owns the registry — these pins // hold our projection + validation honest against the shipped catalog, and @@ -337,9 +341,11 @@ describe("pi thinking truth (pinned against pi-ai upgrades)", () => { }); /** Runs fn with PATH and HOME pointed at a temp dir, so the CLI lookup only * finds a claude binary when installed is true. */ -function withClaudeInstalled(installed: boolean, fn: () => void): void { +function withClaudeInstalled(installed: boolean, fn: () => void, enabled = true): void { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-catalog-")); const saved = { PATH: process.env.PATH, HOME: process.env.HOME, npm_config_prefix: process.env.npm_config_prefix }; + const settings = readSettings(); + writeSettings({ ...(settings ?? { version: 1 }), claudeCode: { enabled } }); if (installed) fs.writeFileSync(path.join(dir, "claude"), "#!/bin/sh\n", { mode: 0o755 }); process.env.PATH = dir; @@ -353,6 +359,8 @@ function withClaudeInstalled(installed: boolean, fn: () => void): void { if (value === undefined) delete process.env[name]; else process.env[name] = value; } + if (settings) writeSettings(settings); + else fs.rmSync(SETTINGS_FILE, { force: true }); fs.rmSync(dir, { recursive: true, force: true }); } } @@ -397,8 +405,14 @@ describe("the claude-code provider (native CLI authentication, no LearnHarness k assert.equal(engine.model.id, "sonnet"); }); - it('providerKeyPresent("claude-code") follows executable presence, not a generic keyless default', () => { + it('providerKeyPresent("claude-code") requires both explicit opt-in and executable presence', () => { withClaudeInstalled(true, () => assert.equal(providerKeyPresent("claude-code"), true)); withClaudeInstalled(false, () => assert.equal(providerKeyPresent("claude-code"), false)); + withClaudeInstalled(true, () => { + assert.equal(providerKeyPresent("claude-code"), false); + assert.equal(catalogProviders().find((p) => p.id === "claude-code")?.keyPresent, false); + assert(!modelCatalog().models.some((m) => m.provider === "claude-code")); + assert.equal(validateChatSettings({ model: { provider: "claude-code", id: "sonnet" } }).ok, false); + }, false); }); }); diff --git a/tests/settings-body.test.ts b/tests/settings-body.test.ts index 16c0ac9..7c7df16 100644 --- a/tests/settings-body.test.ts +++ b/tests/settings-body.test.ts @@ -239,7 +239,7 @@ describe("SETTINGS_SECTIONS (the route's section map)", () => { // threshold is engine behavior, not an agent-section key. assert.deepEqual( Object.keys(SETTINGS_SECTIONS), - ["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation"], + ["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation", "claudeCode"], ); }); });