mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
05516e7a5f
commit
a37a5a6575
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
|
||||
@@ -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-",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 &
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -121,6 +121,7 @@ export type GatewaySessionRow = {
|
||||
thinkingOptions?: string[];
|
||||
thinkingDefault?: string;
|
||||
fastMode?: FastMode;
|
||||
toolOverrides?: SessionEntry["toolOverrides"];
|
||||
effectiveFastMode?: FastMode;
|
||||
effectiveFastModeSource?: FastModeSource;
|
||||
fastAutoOnSeconds?: number;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -74,6 +74,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
|
||||
"thinkingLevel",
|
||||
"cronRunContinuation",
|
||||
"fastMode",
|
||||
"toolOverrides",
|
||||
"verboseLevel",
|
||||
"traceLevel",
|
||||
"reasoningLevel",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user