Files
openclaw/src/config/io.write-config.test.ts
2026-04-09 04:38:50 +01:00

247 lines
7.9 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { createConfigIO } from "./io.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
// test exercise the exact code path that caused the bug: AJV injecting
// defaults during the write-back validation pass.
const mockLoadPluginManifestRegistry = vi.hoisted(() =>
vi.fn(
(): PluginManifestRegistry => ({
diagnostics: [],
plugins: [],
}),
),
);
const mockMaintainConfigBackups = vi.hoisted(() =>
vi.fn<typeof import("./backup-rotation.js").maintainConfigBackups>(async () => {}),
);
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: mockLoadPluginManifestRegistry,
}));
vi.mock("../plugins/doctor-contract-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/doctor-contract-registry.js")>();
return {
...actual,
listPluginDoctorLegacyConfigRules: () => [],
applyPluginDoctorCompatibilityMigrations: () => ({ next: null, changes: [] }),
};
});
vi.mock("./backup-rotation.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./backup-rotation.js")>();
return {
...actual,
maintainConfigBackups: mockMaintainConfigBackups,
};
});
describe("config io write", () => {
const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-config-io-" });
const silentLogger = {
warn: () => {},
error: () => {},
};
async function withSuiteHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const home = await suiteRootTracker.make("case");
return fn(home);
}
beforeAll(async () => {
await suiteRootTracker.setup();
// Default: return an empty plugin list so existing tests that don't need
// plugin-owned channel schemas keep working unchanged.
mockLoadPluginManifestRegistry.mockReturnValue({
diagnostics: [],
plugins: [],
} satisfies PluginManifestRegistry);
});
afterAll(async () => {
await suiteRootTracker.cleanup();
});
const expectInputOwnerDisplayUnchanged = (input: Record<string, unknown>) => {
expect((input.commands as Record<string, unknown>).ownerDisplay).toBe("hash");
};
const readPersistedCommands = async (configPath: string) => {
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
commands?: Record<string, unknown>;
};
return persisted.commands;
};
it.runIf(process.platform !== "win32")(
"tightens world-writable state dir when writing the default config",
async () => {
await withSuiteHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
await fs.mkdir(stateDir, { recursive: true, mode: 0o777 });
await fs.chmod(stateDir, 0o777);
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
logger: silentLogger,
});
await io.writeConfigFile({ gateway: { mode: "local" } });
const stat = await fs.stat(stateDir);
expect(stat.mode & 0o777).toBe(0o700);
});
},
);
it("keeps writes inside an OPENCLAW_STATE_DIR override even when the real home config exists", async () => {
await withSuiteHome(async (home) => {
const liveConfigPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(liveConfigPath), { recursive: true });
await fs.writeFile(
liveConfigPath,
`${JSON.stringify({ gateway: { mode: "local", port: 18789 } }, null, 2)}\n`,
"utf-8",
);
const overrideDir = path.join(home, "isolated-state");
const env = { OPENCLAW_STATE_DIR: overrideDir } as NodeJS.ProcessEnv;
const io = createConfigIO({
env,
homedir: () => home,
logger: silentLogger,
});
expect(io.configPath).toBe(path.join(overrideDir, "openclaw.json"));
await io.writeConfigFile({
agents: { list: [{ id: "main", default: true }] },
gateway: { mode: "local" },
session: { mainKey: "main", store: path.join(overrideDir, "sessions.json") },
});
const livePersisted = JSON.parse(await fs.readFile(liveConfigPath, "utf-8")) as {
gateway?: { mode?: unknown; port?: unknown };
};
expect(livePersisted.gateway).toEqual({ mode: "local", port: 18789 });
const overridePersisted = JSON.parse(
await fs.readFile(path.join(overrideDir, "openclaw.json"), "utf-8"),
) as {
session?: { store?: unknown };
};
expect(overridePersisted.session?.store).toBe(path.join(overrideDir, "sessions.json"));
});
});
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");
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
logger: silentLogger,
});
const input: Record<string, unknown> = {
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
};
await io.writeConfigFile(input, { unsetPaths: [["commands", "ownerDisplay"]] });
expect(input).toEqual({
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
});
expectInputOwnerDisplayUnchanged(input);
expect((await readPersistedCommands(configPath)) ?? {}).not.toHaveProperty("ownerDisplay");
});
});
it("does not log an overwrite audit entry when creating config for the first time", async () => {
await withSuiteHome(async (home) => {
const warn = vi.fn();
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
logger: {
warn,
error: vi.fn(),
},
});
await io.writeConfigFile({
gateway: { mode: "local" },
});
const overwriteLogs = warn.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].startsWith("Config overwrite:"),
);
expect(overwriteLogs).toHaveLength(0);
});
});
it("writes disabled plugin entries without requiring plugin config", async () => {
mockLoadPluginManifestRegistry.mockReturnValue({
diagnostics: [],
plugins: [
{
id: "required-plugin",
origin: "bundled",
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
rootDir: "/tmp/openclaw-test-required-plugin",
source: "/tmp/openclaw-test-required-plugin/index.ts",
manifestPath: "/tmp/openclaw-test-required-plugin/openclaw.plugin.json",
configSchema: {
type: "object",
properties: {
token: { type: "string" },
},
required: ["token"],
additionalProperties: true,
},
},
],
} satisfies PluginManifestRegistry);
await withSuiteHome(async (home) => {
const io = createConfigIO({
env: { VITEST: "true" } as NodeJS.ProcessEnv,
homedir: () => home,
logger: silentLogger,
});
await expect(
io.writeConfigFile({
agents: { list: [{ id: "main", default: true }] },
plugins: {
entries: {
"required-plugin": {
enabled: false,
},
},
},
}),
).resolves.toEqual({ persistedHash: expect.any(String) });
});
mockLoadPluginManifestRegistry.mockReturnValue({
diagnostics: [],
plugins: [],
} satisfies PluginManifestRegistry);
});
});