diff --git a/lib/context-bill.ts b/lib/context-bill.ts index 4af12976c..3ba14b1cc 100644 --- a/lib/context-bill.ts +++ b/lib/context-bill.ts @@ -815,9 +815,15 @@ async function countTokens(text: string, { model, apiKey, fetchImpl }: CountToke return json.input_tokens; } +export interface ExactFile extends WalkedFile { + /** The walked file's identity; --exact reads only that same file. */ + dev: number; + ino: number; +} + export interface ExactSendSet { /** Each real file once, under the first lexical path a skill's walk reaches it by. */ - files: WalkedFile[]; + files: ExactFile[]; /** Realpaths of the SKILL.md files whose frontmatter the bill prices. */ frontmatter: Set; } @@ -833,7 +839,7 @@ export interface ExactSendSet { export function exactSendSet(root: string): ExactSendSet { const resolved = path.resolve(root); if (!fs.existsSync(resolved)) throw new Error(`No such tree: ${resolved}`); - const files: WalkedFile[] = []; + const files: ExactFile[] = []; const frontmatter = new Set(); const seen = new Set(); for (const dir of findSkillDirs(resolved)) { @@ -841,12 +847,36 @@ export function exactSendSet(root: string): ExactSendSet { for (const file of walkMdFiles(dir, { billedRoot: resolved, skipNestedSkills: true })) { if (seen.has(file.real)) continue; seen.add(file.real); - files.push(file); + const { dev, ino } = fs.statSync(file.real); + files.push({ ...file, dev, ino }); } } return { files, frontmatter }; } +/** + * Reads the exact file the walk approved: its realpath, opened without + * following a final link, and only if it is still the same regular file. A + * link retargeted or a file swapped for a link after the walk is refused, so + * nothing the bill did not walk can be sent. + */ +function readWalkedFile(real: string, dev: number, ino: number, file: string, root: string): string { + const changed = () => new Error(`${file} changed after it was walked; nothing from ${root} was sent. Run again.`); + let fd: number; + try { + fd = fs.openSync(real, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + } catch { + throw changed(); + } + try { + const st = fs.fstatSync(fd); + if (!st.isFile() || st.dev !== dev || st.ino !== ino) throw changed(); + return fs.readFileSync(fd, "utf8"); + } finally { + fs.closeSync(fd); + } +} + export interface ExactMeasurement { tokenSource: string; tokensOf: TokensOf; @@ -894,8 +924,8 @@ export async function measureExactTokens( // real file back to the one key that was measured. const lexicalOf = new Map(); const { files, frontmatter } = sendSet ?? exactSendSet(root); - for (const { path: file, real } of files) { - const text = fs.readFileSync(real, "utf8"); + for (const { path: file, real, dev, ino } of files) { + const text = readWalkedFile(real, dev, ino, file, root); texts.set(file, text); lexicalOf.set(real, file); if (!frontmatter.has(real)) continue; diff --git a/test/context-bill.test.ts b/test/context-bill.test.ts index 0dce15afa..3e6f67241 100644 --- a/test/context-bill.test.ts +++ b/test/context-bill.test.ts @@ -1059,6 +1059,57 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () expect(calls.length).toBe(4); }); + it("sends only the files the walk approved, even when links or files change during --diff", async () => { + if (process.platform === "win32") return; + const run = async (swap: (store: string, qa: string) => void) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-swap-")); + for (const tree of ["a", "b"]) { + fs.mkdirSync(path.join(tmp, tree, "qa"), { recursive: true }); + fs.writeFileSync(path.join(tmp, tree, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + } + const store = path.join(tmp, "store"); + fs.mkdirSync(store); + fs.writeFileSync(path.join(store, "safe.md"), "safe"); + fs.writeFileSync(path.join(store, "secret.env"), "SYNTHETIC_PRIVATE_NONMARKDOWN"); + const qa = path.join(tmp, "b", "qa"); + fs.symlinkSync(path.join(store, "safe.md"), path.join(qa, "ref.md")); + const calls: { body: string }[] = []; + const inner = fakeFetch(calls) as unknown as (u: string, i: { body: string }) => Promise; + let swapped = false; + const fetchImpl = (async (u: string, i: { body: string }) => { + if (!swapped) { + swapped = true; + swap(store, qa); + } + return inner(u, i); + }) as never; + const err = capture(); + const code = await contextBillMain(["--diff", path.join(tmp, "a"), path.join(tmp, "b"), "--exact", "--json"], { + stdout: capture().stream, + stderr: err.stream, + apiKey: "sk-test", + fetchImpl, + egressHome, + }); + fs.rmSync(tmp, { recursive: true, force: true }); + return { code, calls, err: err.text() }; + }; + + const retargeted = await run((store, qa) => { + fs.unlinkSync(path.join(qa, "ref.md")); + fs.symlinkSync(path.join(store, "secret.env"), path.join(qa, "ref.md")); + }); + expect(retargeted.calls.some((c) => c.body.includes("SYNTHETIC_PRIVATE"))).toBe(false); + + const replaced = await run((store) => { + fs.unlinkSync(path.join(store, "safe.md")); + fs.symlinkSync(path.join(store, "secret.env"), path.join(store, "safe.md")); + }); + expect(replaced.calls.some((c) => c.body.includes("SYNTHETIC_PRIVATE"))).toBe(false); + expect(replaced.code).toBe(1); + expect(replaced.err).toContain("changed after it was walked"); + }); + it("names a missing tree, without a raw ENOENT or any request", async () => { const parent = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-missing-")); const out = capture();