feat(agents): add per-session tool overrides (#115785)

* feat(agents): per-session tool overrides (mcp/skills/web-search)

* test(agents): use tracked MCP temp dirs
This commit is contained in:
Peter Steinberger
2026-07-29 05:13:56 -04:00
committed by GitHub
parent 05516e7a5f
commit a37a5a6575
72 changed files with 931 additions and 61 deletions
@@ -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"
@@ -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");
+2
View File
@@ -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";
@@ -176,6 +176,7 @@ export {
SessionsCompactionRestoreParamsSchema,
SessionBranchSchema,
SessionRowSchema,
SessionToolOverridesSchema,
SessionsBranchesListParamsSchema,
SessionsBranchesListResultSchema,
SessionsBranchesSwitchParamsSchema,
@@ -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<typeof SessionCreatedActorSchema>;
export type SessionToolOverrides = Static<typeof SessionToolOverridesSchema>;
export type SessionRow = Static<typeof SessionRowSchema>;
@@ -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()])),
@@ -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<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
}): Promise<SessionMcpRuntime> {
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<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
}): Promise<SessionMcpRuntime | undefined> {
return await getSessionMcpRuntimeManager().getOrCreateRequesterScoped(params);
}
@@ -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<string>;
requesterScope?: SessionMcpRequesterScope;
configFingerprint?: string;
toolOverrides?: Pick<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
};
type SessionMcpRuntimeManagerInstall = {
@@ -49,6 +51,7 @@ type SessionMcpRuntimeManagerInstall = {
agentAccountId?: string | null;
messageChannel?: string | null;
requesterScope: SessionMcpRequesterScope;
toolOverrides?: Pick<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
}) => Promise<SessionMcpRuntime | undefined>;
};
@@ -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<string, McpServerConnectionResolved>;
redactConnectionServerNames: ReadonlySet<string>;
requesterScope: SessionMcpRequesterScope;
toolOverrides?: Pick<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
}): Promise<SessionMcpRuntime> => {
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<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
}): Promise<SessionMcpRuntime | undefined> => {
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,
});
};
+10
View File
@@ -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) {
@@ -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 (
@@ -11,11 +11,17 @@ const mocks = vi.hoisted(() => ({
vi.mock("./embedded-agent-mcp.js", () => ({
loadEmbeddedAgentMcpConfig: (params: {
cfg?: { mcp?: { servers?: Record<string, unknown> } };
toolOverrides?: { mcpServers?: Record<string, boolean> };
}) => {
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: {
@@ -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<PluginManifestRegistry, "plugins">;
toolOverrides?: Pick<SessionToolOverrides, "mcpServers">;
}): 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<PluginManifestRegistry, "plugins">;
toolOverrides?: Pick<SessionToolOverrides, "mcpServers">;
}): SessionMcpConfigDiscoveryCacheEntry {
const state = getSessionMcpConfigDiscoveryCacheState();
const key = buildSessionMcpConfigDiscoveryCacheKey(params);
@@ -138,12 +149,29 @@ function sortedSetEntries(values?: ReadonlySet<string>): string[] | undefined {
return values ? [...values].toSorted((a, b) => a.localeCompare(b)) : undefined;
}
function digestMcpToolDenials(
value?: Record<string, string[]>,
): Record<string, string[]> | 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<string>;
excludeServerNames?: ReadonlySet<string>;
redactConnectionServerNames?: ReadonlySet<string>;
safeServerNames?: Record<string, string>;
mcpAppsEnabled: boolean;
mcpToolsDeny?: Record<string, string[]>;
}): 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<string, string>;
mcpToolsDeny?: Record<string, string[]>;
}): 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<string>;
/** Full-set safe-name assignments; folded into fingerprint for all partitions. */
safeServerNamesByServer?: ReadonlyMap<string, string>;
toolOverrides?: Pick<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
}): {
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);
@@ -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<string>;
requesterScope?: SessionMcpRequesterScope;
configFingerprint?: string;
toolOverrides?: Pick<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
}) => SessionMcpRuntime;
export function resolveSessionMcpRuntimeIdleTtlMs(): number {
@@ -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");
+13 -2
View File
@@ -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<string>;
requesterScope?: SessionMcpRequesterScope;
configFingerprint?: string;
toolOverrides?: Pick<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
}): 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) {
+4
View File
@@ -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<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
}) => Promise<SessionMcpRuntime>;
/**
* 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<SessionToolOverrides, "mcpServers" | "mcpToolsDeny">;
}) => Promise<SessionMcpRuntime | undefined>;
/**
* Session-stable advertised catalog for scoped servers. Used by shared-thread
@@ -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({
+2
View File
@@ -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,
+35
View File
@@ -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);
});
});
+12 -3
View File
@@ -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<PluginManifestRegistry, "plugins">;
mapConfiguredServer?: BundleMcpServerMapper;
toolOverrides?: Pick<SessionToolOverrides, "mcpServers">;
}): 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);
+38 -1
View File
@@ -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<string, string[]>,
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. */
+64 -20
View File
@@ -21,6 +21,46 @@ async function readJsonObject(filePath: string): Promise<Record<string, unknown>
: {};
}
async function readGeminiBaseSettings(
inheritedEnv: Record<string, string> | undefined,
): Promise<Record<string, unknown>> {
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<string, unknown>): Record<string, unknown> {
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<string, unknown>;
}
async function writeGeminiSettings(
settings: Record<string, unknown>,
inheritedEnv: Record<string, string> | undefined,
): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> }> {
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<string, string> | undefined,
) {
return await writeGeminiSettings(
mergeGeminiWebSearchDisabled(await readGeminiBaseSettings(inheritedEnv)),
inheritedEnv,
);
}
function resolveEnvPlaceholder(
value: string,
inheritedEnv: Record<string, string> | undefined,
@@ -38,6 +78,7 @@ function resolveEnvPlaceholder(
function normalizeGeminiServerConfig(
server: BundleMcpServerConfig,
inheritedEnv: Record<string, string> | undefined,
deniedTools: readonly string[] | undefined,
): Record<string, unknown> {
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<string, string> | undefined,
mcpToolsDeny?: Record<string, string[]>,
webSearchEnabled?: boolean,
): Promise<{ env: Record<string, string>; cleanup: () => Promise<void> }> {
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<string, unknown>;
) as Record<string, unknown>;
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. */
@@ -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,
@@ -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<string, { url?: string; headers?: Record<string, string> }>;
tools?: { exclude?: string[] };
mcpServers?: Record<
string,
{ url?: string; headers?: Record<string, string>; 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?.();
});
+54
View File
@@ -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<string, unknown>;
};
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-",
+143 -10
View File
@@ -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<string, string[]>,
): Record<string, string[]> | 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<string, string[]> | 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<string, boolean> | 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<string, string>): unknown {
if (!env) {
return value;
@@ -132,20 +201,37 @@ async function prepareModeSpecificBundleMcpConfig(params: {
backend: CliBackendConfig;
mergedConfig: BundleMcpConfig;
env?: Record<string, string>;
mcpToolsDeny?: Record<string, string[]>;
webSearchEnabled?: boolean;
}): Promise<PreparedCliBundleMcpConfig> {
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<string, string>;
}): Promise<PreparedCliBundleMcpConfig> {
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<PreparedCliBundleMcpConfig> {
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,
});
}
+1
View File
@@ -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.
+6 -1
View File
@@ -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. */
+4 -1
View File
@@ -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;
@@ -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: {
+3
View File
@@ -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<PluginManifestRegistry, "plugins">;
toolOverrides?: Pick<SessionToolOverrides, "mcpServers">;
}): EmbeddedAgentMcpConfig {
const bundleMcp = loadMergedBundleMcpConfig({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
manifestRegistry: params.manifestRegistry,
toolOverrides: params.toolOverrides,
});
return {
@@ -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;
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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
@@ -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),
@@ -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,
@@ -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,
@@ -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. */
@@ -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
? {
@@ -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,
+2
View File
@@ -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,
+4
View File
@@ -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;
+2 -1
View File
@@ -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;
}
+4
View File
@@ -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,
@@ -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,
@@ -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,
@@ -184,6 +184,7 @@ export async function prepareReplyRunAdmission(context: PreparedReplyRunContext)
cfg,
execOverrides: params.execOverrides,
skillFilter: opts?.skillFilter,
skillOverrides: opts?.skillOverrides,
});
});
sessionEntry = skillResult.sessionEntry;
@@ -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,
+4 -1
View File
@@ -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;
+2
View File
@@ -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 &
+2 -1
View File
@@ -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;
+3
View File
@@ -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<string, boolean>;
}): 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,
});
@@ -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 } : {}),
+8
View File
@@ -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<string, boolean>;
mcpToolsDeny?: Record<string, string[]>;
skills?: Record<string, boolean>;
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. */
+1
View File
@@ -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,
@@ -622,6 +622,7 @@ async function handleChatHistoryRequest({
sessionInfo,
thinkingLevel,
fastMode: entry?.fastMode,
toolOverrides: entry?.toolOverrides,
verboseLevel,
...(boundedInFlightRun ? { inFlightRun: boundedInFlightRun } : {}),
...(includeAgentsList && startupAgentsList ? { agentsList: startupAgentsList } : {}),
+1
View File
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -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,
+16
View File
@@ -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" }),
+1
View File
@@ -121,6 +121,7 @@ export type GatewaySessionRow = {
thinkingOptions?: string[];
thinkingDefault?: string;
fastMode?: FastMode;
toolOverrides?: SessionEntry["toolOverrides"];
effectiveFastMode?: FastMode;
effectiveFastModeSource?: FastModeSource;
fastAutoOnSeconds?: number;
+50
View File
@@ -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({
+48 -1
View File
@@ -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<string, boolean> | 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);
+1
View File
@@ -74,6 +74,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"thinkingLevel",
"cronRunContinuation",
"fastMode",
"toolOverrides",
"verboseLevel",
"traceLevel",
"reasoningLevel",
+12
View File
@@ -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<Record<string, boolean>> | 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);
}
+34
View File
@@ -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<string, boolean>;
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);
});
});
+39 -15
View File
@@ -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<Record<string, boolean>>,
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<string, boolean>;
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<string, boolean>;
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<string, boolean>;
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>): string {
@@ -1891,9 +1908,16 @@ export function filterWorkspaceSkillEntriesWithOptions(
opts?: {
config?: OpenClawConfig;
skillFilter?: string[];
skillOverrides?: Record<string, boolean>;
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. */
+8 -1
View File
@@ -22,6 +22,7 @@ type ReusableSkillSnapshotParams = {
config: OpenClawConfig;
agentId?: string;
skillFilter?: string[];
skillOverrides?: Record<string, boolean>;
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),
+2
View File
@@ -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<string, boolean>;
/** Effective node-exec eligibility used to select connected node-hosted skills. */
nodeSkillsEligibility?: SkillEligibilityContext["nodeSkills"];
resolvedSkills?: Skill[];