import { describe, expect, it, vi } from "vitest"; import { GatewayResponseError } from "@/lib/gateway/GatewayClient"; import type { GatewayClient } from "@/lib/gateway/GatewayClient"; import { updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig"; describe("updateGatewayAgentOverrides", () => { it("writes additive alsoAllow entries for per-agent tools", async () => { const client = { call: vi.fn(async (method: string, params?: unknown) => { if (method === "config.get") { return { exists: true, hash: "cfg-additive-1", config: { agents: { list: [{ id: "agent-1", tools: { profile: "coding" } }], }, }, }; } if (method === "config.set") { const raw = (params as { raw?: string }).raw ?? ""; const parsed = JSON.parse(raw) as { agents?: { list?: Array<{ id?: string; tools?: { profile?: string; alsoAllow?: string[]; deny?: string[] } }> }; }; const entry = parsed.agents?.list?.find((item) => item.id === "agent-1"); expect(entry?.tools).toEqual({ profile: "coding", alsoAllow: ["group:web", "group:runtime"], deny: ["group:fs"], }); return { ok: true }; } throw new Error(`unexpected method ${method}`); }), } as unknown as GatewayClient; await updateGatewayAgentOverrides({ client, agentId: "agent-1", overrides: { tools: { profile: "coding", alsoAllow: ["group:web", "group:web", " group:runtime "], deny: ["group:fs", "group:fs"], }, }, }); }); it("drops legacy allow when writing additive alsoAllow", async () => { const client = { call: vi.fn(async (method: string, params?: unknown) => { if (method === "config.get") { return { exists: true, hash: "cfg-additive-2", config: { agents: { list: [{ id: "agent-1", tools: { profile: "coding", allow: ["group:web"] } }], }, }, }; } if (method === "config.set") { const raw = (params as { raw?: string }).raw ?? ""; const parsed = JSON.parse(raw) as { agents?: { list?: Array<{ id?: string; tools?: { profile?: string; allow?: string[]; alsoAllow?: string[]; deny?: string[]; }; }>; }; }; const entry = parsed.agents?.list?.find((item) => item.id === "agent-1"); expect(entry?.tools).toEqual({ profile: "coding", alsoAllow: ["group:runtime"], deny: ["group:fs"], }); return { ok: true }; } throw new Error(`unexpected method ${method}`); }), } as unknown as GatewayClient; await updateGatewayAgentOverrides({ client, agentId: "agent-1", overrides: { tools: { alsoAllow: ["group:runtime"], deny: ["group:fs"], }, }, }); }); it("preserves redacted non-agent fields when writing full config", async () => { const client = { call: vi.fn(async (method: string, params?: unknown) => { if (method === "config.get") { return { exists: true, hash: "cfg-redacted-1", config: { models: { providers: { xai: { models: [{ id: "grok", maxTokens: "__OPENCLAW_REDACTED__" }], }, }, }, agents: { list: [{ id: "agent-1" }], }, }, }; } if (method === "config.set") { const raw = (params as { raw?: string }).raw ?? ""; const parsed = JSON.parse(raw) as Record; expect(parsed.models).toEqual({ providers: { xai: { models: [{ id: "grok", maxTokens: "__OPENCLAW_REDACTED__" }], }, }, }); expect(parsed.agents).toEqual({ list: [ { id: "agent-1", tools: { profile: "coding", alsoAllow: ["group:runtime"], }, }, ], }); return { ok: true }; } throw new Error(`unexpected method ${method}`); }), } as unknown as GatewayClient; await updateGatewayAgentOverrides({ client, agentId: "agent-1", overrides: { tools: { profile: "coding", alsoAllow: ["group:runtime"], }, }, }); }); it("preserves concurrent config changes when config.set retries after stale hash", async () => { let configGetCount = 0; let configSetCount = 0; const callOrder: string[] = []; const client = { call: vi.fn(async (method: string, params?: unknown) => { callOrder.push(method); if (method === "config.get") { configGetCount += 1; if (configGetCount === 1) { return { exists: true, hash: "cfg-retry-1", config: { gateway: { reload: { mode: "hybrid", }, }, agents: { list: [{ id: "agent-1" }], }, }, }; } return { exists: true, hash: "cfg-retry-2", config: { gateway: { reload: { mode: "off", }, }, agents: { list: [{ id: "agent-1" }], }, }, }; } if (method === "config.set") { configSetCount += 1; const payload = params as { raw?: string; baseHash?: string }; const parsed = JSON.parse(payload.raw ?? "") as { gateway?: { reload?: { mode?: string } }; agents?: { list?: Array<{ id?: string; tools?: { profile?: string; alsoAllow?: string[]; }; }>; }; }; if (configSetCount === 1) { expect(payload.baseHash).toBe("cfg-retry-1"); expect(parsed.gateway?.reload?.mode).toBe("hybrid"); throw new GatewayResponseError({ code: "INVALID_REQUEST", message: "config changed since last load; re-run config.get and retry", }); } expect(payload.baseHash).toBe("cfg-retry-2"); expect(parsed.gateway?.reload?.mode).toBe("off"); expect(parsed.agents?.list?.find((entry) => entry.id === "agent-1")?.tools).toEqual({ profile: "coding", alsoAllow: ["group:web"], }); return { ok: true }; } throw new Error(`unexpected method ${method}`); }), } as unknown as GatewayClient; await updateGatewayAgentOverrides({ client, agentId: "agent-1", overrides: { tools: { profile: "coding", alsoAllow: ["group:web"], }, }, }); expect(configGetCount).toBe(2); expect(configSetCount).toBe(2); expect(callOrder).toEqual(["config.get", "config.set", "config.get", "config.set"]); }); it("omits baseHash when config does not exist yet", async () => { let configSetCount = 0; const client = { call: vi.fn(async (method: string, params?: unknown) => { if (method === "config.get") { return { exists: false, config: { agents: { list: [], }, }, }; } if (method === "config.set") { configSetCount += 1; const payload = params as { raw?: string; baseHash?: string }; const parsed = JSON.parse(payload.raw ?? "") as { agents?: { list?: Array<{ id?: string; tools?: { alsoAllow?: string[]; deny?: string[]; }; }>; }; }; expect(payload.baseHash).toBeUndefined(); expect(parsed.agents?.list?.find((entry) => entry.id === "agent-1")?.tools).toEqual({ alsoAllow: ["group:runtime"], deny: ["group:fs"], }); return { ok: true }; } throw new Error(`unexpected method ${method}`); }), } as unknown as GatewayClient; await updateGatewayAgentOverrides({ client, agentId: "agent-1", overrides: { tools: { alsoAllow: ["group:runtime"], deny: ["group:fs"], }, }, }); expect(configSetCount).toBe(1); }); it("fails after a single stale-hash retry attempt", async () => { let configGetCount = 0; let configSetCount = 0; const client = { call: vi.fn(async (method: string) => { if (method === "config.get") { configGetCount += 1; return { exists: true, hash: `cfg-stale-${configGetCount}`, config: { agents: { list: [{ id: "agent-1" }], }, }, }; } if (method === "config.set") { configSetCount += 1; throw new GatewayResponseError({ code: "INVALID_REQUEST", message: "config changed since last load; re-run config.get and retry", }); } throw new Error(`unexpected method ${method}`); }), } as unknown as GatewayClient; await expect( updateGatewayAgentOverrides({ client, agentId: "agent-1", overrides: { tools: { alsoAllow: ["group:runtime"], }, }, }) ).rejects.toBeInstanceOf(GatewayResponseError); expect(configGetCount).toBe(2); expect(configSetCount).toBe(2); }); it("fails fast when both allow and alsoAllow are provided", async () => { const client = { call: vi.fn(), } as unknown as GatewayClient; await expect( updateGatewayAgentOverrides({ client, agentId: "agent-1", overrides: { tools: { allow: ["group:runtime"], alsoAllow: ["group:web"], }, }, }) ).rejects.toThrow("Agent tools overrides cannot set both allow and alsoAllow."); expect(client.call).not.toHaveBeenCalled(); }); });