feat: report Claude CLI installation and gate unsupported versions

This commit is contained in:
amosblomqvist
2026-10-28 19:10:47 +02:00
parent c0de3f968d
commit c5a756d707
17 changed files with 334 additions and 21 deletions
+4 -2
View File
@@ -77,14 +77,16 @@ test("Providers is the first/default page; General is last and other pages keep
assert.doesNotMatch(html, />Model providers<\/button>/);
});
test("Providers has exactly four ordered sections; unknown variables stay masked/read-only at the end of Model API keys", async () => {
test("Providers has five ordered sections including External CLI; unknown variables stay masked/read-only at the end of Model API keys", async () => {
globalThis.fetch = async (url) => {
if (url === "/api/claude-code") return Response.json({ state: "not-found", version: null, minimumVersion: "2.1.259" });
assert.equal(url, "/api/keys");
return Response.json({ providers: [{ id: "openai", envVar: "OPENAI_API_KEY", label: "OpenAI" }], keys: [{ id: "FUTURE_VAR", info: null, present: true, masked: "…abcd", source: "file", shadowedByEnv: false }] });
};
fixture(); await mount(<KeysSection />);
const headings = [...document.querySelectorAll('[data-testid^="group-"] > p')].map((el) => el.textContent);
assert.deepEqual(headings, ["Model API keys", "Subscriptions", "Dictation", "Search"]);
assert.deepEqual(headings, ["Model API keys", "Subscriptions", "External CLI", "Dictation", "Search"]);
assert(byTest("claude-code-installation").closest('[data-testid="group-external-cli"]'));
assert(byTest("key-deepgram_api_key").closest('[data-testid="group-dictation"]'));
assert(byTest("key-tavily_api_key").closest('[data-testid="group-search"]'));
assert(byTest("key-brave_api_key").closest('[data-testid="group-search"]'));
+7 -1
View File
@@ -12,7 +12,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { once } from "node:events";
import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
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";
@@ -63,6 +63,12 @@ function toAskScenario(): string[] {
function createFakeRun(t: TestContext, scenarios: string[][]): FakeRun {
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
// below goes through the injected Node fake-CLI seam, not an installed CLI.
writeFileSync(join(dir, process.platform === "win32" ? "claude.exe" : "claude"), "", { mode: 0o755 });
const savedPath = process.env.PATH;
process.env.PATH = `${dir}${delimiter}${savedPath ?? ""}`;
t.after(() => { if (savedPath === undefined) delete process.env.PATH; else process.env.PATH = savedPath; });
const children: ChildProcessWithoutNullStreams[] = [];
const stdinFiles: string[] = [];
+72
View File
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { type ChildProcessWithoutNullStreams, type spawn } from "node:child_process";
import { test } from "node:test";
import { inspectClaudeInstallation } from "../src/agent/claude-code/installation.ts";
import { ClaudeLaunchError, createClaudeLauncher } from "../src/agent/claude-code/launch.ts";
const binary = "/fake/claude";
const options = { cwd: "/fake/work", env: { NODE_ENV: "test" as const, ANTHROPIC_API_KEY: "native-test-key" } };
test("missing installation does not execute a command", async () => {
assert.deepEqual(await inspectClaudeInstallation(() => null, async () => { throw new Error("must not run"); }), {
state: "not-found", version: null, minimumVersion: "2.1.259",
});
});
for (const [output, state, version] of [
["2.1.258 (Claude Code)\n", "update-required", "2.1.258"],
["2.1.259 (Claude Code)", "meets-minimum", "2.1.259"],
["2.1.310 (Claude Code)", "meets-minimum", "2.1.310"],
["2.2.0", "meets-minimum", "2.2.0"],
["2.0.999", "update-required", "2.0.999"],
["3.0.0", "meets-minimum", "3.0.0"],
["unexpected output containing private details", "version-unchecked", null],
["2.1.310-preview", "version-unchecked", null],
["99999999999999999999999999.1.1", "version-unchecked", null],
] as const) test(`installation version output ${JSON.stringify(output)} gives ${state}`, async () => {
const status = await inspectClaudeInstallation(() => binary, async (path) => { assert.equal(path, binary); return output; });
assert.deepEqual(status, { state, version, minimumVersion: "2.1.259" });
});
test("probe failures expose neither raw errors nor binary paths", async () => {
const status = await inspectClaudeInstallation(() => binary, async () => { throw new Error("timeout /private/path secret-token"); });
assert.deepEqual(status, { state: "version-unchecked", version: null, minimumVersion: "2.1.259" });
});
for (const output of ["2.1.258", "garbage", null]) test(`launcher refuses ${output ?? "failed"} version check before model work`, async () => {
const launch = createClaudeLauncher({ findBinary: () => binary,
readVersion: async () => { if (output === null) throw new Error("failed probe"); return output; },
spawnProcess: (() => { assert.fail("must not start model work"); }) as typeof spawn,
});
await assert.rejects(launch(["-p"], options) as Promise<unknown>, ClaudeLaunchError);
});
test("launcher uses the verified executable directly and preserves model args and native env", async () => {
const child = {} as ChildProcessWithoutNullStreams;
let calls = 0;
const launch = createClaudeLauncher({ findBinary: () => binary, readVersion: async () => "2.1.310 (Claude Code)",
spawnProcess: ((path: string, args: string[], opts: unknown) => {
calls++;
assert.equal(path, binary);
assert.deepEqual(args, ["-p", "--tools", ""]);
assert.deepEqual(opts, { ...options, stdio: "pipe" });
return child;
}) as typeof spawn,
});
assert.equal(await launch(["-p", "--tools", ""], options), child);
assert.equal(calls, 1);
});
test("aborting during a version check prevents model process creation", async () => {
const controller = new AbortController();
const launch = createClaudeLauncher({ findBinary: () => binary,
readVersion: async () => { controller.abort(); return "2.1.310"; },
spawnProcess: (() => { assert.fail("must not start after abort"); }) as typeof spawn,
});
await assert.rejects(launch([], { ...options, signal: controller.signal }) as Promise<unknown>, /aborted/);
});
test("missing executable is not mistaken for an unchecked version", async () => {
const launch = createClaudeLauncher({ findBinary: () => null, readVersion: async () => assert.fail("must not probe") });
assert.equal(await launch([], options), null);
});
+81
View File
@@ -0,0 +1,81 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { createRoot, type Root } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx";
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 });
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 label = () => document.querySelector('[role="status"]')?.textContent;
async function mount() {
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
await act(async () => root!.render(<ClaudeCodeInstallationStatus />));
}
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", () => {
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.equal((html.match(/<button/g) ?? []).length, 1);
assert(!html.includes("Connect</button>"));
assert(!html.includes("Sign in</button>"));
});
for (const [value, text] of [
[status("not-found"), "Not found"],
[status("meets-minimum", "2.1.310"), "Version 2.1.310 · meets minimum"],
[status("update-required", "2.1.258"), "Version 2.1.258 · update required"],
[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");
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 () => {
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);
});
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 () => {
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);
});
+4 -2
View File
@@ -37,6 +37,7 @@ import { ClaudeCodeError } from "../src/agent/claude-code/constants.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 { authEnvRecord } from "../src/core/auth-env.ts";
import { ClaudeLaunchError } from "../src/agent/claude-code/launch.ts";
import { createClaudeCodeStreamFunction, toClaudeArgs, toEffort, type RunClaude } from "../src/agent/claude-code/stream.ts";
import { toStreamJsonLine } from "../src/agent/claude-code/transcript.ts";
@@ -899,15 +900,16 @@ test("normal completion escalates termination when the child ignores SIGTERM", a
assert.equal(getOnlyCall(calls).child.signalCode, "SIGKILL");
});
test("a synchronous spawn failure removes both temp directories and the bridge", async () => {
for (const asyncVersionFailure of [false, true]) test(`${asyncVersionFailure ? "an asynchronous version-check" : "a synchronous spawn"} failure removes both temp directories and the bridge`, async () => {
let resources: { cwd: string; privateDir: string; url: string } | undefined;
const stream = createClaudeCodeStreamFunction((args, options) => {
const config = JSON.parse(readFileSync(getFlagValue(args, "--mcp-config"), "utf8"));
resources = { cwd: options.cwd, privateDir: dirname(getFlagValue(args, "--mcp-config")), url: config.mcpServers.learnharness.url };
if (asyncVersionFailure) return Promise.reject(new ClaudeLaunchError("Claude Code requires an update"));
throw new Error("synthetic spawn failure");
});
const events = await collectEvents(stream(getModel(), normalizeContext({ messages: [HI] })));
assert.match(getErrorMessage(events) ?? "", /failed to start/);
assert.match(getErrorMessage(events) ?? "", asyncVersionFailure ? /requires an update/ : /failed to start/);
assert.ok(resources);
assert.equal(existsSync(resources.cwd), false);
assert.equal(existsSync(resources.privateDir), false);
+4 -3
View File
@@ -354,7 +354,7 @@ function withClaudeInstalled(installed: boolean, fn: () => void): void {
}
}
describe("the claude-code provider (subscription CLI, no key)", () => {
describe("the claude-code provider (native CLI authentication, no LearnHarness key)", () => {
it("modelCatalog() lists claude-code opus/sonnet/haiku when the CLI is installed", () => {
withClaudeInstalled(true, () => {
const ids = modelCatalog()
@@ -394,7 +394,8 @@ describe("the claude-code provider (subscription CLI, no key)", () => {
assert.equal(engine.model.id, "sonnet");
});
it('providerKeyPresent("claude-code") is true', () => {
assert.equal(providerKeyPresent("claude-code"), true);
it('providerKeyPresent("claude-code") follows executable presence, not a generic keyless default', () => {
withClaudeInstalled(true, () => assert.equal(providerKeyPresent("claude-code"), true));
withClaudeInstalled(false, () => assert.equal(providerKeyPresent("claude-code"), false));
});
});