diff --git a/src/auto-reply/reply/commands.test-harness.ts b/src/auto-reply/reply/commands.test-harness.ts index 84ef0c0f8..806e36895 100644 --- a/src/auto-reply/reply/commands.test-harness.ts +++ b/src/auto-reply/reply/commands.test-harness.ts @@ -26,7 +26,7 @@ export function buildCommandTestParams( ctx, cfg, isGroup: false, - triggerBodyNormalized: commandBody.trim().toLowerCase(), + triggerBodyNormalized: commandBody.trim(), commandAuthorized: true, }); diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 8f48029fd..f6d2d88f5 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -133,6 +133,31 @@ afterAll(async () => { await fs.rm(testWorkspaceDir, { recursive: true, force: true }); }); +async function withTempConfigPath( + initialConfig: Record, + run: (configPath: string) => Promise, +): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-commands-config-")); + const configPath = path.join(dir, "openclaw.json"); + const previous = process.env.OPENCLAW_CONFIG_PATH; + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.writeFile(configPath, JSON.stringify(initialConfig, null, 2), "utf-8"); + try { + return await run(configPath); + } finally { + if (previous === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = previous; + } + await fs.rm(dir, { recursive: true, force: true }); + } +} + +async function readJsonFile(filePath: string): Promise { + return JSON.parse(await fs.readFile(filePath, "utf-8")) as T; +} + function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Partial) { return buildCommandTestParams(commandBody, cfg, ctxOverrides, { workspaceDir: testWorkspaceDir }); } @@ -702,13 +727,13 @@ describe("handleCommands /config owner gating", () => { } as OpenClawConfig; readConfigFileSnapshotMock.mockResolvedValueOnce({ valid: true, - parsed: { messages: { ackreaction: ":)" } }, + parsed: { messages: { ackReaction: ":)" } }, }); const params = buildParams("/config show messages.ackReaction", cfg); params.command.senderIsOwner = true; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config messages.ackreaction"); + expect(result.reply?.text).toContain("Config messages.ackReaction"); }); }); @@ -795,7 +820,7 @@ describe("handleCommands /config configWrites gating", () => { } as OpenClawConfig; readConfigFileSnapshotMock.mockResolvedValueOnce({ valid: true, - parsed: { messages: { ackreaction: ":)" } }, + parsed: { messages: { ackReaction: ":)" } }, }); const params = buildParams("/config show messages.ackReaction", cfg, { Provider: INTERNAL_MESSAGE_CHANNEL, @@ -806,76 +831,82 @@ describe("handleCommands /config configWrites gating", () => { params.command.senderIsOwner = false; const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config messages.ackreaction"); + expect(result.reply?.text).toContain("Config messages.ackReaction"); }); it("keeps /config set working for gateway operator.admin clients", async () => { - const cfg = { - commands: { config: true, text: true }, - } as OpenClawConfig; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { messages: { ackReaction: ":)" } }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - const params = buildParams('/config set messages.ackReaction=":D"', cfg, { - Provider: INTERNAL_MESSAGE_CHANNEL, - Surface: INTERNAL_MESSAGE_CHANNEL, - GatewayClientScopes: ["operator.write", "operator.admin"], - }); - params.command.channel = INTERNAL_MESSAGE_CHANNEL; - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock).toHaveBeenCalledOnce(); - expect(result.reply?.text).toContain("Config updated"); - }); - - it("keeps /config set working for gateway operator.admin on protected account paths", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { - telegram: { - accounts: { - work: { enabled: true, configWrites: false }, - }, - }, - }, - }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - const params = buildParams( - "/config set channels.telegram.accounts.work.enabled=false", - { + await withTempConfigPath({ messages: { ackReaction: ":)" } }, async (configPath) => { + const cfg = { commands: { config: true, text: true }, - channels: { - telegram: { - accounts: { - work: { enabled: true, configWrites: false }, - }, - }, - }, - } as OpenClawConfig, - { + } as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { messages: { ackReaction: ":)" } }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + const params = buildParams('/config set messages.ackReaction=":D"', cfg, { Provider: INTERNAL_MESSAGE_CHANNEL, Surface: INTERNAL_MESSAGE_CHANNEL, GatewayClientScopes: ["operator.write", "operator.admin"], + }); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config updated"); + const written = await readJsonFile(configPath); + expect(written.messages?.ackReaction).toBe(":D"); + }); + }); + + it("keeps /config set working for gateway operator.admin on protected account paths", async () => { + const initialConfig = { + channels: { + telegram: { + accounts: { + work: { enabled: true, configWrites: false }, + }, + }, }, - ); - params.command.channel = INTERNAL_MESSAGE_CHANNEL; - params.command.senderIsOwner = true; - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Config updated"); - const written = writeConfigFileMock.mock.calls.at(-1)?.[0] as OpenClawConfig; - expect(written.channels?.telegram?.accounts?.work?.enabled).toBe(false); + }; + await withTempConfigPath(initialConfig, async (configPath) => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: structuredClone(initialConfig), + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + const params = buildParams( + "/config set channels.telegram.accounts.work.enabled=false", + { + commands: { config: true, text: true }, + channels: { + telegram: { + accounts: { + work: { enabled: true, configWrites: false }, + }, + }, + }, + } as OpenClawConfig, + { + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: INTERNAL_MESSAGE_CHANNEL, + GatewayClientScopes: ["operator.write", "operator.admin"], + }, + ); + params.command.channel = INTERNAL_MESSAGE_CHANNEL; + params.command.senderIsOwner = true; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Config updated"); + const written = await readJsonFile(configPath); + expect(written.channels?.telegram?.accounts?.work?.enabled).toBe(false); + }); }); }); @@ -940,7 +971,7 @@ function buildPolicyParams( ctx, cfg, isGroup: false, - triggerBodyNormalized: commandBody.trim().toLowerCase(), + triggerBodyNormalized: commandBody.trim(), commandAuthorized: true, }); @@ -986,40 +1017,44 @@ describe("handleCommands /allowlist", () => { }); it("adds entries to config and pairing store", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { + await withTempConfigPath( + { channels: { telegram: { allowFrom: ["123"] } }, }, - }); - validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ - ok: true, - config, - })); - addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ - changed: true, - allowFrom: ["123", "789"], - }); + async (configPath) => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: { + channels: { telegram: { allowFrom: ["123"] } }, + }, + }); + validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({ + ok: true, + config, + })); + addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({ + changed: true, + allowFrom: ["123", "789"], + }); - const cfg = { - commands: { text: true, config: true }, - channels: { telegram: { allowFrom: ["123"] } }, - } as OpenClawConfig; - const params = buildPolicyParams("/allowlist add dm 789", cfg); - const result = await handleCommands(params); + const cfg = { + commands: { text: true, config: true }, + channels: { telegram: { allowFrom: ["123"] } }, + } as OpenClawConfig; + const params = buildPolicyParams("/allowlist add dm 789", cfg); + const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock).toHaveBeenCalledWith( - expect.objectContaining({ - channels: { telegram: { allowFrom: ["123", "789"] } }, - }), + expect(result.shouldContinue).toBe(false); + const written = await readJsonFile(configPath); + expect(written.channels?.telegram?.allowFrom).toEqual(["123", "789"]); + expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ + channel: "telegram", + entry: "789", + accountId: "default", + }); + expect(result.reply?.text).toContain("DM allowlist added"); + }, ); - expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({ - channel: "telegram", - entry: "789", - accountId: "default", - }); - expect(result.reply?.text).toContain("DM allowlist added"); }); it("writes store entries to the selected account scope", async () => { @@ -1151,22 +1186,7 @@ describe("handleCommands /allowlist", () => { })); for (const testCase of cases) { - const previousWriteCount = writeConfigFileMock.mock.calls.length; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - valid: true, - parsed: { - channels: { - [testCase.provider]: { - allowFrom: testCase.initialAllowFrom, - dm: { allowFrom: testCase.initialAllowFrom }, - configWrites: true, - }, - }, - }, - }); - - const cfg = { - commands: { text: true, config: true }, + const initialConfig = { channels: { [testCase.provider]: { allowFrom: testCase.initialAllowFrom, @@ -1174,21 +1194,37 @@ describe("handleCommands /allowlist", () => { configWrites: true, }, }, - } as OpenClawConfig; + }; + await withTempConfigPath(initialConfig, async (configPath) => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + parsed: structuredClone(initialConfig), + }); - const params = buildPolicyParams(`/allowlist remove dm ${testCase.removeId}`, cfg, { - Provider: testCase.provider, - Surface: testCase.provider, + const cfg = { + commands: { text: true, config: true }, + channels: { + [testCase.provider]: { + allowFrom: testCase.initialAllowFrom, + dm: { allowFrom: testCase.initialAllowFrom }, + configWrites: true, + }, + }, + } as OpenClawConfig; + + const params = buildPolicyParams(`/allowlist remove dm ${testCase.removeId}`, cfg, { + Provider: testCase.provider, + Surface: testCase.provider, + }); + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + const written = await readJsonFile(configPath); + const channelConfig = written.channels?.[testCase.provider]; + expect(channelConfig?.allowFrom).toEqual(testCase.expectedAllowFrom); + expect(channelConfig?.dm?.allowFrom).toBeUndefined(); + expect(result.reply?.text).toContain(`channels.${testCase.provider}.allowFrom`); }); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount + 1); - const written = writeConfigFileMock.mock.calls.at(-1)?.[0] as OpenClawConfig; - const channelConfig = written.channels?.[testCase.provider]; - expect(channelConfig?.allowFrom).toEqual(testCase.expectedAllowFrom); - expect(channelConfig?.dm?.allowFrom).toBeUndefined(); - expect(result.reply?.text).toContain(`channels.${testCase.provider}.allowFrom`); } }); });