diff --git a/app/api/claude-code/route.ts b/app/api/claude-code/route.ts new file mode 100644 index 0000000..e587386 --- /dev/null +++ b/app/api/claude-code/route.ts @@ -0,0 +1,6 @@ +import { claudeCodeInstallationStatus } from "@/src/server"; + +/** Installation/version only; never reads auth status or makes a model request. */ +export async function GET() { + return Response.json(await claudeCodeInstallationStatus(), { headers: { "Cache-Control": "no-store" } }); +} diff --git a/components/settings/claude-code-status.tsx b/components/settings/claude-code-status.tsx new file mode 100644 index 0000000..274d5b4 --- /dev/null +++ b/components/settings/claude-code-status.tsx @@ -0,0 +1,49 @@ +"use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "@/src/core"; +import { ConnectionRow } from "./connection-controls.tsx"; + +export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string { + switch (status?.state) { + case "not-found": return "Not found"; + case "meets-minimum": return `Version ${status.version} · meets minimum`; + case "update-required": return `Version ${status.version} · update required`; + default: return "Version could not be checked"; + } +} + +export function ClaudeCodeInstallationStatus() { + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(true); + const request = useRef(null); + const refresh = useCallback(async () => { + request.current?.abort(); + const controller = new AbortController(); + request.current = controller; + setBusy(true); + try { + const response = await fetch("/api/claude-code", { cache: "no-store", signal: controller.signal }); + if (!response.ok) throw new Error("status unavailable"); + const value = await response.json() as ClaudeCodeStatus; + if (!["not-found", "meets-minimum", "update-required", "version-unchecked"].includes(value.state) + || value.minimumVersion !== CLAUDE_CODE_MINIMUM_VERSION + || (value.version !== null && (typeof value.version !== "string" || !/^\d+\.\d+\.\d+$/.test(value.version)))) throw new Error("invalid status"); + if (!controller.signal.aborted) setStatus(value); + } catch { + if (!controller.signal.aborted) setStatus(null); + } finally { + if (!controller.signal.aborted) setBusy(false); + } + }, []); + useEffect(() => { + void refresh(); + return () => request.current?.abort(); + }, [refresh]); + return
+ void refresh()} actionTestid="claude-code-check-again"> + {busy ? "Checking…" : claudeCodeStatusLabel(status)} + +

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

+

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

