diff --git a/actions/setup/js/runtime_import.cjs b/actions/setup/js/runtime_import.cjs index 29adeea26f1..948c0535649 100644 --- a/actions/setup/js/runtime_import.cjs +++ b/actions/setup/js/runtime_import.cjs @@ -746,6 +746,17 @@ async function processRuntimeImport(filepathOrUrl, optional, workspaceDir, start let filepath = filepathOrUrl; let isAgentsPath = false; + // Strip leading "/" or "//" (and any number of slashes) for repo-root-absolute paths + // (e.g. /.agents/skills/..., //.github/agents/...). + // After stripping, the existing .agents/ and .github/ prefix checks handle resolution correctly. + // Only strip when the result begins with .agents/ or .github/ to preserve security restrictions. + if (filepath.startsWith("/")) { + const stripped = filepath.replace(/^\/+/, ""); + if (stripped.startsWith(".agents/") || stripped.startsWith(".agents\\") || stripped.startsWith(".github/") || stripped.startsWith(".github\\")) { + filepath = stripped; + } + } + // Check if this is a .agents/ path (top-level folder for skills) if (filepath.startsWith(".agents/")) { isAgentsPath = true; diff --git a/actions/setup/js/runtime_import.test.cjs b/actions/setup/js/runtime_import.test.cjs index a83110f92ae..2c6c83f7feb 100644 --- a/actions/setup/js/runtime_import.test.cjs +++ b/actions/setup/js/runtime_import.test.cjs @@ -466,6 +466,54 @@ describe("runtime_import", () => { const result = await processRuntimeImport(".agents/test-skill.md", !1, tempDir); expect(result).toBe(content); }), + it("should support /.agents/ prefix (leading slash, repo-root-absolute)", async () => { + // Regression test: /.agents/skills/my-skill/instructions.md should resolve to + // /.agents/skills/my-skill/instructions.md (not .github/workflows/.agents/...) + const skillsDir = path.join(tempDir, ".agents", "skills", "my-skill"); + fs.mkdirSync(skillsDir, { recursive: true }); + const content = "# My Skill\n\nThis is the skill content."; + fs.writeFileSync(path.join(skillsDir, "instructions.md"), content); + const result = await processRuntimeImport("/.agents/skills/my-skill/instructions.md", !1, tempDir); + expect(result).toBe(content); + }), + it("should support //.agents/ prefix (double leading slash, repo-root-absolute)", async () => { + // Regression test: //.agents/skills/my-skill/instructions.md (double slash) should also + // resolve to /.agents/skills/my-skill/instructions.md + const skillsDir = path.join(tempDir, ".agents", "skills", "my-skill"); + fs.mkdirSync(skillsDir, { recursive: true }); + const content = "# My Skill (double slash)\n\nThis is the skill content."; + fs.writeFileSync(path.join(skillsDir, "instructions.md"), content); + const result = await processRuntimeImport("//.agents/skills/my-skill/instructions.md", !1, tempDir); + expect(result).toBe(content); + }), + it("should support /.github/ prefix (leading slash, repo-root-absolute)", async () => { + // Regression test: /.github/agents/planner.md should resolve to + // /.github/agents/planner.md (not .github/workflows/.github/agents/...) + const agentsDir = path.join(tempDir, ".github", "agents"); + fs.mkdirSync(agentsDir, { recursive: true }); + const content = "# Planner Agent\n\nThis is the planner content."; + fs.writeFileSync(path.join(agentsDir, "planner.md"), content); + const result = await processRuntimeImport("/.github/agents/planner.md", !1, tempDir); + expect(result).toBe(content); + }), + it("should support //.github/ prefix (double leading slash, repo-root-absolute)", async () => { + // Regression test: //.github/agents/planner.md (double slash) should also resolve to + // /.github/agents/planner.md + const agentsDir = path.join(tempDir, ".github", "agents"); + fs.mkdirSync(agentsDir, { recursive: true }); + const content = "# Planner Agent (double slash)\n\nThis is the planner content."; + fs.writeFileSync(path.join(agentsDir, "planner.md"), content); + const result = await processRuntimeImport("//.github/agents/planner.md", !1, tempDir); + expect(result).toBe(content); + }), + it("should reject /-prefixed paths not under .agents/ or .github/", async () => { + // A leading "/" that does not map to .agents/ or .github/ should NOT be stripped. + // It falls through to the default branch, and path joining preserves the absolute path + // (/etc/passwd on POSIX), which is then rejected by the .github base-folder security check. + // Assert the security rejection rather than a file-not-found error so the test remains + // stable across platforms. + await expect(processRuntimeImport("/etc/passwd", !1, tempDir)).rejects.toThrow("Security: Path"); + }), it("should support nested .github/workflows/shared/ path (issue: runtime-import fails for .github/workflows/* paths)", async () => { // Regression test: .github/workflows/shared/reporting.md should resolve to // /.github/workflows/shared/reporting.md (not /workflows/shared/reporting.md)