refactor: dedupe core config and runtime helpers

This commit is contained in:
Peter Steinberger
2026-02-22 17:11:34 +00:00
parent 24ea941e28
commit 34ea33f057
29 changed files with 720 additions and 874 deletions

View File

@@ -1,107 +1,80 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { loadConfig } from "./config.js";
import { withTempHome } from "./test-helpers.js";
import { withTempHomeConfig } from "./test-helpers.js";
describe("config compaction settings", () => {
it("preserves memory flush config values", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
agents: {
defaults: {
compaction: {
mode: "safeguard",
reserveTokensFloor: 12_345,
memoryFlush: {
enabled: false,
softThresholdTokens: 1234,
prompt: "Write notes.",
systemPrompt: "Flush memory now.",
},
},
await withTempHomeConfig(
{
agents: {
defaults: {
compaction: {
mode: "safeguard",
reserveTokensFloor: 12_345,
memoryFlush: {
enabled: false,
softThresholdTokens: 1234,
prompt: "Write notes.",
systemPrompt: "Flush memory now.",
},
},
},
null,
2,
),
"utf-8",
);
},
},
async () => {
const cfg = loadConfig();
const cfg = loadConfig();
expect(cfg.agents?.defaults?.compaction?.reserveTokensFloor).toBe(12_345);
expect(cfg.agents?.defaults?.compaction?.mode).toBe("safeguard");
expect(cfg.agents?.defaults?.compaction?.reserveTokens).toBeUndefined();
expect(cfg.agents?.defaults?.compaction?.keepRecentTokens).toBeUndefined();
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.enabled).toBe(false);
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.softThresholdTokens).toBe(1234);
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.prompt).toBe("Write notes.");
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.systemPrompt).toBe("Flush memory now.");
});
expect(cfg.agents?.defaults?.compaction?.reserveTokensFloor).toBe(12_345);
expect(cfg.agents?.defaults?.compaction?.mode).toBe("safeguard");
expect(cfg.agents?.defaults?.compaction?.reserveTokens).toBeUndefined();
expect(cfg.agents?.defaults?.compaction?.keepRecentTokens).toBeUndefined();
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.enabled).toBe(false);
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.softThresholdTokens).toBe(1234);
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.prompt).toBe("Write notes.");
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.systemPrompt).toBe(
"Flush memory now.",
);
},
);
});
it("preserves pi compaction override values", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
agents: {
defaults: {
compaction: {
reserveTokens: 15_000,
keepRecentTokens: 12_000,
},
},
await withTempHomeConfig(
{
agents: {
defaults: {
compaction: {
reserveTokens: 15_000,
keepRecentTokens: 12_000,
},
},
null,
2,
),
"utf-8",
);
const cfg = loadConfig();
expect(cfg.agents?.defaults?.compaction?.reserveTokens).toBe(15_000);
expect(cfg.agents?.defaults?.compaction?.keepRecentTokens).toBe(12_000);
});
},
},
async () => {
const cfg = loadConfig();
expect(cfg.agents?.defaults?.compaction?.reserveTokens).toBe(15_000);
expect(cfg.agents?.defaults?.compaction?.keepRecentTokens).toBe(12_000);
},
);
});
it("defaults compaction mode to safeguard", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
agents: {
defaults: {
compaction: {
reserveTokensFloor: 9000,
},
},
await withTempHomeConfig(
{
agents: {
defaults: {
compaction: {
reserveTokensFloor: 9000,
},
},
null,
2,
),
"utf-8",
);
},
},
async () => {
const cfg = loadConfig();
const cfg = loadConfig();
expect(cfg.agents?.defaults?.compaction?.mode).toBe("safeguard");
expect(cfg.agents?.defaults?.compaction?.reserveTokensFloor).toBe(9000);
});
expect(cfg.agents?.defaults?.compaction?.mode).toBe("safeguard");
expect(cfg.agents?.defaults?.compaction?.reserveTokensFloor).toBe(9000);
},
);
});
});

View File

