Files
openclaw/src/agents/pi-tools.ts
2026-02-17 13:36:48 +09:00

497 lines
18 KiB
TypeScript

import {
codingTools,
createEditTool,
createReadTool,
createWriteTool,
readTool,
} from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../config/config.js";
import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
import { logWarn } from "../logger.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
import { resolveGatewayMessageChannel } from "../utils/message-channel.js";
import { resolveAgentConfig } from "./agent-scope.js";
import { createApplyPatchTool } from "./apply-patch.js";
import {
createExecTool,
createProcessTool,
type ExecToolDefaults,
type ProcessToolDefaults,
} from "./bash-tools.js";
import { listChannelAgentTools } from "./channel-tools.js";
import type { ModelAuthMode } from "./model-auth.js";
import { createOpenClawTools } from "./openclaw-tools.js";
import { wrapToolWithAbortSignal } from "./pi-tools.abort.js";
import { wrapToolWithBeforeToolCallHook } from "./pi-tools.before-tool-call.js";
import {
isToolAllowedByPolicies,
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
resolveSubagentToolPolicy,
} from "./pi-tools.policy.js";
import {
assertRequiredParams,
CLAUDE_PARAM_GROUPS,
createOpenClawReadTool,
createSandboxedEditTool,
createSandboxedReadTool,
createSandboxedWriteTool,
normalizeToolParams,
patchToolSchemaForClaudeCompatibility,
wrapToolWorkspaceRootGuard,
wrapToolParamNormalization,
} from "./pi-tools.read.js";
import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
import type { SandboxContext } from "./sandbox.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import {
applyToolPolicyPipeline,
buildDefaultToolPolicyPipelineSteps,
} from "./tool-policy-pipeline.js";
import {
applyOwnerOnlyToolPolicy,
collectExplicitAllowlist,
mergeAlsoAllowPolicy,
resolveToolProfilePolicy,
} from "./tool-policy.js";
import { resolveWorkspaceRoot } from "./workspace-dir.js";
function isOpenAIProvider(provider?: string) {
const normalized = provider?.trim().toLowerCase();
return normalized === "openai" || normalized === "openai-codex";
}
function isApplyPatchAllowedForModel(params: {
modelProvider?: string;
modelId?: string;
allowModels?: string[];
}) {
const allowModels = Array.isArray(params.allowModels) ? params.allowModels : [];
if (allowModels.length === 0) {
return true;
}
const modelId = params.modelId?.trim();
if (!modelId) {
return false;
}
const normalizedModelId = modelId.toLowerCase();
const provider = params.modelProvider?.trim().toLowerCase();
const normalizedFull =
provider && !normalizedModelId.includes("/")
? `${provider}/${normalizedModelId}`
: normalizedModelId;
return allowModels.some((entry) => {
const normalized = entry.trim().toLowerCase();
if (!normalized) {
return false;
}
return normalized === normalizedModelId || normalized === normalizedFull;
});
}
function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
const cfg = params.cfg;
const globalExec = cfg?.tools?.exec;
const agentExec =
cfg && params.agentId ? resolveAgentConfig(cfg, params.agentId)?.tools?.exec : undefined;
return {
host: agentExec?.host ?? globalExec?.host,
security: agentExec?.security ?? globalExec?.security,
ask: agentExec?.ask ?? globalExec?.ask,
node: agentExec?.node ?? globalExec?.node,
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
backgroundMs: agentExec?.backgroundMs ?? globalExec?.backgroundMs,
timeoutSec: agentExec?.timeoutSec ?? globalExec?.timeoutSec,
approvalRunningNoticeMs:
agentExec?.approvalRunningNoticeMs ?? globalExec?.approvalRunningNoticeMs,
cleanupMs: agentExec?.cleanupMs ?? globalExec?.cleanupMs,
notifyOnExit: agentExec?.notifyOnExit ?? globalExec?.notifyOnExit,
notifyOnExitEmptySuccess:
agentExec?.notifyOnExitEmptySuccess ?? globalExec?.notifyOnExitEmptySuccess,
applyPatch: agentExec?.applyPatch ?? globalExec?.applyPatch,
};
}
function resolveFsConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
const cfg = params.cfg;
const globalFs = cfg?.tools?.fs;
const agentFs =
cfg && params.agentId ? resolveAgentConfig(cfg, params.agentId)?.tools?.fs : undefined;
return {
workspaceOnly: agentFs?.workspaceOnly ?? globalFs?.workspaceOnly,
};
}
export function resolveToolLoopDetectionConfig(params: {
cfg?: OpenClawConfig;
agentId?: string;
}): ToolLoopDetectionConfig | undefined {
const global = params.cfg?.tools?.loopDetection;
const agent =
params.agentId && params.cfg
? resolveAgentConfig(params.cfg, params.agentId)?.tools?.loopDetection
: undefined;
if (!agent) {
return global;
}
if (!global) {
return agent;
}
return {
...global,
...agent,
detectors: {
...global.detectors,
...agent.detectors,
},
};
}
export const __testing = {
cleanToolSchemaForGemini,
normalizeToolParams,
patchToolSchemaForClaudeCompatibility,
wrapToolParamNormalization,
assertRequiredParams,
} as const;
export function createOpenClawCodingTools(options?: {
exec?: ExecToolDefaults & ProcessToolDefaults;
messageProvider?: string;
agentAccountId?: string;
messageTo?: string;
messageThreadId?: string | number;
sandbox?: SandboxContext | null;
sessionKey?: string;
agentDir?: string;
workspaceDir?: string;
config?: OpenClawConfig;
abortSignal?: AbortSignal;
/**
* Provider of the currently selected model (used for provider-specific tool quirks).
* Example: "anthropic", "openai", "google", "openai-codex".
*/
modelProvider?: string;
/** Model id for the current provider (used for model-specific tool gating). */
modelId?: string;
/**
* Auth mode for the current provider. We only need this for Anthropic OAuth
* tool-name blocking quirks.
*/
modelAuthMode?: ModelAuthMode;
/** Current channel ID for auto-threading (Slack). */
currentChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */
currentThreadTs?: string;
/** Group id for channel-level tool policy resolution. */
groupId?: string | null;
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
groupChannel?: string | null;
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
groupSpace?: string | null;
/** Parent session key for subagent group policy inheritance. */
spawnedBy?: string | null;
senderId?: string | null;
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
/** Reply-to mode for Slack auto-threading. */
replyToMode?: "off" | "first" | "all";
/** Mutable ref to track if a reply was sent (for "first" mode). */
hasRepliedRef?: { value: boolean };
/** If true, the model has native vision capability */
modelHasVision?: boolean;
/** Require explicit message targets (no implicit last-route sends). */
requireExplicitMessageTarget?: boolean;
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
/** Whether the sender is an owner (required for owner-only tools). */
senderIsOwner?: boolean;
}): AnyAgentTool[] {
const execToolName = "exec";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
const {
agentId,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
profile,
providerProfile,
profileAlsoAllow,
providerProfileAlsoAllow,
} = resolveEffectiveToolPolicy({
config: options?.config,
sessionKey: options?.sessionKey,
modelProvider: options?.modelProvider,
modelId: options?.modelId,
});
const groupPolicy = resolveGroupToolPolicy({
config: options?.config,
sessionKey: options?.sessionKey,
spawnedBy: options?.spawnedBy,
messageProvider: options?.messageProvider,
groupId: options?.groupId,
groupChannel: options?.groupChannel,
groupSpace: options?.groupSpace,
accountId: options?.agentAccountId,
senderId: options?.senderId,
senderName: options?.senderName,
senderUsername: options?.senderUsername,
senderE164: options?.senderE164,
});
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, profileAlsoAllow);
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(
providerProfilePolicy,
providerProfileAlsoAllow,
);
// Prefer sessionKey for process isolation scope to prevent cross-session process visibility/killing.
// Fallback to agentId if no sessionKey is available (e.g. legacy or global contexts).
const scopeKey =
options?.exec?.scopeKey ?? options?.sessionKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentPolicy =
isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
? resolveSubagentToolPolicy(
options.config,
getSubagentDepthFromSessionStore(options.sessionKey, { cfg: options.config }),
)
: undefined;
const allowBackground = isToolAllowedByPolicies("process", [
profilePolicyWithAlsoAllow,
providerProfilePolicyWithAlsoAllow,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
sandbox?.tools,
subagentPolicy,
]);
const execConfig = resolveExecConfig({ cfg: options?.config, agentId });
const fsConfig = resolveFsConfig({ cfg: options?.config, agentId });
const sandboxRoot = sandbox?.workspaceDir;
const sandboxFsBridge = sandbox?.fsBridge;
const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro";
const workspaceRoot = resolveWorkspaceRoot(options?.workspaceDir);
const workspaceOnly = fsConfig.workspaceOnly === true;
const applyPatchConfig = execConfig.applyPatch;
// Secure by default: apply_patch is workspace-contained unless explicitly disabled.
// (tools.fs.workspaceOnly is a separate umbrella flag for read/write/edit/apply_patch.)
const applyPatchWorkspaceOnly = workspaceOnly || applyPatchConfig?.workspaceOnly !== false;
const applyPatchEnabled =
!!applyPatchConfig?.enabled &&
isOpenAIProvider(options?.modelProvider) &&
isApplyPatchAllowedForModel({
modelProvider: options?.modelProvider,
modelId: options?.modelId,
allowModels: applyPatchConfig?.allowModels,
});
if (sandboxRoot && !sandboxFsBridge) {
throw new Error("Sandbox filesystem bridge is unavailable.");
}
const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => {
if (tool.name === readTool.name) {
if (sandboxRoot) {
const sandboxed = createSandboxedReadTool({
root: sandboxRoot,
bridge: sandboxFsBridge!,
});
return [workspaceOnly ? wrapToolWorkspaceRootGuard(sandboxed, sandboxRoot) : sandboxed];
}
const freshReadTool = createReadTool(workspaceRoot);
const wrapped = createOpenClawReadTool(freshReadTool);
return [workspaceOnly ? wrapToolWorkspaceRootGuard(wrapped, workspaceRoot) : wrapped];
}
if (tool.name === "bash" || tool.name === execToolName) {
return [];
}
if (tool.name === "write") {
if (sandboxRoot) {
return [];
}
// Wrap with param normalization for Claude Code compatibility
const wrapped = wrapToolParamNormalization(
createWriteTool(workspaceRoot),
CLAUDE_PARAM_GROUPS.write,
);
return [workspaceOnly ? wrapToolWorkspaceRootGuard(wrapped, workspaceRoot) : wrapped];
}
if (tool.name === "edit") {
if (sandboxRoot) {
return [];
}
// Wrap with param normalization for Claude Code compatibility
const wrapped = wrapToolParamNormalization(
createEditTool(workspaceRoot),
CLAUDE_PARAM_GROUPS.edit,
);
return [workspaceOnly ? wrapToolWorkspaceRootGuard(wrapped, workspaceRoot) : wrapped];
}
return [tool];
});
const { cleanupMs: cleanupMsOverride, ...execDefaults } = options?.exec ?? {};
const execTool = createExecTool({
...execDefaults,
host: options?.exec?.host ?? execConfig.host,
security: options?.exec?.security ?? execConfig.security,
ask: options?.exec?.ask ?? execConfig.ask,
node: options?.exec?.node ?? execConfig.node,
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
safeBins: options?.exec?.safeBins ?? execConfig.safeBins,
agentId,
cwd: workspaceRoot,
allowBackground,
scopeKey,
sessionKey: options?.sessionKey,
messageProvider: options?.messageProvider,
backgroundMs: options?.exec?.backgroundMs ?? execConfig.backgroundMs,
timeoutSec: options?.exec?.timeoutSec ?? execConfig.timeoutSec,
approvalRunningNoticeMs:
options?.exec?.approvalRunningNoticeMs ?? execConfig.approvalRunningNoticeMs,
notifyOnExit: options?.exec?.notifyOnExit ?? execConfig.notifyOnExit,
notifyOnExitEmptySuccess:
options?.exec?.notifyOnExitEmptySuccess ?? execConfig.notifyOnExitEmptySuccess,
sandbox: sandbox
? {
containerName: sandbox.containerName,
workspaceDir: sandbox.workspaceDir,
containerWorkdir: sandbox.containerWorkdir,
env: sandbox.docker.env,
}
: undefined,
});
const processTool = createProcessTool({
cleanupMs: cleanupMsOverride ?? execConfig.cleanupMs,
scopeKey,
});
const applyPatchTool =
!applyPatchEnabled || (sandboxRoot && !allowWorkspaceWrites)
? null
: createApplyPatchTool({
cwd: sandboxRoot ?? workspaceRoot,
sandbox:
sandboxRoot && allowWorkspaceWrites
? { root: sandboxRoot, bridge: sandboxFsBridge! }
: undefined,
workspaceOnly: applyPatchWorkspaceOnly,
});
const tools: AnyAgentTool[] = [
...base,
...(sandboxRoot
? allowWorkspaceWrites
? [
workspaceOnly
? wrapToolWorkspaceRootGuard(
createSandboxedEditTool({ root: sandboxRoot, bridge: sandboxFsBridge! }),
sandboxRoot,
)
: createSandboxedEditTool({ root: sandboxRoot, bridge: sandboxFsBridge! }),
workspaceOnly
? wrapToolWorkspaceRootGuard(
createSandboxedWriteTool({ root: sandboxRoot, bridge: sandboxFsBridge! }),
sandboxRoot,
)
: createSandboxedWriteTool({ root: sandboxRoot, bridge: sandboxFsBridge! }),
]
: []
: []),
...(applyPatchTool ? [applyPatchTool as unknown as AnyAgentTool] : []),
execTool as unknown as AnyAgentTool,
processTool as unknown as AnyAgentTool,
// Channel docking: include channel-defined agent tools (login, etc.).
...listChannelAgentTools({ cfg: options?.config }),
...createOpenClawTools({
sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl,
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
agentSessionKey: options?.sessionKey,
agentChannel: resolveGatewayMessageChannel(options?.messageProvider),
agentAccountId: options?.agentAccountId,
agentTo: options?.messageTo,
agentThreadId: options?.messageThreadId,
agentGroupId: options?.groupId ?? null,
agentGroupChannel: options?.groupChannel ?? null,
agentGroupSpace: options?.groupSpace ?? null,
agentDir: options?.agentDir,
sandboxRoot,
sandboxFsBridge,
workspaceDir: workspaceRoot,
sandboxed: !!sandbox,
config: options?.config,
pluginToolAllowlist: collectExplicitAllowlist([
profilePolicy,
providerProfilePolicy,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
sandbox?.tools,
subagentPolicy,
]),
currentChannelId: options?.currentChannelId,
currentThreadTs: options?.currentThreadTs,
replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef,
modelHasVision: options?.modelHasVision,
requireExplicitMessageTarget: options?.requireExplicitMessageTarget,
disableMessageTool: options?.disableMessageTool,
requesterAgentIdOverride: agentId,
}),
];
// Security: treat unknown/undefined as unauthorized (opt-in, not opt-out)
const senderIsOwner = options?.senderIsOwner === true;
const toolsByAuthorization = applyOwnerOnlyToolPolicy(tools, senderIsOwner);
const subagentFiltered = applyToolPolicyPipeline({
tools: toolsByAuthorization,
toolMeta: (tool) => getPluginToolMeta(tool),
warn: logWarn,
steps: [
...buildDefaultToolPolicyPipelineSteps({
profilePolicy: profilePolicyWithAlsoAllow,
profile,
providerProfilePolicy: providerProfilePolicyWithAlsoAllow,
providerProfile,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
agentId,
}),
{ policy: sandbox?.tools, label: "sandbox tools.allow" },
{ policy: subagentPolicy, label: "subagent tools.allow" },
],
});
// Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai.
// Without this, some providers (notably OpenAI) will reject root-level union schemas.
// Provider-specific cleaning: Gemini needs constraint keywords stripped, but Anthropic expects them.
const normalized = subagentFiltered.map((tool) =>
normalizeToolParameters(tool, { modelProvider: options?.modelProvider }),
);
const withHooks = normalized.map((tool) =>
wrapToolWithBeforeToolCallHook(tool, {
agentId,
sessionKey: options?.sessionKey,
loopDetection: resolveToolLoopDetectionConfig({ cfg: options?.config, agentId }),
}),
);
const withAbort = options?.abortSignal
? withHooks.map((tool) => wrapToolWithAbortSignal(tool, options.abortSignal))
: withHooks;
// NOTE: Keep canonical (lowercase) tool names here.
// pi-ai's Anthropic OAuth transport remaps tool names to Claude Code-style names
// on the wire and maps them back for tool dispatch.
return withAbort;
}