test: split config io coverage

This commit is contained in:
Peter Steinberger
2026-04-07 08:12:54 +01:00
parent b3afb2f950
commit adededf2f9
10 changed files with 1424 additions and 1390 deletions

View File

@@ -1,148 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, it, expect } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import {
createConfigIO,
readConfigFileSnapshotForWrite,
writeConfigFile as writeConfigFileViaWrapper,
} from "./io.js";
async function withTempConfig(
configContent: string,
run: (configPath: string) => Promise<void>,
): Promise<void> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-env-io-"));
const configPath = path.join(dir, "openclaw.json");
await fs.writeFile(configPath, configContent);
try {
await run(configPath);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
async function withWrapperEnvContext(configPath: string, run: () => Promise<void>): Promise<void> {
await withEnvAsync(
{
OPENCLAW_CONFIG_PATH: configPath,
MY_API_KEY: "original-key-123",
},
run,
);
}
function createGatewayTokenConfigJson(): string {
return JSON.stringify({ gateway: { remote: { token: "${MY_API_KEY}" } } }, null, 2);
}
function createMutableApiKeyEnv(initialValue = "original-key-123"): Record<string, string> {
return { MY_API_KEY: initialValue };
}
async function withGatewayTokenTempConfig(
run: (configPath: string) => Promise<void>,
): Promise<void> {
await withTempConfig(createGatewayTokenConfigJson(), run);
}
async function withWrapperGatewayTokenContext(
run: (configPath: string) => Promise<void>,
): Promise<void> {
await withGatewayTokenTempConfig(async (configPath) => {
await withWrapperEnvContext(configPath, async () => run(configPath));
});
}
async function readGatewayToken(configPath: string): Promise<string> {
const written = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(written) as { gateway: { remote: { token: string } } };
return parsed.gateway.remote.token;
}
describe("env snapshot TOCTOU via createConfigIO", () => {
it("restores env refs using read-time env even after env mutation", async () => {
const env = createMutableApiKeyEnv();
await withGatewayTokenTempConfig(async (configPath) => {
// Instance A: read config (captures env snapshot)
const ioA = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv });
const firstRead = await ioA.readConfigFileSnapshotForWrite();
expect(firstRead.snapshot.config.gateway?.remote?.token).toBe("original-key-123");
// Mutate env between read and write
env.MY_API_KEY = "mutated-key-456";
// Instance B: write config using explicit read context from A
const ioB = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv });
// Write the resolved config back — should restore ${MY_API_KEY}
await ioB.writeConfigFile(firstRead.snapshot.config, firstRead.writeOptions);
// Verify the written file still has ${MY_API_KEY}, not the resolved value
const written = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(written);
expect(parsed.gateway.remote.token).toBe("${MY_API_KEY}");
});
});
it("without snapshot bridging, mutated env causes incorrect restoration", async () => {
const env = createMutableApiKeyEnv();
await withGatewayTokenTempConfig(async (configPath) => {
// Instance A: read config
const ioA = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv });
const snapshot = await ioA.readConfigFileSnapshot();
// Mutate env
env.MY_API_KEY = "mutated-key-456";
// Instance B: write WITHOUT snapshot bridging (simulates the old bug)
const ioB = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv });
// No explicit writeOptions — ioB uses live env
await ioB.writeConfigFile(snapshot.config);
// The written file should have the raw value because the live env
// no longer matches — restoreEnvVarRefs won't find a match
const written = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(written);
// Without snapshot, the resolved value "original-key-123" doesn't match
// live env "mutated-key-456", so restoration fails — value is written as-is
expect(parsed.gateway.remote.token).toBe("original-key-123");
});
});
});
describe("env snapshot TOCTOU via wrapper APIs", () => {
it("uses explicit read context even if another read interleaves", async () => {
await withWrapperGatewayTokenContext(async (configPath) => {
const firstRead = await readConfigFileSnapshotForWrite();
expect(firstRead.snapshot.config.gateway?.remote?.token).toBe("original-key-123");
// Interleaving read from another request context with a different env value.
process.env.MY_API_KEY = "mutated-key-456";
const secondRead = await readConfigFileSnapshotForWrite();
expect(secondRead.snapshot.config.gateway?.remote?.token).toBe("mutated-key-456");
// Write using the first read's explicit context.
await writeConfigFileViaWrapper(firstRead.snapshot.config, firstRead.writeOptions);
expect(await readGatewayToken(configPath)).toBe("${MY_API_KEY}");
});
});
it("ignores read context when expected config path does not match", async () => {
await withWrapperGatewayTokenContext(async (configPath) => {
const firstRead = await readConfigFileSnapshotForWrite();
expect(firstRead.snapshot.config.gateway?.remote?.token).toBe("original-key-123");
expect(firstRead.writeOptions.expectedConfigPath).toBe(configPath);
process.env.MY_API_KEY = "mutated-key-456";
await writeConfigFileViaWrapper(firstRead.snapshot.config, {
...firstRead.writeOptions,
expectedConfigPath: `${configPath}.different`,
});
expect(await readGatewayToken(configPath)).toBe("original-key-123");
});
});
});

204
src/config/io.audit.test.ts Normal file
View File

@@ -0,0 +1,204 @@
import fs from "node:fs";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import {
appendConfigAuditRecord,
createConfigWriteAuditRecordBase,
finalizeConfigWriteAuditRecord,
formatConfigOverwriteLogMessage,
resolveConfigAuditLogPath,
} from "./io.audit.js";
describe("config io audit helpers", () => {
const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-config-audit-" });
beforeAll(async () => {
await suiteRootTracker.setup();
});
afterAll(async () => {
await suiteRootTracker.cleanup();
});
it('ignores literal "undefined" home env values when choosing the audit log path', async () => {
const home = await suiteRootTracker.make("home");
const auditPath = resolveConfigAuditLogPath(
{
HOME: "undefined",
USERPROFILE: "null",
OPENCLAW_HOME: "undefined",
} as NodeJS.ProcessEnv,
() => home,
);
expect(auditPath).toBe(path.join(home, ".openclaw", "logs", "config-audit.jsonl"));
expect(auditPath.startsWith(path.resolve("undefined"))).toBe(false);
});
it("formats overwrite warnings with hash transition and backup path", () => {
expect(
formatConfigOverwriteLogMessage({
configPath: "/tmp/openclaw.json",
previousHash: "prev-hash",
nextHash: "next-hash",
changedPathCount: 3,
}),
).toBe(
"Config overwrite: /tmp/openclaw.json (sha256 prev-hash -> next-hash, backup=/tmp/openclaw.json.bak, changedPaths=3)",
);
});
it("captures watch markers and next stat metadata for successful writes", () => {
const base = createConfigWriteAuditRecordBase({
configPath: "/tmp/openclaw.json",
env: {
OPENCLAW_WATCH_MODE: "1",
OPENCLAW_WATCH_SESSION: "watch-session-1",
OPENCLAW_WATCH_COMMAND: "gateway --force",
} as NodeJS.ProcessEnv,
existsBefore: true,
previousHash: "prev-hash",
nextHash: "next-hash",
previousBytes: 12,
nextBytes: 24,
previousMetadata: {
dev: "10",
ino: "11",
mode: 0o600,
nlink: 1,
uid: 501,
gid: 20,
},
changedPathCount: 2,
hasMetaBefore: false,
hasMetaAfter: true,
gatewayModeBefore: null,
gatewayModeAfter: "local",
suspicious: ["missing-meta-before-write"],
now: "2026-04-07T08:00:00.000Z",
processInfo: {
pid: 101,
ppid: 99,
cwd: "/work",
argv: ["node", "openclaw"],
execArgv: ["--loader"],
},
});
const record = finalizeConfigWriteAuditRecord({
base,
result: "rename",
nextMetadata: {
dev: "12",
ino: "13",
mode: 0o600,
nlink: 1,
uid: 501,
gid: 20,
},
});
expect(record.watchMode).toBe(true);
expect(record.watchSession).toBe("watch-session-1");
expect(record.watchCommand).toBe("gateway --force");
expect(record.nextHash).toBe("next-hash");
expect(record.nextBytes).toBe(24);
expect(record.nextDev).toBe("12");
expect(record.nextIno).toBe("13");
expect(record.result).toBe("rename");
});
it("drops next-file metadata and preserves error details for failed writes", () => {
const base = createConfigWriteAuditRecordBase({
configPath: "/tmp/openclaw.json",
env: {} as NodeJS.ProcessEnv,
existsBefore: true,
previousHash: "prev-hash",
nextHash: "next-hash",
previousBytes: 12,
nextBytes: 24,
previousMetadata: {
dev: "10",
ino: "11",
mode: 0o600,
nlink: 1,
uid: 501,
gid: 20,
},
changedPathCount: 1,
hasMetaBefore: true,
hasMetaAfter: true,
gatewayModeBefore: "local",
gatewayModeAfter: "local",
suspicious: [],
now: "2026-04-07T08:00:00.000Z",
});
const err = Object.assign(new Error("disk full"), { code: "ENOSPC" });
const record = finalizeConfigWriteAuditRecord({
base,
result: "failed",
err,
});
expect(record.result).toBe("failed");
expect(record.nextHash).toBeNull();
expect(record.nextBytes).toBeNull();
expect(record.nextDev).toBeNull();
expect(record.errorCode).toBe("ENOSPC");
expect(record.errorMessage).toBe("disk full");
});
it("appends JSONL audit entries to the resolved audit path", async () => {
const home = await suiteRootTracker.make("append");
const record = finalizeConfigWriteAuditRecord({
base: createConfigWriteAuditRecordBase({
configPath: path.join(home, ".openclaw", "openclaw.json"),
env: {} as NodeJS.ProcessEnv,
existsBefore: true,
previousHash: "prev-hash",
nextHash: "next-hash",
previousBytes: 12,
nextBytes: 24,
previousMetadata: {
dev: "10",
ino: "11",
mode: 0o600,
nlink: 1,
uid: 501,
gid: 20,
},
changedPathCount: 1,
hasMetaBefore: true,
hasMetaAfter: true,
gatewayModeBefore: "local",
gatewayModeAfter: "local",
suspicious: [],
now: "2026-04-07T08:00:00.000Z",
}),
result: "rename",
nextMetadata: {
dev: "12",
ino: "13",
mode: 0o600,
nlink: 1,
uid: 501,
gid: 20,
},
});
await appendConfigAuditRecord({
fs,
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
record,
});
const auditPath = path.join(home, ".openclaw", "logs", "config-audit.jsonl");
const lines = fs.readFileSync(auditPath, "utf-8").trim().split("\n");
expect(lines).toHaveLength(1);
expect(JSON.parse(lines[0])).toMatchObject({
event: "config.write",
result: "rename",
nextHash: "next-hash",
});
});
});

