diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 55a0546a01f3..02a17d7c1c05 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -4999,6 +4999,7 @@ public struct SessionRow: Codable, Sendable { public let estimatedcostusd: Double? public let model: String? public let modelprovider: String? + public let tooloverrides: [String: AnyCodable]? public init( key: String, @@ -5054,7 +5055,8 @@ public struct SessionRow: Codable, Sendable { contexttokens: Double? = nil, estimatedcostusd: Double? = nil, model: String? = nil, - modelprovider: String? = nil) + modelprovider: String? = nil, + tooloverrides: [String: AnyCodable]? = nil) { self.key = key self.sessionid = sessionid @@ -5110,6 +5112,7 @@ public struct SessionRow: Codable, Sendable { self.estimatedcostusd = estimatedcostusd self.model = model self.modelprovider = modelprovider + self.tooloverrides = tooloverrides } private enum CodingKeys: String, CodingKey { @@ -5167,6 +5170,7 @@ public struct SessionRow: Codable, Sendable { case estimatedcostusd = "estimatedCostUsd" case model case modelprovider = "modelProvider" + case tooloverrides = "toolOverrides" } } @@ -7515,6 +7519,7 @@ public struct SessionsPatchParams: Codable, Sendable { public let unread: Bool? public let thinkinglevel: AnyCodable? public let fastmode: AnyCodable? + public let tooloverrides: AnyCodable? public let verboselevel: AnyCodable? public let tracelevel: AnyCodable? public let reasoninglevel: AnyCodable? @@ -7549,6 +7554,7 @@ public struct SessionsPatchParams: Codable, Sendable { unread: Bool? = nil, thinkinglevel: AnyCodable? = nil, fastmode: AnyCodable? = nil, + tooloverrides: AnyCodable? = nil, verboselevel: AnyCodable? = nil, tracelevel: AnyCodable? = nil, reasoninglevel: AnyCodable? = nil, @@ -7582,6 +7588,7 @@ public struct SessionsPatchParams: Codable, Sendable { self.unread = unread self.thinkinglevel = thinkinglevel self.fastmode = fastmode + self.tooloverrides = tooloverrides self.verboselevel = verboselevel self.tracelevel = tracelevel self.reasoninglevel = reasoninglevel @@ -7617,6 +7624,7 @@ public struct SessionsPatchParams: Codable, Sendable { case unread case thinkinglevel = "thinkingLevel" case fastmode = "fastMode" + case tooloverrides = "toolOverrides" case verboselevel = "verboseLevel" case tracelevel = "traceLevel" case reasoninglevel = "reasoningLevel" diff --git a/packages/gateway-protocol/src/index.test.ts b/packages/gateway-protocol/src/index.test.ts index c5b7177e2db4..7fd8fb3f0966 100644 --- a/packages/gateway-protocol/src/index.test.ts +++ b/packages/gateway-protocol/src/index.test.ts @@ -170,6 +170,29 @@ describe("lazy protocol validators", () => { ).toBe(false); }); + it("validates sparse session tool overrides", () => { + const key = "agent:main:main"; + expect( + validateSessionsPatchParams({ + key, + toolOverrides: { + mcpServers: { docs: false }, + mcpToolsDeny: { github: ["delete_issue"] }, + skills: { release: true }, + webSearch: false, + }, + }), + ).toBe(true); + expect(validateSessionsPatchParams({ key, toolOverrides: null })).toBe(true); + expect( + validateSessionsPatchParams({ key, toolOverrides: { mcpServers: { docs: "no" } } }), + ).toBe(false); + expect( + validateSessionsPatchParams({ key, toolOverrides: { mcpToolsDeny: { github: [1] } } }), + ).toBe(false); + expect(validateSessionsPatchParams({ key, toolOverrides: { unknown: true } })).toBe(false); + }); + it("keeps validation errors readable on the exported validator", () => { expect(validateConnectParams({})).toBe(false); expect(formatValidationErrors(validateConnectParams.errors)).toContain("must have required"); diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index c3bedd43b75d..c4986a2b904b 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -26,8 +26,10 @@ export type { export * from "./schema/board.js"; export { SessionCreatedActorSchema, + SessionToolOverridesSchema, type SessionCreatedActor, type SessionRow, + type SessionToolOverrides, } from "./schema/sessions-row.js"; export * from "./schema/sessions-suggestions.js"; export * from "./migration-api.js"; diff --git a/packages/gateway-protocol/src/schema-export-registry.ts b/packages/gateway-protocol/src/schema-export-registry.ts index 703b8965918b..3d0f5c951293 100644 --- a/packages/gateway-protocol/src/schema-export-registry.ts +++ b/packages/gateway-protocol/src/schema-export-registry.ts @@ -176,6 +176,7 @@ export { SessionsCompactionRestoreParamsSchema, SessionBranchSchema, SessionRowSchema, + SessionToolOverridesSchema, SessionsBranchesListParamsSchema, SessionsBranchesListResultSchema, SessionsBranchesSwitchParamsSchema, diff --git a/packages/gateway-protocol/src/schema/sessions-row.ts b/packages/gateway-protocol/src/schema/sessions-row.ts index 1046794b78d7..a5a0b2828fd5 100644 --- a/packages/gateway-protocol/src/schema/sessions-row.ts +++ b/packages/gateway-protocol/src/schema/sessions-row.ts @@ -4,6 +4,15 @@ import { closedObject } from "./closed-object.js"; import { NonEmptyString } from "./primitives.js"; import { SessionSharingRoleSchema, SessionVisibilitySchema } from "./sessions-sharing-values.js"; +export const SessionToolOverridesSchema = closedObject({ + mcpServers: Type.Optional(Type.Record(Type.String({ minLength: 1 }), Type.Boolean())), + mcpToolsDeny: Type.Optional( + Type.Record(Type.String({ minLength: 1 }), Type.Array(NonEmptyString)), + ), + skills: Type.Optional(Type.Record(Type.String({ minLength: 1 }), Type.Boolean())), + webSearch: Type.Optional(Type.Boolean()), +}); + /** Projected actor that caused a session node to be created. */ export const SessionCreatedActorSchema = closedObject({ type: Type.Union([Type.Literal("human"), Type.Literal("agent"), Type.Literal("system")]), @@ -108,9 +117,11 @@ export const SessionRowSchema = Type.Object( estimatedCostUsd: Type.Optional(Type.Number()), model: Type.Optional(Type.String()), modelProvider: Type.Optional(Type.String()), + toolOverrides: Type.Optional(SessionToolOverridesSchema), }, { additionalProperties: true }, ); export type SessionCreatedActor = Static; +export type SessionToolOverrides = Static; export type SessionRow = Static; diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 0beae553931b..313c4b2c98b5 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -7,13 +7,16 @@ import { ErrorShapeSchema } from "./frames.js"; import { PluginJsonValueSchema } from "./plugins.js"; import { NonEmptyString, SessionLabelString } from "./primitives.js"; import { SessionsCreateParamsSchema } from "./sessions-create.js"; +import { SessionToolOverridesSchema } from "./sessions-row.js"; export { SessionsCreateParamsSchema }; export { SessionCreatedActorSchema, SessionRowSchema, + SessionToolOverridesSchema, type SessionCreatedActor, type SessionRow, + type SessionToolOverrides, } from "./sessions-row.js"; export const SESSION_OBSERVER_HEALTH_VALUES = [ @@ -498,6 +501,7 @@ export const SessionsPatchParamsSchema = closedObject({ ), thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), fastMode: Type.Optional(Type.Union([Type.Boolean(), Type.Literal("auto"), Type.Null()])), + toolOverrides: Type.Optional(Type.Union([SessionToolOverridesSchema, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), traceLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), diff --git a/src/agents/agent-bundle-mcp-manager-api.ts b/src/agents/agent-bundle-mcp-manager-api.ts index 1ae46d38fe53..7a72c01d0e23 100644 --- a/src/agents/agent-bundle-mcp-manager-api.ts +++ b/src/agents/agent-bundle-mcp-manager-api.ts @@ -1,5 +1,6 @@ /** Module-level session MCP runtime manager entry APIs. */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { SessionToolOverrides } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; @@ -32,6 +33,7 @@ export async function getOrCreateSessionMcpRuntime(params: { requesterSenderId?: string | null; agentAccountId?: string | null; messageChannel?: string | null; + toolOverrides?: Pick; }): Promise { return await getSessionMcpRuntimeManager().getOrCreate(params); } @@ -50,6 +52,7 @@ export async function getOrCreateRequesterScopedMcpRuntime(params: { requesterSenderId?: string | null; agentAccountId?: string | null; messageChannel?: string | null; + toolOverrides?: Pick; }): Promise { return await getSessionMcpRuntimeManager().getOrCreateRequesterScoped(params); } diff --git a/src/agents/agent-bundle-mcp-manager-install.ts b/src/agents/agent-bundle-mcp-manager-install.ts index 366f6251a366..a1dd26a2e74c 100644 --- a/src/agents/agent-bundle-mcp-manager-install.ts +++ b/src/agents/agent-bundle-mcp-manager-install.ts @@ -1,3 +1,4 @@ +import type { SessionToolOverrides } from "../config/sessions/types.js"; /** Session MCP runtime manager install path: static get-or-create + requester resolve/install. */ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; @@ -28,6 +29,7 @@ type RuntimeEntryParams = { redactConnectionServerNames?: ReadonlySet; requesterScope?: SessionMcpRequesterScope; configFingerprint?: string; + toolOverrides?: Pick; }; type SessionMcpRuntimeManagerInstall = { @@ -49,6 +51,7 @@ type SessionMcpRuntimeManagerInstall = { agentAccountId?: string | null; messageChannel?: string | null; requesterScope: SessionMcpRequesterScope; + toolOverrides?: Pick; }) => Promise; }; @@ -93,6 +96,7 @@ export function createSessionMcpRuntimeManagerInstall( excludeServerNames: params.excludeServerNames, redactConnectionServerNames: params.redactConnectionServerNames, safeServerNamesByServer: params.safeServerNamesByServer, + toolOverrides: params.toolOverrides, }).fingerprint; const existing = store.runtimesBySessionId.get(params.runtimeKey); if (existing) { @@ -149,6 +153,7 @@ export function createSessionMcpRuntimeManagerInstall( redactConnectionServerNames: params.redactConnectionServerNames, requesterScope: params.requesterScope, configFingerprint: nextFingerprint, + toolOverrides: params.toolOverrides, }), ).then((runtime) => { reconcileReusableRetirement(params.sessionId, runtime); @@ -187,6 +192,7 @@ export function createSessionMcpRuntimeManagerInstall( connectionOverrides: Map; redactConnectionServerNames: ReadonlySet; requesterScope: SessionMcpRequesterScope; + toolOverrides?: Pick; }): Promise => { const resolvedNameSet = new Set(params.connectionOverrides.keys()); const { fingerprint: resolvedFingerprint } = loadSessionMcpConfig({ @@ -197,6 +203,7 @@ export function createSessionMcpRuntimeManagerInstall( includeServerNames: resolvedNameSet, redactConnectionServerNames: params.redactConnectionServerNames, safeServerNamesByServer: params.safeServerNamesByServer, + toolOverrides: params.toolOverrides, }); const connectionHash = hashMcpResolvedConnections(params.connectionOverrides); const existing = store.runtimesBySessionId.get(params.runtimeKey); @@ -241,6 +248,7 @@ export function createSessionMcpRuntimeManagerInstall( redactConnectionServerNames: params.redactConnectionServerNames, requesterScope: params.requesterScope, configFingerprint: resolvedFingerprint, + toolOverrides: params.toolOverrides, }); store.connectionMetaByRuntimeKey.set(params.runtimeKey, { connectionHash, @@ -275,6 +283,7 @@ export function createSessionMcpRuntimeManagerInstall( agentAccountId?: string | null; messageChannel?: string | null; requesterScope: SessionMcpRequesterScope; + toolOverrides?: Pick; }): Promise => { const existing = store.runtimesBySessionId.get(params.runtimeKey); const meta = store.connectionMetaByRuntimeKey.get(params.runtimeKey); @@ -330,6 +339,7 @@ export function createSessionMcpRuntimeManagerInstall( connectionOverrides, redactConnectionServerNames: params.scopedNameSet, requesterScope: params.requesterScope, + toolOverrides: params.toolOverrides, }); }; diff --git a/src/agents/agent-bundle-mcp-manager.ts b/src/agents/agent-bundle-mcp-manager.ts index e3a76155302a..2e912a4b5210 100644 --- a/src/agents/agent-bundle-mcp-manager.ts +++ b/src/agents/agent-bundle-mcp-manager.ts @@ -70,6 +70,7 @@ export function createSessionMcpRuntimeManager( cfg: params.cfg, logDiagnostics: false, manifestRegistry: params.manifestRegistry, + toolOverrides: params.toolOverrides, }); // Safe names from the FULL declared set so partial resolution never changes tool names. const safeServerNamesByServer = assignSafeServerNames( @@ -91,6 +92,7 @@ export function createSessionMcpRuntimeManager( manifestRegistry: params.manifestRegistry, idleTtlMs, safeServerNamesByServer, + toolOverrides: params.toolOverrides, }); } @@ -110,6 +112,7 @@ export function createSessionMcpRuntimeManager( idleTtlMs, excludeServerNames: scopedNameSet, safeServerNamesByServer, + toolOverrides: params.toolOverrides, }), ); } else { @@ -125,6 +128,7 @@ export function createSessionMcpRuntimeManager( idleTtlMs, includeServerNames: new Set(), safeServerNamesByServer, + toolOverrides: params.toolOverrides, }); } @@ -153,6 +157,7 @@ export function createSessionMcpRuntimeManager( includeServerNames: scopedNameSet, redactConnectionServerNames: scopedNameSet, safeServerNamesByServer, + toolOverrides: params.toolOverrides, }); const scopedRuntime = await lifecycle.runExclusiveOnRuntimeKey(runtimeKey, () => install.resolveAndInstallRequesterRuntime({ @@ -172,6 +177,7 @@ export function createSessionMcpRuntimeManager( agentAccountId: params.agentAccountId, messageChannel: params.messageChannel, requesterScope, + toolOverrides: params.toolOverrides, }), ); if (scopedRuntime) { @@ -194,6 +200,7 @@ export function createSessionMcpRuntimeManager( idleTtlMs, includeServerNames: new Set(), safeServerNamesByServer, + toolOverrides: params.toolOverrides, })) ); } @@ -226,6 +233,7 @@ export function createSessionMcpRuntimeManager( cfg: params.cfg, logDiagnostics: false, manifestRegistry: params.manifestRegistry, + toolOverrides: params.toolOverrides, }); const { requesterScopedServerNames } = partitionMcpServersByConnectionScope( fullConfig.loaded.mcpServers, @@ -260,6 +268,7 @@ export function createSessionMcpRuntimeManager( includeServerNames: scopedNameSet, redactConnectionServerNames: scopedNameSet, safeServerNamesByServer, + toolOverrides: params.toolOverrides, }); const scopedRuntime = await lifecycle.runExclusiveOnRuntimeKey(runtimeKey, () => install.resolveAndInstallRequesterRuntime({ @@ -279,6 +288,7 @@ export function createSessionMcpRuntimeManager( agentAccountId: params.agentAccountId, messageChannel: params.messageChannel, requesterScope, + toolOverrides: params.toolOverrides, }), ); if (scopedRuntime) { diff --git a/src/agents/agent-bundle-mcp-materialize.ts b/src/agents/agent-bundle-mcp-materialize.ts index 31579634b0a1..49ccf5922096 100644 --- a/src/agents/agent-bundle-mcp-materialize.ts +++ b/src/agents/agent-bundle-mcp-materialize.ts @@ -195,6 +195,9 @@ function serverAllowsUtilityTool( server: McpToolCatalog["servers"][string], operation: string, ): boolean { + if (server.deniedToolNames?.includes(operation)) { + return false; + } const include = server.toolFilter?.include ?? []; const exclude = server.toolFilter?.exclude ?? []; if ( diff --git a/src/agents/agent-bundle-mcp-runtime-config.test.ts b/src/agents/agent-bundle-mcp-runtime-config.test.ts index f3ff936b9fbf..d88fe5c9c788 100644 --- a/src/agents/agent-bundle-mcp-runtime-config.test.ts +++ b/src/agents/agent-bundle-mcp-runtime-config.test.ts @@ -11,11 +11,17 @@ const mocks = vi.hoisted(() => ({ vi.mock("./embedded-agent-mcp.js", () => ({ loadEmbeddedAgentMcpConfig: (params: { cfg?: { mcp?: { servers?: Record } }; + toolOverrides?: { mcpServers?: Record }; }) => { mocks.loadCount += 1; + const servers = Object.fromEntries( + Object.entries(params.cfg?.mcp?.servers ?? {}).filter( + ([name]) => params.toolOverrides?.mcpServers?.[name] !== false, + ), + ); return { diagnostics: structuredClone(mocks.diagnostics), - mcpServers: params.cfg?.mcp?.servers ?? {}, + mcpServers: servers, }; }, })); @@ -101,6 +107,30 @@ describe("session MCP config discovery cache", () => { expect(first.fingerprint).not.toBe(second.fingerprint); }); + it("keeps process-wide discovery isolated across sessions on the same agent", () => { + const cfg = { mcp: { servers: { docs: { command: "docs" } } } }; + const disabled = loadSessionMcpConfig({ + workspaceDir: "/same-agent-workspace", + cfg, + toolOverrides: { mcpServers: { docs: false } }, + }); + const enabled = loadSessionMcpConfig({ + workspaceDir: "/same-agent-workspace", + cfg, + toolOverrides: { mcpServers: { docs: true } }, + }); + const disabledAgain = loadSessionMcpConfig({ + workspaceDir: "/same-agent-workspace", + cfg, + toolOverrides: { mcpServers: { docs: false } }, + }); + + expect(Object.keys(disabled.loaded.mcpServers)).toEqual([]); + expect(Object.keys(enabled.loaded.mcpServers)).toEqual(["docs"]); + expect(disabledAgain).toEqual(disabled); + expect(mocks.loadCount).toBe(2); + }); + it("snapshots nested config values at the cache boundary", () => { const cfg = { mcp: { diff --git a/src/agents/agent-bundle-mcp-runtime-config.ts b/src/agents/agent-bundle-mcp-runtime-config.ts index 13eb01211515..c2b22de7c295 100644 --- a/src/agents/agent-bundle-mcp-runtime-config.ts +++ b/src/agents/agent-bundle-mcp-runtime-config.ts @@ -1,6 +1,7 @@ /** Session MCP config loading, filtering, and catalog fingerprints. */ import crypto from "node:crypto"; import { resolveRuntimeConfigCacheKey } from "../config/runtime-snapshot.js"; +import type { SessionToolOverrides } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; @@ -67,12 +68,21 @@ function buildSessionMcpConfigDiscoveryCacheKey(params: { workspaceDir: string; cfg?: OpenClawConfig; manifestRegistry?: Pick; + toolOverrides?: Pick; }): string { + // Discovery is process-wide, so the session server overlay belongs in the key or sessions leak. return JSON.stringify({ v: 1, workspaceDir: params.workspaceDir, config: resolveRuntimeConfigCacheKey(params.cfg ?? EMPTY_OPENCLAW_CONFIG), manifestRegistry: resolveManifestRegistryCacheId(params.manifestRegistry), + mcpServers: params.toolOverrides?.mcpServers + ? Object.fromEntries( + Object.entries(params.toolOverrides.mcpServers).toSorted(([left], [right]) => + left.localeCompare(right), + ), + ) + : undefined, }); } @@ -88,6 +98,7 @@ function loadCachedEmbeddedAgentMcpConfig(params: { workspaceDir: string; cfg?: OpenClawConfig; manifestRegistry?: Pick; + toolOverrides?: Pick; }): SessionMcpConfigDiscoveryCacheEntry { const state = getSessionMcpConfigDiscoveryCacheState(); const key = buildSessionMcpConfigDiscoveryCacheKey(params); @@ -138,12 +149,29 @@ function sortedSetEntries(values?: ReadonlySet): string[] | undefined { return values ? [...values].toSorted((a, b) => a.localeCompare(b)) : undefined; } +function digestMcpToolDenials( + value?: Record, +): Record | undefined { + const entries = Object.entries(value ?? {}) + .map( + ([serverName, toolNames]) => + [ + serverName, + [...new Set(toolNames)].toSorted((left, right) => left.localeCompare(right)), + ] as const, + ) + .filter(([, toolNames]) => toolNames.length > 0) + .toSorted(([left], [right]) => left.localeCompare(right)); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + function buildPreparedConfigVariantKey(params: { includeServerNames?: ReadonlySet; excludeServerNames?: ReadonlySet; redactConnectionServerNames?: ReadonlySet; safeServerNames?: Record; mcpAppsEnabled: boolean; + mcpToolsDeny?: Record; }): string { return JSON.stringify({ include: sortedSetEntries(params.includeServerNames), @@ -151,6 +179,7 @@ function buildPreparedConfigVariantKey(params: { redact: sortedSetEntries(params.redactConnectionServerNames), safeServerNames: params.safeServerNames, mcpAppsEnabled: params.mcpAppsEnabled, + mcpToolsDeny: params.mcpToolsDeny, }); } @@ -159,6 +188,7 @@ function createCatalogFingerprint(params: { mcpAppsEnabled: boolean; /** Full-set server→safeName map; assignment changes must invalidate all partitions. */ safeServerNames?: Record; + mcpToolsDeny?: Record; }): string { // Session MCP fingerprints only invalidate in-memory runtime catalogs. // Algorithm changes can cause one cache miss, but no persisted state migration. @@ -200,6 +230,7 @@ export function loadSessionMcpConfig(params: { redactConnectionServerNames?: ReadonlySet; /** Full-set safe-name assignments; folded into fingerprint for all partitions. */ safeServerNamesByServer?: ReadonlyMap; + toolOverrides?: Pick; }): { loaded: LoadedMcpConfig; fingerprint: string; @@ -208,6 +239,7 @@ export function loadSessionMcpConfig(params: { workspaceDir: params.workspaceDir, cfg: params.cfg, manifestRegistry: params.manifestRegistry, + toolOverrides: params.toolOverrides, }); if (params.logDiagnostics !== false) { for (const diagnostic of discovery.loaded.diagnostics) { @@ -216,12 +248,14 @@ export function loadSessionMcpConfig(params: { } const safeServerNames = digestSafeServerNameAssignments(params.safeServerNamesByServer); const mcpAppsEnabled = params.cfg?.mcp?.apps?.enabled === true; + const mcpToolsDeny = digestMcpToolDenials(params.toolOverrides?.mcpToolsDeny); const variantKey = buildPreparedConfigVariantKey({ includeServerNames: params.includeServerNames, excludeServerNames: params.excludeServerNames, redactConnectionServerNames: params.redactConnectionServerNames, safeServerNames, mcpAppsEnabled, + mcpToolsDeny, }); const prepared = discovery.preparedByVariant.get(variantKey); if (prepared) { @@ -243,6 +277,7 @@ export function loadSessionMcpConfig(params: { servers: fingerprintServers, mcpAppsEnabled, ...(safeServerNames ? { safeServerNames } : {}), + mcpToolsDeny, }), }; discovery.preparedByVariant.set(variantKey, result); diff --git a/src/agents/agent-bundle-mcp-runtime-shared.ts b/src/agents/agent-bundle-mcp-runtime-shared.ts index acbe70d2576c..d8b4cba8dcee 100644 --- a/src/agents/agent-bundle-mcp-runtime-shared.ts +++ b/src/agents/agent-bundle-mcp-runtime-shared.ts @@ -1,3 +1,4 @@ +import type { SessionToolOverrides } from "../config/sessions/types.js"; /** Shared session MCP runtime constants and create-runtime factory type. */ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; @@ -43,6 +44,7 @@ export type CreateSessionMcpRuntime = (params: { redactConnectionServerNames?: ReadonlySet; requesterScope?: SessionMcpRequesterScope; configFingerprint?: string; + toolOverrides?: Pick; }) => SessionMcpRuntime; export function resolveSessionMcpRuntimeIdleTtlMs(): number { diff --git a/src/agents/agent-bundle-mcp-runtime.test.ts b/src/agents/agent-bundle-mcp-runtime.test.ts index 599e31b6f5ce..783a14e4ffc0 100644 --- a/src/agents/agent-bundle-mcp-runtime.test.ts +++ b/src/agents/agent-bundle-mcp-runtime.test.ts @@ -1301,6 +1301,66 @@ describe("session MCP runtime", () => { } }); + it("applies session tool denials to listed and synthetic MCP tools", async () => { + const tempDir = tempDirTracker.make("bundle-mcp-session-deny-"); + const serverPath = path.join(tempDir, "session-deny.mjs"); + const logPath = path.join(tempDir, "server.log"); + await writeListToolsMcpServer({ + filePath: serverPath, + logPath, + capabilities: { tools: {}, resources: {} }, + tools: [ + { name: "search_docs", inputSchema: { type: "object", properties: {} } }, + { name: "read_docs", inputSchema: { type: "object", properties: {} } }, + ], + }); + + const runtime = await getOrCreateSessionMcpRuntime({ + sessionId: "session-tool-deny", + sessionKey: "agent:test:session-tool-deny", + workspaceDir: tempDir, + cfg: { + mcp: { + servers: { docs: { command: process.execPath, args: [serverPath] } }, + }, + }, + toolOverrides: { + mcpToolsDeny: { docs: ["read_docs", "resources_read"] }, + }, + }); + + try { + const materialized = await materializeBundleMcpToolsForRun({ runtime }); + expect(materialized.tools.map((tool) => tool.name)).toEqual([ + "docs__resources_list", + "docs__search_docs", + ]); + } finally { + await runtime.dispose(); + } + }); + + it("does not read inherited properties as MCP tool denials", async () => { + const tempDir = tempDirTracker.make("bundle-mcp-own-deny-"); + const serverPath = path.join(tempDir, "own-deny.mjs"); + const logPath = path.join(tempDir, "server.log"); + await writeListToolsMcpServer({ filePath: serverPath, logPath }); + const runtime = createSessionMcpRuntime({ + sessionId: "session-own-deny", + workspaceDir: tempDir, + cfg: { mcp: { servers: { constructor: { command: process.execPath, args: [serverPath] } } } }, + toolOverrides: { mcpToolsDeny: { docs: ["slow_tool"] } }, + }); + + try { + expect((await runtime.getCatalog()).tools.map((tool) => tool.toolName)).toEqual([ + "slow_tool", + ]); + } finally { + await runtime.dispose(); + } + }); + it("does not split a surrogate pair at the MCP metadata text limit", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "bundle-mcp-utf16-metadata-")); const serverPath = path.join(tempDir, "utf16-metadata.mjs"); diff --git a/src/agents/agent-bundle-mcp-runtime.ts b/src/agents/agent-bundle-mcp-runtime.ts index 4d7295aa35b1..cd0c4adb39da 100644 --- a/src/agents/agent-bundle-mcp-runtime.ts +++ b/src/agents/agent-bundle-mcp-runtime.ts @@ -14,6 +14,7 @@ import { import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js"; import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import type { SessionToolOverrides } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; import { redactToolPayloadText } from "../logging/redact.js"; @@ -406,6 +407,7 @@ export function createSessionMcpRuntime(params: { redactConnectionServerNames?: ReadonlySet; requesterScope?: SessionMcpRequesterScope; configFingerprint?: string; + toolOverrides?: Pick; }): SessionMcpRuntime { const { loaded, fingerprint: computedFingerprint } = loadSessionMcpConfig({ workspaceDir: params.workspaceDir, @@ -416,6 +418,7 @@ export function createSessionMcpRuntime(params: { excludeServerNames: params.excludeServerNames, redactConnectionServerNames: params.redactConnectionServerNames, safeServerNamesByServer: params.safeServerNamesByServer, + toolOverrides: params.toolOverrides, }); const configFingerprint = params.configFingerprint ?? computedFingerprint; const mcpAppsEnabled = params.cfg?.mcp?.apps?.enabled === true; @@ -825,9 +828,14 @@ export function createSessionMcpRuntime(params: { }); failIfDisposed(); const selection = getMcpToolSelection(rawServer); - const exposedTools = listedTools.filter((tool) => - shouldExposeMcpTool(selection, tool.name.trim()), + const denialMap = params.toolOverrides?.mcpToolsDeny; + const deniedToolNames = new Set( + denialMap && Object.hasOwn(denialMap, serverName) ? denialMap[serverName] : [], ); + const exposedTools = listedTools.filter((tool) => { + const toolName = tool.name.trim(); + return !deniedToolNames.has(toolName) && shouldExposeMcpTool(selection, toolName); + }); const serverEntry: McpServerCatalog = { serverName, safeServerName, @@ -855,6 +863,9 @@ export function createSessionMcpRuntime(params: { }, } : {}), + ...(deniedToolNames.size > 0 + ? { deniedToolNames: [...deniedToolNames].toSorted() } + : {}), }; const toolEntries: McpCatalogTool[] = []; for (const tool of exposedTools) { diff --git a/src/agents/agent-bundle-mcp-types.ts b/src/agents/agent-bundle-mcp-types.ts index 56a0227a5834..d7fdced71d9a 100644 --- a/src/agents/agent-bundle-mcp-types.ts +++ b/src/agents/agent-bundle-mcp-types.ts @@ -5,6 +5,7 @@ import type { ListToolsResult, } from "@modelcontextprotocol/sdk/types.js"; import type { TSchema } from "typebox"; +import type { SessionToolOverrides } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import type { AnyAgentTool } from "./tools/common.js"; @@ -41,6 +42,7 @@ export type McpServerCatalog = { include?: string[]; exclude?: string[]; }; + deniedToolNames?: string[]; }; /** MCP tool entry after server-name sanitization and schema normalization. */ @@ -138,6 +140,7 @@ export type SessionMcpRuntimeManager = { requesterSenderId?: string | null; agentAccountId?: string | null; messageChannel?: string | null; + toolOverrides?: Pick; }) => Promise; /** * Requester-scoped partition only — never creates static transports. @@ -153,6 +156,7 @@ export type SessionMcpRuntimeManager = { requesterSenderId?: string | null; agentAccountId?: string | null; messageChannel?: string | null; + toolOverrides?: Pick; }) => Promise; /** * Session-stable advertised catalog for scoped servers. Used by shared-thread diff --git a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts index 66fdbd0dacd7..173d0a4341b9 100644 --- a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -160,6 +160,13 @@ function cronCreatorToolNames( } describe("createOpenClawCodingTools", () => { + it("forwards the session web-search gate to core tool materialization", () => { + vi.mocked(createOpenClawTools).mockClear(); + createOpenClawCodingTools({ webSearchEnabled: false }); + + expect(latestCreateOpenClawToolsOptions().webSearchEnabled).toBe(false); + }); + it("reads node-hosted skill content through the assembled workspace-only read tool", async () => { const locator = "node://node-1/skills/pond/SKILL.md"; const tools = createOpenClawCodingTools({ diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 579214645b5d..c80d9ebc260d 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -360,6 +360,7 @@ type OpenClawCodingToolsOptions = { modelCompat?: ModelCompatConfig; /** If false, keep OpenClaw web_search even when a provider-native search tool is active. */ suppressManagedWebSearch?: boolean; + webSearchEnabled?: boolean; /** * Auth mode for the current provider. We only need this for Anthropic OAuth * tool-name blocking quirks. @@ -959,6 +960,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) : runtimeRoot, sandboxed: Boolean(sandbox), config: options?.config, + webSearchEnabled: options?.webSearchEnabled, clientCaps: options?.clientCaps, toolBindings: options?.toolBindings, pluginToolAllowlist, diff --git a/src/agents/bundle-mcp-config.test.ts b/src/agents/bundle-mcp-config.test.ts index bb0d96ee9178..0de8a9f88522 100644 --- a/src/agents/bundle-mcp-config.test.ts +++ b/src/agents/bundle-mcp-config.test.ts @@ -97,4 +97,39 @@ describe("loadMergedBundleMcpConfig", () => { expect(merged.config.mcpServers).not.toHaveProperty("bundleProbe"); }); + + it.each([ + { + name: "excludes an enabled server", + override: false, + enabled: true, + expected: false, + }, + { + name: "includes a disabled server", + override: true, + enabled: false, + expected: true, + }, + { + name: "inherits configured state", + override: undefined, + enabled: true, + expected: true, + }, + ])("$name", ({ override, enabled, expected }) => { + const merged = loadMergedBundleMcpConfig({ + workspaceDir: "/workspace", + cfg: { + mcp: { + servers: { + docs: { enabled, command: "node", args: ["docs.mjs"] }, + }, + }, + }, + ...(override === undefined ? {} : { toolOverrides: { mcpServers: { docs: override } } }), + }); + + expect(Object.hasOwn(merged.config.mcpServers, "docs")).toBe(expected); + }); }); diff --git a/src/agents/bundle-mcp-config.ts b/src/agents/bundle-mcp-config.ts index 84f3901a819b..27cef8af8379 100644 --- a/src/agents/bundle-mcp-config.ts +++ b/src/agents/bundle-mcp-config.ts @@ -3,6 +3,7 @@ * runtimes. */ import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js"; +import type { SessionToolOverrides } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loadEnabledBundleMcpConfig, @@ -54,6 +55,7 @@ export function loadMergedBundleMcpConfig(params: { cfg?: OpenClawConfig; manifestRegistry?: Pick; mapConfiguredServer?: BundleMcpServerMapper; + toolOverrides?: Pick; }): MergedBundleMcpConfig { const bundleMcp = loadEnabledBundleMcpConfig({ workspaceDir: params.workspaceDir, @@ -61,17 +63,24 @@ export function loadMergedBundleMcpConfig(params: { manifestRegistry: params.manifestRegistry, }); const configuredMcp = normalizeConfiguredMcpServers(params.cfg?.mcp?.servers); + const serverOverrides = params.toolOverrides?.mcpServers; + const readServerOverride = (name: string) => + serverOverrides && Object.hasOwn(serverOverrides, name) ? serverOverrides[name] : undefined; const disabledConfiguredNames = new Set( Object.entries(configuredMcp) - .filter(([, server]) => server.enabled === false) + .filter(([name, server]) => readServerOverride(name) !== true && server.enabled === false) .map(([name]) => name), ); const enabledConfiguredMcp = Object.fromEntries( - Object.entries(configuredMcp).filter(([, server]) => server.enabled !== false), + Object.entries(configuredMcp).filter( + ([name, server]) => + readServerOverride(name) !== false && + (readServerOverride(name) === true || server.enabled !== false), + ), ); const enabledBundleMcp = Object.fromEntries( Object.entries(bundleMcp.config.mcpServers).filter( - ([name]) => !disabledConfiguredNames.has(name), + ([name]) => readServerOverride(name) !== false && !disabledConfiguredNames.has(name), ), ); const mapConfiguredServer = params.mapConfiguredServer ?? ((server) => server); diff --git a/src/agents/cli-runner/bundle-mcp-claude.ts b/src/agents/cli-runner/bundle-mcp-claude.ts index 8d959cbb7d49..f4fcd941d231 100644 --- a/src/agents/cli-runner/bundle-mcp-claude.ts +++ b/src/agents/cli-runner/bundle-mcp-claude.ts @@ -38,9 +38,40 @@ export function findClaudeMcpConfigPaths(args?: string[]): string[] { } /** Return Claude args with OpenClaw's strict MCP config path injected. */ +function mergeClaudeDisallowedTools(args: string[], deniedTools: string[]): string[] { + if (deniedTools.length === 0) { + return args; + } + const next: string[] = []; + const existingDisallowed: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] ?? ""; + if (arg === "--disallowedTools" || arg === "--disallowed-tools") { + while (typeof args[i + 1] === "string" && !args[i + 1]?.startsWith("-")) { + i += 1; + existingDisallowed.push(args[i] ?? ""); + } + continue; + } + if (arg.startsWith("--disallowedTools=") || arg.startsWith("--disallowed-tools=")) { + existingDisallowed.push(arg.slice(arg.indexOf("=") + 1)); + continue; + } + next.push(arg); + } + next.push("--disallowedTools", [...new Set([...existingDisallowed, ...deniedTools])].join(",")); + return next; +} + +export function injectClaudeWebSearchDisabledArgs(args: string[] | undefined): string[] { + return mergeClaudeDisallowedTools(args ?? [], ["WebSearch"]); +} + export function injectClaudeMcpConfigArgs( args: string[] | undefined, mcpConfigPath: string, + mcpToolsDeny?: Record, + webSearchEnabled?: boolean, ): string[] { const next: string[] = []; for (let i = 0; i < (args?.length ?? 0); i += 1) { @@ -60,7 +91,13 @@ export function injectClaudeMcpConfigArgs( next.push(arg); } next.push("--strict-mcp-config", "--mcp-config", mcpConfigPath); - return next; + const deniedTools = Object.entries(mcpToolsDeny ?? {}).flatMap(([serverName, toolNames]) => + toolNames.map((toolName) => `mcp__${serverName}__${toolName}`), + ); + if (webSearchEnabled === false) { + deniedTools.push("WebSearch"); + } + return mergeClaudeDisallowedTools(next, deniedTools.toSorted()); } /** Writes the active per-attempt capture token into OpenClaw's generated Claude MCP config. */ diff --git a/src/agents/cli-runner/bundle-mcp-gemini.ts b/src/agents/cli-runner/bundle-mcp-gemini.ts index f4fb5e1de4f6..8623ab955f1f 100644 --- a/src/agents/cli-runner/bundle-mcp-gemini.ts +++ b/src/agents/cli-runner/bundle-mcp-gemini.ts @@ -21,6 +21,46 @@ async function readJsonObject(filePath: string): Promise : {}; } +async function readGeminiBaseSettings( + inheritedEnv: Record | undefined, +): Promise> { + const settingsPath = + inheritedEnv?.GEMINI_CLI_SYSTEM_SETTINGS_PATH ?? process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH; + return typeof settingsPath === "string" && settingsPath.trim() + ? await readJsonObject(settingsPath) + : {}; +} + +function mergeGeminiWebSearchDisabled(base: Record): Record { + const existing = + isRecord(base.tools) && Array.isArray(base.tools.exclude) + ? base.tools.exclude.filter((name): name is string => typeof name === "string") + : []; + return applyMergePatch(base, { + tools: { exclude: [...new Set([...existing, "google_web_search"])] }, + }) as Record; +} + +async function writeGeminiSettings( + settings: Record, + inheritedEnv: Record | undefined, +): Promise<{ env: Record; cleanup: () => Promise }> { + const temporary = await writeTemporaryBundleMcpJson("openclaw-gemini-mcp-", settings); + return { + env: { ...inheritedEnv, GEMINI_CLI_SYSTEM_SETTINGS_PATH: temporary.filePath }, + cleanup: temporary.cleanup, + }; +} + +export async function writeGeminiWebSearchDisabledSettings( + inheritedEnv: Record | undefined, +) { + return await writeGeminiSettings( + mergeGeminiWebSearchDisabled(await readGeminiBaseSettings(inheritedEnv)), + inheritedEnv, + ); +} + function resolveEnvPlaceholder( value: string, inheritedEnv: Record | undefined, @@ -38,6 +78,7 @@ function resolveEnvPlaceholder( function normalizeGeminiServerConfig( server: BundleMcpServerConfig, inheritedEnv: Record | undefined, + deniedTools: readonly string[] | undefined, ): Record { const next = normalizeBundleMcpServerConfig(server, GEMINI_MCP_SERVER_FIELDS); const headers = normalizeStringRecord(server.headers); @@ -49,6 +90,12 @@ function normalizeGeminiServerConfig( ]), ); } + if (deniedTools?.length) { + const existing = Array.isArray(server.excludeTools) + ? server.excludeTools.filter((name): name is string => typeof name === "string") + : []; + next.excludeTools = [...new Set([...existing, ...deniedTools])].toSorted(); + } return next; } @@ -56,38 +103,35 @@ function normalizeGeminiServerConfig( export async function writeGeminiSystemSettings( mergedConfig: BundleMcpConfig, inheritedEnv: Record | undefined, + mcpToolsDeny?: Record, + webSearchEnabled?: boolean, ): Promise<{ env: Record; cleanup: () => Promise }> { - const existingSettingsPath = - inheritedEnv?.GEMINI_CLI_SYSTEM_SETTINGS_PATH ?? process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH; - const base = - typeof existingSettingsPath === "string" && existingSettingsPath.trim() - ? await readJsonObject(existingSettingsPath) - : {}; + const base = await readGeminiBaseSettings(inheritedEnv); const normalizedConfig: BundleMcpConfig = { mcpServers: Object.fromEntries( Object.entries(mergedConfig.mcpServers).map(([name, server]) => [ name, - normalizeGeminiServerConfig(server, inheritedEnv), + normalizeGeminiServerConfig( + server, + inheritedEnv, + mcpToolsDeny && Object.hasOwn(mcpToolsDeny, name) ? mcpToolsDeny[name] : undefined, + ), ]), ) as BundleMcpConfig["mcpServers"], }; - const settings = applyMergePatch(base, { - mcp: { - allowed: Object.keys(normalizedConfig.mcpServers), + const settings = applyMergePatch( + webSearchEnabled === false ? mergeGeminiWebSearchDisabled(base) : base, + { + mcp: { + allowed: Object.keys(normalizedConfig.mcpServers), + }, + mcpServers: normalizedConfig.mcpServers, }, - mcpServers: normalizedConfig.mcpServers, - }) as Record; + ) as Record; if (!isRecord(settings.mcp) || !isRecord(settings.mcpServers)) { throw new Error("Gemini MCP settings merge produced an invalid object"); } - const temporary = await writeTemporaryBundleMcpJson("openclaw-gemini-mcp-", settings); - return { - env: { - ...inheritedEnv, - GEMINI_CLI_SYSTEM_SETTINGS_PATH: temporary.filePath, - }, - cleanup: temporary.cleanup, - }; + return await writeGeminiSettings(settings, inheritedEnv); } /** Writes per-attempt Gemini settings with the active loopback capture token. */ diff --git a/src/agents/cli-runner/bundle-mcp.codex.test.ts b/src/agents/cli-runner/bundle-mcp.codex.test.ts index 1035ff02ea42..c2ed4f10e406 100644 --- a/src/agents/cli-runner/bundle-mcp.codex.test.ts +++ b/src/agents/cli-runner/bundle-mcp.codex.test.ts @@ -3,6 +3,42 @@ import { describe, expect, it } from "vitest"; import { prepareCliBundleMcpConfig } from "./bundle-mcp.js"; describe("prepareCliBundleMcpConfig codex", () => { + it("disables Codex native web search without bundle MCP", async () => { + const prepared = await prepareCliBundleMcpConfig({ + enabled: false, + mode: "codex-config-overrides", + backend: { command: "codex", args: ["exec"] }, + workspaceDir: "/tmp/openclaw-cli-codex-web-search-disabled", + toolOverrides: { webSearch: false }, + }); + + expect(prepared.backend.args).toEqual(["exec", "-c", 'web_search="disabled"']); + expect(prepared.mcpConfigHash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("projects session MCP tool denials into Codex disabled_tools", async () => { + const prepared = await prepareCliBundleMcpConfig({ + enabled: true, + mode: "codex-config-overrides", + backend: { command: "codex", args: ["exec"] }, + workspaceDir: "/tmp/openclaw-bundle-mcp-codex-deny", + config: { + plugins: { enabled: false }, + mcp: { + servers: { + docs: { transport: "streamable-http", url: "https://docs.example.com/mcp" }, + }, + }, + }, + toolOverrides: { mcpToolsDeny: { docs: ["delete_docs"] }, webSearch: false }, + }); + + expect(prepared.backend.args?.find((arg) => arg.startsWith("mcp_servers="))).toContain( + 'disabled_tools = ["delete_docs"]', + ); + expect(prepared.backend.args).toContain('web_search="disabled"'); + }); + it("injects codex MCP config overrides with env-backed loopback headers", async () => { const prepared = await prepareCliBundleMcpConfig({ enabled: true, diff --git a/src/agents/cli-runner/bundle-mcp.gemini.test.ts b/src/agents/cli-runner/bundle-mcp.gemini.test.ts index 6863118be3be..15c5d90c9337 100644 --- a/src/agents/cli-runner/bundle-mcp.gemini.test.ts +++ b/src/agents/cli-runner/bundle-mcp.gemini.test.ts @@ -6,6 +6,23 @@ import { describe, expect, it } from "vitest"; import { prepareCliBundleMcpCaptureAttempt, prepareCliBundleMcpConfig } from "./bundle-mcp.js"; describe("prepareCliBundleMcpConfig gemini", () => { + it("disables Gemini native web search without bundle MCP", async () => { + const prepared = await prepareCliBundleMcpConfig({ + enabled: false, + mode: "gemini-system-settings", + backend: { command: "gemini" }, + workspaceDir: "/tmp/openclaw-cli-gemini-web-search-disabled", + toolOverrides: { webSearch: false }, + }); + const raw = JSON.parse( + await fs.readFile(prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH as string, "utf-8"), + ) as { tools?: { exclude?: string[] } }; + + expect(raw.tools?.exclude).toEqual(["google_web_search"]); + expect(prepared.mcpConfigHash).toMatch(/^[0-9a-f]{64}$/); + await prepared.cleanup?.(); + }); + it("writes Gemini system settings for bundle MCP servers", async () => { const prepared = await prepareCliBundleMcpConfig({ enabled: true, @@ -21,6 +38,7 @@ describe("prepareCliBundleMcpConfig gemini", () => { openclaw: { type: "http", url: "http://127.0.0.1:23119/mcp", + excludeTools: ["global_delete"], headers: { Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", "x-openclaw-client-caps": "${OPENCLAW_MCP_CLIENT_CAPS}", @@ -32,6 +50,7 @@ describe("prepareCliBundleMcpConfig gemini", () => { OPENCLAW_MCP_TOKEN: "lb-tk-123", OPENCLAW_MCP_CLIENT_CAPS: "tool-events,inline-widgets", }, + toolOverrides: { mcpToolsDeny: { openclaw: ["delete_docs"] }, webSearch: false }, }); expect(prepared.backend.args).toEqual(["--prompt", "{prompt}"]); @@ -42,7 +61,11 @@ describe("prepareCliBundleMcpConfig gemini", () => { await fs.readFile(prepared.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH as string, "utf-8"), ) as { mcp?: { allowed?: string[] }; - mcpServers?: Record }>; + tools?: { exclude?: string[] }; + mcpServers?: Record< + string, + { url?: string; headers?: Record; excludeTools?: string[] } + >; }; expect(raw.mcp?.allowed).toEqual(["openclaw"]); expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp"); @@ -50,6 +73,8 @@ describe("prepareCliBundleMcpConfig gemini", () => { expect(raw.mcpServers?.openclaw?.headers?.["x-openclaw-client-caps"]).toBe( "tool-events,inline-widgets", ); + expect(raw.mcpServers?.openclaw?.excludeTools).toEqual(["delete_docs", "global_delete"]); + expect(raw.tools?.exclude).toEqual(["google_web_search"]); await prepared.cleanup?.(); }); diff --git a/src/agents/cli-runner/bundle-mcp.test.ts b/src/agents/cli-runner/bundle-mcp.test.ts index d9066b0f9d01..8de19aa17137 100644 --- a/src/agents/cli-runner/bundle-mcp.test.ts +++ b/src/agents/cli-runner/bundle-mcp.test.ts @@ -14,6 +14,19 @@ import { setupCliBundleMcpTestHarness(); describe("prepareCliBundleMcpConfig", () => { + it("disables Claude native web search without bundle MCP", async () => { + const prepared = await prepareCliBundleMcpConfig({ + enabled: false, + mode: "claude-config-file", + backend: { command: "claude", args: ["--print"] }, + workspaceDir: "/tmp/openclaw-cli-web-search-disabled", + toolOverrides: { webSearch: false }, + }); + + expect(prepared.backend.args).toEqual(["--print", "--disallowedTools", "WebSearch"]); + expect(prepared.mcpConfigHash).toMatch(/^[0-9a-f]{64}$/); + }); + it("injects a strict empty --mcp-config overlay for bundle-MCP-enabled backends without servers", async () => { const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( "openclaw-cli-bundle-mcp-empty-", @@ -95,6 +108,47 @@ describe("prepareCliBundleMcpConfig", () => { await prepared.cleanup?.(); }); + it("projects session MCP tool denials into Claude disallowed tools", async () => { + const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( + "openclaw-cli-bundle-mcp-deny-", + ); + const prepared = await prepareCliBundleMcpConfig({ + enabled: true, + mode: "claude-config-file", + backend: { + command: "claude", + args: ["--disallowedTools", "Bash(rm *)"], + }, + workspaceDir, + config: { + plugins: { enabled: false }, + mcp: { servers: { docs: { command: "node", args: ["docs.mjs"] } } }, + }, + toolOverrides: { mcpToolsDeny: { docs: ["delete_docs"] }, webSearch: false }, + }); + + expect(prepared.backend.args).toContain("Bash(rm *),WebSearch,mcp__docs__delete_docs"); + await prepared.cleanup?.(); + }); + + it("applies server disables to exclusive CLI MCP configs", async () => { + const prepared = await prepareCliBundleMcpConfig({ + enabled: true, + mode: "claude-config-file", + backend: { command: "claude" }, + workspaceDir: "/tmp/openclaw-cli-bundle-mcp-exclusive-disable", + exclusiveConfig: { mcpServers: { openclaw: { command: "node" } } }, + toolOverrides: { mcpServers: { openclaw: false } }, + }); + + const generatedConfigPath = requireMcpConfigPath(prepared.backend.args); + const raw = JSON.parse(await fs.readFile(generatedConfigPath, "utf-8")) as { + mcpServers?: Record; + }; + expect(raw.mcpServers).toEqual({}); + await prepared.cleanup?.(); + }); + it("strips variadic Claude --mcp-config values and merges every listed config", async () => { const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir( "openclaw-cli-bundle-mcp-variadic-", diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts index 5c223f2ee86b..e51bba5613ff 100644 --- a/src/agents/cli-runner/bundle-mcp.ts +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -4,6 +4,7 @@ import crypto from "node:crypto"; import path from "node:path"; import { applyMergePatch } from "../../config/merge-patch.js"; +import type { SessionToolOverrides } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { tryReadJson } from "../../infra/json-files.js"; @@ -12,7 +13,11 @@ import { OPENCLAW_TOOLS_MCP_SYSTEM_AGENT_PROPOSAL_ENV, OPENCLAW_TOOLS_MCP_TOOLS_ENV, } from "../../mcp/openclaw-tools-serve-config.js"; -import { extractMcpServerMap, type BundleMcpConfig } from "../../plugins/bundle-mcp.js"; +import { + extractMcpServerMap, + type BundleMcpConfig, + type BundleMcpServerConfig, +} from "../../plugins/bundle-mcp.js"; import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; import type { CliBundleMcpMode } from "../../plugins/types.js"; import { isRecord } from "../bundle-mcp-adapter.js"; @@ -21,10 +26,15 @@ import { resolveMcpBearerBundleConfig } from "../mcp-auth-profile.js"; import { findClaudeMcpConfigPaths, injectClaudeMcpConfigArgs, + injectClaudeWebSearchDisabledArgs, writeClaudeMcpCaptureConfig, } from "./bundle-mcp-claude.js"; import { injectCodexMcpConfigArgs } from "./bundle-mcp-codex.js"; -import { writeGeminiMcpCaptureSettings, writeGeminiSystemSettings } from "./bundle-mcp-gemini.js"; +import { + writeGeminiMcpCaptureSettings, + writeGeminiSystemSettings, + writeGeminiWebSearchDisabledSettings, +} from "./bundle-mcp-gemini.js"; import { injectBundleMcpBackendArgs, writeTemporaryBundleMcpJson } from "./bundle-mcp-runtime.js"; type PreparedCliBundleMcpConfig = { @@ -106,6 +116,65 @@ function canonicalizeBundleMcpConfigForResume(config: BundleMcpConfig): BundleMc const OPENCLAW_MCP_ENV_TEMPLATE_PATTERN = /\$\{(OPENCLAW_MCP_[A-Z0-9_]+)\}/g; +function normalizeMcpToolDenials( + value?: Record, +): Record | undefined { + const entries = Object.entries(value ?? {}) + .map(([serverName, toolNames]) => [serverName, [...new Set(toolNames)].toSorted()] as const) + .filter(([, toolNames]) => toolNames.length > 0) + .toSorted(([left], [right]) => left.localeCompare(right)); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function applyCodexMcpToolDenials( + config: BundleMcpConfig, + denials: Record | undefined, +): BundleMcpConfig { + if (!denials) { + return config; + } + return { + mcpServers: Object.fromEntries( + Object.entries(config.mcpServers).map(([serverName, server]) => { + const denied = Object.hasOwn(denials, serverName) ? denials[serverName] : undefined; + if (!denied?.length) { + return [serverName, server]; + } + const toolFilter = isRecord(server.toolFilter) ? server.toolFilter : {}; + const existing = Array.isArray(toolFilter.exclude) + ? toolFilter.exclude.filter((name): name is string => typeof name === "string") + : []; + return [ + serverName, + { + ...server, + toolFilter: { + ...toolFilter, + exclude: [...new Set([...existing, ...denied])].toSorted(), + }, + } satisfies BundleMcpServerConfig, + ]; + }), + ), + }; +} + +function applyMcpServerOverrides( + config: BundleMcpConfig, + overrides: Record | undefined, +): BundleMcpConfig { + return overrides + ? { + mcpServers: Object.fromEntries( + Object.entries(config.mcpServers).filter( + ([serverName]) => + !Object.hasOwn(overrides, serverName) || overrides[serverName] !== false, + ), + ), + } + : config; +} + function resolveOpenClawMcpEnvTemplates(value: unknown, env?: Record): unknown { if (!env) { return value; @@ -132,20 +201,37 @@ async function prepareModeSpecificBundleMcpConfig(params: { backend: CliBackendConfig; mergedConfig: BundleMcpConfig; env?: Record; + mcpToolsDeny?: Record; + webSearchEnabled?: boolean; }): Promise { - const serializedConfig = `${JSON.stringify(params.mergedConfig, null, 2)}\n`; + const mcpToolsDeny = normalizeMcpToolDenials(params.mcpToolsDeny); + const webSearchDisabled = params.webSearchEnabled === false; + const configHashInput = + mcpToolsDeny || webSearchDisabled + ? { config: params.mergedConfig, mcpToolsDeny, webSearchDisabled } + : params.mergedConfig; + const serializedConfig = `${JSON.stringify(configHashInput, null, 2)}\n`; const mcpConfigHash = crypto.createHash("sha256").update(serializedConfig).digest("hex"); const serializedResumeConfig = `${JSON.stringify( - canonicalizeBundleMcpConfigForResume(params.mergedConfig), + mcpToolsDeny || webSearchDisabled + ? { + config: canonicalizeBundleMcpConfigForResume(params.mergedConfig), + mcpToolsDeny, + webSearchDisabled, + } + : canonicalizeBundleMcpConfigForResume(params.mergedConfig), null, 2, )}\n`; const mcpResumeHash = crypto.createHash("sha256").update(serializedResumeConfig).digest("hex"); if (params.mode === "codex-config-overrides") { + const codexConfig = applyCodexMcpToolDenials(params.mergedConfig, mcpToolsDeny); return { backend: injectBundleMcpBackendArgs(params.backend, (args) => - injectCodexMcpConfigArgs(args, params.mergedConfig), + webSearchDisabled + ? [...injectCodexMcpConfigArgs(args, codexConfig), "-c", 'web_search="disabled"'] + : injectCodexMcpConfigArgs(args, codexConfig), ), mcpConfigHash, mcpResumeHash, @@ -154,7 +240,12 @@ async function prepareModeSpecificBundleMcpConfig(params: { } if (params.mode === "gemini-system-settings") { - const settings = await writeGeminiSystemSettings(params.mergedConfig, params.env); + const settings = await writeGeminiSystemSettings( + params.mergedConfig, + params.env, + mcpToolsDeny, + params.webSearchEnabled, + ); return { backend: params.backend, mcpConfigHash, @@ -176,7 +267,7 @@ async function prepareModeSpecificBundleMcpConfig(params: { ); return { backend: injectBundleMcpBackendArgs(params.backend, (args) => - injectClaudeMcpConfigArgs(args, temporary.filePath), + injectClaudeMcpConfigArgs(args, temporary.filePath, mcpToolsDeny, params.webSearchEnabled), ), mcpConfigHash, mcpResumeHash, @@ -185,6 +276,30 @@ async function prepareModeSpecificBundleMcpConfig(params: { }; } +async function prepareCliWebSearchDisabled(params: { + mode: CliBundleMcpMode; + backend: CliBackendConfig; + env?: Record; +}): Promise { + const fingerprint = crypto.createHash("sha256").update("web-search-disabled-v1").digest("hex"); + if (params.mode === "gemini-system-settings") { + const settings = await writeGeminiWebSearchDisabledSettings(params.env); + return { + backend: params.backend, + env: settings.env, + cleanup: settings.cleanup, + mcpConfigHash: fingerprint, + mcpResumeHash: fingerprint, + }; + } + const backend = injectBundleMcpBackendArgs(params.backend, (args) => + params.mode === "codex-config-overrides" + ? [...(args ?? []), "-c", 'web_search="disabled"'] + : injectClaudeWebSearchDisabledArgs(args), + ); + return { backend, env: params.env, mcpConfigHash: fingerprint, mcpResumeHash: fingerprint }; +} + /** Prepare backend args/env/cleanup for bundle MCP injection into a CLI run. */ export async function prepareCliBundleMcpConfig(params: { enabled: boolean; @@ -192,6 +307,7 @@ export async function prepareCliBundleMcpConfig(params: { backend: CliBackendConfig; workspaceDir: string; config?: OpenClawConfig; + toolOverrides?: SessionToolOverrides; agentDir?: string; additionalConfig?: BundleMcpConfig; /** @@ -204,7 +320,13 @@ export async function prepareCliBundleMcpConfig(params: { warn?: (message: string) => void; }): Promise { if (!params.enabled) { - return { backend: params.backend, env: params.env }; + return params.toolOverrides?.webSearch === false + ? await prepareCliWebSearchDisabled({ + mode: params.mode ?? "claude-config-file", + backend: params.backend, + env: params.env, + }) + : { backend: params.backend, env: params.env }; } const mode = params.mode ?? "claude-config-file"; @@ -212,8 +334,13 @@ export async function prepareCliBundleMcpConfig(params: { return await prepareModeSpecificBundleMcpConfig({ mode, backend: params.backend, - mergedConfig: params.exclusiveConfig, + mergedConfig: applyMcpServerOverrides( + params.exclusiveConfig, + params.toolOverrides?.mcpServers, + ), env: params.env, + mcpToolsDeny: params.toolOverrides?.mcpToolsDeny, + webSearchEnabled: params.toolOverrides?.webSearch, }); } const resumeMcpConfigPaths = @@ -242,6 +369,7 @@ export async function prepareCliBundleMcpConfig(params: { workspaceDir: params.workspaceDir, cfg: params.config, mapConfiguredServer: toCliBundleMcpServerConfig, + toolOverrides: params.toolOverrides, }); for (const diagnostic of bundleConfig.diagnostics) { params.warn?.(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`); @@ -265,8 +393,13 @@ export async function prepareCliBundleMcpConfig(params: { return await prepareModeSpecificBundleMcpConfig({ mode, backend: params.backend, - mergedConfig: resolvedBearerConfig.config, + mergedConfig: applyMcpServerOverrides( + resolvedBearerConfig.config, + params.toolOverrides?.mcpServers, + ), env: resolvedBearerConfig.env, + mcpToolsDeny: params.toolOverrides?.mcpToolsDeny, + webSearchEnabled: params.toolOverrides?.webSearch, }); } diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 48387b16a7aa..1ec88067e2ac 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -981,6 +981,7 @@ export async function prepareCliRunContext( backend: backendResolved.config, workspaceDir, config: params.config, + toolOverrides: params.toolOverrides, agentDir, // Restricted runs serve only the loopback server; merging user/plugin // MCP servers would let the run reach tools outside its allowlist. diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index e4ef1446c95f..48fbcf438bab 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -10,7 +10,11 @@ import type { ReplyOperation } from "../../auto-reply/reply/reply-run-registry.j import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { FastMode } from "../../auto-reply/thinking.shared.js"; import type { InboundEventKind } from "../../channels/inbound-event/kind.js"; -import type { CliSessionBinding, SessionEntry } from "../../config/sessions.js"; +import type { + CliSessionBinding, + SessionEntry, + SessionToolOverrides, +} from "../../config/sessions.js"; import type { SessionTranscriptRuntimeTarget } from "../../config/sessions/session-accessor.types.js"; import type { SessionSystemPromptReport } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -70,6 +74,7 @@ export type RunCliAgentParams = { /** Start a fresh CLI process so per-turn MCP authority is reloaded from this run. */ disableCliLiveSession?: boolean; config?: OpenClawConfig; + toolOverrides?: SessionToolOverrides; prompt: string; transcriptPrompt?: string; /** Undecorated current-turn prompt used to merge inline and offloaded images. */ diff --git a/src/agents/codex-native-web-search-core.ts b/src/agents/codex-native-web-search-core.ts index f34b5fd7ddaa..773113f3c3d1 100644 --- a/src/agents/codex-native-web-search-core.ts +++ b/src/agents/codex-native-web-search-core.ts @@ -101,6 +101,7 @@ export function hasAvailableCodexAuth(params: { /** Resolves whether native search is active or why managed search should remain. */ export function resolveCodexNativeSearchActivation(params: { + webSearchEnabled?: boolean; config?: OpenClawConfig; modelProvider?: string; modelApi?: string; @@ -120,7 +121,8 @@ export function resolveCodexNativeSearchActivation(params: { senderE164?: string | null; agentDir?: string; }): CodexNativeSearchActivation { - const globalWebSearchEnabled = params.config?.tools?.web?.search?.enabled !== false; + const globalWebSearchEnabled = + params.webSearchEnabled !== false && params.config?.tools?.web?.search?.enabled !== false; const codexConfig = resolveCodexNativeWebSearchConfig(params.config); const nativeEligible = isCodexNativeSearchEligibleModel(params); const hasRequiredAuth = @@ -255,6 +257,7 @@ export function patchCodexNativeWebSearchPayload(params: { /** Returns whether the managed OpenClaw web-search tool should be hidden. */ export function shouldSuppressManagedWebSearchTool(params: { + webSearchEnabled?: boolean; config?: OpenClawConfig; modelProvider?: string; modelApi?: string; diff --git a/src/agents/codex-native-web-search.test.ts b/src/agents/codex-native-web-search.test.ts index bd72a28d645d..d8c8a65857b3 100644 --- a/src/agents/codex-native-web-search.test.ts +++ b/src/agents/codex-native-web-search.test.ts @@ -116,6 +116,18 @@ describe("resolveCodexNativeSearchActivation", () => { expect(result.inactiveReason).toBe("globally_disabled"); }); + it("keeps native injection disabled when the session disables web search", () => { + const result = resolveCodexNativeSearchActivation({ + config: baseConfig, + webSearchEnabled: false, + modelProvider: "gateway", + modelApi: "openai-chatgpt-responses", + }); + + expect(result.state).toBe("managed_only"); + expect(result.inactiveReason).toBe("globally_disabled"); + }); + it("keeps native search inactive when the agent denies web_search", () => { const result = resolveCodexNativeSearchActivation({ config: { diff --git a/src/agents/embedded-agent-mcp.ts b/src/agents/embedded-agent-mcp.ts index eb8b28e6ae16..5466a5f8cdda 100644 --- a/src/agents/embedded-agent-mcp.ts +++ b/src/agents/embedded-agent-mcp.ts @@ -1,3 +1,4 @@ +import type { SessionToolOverrides } from "../config/sessions/types.js"; /** * Embedded agent MCP config loader. * @@ -19,11 +20,13 @@ export function loadEmbeddedAgentMcpConfig(params: { workspaceDir: string; cfg?: OpenClawConfig; manifestRegistry?: Pick; + toolOverrides?: Pick; }): EmbeddedAgentMcpConfig { const bundleMcp = loadMergedBundleMcpConfig({ workspaceDir: params.workspaceDir, cfg: params.cfg, manifestRegistry: params.manifestRegistry, + toolOverrides: params.toolOverrides, }); return { diff --git a/src/agents/embedded-agent-runner/compact.types.ts b/src/agents/embedded-agent-runner/compact.types.ts index 8b51707394c6..15f9bd0829bb 100644 --- a/src/agents/embedded-agent-runner/compact.types.ts +++ b/src/agents/embedded-agent-runner/compact.types.ts @@ -5,6 +5,7 @@ import type { Model } from "openclaw/plugin-sdk/llm"; import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import type { ChatType } from "../../channels/chat-type.js"; +import type { SessionToolOverrides } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ContextEngine, ContextEngineRuntimeContext } from "../../context-engine/types.js"; import type { CommandQueueEnqueueFn } from "../../process/command-queue.types.js"; @@ -61,6 +62,7 @@ export type CompactEmbeddedAgentSessionParams = { cwd?: string; agentDir?: string; config?: OpenClawConfig; + toolOverrides?: SessionToolOverrides; skillsSnapshot?: SkillSnapshot; senderIsOwner?: boolean; provider?: string; diff --git a/src/agents/embedded-agent-runner/compaction-runtime-context.ts b/src/agents/embedded-agent-runner/compaction-runtime-context.ts index 6c81855bbccb..9b6a23501311 100644 --- a/src/agents/embedded-agent-runner/compaction-runtime-context.ts +++ b/src/agents/embedded-agent-runner/compaction-runtime-context.ts @@ -374,6 +374,7 @@ export function buildEmbeddedCompactionRuntimeContext( cwd: params.cwd ?? undefined, agentDir: params.agentDir, config: params.config, + toolOverrides: params.toolOverrides, skillsSnapshot: params.skillsSnapshot, senderIsOwner: params.senderIsOwner, senderId: params.senderId ?? undefined, diff --git a/src/agents/embedded-agent-runner/compaction-session-agent.ts b/src/agents/embedded-agent-runner/compaction-session-agent.ts index 51d7cc040ac1..24497b069727 100644 --- a/src/agents/embedded-agent-runner/compaction-session-agent.ts +++ b/src/agents/embedded-agent-runner/compaction-session-agent.ts @@ -42,6 +42,7 @@ export async function prepareCompactionSessionAgent(params: { senderName?: string | null; senderUsername?: string | null; senderE164?: string | null; + webSearchEnabled?: boolean; }) { const authStorage = params.authStorage && @@ -109,6 +110,7 @@ export async function prepareCompactionSessionAgent(params: { // Compaction rebuilds the stream wrapper, so preserve the session policy // inputs that can suppress provider-native search. sessionKey: params.sessionKey, + webSearchEnabled: params.webSearchEnabled, sandboxToolPolicy: params.sandboxToolPolicy, messageProvider: params.messageProvider, agentAccountId: params.agentAccountId, diff --git a/src/agents/embedded-agent-runner/compaction-session-execution.ts b/src/agents/embedded-agent-runner/compaction-session-execution.ts index 42a332165076..7096d9d791f3 100644 --- a/src/agents/embedded-agent-runner/compaction-session-execution.ts +++ b/src/agents/embedded-agent-runner/compaction-session-execution.ts @@ -280,6 +280,7 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti senderName: params.senderName, senderUsername: params.senderUsername, senderE164: params.senderE164, + webSearchEnabled: params.toolOverrides?.webSearch !== false, }); session.agent.streamFn = wrapStreamFnWithDiagnosticModelCallEvents( session.agent.streamFn, diff --git a/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts b/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts index acfa8ba1507c..444119e535a0 100644 --- a/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts +++ b/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts @@ -322,6 +322,7 @@ export async function buildPreparedCompactionRuntime(prepared: DirectCompactionP workspaceDir: effectiveWorkspace, spawnWorkspaceDir, config: params.config, + webSearchEnabled: params.toolOverrides?.webSearch !== false, abortSignal: runAbortController.signal, sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, modelProvider: effectiveModel.provider, diff --git a/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts b/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts index 9e4b0a1530ff..840b5af583fb 100644 --- a/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts +++ b/src/agents/embedded-agent-runner/run/attempt-bundle-tools.ts @@ -95,6 +95,7 @@ export async function prepareEmbeddedAttemptBundleTools(params: { requesterSenderId: params.attempt.senderId, agentAccountId: params.attempt.agentAccountId, messageChannel: params.attempt.messageChannel ?? params.attempt.messageProvider, + toolOverrides: params.attempt.toolOverrides, }) : undefined; const bundleMcpRuntime = bundleMcpSessionRuntime diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-transport.ts b/src/agents/embedded-agent-runner/run/attempt-stream-transport.ts index ab0b3ee0e82d..5139aa990cda 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-transport.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-transport.ts @@ -137,6 +137,7 @@ export async function prepareEmbeddedAttemptTransport(input: { }); } const nativeWebSearchPolicyContext = { + webSearchEnabled: attempt.toolOverrides?.webSearch !== false, sessionKey: input.sandboxSessionKey, sandboxToolPolicy: input.sandbox?.tools, messageProvider: resolveAttemptToolPolicyMessageProvider(attempt), diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts index a92476ed024d..411c2b0e0e07 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts @@ -246,6 +246,7 @@ export function prepareEmbeddedAttemptToolBase(params: { workspaceDir: params.effectiveWorkspace, spawnWorkspaceDir, config: toolSearchRuntimeConfig, + webSearchEnabled: attempt.toolOverrides?.webSearch !== false, abortSignal: params.runAbortController.signal, modelProvider: attempt.provider, modelId: attempt.modelId, diff --git a/src/agents/embedded-agent-runner/run/overflow-context-recovery.ts b/src/agents/embedded-agent-runner/run/overflow-context-recovery.ts index 1fcc2c4ca8a7..782502ba89a8 100644 --- a/src/agents/embedded-agent-runner/run/overflow-context-recovery.ts +++ b/src/agents/embedded-agent-runner/run/overflow-context-recovery.ts @@ -231,6 +231,7 @@ export async function recoverEmbeddedRunOverflow(input: { workspaceDir: input.workspaceDir, agentDir: input.agentDir, config: runParams.config, + toolOverrides: runParams.toolOverrides, skillsSnapshot: runParams.skillsSnapshot, senderId: runParams.senderId, provider: input.provider, diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index e592917877b8..421ec8feaeda 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -13,6 +13,7 @@ import type { ReplyOperation } from "../../../auto-reply/reply/reply-run-registr import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js"; import type { ChatType } from "../../../channels/chat-type.js"; import type { InboundEventKind } from "../../../channels/inbound-event/kind.js"; +import type { SessionToolOverrides } from "../../../config/sessions/types.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { ImageContent } from "../../../llm/types.js"; import type { MediaFact } from "../../../media/media-facts.js"; @@ -188,6 +189,7 @@ export type RunEmbeddedAgentParams = { * overrides are unsupported; use an explicit run param instead. */ config?: OpenClawConfig; + toolOverrides?: SessionToolOverrides; skillsSnapshot?: SkillSnapshot; prompt: string; /** User-visible prompt body to submit and persist; runtime context travels separately. */ diff --git a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts index d8d44404b037..314fdc6ce9df 100644 --- a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts +++ b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts @@ -225,6 +225,7 @@ export async function dispatchEmbeddedRunAttempt(input: { agentDir: runtime.agentDir, preparedModelRuntime: runtime.preparedModelRuntime, config: params.config, + toolOverrides: params.toolOverrides, allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, ...(runtime.contextEngine ? { diff --git a/src/agents/embedded-agent-runner/run/timeout-context-recovery.ts b/src/agents/embedded-agent-runner/run/timeout-context-recovery.ts index 41a4f6b1a805..88f42a021a0c 100644 --- a/src/agents/embedded-agent-runner/run/timeout-context-recovery.ts +++ b/src/agents/embedded-agent-runner/run/timeout-context-recovery.ts @@ -128,6 +128,7 @@ export async function recoverEmbeddedRunTimeout(input: { workspaceDir: input.workspaceDir, agentDir: input.agentDir, config: runParams.config, + toolOverrides: runParams.toolOverrides, skillsSnapshot: runParams.skillsSnapshot, senderId: runParams.senderId, provider: input.provider, diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 22c02cc0e42b..346a8eb85826 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -129,6 +129,7 @@ export function createOpenClawTools( fsPolicy?: ToolFsPolicy; sandboxed?: boolean; config?: OpenClawConfig; + webSearchEnabled?: boolean; /** Capabilities declared by the gateway client that originated this run. */ clientCaps?: string[]; pluginToolAllowlist?: string[]; @@ -362,6 +363,7 @@ export function createOpenClawTools( options?.recordToolPrepStage?.("openclaw-tools:pdf-tool"); const webSearchTool = createWebSearchTool({ config: options?.config, + enabled: options?.webSearchEnabled, agentDir: options?.agentDir, sandboxed: options?.sandboxed, runtimeWebSearch: runtimeWebTools?.search, diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index aadb89716209..b9a34abb8a21 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -16,6 +16,10 @@ import { mergeScopedSearchConfig } from "./web-search-provider-config.js"; import { createWebSearchTool } from "./web-search.js"; describe("web_search tool schema", () => { + it("omits the managed tool when the session disables web search", () => { + expect(createWebSearchTool({ enabled: false })).toBeNull(); + }); + it("marks query as required for model tool-call schemas", () => { const tool = createWebSearchTool(); const parameters = tool?.parameters as { required?: unknown } | undefined; diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 08d5976c9cf9..b9cfef2edf69 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -80,12 +80,13 @@ function isWebSearchDisabled(config?: OpenClawConfig): boolean { /** Creates the `web_search` tool, or `null` when web search is disabled by config. */ export function createWebSearchTool(options?: { config?: OpenClawConfig; + enabled?: boolean; agentDir?: string; sandboxed?: boolean; runtimeWebSearch?: RuntimeWebSearchMetadata; lateBindRuntimeConfig?: boolean; }): AnyAgentTool | null { - if (isWebSearchDisabled(options?.config)) { + if (options?.enabled === false || isWebSearchDisabled(options?.config)) { return null; } diff --git a/src/agents/web-search-tool-policy.ts b/src/agents/web-search-tool-policy.ts index f60a37e3df54..ea1a78f85231 100644 --- a/src/agents/web-search-tool-policy.ts +++ b/src/agents/web-search-tool-policy.ts @@ -9,6 +9,7 @@ import { isToolAllowedByPolicies } from "./tool-policy-match.js"; import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "./tool-policy.js"; export type WebSearchToolPolicyParams = { + webSearchEnabled?: boolean; config?: OpenClawConfig; modelProvider?: string; modelId?: string; @@ -39,6 +40,9 @@ type WebSearchToolPolicyResolution = { export function resolveWebSearchToolPolicy( params: WebSearchToolPolicyParams, ): WebSearchToolPolicyResolution { + if (params.webSearchEnabled === false) { + return { allowed: false, persistentAllowed: false }; + } const { agentId, globalPolicy, diff --git a/src/auto-reply/reply/agent-runner-cli-candidate.ts b/src/auto-reply/reply/agent-runner-cli-candidate.ts index d201993dbc55..a1824811ca84 100644 --- a/src/auto-reply/reply/agent-runner-cli-candidate.ts +++ b/src/auto-reply/reply/agent-runner-cli-candidate.ts @@ -300,6 +300,7 @@ export async function runCliFallbackCandidate(params: { workspaceDir: turn.followupRun.run.workspaceDir, cwd: turn.followupRun.run.cwd, config: params.runtimeConfig, + toolOverrides: turn.followupRun.run.toolOverrides, prompt: turn.commandBody, transcriptPrompt: turn.transcriptCommandBody, media: turn.followupRun.media, diff --git a/src/auto-reply/reply/agent-runner-run-params.ts b/src/auto-reply/reply/agent-runner-run-params.ts index f7464ece52eb..ad341dd66836 100644 --- a/src/auto-reply/reply/agent-runner-run-params.ts +++ b/src/auto-reply/reply/agent-runner-run-params.ts @@ -95,6 +95,7 @@ export function buildEmbeddedRunBaseParams(params: { cwd: params.run.cwd, agentDir: params.run.agentDir, config, + toolOverrides: params.run.toolOverrides, skillsSnapshot: params.run.skillsSnapshot, ownerNumbers: params.run.ownerNumbers, inputProvenance: params.run.inputProvenance, diff --git a/src/auto-reply/reply/get-reply-run-admission.ts b/src/auto-reply/reply/get-reply-run-admission.ts index 22584ff20ec0..517908aae9d5 100644 --- a/src/auto-reply/reply/get-reply-run-admission.ts +++ b/src/auto-reply/reply/get-reply-run-admission.ts @@ -184,6 +184,7 @@ export async function prepareReplyRunAdmission(context: PreparedReplyRunContext) cfg, execOverrides: params.execOverrides, skillFilter: opts?.skillFilter, + skillOverrides: opts?.skillOverrides, }); }); sessionEntry = skillResult.sessionEntry; diff --git a/src/auto-reply/reply/get-reply-run-execute.ts b/src/auto-reply/reply/get-reply-run-execute.ts index 23f717ab0254..1b8fd9eebe62 100644 --- a/src/auto-reply/reply/get-reply-run-execute.ts +++ b/src/auto-reply/reply/get-reply-run-execute.ts @@ -359,6 +359,7 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission) workspaceDir, cwd: normalizeOptionalString(state.sessionEntry?.spawnedCwd), config: cfg, + toolOverrides: preparedSessionState.sessionEntry?.toolOverrides, skillsSnapshot, provider, model, diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 01cbc79a146c..94e52d58944c 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -554,11 +554,14 @@ export async function getReplyFromConfig( } // Utility-model narration is turn-local decoration. Initialize the durable // session first, then keep it completely outside model-locked native runs. + const optsWithSessionSkillOverrides = sessionEntry.toolOverrides?.skills + ? { ...optsWithSkillFilter, skillOverrides: sessionEntry.toolOverrides.skills } + : optsWithSkillFilter; const resolvedOpts = attachProgressNarratorToReplyOptions({ cfg, agentId, userMessage: finalized.agentText, - opts: optsWithSkillFilter, + opts: optsWithSessionSkillOverrides, disabled: sessionModelSelectionLocked, }); const internalResolvedOpts = resolvedOpts as RuntimeInternalGetReplyOptions | undefined; diff --git a/src/auto-reply/reply/get-reply.types.ts b/src/auto-reply/reply/get-reply.types.ts index 199d9e9cc856..3520765fd846 100644 --- a/src/auto-reply/reply/get-reply.types.ts +++ b/src/auto-reply/reply/get-reply.types.ts @@ -1,3 +1,4 @@ +import type { SessionToolOverrides } from "../../config/sessions/types.js"; // Shared get-reply type contracts for command, directive, and runtime layers. import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ReplyOptionsWithHeartbeatRunScope } from "../../infra/heartbeat-run-scope.js"; @@ -28,6 +29,7 @@ type InternalReplySessionOptions = { queueModeOverride?: QueueMode; /** Dispatch-owned operation used to defer hooks until durable run admission. */ replyOperation?: ReplyOperation; + skillOverrides?: SessionToolOverrides["skills"]; }; export type InternalGetReplyOptions = GetReplyOptions & diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index 84a882585a2b..cc8060aafdbf 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -8,7 +8,7 @@ import type { ModelFallbackRouteResolution } from "../../../agents/model-fallbac import type { SilentReplyPromptMode } from "../../../agents/system-prompt.types.js"; import type { ChatType } from "../../../channels/chat-type.js"; import type { InboundEventKind } from "../../../channels/inbound-event/kind.js"; -import type { SessionEntry } from "../../../config/sessions.js"; +import type { SessionEntry, SessionToolOverrides } from "../../../config/sessions.js"; import type { ReplyToMode } from "../../../config/types.base.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { MediaFact } from "../../../media/media-facts.js"; @@ -153,6 +153,7 @@ export type FollowupRun = { /** Task working directory for runtime execution. Defaults to workspaceDir. */ cwd?: string; config: OpenClawConfig; + toolOverrides?: SessionToolOverrides; skillsSnapshot?: SkillSnapshot; provider: string; model: string; diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 6be342eb9040..07992e956a1c 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -171,6 +171,7 @@ export async function ensureSkillSnapshot(params: { execOverrides?: ExecPolicyOverrides; /** If provided, only load skills with these names (for per-channel skill filtering) */ skillFilter?: string[]; + skillOverrides?: Record; }): Promise<{ sessionEntry?: SessionEntry; skillsSnapshot?: SessionEntry["skillsSnapshot"]; @@ -197,6 +198,7 @@ export async function ensureSkillSnapshot(params: { workspaceDir, cfg, skillFilter, + skillOverrides, } = params; let nextEntry = sessionEntryHandle?.getCurrent() ?? sessionEntry; @@ -219,6 +221,7 @@ export async function ensureSkillSnapshot(params: { config: cfg, agentId: sessionAgentId, skillFilter, + skillOverrides, eligibility: { nodeSkills: nodeSkillsEligibility, remote: remoteEligibility }, existingSnapshot: snapshot, }); diff --git a/src/config/sessions/session-entry-selection.ts b/src/config/sessions/session-entry-selection.ts index 9408a6f155ce..8f2861efc6a4 100644 --- a/src/config/sessions/session-entry-selection.ts +++ b/src/config/sessions/session-entry-selection.ts @@ -31,6 +31,7 @@ export function inheritSessionSelection( : {}), ...(parentEntry.thinkingLevel ? { thinkingLevel: parentEntry.thinkingLevel } : {}), ...(parentEntry.fastMode !== undefined ? { fastMode: parentEntry.fastMode } : {}), + ...(parentEntry.toolOverrides ? { toolOverrides: parentEntry.toolOverrides } : {}), ...(parentEntry.verboseLevel ? { verboseLevel: parentEntry.verboseLevel } : {}), ...(parentEntry.traceLevel ? { traceLevel: parentEntry.traceLevel } : {}), ...(parentEntry.reasoningLevel ? { reasoningLevel: parentEntry.reasoningLevel } : {}), diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index c0306afc2738..7cda220d67d6 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -28,6 +28,13 @@ export type SessionScope = "per-sender" | "global"; export type SessionChatType = ChatType; type SessionVisibility = "shared" | "read-only" | "suggest" | "draft"; +export type SessionToolOverrides = { + mcpServers?: Record; + mcpToolsDeny?: Record; + skills?: Record; + webSearch?: boolean; +}; + export type SessionOrigin = { label?: string; provider?: string; @@ -445,6 +452,7 @@ type SessionEntryCore = SessionRestartRecoveryState & }; }; fastMode?: FastMode; + toolOverrides?: SessionToolOverrides; /** Swarm group for collector-mode child sessions. */ swarmGroupId?: string; /** Marks non-interactive collector-mode child sessions. */ diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 0618cbd42186..a361b83dd729 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -555,6 +555,7 @@ export function createAgentEventHandler({ childSessions: row?.childSessions, thinkingLevel: row?.thinkingLevel, fastMode: row?.fastMode, + toolOverrides: row?.toolOverrides, verboseLevel: row?.verboseLevel, traceLevel: row?.traceLevel, reasoningLevel: row?.reasoningLevel, diff --git a/src/gateway/server-methods/chat-history-handler.ts b/src/gateway/server-methods/chat-history-handler.ts index 9234f58fbd8e..70821c94a5e5 100644 --- a/src/gateway/server-methods/chat-history-handler.ts +++ b/src/gateway/server-methods/chat-history-handler.ts @@ -622,6 +622,7 @@ async function handleChatHistoryRequest({ sessionInfo, thinkingLevel, fastMode: entry?.fastMode, + toolOverrides: entry?.toolOverrides, verboseLevel, ...(boundedInFlightRun ? { inFlightRun: boundedInFlightRun } : {}), ...(includeAgentsList && startupAgentsList ? { agentsList: startupAgentsList } : {}), diff --git a/src/gateway/session-event-payload.ts b/src/gateway/session-event-payload.ts index 6ccd7d912887..c157a84ac7df 100644 --- a/src/gateway/session-event-payload.ts +++ b/src/gateway/session-event-payload.ts @@ -71,6 +71,7 @@ export function buildGatewaySessionEventFields(params: { // Explicit null lets subscribed clients clear an override during merge-reconcile. thinkingLevel: sessionRow.thinkingLevel ?? null, fastMode: sessionRow.fastMode, + toolOverrides: sessionRow.toolOverrides ?? null, verboseLevel: sessionRow.verboseLevel, reasoningLevel: sessionRow.reasoningLevel, elevatedLevel: sessionRow.elevatedLevel, diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 1cc5a708340f..f6d62b40ec7a 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -1376,6 +1376,7 @@ export async function performGatewaySessionReset(params: { abortedLastRun: false, thinkingLevel: currentEntry?.thinkingLevel, fastMode: currentEntry?.fastMode, + toolOverrides: currentEntry?.toolOverrides, verboseLevel: currentEntry?.verboseLevel, traceLevel: currentEntry?.traceLevel, reasoningLevel: currentEntry?.reasoningLevel, diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 35e3beddb389..4cf1dbbe0407 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -457,6 +457,7 @@ export function buildGatewaySessionRow(params: { thinkingOptions: thinkingProjection.thinkingOptions, thinkingDefault: thinkingProjection.thinkingDefault, fastMode: entry?.fastMode, + toolOverrides: entry?.toolOverrides, effectiveFastMode: fastModeState.mode, effectiveFastModeSource: fastModeState.source, fastAutoOnSeconds: fastModeState.fastAutoOnSeconds, diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index f42aab977150..b6f9e61c50bb 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -231,6 +231,22 @@ describe("gateway session utils", () => { ); }); + test("projects stored session tool overrides to list and live payloads", () => { + const toolOverrides = { mcpServers: { docs: false }, webSearch: false }; + const row = buildGatewaySessionRow({ + cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }), + storePath: "", + store: {}, + key: "agent:main:main", + entry: { sessionId: "session-tools", updatedAt: 1, toolOverrides }, + }); + + expect(row.toolOverrides).toEqual(toolOverrides); + expect(buildGatewaySessionEventFields({ sessionRow: row }).toolOverrides).toEqual( + toolOverrides, + ); + }); + test("emits a tombstone when a session has no current control owner", () => { const row = buildGatewaySessionRow({ cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }), diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 518bf299e629..5b0efa0d7cc8 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -121,6 +121,7 @@ export type GatewaySessionRow = { thinkingOptions?: string[]; thinkingDefault?: string; fastMode?: FastMode; + toolOverrides?: SessionEntry["toolOverrides"]; effectiveFastMode?: FastMode; effectiveFastModeSource?: FastModeSource; fastAutoOnSeconds?: number; diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index f45e4b09be35..3ed958b6254c 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -660,6 +660,56 @@ describe("gateway sessions patch", () => { expect(entry.fastMode).toBe("auto"); }); + test("sets, replaces, clears, and normalizes tool overrides", async () => { + const store = mainStoreEntry({}); + const set = expectPatchOk( + await runPatch({ + store, + patch: { + key: MAIN_SESSION_KEY, + toolOverrides: { + mcpServers: { zeta: false, alpha: true }, + mcpToolsDeny: { zeta: [], alpha: ["write", "read", "write"] }, + skills: {}, + webSearch: true, + }, + }, + }), + ); + expect(set.toolOverrides).toEqual({ + mcpServers: { alpha: true, zeta: false }, + mcpToolsDeny: { alpha: ["read", "write"] }, + }); + + const replaced = expectPatchOk( + await runPatch({ + store, + patch: { key: MAIN_SESSION_KEY, toolOverrides: { skills: { release: false } } }, + }), + ); + expect(replaced.toolOverrides).toEqual({ skills: { release: false } }); + + const normalizedEmpty = expectPatchOk( + await runPatch({ + store, + patch: { key: MAIN_SESSION_KEY, toolOverrides: { mcpToolsDeny: { docs: [] } } }, + }), + ); + expect(normalizedEmpty.toolOverrides).toBeUndefined(); + + store[MAIN_SESSION_KEY] = { + ...normalizedEmpty, + toolOverrides: { webSearch: false }, + }; + const cleared = expectPatchOk( + await runPatch({ + store, + patch: { key: MAIN_SESSION_KEY, toolOverrides: null }, + }), + ); + expect(cleared.toolOverrides).toBeUndefined(); + }); + test("persists verboseLevel=full", async () => { const entry = expectPatchOk( await runPatch({ diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 4ad23131e6cd..f5a077bb0e21 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -33,7 +33,7 @@ import { normalizeUsageDisplay, resolveSupportedThinkingLevel, } from "../auto-reply/thinking.js"; -import type { SessionEntry } from "../config/sessions.js"; +import type { SessionEntry, SessionToolOverrides } from "../config/sessions.js"; import { projectCanonicalSessionEntryShape } from "../config/sessions/store-entry-shape.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeExecTarget } from "../infra/exec-approvals.js"; @@ -126,6 +126,38 @@ function normalizeExecAsk(raw: string): "off" | "on-miss" | "always" | undefined return undefined; } +function normalizeSessionToolOverrides( + raw: SessionToolOverrides, +): SessionToolOverrides | undefined { + const normalizeBooleanMap = (value: Record | undefined) => { + const entries = Object.entries(value ?? {}).toSorted(([left], [right]) => + left.localeCompare(right), + ); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; + }; + const mcpToolsDeny = Object.fromEntries( + Object.entries(raw.mcpToolsDeny ?? {}) + .map( + ([serverName, toolNames]) => + [ + serverName, + [...new Set(toolNames)].toSorted((left, right) => left.localeCompare(right)), + ] as const, + ) + .filter(([, toolNames]) => toolNames.length > 0) + .toSorted(([left], [right]) => left.localeCompare(right)), + ); + const mcpServers = normalizeBooleanMap(raw.mcpServers); + const skills = normalizeBooleanMap(raw.skills); + const normalized: SessionToolOverrides = { + ...(mcpServers ? { mcpServers } : {}), + ...(Object.keys(mcpToolsDeny).length > 0 ? { mcpToolsDeny } : {}), + ...(skills ? { skills } : {}), + ...(raw.webSearch === false ? { webSearch: false } : {}), + }; + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + type SessionPatchProjectionEntry = { entry: SessionEntry; sessionKey: string; @@ -396,6 +428,21 @@ export async function projectSessionsPatchEntry(params: { } } + if ("toolOverrides" in patch) { + const raw = patch.toolOverrides; + if (raw === null) { + delete next.toolOverrides; + } else if (raw !== undefined) { + // Session patches replace this sparse overlay atomically; they never deep-merge old policy. + const normalized = normalizeSessionToolOverrides(raw); + if (normalized) { + next.toolOverrides = normalized; + } else { + delete next.toolOverrides; + } + } + } + if ("verboseLevel" in patch) { const raw = patch.verboseLevel; const parsed = parseVerboseOverride(raw); diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 936919d271fc..ee00f3755b14 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -74,6 +74,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "thinkingLevel", "cronRunContinuation", "fastMode", + "toolOverrides", "verboseLevel", "traceLevel", "reasoningLevel", diff --git a/src/skills/discovery/agent-filter.ts b/src/skills/discovery/agent-filter.ts index 757f9469f540..225806eda375 100644 --- a/src/skills/discovery/agent-filter.ts +++ b/src/skills/discovery/agent-filter.ts @@ -39,3 +39,15 @@ export function resolveEffectiveAgentSkillsLimits( const { maxSkillsPromptChars } = agentEntry.skillsLimits ?? {}; return typeof maxSkillsPromptChars === "number" ? { maxSkillsPromptChars } : undefined; } + +/** Applies a session's sparse skill overlay after agent/default allowlist resolution. */ +export function isSessionSkillEnabled( + skillName: string, + baseFilter: readonly string[] | undefined, + overrides: Readonly> | undefined, +): boolean { + const override = + overrides && Object.hasOwn(overrides, skillName) ? overrides[skillName] : undefined; + const baseAllows = baseFilter === undefined || baseFilter.includes(skillName); + return override === true || (baseAllows && override !== false); +} diff --git a/src/skills/discovery/filter.test.ts b/src/skills/discovery/filter.test.ts index cd66c627c77c..b95fb63341eb 100644 --- a/src/skills/discovery/filter.test.ts +++ b/src/skills/discovery/filter.test.ts @@ -1,7 +1,37 @@ // Skill filter tests cover allowlist and agent-scoped skill selection behavior. import { describe, expect, it } from "vitest"; +import { isSessionSkillEnabled } from "./agent-filter.js"; import { matchesSkillFilter, normalizeSkillFilter } from "./filter.js"; +const sessionSkillCases: Array<{ + name: string; + skill: string; + base: string[]; + overrides?: Record; + expected: boolean; +}> = [ + { + name: "enables a skill outside the agent allowlist", + skill: "release", + base: ["github"], + overrides: { release: true }, + expected: true, + }, + { + name: "disables a skill inside the agent allowlist", + skill: "github", + base: ["github"], + overrides: { github: false }, + expected: false, + }, + { + name: "inherits the resolved agent filter when absent", + skill: "github", + base: ["github"], + expected: true, + }, +]; + describe("skills/filter", () => { it("normalizes configured filters with trimming", () => { expect(normalizeSkillFilter([" weather ", "", "meme-factory"])).toEqual([ @@ -22,4 +52,8 @@ describe("skills/filter", () => { expect(matchesSkillFilter(undefined, undefined)).toBe(true); expect(matchesSkillFilter([], undefined)).toBe(false); }); + + it.each(sessionSkillCases)("$name", ({ skill, base, overrides, expected }) => { + expect(isSessionSkillEnabled(skill, base, overrides)).toBe(expected); + }); }); diff --git a/src/skills/loading/workspace.ts b/src/skills/loading/workspace.ts index 635ad5fbe967..b9691a49758f 100644 --- a/src/skills/loading/workspace.ts +++ b/src/skills/loading/workspace.ts @@ -18,6 +18,7 @@ import { isPathInside } from "../../infra/path-guards.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { CONFIG_DIR, resolveConfigDir, resolveUserPath } from "../../utils.js"; import { + isSessionSkillEnabled, resolveEffectiveAgentSkillFilter, resolveEffectiveAgentSkillsLimits, } from "../discovery/agent-filter.js"; @@ -201,23 +202,21 @@ function filterSkillEntries( entries: SkillEntry[], config?: OpenClawConfig, skillFilter?: string[], + skillOverrides?: Readonly>, eligibility?: SkillEligibilityContext, ): SkillEntry[] { const bundledAllowlist = resolveBundledAllowlist(config); let filtered = entries.filter((entry) => shouldIncludeSkill({ entry, config, bundledAllowlist, eligibility }), ); - // If skillFilter is provided, only include skills in the filter list. - if (skillFilter !== undefined) { + if (skillFilter !== undefined || skillOverrides !== undefined) { const normalized = normalizeSkillFilter(skillFilter) ?? []; const label = normalized.length > 0 ? normalized.join(", ") : "(none)"; skillsLogger.debug(`Applying skill filter: ${label}`); - if (normalized.length > 0) { - const allowed = new Set(normalized); - filtered = filtered.filter((entry) => allowed.has(entry.skill.name)); - } else { - filtered = []; - } + const resolvedFilter = skillFilter === undefined ? undefined : normalized; + filtered = filtered.filter((entry) => + isSessionSkillEnabled(entry.skill.name, resolvedFilter, skillOverrides), + ); skillsLogger.debug( `After skill filter: ${filtered.map((entry) => entry.skill.name).join(", ") || "(none)"}`, ); @@ -1520,6 +1519,7 @@ export function buildWorkspaceSkillSnapshot( requiredEnv: entry.metadata?.requires?.env?.slice(), })), ...(skillFilter === undefined ? {} : { skillFilter }), + ...(opts?.skillOverrides ? { skillOverrides: opts.skillOverrides } : {}), ...(opts?.eligibility?.nodeSkills ? { nodeSkillsEligibility: opts.eligibility.nodeSkills } : {}), @@ -1548,6 +1548,7 @@ type WorkspaceSkillBuildOptions = { agentId?: string; /** If provided, only include skills with these names */ skillFilter?: string[]; + skillOverrides?: Record; eligibility?: SkillEligibilityContext; }; @@ -1572,9 +1573,6 @@ function resolveWorkspaceSkillPromptState( resolvedSkills: Skill[]; } { const effectiveSkillFilter = resolveEffectiveWorkspaceSkillFilter(opts); - if (effectiveSkillFilter !== undefined && effectiveSkillFilter.length === 0) { - return { eligible: [], prompt: "", resolvedSkills: [] }; - } const skillEntries = opts?.entries ? filterArchivedSkillEntries(opts.entries) : mergeRemoteNodeSkillEntries(loadSkillEntries(workspaceDir, opts), { @@ -1585,6 +1583,7 @@ function resolveWorkspaceSkillPromptState( skillEntries, opts?.config, effectiveSkillFilter, + opts?.skillOverrides, opts?.eligibility, ); const promptEntries = filterPromptVisibleSkillEntries(eligible); @@ -1708,6 +1707,7 @@ export function loadWorkspaceSkillEntries( bundledSkillsDir?: string; pluginSkillsDir?: string; skillFilter?: string[]; + skillOverrides?: Record; agentId?: string; eligibility?: SkillEligibilityContext; workspaceOnly?: boolean; @@ -1719,10 +1719,20 @@ export function loadWorkspaceSkillEntries( node: opts?.eligibility?.nodeSkills?.node, }); const effectiveSkillFilter = resolveEffectiveWorkspaceSkillFilter(opts); - if (effectiveSkillFilter === undefined && opts?.eligibility === undefined) { + if ( + effectiveSkillFilter === undefined && + opts?.skillOverrides === undefined && + opts?.eligibility === undefined + ) { return entries; } - return filterSkillEntries(entries, opts?.config, effectiveSkillFilter, opts?.eligibility); + return filterSkillEntries( + entries, + opts?.config, + effectiveSkillFilter, + opts?.skillOverrides, + opts?.eligibility, + ); } export function loadVisibleWorkspaceSkillEntries( @@ -1732,6 +1742,7 @@ export function loadVisibleWorkspaceSkillEntries( managedSkillsDir?: string; bundledSkillsDir?: string; skillFilter?: string[]; + skillOverrides?: Record; agentId?: string; eligibility?: SkillEligibilityContext; }, @@ -1741,7 +1752,13 @@ export function loadVisibleWorkspaceSkillEntries( node: opts?.eligibility?.nodeSkills?.node, }); const effectiveSkillFilter = resolveEffectiveWorkspaceSkillFilter(opts); - return filterSkillEntries(entries, opts?.config, effectiveSkillFilter, opts?.eligibility); + return filterSkillEntries( + entries, + opts?.config, + effectiveSkillFilter, + opts?.skillOverrides, + opts?.eligibility, + ); } function resolveUniqueSyncedSkillDirName(base: string, used: Set): string { @@ -1891,9 +1908,16 @@ export function filterWorkspaceSkillEntriesWithOptions( opts?: { config?: OpenClawConfig; skillFilter?: string[]; + skillOverrides?: Record; eligibility?: SkillEligibilityContext; }, ): SkillEntry[] { - return filterSkillEntries(entries, opts?.config, opts?.skillFilter, opts?.eligibility); + return filterSkillEntries( + entries, + opts?.config, + opts?.skillFilter, + opts?.skillOverrides, + opts?.eligibility, + ); } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/skills/runtime/session-snapshot.ts b/src/skills/runtime/session-snapshot.ts index ae3a9b8c3119..1429fc1b6028 100644 --- a/src/skills/runtime/session-snapshot.ts +++ b/src/skills/runtime/session-snapshot.ts @@ -22,6 +22,7 @@ type ReusableSkillSnapshotParams = { config: OpenClawConfig; agentId?: string; skillFilter?: string[]; + skillOverrides?: Record; eligibility?: SkillEligibilityContext; existingSnapshot?: SkillSnapshot; snapshotVersion?: number; @@ -69,16 +70,21 @@ export function resolveReusableWorkspaceSkillSnapshot( const nodeSkillsEligibilityChanged = stableStringify(params.existingSnapshot?.nodeSkillsEligibility) !== stableStringify(params.eligibility?.nodeSkills); + const skillOverridesChanged = + stableStringify(params.existingSnapshot?.skillOverrides) !== + stableStringify(params.skillOverrides); const shouldRefresh = promptFormatChanged || skillVersionChanged || nodeSkillsEligibilityChanged || - !matchesSkillFilter(params.existingSnapshot?.skillFilter, params.skillFilter); + !matchesSkillFilter(params.existingSnapshot?.skillFilter, params.skillFilter) || + skillOverridesChanged; const buildSnapshot = () => { return buildWorkspaceSkillSnapshot(params.workspaceDir, { config: params.config, agentId: params.agentId, skillFilter: params.skillFilter, + skillOverrides: params.skillOverrides, eligibility: params.eligibility, snapshotVersion, }); @@ -89,6 +95,7 @@ export function resolveReusableWorkspaceSkillSnapshot( params.workspaceDir, snapshotVersion, params.skillFilter, + params.skillOverrides, params.agentId, params.eligibility, fingerprintSkillSnapshotConfig(params.config), diff --git a/src/skills/types.ts b/src/skills/types.ts index faf6f7caf38f..fd8b67563d61 100644 --- a/src/skills/types.ts +++ b/src/skills/types.ts @@ -128,6 +128,8 @@ export type SkillSnapshot = { }>; /** Normalized agent-level filter used to build this snapshot; undefined means unrestricted. */ skillFilter?: string[]; + /** Sparse per-session overlay applied after the agent-level filter. */ + skillOverrides?: Record; /** Effective node-exec eligibility used to select connected node-hosted skills. */ nodeSkillsEligibility?: SkillEligibilityContext["nodeSkills"]; resolvedSkills?: Skill[];