source dump of claude code
at main 928 lines 26 kB view raw
1/** 2 * Synchronized Permission Prompts for Agent Swarms 3 * 4 * This module provides infrastructure for coordinating permission prompts across 5 * multiple agents in a swarm. When a worker agent needs permission for a tool use, 6 * it can forward the request to the team leader, who can then approve or deny it. 7 * 8 * The system uses the teammate mailbox for message passing: 9 * - Workers send permission requests to the leader's mailbox 10 * - Leaders send permission responses to the worker's mailbox 11 * 12 * Flow: 13 * 1. Worker agent encounters a permission prompt 14 * 2. Worker sends a permission_request message to the leader's mailbox 15 * 3. Leader polls for mailbox messages and detects permission requests 16 * 4. User approves/denies via the leader's UI 17 * 5. Leader sends a permission_response message to the worker's mailbox 18 * 6. Worker polls mailbox for responses and continues execution 19 */ 20 21import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises' 22import { join } from 'path' 23import { z } from 'zod/v4' 24import { logForDebugging } from '../debug.js' 25import { getErrnoCode } from '../errors.js' 26import { lazySchema } from '../lazySchema.js' 27import * as lockfile from '../lockfile.js' 28import { logError } from '../log.js' 29import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js' 30import { jsonParse, jsonStringify } from '../slowOperations.js' 31import { 32 getAgentId, 33 getAgentName, 34 getTeammateColor, 35 getTeamName, 36} from '../teammate.js' 37import { 38 createPermissionRequestMessage, 39 createPermissionResponseMessage, 40 createSandboxPermissionRequestMessage, 41 createSandboxPermissionResponseMessage, 42 writeToMailbox, 43} from '../teammateMailbox.js' 44import { getTeamDir, readTeamFileAsync } from './teamHelpers.js' 45 46/** 47 * Full request schema for a permission request from a worker to the leader 48 */ 49export const SwarmPermissionRequestSchema = lazySchema(() => 50 z.object({ 51 /** Unique identifier for this request */ 52 id: z.string(), 53 /** Worker's CLAUDE_CODE_AGENT_ID */ 54 workerId: z.string(), 55 /** Worker's CLAUDE_CODE_AGENT_NAME */ 56 workerName: z.string(), 57 /** Worker's CLAUDE_CODE_AGENT_COLOR */ 58 workerColor: z.string().optional(), 59 /** Team name for routing */ 60 teamName: z.string(), 61 /** Tool name requiring permission (e.g., "Bash", "Edit") */ 62 toolName: z.string(), 63 /** Original toolUseID from worker's context */ 64 toolUseId: z.string(), 65 /** Human-readable description of the tool use */ 66 description: z.string(), 67 /** Serialized tool input */ 68 input: z.record(z.string(), z.unknown()), 69 /** Suggested permission rules from the permission result */ 70 permissionSuggestions: z.array(z.unknown()), 71 /** Status of the request */ 72 status: z.enum(['pending', 'approved', 'rejected']), 73 /** Who resolved the request */ 74 resolvedBy: z.enum(['worker', 'leader']).optional(), 75 /** Timestamp when resolved */ 76 resolvedAt: z.number().optional(), 77 /** Rejection feedback message */ 78 feedback: z.string().optional(), 79 /** Modified input if changed by resolver */ 80 updatedInput: z.record(z.string(), z.unknown()).optional(), 81 /** "Always allow" rules applied during resolution */ 82 permissionUpdates: z.array(z.unknown()).optional(), 83 /** Timestamp when request was created */ 84 createdAt: z.number(), 85 }), 86) 87 88export type SwarmPermissionRequest = z.infer< 89 ReturnType<typeof SwarmPermissionRequestSchema> 90> 91 92/** 93 * Resolution data returned when leader/worker resolves a request 94 */ 95export type PermissionResolution = { 96 /** Decision: approved or rejected */ 97 decision: 'approved' | 'rejected' 98 /** Who resolved it */ 99 resolvedBy: 'worker' | 'leader' 100 /** Optional feedback message if rejected */ 101 feedback?: string 102 /** Optional updated input if the resolver modified it */ 103 updatedInput?: Record<string, unknown> 104 /** Permission updates to apply (e.g., "always allow" rules) */ 105 permissionUpdates?: PermissionUpdate[] 106} 107 108/** 109 * Get the base directory for a team's permission requests 110 * Path: ~/.claude/teams/{teamName}/permissions/ 111 */ 112export function getPermissionDir(teamName: string): string { 113 return join(getTeamDir(teamName), 'permissions') 114} 115 116/** 117 * Get the pending directory for a team 118 */ 119function getPendingDir(teamName: string): string { 120 return join(getPermissionDir(teamName), 'pending') 121} 122 123/** 124 * Get the resolved directory for a team 125 */ 126function getResolvedDir(teamName: string): string { 127 return join(getPermissionDir(teamName), 'resolved') 128} 129 130/** 131 * Ensure the permissions directory structure exists (async) 132 */ 133async function ensurePermissionDirsAsync(teamName: string): Promise<void> { 134 const permDir = getPermissionDir(teamName) 135 const pendingDir = getPendingDir(teamName) 136 const resolvedDir = getResolvedDir(teamName) 137 138 for (const dir of [permDir, pendingDir, resolvedDir]) { 139 await mkdir(dir, { recursive: true }) 140 } 141} 142 143/** 144 * Get the path to a pending request file 145 */ 146function getPendingRequestPath(teamName: string, requestId: string): string { 147 return join(getPendingDir(teamName), `${requestId}.json`) 148} 149 150/** 151 * Get the path to a resolved request file 152 */ 153function getResolvedRequestPath(teamName: string, requestId: string): string { 154 return join(getResolvedDir(teamName), `${requestId}.json`) 155} 156 157/** 158 * Generate a unique request ID 159 */ 160export function generateRequestId(): string { 161 return `perm-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` 162} 163 164/** 165 * Create a new SwarmPermissionRequest object 166 */ 167export function createPermissionRequest(params: { 168 toolName: string 169 toolUseId: string 170 input: Record<string, unknown> 171 description: string 172 permissionSuggestions?: unknown[] 173 teamName?: string 174 workerId?: string 175 workerName?: string 176 workerColor?: string 177}): SwarmPermissionRequest { 178 const teamName = params.teamName || getTeamName() 179 const workerId = params.workerId || getAgentId() 180 const workerName = params.workerName || getAgentName() 181 const workerColor = params.workerColor || getTeammateColor() 182 183 if (!teamName) { 184 throw new Error('Team name is required for permission requests') 185 } 186 if (!workerId) { 187 throw new Error('Worker ID is required for permission requests') 188 } 189 if (!workerName) { 190 throw new Error('Worker name is required for permission requests') 191 } 192 193 return { 194 id: generateRequestId(), 195 workerId, 196 workerName, 197 workerColor, 198 teamName, 199 toolName: params.toolName, 200 toolUseId: params.toolUseId, 201 description: params.description, 202 input: params.input, 203 permissionSuggestions: params.permissionSuggestions || [], 204 status: 'pending', 205 createdAt: Date.now(), 206 } 207} 208 209/** 210 * Write a permission request to the pending directory with file locking 211 * Called by worker agents when they need permission approval from the leader 212 * 213 * @returns The written request 214 */ 215export async function writePermissionRequest( 216 request: SwarmPermissionRequest, 217): Promise<SwarmPermissionRequest> { 218 await ensurePermissionDirsAsync(request.teamName) 219 220 const pendingPath = getPendingRequestPath(request.teamName, request.id) 221 const lockDir = getPendingDir(request.teamName) 222 223 // Create a directory-level lock file for atomic writes 224 const lockFilePath = join(lockDir, '.lock') 225 await writeFile(lockFilePath, '', 'utf-8') 226 227 let release: (() => Promise<void>) | undefined 228 try { 229 release = await lockfile.lock(lockFilePath) 230 231 // Write the request file 232 await writeFile(pendingPath, jsonStringify(request, null, 2), 'utf-8') 233 234 logForDebugging( 235 `[PermissionSync] Wrote pending request ${request.id} from ${request.workerName} for ${request.toolName}`, 236 ) 237 238 return request 239 } catch (error) { 240 logForDebugging( 241 `[PermissionSync] Failed to write permission request: ${error}`, 242 ) 243 logError(error) 244 throw error 245 } finally { 246 if (release) { 247 await release() 248 } 249 } 250} 251 252/** 253 * Read all pending permission requests for a team 254 * Called by the team leader to see what requests need attention 255 */ 256export async function readPendingPermissions( 257 teamName?: string, 258): Promise<SwarmPermissionRequest[]> { 259 const team = teamName || getTeamName() 260 if (!team) { 261 logForDebugging('[PermissionSync] No team name available') 262 return [] 263 } 264 265 const pendingDir = getPendingDir(team) 266 267 let files: string[] 268 try { 269 files = await readdir(pendingDir) 270 } catch (e: unknown) { 271 const code = getErrnoCode(e) 272 if (code === 'ENOENT') { 273 return [] 274 } 275 logForDebugging(`[PermissionSync] Failed to read pending requests: ${e}`) 276 logError(e) 277 return [] 278 } 279 280 const jsonFiles = files.filter(f => f.endsWith('.json') && f !== '.lock') 281 282 const results = await Promise.all( 283 jsonFiles.map(async file => { 284 const filePath = join(pendingDir, file) 285 try { 286 const content = await readFile(filePath, 'utf-8') 287 const parsed = SwarmPermissionRequestSchema().safeParse( 288 jsonParse(content), 289 ) 290 if (parsed.success) { 291 return parsed.data 292 } 293 logForDebugging( 294 `[PermissionSync] Invalid request file ${file}: ${parsed.error.message}`, 295 ) 296 return null 297 } catch (err) { 298 logForDebugging( 299 `[PermissionSync] Failed to read request file ${file}: ${err}`, 300 ) 301 return null 302 } 303 }), 304 ) 305 306 const requests = results.filter(r => r !== null) 307 308 // Sort by creation time (oldest first) 309 requests.sort((a, b) => a.createdAt - b.createdAt) 310 311 return requests 312} 313 314/** 315 * Read a resolved permission request by ID 316 * Called by workers to check if their request has been resolved 317 * 318 * @returns The resolved request, or null if not yet resolved 319 */ 320export async function readResolvedPermission( 321 requestId: string, 322 teamName?: string, 323): Promise<SwarmPermissionRequest | null> { 324 const team = teamName || getTeamName() 325 if (!team) { 326 return null 327 } 328 329 const resolvedPath = getResolvedRequestPath(team, requestId) 330 331 try { 332 const content = await readFile(resolvedPath, 'utf-8') 333 const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content)) 334 if (parsed.success) { 335 return parsed.data 336 } 337 logForDebugging( 338 `[PermissionSync] Invalid resolved request ${requestId}: ${parsed.error.message}`, 339 ) 340 return null 341 } catch (e: unknown) { 342 const code = getErrnoCode(e) 343 if (code === 'ENOENT') { 344 return null 345 } 346 logForDebugging( 347 `[PermissionSync] Failed to read resolved request ${requestId}: ${e}`, 348 ) 349 logError(e) 350 return null 351 } 352} 353 354/** 355 * Resolve a permission request 356 * Called by the team leader (or worker in self-resolution cases) 357 * 358 * Writes the resolution to resolved/, removes from pending/ 359 */ 360export async function resolvePermission( 361 requestId: string, 362 resolution: PermissionResolution, 363 teamName?: string, 364): Promise<boolean> { 365 const team = teamName || getTeamName() 366 if (!team) { 367 logForDebugging('[PermissionSync] No team name available') 368 return false 369 } 370 371 await ensurePermissionDirsAsync(team) 372 373 const pendingPath = getPendingRequestPath(team, requestId) 374 const resolvedPath = getResolvedRequestPath(team, requestId) 375 const lockFilePath = join(getPendingDir(team), '.lock') 376 377 await writeFile(lockFilePath, '', 'utf-8') 378 379 let release: (() => Promise<void>) | undefined 380 try { 381 release = await lockfile.lock(lockFilePath) 382 383 // Read the pending request 384 let content: string 385 try { 386 content = await readFile(pendingPath, 'utf-8') 387 } catch (e: unknown) { 388 const code = getErrnoCode(e) 389 if (code === 'ENOENT') { 390 logForDebugging( 391 `[PermissionSync] Pending request not found: ${requestId}`, 392 ) 393 return false 394 } 395 throw e 396 } 397 398 const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content)) 399 if (!parsed.success) { 400 logForDebugging( 401 `[PermissionSync] Invalid pending request ${requestId}: ${parsed.error.message}`, 402 ) 403 return false 404 } 405 406 const request = parsed.data 407 408 // Update the request with resolution data 409 const resolvedRequest: SwarmPermissionRequest = { 410 ...request, 411 status: resolution.decision === 'approved' ? 'approved' : 'rejected', 412 resolvedBy: resolution.resolvedBy, 413 resolvedAt: Date.now(), 414 feedback: resolution.feedback, 415 updatedInput: resolution.updatedInput, 416 permissionUpdates: resolution.permissionUpdates, 417 } 418 419 // Write to resolved directory 420 await writeFile( 421 resolvedPath, 422 jsonStringify(resolvedRequest, null, 2), 423 'utf-8', 424 ) 425 426 // Remove from pending directory 427 await unlink(pendingPath) 428 429 logForDebugging( 430 `[PermissionSync] Resolved request ${requestId} with ${resolution.decision}`, 431 ) 432 433 return true 434 } catch (error) { 435 logForDebugging(`[PermissionSync] Failed to resolve request: ${error}`) 436 logError(error) 437 return false 438 } finally { 439 if (release) { 440 await release() 441 } 442 } 443} 444 445/** 446 * Clean up old resolved permission files 447 * Called periodically to prevent file accumulation 448 * 449 * @param teamName - Team name 450 * @param maxAgeMs - Maximum age in milliseconds (default: 1 hour) 451 */ 452export async function cleanupOldResolutions( 453 teamName?: string, 454 maxAgeMs = 3600000, 455): Promise<number> { 456 const team = teamName || getTeamName() 457 if (!team) { 458 return 0 459 } 460 461 const resolvedDir = getResolvedDir(team) 462 463 let files: string[] 464 try { 465 files = await readdir(resolvedDir) 466 } catch (e: unknown) { 467 const code = getErrnoCode(e) 468 if (code === 'ENOENT') { 469 return 0 470 } 471 logForDebugging(`[PermissionSync] Failed to cleanup resolutions: ${e}`) 472 logError(e) 473 return 0 474 } 475 476 const now = Date.now() 477 const jsonFiles = files.filter(f => f.endsWith('.json')) 478 479 const cleanupResults = await Promise.all( 480 jsonFiles.map(async file => { 481 const filePath = join(resolvedDir, file) 482 try { 483 const content = await readFile(filePath, 'utf-8') 484 const request = jsonParse(content) as SwarmPermissionRequest 485 486 // Check if the resolution is old enough to clean up 487 // Use >= to handle edge case where maxAgeMs is 0 (clean up everything) 488 const resolvedAt = request.resolvedAt || request.createdAt 489 if (now - resolvedAt >= maxAgeMs) { 490 await unlink(filePath) 491 logForDebugging(`[PermissionSync] Cleaned up old resolution: ${file}`) 492 return 1 493 } 494 return 0 495 } catch { 496 // If we can't parse it, clean it up anyway 497 try { 498 await unlink(filePath) 499 return 1 500 } catch { 501 // Ignore deletion errors 502 return 0 503 } 504 } 505 }), 506 ) 507 508 const cleanedCount = cleanupResults.reduce<number>((sum, n) => sum + n, 0) 509 510 if (cleanedCount > 0) { 511 logForDebugging( 512 `[PermissionSync] Cleaned up ${cleanedCount} old resolutions`, 513 ) 514 } 515 516 return cleanedCount 517} 518 519/** 520 * Legacy response type for worker polling 521 * Used for backward compatibility with worker integration code 522 */ 523export type PermissionResponse = { 524 /** ID of the request this responds to */ 525 requestId: string 526 /** Decision: approved or denied */ 527 decision: 'approved' | 'denied' 528 /** Timestamp when response was created */ 529 timestamp: string 530 /** Optional feedback message if denied */ 531 feedback?: string 532 /** Optional updated input if the resolver modified it */ 533 updatedInput?: Record<string, unknown> 534 /** Permission updates to apply (e.g., "always allow" rules) */ 535 permissionUpdates?: unknown[] 536} 537 538/** 539 * Poll for a permission response (worker-side convenience function) 540 * Converts the resolved request into a simpler response format 541 * 542 * @returns The permission response, or null if not yet resolved 543 */ 544export async function pollForResponse( 545 requestId: string, 546 _agentName?: string, 547 teamName?: string, 548): Promise<PermissionResponse | null> { 549 const resolved = await readResolvedPermission(requestId, teamName) 550 if (!resolved) { 551 return null 552 } 553 554 return { 555 requestId: resolved.id, 556 decision: resolved.status === 'approved' ? 'approved' : 'denied', 557 timestamp: resolved.resolvedAt 558 ? new Date(resolved.resolvedAt).toISOString() 559 : new Date(resolved.createdAt).toISOString(), 560 feedback: resolved.feedback, 561 updatedInput: resolved.updatedInput, 562 permissionUpdates: resolved.permissionUpdates, 563 } 564} 565 566/** 567 * Remove a worker's response after processing 568 * This is an alias for deleteResolvedPermission for backward compatibility 569 */ 570export async function removeWorkerResponse( 571 requestId: string, 572 _agentName?: string, 573 teamName?: string, 574): Promise<void> { 575 await deleteResolvedPermission(requestId, teamName) 576} 577 578/** 579 * Check if the current agent is a team leader 580 */ 581export function isTeamLeader(teamName?: string): boolean { 582 const team = teamName || getTeamName() 583 if (!team) { 584 return false 585 } 586 587 // Team leaders don't have an agent ID set, or their ID is 'team-lead' 588 const agentId = getAgentId() 589 590 return !agentId || agentId === 'team-lead' 591} 592 593/** 594 * Check if the current agent is a worker in a swarm 595 */ 596export function isSwarmWorker(): boolean { 597 const teamName = getTeamName() 598 const agentId = getAgentId() 599 600 return !!teamName && !!agentId && !isTeamLeader() 601} 602 603/** 604 * Delete a resolved permission file 605 * Called after a worker has processed the resolution 606 */ 607export async function deleteResolvedPermission( 608 requestId: string, 609 teamName?: string, 610): Promise<boolean> { 611 const team = teamName || getTeamName() 612 if (!team) { 613 return false 614 } 615 616 const resolvedPath = getResolvedRequestPath(team, requestId) 617 618 try { 619 await unlink(resolvedPath) 620 logForDebugging( 621 `[PermissionSync] Deleted resolved permission: ${requestId}`, 622 ) 623 return true 624 } catch (e: unknown) { 625 const code = getErrnoCode(e) 626 if (code === 'ENOENT') { 627 return false 628 } 629 logForDebugging( 630 `[PermissionSync] Failed to delete resolved permission: ${e}`, 631 ) 632 logError(e) 633 return false 634 } 635} 636 637/** 638 * Submit a permission request (alias for writePermissionRequest) 639 * Provided for backward compatibility with worker integration code 640 */ 641export const submitPermissionRequest = writePermissionRequest 642 643// ============================================================================ 644// Mailbox-Based Permission System 645// ============================================================================ 646 647/** 648 * Get the leader's name from the team file 649 * This is needed to send permission requests to the leader's mailbox 650 */ 651export async function getLeaderName(teamName?: string): Promise<string | null> { 652 const team = teamName || getTeamName() 653 if (!team) { 654 return null 655 } 656 657 const teamFile = await readTeamFileAsync(team) 658 if (!teamFile) { 659 logForDebugging(`[PermissionSync] Team file not found for team: ${team}`) 660 return null 661 } 662 663 const leadMember = teamFile.members.find( 664 m => m.agentId === teamFile.leadAgentId, 665 ) 666 return leadMember?.name || 'team-lead' 667} 668 669/** 670 * Send a permission request to the leader via mailbox. 671 * This is the new mailbox-based approach that replaces the file-based pending directory. 672 * 673 * @param request - The permission request to send 674 * @returns true if the message was sent successfully 675 */ 676export async function sendPermissionRequestViaMailbox( 677 request: SwarmPermissionRequest, 678): Promise<boolean> { 679 const leaderName = await getLeaderName(request.teamName) 680 if (!leaderName) { 681 logForDebugging( 682 `[PermissionSync] Cannot send permission request: leader name not found`, 683 ) 684 return false 685 } 686 687 try { 688 // Create the permission request message 689 const message = createPermissionRequestMessage({ 690 request_id: request.id, 691 agent_id: request.workerName, 692 tool_name: request.toolName, 693 tool_use_id: request.toolUseId, 694 description: request.description, 695 input: request.input, 696 permission_suggestions: request.permissionSuggestions, 697 }) 698 699 // Send to leader's mailbox (routes to in-process or file-based based on recipient) 700 await writeToMailbox( 701 leaderName, 702 { 703 from: request.workerName, 704 text: jsonStringify(message), 705 timestamp: new Date().toISOString(), 706 color: request.workerColor, 707 }, 708 request.teamName, 709 ) 710 711 logForDebugging( 712 `[PermissionSync] Sent permission request ${request.id} to leader ${leaderName} via mailbox`, 713 ) 714 return true 715 } catch (error) { 716 logForDebugging( 717 `[PermissionSync] Failed to send permission request via mailbox: ${error}`, 718 ) 719 logError(error) 720 return false 721 } 722} 723 724/** 725 * Send a permission response to a worker via mailbox. 726 * This is the new mailbox-based approach that replaces the file-based resolved directory. 727 * 728 * @param workerName - The worker's name to send the response to 729 * @param resolution - The permission resolution 730 * @param requestId - The original request ID 731 * @param teamName - The team name 732 * @returns true if the message was sent successfully 733 */ 734export async function sendPermissionResponseViaMailbox( 735 workerName: string, 736 resolution: PermissionResolution, 737 requestId: string, 738 teamName?: string, 739): Promise<boolean> { 740 const team = teamName || getTeamName() 741 if (!team) { 742 logForDebugging( 743 `[PermissionSync] Cannot send permission response: team name not found`, 744 ) 745 return false 746 } 747 748 try { 749 // Create the permission response message 750 const message = createPermissionResponseMessage({ 751 request_id: requestId, 752 subtype: resolution.decision === 'approved' ? 'success' : 'error', 753 error: resolution.feedback, 754 updated_input: resolution.updatedInput, 755 permission_updates: resolution.permissionUpdates, 756 }) 757 758 // Get the sender name (leader's name) 759 const senderName = getAgentName() || 'team-lead' 760 761 // Send to worker's mailbox (routes to in-process or file-based based on recipient) 762 await writeToMailbox( 763 workerName, 764 { 765 from: senderName, 766 text: jsonStringify(message), 767 timestamp: new Date().toISOString(), 768 }, 769 team, 770 ) 771 772 logForDebugging( 773 `[PermissionSync] Sent permission response for ${requestId} to worker ${workerName} via mailbox`, 774 ) 775 return true 776 } catch (error) { 777 logForDebugging( 778 `[PermissionSync] Failed to send permission response via mailbox: ${error}`, 779 ) 780 logError(error) 781 return false 782 } 783} 784 785// ============================================================================ 786// Sandbox Permission Mailbox System 787// ============================================================================ 788 789/** 790 * Generate a unique sandbox permission request ID 791 */ 792export function generateSandboxRequestId(): string { 793 return `sandbox-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` 794} 795 796/** 797 * Send a sandbox permission request to the leader via mailbox. 798 * Called by workers when sandbox runtime needs network access approval. 799 * 800 * @param host - The host requesting network access 801 * @param requestId - Unique ID for this request 802 * @param teamName - Optional team name 803 * @returns true if the message was sent successfully 804 */ 805export async function sendSandboxPermissionRequestViaMailbox( 806 host: string, 807 requestId: string, 808 teamName?: string, 809): Promise<boolean> { 810 const team = teamName || getTeamName() 811 if (!team) { 812 logForDebugging( 813 `[PermissionSync] Cannot send sandbox permission request: team name not found`, 814 ) 815 return false 816 } 817 818 const leaderName = await getLeaderName(team) 819 if (!leaderName) { 820 logForDebugging( 821 `[PermissionSync] Cannot send sandbox permission request: leader name not found`, 822 ) 823 return false 824 } 825 826 const workerId = getAgentId() 827 const workerName = getAgentName() 828 const workerColor = getTeammateColor() 829 830 if (!workerId || !workerName) { 831 logForDebugging( 832 `[PermissionSync] Cannot send sandbox permission request: worker ID or name not found`, 833 ) 834 return false 835 } 836 837 try { 838 const message = createSandboxPermissionRequestMessage({ 839 requestId, 840 workerId, 841 workerName, 842 workerColor, 843 host, 844 }) 845 846 // Send to leader's mailbox (routes to in-process or file-based based on recipient) 847 await writeToMailbox( 848 leaderName, 849 { 850 from: workerName, 851 text: jsonStringify(message), 852 timestamp: new Date().toISOString(), 853 color: workerColor, 854 }, 855 team, 856 ) 857 858 logForDebugging( 859 `[PermissionSync] Sent sandbox permission request ${requestId} for host ${host} to leader ${leaderName} via mailbox`, 860 ) 861 return true 862 } catch (error) { 863 logForDebugging( 864 `[PermissionSync] Failed to send sandbox permission request via mailbox: ${error}`, 865 ) 866 logError(error) 867 return false 868 } 869} 870 871/** 872 * Send a sandbox permission response to a worker via mailbox. 873 * Called by the leader when approving/denying a sandbox network access request. 874 * 875 * @param workerName - The worker's name to send the response to 876 * @param requestId - The original request ID 877 * @param host - The host that was approved/denied 878 * @param allow - Whether the connection is allowed 879 * @param teamName - Optional team name 880 * @returns true if the message was sent successfully 881 */ 882export async function sendSandboxPermissionResponseViaMailbox( 883 workerName: string, 884 requestId: string, 885 host: string, 886 allow: boolean, 887 teamName?: string, 888): Promise<boolean> { 889 const team = teamName || getTeamName() 890 if (!team) { 891 logForDebugging( 892 `[PermissionSync] Cannot send sandbox permission response: team name not found`, 893 ) 894 return false 895 } 896 897 try { 898 const message = createSandboxPermissionResponseMessage({ 899 requestId, 900 host, 901 allow, 902 }) 903 904 const senderName = getAgentName() || 'team-lead' 905 906 // Send to worker's mailbox (routes to in-process or file-based based on recipient) 907 await writeToMailbox( 908 workerName, 909 { 910 from: senderName, 911 text: jsonStringify(message), 912 timestamp: new Date().toISOString(), 913 }, 914 team, 915 ) 916 917 logForDebugging( 918 `[PermissionSync] Sent sandbox permission response for ${requestId} (host: ${host}, allow: ${allow}) to worker ${workerName} via mailbox`, 919 ) 920 return true 921 } catch (error) { 922 logForDebugging( 923 `[PermissionSync] Failed to send sandbox permission response via mailbox: ${error}`, 924 ) 925 logError(error) 926 return false 927 } 928}