






















@@ -1,4 +1,5 @@
11import fs from "node:fs";
2+import { promises as fsPromises } from "node:fs";
23import path from "node:path";
34import { afterAll, beforeAll, describe, expect, it } from "vitest";
45import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
@@ -9,6 +10,7 @@ import {
910formatConfigOverwriteLogMessage,
1011redactConfigAuditArgv,
1112resolveConfigAuditLogPath,
13+scrubConfigAuditLog,
1214} from "./io.audit.js";
13151416function createAuditRecordBase(configPath: string) {
@@ -475,4 +477,198 @@ describe("config io audit helpers", () => {
475477expect(written.result).toBe("rename");
476478expect(written.nextHash).toBe("next-hash");
477479});
480+481+it("rewrites historical config-audit entries through redactConfigAuditArgv and preserves 0600 mode", async () => {
482+const home = await suiteRootTracker.make("scrub-historical");
483+const auditPath = path.join(home, ".openclaw", "logs", "config-audit.jsonl");
484+fs.mkdirSync(path.dirname(auditPath), { recursive: true, mode: 0o700 });
485+const unredactedRecord = {
486+ts: "2026-05-02T00:03:48.471Z",
487+source: "config-io",
488+event: "config.write",
489+configPath: path.join(home, ".openclaw", "openclaw.json"),
490+pid: 1590563,
491+ppid: 1590548,
492+cwd: home,
493+argv: [
494+"/usr/bin/node",
495+"/usr/local/bin/openclaw.mjs",
496+"config",
497+"set",
498+"channels.slack.botToken",
499+"xoxb-real-bot-token-1234567890abcdef0123456789abcdef",
500+],
501+execArgv: ["--disable-warning=ExperimentalWarning"],
502+suspicious: [],
503+result: "rename",
504+};
505+const alreadyRedactedRecord = {
506+ts: "2026-05-08T12:00:00.000Z",
507+source: "config-io",
508+event: "config.write",
509+configPath: path.join(home, ".openclaw", "openclaw.json"),
510+pid: 1,
511+ppid: 1,
512+cwd: home,
513+argv: ["/usr/bin/node", "/usr/local/bin/openclaw.mjs", "config", "set", "ui.theme", "dark"],
514+execArgv: ["--disable-warning=ExperimentalWarning"],
515+suspicious: [],
516+result: "rename",
517+};
518+fs.writeFileSync(
519+auditPath,
520+`${JSON.stringify(unredactedRecord)}\n${JSON.stringify(alreadyRedactedRecord)}\n`,
521+{ encoding: "utf-8", mode: 0o600 },
522+);
523+524+const env = {} as NodeJS.ProcessEnv;
525+const result = await scrubConfigAuditLog({
526+fs: { promises: fsPromises },
527+ env,
528+homedir: () => home,
529+});
530+531+expect(result).toEqual({ scanned: 2, rewritten: 1, skipped: 0, aborted: false });
532+const after = readAuditLog(home);
533+expect(after).toHaveLength(2);
534+const firstAfter = requireAuditRecord(after[0]);
535+const secondAfter = requireAuditRecord(after[1]);
536+const firstArgv = firstAfter.argv as string[];
537+expect(firstArgv).toHaveLength(unredactedRecord.argv.length);
538+expect(firstArgv.slice(0, 5)).toEqual(unredactedRecord.argv.slice(0, 5));
539+expect(firstArgv[5]).not.toContain("real-bot-token");
540+expect(JSON.stringify(firstAfter)).not.toContain("xoxb-real-bot-token");
541+expect(firstAfter.ts).toBe(unredactedRecord.ts);
542+expect(firstAfter.suspicious).toEqual([]);
543+expect(secondAfter.argv).toEqual(alreadyRedactedRecord.argv);
544+545+const stat = fs.statSync(auditPath);
546+expect(stat.mode & 0o777).toBe(0o600);
547+548+const second = await scrubConfigAuditLog({
549+fs: { promises: fsPromises },
550+ env,
551+homedir: () => home,
552+});
553+expect(second).toEqual({ scanned: 2, rewritten: 0, skipped: 0, aborted: false });
554+});
555+556+it("returns zero counts and does not create the audit file when none exists", async () => {
557+const home = await suiteRootTracker.make("scrub-missing");
558+const result = await scrubConfigAuditLog({
559+fs: { promises: fsPromises },
560+env: {} as NodeJS.ProcessEnv,
561+homedir: () => home,
562+});
563+expect(result).toEqual({ scanned: 0, rewritten: 0, skipped: 0, aborted: false });
564+const auditPath = path.join(home, ".openclaw", "logs", "config-audit.jsonl");
565+expect(fs.existsSync(auditPath)).toBe(false);
566+});
567+568+it("preserves malformed lines verbatim and counts them as skipped", async () => {
569+const home = await suiteRootTracker.make("scrub-malformed");
570+const auditPath = path.join(home, ".openclaw", "logs", "config-audit.jsonl");
571+fs.mkdirSync(path.dirname(auditPath), { recursive: true, mode: 0o700 });
572+const malformed = "{this is not valid json";
573+const validUnredacted = {
574+ts: "2026-05-02T00:03:48.471Z",
575+argv: ["node", "openclaw.mjs", "config", "set", "x", "xoxb-bad-token-1234567890abcdef"],
576+};
577+fs.writeFileSync(auditPath, `${malformed}\n${JSON.stringify(validUnredacted)}\n`, {
578+encoding: "utf-8",
579+mode: 0o600,
580+});
581+582+const result = await scrubConfigAuditLog({
583+fs: { promises: fsPromises },
584+env: {} as NodeJS.ProcessEnv,
585+homedir: () => home,
586+});
587+588+expect(result).toEqual({ scanned: 2, rewritten: 1, skipped: 1, aborted: false });
589+const text = fs.readFileSync(auditPath, "utf-8");
590+expect(text.split("\n")[0]).toBe(malformed);
591+expect(text).not.toContain("xoxb-bad-token");
592+});
593+594+it("does not write when dryRun is true even if records would change", async () => {
595+const home = await suiteRootTracker.make("scrub-dryrun");
596+const auditPath = path.join(home, ".openclaw", "logs", "config-audit.jsonl");
597+fs.mkdirSync(path.dirname(auditPath), { recursive: true, mode: 0o700 });
598+const unredacted = {
599+ts: "2026-05-02T00:03:48.471Z",
600+argv: [
601+"node",
602+"openclaw.mjs",
603+"config",
604+"set",
605+"channels.slack.appToken",
606+"xapp-1-A1B2C3-1234567890-abcdef0123456789abcdef0123456789",
607+],
608+execArgv: [],
609+};
610+const original = `${JSON.stringify(unredacted)}\n`;
611+fs.writeFileSync(auditPath, original, { encoding: "utf-8", mode: 0o600 });
612+613+const result = await scrubConfigAuditLog({
614+fs: { promises: fsPromises },
615+env: {} as NodeJS.ProcessEnv,
616+homedir: () => home,
617+dryRun: true,
618+});
619+620+expect(result).toEqual({ scanned: 1, rewritten: 1, skipped: 0, aborted: false });
621+const text = fs.readFileSync(auditPath, "utf-8");
622+expect(text).toBe(original);
623+expect(text).toContain("xapp-1-A1B2C3");
624+});
625+626+it("aborts without overwriting when the audit log was appended to mid-scrub", async () => {
627+const home = await suiteRootTracker.make("scrub-race-abort");
628+const auditPath = path.join(home, ".openclaw", "logs", "config-audit.jsonl");
629+fs.mkdirSync(path.dirname(auditPath), { recursive: true, mode: 0o700 });
630+const unredacted = {
631+ts: "2026-05-02T00:03:48.471Z",
632+argv: [
633+"node",
634+"openclaw.mjs",
635+"config",
636+"set",
637+"channels.slack.botToken",
638+"xoxb-real-bot-token-1234567890abcdef0123456789abcdef",
639+],
640+execArgv: [],
641+};
642+const original = `${JSON.stringify(unredacted)}\n`;
643+fs.writeFileSync(auditPath, original, { encoding: "utf-8", mode: 0o600 });
644+645+// Mock fs whose .stat() reports a larger size than what readFile returns,
646+// simulating an appendConfigAuditRecord call that fired after the initial
647+// read but before the rename. The scrub should refuse to rename and leave
648+// the file untouched.
649+const raceFs = {
650+promises: {
651+readFile: fsPromises.readFile,
652+stat: async (p: string) => {
653+const realStat = await fsPromises.stat(p);
654+return { size: realStat.size + 200 };
655+},
656+writeFile: fsPromises.writeFile,
657+rename: fsPromises.rename,
658+unlink: fsPromises.unlink,
659+},
660+};
661+const result = await scrubConfigAuditLog({
662+fs: raceFs,
663+env: {} as NodeJS.ProcessEnv,
664+homedir: () => home,
665+});
666+667+expect(result.aborted).toBe(true);
668+expect(result.rewritten).toBeGreaterThan(0);
669+const after = fs.readFileSync(auditPath, "utf-8");
670+expect(after).toBe(original);
671+expect(after).toContain("xoxb-real-bot-token");
672+expect(fs.existsSync(`${auditPath}.scrub.tmp`)).toBe(false);
673+});
478674});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。