Skip to content

Commit b5ba077

Browse files
authored
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
1 parent db0f6f0 commit b5ba077

16 files changed

Lines changed: 124 additions & 11 deletions

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/bundle-mcp.gemini.test.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,19 @@ describe("prepareCliBundleMcpConfig gemini", () => {
2323
url: "http://127.0.0.1:23119/mcp",
2424
headers: {
2525
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
26+
"x-openclaw-client-caps": "${OPENCLAW_MCP_CLIENT_CAPS}",
2627
},
2728
},
2829
},
2930
},
3031
env: {
31-
OPENCLAW_MCP_TOKEN: "loopback-token-123",
32+
OPENCLAW_MCP_TOKEN: "lb-tk-123",
33+
OPENCLAW_MCP_CLIENT_CAPS: "tool-events,inline-widgets",
3234
},
3335
});
3436

3537
expect(prepared.backend.args).toEqual(["--prompt", "{prompt}"]);
36-
expect(prepared.env?.OPENCLAW_MCP_TOKEN).toBe("loopback-token-123");
38+
expect(prepared.env?.OPENCLAW_MCP_TOKEN).toBe("lb-tk-123");
3739
expect(typeof prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH).toBe("string");
3840
// Gemini reads MCP servers from a generated system settings JSON file.
3941
const raw = JSON.parse(
@@ -44,7 +46,10 @@ describe("prepareCliBundleMcpConfig gemini", () => {
4446
};
4547
expect(raw.mcp?.allowed).toEqual(["openclaw"]);
4648
expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp");
47-
expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer loopback-token-123");
49+
expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer lb-tk-123");
50+
expect(raw.mcpServers?.openclaw?.headers?.["x-openclaw-client-caps"]).toBe(
51+
"tool-events,inline-widgets",
52+
);
4853

4954
await prepared.cleanup?.();
5055
});

src/agents/cli-runner/bundle-mcp.test.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ describe("prepareCliBundleMcpConfig", () => {
289289
const prepared = await prepareBundleProbeCliConfig({
290290
additionalConfig,
291291
env: {
292-
OPENCLAW_MCP_TOKEN: "loopback-token-123",
292+
OPENCLAW_MCP_TOKEN: "lb-tk-123",
293293
OPENCLAW_MCP_CLI_CAPTURE_KEY: "",
294294
},
295295
});
@@ -307,7 +307,7 @@ describe("prepareCliBundleMcpConfig", () => {
307307
};
308308
expect(Object.keys(raw.mcpServers ?? {}).toSorted()).toEqual(["bundleProbe", "openclaw"]);
309309
expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp");
310-
expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer loopback-token-123");
310+
expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer lb-tk-123");
311311
expect(raw.mcpServers?.openclaw?.headers?.["x-openclaw-cli-capture-key"]).toBe("");
312312
await prepareCliBundleMcpCaptureAttempt({
313313
mode: "claude-config-file",
@@ -318,9 +318,7 @@ describe("prepareCliBundleMcpConfig", () => {
318318
const attemptRaw = JSON.parse(await fs.readFile(generatedConfigPath, "utf-8")) as {
319319
mcpServers?: Record<string, { url?: string; headers?: Record<string, string> }>;
320320
};
321-
expect(attemptRaw.mcpServers?.openclaw?.headers?.Authorization).toBe(
322-
"Bearer loopback-token-123",
323-
);
321+
expect(attemptRaw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer lb-tk-123");
324322
expect(attemptRaw.mcpServers?.openclaw?.headers?.["x-openclaw-cli-capture-key"]).toBe(
325323
"attempt-123",
326324
);
@@ -346,13 +344,13 @@ describe("prepareCliBundleMcpConfig", () => {
346344
workspaceDir,
347345
config: { plugins: { enabled: false } },
348346
env: {
349-
OPENCLAW_MCP_TOKEN: "loopback-token-123",
347+
OPENCLAW_MCP_TOKEN: "lb-tk-123",
350348
OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123",
351349
},
352350
});
353351

354352
expect(prepared.env).toEqual({
355-
OPENCLAW_MCP_TOKEN: "loopback-token-123",
353+
OPENCLAW_MCP_TOKEN: "lb-tk-123",
356354
OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123",
357355
});
358356

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,

0 commit comments

Comments
 (0)