mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 15:30:22 +00:00
test: split config io coverage
This commit is contained in:
@@ -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
204
src/config/io.audit.test.ts
Normal 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
329
src/config/io.audit.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
43
src/config/io.invalid-config.test.ts
Normal file
43
src/config/io.invalid-config.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
26
src/config/io.invalid-config.ts
Normal file
26
src/config/io.invalid-config.ts
Normal 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;
|
||||
}
|
||||
583
src/config/io.ts
583
src/config/io.ts
@@ -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 = () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
415
src/config/io.write-prepare.test.ts
Normal file
415
src/config/io.write-prepare.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
322
src/config/io.write-prepare.ts
Normal file
322
src/config/io.write-prepare.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user