import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { chmod, cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { AgentRunError } from "../common/errors.js"; import { redactText } from "../common/redaction.js"; import type { InitialPromptAssembly, JsonRecord, ResourceBundleRef } from "../common/types.js"; import { stableHash } from "../common/validation.js"; const maxPromptRefBytes = 16 * 1024; const maxInitialPromptBytes = 64 * 1024; const skillSummaryChars = 600; export interface MaterializedResourceBundle { workspacePath: string; binPath?: string; skillsDir?: string; initialPrompt?: InitialPromptAssembly; event: JsonRecord; } interface MaterializedPromptRef { name: string; path: string; inject: "thread-start"; required: boolean; text: string; bytes: number; sha256: string; } interface MaterializedSkillRef { name: string; path: string; aggregateAs: string; required: boolean; registryPath: string; manifestBytes: number; manifestSha256: string; summary: string; } export async function materializeResourceBundle(resourceBundleRef: ResourceBundleRef | null | undefined, env: NodeJS.ProcessEnv = process.env): Promise { if (!resourceBundleRef) return null; const workspaceRoot = path.resolve(env.AGENTRUN_WORKSPACE_ROOT ?? "/home/agentrun/workspaces"); const checkoutPath = path.join(workspaceRoot, stableHash({ repoUrl: resourceBundleRef.repoUrl, commitId: resourceBundleRef.commitId }).slice(0, 16)); await mkdir(checkoutPath, { recursive: true }); await git(["init"], checkoutPath); await git(["remote", "remove", "origin"], checkoutPath, { allowFailure: true }); await git(["remote", "add", "origin", resourceBundleRef.repoUrl], checkoutPath); if (resourceBundleRef.sparsePaths && resourceBundleRef.sparsePaths.length > 0) { await git(["config", "core.sparseCheckout", "true"], checkoutPath); await mkdir(path.join(checkoutPath, ".git", "info"), { recursive: true }); await writeFile(path.join(checkoutPath, ".git", "info", "sparse-checkout"), `${resourceBundleRef.sparsePaths.join("\n")}\n`, "utf8"); } await git(["fetch", "--depth", "1", "origin", resourceBundleRef.commitId], checkoutPath); await git(["checkout", "--detach", resourceBundleRef.commitId], checkoutPath); const actualCommit = (await git(["rev-parse", "HEAD"], checkoutPath)).stdout.trim(); if (actualCommit !== resourceBundleRef.commitId) throw new AgentRunError("infra-failed", "resource bundle checkout did not land on requested commit", { httpStatus: 500, details: { expectedCommit: resourceBundleRef.commitId, actualCommit } }); const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim(); const workspacePath = resolveWorkspacePath(checkoutPath, resourceBundleRef.subdir); const toolAliases = await materializeToolAliases(checkoutPath, resourceBundleRef.toolAliases ?? [], env); const skills = await materializeSkillRefs(checkoutPath, workspacePath, resourceBundleRef.skillRefs ?? []); const prompts = await materializePromptRefs(checkoutPath, resourceBundleRef.promptRefs ?? []); const workspaceFiles = await materializeWorkspaceFiles(workspacePath, resourceBundleRef.workspaceFiles ?? []); const initialPrompt = assembleInitialPrompt(prompts.items, skills.items); return { workspacePath, ...(toolAliases.binPath ? { binPath: toolAliases.binPath } : {}), ...(skills.skillsDir ? { skillsDir: skills.skillsDir } : {}), ...(initialPrompt ? { initialPrompt } : {}), event: { phase: "resource-bundle-materialized", kind: "git", repoUrl: resourceBundleRef.repoUrl, commitId: resourceBundleRef.commitId, treeId, checkoutPath: pathSummary(checkoutPath), workspacePath: pathSummary(workspacePath), subdir: resourceBundleRef.subdir ?? null, sparsePathCount: resourceBundleRef.sparsePaths?.length ?? 0, toolAliases: toolAliases.event, skillRefs: skills.event, promptRefs: prompts.event, workspaceFiles: workspaceFiles.event, initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillRefCount: skills.items.length, valuesPrinted: false }, valuesPrinted: false, }, }; } async function materializeToolAliases(checkoutPath: string, aliases: NonNullable, env: NodeJS.ProcessEnv): Promise<{ binPath?: string; event: JsonRecord }> { if (aliases.length === 0) return { event: { count: 0, names: [], binPath: null, valuesPrinted: false } }; const binPath = path.resolve(env.AGENTRUN_RESOURCE_BIN_PATH ?? path.join(path.dirname(checkoutPath), ".bin")); await mkdir(binPath, { recursive: true }); const names: string[] = []; for (const alias of aliases) { const target = resolveBundlePath(checkoutPath, alias.path, `toolAliases.${alias.name}.path`); const wrapper = path.join(binPath, alias.name); const content = aliasWrapper(alias.kind, target); await assertAliasWrapperWritable(wrapper, alias.name); await writeFile(wrapper, content, "utf8"); await chmod(wrapper, 0o755); names.push(alias.name); } return { binPath, event: { count: names.length, names, binPath: pathSummary(binPath), valuesPrinted: false } }; } async function materializePromptRefs(checkoutPath: string, refs: NonNullable): Promise<{ items: MaterializedPromptRef[]; event: JsonRecord }> { const items: MaterializedPromptRef[] = []; const eventItems: JsonRecord[] = []; let totalBytes = 0; for (const ref of refs) { const promptPath = resolveBundlePath(checkoutPath, ref.path, `promptRefs.${ref.name}.path`); const required = ref.required === true; let text: string; try { text = await readFile(promptPath, "utf8"); } catch (error) { if (required) throw new AgentRunError("prompt-unavailable", `required resource prompt ${ref.name} is not readable`, { httpStatus: 400, details: { name: ref.name, path: ref.path, error: fileErrorSummary(error), valuesPrinted: false } }); eventItems.push({ name: ref.name, path: ref.path, inject: "thread-start", required, status: "missing", valuesPrinted: false }); continue; } const bytes = Buffer.byteLength(text, "utf8"); if (bytes > maxPromptRefBytes) throw new AgentRunError("prompt-too-large", `resource prompt ${ref.name} exceeds the per-file size limit`, { httpStatus: 400, details: { name: ref.name, path: ref.path, bytes, maxPromptRefBytes, valuesPrinted: false } }); totalBytes += bytes; if (totalBytes > maxInitialPromptBytes) throw new AgentRunError("prompt-too-large", "assembled resource prompt exceeds the total size limit", { httpStatus: 400, details: { totalBytes, maxInitialPromptBytes, valuesPrinted: false } }); const sha = sha256Text(text); items.push({ name: ref.name, path: ref.path, inject: "thread-start", required, text, bytes, sha256: sha }); eventItems.push({ name: ref.name, path: ref.path, inject: "thread-start", required, status: "materialized", sha256: sha, bytes, valuesPrinted: false }); } return { items, event: { count: refs.length, materializedCount: items.length, names: items.map((item) => item.name), items: eventItems, totalBytes, valuesPrinted: false, }, }; } async function materializeSkillRefs(checkoutPath: string, workspacePath: string, refs: NonNullable): Promise<{ items: MaterializedSkillRef[]; skillsDir?: string; event: JsonRecord }> { if (refs.length === 0) return { items: [], event: { count: 0, materializedCount: 0, names: [], skillsDir: null, items: [], valuesPrinted: false } }; const skillsDir = path.join(workspacePath, ".agents", "skills"); await mkdir(skillsDir, { recursive: true }); const items: MaterializedSkillRef[] = []; const eventItems: JsonRecord[] = []; for (const ref of refs) { const manifestPath = resolveBundlePath(checkoutPath, ref.path, `skillRefs.${ref.name}.path`); const required = ref.required === true; let manifestText: string; try { manifestText = await readFile(manifestPath, "utf8"); } catch (error) { if (required) throw new AgentRunError("skill-unavailable", `required resource skill ${ref.name} is not readable`, { httpStatus: 400, details: { name: ref.name, path: ref.path, error: fileErrorSummary(error), valuesPrinted: false } }); eventItems.push({ name: ref.name, path: ref.path, required, aggregateAs: ref.aggregateAs ?? ref.name, status: "missing", valuesPrinted: false }); continue; } const aggregateAs = ref.aggregateAs ?? ref.name; const sourceRoot = path.dirname(manifestPath); const targetRoot = path.join(skillsDir, aggregateAs); if (path.resolve(sourceRoot) !== path.resolve(targetRoot)) { await rm(targetRoot, { recursive: true, force: true }); await cp(sourceRoot, targetRoot, { recursive: true, force: true, dereference: false }); } const bytes = Buffer.byteLength(manifestText, "utf8"); const sha = sha256Text(manifestText); const summary = skillSummary(manifestText); items.push({ name: ref.name, path: ref.path, aggregateAs, required, registryPath: path.join(targetRoot, "SKILL.md"), manifestBytes: bytes, manifestSha256: sha, summary }); eventItems.push({ name: ref.name, path: ref.path, aggregateAs, required, status: "materialized", manifestSha256: sha, manifestBytes: bytes, registryPath: pathSummary(path.join(targetRoot, "SKILL.md")), summary, valuesPrinted: false }); } return { items, skillsDir, event: { count: refs.length, materializedCount: items.length, names: items.map((item) => item.name), skillsDir: pathSummary(skillsDir), items: eventItems, valuesPrinted: false, }, }; } async function materializeWorkspaceFiles(workspacePath: string, files: NonNullable): Promise<{ event: JsonRecord }> { if (files.length === 0) return { event: { count: 0, materializedCount: 0, items: [], totalBytes: 0, valuesPrinted: false } }; const eventItems: JsonRecord[] = []; let totalBytes = 0; for (const file of files) { const target = resolveWorkspaceFilePath(workspacePath, file.path, `workspaceFiles.${file.path}.path`); const content = file.content; const bytes = Buffer.byteLength(content, "utf8"); await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, content, "utf8"); totalBytes += bytes; eventItems.push({ path: file.path, encoding: file.encoding ?? "utf8", status: "materialized", bytes, sha256: sha256Text(content), valuesPrinted: false }); } return { event: { count: files.length, materializedCount: eventItems.length, paths: eventItems.map((item) => item.path), items: eventItems, totalBytes, valuesPrinted: false, }, }; } function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skillRefs: MaterializedSkillRef[]): InitialPromptAssembly | undefined { if (promptRefs.length === 0 && skillRefs.length === 0) return undefined; const sections: string[] = [ "AgentRun initial runtime instructions. These instructions are assembled from ResourceBundleRef promptRefs and skillRefs for the first thread-start turn only.", ]; for (const prompt of promptRefs) { sections.push([`## Resource Prompt: ${prompt.name}`, `path: ${prompt.path}`, prompt.text].join("\n")); } if (skillRefs.length > 0) { const lines = [ "## Resource Skills", "The following required runtime skills are mounted in the current workspace. Use these bundle skills instead of default model skill guesses.", ...skillRefs.map((skill) => `- ${skill.name}: ${skill.summary || "No summary provided."} manifest=.agents/skills/${skill.aggregateAs}/SKILL.md source=${skill.path} required=${skill.required}`), ]; sections.push(lines.join("\n")); } const text = sections.join("\n\n"); const bytes = Buffer.byteLength(text, "utf8"); if (bytes > maxInitialPromptBytes) throw new AgentRunError("prompt-too-large", "assembled initial prompt exceeds the total size limit", { httpStatus: 400, details: { bytes, maxInitialPromptBytes, promptRefCount: promptRefs.length, skillRefCount: skillRefs.length, valuesPrinted: false } }); return { text, summary: { available: true, bytes, sha256: sha256Text(text), promptRefCount: promptRefs.length, promptRefNames: promptRefs.map((item) => item.name), skillRefCount: skillRefs.length, skillRefNames: skillRefs.map((item) => item.name), valuesPrinted: false, }, }; } function skillSummary(text: string): string { const frontmatter = /^---\s*\n([\s\S]*?)\n---\s*/u.exec(text); if (frontmatter) { const descriptionLine = frontmatter[1]?.split(/\r?\n/u).find((line) => /^description\s*:/iu.test(line)); if (descriptionLine) return trimSummary(descriptionLine.replace(/^description\s*:\s*/iu, "").trim().replace(/^['"]|['"]$/gu, "")); } const line = text.split(/\r?\n/u).map((entry) => entry.trim()).find((entry) => entry.length > 0 && !entry.startsWith("#") && entry !== "---"); return trimSummary(line ?? ""); } function trimSummary(value: string): string { const normalized = value.replace(/\s+/gu, " ").trim(); return normalized.length > skillSummaryChars ? `${normalized.slice(0, skillSummaryChars)}...` : normalized; } function sha256Text(text: string): string { return createHash("sha256").update(text, "utf8").digest("hex"); } function fileErrorSummary(error: unknown): JsonRecord { const record = typeof error === "object" && error !== null ? error as { code?: unknown; message?: unknown } : {}; return { code: typeof record.code === "string" ? record.code : null, message: typeof record.message === "string" ? redactText(record.message).slice(0, 300) : null }; } function aliasWrapper(kind: string, target: string): string { if (kind === "node-script") return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec node ${shellArg(target)} "$@"\n`; if (kind === "bun-script") return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec bun ${shellArg(target)} "$@"\n`; if (kind === "sh-script") return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec sh ${shellArg(target)} "$@"\n`; return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec ${shellArg(target)} "$@"\n`; } async function assertAliasWrapperWritable(wrapper: string, name: string): Promise { try { const existing = await readFile(wrapper, "utf8"); if (existing.includes("agentrun-resource-alias-wrapper")) return; throw new AgentRunError("schema-invalid", `resource bundle tool alias ${name} would overwrite an existing command`, { httpStatus: 409, details: { wrapper: pathSummary(wrapper) } }); } catch (error) { if (error instanceof AgentRunError) throw error; if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") return; throw error; } } async function git(args: string[], cwd: string, options: { allowFailure?: boolean } = {}): Promise<{ stdout: string; stderr: string }> { const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk) => { stdout += String(chunk); }); child.stderr.on("data", (chunk) => { stderr += String(chunk); }); const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { child.on("error", reject); child.on("close", (code, signal) => resolve({ code, signal })); }).catch((error: unknown) => { throw new AgentRunError("infra-failed", `failed to start git: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 503 }); }); if (result.code !== 0 && !options.allowFailure) { throw new AgentRunError("infra-failed", `git ${args[0] ?? "command"} failed with code ${result.code}`, { httpStatus: 502, details: { stderr: redactText(stderr.slice(-4000)), stdout: redactText(stdout.slice(-1000)), signal: result.signal } }); } return { stdout, stderr }; } function resolveWorkspacePath(checkoutPath: string, subdir: string | undefined): string { if (!subdir) return checkoutPath; return resolveBundlePath(checkoutPath, subdir, "resourceBundleRef.subdir"); } function resolveBundlePath(checkoutPath: string, relativePath: string, fieldName: string): string { const resolved = path.resolve(checkoutPath, relativePath); const root = path.resolve(checkoutPath); if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) throw new AgentRunError("schema-invalid", `${fieldName} escaped checkout`, { httpStatus: 400 }); return resolved; } function resolveWorkspaceFilePath(workspacePath: string, relativePath: string, fieldName: string): string { const resolved = path.resolve(workspacePath, relativePath); const root = path.resolve(workspacePath); if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) throw new AgentRunError("schema-invalid", `${fieldName} escaped workspace`, { httpStatus: 400 }); return resolved; } function shellArg(value: string): string { return `'${value.replace(/'/gu, `'"'"'`)}'`; } function pathSummary(value: string): JsonRecord { const parts = value.split(/[\\/]+/u).filter(Boolean); return { absolute: path.isAbsolute(value), basename: parts.at(-1) ?? null, depth: parts.length, fingerprint: stableHash(value).slice(0, 16), valuePrinted: false }; }