diff --git a/src-tauri/src/commands/ai/audit/approval.rs b/src-tauri/src/commands/ai/audit/approval.rs index d9cdf08..d4b0463 100644 --- a/src-tauri/src/commands/ai/audit/approval.rs +++ b/src-tauri/src/commands/ai/audit/approval.rs @@ -7,7 +7,7 @@ use std::sync::Arc; -use tauri::AppHandle; +use tauri::{AppHandle, Manager}; use tokio::sync::Mutex; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; @@ -16,6 +16,7 @@ use df_storage::crud::AiToolExecutionRepo; use df_storage::db::Database; use super::cache::{cache_hit_warning, find_cached_high_risk_result, pending_placeholder_for}; +use super::approval_expires_at_millis; use super::super::event_bus::emit_ai_chat_event; use super::diff::build_write_file_diff; use super::reason::build_approval_reason; @@ -205,6 +206,12 @@ pub(super) async fn insert_pending_approval( } // 慢操作锁外:拼 reason(DB 读)+ emit + 审计落 pending 纪录(DB 写) let reason = build_approval_reason(&draft.name, &args, risk_level, db).await; + // 审批超时收敛单一真值=后端(arm_approval_timeout_timers + cleanup 惰性兜底,前端本地 + // 自动拒绝定时器已退役 2026-10-25):到期时刻随事件透传,前端倒计时只读不算。 + let expires_at_millis = approval_expires_at_millis( + app_handle.state::().inner(), + std::time::SystemTime::now(), + ); // L3 emit 双写:Med/High 风险审批挂起 AiApprovalRequired 双路发布(tunnel 透传 miniapp 弹审批窗)。 let ev = AiChatEvent::AiApprovalRequired { id: draft.id.clone(), @@ -213,6 +220,7 @@ pub(super) async fn insert_pending_approval( reason, diff: approval_diff, conversation_id: Some(conv_id.to_string()), + expires_at_millis, }; emit_ai_chat_event(&app_handle, ev); // P1 bg 化:审批挂起需用户介入,主窗隐藏到托盘时系统通知补位(条件发送,失败静默)。 diff --git a/src-tauri/src/commands/ai/audit/path_auth.rs b/src-tauri/src/commands/ai/audit/path_auth.rs index d70c840..a8e78b2 100644 --- a/src-tauri/src/commands/ai/audit/path_auth.rs +++ b/src-tauri/src/commands/ai/audit/path_auth.rs @@ -8,11 +8,12 @@ //! process_tool_calls 通过 `use path_auth::check_file_tool_auth` 裸名调用。 //! 测试模块通过 `super::check_file_tool_auth` 引用。 -use super::PathAuthRequest; +use crate::commands::ai::PathAuthRequest; -/// 「信任所有目录」总开关 KV key(app_settings,bool,默认关)。 -/// 开启后白名单外路径不再挂起授权弹窗,黑名单拦截也一并放行(产品拍板:体验优先, -/// 用户知情选择,后期再收敛)。前端设置页读写同一 key。 +/// 「信任所有目录」总开关 KV key(app_settings)。 +/// 【已退役】授权目录白名单机制退役为默认全放行(2026-10-25 用户拍板),存量 KV 一律忽略, +/// audit 层 bypass 恒开不再读本键。壳保留作恢复点,前端设置页管理入口已撤。 +#[allow(dead_code)] pub(crate) const PATH_AUTH_BYPASS_KEY: &str = "df-ai-path-auth-bypass"; /// 黑名单放行的进程级标志。validate_path(run_command 等)所在调用链无 bypass 参数, diff --git a/src-tauri/src/commands/ai/audit/restore.rs b/src-tauri/src/commands/ai/audit/restore.rs index 9ae13a0..bb9a234 100644 --- a/src-tauri/src/commands/ai/audit/restore.rs +++ b/src-tauri/src/commands/ai/audit/restore.rs @@ -114,7 +114,7 @@ pub async fn restore_pending_approvals(state: &AppState) { #[cfg(test)] mod tests_multi_conv_restore { - use super::super::{ApprovalKind, PendingApproval}; + use crate::commands::ai::{ApprovalKind, PendingApproval}; use super::*; use crate::commands::ai::AiSession; @@ -173,14 +173,7 @@ mod tests_multi_conv_restore { // 不变量 2:pending_approvals HashMap 4 条(含无主),conversation_id 保留(业务语义)。 // 全部进单表 pending_approvals(原 risk_pending 合一,path 审批不恢复)。 assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入 pending_approvals"); - // 构造期恒等式:上方 pending_rows 构造的 4 条 PendingApproval 全部 kind=Risk(本测试构造 - // 时未插入任何 Path(_)),故 filter Path(_) 计数必为 0。这是构造恒等式而非外部不变量—— - // 对齐真实 restore_pending_approvals 的构造(实现 L78 一律 kind=Risk,无 Path 分支)。 - // 语义:首次 restore 无历史(Path 审批是会话级状态不恢复,恢复侧构造时本就不产出 Path)。 - let path_restored = session.pending_approvals.values() - .filter(|a| matches!(a.kind, ApprovalKind::Path(_))) - .count(); - assert_eq!(path_restored, 0, "构造期恒等式:恢复侧构造全部 kind=Risk,无 Path(首次 restore 无历史)"); + // path_auth 退役(2026-10-25):ApprovalKind::Path 变体已删,恢复侧构造恒 kind=Risk。 let conv_a_pending: Vec<_> = session .pending_approvals .values() diff --git a/src-tauri/src/state/allowed_dirs.rs b/src-tauri/src/state/allowed_dirs.rs index f082422..16adfdc 100644 --- a/src-tauri/src/state/allowed_dirs.rs +++ b/src-tauri/src/state/allowed_dirs.rs @@ -98,7 +98,7 @@ impl AllowedDirs { /// /// `check_path_authorization` 返回本枚举,process_tool_calls 据此决定: /// - `Authorized`:路径已授权 → 走原 Low/Med/High 流程 -/// - `NeedsAuthorization`:路径未授权但非黑名单 → Phase B 挂起弹窗(emit AiDirAuthRequired) +/// - `NeedsAuthorization`:路径未授权但非黑名单 → 挂起弹窗(已随 path_auth 退役) /// - `Denied`:路径命中黑名单 → 硬拒(工具返 Err tool_result,不挂起) #[derive(Debug, Clone, PartialEq, Eq)] pub enum PathAuthDecision { diff --git a/src/components/layout/AppLayout.vue b/src/components/layout/AppLayout.vue index cbf060e..04edc50 100644 --- a/src/components/layout/AppLayout.vue +++ b/src/components/layout/AppLayout.vue @@ -44,11 +44,11 @@ const appSettings = useAppSettingsStore() // 持久化到 localStorage(轻量、同步、读早于 appSettings.loadAll 完成,避免闪烁)。 // 各列表页(Tasks/Ideas/...)的筛选恢复由各自 agent 读写各自 localStorage key; // 此处只负责「上次所在页面 + query」级别的恢复(如 /tasks?status=done)。 -// 分离窗口(ai-detached/file-explorer-detached/approval-popup)不参与:它们有专属 +// 分离窗口(ai-detached/file-explorer-detached)不参与:它们有专属 // 入口路由,且会被 watch 误覆盖主窗口记录。 const ROUTE_STORAGE_KEY = 'df-last-route' // 这些路径不持久化(过渡/分离/弹窗/根跳转):避免分离窗口覆盖、避免 '/' redirect 噪音 -const NON_PERSIST_PATHS = new Set(['/', '/ai-detached', '/file-explorer-detached', '/approval-popup']) +const NON_PERSIST_PATHS = new Set(['/', '/ai-detached', '/file-explorer-detached']) const route = useRoute() const router = useRouter() diff --git a/src/components/settings/AdvancedSection.vue b/src/components/settings/AdvancedSection.vue index f4dd809..98a5e75 100644 --- a/src/components/settings/AdvancedSection.vue +++ b/src/components/settings/AdvancedSection.vue @@ -236,12 +236,14 @@ function onLogLevelChange() { flashSaved('logLevel') } -// 下一笔发起的审批即生效(aiShared.startApprovalTimer 每次实时读),已在跑的计时器沿用旧值。 function onCustomPromptChange() { appSettings.set('custom_prompt', customPrompt.value) rowCustomPrompt.value?.markSaved() } +// 审批超时写 KV(df-approval-timeout),后端启动时 reload_approval_timeout 读入 +// approval_timeout_minutes —— 超时收敛单一真值=后端定时任务(前端本地 auto-reject 已退役), +// 到期时刻经 AiApprovalRequired.expires_at_millis 透传,前端只读不算。 function onApprovalTimeoutChange() { appSettings.set('df-approval-timeout', settings.approvalTimeout) flashSaved('approvalTimeout') @@ -273,7 +275,7 @@ function onApprovalTimeoutChange() { flex-direction: column; align-items: stretch; } -/* 端点输入走等宽字体(URL 逐字符核对更易读),对齐 AllowedDirsPanel 路径输入风格 */ +/* 端点输入走等宽字体(URL 逐字符核对更易读) */ .upload-base-input { width: 100%; min-width: 0; diff --git a/src/components/settings/AllowedDirsPanel.vue b/src/components/settings/AllowedDirsPanel.vue deleted file mode 100644 index 4c1d4ef..0000000 --- a/src/components/settings/AllowedDirsPanel.vue +++ /dev/null @@ -1,330 +0,0 @@ - - - - - diff --git a/src/components/settings/FeishuPanel.vue b/src/components/settings/FeishuPanel.vue index 0209f7c..e39b771 100644 --- a/src/components/settings/FeishuPanel.vue +++ b/src/components/settings/FeishuPanel.vue @@ -83,7 +83,7 @@ interface FeishuConfig { whitelist: string } -// 接共享 toast 通道(Settings.vue showToast),与 AllowedDirsPanel 同范式 +// 接共享 toast 通道(Settings.vue showToast),与 ScriptSafetyPanel 同范式 const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info' | 'success'): void }>() const { t } = useI18n() diff --git a/src/components/settings/ScriptSafetyPanel.vue b/src/components/settings/ScriptSafetyPanel.vue new file mode 100644 index 0000000..ef561cb --- /dev/null +++ b/src/components/settings/ScriptSafetyPanel.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/src/components/settings/SettingsNav.vue b/src/components/settings/SettingsNav.vue index 9009967..af23c6f 100644 --- a/src/components/settings/SettingsNav.vue +++ b/src/components/settings/SettingsNav.vue @@ -53,7 +53,7 @@ // ai-model AI 模型(provider 列表 + 负载均衡池 + 模型拉取) // knowledge 知识库(auto_extract/trigger_mode/embedding_*) // performance 性能(global/per-conv concurrency/max-iterations/max-retries) -// security 安全(allowed_dirs) +// security 安全(命令执行脚本白/黑名单) // integrations 集成(飞书渠道凭证) // advanced 高级(autoExecuteMode/logLevel + 连接) // ============================================================ diff --git a/src/components/settings/searchIndex.ts b/src/components/settings/searchIndex.ts index 5ef1896..0a63924 100644 --- a/src/components/settings/searchIndex.ts +++ b/src/components/settings/searchIndex.ts @@ -43,7 +43,7 @@ export interface SettingIndexEntry { * vector_enabled/embedding_provider/embedding_model) * performance 性能(global-concurrency/per-conv-concurrency/ * max-iterations/max-retries) - * security 安全(allowed_dirs) + * security 安全(脚本白/黑名单) * integrations 集成(飞书渠道凭证) * advanced 高级(autoExecuteMode/logLevel + 连接) * about 关于(版本号 / 更新检查) @@ -83,7 +83,6 @@ export const SETTINGS_INDEX: SettingIndexEntry[] = [ { category: 'performance', key: 'agentMaxRetries', labelKey: 'settings.labelAgentMaxRetries', descKey: 'settings.descAgentMaxRetries' }, // ===== 安全 ===== - { category: 'security', key: 'allowedDirs', labelKey: 'settings.panelAllowedDirs', descKey: 'settings.descAllowedDirs' }, { category: 'security', key: 'scriptWhitelist', labelKey: 'settings.labelScriptWhitelist', descKey: 'settings.descScriptWhitelist' }, { category: 'security', key: 'scriptBlacklist', labelKey: 'settings.labelScriptBlacklist', descKey: 'settings.descScriptBlacklist' }, diff --git a/src/composables/ai/useAiApproval.ts b/src/composables/ai/useAiApproval.ts index f23e497..7c7d281 100644 --- a/src/composables/ai/useAiApproval.ts +++ b/src/composables/ai/useAiApproval.ts @@ -33,29 +33,19 @@ const _pendingApprovalIds = new Set() * 不再乐观置 running——原写法让 .ai-tool-approval 整块消失(切骨架屏), * 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。 * - * path_auth 审批链阶段3b(统一审批模型):**按 tc.kind 分派**两条 IPC—— - * - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision) once/session/always/deny。 - * once=本次单次(执行后清) / session=当前会话(切会话清) / always=写持久白名单 / deny=拒绝。 - * - kind='risk'(普通 RiskLevel 审批,默认):调 aiApi.approve(toolCallId, approved) 一次性 approve/reject。 - * approved=true→放行执行,approved=false→拒绝。 - * 入口据 findToolCall(toolCallId).kind 判定,kind 缺省走 risk 老路径(兼容非统一开关 / 老 toolCall)。 - * **注意**:决策分派由 tc.kind 决定,与调用方是否传 decision 入参无关——decision 仅 path 类消费, - * 调用方传 decision 但 tc.kind='risk' 时仍走 approve(approved)分支(忽略 decision)。 + * 普通 RiskLevel 审批:调 aiApi.approve(toolCallId, approved) 一次性 approve/reject。 + * (原按 tc.kind 分派 authorizeDir 的 path 双轨已随 path_auth 退役,2026-10-25。) * - * @param approved risk 类专用(approve/reject, approve=true/reject=false);path 类忽略。 - * @param decision path 类专用('once'|'session'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。 + * @param approved approve=true 放行执行 / reject=false 拒绝。 */ -async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'session' | 'always' | 'deny') { +async function approveToolCall(toolCallId: string, approved: boolean) { // 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试) if (_pendingApprovalIds.has(toolCallId)) return _pendingApprovalIds.add(toolCallId) try { // 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚 const tc = findToolCall(toolCallId) - // path_auth 审批链阶段3b:按 tc.kind 分派 IPC。kind='path' 走 authorizeDir(once/session/always/deny); - // 其余(risk/缺省)走 approve(approve/reject)。 - const isPathKind = !!tc && tc.kind === 'path' - // 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts AiApprovalRequired/AiDirAuthRequired 已 clear, + // 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts AiApprovalRequired 已 clear, // 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 loading。 // 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming), // 避免新增独立计时器与状态分支) @@ -67,12 +57,10 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?: const approvalConvId = state.activeConversationId || undefined resetStreamWatchdog(approvalConvId) try { - // IPC 分派规则(path→authorizeDir / risk→approve)抽离至 dispatchApprovalIPC(useToolApproval.ts), - // 与 ApprovalPopup.runApproval 同源,消除「同语义两套逻辑」漂移。 - // tc 为 null(findToolCall 未找到,时序竞态)时 isPathKind=false,走 risk 兜底 approve 分支 - // (与原实现等价:!!tc && tc.kind==='path' 短路)。 + // IPC 分派抽离至 dispatchApprovalIPC(useToolApproval.ts)统一收口。tc 为 null(findToolCall + // 未找到,时序竞态)时直接按 id 兜底。 if (tc) { - await dispatchApprovalIPC(tc, approved, decision) + await dispatchApprovalIPC(tc, approved) } else { await aiApi.approve(toolCallId, approved) } @@ -82,7 +70,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?: // CR: 批审批场景下后端可能已完成工具执行并 emit AiToolCallCompleted,此时 tc.status 已为 // 'completed',乐观更新不能回退——故仅在 status!=='completed' 时覆写。 if (tc) { - const isDeny = isPathKind ? (decision === 'deny') : !approved + const isDeny = !approved if (tc.status !== 'completed') { tc.status = isDeny ? 'rejected' : 'running' } @@ -119,14 +107,8 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?: /** - * 批量审批:遍历 pendingApprovals 逐个按 kind 分派调用 approveToolCall(决策:批量逐个, - * 非目录聚合——path 类每条独立决策,不合并同目录)。 - * - * path_auth 审批链阶段3b:统一开关开后,pendingApprovals 可能混合 path + risk 两类挂起。 - * 批量操作语义: - * - decision='approve':risk 类 approved=true;path 类 decision='once'(批量授权本次单次, - * 不批量写持久白名单——always 是单卡语义,批量误写持久白名单风险高,故批量只 once)。 - * - decision='reject':risk 类 approved=false;path 类 decision='deny'。 + * 批量审批:遍历 pendingApprovals 逐个调用 approveToolCall。 + * decision='approve' → approved=true;decision='reject' → approved=false。 * 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。 */ const _batchProcessing = ref(false) @@ -139,13 +121,7 @@ async function batchApprove(decision: 'approve' | 'reject') { let failCount = 0 for (const p of items) { try { - if (p.kind === 'path') { - // path 类:approve→once(本次单次,不批量写持久白名单), reject→deny - await approveToolCall(p.id, false, decision === 'approve' ? 'once' : 'deny') - } else { - // risk 类:approve/reject boolean - await approveToolCall(p.id, decision === 'approve') - } + await approveToolCall(p.id, decision === 'approve') successCount++ } catch { // 单条失败不中断批量操作(已由 approveToolCall 内部回滚该条状态) diff --git a/src/composables/ai/useApprovalPopup.ts b/src/composables/ai/useApprovalPopup.ts deleted file mode 100644 index b218d1b..0000000 --- a/src/composables/ai/useApprovalPopup.ts +++ /dev/null @@ -1,177 +0,0 @@ -//! 系统级审批提示浮窗(独立 WebviewWindow,应用失焦/最小化时可见)。 -//! -//! 设计(4 性平衡): -//! - **机制**:主窗口(App.vue)是 pendingApprovals 唯一真相源(已有 useAiEvents 维护), -//! 浮窗是「哑渲染器」—— 主窗口通过 Tauri event 推快照给浮窗,浮窗不重复监听 AI 事件 -//! (避免双 listener 竞态/状态漂移)。浮窗操作按钮直调 aiApi IPC,后端 emit → 主窗口 -//! listener 更新 store → 主窗口推新快照 → 浮窗刷新,闭环。 -//! - **系统级**:独立 WebviewWindow,alwaysOnTop + decorations:false + skipTaskbar, -//! 无边框自定义紫色主题(非 OS notification 默认样式)。 -//! - **可靠兜底**:浮窗创建失败/不可用 → 静默降级(不影响现有 ApprovalOverlay 应用内浮层)。 -//! 浮窗操作 IPC 失败 → 浮窗内 toast,不阻塞主窗口审批流。 -//! - **健壮边界**:pendingApprovals 清空 → 自动关浮窗;浮窗被用户手动关 → 主窗口 unlisten + 允许下次重弹。 -//! -//! 事件协议(主窗口 ↔ 浮窗): -//! - 主窗口 → 浮窗:`approval-popup-update` { approvals: AiToolCallInfo[] } -//! - 浮窗 → 主窗口:`approval-popup-activate`(聚焦主窗口 + 打开 AI 面板) -//! - 浮窗 → 主窗口:`approval-popup-closed`(用户关浮窗,主窗口清引用) -//! -//! 调用方:App.vue 主窗口 onMounted watch pendingApprovals → openOrUpdate/clear。 - -import { watch, type WatchSource } from 'vue' -import type { AiToolCallInfo } from '@/api/types' - -/** 浮窗 label(全局唯一,与 ai-detached / fe-detached 区分) */ -const POPUP_LABEL = 'approval-popup' -/** 浮窗路由 hash(URL 同 AiDetached 模式,走 hash 路由) */ -const POPUP_URL_HASH = '#/approval-popup' -/** 浮窗与主窗口通信的事件名 */ -const EVT_UPDATE = 'approval-popup-update' -const EVT_ACTIVATE = 'approval-popup-activate' -const EVT_CLOSED = 'approval-popup-closed' - -/** 模块级:当前浮窗引用 + 是否用户手动关闭(防 watch 重复弹) */ -type PopupWin = { close: () => Promise; destroy: () => Promise } -let _popupWin: PopupWin | null = null -let _userClosed = false -let _unlistenActivate: (() => void) | null = null -let _unlistenClosed: (() => void) | null = null - -/** - * 启动系统级审批浮窗管理。 - * - * @param source 取 pendingApprovals 的响应式源(通常是 () => store.state.pendingApprovals) - * @param onActivate 浮窗「打开审批面板」按钮回调(主窗口聚焦 + togglePanel) - * @returns cleanup 函数(onUnmounted 调用,关浮窗 + unlisten) - */ -export function startApprovalPopup( - source: WatchSource, - onActivate: () => void, -): () => void { - // 监听浮窗 → 主窗口的 activate 事件(浮窗点「打开审批面板」) - void (async () => { - try { - const { listen } = await import('@tauri-apps/api/event') - _unlistenActivate = await listen(EVT_ACTIVATE, () => { - onActivate() - }) - _unlistenClosed = await listen(EVT_CLOSED, () => { - // 浮窗被用户/系统关闭:清引用 + 标记用户关(下次有新审批才重弹) - _popupWin = null - _userClosed = true - }) - } catch (e) { - console.error('[ApprovalPopup] 主窗口事件监听失败:', e) - } - })() - - // watch pendingApprovals → 弹/更新/关浮窗 - const stop = watch(source, async (approvals) => { - // 无 pending:浮窗存在则关,不存在则不弹 - if (!approvals || approvals.length === 0) { - _userClosed = false // 清空时复位,下次有新审批允许弹 - await closePopup() - return - } - // 有 pending + 用户已手动关浮窗:不再自动弹(尊重用户决定,直到 pending 清空再复位) - if (_userClosed) return - await openOrUpdate(approvals) - }, { deep: true, immediate: true }) - - return () => { - stop() - _unlistenActivate?.() - _unlistenClosed?.() - _unlistenActivate = null - _unlistenClosed = null - } -} - -/** 创建或更新浮窗(已存在则仅推新快照) */ -async function openOrUpdate(approvals: AiToolCallInfo[]): Promise { - try { - const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow') - const existing = await WebviewWindow.getByLabel(POPUP_LABEL) - if (existing) { - // 已存在:仅推新快照(浮窗监听 EVT_UPDATE 渲染) - _popupWin = existing - await existing.emit(EVT_UPDATE, { approvals }) - return - } - // 不存在:创建独立无边框浮窗 - const url = window.location.origin + window.location.pathname + POPUP_URL_HASH - const win = new WebviewWindow(POPUP_LABEL, { - url, - title: 'DevFlow 审批待办', - width: 380, - height: 480, - minWidth: 320, - minHeight: 200, - resizable: true, - decorations: false, // 无系统边框/标题栏(自定义紫色主题) - alwaysOnTop: true, // 系统级:浮于所有桌面窗口上面 - skipTaskbar: true, // 不占任务栏(避免与主窗口双任务栏项) - focus: false, // 不抢焦点(不打断用户当前操作,仅作可见提示) - transparent: false, - center: false, // 位置由浮窗自己定位(右上角,见 ApprovalPopup.vue) - }) - _popupWin = win - win.once('tauri://created', async () => { - // 创建成功后立即推首批数据(浮窗 mount 后开始 listen,需保证时序: - // 浮窗内 onMounted listen → 主窗口 emit;但创建是异步,浮窗可能尚未 listen。 - // 兜底:浮窗 mount 时主动 emit 'request-update' 拉数据,主窗口响应推完整快照。 - // 此处也立即推一次(若浮窗已 listen 则直接收,未 listen 则由 request-update 兜底) - try { - await win.emit(EVT_UPDATE, { approvals }) - } catch { /* 浮窗未就绪,等 request-update 兜底 */ } - }) - win.once('tauri://error', (e) => { - console.error('[ApprovalPopup] 浮窗创建失败:', e) - _popupWin = null - }) - win.once('tauri://destroyed', () => { - _popupWin = null - }) - } catch (e) { - // 静默降级:浮窗不可用不影响现有 ApprovalOverlay 应用内浮层 - console.error('[ApprovalPopup] openOrUpdate 失败,降级到应用内浮层:', e) - } -} - -/** close() 超时降级 destroy() 的阈值(对齐浮窗侧 closeWithFallback 的 CLOSE_TIMEOUT_MS) */ -const CLOSE_TIMEOUT_MS = 1500 - -/** 关闭浮窗(若存在)。 - * 根因:Tauri 2.x 的 close() 走 closeRequested(非强制),偶发原生窗口未及时销毁,而主窗口侧 - * 此前裸 await close() 无兜底 → 浮窗空态 hint「正在关闭…」永久残留。 - * 对齐浮窗侧 closeWithFallback 语义:取引用后立即置空 _popupWin(防重复关/防关闭期间拿到旧引用), - * close 失败或超时未落定 → destroy() 强制销毁。 */ -async function closePopup(): Promise { - const win = _popupWin - _popupWin = null - if (!win) return - const timer = setTimeout(() => { - console.warn('[ApprovalPopup] close() 超时,降级 destroy()') - win.destroy().catch((e: unknown) => { - console.error('[ApprovalPopup] destroy() 兜底也失败:', e) - }) - }, CLOSE_TIMEOUT_MS) - try { - await win.close() - clearTimeout(timer) - } catch (e) { - clearTimeout(timer) - console.warn('[ApprovalPopup] close() 失败,降级 destroy():', e) - try { - await win.destroy() - } catch (e2) { - console.error('[ApprovalPopup] destroy() 兜底也失败:', e2) - } - } -} - -export const ApprovalPopupEvents = { - EVT_UPDATE, - EVT_ACTIVATE, - EVT_CLOSED, -} diff --git a/src/composables/ai/useToolApproval.ts b/src/composables/ai/useToolApproval.ts index 8b6d4b5..5424fb3 100644 --- a/src/composables/ai/useToolApproval.ts +++ b/src/composables/ai/useToolApproval.ts @@ -1,16 +1,16 @@ /** * ToolCard 审批状态机 — 从 ToolCard.vue 抽离的审批 UI 逻辑。 * - * 职责:审批按钮 loading 态、审批挂起时长显示、 - * 审批超时兜底 toast、approve/reject/path_auth 三选项处理、 - * status 变化自动复位 + pending 计时。 + * 职责:审批按钮 loading 态、审批挂起时长显示、审批到期倒计时、 + * 审批超时兜底 toast、approve/reject 处理、status 变化自动复位 + pending 计时。 + * + * High 风险二次确认已退役(2026-10-25 批C):审批按钮本身即确认,再加确认层纯增摩擦; + * 拒绝侧无破坏性,同样不加确认。 * * 不含 store IPC 调用(useAiApproval 负责)和模板渲染——纯状态管理。 */ import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue' import { useI18n } from 'vue-i18n' -import i18n from '@/i18n' -import { useConfirm } from '@/composables/useConfirm' import { aiApi } from '@/api' import { getApprovalRemainingMs } from './aiShared' import { formatWaitTime, waitLevelOf, type WaitLevel } from '@/utils/time' @@ -25,25 +25,22 @@ import type { AiToolCallInfo } from '@/api/types' const APPROVE_LOADING_TIMEOUT_MS = 30_000 /** - * High 风险工具「兜底」名单——仅在后端工具元数据不可用时生效 - * (启动早期尚未拉到 ai_list_tools / e2e 浏览器垫片无此 IPC)。 - * 权威来源是后端工具注册表 risk_level:启动后经 syncHighRiskTools 整体覆盖, - * 不再手工对齐 tools/*.rs 分散注册(原双写已漂移:手写 7 项,后端 High 实际 17 项)。 + * 前端 HIGH_RISK_TOOLS_FALLBACK 兜底名单已退役(2026-10-25 批C): + * 手写名单与后端注册表双写漂移(手写 7 项 vs 后端 High 17 项),兜底反制造假徽章/假高危判定。 + * 未拉到 registry 元数据时按 low 处理(改动最小选型):审批按钮/挂起流程不受影响 + * (是否挂起由后端 risk_level 决定,前端名单只影响徽章着色),少显徽章优于乱标。 + * 权威来源是后端工具注册表 risk_level,启动后经 syncHighRiskTools 覆盖。 */ -const HIGH_RISK_TOOLS_FALLBACK = new Set([ - 'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command', - 'http_request', -]) -/** 后端注册表投影的 High 风险名单;null = 尚未拉到元数据(走兜底名单) */ +/** 后端注册表投影的 High 风险名单;null = 尚未拉到元数据(按 low 处理,不显徽章) */ let registryHighRisk: Set | null = null -/** 后端注册表投影的 Medium 风险名单;null = 尚未拉到元数据(无兜底,安全侧降级不显徽章) */ +/** 后端注册表投影的 Medium 风险名单;null = 尚未拉到元数据(按 low 处理,不显徽章) */ let registryMediumRisk: Set | null = null /** - * 用后端 ai_list_tools 元数据构建 High 风险名单(权威源,整体覆盖兜底)。 - * 空结果不覆盖:注册表为空/数据异常时保留兜底名单,安全侧不放松。 + * 用后端 ai_list_tools 元数据构建 High/Medium 风险名单(权威源,整体覆盖)。 + * 空结果不覆盖:注册表为空/数据异常时保留上次名单,避免瞬时抖动清空。 * 供启动初始化调用(useAiPanel.loadToolRisks),幂等可重复调。 */ export function syncHighRiskTools(tools: { name: string; risk: string }[]): void { @@ -53,74 +50,36 @@ export function syncHighRiskTools(tools: { name: string; risk: string }[]): void if (med.length > 0) registryMediumRisk = new Set(med) } -/** 审批二次确认门控:优先注册表投影,未拉到元数据时退兜底名单。 - * V4 导出:系统浮窗 ApprovalPopup 的批准路径与 ToolCard 同门槛复用。 */ -export function isHighRiskTool(name: string): boolean { - return (registryHighRisk ?? HIGH_RISK_TOOLS_FALLBACK).has(name) -} - /** * 工具风险等级判定(high/medium/low):审批卡着色条+徽章数据源。 - * high 优先注册表投影、未拉到时退兜底名单(与 isHighRiskTool 同口径); - * medium 仅注册表投影,无兜底——未拉到元数据时不误标中危(安全侧降级:少显徽章优于乱标)。 + * 仅注册表投影;未拉到元数据时一律按 low(前端名单已无兜底,少显徽章优于乱标)。 */ export function toolRiskLevel(name: string): 'high' | 'medium' | 'low' { - if ((registryHighRisk ?? HIGH_RISK_TOOLS_FALLBACK).has(name)) return 'high' + if (registryHighRisk?.has(name)) return 'high' if (registryMediumRisk?.has(name)) return 'medium' return 'low' } /** - * 审批 IPC 分派(共享):按 tc.kind 决定调 aiApi.authorizeDir(path 类)或 aiApi.approve(risk 类)。 - * - * 抽离自 useAiApproval.approveToolCall 与 ApprovalPopup.onApprove/onReject 两处重复实现, - * 供两调用方共用同一分派规则,消除「同语义两套逻辑」漂移风险。 - * - * - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision)。decision 必传 - * ('once'|'session'|'always'|'deny'),由调用方按按钮语义给出;缺省兜底 'deny'(安全侧拒绝)。 - * - 其余(risk/缺省,普通 RiskLevel 审批):调 aiApi.approve(toolCallId, approved)。 - * approved=true 放行,false 拒绝。 + * 审批 IPC 分派(共享):普通 RiskLevel 审批调 aiApi.approve。 + * 原按 tc.kind 分派 authorizeDir 的双轨随 path_auth 退役(2026-10-25)收敛为单轨。 * * 不含状态机(loading/防抖/超时兜底)——那些是调用方各自的责任(ToolCard 走 useToolApproval - * 状态机 + emit;ApprovalPopup 走 processingId 局部 ref;useAiApproval 走 _pendingApprovalIds 防抖)。 - * 本函数仅做「kind → IPC」一层映射,纯 IPC 调用,无 store 依赖,可在任意窗口/上下文调用。 + * 状态机 + emit;useAiApproval 走 _pendingApprovalIds 防抖)。 * * @returns invoke 的 Promise(调用方可 await 接力错误处理) */ -export function dispatchApprovalIPC( - tc: AiToolCallInfo, - approved: boolean, - decision?: 'once' | 'session' | 'always' | 'deny', -): Promise { - if (tc.kind === 'path') { - return aiApi.authorizeDir(tc.id, decision ?? 'deny') - } +export function dispatchApprovalIPC(tc: AiToolCallInfo, approved: boolean): Promise { return aiApi.approve(tc.id, approved) } -/** V4:High 风险工具二次确认文案(模块级,供 ApprovalPopup 在无组件上下文复用; - * i18n 全局实例取 t,与 useI18n 同源 locale) */ -export function highRiskConfirmText(name: string): string { - // i18n.global 整体访问触发 vue-i18n 泛型递归 TS2589,先断言窄化再取 t - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const g = i18n.global as any - const gt = g.t as (key: string) => string - if (name === 'run_command') return gt('aiTool.confirmHighExec') - if (name === 'http_request') return gt('aiTool.confirmHighHttp') - if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') { - return gt('aiTool.confirmHighDelete') - } - return gt('aiTool.confirmHighGeneric') -} - /** useToolApproval emit 签名 — 补全类型替代 any */ interface ToolApprovalEmit { - (event: 'approve', payload: { id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }): void + (event: 'approve', payload: { id: string; approved: boolean }): void } export function useToolApproval(tc: Ref, emit: ToolApprovalEmit) { const { t } = useI18n() - const { confirmState, confirmDialog, answerConfirm } = useConfirm() // ── 审批 loading 态 ── const approving = ref(false) @@ -149,7 +108,7 @@ export function useToolApproval(tc: Ref, emit: ToolApprovalEmit) waitSecs.value = 0 } - // formatWaitTime/waitLevelOf 收敛至 @/utils/time(原与 ApprovalPopup 逐字双写) + // formatWaitTime/waitLevelOf 收敛至 @/utils/time const waitLevel = computed(() => waitLevelOf(waitSecs.value)) @@ -178,45 +137,26 @@ export function useToolApproval(tc: Ref, emit: ToolApprovalEmit) _approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000) } - /** High 风险工具的二次确认文案(按操作类型;V4 提升为模块级导出,浮窗复用) */ - function highRiskConfirmMsg(name: string): string { - return highRiskConfirmText(name) - } - /** * 批准按钮处理(risk 类专用)。 - * High 风险工具 → 先二次确认(useConfirm 弹层)。 - * loading + 超时兜底。approving 提前到 confirmDialog 前,防二次确认期间双击重入。 + * 审批按钮本身即确认——High 风险二次确认已退役(2026-10-25 批C),点击直接分派。 + * loading + 超时兜底。 */ async function onApprove() { if (approving.value) return // 防重入 - // 先锁防重入(含二次确认 await 期间) approving.value = true - try { - // High 风险 → 先二次确认 - if (isHighRiskTool(tc.value.name)) { - const ok = await confirmDialog(highRiskConfirmMsg(tc.value.name)) - if (!ok) { - approving.value = false // 取消二次确认 → 解锁 - return - } - } - approvingTimer = setTimeout(() => { - approving.value = false - approvingTimer = null - console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id) - showApproveTimeoutToast() - }, APPROVE_LOADING_TIMEOUT_MS) - emit('approve', { id: tc.value.id, approved: true }) - } catch (e) { - approving.value = false // 异常解锁 - throw e - } + approvingTimer = setTimeout(() => { + approving.value = false + approvingTimer = null + console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id) + showApproveTimeoutToast() + }, APPROVE_LOADING_TIMEOUT_MS) + emit('approve', { id: tc.value.id, approved: true }) } /** * 拒绝按钮处理(risk 类专用)。 - * loading + 超时兜底复用 机制。拒绝不做 High 风险二次确认(无害,加确认反增摩擦)。 + * loading + 超时兜底复用 机制。拒绝无破坏性,不加确认(无害,加确认反增摩擦)。 */ async function onReject() { if (approving.value) return // 防重入 @@ -230,86 +170,6 @@ export function useToolApproval(tc: Ref, emit: ToolApprovalEmit) emit('approve', { id: tc.value.id, approved: false }) } - /** - * path_auth 审批链阶段3b:path 类「仅本次会话授权」处理。 - * 与 onApprove 同款 loading + 超时兜底,emit 带 decision='once'。 - * path 类不做 High 风险二次确认(路径授权是显式白名单写入,非破坏性操作,三选项本身就是用户明示决策)。 - */ - async function onAuthorizeOnce() { - if (approving.value) return - approving.value = true - approvingTimer = setTimeout(() => { - approving.value = false - approvingTimer = null - console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id) - showApproveTimeoutToast() - }, APPROVE_LOADING_TIMEOUT_MS) - emit('approve', { id: tc.value.id, approved: false, decision: 'once' }) - } - - /** - * M4:path 类「当前会话授权」处理(decision='session')。 - * 对齐 DirAuthDialog 四按钮(once/session/always/deny)。session 写会话临时授权层,切会话清; - * IPC authorizeDir 后端 'session' 全支持(api/ai.ts authorizeDir 签名 + 后端 ai_authorize_dir)。 - * 同 onAuthorizeOnce 同款 loading + 超时兜底;无 High 风险二次确认(同 once/always 语义,显式白名单写入)。 - */ - async function onAuthorizeSession() { - if (approving.value) return - approving.value = true - approvingTimer = setTimeout(() => { - approving.value = false - approvingTimer = null - console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id) - showApproveTimeoutToast() - }, APPROVE_LOADING_TIMEOUT_MS) - emit('approve', { id: tc.value.id, approved: false, decision: 'session' }) - } - - /** - * path_auth 审批链阶段3b:path 类「写持久白名单」处理。 - * 同 onAuthorizeOnce,decision='always'。 - */ - async function onAuthorizeAlways() { - if (approving.value) return - approving.value = true - approvingTimer = setTimeout(() => { - approving.value = false - approvingTimer = null - console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id) - showApproveTimeoutToast() - }, APPROVE_LOADING_TIMEOUT_MS) - emit('approve', { id: tc.value.id, approved: false, decision: 'always' }) - } - - /** - * path_auth 审批链阶段3b:path 类「拒绝」处理。 - * 拒绝前二次确认(useConfirm 弹层)。拒绝不可逆——工具返 Err、loop 续跑不再重试该工具, - * 与放行(once/session/always 均可后续再授权)不对称,误点代价高;补确认与 approve(高风险)对称。 - * risk 类拒绝(onReject)不补确认:risk reject 是常规审批否决,语义明确且可重新触发;仅 path 类加。 - * approving 提前到 confirmDialog 前,防二次确认期间双击重入。 - */ - async function onDeny() { - if (approving.value) return - approving.value = true - try { - const ok = await confirmDialog(t('aiChat.dirAuthDenyConfirm')) - if (!ok) { - approving.value = false // 取消二次确认 → 解锁 - return - } - approvingTimer = setTimeout(() => { - approving.value = false - approvingTimer = null - console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id) - showApproveTimeoutToast() - }, APPROVE_LOADING_TIMEOUT_MS) - emit('approve', { id: tc.value.id, approved: false, decision: 'deny' }) - } catch (e) { - approving.value = false // 异常解锁 - throw e - } - } - // status 离开 pending_approval → 复位 approving(后端回执/转态时)。 watch(() => tc.value.status, (s) => { if (s !== 'pending_approval') { @@ -337,12 +197,5 @@ export function useToolApproval(tc: Ref, emit: ToolApprovalEmit) expireLevel, onApprove, onReject, - onAuthorizeOnce, - onAuthorizeSession, - onAuthorizeAlways, - onDeny, - // 供模板 ConfirmDialog 绑定 - confirmState, - answerConfirm, } } diff --git a/src/i18n/en/settings.ts b/src/i18n/en/settings.ts index 6d3f0fa..3dfc99d 100644 --- a/src/i18n/en/settings.ts +++ b/src/i18n/en/settings.ts @@ -236,25 +236,7 @@ export default { labelAgentMaxRetries: 'Stream failure retries', descAgentMaxRetries: 'Auto-retry count on stream failure (0-10, default 3); only retries pre-stream failures (no token output yet); mid-stream failures are abandoned', - // ===== F-260619-03 Phase A: AI tool allowed dirs panel ===== - panelAllowedDirs: '📁 AI Tool Allowed Dirs', - descAllowedDirs: 'AI file tools (read/write/list/patch/search) can only access the following authorized directories. The project root (devflow itself) is always implicitly authorized and need not be added. Access to directories not in the list will be rejected.', - allowedDirsLoading: 'Loading…', - allowedDirsEmpty: 'No extra authorized directories (only project root is authorized by default)', - allowedDirsInputPlaceholder: 'Enter absolute path, e.g. E:/wk-lab/u-abc', - allowedDirsSelectDir: 'Select Dir', - allowedDirsAdd: 'Add', - allowedDirsSave: 'Save', - allowedDirsReload: 'Reload', - allowedDirsLoadFail: 'Failed to load authorized directories', - allowedDirsDup: 'This directory is already in the authorized list', - allowedDirsAddFail: 'Failed to add authorized directory', - allowedDirsRemoveFail: 'Failed to remove authorized directory', - // "Trust all directories" master switch (Settings - Security - AI Tool Allowed Dirs panel) - labelPathAuthBypass: 'Trust all directories', - descPathAuthBypass: 'When enabled, AI can directly access any directory, including sensitive system and credential folders, without per-access prompts. Only enable in trusted environments; exposing sensitive directories risks credential leakage.', - pathAuthBypassConfirmTitle: 'Enable "Trust all directories"?', - pathAuthBypassConfirmBody: 'Once enabled, AI tools will no longer ask for per-access authorization and can directly reach any path, including sensitive directories such as .ssh and .aws. Sensitive credentials may end up in the conversation context; proceed at your own risk. Are you sure you want to enable it?', + // AI tool allowed-dirs whitelist retired to allow-all-by-default (user decision 2026-10-25); related i18n keys removed // Anthropic prompt caching (Settings - AI Model; KV df-ai-anthropic-cache, default on, effective immediately) panelAnthropicCache: 'AI Prompt Caching', diff --git a/src/i18n/zh-CN/settings.ts b/src/i18n/zh-CN/settings.ts index 8485f70..d6ca83c 100644 --- a/src/i18n/zh-CN/settings.ts +++ b/src/i18n/zh-CN/settings.ts @@ -235,25 +235,7 @@ export default { labelAgentMaxRetries: '流式失败重试次数', descAgentMaxRetries: '流式对话失败时自动重试次数(0-10,默认 3);只重试未输出任何 token 的流前失败,已输出文本的失败直接放弃', - // ===== F-260619-03 Phase A: AI 工具授权目录面板 ===== - panelAllowedDirs: '📁 AI 工具授权目录', - descAllowedDirs: 'AI 文件工具(读/写/列/改/搜等)仅可访问以下授权目录。项目根目录(devflow 自身)始终隐式授权,无需在此添加。未在列表内的目录访问将被拒绝。', - allowedDirsLoading: '加载中…', - allowedDirsEmpty: '暂无额外授权目录(仅项目根目录默认授权)', - allowedDirsInputPlaceholder: '输入授权目录绝对路径,如 E:/wk-lab/u-abc', - allowedDirsSelectDir: '选择目录', - allowedDirsAdd: '添加', - allowedDirsSave: '保存', - allowedDirsReload: '重载', - allowedDirsLoadFail: '加载授权目录失败', - allowedDirsDup: '该目录已在授权列表中', - allowedDirsAddFail: '添加授权目录失败', - allowedDirsRemoveFail: '删除授权目录失败', - // 「信任所有目录」总开关(设置-安全-AI 工具授权目录面板) - labelPathAuthBypass: '信任所有目录', - descPathAuthBypass: '开启后 AI 可直接访问任意目录(含系统与凭据等敏感目录),不再逐次询问。仅建议在受信任环境开启,放开敏感目录有凭据泄露风险。', - pathAuthBypassConfirmTitle: '开启「信任所有目录」?', - pathAuthBypassConfirmBody: '开启后 AI 工具将无需逐次授权,直接访问包括 .ssh、.aws 等敏感目录在内的任意路径,敏感凭据可能进入对话上下文,风险自担。确定开启?', + // AI 工具授权目录白名单机制已退役为默认全放行(2026-10-25 用户拍板),相关 i18n 键同批删除 // Anthropic prompt caching(设置-AI 模型;KV df-ai-anthropic-cache,默认开,改后即时生效) panelAnthropicCache: 'AI Prompt 缓存', diff --git a/src/views/ApprovalPopup.vue b/src/views/ApprovalPopup.vue deleted file mode 100644 index 446165f..0000000 --- a/src/views/ApprovalPopup.vue +++ /dev/null @@ -1,820 +0,0 @@ - - - - - diff --git a/src/views/Settings.vue b/src/views/Settings.vue index 30b3751..42f52af 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -64,8 +64,8 @@ @toast="showToast" /> - - + @@ -110,7 +110,7 @@ import AppearanceSection from '@/components/settings/AppearanceSection.vue' import PerformanceSection from '@/components/settings/PerformanceSection.vue' import AdvancedSection from '@/components/settings/AdvancedSection.vue' import KnowledgePanel from '@/components/settings/KnowledgePanel.vue' -import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue' +import ScriptSafetyPanel from '@/components/settings/ScriptSafetyPanel.vue' import FeishuPanel from '@/components/settings/FeishuPanel.vue' import UclawPanel from '@/components/settings/UclawPanel.vue' import UnattendedPanel from '@/components/settings/UnattendedPanel.vue'