mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 03:31:24 +08:00
fix: add missing execution state for approved tool permissions (#11394)
This commit is contained in:
parent
c1f1d7996d
commit
ebfb1c5abf
@ -2,7 +2,14 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import type { CanUseTool, McpHttpServerConfig, Options, SDKMessage } from '@anthropic-ai/claude-agent-sdk'
|
||||
import type {
|
||||
CanUseTool,
|
||||
HookCallback,
|
||||
McpHttpServerConfig,
|
||||
Options,
|
||||
PreToolUseHookInput,
|
||||
SDKMessage
|
||||
} from '@anthropic-ai/claude-agent-sdk'
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk'
|
||||
import { loggerService } from '@logger'
|
||||
import { config as apiConfigService } from '@main/apiServer/config'
|
||||
@ -157,6 +164,63 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
})
|
||||
}
|
||||
|
||||
const preToolUseHook: HookCallback = async (input, toolUseID, options) => {
|
||||
// Type guard to ensure we're handling PreToolUse event
|
||||
if (input.hook_event_name !== 'PreToolUse') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const hookInput = input as PreToolUseHookInput
|
||||
const toolName = hookInput.tool_name
|
||||
|
||||
logger.debug('PreToolUse hook triggered', {
|
||||
session_id: hookInput.session_id,
|
||||
tool_name: hookInput.tool_name,
|
||||
tool_use_id: toolUseID,
|
||||
tool_input: hookInput.tool_input,
|
||||
cwd: hookInput.cwd,
|
||||
permission_mode: hookInput.permission_mode,
|
||||
autoAllowTools: autoAllowTools
|
||||
})
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
logger.debug('PreToolUse hook signal already aborted; skipping tool use', {
|
||||
tool_name: hookInput.tool_name
|
||||
})
|
||||
return {}
|
||||
}
|
||||
|
||||
// handle auto approved tools since it never triggers canUseTool
|
||||
const normalizedToolName = normalizeToolName(toolName)
|
||||
if (toolUseID) {
|
||||
const bypassAll = input.permission_mode === 'bypassPermissions'
|
||||
const autoAllowed = autoAllowTools.has(toolName) || autoAllowTools.has(normalizedToolName)
|
||||
if (bypassAll || autoAllowed) {
|
||||
const namespacedToolCallId = buildNamespacedToolCallId(session.id, toolUseID)
|
||||
logger.debug('handling auto approved tools', {
|
||||
toolName,
|
||||
normalizedToolName,
|
||||
namespacedToolCallId,
|
||||
permission_mode: input.permission_mode,
|
||||
autoAllowTools
|
||||
})
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => {
|
||||
return !!v && typeof v === 'object' && !Array.isArray(v)
|
||||
}
|
||||
const toolInput = isRecord(input.tool_input) ? input.tool_input : {}
|
||||
|
||||
await promptForToolApproval(toolName, toolInput, {
|
||||
...options,
|
||||
toolCallId: namespacedToolCallId,
|
||||
autoApprove: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Return to proceed without modification
|
||||
return {}
|
||||
}
|
||||
|
||||
// Build SDK options from parameters
|
||||
const options: Options = {
|
||||
abortController,
|
||||
@ -180,7 +244,14 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
permissionMode: session.configuration?.permission_mode,
|
||||
maxTurns: session.configuration?.max_turns,
|
||||
allowedTools: session.allowed_tools,
|
||||
canUseTool
|
||||
canUseTool,
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [preToolUseHook]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
if (session.accessible_paths.length > 1) {
|
||||
|
||||
@ -31,6 +31,7 @@ type PendingPermissionRequest = {
|
||||
abortListener?: () => void
|
||||
originalInput: Record<string, unknown>
|
||||
toolName: string
|
||||
toolCallId?: string
|
||||
}
|
||||
|
||||
type RendererPermissionRequestPayload = {
|
||||
@ -45,6 +46,7 @@ type RendererPermissionRequestPayload = {
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
suggestions: PermissionUpdate[]
|
||||
autoApprove?: boolean
|
||||
}
|
||||
|
||||
type RendererPermissionResultPayload = {
|
||||
@ -52,6 +54,7 @@ type RendererPermissionResultPayload = {
|
||||
behavior: ToolPermissionBehavior
|
||||
message?: string
|
||||
reason: 'response' | 'timeout' | 'aborted' | 'no-window'
|
||||
toolCallId?: string
|
||||
}
|
||||
|
||||
const pendingRequests = new Map<string, PendingPermissionRequest>()
|
||||
@ -145,7 +148,8 @@ const finalizeRequest = (
|
||||
requestId,
|
||||
behavior: update.behavior,
|
||||
message: update.behavior === 'deny' ? update.message : undefined,
|
||||
reason
|
||||
reason,
|
||||
toolCallId: pending.toolCallId
|
||||
}
|
||||
|
||||
const dispatched = broadcastToRenderer(IpcChannel.AgentToolPermission_Result, resultPayload)
|
||||
@ -210,6 +214,7 @@ const ensureIpcHandlersRegistered = () => {
|
||||
type PromptForToolApprovalOptions = {
|
||||
signal: AbortSignal
|
||||
suggestions?: PermissionUpdate[]
|
||||
autoApprove?: boolean
|
||||
|
||||
// NOTICE: This ID is namespaced with session ID, not the raw SDK tool call ID.
|
||||
// Format: `${sessionId}:${rawToolCallId}`, e.g., `session_123:WebFetch_0`
|
||||
@ -270,7 +275,8 @@ export async function promptForToolApproval(
|
||||
inputPreview,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
suggestions: sanitizedSuggestions
|
||||
suggestions: sanitizedSuggestions,
|
||||
autoApprove: options.autoApprove
|
||||
}
|
||||
|
||||
const defaultDenyUpdate: PermissionResult = { behavior: 'deny', message: 'Tool request aborted before user decision' }
|
||||
@ -299,7 +305,8 @@ export async function promptForToolApproval(
|
||||
timeout,
|
||||
originalInput: sanitizedInput,
|
||||
toolName,
|
||||
signal: options?.signal
|
||||
signal: options?.signal,
|
||||
toolCallId: options.toolCallId
|
||||
}
|
||||
|
||||
if (options?.signal) {
|
||||
|
||||
@ -175,14 +175,46 @@ export function useAppInit() {
|
||||
useEffect(() => {
|
||||
if (!window.electron?.ipcRenderer) return
|
||||
|
||||
const requestListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionRequestPayload) => {
|
||||
const requestListener = async (_event: Electron.IpcRendererEvent, payload: ToolPermissionRequestPayload) => {
|
||||
logger.debug('Renderer received tool permission request', {
|
||||
requestId: payload.requestId,
|
||||
toolName: payload.toolName,
|
||||
expiresAt: payload.expiresAt,
|
||||
suggestionCount: payload.suggestions.length
|
||||
suggestionCount: payload.suggestions.length,
|
||||
autoApprove: payload.autoApprove
|
||||
})
|
||||
dispatch(toolPermissionsActions.requestReceived(payload))
|
||||
|
||||
// Auto-approve if requested
|
||||
if (payload.autoApprove) {
|
||||
logger.debug('Auto-approving tool permission request', {
|
||||
requestId: payload.requestId,
|
||||
toolName: payload.toolName
|
||||
})
|
||||
|
||||
dispatch(toolPermissionsActions.submissionSent({ requestId: payload.requestId, behavior: 'allow' }))
|
||||
|
||||
try {
|
||||
const response = await window.api.agentTools.respondToPermission({
|
||||
requestId: payload.requestId,
|
||||
behavior: 'allow',
|
||||
updatedInput: payload.input,
|
||||
updatedPermissions: payload.suggestions
|
||||
})
|
||||
|
||||
if (!response?.success) {
|
||||
throw new Error('Auto-approval response rejected by main process')
|
||||
}
|
||||
|
||||
logger.debug('Auto-approval acknowledged by main process', {
|
||||
requestId: payload.requestId,
|
||||
toolName: payload.toolName
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to send auto-approval response', error as Error)
|
||||
dispatch(toolPermissionsActions.submissionFailed({ requestId: payload.requestId }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resultListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionResultPayload) => {
|
||||
|
||||
@ -266,6 +266,7 @@
|
||||
"error": {
|
||||
"sendFailed": "Failed to send your decision. Please try again."
|
||||
},
|
||||
"executing": "Executing...",
|
||||
"expired": "Expired",
|
||||
"inputPreview": "Tool input preview",
|
||||
"pending": "Pending ({{seconds}}s)",
|
||||
|
||||
@ -266,6 +266,7 @@
|
||||
"error": {
|
||||
"sendFailed": "发送您的决定失败,请重试。"
|
||||
},
|
||||
"executing": "正在执行...",
|
||||
"expired": "已过期",
|
||||
"inputPreview": "工具输入预览",
|
||||
"pending": "等待中 ({{seconds}}秒)",
|
||||
|
||||
@ -266,6 +266,7 @@
|
||||
"error": {
|
||||
"sendFailed": "傳送您的決定失敗,請重試。"
|
||||
},
|
||||
"executing": "[to be translated]:Executing...",
|
||||
"expired": "已過期",
|
||||
"inputPreview": "工具輸入預覽",
|
||||
"pending": "等待中 ({{seconds}}秒)",
|
||||
|
||||
@ -266,6 +266,7 @@
|
||||
"error": {
|
||||
"sendFailed": "Ihre Entscheidung konnte nicht gesendet werden. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"executing": "[to be translated]:Executing...",
|
||||
"expired": "Abgelaufen",
|
||||
"inputPreview": "Vorschau der Werkzeugeingabe",
|
||||
"pending": "Ausstehend ({{seconds}}s)",
|
||||
|
||||
@ -266,6 +266,7 @@
|
||||
"error": {
|
||||
"sendFailed": "Αποτυχία αποστολής της απόφασής σας. Προσπαθήστε ξανά."
|
||||
},
|
||||
"executing": "[to be translated]:Executing...",
|
||||
"expired": "Ληγμένο",
|
||||
"inputPreview": "Προεπισκόπηση εισόδου εργαλείου",
|
||||
"pending": "Εκκρεμεί ({{seconds}}δ)",
|
||||
|
||||
@ -266,6 +266,7 @@
|
||||
"error": {
|
||||
"sendFailed": "No se pudo enviar tu decisión. Por favor, inténtalo de nuevo."
|
||||
},
|
||||
"executing": "[to be translated]:Executing...",
|
||||
"expired": "Caducado",
|
||||
"inputPreview": "Vista previa de entrada de herramienta",
|
||||
"pending": "Pendiente ({{seconds}}s)",
|
||||
|
||||
@ -266,6 +266,7 @@
|
||||
"error": {
|
||||
"sendFailed": "Échec de l'envoi de votre décision. Veuillez réessayer."
|
||||
},
|
||||
"executing": "[to be translated]:Executing...",
|
||||
"expired": "Expiré",
|
||||
"inputPreview": "Aperçu de l'entrée de l'outil",
|
||||
"pending": "En attente ({{seconds}}s)",
|
||||
|
||||
@ -266,6 +266,7 @@
|
||||
"error": {
|
||||
"sendFailed": "決定の送信に失敗しました。もう一度お試しください。"
|
||||
},
|
||||
"executing": "[to be translated]:Executing...",
|
||||
"expired": "期限切れ",
|
||||
"inputPreview": "ツール入力プレビュー",
|
||||
"pending": "保留中({{seconds}}秒)",
|
||||
|
||||
@ -266,6 +266,7 @@
|
||||
"error": {
|
||||
"sendFailed": "Falha ao enviar sua decisão. Por favor, tente novamente."
|
||||
},
|
||||
"executing": "[to be translated]:Executing...",
|
||||
"expired": "Expirado",
|
||||
"inputPreview": "Pré-visualização da entrada da ferramenta",
|
||||
"pending": "Pendente ({{seconds}}s)",
|
||||
|
||||
@ -266,6 +266,7 @@
|
||||
"error": {
|
||||
"sendFailed": "Не удалось отправить ваше решение. Попробуйте ещё раз."
|
||||
},
|
||||
"executing": "[to be translated]:Executing...",
|
||||
"expired": "Истёк",
|
||||
"inputPreview": "Предварительный просмотр ввода инструмента",
|
||||
"pending": "Ожидание ({{seconds}}с)",
|
||||
|
||||
@ -3,7 +3,7 @@ import { loggerService } from '@logger'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { selectPendingPermission, toolPermissionsActions } from '@renderer/store/toolPermissions'
|
||||
import type { NormalToolResponse } from '@renderer/types'
|
||||
import { Button } from 'antd'
|
||||
import { Button, Spin } from 'antd'
|
||||
import { ChevronDown, CirclePlay, CircleX } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -52,6 +52,7 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
|
||||
const isSubmittingAllow = request?.status === 'submitting-allow'
|
||||
const isSubmittingDeny = request?.status === 'submitting-deny'
|
||||
const isSubmitting = isSubmittingAllow || isSubmittingDeny
|
||||
const isInvoking = request?.status === 'invoking'
|
||||
|
||||
const handleDecision = useCallback(
|
||||
async (
|
||||
@ -113,6 +114,53 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
if (isInvoking) {
|
||||
return (
|
||||
<div className="w-full max-w-xl rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spin size="small" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold text-default-700 text-sm">{request.toolName}</div>
|
||||
<div className="text-default-500 text-xs">{t('agent.toolPermission.executing')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{request.inputPreview && (
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
aria-label={
|
||||
showDetails
|
||||
? t('agent.toolPermission.aria.hideDetails')
|
||||
: t('agent.toolPermission.aria.showDetails')
|
||||
}
|
||||
className="h-8 text-default-600 transition-colors hover:bg-default-200/50 hover:text-default-800"
|
||||
onClick={() => setShowDetails((value) => !value)}
|
||||
icon={<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />}
|
||||
variant="text"
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDetails && request.inputPreview && (
|
||||
<div className="flex flex-col gap-3 border-default-200 border-t pt-3">
|
||||
<div className="rounded-md border border-default-200 bg-default-100 p-3">
|
||||
<p className="mb-2 font-medium text-default-400 text-xs uppercase tracking-wide">
|
||||
{t('agent.toolPermission.inputPreview')}
|
||||
</p>
|
||||
<div className="max-h-[192px] overflow-auto font-mono text-xs">
|
||||
<pre className="whitespace-pre-wrap break-all p-2 text-left">{request.inputPreview}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-xl rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
@ -42,7 +42,8 @@ export const createCallbacks = (deps: CallbacksDependencies) => {
|
||||
|
||||
const toolCallbacks = createToolCallbacks({
|
||||
blockManager,
|
||||
assistantMsgId
|
||||
assistantMsgId,
|
||||
dispatch
|
||||
})
|
||||
|
||||
const imageCallbacks = createImageCallbacks({
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { AppDispatch } from '@renderer/store'
|
||||
import { toolPermissionsActions } from '@renderer/store/toolPermissions'
|
||||
import type { MCPToolResponse } from '@renderer/types'
|
||||
import { WebSearchSource } from '@renderer/types'
|
||||
import type { ToolMessageBlock } from '@renderer/types/newMessage'
|
||||
@ -12,10 +14,11 @@ const logger = loggerService.withContext('ToolCallbacks')
|
||||
interface ToolCallbacksDependencies {
|
||||
blockManager: BlockManager
|
||||
assistantMsgId: string
|
||||
dispatch: AppDispatch
|
||||
}
|
||||
|
||||
export const createToolCallbacks = (deps: ToolCallbacksDependencies) => {
|
||||
const { blockManager, assistantMsgId } = deps
|
||||
const { blockManager, assistantMsgId, dispatch } = deps
|
||||
|
||||
// 内部维护的状态
|
||||
const toolCallIdToBlockIdMap = new Map<string, string>()
|
||||
@ -53,6 +56,9 @@ export const createToolCallbacks = (deps: ToolCallbacksDependencies) => {
|
||||
},
|
||||
|
||||
onToolCallComplete: (toolResponse: MCPToolResponse) => {
|
||||
if (toolResponse?.id) {
|
||||
dispatch(toolPermissionsActions.removeByToolCallId({ toolCallId: toolResponse.id }))
|
||||
}
|
||||
const existingBlockId = toolCallIdToBlockIdMap.get(toolResponse.id)
|
||||
toolCallIdToBlockIdMap.delete(toolResponse.id)
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ export type ToolPermissionRequestPayload = {
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
suggestions: PermissionUpdate[]
|
||||
autoApprove?: boolean
|
||||
}
|
||||
|
||||
export type ToolPermissionResultPayload = {
|
||||
@ -21,9 +22,10 @@ export type ToolPermissionResultPayload = {
|
||||
behavior: 'allow' | 'deny'
|
||||
message?: string
|
||||
reason: 'response' | 'timeout' | 'aborted' | 'no-window'
|
||||
toolCallId?: string
|
||||
}
|
||||
|
||||
export type ToolPermissionStatus = 'pending' | 'submitting-allow' | 'submitting-deny'
|
||||
export type ToolPermissionStatus = 'pending' | 'submitting-allow' | 'submitting-deny' | 'invoking'
|
||||
|
||||
export type ToolPermissionEntry = ToolPermissionRequestPayload & {
|
||||
status: ToolPermissionStatus
|
||||
@ -61,8 +63,24 @@ const toolPermissionsSlice = createSlice({
|
||||
entry.status = 'pending'
|
||||
},
|
||||
requestResolved: (state, action: PayloadAction<ToolPermissionResultPayload>) => {
|
||||
const { requestId } = action.payload
|
||||
delete state.requests[requestId]
|
||||
const { requestId, behavior } = action.payload
|
||||
const entry = state.requests[requestId]
|
||||
|
||||
if (!entry) return
|
||||
|
||||
if (behavior === 'allow') {
|
||||
entry.status = 'invoking'
|
||||
} else {
|
||||
delete state.requests[requestId]
|
||||
}
|
||||
},
|
||||
removeByToolCallId: (state, action: PayloadAction<{ toolCallId: string }>) => {
|
||||
const { toolCallId } = action.payload
|
||||
|
||||
const entryId = Object.keys(state.requests).find((key) => state.requests[key]?.toolCallId === toolCallId)
|
||||
if (entryId) {
|
||||
delete state.requests[entryId]
|
||||
}
|
||||
},
|
||||
clearAll: (state) => {
|
||||
state.requests = {}
|
||||
@ -73,8 +91,8 @@ const toolPermissionsSlice = createSlice({
|
||||
export const toolPermissionsActions = toolPermissionsSlice.actions
|
||||
|
||||
export const selectActiveToolPermission = (state: ToolPermissionsState): ToolPermissionEntry | null => {
|
||||
const activeEntries = Object.values(state.requests).filter(
|
||||
(entry) => entry.status === 'pending' || entry.status === 'submitting-allow' || entry.status === 'submitting-deny'
|
||||
const activeEntries = Object.values(state.requests).filter((entry) =>
|
||||
['pending', 'submitting-allow', 'submitting-deny', 'invoking'].includes(entry.status)
|
||||
)
|
||||
|
||||
if (activeEntries.length === 0) return null
|
||||
@ -89,9 +107,7 @@ export const selectPendingPermission = (
|
||||
): ToolPermissionEntry | undefined => {
|
||||
const activeEntries = Object.values(state.requests)
|
||||
.filter((entry) => entry.toolCallId === toolCallId)
|
||||
.filter(
|
||||
(entry) => entry.status === 'pending' || entry.status === 'submitting-allow' || entry.status === 'submitting-deny'
|
||||
)
|
||||
.filter((entry) => ['pending', 'submitting-allow', 'submitting-deny', 'invoking'].includes(entry.status))
|
||||
|
||||
if (activeEntries.length === 0) return undefined
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user