mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(workboard): grant dispatch workers required tools
This commit is contained in:
@@ -579,6 +579,7 @@ Plugins can also launch background subagent runs through `api.runtime.subagent`:
|
||||
const result = await api.runtime.subagent.run({
|
||||
sessionKey: "agent:main:subagent:search-helper",
|
||||
message: "Expand this query into focused follow-up searches.",
|
||||
toolsAlsoAllow: ["my_plugin_progress"],
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
deliver: false,
|
||||
@@ -588,6 +589,7 @@ const result = await api.runtime.subagent.run({
|
||||
Notes:
|
||||
|
||||
- `provider` and `model` are optional per-run overrides, not persistent session changes.
|
||||
- `toolsAlsoAllow` accepts exact, uniquely owned tool names registered by the calling plugin. Core and ambiguous names are rejected. It is additive to the normal profile, but operator allowlists and denies remain authoritative.
|
||||
- OpenClaw only honors those override fields for trusted callers.
|
||||
- For plugin-owned fallback runs, operators must opt in with `plugins.entries.<id>.subagent.allowModelOverride: true`.
|
||||
- Use `plugins.entries.<id>.subagent.allowedModels` to restrict trusted plugins to specific canonical `provider/model` targets, or `"*"` to allow any target explicitly.
|
||||
|
||||
@@ -299,6 +299,7 @@ two-party event loops that do not go through the shared inbound reply runner.
|
||||
const { runId } = await api.runtime.subagent.run({
|
||||
sessionKey: "agent:main:subagent:search-helper",
|
||||
message: "Expand this query into focused follow-up searches.",
|
||||
toolsAlsoAllow: ["my_plugin_progress"],
|
||||
provider: "openai", // optional override
|
||||
model: "gpt-5.6-sol", // optional override
|
||||
deliver: false,
|
||||
@@ -323,6 +324,8 @@ two-party event loops that do not go through the shared inbound reply runner.
|
||||
Model overrides (`provider`/`model`) require operator opt-in via `plugins.entries.<id>.subagent.allowModelOverride: true` in config. Untrusted plugins can still run subagents, but override requests are rejected.
|
||||
</Warning>
|
||||
|
||||
`toolsAlsoAllow` adds exact, uniquely owned tools registered by the calling plugin to the worker's normal tool surface. The runtime rejects core tools and names shared with another plugin. Profiles and operator tool policies still apply, including explicit allowlists and denies.
|
||||
|
||||
`deleteSession(...)` can delete sessions created by the same plugin through `api.runtime.subagent.run(...)`. Deleting arbitrary user or operator sessions still requires an admin-scoped Gateway request.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -779,6 +779,11 @@ describe("dispatchAndStartWorkboardCards", () => {
|
||||
workerLogs: [expect.objectContaining({ message: expect.stringContaining("run-first") })],
|
||||
},
|
||||
});
|
||||
expect(run.mock.calls[0]?.[0]?.toolsAlsoAllow).toEqual([
|
||||
"workboard_heartbeat",
|
||||
"workboard_complete",
|
||||
"workboard_block",
|
||||
]);
|
||||
await expect(store.get(second.id)).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
metadata: { automation: { dispatchCount: 1 } },
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { WorkboardCard, WorkboardExecution, WorkboardWorkspace } from "./ty
|
||||
import {
|
||||
assertCanonicalWorkboardRootAccess,
|
||||
assertWorkboardWorkspaceSourceAccess,
|
||||
WORKBOARD_REQUIRED_WORKER_TOOLS,
|
||||
type WorkboardWorkspaceAccess,
|
||||
} from "./workspace-access.js";
|
||||
|
||||
@@ -408,6 +409,7 @@ export async function dispatchAndStartWorkboardCards(params: {
|
||||
ownerId,
|
||||
token: claimValue,
|
||||
}),
|
||||
toolsAlsoAllow: [...WORKBOARD_REQUIRED_WORKER_TOOLS],
|
||||
...(params.options?.provider ? { provider: params.options.provider } : {}),
|
||||
...(params.options?.model ? { model: params.options.model } : {}),
|
||||
lane: `workboard:${cardBoardId(card)}:${card.id}`,
|
||||
|
||||
@@ -62,7 +62,7 @@ export const WORKBOARD_TOOL_NAMES = [
|
||||
"workboard_move",
|
||||
] as const;
|
||||
|
||||
const WORKBOARD_REQUIRED_WORKER_TOOLS = [
|
||||
export const WORKBOARD_REQUIRED_WORKER_TOOLS = [
|
||||
"workboard_heartbeat",
|
||||
"workboard_complete",
|
||||
"workboard_block",
|
||||
|
||||
@@ -22,10 +22,12 @@ import { createMockPluginRegistry } from "../plugins/hooks.test-fixtures.js";
|
||||
import "./test-helpers/fast-bash-tools.js";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import "./test-helpers/fast-openclaw-tools.js";
|
||||
import { isPluginToolAllowed } from "../plugins/tool-grant-allowlist.js";
|
||||
import { wrapToolWithBeforeToolCallHook } from "./agent-tools.before-tool-call.js";
|
||||
import { createOpenClawCodingTools } from "./agent-tools.js";
|
||||
import { runWithAgentRingZeroTools } from "./agent-tools.ring-zero-context.js";
|
||||
import type { AuthProfileStore } from "./auth-profiles/types.js";
|
||||
import { resolveConversationCapabilityProfile } from "./conversation-capability-profile.js";
|
||||
import * as openClawPluginTools from "./openclaw-plugin-tools.js";
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
import { expectReadWriteEditTools } from "./test-helpers/agent-tools-fs-helpers.js";
|
||||
@@ -971,6 +973,42 @@ describe("createOpenClawCodingTools", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("materializes additive runtime tools while preserving normal deny policy", () => {
|
||||
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
|
||||
createOpenClawToolsMock.mockClear();
|
||||
const config: OpenClawConfig = {
|
||||
tools: {
|
||||
profile: "coding",
|
||||
deny: ["workboard_block"],
|
||||
},
|
||||
};
|
||||
|
||||
createOpenClawCodingTools({
|
||||
config,
|
||||
conversationCapabilityProfile: resolveConversationCapabilityProfile({
|
||||
config,
|
||||
runtimePluginToolGrant: {
|
||||
pluginId: "workboard",
|
||||
toolNames: ["workboard_heartbeat", "workboard_complete"],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1);
|
||||
expect(latestCreateOpenClawToolsOptions().pluginToolAllowlist).not.toContain(
|
||||
"workboard_heartbeat",
|
||||
);
|
||||
expect(
|
||||
isPluginToolAllowed(
|
||||
new Set(latestCreateOpenClawToolsOptions().pluginToolAllowlist),
|
||||
"workboard",
|
||||
"workboard_heartbeat",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(latestCreateOpenClawToolsOptions()).not.toHaveProperty("runtimePluginToolGrant");
|
||||
expectListIncludes(latestCreateOpenClawToolsOptions().pluginToolDenylist, ["workboard_block"]);
|
||||
});
|
||||
|
||||
it("passes explicit denylist entries to OpenClaw tool factory planning", () => {
|
||||
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
|
||||
createOpenClawToolsMock.mockClear();
|
||||
|
||||
@@ -71,6 +71,17 @@ export function getActiveAgentRingZeroTools(): readonly AnyAgentTool[] {
|
||||
return scope?.active === true ? scope.tools : [];
|
||||
}
|
||||
|
||||
export function mergeAgentRingZeroTools(
|
||||
ringZeroTools: readonly AnyAgentTool[],
|
||||
tools: AnyAgentTool[],
|
||||
): AnyAgentTool[] {
|
||||
if (ringZeroTools.length === 0) {
|
||||
return tools;
|
||||
}
|
||||
const reservedNames = new Set(ringZeroTools.map((tool) => tool.name));
|
||||
return [...ringZeroTools, ...tools.filter((tool) => !reservedNames.has(tool.name))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a host-owned tool fact for the current run. This does not activate or
|
||||
* grant a tool; only the host can bind executable authority to the run scope.
|
||||
|
||||
+12
-15
@@ -18,6 +18,7 @@ import { resolveEventSessionRoutingPolicy } from "../infra/event-session-routing
|
||||
import { applyExecPolicyLayer } from "../infra/exec-policy.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import type { PluginHookChannelContext } from "../plugins/hook-types.js";
|
||||
import { appendRuntimePluginToolGrant } from "../plugins/tool-grant-allowlist.js";
|
||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||
import { GATEWAY_OWNER_ONLY_CORE_TOOLS } from "../security/dangerous-tools.js";
|
||||
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
@@ -45,7 +46,10 @@ import {
|
||||
wrapToolWorkspaceRootGuard,
|
||||
wrapToolWorkspaceRootGuardWithOptions,
|
||||
} from "./agent-tools.read.js";
|
||||
import { getActiveAgentRingZeroTools } from "./agent-tools.ring-zero-context.js";
|
||||
import {
|
||||
getActiveAgentRingZeroTools,
|
||||
mergeAgentRingZeroTools,
|
||||
} from "./agent-tools.ring-zero-context.js";
|
||||
import { normalizeToolParameters } from "./agent-tools.schema.js";
|
||||
import type { AnyAgentTool } from "./agent-tools.types.js";
|
||||
import { isApplyPatchAllowedForModel } from "./apply-patch-model-policy.js";
|
||||
@@ -444,17 +448,6 @@ type OpenClawCodingToolsOptions = {
|
||||
conversationCapabilityProfile?: ResolvedConversationCapabilityProfile;
|
||||
};
|
||||
|
||||
function mergeRingZeroTools(
|
||||
ringZeroTools: readonly AnyAgentTool[],
|
||||
openClawTools: AnyAgentTool[],
|
||||
): AnyAgentTool[] {
|
||||
if (ringZeroTools.length === 0) {
|
||||
return openClawTools;
|
||||
}
|
||||
const reservedNames = new Set(ringZeroTools.map((tool) => tool.name));
|
||||
return [...ringZeroTools, ...openClawTools.filter((tool) => !reservedNames.has(tool.name))];
|
||||
}
|
||||
|
||||
function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions): AnyAgentTool[] {
|
||||
const execToolName = "exec";
|
||||
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
|
||||
@@ -527,6 +520,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
senderPolicy,
|
||||
subagentPolicy,
|
||||
inheritedToolPolicy,
|
||||
runtimePluginToolGrant,
|
||||
} = capabilityProfile.policy;
|
||||
|
||||
const enableHeartbeatTool =
|
||||
@@ -806,7 +800,10 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
options?.senderIsOwner === false ? [...GATEWAY_OWNER_ONLY_CORE_TOOLS] : [];
|
||||
const ownerOnlyCoreToolPolicy =
|
||||
ownerOnlyCoreToolDenylist.length > 0 ? { deny: ownerOnlyCoreToolDenylist } : undefined;
|
||||
const pluginToolAllowlist = capabilityProfile.policy.explicitToolAllowlist;
|
||||
const pluginToolAllowlist = appendRuntimePluginToolGrant(
|
||||
capabilityProfile.policy.explicitToolAllowlist,
|
||||
runtimePluginToolGrant,
|
||||
);
|
||||
const pluginToolDenylist = [
|
||||
...capabilityProfile.policy.explicitToolDenylist,
|
||||
...ownerOnlyCoreToolDenylist,
|
||||
@@ -928,7 +925,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
// Channel docking: include channel-defined agent tools (login, etc.).
|
||||
...(includeChannelTools ? listChannelAgentTools({ cfg: options?.config }) : []),
|
||||
...(includeOpenClawTools
|
||||
? mergeRingZeroTools(
|
||||
? mergeAgentRingZeroTools(
|
||||
ringZeroTools,
|
||||
createOpenClawTools({
|
||||
...(options?.crestodianTool ? { crestodianTool: options.crestodianTool } : {}),
|
||||
@@ -1104,7 +1101,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
});
|
||||
// Host-bound ring-zero tools carry their own authority checks. Agent policy
|
||||
// must not deadlock setup, but the tools still receive schema/hook wrappers.
|
||||
const authorizedTools = mergeRingZeroTools(ringZeroTools, subagentFiltered);
|
||||
const authorizedTools = mergeAgentRingZeroTools(ringZeroTools, subagentFiltered);
|
||||
if (shouldInheritEffectiveToolAllowlist) {
|
||||
replaceWithEffectiveToolAllowlist(inheritedToolAllowlist, authorizedTools);
|
||||
}
|
||||
|
||||
@@ -934,14 +934,14 @@ export function runAgentAttempt(params: {
|
||||
runId: params.runId,
|
||||
lifecycleGeneration: params.lifecycleGeneration,
|
||||
lane: params.opts.lane,
|
||||
// Hidden internal runs have no assistant-event consumer. Visible subagent
|
||||
// lanes can still feed Control UI, session subscribers, and ACP parent relays.
|
||||
// Hidden internal runs lack an event consumer; visible lanes still feed UI and parent relays.
|
||||
suppressLiveStreamOutput: shouldSuppressEmbeddedLiveStreamOutput(params),
|
||||
abortSignal: params.opts.abortSignal,
|
||||
extraSystemPrompt: params.opts.extraSystemPrompt,
|
||||
bootstrapContextMode: params.opts.bootstrapContextMode,
|
||||
bootstrapContextRunKind: params.opts.bootstrapContextRunKind,
|
||||
toolsAllow: params.opts.toolsAllow,
|
||||
runtimePluginToolGrant: params.opts.runtimePluginToolGrant,
|
||||
internalEvents: params.opts.internalEvents,
|
||||
inputProvenance: params.opts.inputProvenance,
|
||||
sourceReplyDeliveryMode: params.opts.sourceReplyDeliveryMode,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options
|
||||
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.public.js";
|
||||
import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js";
|
||||
import type { PluginHookChannelContext } from "../../plugins/hook-types.js";
|
||||
import type { RuntimePluginToolGrant } from "../../plugins/runtime/tool-grant.js";
|
||||
import type { InputProvenance } from "../../sessions/input-provenance.js";
|
||||
import type {
|
||||
UserTurnInput,
|
||||
@@ -113,6 +114,8 @@ export type AgentCommandOpts = {
|
||||
allowModelOverride?: boolean;
|
||||
/** Optional runtime tool allow-list; when set, only these tools are exposed for this run. */
|
||||
toolsAllow?: string[];
|
||||
/** Trusted owner-scoped plugin tool grant; normal policy and deny rules still apply. */
|
||||
runtimePluginToolGrant?: RuntimePluginToolGrant;
|
||||
/** Internal marker for an auto-applied cap that CLI runtimes must omit. */
|
||||
toolsAllowIsDefault?: boolean;
|
||||
/** Preserve the originating run's message-tool policy across internal continuation turns. */
|
||||
|
||||
@@ -184,6 +184,38 @@ describe("resolveConversationCapabilityProfile", () => {
|
||||
expect(profile.policy.explicitToolOverrideAllowlist).toEqual(["pdf"]);
|
||||
});
|
||||
|
||||
it("adds runtime tools without replacing the configured tool surface", () => {
|
||||
const profile = resolveConversationCapabilityProfile({
|
||||
config: {
|
||||
tools: {
|
||||
profile: "coding",
|
||||
deny: ["workboard_block"],
|
||||
},
|
||||
},
|
||||
runtimePluginToolGrant: {
|
||||
pluginId: "workboard",
|
||||
toolNames: ["workboard_heartbeat", " workboard_complete ", "workboard_heartbeat"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(profile.policy.profileAlsoAllow).toEqual(["workboard_heartbeat", "workboard_complete"]);
|
||||
expect(profile.policy.providerProfileAlsoAllow).toEqual([
|
||||
"workboard_heartbeat",
|
||||
"workboard_complete",
|
||||
]);
|
||||
expect(profile.policy.explicitToolAllowlist).toEqual(expect.arrayContaining(["read", "exec"]));
|
||||
expect(profile.policy.explicitToolAllowlist).not.toContain("workboard_heartbeat");
|
||||
expect(profile.policy.explicitToolOverrideAllowlist).toEqual([]);
|
||||
expect(profile.policy.explicitToolDenylist).toEqual(["workboard_block"]);
|
||||
expect(profile.policy.runtimePluginToolGrant).toEqual({
|
||||
pluginId: "workboard",
|
||||
toolNames: ["workboard_heartbeat", " workboard_complete ", "workboard_heartbeat"],
|
||||
});
|
||||
expect(profile.policy.inheritancePolicies).not.toContainEqual({
|
||||
allow: ["workboard_heartbeat", "workboard_complete"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps inherited subagent grants out of explicit overrides", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-capability-profile-"));
|
||||
const storePath = path.join(tempDir, "sessions.json");
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
* hot paths share. Keep this internal: it prepares existing config/state, not a
|
||||
* new public access-profile config surface.
|
||||
*/
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import type { ChatType } from "../channels/chat-type.js";
|
||||
import { normalizeChatType } from "../channels/chat-type.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { RuntimePluginToolGrant } from "../plugins/runtime/tool-grant.js";
|
||||
import type { SkillSnapshot } from "../skills/types.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js";
|
||||
import { normalizeMessageChannel } from "../utils/message-channel-core.js";
|
||||
@@ -78,6 +80,7 @@ type ConversationCapabilityProfileParams = {
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
sandboxToolPolicy?: SandboxToolPolicy;
|
||||
runtimeToolAllowlist?: string[];
|
||||
runtimePluginToolGrant?: RuntimePluginToolGrant;
|
||||
};
|
||||
|
||||
export type ResolvedConversationCapabilityProfile = {
|
||||
@@ -171,6 +174,7 @@ export type ResolvedConversationCapabilityProfile = {
|
||||
/** Explicit config/runtime grants only; excludes built-in profile expansion. */
|
||||
explicitToolOverrideAllowlist: string[];
|
||||
explicitToolDenylist: string[];
|
||||
runtimePluginToolGrant?: RuntimePluginToolGrant;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -260,6 +264,13 @@ export function resolveConversationCapabilityProfile(
|
||||
const runtimeToolPolicy = params.runtimeToolAllowlist
|
||||
? { allow: params.runtimeToolAllowlist }
|
||||
: undefined;
|
||||
const runtimeToolAlsoAllowlist = uniqueStrings(
|
||||
(params.runtimePluginToolGrant?.toolNames ?? []).map((entry) => entry.trim()).filter(Boolean),
|
||||
);
|
||||
const mergeRuntimeToolAlsoAllowlist = (configured?: string[]) => {
|
||||
const merged = uniqueStrings([...(configured ?? []), ...runtimeToolAlsoAllowlist]);
|
||||
return merged.length > 0 ? merged : undefined;
|
||||
};
|
||||
const explicitOverridePolicies = [...configuredOverridePolicies, runtimeToolPolicy];
|
||||
const inheritancePolicies = [
|
||||
profilePolicy,
|
||||
@@ -350,8 +361,8 @@ export function resolveConversationCapabilityProfile(
|
||||
providerProfile: effective.providerProfile,
|
||||
profilePolicy,
|
||||
providerProfilePolicy,
|
||||
profileAlsoAllow: effective.profileAlsoAllow,
|
||||
providerProfileAlsoAllow: effective.providerProfileAlsoAllow,
|
||||
profileAlsoAllow: mergeRuntimeToolAlsoAllowlist(effective.profileAlsoAllow),
|
||||
providerProfileAlsoAllow: mergeRuntimeToolAlsoAllowlist(effective.providerProfileAlsoAllow),
|
||||
globalPolicy: effective.globalPolicy,
|
||||
globalProviderPolicy: effective.globalProviderPolicy,
|
||||
agentPolicy: effective.agentPolicy,
|
||||
@@ -365,6 +376,7 @@ export function resolveConversationCapabilityProfile(
|
||||
explicitToolAllowlist: collectExplicitAllowlist(inheritancePolicies),
|
||||
explicitToolOverrideAllowlist: collectExplicitAllowlist(explicitOverridePolicies),
|
||||
explicitToolDenylist: collectExplicitDenylist(inheritancePolicies),
|
||||
runtimePluginToolGrant: params.runtimePluginToolGrant,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ export function prepareEmbeddedAttemptToolBase(params: {
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
sandboxToolPolicy: params.sandbox?.tools,
|
||||
runtimeToolAllowlist: effectiveToolsAllow,
|
||||
runtimePluginToolGrant: attempt.runtimePluginToolGrant,
|
||||
});
|
||||
const localModelLeanEnabled = isLocalModelLeanEnabled({
|
||||
config: attempt.config,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import type { ImageContent } from "../../../llm/types.js";
|
||||
import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js";
|
||||
import type { PluginHookChannelContext } from "../../../plugins/hook-types.js";
|
||||
import type { RuntimePluginToolGrant } from "../../../plugins/runtime/tool-grant.js";
|
||||
import type { CommandQueueEnqueueFn } from "../../../process/command-queue.types.js";
|
||||
import type { InputProvenance } from "../../../sessions/input-provenance.js";
|
||||
import type { UserTurnTranscriptRecorder } from "../../../sessions/user-turn-transcript.types.js";
|
||||
@@ -223,6 +224,8 @@ export type RunEmbeddedAgentParams = {
|
||||
bootstrapContextRunKind?: BootstrapContextRunKind;
|
||||
/** Optional tool allow-list; when set, only these tools are sent to the model. */
|
||||
toolsAllow?: string[];
|
||||
/** Owner-scoped plugin tool grant; normal policy and deny rules still apply. */
|
||||
runtimePluginToolGrant?: RuntimePluginToolGrant;
|
||||
/** Seen bootstrap truncation warning signatures for this session (once mode dedupe). */
|
||||
bootstrapPromptWarningSignaturesSeen?: string[];
|
||||
/** Last shown bootstrap truncation warning signature for this session. */
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* OpenClaw built-in and plugin tool assembly.
|
||||
*
|
||||
* Creates the per-run tool inventory from config, channel context, sandbox policy, auth stores, and plugin tools.
|
||||
*/
|
||||
/** Builds the per-run built-in and plugin tool inventory. */
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type {
|
||||
SourceReplyDeliveryMode,
|
||||
|
||||
@@ -244,6 +244,14 @@ export function startAgentRunExecution(params: {
|
||||
sessionKey: params.requestedSessionKeyRaw,
|
||||
});
|
||||
}
|
||||
// Plugin-owned additive grants stay internal to the authenticated in-process run.
|
||||
// Public agent params cannot supply them, and normal tool policy still filters them.
|
||||
const runtimePluginToolGrant =
|
||||
params.client?.internal?.agentRunTracking === "plugin_subagent" &&
|
||||
params.client.internal.pluginRuntimeOwnerId ===
|
||||
params.client.internal.runtimePluginToolGrant?.pluginId
|
||||
? params.client.internal.runtimePluginToolGrant
|
||||
: undefined;
|
||||
|
||||
dispatchAgentRunFromGateway({
|
||||
ingressOpts: {
|
||||
@@ -289,6 +297,7 @@ export function startAgentRunExecution(params: {
|
||||
bootstrapContextMode: params.request.bootstrapContextMode,
|
||||
bootstrapContextRunKind: params.effectiveBootstrapContextRunKind,
|
||||
toolsAllow: params.restoredCronContinuation?.toolsAllow,
|
||||
runtimePluginToolGrant,
|
||||
toolsAllowIsDefault: params.restoredCronContinuation?.toolsAllowIsDefault,
|
||||
requireExplicitMessageTarget:
|
||||
params.restoredCronContinuation?.cliSessionBindingFacts?.requireExplicitMessageTarget,
|
||||
|
||||
@@ -389,6 +389,38 @@ describe("gateway agent handler", () => {
|
||||
expect(capturedEntry?.pluginOwnerId).toBe("memory-core");
|
||||
});
|
||||
|
||||
it("forwards plugin-owned additive tools only for tracked plugin subagent runs", async () => {
|
||||
primeMainAgentRun();
|
||||
|
||||
await invokeAgent(
|
||||
{
|
||||
message: "finish the workboard card",
|
||||
sessionKey: "agent:main:subagent:workboard-card",
|
||||
idempotencyKey: "plugin-tools-also-allow",
|
||||
},
|
||||
{
|
||||
client: {
|
||||
internal: {
|
||||
agentRunTracking: "plugin_subagent",
|
||||
pluginRuntimeOwnerId: "workboard",
|
||||
runtimePluginToolGrant: {
|
||||
pluginId: "workboard",
|
||||
toolNames: ["workboard_heartbeat", "workboard_complete"],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const call = await waitForAgentCommandCall<{
|
||||
runtimePluginToolGrant?: { pluginId: string; toolNames: readonly string[] };
|
||||
}>();
|
||||
expect(call.runtimePluginToolGrant).toEqual({
|
||||
pluginId: "workboard",
|
||||
toolNames: ["workboard_heartbeat", "workboard_complete"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not claim stale pre-existing sessions for plugin runtime cleanup", async () => {
|
||||
const sessionKey = "agent:main:existing-user-session";
|
||||
const existingEntry = {
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
PluginApprovalRequestPayload,
|
||||
} from "../../infra/plugin-approvals.js";
|
||||
import type { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import type { RuntimePluginToolGrant } from "../../plugins/runtime/tool-grant.js";
|
||||
import type { WizardSession } from "../../wizard/session.js";
|
||||
import type { AgentRuntimeIdentity } from "../agent-runtime-identity-token.js";
|
||||
import type { ChatAbortControllerEntry } from "../chat-abort.js";
|
||||
@@ -67,6 +68,8 @@ export type GatewayClient = {
|
||||
agentRuntimeIdentity?: AgentRuntimeIdentity;
|
||||
pluginRuntimeOwnerId?: string;
|
||||
agentRunTracking?: "plugin_subagent";
|
||||
/** Plugin-owned tools authorized for this internal subagent run. */
|
||||
runtimePluginToolGrant?: RuntimePluginToolGrant;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Internal client metadata for trusted in-process plugin runtime calls.
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import {
|
||||
GATEWAY_CLIENT_IDS,
|
||||
GATEWAY_CLIENT_MODES,
|
||||
} from "../../packages/gateway-protocol/src/client-info.js";
|
||||
import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/version.js";
|
||||
import { isKnownCoreToolId } from "../agents/tool-catalog.js";
|
||||
import { normalizeToolName } from "../agents/tool-policy.js";
|
||||
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import type { RuntimePluginToolGrant } from "../plugins/runtime/tool-grant.js";
|
||||
import { APPROVALS_SCOPE, WRITE_SCOPE } from "./method-scopes.js";
|
||||
import type { GatewayRequestOptions } from "./server-methods/types.js";
|
||||
|
||||
export function createSyntheticPluginRuntimeClient(params?: {
|
||||
allowModelOverride?: boolean;
|
||||
agentRunTracking?: "plugin_subagent";
|
||||
cronRunContinuation?: boolean;
|
||||
pluginRuntimeOwnerId?: string;
|
||||
runtimePluginToolGrant?: RuntimePluginToolGrant;
|
||||
scopes?: string[];
|
||||
}): GatewayRequestOptions["client"] {
|
||||
const pluginRuntimeOwnerId =
|
||||
typeof params?.pluginRuntimeOwnerId === "string" && params.pluginRuntimeOwnerId.trim()
|
||||
? params.pluginRuntimeOwnerId.trim()
|
||||
: undefined;
|
||||
return {
|
||||
connect: {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
|
||||
version: "internal",
|
||||
platform: "node",
|
||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
},
|
||||
role: "operator",
|
||||
scopes: params?.scopes ?? [WRITE_SCOPE],
|
||||
},
|
||||
internal: {
|
||||
allowModelOverride: params?.allowModelOverride === true,
|
||||
...(params?.agentRunTracking ? { agentRunTracking: params.agentRunTracking } : {}),
|
||||
...(params?.cronRunContinuation === true ? { cronRunContinuation: true } : {}),
|
||||
...(params?.scopes?.includes(APPROVALS_SCOPE) ? { approvalRuntime: true } : {}),
|
||||
...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}),
|
||||
...(params?.runtimePluginToolGrant
|
||||
? { runtimePluginToolGrant: params.runtimePluginToolGrant }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mergePluginRuntimeClientInternal(
|
||||
client: GatewayRequestOptions["client"] | undefined,
|
||||
internal: NonNullable<GatewayRequestOptions["client"]>["internal"],
|
||||
): GatewayRequestOptions["client"] {
|
||||
if (!client || !internal) {
|
||||
return client ?? null;
|
||||
}
|
||||
return {
|
||||
...client,
|
||||
internal: {
|
||||
...client.internal,
|
||||
...internal,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePluginSubagentToolsAlsoAllow(params: {
|
||||
pluginId?: string;
|
||||
toolsAlsoAllow?: string[];
|
||||
}): RuntimePluginToolGrant | undefined {
|
||||
const requested = uniqueStrings(
|
||||
(params.toolsAlsoAllow ?? []).map((entry) => normalizeToolName(entry.trim())).filter(Boolean),
|
||||
);
|
||||
if (requested.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const pluginId = params.pluginId?.trim();
|
||||
if (!pluginId) {
|
||||
throw new Error("toolsAlsoAllow requires plugin identity for subagent runs.");
|
||||
}
|
||||
const registry = getActivePluginRegistry();
|
||||
for (const toolName of requested) {
|
||||
if (isKnownCoreToolId(toolName)) {
|
||||
throw new Error(`plugin "${pluginId}" may not add core tool "${toolName}" to subagent runs.`);
|
||||
}
|
||||
const owners = uniqueStrings(
|
||||
(registry?.tools ?? [])
|
||||
.filter((registration) =>
|
||||
[...registration.names, ...(registration.declaredNames ?? [])].some(
|
||||
(registeredName) => normalizeToolName(registeredName) === toolName,
|
||||
),
|
||||
)
|
||||
.map((registration) => registration.pluginId),
|
||||
);
|
||||
if (owners.length !== 1 || owners[0] !== pluginId) {
|
||||
throw new Error(`plugin "${pluginId}" does not uniquely own subagent tool "${toolName}".`);
|
||||
}
|
||||
}
|
||||
return { pluginId, toolNames: requested };
|
||||
}
|
||||
@@ -324,6 +324,25 @@ async function createSubagentRuntime(
|
||||
return runtimeModule.createPluginRuntime({ allowGatewaySubagentBinding: true }).subagent;
|
||||
}
|
||||
|
||||
function registerActivePluginToolOwnership(
|
||||
pluginId: string,
|
||||
names: string[],
|
||||
declaredNames: string[] = names,
|
||||
): void {
|
||||
const registry = runtimeRegistryModule.getActivePluginRegistry();
|
||||
if (!registry) {
|
||||
throw new Error("Expected an active plugin registry");
|
||||
}
|
||||
registry.tools.push({
|
||||
pluginId,
|
||||
factory: () => null,
|
||||
names,
|
||||
declaredNames,
|
||||
optional: true,
|
||||
source: `/tmp/${pluginId}/index.js`,
|
||||
});
|
||||
}
|
||||
|
||||
async function reloadFallbackGatewayContextModule() {
|
||||
// Existing runtimes retain the old module graph; only the process-global state owner
|
||||
// must reload to prove a restarted Gateway can replace their fallback context.
|
||||
@@ -1254,6 +1273,133 @@ describe("loadGatewayPlugins", () => {
|
||||
expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("workboard");
|
||||
});
|
||||
|
||||
test("forwards exact plugin-owned additive tools through internal run metadata", async () => {
|
||||
const runtime = await createSubagentRuntime(serverPluginsModule);
|
||||
serverPluginsModule.setFallbackGatewayContext(createTestContext("tools-also-allow"));
|
||||
registerActivePluginToolOwnership("workboard", [
|
||||
"workboard_heartbeat",
|
||||
"workboard_complete",
|
||||
"workboard_block",
|
||||
]);
|
||||
|
||||
await gatewayRequestScopeModule.withPluginRuntimePluginScope(
|
||||
{ pluginId: "workboard", pluginOrigin: "bundled" },
|
||||
() =>
|
||||
runtime.run({
|
||||
sessionKey: "s-tools-also-allow",
|
||||
message: "finish the card",
|
||||
toolsAlsoAllow: ["workboard_heartbeat", " workboard_complete ", "workboard_heartbeat"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getLastDispatchedClientInternal().runtimePluginToolGrant).toEqual({
|
||||
pluginId: "workboard",
|
||||
toolNames: ["workboard_heartbeat", "workboard_complete"],
|
||||
});
|
||||
expect(getRequiredLastDispatchedParams()).not.toHaveProperty("toolsAlsoAllow");
|
||||
});
|
||||
|
||||
test("rejects additive subagent tools not registered by the calling plugin", async () => {
|
||||
const runtime = await createSubagentRuntime(serverPluginsModule);
|
||||
serverPluginsModule.setFallbackGatewayContext(createTestContext("foreign-tools-also-allow"));
|
||||
registerActivePluginToolOwnership("workboard", ["workboard_complete"]);
|
||||
registerActivePluginToolOwnership("other-plugin", ["other_plugin_tool"]);
|
||||
|
||||
await expect(
|
||||
gatewayRequestScopeModule.withPluginRuntimePluginScope(
|
||||
{ pluginId: "workboard", pluginOrigin: "bundled" },
|
||||
() =>
|
||||
runtime.run({
|
||||
sessionKey: "s-foreign-tools-also-allow",
|
||||
message: "finish the card",
|
||||
toolsAlsoAllow: ["other_plugin_tool"],
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow('plugin "workboard" does not uniquely own subagent tool "other_plugin_tool"');
|
||||
expect(handleGatewayRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("accepts additive tools declared by an unnamed plugin factory", async () => {
|
||||
const runtime = await createSubagentRuntime(serverPluginsModule);
|
||||
serverPluginsModule.setFallbackGatewayContext(createTestContext("declared-tools-also-allow"));
|
||||
registerActivePluginToolOwnership("workboard", [], ["workboard_complete"]);
|
||||
|
||||
await gatewayRequestScopeModule.withPluginRuntimePluginScope(
|
||||
{ pluginId: "workboard", pluginOrigin: "bundled" },
|
||||
() =>
|
||||
runtime.run({
|
||||
sessionKey: "s-declared-tools-also-allow",
|
||||
message: "finish the card",
|
||||
toolsAlsoAllow: ["workboard_complete"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getLastDispatchedClientInternal().runtimePluginToolGrant).toEqual({
|
||||
pluginId: "workboard",
|
||||
toolNames: ["workboard_complete"],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects core and ambiguously-owned additive tool names", async () => {
|
||||
const runtime = await createSubagentRuntime(serverPluginsModule);
|
||||
serverPluginsModule.setFallbackGatewayContext(createTestContext("colliding-tools-also-allow"));
|
||||
registerActivePluginToolOwnership("workboard", ["exec", "workboard_complete"]);
|
||||
registerActivePluginToolOwnership("other-plugin", ["workboard_complete"]);
|
||||
|
||||
await expect(
|
||||
gatewayRequestScopeModule.withPluginRuntimePluginIdScope("workboard", () =>
|
||||
runtime.run({
|
||||
sessionKey: "s-core-tools-also-allow",
|
||||
message: "run a command",
|
||||
toolsAlsoAllow: ["exec"],
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow('plugin "workboard" may not add core tool "exec" to subagent runs');
|
||||
await expect(
|
||||
gatewayRequestScopeModule.withPluginRuntimePluginIdScope("workboard", () =>
|
||||
runtime.run({
|
||||
sessionKey: "s-ambiguous-tools-also-allow",
|
||||
message: "finish the card",
|
||||
toolsAlsoAllow: ["workboard_complete"],
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'plugin "workboard" does not uniquely own subagent tool "workboard_complete"',
|
||||
);
|
||||
expect(handleGatewayRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("clears inherited additive grants when a scoped plugin run requests none", async () => {
|
||||
const runtime = await createSubagentRuntime(serverPluginsModule);
|
||||
const scope = {
|
||||
context: createTestContext("clear-tools-also-allow"),
|
||||
client: {
|
||||
connect: { scopes: ["operator.write"] },
|
||||
internal: {
|
||||
agentRunTracking: "plugin_subagent",
|
||||
pluginRuntimeOwnerId: "other-plugin",
|
||||
runtimePluginToolGrant: {
|
||||
pluginId: "other-plugin",
|
||||
toolNames: ["other_plugin_tool"],
|
||||
},
|
||||
},
|
||||
} as unknown as GatewayRequestOptions["client"],
|
||||
isWebchatConnect: () => false,
|
||||
pluginId: "workboard",
|
||||
pluginOrigin: "bundled" as const,
|
||||
} satisfies PluginRuntimeGatewayRequestScope;
|
||||
|
||||
await gatewayRequestScopeModule.withPluginRuntimeGatewayRequestScope(scope, () =>
|
||||
runtime.run({
|
||||
sessionKey: "s-clear-tools-also-allow",
|
||||
message: "do normal work",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getLastDispatchedClientInternal().pluginRuntimeOwnerId).toBe("workboard");
|
||||
expect(getLastDispatchedClientInternal().runtimePluginToolGrant).toBeUndefined();
|
||||
});
|
||||
|
||||
test("forwards lightContext as lightweight bootstrap context on subagent run", async () => {
|
||||
const serverPlugins = serverPluginsModule;
|
||||
const runtime = await createSubagentRuntime(serverPlugins);
|
||||
|
||||
@@ -5,12 +5,7 @@ import { performance } from "node:perf_hooks";
|
||||
import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs";
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { GatewayClientRequestError } from "../../packages/gateway-client/src/index.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_IDS,
|
||||
GATEWAY_CLIENT_MODES,
|
||||
} from "../../packages/gateway-protocol/src/client-info.js";
|
||||
import type { ErrorShape } from "../../packages/gateway-protocol/src/schema/frames.js";
|
||||
import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/version.js";
|
||||
import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
@@ -23,14 +18,20 @@ import type { PluginRegistryParams } from "../plugins/registry-types.js";
|
||||
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
|
||||
import { createPluginRuntimeLoaderLogger } from "../plugins/runtime/load-context.js";
|
||||
import type { RuntimePluginToolGrant } from "../plugins/runtime/tool-grant.js";
|
||||
import type { PluginRuntime, RuntimeGatewayRequestOptions } from "../plugins/runtime/types.js";
|
||||
import type { PluginLogger, PluginOrigin } from "../plugins/types.js";
|
||||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
import { resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js";
|
||||
import { ADMIN_SCOPE, APPROVALS_SCOPE, WRITE_SCOPE } from "./method-scopes.js";
|
||||
import { ADMIN_SCOPE } from "./method-scopes.js";
|
||||
import { normalizeOperatorScopeList, type OperatorScope } from "./operator-scopes.js";
|
||||
import type { GatewayRequestHandler, GatewayRequestOptions } from "./server-methods/types.js";
|
||||
import { getFallbackGatewayContext } from "./server-plugin-fallback-context.js";
|
||||
import {
|
||||
createSyntheticPluginRuntimeClient,
|
||||
mergePluginRuntimeClientInternal,
|
||||
resolvePluginSubagentToolsAlsoAllow,
|
||||
} from "./server-plugin-runtime-client.js";
|
||||
import { projectGatewayRuntimeNodes } from "./server-plugins-node-runtime.js";
|
||||
|
||||
export {
|
||||
@@ -189,40 +190,6 @@ function resolveRequestedFallbackModelRef(params: {
|
||||
|
||||
// ── Internal gateway dispatch for plugin runtime ────────────────────
|
||||
|
||||
function createSyntheticOperatorClient(params?: {
|
||||
allowModelOverride?: boolean;
|
||||
agentRunTracking?: "plugin_subagent";
|
||||
cronRunContinuation?: boolean;
|
||||
pluginRuntimeOwnerId?: string;
|
||||
scopes?: string[];
|
||||
}): GatewayRequestOptions["client"] {
|
||||
const pluginRuntimeOwnerId =
|
||||
typeof params?.pluginRuntimeOwnerId === "string" && params.pluginRuntimeOwnerId.trim()
|
||||
? params.pluginRuntimeOwnerId.trim()
|
||||
: undefined;
|
||||
return {
|
||||
connect: {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
|
||||
version: "internal",
|
||||
platform: "node",
|
||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
},
|
||||
role: "operator",
|
||||
scopes: params?.scopes ?? [WRITE_SCOPE],
|
||||
},
|
||||
internal: {
|
||||
allowModelOverride: params?.allowModelOverride === true,
|
||||
...(params?.agentRunTracking ? { agentRunTracking: params.agentRunTracking } : {}),
|
||||
...(params?.cronRunContinuation === true ? { cronRunContinuation: true } : {}),
|
||||
...(params?.scopes?.includes(APPROVALS_SCOPE) ? { approvalRuntime: true } : {}),
|
||||
...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hasAdminScope(client: GatewayRequestOptions["client"] | undefined): boolean {
|
||||
const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
|
||||
return scopes.includes(ADMIN_SCOPE);
|
||||
@@ -260,22 +227,6 @@ function resolveRuntimeNodeInvokeSyntheticScopes(params: {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function mergeGatewayClientInternal(
|
||||
client: GatewayRequestOptions["client"] | undefined,
|
||||
internal: NonNullable<GatewayRequestOptions["client"]>["internal"],
|
||||
): GatewayRequestOptions["client"] {
|
||||
if (!client || !internal) {
|
||||
return client ?? null;
|
||||
}
|
||||
return {
|
||||
...client,
|
||||
internal: {
|
||||
...client.internal,
|
||||
...internal,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type DispatchGatewayMethodInProcessOptions = {
|
||||
allowSyntheticModelOverride?: boolean;
|
||||
allowSyntheticCronRunContinuation?: boolean;
|
||||
@@ -284,6 +235,7 @@ type DispatchGatewayMethodInProcessOptions = {
|
||||
expectFinal?: boolean;
|
||||
forceSyntheticClient?: boolean;
|
||||
pluginRuntimeOwnerId?: string;
|
||||
runtimePluginToolGrant?: RuntimePluginToolGrant;
|
||||
requireScopedClient?: boolean;
|
||||
syntheticScopes?: string[];
|
||||
timeoutMs?: number;
|
||||
@@ -391,19 +343,23 @@ export async function dispatchGatewayMethodInProcessRaw(
|
||||
typeof options?.pluginRuntimeOwnerId === "string" && options.pluginRuntimeOwnerId.trim()
|
||||
? options.pluginRuntimeOwnerId.trim()
|
||||
: undefined;
|
||||
const syntheticClient = createSyntheticOperatorClient({
|
||||
const syntheticClient = createSyntheticPluginRuntimeClient({
|
||||
allowModelOverride: options?.allowSyntheticModelOverride === true,
|
||||
agentRunTracking: options?.agentRunTracking,
|
||||
cronRunContinuation: options?.allowSyntheticCronRunContinuation === true,
|
||||
...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}),
|
||||
...(options?.runtimePluginToolGrant
|
||||
? { runtimePluginToolGrant: options.runtimePluginToolGrant }
|
||||
: {}),
|
||||
scopes: options?.syntheticScopes,
|
||||
});
|
||||
const scopedClient = mergeGatewayClientInternal(
|
||||
const scopedClient = mergePluginRuntimeClientInternal(
|
||||
scope?.client,
|
||||
pluginRuntimeOwnerId || options?.agentRunTracking
|
||||
pluginRuntimeOwnerId || options?.agentRunTracking || options?.runtimePluginToolGrant
|
||||
? {
|
||||
...(options?.agentRunTracking ? { agentRunTracking: options.agentRunTracking } : {}),
|
||||
...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}),
|
||||
runtimePluginToolGrant: options?.runtimePluginToolGrant,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
@@ -559,6 +515,10 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] {
|
||||
typeof scope?.pluginId === "string" && scope.pluginId.trim()
|
||||
? scope.pluginId.trim()
|
||||
: undefined;
|
||||
const runtimePluginToolGrant = resolvePluginSubagentToolsAlsoAllow({
|
||||
pluginId,
|
||||
toolsAlsoAllow: params.toolsAlsoAllow,
|
||||
});
|
||||
const overrideRequested = Boolean(params.provider || params.model);
|
||||
const hasRequestScopeClient = Boolean(scope?.client);
|
||||
let allowOverride = hasRequestScopeClient && canClientUseModelOverride(scope?.client ?? null);
|
||||
@@ -600,6 +560,7 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] {
|
||||
allowSyntheticModelOverride,
|
||||
agentRunTracking: "plugin_subagent",
|
||||
...(pluginId ? { pluginRuntimeOwnerId: pluginId } : {}),
|
||||
...(runtimePluginToolGrant ? { runtimePluginToolGrant } : {}),
|
||||
},
|
||||
);
|
||||
const runId = payload?.runId;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Owner-scoped additive plugin tools for one trusted agent run. */
|
||||
export type RuntimePluginToolGrant = {
|
||||
pluginId: string;
|
||||
toolNames: readonly string[];
|
||||
};
|
||||
@@ -15,6 +15,8 @@ type PluginRuntimeChannel = import("./types-channel.js").PluginRuntimeChannel;
|
||||
export type SubagentRunParams = {
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
/** Add exact tools registered by the calling plugin to the worker's normal tool surface. */
|
||||
toolsAlsoAllow?: string[];
|
||||
provider?: string;
|
||||
model?: string;
|
||||
extraSystemPrompt?: string;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { normalizeToolName } from "../agents/tool-policy.js";
|
||||
import type { RuntimePluginToolGrant } from "./runtime/tool-grant.js";
|
||||
|
||||
const RUNTIME_PLUGIN_TOOL_GRANT_PREFIX = "__openclaw_runtime_plugin_tool_grant__";
|
||||
|
||||
function runtimePluginToolGrantKey(pluginId: string, toolName: string): string {
|
||||
return `${RUNTIME_PLUGIN_TOOL_GRANT_PREFIX}:${pluginId.trim().toLowerCase()}:${normalizeToolName(toolName)}`;
|
||||
}
|
||||
|
||||
export function appendRuntimePluginToolGrant(
|
||||
allowlist: string[],
|
||||
grant: RuntimePluginToolGrant | undefined,
|
||||
): string[] {
|
||||
return grant
|
||||
? [
|
||||
...allowlist,
|
||||
...grant.toolNames.map((toolName) => runtimePluginToolGrantKey(grant.pluginId, toolName)),
|
||||
]
|
||||
: allowlist;
|
||||
}
|
||||
|
||||
export function isPluginToolAllowed(
|
||||
allowlist: Set<string>,
|
||||
pluginId: string,
|
||||
toolName: string,
|
||||
): boolean {
|
||||
const normalizedToolName = normalizeToolName(toolName);
|
||||
return (
|
||||
allowlist.has(normalizedToolName) ||
|
||||
allowlist.has(runtimePluginToolGrantKey(pluginId, normalizedToolName))
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { resetLogger, setLoggerOverride } from "../logging/logger.js";
|
||||
import { loggingState } from "../logging/state.js";
|
||||
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
|
||||
import { createEmptyPluginRegistry } from "./registry-empty.js";
|
||||
import { appendRuntimePluginToolGrant } from "./tool-grant-allowlist.js";
|
||||
|
||||
type MockRegistryToolEntry = {
|
||||
pluginId: string;
|
||||
@@ -80,10 +81,15 @@ function createResolveToolsParams(params?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
suppressNameConflicts?: boolean;
|
||||
allowGatewaySubagentBinding?: boolean;
|
||||
runtimePluginToolGrant?: { pluginId: string; toolNames: readonly string[] };
|
||||
}) {
|
||||
const toolAllowlist = appendRuntimePluginToolGrant(
|
||||
[...(params?.toolAllowlist ?? [])],
|
||||
params?.runtimePluginToolGrant,
|
||||
);
|
||||
return {
|
||||
context: (params?.context ?? createContext()) as never,
|
||||
...(params?.toolAllowlist ? { toolAllowlist: [...params.toolAllowlist] } : {}),
|
||||
...(toolAllowlist.length > 0 ? { toolAllowlist } : {}),
|
||||
...(params?.toolDenylist ? { toolDenylist: [...params.toolDenylist] } : {}),
|
||||
...(params?.existingToolNames ? { existingToolNames: params.existingToolNames } : {}),
|
||||
...(params?.env ? { env: params.env } : {}),
|
||||
@@ -1408,6 +1414,71 @@ describe("resolvePluginTools optional tools", () => {
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies an additive runtime grant only to its owning plugin", () => {
|
||||
const ownerFactory = vi.fn(() => makeTool("optional_tool"));
|
||||
const foreignFactory = vi.fn(() => makeTool("optional_tool"));
|
||||
setRegistry([
|
||||
{
|
||||
pluginId: "optional-demo",
|
||||
optional: true,
|
||||
source: "/tmp/optional-demo.js",
|
||||
names: ["optional_tool"],
|
||||
factory: ownerFactory,
|
||||
},
|
||||
{
|
||||
pluginId: "multi",
|
||||
optional: true,
|
||||
source: "/tmp/multi.js",
|
||||
names: ["optional_tool"],
|
||||
factory: foreignFactory,
|
||||
},
|
||||
]);
|
||||
|
||||
const tools = resolvePluginTools(
|
||||
createResolveToolsParams({
|
||||
runtimePluginToolGrant: {
|
||||
pluginId: "optional-demo",
|
||||
toolNames: ["optional_tool"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expectResolvedToolNames(tools, ["optional_tool"]);
|
||||
expect(ownerFactory).toHaveBeenCalledTimes(1);
|
||||
expect(foreignFactory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses declared names for an unnamed owner-scoped factory and preserves denies", () => {
|
||||
const factory = vi.fn(() => makeTool("optional_tool"));
|
||||
setRegistry([
|
||||
{
|
||||
pluginId: "optional-demo",
|
||||
optional: true,
|
||||
source: "/tmp/optional-demo.js",
|
||||
names: [],
|
||||
declaredNames: ["optional_tool"],
|
||||
factory,
|
||||
},
|
||||
]);
|
||||
const runtimePluginToolGrant = {
|
||||
pluginId: "optional-demo",
|
||||
toolNames: ["optional_tool"],
|
||||
} as const;
|
||||
|
||||
expectResolvedToolNames(
|
||||
resolvePluginTools(createResolveToolsParams({ runtimePluginToolGrant })),
|
||||
["optional_tool"],
|
||||
);
|
||||
expect(
|
||||
resolvePluginTools(
|
||||
createResolveToolsParams({
|
||||
runtimePluginToolGrant,
|
||||
toolDenylist: ["optional_tool"],
|
||||
}),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "allows optional tools by tool name",
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
type PluginToolDescriptorConfigCacheKeyMemo,
|
||||
writeCachedPluginToolDescriptors,
|
||||
} from "./tool-descriptor-cache.js";
|
||||
import { isPluginToolAllowed } from "./tool-grant-allowlist.js";
|
||||
import type { OpenClawPluginToolContext } from "./types.js";
|
||||
|
||||
/** MCP bridge metadata attached to plugin tools surfaced through agent tool lists. */
|
||||
@@ -352,8 +353,7 @@ function isOptionalToolAllowed(params: {
|
||||
if (params.allowlist.has("*")) {
|
||||
return true;
|
||||
}
|
||||
const toolName = normalizeToolName(params.toolName);
|
||||
if (params.allowlist.has(toolName)) {
|
||||
if (isPluginToolAllowed(params.allowlist, params.pluginId, params.toolName)) {
|
||||
return true;
|
||||
}
|
||||
const pluginKey = normalizeToolName(params.pluginId);
|
||||
@@ -381,7 +381,7 @@ function isOptionalToolEntryPotentiallyAllowed(params: {
|
||||
if (params.names.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return params.names.some((name) => params.allowlist.has(normalizeToolName(name)));
|
||||
return params.names.some((name) => isPluginToolAllowed(params.allowlist, params.pluginId, name));
|
||||
}
|
||||
|
||||
function readPluginToolName(tool: unknown): string {
|
||||
@@ -563,7 +563,7 @@ function listManifestToolNamesForAllowlist(params: {
|
||||
return [...params.toolNames];
|
||||
}
|
||||
const matchedToolNames = params.toolNames.filter((name) =>
|
||||
params.allowlist.has(normalizeToolName(name)),
|
||||
isPluginToolAllowed(params.allowlist, params.pluginId, name),
|
||||
);
|
||||
if (!allowlistIncludesDefaultPluginTools(params.allowlist)) {
|
||||
return matchedToolNames;
|
||||
|
||||
Reference in New Issue
Block a user