Spaces:
Paused
Paused
File size: 7,403 Bytes
c1243f9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 | import { Command } from "commander";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Test for issue #6070:
* `openclaw config set` should use snapshot.parsed (raw user config) instead of
* snapshot.config (runtime-merged config with defaults), to avoid overwriting
* the entire config with defaults when validation fails or config is unreadable.
*/
const mockLog = vi.fn();
const mockError = vi.fn();
const mockExit = vi.fn((code: number) => {
const errorMessages = mockError.mock.calls.map((c) => c.join(" ")).join("; ");
throw new Error(`__exit__:${code} - ${errorMessages}`);
});
vi.mock("../runtime.js", () => ({
defaultRuntime: {
log: (...args: unknown[]) => mockLog(...args),
error: (...args: unknown[]) => mockError(...args),
exit: (code: number) => mockExit(code),
},
}));
async function withTempHome(run: (home: string) => Promise<void>): Promise<void> {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-cli-"));
const originalEnv = { ...process.env };
try {
// Override config path to use temp directory
process.env.OPENCLAW_CONFIG_PATH = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.join(home, ".openclaw"), { recursive: true });
await run(home);
} finally {
process.env = originalEnv;
await fs.rm(home, { recursive: true, force: true });
}
}
async function readConfigFile(home: string): Promise<Record<string, unknown>> {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const content = await fs.readFile(configPath, "utf-8");
return JSON.parse(content);
}
async function writeConfigFile(home: string, config: Record<string, unknown>): Promise<void> {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
}
describe("config cli", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("config set - issue #6070", () => {
it("preserves existing config keys when setting a new value", async () => {
await withTempHome(async (home) => {
// Set up a config file with multiple existing settings (using valid schema)
const initialConfig = {
agents: {
list: [{ id: "main" }, { id: "oracle", workspace: "~/oracle-workspace" }],
},
gateway: {
port: 18789,
},
tools: {
allow: ["group:fs"],
},
logging: {
level: "debug",
},
};
await writeConfigFile(home, initialConfig);
// Run config set to add a new value
const { registerConfigCli } = await import("./config-cli.js");
const program = new Command();
program.exitOverride();
registerConfigCli(program);
await program.parseAsync(["config", "set", "gateway.auth.mode", "token"], { from: "user" });
// Read the config file and verify ALL original keys are preserved
const finalConfig = await readConfigFile(home);
// The new value should be set
expect((finalConfig.gateway as Record<string, unknown>).auth).toEqual({ mode: "token" });
// ALL original settings must still be present (this is the key assertion for #6070)
// The key bug in #6070 was that runtime defaults (like agents.defaults) were being
// written to the file, and paths were being expanded. This test verifies the fix.
expect(finalConfig.agents).not.toHaveProperty("defaults"); // No runtime defaults injected
expect((finalConfig.agents as Record<string, unknown>).list).toEqual(
initialConfig.agents.list,
);
expect((finalConfig.gateway as Record<string, unknown>).port).toBe(18789);
expect(finalConfig.tools).toEqual(initialConfig.tools);
expect(finalConfig.logging).toEqual(initialConfig.logging);
});
});
it("does not inject runtime defaults into the written config", async () => {
await withTempHome(async (home) => {
// Set up a minimal config file
const initialConfig = {
gateway: { port: 18789 },
};
await writeConfigFile(home, initialConfig);
// Run config set
const { registerConfigCli } = await import("./config-cli.js");
const program = new Command();
program.exitOverride();
registerConfigCli(program);
await program.parseAsync(["config", "set", "gateway.auth.mode", "token"], {
from: "user",
});
// Read the config file
const finalConfig = await readConfigFile(home);
// The config should NOT contain runtime defaults that weren't originally in the file
// These are examples of defaults that get merged in by applyModelDefaults, applyAgentDefaults, etc.
expect(finalConfig).not.toHaveProperty("agents.defaults.model");
expect(finalConfig).not.toHaveProperty("agents.defaults.contextWindow");
expect(finalConfig).not.toHaveProperty("agents.defaults.maxTokens");
expect(finalConfig).not.toHaveProperty("messages.ackReaction");
expect(finalConfig).not.toHaveProperty("sessions.persistence");
// Original config should still be present
expect((finalConfig.gateway as Record<string, unknown>).port).toBe(18789);
// New value should be set
expect((finalConfig.gateway as Record<string, unknown>).auth).toEqual({ mode: "token" });
});
});
});
describe("config unset - issue #6070", () => {
it("preserves existing config keys when unsetting a value", async () => {
await withTempHome(async (home) => {
// Set up a config file with multiple existing settings (using valid schema)
const initialConfig = {
agents: { list: [{ id: "main" }] },
gateway: { port: 18789 },
tools: {
profile: "coding",
alsoAllow: ["agents_list"],
},
logging: {
level: "debug",
},
};
await writeConfigFile(home, initialConfig);
// Run config unset to remove a value
const { registerConfigCli } = await import("./config-cli.js");
const program = new Command();
program.exitOverride();
registerConfigCli(program);
await program.parseAsync(["config", "unset", "tools.alsoAllow"], { from: "user" });
// Read the config file and verify ALL original keys (except the unset one) are preserved
const finalConfig = await readConfigFile(home);
// The value should be removed
expect(finalConfig.tools as Record<string, unknown>).not.toHaveProperty("alsoAllow");
// ALL other original settings must still be present (no runtime defaults injected)
expect(finalConfig.agents).not.toHaveProperty("defaults");
expect((finalConfig.agents as Record<string, unknown>).list).toEqual(
initialConfig.agents.list,
);
expect(finalConfig.gateway).toEqual(initialConfig.gateway);
expect((finalConfig.tools as Record<string, unknown>).profile).toBe("coding");
expect(finalConfig.logging).toEqual(initialConfig.logging);
});
});
});
});
|