import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createConfigIO } from "./io.js"; async function withTempHome(run: (home: string) => Promise): Promise { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-")); try { await run(home); } finally { await fs.rm(home, { recursive: true, force: true }); } } async function writeConfig( home: string, dirname: ".openclaw", port: number, filename: string = "openclaw.json", ) { const dir = path.join(home, dirname); await fs.mkdir(dir, { recursive: true }); const configPath = path.join(dir, filename); await fs.writeFile(configPath, JSON.stringify({ gateway: { port } }, null, 2)); return configPath; } function createIoForHome(home: string, env: NodeJS.ProcessEnv = {} as NodeJS.ProcessEnv) { return createConfigIO({ env, homedir: () => home, }); } describe("config io paths", () => { it("uses ~/.openclaw/openclaw.json when config exists", async () => { await withTempHome(async (home) => { const configPath = await writeConfig(home, ".openclaw", 19001); const io = createIoForHome(home); expect(io.configPath).toBe(configPath); expect(io.loadConfig().gateway?.port).toBe(19001); }); }); it("defaults to ~/.openclaw/openclaw.json when config is missing", async () => { await withTempHome(async (home) => { const io = createIoForHome(home); expect(io.configPath).toBe(path.join(home, ".openclaw", "openclaw.json")); }); }); it("uses OPENCLAW_HOME for default config path", async () => { await withTempHome(async (home) => { const io = createConfigIO({ env: { OPENCLAW_HOME: path.join(home, "svc-home") } as NodeJS.ProcessEnv, homedir: () => path.join(home, "ignored-home"), }); expect(io.configPath).toBe(path.join(home, "svc-home", ".openclaw", "openclaw.json")); }); }); it("honors explicit OPENCLAW_CONFIG_PATH override", async () => { await withTempHome(async (home) => { const customPath = await writeConfig(home, ".openclaw", 20002, "custom.json"); const io = createIoForHome(home, { OPENCLAW_CONFIG_PATH: customPath } as NodeJS.ProcessEnv); expect(io.configPath).toBe(customPath); expect(io.loadConfig().gateway?.port).toBe(20002); }); }); it("honors legacy CLAWDBOT_CONFIG_PATH override", async () => { await withTempHome(async (home) => { const customPath = await writeConfig(home, ".openclaw", 20003, "legacy-custom.json"); const io = createIoForHome(home, { CLAWDBOT_CONFIG_PATH: customPath } as NodeJS.ProcessEnv); expect(io.configPath).toBe(customPath); expect(io.loadConfig().gateway?.port).toBe(20003); }); }); it("normalizes safe-bin config entries at config load time", 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( { tools: { exec: { safeBinTrustedDirs: [" /custom/bin ", "", "/custom/bin", "/agent/bin"], safeBinProfiles: { " MyFilter ": { allowedValueFlags: ["--limit", " --limit ", ""], }, }, }, }, agents: { list: [ { id: "ops", tools: { exec: { safeBinTrustedDirs: [" /ops/bin ", "/ops/bin"], safeBinProfiles: { " Custom ": { deniedFlags: ["-f", " -f ", ""], }, }, }, }, }, ], }, }, null, 2, ), "utf-8", ); const io = createIoForHome(home); expect(io.configPath).toBe(configPath); const cfg = io.loadConfig(); expect(cfg.tools?.exec?.safeBinProfiles).toEqual({ myfilter: { allowedValueFlags: ["--limit"], }, }); expect(cfg.tools?.exec?.safeBinTrustedDirs).toEqual(["/custom/bin", "/agent/bin"]); expect(cfg.agents?.list?.[0]?.tools?.exec?.safeBinProfiles).toEqual({ custom: { deniedFlags: ["-f"], }, }); 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("auto-migrates legacy tlon install specs during load and snapshot reads", 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( { plugins: { installs: { tlon: { source: "npm", spec: "@openclaw/tlon@2026.2.21", resolvedName: "@openclaw/tlon", resolvedVersion: "2026.2.21", resolvedSpec: "@openclaw/tlon@2026.2.21", integrity: "sha512-old", shasum: "old", resolvedAt: "2026-03-01T00:00:00.000Z", installPath: "/tmp/tlon", version: "2026.2.21", }, }, }, }, null, 2, ), "utf-8", ); const io = createIoForHome(home); expect(io.loadConfig().plugins?.installs?.tlon?.spec).toBe("@tloncorp/openclaw@2026.2.21"); expect(io.loadConfig().plugins?.installs?.tlon?.resolvedSpec).toBeUndefined(); const snapshot = await io.readConfigFileSnapshot(); expect(snapshot.valid).toBe(true); expect( snapshot.legacyIssues.some((issue) => issue.path === "plugins.installs.tlon.spec"), ).toBe(true); expect(snapshot.config.plugins?.installs?.tlon?.spec).toBe("@tloncorp/openclaw@2026.2.21"); expect(snapshot.config.plugins?.installs?.tlon?.resolvedSpec).toBeUndefined(); }); }); });