329
src/config/io.audit.ts Normal file
View File

@@ -0,0 +1,329 @@
import path from "node:path";
import { resolveStateDir } from "./paths.js";
const CONFIG_AUDIT_LOG_FILENAME = "config-audit.jsonl";
export type ConfigWriteAuditResult = "rename" | "copy-fallback" | "failed";
export 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;
previousDev: string | null;
nextDev: string | null;
previousIno: string | null;
nextIno: string | null;
previousMode: number | null;
nextMode: number | null;
previousNlink: number | null;
nextNlink: number | null;
previousUid: number | null;
nextUid: number | null;
previousGid: number | null;
nextGid: number | null;
changedPathCount: number | null;
hasMetaBefore: boolean;
hasMetaAfter: boolean;
gatewayModeBefore: string | null;
gatewayModeAfter: string | null;
suspicious: string[];
errorCode?: string;
errorMessage?: string;
};
export type ConfigObserveAuditRecord = {
ts: string;
source: "config-io";
event: "config.observe";
phase: "read";
configPath: string;
pid: number;
ppid: number;
cwd: string;
argv: string[];
execArgv: string[];
exists: boolean;
valid: boolean;
hash: string | null;
bytes: number | null;
mtimeMs: number | null;
ctimeMs: number | null;
dev: string | null;
ino: string | null;
mode: number | null;
nlink: number | null;
uid: number | null;
gid: number | null;
hasMeta: boolean;
gatewayMode: string | null;
suspicious: string[];
lastKnownGoodHash: string | null;
lastKnownGoodBytes: number | null;
lastKnownGoodMtimeMs: number | null;
lastKnownGoodCtimeMs: number | null;
lastKnownGoodDev: string | null;
lastKnownGoodIno: string | null;
lastKnownGoodMode: number | null;
lastKnownGoodNlink: number | null;
lastKnownGoodUid: number | null;
lastKnownGoodGid: number | null;
lastKnownGoodGatewayMode: string | null;
backupHash: string | null;
backupBytes: number | null;
backupMtimeMs: number | null;
backupCtimeMs: number | null;
backupDev: string | null;
backupIno: string | null;
backupMode: number | null;
backupNlink: number | null;
backupUid: number | null;
backupGid: number | null;
backupGatewayMode: string | null;
clobberedPath: string | null;
restoredFromBackup: boolean;
restoredBackupPath: string | null;
};
export type ConfigAuditRecord = ConfigWriteAuditRecord | ConfigObserveAuditRecord;
export type ConfigAuditStatMetadata = {
dev: string | null;
ino: string | null;
mode: number | null;
nlink: number | null;
uid: number | null;
gid: number | null;
};
export type ConfigAuditProcessInfo = {
pid: number;
ppid: number;
cwd: string;
argv: string[];
execArgv: string[];
};
export type ConfigWriteAuditRecordBase = Omit<
ConfigWriteAuditRecord,
| "result"
| "errorCode"
| "errorMessage"
| "nextDev"
| "nextIno"
| "nextMode"
| "nextNlink"
| "nextUid"
| "nextGid"
> & {
nextHash: string;
nextBytes: number;
};
type ConfigAuditFs = {
promises: {
mkdir(path: string, options?: { recursive?: boolean; mode?: number }): Promise<unknown>;
appendFile(
path: string,
data: string,
options?: { encoding?: BufferEncoding; mode?: number },
): Promise<unknown>;
};
mkdirSync(path: string, options?: { recursive?: boolean; mode?: number }): unknown;
appendFileSync(
path: string,
data: string,
options?: { encoding?: BufferEncoding; mode?: number },
): unknown;
};
function normalizeAuditLabel(value: string | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function resolveConfigAuditProcessInfo(
processInfo?: ConfigAuditProcessInfo,
): ConfigAuditProcessInfo {
if (processInfo) {
return processInfo;
}
return {
pid: process.pid,
ppid: process.ppid,
cwd: process.cwd(),
argv: process.argv.slice(0, 8),
execArgv: process.execArgv.slice(0, 8),
};
}
export function resolveConfigAuditLogPath(env: NodeJS.ProcessEnv, homedir: () => string): string {
return path.join(resolveStateDir(env, homedir), "logs", CONFIG_AUDIT_LOG_FILENAME);
}
export function formatConfigOverwriteLogMessage(params: {
configPath: string;
previousHash: string | null;
nextHash: string;
changedPathCount?: number;
}): string {
const changeSummary =
typeof params.changedPathCount === "number" ? `, changedPaths=${params.changedPathCount}` : "";
return `Config overwrite: ${params.configPath} (sha256 ${params.previousHash ?? "unknown"} -> ${params.nextHash}, backup=${params.configPath}.bak${changeSummary})`;
}
export function createConfigWriteAuditRecordBase(params: {
configPath: string;
env: NodeJS.ProcessEnv;
existsBefore: boolean;
previousHash: string | null;
nextHash: string;
previousBytes: number | null;
nextBytes: number;
previousMetadata: ConfigAuditStatMetadata;
changedPathCount: number | null | undefined;
hasMetaBefore: boolean;
hasMetaAfter: boolean;
gatewayModeBefore: string | null;
gatewayModeAfter: string | null;
suspicious: string[];
now?: string;
processInfo?: ConfigAuditProcessInfo;
}): ConfigWriteAuditRecordBase {
const processSnapshot = resolveConfigAuditProcessInfo(params.processInfo);
return {
ts: params.now ?? new Date().toISOString(),
source: "config-io",
event: "config.write",
configPath: params.configPath,
pid: processSnapshot.pid,
ppid: processSnapshot.ppid,
cwd: processSnapshot.cwd,
argv: processSnapshot.argv,
execArgv: processSnapshot.execArgv,
watchMode: params.env.OPENCLAW_WATCH_MODE === "1",
watchSession: normalizeAuditLabel(params.env.OPENCLAW_WATCH_SESSION),
watchCommand: normalizeAuditLabel(params.env.OPENCLAW_WATCH_COMMAND),
existsBefore: params.existsBefore,
previousHash: params.previousHash,
nextHash: params.nextHash,
previousBytes: params.previousBytes,
nextBytes: params.nextBytes,
previousDev: params.previousMetadata.dev,
nextDev: null,
previousIno: params.previousMetadata.ino,
nextIno: null,
previousMode: params.previousMetadata.mode,
nextMode: null,
previousNlink: params.previousMetadata.nlink,
nextNlink: null,
previousUid: params.previousMetadata.uid,
nextUid: null,
previousGid: params.previousMetadata.gid,
nextGid: null,
changedPathCount: typeof params.changedPathCount === "number" ? params.changedPathCount : null,
hasMetaBefore: params.hasMetaBefore,
hasMetaAfter: params.hasMetaAfter,
gatewayModeBefore: params.gatewayModeBefore,
gatewayModeAfter: params.gatewayModeAfter,
suspicious: params.suspicious,
};
}
export function finalizeConfigWriteAuditRecord(params: {
base: ConfigWriteAuditRecordBase;
result: ConfigWriteAuditResult;
nextMetadata?: ConfigAuditStatMetadata | null;
err?: unknown;
}): ConfigWriteAuditRecord {
const errorCode =
params.err &&
typeof params.err === "object" &&
"code" in params.err &&
typeof params.err.code === "string"
? params.err.code
: undefined;
const errorMessage =
params.err &&
typeof params.err === "object" &&
"message" in params.err &&
typeof params.err.message === "string"
? params.err.message
: undefined;
const nextMetadata = params.nextMetadata ?? {
dev: null,
ino: null,
mode: null,
nlink: null,
uid: null,
gid: null,
};
const success = params.result !== "failed";
return {
...params.base,
result: params.result,
nextHash: success ? params.base.nextHash : null,
nextBytes: success ? params.base.nextBytes : null,
nextDev: success ? nextMetadata.dev : null,
nextIno: success ? nextMetadata.ino : null,
nextMode: success ? nextMetadata.mode : null,
nextNlink: success ? nextMetadata.nlink : null,
nextUid: success ? nextMetadata.uid : null,
nextGid: success ? nextMetadata.gid : null,
errorCode,
errorMessage,
};
}
export async function appendConfigAuditRecord(params: {
fs: ConfigAuditFs;
env: NodeJS.ProcessEnv;
homedir: () => string;
record: ConfigAuditRecord;
}): Promise<void> {
try {
const auditPath = resolveConfigAuditLogPath(params.env, params.homedir);
await params.fs.promises.mkdir(path.dirname(auditPath), { recursive: true, mode: 0o700 });
await params.fs.promises.appendFile(auditPath, `${JSON.stringify(params.record)}\n`, {
encoding: "utf-8",
mode: 0o600,
});
} catch {
// best-effort
}
}
export function appendConfigAuditRecordSync(params: {
fs: ConfigAuditFs;
env: NodeJS.ProcessEnv;
homedir: () => string;
record: ConfigAuditRecord;
}): void {
try {
const auditPath = resolveConfigAuditLogPath(params.env, params.homedir);
params.fs.mkdirSync(path.dirname(auditPath), { recursive: true, mode: 0o700 });
params.fs.appendFileSync(auditPath, `${JSON.stringify(params.record)}\n`, {
encoding: "utf-8",
mode: 0o600,
});
} catch {
// best-effort
}
}

View File

@@ -1,11 +1,9 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { VERSION } from "../version.js";
import { describe, expect, it } from "vitest";
import { createConfigIO } from "./io.js";
import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js";
import { parseOpenClawVersion } from "./version.js";
async function withTempHome(run: (home: string) => Promise<void>): Promise<void> {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-"));
@@ -36,36 +34,6 @@ function createIoForHome(home: string, env: NodeJS.ProcessEnv = {} as NodeJS.Pro
});
}
async function expectNoNewerVersionWarning(touchedVersion: string) {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "openclaw.json");
await fs.writeFile(
configPath,
JSON.stringify({ meta: { lastTouchedVersion: touchedVersion } }, null, 2),
);
const logger = {
warn: vi.fn(),
error: vi.fn(),
};
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
logger,
});
io.loadConfig();
expect(logger.warn).not.toHaveBeenCalledWith(
expect.stringContaining("Config was last written by a newer OpenClaw"),
);
expect(io.configPath).toBe(configPath);
});
}
describe("config io paths", () => {
it("uses ~/.openclaw/openclaw.json when config exists", async () => {
await withTempHome(async (home) => {
@@ -144,51 +112,4 @@ describe("config io paths", () => {
});
expect(cfg.agents?.list?.[0]?.tools?.exec?.safeBinTrustedDirs).toEqual(["/ops/bin"]);
});
it("logs invalid config path details and throws on invalid config", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "openclaw.json");
await fs.writeFile(
configPath,
JSON.stringify({ gateway: { port: "not-a-number" } }, null, 2),
);
const logger = {
warn: vi.fn(),
error: vi.fn(),
};
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
logger,
});
expect(() => io.loadConfig()).toThrow(/Invalid config/);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(`Invalid config at ${configPath}:\\n`),
);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("- gateway.port:"));
});
});
it("does not warn when config was last touched by a same-base correction publish", async () => {
const parsedVersion = parseOpenClawVersion(VERSION);
if (!parsedVersion) {
throw new Error(`Unable to parse VERSION: ${VERSION}`);
}
const touchedVersion = `${parsedVersion.major}.${parsedVersion.minor}.${parsedVersion.patch}-${(parsedVersion.revision ?? 0) + 1}`;
await expectNoNewerVersionWarning(touchedVersion);
});
it("does not warn for same-base prerelease configs when current version is newer", async () => {
const parsedVersion = parseOpenClawVersion(VERSION);
if (!parsedVersion) {
throw new Error(`Unable to parse VERSION: ${VERSION}`);
}
const touchedVersion = `${parsedVersion.major}.${parsedVersion.minor}.${parsedVersion.patch}-beta.1`;
await expectNoNewerVersionWarning(touchedVersion);
});
});

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
createInvalidConfigError,
formatInvalidConfigDetails,
formatInvalidConfigLogMessage,
} from "./io.invalid-config.js";
describe("config io invalid config formatting", () => {
it("formats issue details with sanitized paths and messages", () => {
const details = formatInvalidConfigDetails([
{
path: "gateway.port",
message: 'Expected number\\nreceived "bad"',
},
{
path: "",
message: "root problem",
},
]);
expect(details).toContain("- gateway.port:");
expect(details).toContain("Expected number");
expect(details).toContain("received");
expect(details).toContain("- <root>: root problem");
});
it("formats the logger message with the escaped newline separator", () => {
expect(formatInvalidConfigLogMessage("/tmp/openclaw.json", "- gateway.port: bad")).toBe(
"Invalid config at /tmp/openclaw.json:\\n- gateway.port: bad",
);
});
it("creates INVALID_CONFIG errors with inline details", () => {
const err = createInvalidConfigError("/tmp/openclaw.json", "- gateway.port: bad") as Error & {
code?: string;
details?: string;
};
expect(err.message).toBe("Invalid config at /tmp/openclaw.json:\n- gateway.port: bad");
expect(err.code).toBe("INVALID_CONFIG");
expect(err.details).toBe("- gateway.port: bad");
});
});

