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; }