Log redacted OpenClaw outbound payload details

This commit is contained in:
Dotta
2026-03-06 10:38:16 -06:00
parent 2ec2dcf9c6
commit 1179d7e75a
2 changed files with 114 additions and 0 deletions

View File

@@ -1,5 +1,6 @@
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils";
import { createHash } from "node:crypto";
import { parseOpenClawResponse } from "./parse.js";
type SessionKeyStrategy = "fixed" | "issue" | "run";
@@ -75,6 +76,62 @@ function toStringRecord(value: unknown): Record<string, string> {
return out;
}
const SENSITIVE_LOG_KEY_PATTERN =
/(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-auth$/i;
function isSensitiveLogKey(key: string): boolean {
return SENSITIVE_LOG_KEY_PATTERN.test(key.trim());
}
function sha256Prefix(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 12);
}
function redactSecretForLog(value: string): string {
return `[redacted len=${value.length} sha256=${sha256Prefix(value)}]`;
}
function truncateForLog(value: string, maxChars = 320): string {
if (value.length <= maxChars) return value;
return `${value.slice(0, maxChars)}... [truncated ${value.length - maxChars} chars]`;
}
function redactForLog(value: unknown, keyPath: string[] = [], depth = 0): unknown {
const currentKey = keyPath[keyPath.length - 1] ?? "";
if (typeof value === "string") {
if (isSensitiveLogKey(currentKey)) return redactSecretForLog(value);
return truncateForLog(value);
}
if (typeof value === "number" || typeof value === "boolean" || value == null) {
return value;
}
if (Array.isArray(value)) {
if (depth >= 6) return "[array-truncated]";
const out = value.slice(0, 20).map((entry, index) => redactForLog(entry, [...keyPath, `${index}`], depth + 1));
if (value.length > 20) out.push(`[+${value.length - 20} more items]`);
return out;
}
if (typeof value === "object") {
if (depth >= 6) return "[object-truncated]";
const entries = Object.entries(value as Record<string, unknown>);
const out: Record<string, unknown> = {};
for (const [key, entry] of entries.slice(0, 80)) {
out[key] = redactForLog(entry, [...keyPath, key], depth + 1);
}
if (entries.length > 80) {
out.__truncated__ = `+${entries.length - 80} keys`;
}
return out;
}
return String(value);
}
function stringifyForLog(value: unknown, maxChars: number): string {
const text = JSON.stringify(value);
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`;
}
type WakePayload = {
runId: string;
agentId: string;
@@ -610,6 +667,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
const outboundHeaderKeys = Array.from(new Set([...Object.keys(headers), "accept"])).sort();
await onLog(
"stdout",
`[openclaw] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`,
);
await onLog(
"stdout",
`[openclaw] outbound payload (redacted): ${stringifyForLog(redactForLog(paperclipBody), 12_000)}\n`,
);
await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`);
await onLog("stdout", `[openclaw] invoking ${method} ${url} (transport=sse)\n`);

View File

@@ -256,6 +256,55 @@ describe("openclaw adapter execute", () => {
).toBe(true);
});
it("logs outbound payload with sensitive fields redacted", async () => {
const fetchMock = vi.fn().mockResolvedValue(
sseResponse([
"event: response.completed\n",
'data: {"type":"response.completed","status":"completed"}\n\n',
]),
);
vi.stubGlobal("fetch", fetchMock);
const logs: string[] = [];
const result = await execute(
buildContext(
{
url: "https://agent.example/sse",
method: "POST",
headers: {
"x-openclaw-auth": "gateway-token",
},
payloadTemplate: {
text: "task prompt",
nested: {
token: "secret-token",
visible: "keep-me",
},
},
},
{
onLog: async (_stream, chunk) => {
logs.push(chunk);
},
},
),
);
expect(result.exitCode).toBe(0);
const headerLog = logs.find((line) => line.includes("[openclaw] outbound headers (redacted):"));
expect(headerLog).toBeDefined();
expect(headerLog).toContain("\"x-openclaw-auth\":\"[redacted");
expect(headerLog).toContain("\"authorization\":\"[redacted");
expect(headerLog).not.toContain("gateway-token");
const payloadLog = logs.find((line) => line.includes("[openclaw] outbound payload (redacted):"));
expect(payloadLog).toBeDefined();
expect(payloadLog).toContain("\"token\":\"[redacted");
expect(payloadLog).not.toContain("secret-token");
expect(payloadLog).toContain("\"visible\":\"keep-me\"");
});
it("derives Authorization header from x-openclaw-auth when webhookAuthHeader is unset", async () => {
const fetchMock = vi.fn().mockResolvedValue(
sseResponse([