456 lines
13 KiB
TypeScript
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
|
|
}
|
|
}
|