Skip to content

Commit 08cda1b

Browse files
committed
hub normalization
1 parent 1ab2fc6 commit 08cda1b

File tree

7 files changed

+503
-18
lines changed

7 files changed

+503
-18
lines changed

extensions/hub/src/channel.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const {
4+
deleteAccountFromConfigSectionMock,
5+
listHubAccountIdsMock,
6+
resolveDefaultHubAccountIdMock,
7+
resolveHubAccountMock,
8+
sendMessageHubMock,
9+
setAccountEnabledInConfigSectionMock,
10+
} = vi.hoisted(() => ({
11+
deleteAccountFromConfigSectionMock: vi.fn(() => ({})),
12+
listHubAccountIdsMock: vi.fn(() => ["default"]),
13+
resolveDefaultHubAccountIdMock: vi.fn(() => "default"),
14+
resolveHubAccountMock: vi.fn(),
15+
sendMessageHubMock: vi.fn(),
16+
setAccountEnabledInConfigSectionMock: vi.fn(() => ({})),
17+
}));
18+
19+
vi.mock("openclaw/plugin-sdk", () => ({
20+
buildBaseAccountStatusSnapshot: vi.fn(() => ({})),
21+
buildBaseChannelStatusSummary: vi.fn(() => ({})),
22+
buildChannelConfigSchema: vi.fn((schema: unknown) => schema),
23+
DEFAULT_ACCOUNT_ID: "default",
24+
deleteAccountFromConfigSection: deleteAccountFromConfigSectionMock,
25+
formatPairingApproveHint: vi.fn(() => "approve via hub"),
26+
PAIRING_APPROVED_MESSAGE: "approved",
27+
setAccountEnabledInConfigSection: setAccountEnabledInConfigSectionMock,
28+
}));
29+
30+
vi.mock("./accounts.js", () => ({
31+
listHubAccountIds: listHubAccountIdsMock,
32+
resolveDefaultHubAccountId: resolveDefaultHubAccountIdMock,
33+
resolveHubAccount: resolveHubAccountMock,
34+
}));
35+
36+
vi.mock("./config-schema.js", () => ({
37+
HubConfigSchema: { type: "object" },
38+
}));
39+
40+
vi.mock("./monitor.js", () => ({
41+
monitorHubProvider: vi.fn(),
42+
}));
43+
44+
vi.mock("./onboarding.js", () => ({
45+
hubOnboardingAdapter: {},
46+
}));
47+
48+
vi.mock("./probe.js", () => ({
49+
probeHub: vi.fn(),
50+
}));
51+
52+
vi.mock("./runtime.js", () => ({
53+
getHubRuntime: vi.fn(() => ({
54+
channel: {
55+
activity: {
56+
record: vi.fn(),
57+
},
58+
text: {
59+
chunkMarkdownText: vi.fn(),
60+
},
61+
},
62+
})),
63+
}));
64+
65+
vi.mock("./send.js", () => ({
66+
sendMessageHub: sendMessageHubMock,
67+
}));
68+
69+
const { hubPlugin } = await import("./channel.js");
70+
71+
describe("hubPlugin normalization", () => {
72+
beforeEach(() => {
73+
sendMessageHubMock.mockReset();
74+
sendMessageHubMock.mockResolvedValue({
75+
messageId: "hub-1",
76+
target: "brain",
77+
});
78+
resolveHubAccountMock.mockReset();
79+
resolveHubAccountMock.mockReturnValue({
80+
accountId: "default",
81+
name: "Hub",
82+
enabled: true,
83+
configured: true,
84+
url: "https://hub.example.test",
85+
agentId: "sender",
86+
secretSource: "inline",
87+
config: {
88+
allowFrom: [" hub:Brain ", "*", "CombinatorAgent ", "hub:"],
89+
defaultTo: " hub:TargetAgent ",
90+
dmPolicy: "open",
91+
},
92+
});
93+
});
94+
95+
it("normalizes outbound hub targets", () => {
96+
expect(hubPlugin.messaging?.normalizeTarget?.(" hub:Brain ")).toBe("Brain");
97+
expect(hubPlugin.messaging?.normalizeTarget?.("CombinatorAgent")).toBe("CombinatorAgent");
98+
expect(hubPlugin.messaging?.normalizeTarget?.("hub:")).toBeUndefined();
99+
});
100+
101+
it("normalizes pairing allow entries and approval targets", async () => {
102+
expect(hubPlugin.pairing?.normalizeAllowEntry?.(" hub:Brain ")).toBe("brain");
103+
expect(hubPlugin.pairing?.normalizeAllowEntry?.("hub:*")).toBe("*");
104+
105+
await hubPlugin.pairing?.notifyApproval?.({ id: " hub:Brain " } as any);
106+
107+
expect(sendMessageHubMock).toHaveBeenCalledWith("Brain", "approved");
108+
});
109+
110+
it("normalizes config-derived allowFrom and defaultTo values", () => {
111+
const cfg = { channels: { hub: {} } };
112+
113+
expect(hubPlugin.config.resolveAllowFrom({ cfg, accountId: "default" } as any)).toEqual([
114+
"brain",
115+
"*",
116+
"combinatoragent",
117+
]);
118+
expect(
119+
hubPlugin.config.formatAllowFrom({
120+
allowFrom: [" hub:Brain ", "COMBINATORAGENT", "*", "hub:"],
121+
} as any),
122+
).toEqual(["brain", "combinatoragent", "*"]);
123+
expect(hubPlugin.config.resolveDefaultTo({ cfg, accountId: "default" } as any)).toBe(
124+
"TargetAgent",
125+
);
126+
});
127+
128+
it("normalizes dm-policy entries, resolver ids, and directory peers", async () => {
129+
const cfg = { channels: { hub: {} } };
130+
const account = resolveHubAccountMock.mock.results[0]?.value ?? resolveHubAccountMock();
131+
const dmPolicy = hubPlugin.security.resolveDmPolicy({
132+
cfg,
133+
accountId: "default",
134+
account,
135+
} as any);
136+
137+
expect(dmPolicy.normalizeEntry(" hub:Brain ")).toBe("brain");
138+
expect(dmPolicy.normalizeEntry("hub:*")).toBe("*");
139+
140+
await expect(
141+
hubPlugin.resolver.resolveTargets({
142+
inputs: [" hub:Brain ", " hub: ", "CombinatorAgent"],
143+
} as any),
144+
).resolves.toEqual([
145+
{ input: " hub:Brain ", resolved: true, id: "Brain", name: "Brain" },
146+
{ input: " hub: ", resolved: false, note: "empty target" },
147+
{
148+
input: "CombinatorAgent",
149+
resolved: true,
150+
id: "CombinatorAgent",
151+
name: "CombinatorAgent",
152+
},
153+
]);
154+
155+
await expect(
156+
hubPlugin.directory.listPeers({
157+
cfg,
158+
accountId: "default",
159+
limit: 10,
160+
} as any),
161+
).resolves.toEqual([
162+
{ kind: "user", id: "brain" },
163+
{ kind: "user", id: "combinatoragent" },
164+
]);
165+
});
166+
});

extensions/hub/src/channel.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { hubOnboardingAdapter } from "./onboarding.js";
2121
import { probeHub } from "./probe.js";
2222
import { getHubRuntime } from "./runtime.js";
2323
import { sendMessageHub } from "./send.js";
24+
import { normalizeHubAllowEntry, normalizeHubTarget } from "./targets.js";
2425
import type { CoreConfig, HubProbe } from "./types.js";
2526

2627
export const hubPlugin: ChannelPlugin<ResolvedHubAccount, HubProbe> = {
@@ -38,9 +39,9 @@ export const hubPlugin: ChannelPlugin<ResolvedHubAccount, HubProbe> = {
3839
onboarding: hubOnboardingAdapter,
3940
pairing: {
4041
idLabel: "hubAgent",
41-
normalizeAllowEntry: (entry) => String(entry).trim().toLowerCase(),
42+
normalizeAllowEntry: (entry) => normalizeHubAllowEntry(entry) ?? "",
4243
notifyApproval: async ({ id }) => {
43-
const target = String(id).trim();
44+
const target = normalizeHubTarget(String(id));
4445
if (!target) {
4546
throw new Error(`invalid Hub pairing id: ${id}`);
4647
}
@@ -84,14 +85,15 @@ export const hubPlugin: ChannelPlugin<ResolvedHubAccount, HubProbe> = {
8485
secretSource: account.secretSource,
8586
}),
8687
resolveAllowFrom: ({ cfg, accountId }) =>
87-
(resolveHubAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
88-
(entry) => String(entry),
89-
),
88+
(resolveHubAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? [])
89+
.map((entry) => normalizeHubAllowEntry(String(entry)))
90+
.filter((entry): entry is string => Boolean(entry)),
9091
formatAllowFrom: ({ allowFrom }) =>
91-
allowFrom.map((entry) => String(entry).trim().toLowerCase()).filter(Boolean),
92+
allowFrom
93+
.map((entry) => normalizeHubAllowEntry(String(entry)))
94+
.filter((entry): entry is string => Boolean(entry)),
9295
resolveDefaultTo: ({ cfg, accountId }) =>
93-
resolveHubAccount({ cfg: cfg as CoreConfig, accountId }).config.defaultTo?.trim() ||
94-
undefined,
96+
normalizeHubTarget(resolveHubAccount({ cfg: cfg as CoreConfig, accountId }).config.defaultTo),
9597
},
9698
security: {
9799
resolveDmPolicy: ({ cfg, accountId, account }) => {
@@ -106,15 +108,12 @@ export const hubPlugin: ChannelPlugin<ResolvedHubAccount, HubProbe> = {
106108
policyPath: `${basePath}dmPolicy`,
107109
allowFromPath: `${basePath}allowFrom`,
108110
approveHint: formatPairingApproveHint("hub"),
109-
normalizeEntry: (raw) => String(raw).trim().toLowerCase(),
111+
normalizeEntry: (raw) => normalizeHubAllowEntry(raw) ?? "",
110112
};
111113
},
112114
},
113115
messaging: {
114-
normalizeTarget: (input) => {
115-
const trimmed = String(input ?? "").trim();
116-
return trimmed || undefined;
117-
},
116+
normalizeTarget: (input) => normalizeHubTarget(input),
118117
targetResolver: {
119118
looksLikeId: (input) => Boolean(String(input ?? "").trim()),
120119
hint: "<agent-id>",
@@ -123,11 +122,11 @@ export const hubPlugin: ChannelPlugin<ResolvedHubAccount, HubProbe> = {
123122
resolver: {
124123
resolveTargets: async ({ inputs }) => {
125124
return inputs.map((input) => {
126-
const trimmed = String(input).trim();
127-
if (!trimmed) {
125+
const normalized = normalizeHubTarget(String(input));
126+
if (!normalized) {
128127
return { input, resolved: false, note: "empty target" };
129128
}
130-
return { input, resolved: true, id: trimmed, name: trimmed };
129+
return { input, resolved: true, id: normalized, name: normalized };
131130
});
132131
},
133132
},
@@ -138,7 +137,7 @@ export const hubPlugin: ChannelPlugin<ResolvedHubAccount, HubProbe> = {
138137
const q = query?.trim().toLowerCase() ?? "";
139138
const ids = new Set<string>();
140139
for (const entry of account.config.allowFrom ?? []) {
141-
const normalized = String(entry).trim().toLowerCase();
140+
const normalized = normalizeHubAllowEntry(String(entry));
142141
if (normalized && normalized !== "*") {
143142
ids.add(normalized);
144143
}

0 commit comments

Comments
 (0)