diff --git a/src/runner/resource-bundle.ts b/src/runner/resource-bundle.ts index e0b7a57..c82c29c 100644 --- a/src/runner/resource-bundle.ts +++ b/src/runner/resource-bundle.ts @@ -11,6 +11,7 @@ import { stableHash } from "../common/validation.js"; const maxPromptRefBytes = 16 * 1024; const maxInitialPromptBytes = 64 * 1024; const skillSummaryChars = 600; +const runtimeBundledToolNames = Object.freeze(["agentrun-git"]); export interface MaterializedResourceBundle { workspacePath: string; @@ -271,48 +272,81 @@ async function prepareGitBundleTools(workspacePath: string, env: NodeJS.ProcessE const sourceBinPath = path.join(workspacePath, "tools"); const installedBinPath = optionalNonEmpty(env.AGENTRUN_RESOURCE_BIN_PATH); const runtimeBinPath = installedBinPath ?? sourceBinPath; - let entries; + 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") return { event: { count: 0, names: [], binPath: null, sourceBinPath: null, installedBinPath: null, installed: false, valuesPrinted: false } }; - throw 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[] = []; - if (installedBinPath) await mkdir(installedBinPath, { recursive: true }); - 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); + 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 }); } - 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 { - ...(names.length > 0 ? { binPath: runtimeBinPath } : {}), + ...(hasInstalledTools ? { binPath: runtimeBinPath } : {}), event: { count: names.length, names, items, - binPath: names.length > 0 ? pathSummary(runtimeBinPath) : null, - sourceBinPath: pathSummary(sourceBinPath), + runtimeTools: runtimeTools.event, + binPath: hasInstalledTools ? pathSummary(runtimeBinPath) : null, + sourceBinPath: sourceToolsAvailable ? pathSummary(sourceBinPath) : null, installedBinPath: installedBinPath ? pathSummary(installedBinPath) : null, - installed: Boolean(installedBinPath && names.length > 0), + 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): Promise<{ items: MaterializedPromptRef[]; event: JsonRecord }> { const items: MaterializedPromptRef[] = []; const eventItems: JsonRecord[] = []; diff --git a/src/selftest/cases/50-hwlab-manual-dispatch.ts b/src/selftest/cases/50-hwlab-manual-dispatch.ts index f41addd..b2f9232 100644 --- a/src/selftest/cases/50-hwlab-manual-dispatch.ts +++ b/src/selftest/cases/50-hwlab-manual-dispatch.ts @@ -102,6 +102,9 @@ process.exit(1); await access(path.join(resourceBinPath, "hwpod")); const resourceBinExec = await execFile(path.join(resourceBinPath, "hwpod"), ["--selftest"]); assert.match(resourceBinExec.stdout, /hwpod-selftest/u); + await access(path.join(resourceBinPath, "agentrun-git")); + const agentrunGitHelp = JSON.parse((await execFile(path.join(resourceBinPath, "agentrun-git"), ["--help"])).stdout) as JsonRecord; + assert.equal(agentrunGitHelp.tool, "agentrun-git"); const session = await store.getSession("hwlab-session-resume"); assert.equal(session?.threadId, "thread_selftest_1"); const resultEnvelope = await client.get(`/api/v1/runs/${sessionRun.runId}/commands/${sessionRun.commandId}/result`) as JsonRecord; @@ -114,8 +117,10 @@ process.exit(1); const resultBundleTargets = (((resultEnvelope.resourceBundleRef as JsonRecord).bundles as JsonRecord).items as JsonRecord[]).map((item) => item.targetPath); assert.deepEqual(resultBundleTargets, ["tools", ".agents/skills"]); const materialized = ((resultEnvelope.resourceBundleRef as JsonRecord).materialized as JsonRecord); - assert.deepEqual(((materialized.tools as JsonRecord).names), ["agentrun-git", "apply_patch", "hwpod", "tran", "trans"]); + assert.deepEqual(((materialized.tools as JsonRecord).names), ["apply_patch", "hwpod", "tran", "trans"]); assert.equal(((materialized.tools as JsonRecord).installed), true); + assert.deepEqual((((materialized.tools as JsonRecord).runtimeTools as JsonRecord).names), ["agentrun-git"]); + assert.equal(((((materialized.tools as JsonRecord).runtimeTools as JsonRecord).items as JsonRecord[])[0]?.overridesResourceTool), false); assert.deepEqual(((materialized.skillDirs as JsonRecord).names), ["dad-dev", "hwpod-cli", "hwpod-ctl"]); const requiredSkillItems = ((materialized.requiredSkills as JsonRecord).items as JsonRecord[]); assert.deepEqual(((materialized.requiredSkills as JsonRecord).names), ["dad-dev"]); @@ -295,7 +300,6 @@ async function createLocalGitBundle(context: SelfTestContext, repoName = "bundle await writeFile(path.join(repo, "tools", "tran"), "#!/usr/bin/env sh\necho tran-selftest\n", "utf8"); await writeFile(path.join(repo, "tools", "trans"), "#!/usr/bin/env sh\necho trans-selftest\n", "utf8"); await writeFile(path.join(repo, "tools", "apply_patch"), "#!/usr/bin/env sh\necho apply-patch-selftest\n", "utf8"); - await writeFile(path.join(repo, "tools", "agentrun-git"), "#!/usr/bin/env sh\necho agentrun-git-selftest\n", "utf8"); await writeFile(path.join(repo, "tools", "hwpod-cli.ts"), "import { hwpodSelftestName } from './src/hwpod-harness-lib.ts';\nconsole.log(JSON.stringify({ ok: true, cli: hwpodSelftestName(), argv: process.argv.slice(2) }));\n", "utf8"); await writeFile(path.join(repo, "tools", "src", "hwpod-harness-lib.ts"), "export function hwpodSelftestName() { return 'hwpod-selftest'; }\n", "utf8"); await writeFile(path.join(repo, "tools", "hwpod-node.test.ts"), "console.log('test-only source file without shebang');\n", "utf8"); @@ -336,7 +340,7 @@ async function createLocalGitBundle(context: SelfTestContext, repoName = "bundle "Use hwpod-ctl for HWPOD runtime inspection and control-plane state.", ].join("\n"), "utf8"); await writeFile(path.join(repo, "skills", "hwpod-ctl", "scripts", "hwpod-ctl.mjs"), "console.log(JSON.stringify({ ok: true, cli: 'hwpod-ctl-skill-selftest' }));\n", "utf8"); - await execFile("git", ["add", "README.md", "tools/hwpod", "tools/tran", "tools/trans", "tools/apply_patch", "tools/agentrun-git", "tools/hwpod-cli.ts", "tools/src/hwpod-harness-lib.ts", "tools/hwpod-node.test.ts", "internal/agent/prompts/hwlab-v02-runtime.md", "skills/dad-dev/SKILL.md", "skills/hwpod-cli/SKILL.md", "skills/hwpod-cli/scripts/hwpod-cli.mjs", "skills/hwpod-ctl/SKILL.md", "skills/hwpod-ctl/scripts/hwpod-ctl.mjs"], { cwd: repo }); + await execFile("git", ["add", "README.md", "tools/hwpod", "tools/tran", "tools/trans", "tools/apply_patch", "tools/hwpod-cli.ts", "tools/src/hwpod-harness-lib.ts", "tools/hwpod-node.test.ts", "internal/agent/prompts/hwlab-v02-runtime.md", "skills/dad-dev/SKILL.md", "skills/hwpod-cli/SKILL.md", "skills/hwpod-cli/scripts/hwpod-cli.mjs", "skills/hwpod-ctl/SKILL.md", "skills/hwpod-ctl/scripts/hwpod-ctl.mjs"], { cwd: repo }); await execFile("git", ["-c", "user.email=selftest@example.invalid", "-c", "user.name=AgentRun SelfTest", "commit", "-m", "bundle selftest"], { cwd: repo }); const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd: repo }); return { repoUrl: repo, commitId: stdout.trim(), requiredSkills: [{ name: "dad-dev" }] };