From 5b60491e59232da71fd43b45780c6d60aaa18841 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 21:19:40 +0200 Subject: [PATCH] fix: authenticate CaseRun HWPOD prepare --- internal/cloud/server-caserun-http.test.ts | 19 +++++++++ internal/cloud/server-caserun-http.ts | 16 +++++++- tools/hwlab-caserun.test.ts | 34 ++++++++++++++++ tools/src/hwlab-caserun-subject.ts | 47 +++++++++++++++++++++- 4 files changed, 113 insertions(+), 3 deletions(-) diff --git a/internal/cloud/server-caserun-http.test.ts b/internal/cloud/server-caserun-http.test.ts index 7dbd9cd9..55973e74 100644 --- a/internal/cloud/server-caserun-http.test.ts +++ b/internal/cloud/server-caserun-http.test.ts @@ -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-")); diff --git a/internal/cloud/server-caserun-http.ts b/internal/cloud/server-caserun-http.ts index a4596e69..959904b4 100644 --- a/internal/cloud/server-caserun-http.ts +++ b/internal/cloud/server-caserun-http.ts @@ -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) { + 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) { return path.resolve(String(env.HWLAB_CASERUN_REPO_ROOT ?? process.cwd())); } diff --git a/tools/hwlab-caserun.test.ts b/tools/hwlab-caserun.test.ts index 1b063ab5..6f361e8d 100644 --- a/tools/hwlab-caserun.test.ts +++ b/tools/hwlab-caserun.test.ts @@ -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 { diff --git a/tools/src/hwlab-caserun-subject.ts b/tools/src/hwlab-caserun-subject.ts index cdd34560..5617edfe 100644 --- a/tools/src/hwlab-caserun-subject.ts +++ b/tools/src/hwlab-caserun-subject.ts @@ -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;