From 77fe211776fe0066d3aff44ca06babd012cb5df3 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 02:26:26 +0000 Subject: [PATCH] fix(context-bill): skill discovery refuses a tree past the entry cap too Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/context-bill.ts | 20 ++++++++++++-------- test/context-bill.test.ts | 9 +++++++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/lib/context-bill.ts b/lib/context-bill.ts index 342b95e39..d2040d210 100644 --- a/lib/context-bill.ts +++ b/lib/context-bill.ts @@ -43,9 +43,16 @@ const PROSE_REF = /`(references\/[^`<>]+\.md)`/g; const ROUTER_KEYS = new Set(["name", "description", "version", "allowed-tools", "triggers", "preamble-tier"]); // Skill-shaped files other hosts drop into scanner scope. const FOREIGN_SKILL_FILE = /^(skill\.(ya?ml|json)|agents?\.md|\.cursorrules|\.windsurfrules)$/i; -/** Directory entries one walkMd call may examine before it refuses the tree. */ +/** Directory entries one walkMd or findSkillDirs call may examine before it refuses the tree. */ const WALK_ENTRY_CAP = 200_000; +function entryCapError(dir: string, cap: number): Error { + return new Error( + `${dir} has more than ${cap} entries to walk; a symlink in it probably points at a large tree. ` + + `Nothing was billed or sent. Remove or narrow that link and run again.`, + ); +} + /** * Bytes per token, per content class, fitted to real count_tokens measurements. * @@ -283,12 +290,7 @@ export function walkMd(dir: string, opts: { billedRoot?: string; skipNestedSkill } entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); for (const e of entries) { - if (++entryCount > WALK_ENTRY_CAP) { - throw new Error( - `${dir} has more than ${WALK_ENTRY_CAP} entries to walk; a symlink in it probably points at a large tree. ` + - `Nothing was billed or sent. Remove or narrow that link and run again.`, - ); - } + if (++entryCount > WALK_ENTRY_CAP) throw entryCapError(dir, WALK_ENTRY_CAP); if (e.name.startsWith(".") || e.name === "node_modules") continue; const p = path.join(current, e.name); const kind = entryKind(p, e); @@ -441,10 +443,11 @@ export function parseSkill( * A realpath seen-set breaks cycles, a link to a directory that encloses the * root is never followed, and every returned path stays under the root. */ -export function findSkillDirs(root: string): string[] { +export function findSkillDirs(root: string, entryCap: number = WALK_ENTRY_CAP): string[] { const out: string[] = []; const visited = new Set(); const rootDir = path.resolve(root); + let entryCount = 0; let rootReal: string; try { rootReal = fs.realpathSync(rootDir); @@ -474,6 +477,7 @@ export function findSkillDirs(root: string): string[] { if (visited.has(real)) return; visited.add(real); for (const e of entries) { + if (++entryCount > entryCap) throw entryCapError(rootDir, entryCap); if (e.name.startsWith(".") || e.name === "node_modules") continue; const child = path.join(dir, e.name); if (entryKind(child, e) !== "dir") continue; diff --git a/test/context-bill.test.ts b/test/context-bill.test.ts index 304d0efae..5654903f5 100644 --- a/test/context-bill.test.ts +++ b/test/context-bill.test.ts @@ -391,6 +391,15 @@ describe("upstream fix b: installed-tree layout (repo-checkout subdir skip)", () fs.rmSync(root, { recursive: true, force: true }); }); + it("refuses skill discovery past its entry cap instead of walking on", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-discovery-cap-")); + fs.mkdirSync(path.join(root, "vendor")); + for (let i = 0; i < 10; i++) fs.mkdirSync(path.join(root, "vendor", `d${i}`)); + expect(() => findSkillDirs(root, 5)).toThrow(/more than 5 entries to walk/); + expect(findSkillDirs(root)).toEqual([]); + fs.rmSync(root, { recursive: true, force: true }); + }); + it("bills a subdirectory whose SKILL.md is a directory to its container, as discovery does not count it as a skill", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-skilldir-")); fs.writeFileSync(path.join(root, "SKILL.md"), "---\nname: c\ndescription: d\n---\n# c\n");