feat: require explicit opt-in for Claude Code use
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 { 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);
|
||||
|
||||
@@ -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(), "");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user