fix: authenticate CaseRun HWPOD prepare

This commit is contained in:
root
2026-07-16 21:19:40 +02:00
parent 66afd74c06
commit 5b60491e59
4 changed files with 113 additions and 3 deletions
@@ -5,6 +5,25 @@ import path from "node:path";
import { test } from "bun:test";
import { createCloudApiServer } from "./server.ts";
import { caseRunInternalFetch } from "./server-caserun-http.ts";
test("CaseRun internal requests reuse the existing bootstrap admin API key only for the internal origin", async () => {
const calls: Array<{ url: string; authorization: string | null }> = [];
const fetchImpl = async (input: URL | RequestInfo, init: RequestInit = {}) => {
const headers = new Headers(init.headers);
calls.push({ url: String(input), authorization: headers.get("authorization") });
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
};
const internalFetch = caseRunInternalFetch(fetchImpl as typeof fetch, "http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667", { HWLAB_BOOTSTRAP_ADMIN_API_KEY: "hwl_live_test" });
await internalFetch("http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops", { method: "POST" });
await internalFetch("https://lab-dev.hwpod.com/v1/hwpod-node-ops", { method: "POST" });
assert.deepEqual(calls, [
{ url: "http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops", authorization: "Bearer hwl_live_test" },
{ url: "https://lab-dev.hwpod.com/v1/hwpod-node-ops", authorization: null }
]);
});
test("cloud-api exposes web CaseRun cases, run status, events and aggregate", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-api-"));
+15 -1
View File
@@ -184,6 +184,7 @@ async function runSoftwareSmokeCase(context: any, emit: (stage: string, payload?
function caseContextForRecord(record: any, input: any) {
const internalApiUrl = internalRuntimeApiUrlForCaseRun(record, input.env ?? {});
const fetchImpl = caseRunInternalFetch(input.fetchImpl ?? globalThis.fetch, internalApiUrl, input.env ?? {});
const parsed = {
_: [],
caseId: record.caseId,
@@ -204,7 +205,7 @@ function caseContextForRecord(record: any, input: any) {
HWLAB_CASERUN_PUBLIC_RUNTIME_API_URL: record.runtimeApiUrl,
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1"
},
fetchImpl: input.fetchImpl ?? globalThis.fetch,
fetchImpl,
cwd: input.cwd,
now: () => new Date().toISOString(),
sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -215,6 +216,19 @@ function caseContextForRecord(record: any, input: any) {
};
}
export function caseRunInternalFetch(fetchImpl: typeof fetch, internalApiUrl: string, env: Record<string, string | undefined>) {
const apiKey = String(env.HWLAB_BOOTSTRAP_ADMIN_API_KEY ?? "").trim();
if (!apiKey) return fetchImpl;
const internalOrigin = new URL(internalApiUrl).origin;
return async (input: URL | RequestInfo, init: RequestInit = {}) => {
const target = new URL(typeof input === "string" || input instanceof URL ? input : input.url);
if (target.origin !== internalOrigin) return fetchImpl(input, init);
const headers = new Headers(init.headers);
if (!headers.has("authorization")) headers.set("authorization", `Bearer ${apiKey}`);
return fetchImpl(input, { ...init, headers });
};
}
function resolveCwd(env: Record<string, string | undefined>) {
return path.resolve(String(env.HWLAB_CASERUN_REPO_ROOT ?? process.cwd()));
}
+34
View File
@@ -5,11 +5,45 @@ import path from "node:path";
import { test } from "bun:test";
import { prepareCaseRun } from "./src/hwlab-caserun-lib.ts";
import { subjectWorktreeFailureDetails } from "./src/hwlab-caserun-subject.ts";
const NOW = "2026-06-06T00:00:00.000Z";
const SUBJECT_COMMIT = "df7a4e6e551fa90d64bde5537cc000f89d63dd20";
const TEST_RUNTIME_API_URL = "http://api.test";
test("subject worktree failures preserve common Windows operation identity without run-specific constants", () => {
for (const repoLocalPath of ["F:\\Work\\HWLAB-CASE-F103", "F:\\Work\\ConStart"]) {
const plan = {
contractVersion: "hwpod-node-ops-v1",
planId: "case_subject_test",
hwpodId: "d601-f103-v2",
nodeId: "node-d601-f103-v2",
intent: "cmd.run",
ops: [
{ opId: "op_01_subject_commit", op: "cmd.run", args: { workspacePath: repoLocalPath } },
{ opId: "op_02_subject_worktree_add", op: "cmd.run", args: { workspacePath: repoLocalPath } },
{ opId: "op_03_subject_worktree_head", op: "cmd.run", args: { workspacePath: repoLocalPath } }
]
};
const body = {
contractVersion: "hwpod-node-ops-v1",
planId: plan.planId,
hwpodId: plan.hwpodId,
nodeId: plan.nodeId,
status: "blocked",
results: plan.ops.map((op) => ({ opId: op.opId, op: op.op, ok: false, status: "blocked", blocker: { code: "hwpod_node_unavailable", layer: "hwpod-node", retryable: true, summary: "node unavailable token=hwl_live_should_not_leak" } })),
blocker: { code: "hwpod_node_unavailable", layer: "hwpod-node", retryable: true, summary: "node unavailable token=hwl_live_should_not_leak" }
};
const failure = subjectWorktreeFailureDetails(body, plan, { stderr: "" });
assert.equal(failure.partialWrite, false);
assert.equal(failure.blocker.details.dispatched, false);
assert.equal(failure.operation.nodeId, "node-d601-f103-v2");
assert.deepEqual(failure.operation.ops.map((op: any) => op.opId), plan.ops.map((op) => op.opId));
assert.doesNotMatch(failure.errorSummary, /hwl_live_should_not_leak/u);
}
});
test("CaseRun records source root baseline so pre-existing dirty files are not attributed to the run", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-source-root-"));
try {
+45 -2
View File
@@ -119,9 +119,10 @@ export async function requestSubjectWorktree(context: CaseContext, input: { case
const body = parseJsonMaybe(textBody) ?? { raw: textBody };
const status = subjectWorktreeStatus(response.status, body, { commitId: subject.commitId, worktreePath });
if (!status.ok) {
throw cliError("subject_worktree_prepare_failed", "failed to prepare isolated subject worktree on the HWPOD node", clean({ httpStatus: response.status, nodeId: document.spec.nodeBinding.nodeId, repoLocalPath: subject.repoLocalPath, commitId: subject.commitId, worktreePath, exitCode: status.exitCode, stdout: clipText(status.stdout), stderr: clipText(status.stderr), body: compactObject(body) }));
const failure = subjectWorktreeFailureDetails(body, plan, status);
throw cliError("subject_worktree_prepare_failed", "failed to prepare isolated subject worktree on the HWPOD node", clean({ httpStatus: response.status, nodeId: document.spec.nodeBinding.nodeId, repoLocalPath: subject.repoLocalPath, commitId: subject.commitId, worktreePath, exitCode: status.exitCode, partialWrite: failure.partialWrite, dispatched: false, operation: failure.operation, errorSummary: failure.errorSummary, blocker: failure.blocker }));
}
return clean({ httpStatus: response.status, planId: plan.planId, nodeId: document.spec.nodeBinding.nodeId, exitCode: status.exitCode, stdout: clipText(status.stdout), stderr: clipText(status.stderr), keilSidecars: keilSidecarSummary(status.stdout) });
return clean({ httpStatus: response.status, planId: plan.planId, nodeId: document.spec.nodeBinding.nodeId, exitCode: status.exitCode, operation: subjectWorktreeOperationIdentity(body, plan), stdout: clipText(status.stdout), stderr: clipText(status.stderr), keilSidecars: keilSidecarSummary(status.stdout) });
}
export function subjectWorktreeStatus(httpStatus: number, body: any, expected: { commitId: string; worktreePath: string }) {
@@ -136,6 +137,48 @@ export function subjectWorktreeStatus(httpStatus: number, body: any, expected: {
return { ok, exitCode: exitCodes.find((exitCode) => exitCode !== 0) ?? exitCodes[exitCodes.length - 1], stdout, stderr, actualHead, actualWorktreePath: expected.worktreePath };
}
export function subjectWorktreeFailureDetails(body: any, plan: any, status: { stderr?: string }) {
const results = subjectWorktreeResults(body);
const mutatingOpIds = new Set(["op_02_subject_worktree_add", "op_04_keil_sidecars"]);
const partialWrite = results.length > 0 ? results.some((result: any) => mutatingOpIds.has(text(result?.opId)) && result?.ok === true) : null;
const sourceBlocker = body?.blocker ?? results.find((result: any) => result?.blocker)?.blocker ?? body?.error ?? null;
const errorSummary = redactSubjectWorktreeError(text(sourceBlocker?.summary ?? sourceBlocker?.message ?? status.stderr) || "HWPOD subject worktree operation failed without an error summary");
const blocker = clean({
code: text(sourceBlocker?.code) || "subject_worktree_prepare_failed",
layer: text(sourceBlocker?.layer) || "hwpod-node",
retryable: typeof sourceBlocker?.retryable === "boolean" ? sourceBlocker.retryable : true,
summary: errorSummary,
details: clean({ nodeId: plan.nodeId, planId: plan.planId, hwpodId: plan.hwpodId, partialWrite, dispatched: false })
});
return { partialWrite, errorSummary, blocker, operation: subjectWorktreeOperationIdentity(body, plan) };
}
function subjectWorktreeOperationIdentity(body: any, plan: any) {
const results = subjectWorktreeResults(body);
const resultById = new Map(results.map((result: any) => [text(result?.opId), result]));
return clean({
contractVersion: text(body?.contractVersion) || plan.contractVersion,
planId: text(body?.planId) || plan.planId,
hwpodId: text(body?.hwpodId) || plan.hwpodId,
nodeId: text(body?.nodeId) || plan.nodeId,
intent: plan.intent,
ops: plan.ops.map((op: any) => {
const result: any = resultById.get(text(op.opId));
return clean({ opId: op.opId, op: op.op, status: text(result?.status) || null, ok: typeof result?.ok === "boolean" ? result.ok : null, blockerCode: text(result?.blocker?.code) || null });
})
});
}
function subjectWorktreeResults(body: any) {
return Array.isArray(body?.body?.results) ? body.body.results : Array.isArray(body?.results) ? body.results : [];
}
function redactSubjectWorktreeError(value: string) {
return clipText(value)
.replace(/\b(?:hwl_live|sk|ghp)_[A-Za-z0-9_-]+\b/gu, "[REDACTED]")
.replace(/\b(?:authorization|api[_-]?key|token|secret|password)\s*[:=]\s*\S+/giu, "$1=[REDACTED]");
}
export function keilSidecarCopyOp(document: any, subject: { repoLocalPath: string }, hwpodId: string, relativeWorktreePath: string) {
const project = subjectRelativeKeilProject(document, subject.repoLocalPath);
if (!project) return null;