diff --git a/src/logging/diagnostic-support-export.test.ts b/src/logging/diagnostic-support-export.test.ts index ae5efe11596..d7d5bd9d16d 100644 --- a/src/logging/diagnostic-support-export.test.ts +++ b/src/logging/diagnostic-support-export.test.ts @@ -14,7 +14,11 @@ import { stopDiagnosticStabilityRecorder, } from "./diagnostic-stability.js"; import { writeDiagnosticSupportExport } from "./diagnostic-support-export.js"; -import { redactTextForSupport } from "./diagnostic-support-redaction.js"; +import { + redactSupportString, + redactTextForSupport, + sanitizeSupportSnapshotValue, +} from "./diagnostic-support-redaction.js"; import type { LogTailPayload } from "./log-tail.js"; async function readZipTextEntries(file: string): Promise> { @@ -410,6 +414,47 @@ describe("diagnostic support export", () => { } }); + it("redacts Windows USERPROFILE paths when HOME is unset", () => { + const userProfile = "C:\\Users\\support-user"; + const stateDir = `${userProfile}\\AppData\\Roaming\\openclaw`; + const redaction = { + env: { + USERPROFILE: userProfile, + OPENCLAW_STATE_DIR: stateDir, + }, + stateDir, + }; + + expect(redactSupportString(`${stateDir}\\logs\\gateway.log`, redaction)).toBe( + "$OPENCLAW_STATE_DIR\\logs\\gateway.log", + ); + expect( + redactSupportString(`failed at ${userProfile}\\Documents\\snapshot-error.txt`, redaction), + ).toBe("failed at ~\\Documents\\snapshot-error.txt"); + + const status = sanitizeSupportSnapshotValue( + { + service: { + command: { + programArguments: [ + "node", + `${userProfile}\\openclaw\\dist\\index.js`, + "--config", + `${stateDir}\\openclaw.json`, + ], + sourcePath: `${userProfile}\\AppData\\Local\\openclaw\\gateway-service.json`, + }, + }, + }, + redaction, + ); + const serialized = JSON.stringify(status); + expect(serialized).not.toContain("support-user"); + expect(serialized).toContain("~\\\\openclaw\\\\dist\\\\index.js"); + expect(serialized).toContain("$OPENCLAW_STATE_DIR\\\\openclaw.json"); + expect(serialized).toContain("~\\\\AppData\\\\Local\\\\openclaw\\\\gateway-service.json"); + }); + it("keeps writing when status and health snapshots fail", async () => { const fakeToken = "sk-test-support-export-secret-token-1234567890"; const outputPath = path.join(tempDir, "support-failed-snapshots.zip"); diff --git a/src/logging/diagnostic-support-redaction.ts b/src/logging/diagnostic-support-redaction.ts index 13814ba7161..4072b6a3556 100644 --- a/src/logging/diagnostic-support-redaction.ts +++ b/src/logging/diagnostic-support-redaction.ts @@ -40,6 +40,11 @@ type RedactSupportStringOptions = { truncationSuffix?: string; }; +type PathRedactionPrefix = { + prefix: string; + label: string; +}; + function asRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; @@ -76,31 +81,75 @@ function privateMapEntryLabel(key: string): string { return normalized.endsWith("s") ? normalized.slice(0, -1) : normalized; } -function pathRedactionPrefixes(options: SupportRedactionContext): Array<{ - prefix: string; - label: string; -}> { - const home = options.env.HOME ? path.resolve(options.env.HOME) : undefined; - return [ - { prefix: path.resolve(options.stateDir), label: "$OPENCLAW_STATE_DIR" }, - ...(home ? [{ prefix: home, label: "~" }] : []), - ].toSorted((a, b) => b.prefix.length - a.prefix.length); +function isWindowsAbsolutePath(value: string): boolean { + return /^(?:[A-Za-z]:[\\/]|\\\\)/u.test(value); +} + +function normalizePathPrefix(value: string): string { + return isWindowsAbsolutePath(value) ? path.win32.resolve(value) : path.resolve(value); +} + +function addPathPrefixVariants( + prefixes: Map, + value: string | undefined, + label: string, +): void { + if (!value) { + return; + } + const normalized = normalizePathPrefix(value); + if (!prefixes.has(normalized)) { + prefixes.set(normalized, label); + } + if (isWindowsAbsolutePath(normalized)) { + const forwardSlashPrefix = normalized.replaceAll("\\", "/"); + if (!prefixes.has(forwardSlashPrefix)) { + prefixes.set(forwardSlashPrefix, label); + } + } +} + +function pathRedactionPrefixes(options: SupportRedactionContext): PathRedactionPrefix[] { + const prefixes = new Map(); + addPathPrefixVariants(prefixes, options.stateDir, "$OPENCLAW_STATE_DIR"); + addPathPrefixVariants(prefixes, options.env.HOME, "~"); + addPathPrefixVariants(prefixes, options.env.USERPROFILE, "~"); + return [...prefixes.entries()] + .map(([prefix, label]) => ({ prefix, label })) + .toSorted((a, b) => b.prefix.length - a.prefix.length); +} + +function pathCandidates(file: string): string[] { + return isWindowsAbsolutePath(file) + ? [path.win32.resolve(file), path.win32.resolve(file).replaceAll("\\", "/")] + : [path.resolve(file)]; +} + +function matchPathPrefix(file: string, prefix: string): string | undefined { + if (file === prefix) { + return ""; + } + const next = file[prefix.length]; + return next === "/" || next === "\\" ? file.slice(prefix.length) : undefined; +} + +function isSupportAbsolutePath(value: string): boolean { + return path.isAbsolute(value) || isWindowsAbsolutePath(value); } export function redactPathForSupport(file: string, options: SupportRedactionContext): string { if (file.startsWith("$")) { return file; } - const next = path.resolve(file); - for (const { prefix, label } of pathRedactionPrefixes(options)) { - if (next === prefix) { - return label; - } - if (next.startsWith(`${prefix}${path.sep}`)) { - return `${label}${next.slice(prefix.length)}`; + for (const next of pathCandidates(file)) { + for (const { prefix, label } of pathRedactionPrefixes(options)) { + const suffix = matchPathPrefix(next, prefix); + if (suffix !== undefined) { + return `${label}${suffix}`; + } } } - return redactSensitiveTextForSupport(next); + return redactSensitiveTextForSupport(pathCandidates(file)[0] ?? file); } function redactKnownPathPrefixesForSupport( @@ -168,7 +217,7 @@ export function redactSupportString( const maxLength = options.maxLength ?? MAX_SUPPORT_STRING_LENGTH; const truncationSuffix = options.truncationSuffix ?? DEFAULT_TRUNCATION_SUFFIX; const redacted = redactTextForSupport(value); - const pathRedacted = path.isAbsolute(redacted) + const pathRedacted = isSupportAbsolutePath(redacted) ? redactPathForSupport(redacted, redaction) : redactKnownPathPrefixesForSupport(redacted, redaction); if (pathRedacted.length <= maxLength) {