@@ -1,8 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadConfig, validateConfigObject } from "./config.js";
import { withTempHome } from "./test-helpers.js";
import { withTempHomeConfig } from "./test-helpers.js";
describe("config discord", () => {
let previousHome: string | undefined;
@@ -16,57 +14,48 @@ describe("config discord", () => {
});
it("loads discord guild map + dm group settings", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
channels: {
discord: {
enabled: true,
dm: {
enabled: true,
allowFrom: ["steipete"],
groupEnabled: true,
groupChannels: ["openclaw-dm"],
},
actions: {
emojiUploads: true,
stickerUploads: false,
channels: true,
},
guilds: {
"123": {
slug: "friends-of-openclaw",
requireMention: false,
users: ["steipete"],
channels: {
general: { allow: true },
},
},
await withTempHomeConfig(
{
channels: {
discord: {
enabled: true,
dm: {
enabled: true,
allowFrom: ["steipete"],
groupEnabled: true,
groupChannels: ["openclaw-dm"],
},
actions: {
emojiUploads: true,
stickerUploads: false,
channels: true,
},
guilds: {
"123": {
slug: "friends-of-openclaw",
requireMention: false,
users: ["steipete"],
channels: {
general: { allow: true },
},
},
},
},
null,
2,
),
"utf-8",
);
},
},
async () => {
const cfg = loadConfig();
const cfg = loadConfig();
expect(cfg.channels?.discord?.enabled).toBe(true);
expect(cfg.channels?.discord?.dm?.groupEnabled).toBe(true);
expect(cfg.channels?.discord?.dm?.groupChannels).toEqual(["openclaw-dm"]);
expect(cfg.channels?.discord?.actions?.emojiUploads).toBe(true);
expect(cfg.channels?.discord?.actions?.stickerUploads).toBe(false);
expect(cfg.channels?.discord?.actions?.channels).toBe(true);
expect(cfg.channels?.discord?.guilds?.["123"]?.slug).toBe("friends-of-openclaw");
expect(cfg.channels?.discord?.guilds?.["123"]?.channels?.general?.allow).toBe(true);
});
expect(cfg.channels?.discord?.enabled).toBe(true);
expect(cfg.channels?.discord?.dm?.groupEnabled).toBe(true);
expect(cfg.channels?.discord?.dm?.groupChannels).toEqual(["openclaw-dm"]);
expect(cfg.channels?.discord?.actions?.emojiUploads).toBe(true);
expect(cfg.channels?.discord?.actions?.stickerUploads).toBe(false);
expect(cfg.channels?.discord?.actions?.channels).toBe(true);
expect(cfg.channels?.discord?.guilds?.["123"]?.slug).toBe("friends-of-openclaw");
expect(cfg.channels?.discord?.guilds?.["123"]?.channels?.general?.allow).toBe(true);
},
);
});
it("rejects numeric discord allowlist entries", () => {

View File

@@ -26,6 +26,22 @@ async function expectLoadRejectionPreservesField(params: {
});
}
type ConfigSnapshot = Awaited<ReturnType<typeof readConfigFileSnapshot>>;
async function withSnapshotForConfig(
config: unknown,
run: (params: { snapshot: ConfigSnapshot; parsed: unknown; configPath: string }) => Promise<void>,
) {
await withTempHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
const snapshot = await readConfigFileSnapshot();
const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as unknown;
await run({ snapshot, parsed, configPath });
});
}
function expectValidConfigValue(params: {
config: unknown;
readValue: (config: unknown) => unknown;
@@ -47,6 +63,20 @@ function expectInvalidIssuePath(config: unknown, expectedPath: string) {
}
}
function expectRoutingAllowFromLegacySnapshot(
ctx: { snapshot: ConfigSnapshot; parsed: unknown },
expectedAllowFrom: string[],
) {
expect(ctx.snapshot.valid).toBe(false);
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "routing.allowFrom")).toBe(true);
const parsed = ctx.parsed as {
routing?: { allowFrom?: string[] };
channels?: unknown;
};
expect(parsed.routing?.allowFrom).toEqual(expectedAllowFrom);
expect(parsed.channels).toBeUndefined();
}
describe("legacy config detection", () => {
it('accepts imessage.dmPolicy="open" with allowFrom "*"', async () => {
const res = validateConfigObject({
@@ -224,43 +254,30 @@ describe("legacy config detection", () => {
expect((res.config as { agent?: unknown } | undefined)?.agent).toBeUndefined();
});
it("flags legacy config in snapshot", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ routing: { allowFrom: ["+15555550123"] } }),
"utf-8",
);
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(false);
expect(snap.legacyIssues.some((issue) => issue.path === "routing.allowFrom")).toBe(true);
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
routing?: { allowFrom?: string[] };
channels?: unknown;
};
expect(parsed.routing?.allowFrom).toEqual(["+15555550123"]);
expect(parsed.channels).toBeUndefined();
await withSnapshotForConfig({ routing: { allowFrom: ["+15555550123"] } }, async (ctx) => {
expectRoutingAllowFromLegacySnapshot(ctx, ["+15555550123"]);
});
});
it("flags top-level memorySearch as legacy in snapshot", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ memorySearch: { provider: "local", fallback: "none" } }),
"utf-8",
);
await withSnapshotForConfig(
{ memorySearch: { provider: "local", fallback: "none" } },
async (ctx) => {
expect(ctx.snapshot.valid).toBe(false);
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "memorySearch")).toBe(true);
},
);
});
it("flags legacy provider sections in snapshot", async () => {
await withSnapshotForConfig({ whatsapp: { allowFrom: ["+1555"] } }, async (ctx) => {
expect(ctx.snapshot.valid).toBe(false);
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "whatsapp")).toBe(true);
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(false);
expect(snap.legacyIssues.some((issue) => issue.path === "memorySearch")).toBe(true);
const parsed = ctx.parsed as {
channels?: unknown;
whatsapp?: unknown;
};
expect(parsed.channels).toBeUndefined();
expect(parsed.whatsapp).toBeTruthy();
});
});
it("does not auto-migrate claude-cli auth profile mode on load", async () => {
@@ -293,52 +310,9 @@ describe("legacy config detection", () => {
expect(parsed.auth?.profiles?.["anthropic:claude-cli"]?.mode).toBe("token");
});
});
it("flags legacy provider sections in snapshot", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ whatsapp: { allowFrom: ["+1555"] } }, null, 2),
"utf-8",
);
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(false);
expect(snap.legacyIssues.some((issue) => issue.path === "whatsapp")).toBe(true);
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
channels?: unknown;
whatsapp?: unknown;
};
expect(parsed.channels).toBeUndefined();
expect(parsed.whatsapp).toBeTruthy();
});
});
it("flags routing.allowFrom in snapshot", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ routing: { allowFrom: ["+1666"] } }, null, 2),
"utf-8",
);
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(false);
expect(snap.legacyIssues.some((issue) => issue.path === "routing.allowFrom")).toBe(true);
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
channels?: unknown;
routing?: { allowFrom?: string[] };
};
expect(parsed.channels).toBeUndefined();
expect(parsed.routing?.allowFrom).toEqual(["+1666"]);
await withSnapshotForConfig({ routing: { allowFrom: ["+1666"] } }, async (ctx) => {
expectRoutingAllowFromLegacySnapshot(ctx, ["+1666"]);
});
});
it("rejects bindings[].match.provider on load", async () => {
@@ -374,61 +348,40 @@ describe("legacy config detection", () => {
});
});
it("rejects session.sendPolicy.rules[].match.provider on load", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
session: {
sendPolicy: {
rules: [{ action: "deny", match: { provider: "telegram" } }],
},
},
await withSnapshotForConfig(
{
session: {
sendPolicy: {
rules: [{ action: "deny", match: { provider: "telegram" } }],
},
null,
2,
),
"utf-8",
);
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(false);
expect(snap.issues.length).toBeGreaterThan(0);
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
session?: { sendPolicy?: { rules?: Array<{ match?: { provider?: string } }> } };
};
expect(parsed.session?.sendPolicy?.rules?.[0]?.match?.provider).toBe("telegram");
});
},
},
async (ctx) => {
expect(ctx.snapshot.valid).toBe(false);
expect(ctx.snapshot.issues.length).toBeGreaterThan(0);
const parsed = ctx.parsed as {
session?: { sendPolicy?: { rules?: Array<{ match?: { provider?: string } }> } };
};
expect(parsed.session?.sendPolicy?.rules?.[0]?.match?.provider).toBe("telegram");
},
);
});
it("rejects messages.queue.byProvider on load", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ messages: { queue: { byProvider: { whatsapp: "queue" } } } }, null, 2),
"utf-8",
);
await withSnapshotForConfig(
{ messages: { queue: { byProvider: { whatsapp: "queue" } } } },
async (ctx) => {
expect(ctx.snapshot.valid).toBe(false);
expect(ctx.snapshot.issues.length).toBeGreaterThan(0);
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(false);
expect(snap.issues.length).toBeGreaterThan(0);
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
messages?: {
queue?: {
byProvider?: Record<string, unknown>;
const parsed = ctx.parsed as {
messages?: {
queue?: {
byProvider?: Record<string, unknown>;
};
};
};
};
expect(parsed.messages?.queue?.byProvider?.whatsapp).toBe("queue");
});
expect(parsed.messages?.queue?.byProvider?.whatsapp).toBe("queue");
},
);
});
});

