diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 550d2bd5378b..2807a31ad28d 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -109,7 +109,7 @@ function isEnvOnlyAuthProfileRuntime(): boolean { return authProfileRuntimeMode.getStore()?.kind === "env-only"; } -function resolveRuntimeAuthProfileAgentDir(agentDir?: string): string | undefined { +export function resolveRuntimeAuthProfileAgentDir(agentDir?: string): string | undefined { const mode = authProfileRuntimeMode.getStore(); return mode?.kind === "agent-dir" ? mode.agentDir : agentDir; } diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index a26cf2cc41d3..40c26d3b794f 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -2733,8 +2733,15 @@ describe("prepareCliRunContext", () => { const grantContext = mintMcpLoopbackClientGrant.mock.calls.at(-1)?.[0]?.context; expect(projected).toBeDefined(); expect(grantContext).toBeDefined(); - const { cfg: projectedConfig, ...projectedContext } = projected ?? {}; + const { + cfg: projectedConfig, + authProfileStore, + authProfileStoreAgentDir, + ...projectedContext + } = projected ?? {}; expect(projectedConfig).toEqual(expect.any(Object)); + expect(authProfileStore).toMatchObject({ version: 1, profiles: {} }); + expect(authProfileStoreAgentDir).toEqual(expect.any(String)); expect(projectedContext).toEqual(grantContext); expect(projectedContext).toMatchObject({ sessionKey: "agent:worker:main", @@ -2981,6 +2988,10 @@ describe("prepareCliRunContext", () => { spawnedBy: "agent:main:telegram:group:parent", }, runtimeOwnerToken: "loopback-owner-token", + toolAuth: { + agentDir: expect.any(String), + store: expect.objectContaining({ version: 1, profiles: {} }), + }, }); context.preparedBackend.mcpClientGrantCapture?.activate("capture-test"); context.preparedBackend.mcpClientGrantCapture?.deactivate("capture-test"); @@ -3626,10 +3637,14 @@ describe("prepareCliRunContext", () => { const { cfg: _projectedConfig, toolsAllow: projectedPolicy, + authProfileStore, + authProfileStoreAgentDir, ...projectedTrustedContext } = projected ?? {}; const { toolsAllow: grantedTools, ...grantTrustedContext } = grantContext ?? {}; expect(projectedPolicy).toEqual(["write"]); + expect(authProfileStore).toMatchObject({ version: 1, profiles: {} }); + expect(authProfileStoreAgentDir).toEqual(expect.any(String)); expect(grantedTools).toEqual(["write", "apply_patch"]); expect(projectedTrustedContext).toEqual(grantTrustedContext); } finally { diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 976ae3ed93d7..0aacd025c685 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -58,7 +58,10 @@ import { import { buildOAuthRefreshFailureLoginCommand } from "../auth-profiles/oauth-refresh-failure.js"; import { resolveApiKeyForProfile } from "../auth-profiles/oauth.js"; import { resolveAuthProfileOrder } from "../auth-profiles/order.js"; -import { loadAuthProfileStoreForRuntime } from "../auth-profiles/store.js"; +import { + loadAuthProfileStoreForRuntime, + resolveRuntimeAuthProfileAgentDir, +} from "../auth-profiles/store.js"; import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js"; import { buildBootstrapBudgetState, @@ -936,6 +939,15 @@ export async function prepareCliRunContext( modelId, }) : undefined; + const mcpToolAuthAgentDir = mcpContextBase + ? resolveRuntimeAuthProfileAgentDir(agentDir) + : undefined; + const mcpToolAuth = mcpContextBase + ? { + ...(mcpToolAuthAgentDir ? { agentDir: mcpToolAuthAgentDir } : {}), + store: authStore ?? loadScopedAuthStore(), + } + : undefined; const requestedLoopbackToolsAllow = runtimeToolsAllowPolicy ?? params.cliToolAvailability?.openClaw; const mcpProjectionContext = @@ -948,7 +960,12 @@ export async function prepareCliRunContext( : prepareDeps.resolveMcpLoopbackScopedTools; const projectedToolsBeforePromptBuild = (bundleMcpEnabled || shouldMaterializeRuntimePolicy) && mcpProjectionContext - ? resolveProjectedTools({ cfg: runtimeConfig, ...mcpProjectionContext }).tools + ? resolveProjectedTools({ + cfg: runtimeConfig, + ...mcpProjectionContext, + ...(mcpToolAuth ? { authProfileStore: mcpToolAuth.store } : {}), + ...(mcpToolAuth?.agentDir ? { authProfileStoreAgentDir: mcpToolAuth.agentDir } : {}), + }).tools : []; const hookFilteredProjectedTools = applyEmbeddedAttemptToolsAllow( projectedToolsBeforePromptBuild, @@ -1047,6 +1064,7 @@ export async function prepareCliRunContext( ? prepareDeps.mintMcpLoopbackClientGrant({ context: mcpGrantContext, runtimeOwnerToken: mcpLoopbackRuntime.ownerToken, + ...(mcpToolAuth ? { toolAuth: mcpToolAuth } : {}), }) : undefined; const mcpClientGrantCapture = diff --git a/src/gateway/mcp-grant-store.test.ts b/src/gateway/mcp-grant-store.test.ts index 92fabdefca81..3b904feb45b5 100644 --- a/src/gateway/mcp-grant-store.test.ts +++ b/src/gateway/mcp-grant-store.test.ts @@ -4,6 +4,7 @@ import { deactivateMcpLoopbackClientGrantCapture, mintAttachGrant, mintMcpLoopbackClientGrant, + registerMcpLoopbackClientGrantRevocationListener, resolveAttachGrant, resolveMcpLoopbackClientGrant, revokeAttachGrant, @@ -205,6 +206,40 @@ describe("mcp-grant-store", () => { expect(revokeMcpLoopbackClientGrant(successor.token)).toBe(false); }); + it("notifies revocation listeners for single and runtime-wide cleanup", () => { + const events: Array<{ token: string; runtimeOwnerToken: string }> = []; + const unregister = registerMcpLoopbackClientGrantRevocationListener((event) => { + events.push(event); + }); + try { + const first = mintMcpLoopbackClientGrant({ + context: { sessionKey: "agent:main:first", senderIsOwner: false }, + runtimeOwnerToken: "runtime-one", + }); + const second = mintMcpLoopbackClientGrant({ + context: { sessionKey: "agent:main:second", senderIsOwner: false }, + runtimeOwnerToken: "runtime-one", + }); + + expect(revokeMcpLoopbackClientGrant(first.token)).toBe(true); + expect(revokeMcpLoopbackClientGrant(first.token)).toBe(false); + expect(revokeMcpLoopbackClientGrantsForRuntime("runtime-one")).toBe(1); + expect(events).toEqual([ + { token: first.token, runtimeOwnerToken: "runtime-one" }, + { token: second.token, runtimeOwnerToken: "runtime-one" }, + ]); + } finally { + unregister(); + } + + const afterUnregister = mintMcpLoopbackClientGrant({ + context: { sessionKey: "agent:main:later", senderIsOwner: false }, + runtimeOwnerToken: "runtime-one", + }); + expect(revokeMcpLoopbackClientGrant(afterUnregister.token)).toBe(true); + expect(events).toHaveLength(2); + }); + it("requires a session key for loopback client grants", () => { expect(() => mintMcpLoopbackClientGrant({ diff --git a/src/gateway/mcp-grant-store.ts b/src/gateway/mcp-grant-store.ts index 8bb0889263d1..d9b5c83b7e49 100644 --- a/src/gateway/mcp-grant-store.ts +++ b/src/gateway/mcp-grant-store.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import type { ExecElevatedDefaults } from "../agents/bash-tools.exec-types.js"; import type { ExecPolicyOverrides, ExecSessionDefaults } from "../agents/exec-defaults.js"; import type { ScheduledToolPolicyContext } from "../agents/scheduled-tool-policy.js"; @@ -79,11 +80,24 @@ interface McpLoopbackClientGrant { readonly context: McpLoopbackRequestContext; } +type McpLoopbackToolAuth = { + agentDir?: string; + store: AuthProfileStore; +}; + type StoredMcpLoopbackClientGrant = McpLoopbackClientGrant & { runtimeOwnerToken: string; activeCaptureKey?: string; + toolAuth?: McpLoopbackToolAuth; }; +type McpLoopbackClientGrantRevocation = { + token: string; + runtimeOwnerToken: string; +}; + +const clientGrantRevocationListeners = new Set<(event: McpLoopbackClientGrantRevocation) => void>(); + const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h const MAX_TTL_MS = 12 * 60 * 60 * 1000; @@ -171,6 +185,7 @@ function sweepExpiredAttachGrants(nowMs: number = Date.now()): number { export function mintMcpLoopbackClientGrant(params: { context: McpLoopbackRequestContext; runtimeOwnerToken: string; + toolAuth?: McpLoopbackToolAuth; }): McpLoopbackClientGrant { const sessionKey = params.context.sessionKey.trim(); if (!sessionKey) { @@ -184,6 +199,7 @@ export function mintMcpLoopbackClientGrant(params: { token: crypto.randomBytes(32).toString("hex"), context: structuredClone({ ...params.context, sessionKey }), runtimeOwnerToken, + ...(params.toolAuth ? { toolAuth: structuredClone(params.toolAuth) } : {}), }; clientGrantsByToken.set(grant.token, grant); return structuredClone({ @@ -233,7 +249,13 @@ export function resolveMcpLoopbackClientGrant(params: { token: string; runtimeOwnerToken: string; captureKey: string; -}): { context: McpLoopbackRequestContext; captureKey: string } | undefined { +}): + | { + context: McpLoopbackRequestContext; + captureKey: string; + toolAuth?: McpLoopbackToolAuth; + } + | undefined { const grant = clientGrantsByToken.get(params.token); if ( !grant || @@ -243,19 +265,41 @@ export function resolveMcpLoopbackClientGrant(params: { ) { return undefined; } - return structuredClone({ context: grant.context, captureKey: grant.activeCaptureKey }); + // Cached tools and OAuth refreshes must share the prepared store for this + // grant; cloning on each request would discard refreshed credentials. + return { + context: structuredClone(grant.context), + captureKey: grant.activeCaptureKey, + ...(grant.toolAuth ? { toolAuth: grant.toolAuth } : {}), + }; +} + +/** Registers cleanup tied to the exact lifetime of loopback client grants. */ +export function registerMcpLoopbackClientGrantRevocationListener( + listener: (event: McpLoopbackClientGrantRevocation) => void, +): () => void { + clientGrantRevocationListeners.add(listener); + return () => clientGrantRevocationListeners.delete(listener); } export function revokeMcpLoopbackClientGrant(token: string): boolean { - return clientGrantsByToken.delete(token); + const grant = clientGrantsByToken.get(token); + if (!grant || !clientGrantsByToken.delete(token)) { + return false; + } + // Revocation must also release server-owned projections whose closures retain + // this grant's prepared credentials. + for (const listener of clientGrantRevocationListeners) { + listener({ token, runtimeOwnerToken: grant.runtimeOwnerToken }); + } + return true; } export function revokeMcpLoopbackClientGrantsForRuntime(runtimeOwnerToken: string): number { let removed = 0; for (const [token, grant] of clientGrantsByToken) { if (grant.runtimeOwnerToken === runtimeOwnerToken) { - clientGrantsByToken.delete(token); - removed += 1; + removed += revokeMcpLoopbackClientGrant(token) ? 1 : 0; } } return removed; diff --git a/src/gateway/mcp-http.oauth-tools.test.ts b/src/gateway/mcp-http.oauth-tools.test.ts new file mode 100644 index 000000000000..563d43768fd1 --- /dev/null +++ b/src/gateway/mcp-http.oauth-tools.test.ts @@ -0,0 +1,64 @@ +import { Type } from "typebox"; +import { describe, expect, it, vi } from "vitest"; +import { jsonResult } from "../agents/tools/common.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveMcpLoopbackScopedTools } from "./mcp-http.runtime.js"; + +const pluginTools = vi.hoisted(() => ({ + resolve: vi.fn(), +})); + +vi.mock("../plugins/tools.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolvePluginTools: (params: unknown) => pluginTools.resolve(params), +})); + +describe("MCP loopback OAuth tools", () => { + it("exposes a plugin tool backed by a prepared OAuth profile", () => { + pluginTools.resolve.mockImplementation( + (params: { + context?: { activeModel?: { provider?: string; modelId?: string } }; + hasAuthForProvider?: (providerId: string) => boolean; + }) => + params.hasAuthForProvider?.("xai") && + params.context?.activeModel?.provider === "anthropic" && + params.context.activeModel.modelId === "claude-haiku-4-5" + ? [ + { + name: "x_search", + label: "X search", + description: "X search", + parameters: Type.Object({}), + execute: async () => jsonResult({ ok: true }), + }, + ] + : [], + ); + + const result = resolveMcpLoopbackScopedTools({ + cfg: { + auth: { order: { xai: ["xai:oauth"] } }, + plugins: { allow: ["xai"] }, + } as OpenClawConfig, + sessionKey: "agent:main:main", + agentId: "main", + modelProvider: "anthropic", + modelId: "claude-haiku-4-5", + senderIsOwner: true, + authProfileStore: { + version: 1, + profiles: { + "xai:oauth": { + type: "oauth", + provider: "xai", + access: "xai-access-token", + refresh: "xai-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + }); + + expect(result.tools.map((tool) => tool.name)).toContain("x_search"); + }); +}); diff --git a/src/gateway/mcp-http.runtime.test.ts b/src/gateway/mcp-http.runtime.test.ts index db80b8542f9f..a87cb76c887f 100644 --- a/src/gateway/mcp-http.runtime.test.ts +++ b/src/gateway/mcp-http.runtime.test.ts @@ -201,6 +201,42 @@ describe("McpLoopbackToolCache", () => { expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(3); }); + it("evicts only the revoked grant's cached tool closures", () => { + const cache = new McpLoopbackToolCache(); + const cfg = {} as OpenClawConfig; + + cache.resolve(scopeParams({ cfg, grantToken: "grant-a" })); + cache.resolve(scopeParams({ cfg, grantToken: "grant-b" })); + cache.resolve(scopeParams({ cfg, grantToken: "grant-a" })); + cache.resolve(scopeParams({ cfg, grantToken: "grant-b" })); + expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(2); + + expect(cache.evictGrant("grant-a")).toBe(true); + cache.resolve(scopeParams({ cfg, grantToken: "grant-a" })); + cache.resolve(scopeParams({ cfg, grantToken: "grant-b" })); + + expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(3); + }); + + it("preserves the global 256-entry cache cap across grants", () => { + const cache = new McpLoopbackToolCache(); + const cfg = {} as OpenClawConfig; + + for (let index = 0; index < 256; index += 1) { + cache.resolve( + scopeParams({ cfg, grantToken: "grant-a", currentMessageId: `message-${index}` }), + ); + } + cache.resolve(scopeParams({ cfg, grantToken: "grant-b", currentMessageId: "message-b" })); + expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(257); + + cache.resolve(scopeParams({ cfg, grantToken: "grant-a", currentMessageId: "message-0" })); + expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(258); + + cache.resolve(scopeParams({ cfg, grantToken: "grant-b", currentMessageId: "message-b" })); + expect(resolveGatewayScopedTools).toHaveBeenCalledTimes(258); + }); + it("never reuses ordinary private-mode tools for a source-reply-only grant", () => { const cache = new McpLoopbackToolCache(); const cfg = {} as OpenClawConfig; diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index f3d014c81fd1..4a21adf14ea0 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -1,3 +1,4 @@ +import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; // MCP loopback runtime scope cache. // Resolves Gateway-visible tools for MCP clients with short-lived schema caching. import { applyEmbeddedAttemptToolsAllow } from "../agents/embedded-agent-runner/run/attempt-tool-construction-plan.js"; @@ -33,6 +34,9 @@ type CachedScopedTools = { type McpLoopbackScopeParams = Omit & { cfg: OpenClawConfig; + authProfileStore?: AuthProfileStore; + authProfileStoreAgentDir?: string; + grantToken?: string; senderIsOwner: boolean | undefined; yieldContextCacheKey?: string; onYield?: (message: string) => Promise | void; @@ -84,9 +88,15 @@ function resolveMcpLoopbackTools( if (includeNodeExecTool) { excludeToolNames.delete("exec"); } - const { toolsAllow: _toolsAllow, ...scopeParams } = params; + const { + toolsAllow: _toolsAllow, + authProfileStoreAgentDir, + grantToken: _grantToken, + ...scopeParams + } = params; const scoped = resolveGatewayScopedTools({ ...scopeParams, + agentDir: authProfileStoreAgentDir, conversationReadOrigin: "delegated", surface: "loopback", excludeToolNames, @@ -161,11 +171,14 @@ function applyPolicyToolsAllow( /** Short-lived cache for loopback tool lists keyed by session/channel context. */ export class McpLoopbackToolCache { #entries = new DirectoryCache(TOOL_CACHE_TTL_MS, TOOL_CACHE_MAX_ENTRIES); + // Revocation needs the config scopes where one grant may have cached tools. + #grantConfigScopes = new Map>(); 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.grantToken ?? "", params.sessionKey, params.runtimePolicySessionKey ?? "", params.agentId ?? "", @@ -241,6 +254,29 @@ export class McpLoopbackToolCache { toolSchema: buildMcpToolSchema(next.tools), }; this.#entries.set(cacheKey, nextEntry, params.cfg); + if (params.grantToken) { + const scopes = this.#grantConfigScopes.get(params.grantToken) ?? new Set(); + scopes.add(params.cfg); + this.#grantConfigScopes.set(params.grantToken, scopes); + } return nextEntry; } + + evictGrant(token: string): boolean { + const scopes = this.#grantConfigScopes.get(token); + if (!scopes) { + return false; + } + const cacheKeyPrefix = `${token}\u0000`; + for (const cfg of scopes) { + this.#entries.clearMatching((cacheKey) => cacheKey.startsWith(cacheKeyPrefix), cfg); + } + this.#grantConfigScopes.delete(token); + return true; + } + + clear(): void { + this.#entries.clear(); + this.#grantConfigScopes.clear(); + } } diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 6945225568b1..ce68bf05df7c 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -3,6 +3,7 @@ import { request } from "node:http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { runBeforeToolCallHook } from "../agents/agent-tools.before-tool-call.js"; +import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js"; import type { McpLoopbackRequestContext } from "./mcp-grant-store.js"; @@ -1296,6 +1297,59 @@ describe("mcp loopback server", () => { expect(getBeforeToolCallHookInput(0).ctx).toHaveProperty("loopDetection"); }); + it("keeps prepared auth stores isolated between CLI grants", async () => { + const { runtime } = await startLoopbackServerForTest(); + const firstStore: AuthProfileStore = { + version: 1, + profiles: { + "xai:first": { type: "token", provider: "xai", token: "first-token" }, + }, + }; + const secondStore: AuthProfileStore = { + version: 1, + profiles: { + "xai:second": { type: "token", provider: "xai", token: "second-token" }, + }, + }; + const mintGrant = (agentDir: string, store: AuthProfileStore) => + mintMcpLoopbackClientGrant({ + context: { sessionKey: "agent:main:main", senderIsOwner: true }, + runtimeOwnerToken: runtime.ownerToken, + toolAuth: { agentDir, store }, + }); + const firstGrant = mintGrant("/agents/first", firstStore); + const secondGrant = mintGrant("/agents/second", secondStore); + const listForGrant = async (token: string, captureKey: string) => { + expect( + activateMcpLoopbackClientGrantCapture({ + token, + runtimeOwnerToken: runtime.ownerToken, + captureKey, + }), + ).toBe(true); + expect( + ( + await sendLoopbackToolsList({ + token, + headers: { "x-openclaw-cli-capture-key": captureKey }, + }) + ).status, + ).toBe(200); + }; + + await listForGrant(firstGrant.token, "capture-first"); + await listForGrant(secondGrant.token, "capture-second"); + + expect(getScopedToolsCall(0)).toMatchObject({ + agentDir: "/agents/first", + authProfileStore: firstStore, + }); + expect(getScopedToolsCall(1)).toMatchObject({ + agentDir: "/agents/second", + authProfileStore: secondStore, + }); + }); + it("carries the resolved workspace dir into the before-tool-call hook context", async () => { resolveGatewayScopedToolsMock.mockReturnValue({ agentId: "main", @@ -1427,6 +1481,68 @@ describe("mcp loopback server", () => { ).toBe(401); }); + it("rejects a slow tools request revoked after header admission", async () => { + const captureKey = "slow-revoked-grant"; + let resolveRequestStarted: (() => void) | undefined; + const requestStarted = new Promise((resolve) => { + resolveRequestStarted = resolve; + }); + beginMcpLoopbackToolCallCapture({ + captureKey, + onRequestStart: () => resolveRequestStarted?.(), + onRequestClassified: vi.fn(), + onToolCallResult: vi.fn(), + }); + const { runtime, port } = await startLoopbackServerForTest(); + const grant = mintMcpLoopbackClientGrant({ + context: { sessionKey: "agent:main:slow-revoked", senderIsOwner: true }, + runtimeOwnerToken: runtime.ownerToken, + toolAuth: { store: { version: 1, profiles: {} } }, + }); + expect( + activateMcpLoopbackClientGrantCapture({ + token: grant.token, + runtimeOwnerToken: runtime.ownerToken, + captureKey, + }), + ).toBe(true); + + let revoked = false; + const responsePromise = new Promise<{ status: number | undefined }>((resolve, reject) => { + const req = request( + { + hostname: "127.0.0.1", + port, + path: "/mcp", + method: "POST", + headers: { + authorization: `Bearer ${grant.token}`, + "content-type": "application/json", + "transfer-encoding": "chunked", + "x-openclaw-cli-capture-key": captureKey, + }, + }, + (res) => { + res.resume(); + res.on("end", () => resolve({ status: res.statusCode })); + }, + ); + req.on("error", reject); + req.flushHeaders(); + void requestStarted.then(() => { + revoked = revokeMcpLoopbackClientGrant(grant.token); + req.end(mcpToolsListBody()); + }); + }); + + await requestStarted; + const resolverCallsBeforeBody = resolveGatewayScopedToolsMock.mock.calls.length; + await expect(responsePromise).resolves.toEqual({ status: 401 }); + expect(revoked).toBe(true); + expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(resolverCallsBeforeBody); + clearMcpLoopbackToolCallCapture(captureKey); + }); + it("routes sessions_yield to the current CLI capture", async () => { resolveGatewayScopedToolsMock.mockImplementation((input): MockGatewayScopedTools => { const call = input as ScopedToolsCall; diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index 29917b603de2..f357f6c2a872 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -20,6 +20,7 @@ import { isAgentHarnessSessionStoreEntryProtected, } from "../sessions/agent-harness-session-key.js"; import { + registerMcpLoopbackClientGrantRevocationListener, resolveMcpLoopbackClientGrant, revokeMcpLoopbackClientGrantsForRuntime, } from "./mcp-grant-store.js"; @@ -247,9 +248,22 @@ async function startMcpLoopbackServer(port = 0): Promise<{ }); }); markMcpLoopbackRequestClassified(cliRequestCaptureHandle); + const { boundGrantToken, boundCaptureKey } = auth; + const activeBoundGrant = + boundGrantToken && boundCaptureKey + ? resolveMcpLoopbackClientGrant({ + token: boundGrantToken, + runtimeOwnerToken: ownerToken, + captureKey: boundCaptureKey, + }) + : undefined; + if (boundGrantToken && !activeBoundGrant) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "unauthorized" })); + return; + } const cfg = getRuntimeConfig(); const requestContext = resolveMcpRequestContext(req, cfg, auth); - const { boundGrantToken, boundCaptureKey } = auth; const authorizeToolCall = boundGrantToken && boundCaptureKey ? () => @@ -300,6 +314,15 @@ async function startMcpLoopbackServer(port = 0): Promise<{ cwd: requestContext.cwd, modelProvider: requestContext.modelProvider, modelId: requestContext.modelId, + ...(activeBoundGrant?.toolAuth + ? { + authProfileStore: activeBoundGrant.toolAuth.store, + ...(activeBoundGrant.toolAuth.agentDir + ? { authProfileStoreAgentDir: activeBoundGrant.toolAuth.agentDir } + : {}), + } + : {}), + ...(boundGrantToken ? { grantToken: boundGrantToken } : {}), yieldContextCacheKey: yieldContext?.cacheKey, onYield: yieldContext?.onYield, messageProvider: requestContext.messageProvider, @@ -476,6 +499,11 @@ async function startMcpLoopbackServer(port = 0): Promise<{ if (!address || typeof address === "string") { throw new Error("mcp loopback did not bind to a TCP port"); } + const unregisterGrantRevocation = registerMcpLoopbackClientGrantRevocationListener((event) => { + if (event.runtimeOwnerToken === ownerToken) { + toolCache.evictGrant(event.token); + } + }); // Register tokens only after the TCP listener is live so clients never learn // a bearer token for a server that failed to bind. setActiveMcpLoopbackRuntime({ port: address.port, ownerToken, nonOwnerToken }); @@ -488,6 +516,8 @@ async function startMcpLoopbackServer(port = 0): Promise<{ // requests. A delayed old-server close cannot revoke a successor runtime. clearActiveMcpLoopbackRuntimeByOwnerToken(ownerToken); revokeMcpLoopbackClientGrantsForRuntime(ownerToken); + unregisterGrantRevocation(); + toolCache.clear(); return new Promise((resolve, reject) => { httpServer.close((error) => { if (!error) { diff --git a/src/gateway/tool-resolution.test.ts b/src/gateway/tool-resolution.test.ts index 7fd42179c963..57dffa3fe2b5 100644 --- a/src/gateway/tool-resolution.test.ts +++ b/src/gateway/tool-resolution.test.ts @@ -70,6 +70,26 @@ describe("resolveGatewayScopedTools", () => { expect(result.tools.some((tool) => tool.name === "message")).toBe(false); }); + it("keeps default-agent credentials out of unbound gateway calls", () => { + const cfg = { + agents: { defaults: { imageModel: { primary: "openai/gpt-5.4-mini" } } }, + } as OpenClawConfig; + const unbound = resolveGatewayScopedTools({ + cfg, + sessionKey: "agent:main:main", + surface: "loopback", + }); + const grantBound = resolveGatewayScopedTools({ + cfg, + agentDir: "/agents/cli", + sessionKey: "agent:main:main", + surface: "loopback", + }); + + expect(unbound.tools.some((tool) => tool.name === "image")).toBe(false); + expect(grantBound.tools.some((tool) => tool.name === "image")).toBe(true); + }); + it("materializes an executable write tool on the mediated CLI surface", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mediated-write-")); try { diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index 4f6936db9d1c..67fc77eccb1e 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -3,6 +3,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { createOpenClawCodingTools } from "../agents/agent-tools.js"; import { filterToolsByMessageProvider } from "../agents/agent-tools.message-provider-policy.js"; import { resolveEffectiveToolPolicy } from "../agents/agent-tools.policy.js"; +import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import type { ExecElevatedDefaults } from "../agents/bash-tools.exec-types.js"; import { nodeExecSchema } from "../agents/bash-tools.schemas.js"; import { @@ -57,6 +58,8 @@ type GatewayScopedToolSurface = "http" | "loopback"; /** Resolve the tools visible to a gateway caller after agent, channel, and surface policy. */ export function resolveGatewayScopedTools(params: { cfg: OpenClawConfig; + authProfileStore?: AuthProfileStore; + agentDir?: string; sessionKey: string; runtimePolicySessionKey?: string; agentId?: string; @@ -272,6 +275,10 @@ export function resolveGatewayScopedTools(params: { disablePluginTools: params.disablePluginTools, wrapBeforeToolCallHook: false, config: params.cfg, + agentDir: params.agentDir, + authProfileStore: params.authProfileStore, + modelProvider: params.modelProvider, + modelId: params.modelId, clientCaps: params.clientCaps, workspaceDir, sandboxed: sandboxRuntime.sandboxed,