+
; +} diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 198e93d..5d5f18e 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -27,6 +27,7 @@ import { useEffect, useRef, useState } from "react"; import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core"; import { SettingsGroup } from "./controls.tsx"; import { SubscriptionConnections } from "./subscription-connections.tsx"; +import { ClaudeCodeInstallationStatus } from "./claude-code-status.tsx"; import { ProviderMenuList } from "./model-menu.tsx"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved } from "@/components/settings-signal"; @@ -471,6 +472,10 @@ export function KeysSection({ + + + + {(["Dictation", "Search"] as const).map((label) => {toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => ( Promise; + +/** No shell, auth-status command, model call, or credential-file inspection. */ +export const readClaudeVersion: ReadClaudeVersion = (binary) => new Promise((resolve, reject) => { + execFile(binary, ["--version"], { + cwd: tmpdir(), env: externalCliEnv(), windowsHide: true, + timeout: 5_000, killSignal: "SIGKILL", maxBuffer: 4_096, + }, (error, stdout) => error ? reject(error) : resolve(stdout)); +}); + +export async function inspectClaudeInstallation( + findBinary: () => string | null = getClaudeBinary, + readVersion: ReadClaudeVersion = readClaudeVersion, +): Promise { + const base = { version: null, minimumVersion: CLAUDE_CODE_MINIMUM_VERSION }; + const binary = findBinary(); + if (!binary) return { ...base, state: "not-found" }; + try { + const output = await readVersion(binary); + // Expose only a version, never arbitrary CLI stdout/stderr or an absolute path. + const match = /^(\d+)\.(\d+)\.(\d+)(?: \(Claude Code\))?$/.exec(output.trim()); + if (!match) return { ...base, state: "version-unchecked" }; + const parts = match.slice(1).map(Number); + if (!parts.every(Number.isSafeInteger)) return { ...base, state: "version-unchecked" }; + const minimum = CLAUDE_CODE_MINIMUM_VERSION.split(".").map(Number); + const differing = parts.findIndex((part, index) => part !== minimum[index]); + const meetsMinimum = differing === -1 || parts[differing]! > minimum[differing]!; + return { ...base, version: parts.join("."), state: meetsMinimum ? "meets-minimum" : "update-required" }; + } catch { + return { ...base, state: "version-unchecked" }; + } +} diff --git a/src/agent/claude-code/launch.ts b/src/agent/claude-code/launch.ts new file mode 100644 index 0000000..5b296bd --- /dev/null +++ b/src/agent/claude-code/launch.ts @@ -0,0 +1,32 @@ +import { spawn } from "node:child_process"; +import { getClaudeBinary } from "./binary.ts"; +import { inspectClaudeInstallation, readClaudeVersion, type ReadClaudeVersion } from "./installation.ts"; +import type { RunClaude } from "./stream.ts"; + +export class ClaudeLaunchError extends Error {} + +/** Injectable seams keep all verification offline; production uses the installed CLI. */ +export function createClaudeLauncher({ + findBinary = getClaudeBinary, + readVersion = readClaudeVersion, + spawnProcess = spawn, +}: { + findBinary?: () => string | null; + readVersion?: ReadClaudeVersion; + spawnProcess?: typeof spawn; +} = {}): RunClaude { + return async (args, options) => { + if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted"); + const binary = findBinary(); + if (!binary) return null; + const status = await inspectClaudeInstallation(() => binary, readVersion); + if (status.state === "update-required") { + throw new ClaudeLaunchError(`Claude Code ${status.version} requires an update. LearnHarness requires at least ${status.minimumVersion}; update Claude Code outside LearnHarness.`); + } + if (status.state !== "meets-minimum") { + throw new ClaudeLaunchError("Claude Code's version could not be checked. Check your installation outside LearnHarness and try again."); + } + if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted"); + return spawnProcess(binary, args, { cwd: options.cwd, env: options.env, stdio: "pipe" }); + }; +} diff --git a/src/agent/claude-code/stream.ts b/src/agent/claude-code/stream.ts index 4eda283..39a49ef 100644 --- a/src/agent/claude-code/stream.ts +++ b/src/agent/claude-code/stream.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { type ChildProcessWithoutNullStreams } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -24,7 +24,7 @@ import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messag import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse"; import { externalCliEnv } from "../../core/auth-env.ts"; -import { getClaudeBinary } from "./binary.ts"; +import { ClaudeLaunchError, createClaudeLauncher } from "./launch.ts"; import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts"; import { ClaudeCodeError } from "./constants.ts"; import { addLiveProcess, getLiveProcess, removeLiveProcess, type LiveProcess } from "./live-processes.ts"; @@ -32,9 +32,10 @@ import { configurationForReuse, conversationForReuse } from "./reuse-context.ts" import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts"; import { toClaudeInput, toStreamJsonLine } from "./transcript.ts"; -type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv }; +type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv; signal?: AbortSignal }; -export type RunClaude = (args: string[], options: RunClaudeOptions) => ChildProcessWithoutNullStreams | null; +type ClaudeChild = ChildProcessWithoutNullStreams | null; +export type RunClaude = (args: string[], options: RunClaudeOptions) => ClaudeChild | Promise; // A held MCP call lasts until pi calls back with the tool result, which for a question tool is // whenever the learner answers; this must outlast the live-process idle timeout so the CLI never @@ -637,7 +638,7 @@ async function startProcess( mcpConfigFile: files.mcpConfigFile, allowedTools: bridge.allowedTools, }); - child = runClaude(args, { cwd: files.cwd, env }); + child = await runClaude(args, { cwd: files.cwd, env, signal: options?.signal }); } catch (error) { await removeTurnResources(bridge, files); throw error; @@ -666,17 +667,11 @@ export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunc startProcess(runClaude, turn, model, context, options, owner, configuration, env).catch((error: unknown) => { console.error("claude CLI turn failed:", error); - turn.fail(START_FAILED_MESSAGE, "error"); + turn.fail(options?.signal?.aborted ? ABORT_MESSAGE : error instanceof ClaudeLaunchError ? error.message : START_FAILED_MESSAGE, options?.signal?.aborted ? "aborted" : "error"); }); return stream; }; } -function spawnClaude(args: string[], options: RunClaudeOptions): ChildProcessWithoutNullStreams | null { - const binary = getClaudeBinary(); - - return binary ? spawn(binary, args, { ...options, stdio: "pipe" }) : null; -} - -export const streamClaudeCode = createClaudeCodeStreamFunction(spawnClaude); +export const streamClaudeCode = createClaudeCodeStreamFunction(createClaudeLauncher()); diff --git a/src/core/contracts/claude-code.ts b/src/core/contracts/claude-code.ts new file mode 100644 index 0000000..50abd27 --- /dev/null +++ b/src/core/contracts/claude-code.ts @@ -0,0 +1,10 @@ +/** Installation/version only. Never an authentication, quota, or billing verdict. */ +export interface ClaudeCodeStatus { + state: "not-found" | "meets-minimum" | "update-required" | "version-unchecked"; + version: string | null; + minimumVersion: string; +} + +// --permission-prompts none was added in 2.1.259. This is not an official +// minimum-version/stability contract for claudecode/toolUseId; admission fails closed. +export const CLAUDE_CODE_MINIMUM_VERSION = "2.1.259"; diff --git a/src/core/index.ts b/src/core/index.ts index bae8b33..4ca5f03 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -54,6 +54,7 @@ export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts export { NATIVE_SUBSCRIPTIONS } from "./contracts/subscriptions.ts"; export type { NativeSubscriptionProviderId, NativeSubscriptionLogicalId, NativeSubscriptionStatus, NativeSubscriptionSnapshot, NativeSubscriptionScope, NativeSubscriptionControlBridge, SubscriptionInteraction, SubscriptionPrompt } from "./contracts/subscriptions.ts"; export * from "./cost-summary.ts"; +export { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "./contracts/claude-code.ts"; export * from "./fuzzy.ts"; // settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19): // the user-settings record's shape/sanitizer (pure) and its fully-resolved diff --git a/src/server/claude-code.ts b/src/server/claude-code.ts new file mode 100644 index 0000000..be52c06 --- /dev/null +++ b/src/server/claude-code.ts @@ -0,0 +1,8 @@ +import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts"; +import { initAuthEnv } from "./keys.ts"; + +/** The Settings status seam: initialize provenance before any child env is copied. */ +export async function claudeCodeInstallationStatus() { + initAuthEnv(); + return inspectClaudeInstallation(); +} diff --git a/src/server/index.ts b/src/server/index.ts index a04dfb5..cd65868 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -45,3 +45,4 @@ export * from "./observability.ts"; export * from "./model-catalog.ts"; export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts"; export { sessionCost } from "./pricing.ts"; +export { claudeCodeInstallationStatus } from "./claude-code.ts"; diff --git a/src/server/keys.ts b/src/server/keys.ts index 7f65362..03b8d4c 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -53,6 +53,8 @@ import { } from "../core/auth-keys.ts"; import { authStore } from "../store/auth.ts"; import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts"; +import { getClaudeBinary } from "../agent/claude-code/binary.ts"; +import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts"; // Shared HMR-stable provenance lets external CLIs exclude file-only injections // without importing this store-backed module into the agent layer. @@ -187,6 +189,8 @@ export function keyValue(id: string): string | undefined { * (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take * no key at all: nothing to gate, present by definition. */ export function providerKeyPresent(provider: string): boolean { + // Presence only, not authentication readiness. Launch performs the version gate. + if (provider === CLAUDE_CODE_PROVIDER) return getClaudeBinary() !== null; if (isSubscriptionProvider(provider)) return subscriptionConnected(provider); const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider); return info ? keyValue(info.envVar) !== undefined : true; diff --git a/tests/chatgpt-connection-ui.test.tsx b/tests/chatgpt-connection-ui.test.tsx index 2edcf2f..b329ba7 100644 --- a/tests/chatgpt-connection-ui.test.tsx +++ b/tests/chatgpt-connection-ui.test.tsx @@ -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(); 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"]')); diff --git a/tests/claude-code-harness.test.ts b/tests/claude-code-harness.test.ts index aab05bb..875c96c 100644 --- a/tests/claude-code-harness.test.ts +++ b/tests/claude-code-harness.test.ts @@ -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[] = []; diff --git a/tests/claude-code-installation.test.ts b/tests/claude-code-installation.test.ts new file mode 100644 index 0000000..cc33126 --- /dev/null +++ b/tests/claude-code-installation.test.ts @@ -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, 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, /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); +}); diff --git a/tests/claude-code-status-ui.test.tsx b/tests/claude-code-status-ui.test.tsx new file mode 100644 index 0000000..5c09ddb --- /dev/null +++ b/tests/claude-code-status-ui.test.tsx @@ -0,0 +1,81 @@ +import React, { act } from "react"; +(globalThis as Record).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(""); +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('[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()); +} +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(); + 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(/")); +}); + +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((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); +}); diff --git a/tests/claude-code-stream.test.ts b/tests/claude-code-stream.test.ts index 67885c7..19e3a76 100644 --- a/tests/claude-code-stream.test.ts +++ b/tests/claude-code-stream.test.ts @@ -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); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index b3de078..62b14fc 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -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)); }); });