244 lines
11 KiB
TypeScript
244 lines
11 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import { basename, dirname, relative, resolve } from "node:path";
|
|
|
|
import { resolveCliChildJsonCommandResult } from "./cli-child-json-recovery";
|
|
import { rootPath } from "./config";
|
|
import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane } from "./hwlab-node-lanes";
|
|
import { isSafeWebProbeScriptArtifactPath, runTransWorkspaceStdinScript } from "./hwlab-node/public-exposure";
|
|
import { compactCommandResultRedacted, shellQuote } from "./hwlab-node/utils";
|
|
import { CliInputError } from "./output";
|
|
|
|
interface WebProbeArtifactInspectOptions {
|
|
readonly node: string;
|
|
readonly lane: string;
|
|
readonly path: string;
|
|
readonly sha256: string;
|
|
}
|
|
|
|
export function parseWebProbeArtifactInspectOptions(args: string[]): WebProbeArtifactInspectOptions {
|
|
if (args[0] !== "inspect") throw artifactInputError("web-probe artifact 需要 inspect action");
|
|
const values = new Map<string, string>();
|
|
const known = new Set(["--node", "--lane", "--path", "--sha256"]);
|
|
for (let index = 1; index < args.length; index += 1) {
|
|
const item = args[index];
|
|
const equals = item.indexOf("=");
|
|
const key = equals > 0 ? item.slice(0, equals) : item;
|
|
if (!known.has(key)) throw artifactInputError(`web-probe artifact inspect 不支持参数 ${item}`, item);
|
|
const value = equals > 0 ? item.slice(equals + 1) : args[++index];
|
|
if (value === undefined || value.startsWith("--")) throw artifactInputError(`${key} 需要一个值`, key);
|
|
if (values.has(key)) throw artifactInputError(`${key} 不能重复`, key);
|
|
values.set(key, value);
|
|
}
|
|
const node = required(values, "--node");
|
|
const lane = required(values, "--lane");
|
|
const path = required(values, "--path");
|
|
const sha256 = required(values, "--sha256");
|
|
if (!/^[A-Za-z0-9_.-]+$/u.test(node)) throw artifactInputError("--node 只能包含字母、数字、点、下划线和连字符", "--node");
|
|
if (!isHwlabRuntimeLane(lane)) throw artifactInputError(`未知 --lane ${lane}`, "--lane");
|
|
if (!isSafeWebProbeScriptArtifactPath(path) && !isSafeDownloadedWebProbeArtifactPath(path)) {
|
|
throw artifactInputError("--path 不是受控 web-probe script artifact 路径", "--path");
|
|
}
|
|
if (!/^sha256:[a-f0-9]{64}$/u.test(sha256)) throw artifactInputError("--sha256 必须是 sha256:<64位小写十六进制>", "--sha256");
|
|
return { node, lane, path, sha256 };
|
|
}
|
|
|
|
export function runWebProbeArtifactInspect(args: string[]): Record<string, unknown> {
|
|
const options = parseWebProbeArtifactInspectOptions(args);
|
|
if (isSafeDownloadedWebProbeArtifactPath(options.path)) return inspectDownloadedWebProbeArtifact(options);
|
|
const spec = hwlabRuntimeLaneSpecForNode(options.lane, options.node);
|
|
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, artifactInspectScript(options), 30);
|
|
const resolution = resolveCliChildJsonCommandResult({
|
|
result,
|
|
requestedStdoutType: "bounded web-probe artifact inspection JSON",
|
|
acceptParsed: (value) => typeof value.ok === "boolean" && typeof value.sha256 === "string" && typeof value.path === "string",
|
|
});
|
|
const artifact = resolution.parsed;
|
|
return {
|
|
ok: result.exitCode === 0 && artifact?.ok === true,
|
|
status: result.exitCode === 0 && artifact?.ok === true ? "inspected" : "blocked",
|
|
command: "web-probe artifact inspect",
|
|
node: options.node,
|
|
lane: options.lane,
|
|
artifact,
|
|
stdoutRecovery: resolution.diagnostics,
|
|
result: compactCommandResultRedacted(result, []),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function inspectDownloadedWebProbeArtifact(options: WebProbeArtifactInspectOptions): Record<string, unknown> {
|
|
const bytes = readFileSync(rootPath(options.path));
|
|
const sha256 = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
const verified = sha256 === options.sha256;
|
|
const report = options.path.endsWith(".json") ? JSON.parse(bytes.toString("utf8")) as unknown : null;
|
|
return {
|
|
ok: verified,
|
|
status: verified ? "inspected" : "blocked",
|
|
command: "web-probe artifact inspect",
|
|
node: options.node,
|
|
lane: options.lane,
|
|
artifact: {
|
|
ok: verified,
|
|
kind: report === null ? "screenshot" : "json",
|
|
path: options.path,
|
|
byteCount: bytes.length,
|
|
sha256,
|
|
expectedSha256: options.sha256,
|
|
verified,
|
|
evidence: report === null ? null : compactDownloadedReport(report),
|
|
valuesRedacted: true,
|
|
},
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function compactDownloadedReport(value: unknown): Record<string, unknown> {
|
|
const report = objectValue(value);
|
|
const summary = objectValue(report.summary);
|
|
return {
|
|
ok: report.ok === true,
|
|
status: typeof report.status === "string" ? report.status : null,
|
|
failureKind: typeof report.failureKind === "string" ? report.failureKind : null,
|
|
auth: compactArtifactValue(report.auth),
|
|
summary: {
|
|
ok: summary.ok === true,
|
|
status: summary.status ?? null,
|
|
degradedReason: summary.degradedReason ?? null,
|
|
failureKind: summary.failureKind ?? null,
|
|
failedCondition: compactArtifactValue(summary.failedCondition),
|
|
nextAction: compactArtifactValue(summary.nextAction),
|
|
baseUrl: summary.baseUrl ?? null,
|
|
finalUrl: summary.finalUrl ?? null,
|
|
reportPath: summary.reportPath ?? null,
|
|
reportSha256: summary.reportSha256 ?? null,
|
|
lastStep: compactArtifactValue(summary.lastStep),
|
|
issueEvidence: compactIssueEvidence(summary.issueEvidence),
|
|
},
|
|
issueEvidence: compactIssueEvidence(report.issueEvidence),
|
|
failureEvidence: compactArtifactValue(report.failureEvidence),
|
|
lastStep: compactArtifactValue(report.lastStep ?? summary.lastStep),
|
|
steps: compactArtifactValue(report.steps),
|
|
artifacts: compactArtifactValue(report.artifacts),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function compactIssueEvidence(value: unknown): unknown {
|
|
const issue = objectValue(value);
|
|
const result = objectValue(issue.result);
|
|
if (!("pageErrors" in result || "consoleErrors" in result || "requestFailures" in result || "responseErrors" in result)) {
|
|
return compactArtifactValue(value);
|
|
}
|
|
return {
|
|
ok: issue.ok ?? result.ok ?? null,
|
|
status: issue.status ?? result.status ?? null,
|
|
profileName: result.profileName ?? null,
|
|
configRef: result.configRef ?? null,
|
|
failureCount: result.failureCount ?? null,
|
|
failures: compactArtifactValue(result.failures),
|
|
pageErrorCount: result.pageErrorCount ?? null,
|
|
pageErrors: compactArtifactValue(result.pageErrors),
|
|
consoleErrorCount: result.consoleErrorCount ?? null,
|
|
consoleErrors: compactArtifactValue(result.consoleErrors),
|
|
requestFailureCount: result.requestFailureCount ?? null,
|
|
requestFailures: compactArtifactValue(result.requestFailures),
|
|
responseErrorCount: result.responseErrorCount ?? null,
|
|
responseErrors: compactArtifactValue(result.responseErrors),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function compactArtifactValue(value: unknown, depth = 0): unknown {
|
|
if (value === null || value === undefined) return value ?? null;
|
|
if (typeof value === "string") return value.replace(/\s+/gu, " ").trim().slice(0, 300);
|
|
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
if (depth >= 5) return "[max-depth]";
|
|
if (Array.isArray(value)) return value.slice(0, 4).map((item) => compactArtifactValue(item, depth + 1));
|
|
if (typeof value === "object") {
|
|
const output: Record<string, unknown> = {};
|
|
for (const [key, nested] of Object.entries(value as Record<string, unknown>).slice(0, 24)) {
|
|
output[key] = compactArtifactValue(nested, depth + 1);
|
|
}
|
|
return output;
|
|
}
|
|
return String(value).slice(0, 300);
|
|
}
|
|
|
|
function objectValue(value: unknown): Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
function isSafeDownloadedWebProbeArtifactPath(path: string): boolean {
|
|
const root = resolve(rootPath(".state/web-probe-script-artifacts"));
|
|
const candidate = resolve(rootPath(path));
|
|
const relativePath = relative(root, candidate);
|
|
return dirname(relativePath) === "."
|
|
&& basename(relativePath) === relativePath
|
|
&& /^[A-Za-z0-9_.-]+[.](?:png|json)$/u.test(relativePath);
|
|
}
|
|
|
|
export function artifactInspectScript(options: WebProbeArtifactInspectOptions): string {
|
|
return [
|
|
"set -eu",
|
|
`node - ${shellQuote(options.path)} ${shellQuote(options.sha256)} <<'NODE'`,
|
|
"const fs = require('fs');",
|
|
"const { createHash } = require('crypto');",
|
|
"const path = process.argv[2];",
|
|
"const expectedSha256 = process.argv[3];",
|
|
"const bytes = fs.readFileSync(path);",
|
|
"const sha256 = 'sha256:' + createHash('sha256').update(bytes).digest('hex');",
|
|
"const compact = (value, depth = 0) => {",
|
|
" if (value === null || value === undefined) return value ?? null;",
|
|
" if (typeof value === 'string') return value.replace(/\\s+/gu, ' ').trim().slice(0, 600);",
|
|
" if (typeof value === 'number' || typeof value === 'boolean') return value;",
|
|
" if (depth >= 6) return '[max-depth]';",
|
|
" if (Array.isArray(value)) return value.slice(-8).map((item) => compact(item, depth + 1));",
|
|
" if (typeof value === 'object') {",
|
|
" const out = {};",
|
|
" for (const [key, nested] of Object.entries(value).slice(0, 32)) out[key] = compact(nested, depth + 1);",
|
|
" return out;",
|
|
" }",
|
|
" return String(value).slice(0, 600);",
|
|
"};",
|
|
"let evidence = null;",
|
|
"if (path.endsWith('.json')) {",
|
|
" const report = JSON.parse(bytes.toString('utf8'));",
|
|
" evidence = {",
|
|
" ok: report.ok === true,",
|
|
" status: typeof report.status === 'string' ? report.status : null,",
|
|
" failureKind: typeof report.failureKind === 'string' ? report.failureKind : null,",
|
|
" auth: compact(report.auth),",
|
|
" summary: compact(report.summary),",
|
|
" issueEvidence: compact(report.issueEvidence),",
|
|
" failureEvidence: compact(report.failureEvidence),",
|
|
" lastStep: compact(report.lastStep ?? report.summary?.lastStep),",
|
|
" steps: compact(report.steps),",
|
|
" artifacts: compact(report.artifacts),",
|
|
" valuesRedacted: true,",
|
|
" };",
|
|
"}",
|
|
"const ok = sha256 === expectedSha256;",
|
|
"console.log(JSON.stringify({ ok, kind: path.endsWith('.json') ? 'json' : 'screenshot', path, byteCount: bytes.length, sha256, expectedSha256, verified: ok, evidence, valuesRedacted: true }));",
|
|
"if (!ok) process.exitCode = 3;",
|
|
"NODE",
|
|
].join("\n");
|
|
}
|
|
|
|
function required(values: Map<string, string>, key: string): string {
|
|
const value = values.get(key);
|
|
if (value === undefined || value.length === 0) throw artifactInputError(`${key} 是必填参数`, key);
|
|
return value;
|
|
}
|
|
|
|
function artifactInputError(message: string, argument?: string): CliInputError {
|
|
return new CliInputError(message, {
|
|
code: "web-probe-artifact-input-invalid",
|
|
reason: message,
|
|
mutation: false,
|
|
argument,
|
|
usage: "bun scripts/cli.ts web-probe artifact inspect --node NODE --lane LANE --path PATH --sha256 sha256:<digest>",
|
|
hint: "仅使用 product-smoke 输出的受控 artifact 路径和对应 SHA。",
|
|
});
|
|
}
|