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);
      });
    });
  });
});