mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix(desktop): route teammate permission prompts to the lead session
Print-mode team leads were treating mailbox permission asks as ordinary chat, so desktop members hung forever with no approval UI. Forward those requests through the existing can_use_tool host prompt and label them with the teammate name.
This commit is contained in:
@@ -3834,6 +3834,7 @@ export const MessageBlock = memo(function MessageBlock({
|
||||
toolName={message.toolName}
|
||||
input={message.input}
|
||||
description={message.description}
|
||||
displayName={message.displayName}
|
||||
/>
|
||||
)
|
||||
case 'error': {
|
||||
|
||||
@@ -21,6 +21,7 @@ type Props = {
|
||||
toolName: string
|
||||
input: unknown
|
||||
description?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,19 +81,27 @@ function extractToolDetails(toolName: string, input: unknown, t: (key: Translati
|
||||
}
|
||||
}
|
||||
|
||||
function getPermissionTitle(toolName: string, input: unknown, t: (key: TranslationKey, params?: Record<string, string | number>) => string) {
|
||||
function getPermissionTitle(
|
||||
toolName: string,
|
||||
input: unknown,
|
||||
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
displayName?: string,
|
||||
) {
|
||||
const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
|
||||
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
|
||||
const fileName = filePath ? filePath.split('/').pop() || filePath : ''
|
||||
const actor = displayName || 'Claude'
|
||||
|
||||
switch (toolName) {
|
||||
case 'Edit':
|
||||
case 'Write':
|
||||
return fileName ? t('permission.allowEditFile', { toolName, fileName }) : t('permission.allowEditFileGeneric', { toolName: toolName.toLowerCase() })
|
||||
return fileName
|
||||
? t('permission.allowEditFile', { actor, toolName, fileName })
|
||||
: t('permission.allowEditFileGeneric', { actor, toolName: toolName.toLowerCase() })
|
||||
case 'Bash':
|
||||
return t('permission.allowBash')
|
||||
return t('permission.allowBash', { actor })
|
||||
default:
|
||||
return t('permission.allowTool', { toolName })
|
||||
return t('permission.allowTool', { actor, toolName })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +130,7 @@ function renderPermissionPreview(toolName: string, input: unknown) {
|
||||
return null
|
||||
}
|
||||
|
||||
export function PermissionDialog({ sessionId, requestId, toolName, input, description }: Props) {
|
||||
export function PermissionDialog({ sessionId, requestId, toolName, input, description, displayName }: Props) {
|
||||
const { respondToPermission } = useChatStore()
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const targetSessionId = sessionId ?? activeTabId
|
||||
@@ -148,7 +157,7 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
|
||||
const details = extractToolDetails(toolName, input, t)
|
||||
const rawInput = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
|
||||
const preview = renderPermissionPreview(toolName, input)
|
||||
const title = getPermissionTitle(toolName, input, t)
|
||||
const title = getPermissionTitle(toolName, input, t, displayName)
|
||||
const allowRawToggle = !preview
|
||||
const permissionContext = (details.primary || description || toolName).slice(0, 160)
|
||||
|
||||
|
||||
@@ -1031,4 +1031,44 @@ describe('chat blocks', () => {
|
||||
expect(screen.getByRole('button', { name: 'Allow: /outside/two.ts' })).toBeTruthy()
|
||||
expect(screen.queryByText('Responded')).toBeNull()
|
||||
})
|
||||
|
||||
it('labels teammate permission prompts with the member name on the lead session', () => {
|
||||
const teammatePermission = {
|
||||
requestId: 'perm-teammate-1',
|
||||
toolName: 'Bash',
|
||||
toolUseId: 'tool-teammate-1',
|
||||
input: { command: 'ls' },
|
||||
displayName: 'researcher',
|
||||
}
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
'active-tab': {
|
||||
messages: [],
|
||||
chatState: 'permission_pending',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: teammatePermission,
|
||||
pendingPermissions: {
|
||||
[teammatePermission.requestId]: teammatePermission,
|
||||
},
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
streamingResponseChars: 0,
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(<PermissionDialog {...teammatePermission} />)
|
||||
|
||||
expect(screen.getByText('Allow researcher to run this command?')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2646,10 +2646,10 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'streaming.working': 'Working',
|
||||
|
||||
// ─── Permission Dialog ──────────────────────────────────────
|
||||
'permission.allowEditFile': 'Allow Claude to {toolName} {fileName}?',
|
||||
'permission.allowEditFileGeneric': 'Allow Claude to {toolName} this file?',
|
||||
'permission.allowBash': 'Allow Claude to run this command?',
|
||||
'permission.allowTool': 'Allow Claude to use {toolName}?',
|
||||
'permission.allowEditFile': 'Allow {actor} to {toolName} {fileName}?',
|
||||
'permission.allowEditFileGeneric': 'Allow {actor} to {toolName} this file?',
|
||||
'permission.allowBash': 'Allow {actor} to run this command?',
|
||||
'permission.allowTool': 'Allow {actor} to use {toolName}?',
|
||||
'permission.awaitingApproval': 'Awaiting approval',
|
||||
'permission.responded': 'Responded',
|
||||
'permission.allow': 'Allow',
|
||||
|
||||
@@ -2649,10 +2649,10 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'streaming.working': '作業中',
|
||||
|
||||
// ─── Permission Dialog ──────────────────────────────────────
|
||||
'permission.allowEditFile': 'Claude が {fileName} を {toolName} することを許可しますか?',
|
||||
'permission.allowEditFileGeneric': 'Claude がこのファイルを {toolName} することを許可しますか?',
|
||||
'permission.allowBash': 'Claude がこのコマンドを実行することを許可しますか?',
|
||||
'permission.allowTool': 'Claude が {toolName} を使用することを許可しますか?',
|
||||
'permission.allowEditFile': '{actor} が {fileName} を {toolName} することを許可しますか?',
|
||||
'permission.allowEditFileGeneric': '{actor} がこのファイルを {toolName} することを許可しますか?',
|
||||
'permission.allowBash': '{actor} がこのコマンドを実行することを許可しますか?',
|
||||
'permission.allowTool': '{actor} が {toolName} を使用することを許可しますか?',
|
||||
'permission.awaitingApproval': '承認待ち',
|
||||
'permission.responded': '応答済み',
|
||||
'permission.allow': '許可',
|
||||
|
||||
@@ -2649,10 +2649,10 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'streaming.working': '작업 중',
|
||||
|
||||
// ─── Permission Dialog ──────────────────────────────────────
|
||||
'permission.allowEditFile': 'Claude가 {fileName}을(를) {toolName}하도록 허용하시겠습니까?',
|
||||
'permission.allowEditFileGeneric': 'Claude가 이 파일을 {toolName}하도록 허용하시겠습니까?',
|
||||
'permission.allowBash': 'Claude가 이 명령을 실행하도록 허용하시겠습니까?',
|
||||
'permission.allowTool': 'Claude가 {toolName}을(를) 사용하도록 허용하시겠습니까?',
|
||||
'permission.allowEditFile': '{actor}가 {fileName}을(를) {toolName}하도록 허용하시겠습니까?',
|
||||
'permission.allowEditFileGeneric': '{actor}가 이 파일을 {toolName}하도록 허용하시겠습니까?',
|
||||
'permission.allowBash': '{actor}가 이 명령을 실행하도록 허용하시겠습니까?',
|
||||
'permission.allowTool': '{actor}가 {toolName}을(를) 사용하도록 허용하시겠습니까?',
|
||||
'permission.awaitingApproval': '승인 대기 중',
|
||||
'permission.responded': '응답함',
|
||||
'permission.allow': '허용',
|
||||
|
||||
@@ -2648,10 +2648,10 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'streaming.working': '工作中',
|
||||
|
||||
// ─── Permission Dialog ──────────────────────────────────────
|
||||
'permission.allowEditFile': '允許 Claude {toolName} {fileName}?',
|
||||
'permission.allowEditFileGeneric': '允許 Claude {toolName}此檔案?',
|
||||
'permission.allowBash': '允許 Claude 執行此命令?',
|
||||
'permission.allowTool': '允許 Claude 使用 {toolName}?',
|
||||
'permission.allowEditFile': '允許 {actor} {toolName} {fileName}?',
|
||||
'permission.allowEditFileGeneric': '允許 {actor} {toolName}此檔案?',
|
||||
'permission.allowBash': '允許 {actor} 執行此命令?',
|
||||
'permission.allowTool': '允許 {actor} 使用 {toolName}?',
|
||||
'permission.awaitingApproval': '等待審批',
|
||||
'permission.responded': '已響應',
|
||||
'permission.allow': '允許',
|
||||
|
||||
@@ -2647,10 +2647,10 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'streaming.working': '工作中',
|
||||
|
||||
// ─── Permission Dialog ──────────────────────────────────────
|
||||
'permission.allowEditFile': '允许 Claude {toolName} {fileName}?',
|
||||
'permission.allowEditFileGeneric': '允许 Claude {toolName}此文件?',
|
||||
'permission.allowBash': '允许 Claude 执行此命令?',
|
||||
'permission.allowTool': '允许 Claude 使用 {toolName}?',
|
||||
'permission.allowEditFile': '允许 {actor} {toolName} {fileName}?',
|
||||
'permission.allowEditFileGeneric': '允许 {actor} {toolName}此文件?',
|
||||
'permission.allowBash': '允许 {actor} 执行此命令?',
|
||||
'permission.allowTool': '允许 {actor} 使用 {toolName}?',
|
||||
'permission.awaitingApproval': '等待审批',
|
||||
'permission.responded': '已响应',
|
||||
'permission.allow': '允许',
|
||||
|
||||
@@ -9243,6 +9243,33 @@ describe('chatStore history mapping', () => {
|
||||
expect(session?.chatState).toBe('tool_executing')
|
||||
})
|
||||
|
||||
it('keeps teammate displayName on the lead-session permission prompt', () => {
|
||||
useChatStore.setState({
|
||||
sessions: { [TEST_SESSION_ID]: makeSession() },
|
||||
})
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'permission_request',
|
||||
requestId: 'perm-teammate-1',
|
||||
toolName: 'Bash',
|
||||
toolUseId: 'tool-teammate-1',
|
||||
input: { command: 'ls' },
|
||||
description: 'list files',
|
||||
displayName: 'researcher',
|
||||
})
|
||||
|
||||
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.pendingPermission).toEqual(expect.objectContaining({
|
||||
requestId: 'perm-teammate-1',
|
||||
displayName: 'researcher',
|
||||
}))
|
||||
expect(session?.messages).toContainEqual(expect.objectContaining({
|
||||
type: 'permission_request',
|
||||
requestId: 'perm-teammate-1',
|
||||
displayName: 'researcher',
|
||||
}))
|
||||
})
|
||||
|
||||
it('removes replayed or cancelled requests when the server resolves them', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
|
||||
@@ -103,6 +103,7 @@ export type PendingPermission = {
|
||||
toolUseId?: string
|
||||
input: unknown
|
||||
description?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
type PendingPermissions = Record<string, PendingPermission>
|
||||
@@ -4776,7 +4777,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
cooldownScope: 'permission-prompt',
|
||||
requestAttention: true,
|
||||
title: 'Claude Code Haha 需要你的确认',
|
||||
body: msg.toolName
|
||||
body: msg.displayName && msg.toolName
|
||||
? `${msg.displayName} 请求使用 ${msg.toolName},正在等待允许。`
|
||||
: msg.toolName
|
||||
? `${msg.toolName} 请求执行,正在等待允许。`
|
||||
: '有一个工具请求正在等待允许。',
|
||||
target: { type: 'session', sessionId },
|
||||
@@ -4788,6 +4791,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
toolUseId: msg.toolUseId,
|
||||
input: msg.input,
|
||||
description: msg.description,
|
||||
...(msg.displayName ? { displayName: msg.displayName } : {}),
|
||||
}
|
||||
const pendingPermissions = {
|
||||
...getPendingPermissionRecord(s),
|
||||
@@ -4828,6 +4832,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
toolUseId: msg.toolUseId,
|
||||
input: msg.input,
|
||||
description: msg.description,
|
||||
...(msg.displayName ? { displayName: msg.displayName } : {}),
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ export type ServerMessage =
|
||||
toolUseId?: string
|
||||
input: unknown
|
||||
description?: string
|
||||
displayName?: string
|
||||
}
|
||||
| {
|
||||
type: 'computer_use_permission_request'
|
||||
@@ -401,6 +402,7 @@ export type UIMessage =
|
||||
toolUseId?: string
|
||||
input: unknown
|
||||
description?: string
|
||||
displayName?: string
|
||||
timestamp: number
|
||||
}
|
||||
| { id: string; type: 'error'; message: string; code: string; businessErrorCode?: string; timestamp: number }
|
||||
|
||||
@@ -10,3 +10,12 @@ test('model metadata advertises Auto by feature instead of provider or model', (
|
||||
)
|
||||
expect(source).toContain('const hasAutoMode = autoModeSupported')
|
||||
})
|
||||
|
||||
test('print mode routes teammate permission mailbox messages to the host instead of the model', () => {
|
||||
expect(source).toContain('partitionLeadMailboxMessages')
|
||||
expect(source).toContain('resolveTeammatePermissionRequests')
|
||||
expect(source).toContain('hostPermissionPromptAvailable')
|
||||
expect(source).not.toMatch(
|
||||
/formatted = unread\s*\.map/,
|
||||
)
|
||||
})
|
||||
|
||||
+38
-5
@@ -357,6 +357,10 @@ import {
|
||||
markMessagesAsRead,
|
||||
isShutdownApproved,
|
||||
} from '../utils/teammateMailbox.js'
|
||||
import {
|
||||
partitionLeadMailboxMessages,
|
||||
resolveTeammatePermissionRequests,
|
||||
} from '../utils/swarm/printLeaderPermissionBridge.js'
|
||||
import { removeTeammateFromTeamFile } from '../utils/swarm/teamHelpers.js'
|
||||
import { unassignTeammateTasks } from '../utils/tasks.js'
|
||||
import { getRunningTasks } from '../utils/task/framework.js'
|
||||
@@ -891,7 +895,11 @@ export async function runHeadless(
|
||||
getAppState,
|
||||
setAppState,
|
||||
agents,
|
||||
options,
|
||||
{
|
||||
...options,
|
||||
hostPermissionPromptAvailable:
|
||||
effectivePermissionPromptToolName === 'stdio',
|
||||
},
|
||||
turnInterruptionState,
|
||||
)) {
|
||||
partialOutputTracker.observe(message)
|
||||
@@ -1017,6 +1025,7 @@ function runHeadlessStreaming(
|
||||
verbose: boolean | undefined
|
||||
jsonSchema: Record<string, unknown> | undefined
|
||||
permissionPromptToolName: string | undefined
|
||||
hostPermissionPromptAvailable?: boolean
|
||||
allowedTools: string[] | undefined
|
||||
thinkingConfig: ThinkingConfig | undefined
|
||||
maxTurns: number | undefined
|
||||
@@ -2564,10 +2573,29 @@ function runHeadlessStreaming(
|
||||
refreshedState.teamContext?.teamName,
|
||||
)
|
||||
|
||||
const teamName = refreshedState.teamContext?.teamName
|
||||
const partitioned = partitionLeadMailboxMessages(unread)
|
||||
|
||||
if (
|
||||
partitioned.permissionRequests.length > 0 ||
|
||||
partitioned.sandboxPermissionRequests.length > 0
|
||||
) {
|
||||
logForDebugging(
|
||||
`[print.ts] Routing ${partitioned.permissionRequests.length} teammate permission request(s) and ${partitioned.sandboxPermissionRequests.length} sandbox request(s) to the host`,
|
||||
)
|
||||
void resolveTeammatePermissionRequests({
|
||||
host: structuredIO,
|
||||
canPromptHost: options.hostPermissionPromptAvailable === true,
|
||||
teamName,
|
||||
permissionRequests: partitioned.permissionRequests,
|
||||
sandboxPermissionRequests:
|
||||
partitioned.sandboxPermissionRequests,
|
||||
})
|
||||
}
|
||||
|
||||
// Process shutdown_approved messages - remove teammates from team file
|
||||
// This mirrors what useInboxPoller does in interactive mode (lines 546-606)
|
||||
const teamName = refreshedState.teamContext?.teamName
|
||||
for (const m of unread) {
|
||||
for (const m of partitioned.remaining) {
|
||||
const shutdownApproval = isShutdownApproved(m.text)
|
||||
if (shutdownApproval && teamName) {
|
||||
const teammateToRemove = shutdownApproval.from
|
||||
@@ -2618,8 +2646,13 @@ function runHeadlessStreaming(
|
||||
}
|
||||
}
|
||||
|
||||
// Format messages same as useInboxPoller
|
||||
const formatted = unread
|
||||
if (partitioned.remaining.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Format remaining teammate chat the same way as useInboxPoller.
|
||||
// Permission requests stay out of the model context.
|
||||
const formatted = partitioned.remaining
|
||||
.map(
|
||||
(m: { from: string; text: string; color?: string }) =>
|
||||
`<${TEAMMATE_MESSAGE_TAG} teammate_id="${m.from}"${m.color ? ` color="${m.color}"` : ''}>\n${m.text}\n</${TEAMMATE_MESSAGE_TAG}>`,
|
||||
|
||||
+36
-10
@@ -752,16 +752,12 @@ export class StructuredIO {
|
||||
}) => Promise<boolean> {
|
||||
return async (hostPattern): Promise<boolean> => {
|
||||
try {
|
||||
const result = await this.sendRequest<PermissionToolOutput>(
|
||||
{
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: SANDBOX_NETWORK_ACCESS_TOOL_NAME,
|
||||
input: { host: hostPattern.host },
|
||||
tool_use_id: randomUUID(),
|
||||
description: `Allow network connection to ${hostPattern.host}?`,
|
||||
},
|
||||
permissionToolOutputSchema(),
|
||||
)
|
||||
const result = await this.askHostForToolPermission({
|
||||
toolName: SANDBOX_NETWORK_ACCESS_TOOL_NAME,
|
||||
input: { host: hostPattern.host },
|
||||
toolUseId: randomUUID(),
|
||||
description: `Allow network connection to ${hostPattern.host}?`,
|
||||
})
|
||||
return result.behavior === 'allow'
|
||||
} catch {
|
||||
// If the request fails (stream closed, abort, etc.), deny the connection
|
||||
@@ -770,6 +766,36 @@ export class StructuredIO {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the SDK host for a tool permission without going through a live
|
||||
* tool-use context. Used by teammate mailbox requests in --print: the worker
|
||||
* already decided it needs a human, so the lead session just forwards that
|
||||
* ask onto the existing can_use_tool protocol.
|
||||
*/
|
||||
askHostForToolPermission(params: {
|
||||
toolName: string
|
||||
input: Record<string, unknown>
|
||||
toolUseId: string
|
||||
description?: string
|
||||
displayName?: string
|
||||
permissionSuggestions?: unknown[]
|
||||
}): Promise<PermissionToolOutput> {
|
||||
return this.sendRequest<PermissionToolOutput>(
|
||||
{
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: params.toolName,
|
||||
input: params.input,
|
||||
tool_use_id: params.toolUseId,
|
||||
...(params.description ? { description: params.description } : {}),
|
||||
...(params.displayName ? { display_name: params.displayName } : {}),
|
||||
...(Array.isArray(params.permissionSuggestions)
|
||||
? { permission_suggestions: params.permissionSuggestions }
|
||||
: {}),
|
||||
},
|
||||
permissionToolOutputSchema(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an MCP message to an SDK server and waits for the response
|
||||
*/
|
||||
|
||||
@@ -1630,6 +1630,39 @@ describe('ConversationService', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
test('retains teammate display_name on pending permission requests', () => {
|
||||
const service = new ConversationService() as any
|
||||
service.sessions.set('lead-session', {
|
||||
outputCallbacks: [],
|
||||
seenSdkMessageUuids: new Set<string>(),
|
||||
sdkMessages: [],
|
||||
initMessage: null,
|
||||
pendingPermissionRequests: new Map(),
|
||||
})
|
||||
|
||||
service.handleSdkPayload('lead-session', JSON.stringify({
|
||||
type: 'control_request',
|
||||
request_id: 'teammate-perm',
|
||||
request: {
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: 'Bash',
|
||||
tool_use_id: 'toolu_teammate',
|
||||
input: { command: 'ls' },
|
||||
description: 'list files',
|
||||
display_name: 'researcher',
|
||||
},
|
||||
}))
|
||||
|
||||
expect(service.getPendingPermissionRequests('lead-session')).toEqual([{
|
||||
requestId: 'teammate-perm',
|
||||
toolName: 'Bash',
|
||||
toolUseId: 'toolu_teammate',
|
||||
input: { command: 'ls' },
|
||||
description: 'list files',
|
||||
displayName: 'researcher',
|
||||
}])
|
||||
})
|
||||
|
||||
// CLI 的 WebSocketTransport 每次重连成功都会把整个发送缓冲区重放一遍,并假定
|
||||
// 「The server deduplicates by UUID」。以前 server 没实现这个契约:笔记本睡醒后
|
||||
// CLI 重连,一整轮早已结束的对话会被重新推上来,前端当成实时输出再渲染一遍
|
||||
|
||||
@@ -389,6 +389,24 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"teammate-permission-request": [
|
||||
{
|
||||
"in": "control_request/can_use_tool",
|
||||
"out": [
|
||||
{
|
||||
"type": "permission_request",
|
||||
"requestId": "req_teammate_perm",
|
||||
"toolName": "Bash",
|
||||
"toolUseId": "toolu_teammate_perm",
|
||||
"input": {
|
||||
"command": "ls"
|
||||
},
|
||||
"description": "list files",
|
||||
"displayName": "researcher"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"system-init-and-slash": [
|
||||
{
|
||||
"in": "system/init",
|
||||
|
||||
@@ -220,6 +220,25 @@ export const goldenScenarios: GoldenScenario[] = [
|
||||
{ type: 'control_cancel_request', request_id: 'req_golden_1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'teammate-permission-request',
|
||||
description:
|
||||
'A teammate can_use_tool prompt keeps display_name so the lead session can label the member.',
|
||||
messages: [
|
||||
{
|
||||
type: 'control_request',
|
||||
request_id: 'req_teammate_perm',
|
||||
request: {
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: 'Bash',
|
||||
tool_use_id: 'toolu_teammate_perm',
|
||||
input: { command: 'ls' },
|
||||
description: 'list files',
|
||||
display_name: 'researcher',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system-init-and-slash',
|
||||
description: 'Session init advertises slash commands; local command output renders separately.',
|
||||
|
||||
@@ -121,6 +121,29 @@ describe('translateCliMessage usage mapping', () => {
|
||||
allowed: false,
|
||||
}])
|
||||
})
|
||||
|
||||
it('forwards teammate display_name onto the lead-session permission prompt', () => {
|
||||
expect(translateCliMessage({
|
||||
type: 'control_request',
|
||||
request_id: 'teammate-perm-1',
|
||||
request: {
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: 'Bash',
|
||||
tool_use_id: 'toolu_teammate',
|
||||
input: { command: 'ls' },
|
||||
description: 'list files',
|
||||
display_name: 'researcher',
|
||||
},
|
||||
}, 'session-1')).toEqual([{
|
||||
type: 'permission_request',
|
||||
requestId: 'teammate-perm-1',
|
||||
toolName: 'Bash',
|
||||
toolUseId: 'toolu_teammate',
|
||||
input: { command: 'ls' },
|
||||
description: 'list files',
|
||||
displayName: 'researcher',
|
||||
}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSocket handler session title lifecycle', () => {
|
||||
|
||||
@@ -230,6 +230,7 @@ type SessionProcess = {
|
||||
toolName: string
|
||||
toolUseId?: string
|
||||
description?: string
|
||||
displayName?: string
|
||||
input: Record<string, unknown>
|
||||
permissionSuggestions?: unknown[]
|
||||
}
|
||||
@@ -243,6 +244,7 @@ export type PendingPermissionRequest = {
|
||||
toolUseId?: string
|
||||
input: Record<string, unknown>
|
||||
description?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
type SessionStartOptions = {
|
||||
@@ -982,6 +984,7 @@ export class ConversationService {
|
||||
...(request.toolUseId ? { toolUseId: request.toolUseId } : {}),
|
||||
input: request.input,
|
||||
...(request.description ? { description: request.description } : {}),
|
||||
...(request.displayName ? { displayName: request.displayName } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1123,6 +1126,10 @@ export class ConversationService {
|
||||
typeof msg.request.description === 'string' && msg.request.description.trim()
|
||||
? msg.request.description
|
||||
: undefined,
|
||||
displayName:
|
||||
typeof msg.request.display_name === 'string' && msg.request.display_name.trim()
|
||||
? msg.request.display_name.trim()
|
||||
: undefined,
|
||||
permissionSuggestions: Array.isArray(msg.request.permission_suggestions)
|
||||
? msg.request.permission_suggestions
|
||||
: undefined,
|
||||
|
||||
@@ -81,6 +81,7 @@ export type ServerMessage =
|
||||
toolUseId?: string
|
||||
input: unknown
|
||||
description?: string
|
||||
displayName?: string
|
||||
}
|
||||
| {
|
||||
type: 'computer_use_permission_request'
|
||||
|
||||
@@ -3226,6 +3226,10 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
|
||||
: undefined,
|
||||
input: cliMsg.request.input || {},
|
||||
description: cliMsg.request.description,
|
||||
...(typeof cliMsg.request.display_name === 'string' &&
|
||||
cliMsg.request.display_name.trim()
|
||||
? { displayName: cliMsg.request.display_name.trim() }
|
||||
: {}),
|
||||
}]
|
||||
}
|
||||
return []
|
||||
@@ -3729,6 +3733,7 @@ function replayPendingPermissionRequests(
|
||||
...(request.toolUseId ? { toolUseId: request.toolUseId } : {}),
|
||||
input: request.input,
|
||||
...(request.description ? { description: request.description } : {}),
|
||||
...(request.displayName ? { displayName: request.displayName } : {}),
|
||||
})
|
||||
}
|
||||
return requests.map((request) => request.requestId)
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'
|
||||
import { jsonStringify } from '../slowOperations.js'
|
||||
import {
|
||||
createPermissionRequestMessage,
|
||||
createSandboxPermissionRequestMessage,
|
||||
isPermissionResponse,
|
||||
isSandboxPermissionResponse,
|
||||
type TeammateMessage,
|
||||
} from '../teammateMailbox.js'
|
||||
import * as mailbox from '../teammateMailbox.js'
|
||||
import {
|
||||
HEADLESS_TEAMMATE_PERMISSION_UNAVAILABLE,
|
||||
partitionLeadMailboxMessages,
|
||||
resolveTeammatePermissionRequests,
|
||||
type HostToolPermissionPrompt,
|
||||
} from './printLeaderPermissionBridge.js'
|
||||
|
||||
function mailboxMessage(
|
||||
from: string,
|
||||
payload: unknown,
|
||||
extras: Partial<TeammateMessage> = {},
|
||||
): TeammateMessage {
|
||||
return {
|
||||
from,
|
||||
text: jsonStringify(payload),
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
...extras,
|
||||
}
|
||||
}
|
||||
|
||||
describe('printLeaderPermissionBridge', () => {
|
||||
const writes: Array<{ to: string; text: string; teamName?: string }> = []
|
||||
let writeSpy: ReturnType<typeof spyOn> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
writes.length = 0
|
||||
writeSpy?.mockRestore()
|
||||
writeSpy = undefined
|
||||
})
|
||||
|
||||
function captureMailboxWrites() {
|
||||
writeSpy = spyOn(mailbox, 'writeToMailbox').mockImplementation(
|
||||
async (to, message, teamName) => {
|
||||
writes.push({ to, text: message.text, teamName })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
test('keeps permission and sandbox asks out of remaining teammate chat', () => {
|
||||
const permission = createPermissionRequestMessage({
|
||||
request_id: 'perm-1',
|
||||
agent_id: 'researcher',
|
||||
tool_name: 'Bash',
|
||||
tool_use_id: 'toolu_1',
|
||||
description: 'list files',
|
||||
input: { command: 'ls' },
|
||||
})
|
||||
const sandbox = createSandboxPermissionRequestMessage({
|
||||
requestId: 'sandbox-1',
|
||||
workerId: 'w1',
|
||||
workerName: 'researcher',
|
||||
host: 'example.com',
|
||||
})
|
||||
const chat = mailboxMessage('researcher', 'need a second look at the plan')
|
||||
|
||||
const partitioned = partitionLeadMailboxMessages([
|
||||
mailboxMessage('researcher', permission),
|
||||
mailboxMessage('researcher', sandbox),
|
||||
chat,
|
||||
])
|
||||
|
||||
expect(partitioned.permissionRequests).toHaveLength(1)
|
||||
expect(partitioned.permissionRequests[0]?.parsed.request_id).toBe('perm-1')
|
||||
expect(partitioned.sandboxPermissionRequests).toHaveLength(1)
|
||||
expect(partitioned.sandboxPermissionRequests[0]?.parsed.requestId).toBe(
|
||||
'sandbox-1',
|
||||
)
|
||||
expect(partitioned.remaining).toEqual([chat])
|
||||
})
|
||||
|
||||
test('rejects teammate tool asks when the host cannot prompt', async () => {
|
||||
captureMailboxWrites()
|
||||
const parsed = createPermissionRequestMessage({
|
||||
request_id: 'perm-deny-no-host',
|
||||
agent_id: 'researcher',
|
||||
tool_name: 'Bash',
|
||||
tool_use_id: 'toolu_deny',
|
||||
description: 'rm -rf /',
|
||||
input: { command: 'rm -rf /' },
|
||||
})
|
||||
const host: HostToolPermissionPrompt = {
|
||||
askHostForToolPermission: mock(() => {
|
||||
throw new Error('host should not be prompted')
|
||||
}),
|
||||
}
|
||||
|
||||
await resolveTeammatePermissionRequests({
|
||||
host,
|
||||
canPromptHost: false,
|
||||
teamName: 'team-alpha',
|
||||
permissionRequests: [
|
||||
{ message: mailboxMessage('researcher', parsed), parsed },
|
||||
],
|
||||
sandboxPermissionRequests: [],
|
||||
})
|
||||
|
||||
expect(host.askHostForToolPermission).not.toHaveBeenCalled()
|
||||
expect(writes).toHaveLength(1)
|
||||
expect(writes[0]?.to).toBe('researcher')
|
||||
expect(writes[0]?.teamName).toBe('team-alpha')
|
||||
expect(isPermissionResponse(writes[0]?.text ?? '')).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'permission_response',
|
||||
request_id: 'perm-deny-no-host',
|
||||
subtype: 'error',
|
||||
error: HEADLESS_TEAMMATE_PERMISSION_UNAVAILABLE,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('writes an approved mailbox response after the host allows the tool', async () => {
|
||||
captureMailboxWrites()
|
||||
const parsed = createPermissionRequestMessage({
|
||||
request_id: 'perm-allow',
|
||||
agent_id: 'researcher',
|
||||
tool_name: 'Bash',
|
||||
tool_use_id: 'toolu_allow',
|
||||
description: 'list files',
|
||||
input: { command: 'ls' },
|
||||
})
|
||||
const host: HostToolPermissionPrompt = {
|
||||
askHostForToolPermission: mock(async params => {
|
||||
expect(params).toEqual({
|
||||
toolName: 'Bash',
|
||||
input: { command: 'ls' },
|
||||
toolUseId: 'toolu_allow',
|
||||
description: 'list files',
|
||||
displayName: 'researcher',
|
||||
permissionSuggestions: [],
|
||||
})
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: { command: 'ls -la' },
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
await resolveTeammatePermissionRequests({
|
||||
host,
|
||||
canPromptHost: true,
|
||||
teamName: 'team-alpha',
|
||||
permissionRequests: [
|
||||
{ message: mailboxMessage('researcher', parsed), parsed },
|
||||
],
|
||||
sandboxPermissionRequests: [],
|
||||
})
|
||||
|
||||
expect(writes).toHaveLength(1)
|
||||
const parsedResponse = isPermissionResponse(writes[0]?.text ?? '')
|
||||
expect(parsedResponse).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'permission_response',
|
||||
request_id: 'perm-allow',
|
||||
subtype: 'success',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
parsedResponse && 'response' in parsedResponse
|
||||
? parsedResponse.response?.updated_input
|
||||
: undefined,
|
||||
).toEqual({ command: 'ls -la' })
|
||||
})
|
||||
|
||||
test('forwards teammate sandbox network asks through the host prompt', async () => {
|
||||
captureMailboxWrites()
|
||||
const parsed = createSandboxPermissionRequestMessage({
|
||||
requestId: 'sandbox-allow',
|
||||
workerId: 'w1',
|
||||
workerName: 'researcher',
|
||||
host: 'api.example.com',
|
||||
})
|
||||
const host: HostToolPermissionPrompt = {
|
||||
askHostForToolPermission: mock(async params => {
|
||||
expect(params.toolName).toBe('SandboxNetworkAccess')
|
||||
expect(params.description).toContain('api.example.com')
|
||||
expect(params.input).toEqual({ host: 'api.example.com' })
|
||||
expect(params.displayName).toBe('researcher')
|
||||
return { behavior: 'allow', updatedInput: params.input }
|
||||
}),
|
||||
}
|
||||
|
||||
await resolveTeammatePermissionRequests({
|
||||
host,
|
||||
canPromptHost: true,
|
||||
teamName: 'team-alpha',
|
||||
permissionRequests: [],
|
||||
sandboxPermissionRequests: [
|
||||
{ message: mailboxMessage('researcher', parsed), parsed },
|
||||
],
|
||||
})
|
||||
|
||||
expect(writes).toHaveLength(1)
|
||||
expect(isSandboxPermissionResponse(writes[0]?.text ?? '')).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'sandbox_permission_response',
|
||||
requestId: 'sandbox-allow',
|
||||
host: 'api.example.com',
|
||||
allow: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Headless / desktop host for teammate permission requests.
|
||||
*
|
||||
* Interactive REPL consumes mailbox permission_request via useInboxPoller and
|
||||
* the in-memory leaderPermissionBridge. --print never mounts REPL, so those
|
||||
* requests used to be formatted as ordinary teammate chat and injected into
|
||||
* the model while the worker waited forever.
|
||||
*
|
||||
* This module is the missing leader-side consumer: classify mailbox messages,
|
||||
* prompt the SDK host through the existing can_use_tool protocol, then write
|
||||
* the resolution back to the worker mailbox. Members never see a permission
|
||||
* UI of their own — the lead session is the only approval surface.
|
||||
*/
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { errorMessage } from '../errors.js'
|
||||
import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js'
|
||||
import {
|
||||
isPermissionRequest,
|
||||
isSandboxPermissionRequest,
|
||||
type PermissionRequestMessage,
|
||||
type SandboxPermissionRequestMessage,
|
||||
type TeammateMessage,
|
||||
} from '../teammateMailbox.js'
|
||||
import {
|
||||
sendPermissionResponseViaMailbox,
|
||||
sendSandboxPermissionResponseViaMailbox,
|
||||
} from './permissionSync.js'
|
||||
|
||||
export const HEADLESS_TEAMMATE_PERMISSION_UNAVAILABLE =
|
||||
'Team lead cannot prompt for teammate tool permission in this session.'
|
||||
|
||||
export type HostToolPermissionDecision =
|
||||
| {
|
||||
behavior: 'allow'
|
||||
updatedInput?: Record<string, unknown>
|
||||
updatedPermissions?: PermissionUpdate[]
|
||||
}
|
||||
| {
|
||||
behavior: 'deny'
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type HostToolPermissionPrompt = {
|
||||
askHostForToolPermission(params: {
|
||||
toolName: string
|
||||
input: Record<string, unknown>
|
||||
toolUseId: string
|
||||
description?: string
|
||||
displayName?: string
|
||||
permissionSuggestions?: unknown[]
|
||||
}): Promise<HostToolPermissionDecision>
|
||||
}
|
||||
|
||||
export type PartitionedLeadMailbox = {
|
||||
permissionRequests: Array<{
|
||||
message: TeammateMessage
|
||||
parsed: PermissionRequestMessage
|
||||
}>
|
||||
sandboxPermissionRequests: Array<{
|
||||
message: TeammateMessage
|
||||
parsed: SandboxPermissionRequestMessage
|
||||
}>
|
||||
remaining: TeammateMessage[]
|
||||
}
|
||||
|
||||
export function partitionLeadMailboxMessages(
|
||||
messages: TeammateMessage[],
|
||||
): PartitionedLeadMailbox {
|
||||
const permissionRequests: PartitionedLeadMailbox['permissionRequests'] = []
|
||||
const sandboxPermissionRequests: PartitionedLeadMailbox['sandboxPermissionRequests'] =
|
||||
[]
|
||||
const remaining: TeammateMessage[] = []
|
||||
|
||||
for (const message of messages) {
|
||||
const permissionRequest = isPermissionRequest(message.text)
|
||||
if (permissionRequest) {
|
||||
permissionRequests.push({ message, parsed: permissionRequest })
|
||||
continue
|
||||
}
|
||||
const sandboxRequest = isSandboxPermissionRequest(message.text)
|
||||
if (sandboxRequest) {
|
||||
sandboxPermissionRequests.push({ message, parsed: sandboxRequest })
|
||||
continue
|
||||
}
|
||||
remaining.push(message)
|
||||
}
|
||||
|
||||
return { permissionRequests, sandboxPermissionRequests, remaining }
|
||||
}
|
||||
|
||||
export async function resolveTeammatePermissionRequests(params: {
|
||||
host: HostToolPermissionPrompt
|
||||
canPromptHost: boolean
|
||||
teamName: string | undefined
|
||||
permissionRequests: PartitionedLeadMailbox['permissionRequests']
|
||||
sandboxPermissionRequests: PartitionedLeadMailbox['sandboxPermissionRequests']
|
||||
}): Promise<void> {
|
||||
const {
|
||||
host,
|
||||
canPromptHost,
|
||||
teamName,
|
||||
permissionRequests,
|
||||
sandboxPermissionRequests,
|
||||
} = params
|
||||
|
||||
await Promise.all([
|
||||
...permissionRequests.map(({ parsed }) =>
|
||||
resolveToolPermission({
|
||||
host,
|
||||
canPromptHost,
|
||||
teamName,
|
||||
parsed,
|
||||
}),
|
||||
),
|
||||
...sandboxPermissionRequests.map(({ parsed }) =>
|
||||
resolveSandboxPermission({
|
||||
host,
|
||||
canPromptHost,
|
||||
teamName,
|
||||
parsed,
|
||||
}),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
async function resolveToolPermission(params: {
|
||||
host: HostToolPermissionPrompt
|
||||
canPromptHost: boolean
|
||||
teamName: string | undefined
|
||||
parsed: PermissionRequestMessage
|
||||
}): Promise<void> {
|
||||
const { host, canPromptHost, teamName, parsed } = params
|
||||
const reject = (feedback: string) =>
|
||||
sendPermissionResponseViaMailbox(
|
||||
parsed.agent_id,
|
||||
{
|
||||
decision: 'rejected',
|
||||
resolvedBy: 'leader',
|
||||
feedback,
|
||||
},
|
||||
parsed.request_id,
|
||||
teamName,
|
||||
)
|
||||
|
||||
if (!canPromptHost) {
|
||||
logForDebugging(
|
||||
`[print.ts] Rejecting teammate permission for ${parsed.tool_name} from ${parsed.agent_id}: no host prompt`,
|
||||
)
|
||||
await reject(HEADLESS_TEAMMATE_PERMISSION_UNAVAILABLE)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await host.askHostForToolPermission({
|
||||
toolName: parsed.tool_name,
|
||||
input: parsed.input,
|
||||
toolUseId: parsed.tool_use_id,
|
||||
description: parsed.description,
|
||||
// display_name labels the member on the lead-session prompt.
|
||||
// Do not send worker identity as agent_id: desktop treats a set
|
||||
// agent_id as a stopped-subagent permission and drops it.
|
||||
displayName: parsed.agent_id,
|
||||
permissionSuggestions: parsed.permission_suggestions,
|
||||
})
|
||||
if (result.behavior === 'allow') {
|
||||
await sendPermissionResponseViaMailbox(
|
||||
parsed.agent_id,
|
||||
{
|
||||
decision: 'approved',
|
||||
resolvedBy: 'leader',
|
||||
updatedInput: result.updatedInput,
|
||||
permissionUpdates: result.updatedPermissions,
|
||||
},
|
||||
parsed.request_id,
|
||||
teamName,
|
||||
)
|
||||
return
|
||||
}
|
||||
await reject(result.message || 'Permission denied')
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`[print.ts] Host prompt failed for teammate permission ${parsed.request_id}: ${errorMessage(error)}`,
|
||||
)
|
||||
await reject(errorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSandboxPermission(params: {
|
||||
host: HostToolPermissionPrompt
|
||||
canPromptHost: boolean
|
||||
teamName: string | undefined
|
||||
parsed: SandboxPermissionRequestMessage
|
||||
}): Promise<void> {
|
||||
const { host, canPromptHost, teamName, parsed } = params
|
||||
const hostName = parsed.hostPattern?.host
|
||||
if (!hostName) {
|
||||
logForDebugging(
|
||||
'[print.ts] Ignoring sandbox permission request with no host',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const respond = (allow: boolean) =>
|
||||
sendSandboxPermissionResponseViaMailbox(
|
||||
parsed.workerName,
|
||||
parsed.requestId,
|
||||
hostName,
|
||||
allow,
|
||||
teamName,
|
||||
)
|
||||
|
||||
if (!canPromptHost) {
|
||||
logForDebugging(
|
||||
`[print.ts] Denying teammate sandbox access to ${hostName} from ${parsed.workerName}: no host prompt`,
|
||||
)
|
||||
await respond(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await host.askHostForToolPermission({
|
||||
// Keep this aligned with SANDBOX_NETWORK_ACCESS_TOOL_NAME in structuredIO.
|
||||
toolName: 'SandboxNetworkAccess',
|
||||
input: { host: hostName },
|
||||
toolUseId: parsed.requestId,
|
||||
description: `${parsed.workerName} needs network access to ${hostName}`,
|
||||
displayName: parsed.workerName,
|
||||
})
|
||||
await respond(result.behavior === 'allow')
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`[print.ts] Host prompt failed for teammate sandbox permission ${parsed.requestId}: ${errorMessage(error)}`,
|
||||
)
|
||||
await respond(false)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user