mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
db0f6f09dc
commit
b5ba0771f9
16 changed files with 124 additions and 11 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ??
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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) : "",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue