activeclaw 2026.3.10 → 2026.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2026.3.10",
3
- "commit": "1ab2fc6ea71de2afec2cccf4285e21f0e527dff4",
4
- "builtAt": "2026-03-11T21:16:28.674Z"
2
+ "version": "2026.3.11",
3
+ "commit": "0b4519ca6e3061aeee04d634e16d467e934e7516",
4
+ "builtAt": "2026-03-12T14:55:45.177Z"
5
5
  }
@@ -1 +1 @@
1
- b0b2172aedd17dc0856c0cdf14d6967801496efd964e8fb717a0cc002c336a0e
1
+ 0e9f701206ce2812d0e33952c84b8517021bd775c5fb5b1e0e423e5f9de2d26d
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import chalk, { Chalk } from "chalk";
6
6
  import { Logger } from "tslog";
7
- import JSON5 from "json5";
7
+ import json5 from "json5";
8
8
  import { promisify } from "node:util";
9
9
  import fs$1, { mkdtemp, rm } from "node:fs/promises";
10
10
  import { execFile, spawn } from "node:child_process";
@@ -372,7 +372,7 @@ function readLoggingConfig() {
372
372
  try {
373
373
  if (!fs.existsSync(configPath)) return;
374
374
  const raw = fs.readFileSync(configPath, "utf-8");
375
- const logging = JSON5.parse(raw)?.logging;
375
+ const logging = json5.parse(raw)?.logging;
376
376
  if (!logging || typeof logging !== "object" || Array.isArray(logging)) return;
377
377
  return logging;
378
378
  } catch {
@@ -5,7 +5,7 @@ import chalk, { Chalk } from "chalk";
5
5
  import fs from "node:fs";
6
6
  import { Logger } from "tslog";
7
7
  import os from "node:os";
8
- import JSON5 from "json5";
8
+ import json5 from "json5";
9
9
  import { promisify } from "node:util";
10
10
  import { execFile } from "node:child_process";
11
11
  import { getOAuthProviders } from "@mariozechner/pi-ai";
@@ -415,7 +415,7 @@ function readLoggingConfig() {
415
415
  try {
416
416
  if (!fs.existsSync(configPath)) return;
417
417
  const raw = fs.readFileSync(configPath, "utf-8");
418
- const logging = JSON5.parse(raw)?.logging;
418
+ const logging = json5.parse(raw)?.logging;
419
419
  if (!logging || typeof logging !== "object" || Array.isArray(logging)) return;
420
420
  return logging;
421
421
  } catch {
@@ -4,7 +4,7 @@ import fs, { constants } from "node:fs";
4
4
  import os from "node:os";
5
5
  import chalk, { Chalk } from "chalk";
6
6
  import { Logger } from "tslog";
7
- import JSON5 from "json5";
7
+ import json5 from "json5";
8
8
  import { promisify } from "node:util";
9
9
  import fs$1 from "node:fs/promises";
10
10
  import { execFile } from "node:child_process";
@@ -373,7 +373,7 @@ function readLoggingConfig() {
373
373
  try {
374
374
  if (!fs.existsSync(configPath)) return;
375
375
  const raw = fs.readFileSync(configPath, "utf-8");
376
- const logging = JSON5.parse(raw)?.logging;
376
+ const logging = json5.parse(raw)?.logging;
377
377
  if (!logging || typeof logging !== "object" || Array.isArray(logging)) return;
378
378
  return logging;
379
379
  } catch {
@@ -6,7 +6,7 @@ import fs from "node:fs";
6
6
  import os from "node:os";
7
7
  import chalk, { Chalk } from "chalk";
8
8
  import { Logger } from "tslog";
9
- import JSON5 from "json5";
9
+ import json5 from "json5";
10
10
  import { format, promisify } from "node:util";
11
11
  import fs$1 from "node:fs/promises";
12
12
  import process$1 from "node:process";
@@ -722,7 +722,7 @@ function readLoggingConfig() {
722
722
  try {
723
723
  if (!fs.existsSync(configPath)) return;
724
724
  const raw = fs.readFileSync(configPath, "utf-8");
725
- const logging = JSON5.parse(raw)?.logging;
725
+ const logging = json5.parse(raw)?.logging;
726
726
  if (!logging || typeof logging !== "object" || Array.isArray(logging)) return;
727
727
  return logging;
728
728
  } catch {
@@ -4,7 +4,7 @@ import chalk, { Chalk } from "chalk";
4
4
  import fs, { constants, createWriteStream } from "node:fs";
5
5
  import { Logger } from "tslog";
6
6
  import os from "node:os";
7
- import json5 from "json5";
7
+ import JSON5 from "json5";
8
8
  import { promisify } from "node:util";
9
9
  import fs$1 from "node:fs/promises";
10
10
  import "@clack/prompts";
@@ -780,7 +780,7 @@ function readLoggingConfig() {
780
780
  try {
781
781
  if (!fs.existsSync(configPath)) return;
782
782
  const raw = fs.readFileSync(configPath, "utf-8");
783
- const logging = json5.parse(raw)?.logging;
783
+ const logging = JSON5.parse(raw)?.logging;
784
784
  if (!logging || typeof logging !== "object" || Array.isArray(logging)) return;
785
785
  return logging;
786
786
  } catch {
@@ -4,7 +4,7 @@ import chalk, { Chalk } from "chalk";
4
4
  import fs from "node:fs";
5
5
  import { Logger } from "tslog";
6
6
  import os from "node:os";
7
- import JSON5 from "json5";
7
+ import json5 from "json5";
8
8
  import { promisify } from "node:util";
9
9
  import fs$1 from "node:fs/promises";
10
10
  import { execFile, spawn } from "node:child_process";
@@ -772,7 +772,7 @@ function readLoggingConfig() {
772
772
  try {
773
773
  if (!fs.existsSync(configPath)) return;
774
774
  const raw = fs.readFileSync(configPath, "utf-8");
775
- const logging = JSON5.parse(raw)?.logging;
775
+ const logging = json5.parse(raw)?.logging;
776
776
  if (!logging || typeof logging !== "object" || Array.isArray(logging)) return;
777
777
  return logging;
778
778
  } catch {
@@ -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
+ });
@@ -21,6 +21,7 @@ import { hubOnboardingAdapter } from "./onboarding.js";
21
21
  import { probeHub } from "./probe.js";
22
22
  import { getHubRuntime } from "./runtime.js";
23
23
  import { sendMessageHub } from "./send.js";
24
+ import { normalizeHubAllowEntry, normalizeHubTarget } from "./targets.js";
24
25
  import type { CoreConfig, HubProbe } from "./types.js";
25
26
 
26
27
  export const hubPlugin: ChannelPlugin<ResolvedHubAccount, HubProbe> = {
@@ -38,9 +39,9 @@ export const hubPlugin: ChannelPlugin<ResolvedHubAccount, HubProbe> = {
38
39
  onboarding: hubOnboardingAdapter,
39
40
  pairing: {
40
41
  idLabel: "hubAgent",
41
- normalizeAllowEntry: (entry) => String(entry).trim().toLowerCase(),
42
+ normalizeAllowEntry: (entry) => normalizeHubAllowEntry(entry) ?? "",
42
43
  notifyApproval: async ({ id }) => {
43
- const target = String(id).trim();
44
+ const target = normalizeHubTarget(String(id));
44
45
  if (!target) {
45
46
  throw new Error(`invalid Hub pairing id: ${id}`);
46
47
  }
@@ -84,14 +85,15 @@ export const hubPlugin: ChannelPlugin<ResolvedHubAccount, HubProbe> = {
84
85
  secretSource: account.secretSource,
85
86
  }),
86
87
  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)),
90
91
  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)),
92
95
  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),
95
97
  },
96
98
  security: {
97
99
  resolveDmPolicy: ({ cfg, accountId, account }) => {
@@ -106,15 +108,12 @@ export const hubPlugin: ChannelPlugin<ResolvedHubAccount, HubProbe> = {
106
108
  policyPath: `${basePath}dmPolicy`,
107
109
  allowFromPath: `${basePath}allowFrom`,
108
110
  approveHint: formatPairingApproveHint("hub"),
109
- normalizeEntry: (raw) => String(raw).trim().toLowerCase(),
111
+ normalizeEntry: (raw) => normalizeHubAllowEntry(raw) ?? "",
110
112
  };
111
113
  },
112
114
  },
113
115
  messaging: {
114
- normalizeTarget: (input) => {
115
- const trimmed = String(input ?? "").trim();
116
- return trimmed || undefined;
117
- },
116
+ normalizeTarget: (input) => normalizeHubTarget(input),
118
117
  targetResolver: {
119
118
  looksLikeId: (input) => Boolean(String(input ?? "").trim()),
120
119
  hint: "<agent-id>",
@@ -123,11 +122,11 @@ export const hubPlugin: ChannelPlugin<ResolvedHubAccount, HubProbe> = {
123
122
  resolver: {
124
123
  resolveTargets: async ({ inputs }) => {
125
124
  return inputs.map((input) => {
126
- const trimmed = String(input).trim();
127
- if (!trimmed) {
125
+ const normalized = normalizeHubTarget(String(input));
126
+ if (!normalized) {
128
127
  return { input, resolved: false, note: "empty target" };
129
128
  }
130
- return { input, resolved: true, id: trimmed, name: trimmed };
129
+ return { input, resolved: true, id: normalized, name: normalized };
131
130
  });
132
131
  },
133
132
  },
@@ -138,7 +137,7 @@ export const hubPlugin: ChannelPlugin<ResolvedHubAccount, HubProbe> = {
138
137
  const q = query?.trim().toLowerCase() ?? "";
139
138
  const ids = new Set<string>();
140
139
  for (const entry of account.config.allowFrom ?? []) {
141
- const normalized = String(entry).trim().toLowerCase();
140
+ const normalized = normalizeHubAllowEntry(String(entry));
142
141
  if (normalized && normalized !== "*") {
143
142
  ids.add(normalized);
144
143
  }
@@ -0,0 +1,172 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const {
4
+ createNormalizedOutboundDelivererMock,
5
+ createReplyPrefixOptionsMock,
6
+ createScopedPairingAccessMock,
7
+ dispatchReplyWithBufferedBlockDispatcherMock,
8
+ emitInboundHistoryMock,
9
+ emitOutboundHistoryMock,
10
+ formatTextWithAttachmentLinksMock,
11
+ getHubRuntimeMock,
12
+ logInboundDropMock,
13
+ readStoreAllowFromForDmPolicyMock,
14
+ recordInboundSessionMock,
15
+ resolveControlCommandGateMock,
16
+ resolveEffectiveAllowFromListsMock,
17
+ resolveOutboundMediaUrlsMock,
18
+ sendMessageHubMock,
19
+ } = vi.hoisted(() => ({
20
+ createNormalizedOutboundDelivererMock: vi.fn(),
21
+ createReplyPrefixOptionsMock: vi.fn(),
22
+ createScopedPairingAccessMock: vi.fn(),
23
+ dispatchReplyWithBufferedBlockDispatcherMock: vi.fn(),
24
+ emitInboundHistoryMock: vi.fn(),
25
+ emitOutboundHistoryMock: vi.fn(),
26
+ formatTextWithAttachmentLinksMock: vi.fn(),
27
+ getHubRuntimeMock: vi.fn(),
28
+ logInboundDropMock: vi.fn(),
29
+ readStoreAllowFromForDmPolicyMock: vi.fn(),
30
+ recordInboundSessionMock: vi.fn(),
31
+ resolveControlCommandGateMock: vi.fn(),
32
+ resolveEffectiveAllowFromListsMock: vi.fn(),
33
+ resolveOutboundMediaUrlsMock: vi.fn(),
34
+ sendMessageHubMock: vi.fn(),
35
+ }));
36
+
37
+ vi.mock("openclaw/plugin-sdk", () => ({
38
+ createScopedPairingAccess: createScopedPairingAccessMock,
39
+ createNormalizedOutboundDeliverer: createNormalizedOutboundDelivererMock,
40
+ createReplyPrefixOptions: createReplyPrefixOptionsMock,
41
+ emitInboundHistory: emitInboundHistoryMock,
42
+ emitOutboundHistory: emitOutboundHistoryMock,
43
+ formatTextWithAttachmentLinks: formatTextWithAttachmentLinksMock,
44
+ logInboundDrop: logInboundDropMock,
45
+ readStoreAllowFromForDmPolicy: readStoreAllowFromForDmPolicyMock,
46
+ resolveControlCommandGate: resolveControlCommandGateMock,
47
+ resolveOutboundMediaUrls: resolveOutboundMediaUrlsMock,
48
+ resolveEffectiveAllowFromLists: resolveEffectiveAllowFromListsMock,
49
+ }));
50
+
51
+ vi.mock("./runtime.js", () => ({
52
+ getHubRuntime: getHubRuntimeMock,
53
+ }));
54
+
55
+ vi.mock("./send.js", () => ({
56
+ sendMessageHub: sendMessageHubMock,
57
+ }));
58
+
59
+ import { handleHubInbound } from "./inbound.js";
60
+
61
+ describe("handleHubInbound", () => {
62
+ beforeEach(() => {
63
+ createScopedPairingAccessMock.mockReset();
64
+ createNormalizedOutboundDelivererMock.mockReset();
65
+ createReplyPrefixOptionsMock.mockReset();
66
+ dispatchReplyWithBufferedBlockDispatcherMock.mockReset();
67
+ emitInboundHistoryMock.mockReset();
68
+ emitOutboundHistoryMock.mockReset();
69
+ formatTextWithAttachmentLinksMock.mockReset();
70
+ getHubRuntimeMock.mockReset();
71
+ logInboundDropMock.mockReset();
72
+ readStoreAllowFromForDmPolicyMock.mockReset();
73
+ recordInboundSessionMock.mockReset();
74
+ resolveControlCommandGateMock.mockReset();
75
+ resolveEffectiveAllowFromListsMock.mockReset();
76
+ resolveOutboundMediaUrlsMock.mockReset();
77
+ sendMessageHubMock.mockReset();
78
+
79
+ createScopedPairingAccessMock.mockReturnValue({
80
+ readStoreForDmPolicy: vi.fn(),
81
+ upsertPairingRequest: vi.fn(),
82
+ });
83
+ createNormalizedOutboundDelivererMock.mockImplementation((deliver: unknown) => deliver);
84
+ createReplyPrefixOptionsMock.mockReturnValue({
85
+ onModelSelected: vi.fn(),
86
+ includePrefix: false,
87
+ });
88
+ readStoreAllowFromForDmPolicyMock.mockResolvedValue([]);
89
+ resolveEffectiveAllowFromListsMock.mockReturnValue({
90
+ effectiveAllowFrom: ["*"],
91
+ });
92
+ resolveControlCommandGateMock.mockReturnValue({
93
+ commandAuthorized: true,
94
+ shouldBlock: false,
95
+ });
96
+ resolveOutboundMediaUrlsMock.mockReturnValue([]);
97
+ formatTextWithAttachmentLinksMock.mockImplementation((text: string) => text);
98
+ recordInboundSessionMock.mockResolvedValue(undefined);
99
+ dispatchReplyWithBufferedBlockDispatcherMock.mockImplementation(
100
+ async (params: Record<string, any>) => {
101
+ await params.dispatcherOptions.deliver({ text: "reply body" });
102
+ },
103
+ );
104
+ getHubRuntimeMock.mockReturnValue({
105
+ channel: {
106
+ commands: {
107
+ shouldHandleTextCommands: vi.fn(() => true),
108
+ },
109
+ reply: {
110
+ dispatchReplyWithBufferedBlockDispatcher: dispatchReplyWithBufferedBlockDispatcherMock,
111
+ finalizeInboundContext: vi.fn((ctx: Record<string, unknown>) => ctx),
112
+ formatAgentEnvelope: vi.fn(() => "formatted inbound"),
113
+ resolveEnvelopeFormatOptions: vi.fn(() => ({})),
114
+ },
115
+ routing: {
116
+ resolveAgentRoute: vi.fn(() => ({
117
+ agentId: "main",
118
+ sessionKey: "agent:main:hub:direct:CombinatorAgent",
119
+ accountId: "default",
120
+ })),
121
+ },
122
+ session: {
123
+ readSessionUpdatedAt: vi.fn(() => undefined),
124
+ recordInboundSession: recordInboundSessionMock,
125
+ resolveStorePath: vi.fn(() => "/tmp/openclaw-store"),
126
+ },
127
+ text: {
128
+ hasControlCommand: vi.fn(() => false),
129
+ },
130
+ },
131
+ });
132
+ });
133
+
134
+ it("replies to the plain Hub id while keeping prefixed conversation history keys", async () => {
135
+ const sendReplyMock = vi.fn().mockResolvedValue(undefined);
136
+
137
+ await handleHubInbound({
138
+ message: {
139
+ messageId: "msg-1",
140
+ from: "CombinatorAgent",
141
+ text: "hello",
142
+ timestamp: 123,
143
+ },
144
+ account: {
145
+ accountId: "default",
146
+ config: {
147
+ dmPolicy: "open",
148
+ allowFrom: ["*"],
149
+ },
150
+ } as any,
151
+ config: {
152
+ commands: {},
153
+ session: {
154
+ store: {},
155
+ },
156
+ } as any,
157
+ runtime: {
158
+ error: vi.fn(),
159
+ log: vi.fn(),
160
+ } as any,
161
+ sendReply: sendReplyMock,
162
+ });
163
+
164
+ expect(sendReplyMock).toHaveBeenCalledWith("CombinatorAgent", "reply body");
165
+ expect(sendMessageHubMock).not.toHaveBeenCalled();
166
+ expect(emitOutboundHistoryMock).toHaveBeenCalledWith(
167
+ expect.objectContaining({
168
+ conversationKey: "hub:CombinatorAgent",
169
+ }),
170
+ );
171
+ });
172
+ });
@@ -0,0 +1,97 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const {
4
+ convertMarkdownTablesMock,
5
+ fetchWithSsrFGuardMock,
6
+ getHubRuntimeMock,
7
+ loadConfigMock,
8
+ recordActivityMock,
9
+ resolveHubAccountMock,
10
+ resolveMarkdownTableModeMock,
11
+ releaseMock,
12
+ } = vi.hoisted(() => ({
13
+ convertMarkdownTablesMock: vi.fn(),
14
+ fetchWithSsrFGuardMock: vi.fn(),
15
+ getHubRuntimeMock: vi.fn(),
16
+ loadConfigMock: vi.fn(),
17
+ recordActivityMock: vi.fn(),
18
+ resolveHubAccountMock: vi.fn(),
19
+ resolveMarkdownTableModeMock: vi.fn(),
20
+ releaseMock: vi.fn(),
21
+ }));
22
+
23
+ vi.mock("openclaw/plugin-sdk", () => ({
24
+ fetchWithSsrFGuard: fetchWithSsrFGuardMock,
25
+ }));
26
+
27
+ vi.mock("./accounts.js", () => ({
28
+ resolveHubAccount: resolveHubAccountMock,
29
+ }));
30
+
31
+ vi.mock("./runtime.js", () => ({
32
+ getHubRuntime: getHubRuntimeMock,
33
+ }));
34
+
35
+ import { sendMessageHub } from "./send.js";
36
+
37
+ describe("sendMessageHub", () => {
38
+ beforeEach(() => {
39
+ convertMarkdownTablesMock.mockReset();
40
+ fetchWithSsrFGuardMock.mockReset();
41
+ getHubRuntimeMock.mockReset();
42
+ loadConfigMock.mockReset();
43
+ recordActivityMock.mockReset();
44
+ releaseMock.mockReset();
45
+ resolveHubAccountMock.mockReset();
46
+ resolveMarkdownTableModeMock.mockReset();
47
+
48
+ releaseMock.mockResolvedValue(undefined);
49
+ loadConfigMock.mockReturnValue({});
50
+ resolveMarkdownTableModeMock.mockReturnValue("off");
51
+ convertMarkdownTablesMock.mockImplementation((text: string) => text);
52
+ recordActivityMock.mockReturnValue(undefined);
53
+ resolveHubAccountMock.mockReturnValue({
54
+ configured: true,
55
+ accountId: "default",
56
+ url: "https://hub.example.test",
57
+ agentId: "sender",
58
+ secret: "shared-secret",
59
+ });
60
+ getHubRuntimeMock.mockReturnValue({
61
+ config: { loadConfig: loadConfigMock },
62
+ channel: {
63
+ text: {
64
+ resolveMarkdownTableMode: resolveMarkdownTableModeMock,
65
+ convertMarkdownTables: convertMarkdownTablesMock,
66
+ },
67
+ activity: {
68
+ record: recordActivityMock,
69
+ },
70
+ },
71
+ });
72
+ fetchWithSsrFGuardMock.mockResolvedValue({
73
+ response: {
74
+ ok: true,
75
+ },
76
+ release: releaseMock,
77
+ });
78
+ });
79
+
80
+ it("posts to the plain agent id when given a hub:-prefixed recipient", async () => {
81
+ const result = await sendMessageHub(" hub:Brain ", "hello");
82
+
83
+ expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
84
+ expect.objectContaining({
85
+ url: "https://hub.example.test/agents/Brain/message",
86
+ }),
87
+ );
88
+ expect(result.target).toBe("Brain");
89
+ });
90
+
91
+ it("rejects empty recipients after normalization", async () => {
92
+ await expect(sendMessageHub("hub:", "hello")).rejects.toThrow(
93
+ "Hub send target must be non-empty",
94
+ );
95
+ expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
96
+ });
97
+ });
@@ -1,6 +1,7 @@
1
1
  import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
2
2
  import { resolveHubAccount } from "./accounts.js";
3
3
  import { getHubRuntime } from "./runtime.js";
4
+ import { normalizeHubTarget } from "./targets.js";
4
5
  import type { CoreConfig } from "./types.js";
5
6
 
6
7
  type SendHubOptions = {
@@ -27,7 +28,7 @@ export async function sendMessageHub(
27
28
  );
28
29
  }
29
30
 
30
- const target = to.trim();
31
+ const target = normalizeHubTarget(to);
31
32
  if (!target) {
32
33
  throw new Error("Hub send target must be non-empty");
33
34
  }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeHubAllowEntry, normalizeHubTarget } from "./targets.js";
3
+
4
+ describe("Hub target normalization", () => {
5
+ it("strips a leading hub: prefix and trims whitespace", () => {
6
+ expect(normalizeHubTarget(" hub:Brain ")).toBe("Brain");
7
+ expect(normalizeHubTarget("HUB:CombinatorAgent")).toBe("CombinatorAgent");
8
+ });
9
+
10
+ it("keeps plain agent ids intact", () => {
11
+ expect(normalizeHubTarget("brain")).toBe("brain");
12
+ });
13
+
14
+ it("normalizes allowlist entries to lowercase agent ids", () => {
15
+ expect(normalizeHubAllowEntry(" hub:Brain ")).toBe("brain");
16
+ expect(normalizeHubAllowEntry("CombinatorAgent")).toBe("combinatoragent");
17
+ });
18
+
19
+ it("preserves wildcard allowlist entries", () => {
20
+ expect(normalizeHubAllowEntry("*")).toBe("*");
21
+ expect(normalizeHubAllowEntry("hub:*")).toBe("*");
22
+ });
23
+
24
+ it("returns undefined for empty values", () => {
25
+ expect(normalizeHubTarget("")).toBeUndefined();
26
+ expect(normalizeHubTarget("hub:")).toBeUndefined();
27
+ expect(normalizeHubAllowEntry(" ")).toBeUndefined();
28
+ });
29
+ });
@@ -0,0 +1,21 @@
1
+ const HUB_PREFIX_RE = /^hub:/i;
2
+
3
+ export function normalizeHubTarget(raw: string | null | undefined): string | undefined {
4
+ const trimmed = String(raw ?? "").trim();
5
+ if (!trimmed) {
6
+ return undefined;
7
+ }
8
+ const normalized = trimmed.replace(HUB_PREFIX_RE, "").trim();
9
+ return normalized || undefined;
10
+ }
11
+
12
+ export function normalizeHubAllowEntry(raw: string | null | undefined): string | undefined {
13
+ const normalized = normalizeHubTarget(raw);
14
+ if (!normalized) {
15
+ return undefined;
16
+ }
17
+ if (normalized === "*") {
18
+ return normalized;
19
+ }
20
+ return normalized.toLowerCase();
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "activeclaw",
3
- "version": "2026.3.10",
3
+ "version": "2026.3.11",
4
4
  "description": "Multi-channel AI gateway with extensible messaging integrations",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/openclaw/openclaw#readme",