View File

@@ -1,9 +1,8 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { loadConfig, validateConfigObject } from "./config.js";
import { withTempHome } from "./test-helpers.js";
import { withTempHomeConfig } from "./test-helpers.js";
describe("multi-agent agentDir validation", () => {
it("rejects shared agents.list agentDir", async () => {
@@ -24,31 +23,22 @@ describe("multi-agent agentDir validation", () => {
});
it("throws on shared agentDir during loadConfig()", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
agents: {
list: [
{ id: "a", agentDir: "~/.openclaw/agents/shared/agent" },
{ id: "b", agentDir: "~/.openclaw/agents/shared/agent" },
],
},
bindings: [{ agentId: "a", match: { channel: "telegram" } }],
},
null,
2,
),
"utf-8",
);
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => loadConfig()).toThrow(/duplicate agentDir/i);
expect(spy.mock.calls.flat().join(" ")).toMatch(/Duplicate agentDir/i);
spy.mockRestore();
});
await withTempHomeConfig(
{
agents: {
list: [
{ id: "a", agentDir: "~/.openclaw/agents/shared/agent" },
{ id: "b", agentDir: "~/.openclaw/agents/shared/agent" },
],
},
bindings: [{ agentId: "a", match: { channel: "telegram" } }],
},
async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => loadConfig()).toThrow(/duplicate agentDir/i);
expect(spy.mock.calls.flat().join(" ")).toMatch(/Duplicate agentDir/i);
spy.mockRestore();
},
);
});
});

