test(qa): redact script evidence diagnostics (#99629)

This commit is contained in:
Dallin Romney
2026-07-03 15:42:33 -07:00
committed by GitHub
parent 219f27a5ec
commit 0cd936cc77
6 changed files with 148 additions and 45 deletions
+23
View File
@@ -0,0 +1,23 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
type LoggingConfig = OpenClawConfig["logging"];
type InternalLoggingConfig = NonNullable<LoggingConfig> & {
[fullContextToolPayloadRedaction]: true;
};
const fullContextToolPayloadRedaction = Symbol("full-context-tool-payload-redaction");
export function withFullContextToolPayloadRedaction(
loggingConfig: LoggingConfig,
): InternalLoggingConfig {
return {
...loggingConfig,
[fullContextToolPayloadRedaction]: true,
};
}
export function isFullContextToolPayloadRedaction(loggingConfig: LoggingConfig): boolean {
return Boolean(
(loggingConfig as InternalLoggingConfig | undefined)?.[fullContextToolPayloadRedaction],
);
}
+13 -4
View File
@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { compileConfigRegex } from "../security/config-regex.js";
import { readLoggingConfig } from "./config.js";
import { replacePatternBounded } from "./redact-bounded.js";
import { isFullContextToolPayloadRedaction } from "./redact-internal.js";
export type RedactSensitiveMode = "off" | "tools";
export type RedactPattern = string | RegExp;
@@ -850,7 +851,7 @@ function redactMatch(
function redactText(
text: string,
patterns: RegExp[],
options?: { redactFormBodies?: boolean },
options?: { fullContext?: boolean; redactFormBodies?: boolean },
): string {
let next = text;
if (options?.redactFormBodies) {
@@ -873,9 +874,10 @@ function redactText(
const input = typeof args[inputIndex] === "string" ? args[inputIndex] : "";
return redactMatch(match, groups, pattern, { input, offset });
};
next = chunkUnsafePatterns.has(pattern)
? next.replace(pattern, replacer)
: replacePatternBounded(next, pattern, replacer);
next =
options?.fullContext || chunkUnsafePatterns.has(pattern)
? next.replace(pattern, replacer)
: replacePatternBounded(next, pattern, replacer);
}
return next;
}
@@ -1028,6 +1030,13 @@ export function redactToolPayloadTextWithConfig(
if (!text) {
return text;
}
if (isFullContextToolPayloadRedaction(loggingConfig)) {
const resolved = resolveRedactOptions(resolveToolPayloadRedaction(loggingConfig));
return redactText(text, resolved.patterns, {
fullContext: true,
redactFormBodies: resolved.redactFormBodies,
});
}
return redactSensitiveText(text, resolveToolPayloadRedaction(loggingConfig));
}
@@ -7,7 +7,6 @@ import {
type QaEvidenceSummaryJson,
} from "../../../../extensions/qa-lab/api.js";
import { spawnPnpmRunner as _spawnPnpmRunner } from "../../../../scripts/pnpm-runner.mjs";
import { createBoundedChildOutput } from "../../../helpers/bounded-child-output.js";
import {
createQaScriptBlockedStatusTracker,
createQaScriptEvidenceWriter,
@@ -751,28 +750,19 @@ async function runHostedMediaProof(
env: command.env,
stdio: ["ignore", "pipe", "pipe"],
});
const stdout = createBoundedChildOutput();
const stderr = createBoundedChildOutput();
const statusTracker = createQaScriptBlockedStatusTracker(HOSTED_MEDIA_BLOCKED_PATTERNS);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout.append(chunk);
writer.appendLog(chunk);
statusTracker.append(chunk);
});
child.stderr.on("data", (chunk: string) => {
stderr.append(chunk);
writer.appendLog(chunk);
statusTracker.append(chunk);
});
child.on("error", reject);
child.on("close", (status, signal) => {
const stdoutText = stdout.text();
const stderrText = stderr.text();
const output = [
stdoutText ? `\n--- stdout ---\n${stdoutText}` : "",
stderrText ? `\n--- stderr ---\n${stderrText}` : "",
].join("");
writer.appendLog(output);
const durationMs = Math.max(1, Date.now() - startedAt);
if (status === 0 && !signal) {
resolve({
@@ -785,9 +775,8 @@ async function runHostedMediaProof(
const details = signal
? `${options.suiteId} hosted media live suite terminated by ${signal}`
: `${options.suiteId} hosted media live suite exited with ${status ?? 1}`;
const combined = `${details}\n${stderrText || stdoutText}`;
resolve({
details: combined,
details,
durationMs,
status: statusTracker.status(),
});
@@ -253,44 +253,36 @@ async function runParallelsProof(params: {
}
params.writer.appendLog(`$ bash ${args.map((arg) => JSON.stringify(arg)).join(" ")}\n`);
return await new Promise<{ stderr: string; stdout: string }>((resolve, reject) => {
return await new Promise<string>((resolve, reject) => {
const child = spawn("bash", args, {
cwd: params.options.repoRoot,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
const stdout = createBoundedChildOutput(1024 * 1024);
const stderr = createBoundedChildOutput();
const statusTracker = createQaScriptBlockedStatusTracker(CLAWHUB_BLOCKED_PREREQUISITE_PATTERNS);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
params.writer.appendLog(chunk);
stdout.append(chunk);
statusTracker.append(chunk);
});
child.stderr.on("data", (chunk: string) => {
stderr.append(chunk);
params.writer.appendLog(chunk);
statusTracker.append(chunk);
});
child.on("error", reject);
child.on("close", (status, signal) => {
const stdoutText = stdout.text();
const stderrText = stderr.text();
const output = [
stdoutText ? `\n--- stdout ---\n${stdoutText}` : "",
stderrText ? `\n--- stderr ---\n${stderrText}` : "",
].join("");
params.writer.appendLog(output);
if (status === 0 && !signal) {
resolve({ stderr: stderrText, stdout: stdoutText });
resolve(stdoutText);
return;
}
const reason = signal
? `Parallels npm-update proof terminated by ${signal}`
: `Parallels npm-update proof exited with ${status ?? 1}`;
reject(
new ParallelsProofError(`${reason}\n${stderrText || stdoutText}`, statusTracker.status()),
);
reject(new ParallelsProofError(reason, statusTracker.status()));
});
});
}
@@ -410,8 +402,8 @@ async function produceProof(
writer.appendLog(
`candidate: ${tarballPath}\nversion: ${metadata.version}\nbuild commit: ${metadata.buildCommit}\n`,
);
const commandResult = await runParallelsProof({ options, tarballPath, writer });
const summary = parseParallelsSummary(commandResult.stdout);
const commandOutput = await runParallelsProof({ options, tarballPath, writer });
const summary = parseParallelsSummary(commandOutput);
assertParallelsSummary({
summary,
tarballPath,
@@ -2,7 +2,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { validateQaEvidenceSummaryJson } from "../../../../extensions/qa-lab/api.js";
import {
createQaScriptBlockedStatusTracker,
@@ -11,11 +11,14 @@ import {
const tempRoots: string[] = [];
async function makeWriter(params: { maxDetailsBytes?: number; maxLogBytes?: number } = {}) {
async function makeWriter(
params: { maxDetailsBytes?: number; maxLogBytes?: number } = {},
) {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-script-evidence-"));
tempRoots.push(repoRoot);
return {
artifactBase: path.join(repoRoot, ".artifacts", "qa-e2e", "script"),
repoRoot,
writer: createQaScriptEvidenceWriter({
artifactBase: path.join(repoRoot, ".artifacts", "qa-e2e", "script"),
logFileName: "producer.log",
@@ -35,6 +38,7 @@ async function makeWriter(params: { maxDetailsBytes?: number; maxLogBytes?: numb
}
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
);
@@ -128,6 +132,47 @@ describe("QA script evidence writer", () => {
expect(Buffer.byteLength(reason, "utf8")).toBeLessThanOrEqual(9);
});
it("applies built-in and configured redaction before bounding logs and details", async () => {
const { artifactBase, repoRoot, writer } = await makeWriter({
maxDetailsBytes: 4096,
maxLogBytes: 4096,
});
const configPath = path.join(repoRoot, "openclaw.json");
await fs.writeFile(
configPath,
`${JSON.stringify({ logging: { redactPatterns: ["/internal-\\d+/g"] } })}\n`,
"utf8",
);
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
writer.appendLog(`${"x".repeat(16_380)}inter`);
writer.appendLog("nal-12345 should hide password=s");
writer.appendLog("k-split-secret-1234567890");
const evidence = await writer.write({
details: "reason internal-67890 should hide",
durationMs: 1,
status: "fail",
});
const log = await fs.readFile(path.join(artifactBase, "producer.log"), "utf8");
expect(log).toContain("*** should hide password=sk-spl…7890");
expect(log).not.toContain("internal-12345");
expect(evidence.entries[0]?.result.failure?.reason).toBe("reason *** should hide");
});
it("omits oversized raw logs instead of truncating before redaction", async () => {
const secretTail = "private-secret-tail-1234567890";
const { artifactBase, writer } = await makeWriter({ maxLogBytes: 128 });
writer.appendLog(`password=${"x".repeat(40 * 1024)}${secretTail}`);
await writer.write({ durationMs: 1, status: "fail" });
const log = await fs.readFile(path.join(artifactBase, "producer.log"), "utf8");
expect(log).toBe("QA evidence log omitted: safe redaction buffer exceeded.\n");
expect(log).not.toContain(secretTail);
expect(Buffer.byteLength(log, "utf8")).toBeLessThanOrEqual(128);
});
it.each(["..", "../outside.log"])(
"rejects artifact path %s outside the producer output directory",
async (filePath) => {
+55 -10
View File
@@ -9,13 +9,16 @@ import {
type QaEvidenceSummaryJson,
type QaProviderMode,
} from "../../../../extensions/qa-lab/api.js";
import {
createBoundedChildOutput,
DEFAULT_CHILD_OUTPUT_TAIL_BYTES,
} from "../../../helpers/bounded-child-output.js";
import { readLoggingConfig } from "../../../../src/logging/config.js";
import { withFullContextToolPayloadRedaction } from "../../../../src/logging/redact-internal.js";
import { redactToolPayloadTextWithConfig } from "../../../../src/logging/redact.js";
import { DEFAULT_CHILD_OUTPUT_TAIL_BYTES } from "../../../helpers/bounded-child-output.js";
export const DEFAULT_QA_SCRIPT_EVIDENCE_DETAILS_BYTES = 32 * 1024;
const QA_SCRIPT_STATUS_MATCH_CARRY_CHARS = 1024;
const QA_SCRIPT_LOG_OVERFLOW_MESSAGE = "QA evidence log omitted: safe redaction buffer exceeded.\n";
const QA_SCRIPT_DETAILS_OVERFLOW_MESSAGE =
"QA evidence details omitted: safe redaction buffer exceeded.";
type QaScriptEvidenceArtifactInput = {
filePath: string;
@@ -90,6 +93,37 @@ function utf8Tail(text: string, maxBytes: number) {
return buffer.subarray(start).toString("utf8");
}
function createSafeRedactionBuffer(maxBytes: number) {
const chunks: Buffer[] = [];
let totalBytes = 0;
let overflowed = false;
return {
append(chunk: string) {
if (overflowed) {
return;
}
const buffer = Buffer.from(chunk);
if (totalBytes + buffer.byteLength > maxBytes) {
// Arbitrary configured patterns need the complete raw context. Omit an
// oversized log instead of truncating before redaction and leaking a tail.
chunks.length = 0;
totalBytes = 0;
overflowed = true;
return;
}
chunks.push(buffer);
totalBytes += buffer.byteLength;
},
overflowed() {
return overflowed;
},
text() {
return Buffer.concat(chunks, totalBytes).toString("utf8");
},
};
}
export function createQaScriptBlockedStatusTracker(blockedPatterns: readonly RegExp[]) {
let blocked = false;
let carry = "";
@@ -115,21 +149,32 @@ export function createQaScriptBlockedStatusTracker(blockedPatterns: readonly Reg
export function createQaScriptEvidenceWriter(options: QaScriptEvidenceWriterOptions) {
const maxLogBytes = resolveByteLimit(options.maxLogBytes, DEFAULT_CHILD_OUTPUT_TAIL_BYTES);
const log = createBoundedChildOutput(maxLogBytes);
const logFile = resolveArtifactPath(options.artifactBase, options.logFileName);
const maxDetailsBytes = resolveByteLimit(
options.maxDetailsBytes,
DEFAULT_QA_SCRIPT_EVIDENCE_DETAILS_BYTES,
);
const boundedLogText = () => utf8Tail(log.text(), maxLogBytes);
const log = createSafeRedactionBuffer(maxLogBytes + DEFAULT_QA_SCRIPT_EVIDENCE_DETAILS_BYTES);
const redact = (text: string) =>
redactToolPayloadTextWithConfig(text, withFullContextToolPayloadRedaction(readLoggingConfig()));
const boundedLogText = () => {
if (log.overflowed()) {
return utf8Tail(QA_SCRIPT_LOG_OVERFLOW_MESSAGE, maxLogBytes);
}
return utf8Tail(redact(log.text()), maxLogBytes);
};
const boundedDetails = (details: string | undefined) => {
if (!details) {
return undefined;
}
const output = createBoundedChildOutput(maxDetailsBytes);
output.append(details);
return utf8Tail(output.text(), maxDetailsBytes);
if (
Buffer.byteLength(details, "utf8") >
maxDetailsBytes + DEFAULT_QA_SCRIPT_EVIDENCE_DETAILS_BYTES
) {
return utf8Tail(QA_SCRIPT_DETAILS_OVERFLOW_MESSAGE, maxDetailsBytes);
}
return utf8Tail(redact(details), maxDetailsBytes);
};
const normalizeArtifacts = (artifacts: readonly QaScriptEvidenceArtifactInput[] = []) => {
@@ -171,7 +216,7 @@ export function createQaScriptEvidenceWriter(options: QaScriptEvidenceWriterOpti
return {
appendLog(chunk: unknown) {
log.append(chunk);
log.append(String(chunk));
},
build,
logText() {