import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { mkdir, readdir, readFile, rm, unlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { Capability, ZoneName } from "./config.ts"; import type { GrantKind, GrantScope } from "./grants.ts"; const BASE = "/tmp/pi-subagents-messages"; const NAMESPACE = "permission-control-v2"; const POLL_MS = 250; export interface ForwardedRequest { id: string; agent: string; tool: string; capability: Capability; resource: string; zone: ZoneName; kind: GrantKind; command?: string; justification?: string; createdAt: number; expiresAt: number; } export interface ForwardedReply { id: string; allowed: boolean; scope?: GrantScope; reason?: string; abort?: boolean; createdAt: number; } export function isChildProcess(): boolean { return process.env.PI_SUBAGENT_ROLE === "child"; } export function childChannel(): { root: string; agent: string } | undefined { const root = process.env.PI_SUBAGENT_CHANNEL_ROOT?.trim(); if (!root) return undefined; return { root, agent: process.env.PI_SUBAGENT_AGENT?.trim() || "subagent" }; } export function mainChannel(sessionId: string): string { return join(BASE, sessionId.replace(/[^\w.-]+/g, "_")); } function requestDir(root: string): string { return join(root, NAMESPACE, "requests"); } function replyDir(root: string): string { return join(root, NAMESPACE, "replies"); } async function writeAtomic(path: string, value: unknown): Promise { await mkdir(dirname(path), { recursive: true }); const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; await writeFile(temporary, JSON.stringify(value, null, 2), { encoding: "utf8", mode: 0o600 }); await import("node:fs/promises").then(({ rename }) => rename(temporary, path)); } export async function forwardApproval( channel: { root: string; agent: string }, request: Omit, options: { timeoutMs?: number; signal?: AbortSignal } = {}, ): Promise { const id = randomUUID(); const timeoutMs = options.timeoutMs ?? 120_000; const now = Date.now(); const payload: ForwardedRequest = { ...request, id, agent: channel.agent, createdAt: now, expiresAt: now + timeoutMs, }; const reqPath = join(requestDir(channel.root), `${id}.json`); const repPath = join(replyDir(channel.root), `${id}.json`); await writeAtomic(reqPath, payload); try { const deadline = now + timeoutMs; for (;;) { if (options.signal?.aborted) { return { id, allowed: false, abort: true, reason: "approval cancelled", createdAt: Date.now() }; } if (Date.now() > deadline) { return { id, allowed: false, reason: "approval timed out", createdAt: Date.now() }; } if (existsSync(repPath)) { try { const reply = JSON.parse(readFileSync(repPath, "utf8")) as ForwardedReply; if (reply.id === id) return reply; } finally { await unlink(repPath).catch(() => undefined); } } await new Promise((resolve) => setTimeout(resolve, POLL_MS)); } } finally { await unlink(reqPath).catch(() => undefined); } } export interface ApprovalServer { dispose(): Promise; } export async function startApprovalServer( root: string, handler: (request: ForwardedRequest) => Promise, ): Promise { let active = true; const inFlight = new Set(); await mkdir(requestDir(root), { recursive: true }); await mkdir(replyDir(root), { recursive: true }); const timer = setInterval(async () => { if (!active) return; const files = await readdir(requestDir(root)).catch(() => []); for (const file of files.filter((name) => name.endsWith(".json"))) { const path = join(requestDir(root), file); if (inFlight.has(path)) continue; inFlight.add(path); void (async () => { try { const request = JSON.parse(await readFile(path, "utf8")) as ForwardedRequest; await unlink(path).catch(() => undefined); if (!request.id || request.expiresAt < Date.now()) return; const reply = await handler(request); await writeAtomic(join(replyDir(root), `${request.id}.json`), reply); } catch { await unlink(path).catch(() => undefined); } finally { inFlight.delete(path); } })(); } }, POLL_MS); return { async dispose() { active = false; clearInterval(timer); await rm(join(root, NAMESPACE), { recursive: true, force: true }); }, }; }