feat: require explicit opt-in for Claude Code use
This commit is contained in:
@@ -39,7 +39,7 @@ export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined;
|
|||||||
catalogReaders.add(read);
|
catalogReaders.add(read);
|
||||||
if (first) {
|
if (first) {
|
||||||
stopCatalogSignals = onSettingsSaved((section) => {
|
stopCatalogSignals = onSettingsSaved((section) => {
|
||||||
if (section === "keys") {
|
if (section === "keys" || section === "claudeCode") {
|
||||||
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
|
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
|
||||||
fetchCatalog();
|
fetchCatalog();
|
||||||
} else if (section === "models") fetchCatalog(true);
|
} else if (section === "models") fetchCatalog(true);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const SETTINGS_SAVED_EVENT = "lh:settings-saved";
|
|||||||
// "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section
|
// "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section
|
||||||
// dispatches it after save AND delete — deletions used to be invisible to
|
// dispatches it after save AND delete — deletions used to be invisible to
|
||||||
// the zero-keys pill and the setup dialog's auto-advance.
|
// 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
|
/** Writers call this after the PUT resolves ok — never before (a failed
|
||||||
* write notifies nothing; the record is unchanged). */
|
* write notifies nothing; the record is unchanged). */
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "@/src/core";
|
import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus, type UserSettings } from "@/src/core";
|
||||||
import { ConnectionRow } from "./connection-controls.tsx";
|
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Toggle } from "./controls";
|
||||||
|
|
||||||
export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string {
|
export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string {
|
||||||
switch (status?.state) {
|
switch (status?.state) {
|
||||||
@@ -13,10 +15,65 @@ export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ClaudeCodeInstallationStatus() {
|
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 [status, setStatus] = useState<ClaudeCodeStatus | null>(null);
|
||||||
const [busy, setBusy] = useState(true);
|
const [busy, setBusy] = useState(false);
|
||||||
const request = useRef<AbortController | null>(null);
|
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 () => {
|
const refresh = useCallback(async () => {
|
||||||
|
if (!enabled) return;
|
||||||
request.current?.abort();
|
request.current?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
request.current = controller;
|
request.current = controller;
|
||||||
@@ -34,16 +91,27 @@ export function ClaudeCodeInstallationStatus() {
|
|||||||
} finally {
|
} finally {
|
||||||
if (!controller.signal.aborted) setBusy(false);
|
if (!controller.signal.aborted) setBusy(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [enabled]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
if (enabled) void refresh();
|
||||||
|
else { request.current?.abort(); setStatus(null); setBusy(false); }
|
||||||
return () => request.current?.abort();
|
return () => request.current?.abort();
|
||||||
}, [refresh]);
|
}, [enabled, refresh]);
|
||||||
|
|
||||||
return <div className="space-y-2" data-testid="claude-code-installation">
|
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">
|
<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>
|
<span role="status" className="text-xs text-muted-foreground">{busy ? "Checking…" : claudeCodeStatusLabel(status)}</span>
|
||||||
</ConnectionRow>
|
<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>
|
||||||
<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>
|
</div>
|
||||||
<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>
|
<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>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -29,6 +29,7 @@
|
|||||||
|
|
||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts";
|
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 { ModelRole } from "../../config.ts";
|
||||||
import type { ChatSettings } from "./contracts/chat.ts";
|
import type { ChatSettings } from "./contracts/chat.ts";
|
||||||
import type { SearchEngineId } from "./search.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 refusal wording's invariant tail — the matcher below keys on it
|
||||||
* (the label prefix varies by provider). */
|
* (the label prefix varies by provider). */
|
||||||
const PROVIDER_KEY_MISSING_TAIL = "key is set — add one in Settings → Keys";
|
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 {
|
export function providerKeyMissing(provider: string, slots: ModelSlotName[]): ProviderKeyMissing {
|
||||||
const label = AUTH_PROVIDER_KEYS.find((p) => p.id === provider)?.label ?? provider;
|
const label = AUTH_PROVIDER_KEYS.find((p) => p.id === provider)?.label ?? provider;
|
||||||
return {
|
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,
|
code: PROVIDER_KEY_MISSING_CODE,
|
||||||
provider,
|
provider,
|
||||||
slots,
|
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
|
* scary-error) — the envelope's home module owns the string so the
|
||||||
* writer and the matcher can never drift. */
|
* writer and the matcher can never drift. */
|
||||||
export function isProviderKeyRefusal(error: string | undefined): boolean {
|
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
|
/** The typed throw for in-process seams (extraction pipeline) — the
|
||||||
* ProviderKeyMissing sibling of ModelsNotConfiguredError: callers catch
|
* ProviderKeyMissing sibling of ModelsNotConfiguredError: callers catch
|
||||||
@@ -368,6 +370,8 @@ export interface UserSettings {
|
|||||||
version: number;
|
version: number;
|
||||||
general?: GeneralSettings;
|
general?: GeneralSettings;
|
||||||
dictation?: DictationSettings;
|
dictation?: DictationSettings;
|
||||||
|
/** Explicit app-wide opt-in; absent/invalid is off, including older records. */
|
||||||
|
claudeCode?: { enabled?: boolean };
|
||||||
agent?: AgentSettings;
|
agent?: AgentSettings;
|
||||||
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
|
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
|
||||||
models?: ModelsSettings;
|
models?: ModelsSettings;
|
||||||
@@ -631,6 +635,12 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
|
|||||||
? { language: (raw as DictationSettings).language }
|
? { 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)) {
|
if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) {
|
||||||
out.agent = sanitizeAgent(r.agent as Record<string, unknown>);
|
out.agent = sanitizeAgent(r.agent as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
type TeachingAgent,
|
type TeachingAgent,
|
||||||
} from "../agent/teacher-agent.ts";
|
} from "../agent/teacher-agent.ts";
|
||||||
import { piEngineForSpec, type PiEngine } from "../agent/models.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 { nativeSubscriptionError } from "../agent/subscription-stream.ts";
|
||||||
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
|
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
|
||||||
import { SubscriptionRequestCycle, type SubscriptionDispatchLifecycle } from "../agent/subscription-request-cycle.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 { effectiveSettings, stores } from "./stores.ts";
|
||||||
import { searchAvailable, searchProvider } from "./search.ts";
|
import { searchAvailable, searchProvider } from "./search.ts";
|
||||||
import { providerKeyPresent } from "./keys.ts";
|
import { providerKeyPresent } from "./keys.ts";
|
||||||
|
import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts";
|
||||||
import {
|
import {
|
||||||
extractionEnabled,
|
extractionEnabled,
|
||||||
graphToolsEnabled,
|
graphToolsEnabled,
|
||||||
@@ -80,6 +81,12 @@ import type {CallUsage} from "../core/contracts.ts";
|
|||||||
* for write-ahead persistence, turn liveness, and the turn services.
|
* 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 {
|
function settlementErrorText(error: unknown): string {
|
||||||
return error instanceof TurnPersistenceError ? error.message : "An error occurred.";
|
return error instanceof TurnPersistenceError ? error.message : "An error occurred.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts";
|
import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts";
|
||||||
import { initAuthEnv } from "./keys.ts";
|
import { initAuthEnv } from "./keys.ts";
|
||||||
import { subscriptionRequestOrigin } from "./web-subscription-http.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. */
|
/** The Settings status seam: initialize provenance before any child env is copied. */
|
||||||
export async function claudeCodeInstallationStatus() {
|
export async function claudeCodeInstallationStatus() {
|
||||||
@@ -13,6 +14,7 @@ export async function claudeCodeInstallationStatus() {
|
|||||||
export async function claudeCodeInstallationResponse(
|
export async function claudeCodeInstallationResponse(
|
||||||
request: Request,
|
request: Request,
|
||||||
inspect = claudeCodeInstallationStatus,
|
inspect = claudeCodeInstallationStatus,
|
||||||
|
enabled = claudeCodeEnabled,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const origin = subscriptionRequestOrigin(request);
|
const origin = subscriptionRequestOrigin(request);
|
||||||
const supplied = request.headers.get("origin");
|
const supplied = request.headers.get("origin");
|
||||||
@@ -23,5 +25,6 @@ export async function claudeCodeInstallationResponse(
|
|||||||
|| (supplied !== null && supplied !== origin)) {
|
|| (supplied !== null && supplied !== origin)) {
|
||||||
return Response.json({ error: "Claude Code checks require a same-origin LearnHarness request." }, { status: 403, headers });
|
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 });
|
return Response.json(await inspect(), { headers });
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -53,7 +53,7 @@ import {
|
|||||||
} from "../core/auth-keys.ts";
|
} from "../core/auth-keys.ts";
|
||||||
import { authStore } from "../store/auth.ts";
|
import { authStore } from "../store/auth.ts";
|
||||||
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.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";
|
import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
|
||||||
|
|
||||||
// Shared HMR-stable provenance lets external CLIs exclude file-only injections
|
// 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
|
* (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
|
||||||
* no key at all: nothing to gate, present by definition. */
|
* no key at all: nothing to gate, present by definition. */
|
||||||
export function providerKeyPresent(provider: string): boolean {
|
export function providerKeyPresent(provider: string): boolean {
|
||||||
// Presence only, not authentication readiness. Launch performs the version gate.
|
// Explicit opt-in + installation only, never authentication readiness.
|
||||||
if (provider === CLAUDE_CODE_PROVIDER) return getClaudeBinary() !== null;
|
// Consulted at admission, not inside already-running agent loops.
|
||||||
|
if (provider === CLAUDE_CODE_PROVIDER) return claudeCodeAvailable();
|
||||||
if (isSubscriptionProvider(provider)) return subscriptionConnected(provider);
|
if (isSubscriptionProvider(provider)) return subscriptionConnected(provider);
|
||||||
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
|
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
|
||||||
return info ? keyValue(info.envVar) !== undefined : true;
|
return info ? keyValue(info.envVar) !== undefined : true;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export async function refreshConnectionCatalog(provider?: string): Promise<void>
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
function connectionModel(provider: string, id: string): Model<Api> | undefined {
|
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);
|
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)]; }
|
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";
|
} from "../core/contracts/models.ts";
|
||||||
import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts";
|
import type { EffectiveModelSpec, EffectiveSlot } from "../core/settings-resolution.ts";
|
||||||
import { AUTH_PROVIDER_KEYS } from "../core/auth-keys.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 { 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";
|
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,
|
keyPresent: keyValue(p.envVar) !== undefined,
|
||||||
})).concat([
|
})).concat([
|
||||||
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
|
{ 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) })),
|
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
|
||||||
]).sort((a, b) => a.id.localeCompare(b.id));
|
]).sort((a, b) => a.id.localeCompare(b.id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,15 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult<Gene
|
|||||||
return { ok: true, settings };
|
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
|
/** agent.models: only the six known background roles; each override's
|
||||||
* fields mirror ChatSettings (model pair + independent thinkingLevel). */
|
* fields mirror ChatSettings (model pair + independent thinkingLevel). */
|
||||||
function parseRoleOverride(value: unknown, role: string): { ok: true; override: RoleModelOverride } | { ok: false; error: string } {
|
function parseRoleOverride(value: unknown, role: string): { ok: true; override: RoleModelOverride } | { ok: false; error: string } {
|
||||||
@@ -397,6 +406,7 @@ export const SETTINGS_SECTIONS = {
|
|||||||
compaction: parseCompactionSettingsBody,
|
compaction: parseCompactionSettingsBody,
|
||||||
models: parseModelsSettingsBody,
|
models: parseModelsSettingsBody,
|
||||||
dictation: parseDictationSettingsBody,
|
dictation: parseDictationSettingsBody,
|
||||||
|
claudeCode: parseClaudeCodeSettingsBody,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;
|
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;
|
||||||
|
|||||||
@@ -15,19 +15,26 @@ import { tmpdir } from "node:os";
|
|||||||
import { delimiter, join } from "node:path";
|
import { delimiter, join } from "node:path";
|
||||||
import { setTimeout as sleep } from "node:timers/promises";
|
import { setTimeout as sleep } from "node:timers/promises";
|
||||||
import { fileURLToPath } from "node:url";
|
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 type { RunClaude } from "../src/agent/claude-code/stream.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 { PiEngine } from "../src/agent/models.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";
|
// Opt-in writes must not race another test process's settings record.
|
||||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
const testData = mkdtempSync(join(tmpdir(), "lh-cli-harness-data-"));
|
||||||
import { isTurnRunning } from "../src/server/turn-liveness.ts";
|
process.env.LH_DATA_DIR = testData;
|
||||||
import { runningTurn } from "../src/server/turn-runs.ts";
|
after(() => rmSync(testData, { recursive: true, force: true }));
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
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 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));
|
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 {
|
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-"));
|
const dir = mkdtempSync(join(tmpdir(), "claude-code-harness-"));
|
||||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
// Satisfy the real presence gate; this marker is NEVER executed. All work
|
// 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) });
|
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) => {
|
test("a question answered before the idle timeout continues the waiting CLI process with the answer", async (t) => {
|
||||||
const fakeRun = createFakeRun(t, [toAskScenario()]);
|
const fakeRun = createFakeRun(t, [toAskScenario()]);
|
||||||
const run = fakeRun.runClaude;
|
const run = fakeRun.runClaude;
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
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> = {}) {
|
function request(headers: Record<string, string> = {}) {
|
||||||
return new Request("http://localhost:3000/api/claude-code", { headers: { "x-learnharness-claude-code": "1", ...headers } });
|
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);
|
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 () => {
|
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"));
|
const response = await claudeCodeInstallationResponse(new Request("http://localhost:3000/api/claude-code"), async () => assert.fail("must not probe"));
|
||||||
assert.equal(response.status, 403);
|
assert.equal(response.status, 403);
|
||||||
|
|||||||
@@ -7,45 +7,81 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import { parseHTML } from "linkedom";
|
import { parseHTML } from "linkedom";
|
||||||
import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx";
|
import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx";
|
||||||
import { ConnectionRow } from "../components/settings/connection-controls.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";
|
import type { ClaudeCodeStatus } from "../src/core/contracts/claude-code.ts";
|
||||||
|
|
||||||
const dom = parseHTML("<html><body></body></html>");
|
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;
|
const originalFetch = globalThis.fetch;
|
||||||
let root: Root | undefined, host: HTMLDivElement | undefined;
|
let root: Root | undefined, host: HTMLDivElement | undefined;
|
||||||
const status = (state: ClaudeCodeStatus["state"], version: string | null = null): ClaudeCodeStatus => ({ state, version, minimumVersion: "2.1.259" });
|
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 button = () => document.querySelector<HTMLButtonElement>('[data-testid="claude-code-check-again"]')!;
|
||||||
|
const toggle = () => document.querySelector<HTMLButtonElement>('[role="switch"]')!;
|
||||||
const label = () => document.querySelector('[role="status"]')?.textContent;
|
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);
|
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 () => {
|
afterEach(async () => {
|
||||||
await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined;
|
await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined;
|
||||||
globalThis.fetch = originalFetch;
|
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 />);
|
const html = renderToStaticMarkup(<ClaudeCodeInstallationStatus />);
|
||||||
assert.match(html, /Authentication is not checked/);
|
assert.match(html, /Claude Code/); assert.match(html, /aria-checked="false"/);
|
||||||
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.equal((html.match(/<button/g) ?? []).length, 1);
|
assert.equal((html.match(/<button/g) ?? []).length, 1);
|
||||||
assert(!html.includes("Connect</button>"));
|
assert(!html.includes("Check again")); assert(!html.includes("Authenticate")); assert(!html.includes("Setup ↗"));
|
||||||
assert(!html.includes("Sign in</button>"));
|
|
||||||
assert.match(html, /data-variant="outline"/);
|
|
||||||
assert.match(html, /data-size="xs"/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("recheck matches the existing Connect button styling without restyling Manage actions", () => {
|
test("off by default; enabling persists before probing and reveals the version/action row below the toggle", async () => {
|
||||||
const row = (action: string) => renderToStaticMarkup(<ConnectionRow label="Fixture" action={action} disabled={false} onAction={() => {}} />);
|
const calls = fixture(async () => Response.json(status("not-found")), false);
|
||||||
const connect = parseHTML(row("Connect")).document.querySelector("button")!;
|
await mount();
|
||||||
const manage = parseHTML(row("Manage")).document.querySelector("button")!;
|
assert.equal(toggle().getAttribute("aria-checked"), "false"); assert(!button());
|
||||||
const check = parseHTML(renderToStaticMarkup(<ClaudeCodeInstallationStatus />)).document.querySelector("button")!;
|
assert.deepEqual(calls, ["GET /api/settings"]);
|
||||||
assert.equal(check.getAttribute("class"), connect.getAttribute("class"));
|
await act(async () => toggle().click());
|
||||||
assert.equal(check.getAttribute("data-variant"), "outline");
|
assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.equal(label(), "Not found");
|
||||||
assert.equal(manage.getAttribute("data-variant"), "ghost");
|
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 [
|
for (const [value, text] of [
|
||||||
@@ -55,41 +91,51 @@ for (const [value, text] of [
|
|||||||
[status("version-unchecked"), "Version could not be checked"],
|
[status("version-unchecked"), "Version could not be checked"],
|
||||||
] as const) test(`installation display: ${text}`, () => assert.equal(claudeCodeStatusLabel(value), text));
|
] 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 () => {
|
test("enabled mount checks once; outlined Check again refreshes without probing authentication", async () => {
|
||||||
const calls: string[] = [];
|
let probes = 0;
|
||||||
globalThis.fetch = (async (url, init) => {
|
const calls = fixture(async () => Response.json(++probes === 1 ? status("not-found") : status("meets-minimum", "2.1.310")));
|
||||||
calls.push(String(url));
|
await mount(); assert.equal(label(), "Not found");
|
||||||
assert.equal(init?.cache, "no-store");
|
await act(async () => button().click()); assert.equal(label(), "Version 2.1.310 · meets minimum");
|
||||||
assert.equal(new Headers(init?.headers).get("x-learnharness-claude-code"), "1");
|
assert.equal(calls.filter((call) => call === "GET /api/claude-code").length, 2);
|
||||||
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("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 signal: AbortSignal | null | undefined;
|
||||||
let resolve!: (response: Response) => void;
|
let resolve!: (response: Response) => void;
|
||||||
globalThis.fetch = ((_url, init) => { signal = init?.signal; return new Promise<Response>((done) => { resolve = done; }); }) as typeof fetch;
|
fixture((init) => { signal = init?.signal; return new Promise<Response>((done) => { resolve = done; }); });
|
||||||
await mount();
|
await mount(); assert.equal(label(), "Checking…"); assert(button().disabled); assert(!toggle().disabled);
|
||||||
assert.equal(label(), "Checking…"); assert(button().disabled);
|
await act(async () => toggle().click()); assert(signal?.aborted); assert(!button());
|
||||||
await act(async () => { root!.unmount(); root = undefined; });
|
await act(async () => resolve(Response.json(status("meets-minimum", "2.1.310")))); assert.equal(label(), undefined);
|
||||||
assert(signal?.aborted);
|
|
||||||
await act(async () => resolve(Response.json(status("not-found"))));
|
|
||||||
assert.equal(document.querySelector('[role="status"]'), null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const failure of ["network", "http", "payload"] as const) test(`${failure} failure never retains a stale meets-minimum status`, async () => {
|
for (const failure of ["network", "http", "payload"] as const) test(`${failure} failure never retains a stale meets-minimum status`, async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
globalThis.fetch = (async () => {
|
fixture(async () => {
|
||||||
if (++calls === 1) return Response.json(status("meets-minimum", "2.1.310"));
|
if (++calls === 1) return Response.json(status("meets-minimum", "2.1.310"));
|
||||||
if (failure === "network") throw new Error("offline");
|
if (failure === "network") throw new Error("offline");
|
||||||
return failure === "http" ? new Response(null, { status: 503 }) : Response.json({ state: "authenticated" });
|
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 mount(); assert.equal(label(), "Version 2.1.310 · meets minimum");
|
||||||
await act(async () => button().click());
|
await act(async () => button().click()); assert.equal(label(), "Version could not be checked"); assert(!button().disabled);
|
||||||
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(), "");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
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 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,
|
catalogContextLimits,
|
||||||
catalogDefaultsFor,
|
catalogDefaultsFor,
|
||||||
catalogProviders,
|
catalogProviders,
|
||||||
@@ -16,17 +19,18 @@ import {
|
|||||||
toModelDto,
|
toModelDto,
|
||||||
validateChatSettings,
|
validateChatSettings,
|
||||||
validateModelsSettings,
|
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 { EffectiveSlot } from "../src/core/settings-resolution.ts";
|
||||||
import type { SettingsValidation } from "../src/server/model-catalog.ts";
|
import type { SettingsValidation } from "../src/server/model-catalog.ts";
|
||||||
import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
||||||
import { DEFAULT_THINKING_ORDER } from "../src/core/settings.ts";
|
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 { BACKGROUND_MODEL_ROLES } from "../src/core/settings.ts";
|
||||||
import { resolveSettings } from "../src/core/settings-resolution.ts";
|
import { resolveSettings } from "../src/core/settings-resolution.ts";
|
||||||
import { readSettings } from "../src/store/settings.ts";
|
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
|
||||||
import { effectiveSettings } from "../src/server/stores.ts";
|
const { SETTINGS_FILE } = await import("../config.ts");
|
||||||
import { providerKeyPresent } from "../src/server/keys.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
|
// The model-control wave (2026-09-12): pi-ai owns the registry — these pins
|
||||||
// hold our projection + validation honest against the shipped catalog, and
|
// 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
|
/** Runs fn with PATH and HOME pointed at a temp dir, so the CLI lookup only
|
||||||
* finds a claude binary when installed is true. */
|
* 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 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 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 });
|
if (installed) fs.writeFileSync(path.join(dir, "claude"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
process.env.PATH = dir;
|
process.env.PATH = dir;
|
||||||
@@ -353,6 +359,8 @@ function withClaudeInstalled(installed: boolean, fn: () => void): void {
|
|||||||
if (value === undefined) delete process.env[name];
|
if (value === undefined) delete process.env[name];
|
||||||
else process.env[name] = value;
|
else process.env[name] = value;
|
||||||
}
|
}
|
||||||
|
if (settings) writeSettings(settings);
|
||||||
|
else fs.rmSync(SETTINGS_FILE, { force: true });
|
||||||
fs.rmSync(dir, { recursive: true, 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");
|
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(true, () => assert.equal(providerKeyPresent("claude-code"), true));
|
||||||
withClaudeInstalled(false, () => assert.equal(providerKeyPresent("claude-code"), false));
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ describe("SETTINGS_SECTIONS (the route's section map)", () => {
|
|||||||
// threshold is engine behavior, not an agent-section key.
|
// threshold is engine behavior, not an agent-section key.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
Object.keys(SETTINGS_SECTIONS),
|
Object.keys(SETTINGS_SECTIONS),
|
||||||
["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation"],
|
["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation", "claudeCode"],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user