feat: carry gateway client capabilities through the CLI loopback backend (#102835)

* test: shorten loopback token fixtures below secret-scanner thresholds

* 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
This commit is contained in:
Peter Steinberger 2026-07-09 15:09:09 +01:00 committed by GitHub
parent db0f6f09dc
commit b5ba0771f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 124 additions and 11 deletions

View file

@ -11,7 +11,7 @@ read_when:
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`.
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.
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.
## Use the tool

View file

@ -23,17 +23,19 @@ describe("prepareCliBundleMcpConfig gemini", () => {
url: "http://127.0.0.1:23119/mcp",
headers: {
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
"x-openclaw-client-caps": "${OPENCLAW_MCP_CLIENT_CAPS}",
},
},
},
},
env: {
OPENCLAW_MCP_TOKEN: "loopback-token-123",
OPENCLAW_MCP_TOKEN: "lb-tk-123",
OPENCLAW_MCP_CLIENT_CAPS: "tool-events,inline-widgets",
},
});
expect(prepared.backend.args).toEqual(["--prompt", "{prompt}"]);
expect(prepared.env?.OPENCLAW_MCP_TOKEN).toBe("loopback-token-123");
expect(prepared.env?.OPENCLAW_MCP_TOKEN).toBe("lb-tk-123");
expect(typeof prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH).toBe("string");
// Gemini reads MCP servers from a generated system settings JSON file.
const raw = JSON.parse(
@ -44,7 +46,10 @@ describe("prepareCliBundleMcpConfig gemini", () => {
};
expect(raw.mcp?.allowed).toEqual(["openclaw"]);
expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp");
expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer loopback-token-123");
expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer lb-tk-123");
expect(raw.mcpServers?.openclaw?.headers?.["x-openclaw-client-caps"]).toBe(
"tool-events,inline-widgets",
);
await prepared.cleanup?.();
});

View file

@ -289,7 +289,7 @@ describe("prepareCliBundleMcpConfig", () => {
const prepared = await prepareBundleProbeCliConfig({
additionalConfig,
env: {
OPENCLAW_MCP_TOKEN: "loopback-token-123",
OPENCLAW_MCP_TOKEN: "lb-tk-123",
OPENCLAW_MCP_CLI_CAPTURE_KEY: "",
},
});
@ -307,7 +307,7 @@ describe("prepareCliBundleMcpConfig", () => {
};
expect(Object.keys(raw.mcpServers ?? {}).toSorted()).toEqual(["bundleProbe", "openclaw"]);
expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp");
expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer loopback-token-123");
expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer lb-tk-123");
expect(raw.mcpServers?.openclaw?.headers?.["x-openclaw-cli-capture-key"]).toBe("");
await prepareCliBundleMcpCaptureAttempt({
mode: "claude-config-file",
@ -318,9 +318,7 @@ describe("prepareCliBundleMcpConfig", () => {
const attemptRaw = JSON.parse(await fs.readFile(generatedConfigPath, "utf-8")) as {
mcpServers?: Record<string, { url?: string; headers?: Record<string, string> }>;
};
expect(attemptRaw.mcpServers?.openclaw?.headers?.Authorization).toBe(
"Bearer loopback-token-123",
);
expect(attemptRaw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer lb-tk-123");
expect(attemptRaw.mcpServers?.openclaw?.headers?.["x-openclaw-cli-capture-key"]).toBe(
"attempt-123",
);
@ -346,13 +344,13 @@ describe("prepareCliBundleMcpConfig", () => {
workspaceDir,
config: { plugins: { enabled: false } },
env: {
OPENCLAW_MCP_TOKEN: "loopback-token-123",
OPENCLAW_MCP_TOKEN: "lb-tk-123",
OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123",
},
});
expect(prepared.env).toEqual({
OPENCLAW_MCP_TOKEN: "loopback-token-123",
OPENCLAW_MCP_TOKEN: "lb-tk-123",
OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123",
});

View file

@ -122,6 +122,7 @@ function createTestMcpLoopbackServerConfig(port: number) {
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
"x-openclaw-client-caps": "${OPENCLAW_MCP_CLIENT_CAPS}",
"x-openclaw-current-channel-id": "${OPENCLAW_MCP_CURRENT_CHANNEL_ID}",
"x-openclaw-current-thread-ts": "${OPENCLAW_MCP_CURRENT_THREAD_TS}",
"x-openclaw-current-message-id": "${OPENCLAW_MCP_CURRENT_MESSAGE_ID}",
@ -2804,6 +2805,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
cfg: expect.any(Object),
sessionKey: "agent:main:test",
messageProvider: undefined,
clientCaps: undefined,
currentChannelId: undefined,
currentThreadTs: undefined,
currentMessageId: undefined,
@ -2811,6 +2813,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
accountId: undefined,
inboundEventKind: undefined,
sourceReplyDeliveryMode: undefined,
taskSuggestionDeliveryMode: undefined,
requireExplicitMessageTarget: false,
senderIsOwner: undefined,
});
@ -2965,6 +2968,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
config: createCliBackendConfig(),
currentInboundEventKind: "room_event",
messageChannel: "telegram",
clientCaps: ["tool-events", "inline-widgets"],
currentChannelId: "telegram:-100123:topic:42",
currentThreadTs: "42",
currentMessageId: "reply-message-1",
@ -2977,6 +2981,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
expect(context.preparedBackend.env).toMatchObject({
OPENCLAW_MCP_SESSION_ID: "session-test",
OPENCLAW_MCP_MESSAGE_CHANNEL: "telegram",
OPENCLAW_MCP_CLIENT_CAPS: "tool-events,inline-widgets",
OPENCLAW_MCP_CURRENT_CHANNEL_ID: "telegram:-100123:topic:42",
OPENCLAW_MCP_CURRENT_THREAD_TS: "42",
OPENCLAW_MCP_CURRENT_MESSAGE_ID: "reply-message-1",
@ -2990,6 +2995,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
expect(context.mcpDeliveryCapture).toBe(true);
expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledWith(
expect.objectContaining({
clientCaps: ["tool-events", "inline-widgets"],
taskSuggestionDeliveryMode: "gateway",
requireExplicitMessageTarget: true,
}),

View file

@ -588,6 +588,7 @@ export async function prepareCliRunContext(
OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "",
OPENCLAW_MCP_SESSION_ID: params.sessionId,
OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageChannel ?? params.messageProvider ?? "",
OPENCLAW_MCP_CLIENT_CAPS: params.clientCaps?.join(",") ?? "",
OPENCLAW_MCP_CURRENT_CHANNEL_ID: params.currentChannelId ?? "",
OPENCLAW_MCP_CURRENT_THREAD_TS: params.currentThreadTs ?? "",
OPENCLAW_MCP_CURRENT_MESSAGE_ID:
@ -710,6 +711,7 @@ export async function prepareCliRunContext(
cfg: params.config ?? getRuntimeConfig(),
sessionKey: params.sessionKey ?? "",
messageProvider: params.messageChannel ?? params.messageProvider,
clientCaps: params.clientCaps,
currentChannelId: params.currentChannelId,
// CLI binding hashes must use session-stable prompt facts. Per-sender
// and per-message scope stays in the runtime MCP env/list-call path.

View file

@ -120,6 +120,8 @@ export type RunCliAgentParams = {
skillsSnapshot?: SkillSnapshot;
messageChannel?: string;
messageProvider?: string;
/** Capabilities declared by the gateway client that originated this run. */
clientCaps?: string[];
currentChannelId?: string;
chatId?: string;
channelContext?: PluginHookChannelContext;

View file

@ -1676,6 +1676,7 @@ describe("runAgentTurnWithFallback", () => {
const followupRun = createFollowupRun();
followupRun.run.provider = "codex-cli";
followupRun.run.model = "gpt-5.4";
followupRun.run.clientCaps = ["tool-events", "inline-widgets"];
const typingSignals = createMockTypingSignaler();
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
@ -1691,6 +1692,7 @@ describe("runAgentTurnWithFallback", () => {
expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
provider: "codex-cli",
model: "gpt-5.4",
clientCaps: ["tool-events", "inline-widgets"],
});
});

View file

@ -2565,6 +2565,7 @@ async function runAgentTurnWithFallbackInternal(
skillsSnapshot: params.followupRun.run.skillsSnapshot,
messageChannel: params.followupRun.originatingChannel ?? undefined,
messageProvider: hookMessageProvider,
clientCaps: params.followupRun.run.clientCaps,
currentChannelId:
params.followupRun.originatingTo ??
params.sessionCtx.OriginatingTo ??

View file

@ -1300,6 +1300,7 @@ describe("createFollowupRunner runtime config", () => {
provider: "anthropic",
model: "claude-opus-4-7",
messageProvider: "telegram",
clientCaps: ["tool-events", "inline-widgets"],
senderId: "sender-42",
senderIsOwner: true,
cwd: "/tmp/task-repo",
@ -1320,6 +1321,7 @@ describe("createFollowupRunner runtime config", () => {
expect(call.config).toBe(runtimeConfig);
expect(call.cliSessionId).toBe("cli-session-1");
expect(call.messageChannel).toBe("telegram");
expect(call.clientCaps).toEqual(["tool-events", "inline-widgets"]);
expect(call.currentChannelId).toBe("telegram:-100123:topic:42");
expect(call.currentThreadTs).toBe("42");
expect(call.currentMessageId).toBe("reply-42");

View file

@ -1216,6 +1216,7 @@ export function createFollowupRunner(params: {
originatingChannel: queued.originatingChannel,
provider: run.messageProvider,
}),
clientCaps: run.clientCaps,
currentChannelId: queued.originatingTo,
senderId: run.senderId,
chatId: queued.originatingChatId,

View file

@ -392,6 +392,7 @@ const MCP_CONTEXT_HEADERS = {
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
"x-openclaw-client-caps": "${OPENCLAW_MCP_CLIENT_CAPS}",
"x-openclaw-current-channel-id": "${OPENCLAW_MCP_CURRENT_CHANNEL_ID}",
"x-openclaw-current-thread-ts": "${OPENCLAW_MCP_CURRENT_THREAD_TS}",
"x-openclaw-current-message-id": "${OPENCLAW_MCP_CURRENT_MESSAGE_ID}",

View file

@ -56,6 +56,7 @@ type McpRequestContext = {
sessionKey: string;
sessionId: string | undefined;
messageProvider: string | undefined;
clientCaps: string[] | undefined;
currentChannelId: string | undefined;
currentThreadTs: string | undefined;
currentMessageId: string | undefined;
@ -370,6 +371,13 @@ export function resolveMcpCliCaptureKey(req: IncomingMessage): string | undefine
return normalizeOptionalString(getHeader(req, "x-openclaw-cli-capture-key"));
}
function normalizeMcpClientCapsHeader(value: string | undefined): string[] | undefined {
const clientCaps = [...new Set((value ?? "").split(",").map((cap) => cap.trim()))].filter(
Boolean,
);
return clientCaps.length > 0 ? clientCaps : undefined;
}
export function resolveMcpRequestContext(
req: IncomingMessage,
cfg: OpenClawConfig,
@ -382,6 +390,7 @@ export function resolveMcpRequestContext(
sessionKey: auth.boundSessionKey,
sessionId: undefined,
messageProvider: undefined,
clientCaps: undefined,
currentChannelId: undefined,
currentThreadTs: undefined,
currentMessageId: undefined,
@ -399,6 +408,9 @@ export function resolveMcpRequestContext(
sessionId: normalizeOptionalString(getHeader(req, "x-openclaw-session-id")),
messageProvider:
normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? undefined,
// The token-authenticated loopback client is gateway-spawned on 127.0.0.1. Caps only
// widen tool availability; sender ownership remains derived from the bearer token.
clientCaps: normalizeMcpClientCapsHeader(getHeader(req, "x-openclaw-client-caps")),
currentChannelId: normalizeOptionalString(getHeader(req, "x-openclaw-current-channel-id")),
currentThreadTs: normalizeOptionalString(getHeader(req, "x-openclaw-current-thread-ts")),
currentMessageId: normalizeOptionalString(getHeader(req, "x-openclaw-current-message-id")),

View file

@ -35,6 +35,7 @@ type McpLoopbackScopeParams = {
yieldContextCacheKey?: string;
onYield?: (message: string) => Promise<void> | void;
messageProvider: string | undefined;
clientCaps?: string[];
currentChannelId: string | undefined;
currentThreadTs: string | undefined;
currentMessageId: string | number | undefined;
@ -68,11 +69,14 @@ export class McpLoopbackToolCache {
#entries = new Map<string, CachedScopedTools>();
resolve(params: McpLoopbackScopeParams): CachedScopedTools {
// Callers differing only in capabilities must not share cached tool lists.
const clientCapsCacheKey = [...new Set(params.clientCaps ?? [])].toSorted().join(",");
const cacheKey = [
params.sessionKey,
params.sessionId ?? "",
params.yieldContextCacheKey ?? "",
params.messageProvider ?? "",
clientCapsCacheKey,
params.currentChannelId ?? "",
params.currentThreadTs ?? "",
params.currentMessageId != null ? String(params.currentMessageId) : "",

View file

@ -45,6 +45,7 @@ type ScopedToolsCall = {
onYield?: (message: string) => Promise<void> | void;
accountId?: string;
messageProvider?: string;
clientCaps?: string[];
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
@ -808,6 +809,7 @@ describe("mcp loopback server", () => {
"x-openclaw-session-id": "session-123",
"x-openclaw-account-id": "work",
"x-openclaw-message-channel": "telegram",
"x-openclaw-client-caps": "tool-events,inline-widgets",
"x-openclaw-current-channel-id": "telegram:chat123",
"x-openclaw-current-thread-ts": "42",
"x-openclaw-current-message-id": "reply-message-1",
@ -826,6 +828,7 @@ describe("mcp loopback server", () => {
expect(call.sessionId).toBe("session-123");
expect(call.accountId).toBe("work");
expect(call.messageProvider).toBe("telegram");
expect(call.clientCaps).toEqual(["tool-events", "inline-widgets"]);
expect(call.currentChannelId).toBe("telegram:chat123");
expect(call.currentThreadTs).toBe("42");
expect(call.currentMessageId).toBe("reply-message-1");
@ -845,6 +848,27 @@ describe("mcp loopback server", () => {
]);
});
it("normalizes whitespace, duplicate, and empty client capability headers", async () => {
const { runtime } = await startLoopbackServerForTest();
const sendWithCaps = async (clientCaps: string, currentMessageId: string) =>
await sendLoopbackToolsList({
token: runtime.ownerToken,
headers: {
"x-session-key": "agent:main:main",
"x-openclaw-current-message-id": currentMessageId,
"x-openclaw-client-caps": clientCaps,
},
});
expect(
(await sendWithCaps(" tool-events, inline-widgets,tool-events, , ", "message-capped")).status,
).toBe(200);
expect((await sendWithCaps("", "message-capless")).status).toBe(200);
expect(getScopedToolsCall(0).clientCaps).toEqual(["tool-events", "inline-widgets"]);
expect(getScopedToolsCall(1).clientCaps).toBeUndefined();
});
it("binds an attach grant's session and ignores ALL spoofed context headers (no scope-shop)", async () => {
resetAttachGrantsForTest();
const grant = mintAttachGrant({ sessionKey: "agent:main:attach-host" });
@ -860,6 +884,7 @@ describe("mcp loopback server", () => {
headers: jsonHeaders({
"x-session-key": "agent:main:SPOOFED-other-session",
"x-openclaw-message-channel": "telegram",
"x-openclaw-client-caps": "inline-widgets",
"x-openclaw-account-id": "victim-account",
"x-openclaw-current-channel-id": "telegram:victim-chat",
"x-openclaw-current-thread-ts": "999",
@ -875,6 +900,7 @@ describe("mcp loopback server", () => {
expect(call.senderIsOwner).toBe(false);
expect(call.surface).toBe("loopback");
expect(call.messageProvider).toBeUndefined();
expect(call.clientCaps).toBeUndefined();
expect(call.accountId).toBeUndefined();
expect(call.currentChannelId).toBeUndefined();
expect(call.currentThreadTs).toBeUndefined();
@ -987,6 +1013,39 @@ describe("mcp loopback server", () => {
expect(getScopedToolsCall(5).taskSuggestionDeliveryMode).toBe("gateway");
});
it("keeps capless and capability-scoped tool cache entries separate", async () => {
resolveGatewayScopedToolsMock.mockImplementation((input): MockGatewayScopedTools => {
const call = input as ScopedToolsCall;
return {
agentId: "main",
tools: [
makeMessageTool(),
...(call.clientCaps?.includes("inline-widgets")
? [makeMockTool({ name: "show_widget" })]
: []),
],
};
});
const { runtime } = await startLoopbackServerForTest();
const listTools = async (clientCaps?: string) =>
await readMcpPayload(
await sendLoopbackToolsList({
token: runtime.ownerToken,
headers: {
"x-session-key": "agent:main:main",
...(clientCaps ? { "x-openclaw-client-caps": clientCaps } : {}),
},
}),
);
const capless = await listTools();
const capped = await listTools("inline-widgets");
expect(capless.result?.tools?.map((tool) => tool.name)).not.toContain("show_widget");
expect(capped.result?.tools?.map((tool) => tool.name)).toContain("show_widget");
expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(2);
});
it("keeps explicit non-owner and unknown-owner loopback cache entries separate", () => {
const cache = new McpLoopbackToolCache();
const baseParams = {
@ -2190,6 +2249,9 @@ describe("createMcpLoopbackServerConfig", () => {
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-message-channel"]).toBe(
"${OPENCLAW_MCP_MESSAGE_CHANNEL}",
);
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-client-caps"]).toBe(
"${OPENCLAW_MCP_CLIENT_CAPS}",
);
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-current-channel-id"]).toBe(
"${OPENCLAW_MCP_CURRENT_CHANNEL_ID}",
);

View file

@ -235,6 +235,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
yieldContextCacheKey: yieldContext?.cacheKey,
onYield: yieldContext?.onYield,
messageProvider: requestContext.messageProvider,
clientCaps: requestContext.clientCaps,
currentChannelId: requestContext.currentChannelId,
currentThreadTs: requestContext.currentThreadTs,
currentMessageId: requestContext.currentMessageId,

View file

@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
type CreateOpenClawToolsArg = {
clientCaps?: string[];
cronCreatorToolAllowlist?: Array<string | { name: string; pluginId?: string }>;
inheritedToolDenylist?: string[];
pluginToolDenylist?: string[];
@ -43,6 +44,7 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
});
function readCreateToolsArgs(index = 0): {
clientCaps?: string[];
cronCreatorToolAllowlist?: Array<string | { name: string; pluginId?: string }>;
inheritedToolDenylist?: string[];
pluginToolDenylist?: string[];
@ -52,12 +54,24 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
throw new Error("expected createOpenClawTools args");
}
return args as {
clientCaps?: string[];
cronCreatorToolAllowlist?: Array<string | { name: string; pluginId?: string }>;
inheritedToolDenylist?: string[];
pluginToolDenylist?: string[];
};
}
it("passes gateway client capabilities into tool construction", () => {
resolveGatewayScopedTools({
cfg: {} as OpenClawConfig,
sessionKey: "agent:main:direct:test",
surface: "loopback",
clientCaps: ["tool-events", "inline-widgets"],
});
expect(readCreateToolsArgs().clientCaps).toEqual(["tool-events", "inline-widgets"]);
});
it("filters loopback dedup exclusions without inheriting policy denies", () => {
const result = resolveGatewayScopedTools({
cfg: {} as OpenClawConfig,