Skip to content

Commit 3b993cb

Browse files
committed
feat: carry gateway client capabilities through the CLI loopback backend
CLI-backed model runs now transport the originating client's declared capabilities along the existing per-field loopback contract: RunCliAgentParams gains clientCaps, prepare emits OPENCLAW_MCP_CLIENT_CAPS, the loopback header template forwards x-openclaw-client-caps, the request context parses and normalizes it (grant-authenticated callers keep ignoring spoofable headers), and the loopback tool cache keys on a stable caps serialization so capless and capped requests never share tool lists. Capability-gated tools such as show_widget now work on CLI backends; the CLI session binding hash includes caps, consistent with messageProvider. Cron and command-attempt runs stay capless (fail closed). Fixes #102577
1 parent f60b150 commit 3b993cb

14 files changed

Lines changed: 111 additions & 1 deletion

docs/tools/show-widget.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ read_when:
1111

1212
The tool is available only when the originating Gateway client declares the `inline-widgets` capability. The Control UI declares this capability automatically. Channel runs such as Telegram and WhatsApp do not receive `show_widget`.
1313

14-
Capability transport currently covers the embedded runner and the Codex app-server backend. CLI-backed model backends do not yet carry client capabilities, so capability-gated tools stay unavailable (fail closed) on those backends.
14+
Capability transport covers embedded, Codex app-server, and CLI-backed model backends. Grant-authenticated MCP callers and direct HTTP tool-invoke callers remain fail closed because they do not declare client capabilities.
1515

1616
## Use the tool
1717

