fix: add missing execution state for approved tool permissions (#11394)

This commit is contained in:
defi-failure 2025-11-22 21:45:42 +08:00 committed by GitHub
parent c1f1d7996d
commit ebfb1c5abf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 209 additions and 18 deletions

View File

@ -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) {

View File

@ -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) {

View File

@ -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) => {

View File

@ -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)",

View File

@ -266,6 +266,7 @@
"error": {
"sendFailed": "发送您的决定失败,请重试。"
},
"executing": "正在执行...",
"expired": "已过期",
"inputPreview": "工具输入预览",
"pending": "等待中 ({{seconds}}秒)",

View File

@ -266,6 +266,7 @@
"error": {
"sendFailed": "傳送您的決定失敗,請重試。"
},
"executing": "[to be translated]:Executing...",
"expired": "已過期",
"inputPreview": "工具輸入預覽",
"pending": "等待中 ({{seconds}}秒)",

View File

@ -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)",

View File

@ -266,6 +266,7 @@
"error": {
"sendFailed": "Αποτυχία αποστολής της απόφασής σας. Προσπαθήστε ξανά."
},
"executing": "[to be translated]:Executing...",
"expired": "Ληγμένο",
"inputPreview": "Προεπισκόπηση εισόδου εργαλείου",
"pending": "Εκκρεμεί ({{seconds}}δ)",

View File

@ -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)",

View File

@ -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)",

View File

@ -266,6 +266,7 @@
"error": {
"sendFailed": "決定の送信に失敗しました。もう一度お試しください。"
},
"executing": "[to be translated]:Executing...",
"expired": "期限切れ",
"inputPreview": "ツール入力プレビュー",
"pending": "保留中({{seconds}}秒)",

View File

@ -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)",

View File

@ -266,6 +266,7 @@
"error": {
"sendFailed": "Не удалось отправить ваше решение. Попробуйте ещё раз."
},
"executing": "[to be translated]:Executing...",
"expired": "Истёк",
"inputPreview": "Предварительный просмотр ввода инструмента",
"pending": "Ожидание ({{seconds}}с)",

View File

@ -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">

View File

@ -42,7 +42,8 @@ export const createCallbacks = (deps: CallbacksDependencies) => {
const toolCallbacks = createToolCallbacks({
blockManager,
assistantMsgId
assistantMsgId,
dispatch
})
const imageCallbacks = createImageCallbacks({

View File

@ -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)

View File

@ -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