mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
test(qa): redact script evidence diagnostics (#99629)
This commit is contained in:
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user