Files
openclaude/src/utils/swarm/permissionSync.ts

456 lines
13 KiB
TypeScript

/**
* Synchronized Permission Prompts for Agent Swarms
*
* This module provides infrastructure for coordinating permission prompts across
* multiple agents in a swarm. When a worker agent needs permission for a tool use,
* it can forward the request to the team leader, who can then approve or deny it.
*
* The system uses the teammate mailbox for message passing:
* - Workers send permission requests to the leader's mailbox
* - Leaders send permission responses to the worker's mailbox
*
* Flow:
* 1. Worker agent encounters a permission prompt
* 2. Worker sends a permission_request message to the leader's mailbox
* 3. Leader polls for mailbox messages and detects permission requests
* 4. User approves/denies via the leader's UI
* 5. Leader sends a permission_response message to the worker's mailbox
* 6. Worker polls mailbox for responses and continues execution
*/
import { logForDebugging } from '../debug.js'
import { logError } from '../log.js'
import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js'
import { jsonStringify } from '../slowOperations.js'
import {
getAgentId,
getAgentName,
getTeammateColor,
getTeamName,
} from '../teammate.js'
import {
createPermissionRequestMessage,
createPermissionResponseMessage,
createSandboxPermissionRequestMessage,
createSandboxPermissionResponseMessage,
writeToMailbox,
} from '../teammateMailbox.js'
import { readTeamFileAsync } from './teamHelpers.js'
export type SwarmPermissionRequest = {
/** Unique identifier for this request */
id: string
/** Worker's CLAUDE_CODE_AGENT_ID */
workerId: string
/** Worker's CLAUDE_CODE_AGENT_NAME */
workerName: string
/** Worker's CLAUDE_CODE_AGENT_COLOR */
workerColor?: string
/** Team name for routing */
teamName: string
/** Tool name requiring permission (e.g., "Bash", "Edit") */
toolName: string
/** Original toolUseID from worker's context */
toolUseId: string
/** Human-readable description of the tool use */
description: string
/** Serialized tool input */
input: Record<string, unknown>
/** Suggested permission rules from the permission result */
permissionSuggestions: unknown[]
/** Status of the request */
status: 'pending' | 'approved' | 'rejected'
/** Who resolved the request */
resolvedBy?: 'worker' | 'leader'
/** Timestamp when resolved */
resolvedAt?: number
/** Rejection feedback message */
feedback?: string
/** Modified input if changed by resolver */
updatedInput?: Record<string, unknown>
/** "Always allow" rules applied during resolution */
permissionUpdates?: unknown[]
/** Timestamp when request was created */
createdAt: number
}
/**
* Resolution data returned when leader/worker resolves a request
*/
export type PermissionResolution = {
/** Decision: approved or rejected */
decision: 'approved' | 'rejected'
/** Who resolved it */
resolvedBy: 'worker' | 'leader'
/** Optional feedback message if rejected */
feedback?: string
/** Optional updated input if the resolver modified it */
updatedInput?: Record<string, unknown>
/** Permission updates to apply (e.g., "always allow" rules) */
permissionUpdates?: PermissionUpdate[]
}
/**
* Generate a unique request ID
*/
export function generateRequestId(): string {
return `perm-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
/**
* Create a new SwarmPermissionRequest object
*/
export function createPermissionRequest(params: {
toolName: string
toolUseId: string
input: Record<string, unknown>
description: string
permissionSuggestions?: unknown[]
teamName?: string
workerId?: string
workerName?: string
workerColor?: string
}): SwarmPermissionRequest {
const teamName = params.teamName || getTeamName()
const workerId = params.workerId || getAgentId()
const workerName = params.workerName || getAgentName()
const workerColor = params.workerColor || getTeammateColor()
if (!teamName) {
throw new Error('Team name is required for permission requests')
}
if (!workerId) {
throw new Error('Worker ID is required for permission requests')
}
if (!workerName) {
throw new Error('Worker name is required for permission requests')
}
return {
id: generateRequestId(),
workerId,
workerName,
workerColor,
teamName,
toolName: params.toolName,
toolUseId: params.toolUseId,
description: params.description,
input: params.input,
permissionSuggestions: params.permissionSuggestions || [],
status: 'pending',
createdAt: Date.now(),
}
}
/**
* Check if the current agent is a team leader
*/
export function isTeamLeader(teamName?: string): boolean {
const team = teamName || getTeamName()
if (!team) {
return false
}
// Team leaders don't have an agent ID set, or their ID is 'team-lead'
const agentId = getAgentId()
return !agentId || agentId === 'team-lead'
}
/**
* Check if the current agent is a worker in a swarm
*/
export function isSwarmWorker(): boolean {
const teamName = getTeamName()
const agentId = getAgentId()
return !!teamName && !!agentId && !isTeamLeader()
}
// ============================================================================
// Mailbox-Based Permission System
// ============================================================================
/**
* Get the leader's name from the team file
* This is needed to send permission requests to the leader's mailbox
*/
export async function getLeaderName(teamName?: string): Promise<string | null> {
const team = teamName || getTeamName()
if (!team) {
return null
}
const teamFile = await readTeamFileAsync(team)
if (!teamFile) {
logForDebugging(`[PermissionSync] Team file not found for team: ${team}`)
return null
}
const leadMember = teamFile.members.find(
m => m.agentId === teamFile.leadAgentId,
)
return leadMember?.name || 'team-lead'
}
/**
* Send a permission request to the leader via mailbox.
* This is the new mailbox-based approach that replaces the file-based pending directory.
*
* @param request - The permission request to send
* @returns true if the message was sent successfully
*/
export async function sendPermissionRequestViaMailbox(
request: SwarmPermissionRequest,
): Promise<boolean> {
const leaderName = await getLeaderName(request.teamName)
if (!leaderName) {
logForDebugging(
`[PermissionSync] Cannot send permission request: leader name not found`,
)
return false
}
try {
// Create the permission request message
const message = createPermissionRequestMessage({
request_id: request.id,
agent_id: request.workerName,
tool_name: request.toolName,
tool_use_id: request.toolUseId,
description: request.description,
input: request.input,
permission_suggestions: request.permissionSuggestions,
})
// Send to leader's mailbox (routes to in-process or file-based based on recipient)
await writeToMailbox(
leaderName,
{
from: request.workerName,
text: jsonStringify(message),
timestamp: new Date().toISOString(),
color: request.workerColor,
},
request.teamName,
)
logForDebugging(
`[PermissionSync] Sent permission request ${request.id} to leader ${leaderName} via mailbox`,
)
return true
} catch (error) {
logForDebugging(
`[PermissionSync] Failed to send permission request via mailbox: ${error}`,
)
logError(error)
return false
}
}
/**
* Send a permission response to a worker via mailbox.
* This is the new mailbox-based approach that replaces the file-based resolved directory.
*
* @param workerName - The worker's name to send the response to
* @param resolution - The permission resolution
* @param requestId - The original request ID
* @param teamName - The team name
* @returns true if the message was sent successfully
*/
export async function sendPermissionResponseViaMailbox(
workerName: string,
resolution: PermissionResolution,
requestId: string,
teamName?: string,
): Promise<boolean> {
const team = teamName || getTeamName()
if (!team) {
logForDebugging(
`[PermissionSync] Cannot send permission response: team name not found`,
)
return false
}
try {
// Create the permission response message
const message = createPermissionResponseMessage({
request_id: requestId,
subtype: resolution.decision === 'approved' ? 'success' : 'error',
error: resolution.feedback,
updated_input: resolution.updatedInput,
permission_updates: resolution.permissionUpdates,
})
// Get the sender name (leader's name)
const senderName = getAgentName() || 'team-lead'
// Send to worker's mailbox (routes to in-process or file-based based on recipient)
await writeToMailbox(
workerName,
{
from: senderName,
text: jsonStringify(message),
timestamp: new Date().toISOString(),
},
team,
)
logForDebugging(
`[PermissionSync] Sent permission response for ${requestId} to worker ${workerName} via mailbox`,
)
return true
} catch (error) {
logForDebugging(
`[PermissionSync] Failed to send permission response via mailbox: ${error}`,
)
logError(error)
return false
}
}
// ============================================================================
// Sandbox Permission Mailbox System
// ============================================================================
/**
* Generate a unique sandbox permission request ID
*/
export function generateSandboxRequestId(): string {
return `sandbox-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
/**
* Send a sandbox permission request to the leader via mailbox.
* Called by workers when sandbox runtime needs network access approval.
*
* @param host - The host requesting network access
* @param requestId - Unique ID for this request
* @param teamName - Optional team name
* @returns true if the message was sent successfully
*/
export async function sendSandboxPermissionRequestViaMailbox(
host: string,
requestId: string,
teamName?: string,
): Promise<boolean> {
const team = teamName || getTeamName()
if (!team) {
logForDebugging(
`[PermissionSync] Cannot send sandbox permission request: team name not found`,
)
return false
}
const leaderName = await getLeaderName(team)
if (!leaderName) {
logForDebugging(
`[PermissionSync] Cannot send sandbox permission request: leader name not found`,
)
return false
}
const workerId = getAgentId()
const workerName = getAgentName()
const workerColor = getTeammateColor()
if (!workerId || !workerName) {
logForDebugging(
`[PermissionSync] Cannot send sandbox permission request: worker ID or name not found`,
)
return false
}
try {
const message = createSandboxPermissionRequestMessage({
requestId,
workerId,
workerName,
workerColor,
host,
})
// Send to leader's mailbox (routes to in-process or file-based based on recipient)
await writeToMailbox(
leaderName,
{
from: workerName,
text: jsonStringify(message),
timestamp: new Date().toISOString(),
color: workerColor,
},
team,
)
logForDebugging(
`[PermissionSync] Sent sandbox permission request ${requestId} for host ${host} to leader ${leaderName} via mailbox`,
)
return true
} catch (error) {
logForDebugging(
`[PermissionSync] Failed to send sandbox permission request via mailbox: ${error}`,
)
logError(error)
return false
}
}
/**
* Send a sandbox permission response to a worker via mailbox.
* Called by the leader when approving/denying a sandbox network access request.
*
* @param workerName - The worker's name to send the response to
* @param requestId - The original request ID
* @param host - The host that was approved/denied
* @param allow - Whether the connection is allowed
* @param teamName - Optional team name
* @returns true if the message was sent successfully
*/
export async function sendSandboxPermissionResponseViaMailbox(
workerName: string,
requestId: string,
host: string,
allow: boolean,
teamName?: string,
): Promise<boolean> {
const team = teamName || getTeamName()
if (!team) {
logForDebugging(
`[PermissionSync] Cannot send sandbox permission response: team name not found`,
)
return false
}
try {
const message = createSandboxPermissionResponseMessage({
requestId,
host,
allow,
})
const senderName = getAgentName() || 'team-lead'
// Send to worker's mailbox (routes to in-process or file-based based on recipient)
await writeToMailbox(
workerName,
{
from: senderName,
text: jsonStringify(message),
timestamp: new Date().toISOString(),
},
team,
)
logForDebugging(
`[PermissionSync] Sent sandbox permission response for ${requestId} (host: ${host}, allow: ${allow}) to worker ${workerName} via mailbox`,
)
return true
} catch (error) {
logForDebugging(
`[PermissionSync] Failed to send sandbox permission response via mailbox: ${error}`,
)
logError(error)
return false
}
}