



















@@ -11,17 +11,24 @@ import {
1111import { registerRuntimeConfigWriteListener, resetConfigRuntimeState } from "./runtime-snapshot.js";
1212import type { ConfigFileSnapshot, OpenClawConfig } from "./types.js";
131314+type MockValidationIssue = { path: string; message: string };
15+type MockValidationResult =
16+| { ok: true; config: OpenClawConfig; warnings: MockValidationIssue[] }
17+| { ok: false; issues: MockValidationIssue[]; warnings: MockValidationIssue[] };
18+1419const ioMocks = vi.hoisted(() => ({
1520readConfigFileSnapshotForWrite: vi.fn(),
1621resolveConfigSnapshotHash: vi.fn(),
1722writeConfigFile: vi.fn(),
1823}));
1924const validationMocks = vi.hoisted(() => ({
20-validateConfigObjectWithPlugins: vi.fn((config: OpenClawConfig) => ({
21-ok: true,
22- config,
23-warnings: [],
24-})),
25+validateConfigObjectWithPlugins: vi.fn(
26+(config: OpenClawConfig): MockValidationResult => ({
27+ok: true,
28+ config,
29+warnings: [],
30+}),
31+),
2532}));
26332734vi.mock("./io.js", () => ioMocks);
@@ -533,6 +540,132 @@ describe("config mutate helpers", () => {
533540expect(persistedPlugins.installs).toBeUndefined();
534541});
535542543+it("keeps single-file top-level plugins include writes when plugin validation is skipped", async () => {
544+const home = await suiteRootTracker.make("include-skip-plugin-validation");
545+const configPath = path.join(home, ".openclaw", "openclaw.json");
546+const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5");
547+await fs.mkdir(path.dirname(pluginsPath), { recursive: true });
548+await fs.writeFile(
549+configPath,
550+`${JSON.stringify({ plugins: { $include: "./config/plugins.json5" } }, null, 2)}\n`,
551+"utf-8",
552+);
553+await fs.writeFile(pluginsPath, `${JSON.stringify({ entries: {} }, null, 2)}\n`, "utf-8");
554+const snapshot = createSnapshot({
555+hash: "hash-include-skip",
556+path: configPath,
557+parsed: { plugins: { $include: "./config/plugins.json5" } },
558+sourceConfig: { plugins: { entries: {} } },
559+});
560+const refreshedSnapshot = createSnapshot({
561+hash: "hash-include-skip-refreshed",
562+path: configPath,
563+parsed: { plugins: { $include: "./config/plugins.json5" } },
564+sourceConfig: {
565+plugins: {
566+entries: {
567+"strict-plugin": { enabled: true },
568+},
569+},
570+},
571+});
572+ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({
573+snapshot: refreshedSnapshot,
574+writeOptions: { expectedConfigPath: configPath },
575+});
576+const nextConfig: OpenClawConfig = {
577+plugins: {
578+entries: {
579+"strict-plugin": { enabled: true },
580+},
581+},
582+};
583+584+await replaceConfigFile({
585+baseHash: snapshot.hash,
586+ snapshot,
587+writeOptions: {
588+expectedConfigPath: snapshot.path,
589+skipPluginValidation: true,
590+},
591+ nextConfig,
592+});
593+594+expect(ioMocks.writeConfigFile).not.toHaveBeenCalled();
595+expect(validationMocks.validateConfigObjectWithPlugins).toHaveBeenCalledWith(nextConfig, {
596+pluginValidation: "skip",
597+});
598+expect(ioMocks.readConfigFileSnapshotForWrite).toHaveBeenCalledWith({
599+skipPluginValidation: true,
600+});
601+await expect(fs.readFile(configPath, "utf-8")).resolves.toContain(
602+'"$include": "./config/plugins.json5"',
603+);
604+const persistedPlugins = JSON.parse(await fs.readFile(pluginsPath, "utf-8")) as {
605+entries?: Record<string, unknown>;
606+};
607+expect(persistedPlugins.entries?.["strict-plugin"]).toEqual({ enabled: true });
608+});
609+610+it("rejects invalid base config before skipped-plugin include writes", async () => {
611+const home = await suiteRootTracker.make("include-skip-invalid-base");
612+const configPath = path.join(home, ".openclaw", "openclaw.json");
613+const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5");
614+await fs.mkdir(path.dirname(pluginsPath), { recursive: true });
615+await fs.writeFile(
616+configPath,
617+`${JSON.stringify({ plugins: { $include: "./config/plugins.json5" } }, null, 2)}\n`,
618+"utf-8",
619+);
620+await fs.writeFile(
621+pluginsPath,
622+`${JSON.stringify({ entries: { old: { enabled: true } } }, null, 2)}\n`,
623+"utf-8",
624+);
625+const snapshot = createSnapshot({
626+hash: "hash-include-invalid-base",
627+path: configPath,
628+parsed: { plugins: { $include: "./config/plugins.json5" } },
629+sourceConfig: { plugins: { entries: { old: { enabled: true } } } },
630+});
631+const nextConfig = {
632+plugins: {
633+entries: {
634+"strict-plugin": { enabled: "yes" },
635+},
636+},
637+} as unknown as OpenClawConfig;
638+validationMocks.validateConfigObjectWithPlugins.mockReturnValue({
639+ok: false,
640+issues: [
641+{
642+path: "plugins.entries.strict-plugin.enabled",
643+message: "Expected boolean",
644+},
645+],
646+warnings: [],
647+});
648+649+await expect(
650+replaceConfigFile({
651+baseHash: snapshot.hash,
652+ snapshot,
653+writeOptions: {
654+expectedConfigPath: snapshot.path,
655+skipPluginValidation: true,
656+},
657+ nextConfig,
658+}),
659+).rejects.toThrow("plugins.entries.strict-plugin.enabled: Expected boolean");
660+661+expect(ioMocks.writeConfigFile).not.toHaveBeenCalled();
662+expect(ioMocks.readConfigFileSnapshotForWrite).not.toHaveBeenCalled();
663+const persistedPlugins = JSON.parse(await fs.readFile(pluginsPath, "utf-8")) as {
664+entries?: Record<string, unknown>;
665+};
666+expect(persistedPlugins.entries).toEqual({ old: { enabled: true } });
667+});
668+536669it("falls back to the root writer when a plugins include write is not isolated", async () => {
537670const snapshot = createSnapshot({
538671hash: "hash-multi",
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。