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
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
configToRuleset,
getGlobalConfigPath,
loadConfig,
loadGlobalConfig,
mergeConfigs,
} from "../../src/config/loader.js";
import { evaluate } from "../../src/permission/evaluate.js";
import type { DispatchConfig } from "../../src/types/index.js";
const TMP = join("/tmp/opencode", "dispatch-config-merge-test");
const LOCAL_DIR = join(TMP, "project");
const GLOBAL_PATH = join(TMP, "global", "dispatch.toml");
const prevGlobal = process.env.DISPATCH_GLOBAL_CONFIG;
beforeEach(() => {
mkdirSync(LOCAL_DIR, { recursive: true });
mkdirSync(join(TMP, "global"), { recursive: true });
process.env.DISPATCH_GLOBAL_CONFIG = GLOBAL_PATH;
});
afterEach(() => {
rmSync(TMP, { recursive: true, force: true });
if (prevGlobal === undefined) delete process.env.DISPATCH_GLOBAL_CONFIG;
else process.env.DISPATCH_GLOBAL_CONFIG = prevGlobal;
});
function writeGlobal(content: string): void {
writeFileSync(GLOBAL_PATH, content, "utf-8");
}
function writeLocal(content: string): void {
writeFileSync(join(LOCAL_DIR, "dispatch.toml"), content, "utf-8");
}
// ─── mergeConfigs (pure) ─────────────────────────────────────────
describe("mergeConfigs — lsp by id", () => {
it("keeps non-conflicting servers from both global and local", () => {
const global: DispatchConfig = {
permissions: {},
lsp: { biome: { command: ["biome"], extensions: [".ts"] } },
};
const local: DispatchConfig = {
permissions: {},
lsp: { luau: { command: ["luau-lsp"], extensions: [".luau"] } },
};
const merged = mergeConfigs(global, local);
expect(Object.keys(merged.lsp ?? {}).sort()).toEqual(["biome", "luau"]);
});
it("local overrides global for the same server id", () => {
const global: DispatchConfig = {
permissions: {},
lsp: { biome: { command: ["global-biome"], extensions: [".ts"] } },
};
const local: DispatchConfig = {
permissions: {},
lsp: { biome: { command: ["local-biome"], extensions: [".ts", ".tsx"] } },
};
const merged = mergeConfigs(global, local);
expect(merged.lsp?.biome.command).toEqual(["local-biome"]);
expect(merged.lsp?.biome.extensions).toEqual([".ts", ".tsx"]);
});
it("omits lsp entirely when neither side declares one", () => {
const merged = mergeConfigs({ permissions: {} }, { permissions: {} });
expect(merged.lsp).toBeUndefined();
});
it("does not mutate inputs", () => {
const global: DispatchConfig = {
permissions: {},
lsp: { biome: { command: ["g"], extensions: [".ts"] } },
};
const local: DispatchConfig = {
permissions: {},
lsp: { biome: { command: ["l"], extensions: [".ts"] } },
};
mergeConfigs(global, local);
expect(global.lsp?.biome.command).toEqual(["g"]);
expect(local.lsp?.biome.command).toEqual(["l"]);
});
});
describe("mergeConfigs — keys by id", () => {
it("merges keys by id, local overriding global", () => {
const global: DispatchConfig = {
permissions: {},
keys: [
{ id: "a", provider: "x", base_url: "g-a" },
{ id: "b", provider: "x", base_url: "g-b" },
],
};
const local: DispatchConfig = {
permissions: {},
keys: [
{ id: "b", provider: "x", base_url: "l-b" },
{ id: "c", provider: "x", base_url: "l-c" },
],
};
const merged = mergeConfigs(global, local);
const byId = Object.fromEntries((merged.keys ?? []).map((k) => [k.id, k.base_url]));
expect(byId).toEqual({ a: "g-a", b: "l-b", c: "l-c" });
});
it("carries global keys through when local has none", () => {
const global: DispatchConfig = {
permissions: {},
keys: [{ id: "a", provider: "x", base_url: "g-a" }],
};
const merged = mergeConfigs(global, { permissions: {} });
expect(merged.keys).toEqual([{ id: "a", provider: "x", base_url: "g-a" }]);
});
});
describe("mergeConfigs — permissions", () => {
it("merges nested groups pattern-by-pattern with local winning", () => {
const global: DispatchConfig = {
permissions: { bash: { "git status": "allow", "*": "ask" } },
};
const local: DispatchConfig = {
permissions: { bash: { "*": "allow" } },
};
const merged = mergeConfigs(global, local);
const bash = merged.permissions.bash as Record<string, string>;
expect(bash["git status"]).toBe("allow");
expect(bash["*"]).toBe("allow"); // local override
});
it("local string value replaces a global nested group", () => {
const global: DispatchConfig = {
permissions: { read: { "src/**": "allow" } },
};
const local: DispatchConfig = { permissions: { read: "deny" } };
const merged = mergeConfigs(global, local);
expect(merged.permissions.read).toBe("deny");
});
it("keeps global-only permission groups", () => {
const global: DispatchConfig = { permissions: { read: "allow" } };
const local: DispatchConfig = { permissions: { edit: "ask" } };
const merged = mergeConfigs(global, local);
expect(merged.permissions.read).toBe("allow");
expect(merged.permissions.edit).toBe("ask");
});
it("local wins at evaluation time (findLast ordering)", () => {
const merged = mergeConfigs(
{ permissions: { bash: { "*": "ask" } } },
{ permissions: { bash: { "*": "allow" } } },
);
const ruleset = configToRuleset(merged);
expect(evaluate("bash", "anything", ruleset).action).toBe("allow");
});
// Regression: a SPECIFIC local override must not be shadowed by a more
// GENERAL global pattern (e.g. "*") that happened to be declared lower in
// the global block. `evaluate` uses findLast, so every local pattern must be
// emitted AFTER all global patterns of the same group.
it("specific local override beats a general global wildcard regardless of declaration order", () => {
const merged = mergeConfigs(
{ permissions: { bash: { "npm test": "allow", "*": "ask" } } },
{ permissions: { bash: { "npm test": "deny" } } },
);
// Local "npm test" must be emitted after global "*".
expect(Object.keys(merged.permissions.bash as object)).toEqual(["*", "npm test"]);
const ruleset = configToRuleset(merged);
expect(evaluate("bash", "npm test", ruleset).action).toBe("deny");
// And the inherited global wildcard still applies to other commands.
expect(evaluate("bash", "rm -rf /", ruleset).action).toBe("ask");
});
});
// ─── loadConfig (filesystem integration) ─────────────────────────
describe("loadConfig — global + local integration", () => {
it("returns global config when local dispatch.toml is missing", () => {
writeGlobal(`[lsp.biome]\ncommand = ["biome"]\nextensions = [".ts"]\n`);
const config = loadConfig(LOCAL_DIR);
expect(config.lsp?.biome.command).toEqual(["biome"]);
});
it("returns local config when global is missing", () => {
writeLocal(`[permissions]\nread = "allow"\n`);
const config = loadConfig(LOCAL_DIR);
expect(config.permissions.read).toBe("allow");
expect(config.lsp).toBeUndefined();
});
it("merges global LSP servers with local ones (local wins on id)", () => {
writeGlobal(
`[lsp.biome]\ncommand = ["global-biome"]\nextensions = [".ts"]\n\n[lsp.luau]\ncommand = ["luau-lsp"]\nextensions = [".luau"]\n`,
);
writeLocal(`[lsp.biome]\ncommand = ["local-biome"]\nextensions = [".ts"]\n`);
const config = loadConfig(LOCAL_DIR);
expect(config.lsp?.biome.command).toEqual(["local-biome"]);
expect(config.lsp?.luau.command).toEqual(["luau-lsp"]);
});
it("a malformed global config is ignored, local still loads", () => {
writeGlobal("not valid toml [[[");
writeLocal(`[permissions]\nread = "allow"\n`);
const config = loadConfig(LOCAL_DIR);
expect(config.permissions.read).toBe("allow");
});
});
describe("loadGlobalConfig", () => {
it("returns empty default when the global file is missing", () => {
expect(loadGlobalConfig()).toEqual({ permissions: {} });
});
it("loads the file at getGlobalConfigPath()", () => {
writeGlobal(`[permissions]\nedit = "deny"\n`);
expect(getGlobalConfigPath()).toBe(GLOBAL_PATH);
expect(loadGlobalConfig().permissions.edit).toBe("deny");
});
});
|