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:
程序员阿江(Relakkes)
2026-09-16 22:27:15 +08:00
parent 88ea66f86e
commit 35736f3f3a
23 changed files with 749 additions and 42 deletions
@@ -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()
})
})
+4 -4
View File
@@ -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',
+4 -4
View File
@@ -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': '許可',
+4 -4
View File
@@ -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': '허용',
+4 -4
View File
@@ -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': '允許',
+4 -4
View File
@@ -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': '允许',
+27
View File
@@ -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: {
+6 -1
View File
@@ -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(),
}],
}
+2
View File
@@ -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 }
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+1
View File
@@ -81,6 +81,7 @@ export type ServerMessage =
toolUseId?: string
input: unknown
description?: string
displayName?: string
}
| {
type: 'computer_use_permission_request'
+5
View File
@@ -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)
}
}