feat: serve 模式默认启用权限问询(acceptEdits)并修复回复链路

- 默认权限模式从 undefined 改为 acceptEdits(工作目录内读写自动放行)
- 始终传递 --permission-prompt-tool stdio 给子进程
- 修复 updatedInput 为 undefined 导致子进程 Zod 解析失败
- always 回复附带 updatedPermissions + decisionClassification
- 新增 permission_replied/question_replied SSE 事件通知 UI 撤销弹窗
- hook_callback 控制请求自动回复防止子进程阻塞
- PendingPermission 保存 permission_suggestions 供 always 回复使用
This commit is contained in:
DoSun 2026-05-09 11:10:13 +08:00
parent e37e9d3ad5
commit 9c3441331c
2 changed files with 61 additions and 12 deletions

View File

@ -21,16 +21,22 @@ export function createPermissionRoutes(sessionManager: SessionManager): Hono {
interrupt?: boolean interrupt?: boolean
}>() }>()
const isAlways = body.behavior === 'always'
const mappedBehavior: 'allow' | 'deny' = const mappedBehavior: 'allow' | 'deny' =
body.behavior === 'reject' ? 'deny' : body.behavior === 'reject' ? 'deny' :
body.behavior === 'once' || body.behavior === 'always' ? 'allow' : body.behavior === 'once' || body.behavior === 'always' ? 'allow' :
body.behavior === 'deny' ? 'deny' : 'allow' body.behavior === 'deny' ? 'deny' : 'allow'
const updatedPermissions = body.updated_permissions
?? (isAlways ? found.perm.suggestions : undefined)
found.handle.replyPermission(requestId, mappedBehavior, { found.handle.replyPermission(requestId, mappedBehavior, {
updatedInput: body.updated_input, updatedInput: body.updated_input ?? {},
updatedPermissions: body.updated_permissions, updatedPermissions,
message: body.message, message: body.message,
interrupt: body.interrupt, interrupt: body.interrupt,
decisionClassification: isAlways ? 'user_permanent' : mappedBehavior === 'allow' ? 'user_temporary' : undefined,
}) })
return c.json({ resolved: true }) return c.json({ resolved: true })

View File

@ -37,6 +37,7 @@ export type PendingPermission = {
input: Record<string, unknown> input: Record<string, unknown>
title: string title: string
description: string description: string
suggestions: Record<string, unknown>[]
} }
export type PendingQuestion = { export type PendingQuestion = {
@ -211,7 +212,7 @@ export class SessionHandle {
this.cwd = opts.cwd this.cwd = opts.cwd
this.eventBus = opts.eventBus this.eventBus = opts.eventBus
this._model = opts.model this._model = opts.model
this._permissionMode = opts.permissionMode this._permissionMode = opts.permissionMode ?? 'acceptEdits'
this.verbose = opts.verbose ?? false this.verbose = opts.verbose ?? false
} }
@ -235,14 +236,10 @@ export class SessionHandle {
'--session-id', '--session-id',
this.sessionId, this.sessionId,
...(this.opts.model ? ['--model', this.opts.model] : []), ...(this.opts.model ? ['--model', this.opts.model] : []),
...(this.opts.permissionMode '--permission-mode',
? [ this._permissionMode,
'--permission-mode', '--permission-prompt-tool',
this.opts.permissionMode, 'stdio',
'--permission-prompt-tool',
'stdio',
]
: []),
...(this.opts.resumeSessionId ...(this.opts.resumeSessionId
? ['--resume', this.opts.resumeSessionId] ? ['--resume', this.opts.resumeSessionId]
: []), : []),
@ -495,6 +492,8 @@ export class SessionHandle {
input: (request.input as Record<string, unknown>) ?? {}, input: (request.input as Record<string, unknown>) ?? {},
title: `${request.tool_name}: ${JSON.stringify(request.input).slice(0, 80)}`, title: `${request.tool_name}: ${JSON.stringify(request.input).slice(0, 80)}`,
description: `Execute ${request.tool_name}`, description: `Execute ${request.tool_name}`,
suggestions:
(request.permission_suggestions as Record<string, unknown>[]) ?? [],
} }
this.pendingPermissions.set(requestId, perm) this.pendingPermissions.set(requestId, perm)
this.emitEvent('control_request', { request_id: requestId, request }) this.emitEvent('control_request', { request_id: requestId, request })
@ -512,6 +511,38 @@ export class SessionHandle {
this.emitEvent('control_request', { request_id: requestId, request }) this.emitEvent('control_request', { request_id: requestId, request })
} else if (request?.subtype === 'hook_callback') { } else if (request?.subtype === 'hook_callback') {
this.emitEvent('control_request', { request_id: requestId, request }) this.emitEvent('control_request', { request_id: requestId, request })
const callbackId = request.callback_id as string | undefined
if (!callbackId || callbackId === 'AUTO_APPROVE_CALLBACK_ID') {
this.writeStdin(jsonStringify({
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow',
permissionDecisionReason: 'Auto-approved by serve',
},
},
},
}))
} else {
this.writeStdin(jsonStringify({
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'ask',
permissionDecisionReason: 'Forwarding to can_use_tool',
},
},
},
}))
}
} }
break break
} }
@ -669,6 +700,7 @@ export class SessionHandle {
updatedPermissions?: Record<string, unknown>[] updatedPermissions?: Record<string, unknown>[]
message?: string message?: string
interrupt?: boolean interrupt?: boolean
decisionClassification?: string
}, },
): void { ): void {
const response = { const response = {
@ -680,10 +712,13 @@ export class SessionHandle {
behavior === 'allow' behavior === 'allow'
? { ? {
behavior: 'allow', behavior: 'allow',
updatedInput: opts?.updatedInput, updatedInput: opts?.updatedInput ?? {},
...(opts?.updatedPermissions ...(opts?.updatedPermissions
? { updatedPermissions: opts.updatedPermissions } ? { updatedPermissions: opts.updatedPermissions }
: {}), : {}),
...(opts?.decisionClassification
? { decisionClassification: opts.decisionClassification }
: {}),
} }
: { : {
behavior: 'deny', behavior: 'deny',
@ -694,6 +729,10 @@ export class SessionHandle {
} }
this.writeStdin(jsonStringify(response)) this.writeStdin(jsonStringify(response))
this.pendingPermissions.delete(requestId) this.pendingPermissions.delete(requestId)
this.emitEvent('permission_replied', {
request_id: requestId,
behavior,
})
} }
replyQuestion( replyQuestion(
@ -714,6 +753,10 @@ export class SessionHandle {
} }
this.writeStdin(jsonStringify(response)) this.writeStdin(jsonStringify(response))
this.pendingQuestions.delete(requestId) this.pendingQuestions.delete(requestId)
this.emitEvent('question_replied', {
request_id: requestId,
action,
})
} }
kill(): void { kill(): void {