mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix(config): add forensic config write audit and watch attribution
This commit is contained in:
@@ -3,6 +3,7 @@ import Foundation
|
||||
|
||||
enum OpenClawConfigFile {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "config")
|
||||
private static let configAuditFileName = "config-audit.jsonl"
|
||||
|
||||
static func url() -> URL {
|
||||
OpenClawPaths.configURL
|
||||
@@ -35,15 +36,61 @@ enum OpenClawConfigFile {
|
||||
static func saveDict(_ dict: [String: Any]) {
|
||||
// Nix mode disables config writes in production, but tests rely on saving temp configs.
|
||||
if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return }
|
||||
let url = self.url()
|
||||
let previousData = try? Data(contentsOf: url)
|
||||
let previousRoot = previousData.flatMap { self.parseConfigData($0) }
|
||||
let previousBytes = previousData?.count
|
||||
let hadMetaBefore = self.hasMeta(previousRoot)
|
||||
let gatewayModeBefore = self.gatewayMode(previousRoot)
|
||||
|
||||
var output = dict
|
||||
self.stampMeta(&output)
|
||||
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||
let url = self.url()
|
||||
let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys])
|
||||
try FileManager().createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
let nextBytes = data.count
|
||||
let gatewayModeAfter = self.gatewayMode(output)
|
||||
let suspicious = self.configWriteSuspiciousReasons(
|
||||
existsBefore: previousData != nil,
|
||||
previousBytes: previousBytes,
|
||||
nextBytes: nextBytes,
|
||||
hadMetaBefore: hadMetaBefore,
|
||||
gatewayModeBefore: gatewayModeBefore,
|
||||
gatewayModeAfter: gatewayModeAfter)
|
||||
if !suspicious.isEmpty {
|
||||
self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)")
|
||||
}
|
||||
self.appendConfigWriteAudit([
|
||||
"result": "success",
|
||||
"configPath": url.path,
|
||||
"existsBefore": previousData != nil,
|
||||
"previousBytes": previousBytes ?? NSNull(),
|
||||
"nextBytes": nextBytes,
|
||||
"hasMetaBefore": hadMetaBefore,
|
||||
"hasMetaAfter": self.hasMeta(output),
|
||||
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
|
||||
"gatewayModeAfter": gatewayModeAfter ?? NSNull(),
|
||||
"suspicious": suspicious,
|
||||
])
|
||||
} catch {
|
||||
self.logger.error("config save failed: \(error.localizedDescription)")
|
||||
self.appendConfigWriteAudit([
|
||||
"result": "failed",
|
||||
"configPath": url.path,
|
||||
"existsBefore": previousData != nil,
|
||||
"previousBytes": previousBytes ?? NSNull(),
|
||||
"nextBytes": NSNull(),
|
||||
"hasMetaBefore": hadMetaBefore,
|
||||
"hasMetaAfter": self.hasMeta(output),
|
||||
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
|
||||
"gatewayModeAfter": self.gatewayMode(output) ?? NSNull(),
|
||||
"suspicious": [],
|
||||
"error": error.localizedDescription,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,4 +261,100 @@ enum OpenClawConfigFile {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func stampMeta(_ root: inout [String: Any]) {
|
||||
var meta = root["meta"] as? [String: Any] ?? [:]
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "macos-app"
|
||||
meta["lastTouchedVersion"] = version
|
||||
meta["lastTouchedAt"] = ISO8601DateFormatter().string(from: Date())
|
||||
root["meta"] = meta
|
||||
}
|
||||
|
||||
private static func hasMeta(_ root: [String: Any]?) -> Bool {
|
||||
guard let root else { return false }
|
||||
return root["meta"] is [String: Any]
|
||||
}
|
||||
|
||||
private static func hasMeta(_ root: [String: Any]) -> Bool {
|
||||
root["meta"] is [String: Any]
|
||||
}
|
||||
|
||||
private static func gatewayMode(_ root: [String: Any]?) -> String? {
|
||||
guard let root else { return nil }
|
||||
return self.gatewayMode(root)
|
||||
}
|
||||
|
||||
private static func gatewayMode(_ root: [String: Any]) -> String? {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let mode = gateway["mode"] as? String
|
||||
else { return nil }
|
||||
let trimmed = mode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func configWriteSuspiciousReasons(
|
||||
existsBefore: Bool,
|
||||
previousBytes: Int?,
|
||||
nextBytes: Int,
|
||||
hadMetaBefore: Bool,
|
||||
gatewayModeBefore: String?,
|
||||
gatewayModeAfter: String?) -> [String]
|
||||
{
|
||||
var reasons: [String] = []
|
||||
if !existsBefore {
|
||||
return reasons
|
||||
}
|
||||
if let previousBytes, previousBytes >= 512, nextBytes < max(1, previousBytes / 2) {
|
||||
reasons.append("size-drop:\(previousBytes)->\(nextBytes)")
|
||||
}
|
||||
if !hadMetaBefore {
|
||||
reasons.append("missing-meta-before-write")
|
||||
}
|
||||
if gatewayModeBefore != nil, gatewayModeAfter == nil {
|
||||
reasons.append("gateway-mode-removed")
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
private static func configAuditLogURL() -> URL {
|
||||
self.stateDirURL()
|
||||
.appendingPathComponent("logs", isDirectory: true)
|
||||
.appendingPathComponent(self.configAuditFileName, isDirectory: false)
|
||||
}
|
||||
|
||||
private static func appendConfigWriteAudit(_ fields: [String: Any]) {
|
||||
var record: [String: Any] = [
|
||||
"ts": ISO8601DateFormatter().string(from: Date()),
|
||||
"source": "macos-openclaw-config-file",
|
||||
"event": "config.write",
|
||||
"pid": ProcessInfo.processInfo.processIdentifier,
|
||||
"argv": Array(ProcessInfo.processInfo.arguments.prefix(8)),
|
||||
]
|
||||
for (key, value) in fields {
|
||||
record[key] = value is NSNull ? NSNull() : value
|
||||
}
|
||||
guard JSONSerialization.isValidJSONObject(record),
|
||||
let data = try? JSONSerialization.data(withJSONObject: record)
|
||||
else {
|
||||
return
|
||||
}
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A)
|
||||
let logURL = self.configAuditLogURL()
|
||||
do {
|
||||
try FileManager().createDirectory(
|
||||
at: logURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
if !FileManager().fileExists(atPath: logURL.path) {
|
||||
FileManager().createFile(atPath: logURL.path, contents: nil)
|
||||
}
|
||||
let handle = try FileHandle(forWritingTo: logURL)
|
||||
defer { try? handle.close() }
|
||||
try handle.seekToEnd()
|
||||
try handle.write(contentsOf: line)
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,4 +76,43 @@ struct OpenClawConfigFileTests {
|
||||
#expect(OpenClawConfigFile.url().path == "\(dir)/openclaw.json")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func saveDictAppendsConfigAuditLog() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
"OPENCLAW_CONFIG_PATH": configPath.path,
|
||||
]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": ["mode": "local"],
|
||||
])
|
||||
|
||||
let configData = try Data(contentsOf: configPath)
|
||||
let configRoot = try JSONSerialization.jsonObject(with: configData) as? [String: Any]
|
||||
#expect((configRoot?["meta"] as? [String: Any]) != nil)
|
||||
|
||||
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
|
||||
let lines = rawAudit
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map(String.init)
|
||||
#expect(!lines.isEmpty)
|
||||
guard let last = lines.last else {
|
||||
Issue.record("Missing config audit line")
|
||||
return
|
||||
}
|
||||
let auditRoot = try JSONSerialization.jsonObject(with: Data(last.utf8)) as? [String: Any]
|
||||
#expect(auditRoot?["source"] as? String == "macos-openclaw-config-file")
|
||||
#expect(auditRoot?["event"] as? String == "config.write")
|
||||
#expect(auditRoot?["result"] as? String == "success")
|
||||
#expect(auditRoot?["configPath"] as? String == configPath.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ const args = process.argv.slice(2);
|
||||
const env = { ...process.env };
|
||||
const cwd = process.cwd();
|
||||
const compiler = "tsdown";
|
||||
const watchSession = `${Date.now()}-${process.pid}`;
|
||||
env.OPENCLAW_WATCH_MODE = "1";
|
||||
env.OPENCLAW_WATCH_SESSION = watchSession;
|
||||
if (args.length > 0) {
|
||||
env.OPENCLAW_WATCH_COMMAND = args.join(" ");
|
||||
}
|
||||
|
||||
const initialBuild = spawnSync("pnpm", ["exec", compiler], {
|
||||
cwd,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Command } from "commander";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { GatewayAuthMode } from "../../config/config.js";
|
||||
import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js";
|
||||
import {
|
||||
CONFIG_PATH,
|
||||
loadConfig,
|
||||
readConfigFileSnapshot,
|
||||
resolveStateDir,
|
||||
resolveGatewayPort,
|
||||
} from "../../config/config.js";
|
||||
import { resolveGatewayAuth } from "../../gateway/auth.js";
|
||||
@@ -160,6 +162,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
|
||||
|
||||
const snapshot = await readConfigFileSnapshot().catch(() => null);
|
||||
const configExists = snapshot?.exists ?? fs.existsSync(CONFIG_PATH);
|
||||
const configAuditPath = path.join(resolveStateDir(process.env), "logs", "config-audit.jsonl");
|
||||
const mode = cfg.gateway?.mode;
|
||||
if (!opts.allowUnconfigured && mode !== "local") {
|
||||
if (!configExists) {
|
||||
@@ -170,6 +173,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
|
||||
defaultRuntime.error(
|
||||
`Gateway start blocked: set gateway.mode=local (current: ${mode ?? "unset"}) or pass --allow-unconfigured.`,
|
||||
);
|
||||
defaultRuntime.error(`Config write audit: ${configAuditPath}`);
|
||||
}
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
|
||||
235
src/config/io.ts
235
src/config/io.ts
@@ -68,8 +68,40 @@ const SHELL_ENV_EXPECTED_KEYS = [
|
||||
];
|
||||
|
||||
const CONFIG_BACKUP_COUNT = 5;
|
||||
const CONFIG_AUDIT_LOG_FILENAME = "config-audit.jsonl";
|
||||
const loggedInvalidConfigs = new Set<string>();
|
||||
|
||||
type ConfigWriteAuditResult = "rename" | "copy-fallback" | "failed";
|
||||
|
||||
type ConfigWriteAuditRecord = {
|
||||
ts: string;
|
||||
source: "config-io";
|
||||
event: "config.write";
|
||||
result: ConfigWriteAuditResult;
|
||||
configPath: string;
|
||||
pid: number;
|
||||
ppid: number;
|
||||
cwd: string;
|
||||
argv: string[];
|
||||
execArgv: string[];
|
||||
watchMode: boolean;
|
||||
watchSession: string | null;
|
||||
watchCommand: string | null;
|
||||
existsBefore: boolean;
|
||||
previousHash: string | null;
|
||||
nextHash: string | null;
|
||||
previousBytes: number | null;
|
||||
nextBytes: number | null;
|
||||
changedPathCount: number | null;
|
||||
hasMetaBefore: boolean;
|
||||
hasMetaAfter: boolean;
|
||||
gatewayModeBefore: string | null;
|
||||
gatewayModeAfter: string | null;
|
||||
suspicious: string[];
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string };
|
||||
export type ConfigWriteOptions = {
|
||||
/**
|
||||
@@ -123,6 +155,26 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasConfigMeta(value: unknown): boolean {
|
||||
if (!isPlainObject(value)) {
|
||||
return false;
|
||||
}
|
||||
const meta = value.meta;
|
||||
return isPlainObject(meta);
|
||||
}
|
||||
|
||||
function resolveGatewayMode(value: unknown): string | null {
|
||||
if (!isPlainObject(value)) {
|
||||
return null;
|
||||
}
|
||||
const gateway = value.gateway;
|
||||
if (!isPlainObject(gateway) || typeof gateway.mode !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = gateway.mode.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function cloneUnknown<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
@@ -307,6 +359,55 @@ async function rotateConfigBackups(configPath: string, ioFs: typeof fs.promises)
|
||||
});
|
||||
}
|
||||
|
||||
function resolveConfigAuditLogPath(env: NodeJS.ProcessEnv, homedir: () => string): string {
|
||||
return path.join(resolveStateDir(env, homedir), "logs", CONFIG_AUDIT_LOG_FILENAME);
|
||||
}
|
||||
|
||||
function resolveConfigWriteSuspiciousReasons(params: {
|
||||
existsBefore: boolean;
|
||||
previousBytes: number | null;
|
||||
nextBytes: number | null;
|
||||
hasMetaBefore: boolean;
|
||||
gatewayModeBefore: string | null;
|
||||
gatewayModeAfter: string | null;
|
||||
}): string[] {
|
||||
const reasons: string[] = [];
|
||||
if (!params.existsBefore) {
|
||||
return reasons;
|
||||
}
|
||||
if (
|
||||
typeof params.previousBytes === "number" &&
|
||||
typeof params.nextBytes === "number" &&
|
||||
params.previousBytes >= 512 &&
|
||||
params.nextBytes < Math.floor(params.previousBytes * 0.5)
|
||||
) {
|
||||
reasons.push(`size-drop:${params.previousBytes}->${params.nextBytes}`);
|
||||
}
|
||||
if (!params.hasMetaBefore) {
|
||||
reasons.push("missing-meta-before-write");
|
||||
}
|
||||
if (params.gatewayModeBefore && !params.gatewayModeAfter) {
|
||||
reasons.push("gateway-mode-removed");
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
async function appendConfigWriteAuditRecord(
|
||||
deps: Required<ConfigIoDeps>,
|
||||
record: ConfigWriteAuditRecord,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const auditPath = resolveConfigAuditLogPath(deps.env, deps.homedir);
|
||||
await deps.fs.promises.mkdir(path.dirname(auditPath), { recursive: true, mode: 0o700 });
|
||||
await deps.fs.promises.appendFile(auditPath, `${JSON.stringify(record)}\n`, {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export type ConfigIoDeps = {
|
||||
fs?: typeof fs;
|
||||
json5?: typeof JSON5;
|
||||
@@ -822,10 +923,26 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
: cfgToWrite;
|
||||
// Do NOT apply runtime defaults when writing — user config should only contain
|
||||
// explicitly set values. Runtime defaults are applied when loading (issue #6070).
|
||||
const json = JSON.stringify(stampConfigVersion(outputConfig), null, 2).trimEnd().concat("\n");
|
||||
const stampedOutputConfig = stampConfigVersion(outputConfig);
|
||||
const json = JSON.stringify(stampedOutputConfig, null, 2).trimEnd().concat("\n");
|
||||
const nextHash = hashConfigRaw(json);
|
||||
const previousHash = resolveConfigSnapshotHash(snapshot);
|
||||
const changedPathCount = changedPaths?.size;
|
||||
const previousBytes =
|
||||
typeof snapshot.raw === "string" ? Buffer.byteLength(snapshot.raw, "utf-8") : null;
|
||||
const nextBytes = Buffer.byteLength(json, "utf-8");
|
||||
const hasMetaBefore = hasConfigMeta(snapshot.parsed);
|
||||
const hasMetaAfter = hasConfigMeta(stampedOutputConfig);
|
||||
const gatewayModeBefore = resolveGatewayMode(snapshot.resolved);
|
||||
const gatewayModeAfter = resolveGatewayMode(stampedOutputConfig);
|
||||
const suspiciousReasons = resolveConfigWriteSuspiciousReasons({
|
||||
existsBefore: snapshot.exists,
|
||||
previousBytes,
|
||||
nextBytes,
|
||||
hasMetaBefore,
|
||||
gatewayModeBefore,
|
||||
gatewayModeAfter,
|
||||
});
|
||||
const logConfigOverwrite = () => {
|
||||
if (!snapshot.exists) {
|
||||
return;
|
||||
@@ -841,46 +958,112 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
`Config overwrite: ${configPath} (sha256 ${previousHash ?? "unknown"} -> ${nextHash}, backup=${configPath}.bak${changeSummary})`,
|
||||
);
|
||||
};
|
||||
const logConfigWriteAnomalies = () => {
|
||||
if (suspiciousReasons.length === 0) {
|
||||
return;
|
||||
}
|
||||
deps.logger.warn(`Config write anomaly: ${configPath} (${suspiciousReasons.join(", ")})`);
|
||||
};
|
||||
const auditRecordBase = {
|
||||
ts: new Date().toISOString(),
|
||||
source: "config-io" as const,
|
||||
event: "config.write" as const,
|
||||
configPath,
|
||||
pid: process.pid,
|
||||
ppid: process.ppid,
|
||||
cwd: process.cwd(),
|
||||
argv: process.argv.slice(0, 8),
|
||||
execArgv: process.execArgv.slice(0, 8),
|
||||
watchMode: deps.env.OPENCLAW_WATCH_MODE === "1",
|
||||
watchSession:
|
||||
typeof deps.env.OPENCLAW_WATCH_SESSION === "string" &&
|
||||
deps.env.OPENCLAW_WATCH_SESSION.trim().length > 0
|
||||
? deps.env.OPENCLAW_WATCH_SESSION.trim()
|
||||
: null,
|
||||
watchCommand:
|
||||
typeof deps.env.OPENCLAW_WATCH_COMMAND === "string" &&
|
||||
deps.env.OPENCLAW_WATCH_COMMAND.trim().length > 0
|
||||
? deps.env.OPENCLAW_WATCH_COMMAND.trim()
|
||||
: null,
|
||||
existsBefore: snapshot.exists,
|
||||
previousHash: previousHash ?? null,
|
||||
nextHash,
|
||||
previousBytes,
|
||||
nextBytes,
|
||||
changedPathCount: typeof changedPathCount === "number" ? changedPathCount : null,
|
||||
hasMetaBefore,
|
||||
hasMetaAfter,
|
||||
gatewayModeBefore,
|
||||
gatewayModeAfter,
|
||||
suspicious: suspiciousReasons,
|
||||
};
|
||||
const appendWriteAudit = async (result: ConfigWriteAuditResult, err?: unknown) => {
|
||||
const errorCode =
|
||||
err && typeof err === "object" && "code" in err && typeof err.code === "string"
|
||||
? err.code
|
||||
: undefined;
|
||||
const errorMessage =
|
||||
err && typeof err === "object" && "message" in err && typeof err.message === "string"
|
||||
? err.message
|
||||
: undefined;
|
||||
await appendConfigWriteAuditRecord(deps, {
|
||||
...auditRecordBase,
|
||||
result,
|
||||
nextHash: result === "failed" ? null : auditRecordBase.nextHash,
|
||||
nextBytes: result === "failed" ? null : auditRecordBase.nextBytes,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
});
|
||||
};
|
||||
|
||||
const tmp = path.join(
|
||||
dir,
|
||||
`${path.basename(configPath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
|
||||
);
|
||||
|
||||
await deps.fs.promises.writeFile(tmp, json, {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
if (deps.fs.existsSync(configPath)) {
|
||||
await rotateConfigBackups(configPath, deps.fs.promises);
|
||||
await deps.fs.promises.copyFile(configPath, `${configPath}.bak`).catch(() => {
|
||||
// best-effort
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.fs.promises.rename(tmp, configPath);
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
// Windows doesn't reliably support atomic replace via rename when dest exists.
|
||||
if (code === "EPERM" || code === "EEXIST") {
|
||||
await deps.fs.promises.copyFile(tmp, configPath);
|
||||
await deps.fs.promises.chmod(configPath, 0o600).catch(() => {
|
||||
await deps.fs.promises.writeFile(tmp, json, {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
if (deps.fs.existsSync(configPath)) {
|
||||
await rotateConfigBackups(configPath, deps.fs.promises);
|
||||
await deps.fs.promises.copyFile(configPath, `${configPath}.bak`).catch(() => {
|
||||
// best-effort
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.fs.promises.rename(tmp, configPath);
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
// Windows doesn't reliably support atomic replace via rename when dest exists.
|
||||
if (code === "EPERM" || code === "EEXIST") {
|
||||
await deps.fs.promises.copyFile(tmp, configPath);
|
||||
await deps.fs.promises.chmod(configPath, 0o600).catch(() => {
|
||||
// best-effort
|
||||
});
|
||||
await deps.fs.promises.unlink(tmp).catch(() => {
|
||||
// best-effort
|
||||
});
|
||||
logConfigOverwrite();
|
||||
logConfigWriteAnomalies();
|
||||
await appendWriteAudit("copy-fallback");
|
||||
return;
|
||||
}
|
||||
await deps.fs.promises.unlink(tmp).catch(() => {
|
||||
// best-effort
|
||||
});
|
||||
logConfigOverwrite();
|
||||
return;
|
||||
throw err;
|
||||
}
|
||||
await deps.fs.promises.unlink(tmp).catch(() => {
|
||||
// best-effort
|
||||
});
|
||||
logConfigOverwrite();
|
||||
logConfigWriteAnomalies();
|
||||
await appendWriteAudit("rename");
|
||||
} catch (err) {
|
||||
await appendWriteAudit("failed", err);
|
||||
throw err;
|
||||
}
|
||||
logConfigOverwrite();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -311,4 +311,91 @@ describe("config io write", () => {
|
||||
expect(overwriteLogs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("appends config write audit JSONL entries with forensic metadata", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
const auditPath = path.join(home, ".openclaw", "logs", "config-audit.jsonl");
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ gateway: { port: 18789 } }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const io = createConfigIO({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
homedir: () => home,
|
||||
logger: {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const snapshot = await io.readConfigFileSnapshot();
|
||||
expect(snapshot.valid).toBe(true);
|
||||
|
||||
const next = structuredClone(snapshot.config);
|
||||
next.gateway = {
|
||||
...next.gateway,
|
||||
mode: "local",
|
||||
};
|
||||
|
||||
await io.writeConfigFile(next);
|
||||
|
||||
const lines = (await fs.readFile(auditPath, "utf-8")).trim().split("\n").filter(Boolean);
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
const last = JSON.parse(lines.at(-1) ?? "{}") as Record<string, unknown>;
|
||||
expect(last.source).toBe("config-io");
|
||||
expect(last.event).toBe("config.write");
|
||||
expect(last.configPath).toBe(configPath);
|
||||
expect(last.existsBefore).toBe(true);
|
||||
expect(last.hasMetaAfter).toBe(true);
|
||||
expect(last.previousHash).toBeTypeOf("string");
|
||||
expect(last.nextHash).toBeTypeOf("string");
|
||||
expect(last.result === "rename" || last.result === "copy-fallback").toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("records gateway watch session markers in config audit entries", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
const auditPath = path.join(home, ".openclaw", "logs", "config-audit.jsonl");
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ gateway: { mode: "local" } }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const io = createConfigIO({
|
||||
env: {
|
||||
OPENCLAW_WATCH_MODE: "1",
|
||||
OPENCLAW_WATCH_SESSION: "watch-session-1",
|
||||
OPENCLAW_WATCH_COMMAND: "gateway --force",
|
||||
} as NodeJS.ProcessEnv,
|
||||
homedir: () => home,
|
||||
logger: {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const snapshot = await io.readConfigFileSnapshot();
|
||||
expect(snapshot.valid).toBe(true);
|
||||
const next = structuredClone(snapshot.config);
|
||||
next.gateway = {
|
||||
...next.gateway,
|
||||
bind: "loopback",
|
||||
};
|
||||
|
||||
await io.writeConfigFile(next);
|
||||
|
||||
const lines = (await fs.readFile(auditPath, "utf-8")).trim().split("\n").filter(Boolean);
|
||||
const last = JSON.parse(lines.at(-1) ?? "{}") as Record<string, unknown>;
|
||||
expect(last.watchMode).toBe(true);
|
||||
expect(last.watchSession).toBe("watch-session-1");
|
||||
expect(last.watchCommand).toBe("gateway --force");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user