From b1cccf6e3c2776bb9151d1635471bac7127732e1 Mon Sep 17 00:00:00 2001 From: infiniti <52129260+developerInfiniti@users.noreply.github.com> Date: Sun, 17 May 2026 01:18:48 +0300 Subject: [PATCH] fix(team): respond to teammate permission requests --- .../services/team/TeamProvisioningService.ts | 163 ++++++++++++++++-- .../team/TeamProvisioningService.test.ts | 158 +++++++++++++++++ 2 files changed, 302 insertions(+), 19 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index d55d0e91..2f0986a4 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -31483,7 +31483,9 @@ export class TeamProvisioningService { perm.requestId, true, undefined, - perm.permissionSuggestions + perm.permissionSuggestions, + perm.toolName, + perm.input ); this.emitToolApprovalEvent({ autoResolved: true, @@ -31703,8 +31705,10 @@ export class TeamProvisioningService { approval.source, requestId, allow, - allow ? undefined : 'Timed out — auto-denied by settings', - approval.permissionSuggestions + allow ? undefined : 'Timed out - auto-denied by settings', + approval.permissionSuggestions, + approval.toolName, + approval.toolInput ).finally(() => { run.pendingApprovals.delete(requestId); this.inFlightResponses.delete(requestId); @@ -31789,7 +31793,9 @@ export class TeamProvisioningService { requestId, true, undefined, - approval.permissionSuggestions + approval.permissionSuggestions, + approval.toolName, + approval.toolInput ); } else { this.autoAllowControlRequest(run, requestId); @@ -31865,7 +31871,9 @@ export class TeamProvisioningService { requestId, allow, message, - approval.permissionSuggestions + approval.permissionSuggestions, + approval.toolName, + approval.toolInput ); } finally { run.pendingApprovals.delete(requestId); @@ -31951,32 +31959,52 @@ export class TeamProvisioningService { /** * Respond to a teammate's permission_request by applying permission_suggestions. * - * FACT: Claude Code teammate runtime sends permission_request via SendMessage (inbox protocol). - * FACT: Writing permission_response to teammate inbox does NOT work - runtime ignores it. - * FACT: control_response via stdin does NOT work for teammate requests - request_id doesn't match. + * FACT: Claude Code teammate runtime sends permission_request via the inbox protocol. + * FACT: Teammates wait for permission_response in their own inbox. + * FACT: control_response via the lead stdin does not reliably reach teammate request ids. * FACT: permission_suggestions.destination "localSettings" refers to {cwd}/.claude/settings.local.json. * FACT: Claude Code CLI reads this file via --setting-sources user,project,local. * - * When allow=true: applies permission_suggestions (adds tool rules to project settings). - * When allow=false: no action needed - tool stays blocked by default. + * When allow=true: applies permission_suggestions, then replies to the teammate. + * When allow=false: replies with an error so the teammate does not hang. */ private async respondToTeammatePermission( run: ProvisioningRun, agentId: string, requestId: string, allow: boolean, - _message?: string, - permissionSuggestions?: import('@shared/utils/inboxNoise').PermissionSuggestion[] + message?: string, + permissionSuggestions?: import('@shared/utils/inboxNoise').PermissionSuggestion[], + toolName?: string, + toolInput?: Record ): Promise { if (!allow) { logger.info(`[${run.teamName}] Denied teammate ${agentId} permission ${requestId}`); + this.sendTeammatePermissionResponse(run, agentId, requestId, { + allow: false, + message, + toolName, + }); return; } - // Apply permission_suggestions: add tool rules to project settings file const suggestions = permissionSuggestions ?? []; + const sendSuccessResponse = (): void => { + this.sendTeammatePermissionResponse(run, agentId, requestId, { + allow: true, + message, + permissionUpdates: suggestions, + toolName, + toolInput, + }); + }; + + // Apply permission_suggestions: add tool rules to project settings file if (suggestions.length === 0) { - logger.warn(`[${run.teamName}] No permission_suggestions for ${requestId} — cannot add rule`); + sendSuccessResponse(); + logger.info( + `[${run.teamName}] No permission_suggestions for ${requestId}; inbox response sent` + ); return; } @@ -31989,7 +32017,10 @@ export class TeamProvisioningService { // best-effort } if (!projectCwd) { - logger.warn(`[${run.teamName}] Cannot resolve project cwd for permission rule — skipping`); + logger.warn( + `[${run.teamName}] Cannot resolve project cwd for permission rule; sending inbox response only` + ); + sendSuccessResponse(); return; } @@ -32004,7 +32035,7 @@ export class TeamProvisioningService { if (mode === 'acceptEdits') { toolNames = ['Edit', 'Write', 'NotebookEdit']; } else if (mode === 'bypassPermissions') { - // Broad approval — add common tools + // Broad approval - add common tools toolNames = ['Edit', 'Write', 'NotebookEdit', 'Bash', 'Read', 'Grep', 'Glob']; } if (toolNames.length > 0) { @@ -32057,7 +32088,7 @@ export class TeamProvisioningService { try { await this.addPermissionRulesToSettings(settingsPath, toolNames, behavior); logger.info( - `[${run.teamName}] Added permission rules for ${agentId}: ${toolNames.join(', ')} → ${behavior} in ${settingsPath}` + `[${run.teamName}] Added permission rules for ${agentId}: ${toolNames.join(', ')} -> ${behavior} in ${settingsPath}` ); } catch (error) { logger.error( @@ -32068,17 +32099,21 @@ export class TeamProvisioningService { } } - // Also attempt control_response via stdin — the lead runtime MAY forward it + sendSuccessResponse(); + + // Also attempt control_response via stdin - the lead runtime MAY forward it // to the teammate subprocess. This was broken before (missing updatedInput: {}) // but is now fixed. Belt-and-suspenders: settings handle future calls, // control_response may unblock the CURRENT waiting prompt. if (allow && run.child?.stdin?.writable) { + const updatedInput = + this.buildTeammatePermissionUpdatedInput(toolName, toolInput, message) ?? {}; const controlResponse = { type: 'control_response', response: { subtype: 'success', request_id: requestId, - response: { behavior: 'allow', updatedInput: {} }, + response: { behavior: 'allow', updatedInput }, }, }; run.child.stdin.write(JSON.stringify(controlResponse) + '\n', (err) => { @@ -32091,6 +32126,96 @@ export class TeamProvisioningService { } } + private sendTeammatePermissionResponse( + run: ProvisioningRun, + agentId: string, + requestId: string, + params: { + allow: boolean; + message?: string; + permissionUpdates?: unknown[]; + toolName?: string; + toolInput?: Record; + } + ): void { + const payload = params.allow + ? { + type: 'permission_response', + request_id: requestId, + subtype: 'success', + response: { + updated_input: this.buildTeammatePermissionUpdatedInput( + params.toolName, + params.toolInput, + params.message + ), + permission_updates: params.permissionUpdates ?? [], + }, + } + : { + type: 'permission_response', + request_id: requestId, + subtype: 'error', + error: params.message ?? 'Permission denied', + }; + + this.persistInboxMessage(run.teamName, agentId, { + from: + run.request?.members.find((member) => member.role?.toLowerCase().includes('lead'))?.name ?? + 'team-lead', + to: agentId, + text: JSON.stringify(payload), + timestamp: nowIso(), + read: false, + summary: params.allow + ? `Approved ${params.toolName ?? 'tool'} request` + : `Denied ${params.toolName ?? 'tool'} request`, + messageId: `permission-response-${run.runId}-${requestId}-${Date.now()}`, + source: 'lead_process', + }); + this.teamChangeEmitter?.({ + type: 'inbox', + teamName: run.teamName, + detail: `inboxes/${agentId}.json`, + }); + } + + private buildTeammatePermissionUpdatedInput( + toolName: string | undefined, + toolInput: Record | undefined, + message: string | undefined + ): Record | undefined { + if (!toolInput) return undefined; + if (toolName !== 'AskUserQuestion' || !message) return toolInput; + + const answers = this.parseAskUserQuestionAnswers(message, toolInput); + return Object.keys(answers).length > 0 ? { ...toolInput, answers } : toolInput; + } + + private parseAskUserQuestionAnswers( + message: string, + toolInput: Record + ): Record { + try { + const parsed = JSON.parse(message) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return Object.fromEntries( + Object.entries(parsed as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ); + } + } catch { + // Fall back to using the raw message as the first answer. + } + + const questions = Array.isArray(toolInput.questions) + ? (toolInput.questions as { question?: unknown }[]) + : []; + const firstQuestion = questions.find((question) => typeof question.question === 'string'); + return typeof firstQuestion?.question === 'string' ? { [firstQuestion.question]: message } : {}; + } + /** * Safely add tool names to the permissions.allow (or deny) array in a Claude settings file. * Creates the file and parent directories if they don't exist. diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index ad7b7129..0243cf14 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -14350,6 +14350,8 @@ describe('TeamProvisioningService', () => { getConfig, getConfigSnapshot, } as any); + const persistInboxMessage = vi.fn(); + (svc as any).persistInboxMessage = persistInboxMessage; await (svc as any).respondToTeammatePermission( { teamName: 'ops-team' }, @@ -14378,6 +14380,13 @@ describe('TeamProvisioningService', () => { expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear'); expect(getConfig).toHaveBeenCalledWith('ops-team'); expect(getConfigSnapshot).not.toHaveBeenCalled(); + expect(persistInboxMessage).toHaveBeenCalledWith( + 'ops-team', + 'alice', + expect.objectContaining({ + text: expect.stringContaining('"type":"permission_response"'), + }) + ); }); it('does not broaden admin/runtime teammate permission suggestions', async () => { @@ -14388,6 +14397,7 @@ describe('TeamProvisioningService', () => { members: [{ cwd: tempClaudeRoot }], })), } as any); + (svc as any).persistInboxMessage = vi.fn(); await (svc as any).respondToTeammatePermission( { teamName: 'ops-team' }, @@ -14412,6 +14422,154 @@ describe('TeamProvisioningService', () => { expect(settings.permissions?.allow).toEqual(['mcp__agent-teams__team_stop']); }); + it('builds teammate AskUserQuestion permission responses with answers', () => { + const svc = new TeamProvisioningService(); + const toolInput = { + questions: [ + { + question: 'What type of calculator app would you like?', + header: 'App type', + options: [ + { label: 'Web UI (Recommended)', description: 'Browser app' }, + { label: 'CLI', description: 'Terminal app' }, + ], + multiSelect: false, + }, + ], + }; + + expect( + (svc as any).buildTeammatePermissionUpdatedInput( + 'AskUserQuestion', + toolInput, + JSON.stringify({ + 'What type of calculator app would you like?': 'Web UI (Recommended)', + }) + ) + ).toEqual({ + ...toolInput, + answers: { + 'What type of calculator app would you like?': 'Web UI (Recommended)', + }, + }); + }); + + it('sends teammate AskUserQuestion permission responses to the teammate inbox', async () => { + const svc = new TeamProvisioningService(); + const persistInboxMessage = vi.fn(); + (svc as any).persistInboxMessage = persistInboxMessage; + const toolInput = { + questions: [ + { + question: 'What type of calculator app would you like?', + options: [{ label: 'Web UI (Recommended)', description: 'Browser app' }], + }, + ], + }; + + await (svc as any).respondToTeammatePermission( + { teamName: 'ops-team', runId: 'run-1' }, + 'bob', + 'perm-1', + true, + JSON.stringify({ + 'What type of calculator app would you like?': 'Web UI (Recommended)', + }), + [], + 'AskUserQuestion', + toolInput + ); + + expect(persistInboxMessage).toHaveBeenCalledTimes(1); + const [, recipient, message] = persistInboxMessage.mock.calls[0]; + expect(recipient).toBe('bob'); + expect(JSON.parse(message.text)).toEqual({ + type: 'permission_response', + request_id: 'perm-1', + subtype: 'success', + response: { + updated_input: { + ...toolInput, + answers: { + 'What type of calculator app would you like?': 'Web UI (Recommended)', + }, + }, + permission_updates: [], + }, + }); + }); + + it('sends teammate denial responses to the teammate inbox', async () => { + const svc = new TeamProvisioningService(); + const persistInboxMessage = vi.fn(); + (svc as any).persistInboxMessage = persistInboxMessage; + + await (svc as any).respondToTeammatePermission( + { teamName: 'ops-team', runId: 'run-1' }, + 'bob', + 'perm-deny', + false, + 'Denied by test', + [], + 'Bash', + { command: 'echo blocked' } + ); + + expect(persistInboxMessage).toHaveBeenCalledTimes(1); + const [, recipient, message] = persistInboxMessage.mock.calls[0]; + expect(recipient).toBe('bob'); + expect(JSON.parse(message.text)).toEqual({ + type: 'permission_response', + request_id: 'perm-deny', + subtype: 'error', + error: 'Denied by test', + }); + }); + + it('keeps AskUserQuestion answers in teammate fallback control responses', async () => { + const write = vi.fn((_line: string, cb?: (error?: Error | null) => void) => { + cb?.(); + return true; + }); + const svc = new TeamProvisioningService({ + getConfig: vi.fn(async () => ({ + projectPath: tempClaudeRoot, + members: [{ cwd: tempClaudeRoot }], + })), + } as any); + (svc as any).persistInboxMessage = vi.fn(); + const toolInput = { + questions: [ + { + question: 'What features do you need?', + options: [{ label: 'Basic', description: 'Arithmetic' }], + }, + ], + }; + + await (svc as any).respondToTeammatePermission( + { + teamName: 'ops-team', + runId: 'run-1', + child: { stdin: { writable: true, write } }, + }, + 'bob', + 'perm-2', + true, + JSON.stringify({ 'What features do you need?': 'Basic' }), + [{ type: 'setMode', mode: 'acceptEdits', destination: 'session' }], + 'AskUserQuestion', + toolInput + ); + + expect(write).toHaveBeenCalledTimes(1); + const payload = JSON.parse(write.mock.calls[0][0]); + expect(payload.response.response.updatedInput).toEqual({ + ...toolInput, + answers: { 'What features do you need?': 'Basic' }, + }); + }); + it('uses a non-alarming model delay message before 2 minutes of silence', () => { const svc = new TeamProvisioningService();