diff --git a/src/runner/resource-bundle.ts b/src/runner/resource-bundle.ts index 9c8d008..b7e70aa 100644 --- a/src/runner/resource-bundle.ts +++ b/src/runner/resource-bundle.ts @@ -13,6 +13,7 @@ const maxPromptRefBytes = 16 * 1024; const maxInitialPromptBytes = 64 * 1024; const skillSummaryChars = 600; const runtimeBundledToolNames = Object.freeze(bundledWorkReadyTools.map((tool) => tool.name)); +const managedOverlayDirectory = "agentrun-managed-overlay"; export interface MaterializedResourceBundle { workspacePath: string; @@ -173,8 +174,10 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl checkoutPath = defaultCheckout.checkoutPath; const stagePath = await stagePrimaryWorkspace(defaultCheckout.checkoutPath, workspacePath, runScope); try { - const stagedBundles = await materializeGitBundles(stagePath, resourceBundleRef, defaultSource, defaultCheckout, checkoutFor); + const stageOverlayRoot = await prepareManagedOverlay(stagePath); + const stagedBundles = await materializeGitBundles(stageOverlayRoot, resourceBundleRef, defaultSource, defaultCheckout, checkoutFor); const stagedProvenance = createPrimaryWorkspaceProvenance(resourceBundleRef, workspaceRef, defaultCheckout, stagedBundles); + await assertCleanPrimarySourceView(stagePath); await verifyPrimaryWorkspace(stagePath, workspaceRef, stagedProvenance, "first-materialization", true); await writePrimaryWorkspaceProvenance(stagePath, stagedProvenance); const installed = await installPrimaryWorkspace(stagePath, workspacePath); @@ -192,8 +195,9 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl } const primaryWorkspace = await verifyPrimaryWorkspace(workspacePath, workspaceRef, provenance, workspaceLifecycle, false); - const tools = await prepareGitBundleTools(workspacePath, env); - const skills = await discoverGitBundleSkills(workspacePath, materializedBundles); + const managedOverlayRoot = await verifyManagedOverlay(workspacePath, materializedBundles); + const tools = await prepareGitBundleTools(managedOverlayRoot, env); + const skills = await discoverGitBundleSkills(managedOverlayRoot, materializedBundles); const requiredSkills = materializeRequiredSkills(resourceBundleRef.requiredSkills ?? [], skills.items); const prompts = await materializePromptRefs(checkoutPath ?? workspacePath, resourceBundleRef.promptRefs ?? []); const initialPrompt = assembleInitialPrompt(prompts.items, skills.items); @@ -221,10 +225,18 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl workspaceLifecycle, resolutionAuthority: provenance.resolutionAuthority, primaryWorkspace, + managedOverlay: { + kind: "git-metadata", + root: pathSummary(managedOverlayRoot), + rootRelativeToWorkspace: `.git/${managedOverlayDirectory}`, + sourceViewIsolated: true, + targetPathAuthority: "resourceBundleRef.bundles[].targetPath", + valuesPrinted: false, + }, gitTransport: gitTransportSummary({ scope: "resource-bundle" }), bundles: { count: materializedBundles.length, - items: materializedBundles.map((item) => ({ ...item, valuesPrinted: false })), + items: materializedBundles.map((item) => ({ ...item, logicalTargetPath: item.targetPath, storageKind: "git-metadata-overlay", valuesPrinted: false })), valuesPrinted: false, }, tools: tools.event, @@ -608,6 +620,57 @@ async function installPrimaryWorkspace(stagePath: string, workspacePath: string) } } +async function prepareManagedOverlay(workspacePath: string): Promise { + const overlayRoot = managedOverlayRoot(workspacePath); + await rm(overlayRoot, { recursive: true, force: true }); + await mkdir(overlayRoot, { recursive: true }); + return overlayRoot; +} + +async function verifyManagedOverlay(workspacePath: string, bundles: MaterializedGitBundle[]): Promise { + const overlayRoot = managedOverlayRoot(workspacePath); + let root; + try { + root = await stat(overlayRoot); + } catch (error) { + throw new AgentRunError("schema-invalid", "primary workspace managed capability overlay is missing", { httpStatus: 400, details: { overlayRoot: pathSummary(overlayRoot), error: fileErrorSummary(error), valuesPrinted: false } }); + } + if (!root.isDirectory()) { + throw new AgentRunError("schema-invalid", "primary workspace managed capability overlay is not a directory", { httpStatus: 400, details: { overlayRoot: pathSummary(overlayRoot), valuesPrinted: false } }); + } + for (const [index, bundle] of bundles.entries()) { + const target = resolveManagedOverlayTargetPath(overlayRoot, bundle.targetPath, `bundleProvenance[${index}].targetPath`); + let targetStat; + try { + targetStat = await stat(target); + } catch (error) { + throw new AgentRunError("schema-invalid", "primary workspace managed capability target is missing", { httpStatus: 400, details: { index, targetPath: bundle.targetPath, overlayRoot: pathSummary(overlayRoot), error: fileErrorSummary(error), valuesPrinted: false } }); + } + const actualKind = targetStat.isDirectory() ? "directory" : targetStat.isFile() ? "file" : "unsupported"; + if (actualKind !== bundle.sourceKind) { + throw new AgentRunError("schema-invalid", "primary workspace managed capability target kind conflicts with frozen provenance", { httpStatus: 400, details: { index, targetPath: bundle.targetPath, expectedKind: bundle.sourceKind, actualKind, valuesPrinted: false } }); + } + } + return overlayRoot; +} + +function managedOverlayRoot(workspacePath: string): string { + return path.join(workspacePath, ".git", managedOverlayDirectory); +} + +async function assertCleanPrimarySourceView(workspacePath: string): Promise { + const status = await git(["status", "--porcelain=v1", "--untracked-files=all"], workspacePath); + if (status.stdout.trim().length > 0) { + throw new AgentRunError("infra-failed", "managed capability assembly polluted the staged primary Git source view", { + httpStatus: 500, + details: { + changedEntries: status.stdout.split(/\r?\n/u).filter((line) => line.trim().length > 0).length, + valuesPrinted: false, + }, + }); + } +} + async function verifyPrimaryWorkspace(workspacePath: string, workspaceRef: WorkspaceRef, provenance: PrimaryWorkspaceProvenance, workspaceLifecycle: "first-materialization" | "continuation-reuse", requireExactSource: boolean): Promise { let rootStat; let gitStat; @@ -714,13 +777,13 @@ function cleanGithubPath(owner: string, repo: string): string | null { return `${cleanOwner}/${cleanRepo}`; } -async function materializeGitBundles(workspacePath: string, resourceBundleRef: ResourceBundleRef, defaultSource: GitBundleSource, defaultCheckout: GitCheckout, checkoutFor: (source: GitBundleSource, sourceContext: GitBundleSourceContext) => Promise): Promise { +async function materializeGitBundles(overlayRoot: string, resourceBundleRef: ResourceBundleRef, defaultSource: GitBundleSource, defaultCheckout: GitCheckout, checkoutFor: (source: GitBundleSource, sourceContext: GitBundleSourceContext) => Promise): Promise { 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`); + const target = resolveManagedOverlayTargetPath(overlayRoot, bundle.targetPath, `bundles[${index}].target_path`); let sourceStat; try { sourceStat = await stat(source); @@ -747,8 +810,8 @@ 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"); +async function prepareGitBundleTools(overlayRoot: string, env: NodeJS.ProcessEnv): Promise<{ binPath?: string; event: JsonRecord }> { + const sourceBinPath = path.join(overlayRoot, "tools"); const installedBinPath = optionalNonEmpty(env.AGENTRUN_RESOURCE_BIN_PATH); const runtimeBinPath = installedBinPath ?? sourceBinPath; const runtimeToolTargetBinPath = runtimeBinPath; @@ -862,8 +925,8 @@ async function materializePromptRefs(checkoutPath: string, refs: NonNullable { - const skillsDir = path.join(workspacePath, ".agents", "skills"); +async function discoverGitBundleSkills(overlayRoot: string, bundles: MaterializedGitBundle[]): Promise<{ items: MaterializedSkillRef[]; skillsDir?: string; event: JsonRecord }> { + const skillsDir = path.join(overlayRoot, ".agents", "skills"); let entries; try { entries = await readdir(skillsDir, { withFileTypes: true }); @@ -984,8 +1047,8 @@ function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skills: Mate 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}`), + "The following required runtime skills are published through the runtime-managed AGENTRUN_SKILLS_DIRS directory outside the Git source view. Use these bundle skills instead of default model skill guesses.", + ...skills.map((skill) => `- ${skill.name}: ${skill.summary || "No summary provided."} manifest=${skill.registryPath} skillsDirEnv=AGENTRUN_SKILLS_DIRS source=${skill.path} required=${skill.required}`), ]; sections.push(lines.join("\n")); } @@ -1212,10 +1275,10 @@ function resolveBundlePath(checkoutPath: string, relativePath: string, fieldName 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 }); +function resolveManagedOverlayTargetPath(overlayRoot: string, relativePath: string, fieldName: string): string { + const resolved = path.resolve(overlayRoot, relativePath); + const root = path.resolve(overlayRoot); + if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) throw new AgentRunError("schema-invalid", `${fieldName} escaped managed overlay`, { httpStatus: 400 }); return resolved; } diff --git a/src/selftest/cases/79-primary-workspace.ts b/src/selftest/cases/79-primary-workspace.ts index e9629d8..2079a54 100644 --- a/src/selftest/cases/79-primary-workspace.ts +++ b/src/selftest/cases/79-primary-workspace.ts @@ -19,9 +19,16 @@ const selfTest: SelfTestCase = async (context) => { const primary = await createRepo(context, "primary-source", { "AGENTS.md": "# Primary workspace\n", "package.json": "{\"name\":\"primary-workspace-selftest\"}\n", + "tools/source-tool": "#!/usr/bin/env sh\necho primary-source-tool\n", + "tools/probe": "#!/usr/bin/env sh\necho primary-source-probe\n", + ".agents/skills/source-skill/SKILL.md": "# Source-owned skill\n", + ".agents/skills/dad-dev/SKILL.md": "# Source-owned dad-dev skill\n", }); const addon = await createRepo(context, "addon-source", { "capability/probe": "#!/usr/bin/env sh\necho capability\n", + "dad-dev/SKILL.md": "---\ndescription: Managed dad-dev capability\n---\n", + "git-spec/SKILL.md": "---\ndescription: Managed git-spec capability\n---\n", + "unidesk-gh/SKILL.md": "---\ndescription: Managed unidesk-gh capability\n---\n", }); const workspaceRef: WorkspaceRef = { kind: "opaque", path: "." }; const resourceBundleRef: ResourceBundleRef = { @@ -29,7 +36,14 @@ const selfTest: SelfTestCase = async (context) => { repoUrl: primary.path, ref: primary.branch, commitId: primary.commitId, - bundles: [{ name: "addon", repoUrl: addon.path, commitId: addon.commitId, subpath: "capability", targetPath: "tools" }], + bundles: [ + { name: "primary-skills", subpath: ".agents/skills", targetPath: ".agents/skills" }, + { name: "addon-tools", repoUrl: addon.path, commitId: addon.commitId, subpath: "capability", targetPath: "tools" }, + { name: "dad-dev-skill", repoUrl: addon.path, commitId: addon.commitId, subpath: "dad-dev", targetPath: ".agents/skills/dad-dev" }, + { name: "git-spec-skill", repoUrl: addon.path, commitId: addon.commitId, subpath: "git-spec", targetPath: ".agents/skills/git-spec" }, + { name: "unidesk-gh-skill", repoUrl: addon.path, commitId: addon.commitId, subpath: "unidesk-gh", targetPath: ".agents/skills/unidesk-gh" }, + ], + requiredSkills: [{ name: "source-skill" }, { name: "dad-dev" }, { name: "git-spec" }, { name: "unidesk-gh" }], }; const workspacePath = path.join(context.tmp, "primary-workspace-materialized"); const env = { AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "primary-workspace-assembly"), AGENTRUN_WORKSPACE_PATH: workspacePath, AGENTRUN_RUN_ID: "run_primary_workspace_selftest" }; @@ -40,7 +54,25 @@ const selfTest: SelfTestCase = async (context) => { await access(path.join(workspacePath, ".git")); await access(path.join(workspacePath, "AGENTS.md")); await access(path.join(workspacePath, "package.json")); - await access(path.join(workspacePath, "tools", "probe")); + await access(path.join(workspacePath, "tools", "source-tool")); + await access(path.join(workspacePath, ".agents", "skills", "source-skill", "SKILL.md")); + assert.equal(await readFile(path.join(workspacePath, "tools", "probe"), "utf8"), "#!/usr/bin/env sh\necho primary-source-probe\n"); + assert.equal(await readFile(path.join(workspacePath, ".agents", "skills", "dad-dev", "SKILL.md"), "utf8"), "# Source-owned dad-dev skill\n"); + assert.ok(first.binPath); + assert.ok(first.skillsDir); + assert.match(first.binPath, /\.git\/agentrun-managed-overlay\/tools$/u); + assert.match(first.skillsDir, /\.git\/agentrun-managed-overlay\/\.agents\/skills$/u); + await access(path.join(first.binPath, "probe")); + await access(path.join(first.skillsDir, "source-skill", "SKILL.md")); + await access(path.join(first.skillsDir, "dad-dev", "SKILL.md")); + await access(path.join(first.skillsDir, "git-spec", "SKILL.md")); + await access(path.join(first.skillsDir, "unidesk-gh", "SKILL.md")); + assert.equal(await readFile(path.join(first.binPath, "probe"), "utf8"), "#!/usr/bin/env sh\necho capability\n"); + assert.match(await readFile(path.join(first.skillsDir, "dad-dev", "SKILL.md"), "utf8"), /Managed dad-dev capability/u); + assert.match(first.initialPrompt?.text ?? "", /manifest=.*\.git\/agentrun-managed-overlay\/\.agents\/skills\/dad-dev\/SKILL\.md skillsDirEnv=AGENTRUN_SKILLS_DIRS/u); + assert.match(first.initialPrompt?.text ?? "", /manifest=.*\.git\/agentrun-managed-overlay\/\.agents\/skills\/git-spec\/SKILL\.md skillsDirEnv=AGENTRUN_SKILLS_DIRS/u); + assert.match(first.initialPrompt?.text ?? "", /manifest=.*\.git\/agentrun-managed-overlay\/\.agents\/skills\/unidesk-gh\/SKILL\.md skillsDirEnv=AGENTRUN_SKILLS_DIRS/u); + assert.equal((await execFile("git", ["status", "--porcelain=v1", "--untracked-files=all"], { cwd: workspacePath })).stdout, ""); assert.equal((await execFile("git", ["rev-parse", "HEAD"], { cwd: workspacePath })).stdout.trim(), primary.commitId); assert.equal((await execFile("git", ["branch", "--show-current"], { cwd: workspacePath })).stdout.trim(), primary.branch); assert.equal((await execFile("git", ["remote", "get-url", "origin"], { cwd: workspacePath })).stdout.trim(), primary.path); @@ -51,17 +83,37 @@ const selfTest: SelfTestCase = async (context) => { assert.equal(firstPrimary.workspaceLifecycle, "first-materialization"); assert.equal(firstPrimary.resolutionAuthority, "declared-commit"); assert.equal(firstPrimary.writable, true); + const managedOverlay = first.event.managedOverlay as JsonRecord; + assert.equal(managedOverlay.kind, "git-metadata"); + assert.equal(managedOverlay.rootRelativeToWorkspace, ".git/agentrun-managed-overlay"); + assert.equal(managedOverlay.sourceViewIsolated, true); + const bundleItems = ((first.event.bundles as JsonRecord).items as JsonRecord[]); + assert.deepEqual(bundleItems.map((item) => item.logicalTargetPath), [".agents/skills", "tools", ".agents/skills/dad-dev", ".agents/skills/git-spec", ".agents/skills/unidesk-gh"]); + assert.equal(bundleItems.every((item) => item.storageKind === "git-metadata-overlay"), true); + assert.deepEqual(((first.event.requiredSkills as JsonRecord).names), ["source-skill", "dad-dev", "git-spec", "unidesk-gh"]); const provenancePath = path.join(workspacePath, ".git", "agentrun-primary-workspace.json"); const persistedProvenance = JSON.parse(await readFile(provenancePath, "utf8")) as JsonRecord; assert.equal(persistedProvenance.resolutionAuthority, "declared-commit"); assert.equal(((persistedProvenance.resolvedSource as JsonRecord).commitId), primary.commitId); await writeFile(path.join(workspacePath, "dirty-from-previous-attempt.txt"), "preserve me\n", "utf8"); + await writeFile(path.join(workspacePath, "tools", "source-tool"), "#!/usr/bin/env sh\necho source-modified\n", "utf8"); + const dirtyStatus = (await execFile("git", ["status", "--porcelain=v1", "--untracked-files=all"], { cwd: workspacePath })).stdout; + assert.match(dirtyStatus, / M tools\/source-tool/u); + assert.match(dirtyStatus, /\?\? dirty-from-previous-attempt\.txt/u); const replay = await materializeResourceBundle(resourceBundleRef, workspaceRef, env); assert.ok(replay); assert.equal(await readFile(path.join(workspacePath, "dirty-from-previous-attempt.txt"), "utf8"), "preserve me\n"); + assert.equal(await readFile(path.join(workspacePath, "tools", "source-tool"), "utf8"), "#!/usr/bin/env sh\necho source-modified\n"); + assert.equal(replay.binPath, first.binPath); + assert.equal(replay.skillsDir, first.skillsDir); + await access(path.join(replay.binPath!, "probe")); + await access(path.join(replay.skillsDir!, "dad-dev", "SKILL.md")); assert.equal((replay.event.primaryWorkspace as JsonRecord).workspaceLifecycle, "continuation-reuse"); assert.equal((replay.event.primaryWorkspace as JsonRecord).provenanceHash, firstPrimary.provenanceHash); + const replayStatus = (await execFile("git", ["status", "--porcelain=v1", "--untracked-files=all"], { cwd: workspacePath })).stdout; + assert.match(replayStatus, / M tools\/source-tool/u); + assert.match(replayStatus, /\?\? dirty-from-previous-attempt\.txt/u); await assert.rejects( () => materializeResourceBundle({ ...resourceBundleRef, ref: "changed-source-ref" }, workspaceRef, { ...env, AGENTRUN_RUN_ID: "run_changed_source_contract" }), @@ -103,6 +155,7 @@ const selfTest: SelfTestCase = async (context) => { const mutablePrimary = await createRepo(context, "mutable-primary-source", { "AGENTS.md": "# Frozen primary workspace\n", "package.json": "{\"name\":\"mutable-primary-workspace-selftest\"}\n", + "tools/source-tool": "#!/usr/bin/env sh\necho mutable-source-tool\n", }); const mutableBundle: ResourceBundleRef = { kind: "gitbundle", @@ -117,6 +170,8 @@ const selfTest: SelfTestCase = async (context) => { const mutableFirstPrimary = mutableFirst.event.primaryWorkspace as JsonRecord; assert.equal(mutableFirstPrimary.sourceCommit, mutablePrimary.commitId); assert.equal(mutableFirstPrimary.resolutionAuthority, "runner-first-materialization"); + assert.ok(mutableFirst.binPath); + await access(path.join(mutableFirst.binPath, "probe")); const workspaceSuccessorCommit = await commitFile(mutableWorkspacePath, "workspace-successor.txt", "workspace successor\n", "workspace successor"); await writeFile(path.join(mutableWorkspacePath, "AGENTS.md"), "# Dirty continuation state\n", "utf8"); @@ -125,6 +180,7 @@ const selfTest: SelfTestCase = async (context) => { assert.notEqual(advancedUpstreamCommit, mutablePrimary.commitId); assert.notEqual(workspaceSuccessorCommit, advancedUpstreamCommit); await rename(mutablePrimary.path, `${mutablePrimary.path}-unavailable-after-advance`); + await rename(addon.path, `${addon.path}-unavailable-after-first-materialization`); const mutableReplay = await materializeResourceBundle(mutableBundle, workspaceRef, { ...mutableEnv, AGENTRUN_RUN_ID: "run_mutable_primary_continuation" }); assert.ok(mutableReplay); @@ -205,6 +261,10 @@ const selfTest: SelfTestCase = async (context) => { "primary-workspace-exact-source-materialization", "primary-workspace-provenance-and-declared-origin", "primary-workspace-empty-root-atomic-installation", + "managed-capability-overlay-keeps-primary-source-view-clean", + "managed-capability-overlay-preserves-nested-targets", + "managed-capability-overlay-publishes-tools-and-skills", + "real-primary-source-modifications-remain-git-visible", "primary-workspace-continuation-preserves-dirty-tree", "primary-workspace-continuation-freezes-mutable-ref", "primary-workspace-continuation-does-not-fetch",