diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da89088d..11da919cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,10 @@ **`gstack-context-bill` now bills a real install.** On `~/.claude/skills` it reported "2 skills, ~134 tok" always-on, because setup installs each skill as a real directory whose `SKILL.md` and `sections/` are symlinks into the gstack checkout, and the walker only counted regular files. The tool built to answer the token-cost complaint gave a falsely tiny answer on the layout users actually have. On a fresh install it now reports 58 skills, ~6.8K tokens always-on, and 3.6 MB on disk (it was 37 KB across 2 skills). Billing the checkout itself is unchanged (66 skills, ~7.2K tokens). ### Fixed -- **`lib/context-bill.ts`**: `findSkillDirs`, `walkMd` and the foreign-file check follow symlinked files and directories. Every path the bill reports, and every file `--exact` keys on, stays the lexical path under the root you passed. A link to a directory that contains the tree being billed or the skill being walked (`..`, `/`, `$HOME`) is never followed, so a link cannot climb out of the root into its parent or your home directory, even from a symlinked skill that lives elsewhere. The markdown walk expands each real directory once and counts each real file once per skill, under the first path reached in sorted order, which is how a skill loads it: loops end, links that fan out into each other cannot multiply the walk, and a tree with more than 200,000 entries is refused with an error instead of being cut short. `--exact` walks and sends only what the bill walks (the same per-skill walks and fences, so a link no skill bills is never walked), each real file once, measures frontmatter only for the `SKILL.md` files the bill prices it for, and prices every link to it with that one measurement. It sends only files inside the tree, inside the gstack checkout that the tree's `gstack/` entry links to (a git checkout with `VERSION`, `setup` and a `SKILL.md` named `gstack`; `bin/dev-setup`'s `.claude/skills/gstack -> ` counts), or inside gstack's render folder `$GSTACK_STATE_ROOT/render` (where setup renders the `SKILL.md` files that gbrain, `--claude-model` and per-install renders link to). Each file is judged by its realpath, so a link out of one of those folders is refused too; anything else a symlink reaches (a skill's `notes -> ~/Documents`, say) is not sent, its directory is named in the notice, the files are listed, and they are estimated offline. The notice also says how many sent files come from the checkout and how many from the render folder. A bill in which `--exact` had to estimate any item (a refused file, or one that changed after it was measured) says so: `tokenSource` starts with `mixed:` and names the count, `tokenEstimateErrorPct` keeps the estimate's 40% band, and the text output says the counts are mixed instead of measured. A measurement is reused only for a file that still has the realpath, size and mtime it was measured with, so a link retargeted or a file rewritten after the measurement (during `--diff`, say) is estimated and counted as estimated instead of inheriting the old count. Billed paths are matched to their measurement one link at a time, so a second skill sharing a link chain deeper than the kernel's limit is still priced from the one measurement. Files are opened without blocking, so a file swapped for a FIFO is refused instead of hanging the run. Each file is read through the realpath its walk recorded and only while it is still that same file, so a link retargeted or a file swapped for a link mid-run is refused rather than sent. A link named `*.md` counts only when its target is a `.md` file, so `--exact` never sends a non-markdown file through a markdown-named link. `walkMd` skips a child repo checkout (the `gstack/` link beside the installed skills), as `findSkillDirs` already did, so `--exact` does not send the whole checkout. The walk and skill discovery read each directory through its realpath, so a chain of links deeper than the kernel's 40-link limit no longer drops files or skills silently. Skill discovery has the same 200,000-entry cap as the walk. A container skill skips only nested directories whose `SKILL.md` is a file, as discovery does, so a `sub/` whose `SKILL.md` is a directory is billed to the container instead of to nobody. The root container also skips a nested skill that a directory link reaches at any depth (discovery already bills it), so `refs/alias -> ../sub` no longer counts `sub`'s files a third time in TOTAL. `--exact` on a path that does not exist now stops with `No such tree` before any request, including `--diff` with one missing tree (it used to send its one-character envelope probe first, and `--diff` sent the whole first tree), and its notice counts the same list of files it then sends. An existing tree with no skills sends nothing and writes no egress receipt, a tree whose skills have no markdown file to send says the offline estimate was used, and a tree whose every billed file was refused says so. `--help` now says which `.md` files `--exact` sends and that others are named and estimated. +- **`lib/context-bill.ts`**: `findSkillDirs`, `walkMd` and the foreign-file check follow symlinked files and directories. Every path the bill reports, and every file `--exact` keys on, stays the lexical path under the root you passed. A link to a directory that contains the tree being billed or the skill being walked (`..`, `/`, `$HOME`) is never followed, so a link cannot climb out of the root into its parent or your home directory, even from a symlinked skill that lives elsewhere. The markdown walk expands each real directory once and counts each real file once per skill, under the first path reached in sorted order, which is how a skill loads it: loops end, links that fan out into each other cannot multiply the walk, and a tree with more than 200,000 entries is refused with an error instead of being cut short. `--exact` walks and sends only what the bill walks (the same per-skill walks and fences, so a link no skill bills is never walked), each real file once, measures frontmatter only for the `SKILL.md` files the bill prices it for, and prices every link to it with that one measurement. It sends only files inside the tree, inside the gstack checkout that the tree's `gstack/` entry links to (a git checkout with `VERSION`, `setup` and a `SKILL.md` named `gstack`; `bin/dev-setup`'s `.claude/skills/gstack -> ` counts), or inside gstack's render folder `$GSTACK_STATE_ROOT/render` (where setup renders the `SKILL.md` files that gbrain, `--claude-model` and per-install renders link to; trusted only when `render` is a real directory, not a link). Each file is judged by its realpath, so a link out of one of those folders is refused too; anything else a symlink reaches (a skill's `notes -> ~/Documents`, say) is not sent, its directory is named in the notice, the files are listed, and they are estimated offline. The notice also says how many sent files come from the checkout and how many from the render folder. A bill in which `--exact` had to estimate any item (a refused file, or one that changed after it was measured) says so: `tokenSource` starts with `mixed:` and names the count, `tokenEstimateErrorPct` keeps the estimate's 40% band, and the text output says the counts are mixed instead of measured. A measurement is reused only for a file that still has the realpath, inode, size and mtime it was measured with, so a link retargeted or a file rewritten after the measurement (during `--diff`, say) is estimated and counted as estimated instead of inheriting the old count. Billed paths are matched to their measurement one link at a time, so a second skill sharing a link chain deeper than the kernel's limit is still priced from the one measurement. Files are opened without blocking, so a file swapped for a FIFO is refused instead of hanging the run. An empty `.md` file and a `SKILL.md` without frontmatter are measured as zero tokens without a request, so they no longer make `count_tokens` reject the run (an empty file used to drop every measurement with a 400) or turn a fully measured bill into a mixed one. Each file is read through the realpath its walk recorded and only while it is still that same file, so a link retargeted or a file swapped for a link mid-run is refused rather than sent. A link named `*.md` counts only when its target is a `.md` file, so `--exact` never sends a non-markdown file through a markdown-named link. `walkMd` skips a child repo checkout (the `gstack/` link beside the installed skills), as `findSkillDirs` already did, so `--exact` does not send the whole checkout. The walk and skill discovery read each directory through its realpath, so a chain of links deeper than the kernel's 40-link limit no longer drops files or skills silently. Skill discovery has the same 200,000-entry cap as the walk. A container skill skips only nested directories whose `SKILL.md` is a file, as discovery does, so a `sub/` whose `SKILL.md` is a directory is billed to the container instead of to nobody. The root container also skips a nested skill that a directory link reaches at any depth (discovery already bills it), so `refs/alias -> ../sub` no longer counts `sub`'s files a third time in TOTAL. `--exact` on a path that does not exist now stops with `No such tree` before any request, including `--diff` with one missing tree (it used to send its one-character envelope probe first, and `--diff` sent the whole first tree), and its notice counts the same list of files it then sends. An existing tree with no skills sends nothing and writes no egress receipt, a tree whose skills have no markdown file to send says the offline estimate was used, and a tree whose every billed file was refused says so. `--help` now says which `.md` files `--exact` sends and that others are named and estimated. ### Tests -- `test/context-bill.test.ts`: an installed-layout fixture (a checkout outside the skills root, real skill directories with symlinked `SKILL.md` and `sections/`, and a `gstack/` link to the checkout) bills both skills with the target's exact frontmatter, `SKILL.md` and section bytes, and keeps every walked path under the root. Negative controls: a dangling `SKILL.md` link and a `SKILL.md` link to a directory are not skills. A second fixture holds a self link, a parent link that reaches a skill outside the root, a loop back into the skill and a link up to the temp root; only the one real skill and its own files are billed. Further cases cover a loop to a directory below the walk root, a root whose name starts with `..`, a symlinked `agents.md` (with a dangling link and a directory of a foreign-file name as controls), `--exact` on the installed layout (every billed file measured; nothing behind `gstack/` or a `.md`-named link to a non-markdown file is sent), a 12-level fan-out of links that reaches one file by 2^12 paths (walked once, where a walker that expands every link would return all 4,096), two skills linking one checkout file and directory (each real file sent once, both skills priced from it), a symlinked skill outside the root with a link up to the root's parent, the `--exact` notice's count of files from the gstack checkout, a skill whose `SKILL.md` another skill's walk reaches first under another name (its frontmatter still measured), a container skill whose nested skill links to the container's own files (both skills still bill them), `--exact` on a missing tree and `--diff --exact` with a missing second tree (named, with no request sent), a symlinked skill with a link up to its real parent (the files there are not billed, so not sent, and no billed file is keyed through that link), an existing tree with no skills (no request, no receipt), a skill whose `SKILL.md` links to a non-markdown file (no request, no receipt, and a line saying the estimate was used), a reference file with frontmatter (no frontmatter request for it), an install whose skill also links `notes` to a documents folder (the checkout files are sent; the documents file is refused, named, listed and estimated), a `gstack/` entry that is not a gstack checkout (a plain folder, a bare `.git` folder, another project's repo, or a gstack-shaped folder without `.git`: nothing behind it counts as the checkout), a `bin/dev-setup` tree inside the gstack repo (its skill files are sent), refused files in nested and sibling folders (each folder named once), every billed file refused (said so), a link retargeted, a file replaced by a link or a hard link, or a parent folder swapped during `--diff --exact` (never sent; a swapped file stops the run), a 50-deep chain of links (every file walked, billed and, under `--exact`, measured), a skill 45 directory links deep (discovered), skill discovery past its entry cap (refused), a `sub/` whose `SKILL.md` is a directory (billed to the container), a tree with no skills under `--exact` (no estimate line), a root container with a `refs/alias -> ../sub` link and a link to an outside folder holding a skill (neither nested skill billed to the container), a file swapped for a FIFO after the walk (refused within the subprocess timeout, where it used to hang), two skills sharing a 50-deep link chain (both priced from one measurement, nothing estimated), a `SKILL.md` link retargeted to a bigger file and a reference rewritten in place after measurement (both estimated and counted as such), a bill with a refused skill (`mixed:` in `--json` and in text), and a render install (its `SKILL.md` and sections sent from `$GSTACK_STATE_ROOT/render`, a link from there to a documents file refused and listed). The installed-layout and escape fixtures fail on main (the first finds no skills, the second bills the outside skill), the missing-tree case fails on main (one request sent), and each later case failed on the commit before its fix, except the hard-link and parent-swap cases, which pin the identity check added with the realpath reads (they fail when that check is removed). +- `test/context-bill.test.ts`: an installed-layout fixture (a checkout outside the skills root, real skill directories with symlinked `SKILL.md` and `sections/`, and a `gstack/` link to the checkout) bills both skills with the target's exact frontmatter, `SKILL.md` and section bytes, and keeps every walked path under the root. Negative controls: a dangling `SKILL.md` link and a `SKILL.md` link to a directory are not skills. A second fixture holds a self link, a parent link that reaches a skill outside the root, a loop back into the skill and a link up to the temp root; only the one real skill and its own files are billed. Further cases cover a loop to a directory below the walk root, a root whose name starts with `..`, a symlinked `agents.md` (with a dangling link and a directory of a foreign-file name as controls), `--exact` on the installed layout (every billed file measured; nothing behind `gstack/` or a `.md`-named link to a non-markdown file is sent), a 12-level fan-out of links that reaches one file by 2^12 paths (walked once, where a walker that expands every link would return all 4,096), two skills linking one checkout file and directory (each real file sent once, both skills priced from it), a symlinked skill outside the root with a link up to the root's parent, the `--exact` notice's count of files from the gstack checkout, a skill whose `SKILL.md` another skill's walk reaches first under another name (its frontmatter still measured), a container skill whose nested skill links to the container's own files (both skills still bill them), `--exact` on a missing tree and `--diff --exact` with a missing second tree (named, with no request sent), a symlinked skill with a link up to its real parent (the files there are not billed, so not sent, and no billed file is keyed through that link), an existing tree with no skills (no request, no receipt), a skill whose `SKILL.md` links to a non-markdown file (no request, no receipt, and a line saying the estimate was used), a reference file with frontmatter (no frontmatter request for it), an install whose skill also links `notes` to a documents folder (the checkout files are sent; the documents file is refused, named, listed and estimated), a `gstack/` entry that is not a gstack checkout (a plain folder, a bare `.git` folder, another project's repo, or a gstack-shaped folder without `.git`: nothing behind it counts as the checkout), a `bin/dev-setup` tree inside the gstack repo (its skill files are sent), refused files in nested and sibling folders (each folder named once), every billed file refused (said so), a link retargeted, a file replaced by a link or a hard link, or a parent folder swapped during `--diff --exact` (never sent; a swapped file stops the run), a 50-deep chain of links (every file walked, billed and, under `--exact`, measured), a skill 45 directory links deep (discovered), skill discovery past its entry cap (refused), a `sub/` whose `SKILL.md` is a directory (billed to the container), a tree with no skills under `--exact` (no estimate line), a root container with a `refs/alias -> ../sub` link and a link to an outside folder holding a skill (neither nested skill billed to the container), a file swapped for a FIFO after the walk (refused within the subprocess timeout, where it used to hang), two skills sharing a 50-deep link chain (both priced from one measurement, nothing estimated), a `SKILL.md` link retargeted to a bigger file and a reference rewritten in place after measurement (both estimated and counted as such), a bill with a refused skill (`mixed:` in `--json` and in text), a file replaced by one of the same size and mtime (estimated), a skill without frontmatter plus an empty reference under a `count_tokens` stand-in that rejects empty content (still fully measured), a render folder that is itself a link (not trusted), and a render install (its `SKILL.md` and sections sent from `$GSTACK_STATE_ROOT/render`, a link from there to a documents file refused and listed). The installed-layout and escape fixtures fail on main (the first finds no skills, the second bills the outside skill), the missing-tree case fails on main (one request sent), and each later case failed on the commit before its fix, except the hard-link and parent-swap cases, which pin the identity check added with the realpath reads (they fail when that check is removed). ### Gate integrity - No threshold, budget, timeout, trial count, case kind or assertion changed. The bill of this repo's own tree, which the context-budget ratchet reads, has the same skills, bytes and rounded token figures as on main. Four raw `totalMdTokens` values in `--json` differ in the last floating-point digit, because each skill's files are now summed in sorted order. diff --git a/lib/context-bill.ts b/lib/context-bill.ts index 648de7caf..3dc5fb8e6 100644 --- a/lib/context-bill.ts +++ b/lib/context-bill.ts @@ -18,13 +18,16 @@ * Token figures come from one of two sources, always named in the output: * ESTIMATE (default, offline) bytes / TOKEN_DIVISOR, calibrated against real * count_tokens measurements. - * EXACT (--exact, opt-in) Anthropic's count_tokens for every file the - * bill touches. Sends file content off-machine, - * so it is never implicit: an egress receipt is - * written before the POSTs (sink - * 'context-bill-exact'), and if the receipt - * cannot be written the run degrades to the - * offline estimate with a warning instead of + * EXACT (--exact, opt-in) Anthropic's count_tokens for every billed .md + * file inside the tree, the linked gstack + * checkout or $GSTACK_STATE_ROOT/render; anything + * else a symlink reaches is named and estimated, + * and the bill is labelled mixed. Sends file + * content off-machine, so it is never implicit: + * an egress receipt is written before the POSTs + * (sink 'context-bill-exact'), and if the + * receipt cannot be written the run degrades to + * the offline estimate with a warning instead of * sending unrecorded. * Both bytes and tokens are always shown, and the estimate's measured error * band is printed with it. The tool never writes state anywhere (the egress @@ -44,8 +47,10 @@ 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 or findSkillDirs call may examine before it refuses the tree. */ +/** Directory entries one walkMdFiles or findSkillDirs call may examine before it refuses the tree. */ const WALK_ENTRY_CAP = 200_000; +/** tokenSource prefix of a bill that --exact priced partly by estimate. */ +const MIXED_PREFIX = "mixed: "; function entryCapError(dir: string, cap: number): Error { return new Error( @@ -239,11 +244,6 @@ function entryKind(p: string, e: fs.Dirent): "file" | "dir" | null { } } -/** - * Whether directory `outer` contains (or is) `inner`, both realpaths. A - * followed directory symlink that contains the tree being walked (`..`, `/`, - * `$HOME`) would climb out of that tree, so walkers refuse it. - */ /** * The realpath of `p`, resolved one component at a time the way the walk * reads directories, so a lexical path that crosses more links than the @@ -259,12 +259,17 @@ function realpathStepwise(p: string): string { return real; } +/** + * Whether directory `outer` contains (or is) `inner`, both realpaths. A + * followed directory symlink that contains the tree being walked (`..`, `/`, + * `$HOME`) would climb out of that tree, so walkers refuse it. + */ function encloses(outer: string, inner: string): boolean { const rel = path.relative(outer, inner); return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)); } -/** walkMdFiles(dir)'s lexical paths, with no billedRoot fence or nested-skill skip. */ +/** walkMdFiles(dir)'s lexical paths, with no billedRoot fence or nested-skill skip. Only tests call it; the bill and --exact use walkMdFiles. */ export function walkMd(dir: string): string[] { return walkMdFiles(dir).map((f) => f.path); } @@ -591,7 +596,7 @@ export function buildBill( tokenSource: !tokenSource ? "estimate: calibrated bytes/token per content class" : estimated - ? `mixed: ${tokenSource}, with ${estimated} item(s) estimated offline` + ? `${MIXED_PREFIX}${tokenSource}, with ${estimated} item(s) estimated offline` : tokenSource, tokenEstimate: TOKEN_DIVISORS, tokenEstimateErrorPct: tokenSource && !estimated ? 0 : TOKEN_ESTIMATE_ERROR_PCT, @@ -777,9 +782,9 @@ export function tokenDisclaimer(bill: Pick; } +/** A git checkout with VERSION, setup and a SKILL.md named gstack. */ +function isGstackCheckout(dir: string): boolean { + if (!fs.existsSync(path.join(dir, ".git"))) return false; + if (!["VERSION", "setup", "SKILL.md"].every((name) => isFile(path.join(dir, name)))) return false; + const { block } = parseFrontmatter(fs.readFileSync(path.join(dir, "SKILL.md"), "utf8")); + return /^name:\s*gstack\s*$/m.test(block); +} + /** * What --exact sends: the same per-skill walks totalMd bills, with the same * fences, so nothing is walked or sent that no skill bills. Of those, only @@ -891,13 +904,6 @@ export interface ExactSendSet { * relative root here would produce keys that never match and every lookup * would fall back to the estimate. */ -function isGstackCheckout(dir: string): boolean { - if (!fs.existsSync(path.join(dir, ".git"))) return false; - if (!["VERSION", "setup", "SKILL.md"].every((name) => isFile(path.join(dir, name)))) return false; - const { block } = parseFrontmatter(fs.readFileSync(path.join(dir, "SKILL.md"), "utf8")); - return /^name:\s*gstack\s*$/m.test(block); -} - export function exactSendSet(root: string, stateRoot: string = resolveStateRoot()): ExactSendSet { const resolved = path.resolve(root); if (!fs.existsSync(resolved)) throw new Error(`No such tree: ${resolved}`); @@ -908,10 +914,8 @@ export function exactSendSet(root: string, stateRoot: string = resolveStateRoot( const rootReal = fs.realpathSync(resolved); const gstackEntry = path.join(resolved, "gstack"); const checkout = isGstackCheckout(gstackEntry) ? fs.realpathSync(gstackEntry) : undefined; - let render: string | undefined; - try { - render = fs.realpathSync(path.join(stateRoot, "render")); - } catch {} + const renderDir = path.join(stateRoot, "render"); + const render = fs.lstatSync(renderDir, { throwIfNoEntry: false })?.isDirectory() ? fs.realpathSync(renderDir) : undefined; const approved = [rootReal, checkout, render].filter((d): d is string => d !== undefined); for (const dir of findSkillDirs(resolved)) { frontmatter.add(fs.realpathSync(path.join(dir, "SKILL.md"))); @@ -962,8 +966,8 @@ function readWalkedFile({ path: file, real, dev, ino, size, mtimeMs }: WalkedFil } } -function fileIdentity(real: string, { size, mtimeMs }: { size: number; mtimeMs: number }): string { - return `${real}\0${size}\0${mtimeMs}`; +function fileIdentity(real: string, { dev, ino, size, mtimeMs }: { dev: number; ino: number; size: number; mtimeMs: number }): string { + return `${real}\0${dev}\0${ino}\0${size}\0${mtimeMs}`; } export interface ExactMeasurement { @@ -978,8 +982,9 @@ export interface ExactMeasurement { } /** - * Measures every text in the send set: what the bill prices inside the root - * or the linked gstack checkout. Returns a `tokensOf` lookup. + * Measures every text in the send set: what the bill prices inside the root, + * the linked gstack checkout or gstack's render folder. Returns a `tokensOf` + * lookup. * * count_tokens prices a whole request, so it includes a fixed message envelope. * That envelope is measured once and subtracted, leaving the tokens each file's @@ -1011,22 +1016,27 @@ export async function measureExactTokens( const opts: CountTokensOptions = { model, apiKey, fetchImpl }; const texts = new Map(); + // Empty texts are not sent (count_tokens rejects empty content); they are + // measured as zero tokens. + const counts = new Map(); // The send set holds each real file once, so each is sent once. The bill keys // the same file by every lexical path it is billed under; measuredAs maps a - // real file, at the size and mtime it was walked with, back to the one key - // that was measured. + // real file, at the inode, size and mtime it was walked with, back to the + // one key that was measured. const measuredAs = new Map(); const realOf = new Map(); const { files, frontmatter } = sendSet ?? exactSendSet(root); for (const walked of files) { const { path: file, real } = walked; const text = readWalkedFile(walked, root); - texts.set(file, text); measuredAs.set(fileIdentity(real, walked), file); realOf.set(file, real); + if (text) texts.set(file, text); + else counts.set(file, 0); if (!frontmatter.has(real)) continue; const fm = parseFrontmatter(text); if (fm.block) texts.set(frontmatterKey(file), fm.block); + else counts.set(frontmatterKey(file), 0); } // Receipt-before-send. Content-free: file count + total bytes only. @@ -1052,7 +1062,6 @@ export async function measureExactTokens( // One-char body: subtracting its single content token leaves the envelope. const envelope = (await countTokens("x", opts)) - 1; - const counts = new Map(); const keys = [...texts.keys()]; let next = 0; let done = 0; @@ -1067,7 +1076,7 @@ export async function measureExactTokens( await Promise.all(Array.from({ length: Math.min(EXACT_CONCURRENCY, keys.length) }, worker)); // A key the send set does not hold (a non-.md foreign-host file, or a file - // refused as outside the root and the checkout) falls back to the + // refused as outside the root, the checkout and the render folder) falls back to the // estimate rather than billing zero. So does a key whose file is no longer // the one measured (a link retargeted, or the file rewritten, since the walk). // Misses are counted, not swallowed: a bill that is part-measured and diff --git a/test/context-bill.test.ts b/test/context-bill.test.ts index 41ea3e2d2..728e4893c 100644 --- a/test/context-bill.test.ts +++ b/test/context-bill.test.ts @@ -701,10 +701,15 @@ describe("--budget", () => { describe("--exact (opt-in measurement; offline here via an injected fetch)", () => { let egressHome: string; + let savedStateRoot: string | undefined; beforeAll(() => { egressHome = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-egress-")); + savedStateRoot = process.env.GSTACK_STATE_ROOT; + process.env.GSTACK_STATE_ROOT = path.join(egressHome, "state"); }); afterAll(() => { + if (savedStateRoot === undefined) delete process.env.GSTACK_STATE_ROOT; + else process.env.GSTACK_STATE_ROOT = savedStateRoot; try { fs.chmodSync(path.join(egressHome, "security"), 0o700); } catch {} fs.rmSync(egressHome, { recursive: true, force: true }); }); @@ -1218,6 +1223,45 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () expect(bill.skills[0].totalMdTokens).toBeGreaterThan(12_000); }); + it("never reuses a measurement for a file replaced by one with the same size and mtime", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-same-stat-")); + fs.mkdirSync(path.join(tmp, "qa")); + fs.writeFileSync(path.join(tmp, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + const ref = path.join(tmp, "qa", "ref.md"); + fs.writeFileSync(ref, "a".repeat(3_000)); + const mtime = new Date("2026-01-01T00:00:00Z"); + fs.utimesSync(ref, mtime, mtime); + const measured = await measureExactTokens(tmp, { model: "m", apiKey: "sk-test", fetchImpl: fakeFetch(), egressHome }); + const replacement = path.join(tmp, "qa", ".replacement"); + fs.writeFileSync(replacement, "b".repeat(3_000)); + fs.utimesSync(replacement, mtime, mtime); + fs.renameSync(replacement, ref); + buildBill(tmp, { tokensOf: measured.tokensOf, tokenSource: measured.tokenSource, missedKeys: measured.missedKeys }); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(measured.missedKeys.has(ref)).toBe(true); + }); + + it("keeps a bill measured when a skill has no frontmatter or a reference is empty", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-zero-")); + fs.mkdirSync(path.join(tmp, "alpha")); + fs.writeFileSync(path.join(tmp, "alpha", "SKILL.md"), "---\nname: alpha\ndescription: d\n---\n# alpha\n"); + fs.writeFileSync(path.join(tmp, "alpha", "empty.md"), ""); + fs.mkdirSync(path.join(tmp, "beta")); + fs.writeFileSync(path.join(tmp, "beta", "SKILL.md"), "# beta has no frontmatter\n"); + const inner = fakeFetch() as unknown as (u: string, i: { body: string }) => Promise; + const strict = (async (u: string, i: { body: string }) => + JSON.parse(i.body).messages[0].content === "" + ? { ok: false, status: 400, text: async () => "messages.0: user messages must have non-empty content" } + : inner(u, i)) as never; + const measured = await measureExactTokens(tmp, { model: "m", apiKey: "sk-test", fetchImpl: strict, egressHome }); + const bill = buildBill(tmp, { tokensOf: measured.tokensOf, tokenSource: measured.tokenSource, missedKeys: measured.missedKeys }); + fs.rmSync(tmp, { recursive: true, force: true }); + expect([...measured.missedKeys]).toEqual([]); + expect(bill.tokenEstimateErrorPct).toBe(0); + expect(bill.tokenSource).toBe("count_tokens (m)"); + }); + it("refuses a walked file swapped for a FIFO instead of hanging", () => { if (process.platform === "win32") return; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-fifo-")); @@ -1233,9 +1277,10 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () `import { spawnSync } from "node:child_process";`, `import { exactSendSet, measureExactTokens } from ${JSON.stringify(path.join(ROOT, "lib", "context-bill.ts"))};`, `const root = ${JSON.stringify(skills)};`, - `const sendSet = exactSendSet(root);`, + `const sendSet = exactSendSet(root, ${JSON.stringify(path.join(tmp, "state"))});`, `fs.unlinkSync(${JSON.stringify(path.join(skills, "qa", "a.md"))});`, `spawnSync("mkfifo", [${JSON.stringify(path.join(skills, "qa", "a.md"))}], { timeout: 5_000 });`, + `if (!fs.lstatSync(${JSON.stringify(path.join(skills, "qa", "a.md"))}).isFIFO()) { console.log("NOFIFO"); process.exit(0); }`, `const fetchImpl = (async () => ({ ok: true, json: async () => ({ input_tokens: 9 }) })) as never;`, `try { await measureExactTokens(root, { sendSet, model: "m", apiKey: "sk-test", fetchImpl, egressHome: ${JSON.stringify(tmp)} }); console.log("SENT"); }`, `catch (error) { console.log("REFUSED " + (error as Error).message); }`, @@ -1244,6 +1289,7 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () const result = Bun.spawnSync([process.execPath, ...bunIsolationArgs(), script], { timeout: 15_000 }); fs.rmSync(tmp, { recursive: true, force: true }); expect(result.exitCode).toBe(0); + expect(result.stdout.toString()).not.toContain("NOFIFO"); expect(result.stdout.toString()).toContain("REFUSED"); expect(result.stdout.toString()).toContain("changed after it was walked"); }); @@ -1377,6 +1423,35 @@ describe("--exact (opt-in measurement; offline here via an injected fetch)", () expect(JSON.parse(stdout.text()).tokenSource).toStartWith("mixed: "); }); + it("does not trust a render folder that is itself a link", async () => { + if (process.platform === "win32") return; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-render-link-")); + const elsewhere = path.join(tmp, "elsewhere"); + fs.mkdirSync(elsewhere); + fs.writeFileSync(path.join(elsewhere, "notes.md"), "PRIVATE-NOTES"); + const stateRoot = path.join(tmp, "state"); + fs.mkdirSync(stateRoot); + fs.symlinkSync(elsewhere, path.join(stateRoot, "render")); + const skills = path.join(tmp, "skills"); + fs.mkdirSync(path.join(skills, "qa"), { recursive: true }); + fs.writeFileSync(path.join(skills, "qa", "SKILL.md"), "---\nname: qa\ndescription: d\n---\n# qa\n"); + fs.symlinkSync(path.join(elsewhere, "notes.md"), path.join(skills, "qa", "notes.md")); + const calls: { body: string }[] = []; + const stderr = capture(); + const code = await contextBillMain([skills, "--exact", "--json"], { + stdout: capture().stream, + stderr: stderr.stream, + apiKey: "sk-test", + fetchImpl: fakeFetch(calls), + egressHome, + stateRoot, + }); + fs.rmSync(tmp, { recursive: true, force: true }); + expect(code).toBe(0); + expect(calls.some((c) => c.body.includes("PRIVATE-NOTES"))).toBe(false); + expect(stderr.text()).toContain("not sending 1 .md file(s)"); + }); + it("names each outside directory once, collapsing nested ones, and lists every refused file", async () => { if (process.platform === "win32") return; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "context-bill-egress-dirs-"));