Skip to content

Commit 3e9199f

Browse files
amknightsteipete
authored andcommitted
fix(reply): derive explicit control command turns
1 parent b74cd69 commit 3e9199f

6 files changed

Lines changed: 391 additions & 16 deletions

src/auto-reply/command-turn-context.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from "vitest";
2+
import type { OpenClawConfig } from "../config/types.openclaw.js";
23
import {
34
createCommandTurnContext,
45
isAuthorizedTextSlashCommandTurn,
@@ -7,6 +8,9 @@ import {
78
resolveCommandTurnContext,
89
resolveCommandTurnTargetSessionKey,
910
} from "./command-turn-context.js";
11+
import { isExplicitCommandTurnContext } from "./command-turn-detection.js";
12+
13+
const emptyConfig = {} as const satisfies OpenClawConfig;
1014

1115
describe("resolveCommandTurnContext", () => {
1216
it("derives native command turns from legacy context fields", () => {
@@ -53,6 +57,67 @@ describe("resolveCommandTurnContext", () => {
5357
expect(isExplicitCommandTurn(commandTurn)).toBe(false);
5458
});
5559

60+
it("treats authorized control command bodies as explicit without legacy source tags", () => {
61+
expect(
62+
isExplicitCommandTurnContext(
63+
{
64+
CommandAuthorized: true,
65+
CommandBody: "/reset",
66+
},
67+
emptyConfig,
68+
),
69+
).toBe(true);
70+
expect(
71+
isExplicitCommandTurnContext(
72+
{
73+
CommandAuthorized: true,
74+
CommandBody: "hey can you /status please",
75+
},
76+
emptyConfig,
77+
),
78+
).toBe(false);
79+
});
80+
81+
it("keeps structured normal command turns non-explicit", () => {
82+
expect(
83+
isExplicitCommandTurnContext(
84+
{
85+
CommandTurn: {
86+
kind: "normal",
87+
source: "message",
88+
authorized: false,
89+
body: "/think high through this",
90+
},
91+
CommandAuthorized: true,
92+
Body: "through this",
93+
RawBody: "through this",
94+
CommandBody: "/think high through this",
95+
},
96+
emptyConfig,
97+
),
98+
).toBe(false);
99+
});
100+
101+
it("uses cleaned command bodies for command-shaped structured normal turns", () => {
102+
expect(
103+
isExplicitCommandTurnContext(
104+
{
105+
CommandTurn: {
106+
kind: "normal",
107+
source: "message",
108+
authorized: false,
109+
body: "/reset",
110+
},
111+
CommandAuthorized: true,
112+
Body: "/reset@openclaw",
113+
RawBody: "/reset@openclaw",
114+
CommandBody: "/reset",
115+
},
116+
emptyConfig,
117+
),
118+
).toBe(true);
119+
});
120+
56121
it("lets structured command turns override legacy command fields", () => {
57122
expect(
58123
resolveCommandTurnContext({
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
import { normalizeOptionalString } from "../shared/string-coerce.js";
3+
import { isControlCommandMessage } from "./command-detection.js";
4+
import {
5+
isExplicitCommandTurn,
6+
resolveCommandTurnContext,
7+
type CommandTurnContextInput,
8+
} from "./command-turn-context.js";
9+
10+
function resolveCommandBody(input: CommandTurnContextInput): string | undefined {
11+
return (
12+
normalizeOptionalString(input.CommandBody) ??
13+
normalizeOptionalString(input.BodyForCommands) ??
14+
normalizeOptionalString(input.RawBody) ??
15+
normalizeOptionalString(input.Body)
16+
);
17+
}
18+
19+
function resolveVisibleMessageBody(input: CommandTurnContextInput): string | undefined {
20+
return normalizeOptionalString(input.RawBody) ?? normalizeOptionalString(input.Body);
21+
}
22+
23+
function resolveStructuredNormalFallbackBody(input: CommandTurnContextInput): string | undefined {
24+
const visibleBody = resolveVisibleMessageBody(input);
25+
if (!/^[!/]/.test(visibleBody ?? "")) {
26+
return undefined;
27+
}
28+
return resolveCommandBody(input) ?? visibleBody;
29+
}
30+
31+
function hasCommandSourceMetadata(input: CommandTurnContextInput): boolean {
32+
return (
33+
input.CommandSource === "native" ||
34+
input.CommandSource === "text" ||
35+
input.CommandSource === "message"
36+
);
37+
}
38+
39+
export function isExplicitCommandTurnContext(
40+
input: CommandTurnContextInput,
41+
cfg: OpenClawConfig,
42+
): boolean {
43+
if (isExplicitCommandTurn(resolveCommandTurnContext(input))) {
44+
return true;
45+
}
46+
if (input.CommandSource === "native" || input.CommandSource === "text") {
47+
return false;
48+
}
49+
const fallbackBody =
50+
input.CommandTurn !== undefined || hasCommandSourceMetadata(input)
51+
? resolveStructuredNormalFallbackBody(input)
52+
: resolveCommandBody(input);
53+
return input.CommandAuthorized === true && isControlCommandMessage(fallbackBody, cfg);
54+
}

src/auto-reply/reply/dispatch-from-config.test.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5360,6 +5360,7 @@ describe("dispatchReplyFromConfig", () => {
53605360
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
53615361
status: "no_handler",
53625362
});
5363+
hookMocks.runner.runInboundClaimForPluginOutcome.mockClear();
53635364
sessionBindingMocks.resolveByConversation.mockReturnValue({
53645365
bindingId: "binding-no-handler-1",
53655366
targetSessionKey: "plugin-binding:codex:nohandler123",
@@ -6781,6 +6782,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
67816782
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
67826783
status: "no_handler",
67836784
});
6785+
hookMocks.runner.runInboundClaimForPluginOutcome.mockClear();
67846786
sessionBindingMocks.resolveByConversation.mockReturnValue({
67856787
bindingId: "binding-message-tool-fallback",
67866788
targetSessionKey: "plugin-binding:codex:abc123",
@@ -6852,6 +6854,231 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
68526854
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
68536855
});
68546856

6857+
it("lets authorized control commands without CommandSource escape plugin-bound fallback", async () => {
6858+
setNoAbort();
6859+
hookMocks.runner.hasHooks.mockImplementation(
6860+
((hookName?: string) =>
6861+
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
6862+
);
6863+
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
6864+
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
6865+
status: "no_handler",
6866+
});
6867+
hookMocks.runner.runInboundClaimForPluginOutcome.mockClear();
6868+
sessionBindingMocks.resolveByConversation.mockReturnValue({
6869+
bindingId: "binding-message-tool-command",
6870+
targetSessionKey: "plugin-binding:codex:abc123",
6871+
targetKind: "session",
6872+
conversation: {
6873+
channel: "telegram",
6874+
accountId: "default",
6875+
conversationId: "-1001234567890:topic:11",
6876+
parentConversationId: "-1001234567890",
6877+
},
6878+
status: "active",
6879+
boundAt: 1710000000000,
6880+
metadata: {
6881+
pluginBindingOwner: "plugin",
6882+
pluginId: "openclaw-codex-app-server",
6883+
pluginRoot: "/tmp/plugin",
6884+
},
6885+
} satisfies SessionBindingRecord);
6886+
sessionStoreMocks.currentEntry = {
6887+
sessionId: "s1",
6888+
updatedAt: 0,
6889+
sendPolicy: "allow",
6890+
};
6891+
const cfg = { messages: { visibleReplies: "message_tool" } } as OpenClawConfig;
6892+
const dispatcher = createDispatcher();
6893+
const replyResolver = vi.fn(async () => ({ text: "reset ack" }) satisfies ReplyPayload);
6894+
const ctx = buildTestCtx({
6895+
Provider: "telegram",
6896+
Surface: "telegram",
6897+
OriginatingChannel: "telegram",
6898+
OriginatingTo: "telegram:-1001234567890",
6899+
To: "telegram:-1001234567890",
6900+
AccountId: "default",
6901+
MessageThreadId: 11,
6902+
SessionKey: "agent:main:telegram:group:-1001234567890:topic:11",
6903+
ChatType: "group",
6904+
GroupSubject: "Dev",
6905+
Body: "/reset@openclaw",
6906+
RawBody: "/reset@openclaw",
6907+
CommandBody: "/reset",
6908+
CommandSource: undefined,
6909+
CommandAuthorized: true,
6910+
WasMentioned: false,
6911+
});
6912+
6913+
await dispatchReplyFromConfig({
6914+
ctx,
6915+
cfg,
6916+
dispatcher,
6917+
replyResolver,
6918+
});
6919+
6920+
expect(hookMocks.runner.runInboundClaimForPluginOutcome).not.toHaveBeenCalled();
6921+
expect(replyResolver).toHaveBeenCalledTimes(1);
6922+
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "reset ack" });
6923+
});
6924+
6925+
it("keeps unauthorized native commands on the plugin-bound claim path", async () => {
6926+
setNoAbort();
6927+
hookMocks.runner.hasHooks.mockImplementation(
6928+
((hookName?: string) =>
6929+
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
6930+
);
6931+
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
6932+
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
6933+
status: "handled",
6934+
result: {},
6935+
});
6936+
hookMocks.runner.runInboundClaimForPluginOutcome.mockClear();
6937+
sessionBindingMocks.resolveByConversation.mockReturnValue({
6938+
bindingId: "binding-native-unauthorized",
6939+
targetSessionKey: "plugin-binding:codex:abc123",
6940+
targetKind: "session",
6941+
conversation: {
6942+
channel: "telegram",
6943+
accountId: "default",
6944+
conversationId: "-1001234567890:topic:11",
6945+
parentConversationId: "-1001234567890",
6946+
},
6947+
status: "active",
6948+
boundAt: 1710000000000,
6949+
metadata: {
6950+
pluginBindingOwner: "plugin",
6951+
pluginId: "openclaw-codex-app-server",
6952+
pluginRoot: "/tmp/plugin",
6953+
},
6954+
} satisfies SessionBindingRecord);
6955+
sessionStoreMocks.currentEntry = {
6956+
sessionId: "s1",
6957+
updatedAt: 0,
6958+
sendPolicy: "allow",
6959+
};
6960+
const dispatcher = createDispatcher();
6961+
const replyResolver = vi.fn(async () => ({ text: "core reply" }) satisfies ReplyPayload);
6962+
const ctx = buildTestCtx({
6963+
Provider: "telegram",
6964+
Surface: "telegram",
6965+
OriginatingChannel: "telegram",
6966+
OriginatingTo: "telegram:-1001234567890",
6967+
To: "telegram:-1001234567890",
6968+
AccountId: "default",
6969+
MessageThreadId: 11,
6970+
SessionKey: "agent:main:telegram:group:-1001234567890:topic:11",
6971+
ChatType: "group",
6972+
GroupSubject: "Dev",
6973+
Body: "/status",
6974+
RawBody: "/status",
6975+
CommandBody: "/status",
6976+
CommandSource: "native",
6977+
CommandAuthorized: false,
6978+
WasMentioned: false,
6979+
});
6980+
6981+
await dispatchReplyFromConfig({
6982+
ctx,
6983+
cfg: emptyConfig,
6984+
dispatcher,
6985+
replyResolver,
6986+
});
6987+
6988+
expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith(
6989+
"openclaw-codex-app-server",
6990+
expect.objectContaining({
6991+
channel: "telegram",
6992+
content: "/status",
6993+
}),
6994+
expect.objectContaining({
6995+
pluginBinding: expect.objectContaining({ bindingId: "binding-native-unauthorized" }),
6996+
}),
6997+
);
6998+
expect(replyResolver).not.toHaveBeenCalled();
6999+
});
7000+
7001+
it("keeps structured normal command turns on the plugin-bound claim path", async () => {
7002+
setNoAbort();
7003+
hookMocks.runner.hasHooks.mockImplementation(
7004+
((hookName?: string) =>
7005+
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
7006+
);
7007+
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
7008+
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
7009+
status: "handled",
7010+
result: {},
7011+
});
7012+
hookMocks.runner.runInboundClaimForPluginOutcome.mockClear();
7013+
sessionBindingMocks.resolveByConversation.mockReturnValue({
7014+
bindingId: "binding-structured-normal-turn",
7015+
targetSessionKey: "plugin-binding:codex:abc123",
7016+
targetKind: "session",
7017+
conversation: {
7018+
channel: "telegram",
7019+
accountId: "default",
7020+
conversationId: "-1001234567890:topic:11",
7021+
parentConversationId: "-1001234567890",
7022+
},
7023+
status: "active",
7024+
boundAt: 1710000000000,
7025+
metadata: {
7026+
pluginBindingOwner: "plugin",
7027+
pluginId: "openclaw-codex-app-server",
7028+
pluginRoot: "/tmp/plugin",
7029+
},
7030+
} satisfies SessionBindingRecord);
7031+
sessionStoreMocks.currentEntry = {
7032+
sessionId: "s1",
7033+
updatedAt: 0,
7034+
sendPolicy: "allow",
7035+
};
7036+
const dispatcher = createDispatcher();
7037+
const replyResolver = vi.fn(async () => ({ text: "core reply" }) satisfies ReplyPayload);
7038+
const ctx = buildTestCtx({
7039+
Provider: "telegram",
7040+
Surface: "telegram",
7041+
OriginatingChannel: "telegram",
7042+
OriginatingTo: "telegram:-1001234567890",
7043+
To: "telegram:-1001234567890",
7044+
AccountId: "default",
7045+
MessageThreadId: 11,
7046+
SessionKey: "agent:main:telegram:group:-1001234567890:topic:11",
7047+
ChatType: "group",
7048+
GroupSubject: "Dev",
7049+
Body: "through this",
7050+
RawBody: "through this",
7051+
CommandBody: "/think high through this",
7052+
CommandAuthorized: true,
7053+
CommandTurn: {
7054+
kind: "normal",
7055+
source: "message",
7056+
authorized: false,
7057+
body: "/think high through this",
7058+
},
7059+
WasMentioned: false,
7060+
});
7061+
7062+
await dispatchReplyFromConfig({
7063+
ctx,
7064+
cfg: emptyConfig,
7065+
dispatcher,
7066+
replyResolver,
7067+
});
7068+
7069+
expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith(
7070+
"openclaw-codex-app-server",
7071+
expect.objectContaining({
7072+
channel: "telegram",
7073+
content: "/think high through this",
7074+
}),
7075+
expect.objectContaining({
7076+
pluginBinding: expect.objectContaining({ bindingId: "binding-structured-normal-turn" }),
7077+
}),
7078+
);
7079+
expect(replyResolver).not.toHaveBeenCalled();
7080+
});
7081+
68557082
it("keeps message-tool-only source delivery private while still processing the turn", async () => {
68567083
setNoAbort();
68577084
sessionStoreMocks.currentEntry = {

0 commit comments

Comments
 (0)