View File

@@ -0,0 +1,26 @@
import { sanitizeTerminalText } from "../terminal/safe-text.js";
export type ConfigValidationIssueLike = {
path: string;
message: string;
};
export function formatInvalidConfigDetails(issues: ConfigValidationIssueLike[]): string {
return issues
.map(
(issue) =>
`- ${sanitizeTerminalText(issue.path || "<root>")}: ${sanitizeTerminalText(issue.message)}`,
)
.join("\n");
}
export function formatInvalidConfigLogMessage(configPath: string, details: string): string {
return `Invalid config at ${configPath}:\\n${details}`;
}
export function createInvalidConfigError(configPath: string, details: string): Error {
const error = new Error(`Invalid config at ${configPath}:\n${details}`);
(error as { code?: string; details?: string }).code = "INVALID_CONFIG";
(error as { code?: string; details?: string }).details = details;
return error;
}

View File

@@ -2,7 +2,6 @@ import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import JSON5 from "json5";
import { ensureOwnerDisplaySecret } from "../agents/owner-display.js";
import { applyRuntimeLegacyConfigMigrations } from "../commands/doctor/shared/runtime-compat-api.js";
@@ -19,7 +18,6 @@ import {
collectRelevantDoctorPluginIds,
listPluginDoctorLegacyConfigRules,
} from "../plugins/doctor-contract-registry.js";
import { sanitizeTerminalText } from "../terminal/safe-text.js";
import { isRecord } from "../utils.js";
import { VERSION } from "../version.js";
import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
@@ -37,7 +35,30 @@ import {
readConfigIncludeFileWithGuards,
resolveConfigIncludes,
} from "./includes.js";
import {
appendConfigAuditRecord,
appendConfigAuditRecordSync,
createConfigWriteAuditRecordBase,
finalizeConfigWriteAuditRecord,
formatConfigOverwriteLogMessage,
type ConfigWriteAuditResult,
} from "./io.audit.js";
import {
createInvalidConfigError,
formatInvalidConfigDetails,
formatInvalidConfigLogMessage,
} from "./io.invalid-config.js";
import { persistGeneratedOwnerDisplaySecret } from "./io.owner-display-secret.js";
import {
collectChangedPaths,
createMergePatch,
formatConfigValidationFailure,
projectSourceOntoRuntimeShape,
restoreEnvRefsFromMap,
resolvePersistCandidateForWrite,
resolveWriteEnvSnapshotForPath,
unsetPathForWrite,
} from "./io.write-prepare.js";
import { findLegacyConfigIssues } from "./legacy.js";
import {
asResolvedSourceConfig,
@@ -46,7 +67,6 @@ import {
} from "./materialize.js";
import { applyMergePatch } from "./merge-patch.js";
import { resolveConfigPath, resolveStateDir } from "./paths.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
import { applyConfigOverrides } from "./runtime-overrides.js";
import {
clearRuntimeConfigSnapshot as clearRuntimeConfigSnapshotState,
@@ -79,56 +99,9 @@ export { CircularIncludeError, ConfigIncludeError } from "./includes.js";
export { MissingEnvVarError } from "./env-substitution.js";
export { resolveShellEnvExpectedKeys } from "./shell-env-expected-keys.js";
const OPEN_DM_POLICY_ALLOW_FROM_RE =
/^(?<policyPath>[a-z0-9_.-]+)\s*=\s*"open"\s+requires\s+(?<allowPath>[a-z0-9_.-]+)(?:\s+\(or\s+[a-z0-9_.-]+\))?\s+to include "\*"$/i;
const CONFIG_AUDIT_LOG_FILENAME = "config-audit.jsonl";
const CONFIG_HEALTH_STATE_FILENAME = "config-health.json";
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;
previousDev: string | null;
nextDev: string | null;
previousIno: string | null;
nextIno: string | null;
previousMode: number | null;
nextMode: number | null;
previousNlink: number | null;
nextNlink: number | null;
previousUid: number | null;
nextUid: number | null;
previousGid: number | null;
nextGid: number | null;
changedPathCount: number | null;
hasMetaBefore: boolean;
hasMetaAfter: boolean;
gatewayModeBefore: string | null;
gatewayModeAfter: string | null;
suspicious: string[];
errorCode?: string;
errorMessage?: string;
};
type ConfigHealthFingerprint = {
hash: string;
bytes: number;
@@ -154,61 +127,6 @@ type ConfigHealthState = {
entries?: Record<string, ConfigHealthEntry>;
};
type ConfigObserveAuditRecord = {
ts: string;
source: "config-io";
event: "config.observe";
phase: "read";
configPath: string;
pid: number;
ppid: number;
cwd: string;
argv: string[];
execArgv: string[];
exists: boolean;
valid: boolean;
hash: string | null;
bytes: number | null;
mtimeMs: number | null;
ctimeMs: number | null;
dev: string | null;
ino: string | null;
mode: number | null;
nlink: number | null;
uid: number | null;
gid: number | null;
hasMeta: boolean;
gatewayMode: string | null;
suspicious: string[];
lastKnownGoodHash: string | null;
lastKnownGoodBytes: number | null;
lastKnownGoodMtimeMs: number | null;
lastKnownGoodCtimeMs: number | null;
lastKnownGoodDev: string | null;
lastKnownGoodIno: string | null;
lastKnownGoodMode: number | null;
lastKnownGoodNlink: number | null;
lastKnownGoodUid: number | null;
lastKnownGoodGid: number | null;
lastKnownGoodGatewayMode: string | null;
backupHash: string | null;
backupBytes: number | null;
backupMtimeMs: number | null;
backupCtimeMs: number | null;
backupDev: string | null;
backupIno: string | null;
backupMode: number | null;
backupNlink: number | null;
backupUid: number | null;
backupGid: number | null;
backupGatewayMode: string | null;
clobberedPath: string | null;
restoredFromBackup: boolean;
restoredBackupPath: string | null;
};
type ConfigAuditRecord = ConfigWriteAuditRecord | ConfigObserveAuditRecord;
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string };
export type ConfigWriteOptions = {
/**
@@ -281,135 +199,6 @@ async function tightenStateDirPermissionsIfNeeded(params: {
}
}
function formatConfigValidationFailure(pathLabel: string, issueMessage: string): string {
const match = issueMessage.match(OPEN_DM_POLICY_ALLOW_FROM_RE);
const policyPath = match?.groups?.policyPath?.trim();
const allowPath = match?.groups?.allowPath?.trim();
if (!policyPath || !allowPath) {
return `Config validation failed: ${pathLabel}: ${issueMessage}`;
}
return [
`Config validation failed: ${pathLabel}`,
"",
`Configuration mismatch: ${policyPath} is "open", but ${allowPath} does not include "*".`,
"",
"Fix with:",
` openclaw config set ${allowPath} '["*"]'`,
"",
"Or switch policy:",
` openclaw config set ${policyPath} "pairing"`,
].join("\n");
}
function isNumericPathSegment(raw: string): boolean {
return /^[0-9]+$/.test(raw);
}
function isWritePlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function hasOwnObjectKey(value: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
const WRITE_PRUNED_OBJECT = Symbol("write-pruned-object");
type UnsetPathWriteResult = {
changed: boolean;
value: unknown;
};
function unsetPathForWriteAt(
value: unknown,
pathSegments: string[],
depth: number,
): UnsetPathWriteResult {
if (depth >= pathSegments.length) {
return { changed: false, value };
}
const segment = pathSegments[depth];
const isLeaf = depth === pathSegments.length - 1;
if (Array.isArray(value)) {
if (!isNumericPathSegment(segment)) {
return { changed: false, value };
}
const index = Number.parseInt(segment, 10);
if (!Number.isFinite(index) || index < 0 || index >= value.length) {
return { changed: false, value };
}
if (isLeaf) {
const next = value.slice();
next.splice(index, 1);
return { changed: true, value: next };
}
const child = unsetPathForWriteAt(value[index], pathSegments, depth + 1);
if (!child.changed) {
return { changed: false, value };
}
const next = value.slice();
if (child.value === WRITE_PRUNED_OBJECT) {
next.splice(index, 1);
} else {
next[index] = child.value;
}
return { changed: true, value: next };
}
if (
isBlockedObjectKey(segment) ||
!isWritePlainObject(value) ||
!hasOwnObjectKey(value, segment)
) {
return { changed: false, value };
}
if (isLeaf) {
const next: Record<string, unknown> = { ...value };
delete next[segment];
return {
changed: true,
value: Object.keys(next).length === 0 ? WRITE_PRUNED_OBJECT : next,
};
}
const child = unsetPathForWriteAt(value[segment], pathSegments, depth + 1);
if (!child.changed) {
return { changed: false, value };
}
const next: Record<string, unknown> = { ...value };
if (child.value === WRITE_PRUNED_OBJECT) {
delete next[segment];
} else {
next[segment] = child.value;
}
return {
changed: true,
value: Object.keys(next).length === 0 ? WRITE_PRUNED_OBJECT : next,
};
}
function unsetPathForWrite(
root: OpenClawConfig,
pathSegments: string[],
): { changed: boolean; next: OpenClawConfig } {
if (pathSegments.length === 0) {
return { changed: false, next: root };
}
const result = unsetPathForWriteAt(root, pathSegments, 0);
if (!result.changed) {
return { changed: false, next: root };
}
if (result.value === WRITE_PRUNED_OBJECT) {
return { changed: true, next: {} };
}
if (isWritePlainObject(result.value)) {
return { changed: true, next: coerceConfig(result.value) };
}
return { changed: false, next: root };
}
export function resolveConfigSnapshotHash(snapshot: {
hash?: string;
raw?: string | null;
@@ -453,60 +242,6 @@ function resolveGatewayMode(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : null;
}
function cloneUnknown<T>(value: T): T {
return structuredClone(value);
}
function createMergePatch(base: unknown, target: unknown): unknown {
if (!isRecord(base) || !isRecord(target)) {
return cloneUnknown(target);
}
const patch: Record<string, unknown> = {};
const keys = new Set([...Object.keys(base), ...Object.keys(target)]);
for (const key of keys) {
const hasBase = key in base;
const hasTarget = key in target;
if (!hasTarget) {
patch[key] = null;
continue;
}
const targetValue = target[key];
if (!hasBase) {
patch[key] = cloneUnknown(targetValue);
continue;
}
const baseValue = base[key];
if (isRecord(baseValue) && isRecord(targetValue)) {
const childPatch = createMergePatch(baseValue, targetValue);
if (isRecord(childPatch) && Object.keys(childPatch).length === 0) {
continue;
}
patch[key] = childPatch;
continue;
}
if (!isDeepStrictEqual(baseValue, targetValue)) {
patch[key] = cloneUnknown(targetValue);
}
}
return patch;
}
function projectSourceOntoRuntimeShape(source: unknown, runtime: unknown): unknown {
if (!isRecord(source) || !isRecord(runtime)) {
return cloneUnknown(source);
}
const next: Record<string, unknown> = {};
for (const [key, sourceValue] of Object.entries(source)) {
if (!(key in runtime)) {
continue;
}
next[key] = projectSourceOntoRuntimeShape(sourceValue, runtime[key]);
}
return next;
}
function collectEnvRefPaths(value: unknown, path: string, output: Map<string, string>): void {
if (typeof value === "string") {
if (containsEnvVarReference(value)) {
@@ -528,115 +263,6 @@ function collectEnvRefPaths(value: unknown, path: string, output: Map<string, st
}
}
function collectChangedPaths(
base: unknown,
target: unknown,
path: string,
output: Set<string>,
): void {
if (Array.isArray(base) && Array.isArray(target)) {
const max = Math.max(base.length, target.length);
for (let index = 0; index < max; index += 1) {
const childPath = path ? `${path}[${index}]` : `[${index}]`;
if (index >= base.length || index >= target.length) {
output.add(childPath);
continue;
}
collectChangedPaths(base[index], target[index], childPath, output);
}
return;
}
if (isRecord(base) && isRecord(target)) {
const keys = new Set([...Object.keys(base), ...Object.keys(target)]);
for (const key of keys) {
const childPath = path ? `${path}.${key}` : key;
const hasBase = key in base;
const hasTarget = key in target;
if (!hasTarget || !hasBase) {
output.add(childPath);
continue;
}
collectChangedPaths(base[key], target[key], childPath, output);
}
return;
}
if (!isDeepStrictEqual(base, target)) {
output.add(path);
}
}
function parentPath(value: string): string {
if (!value) {
return "";
}
if (value.endsWith("]")) {
const index = value.lastIndexOf("[");
return index > 0 ? value.slice(0, index) : "";
}
const index = value.lastIndexOf(".");
return index >= 0 ? value.slice(0, index) : "";
}
function isPathChanged(path: string, changedPaths: Set<string>): boolean {
if (changedPaths.has(path)) {
return true;
}
let current = parentPath(path);
while (current) {
if (changedPaths.has(current)) {
return true;
}
current = parentPath(current);
}
return changedPaths.has("");
}
function restoreEnvRefsFromMap(
value: unknown,
path: string,
envRefMap: Map<string, string>,
changedPaths: Set<string>,
): unknown {
if (typeof value === "string") {
if (!isPathChanged(path, changedPaths)) {
const original = envRefMap.get(path);
if (original !== undefined) {
return original;
}
}
return value;
}
if (Array.isArray(value)) {
let changed = false;
const next = value.map((item, index) => {
const updated = restoreEnvRefsFromMap(item, `${path}[${index}]`, envRefMap, changedPaths);
if (updated !== item) {
changed = true;
}
return updated;
});
return changed ? next : value;
}
if (isRecord(value)) {
let changed = false;
const next: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
const childPath = path ? `${path}.${key}` : key;
const updated = restoreEnvRefsFromMap(child, childPath, envRefMap, changedPaths);
if (updated !== child) {
changed = true;
}
next[key] = updated;
}
return changed ? next : value;
}
return value;
}
function resolveConfigAuditLogPath(env: NodeJS.ProcessEnv, homedir: () => string): string {
return path.join(resolveStateDir(env, homedir), "logs", CONFIG_AUDIT_LOG_FILENAME);
}
function resolveConfigHealthStatePath(env: NodeJS.ProcessEnv, homedir: () => string): string {
return path.join(resolveStateDir(env, homedir), "logs", CONFIG_HEALTH_STATE_FILENAME);
}
@@ -697,38 +323,6 @@ function resolveConfigWriteSuspiciousReasons(params: {
return reasons;
}
async function appendConfigAuditRecord(
deps: Required<ConfigIoDeps>,
record: ConfigAuditRecord,
): 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
}
}
function appendConfigAuditRecordSync(
deps: Required<ConfigIoDeps>,
record: ConfigAuditRecord,
): void {
try {
const auditPath = resolveConfigAuditLogPath(deps.env, deps.homedir);
deps.fs.mkdirSync(path.dirname(auditPath), { recursive: true, mode: 0o700 });
deps.fs.appendFileSync(auditPath, `${JSON.stringify(record)}\n`, {
encoding: "utf-8",
mode: 0o600,
});
} catch {
// best-effort
}
}
async function readConfigHealthState(deps: Required<ConfigIoDeps>): Promise<ConfigHealthState> {
try {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
@@ -1010,7 +604,10 @@ async function maybeRecoverSuspiciousConfigRead(params: {
params.deps.logger.warn(
`Config auto-restored from backup: ${params.configPath} (${suspicious.join(", ")})`,
);
await appendConfigAuditRecord(params.deps, {
await appendConfigAuditRecord({
fs: params.deps.fs,
env: params.deps.env,
homedir: params.deps.homedir,
ts: now,
source: "config-io",
event: "config.observe",
@@ -1140,7 +737,10 @@ function maybeRecoverSuspiciousConfigReadSync(params: {
params.deps.logger.warn(
`Config auto-restored from backup: ${params.configPath} (${suspicious.join(", ")})`,
);
appendConfigAuditRecordSync(params.deps, {
appendConfigAuditRecordSync({
fs: params.deps.fs,
env: params.deps.env,
homedir: params.deps.homedir,
ts: now,
source: "config-io",
event: "config.observe",
@@ -1292,7 +892,10 @@ async function observeConfigSnapshot(
});
deps.logger.warn(`Config observe anomaly: ${snapshot.path} (${suspicious.join(", ")})`);
await appendConfigAuditRecord(deps, {
await appendConfigAuditRecord({
fs: deps.fs,
env: deps.env,
homedir: deps.homedir,
ts: now,
source: "config-io",
event: "config.observe",
@@ -1420,7 +1023,10 @@ function observeConfigSnapshotSync(
});
deps.logger.warn(`Config observe anomaly: ${snapshot.path} (${suspicious.join(", ")})`);
appendConfigAuditRecordSync(deps, {
appendConfigAuditRecordSync({
fs: deps.fs,
env: deps.env,
homedir: deps.homedir,
ts: now,
source: "config-io",
event: "config.observe",
@@ -1773,20 +1379,12 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
legacyIssues: legacyResolution.sourceLegacyIssues,
}),
});
const details = validated.issues
.map(
(iss) =>
`- ${sanitizeTerminalText(iss.path || "<root>")}: ${sanitizeTerminalText(iss.message)}`,
)
.join("\n");
const details = formatInvalidConfigDetails(validated.issues);
if (!loggedInvalidConfigs.has(configPath)) {
loggedInvalidConfigs.add(configPath);
deps.logger.error(`Invalid config at ${configPath}:\\n${details}`);
deps.logger.error(formatInvalidConfigLogMessage(configPath, details));
}
const error = new Error(`Invalid config at ${configPath}:\n${details}`);
(error as { code?: string; details?: string }).code = "INVALID_CONFIG";
(error as { code?: string; details?: string }).details = details;
throw error;
throw createInvalidConfigError(configPath, details);
}
if (validated.warnings.length > 0) {
const details = validated.warnings
@@ -2069,9 +1667,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
let envRefMap: Map<string, string> | null = null;
let changedPaths: Set<string> | null = null;
if (snapshot.valid && snapshot.exists) {
const patch = createMergePatch(snapshot.config, cfg);
const projectedSource = projectSourceOntoRuntimeShape(snapshot.resolved, snapshot.config);
persistCandidate = applyMergePatch(projectedSource, patch);
persistCandidate = resolvePersistCandidateForWrite({
runtimeConfig: snapshot.config,
sourceConfig: snapshot.resolved,
nextConfig: cfg,
});
try {
const resolvedIncludes = resolveConfigIncludes(snapshot.parsed, configPath, {
readFile: (candidate) => deps.fs.readFileSync(candidate, "utf-8"),
@@ -2202,10 +1802,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
if (isVitest && !shouldLogInVitest) {
return;
}
const changeSummary =
typeof changedPathCount === "number" ? `, changedPaths=${changedPathCount}` : "";
deps.logger.warn(
`Config overwrite: ${configPath} (sha256 ${previousHash ?? "unknown"} -> ${nextHash}, backup=${configPath}.bak${changeSummary})`,
formatConfigOverwriteLogMessage({
configPath,
previousHash: previousHash ?? null,
nextHash,
changedPathCount,
}),
);
};
const logConfigWriteAnomalies = () => {
@@ -2220,78 +1823,38 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}
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,
const previousMetadata = resolveConfigStatMetadata(previousStat);
const auditRecordBase = createConfigWriteAuditRecordBase({
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,
env: deps.env,
existsBefore: snapshot.exists,
previousHash: previousHash ?? null,
nextHash,
previousBytes,
nextBytes,
previousDev: resolveConfigStatMetadata(previousStat).dev,
nextDev: null,
previousIno: resolveConfigStatMetadata(previousStat).ino,
nextIno: null,
previousMode: resolveConfigStatMetadata(previousStat).mode,
nextMode: null,
previousNlink: resolveConfigStatMetadata(previousStat).nlink,
nextNlink: null,
previousUid: resolveConfigStatMetadata(previousStat).uid,
nextUid: null,
previousGid: resolveConfigStatMetadata(previousStat).gid,
nextGid: null,
changedPathCount: typeof changedPathCount === "number" ? changedPathCount : null,
previousMetadata,
changedPathCount,
hasMetaBefore,
hasMetaAfter,
gatewayModeBefore,
gatewayModeAfter,
suspicious: suspiciousReasons,
};
});
const appendWriteAudit = async (
result: ConfigWriteAuditResult,
err?: unknown,
nextStat?: fs.Stats | null,
) => {
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;
const nextMetadata = resolveConfigStatMetadata(nextStat ?? null);
await appendConfigAuditRecord(deps, {
...auditRecordBase,
result,
nextHash: result === "failed" ? null : auditRecordBase.nextHash,
nextBytes: result === "failed" ? null : auditRecordBase.nextBytes,
nextDev: result === "failed" ? null : nextMetadata.dev,
nextIno: result === "failed" ? null : nextMetadata.ino,
nextMode: result === "failed" ? null : nextMetadata.mode,
nextNlink: result === "failed" ? null : nextMetadata.nlink,
nextUid: result === "failed" ? null : nextMetadata.uid,
nextGid: result === "failed" ? null : nextMetadata.gid,
errorCode,
errorMessage,
await appendConfigAuditRecord({
fs: deps.fs,
env: deps.env,
homedir: deps.homedir,
record: finalizeConfigWriteAuditRecord({
base: auditRecordBase,
result,
err,
nextMetadata: resolveConfigStatMetadata(nextStat ?? null),
}),
});
};
@@ -2500,10 +2063,12 @@ export async function writeConfigFile(
const runtimePatch = createMergePatch(runtimeConfigSnapshot!, cfg);
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot!, runtimePatch));
}
const sameConfigPath =
options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
const writeResult = await io.writeConfigFile(nextCfg, {
envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
envSnapshotForRestore: resolveWriteEnvSnapshotForPath({
actualConfigPath: io.configPath,
expectedConfigPath: options.expectedConfigPath,
envSnapshotForRestore: options.envSnapshotForRestore,
}),
unsetPaths: options.unsetPaths,
});
const notifyCommittedWrite = () => {

View File

@@ -1,10 +1,9 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { PluginManifestRecord, PluginManifestRegistry } from "../plugins/manifest-registry.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { createConfigIO } from "./io.js";
import type { OpenClawConfig } from "./types.js";
// Mock the plugin manifest registry so we can register a fake channel whose
// AJV JSON Schema carries a `default` value. This lets the #56772 regression
@@ -18,11 +17,20 @@ const mockLoadPluginManifestRegistry = vi.hoisted(() =>
}),
),
);
const mockMaintainConfigBackups = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: mockLoadPluginManifestRegistry,
}));
vi.mock("./backup-rotation.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./backup-rotation.js")>();
return {
...actual,
maintainConfigBackups: (...args: unknown[]) => mockMaintainConfigBackups(...args),
};
});
describe("config io write", () => {
const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-config-io-" });
const silentLogger = {
@@ -30,39 +38,6 @@ describe("config io write", () => {
error: () => {},
};
function createBlueBubblesManifestRecord(): PluginManifestRecord {
return {
id: "bluebubbles",
origin: "bundled",
channels: ["bluebubbles"],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
rootDir: "/virtual/plugins/bluebubbles",
source: "/virtual/plugins/bluebubbles/openclaw.plugin.json",
manifestPath: "/virtual/plugins/bluebubbles/openclaw.plugin.json",
channelCatalogMeta: {
id: "bluebubbles",
label: "BlueBubbles",
blurb: "BlueBubbles channel",
},
channelConfigs: {
bluebubbles: {
schema: {
type: "object",
properties: {
enrichGroupParticipantsFromContacts: { type: "boolean", default: true },
serverUrl: { type: "string" },
},
additionalProperties: true,
},
uiHints: {},
},
},
};
}
async function withSuiteHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const home = await suiteRootTracker.make("case");
return fn(home);
@@ -83,80 +58,6 @@ describe("config io write", () => {
await suiteRootTracker.cleanup();
});
async function writeConfigAndCreateIo(params: {
home: string;
initialConfig: Record<string, unknown>;
env?: NodeJS.ProcessEnv;
logger?: { warn: (msg: string) => void; error: (msg: string) => void };
}) {
const configPath = path.join(params.home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, JSON.stringify(params.initialConfig, null, 2), "utf-8");
const io = createConfigIO({
env: params.env ?? {},
homedir: () => params.home,
logger: params.logger ?? silentLogger,
});
const snapshot = await io.readConfigFileSnapshot();
expect(snapshot.valid).toBe(true);
return { configPath, io, snapshot };
}
async function writeTokenAuthAndReadConfig(params: {
io: { writeConfigFile: (config: Record<string, unknown>) => Promise<unknown> };
snapshot: { config: Record<string, unknown> };
configPath: string;
}) {
const next = structuredClone(params.snapshot.config);
const gateway =
next.gateway && typeof next.gateway === "object"
? (next.gateway as Record<string, unknown>)
: {};
next.gateway = {
...gateway,
auth: { mode: "token" },
};
await params.io.writeConfigFile(next);
return JSON.parse(await fs.readFile(params.configPath, "utf-8")) as Record<string, unknown>;
}
async function writeGatewayPatchAndReadLastAuditEntry(params: {
home: string;
initialConfig: Record<string, unknown>;
gatewayPatch: Record<string, unknown>;
env?: NodeJS.ProcessEnv;
}) {
const { io, snapshot, configPath } = await writeConfigAndCreateIo({
home: params.home,
initialConfig: params.initialConfig,
env: params.env,
logger: {
warn: vi.fn(),
error: vi.fn(),
},
});
const auditPath = path.join(params.home, ".openclaw", "logs", "config-audit.jsonl");
const next = structuredClone(snapshot.config);
const gateway =
next.gateway && typeof next.gateway === "object"
? (next.gateway as Record<string, unknown>)
: {};
next.gateway = {
...gateway,
...params.gatewayPatch,
};
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>;
return { last, lines, configPath };
}
const createGatewayCommandsInput = (): Record<string, unknown> => ({
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
});
const expectInputOwnerDisplayUnchanged = (input: Record<string, unknown>) => {
expect((input.commands as Record<string, unknown>).ownerDisplay).toBe("hash");
};
@@ -168,36 +69,6 @@ describe("config io write", () => {
return persisted.commands;
};
async function runUnsetNoopCase(params: { home: string; unsetPaths: string[][] }) {
const { configPath, io } = await writeConfigAndCreateIo({
home: params.home,
initialConfig: createGatewayCommandsInput(),
});
const input = createGatewayCommandsInput();
await io.writeConfigFile(input, { unsetPaths: params.unsetPaths });
expectInputOwnerDisplayUnchanged(input);
expect((await readPersistedCommands(configPath))?.ownerDisplay).toBe("hash");
}
it("persists caller changes onto resolved config without leaking runtime defaults", async () => {
await withSuiteHome(async (home) => {
const { configPath, io, snapshot } = await writeConfigAndCreateIo({
home,
initialConfig: { gateway: { port: 18789 } },
});
const persisted = await writeTokenAuthAndReadConfig({ io, snapshot, configPath });
expect(persisted.gateway).toEqual({
port: 18789,
auth: { mode: "token" },
});
expect(persisted).not.toHaveProperty("agents.defaults");
expect(persisted).not.toHaveProperty("messages.ackReaction");
expect(persisted).not.toHaveProperty("sessions.persistence");
});
});
it.runIf(process.platform !== "win32")(
"tightens world-writable state dir when writing the default config",
async () => {
@@ -260,60 +131,6 @@ describe("config io write", () => {
});
});
it('shows actionable guidance for dmPolicy="open" without wildcard allowFrom', async () => {
await withSuiteHome(async (home) => {
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
logger: silentLogger,
});
const invalidConfig: OpenClawConfig = {
channels: {
telegram: {
dmPolicy: "open",
allowFrom: [],
},
},
} satisfies OpenClawConfig;
await expect(io.writeConfigFile(invalidConfig)).rejects.toThrow(
"openclaw config set channels.telegram.allowFrom '[\"*\"]'",
);
await expect(io.writeConfigFile(invalidConfig)).rejects.toThrow(
'openclaw config set channels.telegram.dmPolicy "pairing"',
);
});
});
it("honors explicit unset paths when schema defaults would otherwise reappear", async () => {
await withSuiteHome(async (home) => {
const { configPath, io, snapshot } = await writeConfigAndCreateIo({
home,
initialConfig: {
gateway: { auth: { mode: "none" } },
commands: { ownerDisplay: "hash" },
},
});
const next = structuredClone(snapshot.resolved) as Record<string, unknown>;
if (
next.commands &&
typeof next.commands === "object" &&
"ownerDisplay" in (next.commands as Record<string, unknown>)
) {
delete (next.commands as Record<string, unknown>).ownerDisplay;
}
await io.writeConfigFile(next, { unsetPaths: [["commands", "ownerDisplay"]] });
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
commands?: Record<string, unknown>;
};
expect(persisted.commands ?? {}).not.toHaveProperty("ownerDisplay");
});
});
it("does not mutate caller config when unsetPaths is applied on first write", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
@@ -339,323 +156,6 @@ describe("config io write", () => {
});
});
it("does not mutate caller config when unsetPaths is applied on existing files", async () => {
await withSuiteHome(async (home) => {
const { configPath, io, snapshot } = await writeConfigAndCreateIo({
home,
initialConfig: {
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
},
});
const input = structuredClone(snapshot.config) as Record<string, unknown>;
await io.writeConfigFile(input, { unsetPaths: [["commands", "ownerDisplay"]] });
expectInputOwnerDisplayUnchanged(input);
expect((await readPersistedCommands(configPath)) ?? {}).not.toHaveProperty("ownerDisplay");
});
});
it("keeps caller arrays immutable when unsetting array entries", async () => {
await withSuiteHome(async (home) => {
const { configPath, io, snapshot } = await writeConfigAndCreateIo({
home,
initialConfig: {
gateway: { mode: "local" },
tools: { alsoAllow: ["exec", "fetch", "read"] },
},
});
const input = structuredClone(snapshot.config) as Record<string, unknown>;
await io.writeConfigFile(input, { unsetPaths: [["tools", "alsoAllow", "1"]] });
expect((input.tools as { alsoAllow: string[] }).alsoAllow).toEqual(["exec", "fetch", "read"]);
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
tools?: { alsoAllow?: string[] };
};
expect(persisted.tools?.alsoAllow).toEqual(["exec", "read"]);
});
});
it("treats missing unset paths as no-op without mutating caller config", async () => {
await withSuiteHome(async (home) => {
await runUnsetNoopCase({
home,
unsetPaths: [["commands", "missingKey"]],
});
});
});
it("ignores blocked prototype-key unset path segments", async () => {
await withSuiteHome(async (home) => {
await runUnsetNoopCase({
home,
unsetPaths: [
["commands", "__proto__"],
["commands", "constructor"],
["commands", "prototype"],
],
});
});
});
it("preserves env var references when writing", async () => {
await withSuiteHome(async (home) => {
const { configPath, io, snapshot } = await writeConfigAndCreateIo({
home,
env: { OPENAI_API_KEY: "sk-secret" } as NodeJS.ProcessEnv,
initialConfig: {
agents: {
defaults: {
cliBackends: {
codex: {
command: "codex",
env: {
OPENAI_API_KEY: "${OPENAI_API_KEY}",
},
},
},
},
},
gateway: { port: 18789 },
},
});
const persisted = (await writeTokenAuthAndReadConfig({ io, snapshot, configPath })) as {
agents: { defaults: { cliBackends: { codex: { env: { OPENAI_API_KEY: string } } } } };
gateway: { port: number; auth: { mode: string } };
};
expect(persisted.agents.defaults.cliBackends.codex.env.OPENAI_API_KEY).toBe(
"${OPENAI_API_KEY}",
);
expect(persisted.gateway).toEqual({
port: 18789,
auth: { mode: "token" },
});
});
});
it("does not leak channel plugin AJV defaults into persisted config (issue #56772)", async () => {
// Regression test for #56772. Mock the BlueBubbles channel metadata so
// read-time AJV validation injects the same default that triggered the
// write-back leak.
mockLoadPluginManifestRegistry.mockReturnValue({
diagnostics: [],
plugins: [createBlueBubblesManifestRecord()],
});
await withSuiteHome(async (home) => {
const { configPath, io, snapshot } = await writeConfigAndCreateIo({
home,
initialConfig: {
gateway: { port: 18789 },
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
},
});
// Simulate doctor: clone snapshot.config, make a small change, write back.
const next = structuredClone(snapshot.config);
const gateway =
next.gateway && typeof next.gateway === "object"
? (next.gateway as Record<string, unknown>)
: {};
next.gateway = {
...gateway,
auth: { mode: "token" },
};
await io.writeConfigFile(next);
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record<
string,
unknown
>;
// The persisted config should contain only explicitly set values.
expect(persisted.gateway).toEqual({
port: 18789,
auth: { mode: "token" },
});
// The critical assertion: the AJV-injected BlueBubbles default must not
// appear in the persisted config.
const channels = persisted.channels as Record<string, Record<string, unknown>> | undefined;
expect(channels?.bluebubbles).toBeDefined();
expect(channels?.bluebubbles).not.toHaveProperty("enrichGroupParticipantsFromContacts");
expect(channels?.bluebubbles?.serverUrl).toBe("http://localhost:1234");
expect(channels?.bluebubbles?.password).toBe("test-password");
});
// Restore the default empty-plugins mock for subsequent tests.
mockLoadPluginManifestRegistry.mockReturnValue({
diagnostics: [],
plugins: [],
} satisfies PluginManifestRegistry);
});
it("does not reintroduce Slack/Discord legacy dm.policy defaults when writing", async () => {
await withSuiteHome(async (home) => {
const { configPath, io, snapshot } = await writeConfigAndCreateIo({
home,
initialConfig: {
channels: {
discord: {
dmPolicy: "pairing",
dm: { enabled: true, policy: "pairing" },
},
slack: {
dmPolicy: "pairing",
dm: { enabled: true, policy: "pairing" },
},
},
gateway: { port: 18789 },
},
});
const next = structuredClone(snapshot.config);
// Simulate doctor removing legacy keys while keeping dm enabled.
if (next.channels?.discord?.dm && typeof next.channels.discord.dm === "object") {
delete (next.channels.discord.dm as any).policy;
}
if (next.channels?.slack?.dm && typeof next.channels.slack.dm === "object") {
delete (next.channels.slack.dm as any).policy;
}
await io.writeConfigFile(next);
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
channels?: {
discord?: { dm?: Record<string, unknown>; dmPolicy?: unknown };
slack?: { dm?: Record<string, unknown>; dmPolicy?: unknown };
};
};
expect(persisted.channels?.discord?.dmPolicy).toBe("pairing");
expect(persisted.channels?.discord?.dm).toEqual({ enabled: true });
expect(persisted.channels?.slack?.dmPolicy).toBe("pairing");
expect(persisted.channels?.slack?.dm).toEqual({ enabled: true });
});
});
it("keeps env refs in arrays when appending entries", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
agents: {
defaults: {
cliBackends: {
codex: {
command: "codex",
args: ["${DISCORD_USER_ID}", "123"],
},
},
},
},
},
null,
2,
),
"utf-8",
);
const io = createConfigIO({
env: { DISCORD_USER_ID: "999" } as NodeJS.ProcessEnv,
homedir: () => home,
logger: silentLogger,
});
const snapshot = await io.readConfigFileSnapshot();
expect(snapshot.valid).toBe(true);
const next = structuredClone(snapshot.config) as {
agents?: {
defaults?: {
cliBackends?: Record<
string,
{
command?: string;
args?: string[];
}
>;
};
};
};
const codexBackend = next.agents?.defaults?.cliBackends?.codex;
const args = Array.isArray(codexBackend?.args) ? codexBackend?.args : [];
next.agents = {
...next.agents,
defaults: {
...next.agents?.defaults,
cliBackends: {
...next.agents?.defaults?.cliBackends,
codex: {
...codexBackend,
command: typeof codexBackend?.command === "string" ? codexBackend.command : "codex",
args: [...args, "456"],
},
},
},
};
await io.writeConfigFile(next as OpenClawConfig);
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
agents: {
defaults: {
cliBackends: {
codex: {
args: string[];
};
};
};
};
};
expect(persisted.agents.defaults.cliBackends.codex.args).toEqual([
"${DISCORD_USER_ID}",
"123",
"456",
]);
});
});
it("logs an overwrite audit entry when replacing an existing config file", async () => {
await withSuiteHome(async (home) => {
const warn = vi.fn();
const { configPath, io, snapshot } = await writeConfigAndCreateIo({
home,
initialConfig: { gateway: { port: 18789 } },
env: {} as NodeJS.ProcessEnv,
logger: {
warn: warn as (msg: string) => void,
error: vi.fn() as (msg: string) => void,
},
});
const next = structuredClone(snapshot.config);
next.gateway = {
...next.gateway,
auth: { mode: "token" },
};
await io.writeConfigFile(next);
const overwriteLog = warn.mock.calls
.map((call) => call[0])
.find((entry) => typeof entry === "string" && entry.startsWith("Config overwrite:"));
expect(typeof overwriteLog).toBe("string");
expect(overwriteLog).toContain(configPath);
expect(overwriteLog).toContain(`${configPath}.bak`);
expect(overwriteLog).toContain("sha256");
});
});
it("does not log an overwrite audit entry when creating config for the first time", async () => {
await withSuiteHome(async (home) => {
const warn = vi.fn();
@@ -678,147 +178,4 @@ describe("config io write", () => {
expect(overwriteLogs).toHaveLength(0);
});
});
it("appends config write audit JSONL entries with forensic metadata", async () => {
await withSuiteHome(async (home) => {
const { configPath, lines, last } = await writeGatewayPatchAndReadLastAuditEntry({
home,
initialConfig: { gateway: { port: 18789 } },
gatewayPatch: { mode: "local" },
env: {} as NodeJS.ProcessEnv,
});
expect(lines.length).toBeGreaterThan(0);
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.previousMode).toBeTypeOf("number");
expect(last.nextMode).toBeTypeOf("number");
expect(last.previousIno).toBeTypeOf("string");
expect(last.nextIno).toBeTypeOf("string");
expect(last.result === "rename" || last.result === "copy-fallback").toBe(true);
});
});
it('ignores literal "undefined" home env values when choosing the audit log path', async () => {
await withSuiteHome(async (home) => {
const { lines } = await writeGatewayPatchAndReadLastAuditEntry({
home,
initialConfig: { gateway: { mode: "local" } },
gatewayPatch: { bind: "loopback" },
env: {
HOME: "undefined",
USERPROFILE: "null",
OPENCLAW_HOME: "undefined",
} as NodeJS.ProcessEnv,
});
expect(lines.length).toBeGreaterThan(0);
await expect(
fs.stat(path.join(home, ".openclaw", "logs", "config-audit.jsonl")),
).resolves.toBeDefined();
await expect(
fs.stat(path.resolve("undefined", ".openclaw", "logs", "config-audit.jsonl")),
).rejects.toThrow();
});
});
it("records gateway watch session markers in config audit entries", async () => {
await withSuiteHome(async (home) => {
const { last } = await writeGatewayPatchAndReadLastAuditEntry({
home,
initialConfig: { gateway: { mode: "local" } },
gatewayPatch: { bind: "loopback" },
env: {
OPENCLAW_WATCH_MODE: "1",
OPENCLAW_WATCH_SESSION: "watch-session-1",
OPENCLAW_WATCH_COMMAND: "gateway --force",
} as NodeJS.ProcessEnv,
});
expect(last.watchMode).toBe(true);
expect(last.watchSession).toBe("watch-session-1");
expect(last.watchCommand).toBe("gateway --force");
});
});
it("accepts unrelated writes when the file still contains legacy nested allow aliases", async () => {
await withSuiteHome(async (home) => {
const { configPath, io, snapshot } = await writeConfigAndCreateIo({
home,
initialConfig: {
channels: {
slack: {
channels: {
ops: {
allow: false,
},
},
},
googlechat: {
groups: {
"spaces/aaa": {
allow: true,
},
},
},
discord: {
guilds: {
"100": {
channels: {
general: {
allow: false,
},
},
},
},
},
},
},
});
const next = structuredClone(snapshot.config);
next.gateway = {
...next.gateway,
auth: { mode: "token" },
};
await io.writeConfigFile(next);
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
channels?: Record<string, unknown>;
gateway?: Record<string, unknown>;
};
expect(persisted.gateway).toEqual({
auth: { mode: "token" },
});
expect(
(
(persisted.channels?.slack as { channels?: Record<string, unknown> } | undefined)
?.channels?.ops as Record<string, unknown> | undefined
)?.enabled,
).toBe(false);
expect(
(
(persisted.channels?.googlechat as { groups?: Record<string, unknown> } | undefined)
?.groups?.["spaces/aaa"] as Record<string, unknown> | undefined
)?.enabled,
).toBe(true);
expect(
(
(
(persisted.channels?.discord as { guilds?: Record<string, unknown> } | undefined)
?.guilds?.["100"] as { channels?: Record<string, unknown> } | undefined
)?.channels?.general as Record<string, unknown> | undefined
)?.enabled,
).toBe(false);
expect(
(
(persisted.channels?.slack as { channels?: Record<string, unknown> } | undefined)
?.channels?.ops as Record<string, unknown> | undefined
)?.allow,
).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,415 @@
import { describe, expect, it } from "vitest";
import {
collectChangedPaths,
formatConfigValidationFailure,
restoreEnvRefsFromMap,
resolvePersistCandidateForWrite,
resolveWriteEnvSnapshotForPath,
unsetPathForWrite,
} from "./io.write-prepare.js";
describe("config io write prepare", () => {
it("persists caller changes onto resolved config without leaking runtime defaults", () => {
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: {
gateway: { port: 18789 },
agents: { defaults: { cliBackend: "codex" } },
messages: { ackReaction: "eyes" },
sessions: { persistence: true },
},
sourceConfig: {
gateway: { port: 18789 },
},
nextConfig: {
gateway: {
port: 18789,
auth: { mode: "token" },
},
},
}) as Record<string, unknown>;
expect(persisted.gateway).toEqual({
port: 18789,
auth: { mode: "token" },
});
expect(persisted).not.toHaveProperty("agents.defaults");
expect(persisted).not.toHaveProperty("messages.ackReaction");
expect(persisted).not.toHaveProperty("sessions.persistence");
});
it('formats actionable guidance for dmPolicy="open" without wildcard allowFrom', () => {
const message = formatConfigValidationFailure(
"channels.telegram.allowFrom",
'channels.telegram.dmPolicy = "open" requires channels.telegram.allowFrom to include "*"',
);
expect(message).toContain("openclaw config set channels.telegram.allowFrom '[\"*\"]'");
expect(message).toContain('openclaw config set channels.telegram.dmPolicy "pairing"');
});
it("unsets explicit paths when runtime defaults would otherwise reappear", () => {
const next = unsetPathForWrite(
{
gateway: { auth: { mode: "none" } },
commands: { ownerDisplay: "hash" },
},
["commands", "ownerDisplay"],
);
expect(next.changed).toBe(true);
expect(next.next.commands ?? {}).not.toHaveProperty("ownerDisplay");
});
it("does not mutate caller config when unsetting existing config objects", () => {
const input = {
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
};
const next = unsetPathForWrite(input, ["commands", "ownerDisplay"]);
expect(input).toEqual({
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
});
expect(next.next.commands ?? {}).not.toHaveProperty("ownerDisplay");
});
it("keeps caller arrays immutable when unsetting array entries", () => {
const input = {
gateway: { mode: "local" },
tools: { alsoAllow: ["exec", "fetch", "read"] },
};
const next = unsetPathForWrite(input, ["tools", "alsoAllow", "1"]);
expect(input.tools.alsoAllow).toEqual(["exec", "fetch", "read"]);
expect((next.next.tools as { alsoAllow?: string[] } | undefined)?.alsoAllow).toEqual([
"exec",
"read",
]);
});
it("treats missing unset paths as no-op without mutating caller config", () => {
const input = {
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
};
const next = unsetPathForWrite(input, ["commands", "missingKey"]);
expect(next.changed).toBe(false);
expect(next.next).toBe(input);
expect(input).toEqual({
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
});
});
it("ignores blocked prototype-key unset path segments", () => {
const input = {
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
};
const blocked = [
["commands", "__proto__"],
["commands", "constructor"],
["commands", "prototype"],
].map((segments) => unsetPathForWrite(input, segments));
for (const result of blocked) {
expect(result.changed).toBe(false);
expect(result.next).toBe(input);
}
expect(input).toEqual({
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
});
});
it("preserves env refs on unchanged paths while keeping changed paths resolved", () => {
const changedPaths = new Set<string>();
collectChangedPaths(
{
agents: {
defaults: {
cliBackends: {
codex: {
env: { OPENAI_API_KEY: "sk-secret" },
},
},
},
},
gateway: { port: 18789 },
},
{
agents: {
defaults: {
cliBackends: {
codex: {
env: { OPENAI_API_KEY: "sk-secret" },
},
},
},
},
gateway: {
port: 18789,
auth: { mode: "token" },
},
},
"",
changedPaths,
);
const restored = restoreEnvRefsFromMap(
{
agents: {
defaults: {
cliBackends: {
codex: {
env: { OPENAI_API_KEY: "sk-secret" },
},
},
},
},
gateway: {
port: 18789,
auth: { mode: "token" },
},
},
"",
new Map([["agents.defaults.cliBackends.codex.env.OPENAI_API_KEY", "${OPENAI_API_KEY}"]]),
changedPaths,
) as {
agents: { defaults: { cliBackends: { codex: { env: { OPENAI_API_KEY: string } } } } };
gateway: { port: number; auth: { mode: string } };
};
expect(restored.agents.defaults.cliBackends.codex.env.OPENAI_API_KEY).toBe("${OPENAI_API_KEY}");
expect(restored.gateway).toEqual({
port: 18789,
auth: { mode: "token" },
});
});
it("preserves env refs in arrays while keeping appended entries resolved", () => {
const changedPaths = new Set<string>();
collectChangedPaths(
{
agents: {
defaults: {
cliBackends: {
codex: {
args: ["${DISCORD_USER_ID}", "123"],
},
},
},
},
},
{
agents: {
defaults: {
cliBackends: {
codex: {
args: ["${DISCORD_USER_ID}", "123", "456"],
},
},
},
},
},
"",
changedPaths,
);
const restored = restoreEnvRefsFromMap(
{
agents: {
defaults: {
cliBackends: {
codex: {
args: ["999", "123", "456"],
},
},
},
},
},
"",
new Map([["agents.defaults.cliBackends.codex.args[0]", "${DISCORD_USER_ID}"]]),
changedPaths,
) as {
agents: { defaults: { cliBackends: { codex: { args: string[] } } } };
};
expect(restored.agents.defaults.cliBackends.codex.args).toEqual([
"${DISCORD_USER_ID}",
"123",
"456",
]);
});
it("keeps the read-time env snapshot when writing the same config path", () => {
const snapshot = { OPENAI_API_KEY: "sk-secret" };
expect(
resolveWriteEnvSnapshotForPath({
actualConfigPath: "/tmp/openclaw.json",
expectedConfigPath: "/tmp/openclaw.json",
envSnapshotForRestore: snapshot,
}),
).toBe(snapshot);
});
it("drops the read-time env snapshot when writing a different config path", () => {
expect(
resolveWriteEnvSnapshotForPath({
actualConfigPath: "/tmp/openclaw.json",
expectedConfigPath: "/tmp/other.json",
envSnapshotForRestore: { OPENAI_API_KEY: "sk-secret" },
}),
).toBeUndefined();
});
it("keeps plugin AJV defaults out of the persisted candidate", () => {
const sourceConfig = {
gateway: { port: 18789 },
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const runtimeConfig = {
gateway: { port: 18789 },
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
enrichGroupParticipantsFromContacts: true,
},
},
};
const nextConfig = structuredClone(runtimeConfig);
nextConfig.gateway = {
...nextConfig.gateway,
auth: { mode: "token" },
};
const persisted = resolvePersistCandidateForWrite({
runtimeConfig,
sourceConfig,
nextConfig,
}) as Record<string, unknown>;
expect(persisted.gateway).toEqual({
port: 18789,
auth: { mode: "token" },
});
const channels = persisted.channels as Record<string, Record<string, unknown>> | undefined;
expect(channels?.bluebubbles).toBeDefined();
expect(channels?.bluebubbles).not.toHaveProperty("enrichGroupParticipantsFromContacts");
expect(channels?.bluebubbles?.serverUrl).toBe("http://localhost:1234");
expect(channels?.bluebubbles?.password).toBe("test-password");
});
it("does not reintroduce legacy nested dm.policy defaults in the persisted candidate", () => {
const sourceConfig = {
channels: {
discord: {
dmPolicy: "pairing",
dm: { enabled: true, policy: "pairing" },
},
slack: {
dmPolicy: "pairing",
dm: { enabled: true, policy: "pairing" },
},
},
gateway: { port: 18789 },
};
const nextConfig = structuredClone(sourceConfig);
delete nextConfig.channels.discord.dm.policy;
delete nextConfig.channels.slack.dm.policy;
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: sourceConfig,
sourceConfig,
nextConfig,
}) as {
channels?: {
discord?: { dm?: Record<string, unknown>; dmPolicy?: unknown };
slack?: { dm?: Record<string, unknown>; dmPolicy?: unknown };
};
};
expect(persisted.channels?.discord?.dmPolicy).toBe("pairing");
expect(persisted.channels?.discord?.dm).toEqual({ enabled: true });
expect(persisted.channels?.slack?.dmPolicy).toBe("pairing");
expect(persisted.channels?.slack?.dm).toEqual({ enabled: true });
});
it("preserves normalized nested channel enabled keys during unrelated writes", () => {
const sourceConfig = {
channels: {
slack: {
channels: {
ops: {
enabled: false,
},
},
},
googlechat: {
groups: {
"spaces/aaa": {
enabled: true,
},
},
},
discord: {
guilds: {
"100": {
channels: {
general: {
enabled: false,
},
},
},
},
},
},
};
const nextConfig = {
...structuredClone(sourceConfig),
gateway: {
auth: { mode: "token" },
},
};
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: sourceConfig,
sourceConfig,
nextConfig,
}) as {
channels?: {
slack?: { channels?: Record<string, Record<string, unknown>> };
googlechat?: { groups?: Record<string, Record<string, unknown>> };
discord?: {
guilds?: Record<string, { channels?: Record<string, Record<string, unknown>> }>;
};
};
gateway?: Record<string, unknown>;
};
expect(persisted.gateway).toEqual({
auth: { mode: "token" },
});
expect(persisted.channels?.slack?.channels?.ops).toEqual({ enabled: false });
expect(persisted.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({ enabled: true });
expect(persisted.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({
enabled: false,
});
});
});

View File

@@ -0,0 +1,322 @@
import { isDeepStrictEqual } from "node:util";
import { isRecord } from "../utils.js";
import { applyMergePatch } from "./merge-patch.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
import type { OpenClawConfig } from "./types.js";
const OPEN_DM_POLICY_ALLOW_FROM_RE =
/^(?<policyPath>[a-z0-9_.-]+)\s*=\s*"open"\s+requires\s+(?<allowPath>[a-z0-9_.-]+)(?:\s+\(or\s+[a-z0-9_.-]+\))?\s+to include "\*"$/i;
function cloneUnknown<T>(value: T): T {
return structuredClone(value);
}
export function createMergePatch(base: unknown, target: unknown): unknown {
if (!isRecord(base) || !isRecord(target)) {
return cloneUnknown(target);
}
const patch: Record<string, unknown> = {};
const keys = new Set([...Object.keys(base), ...Object.keys(target)]);
for (const key of keys) {
const hasBase = key in base;
const hasTarget = key in target;
if (!hasTarget) {
patch[key] = null;
continue;
}
const targetValue = target[key];
if (!hasBase) {
patch[key] = cloneUnknown(targetValue);
continue;
}
const baseValue = base[key];
if (isRecord(baseValue) && isRecord(targetValue)) {
const childPatch = createMergePatch(baseValue, targetValue);
if (isRecord(childPatch) && Object.keys(childPatch).length === 0) {
continue;
}
patch[key] = childPatch;
continue;
}
if (!isDeepStrictEqual(baseValue, targetValue)) {
patch[key] = cloneUnknown(targetValue);
}
}
return patch;
}
export function projectSourceOntoRuntimeShape(source: unknown, runtime: unknown): unknown {
if (!isRecord(source) || !isRecord(runtime)) {
return cloneUnknown(source);
}
const next: Record<string, unknown> = {};
for (const [key, sourceValue] of Object.entries(source)) {
if (!(key in runtime)) {
continue;
}
next[key] = projectSourceOntoRuntimeShape(sourceValue, runtime[key]);
}
return next;
}
export function resolvePersistCandidateForWrite(params: {
runtimeConfig: unknown;
sourceConfig: unknown;
nextConfig: unknown;
}): unknown {
const patch = createMergePatch(params.runtimeConfig, params.nextConfig);
const projectedSource = projectSourceOntoRuntimeShape(params.sourceConfig, params.runtimeConfig);
return applyMergePatch(projectedSource, patch);
}
export function formatConfigValidationFailure(pathLabel: string, issueMessage: string): string {
const match = issueMessage.match(OPEN_DM_POLICY_ALLOW_FROM_RE);
const policyPath = match?.groups?.policyPath?.trim();
const allowPath = match?.groups?.allowPath?.trim();
if (!policyPath || !allowPath) {
return `Config validation failed: ${pathLabel}: ${issueMessage}`;
}
return [
`Config validation failed: ${pathLabel}`,
"",
`Configuration mismatch: ${policyPath} is "open", but ${allowPath} does not include "*".`,
"",
"Fix with:",
` openclaw config set ${allowPath} '["*"]'`,
"",
"Or switch policy:",
` openclaw config set ${policyPath} "pairing"`,
].join("\n");
}
function isNumericPathSegment(raw: string): boolean {
return /^[0-9]+$/.test(raw);
}
function isWritePlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function hasOwnObjectKey(value: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
const WRITE_PRUNED_OBJECT = Symbol("write-pruned-object");
function coerceConfig(value: unknown): OpenClawConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as OpenClawConfig;
}
function unsetPathForWriteAt(
value: unknown,
pathSegments: string[],
depth: number,
): { changed: boolean; value: unknown } {
if (depth >= pathSegments.length) {
return { changed: false, value };
}
const segment = pathSegments[depth];
const isLeaf = depth === pathSegments.length - 1;
if (Array.isArray(value)) {
if (!isNumericPathSegment(segment)) {
return { changed: false, value };
}
const index = Number.parseInt(segment, 10);
if (!Number.isFinite(index) || index < 0 || index >= value.length) {
return { changed: false, value };
}
if (isLeaf) {
const next = value.slice();
next.splice(index, 1);
return { changed: true, value: next };
}
const child = unsetPathForWriteAt(value[index], pathSegments, depth + 1);
if (!child.changed) {
return { changed: false, value };
}
const next = value.slice();
if (child.value === WRITE_PRUNED_OBJECT) {
next.splice(index, 1);
} else {
next[index] = child.value;
}
return { changed: true, value: next };
}
if (
isBlockedObjectKey(segment) ||
!isWritePlainObject(value) ||
!hasOwnObjectKey(value, segment)
) {
return { changed: false, value };
}
if (isLeaf) {
const next: Record<string, unknown> = { ...value };
delete next[segment];
return {
changed: true,
value: Object.keys(next).length === 0 ? WRITE_PRUNED_OBJECT : next,
};
}
const child = unsetPathForWriteAt(value[segment], pathSegments, depth + 1);
if (!child.changed) {
return { changed: false, value };
}
const next: Record<string, unknown> = { ...value };
if (child.value === WRITE_PRUNED_OBJECT) {
delete next[segment];
} else {
next[segment] = child.value;
}
return {
changed: true,
value: Object.keys(next).length === 0 ? WRITE_PRUNED_OBJECT : next,
};
}
export function unsetPathForWrite(
root: OpenClawConfig,
pathSegments: string[],
): { changed: boolean; next: OpenClawConfig } {
if (pathSegments.length === 0) {
return { changed: false, next: root };
}
const result = unsetPathForWriteAt(root, pathSegments, 0);
if (!result.changed) {
return { changed: false, next: root };
}
if (result.value === WRITE_PRUNED_OBJECT) {
return { changed: true, next: {} };
}
if (isWritePlainObject(result.value)) {
return { changed: true, next: coerceConfig(result.value) };
}
return { changed: false, next: root };
}
export function collectChangedPaths(
base: unknown,
target: unknown,
path: string,
output: Set<string>,
): void {
if (Array.isArray(base) && Array.isArray(target)) {
const max = Math.max(base.length, target.length);
for (let index = 0; index < max; index += 1) {
const childPath = path ? `${path}[${index}]` : `[${index}]`;
if (index >= base.length || index >= target.length) {
output.add(childPath);
continue;
}
collectChangedPaths(base[index], target[index], childPath, output);
}
return;
}
if (isRecord(base) && isRecord(target)) {
const keys = new Set([...Object.keys(base), ...Object.keys(target)]);
for (const key of keys) {
const childPath = path ? `${path}.${key}` : key;
const hasBase = key in base;
const hasTarget = key in target;
if (!hasTarget || !hasBase) {
output.add(childPath);
continue;
}
collectChangedPaths(base[key], target[key], childPath, output);
}
return;
}
if (!isDeepStrictEqual(base, target)) {
output.add(path);
}
}
function parentPath(value: string): string {
if (!value) {
return "";
}
if (value.endsWith("]")) {
const index = value.lastIndexOf("[");
return index > 0 ? value.slice(0, index) : "";
}
const index = value.lastIndexOf(".");
return index >= 0 ? value.slice(0, index) : "";
}
function isPathChanged(path: string, changedPaths: Set<string>): boolean {
if (changedPaths.has(path)) {
return true;
}
let current = parentPath(path);
while (current) {
if (changedPaths.has(current)) {
return true;
}
current = parentPath(current);
}
return changedPaths.has("");
}
export function restoreEnvRefsFromMap(
value: unknown,
path: string,
envRefMap: Map<string, string>,
changedPaths: Set<string>,
): unknown {
if (typeof value === "string") {
if (!isPathChanged(path, changedPaths)) {
const original = envRefMap.get(path);
if (original !== undefined) {
return original;
}
}
return value;
}
if (Array.isArray(value)) {
let changed = false;
const next = value.map((item, index) => {
const updated = restoreEnvRefsFromMap(item, `${path}[${index}]`, envRefMap, changedPaths);
if (updated !== item) {
changed = true;
}
return updated;
});
return changed ? next : value;
}
if (isRecord(value)) {
let changed = false;
const next: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
const childPath = path ? `${path}.${key}` : key;
const updated = restoreEnvRefsFromMap(child, childPath, envRefMap, changedPaths);
if (updated !== child) {
changed = true;
}
next[key] = updated;
}
return changed ? next : value;
}
return value;
}
export function resolveWriteEnvSnapshotForPath(params: {
actualConfigPath: string;
expectedConfigPath?: string;
envSnapshotForRestore?: Record<string, string | undefined>;
}): Record<string, string | undefined> | undefined {
if (
params.expectedConfigPath === undefined ||
params.expectedConfigPath === params.actualConfigPath
) {
return params.envSnapshotForRestore;
}
return undefined;
}