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
+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`);
});
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", () => {
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) => {
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();
+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");
});