View File

@@ -9,7 +9,7 @@ import {
resolveIsNixMode,
resolveStateDir,
} from "./config.js";
import { withTempHome } from "./test-helpers.js";
import { withTempHome, withTempHomeConfig } from "./test-helpers.js";
function envWith(overrides: Record<string, string | undefined>): NodeJS.ProcessEnv {
// Hermetic env: don't inherit process.env because other tests may mutate it.
@@ -23,6 +23,16 @@ function loadConfigForHome(home: string) {
}).loadConfig();
}
async function withLoadedConfigForHome(
config: unknown,
run: (cfg: ReturnType<typeof loadConfigForHome>) => Promise<void> | void,
) {
await withTempHomeConfig(config, async ({ home }) => {
const cfg = loadConfigForHome(home);
await run(cfg);
});
}
describe("Nix integration (U3, U5, U9)", () => {
describe("U3: isNixMode env var detection", () => {
it("isNixMode is false when OPENCLAW_NIX_MODE is not set", () => {
@@ -211,62 +221,44 @@ describe("Nix integration (U3, U5, U9)", () => {
describe("U9: telegram.tokenFile schema validation", () => {
it("accepts config with only botToken", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify({
channels: { telegram: { botToken: "123:ABC" } },
}),
"utf-8",
);
const cfg = loadConfigForHome(home);
expect(cfg.channels?.telegram?.botToken).toBe("123:ABC");
expect(cfg.channels?.telegram?.tokenFile).toBeUndefined();
});
await withLoadedConfigForHome(
{
channels: { telegram: { botToken: "123:ABC" } },
},
async (cfg) => {
expect(cfg.channels?.telegram?.botToken).toBe("123:ABC");
expect(cfg.channels?.telegram?.tokenFile).toBeUndefined();
},
);
});
it("accepts config with only tokenFile", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify({
channels: { telegram: { tokenFile: "/run/agenix/telegram-token" } },
}),
"utf-8",
);
const cfg = loadConfigForHome(home);
expect(cfg.channels?.telegram?.tokenFile).toBe("/run/agenix/telegram-token");
expect(cfg.channels?.telegram?.botToken).toBeUndefined();
});
await withLoadedConfigForHome(
{
channels: { telegram: { tokenFile: "/run/agenix/telegram-token" } },
},
async (cfg) => {
expect(cfg.channels?.telegram?.tokenFile).toBe("/run/agenix/telegram-token");
expect(cfg.channels?.telegram?.botToken).toBeUndefined();
},
);
});
it("accepts config with both botToken and tokenFile", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify({
channels: {
telegram: {
botToken: "fallback:token",
tokenFile: "/run/agenix/telegram-token",
},
await withLoadedConfigForHome(
{
channels: {
telegram: {
botToken: "fallback:token",
tokenFile: "/run/agenix/telegram-token",
},
}),
"utf-8",
);
const cfg = loadConfigForHome(home);
expect(cfg.channels?.telegram?.botToken).toBe("fallback:token");
expect(cfg.channels?.telegram?.tokenFile).toBe("/run/agenix/telegram-token");
});
},
},
async (cfg) => {
expect(cfg.channels?.telegram?.botToken).toBe("fallback:token");
expect(cfg.channels?.telegram?.tokenFile).toBe("/run/agenix/telegram-token");
},
);
});
});
});

