diff --git a/src/tools/council-archive/council-finalize-helpers.ts b/src/tools/council-archive/council-finalize-helpers.ts index c7af90c1e..378c3b778 100644 --- a/src/tools/council-archive/council-finalize-helpers.ts +++ b/src/tools/council-archive/council-finalize-helpers.ts @@ -27,6 +27,33 @@ export function isPathEscaping(expectedRoot: string, targetPath: string): boolea return rel === ".." || rel.startsWith("../") || rel.startsWith("..\\") || isAbsolute(rel) } +function resolvePromptTempFilePath(promptFilePath: string, base: string): string | undefined { + const absPromptPath = isAbsolute(promptFilePath) ? promptFilePath : resolve(base, promptFilePath) + const expectedPromptRoot = join(base, ".sisyphus", "tmp") + + if (isPathEscaping(expectedPromptRoot, absPromptPath)) { + log("[council-finalize] Rejected prompt_file outside .sisyphus/tmp/", { promptFile: promptFilePath }) + return undefined + } + + return absPromptPath +} + +export async function cleanupPromptFile(promptFilePath: string, base: string): Promise { + const absPromptPath = resolvePromptTempFilePath(promptFilePath, base) + if (!absPromptPath) return + + try { + await unlink(absPromptPath) + log("[council-finalize] Cleaned up prompt temp file", { promptFile: promptFilePath }) + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code !== "ENOENT") { + log("[council-finalize] Failed to clean up prompt temp file", { promptFile: promptFilePath, error: String(err), code }) + } + } +} + export async function movePromptFile( promptFilePath: string, base: string, @@ -35,12 +62,8 @@ export async function movePromptFile( ): Promise { try { const promptFilename = "council-prompt.md" - const absPromptSrc = isAbsolute(promptFilePath) ? promptFilePath : resolve(base, promptFilePath) - const expectedPromptRoot = join(base, ".sisyphus", "tmp") - if (isPathEscaping(expectedPromptRoot, absPromptSrc)) { - log("[council-finalize] Rejected prompt_file outside .sisyphus/tmp/", { promptFile: promptFilePath }) - return undefined - } + const absPromptSrc = resolvePromptTempFilePath(promptFilePath, base) + if (!absPromptSrc) return undefined const absPromptDest = join(absArchiveDir, promptFilename) await rename(absPromptSrc, absPromptDest).catch(async (renameErr) => { log("[council-finalize] Rename failed, falling back to copy", { promptFile: promptFilePath, error: String(renameErr) }) diff --git a/src/tools/council-archive/create-council-finalize.ts b/src/tools/council-archive/create-council-finalize.ts index c81519a9d..6f5063563 100644 --- a/src/tools/council-archive/create-council-finalize.ts +++ b/src/tools/council-archive/create-council-finalize.ts @@ -3,7 +3,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises" import { join } from "node:path" import { randomBytes } from "node:crypto" import { extractCouncilResponse } from "./council-response-extractor" -import { TASK_ID_PATTERN, slugify, toPosixPath, extractAgentFromFrontmatter, isPathEscaping, movePromptFile } from "./council-finalize-helpers" +import { TASK_ID_PATTERN, slugify, toPosixPath, extractAgentFromFrontmatter, isPathEscaping, movePromptFile, cleanupPromptFile } from "./council-finalize-helpers" import { formatMetaYaml, type MetaMember } from "./meta-yaml-formatter" import { buildAthenaRuntimeGuidance, @@ -43,125 +43,131 @@ export function createCouncilFinalize( } const base = basePath ?? process.cwd() - const hexId = randomBytes(COUNCIL_DEFAULTS.ARCHIVE_ID_BYTES).toString("hex") - const safeName = slugify(args.name) || "unnamed" - const archiveName = `council-${safeName}-${hexId}` - const relArchiveDir = join(".sisyphus", "athena", archiveName) - const relArchiveDirForOutput = toPosixPath(relArchiveDir) - const absArchiveDir = join(base, relArchiveDir) - if (isPathEscaping(join(base, ".sisyphus", "athena"), absArchiveDir)) { - return `Security error: archive directory would escape .sisyphus/athena/` - } + try { + const hexId = randomBytes(COUNCIL_DEFAULTS.ARCHIVE_ID_BYTES).toString("hex") + const safeName = slugify(args.name) || "unnamed" + const archiveName = `council-${safeName}-${hexId}` + const relArchiveDir = join(".sisyphus", "athena", archiveName) + const relArchiveDirForOutput = toPosixPath(relArchiveDir) + const absArchiveDir = join(base, relArchiveDir) - await mkdir(absArchiveDir, { recursive: true }) - - const members: CouncilMemberResult[] = [] - const metaMembers: MetaMember[] = [] - - for (const taskId of args.task_ids) { - if (!TASK_ID_PATTERN.test(taskId)) { - members.push({ - task_id: taskId, - member: "unknown", - has_response: false, - error: "Invalid task ID: contains unsafe characters", - }) - metaMembers.push({ - task_id: taskId, - member: "unknown", - member_slug: "unknown", - task_output_path: "", - archive_file: "", - has_response: false, - response_complete: false, - }) - continue + if (isPathEscaping(join(base, ".sisyphus", "athena"), absArchiveDir)) { + return `Security error: archive directory would escape .sisyphus/athena/` } - // Task ID is pre-validated by TASK_ID_PATTERN — path escaping is impossible - const relTaskOutput = join(".sisyphus", "task-outputs", `${taskId}.md`) - const relTaskOutputForOutput = toPosixPath(relTaskOutput) - const absTaskOutput = join(base, relTaskOutput) + await mkdir(absArchiveDir, { recursive: true }) + const members: CouncilMemberResult[] = [] + const metaMembers: MetaMember[] = [] - let fileContent: string - try { - fileContent = await readFile(absTaskOutput, "utf-8") - } catch { - members.push({ + for (const taskId of args.task_ids) { + if (!TASK_ID_PATTERN.test(taskId)) { + members.push({ + task_id: taskId, + member: "unknown", + has_response: false, + error: "Invalid task ID: contains unsafe characters", + }) + metaMembers.push({ + task_id: taskId, + member: "unknown", + member_slug: "unknown", + task_output_path: "", + archive_file: "", + has_response: false, + response_complete: false, + }) + continue + } + + // Task ID is pre-validated by TASK_ID_PATTERN - path escaping is impossible + const relTaskOutput = join(".sisyphus", "task-outputs", `${taskId}.md`) + const relTaskOutputForOutput = toPosixPath(relTaskOutput) + const absTaskOutput = join(base, relTaskOutput) + + let fileContent: string + try { + fileContent = await readFile(absTaskOutput, "utf-8") + } catch { + members.push({ + task_id: taskId, + member: "unknown", + has_response: false, + error: "Task output file not found", + }) + metaMembers.push({ + task_id: taskId, + member: "unknown", + member_slug: "unknown", + task_output_path: relTaskOutputForOutput, + archive_file: "", + has_response: false, + response_complete: false, + }) + continue + } + + const agentName = extractAgentFromFrontmatter(fileContent) ?? "unknown" + const memberSlug = slugify(agentName) || "unknown" + const taskSlug = slugify(taskId) || taskId.replace(/[^a-zA-Z0-9_-]+/g, "-") + const extraction = extractCouncilResponse(fileContent) + + const relArchiveFile = join(relArchiveDir, `${memberSlug}-${taskSlug}.md`) + const relArchiveFileForOutput = toPosixPath(relArchiveFile) + const absArchiveFile = join(base, relArchiveFile) + + const memberResult: CouncilMemberResult = { task_id: taskId, - member: "unknown", - has_response: false, - error: "Task output file not found", - }) + member: agentName, + has_response: extraction.has_response, + } + + if (extraction.has_response) { + memberResult.response_complete = extraction.response_complete + } + + if (extraction.result !== null) { + await writeFile(absArchiveFile, extraction.result, "utf-8") + memberResult.archive_file = relArchiveFileForOutput + } + + members.push(memberResult) metaMembers.push({ task_id: taskId, - member: "unknown", - member_slug: "unknown", + member: agentName, + member_slug: memberSlug, task_output_path: relTaskOutputForOutput, - archive_file: "", - has_response: false, - response_complete: false, + archive_file: extraction.result !== null ? relArchiveFileForOutput : "", + has_response: extraction.has_response, + response_complete: extraction.response_complete, }) - continue } - const agentName = extractAgentFromFrontmatter(fileContent) ?? "unknown" - const memberSlug = slugify(agentName) || "unknown" - const taskSlug = slugify(taskId) || taskId.replace(/[^a-zA-Z0-9_-]+/g, "-") - const extraction = extractCouncilResponse(fileContent) + const relPromptFile = args.prompt_file + ? await movePromptFile(args.prompt_file, base, absArchiveDir, relArchiveDir) + : undefined - const relArchiveFile = join(relArchiveDir, `${memberSlug}-${taskSlug}.md`) - const relArchiveFileForOutput = toPosixPath(relArchiveFile) - const absArchiveFile = join(base, relArchiveFile) + const relMetaFile = join(relArchiveDir, "meta.yaml") + const relMetaFileForOutput = toPosixPath(relMetaFile) + const absMetaFile = join(base, relMetaFile) + const createdAt = new Date().toISOString() + await writeFile(absMetaFile, formatMetaYaml(archiveName, createdAt, metaMembers, args.question, relPromptFile), "utf-8") - const memberResult: CouncilMemberResult = { - task_id: taskId, - member: agentName, - has_response: extraction.has_response, + const result: CouncilFinalizeResult = { + archive_dir: relArchiveDirForOutput, + meta_file: relMetaFileForOutput, + members, } - if (extraction.has_response) { - memberResult.response_complete = extraction.response_complete + const resolvedMode = (args.mode === "non-interactive" ? "non-interactive" : "interactive") as CouncilGuidanceMode + const guidance = buildAthenaRuntimeGuidance(resolvedIntent, resolvedMode) + return JSON.stringify(result, null, 2) + "\n\n" + guidance + } finally { + if (args.prompt_file) { + await cleanupPromptFile(args.prompt_file, base) } - - if (extraction.result !== null) { - await writeFile(absArchiveFile, extraction.result, "utf-8") - memberResult.archive_file = relArchiveFileForOutput - } - - members.push(memberResult) - metaMembers.push({ - task_id: taskId, - member: agentName, - member_slug: memberSlug, - task_output_path: relTaskOutputForOutput, - archive_file: extraction.result !== null ? relArchiveFileForOutput : "", - has_response: extraction.has_response, - response_complete: extraction.response_complete, - }) } - - const relPromptFile = args.prompt_file - ? await movePromptFile(args.prompt_file, base, absArchiveDir, relArchiveDir) - : undefined - - const relMetaFile = join(relArchiveDir, "meta.yaml") - const relMetaFileForOutput = toPosixPath(relMetaFile) - const absMetaFile = join(base, relMetaFile) - const createdAt = new Date().toISOString() - await writeFile(absMetaFile, formatMetaYaml(archiveName, createdAt, metaMembers, args.question, relPromptFile), "utf-8") - - const result: CouncilFinalizeResult = { - archive_dir: relArchiveDirForOutput, - meta_file: relMetaFileForOutput, - members, - } - - const resolvedMode = (args.mode === "non-interactive" ? "non-interactive" : "interactive") as CouncilGuidanceMode - const guidance = buildAthenaRuntimeGuidance(resolvedIntent, resolvedMode) - return JSON.stringify(result, null, 2) + "\n\n" + guidance }, }) } diff --git a/src/tools/prepare-council-prompt/tools.ts b/src/tools/prepare-council-prompt/tools.ts index 0a24a5a18..e50f68b08 100644 --- a/src/tools/prepare-council-prompt/tools.ts +++ b/src/tools/prepare-council-prompt/tools.ts @@ -111,14 +111,6 @@ Returns the file path to reference in subsequent task() calls.` await writeFile(filePath, content, "utf-8") - setTimeout(() => { - unlink(filePath).catch((err) => { - const code = (err as NodeJS.ErrnoException).code - if (code !== "ENOENT") { - log("[prepare-council-prompt] Failed to clean up temp file", { filePath, error: String(err) }) - } - }) - }, CLEANUP_DELAY_MS) log("[prepare-council-prompt] Saved prompt", { filePath, length: args.prompt.length, mode }) @@ -127,7 +119,7 @@ Returns the file path to reference in subsequent task() calls.` Use this path in each council member's task() call: - prompt: "Read ${filePath} for your instructions." -The file auto-deletes after 30 minutes.` +The file stays in .sisyphus/tmp until council_finalize archives and cleans it up.` } catch (err) { return `Error saving council prompt: ${String(err)}` }