Remove legacy swarm permission disk sync
This commit is contained in:
@@ -1,31 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Swarm Permission Poller Hook
|
* Swarm permission callback registry helpers.
|
||||||
*
|
*
|
||||||
* This hook polls for permission responses from the team leader when running
|
* Permission requests/responses now flow entirely through teammate mailboxes.
|
||||||
* as a worker agent in a swarm. When a response is received, it calls the
|
* Workers register callbacks here, and the inbox poller dispatches mailbox
|
||||||
* appropriate callback (onAllow/onReject) to continue execution.
|
* responses back into those callbacks.
|
||||||
*
|
|
||||||
* This hook should be used in conjunction with the worker-side integration
|
|
||||||
* in useCanUseTool.ts, which creates pending requests that this hook monitors.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
|
||||||
import { useInterval } from 'usehooks-ts'
|
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
import { errorMessage } from '../utils/errors.js'
|
|
||||||
import {
|
import {
|
||||||
type PermissionUpdate,
|
type PermissionUpdate,
|
||||||
permissionUpdateSchema,
|
permissionUpdateSchema,
|
||||||
} from '../utils/permissions/PermissionUpdateSchema.js'
|
} from '../utils/permissions/PermissionUpdateSchema.js'
|
||||||
import {
|
|
||||||
isSwarmWorker,
|
|
||||||
type PermissionResponse,
|
|
||||||
pollForResponse,
|
|
||||||
removeWorkerResponse,
|
|
||||||
} from '../utils/swarm/permissionSync.js'
|
|
||||||
import { getAgentName, getTeamName } from '../utils/teammate.js'
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 500
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate permissionUpdates from external sources (mailbox IPC, disk polling).
|
* Validate permissionUpdates from external sources (mailbox IPC, disk polling).
|
||||||
@@ -226,105 +211,9 @@ export function processSandboxPermissionResponse(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a permission response by invoking the registered callback
|
* Legacy no-op hook kept for compatibility with older imports.
|
||||||
*/
|
* Mailbox responses are handled by useInboxPoller instead of disk polling.
|
||||||
function processResponse(response: PermissionResponse): boolean {
|
|
||||||
const callback = pendingCallbacks.get(response.requestId)
|
|
||||||
|
|
||||||
if (!callback) {
|
|
||||||
logForDebugging(
|
|
||||||
`[SwarmPermissionPoller] No callback registered for request ${response.requestId}`,
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
logForDebugging(
|
|
||||||
`[SwarmPermissionPoller] Processing response for request ${response.requestId}: ${response.decision}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Remove from registry before invoking callback
|
|
||||||
pendingCallbacks.delete(response.requestId)
|
|
||||||
|
|
||||||
if (response.decision === 'approved') {
|
|
||||||
const permissionUpdates = parsePermissionUpdates(response.permissionUpdates)
|
|
||||||
const updatedInput = response.updatedInput
|
|
||||||
callback.onAllow(updatedInput, permissionUpdates)
|
|
||||||
} else {
|
|
||||||
callback.onReject(response.feedback)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook that polls for permission responses when running as a swarm worker.
|
|
||||||
*
|
|
||||||
* This hook:
|
|
||||||
* 1. Only activates when isSwarmWorker() returns true
|
|
||||||
* 2. Polls every 500ms for responses
|
|
||||||
* 3. When a response is found, invokes the registered callback
|
|
||||||
* 4. Cleans up the response file after processing
|
|
||||||
*/
|
*/
|
||||||
export function useSwarmPermissionPoller(): void {
|
export function useSwarmPermissionPoller(): void {
|
||||||
const isProcessingRef = useRef(false)
|
// Intentionally empty.
|
||||||
|
|
||||||
const poll = useCallback(async () => {
|
|
||||||
// Don't poll if not a swarm worker
|
|
||||||
if (!isSwarmWorker()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent concurrent polling
|
|
||||||
if (isProcessingRef.current) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't poll if no callbacks are registered
|
|
||||||
if (pendingCallbacks.size === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isProcessingRef.current = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const agentName = getAgentName()
|
|
||||||
const teamName = getTeamName()
|
|
||||||
|
|
||||||
if (!agentName || !teamName) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check each pending request for a response
|
|
||||||
for (const [requestId, _callback] of pendingCallbacks) {
|
|
||||||
const response = await pollForResponse(requestId, agentName, teamName)
|
|
||||||
|
|
||||||
if (response) {
|
|
||||||
// Process the response
|
|
||||||
const processed = processResponse(response)
|
|
||||||
|
|
||||||
if (processed) {
|
|
||||||
// Clean up the response from the worker's inbox
|
|
||||||
await removeWorkerResponse(requestId, agentName, teamName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logForDebugging(
|
|
||||||
`[SwarmPermissionPoller] Error during poll: ${errorMessage(error)}`,
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
isProcessingRef.current = false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Only poll if we're a swarm worker
|
|
||||||
const shouldPoll = isSwarmWorker()
|
|
||||||
useInterval(() => void poll(), shouldPoll ? POLL_INTERVAL_MS : null)
|
|
||||||
|
|
||||||
// Initial poll on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSwarmWorker()) {
|
|
||||||
void poll()
|
|
||||||
}
|
|
||||||
}, [poll])
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ function appendToLog(path: string, message: object): void {
|
|||||||
const messageWithTimestamp = {
|
const messageWithTimestamp = {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
...message,
|
...message,
|
||||||
cwd: getFsImplementation().cwd(),
|
|
||||||
userType: process.env.USER_TYPE,
|
userType: process.env.USER_TYPE,
|
||||||
sessionId: getSessionId(),
|
sessionId: getSessionId(),
|
||||||
version: MACRO.VERSION,
|
version: MACRO.VERSION,
|
||||||
@@ -125,25 +124,12 @@ function appendToLog(path: string, message: object): void {
|
|||||||
getLogWriter(path).write(messageWithTimestamp)
|
getLogWriter(path).write(messageWithTimestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractServerMessage(data: unknown): string | undefined {
|
function summarizeUrlForLogs(url: string): string | undefined {
|
||||||
if (typeof data === 'string') {
|
try {
|
||||||
return data
|
return new URL(url).host || undefined
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
if (data && typeof data === 'object') {
|
|
||||||
const obj = data as Record<string, unknown>
|
|
||||||
if (typeof obj.message === 'string') {
|
|
||||||
return obj.message
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof obj.error === 'object' &&
|
|
||||||
obj.error &&
|
|
||||||
'message' in obj.error &&
|
|
||||||
typeof (obj.error as Record<string, unknown>).message === 'string'
|
|
||||||
) {
|
|
||||||
return (obj.error as Record<string, unknown>).message as string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,15 +141,15 @@ function logErrorImpl(error: Error): void {
|
|||||||
// Enrich axios errors with request URL, status, and server message for debugging
|
// Enrich axios errors with request URL, status, and server message for debugging
|
||||||
let context = ''
|
let context = ''
|
||||||
if (axios.isAxiosError(error) && error.config?.url) {
|
if (axios.isAxiosError(error) && error.config?.url) {
|
||||||
const parts = [`url=${error.config.url}`]
|
const parts: string[] = []
|
||||||
|
const host = summarizeUrlForLogs(error.config.url)
|
||||||
|
if (host) {
|
||||||
|
parts.push(`host=${host}`)
|
||||||
|
}
|
||||||
if (error.response?.status !== undefined) {
|
if (error.response?.status !== undefined) {
|
||||||
parts.push(`status=${error.response.status}`)
|
parts.push(`status=${error.response.status}`)
|
||||||
}
|
}
|
||||||
const serverMessage = extractServerMessage(error.response?.data)
|
context = parts.length > 0 ? `[${parts.join(',')}] ` : ''
|
||||||
if (serverMessage) {
|
|
||||||
parts.push(`body=${serverMessage}`)
|
|
||||||
}
|
|
||||||
context = `[${parts.join(',')}] `
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logForDebugging(`${error.name}: ${context}${errorStr}`, { level: 'error' })
|
logForDebugging(`${error.name}: ${context}${errorStr}`, { level: 'error' })
|
||||||
@@ -188,7 +174,6 @@ function logMCPErrorImpl(serverName: string, error: unknown): void {
|
|||||||
error: errorStr,
|
error: errorStr,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
sessionId: getSessionId(),
|
sessionId: getSessionId(),
|
||||||
cwd: getFsImplementation().cwd(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getLogWriter(logFile).write(errorInfo)
|
getLogWriter(logFile).write(errorInfo)
|
||||||
@@ -206,7 +191,6 @@ function logMCPDebugImpl(serverName: string, message: string): void {
|
|||||||
debug: message,
|
debug: message,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
sessionId: getSessionId(),
|
sessionId: getSessionId(),
|
||||||
cwd: getFsImplementation().cwd(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getLogWriter(logFile).write(debugInfo)
|
getLogWriter(logFile).write(debugInfo)
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export const setupGracefulShutdown = memoize(() => {
|
|||||||
process.on('uncaughtException', error => {
|
process.on('uncaughtException', error => {
|
||||||
logForDiagnosticsNoPII('error', 'uncaught_exception', {
|
logForDiagnosticsNoPII('error', 'uncaught_exception', {
|
||||||
error_name: error.name,
|
error_name: error.name,
|
||||||
error_message: error.message.slice(0, 2000),
|
has_message: error.message.length > 0,
|
||||||
})
|
})
|
||||||
logEvent('tengu_uncaught_exception', {
|
logEvent('tengu_uncaught_exception', {
|
||||||
error_name:
|
error_name:
|
||||||
@@ -321,10 +321,10 @@ export const setupGracefulShutdown = memoize(() => {
|
|||||||
reason instanceof Error
|
reason instanceof Error
|
||||||
? {
|
? {
|
||||||
error_name: reason.name,
|
error_name: reason.name,
|
||||||
error_message: reason.message.slice(0, 2000),
|
has_message: reason.message.length > 0,
|
||||||
error_stack: reason.stack?.slice(0, 4000),
|
has_stack: Boolean(reason.stack),
|
||||||
}
|
}
|
||||||
: { error_message: String(reason).slice(0, 2000) }
|
: { reason_type: typeof reason }
|
||||||
logForDiagnosticsNoPII('error', 'unhandled_rejection', errorInfo)
|
logForDiagnosticsNoPII('error', 'unhandled_rejection', errorInfo)
|
||||||
logEvent('tengu_unhandled_rejection', {
|
logEvent('tengu_unhandled_rejection', {
|
||||||
error_name:
|
error_name:
|
||||||
|
|||||||
@@ -18,16 +18,10 @@
|
|||||||
* 6. Worker polls mailbox for responses and continues execution
|
* 6. Worker polls mailbox for responses and continues execution
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises'
|
|
||||||
import { join } from 'path'
|
|
||||||
import { z } from 'zod/v4'
|
|
||||||
import { logForDebugging } from '../debug.js'
|
import { logForDebugging } from '../debug.js'
|
||||||
import { getErrnoCode } from '../errors.js'
|
|
||||||
import { lazySchema } from '../lazySchema.js'
|
|
||||||
import * as lockfile from '../lockfile.js'
|
|
||||||
import { logError } from '../log.js'
|
import { logError } from '../log.js'
|
||||||
import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js'
|
import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js'
|
||||||
import { jsonParse, jsonStringify } from '../slowOperations.js'
|
import { jsonStringify } from '../slowOperations.js'
|
||||||
import {
|
import {
|
||||||
getAgentId,
|
getAgentId,
|
||||||
getAgentName,
|
getAgentName,
|
||||||
@@ -41,53 +35,44 @@ import {
|
|||||||
createSandboxPermissionResponseMessage,
|
createSandboxPermissionResponseMessage,
|
||||||
writeToMailbox,
|
writeToMailbox,
|
||||||
} from '../teammateMailbox.js'
|
} from '../teammateMailbox.js'
|
||||||
import { getTeamDir, readTeamFileAsync } from './teamHelpers.js'
|
import { readTeamFileAsync } from './teamHelpers.js'
|
||||||
|
|
||||||
/**
|
export type SwarmPermissionRequest = {
|
||||||
* Full request schema for a permission request from a worker to the leader
|
/** Unique identifier for this request */
|
||||||
*/
|
id: string
|
||||||
export const SwarmPermissionRequestSchema = lazySchema(() =>
|
/** Worker's CLAUDE_CODE_AGENT_ID */
|
||||||
z.object({
|
workerId: string
|
||||||
/** Unique identifier for this request */
|
/** Worker's CLAUDE_CODE_AGENT_NAME */
|
||||||
id: z.string(),
|
workerName: string
|
||||||
/** Worker's CLAUDE_CODE_AGENT_ID */
|
/** Worker's CLAUDE_CODE_AGENT_COLOR */
|
||||||
workerId: z.string(),
|
workerColor?: string
|
||||||
/** Worker's CLAUDE_CODE_AGENT_NAME */
|
/** Team name for routing */
|
||||||
workerName: z.string(),
|
teamName: string
|
||||||
/** Worker's CLAUDE_CODE_AGENT_COLOR */
|
/** Tool name requiring permission (e.g., "Bash", "Edit") */
|
||||||
workerColor: z.string().optional(),
|
toolName: string
|
||||||
/** Team name for routing */
|
/** Original toolUseID from worker's context */
|
||||||
teamName: z.string(),
|
toolUseId: string
|
||||||
/** Tool name requiring permission (e.g., "Bash", "Edit") */
|
/** Human-readable description of the tool use */
|
||||||
toolName: z.string(),
|
description: string
|
||||||
/** Original toolUseID from worker's context */
|
/** Serialized tool input */
|
||||||
toolUseId: z.string(),
|
input: Record<string, unknown>
|
||||||
/** Human-readable description of the tool use */
|
/** Suggested permission rules from the permission result */
|
||||||
description: z.string(),
|
permissionSuggestions: unknown[]
|
||||||
/** Serialized tool input */
|
/** Status of the request */
|
||||||
input: z.record(z.string(), z.unknown()),
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
/** Suggested permission rules from the permission result */
|
/** Who resolved the request */
|
||||||
permissionSuggestions: z.array(z.unknown()),
|
resolvedBy?: 'worker' | 'leader'
|
||||||
/** Status of the request */
|
/** Timestamp when resolved */
|
||||||
status: z.enum(['pending', 'approved', 'rejected']),
|
resolvedAt?: number
|
||||||
/** Who resolved the request */
|
/** Rejection feedback message */
|
||||||
resolvedBy: z.enum(['worker', 'leader']).optional(),
|
feedback?: string
|
||||||
/** Timestamp when resolved */
|
/** Modified input if changed by resolver */
|
||||||
resolvedAt: z.number().optional(),
|
updatedInput?: Record<string, unknown>
|
||||||
/** Rejection feedback message */
|
/** "Always allow" rules applied during resolution */
|
||||||
feedback: z.string().optional(),
|
permissionUpdates?: unknown[]
|
||||||
/** Modified input if changed by resolver */
|
/** Timestamp when request was created */
|
||||||
updatedInput: z.record(z.string(), z.unknown()).optional(),
|
createdAt: number
|
||||||
/** "Always allow" rules applied during resolution */
|
}
|
||||||
permissionUpdates: z.array(z.unknown()).optional(),
|
|
||||||
/** Timestamp when request was created */
|
|
||||||
createdAt: z.number(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export type SwarmPermissionRequest = z.infer<
|
|
||||||
ReturnType<typeof SwarmPermissionRequestSchema>
|
|
||||||
>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolution data returned when leader/worker resolves a request
|
* Resolution data returned when leader/worker resolves a request
|
||||||
@@ -105,55 +90,6 @@ export type PermissionResolution = {
|
|||||||
permissionUpdates?: PermissionUpdate[]
|
permissionUpdates?: PermissionUpdate[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the base directory for a team's permission requests
|
|
||||||
* Path: ~/.claude/teams/{teamName}/permissions/
|
|
||||||
*/
|
|
||||||
export function getPermissionDir(teamName: string): string {
|
|
||||||
return join(getTeamDir(teamName), 'permissions')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the pending directory for a team
|
|
||||||
*/
|
|
||||||
function getPendingDir(teamName: string): string {
|
|
||||||
return join(getPermissionDir(teamName), 'pending')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the resolved directory for a team
|
|
||||||
*/
|
|
||||||
function getResolvedDir(teamName: string): string {
|
|
||||||
return join(getPermissionDir(teamName), 'resolved')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the permissions directory structure exists (async)
|
|
||||||
*/
|
|
||||||
async function ensurePermissionDirsAsync(teamName: string): Promise<void> {
|
|
||||||
const permDir = getPermissionDir(teamName)
|
|
||||||
const pendingDir = getPendingDir(teamName)
|
|
||||||
const resolvedDir = getResolvedDir(teamName)
|
|
||||||
|
|
||||||
for (const dir of [permDir, pendingDir, resolvedDir]) {
|
|
||||||
await mkdir(dir, { recursive: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the path to a pending request file
|
|
||||||
*/
|
|
||||||
function getPendingRequestPath(teamName: string, requestId: string): string {
|
|
||||||
return join(getPendingDir(teamName), `${requestId}.json`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the path to a resolved request file
|
|
||||||
*/
|
|
||||||
function getResolvedRequestPath(teamName: string, requestId: string): string {
|
|
||||||
return join(getResolvedDir(teamName), `${requestId}.json`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique request ID
|
* Generate a unique request ID
|
||||||
*/
|
*/
|
||||||
@@ -206,375 +142,6 @@ export function createPermissionRequest(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Write a permission request to the pending directory with file locking
|
|
||||||
* Called by worker agents when they need permission approval from the leader
|
|
||||||
*
|
|
||||||
* @returns The written request
|
|
||||||
*/
|
|
||||||
export async function writePermissionRequest(
|
|
||||||
request: SwarmPermissionRequest,
|
|
||||||
): Promise<SwarmPermissionRequest> {
|
|
||||||
await ensurePermissionDirsAsync(request.teamName)
|
|
||||||
|
|
||||||
const pendingPath = getPendingRequestPath(request.teamName, request.id)
|
|
||||||
const lockDir = getPendingDir(request.teamName)
|
|
||||||
|
|
||||||
// Create a directory-level lock file for atomic writes
|
|
||||||
const lockFilePath = join(lockDir, '.lock')
|
|
||||||
await writeFile(lockFilePath, '', 'utf-8')
|
|
||||||
|
|
||||||
let release: (() => Promise<void>) | undefined
|
|
||||||
try {
|
|
||||||
release = await lockfile.lock(lockFilePath)
|
|
||||||
|
|
||||||
// Write the request file
|
|
||||||
await writeFile(pendingPath, jsonStringify(request, null, 2), 'utf-8')
|
|
||||||
|
|
||||||
logForDebugging(
|
|
||||||
`[PermissionSync] Wrote pending request ${request.id} from ${request.workerName} for ${request.toolName}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
return request
|
|
||||||
} catch (error) {
|
|
||||||
logForDebugging(
|
|
||||||
`[PermissionSync] Failed to write permission request: ${error}`,
|
|
||||||
)
|
|
||||||
logError(error)
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
if (release) {
|
|
||||||
await release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read all pending permission requests for a team
|
|
||||||
* Called by the team leader to see what requests need attention
|
|
||||||
*/
|
|
||||||
export async function readPendingPermissions(
|
|
||||||
teamName?: string,
|
|
||||||
): Promise<SwarmPermissionRequest[]> {
|
|
||||||
const team = teamName || getTeamName()
|
|
||||||
if (!team) {
|
|
||||||
logForDebugging('[PermissionSync] No team name available')
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingDir = getPendingDir(team)
|
|
||||||
|
|
||||||
let files: string[]
|
|
||||||
try {
|
|
||||||
files = await readdir(pendingDir)
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const code = getErrnoCode(e)
|
|
||||||
if (code === 'ENOENT') {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
logForDebugging(`[PermissionSync] Failed to read pending requests: ${e}`)
|
|
||||||
logError(e)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonFiles = files.filter(f => f.endsWith('.json') && f !== '.lock')
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
|
||||||
jsonFiles.map(async file => {
|
|
||||||
const filePath = join(pendingDir, file)
|
|
||||||
try {
|
|
||||||
const content = await readFile(filePath, 'utf-8')
|
|
||||||
const parsed = SwarmPermissionRequestSchema().safeParse(
|
|
||||||
jsonParse(content),
|
|
||||||
)
|
|
||||||
if (parsed.success) {
|
|
||||||
return parsed.data
|
|
||||||
}
|
|
||||||
logForDebugging(
|
|
||||||
`[PermissionSync] Invalid request file ${file}: ${parsed.error.message}`,
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
} catch (err) {
|
|
||||||
logForDebugging(
|
|
||||||
`[PermissionSync] Failed to read request file ${file}: ${err}`,
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const requests = results.filter(r => r !== null)
|
|
||||||
|
|
||||||
// Sort by creation time (oldest first)
|
|
||||||
requests.sort((a, b) => a.createdAt - b.createdAt)
|
|
||||||
|
|
||||||
return requests
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a resolved permission request by ID
|
|
||||||
* Called by workers to check if their request has been resolved
|
|
||||||
*
|
|
||||||
* @returns The resolved request, or null if not yet resolved
|
|
||||||
*/
|
|
||||||
export async function readResolvedPermission(
|
|
||||||
requestId: string,
|
|
||||||
teamName?: string,
|
|
||||||
): Promise<SwarmPermissionRequest | null> {
|
|
||||||
const team = teamName || getTeamName()
|
|
||||||
if (!team) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedPath = getResolvedRequestPath(team, requestId)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = await readFile(resolvedPath, 'utf-8')
|
|
||||||
const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content))
|
|
||||||
if (parsed.success) {
|
|
||||||
return parsed.data
|
|
||||||
}
|
|
||||||
logForDebugging(
|
|
||||||
`[PermissionSync] Invalid resolved request ${requestId}: ${parsed.error.message}`,
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const code = getErrnoCode(e)
|
|
||||||
if (code === 'ENOENT') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
logForDebugging(
|
|
||||||
`[PermissionSync] Failed to read resolved request ${requestId}: ${e}`,
|
|
||||||
)
|
|
||||||
logError(e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a permission request
|
|
||||||
* Called by the team leader (or worker in self-resolution cases)
|
|
||||||
*
|
|
||||||
* Writes the resolution to resolved/, removes from pending/
|
|
||||||
*/
|
|
||||||
export async function resolvePermission(
|
|
||||||
requestId: string,
|
|
||||||
resolution: PermissionResolution,
|
|
||||||
teamName?: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const team = teamName || getTeamName()
|
|
||||||
if (!team) {
|
|
||||||
logForDebugging('[PermissionSync] No team name available')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensurePermissionDirsAsync(team)
|
|
||||||
|
|
||||||
const pendingPath = getPendingRequestPath(team, requestId)
|
|
||||||
const resolvedPath = getResolvedRequestPath(team, requestId)
|
|
||||||
const lockFilePath = join(getPendingDir(team), '.lock')
|
|
||||||
|
|
||||||
await writeFile(lockFilePath, '', 'utf-8')
|
|
||||||
|
|
||||||
let release: (() => Promise<void>) | undefined
|
|
||||||
try {
|
|
||||||
release = await lockfile.lock(lockFilePath)
|
|
||||||
|
|
||||||
// Read the pending request
|
|
||||||
let content: string
|
|
||||||
try {
|
|
||||||
content = await readFile(pendingPath, 'utf-8')
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const code = getErrnoCode(e)
|
|
||||||
if (code === 'ENOENT') {
|
|
||||||
logForDebugging(
|
|
||||||
`[PermissionSync] Pending request not found: ${requestId}`,
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content))
|
|
||||||
if (!parsed.success) {
|
|
||||||
logForDebugging(
|
|
||||||
`[PermissionSync] Invalid pending request ${requestId}: ${parsed.error.message}`,
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = parsed.data
|
|
||||||
|
|
||||||
// Update the request with resolution data
|
|
||||||
const resolvedRequest: SwarmPermissionRequest = {
|
|
||||||
...request,
|
|
||||||
status: resolution.decision === 'approved' ? 'approved' : 'rejected',
|
|
||||||
resolvedBy: resolution.resolvedBy,
|
|
||||||
resolvedAt: Date.now(),
|
|
||||||
feedback: resolution.feedback,
|
|
||||||
updatedInput: resolution.updatedInput,
|
|
||||||
permissionUpdates: resolution.permissionUpdates,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write to resolved directory
|
|
||||||
await writeFile(
|
|
||||||
resolvedPath,
|
|
||||||
jsonStringify(resolvedRequest, null, 2),
|
|
||||||
'utf-8',
|
|
||||||
)
|
|
||||||
|
|
||||||
// Remove from pending directory
|
|
||||||
await unlink(pendingPath)
|
|
||||||
|
|
||||||
logForDebugging(
|
|
||||||
`[PermissionSync] Resolved request ${requestId} with ${resolution.decision}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
logForDebugging(`[PermissionSync] Failed to resolve request: ${error}`)
|
|
||||||
logError(error)
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
if (release) {
|
|
||||||
await release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up old resolved permission files
|
|
||||||
* Called periodically to prevent file accumulation
|
|
||||||
*
|
|
||||||
* @param teamName - Team name
|
|
||||||
* @param maxAgeMs - Maximum age in milliseconds (default: 1 hour)
|
|
||||||
*/
|
|
||||||
export async function cleanupOldResolutions(
|
|
||||||
teamName?: string,
|
|
||||||
maxAgeMs = 3600000,
|
|
||||||
): Promise<number> {
|
|
||||||
const team = teamName || getTeamName()
|
|
||||||
if (!team) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedDir = getResolvedDir(team)
|
|
||||||
|
|
||||||
let files: string[]
|
|
||||||
try {
|
|
||||||
files = await readdir(resolvedDir)
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const code = getErrnoCode(e)
|
|
||||||
if (code === 'ENOENT') {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
logForDebugging(`[PermissionSync] Failed to cleanup resolutions: ${e}`)
|
|
||||||
logError(e)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now()
|
|
||||||
const jsonFiles = files.filter(f => f.endsWith('.json'))
|
|
||||||
|
|
||||||
const cleanupResults = await Promise.all(
|
|
||||||
jsonFiles.map(async file => {
|
|
||||||
const filePath = join(resolvedDir, file)
|
|
||||||
try {
|
|
||||||
const content = await readFile(filePath, 'utf-8')
|
|
||||||
const request = jsonParse(content) as SwarmPermissionRequest
|
|
||||||
|
|
||||||
// Check if the resolution is old enough to clean up
|
|
||||||
// Use >= to handle edge case where maxAgeMs is 0 (clean up everything)
|
|
||||||
const resolvedAt = request.resolvedAt || request.createdAt
|
|
||||||
if (now - resolvedAt >= maxAgeMs) {
|
|
||||||
await unlink(filePath)
|
|
||||||
logForDebugging(`[PermissionSync] Cleaned up old resolution: ${file}`)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
} catch {
|
|
||||||
// If we can't parse it, clean it up anyway
|
|
||||||
try {
|
|
||||||
await unlink(filePath)
|
|
||||||
return 1
|
|
||||||
} catch {
|
|
||||||
// Ignore deletion errors
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const cleanedCount = cleanupResults.reduce<number>((sum, n) => sum + n, 0)
|
|
||||||
|
|
||||||
if (cleanedCount > 0) {
|
|
||||||
logForDebugging(
|
|
||||||
`[PermissionSync] Cleaned up ${cleanedCount} old resolutions`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cleanedCount
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy response type for worker polling
|
|
||||||
* Used for backward compatibility with worker integration code
|
|
||||||
*/
|
|
||||||
export type PermissionResponse = {
|
|
||||||
/** ID of the request this responds to */
|
|
||||||
requestId: string
|
|
||||||
/** Decision: approved or denied */
|
|
||||||
decision: 'approved' | 'denied'
|
|
||||||
/** Timestamp when response was created */
|
|
||||||
timestamp: string
|
|
||||||
/** Optional feedback message if denied */
|
|
||||||
feedback?: string
|
|
||||||
/** Optional updated input if the resolver modified it */
|
|
||||||
updatedInput?: Record<string, unknown>
|
|
||||||
/** Permission updates to apply (e.g., "always allow" rules) */
|
|
||||||
permissionUpdates?: unknown[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Poll for a permission response (worker-side convenience function)
|
|
||||||
* Converts the resolved request into a simpler response format
|
|
||||||
*
|
|
||||||
* @returns The permission response, or null if not yet resolved
|
|
||||||
*/
|
|
||||||
export async function pollForResponse(
|
|
||||||
requestId: string,
|
|
||||||
_agentName?: string,
|
|
||||||
teamName?: string,
|
|
||||||
): Promise<PermissionResponse | null> {
|
|
||||||
const resolved = await readResolvedPermission(requestId, teamName)
|
|
||||||
if (!resolved) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
requestId: resolved.id,
|
|
||||||
decision: resolved.status === 'approved' ? 'approved' : 'denied',
|
|
||||||
timestamp: resolved.resolvedAt
|
|
||||||
? new Date(resolved.resolvedAt).toISOString()
|
|
||||||
: new Date(resolved.createdAt).toISOString(),
|
|
||||||
feedback: resolved.feedback,
|
|
||||||
updatedInput: resolved.updatedInput,
|
|
||||||
permissionUpdates: resolved.permissionUpdates,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a worker's response after processing
|
|
||||||
* This is an alias for deleteResolvedPermission for backward compatibility
|
|
||||||
*/
|
|
||||||
export async function removeWorkerResponse(
|
|
||||||
requestId: string,
|
|
||||||
_agentName?: string,
|
|
||||||
teamName?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await deleteResolvedPermission(requestId, teamName)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current agent is a team leader
|
* Check if the current agent is a team leader
|
||||||
*/
|
*/
|
||||||
@@ -600,46 +167,6 @@ export function isSwarmWorker(): boolean {
|
|||||||
return !!teamName && !!agentId && !isTeamLeader()
|
return !!teamName && !!agentId && !isTeamLeader()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a resolved permission file
|
|
||||||
* Called after a worker has processed the resolution
|
|
||||||
*/
|
|
||||||
export async function deleteResolvedPermission(
|
|
||||||
requestId: string,
|
|
||||||
teamName?: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const team = teamName || getTeamName()
|
|
||||||
if (!team) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedPath = getResolvedRequestPath(team, requestId)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await unlink(resolvedPath)
|
|
||||||
logForDebugging(
|
|
||||||
`[PermissionSync] Deleted resolved permission: ${requestId}`,
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const code = getErrnoCode(e)
|
|
||||||
if (code === 'ENOENT') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
logForDebugging(
|
|
||||||
`[PermissionSync] Failed to delete resolved permission: ${e}`,
|
|
||||||
)
|
|
||||||
logError(e)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit a permission request (alias for writePermissionRequest)
|
|
||||||
* Provided for backward compatibility with worker integration code
|
|
||||||
*/
|
|
||||||
export const submitPermissionRequest = writePermissionRequest
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Mailbox-Based Permission System
|
// Mailbox-Based Permission System
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ export type TeamFile = {
|
|||||||
description?: string
|
description?: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
leadAgentId: string
|
leadAgentId: string
|
||||||
leadSessionId?: string // Legacy field; stripped from persisted configs
|
|
||||||
hiddenPaneIds?: string[] // Pane IDs that are currently hidden from the UI
|
hiddenPaneIds?: string[] // Pane IDs that are currently hidden from the UI
|
||||||
teamAllowedPaths?: TeamAllowedPath[] // Paths all teammates can edit without asking
|
teamAllowedPaths?: TeamAllowedPath[] // Paths all teammates can edit without asking
|
||||||
members: Array<{
|
members: Array<{
|
||||||
@@ -81,8 +80,6 @@ export type TeamFile = {
|
|||||||
tmuxPaneId: string
|
tmuxPaneId: string
|
||||||
cwd: string
|
cwd: string
|
||||||
worktreePath?: string
|
worktreePath?: string
|
||||||
sessionId?: string // Legacy field; stripped from persisted configs
|
|
||||||
subscriptions?: string[] // Legacy field; stripped from persisted configs
|
|
||||||
backendType?: BackendType
|
backendType?: BackendType
|
||||||
isActive?: boolean // false when idle, undefined/true when active
|
isActive?: boolean // false when idle, undefined/true when active
|
||||||
mode?: PermissionMode // Current permission mode for this teammate
|
mode?: PermissionMode // Current permission mode for this teammate
|
||||||
|
|||||||
Reference in New Issue
Block a user