View File

@@ -62,6 +62,28 @@ async function withWrapperEnvContext(configPath: string, run: () => Promise<void
);
}
function createGatewayTokenConfigJson(): string {
return JSON.stringify({ gateway: { remote: { token: "${MY_API_KEY}" } } }, null, 2);
}
function createMutableApiKeyEnv(initialValue = "original-key-123"): Record<string, string> {
return { MY_API_KEY: initialValue };
}
async function withGatewayTokenTempConfig(
run: (configPath: string) => Promise<void>,
): Promise<void> {
await withTempConfig(createGatewayTokenConfigJson(), run);
}
async function withWrapperGatewayTokenContext(
run: (configPath: string) => Promise<void>,
): Promise<void> {
await withGatewayTokenTempConfig(async (configPath) => {
await withWrapperEnvContext(configPath, async () => run(configPath));
});
}
async function readGatewayToken(configPath: string): Promise<string> {
const written = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(written) as { gateway: { remote: { token: string } } };
@@ -70,13 +92,8 @@ async function readGatewayToken(configPath: string): Promise<string> {
describe("env snapshot TOCTOU via createConfigIO", () => {
it("restores env refs using read-time env even after env mutation", async () => {
const env: Record<string, string> = {
MY_API_KEY: "original-key-123",
};
const configJson = JSON.stringify({ gateway: { remote: { token: "${MY_API_KEY}" } } }, null, 2);
await withTempConfig(configJson, async (configPath) => {
const env = createMutableApiKeyEnv();
await withGatewayTokenTempConfig(async (configPath) => {
// Instance A: read config (captures env snapshot)
const ioA = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv });
const firstRead = await ioA.readConfigFileSnapshotForWrite();
@@ -99,13 +116,8 @@ describe("env snapshot TOCTOU via createConfigIO", () => {
});
it("without snapshot bridging, mutated env causes incorrect restoration", async () => {
const env: Record<string, string> = {
MY_API_KEY: "original-key-123",
};
const configJson = JSON.stringify({ gateway: { remote: { token: "${MY_API_KEY}" } } }, null, 2);
await withTempConfig(configJson, async (configPath) => {
const env = createMutableApiKeyEnv();
await withGatewayTokenTempConfig(async (configPath) => {
// Instance A: read config
const ioA = createConfigIO({ configPath, env: env as unknown as NodeJS.ProcessEnv });
const snapshot = await ioA.readConfigFileSnapshot();
@@ -132,40 +144,34 @@ describe("env snapshot TOCTOU via createConfigIO", () => {
describe("env snapshot TOCTOU via wrapper APIs", () => {
it("uses explicit read context even if another read interleaves", async () => {
const configJson = JSON.stringify({ gateway: { remote: { token: "${MY_API_KEY}" } } }, null, 2);
await withTempConfig(configJson, async (configPath) => {
await withWrapperEnvContext(configPath, async () => {
const firstRead = await readConfigFileSnapshotForWrite();
expect(firstRead.snapshot.config.gateway?.remote?.token).toBe("original-key-123");
await withWrapperGatewayTokenContext(async (configPath) => {
const firstRead = await readConfigFileSnapshotForWrite();
expect(firstRead.snapshot.config.gateway?.remote?.token).toBe("original-key-123");
// Interleaving read from another request context with a different env value.
process.env.MY_API_KEY = "mutated-key-456";
const secondRead = await readConfigFileSnapshotForWrite();
expect(secondRead.snapshot.config.gateway?.remote?.token).toBe("mutated-key-456");
// Interleaving read from another request context with a different env value.
process.env.MY_API_KEY = "mutated-key-456";
const secondRead = await readConfigFileSnapshotForWrite();
expect(secondRead.snapshot.config.gateway?.remote?.token).toBe("mutated-key-456");
// Write using the first read's explicit context.
await writeConfigFileViaWrapper(firstRead.snapshot.config, firstRead.writeOptions);
expect(await readGatewayToken(configPath)).toBe("${MY_API_KEY}");
});
// Write using the first read's explicit context.
await writeConfigFileViaWrapper(firstRead.snapshot.config, firstRead.writeOptions);
expect(await readGatewayToken(configPath)).toBe("${MY_API_KEY}");
});
});
it("ignores read context when expected config path does not match", async () => {
const configJson = JSON.stringify({ gateway: { remote: { token: "${MY_API_KEY}" } } }, null, 2);
await withTempConfig(configJson, async (configPath) => {
await withWrapperEnvContext(configPath, async () => {
const firstRead = await readConfigFileSnapshotForWrite();
expect(firstRead.snapshot.config.gateway?.remote?.token).toBe("original-key-123");
expect(firstRead.writeOptions.expectedConfigPath).toBe(configPath);
await withWrapperGatewayTokenContext(async (configPath) => {
const firstRead = await readConfigFileSnapshotForWrite();
expect(firstRead.snapshot.config.gateway?.remote?.token).toBe("original-key-123");
expect(firstRead.writeOptions.expectedConfigPath).toBe(configPath);
process.env.MY_API_KEY = "mutated-key-456";
await writeConfigFileViaWrapper(firstRead.snapshot.config, {
...firstRead.writeOptions,
expectedConfigPath: `${configPath}.different`,
});
expect(await readGatewayToken(configPath)).toBe("original-key-123");
process.env.MY_API_KEY = "mutated-key-456";
await writeConfigFileViaWrapper(firstRead.snapshot.config, {
...firstRead.writeOptions,
expectedConfigPath: `${configPath}.different`,
});
expect(await readGatewayToken(configPath)).toBe("original-key-123");
});
});
});

View File

@@ -46,6 +46,27 @@ function restoreRedactedValues<TOriginal>(
return result.result as TOriginal;
}
function expectNestedLevelPairValue(
source: Record<string, Record<string, Record<string, unknown>>>,
field: string,
expected: readonly [unknown, unknown],
): void {
const values = source.nested.level[field] as unknown[];
expect(values[0]).toBe(expected[0]);
expect(values[1]).toBe(expected[1]);
}
function expectGatewayAuthFieldValue(
result: ReturnType<typeof redactConfigSnapshot>,
field: "token" | "password",
expected: string,
): void {
const gateway = result.config.gateway as Record<string, Record<string, string>>;
const resolved = result.resolved as Record<string, Record<string, Record<string, string>>>;
expect(gateway.auth[field]).toBe(expected);
expect(resolved.gateway.auth[field]).toBe(expected);
}
describe("redactConfigSnapshot", () => {
it("redacts common secret field patterns across config sections", () => {
const snapshot = makeSnapshot({
@@ -560,12 +581,10 @@ describe("redactConfigSnapshot", () => {
}),
assert: ({ redacted, restored }) => {
const cfg = redacted as Record<string, Record<string, Record<string, unknown>>>;
expect((cfg.nested.level.token as unknown[])[0]).toBe(42);
expect((cfg.nested.level.token as unknown[])[1]).toBe(815);
expectNestedLevelPairValue(cfg, "token", [42, 815]);
const out = restored as Record<string, Record<string, Record<string, unknown>>>;
expect((out.nested.level.token as unknown[])[0]).toBe(42);
expect((out.nested.level.token as unknown[])[1]).toBe(815);
expectNestedLevelPairValue(out, "token", [42, 815]);
},
},
{
@@ -604,12 +623,10 @@ describe("redactConfigSnapshot", () => {
}),
assert: ({ redacted, restored }) => {
const cfg = redacted as Record<string, Record<string, Record<string, unknown>>>;
expect((cfg.nested.level.custom as unknown[])[0]).toBe(42);
expect((cfg.nested.level.custom as unknown[])[1]).toBe(815);
expectNestedLevelPairValue(cfg, "custom", [42, 815]);
const out = restored as Record<string, Record<string, Record<string, unknown>>>;
expect((out.nested.level.custom as unknown[])[0]).toBe(42);
expect((out.nested.level.custom as unknown[])[1]).toBe(815);
expectNestedLevelPairValue(out, "custom", [42, 815]);
},
},
];
@@ -636,10 +653,7 @@ describe("redactConfigSnapshot", () => {
gateway: { auth: { token: "not-actually-secret-value" } },
});
const result = redactConfigSnapshot(snapshot, hints);
const gw = result.config.gateway as Record<string, Record<string, string>>;
const resolved = result.resolved as Record<string, Record<string, Record<string, string>>>;
expect(gw.auth.token).toBe("not-actually-secret-value");
expect(resolved.gateway.auth.token).toBe("not-actually-secret-value");
expectGatewayAuthFieldValue(result, "token", "not-actually-secret-value");
});
it("does not redact paths absent from uiHints (schema is single source of truth)", () => {
@@ -650,10 +664,7 @@ describe("redactConfigSnapshot", () => {
gateway: { auth: { password: "not-in-hints-value" } },
});
const result = redactConfigSnapshot(snapshot, hints);
const gw = result.config.gateway as Record<string, Record<string, string>>;
const resolved = result.resolved as Record<string, Record<string, Record<string, string>>>;
expect(gw.auth.password).toBe("not-in-hints-value");
expect(resolved.gateway.auth.password).toBe("not-in-hints-value");
expectGatewayAuthFieldValue(result, "password", "not-in-hints-value");
});
it("uses wildcard hints for array items", () => {

View File

@@ -43,6 +43,13 @@ async function createCaseDir(prefix: string): Promise<string> {
return dir;
}
function createStaleAndFreshStore(now = Date.now()): Record<string, SessionEntry> {
return {
stale: makeEntry(now - 30 * DAY_MS),
fresh: makeEntry(now),
};
}
describe("Integration: saveSessionStore with pruning", () => {
let testDir: string;
let storePath: string;
@@ -78,11 +85,7 @@ describe("Integration: saveSessionStore with pruning", () => {
it("saveSessionStore prunes stale entries on write", async () => {
applyEnforcedMaintenanceConfig(mockLoadConfig);
const now = Date.now();
const store: Record<string, SessionEntry> = {
stale: makeEntry(now - 30 * DAY_MS),
fresh: makeEntry(now),
};
const store = createStaleAndFreshStore();
await saveSessionStore(storePath, store);
@@ -168,11 +171,7 @@ describe("Integration: saveSessionStore with pruning", () => {
},
});
const now = Date.now();
const store: Record<string, SessionEntry> = {
stale: makeEntry(now - 30 * DAY_MS),
fresh: makeEntry(now),
};
const store = createStaleAndFreshStore();
await saveSessionStore(storePath, store);

View File

@@ -1,9 +1,28 @@
import fs from "node:fs/promises";
import path from "node:path";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
export async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "openclaw-config-" });
}
export async function writeOpenClawConfig(home: string, config: unknown): Promise<string> {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
return configPath;
}
export async function withTempHomeConfig<T>(
config: unknown,
fn: (params: { home: string; configPath: string }) => Promise<T>,
): Promise<T> {
return withTempHome(async (home) => {
const configPath = await writeOpenClawConfig(home, config);
return fn({ home, configPath });
});
}
/**
* Helper to test env var overrides. Saves/restores env vars for a callback.
*/

View File

@@ -440,13 +440,17 @@ export const AgentSandboxSchema = z
.strict()
.optional();
const CommonToolPolicyFields = {
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(),
alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(),
};
export const AgentToolsSchema = z
.object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(),
alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(),
...CommonToolPolicyFields,
elevated: z
.object({
enabled: z.boolean().optional(),
@@ -641,11 +645,7 @@ export const AgentEntrySchema = z
export const ToolsSchema = z
.object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(),
alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(),
...CommonToolPolicyFields,
web: ToolsWebSchema,
media: ToolsMediaSchema,
links: ToolsLinksSchema,

View File

@@ -430,6 +430,16 @@ const ProviderOptionsSchema = z
.record(z.string(), z.record(z.string(), ProviderOptionValueSchema))
.optional();
const MediaUnderstandingRuntimeFields = {
prompt: z.string().optional(),
timeoutSeconds: z.number().int().positive().optional(),
language: z.string().optional(),
providerOptions: ProviderOptionsSchema,
deepgram: DeepgramAudioSchema,
baseUrl: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
};
export const MediaUnderstandingModelSchema = z
.object({
provider: z.string().optional(),
@@ -438,15 +448,9 @@ export const MediaUnderstandingModelSchema = z
type: z.union([z.literal("provider"), z.literal("cli")]).optional(),
command: z.string().optional(),
args: z.array(z.string()).optional(),
prompt: z.string().optional(),
maxChars: z.number().int().positive().optional(),
maxBytes: z.number().int().positive().optional(),
timeoutSeconds: z.number().int().positive().optional(),
language: z.string().optional(),
providerOptions: ProviderOptionsSchema,
deepgram: DeepgramAudioSchema,
baseUrl: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
...MediaUnderstandingRuntimeFields,
profile: z.string().optional(),
preferredProfile: z.string().optional(),
})
@@ -459,13 +463,7 @@ export const ToolsMediaUnderstandingSchema = z
scope: MediaUnderstandingScopeSchema,
maxBytes: z.number().int().positive().optional(),
maxChars: z.number().int().positive().optional(),
prompt: z.string().optional(),
timeoutSeconds: z.number().int().positive().optional(),
language: z.string().optional(),
providerOptions: ProviderOptionsSchema,
deepgram: DeepgramAudioSchema,
baseUrl: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
...MediaUnderstandingRuntimeFields,
attachments: MediaUnderstandingAttachmentsSchema,
models: z.array(MediaUnderstandingModelSchema).optional(),
})