feat: require explicit opt-in for Claude Code use

This commit is contained in:
amosblomqvist
2026-10-28 21:37:03 +02:00
parent ab76e4ff50
commit c9c8cead57
16 changed files with 417 additions and 88 deletions
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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). */
+79 -11
View File
@@ -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<string | null>(null);
const [status, setStatus] = useState<ClaudeCodeStatus | null>(null);
const [busy, setBusy] = useState(true);
const [busy, setBusy] = useState(false);
const request = useRef<AbortController | null>(null);
const settingsRead = useRef<AbortController | null>(null);
const settingsWrite = useRef<AbortController | null>(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 <div className="space-y-2" data-testid="claude-code-installation">
<ConnectionRow label="Claude Code" labelClassName="font-prose text-(length:--lh-prose-size) text-foreground" action="Check again" actionVariant="outline" disabled={busy} onAction={() => void refresh()} actionTestid="claude-code-check-again">
<span role="status" className="text-xs text-muted-foreground">{busy ? "Checking…" : claudeCodeStatusLabel(status)}</span>
</ConnectionRow>
<p className="text-xs leading-relaxed text-muted-foreground">Install and authenticate in Claude Code outside LearnHarness. Authentication is not checked. Your Claude Code configuration determines billing.</p>
<p className="text-xs leading-relaxed text-muted-foreground">Minimum version: {CLAUDE_CODE_MINIMUM_VERSION}. Meeting this minimum does not guarantee MCP compatibility, model access, or available quota. <a href="https://code.claude.com/docs/en/setup" target="_blank" rel="noreferrer" className="underline underline-offset-2 hover:text-foreground">Claude Code setup ↗</a></p>
<div className="flex items-center gap-3">
<span className="font-prose text-(length:--lh-prose-size) text-foreground">Claude Code</span>
<Toggle label="Claude Code" checked={enabled} disabled={!loaded || saving} onChange={(next) => void save(next)} />
</div>
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
{enabled && <>
<div className="flex flex-wrap items-center gap-3">
<span role="status" className="text-xs text-muted-foreground">{busy ? "Checking…" : claudeCodeStatusLabel(status)}</span>
<Button type="button" size="xs" variant="outline" className="cursor-pointer disabled:cursor-not-allowed" disabled={busy} onClick={() => void refresh()} data-testid="claude-code-check-again">Check again</Button>
</div>
<p className="text-xs leading-relaxed text-muted-foreground"><strong className="font-medium">Install and authenticate Claude Code in your terminal, then select a Claude Code model here.</strong> LearnHarness starts it when needed—no background process required.</p>
<p className="text-xs leading-relaxed text-muted-foreground">Checks installation/version only—not authentication, model access, or quota. Claude Code controls billing. Requires v{CLAUDE_CODE_MINIMUM_VERSION}+. <a href="https://code.claude.com/docs/en/setup" target="_blank" rel="noreferrer" className="underline underline-offset-2 hover:text-foreground">Setup ↗</a></p>
<p className="text-xs leading-relaxed text-muted-foreground">Verify AI claims independently—they may be inaccurate, incomplete, misleading, or outdated.</p>
</>}
</div>;
}
+12 -2
View File
@@ -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<string, unknown>).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<string, unknown>);
}
+8 -1
View File
@@ -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.";
}
+13
View File
@@ -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;
}
+3
View File
@@ -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<Response> {
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 });
}
+4 -3
View File
@@ -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;
+3 -2
View File
@@ -31,6 +31,7 @@ export async function refreshConnectionCatalog(provider?: string): Promise<void>
]);
}
function connectionModel(provider: string, id: string): Model<Api> | 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));
}
+10
View File
@@ -91,6 +91,15 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<Gene
return { ok: true, settings };
}
/** claudeCode: explicit app-wide opt-in. Empty section resets to off. */
export function parseClaudeCodeSettingsBody(body: unknown): SettingsBodyResult<{ enabled?: boolean }> {
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;
+62 -11
View File
@@ -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<void>
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<ChildProcessWithoutNullStreams>((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;
+98
View File
@@ -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"));
});
+8 -1
View File
@@ -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<string, string> = {}) {
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);
+91 -45
View File
@@ -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("<html><body></body></html>");
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<HTMLButtonElement>('[data-testid="claude-code-check-again"]')!;
const toggle = () => document.querySelector<HTMLButtonElement>('[role="switch"]')!;
const label = () => document.querySelector('[role="status"]')?.textContent;
async function mount() {
function fixture(probe: (init?: RequestInit) => Promise<Response>, 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(<ClaudeCodeInstallationStatus />));
await act(async () => root!.render(<><ClaudeCodeInstallationStatus />{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(<ClaudeCodeInstallationStatus />);
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(/<button/g) ?? []).length, 1);
assert(!html.includes("Connect</button>"));
assert(!html.includes("Sign in</button>"));
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(<ConnectionRow label="Fixture" action={action} disabled={false} onAction={() => {}} />);
const connect = parseHTML(row("Connect")).document.querySelector("button")!;
const manage = parseHTML(row("Manage")).document.querySelector("button")!;
const check = parseHTML(renderToStaticMarkup(<ClaudeCodeInstallationStatus />)).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(<ConnectionRow label="Fixture" action={action} disabled={false} onAction={() => {}} />)).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<Response>((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<Response>((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 <span data-testid="cli-catalog-models">{catalog?.models.map((m) => `${m.provider}:${m.id}`).join(",") ?? "loading"}</span>;
}
test("persisted toggle updates invalidate shared model availability in both directions", async () => {
fixture(async () => Response.json(status("not-found")), false);
await mount(<CatalogReader />);
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(), "");
});
+23 -9
View File
@@ -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);
});
});
+1 -1
View File
@@ -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"],
);
});
});