src/agents/cli-runner/prepare.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ function createTestMcpLoopbackServerConfig(port: number) {
122122
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
123123
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
124124
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
125+
"x-openclaw-client-caps": "${OPENCLAW_MCP_CLIENT_CAPS}",
125126
"x-openclaw-current-channel-id": "${OPENCLAW_MCP_CURRENT_CHANNEL_ID}",
126127
"x-openclaw-current-thread-ts": "${OPENCLAW_MCP_CURRENT_THREAD_TS}",
127128
"x-openclaw-current-message-id": "${OPENCLAW_MCP_CURRENT_MESSAGE_ID}",
@@ -2804,13 +2805,15 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
28042805
cfg: expect.any(Object),
28052806
sessionKey: "agent:main:test",
28062807
messageProvider: undefined,
2808+
clientCaps: undefined,
28072809
currentChannelId: undefined,
28082810
currentThreadTs: undefined,
28092811
currentMessageId: undefined,
28102812
currentInboundAudio: undefined,
28112813
accountId: undefined,
28122814
inboundEventKind: undefined,
28132815
sourceReplyDeliveryMode: undefined,
2816+
taskSuggestionDeliveryMode: undefined,
28142817
requireExplicitMessageTarget: false,
28152818
senderIsOwner: undefined,
28162819
});
@@ -2965,6 +2968,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
29652968
config: createCliBackendConfig(),
29662969
currentInboundEventKind: "room_event",
29672970
messageChannel: "telegram",
2971+
clientCaps: ["tool-events", "inline-widgets"],
29682972
currentChannelId: "telegram:-100123:topic:42",
29692973
currentThreadTs: "42",
29702974
currentMessageId: "reply-message-1",
@@ -2977,6 +2981,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
29772981
expect(context.preparedBackend.env).toMatchObject({
29782982
OPENCLAW_MCP_SESSION_ID: "session-test",
29792983
OPENCLAW_MCP_MESSAGE_CHANNEL: "telegram",
2984+
OPENCLAW_MCP_CLIENT_CAPS: "tool-events,inline-widgets",
29802985
OPENCLAW_MCP_CURRENT_CHANNEL_ID: "telegram:-100123:topic:42",
29812986
OPENCLAW_MCP_CURRENT_THREAD_TS: "42",
29822987
OPENCLAW_MCP_CURRENT_MESSAGE_ID: "reply-message-1",
@@ -2990,6 +2995,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
29902995
expect(context.mcpDeliveryCapture).toBe(true);
29912996
expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledWith(
29922997
expect.objectContaining({
2998+
clientCaps: ["tool-events", "inline-widgets"],
29932999
taskSuggestionDeliveryMode: "gateway",
29943000
requireExplicitMessageTarget: true,
29953001
}),

src/agents/cli-runner/prepare.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,7 @@ export async function prepareCliRunContext(
588588
OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "",
589589
OPENCLAW_MCP_SESSION_ID: params.sessionId,
590590
OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageChannel ?? params.messageProvider ?? "",
591+
OPENCLAW_MCP_CLIENT_CAPS: params.clientCaps?.join(",") ?? "",
591592
OPENCLAW_MCP_CURRENT_CHANNEL_ID: params.currentChannelId ?? "",
592593
OPENCLAW_MCP_CURRENT_THREAD_TS: params.currentThreadTs ?? "",
593594
OPENCLAW_MCP_CURRENT_MESSAGE_ID:
@@ -710,6 +711,7 @@ export async function prepareCliRunContext(
710711
cfg: params.config ?? getRuntimeConfig(),
711712
sessionKey: params.sessionKey ?? "",
712713
messageProvider: params.messageChannel ?? params.messageProvider,
714+
clientCaps: params.clientCaps,
713715
currentChannelId: params.currentChannelId,
714716
// CLI binding hashes must use session-stable prompt facts. Per-sender
715717
// and per-message scope stays in the runtime MCP env/list-call path.

src/agents/cli-runner/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ export type RunCliAgentParams = {
120120
skillsSnapshot?: SkillSnapshot;
121121
messageChannel?: string;
122122
messageProvider?: string;
123+
/** Capabilities declared by the gateway client that originated this run. */
124+
clientCaps?: string[];
123125
currentChannelId?: string;
124126
chatId?: string;
125127
channelContext?: PluginHookChannelContext;

src/auto-reply/reply/agent-runner-execution.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1676,6 +1676,7 @@ describe("runAgentTurnWithFallback", () => {
16761676
const followupRun = createFollowupRun();
16771677
followupRun.run.provider = "codex-cli";
16781678
followupRun.run.model = "gpt-5.4";
1679+
followupRun.run.clientCaps = ["tool-events", "inline-widgets"];
16791680
const typingSignals = createMockTypingSignaler();
16801681

16811682
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
@@ -1691,6 +1692,7 @@ describe("runAgentTurnWithFallback", () => {
16911692
expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
16921693
provider: "codex-cli",
16931694
model: "gpt-5.4",
1695+
clientCaps: ["tool-events", "inline-widgets"],
16941696
});
16951697
});
16961698

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2565,6 +2565,7 @@ async function runAgentTurnWithFallbackInternal(
25652565
skillsSnapshot: params.followupRun.run.skillsSnapshot,
25662566
messageChannel: params.followupRun.originatingChannel ?? undefined,
25672567
messageProvider: hookMessageProvider,
2568+
clientCaps: params.followupRun.run.clientCaps,
25682569
currentChannelId:
25692570
params.followupRun.originatingTo ??
25702571
params.sessionCtx.OriginatingTo ??

src/auto-reply/reply/followup-runner.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1300,6 +1300,7 @@ describe("createFollowupRunner runtime config", () => {
13001300
provider: "anthropic",
13011301
model: "claude-opus-4-7",
13021302
messageProvider: "telegram",
1303+
clientCaps: ["tool-events", "inline-widgets"],
13031304
senderId: "sender-42",
13041305
senderIsOwner: true,
13051306
cwd: "/tmp/task-repo",
@@ -1320,6 +1321,7 @@ describe("createFollowupRunner runtime config", () => {
13201321
expect(call.config).toBe(runtimeConfig);
13211322
expect(call.cliSessionId).toBe("cli-session-1");
13221323
expect(call.messageChannel).toBe("telegram");
1324+
expect(call.clientCaps).toEqual(["tool-events", "inline-widgets"]);
13231325
expect(call.currentChannelId).toBe("telegram:-100123:topic:42");
13241326
expect(call.currentThreadTs).toBe("42");
13251327
expect(call.currentMessageId).toBe("reply-42");

src/auto-reply/reply/followup-runner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,6 +1216,7 @@ export function createFollowupRunner(params: {
12161216
originatingChannel: queued.originatingChannel,
12171217
provider: run.messageProvider,
12181218
}),
1219+
clientCaps: run.clientCaps,
12191220
currentChannelId: queued.originatingTo,
12201221
senderId: run.senderId,
12211222
chatId: queued.originatingChatId,

src/gateway/mcp-http.loopback-runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,7 @@ const MCP_CONTEXT_HEADERS = {
392392
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
393393
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
394394
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
395+
"x-openclaw-client-caps": "${OPENCLAW_MCP_CLIENT_CAPS}",
395396
"x-openclaw-current-channel-id": "${OPENCLAW_MCP_CURRENT_CHANNEL_ID}",
396397
"x-openclaw-current-thread-ts": "${OPENCLAW_MCP_CURRENT_THREAD_TS}",
397398
"x-openclaw-current-message-id": "${OPENCLAW_MCP_CURRENT_MESSAGE_ID}",

src/gateway/mcp-http.request.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ type McpRequestContext = {
5656
sessionKey: string;
5757
sessionId: string | undefined;
5858
messageProvider: string | undefined;
59+
clientCaps: string[] | undefined;
5960
currentChannelId: string | undefined;
6061
currentThreadTs: string | undefined;
6162
currentMessageId: string | undefined;
@@ -370,6 +371,13 @@ export function resolveMcpCliCaptureKey(req: IncomingMessage): string | undefine
370371
return normalizeOptionalString(getHeader(req, "x-openclaw-cli-capture-key"));
371372
}
372373

374+
function normalizeMcpClientCapsHeader(value: string | undefined): string[] | undefined {
375+
const clientCaps = [...new Set((value ?? "").split(",").map((cap) => cap.trim()))].filter(
376+
Boolean,
377+
);
378+
return clientCaps.length > 0 ? clientCaps : undefined;
379+
}
380+
373381
export function resolveMcpRequestContext(
374382
req: IncomingMessage,
375383
cfg: OpenClawConfig,
@@ -382,6 +390,7 @@ export function resolveMcpRequestContext(
382390
sessionKey: auth.boundSessionKey,
383391
sessionId: undefined,
384392
messageProvider: undefined,
393+
clientCaps: undefined,
385394
currentChannelId: undefined,
386395
currentThreadTs: undefined,
387396
currentMessageId: undefined,
@@ -399,6 +408,9 @@ export function resolveMcpRequestContext(
399408
sessionId: normalizeOptionalString(getHeader(req, "x-openclaw-session-id")),
400409
messageProvider:
401410
normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? undefined,
411+
// The token-authenticated loopback client is gateway-spawned on 127.0.0.1. Caps only
412+
// widen tool availability; sender ownership remains derived from the bearer token.
413+
clientCaps: normalizeMcpClientCapsHeader(getHeader(req, "x-openclaw-client-caps")),
402414
currentChannelId: normalizeOptionalString(getHeader(req, "x-openclaw-current-channel-id")),
403415
currentThreadTs: normalizeOptionalString(getHeader(req, "x-openclaw-current-thread-ts")),
404416
currentMessageId: normalizeOptionalString(getHeader(req, "x-openclaw-current-message-id")),

0 commit comments

Comments
 (0)