fix: authorize CLI probes and preserve executable identity

This commit is contained in:
amosblomqvist
2026-10-28 19:26:36 +02:00
parent c5a756d707
commit 5093809ea0
9 changed files with 138 additions and 7 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
import { claudeCodeInstallationStatus } from "@/src/server"; import { claudeCodeInstallationResponse } from "@/src/server";
/** Installation/version only; never reads auth status or makes a model request. */ /** Installation/version only; never reads auth status or makes a model request. */
export async function GET() { export async function GET(request: Request) {
return Response.json(await claudeCodeInstallationStatus(), { headers: { "Cache-Control": "no-store" } }); return claudeCodeInstallationResponse(request);
} }
+1 -1
View File
@@ -22,7 +22,7 @@ export function ClaudeCodeInstallationStatus() {
request.current = controller; request.current = controller;
setBusy(true); setBusy(true);
try { try {
const response = await fetch("/api/claude-code", { cache: "no-store", signal: controller.signal }); const response = await fetch("/api/claude-code", { cache: "no-store", signal: controller.signal, headers: { "x-learnharness-claude-code": "1" } });
if (!response.ok) throw new Error("status unavailable"); if (!response.ok) throw new Error("status unavailable");
const value = await response.json() as ClaudeCodeStatus; const value = await response.json() as ClaudeCodeStatus;
if (!["not-found", "meets-minimum", "update-required", "version-unchecked"].includes(value.state) if (!["not-found", "meets-minimum", "update-required", "version-unchecked"].includes(value.state)
+5 -2
View File
@@ -6,6 +6,7 @@ type BinaryLookup = {
env: Record<string, string | undefined>; env: Record<string, string | undefined>;
platform: NodeJS.Platform; platform: NodeJS.Platform;
homeDir: string; homeDir: string;
cwd?: string;
isExecutable: (path: string) => boolean; isExecutable: (path: string) => boolean;
}; };
@@ -51,7 +52,7 @@ function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSp
}; };
} }
export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: BinaryLookup): string | null { export function resolveClaudeBinary({ env, platform, homeDir, cwd = process.cwd(), isExecutable }: BinaryLookup): string | null {
const { paths, names, knownDirs } = platform === "win32" const { paths, names, knownDirs } = platform === "win32"
? getWindowsSearchSpec(env, homeDir) ? getWindowsSearchSpec(env, homeDir)
: getPosixSearchSpec(env, homeDir); : getPosixSearchSpec(env, homeDir);
@@ -60,7 +61,9 @@ export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: Bi
for (const name of names) { for (const name of names) {
for (const dir of [...pathDirs, ...knownDirs]) { for (const dir of [...pathDirs, ...knownDirs]) {
const candidate = paths.join(dir, name); // Discovery is relative to the server cwd; probes/model work use
// different directories and must receive the same absolute identity.
const candidate = paths.resolve(cwd, dir, name);
if (isExecutable(candidate)) return candidate; if (isExecutable(candidate)) return candidate;
} }
} }
+19
View File
@@ -1,8 +1,27 @@
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";
/** 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() {
initAuthEnv(); initAuthEnv();
return inspectClaudeInstallation(); return inspectClaudeInstallation();
} }
/** Same-origin browser intent, not user authentication. No CORS grants.
* A public/shared server still requires its own access-control boundary. */
export async function claudeCodeInstallationResponse(
request: Request,
inspect = claudeCodeInstallationStatus,
): Promise<Response> {
const origin = subscriptionRequestOrigin(request);
const supplied = request.headers.get("origin");
const headers = { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" };
if (request.method !== "GET" || !origin
|| request.headers.get("x-learnharness-claude-code") !== "1"
|| request.headers.get("sec-fetch-site") === "cross-site"
|| (supplied !== null && supplied !== origin)) {
return Response.json({ error: "Claude Code checks require a same-origin LearnHarness request." }, { status: 403, headers });
}
return Response.json(await inspect(), { headers });
}
+1 -1
View File
@@ -45,4 +45,4 @@ export * from "./observability.ts";
export * from "./model-catalog.ts"; export * from "./model-catalog.ts";
export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts"; export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
export { sessionCost } from "./pricing.ts"; export { sessionCost } from "./pricing.ts";
export { claudeCodeInstallationStatus } from "./claude-code.ts"; export { claudeCodeInstallationResponse } from "./claude-code.ts";
+6
View File
@@ -59,6 +59,12 @@ test("windows prefers claude.exe anywhere over claude.cmd, which Node cannot spa
assert.equal(resolveWith(executables, env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`); assert.equal(resolveWith(executables, env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`);
}); });
test("relative Windows PATH entries are fixed to an absolute discovery identity", () => {
assert.equal(resolveClaudeBinary({ env: { PATH: ".\\bin" }, platform: "win32", homeDir: "C:\\Users\\u", cwd: "C:\\server",
isExecutable: (candidate) => candidate === "C:\\server\\bin\\claude.exe",
}), "C:\\server\\bin\\claude.exe");
});
test("returns null when every location misses", () => { test("returns null when every location misses", () => {
assert.equal(resolveWith([], { PATH: "/usr/bin:/bin" }), null); assert.equal(resolveWith([], { PATH: "/usr/bin:/bin" }), null);
}); });
+39
View File
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { claudeCodeInstallationResponse } from "../src/server/claude-code.ts";
function request(headers: Record<string, string> = {}) {
return new Request("http://localhost:3000/api/claude-code", { headers: { "x-learnharness-claude-code": "1", ...headers } });
}
const status = { state: "not-found" as const, version: null, minimumVersion: "2.1.259" };
for (const headers of [
{ origin: "https://attacker.invalid" },
{ "sec-fetch-site": "cross-site" },
{ origin: "http://localhost:3000", "sec-fetch-site": "cross-site" },
{ origin: "null" },
{ "x-learnharness-claude-code": "" },
] as Record<string, string>[]) test(`unsafe status request ${JSON.stringify(headers)} never invokes the probe`, async () => {
let probes = 0;
const response = await claudeCodeInstallationResponse(request(headers), async () => { probes++; return status; });
assert.equal(response.status, 403); assert.equal(probes, 0);
assert.equal(response.headers.get("access-control-allow-origin"), null);
});
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);
});
for (const headers of [{}, { origin: "http://localhost:3000", "sec-fetch-site": "same-origin" }] as Record<string, string>[]) test(`legitimate Providers GET ${JSON.stringify(headers)} is supported`, async () => {
let probes = 0;
const response = await claudeCodeInstallationResponse(request(headers), async () => { probes++; return status; });
assert.equal(response.status, 200); assert.equal(probes, 1);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.deepEqual(await response.json(), status);
});
test("a forged forwarded origin cannot authorize a foreign Origin header", async () => {
const response = await claudeCodeInstallationResponse(request({ origin: "https://attacker.invalid", "x-forwarded-host": "attacker.invalid", "x-forwarded-proto": "https" }), async () => assert.fail("must not probe"));
assert.equal(response.status, 403);
});
+1
View File
@@ -47,6 +47,7 @@ test("mount checks once, Check again refreshes, and neither request asks for aut
globalThis.fetch = (async (url, init) => { globalThis.fetch = (async (url, init) => {
calls.push(String(url)); calls.push(String(url));
assert.equal(init?.cache, "no-store"); 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")); return Response.json(calls.length === 1 ? status("not-found") : status("meets-minimum", "2.1.310"));
}) as typeof fetch; }) as typeof fetch;
await mount(); await mount();
+63
View File
@@ -0,0 +1,63 @@
/** Real execFile boundary, authored fixture executables only. Never the installed CLI. */
import assert from "node:assert/strict";
import { type spawn } from "node:child_process";
import { accessSync, constants, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test, type TestContext } from "node:test";
import { resolveClaudeBinary } from "../src/agent/claude-code/binary.ts";
import { readClaudeVersion } from "../src/agent/claude-code/installation.ts";
import { createClaudeLauncher } from "../src/agent/claude-code/launch.ts";
const unsupportedShebang = process.platform === "win32" || /\s/.test(process.execPath);
function fixture(t: TestContext, mode: "normal" | "oversize" | "hang", name = "claude") {
const dir = mkdtempSync(join(tmpdir(), "lh-version-runner-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const bin = join(dir, "bin"); mkdirSync(bin);
const binary = join(bin, name);
const body = mode === "hang" ? `
import { writeFileSync } from "node:fs";
writeFileSync(process.argv[1] + ".pid", String(process.pid));
process.on("SIGTERM", () => {});
setInterval(() => {}, 1000);
` : mode === "oversize" ? 'process.stdout.write("x".repeat(100000));' : `
if (JSON.stringify(process.argv.slice(2)) !== '["--version"]') process.exit(2);
process.stdout.write("2.1.310 (Claude Code)\\n");
`;
writeFileSync(binary, `#!${process.execPath}\n${body}`, { mode: 0o755 });
return { dir, binary };
}
test("production version runner directly executes a spaced/metacharacter filename with only --version", { skip: unsupportedShebang }, async (t) => {
const { binary } = fixture(t, "normal", "claude ; fixture");
assert.equal(await readClaudeVersion(binary), "2.1.310 (Claude Code)\n");
});
test("production version runner bounds stdout", { skip: unsupportedShebang }, async (t) => {
const { binary } = fixture(t, "oversize");
await assert.rejects(readClaudeVersion(binary), (error: NodeJS.ErrnoException) => error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER");
});
test("production version runner times out and SIGKILLs an uncooperative fixture", { skip: unsupportedShebang, timeout: 10000 }, async (t) => {
const { binary } = fixture(t, "hang");
const start = Date.now();
await assert.rejects(readClaudeVersion(binary), (error: Error & { signal?: string }) => error.signal === "SIGKILL");
assert(Date.now() - start < 8500, "bounded five-second timeout");
const pid = Number(readFileSync(`${binary}.pid`, "utf8"));
assert.throws(() => process.kill(pid, 0), /ESRCH/, "fixture no longer exists");
});
test("relative PATH is resolved at server cwd and stays the same for probe and model launch", { skip: unsupportedShebang }, async (t) => {
const { dir, binary } = fixture(t, "normal");
const found = resolveClaudeBinary({ env: { PATH: "./bin" }, homeDir: dir, platform: process.platform, cwd: dir,
isExecutable: (path) => { try { accessSync(path, constants.X_OK); return statSync(path).isFile(); } catch { return false; } },
});
assert.equal(found, binary);
assert.equal(await readClaudeVersion(found!), "2.1.310 (Claude Code)\n", "probe changes cwd to tmpdir but executes the discovered file");
let spawned = false;
const launch = createClaudeLauncher({ findBinary: () => found,
spawnProcess: ((path: string) => { assert.equal(path, binary); spawned = true; return null; }) as unknown as typeof spawn,
});
await launch(["-p"], { cwd: tmpdir(), env: { NODE_ENV: "test" } });
assert(spawned, "same verified absolute identity passed to the model launcher");
});