781 lines
38 KiB
TypeScript
781 lines
38 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { chmod, cp, mkdir, readdir, readFile, rm, stat, 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 { defaultGitDirectHosts, defaultGitHttpVersion, defaultGitLowSpeedLimitBytes, defaultGitLowSpeedTimeSeconds, defaultGitOperationTimeoutMs, gitTransportSummary } from "../common/git-transport.js";
|
|
import { stableHash } from "../common/validation.js";
|
|
import { bundledWorkReadyTools } from "../common/work-ready.js";
|
|
|
|
const maxPromptRefBytes = 16 * 1024;
|
|
const maxInitialPromptBytes = 64 * 1024;
|
|
const skillSummaryChars = 600;
|
|
const runtimeBundledToolNames = Object.freeze(bundledWorkReadyTools.map((tool) => tool.name));
|
|
|
|
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;
|
|
sourceBundle: JsonRecord | null;
|
|
}
|
|
|
|
interface GitCheckout {
|
|
repoUrl: string;
|
|
fetchRepoUrl: string;
|
|
mirrorUsed: boolean;
|
|
mirrorBaseUrl?: string;
|
|
commitId: string;
|
|
requestedCommitId?: string;
|
|
requestedRef?: string;
|
|
checkoutPath: string;
|
|
treeId: string;
|
|
}
|
|
|
|
interface GitMirrorConfig {
|
|
baseUrl: string;
|
|
}
|
|
|
|
interface GitBundleSource {
|
|
repoUrl: string;
|
|
commitId?: string;
|
|
ref?: string;
|
|
gitMirror?: GitMirrorConfig;
|
|
credentialRefPresent: boolean;
|
|
}
|
|
|
|
interface GitBundleSourceContext extends JsonRecord {
|
|
kind: "resource-bundle" | "bundle";
|
|
index: number | null;
|
|
name: string | null;
|
|
}
|
|
|
|
interface GitSourceRef {
|
|
kind: "ref" | "commit";
|
|
value: string;
|
|
}
|
|
|
|
interface MaterializedGitBundle {
|
|
name: string | null;
|
|
repoUrl: string;
|
|
fetchRepoUrl: string;
|
|
mirrorUsed: boolean;
|
|
mirrorBaseUrl: string | null;
|
|
commitId: string;
|
|
requestedCommitId: string | null;
|
|
requestedRef: string | null;
|
|
subpath: string;
|
|
targetPath: string;
|
|
sourceKind: "file" | "directory";
|
|
sourceBytes: number | null;
|
|
}
|
|
|
|
export async function materializeResourceBundle(resourceBundleRef: ResourceBundleRef | null | undefined, env: NodeJS.ProcessEnv = process.env): Promise<MaterializedResourceBundle | null> {
|
|
if (!resourceBundleRef) return null;
|
|
const workspaceRoot = path.resolve(env.AGENTRUN_WORKSPACE_ROOT ?? "/home/agentrun/workspaces");
|
|
const runScope = env.AGENTRUN_RUN_ID ?? env.AGENTRUN_ATTEMPT_ID ?? "standalone";
|
|
const assemblyRoot = path.join(workspaceRoot, `gitbundle-${stableHash({ runScope, resourceBundleRef }).slice(0, 16)}`);
|
|
const checkoutRoot = path.join(assemblyRoot, "checkouts");
|
|
const explicitWorkspacePath = optionalNonEmpty(env.AGENTRUN_WORKSPACE_PATH);
|
|
const workspacePath = explicitWorkspacePath ? path.resolve(explicitWorkspacePath) : path.join(assemblyRoot, "workspace");
|
|
if (explicitWorkspacePath && isSameOrChildPath(workspacePath, assemblyRoot)) {
|
|
throw new AgentRunError("schema-invalid", "AGENTRUN_WORKSPACE_PATH must not be inside transient resource assembly root", { httpStatus: 400, details: { workspacePath: pathSummary(workspacePath), assemblyRoot: pathSummary(assemblyRoot), valuesPrinted: false } });
|
|
}
|
|
await rm(assemblyRoot, { recursive: true, force: true });
|
|
await mkdir(checkoutRoot, { recursive: true });
|
|
await mkdir(workspacePath, { recursive: true });
|
|
const gitMirror = gitMirrorConfig(resourceBundleRef, env);
|
|
const defaultSource = defaultGitBundleSource(resourceBundleRef, env, gitMirror);
|
|
const checkoutCache = new Map<string, Promise<GitCheckout>>();
|
|
const checkoutFor = (source: GitBundleSource, sourceContext: GitBundleSourceContext) => {
|
|
const key = stableHash(gitSourceIdentity(source));
|
|
let checkout = checkoutCache.get(key);
|
|
if (!checkout) {
|
|
checkout = checkoutGitSource(checkoutRoot, source, sourceContext);
|
|
checkoutCache.set(key, checkout);
|
|
}
|
|
return checkout;
|
|
};
|
|
const defaultCheckout = await checkoutFor(defaultSource, { kind: "resource-bundle", index: null, name: null });
|
|
const materializedBundles = await materializeGitBundles(workspacePath, resourceBundleRef, defaultSource, defaultCheckout, checkoutFor);
|
|
const tools = await prepareGitBundleTools(workspacePath, env);
|
|
const skills = await discoverGitBundleSkills(workspacePath, materializedBundles);
|
|
const requiredSkills = materializeRequiredSkills(resourceBundleRef.requiredSkills ?? [], skills.items);
|
|
const prompts = await materializePromptRefs(defaultCheckout.checkoutPath, resourceBundleRef.promptRefs ?? []);
|
|
const initialPrompt = assembleInitialPrompt(prompts.items, skills.items);
|
|
return {
|
|
workspacePath,
|
|
...(tools.binPath ? { binPath: tools.binPath } : {}),
|
|
...(skills.skillsDir ? { skillsDir: skills.skillsDir } : {}),
|
|
...(initialPrompt ? { initialPrompt } : {}),
|
|
event: {
|
|
phase: "resource-bundle-materialized",
|
|
kind: "gitbundle",
|
|
repoUrl: resourceBundleRef.repoUrl,
|
|
fetchRepoUrl: defaultCheckout.fetchRepoUrl,
|
|
mirrorUsed: defaultCheckout.mirrorUsed,
|
|
mirrorBaseUrl: defaultCheckout.mirrorBaseUrl ?? null,
|
|
gitMirror: gitMirror ? { enabled: true, baseUrl: gitMirror.baseUrl, valuesPrinted: false } : { enabled: false, baseUrl: null, valuesPrinted: false },
|
|
commitId: defaultCheckout.commitId,
|
|
requestedCommitId: resourceBundleRef.commitId ?? null,
|
|
requestedRef: defaultCheckout.requestedRef ?? null,
|
|
treeId: defaultCheckout.treeId,
|
|
checkoutPath: pathSummary(defaultCheckout.checkoutPath),
|
|
workspacePath: pathSummary(workspacePath),
|
|
workspacePersistence: explicitWorkspacePath ? { mode: "session", path: pathSummary(workspacePath), valuesPrinted: false } : { mode: "run", path: pathSummary(workspacePath), valuesPrinted: false },
|
|
gitTransport: gitTransportSummary(),
|
|
bundles: {
|
|
count: materializedBundles.length,
|
|
items: materializedBundles.map((item) => ({ ...item, valuesPrinted: false })),
|
|
valuesPrinted: false,
|
|
},
|
|
tools: tools.event,
|
|
skillDirs: skills.event,
|
|
requiredSkills: requiredSkills.event,
|
|
promptRefs: prompts.event,
|
|
initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillCount: skills.items.length, valuesPrinted: false },
|
|
valuesPrinted: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
function isSameOrChildPath(candidate: string, parent: string): boolean {
|
|
const relative = path.relative(parent, candidate);
|
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
}
|
|
|
|
function gitMirrorConfig(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv): GitMirrorConfig | undefined {
|
|
void resourceBundleRef;
|
|
return defaultGitMirrorConfig(env);
|
|
}
|
|
|
|
function defaultGitMirrorConfig(env: NodeJS.ProcessEnv): GitMirrorConfig {
|
|
const baseUrl = optionalNonEmpty(env.AGENTRUN_GIT_MIRROR_BASE_URL) ?? "http://git-mirror-http.devops-infra.svc.cluster.local";
|
|
return { baseUrl: normalizeMirrorBaseUrl(baseUrl) };
|
|
}
|
|
|
|
function defaultGitBundleSource(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv, gitMirror?: GitMirrorConfig): GitBundleSource {
|
|
const ref = optionalNonEmpty(resourceBundleRef.ref) ?? optionalNonEmpty(env.AGENTRUN_RESOURCE_BUNDLE_REF) ?? optionalNonEmpty(env.AGENTRUN_WORKSPACE_REF) ?? optionalNonEmpty(env.AGENTRUN_WORKSPACE_BRANCH);
|
|
const credentialRefPresent = resourceBundleRef.credentialRef !== undefined;
|
|
if (ref) return { repoUrl: resourceBundleRef.repoUrl, ref, credentialRefPresent, ...(gitMirror ? { gitMirror } : {}) };
|
|
const commitId = optionalNonEmpty(resourceBundleRef.commitId);
|
|
if (commitId) return { repoUrl: resourceBundleRef.repoUrl, commitId, credentialRefPresent, ...(gitMirror ? { gitMirror } : {}) };
|
|
return { repoUrl: resourceBundleRef.repoUrl, ref: "HEAD", credentialRefPresent, ...(gitMirror ? { gitMirror } : {}) };
|
|
}
|
|
|
|
function bundleGitSource(bundle: ResourceBundleRef["bundles"][number], resourceBundleRef: ResourceBundleRef, defaultSource: GitBundleSource): GitBundleSource {
|
|
const repoUrl = bundle.repoUrl ?? resourceBundleRef.repoUrl;
|
|
const ref = optionalNonEmpty(bundle.ref);
|
|
const mirror = defaultSource.gitMirror;
|
|
const credentialRefPresent = defaultSource.credentialRefPresent;
|
|
if (ref) return { repoUrl, ref, credentialRefPresent, ...(mirror ? { gitMirror: mirror } : {}) };
|
|
const commitId = optionalNonEmpty(bundle.commitId);
|
|
if (commitId) return { repoUrl, commitId, credentialRefPresent, ...(mirror ? { gitMirror: mirror } : {}) };
|
|
if (repoUrl === defaultSource.repoUrl) return defaultSource;
|
|
if (defaultSource.ref) return { repoUrl, ref: defaultSource.ref, credentialRefPresent, ...(mirror ? { gitMirror: mirror } : {}) };
|
|
if (defaultSource.commitId) return { repoUrl, commitId: defaultSource.commitId, credentialRefPresent, ...(mirror ? { gitMirror: mirror } : {}) };
|
|
return { repoUrl, ref: "HEAD", credentialRefPresent, ...(mirror ? { gitMirror: mirror } : {}) };
|
|
}
|
|
|
|
async function checkoutGitSource(checkoutRoot: string, source: GitBundleSource, sourceContext: GitBundleSourceContext): Promise<GitCheckout> {
|
|
const checkoutPath = path.join(checkoutRoot, stableHash(gitSourceIdentity(source)).slice(0, 16));
|
|
const fetch = gitFetchSource(source);
|
|
const sourceRef: GitSourceRef = source.ref ? { kind: "ref", value: source.ref } : { kind: "commit", value: source.commitId ?? "" };
|
|
const fetchOptions: GitCommandOptions = {
|
|
operation: "fetch",
|
|
repoUrl: source.repoUrl,
|
|
fetchRepoUrl: fetch.fetchRepoUrl,
|
|
mirrorUsed: fetch.mirrorUsed,
|
|
sourceRef,
|
|
sourceContext,
|
|
credentialRefPresent: source.credentialRefPresent,
|
|
};
|
|
await mkdir(checkoutPath, { recursive: true });
|
|
await git(["init"], checkoutPath);
|
|
await git(["remote", "remove", "origin"], checkoutPath, { allowFailure: true });
|
|
await git(["remote", "add", "origin", fetch.fetchRepoUrl], checkoutPath);
|
|
if (source.ref) {
|
|
await git(["fetch", "--depth", "1", "origin", source.ref], checkoutPath, fetchOptions);
|
|
await git(["checkout", "--detach", "FETCH_HEAD"], checkoutPath);
|
|
} else if (source.commitId) {
|
|
await git(["fetch", "--depth", "1", "origin", source.commitId], checkoutPath, fetchOptions);
|
|
await git(["checkout", "--detach", source.commitId], checkoutPath);
|
|
} else {
|
|
throw new AgentRunError("schema-invalid", "gitbundle source must include repo ref or commit", { httpStatus: 400 });
|
|
}
|
|
const actualCommit = (await git(["rev-parse", "HEAD"], checkoutPath)).stdout.trim();
|
|
if (source.commitId && actualCommit !== source.commitId) throw new AgentRunError("infra-failed", "gitbundle checkout did not land on requested commit", { httpStatus: 500, details: { expectedCommit: source.commitId, actualCommit } });
|
|
const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim();
|
|
return { repoUrl: source.repoUrl, fetchRepoUrl: fetch.fetchRepoUrl, mirrorUsed: fetch.mirrorUsed, ...(fetch.mirrorBaseUrl ? { mirrorBaseUrl: fetch.mirrorBaseUrl } : {}), commitId: actualCommit, ...(source.commitId ? { requestedCommitId: source.commitId } : {}), ...(source.ref ? { requestedRef: source.ref } : {}), checkoutPath, treeId };
|
|
}
|
|
|
|
function gitSourceIdentity(source: GitBundleSource): JsonRecord {
|
|
return { repoUrl: source.repoUrl, commitId: source.commitId ?? null, ref: source.ref ?? null, gitMirror: source.gitMirror ? { baseUrl: source.gitMirror.baseUrl } : null };
|
|
}
|
|
|
|
export function resolveGitBundleFetchSource(repoUrl: string, gitMirror?: GitMirrorConfig, env: NodeJS.ProcessEnv = process.env): { fetchRepoUrl: string; mirrorUsed: boolean; mirrorBaseUrl?: string } {
|
|
const mirror = gitMirror ? { baseUrl: normalizeMirrorBaseUrl(gitMirror.baseUrl) } : defaultGitMirrorConfig(env);
|
|
const githubPath = githubRepoPath(repoUrl);
|
|
if (!githubPath) return { fetchRepoUrl: repoUrl, mirrorUsed: false };
|
|
return { fetchRepoUrl: `${mirror.baseUrl}/${githubPath}.git`, mirrorUsed: true, mirrorBaseUrl: mirror.baseUrl };
|
|
}
|
|
|
|
function normalizeMirrorBaseUrl(baseUrl: string): string {
|
|
return baseUrl.replace(/\/+$/u, "");
|
|
}
|
|
|
|
function gitFetchSource(source: GitBundleSource): { fetchRepoUrl: string; mirrorUsed: boolean; mirrorBaseUrl?: string } {
|
|
return resolveGitBundleFetchSource(source.repoUrl, source.gitMirror);
|
|
}
|
|
|
|
function githubRepoPath(repoUrl: string): string | null {
|
|
const raw = repoUrl.trim();
|
|
const scp = /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/u.exec(raw);
|
|
if (scp) return cleanGithubPath(scp[1] ?? "", scp[2] ?? "");
|
|
const ssh = /^ssh:\/\/git@(?:github\.com|ssh\.github\.com)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/u.exec(raw);
|
|
if (ssh) return cleanGithubPath(ssh[1] ?? "", ssh[2] ?? "");
|
|
const http = /^https?:\/\/github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?\/?$/u.exec(raw);
|
|
if (http) return cleanGithubPath(http[1] ?? "", http[2] ?? "");
|
|
return null;
|
|
}
|
|
|
|
function cleanGithubPath(owner: string, repo: string): string | null {
|
|
const cleanOwner = owner.trim();
|
|
const cleanRepo = repo.trim().replace(/\.git$/u, "");
|
|
if (!/^[A-Za-z0-9_.-]+$/u.test(cleanOwner) || !/^[A-Za-z0-9_.-]+$/u.test(cleanRepo)) return null;
|
|
return `${cleanOwner}/${cleanRepo}`;
|
|
}
|
|
|
|
async function materializeGitBundles(workspacePath: string, resourceBundleRef: ResourceBundleRef, defaultSource: GitBundleSource, defaultCheckout: GitCheckout, checkoutFor: (source: GitBundleSource, sourceContext: GitBundleSourceContext) => Promise<GitCheckout>): Promise<MaterializedGitBundle[]> {
|
|
const items: MaterializedGitBundle[] = [];
|
|
for (const [index, bundle] of resourceBundleRef.bundles.entries()) {
|
|
const gitSource = bundleGitSource(bundle, resourceBundleRef, defaultSource);
|
|
const checkout = gitSource === defaultSource ? defaultCheckout : await checkoutFor(gitSource, { kind: "bundle", index, name: bundle.name ?? null });
|
|
const source = resolveBundlePath(checkout.checkoutPath, bundle.subpath, `bundles[${index}].subpath`);
|
|
const target = resolveWorkspaceTargetPath(workspacePath, bundle.targetPath, `bundles[${index}].target_path`);
|
|
let sourceStat;
|
|
try {
|
|
sourceStat = await stat(source);
|
|
} catch (error) {
|
|
throw new AgentRunError("schema-invalid", `gitbundle subpath ${bundle.subpath} is not readable`, { httpStatus: 400, details: { index, subpath: bundle.subpath, error: fileErrorSummary(error), valuesPrinted: false } });
|
|
}
|
|
await mkdir(path.dirname(target), { recursive: true });
|
|
await rm(target, { recursive: true, force: true });
|
|
await cp(source, target, { recursive: true, force: true, dereference: false });
|
|
items.push({ name: bundle.name ?? null, repoUrl: checkout.repoUrl, fetchRepoUrl: checkout.fetchRepoUrl, mirrorUsed: checkout.mirrorUsed, mirrorBaseUrl: checkout.mirrorBaseUrl ?? null, commitId: checkout.commitId, requestedCommitId: bundle.commitId ?? resourceBundleRef.commitId ?? null, requestedRef: checkout.requestedRef ?? null, subpath: bundle.subpath, targetPath: bundle.targetPath, sourceKind: sourceStat.isDirectory() ? "directory" : "file", sourceBytes: sourceStat.isFile() ? sourceStat.size : null });
|
|
}
|
|
return items;
|
|
}
|
|
|
|
function optionalNonEmpty(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
}
|
|
|
|
function shellSingleQuote(value: string): string {
|
|
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
}
|
|
|
|
function installedToolShim(sourcePath: string): string {
|
|
return `#!/usr/bin/env sh\nexec ${shellSingleQuote(sourcePath)} "$@"\n`;
|
|
}
|
|
|
|
async function prepareGitBundleTools(workspacePath: string, env: NodeJS.ProcessEnv): Promise<{ binPath?: string; event: JsonRecord }> {
|
|
const sourceBinPath = path.join(workspacePath, "tools");
|
|
const installedBinPath = optionalNonEmpty(env.AGENTRUN_RESOURCE_BIN_PATH);
|
|
const runtimeBinPath = installedBinPath ?? sourceBinPath;
|
|
const runtimeToolTargetBinPath = runtimeBinPath;
|
|
let entries: { name: string; isFile(): boolean }[] = [];
|
|
let sourceToolsAvailable = true;
|
|
try {
|
|
entries = await readdir(sourceBinPath, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") sourceToolsAvailable = false;
|
|
else throw error;
|
|
}
|
|
const names: string[] = [];
|
|
const items: JsonRecord[] = [];
|
|
await mkdir(runtimeToolTargetBinPath, { recursive: true });
|
|
if (sourceToolsAvailable) {
|
|
for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
|
|
if (!entry.isFile()) continue;
|
|
const filePath = path.join(sourceBinPath, entry.name);
|
|
const text = await readFile(filePath, "utf8");
|
|
const firstLine = text.split(/\r?\n/u, 1)[0] ?? "";
|
|
if (!firstLine.startsWith("#!")) continue;
|
|
await chmod(filePath, 0o755);
|
|
if (installedBinPath) {
|
|
const targetPath = path.join(installedBinPath, entry.name);
|
|
if (targetPath !== filePath) {
|
|
await writeFile(targetPath, installedToolShim(filePath), "utf8");
|
|
await chmod(targetPath, 0o755);
|
|
}
|
|
}
|
|
names.push(entry.name);
|
|
items.push({ name: entry.name, sha256: sha256Text(text), bytes: Buffer.byteLength(text, "utf8"), shebang: firstLine.slice(0, 80), valuesPrinted: false });
|
|
}
|
|
}
|
|
const runtimeTools = await installRuntimeBundledTools(runtimeToolTargetBinPath, names);
|
|
const hasInstalledTools = names.length > 0 || runtimeTools.names.length > 0;
|
|
return {
|
|
...(hasInstalledTools ? { binPath: runtimeBinPath } : {}),
|
|
event: {
|
|
count: names.length,
|
|
names,
|
|
items,
|
|
runtimeTools: runtimeTools.event,
|
|
binPath: hasInstalledTools ? pathSummary(runtimeBinPath) : null,
|
|
sourceBinPath: sourceToolsAvailable ? pathSummary(sourceBinPath) : null,
|
|
installedBinPath: installedBinPath ? pathSummary(installedBinPath) : null,
|
|
installed: Boolean(installedBinPath && hasInstalledTools),
|
|
valuesPrinted: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function installRuntimeBundledTools(targetBinPath: string | undefined, resourceToolNames: string[]): Promise<{ names: string[]; event: JsonRecord }> {
|
|
const names: string[] = [];
|
|
const items: JsonRecord[] = [];
|
|
if (!targetBinPath) return { names, event: { count: 0, names, items, installed: false, installedBinPath: null, source: "agentrun-runtime", valuesPrinted: false } };
|
|
for (const name of runtimeBundledToolNames) {
|
|
const sourcePath = path.resolve(import.meta.dirname, "../../tools", name);
|
|
let text: string;
|
|
try {
|
|
text = await readFile(sourcePath, "utf8");
|
|
} catch (error) {
|
|
throw new AgentRunError("infra-failed", `runner runtime tool ${name} is not readable`, { httpStatus: 503, details: { name, sourcePath: pathSummary(sourcePath), error: fileErrorSummary(error), valuesPrinted: false } });
|
|
}
|
|
const firstLine = text.split(/\r?\n/u, 1)[0] ?? "";
|
|
if (!firstLine.startsWith("#!")) throw new AgentRunError("infra-failed", `runner runtime tool ${name} is not executable`, { httpStatus: 503, details: { name, sourcePath: pathSummary(sourcePath), valuesPrinted: false } });
|
|
await chmod(sourcePath, 0o755);
|
|
const targetPath = path.join(targetBinPath, name);
|
|
if (targetPath !== sourcePath) {
|
|
await writeFile(targetPath, installedToolShim(sourcePath), "utf8");
|
|
await chmod(targetPath, 0o755);
|
|
}
|
|
names.push(name);
|
|
items.push({ name, source: "agentrun-runtime", sha256: sha256Text(text), bytes: Buffer.byteLength(text, "utf8"), shebang: firstLine.slice(0, 80), overridesResourceTool: resourceToolNames.includes(name), valuesPrinted: false });
|
|
}
|
|
return { names, event: { count: names.length, names, items, installed: true, installedBinPath: pathSummary(targetBinPath), source: "agentrun-runtime", valuesPrinted: false } };
|
|
}
|
|
|
|
async function materializePromptRefs(checkoutPath: string, refs: NonNullable<ResourceBundleRef["promptRefs"]>): 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 discoverGitBundleSkills(workspacePath: string, bundles: MaterializedGitBundle[]): Promise<{ items: MaterializedSkillRef[]; skillsDir?: string; event: JsonRecord }> {
|
|
const skillsDir = path.join(workspacePath, ".agents", "skills");
|
|
let entries;
|
|
try {
|
|
entries = await readdir(skillsDir, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") return { items: [], event: { count: 0, materializedCount: 0, names: [], skillsDir: null, items: [], valuesPrinted: false } };
|
|
throw error;
|
|
}
|
|
const items: MaterializedSkillRef[] = [];
|
|
const eventItems: JsonRecord[] = [];
|
|
for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
|
|
if (!entry.isDirectory()) continue;
|
|
const aggregateAs = entry.name;
|
|
const relativeManifestPath = `.agents/skills/${aggregateAs}/SKILL.md`;
|
|
const manifestPath = path.join(skillsDir, aggregateAs, "SKILL.md");
|
|
const sourceBundle = skillSourceBundle(relativeManifestPath, bundles);
|
|
let manifestText: string;
|
|
try {
|
|
manifestText = await readFile(manifestPath, "utf8");
|
|
} catch (error) {
|
|
eventItems.push({ name: aggregateAs, path: relativeManifestPath, required: true, aggregateAs, status: "missing", error: fileErrorSummary(error), sourceBundle, valuesPrinted: false });
|
|
continue;
|
|
}
|
|
const bytes = Buffer.byteLength(manifestText, "utf8");
|
|
const sha = sha256Text(manifestText);
|
|
const summary = skillSummary(manifestText);
|
|
items.push({ name: aggregateAs, path: relativeManifestPath, aggregateAs, required: true, registryPath: manifestPath, manifestBytes: bytes, manifestSha256: sha, summary, sourceBundle });
|
|
eventItems.push({ name: aggregateAs, path: relativeManifestPath, aggregateAs, required: true, status: "materialized", manifestSha256: sha, manifestBytes: bytes, registryPath: pathSummary(manifestPath), sourceBundle, summary, valuesPrinted: false });
|
|
}
|
|
return {
|
|
items,
|
|
skillsDir,
|
|
event: {
|
|
count: entries.filter((entry) => entry.isDirectory()).length,
|
|
materializedCount: items.length,
|
|
names: items.map((item) => item.name),
|
|
skillsDir: pathSummary(skillsDir),
|
|
items: eventItems,
|
|
valuesPrinted: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
function materializeRequiredSkills(refs: NonNullable<ResourceBundleRef["requiredSkills"]>, skills: MaterializedSkillRef[]): { items: MaterializedSkillRef[]; event: JsonRecord } {
|
|
if (refs.length === 0) return { items: [], event: { count: 0, materializedCount: 0, names: [], items: [], valuesPrinted: false } };
|
|
const byName = new Map(skills.map((skill) => [skill.name, skill]));
|
|
const items: MaterializedSkillRef[] = [];
|
|
const eventItems: JsonRecord[] = [];
|
|
const missing: JsonRecord[] = [];
|
|
const missingNames: string[] = [];
|
|
for (const ref of refs) {
|
|
const skill = byName.get(ref.name);
|
|
if (!skill) {
|
|
const item = { name: ref.name, path: `.agents/skills/${ref.name}/SKILL.md`, status: "missing", valuesPrinted: false };
|
|
missingNames.push(ref.name);
|
|
missing.push(item);
|
|
eventItems.push(item);
|
|
continue;
|
|
}
|
|
items.push(skill);
|
|
eventItems.push({ name: skill.name, path: skill.path, aggregateAs: skill.aggregateAs, status: "materialized", manifestSha256: skill.manifestSha256, manifestBytes: skill.manifestBytes, sourceBundle: skill.sourceBundle, summary: skill.summary, valuesPrinted: false });
|
|
}
|
|
if (missing.length > 0) {
|
|
throw new AgentRunError("required-skill-unavailable", `required resource skill ${missingNames.join(", ")} is not materialized`, {
|
|
httpStatus: 400,
|
|
details: {
|
|
required: refs.map((ref) => ref.name),
|
|
missing,
|
|
available: skills.map((skill) => ({ name: skill.name, path: skill.path, manifestSha256: skill.manifestSha256, manifestBytes: skill.manifestBytes, sourceBundle: skill.sourceBundle, valuesPrinted: false })),
|
|
valuesPrinted: false,
|
|
},
|
|
});
|
|
}
|
|
return {
|
|
items,
|
|
event: {
|
|
count: refs.length,
|
|
materializedCount: items.length,
|
|
names: items.map((item) => item.name),
|
|
items: eventItems,
|
|
valuesPrinted: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
function skillSourceBundle(relativePath: string, bundles: MaterializedGitBundle[]): JsonRecord | null {
|
|
const match = bundles
|
|
.filter((bundle) => relativePathMatchesTarget(relativePath, bundle.targetPath))
|
|
.sort((left, right) => right.targetPath.length - left.targetPath.length)[0];
|
|
if (!match) return null;
|
|
return {
|
|
name: match.name,
|
|
repoUrl: match.repoUrl,
|
|
fetchRepoUrl: match.fetchRepoUrl,
|
|
mirrorUsed: match.mirrorUsed,
|
|
mirrorBaseUrl: match.mirrorBaseUrl,
|
|
commitId: match.commitId,
|
|
requestedCommitId: match.requestedCommitId,
|
|
requestedRef: match.requestedRef,
|
|
subpath: match.subpath,
|
|
targetPath: match.targetPath,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function relativePathMatchesTarget(relativePath: string, targetPath: string): boolean {
|
|
const normalizedTarget = targetPath.replace(/\/+$/u, "");
|
|
return relativePath === normalizedTarget || relativePath.startsWith(`${normalizedTarget}/`);
|
|
}
|
|
|
|
function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skills: MaterializedSkillRef[]): InitialPromptAssembly | undefined {
|
|
if (promptRefs.length === 0 && skills.length === 0) return undefined;
|
|
const sections: string[] = [
|
|
"AgentRun initial runtime instructions. These instructions are assembled from ResourceBundleRef promptRefs and gitbundle skill directories 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 (skills.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.",
|
|
...skills.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, skillCount: skills.length, valuesPrinted: false } });
|
|
return {
|
|
text,
|
|
summary: {
|
|
available: true,
|
|
bytes,
|
|
sha256: sha256Text(text),
|
|
promptRefCount: promptRefs.length,
|
|
promptRefNames: promptRefs.map((item) => item.name),
|
|
skillCount: skills.length,
|
|
skillNames: skills.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 };
|
|
}
|
|
|
|
interface GitCommandOptions {
|
|
allowFailure?: boolean;
|
|
operation?: string;
|
|
repoUrl?: string;
|
|
fetchRepoUrl?: string;
|
|
mirrorUsed?: boolean;
|
|
sourceRef?: GitSourceRef;
|
|
sourceContext?: GitBundleSourceContext;
|
|
credentialRefPresent?: boolean;
|
|
}
|
|
|
|
export function classifyGitStderr(stderr: string): string {
|
|
const text = stderr.trim();
|
|
if (/repository\s+['"].+['"]\s+not found|repository not found/iu.test(text)) return "repository-not-found";
|
|
if (/couldn't find remote ref|remote ref .+ not found|not our ref|unadvertised object/iu.test(text)) return "source-ref-not-found";
|
|
if (/authentication failed|permission denied|could not read username|access denied|http[^\n]*\b(?:401|403)\b/iu.test(text)) return "authentication-failed";
|
|
if (/could not resolve host|name or service not known|temporary failure in name resolution/iu.test(text)) return "dns-failed";
|
|
if (/failed to connect|connection refused|connection timed out|network is unreachable|no route to host/iu.test(text)) return "connection-failed";
|
|
if (/certificate|\bssl\b|\btls\b/iu.test(text)) return "tls-failed";
|
|
if (/early eof|rpc failed|remote end hung up|http[^\n]*\b5\d\d\b/iu.test(text)) return "transport-failed";
|
|
return text.length === 0 ? "stderr-empty" : "unclassified";
|
|
}
|
|
|
|
async function git(args: string[], cwd: string, options: GitCommandOptions = {}): Promise<{ stdout: string; stderr: string }> {
|
|
const env = gitCommandEnv(process.env);
|
|
const child = spawn("git", gitArgs(args), { cwd, stdio: ["ignore", "pipe", "pipe"], env });
|
|
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 timeoutMs = gitTimeoutMs(process.env);
|
|
const startedAt = Date.now();
|
|
let timedOut = false;
|
|
let closed = false;
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
child.kill("SIGTERM");
|
|
setTimeout(() => {
|
|
if (!closed) child.kill("SIGKILL");
|
|
}, 2_000).unref();
|
|
}, timeoutMs);
|
|
const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
|
child.on("error", reject);
|
|
child.on("close", (code, signal) => {
|
|
closed = true;
|
|
resolve({ code, signal });
|
|
});
|
|
}).catch((error: unknown) => {
|
|
clearTimeout(timeout);
|
|
throw new AgentRunError("infra-failed", `failed to start git: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 503 });
|
|
});
|
|
clearTimeout(timeout);
|
|
if (result.code !== 0 && !options.allowFailure) {
|
|
const failureKind = timedOut ? "backend-timeout" : "infra-failed";
|
|
const stderrCategory = timedOut ? "operation-timeout" : classifyGitStderr(stderr);
|
|
throw new AgentRunError(failureKind, `git ${args[0] ?? "command"} ${timedOut ? `timed out after ${timeoutMs}ms` : `failed with code ${result.code} (${stderrCategory})`}`, {
|
|
httpStatus: timedOut ? 504 : 502,
|
|
details: {
|
|
operation: options.operation ?? args[0] ?? "git",
|
|
stage: options.operation === "fetch" ? "git-fetch" : `git-${options.operation ?? args[0] ?? "command"}`,
|
|
source: {
|
|
bundle: options.sourceContext ?? null,
|
|
sourceRef: options.sourceRef ? { present: true, kind: options.sourceRef.kind, value: options.sourceRef.value, valuesPrinted: false } : { present: false, kind: null, value: null, valuesPrinted: false },
|
|
credentialRefPresent: options.credentialRefPresent === true,
|
|
valuesPrinted: false,
|
|
},
|
|
remote: {
|
|
declared: gitRemoteSummary(options.repoUrl),
|
|
fetch: gitRemoteSummary(options.fetchRepoUrl),
|
|
mirrorUsed: options.mirrorUsed === true,
|
|
valuesPrinted: false,
|
|
},
|
|
stderr: {
|
|
category: stderrCategory,
|
|
firstLineCategory: classifyGitStderr(firstNonEmptyLine(stderr)),
|
|
bytes: Buffer.byteLength(stderr, "utf8"),
|
|
lines: nonEmptyLineCount(stderr),
|
|
truncated: stderr.length > 4000,
|
|
valuesPrinted: false,
|
|
},
|
|
stdout: {
|
|
bytes: Buffer.byteLength(stdout, "utf8"),
|
|
truncated: stdout.length > 1000,
|
|
valuesPrinted: false,
|
|
},
|
|
timeoutMs,
|
|
elapsedMs: Date.now() - startedAt,
|
|
protocol: defaultGitHttpVersion,
|
|
terminalPrompt: false,
|
|
credentialHelper: "gh-auth-setup-git",
|
|
proxyDecision: proxyDecisionForUrl(options.fetchRepoUrl ?? options.repoUrl ?? ""),
|
|
signal: timedOut ? "SIGTERM" : result.signal,
|
|
valuesPrinted: false,
|
|
},
|
|
});
|
|
}
|
|
return { stdout, stderr };
|
|
}
|
|
|
|
function gitRemoteSummary(value: string | undefined): JsonRecord | null {
|
|
const remote = optionalNonEmpty(value);
|
|
if (!remote) return null;
|
|
const scp = /^(?:[^@\s]+@)?([^:/\s]+):/u.exec(remote);
|
|
let scheme = scp ? "ssh" : "file";
|
|
let host: string | null = scp?.[1] ?? null;
|
|
try {
|
|
const parsed = new URL(remote);
|
|
scheme = parsed.protocol.replace(/:$/u, "") || scheme;
|
|
host = parsed.hostname || null;
|
|
} catch {
|
|
// Local paths and SCP-style SSH remotes are summarized without parsing their path.
|
|
}
|
|
return { scheme, host, fingerprint: sha256Text(remote), valuesPrinted: false };
|
|
}
|
|
|
|
function firstNonEmptyLine(value: string): string {
|
|
return value.split(/\r?\n/u).find((line) => line.trim().length > 0)?.trim() ?? "";
|
|
}
|
|
|
|
function nonEmptyLineCount(value: string): number {
|
|
return value.split(/\r?\n/u).filter((line) => line.trim().length > 0).length;
|
|
}
|
|
|
|
function gitArgs(args: string[]): string[] {
|
|
return [
|
|
"-c", `http.version=${defaultGitHttpVersion}`,
|
|
"-c", `http.lowSpeedLimit=${process.env.GIT_HTTP_LOW_SPEED_LIMIT ?? String(defaultGitLowSpeedLimitBytes)}`,
|
|
"-c", `http.lowSpeedTime=${process.env.GIT_HTTP_LOW_SPEED_TIME ?? String(defaultGitLowSpeedTimeSeconds)}`,
|
|
...args,
|
|
];
|
|
}
|
|
|
|
function gitCommandEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
const next: NodeJS.ProcessEnv = {
|
|
...env,
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
GIT_HTTP_VERSION: env.GIT_HTTP_VERSION ?? defaultGitHttpVersion,
|
|
};
|
|
const noProxy = new Set(String(next.NO_PROXY || next.no_proxy || "").split(",").map((item) => item.trim()).filter(Boolean));
|
|
for (const host of gitDirectHosts(env)) noProxy.add(host);
|
|
next.NO_PROXY = [...noProxy].join(",");
|
|
next.no_proxy = next.NO_PROXY;
|
|
return next;
|
|
}
|
|
|
|
function gitTimeoutMs(env: NodeJS.ProcessEnv): number {
|
|
const value = Number(env.AGENTRUN_GIT_DEFAULT_TIMEOUT_MS);
|
|
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : defaultGitOperationTimeoutMs;
|
|
}
|
|
|
|
function proxyDecisionForUrl(url: string): JsonRecord {
|
|
let host: string | null = null;
|
|
try {
|
|
host = new URL(url).hostname;
|
|
} catch {
|
|
if (/^git@github\.com:/u.test(url.trim())) host = "github.com";
|
|
}
|
|
const directHosts = new Set(gitDirectHosts(process.env));
|
|
return {
|
|
host,
|
|
mode: host && directHosts.has(host) ? "direct-preferred" : "env-proxy",
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function gitDirectHosts(env: NodeJS.ProcessEnv): string[] {
|
|
const raw = env.AGENTRUN_GIT_DIRECT_HOSTS;
|
|
if (!raw || raw.trim().length === 0) return [...defaultGitDirectHosts];
|
|
return raw.split(",").map((item) => item.trim()).filter(Boolean);
|
|
}
|
|
|
|
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 resolveWorkspaceTargetPath(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 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 };
|
|
}
|