新增: 路径授权三档化 once/session/always

Phase B 弹窗 once/always → once/session/always 三档(本次单次执行后清/当前会话切会话清/始终落KV)。修 once 原写 session 致本次实为本会话名实不符。AllowedDirs 加 once 层 + ai_authorize_dir decision 改 match 四分支 + 执行后 clear_once。
This commit is contained in:
2026-06-25 00:23:07 +08:00
parent 56a08fb5c1
commit 24c3fa08f1
10 changed files with 116 additions and 46 deletions

View File

@@ -77,10 +77,10 @@ export const aiApi = {
/**
* F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
* decision: 'once'(仅本次,会话级)/ 'always'(未来都允许,持久化)/ 'deny'(拒绝)。
* 后端 remove pending → 写授权目录(session/persistent)→ 执行工具 → try_continue 恢复 loop。
* decision: 'once'(本次单次,执行后清)/ 'session'(当前会话,切会话清)/ 'always'(始终,持久化)/ 'deny'(拒绝)。
* 后端 remove pending → 写授权目录(once/session/persistent)→ 执行工具 → try_continue 恢复 loop。
*/
authorizeDir(toolCallId: string, decision: 'once' | 'always' | 'deny'): Promise<string> {
authorizeDir(toolCallId: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<string> {
return invoke('ai_authorize_dir', { toolCallId, decision })
},

View File

@@ -358,7 +358,7 @@ export type AiChatEvent = ({
type: 'AiToolAutoApproved'; id: string; tool: string; dir: string
} | {
// path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分
// 'path'(F-260619-03 Phase B 路径授权挂起,走 once/always/deny)vs 'risk'(普通 RiskLevel 审批,
// 'path'(F-260619-03 Phase B 路径授权挂起,走 once/session/always/deny)vs 'risk'(普通 RiskLevel 审批,
// 走 approve/reject)。后端阶段3a 未在 wire 上给该事件加 kind(老 path 挂起仍走独立
// AiDirAuthRequired 事件),前端 useAiEvents 入口处据 event.type 归一打标:risk 类默认 'risk',
// path 类(AiDirAuthRequired 归一)打 'path'。故此处 kind 为可选(老后端不传时前端默认 'risk')。
@@ -387,7 +387,7 @@ export type AiChatEvent = ({
type: 'AiMaxRoundsReached'
} | {
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
// 前端弹窗选项:"本次"(session)/"未来都允许"(persistent)/"拒绝" → ai_authorize_dir IPC。
// 前端弹窗选项:"本次"(once 执行后清)/"当前会话"(session 切会话清)/"始终放行"(persistent)/"拒绝" → ai_authorize_dir IPC。
type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string
} | {
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
@@ -544,8 +544,8 @@ export interface AiToolCallInfo {
diff?: string
/**
* path_auth 审批链阶段3b(统一审批模型):审批类型,区分两类挂起。
* - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/always/deny 选项,
* 调 aiApi.authorizeDir(once 写会话临时授权 / always 写持久白名单 / deny 工具返 Err)。
* - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/session/always/deny 选项,
* 调 aiApi.authorizeDir(once 写 once 层单次执行后清 / session 写会话临时 / always 写持久白名单 / deny 工具返 Err)。
* - 'risk'(普通 RiskLevel 审批,Med/High 风险工具):ToolCard 显 approve/reject 两选项,
* 调 aiApi.approve(一次性)。
* undefined = 未进 pending_approval 态(非挂起)或老后端 risk 类(默认 'risk')。

View File

@@ -3,8 +3,9 @@
后端挂起 loop 等用户决定)阶段1 改遍历 pendingDirAuths(数组)同轮多个文件工具落不同
未授权目录时后端连发多条 AiDirAuthRequired,数组并存不互覆盖
条件:pendingDirAuths 非空(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)
每项独立按钮:"本次" ai_authorize_dir(decision=once, 会话临时授权) 后端执行工具 + try_continue
"未来都允许" ai_authorize_dir(decision=always, 写持久化 Settings KV) 执行 + loop
每项独立按钮:"本次" ai_authorize_dir(decision=once, once 层单次授权,执行后清) 后端执行工具 + try_continue
"当前会话" ai_authorize_dir(decision=session, session 层会话授权,切会话清) 执行 + loop
"始终放行" ai_authorize_dir(decision=always, 写持久化 Settings KV) 执行 + loop
"拒绝" ai_authorize_dir(decision=deny, 工具返 Err) loop
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入
toast emit 转父(保持单一 toast ) -->
@@ -19,6 +20,11 @@
:disabled="actingIds.has(item.id)"
@click="handleAuthorize(item.id, 'once')"
>{{ $t('aiChat.dirAuthOnce') }}</button>
<button
class="ai-dir-auth-btn ai-dir-auth-btn--session"
:disabled="actingIds.has(item.id)"
@click="handleAuthorize(item.id, 'session')"
>{{ $t('aiChat.dirAuthSession') }}</button>
<button
class="ai-dir-auth-btn ai-dir-auth-btn--always"
:disabled="actingIds.has(item.id)"
@@ -74,7 +80,7 @@ const actingIds = reactive(new Set<string>())
/** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 →
* execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError
* 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */
async function handleAuthorize(id: string, decision: 'once' | 'always' | 'deny'): Promise<void> {
async function handleAuthorize(id: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<void> {
if (actingIds.has(id)) return
actingIds.add(id)
try {
@@ -148,6 +154,12 @@ watch(() => pendingDirAuths.value, (items) => {
color: #fff;
}
.ai-dir-auth-btn--once:hover:not(:disabled) { filter: brightness(1.1); }
.ai-dir-auth-btn--session {
background: color-mix(in srgb, var(--df-accent) 15%, transparent);
color: var(--df-accent);
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
}
.ai-dir-auth-btn--session:hover:not(:disabled) { filter: brightness(1.1); }
.ai-dir-auth-btn--always {
background: var(--df-warning);
color: #000;

View File

@@ -31,8 +31,8 @@ const _pendingApprovalIds = new Set<string>()
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。
*
* path_auth 审批链阶段3b(统一审批模型):**按 tc.kind 分派**两条 IPC——
* - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision) once/always/deny。
* once=本次会话授权 / always=写持久白名单 / deny=拒绝。
* - 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)。
@@ -40,16 +40,16 @@ const _pendingApprovalIds = new Set<string>()
* 调用方传 decision 但 tc.kind='risk' 时仍走 approve(approved)分支(忽略 decision)。
*
* @param approved risk 类专用(approve/reject, approve=true/reject=false);path 类忽略。
* @param decision path 类专用('once'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。
* @param decision path 类专用('once'|'session'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。
*/
async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'always' | 'deny') {
async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'session' | 'always' | 'deny') {
// F-260616-06: 防抖——同一 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/always/deny);
// 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,
@@ -95,7 +95,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
*
* path_auth 审批链阶段3b:统一开关开后,pendingApprovals 可能混合 path + risk 两类挂起。
* 批量操作语义:
* - decision='approve':risk 类 approved=true;path 类 decision='once'(批量授权本次,
* - decision='approve':risk 类 approved=true;path 类 decision='once'(批量授权本次单次,
* 不批量写持久白名单——always 是单卡语义,批量误写持久白名单风险高,故批量只 once)。
* - decision='reject':risk 类 approved=false;path 类 decision='deny'。
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
@@ -106,7 +106,7 @@ async function batchApprove(decision: 'approve' | 'reject') {
for (const p of items) {
try {
if (p.kind === 'path') {
// path 类:approve→once(本次,不批量写持久白名单), reject→deny
// path 类:approve→once(本次单次,不批量写持久白名单), reject→deny
await approveToolCall(p.id, false, decision === 'approve' ? 'once' : 'deny')
} else {
// risk 类:approve/reject boolean

View File

@@ -155,7 +155,8 @@ export default {
dirAuthRequired: 'Path authorization required',
dirAuthHint: '{tool} wants to access: {path}',
dirAuthOnce: 'This time',
dirAuthAlways: 'Always allow',
dirAuthSession: 'This session',
dirAuthAlways: 'Always',
dirAuthDeny: 'Deny',
dirAuthFailed: 'Authorization failed: {msg}',

View File

@@ -155,8 +155,9 @@ export default {
// ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ──
dirAuthRequired: '需要路径授权',
dirAuthHint: '{tool} 想访问:{path}',
dirAuthOnce: '本次',
dirAuthAlways: '未来都允许',
dirAuthOnce: '本次',
dirAuthSession: '当前会话',
dirAuthAlways: '始终放行',
dirAuthDeny: '拒绝',
dirAuthFailed: '授权失败:{msg}',