Skip to content

Commit e523661

Browse files
committed
feat(config): add canonical group-policy scope-tree resolver
1 parent 93bad8d commit e523661

4 files changed

Lines changed: 419 additions & 5 deletions

File tree

scripts/plugin-sdk-surface-report.mjs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
135135
types: 6,
136136
"agent-config-primitives": 2,
137137
"command-auth": 81,
138-
compat: 152,
138+
compat: 158,
139139
"direct-dm": 9,
140140
"direct-dm-access": 5,
141141
discord: 48,
@@ -159,7 +159,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
159159
"channel-pairing": 1,
160160
"conversation-runtime": 4,
161161
"channel-send-result": 1,
162-
"channel-policy": 8,
162+
"channel-policy": 14,
163163
"channel-route": 5,
164164
"session-store-runtime": 4,
165165
"session-transcript-runtime": 2,
@@ -202,19 +202,20 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
202202
327,
203203
env,
204204
),
205+
// ScopeTree adds six channel-policy exports, mirrored by compat, including three functions.
205206
publicExports: readPluginSdkSurfaceBudgetEnv(
206207
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
207-
10648,
208+
10660,
208209
env,
209210
),
210211
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
211212
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
212-
5360,
213+
5366,
213214
env,
214215
),
215216
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
216217
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
217-
3283,
218+
3289,
218219
env,
219220
),
220221
publicWildcardReexports: readPluginSdkSurfaceBudgetEnv(
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
// Verifies canonical group scope precedence and sender policy resolution.
2+
import { describe, expect, it } from "vitest";
3+
import type { OpenClawConfig } from "./config.js";
4+
import { resolveChannelGroupRequireMention, resolveToolsBySender } from "./group-policy.js";
5+
import {
6+
resolveScopeIntroHint,
7+
resolveScopeRequireMention,
8+
resolveScopeToolsPolicy,
9+
type ScopeTree,
10+
} from "./group-scope-tree.js";
11+
12+
describe("resolveScopeRequireMention", () => {
13+
const scalarCases: Array<{
14+
name: string;
15+
tree: ScopeTree;
16+
path: string[];
17+
expected: boolean;
18+
}> = [
19+
{
20+
name: "uses the most specific configured scope",
21+
tree: {
22+
defaults: { requireMention: false },
23+
scopes: {
24+
broad: { requireMention: false },
25+
narrow: { requireMention: true },
26+
},
27+
},
28+
path: ["broad", "narrow"],
29+
expected: true,
30+
},
31+
{
32+
name: "skips missing scope keys",
33+
tree: { scopes: { broad: { requireMention: false } } },
34+
path: ["broad", "missing"],
35+
expected: false,
36+
},
37+
{
38+
name: "uses defaults after path scopes",
39+
tree: { defaults: { requireMention: false }, scopes: { room: {} } },
40+
path: ["room"],
41+
expected: false,
42+
},
43+
{
44+
name: "defaults to requiring a mention",
45+
tree: { scopes: {} },
46+
path: ["missing"],
47+
expected: true,
48+
},
49+
];
50+
51+
it.each(scalarCases)("$name", ({ tree, path, expected }) => {
52+
expect(resolveScopeRequireMention({ tree, path })).toBe(expected);
53+
});
54+
55+
it("honors Telegram wildcard-topic precedence encoded by the path", () => {
56+
const tree: ScopeTree = {
57+
scopes: {
58+
"*#topic:7": { requireMention: false },
59+
"<chat>": { requireMention: true },
60+
},
61+
};
62+
63+
expect(
64+
resolveScopeRequireMention({
65+
tree,
66+
path: ["*", "<chat>", "*#topic:7", "<chat>#topic:7"],
67+
}),
68+
).toBe(false);
69+
});
70+
71+
it.each([
72+
{
73+
name: "no override",
74+
configured: false,
75+
override: undefined,
76+
overrideOrder: undefined,
77+
},
78+
{
79+
name: "before-config override",
80+
configured: false,
81+
override: true,
82+
overrideOrder: "before-config" as const,
83+
},
84+
{
85+
name: "after-config override",
86+
configured: false,
87+
override: true,
88+
overrideOrder: "after-config" as const,
89+
},
90+
{
91+
name: "after-config fallback override",
92+
configured: undefined,
93+
override: false,
94+
overrideOrder: "after-config" as const,
95+
},
96+
])("matches flat group-policy behavior: $name", ({ configured, override, overrideOrder }) => {
97+
const node = typeof configured === "boolean" ? { requireMention: configured } : {};
98+
const tree: ScopeTree = { scopes: { room: node } };
99+
const cfg = {
100+
channels: { whatsapp: { groups: { room: node } } },
101+
} as OpenClawConfig;
102+
103+
expect(
104+
resolveScopeRequireMention({
105+
tree,
106+
path: ["room"],
107+
requireMentionOverride: override,
108+
overrideOrder,
109+
}),
110+
).toBe(
111+
resolveChannelGroupRequireMention({
112+
cfg,
113+
channel: "whatsapp",
114+
groupId: "room",
115+
requireMentionOverride: override,
116+
overrideOrder,
117+
}),
118+
);
119+
});
120+
121+
it("defaults configured-but-unset scopes to no mention only when requested", () => {
122+
const tree: ScopeTree = { scopes: { configured: {} } };
123+
124+
expect(
125+
resolveScopeRequireMention({
126+
tree,
127+
path: ["configured"],
128+
configuredScopeDefaultsToNoMention: true,
129+
}),
130+
).toBe(false);
131+
expect(
132+
resolveScopeRequireMention({
133+
tree,
134+
path: ["missing"],
135+
configuredScopeDefaultsToNoMention: true,
136+
}),
137+
).toBe(true);
138+
});
139+
});
140+
141+
describe("resolveScopeToolsPolicy", () => {
142+
const cascadeCases: Array<{
143+
name: string;
144+
tree: ScopeTree;
145+
senderId: string;
146+
expected: { allow?: string[]; deny?: string[] };
147+
}> = [
148+
{
149+
name: "channel sender policy",
150+
tree: {
151+
scopes: {
152+
team: { tools: { deny: ["team"] } },
153+
channel: {
154+
toolsBySender: { "id:alice": { allow: ["channel-sender"] } },
155+
tools: { allow: ["channel"] },
156+
},
157+
},
158+
},
159+
senderId: "alice",
160+
expected: { allow: ["channel-sender"] },
161+
},
162+
{
163+
name: "channel policy",
164+
tree: {
165+
scopes: {
166+
team: { tools: { deny: ["team"] } },
167+
channel: {
168+
toolsBySender: { "id:alice": { allow: ["channel-sender"] } },
169+
tools: { allow: ["channel"] },
170+
},
171+
},
172+
},
173+
senderId: "bob",
174+
expected: { allow: ["channel"] },
175+
},
176+
{
177+
name: "team sender policy",
178+
tree: {
179+
scopes: {
180+
team: {
181+
toolsBySender: { "id:bob": { allow: ["team-sender"] } },
182+
tools: { deny: ["team"] },
183+
},
184+
channel: {},
185+
},
186+
},
187+
senderId: "bob",
188+
expected: { allow: ["team-sender"] },
189+
},
190+
{
191+
name: "team policy",
192+
tree: {
193+
scopes: {
194+
team: {
195+
toolsBySender: { "id:bob": { allow: ["team-sender"] } },
196+
tools: { deny: ["team"] },
197+
},
198+
channel: {},
199+
},
200+
},
201+
senderId: "carol",
202+
expected: { deny: ["team"] },
203+
},
204+
];
205+
206+
it.each(cascadeCases)(
207+
"resolves the MSTeams cascade through $name",
208+
({ tree, senderId, expected }) => {
209+
expect(resolveScopeToolsPolicy({ tree, path: ["team", "channel"], senderId })).toEqual(
210+
expected,
211+
);
212+
},
213+
);
214+
215+
it.each([
216+
{ name: "id", sender: { senderId: "user:alice" }, expected: { allow: ["id"] } },
217+
{
218+
name: "username",
219+
sender: { senderUsername: "@Alice" },
220+
expected: { allow: ["username"] },
221+
},
222+
{
223+
name: "channel",
224+
sender: { senderId: "user:alice", messageProvider: "discord" },
225+
expected: { allow: ["channel"] },
226+
},
227+
{
228+
name: "channel without provider",
229+
sender: { senderId: "user:alice" },
230+
expected: { allow: ["id"] },
231+
},
232+
])("matches resolveToolsBySender for typed $name keys", ({ sender, expected }) => {
233+
const toolsBySender = {
234+
"id:user:alice": { allow: ["id"] },
235+
"username:alice": { allow: ["username"] },
236+
"channel:discord:user:alice": { allow: ["channel"] },
237+
"*": { deny: ["fallback"] },
238+
};
239+
const tree: ScopeTree = { scopes: { room: { toolsBySender } } };
240+
241+
const directPolicy = resolveToolsBySender({ toolsBySender, ...sender });
242+
expect(resolveScopeToolsPolicy({ tree, path: ["room"], ...sender })).toEqual(directPolicy);
243+
expect(directPolicy).toEqual(expected);
244+
});
245+
246+
it("uses sender and plain policies from defaults after path scopes", () => {
247+
const senderTree: ScopeTree = {
248+
defaults: {
249+
toolsBySender: { "id:alice": { allow: ["default-sender"] } },
250+
tools: { deny: ["default"] },
251+
},
252+
scopes: { channel: {} },
253+
};
254+
255+
expect(
256+
resolveScopeToolsPolicy({ tree: senderTree, path: ["channel"], senderId: "alice" }),
257+
).toEqual({ allow: ["default-sender"] });
258+
expect(
259+
resolveScopeToolsPolicy({ tree: senderTree, path: ["channel"], senderId: "bob" }),
260+
).toEqual({ deny: ["default"] });
261+
expect(resolveScopeToolsPolicy({ tree: { scopes: {} }, path: [] })).toBeUndefined();
262+
});
263+
264+
it("keeps a narrower plain policy ahead of a broader sender match", () => {
265+
const tree: ScopeTree = {
266+
scopes: {
267+
team: { toolsBySender: { "id:alice": { allow: ["team-sender"] } } },
268+
channel: { tools: { deny: ["channel"] } },
269+
},
270+
};
271+
272+
expect(
273+
resolveScopeToolsPolicy({
274+
tree,
275+
path: ["team", "channel"],
276+
senderId: "alice",
277+
}),
278+
).toEqual({ deny: ["channel"] });
279+
});
280+
});
281+
282+
describe("resolveScopeIntroHint", () => {
283+
it("uses the first defined hint from narrowest scope through defaults", () => {
284+
const tree: ScopeTree = {
285+
defaults: { introHint: "default" },
286+
scopes: {
287+
team: { introHint: "team" },
288+
channel: {},
289+
thread: { introHint: "thread" },
290+
},
291+
};
292+
293+
expect(resolveScopeIntroHint({ tree, path: ["team", "channel", "thread"] })).toBe("thread");
294+
expect(resolveScopeIntroHint({ tree, path: ["missing"] })).toBe("default");
295+
});
296+
});

0 commit comments

Comments
 (0)