From ab82cbb9f949b58a64d28fbb00b68165152b27d1 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 01:50:01 +0300 Subject: [PATCH] fix(session-render): refuse a managed output the reader would refuse 0.4.23 raised the READ bound on managed session-render outputs to SESSION_MANAGED_OUTPUT_MAX_BYTES and left the WRITE unbounded, so the two agreed only by headroom. Measured on origin/main @ 6091ba6: a render emitted an 8,389,869-byte AGENTS.md at rc=0 with zero conflicts, and every subsequent planSessionRender on that home threw PROJECT_CONTEXT_INPUT_TOO_LARGE from observeProjectContextSessionGuard -- including the render that would have shrunk it back under the bound. Once written, the home is unrecoverable through this tool, because planning reads the oversized file before it can decide to replace it. Refuse at plan time, against the same constant the reader uses. planSessionRender writes nothing, so the previous home survives intact and merely stale, and `session plan` / `--dry-run` predict the failure instead of discovering it. Refusing at the write instead would land mid-loop over per-file atomic replacements and leave the home half-new, half-old. Not truncation: a silently shortened instruction home looks complete to every agent that reads it and is missing directives. A headroom warning at half the bound is what keeps the refusal a backstop rather than the thing that discovers the problem. Measured on station01 2026-08-08 the real corpus renders to 298,164 bytes, 28.1x under the bound, with no warning. Also corrects the comment on SESSION_MANAGED_OUTPUT_PATHS, which claimed to be "every path in projectContextSessionGuardPaths()". It is not: the guard also hashes the project-context manifest, cache and fragment, which deliberately stay at FOREIGN_INPUT_MAX_BYTES. A maintainer adding a guard path and following that comment would promote a foreign-content path to the 8 MiB bound. Regression is two-sided and demonstrated, not asserted: with the refusal call removed and nothing else changed, 3 of 5 arms fail ("DID NOT THROW") while both negative controls -- an ordinary render, and the headroom arm that must still apply -- keep passing. Agent: Silvanus --- src/lib/project-context.ts | 44 +++++- ...ession-render-managed-output-bound.test.ts | 126 ++++++++++++++++++ src/lib/session-render.ts | 66 +++++++++ 3 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 src/lib/session-render-managed-output-bound.test.ts diff --git a/src/lib/project-context.ts b/src/lib/project-context.ts index 5b9a882..71c888a 100644 --- a/src/lib/project-context.ts +++ b/src/lib/project-context.ts @@ -45,8 +45,19 @@ export const FOREIGN_INPUT_MAX_BYTES = 256 * 1024; // reader can assume. A managed output that the writer can emit and the reader refuses // wedges the home permanently and silently, so these two bounds must not disagree. export const SESSION_MANAGED_OUTPUT_MAX_BYTES = SESSION_COMPATIBILITY_MANIFEST_MAX_BYTES; -// Every path in projectContextSessionGuardPaths(), expressed workspace-relative. Keep in -// step with runtimePaths(); the guard hashes each of these to detect concurrent writes. +// The SESSION-RENDER outputs among projectContextSessionGuardPaths(), expressed +// workspace-relative — the provider entrypoints and the render manifests, and NOTHING ELSE. +// +// This is deliberately NARROWER than projectContextSessionGuardPaths(), which also hashes +// the project-context manifest, cache and fragment. Those three stay at FOREIGN_INPUT_MAX_BYTES +// on purpose: their size is bounded at the write by PROJECT_CONTEXT_MAX_INPUT_BYTES and +// scanGeneratedContent(), so they can never legitimately grow past it, and raising their read +// bound would buy nothing while widening an untrusted read. +// +// The comment here previously read "Every path in projectContextSessionGuardPaths()", which is +// FALSE and recruits the wrong change: a maintainer adding a guard path follows it, adds that +// path here, and silently promotes a foreign-content file to the 8 MiB bound. Add a path here +// only when the RENDERER AUTHORS IT and the writer is bounded at the same constant below. export const SESSION_MANAGED_OUTPUT_PATHS = [ ".hasna/session-render-manifest.json", ".codewith/.hasna/session-render-manifest.json", @@ -56,11 +67,40 @@ export const SESSION_MANAGED_OUTPUT_PATHS = [ ".codewith/CODEWITH.override.md", ]; +/** + * Headroom threshold. A managed output past this is still written and still read, but the + * render says so — which is the whole answer to "a bound that refuses eventually stops every + * render unless someone is watching". The refusal below is the backstop; THIS is what buys + * the runway to raise the bound before the backstop ever fires. + * + * Half is chosen because the corpus grows by whole instruction sources: measured on station01 + * 2026-08-08 the largest single rendered source is 26,564 bytes against a 291,867-byte codex + * home, so no single addition can cross from "silent" to "refused" — the warning is guaranteed + * to fire on at least one render first, and in practice on hundreds. + */ +export const SESSION_MANAGED_OUTPUT_WARN_BYTES = Math.floor(SESSION_MANAGED_OUTPUT_MAX_BYTES / 2); + export function managedObservationMaxBytes(relativePath: string): number { return SESSION_MANAGED_OUTPUT_PATHS.includes(relativePath) ? SESSION_MANAGED_OUTPUT_MAX_BYTES : FOREIGN_INPUT_MAX_BYTES; } + +/** + * Whether a render-plan path names one of the managed outputs above. + * + * SESSION_MANAGED_OUTPUT_PATHS is WORKSPACE-relative; a render plan's `relativePath` is + * TARGET-HOME-relative, and for codewith those two bases differ by one segment — the plan + * says `CODEWITH.md` where the allowlist says `.codewith/CODEWITH.md`. Comparing them + * directly therefore matches for claude and codex and MISSES for codewith, which would leave + * exactly one tool unguarded while the tests for the other two pass. Match on either base. + */ +export function isSessionManagedOutputRelativePath(relativePath: string): boolean { + const normalized = relativePath.replaceAll("\\", "/"); + return SESSION_MANAGED_OUTPUT_PATHS.some((managed) => + managed === normalized || managed.endsWith(`/${normalized}`) + ); +} const PROJECT_CONTEXT_LOCK_STALE_MS = 5 * 60 * 1_000; export const LEGACY_CONFIGS_PACKAGE = "@hasna/configs" as const; export const LEGACY_CONFIGS_COMPAT_VERSION = "0.2.45" as const; diff --git a/src/lib/session-render-managed-output-bound.test.ts b/src/lib/session-render-managed-output-bound.test.ts new file mode 100644 index 0000000..dd7ab19 --- /dev/null +++ b/src/lib/session-render-managed-output-bound.test.ts @@ -0,0 +1,126 @@ +/** + * The writer must refuse exactly what the reader refuses. + * + * 0.4.23 raised the managed-output READ bound and left the WRITE unbounded, so the two agreed + * only by headroom. Measured on origin/main @ 6091ba6 before this fix: applySessionRender wrote + * an 8,389,869-byte AGENTS.md at rc=0 with zero conflicts, and the NEXT planSessionRender on + * that home threw `PROJECT_CONTEXT_INPUT_TOO_LARGE: managed input exceeds 8388608 bytes` from + * observeProjectContextSessionGuard — including the render that would have shrunk it back. + * + * These tests are two-sided on purpose. The oversized arms must throw and the ordinary arms must + * stay silent: a size guard that refuses a normal render is worse than no guard, because it stops + * every home rather than the one that outgrew the bound. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { applySessionRender } from "./session-apply"; +import { planSessionRender, type SessionInstructionSource, type SessionRenderTool } from "./session-render"; +import { SESSION_MANAGED_OUTPUT_MAX_BYTES, SESSION_MANAGED_OUTPUT_WARN_BYTES } from "./project-context"; +import { tempRootPath } from "./test-temp-root"; + +let tmpRoot = ""; + +beforeEach(() => { + tmpRoot = tempRootPath(`session-managed-output-bound-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(tmpRoot, { recursive: true }); +}); + +afterEach(() => { + rmSync(tmpRoot, { recursive: true, force: true }); +}); + +function source(id: string, bytes: number): SessionInstructionSource { + return { id, label: id, layer: "global", order: 0, content: "x".repeat(bytes) }; +} + +function planFor(tool: SessionRenderTool, targetHome: string, sources: SessionInstructionSource[]) { + return planSessionRender({ tool, profile: "bound-probe", targetHome, sources }); +} + +/** + * The thrown message, or the literal string "DID NOT THROW". + * + * Deliberately not `expect(fn).toThrow()`: on failure bun prints the returned value, and the + * returned value here is a render plan carrying the whole 8 MiB entrypoint — measured at 48 MB + * of test output for three failing arms. A diagnostic nobody can read is a diagnostic nobody + * reads. + */ +function throwMessage(run: () => unknown): string { + try { + run(); + return "DID NOT THROW"; + } catch (error) { + return (error as Error).message; + } +} + +describe("managed session-render outputs are bounded at the write", () => { + // The flattened adapters inline every source into the entrypoint, so the entrypoint is the + // path that actually crosses the bound. codewith is included because its plan-relative path + // (`CODEWITH.md`) differs from its workspace-relative allowlist entry (`.codewith/CODEWITH.md`) + // — a check that compares those two strings directly passes for claude and codex and silently + // leaves codewith unguarded. + for (const tool of ["codex", "codewith"] as const) { + test(`${tool}: a render past the managed read bound is refused before anything is written`, () => { + const targetHome = join(tmpRoot, `${tool}-over`); + const message = throwMessage(() => + planFor(tool, targetHome, [source("big", SESSION_MANAGED_OUTPUT_MAX_BYTES + 1024)]) + ); + + expect(message).toContain("managed read bound"); + // Plan-time refusal means zero side effects: no home, no partial entrypoint, no manifest. + expect(existsSync(targetHome)).toBe(false); + }); + } + + test("an ordinary render is not refused and carries no headroom warning", () => { + const targetHome = join(tmpRoot, "codex-normal"); + // 291,867 bytes was the real codex home on station01, 2026-08-08. Anchoring the negative + // control to the measured production size is what makes its silence mean something. + const plan = planFor("codex", targetHome, [source("real-world", 291_867)]); + + expect(plan.files.length).toBeGreaterThan(0); + expect(plan.warnings.filter((warning) => warning.includes("Managed output"))).toEqual([]); + + const applied = applySessionRender(plan, {}); + expect(applied.applied).toBe(true); + expect(applied.conflicts).toEqual([]); + expect(statSync(join(targetHome, "AGENTS.md")).size).toBeGreaterThan(291_867); + }); + + test("a render past the headroom threshold warns and still applies", () => { + const targetHome = join(tmpRoot, "codex-warn"); + const plan = planFor("codex", targetHome, [source("large", SESSION_MANAGED_OUTPUT_WARN_BYTES + 4096)]); + + const headroom = plan.warnings.filter((warning) => warning.includes("Managed output")); + expect(headroom.length).toBeGreaterThan(0); + expect(headroom[0]).toContain("AGENTS.md"); + + // A warning that blocks the write would be a refusal wearing a warning's name. + const applied = applySessionRender(plan, {}); + expect(applied.applied).toBe(true); + expect(existsSync(join(targetHome, "AGENTS.md"))).toBe(true); + }); + + test("the home stays readable after the refusal — the wedge does not happen", () => { + const targetHome = join(tmpRoot, "codex-recover"); + + // A good render lands. + const good = applySessionRender(planFor("codex", targetHome, [source("ok", 1024)]), {}); + expect(good.applied).toBe(true); + const before = statSync(join(targetHome, "AGENTS.md")).size; + + // An oversized corpus is refused. + expect(throwMessage(() => + planFor("codex", targetHome, [source("big", SESSION_MANAGED_OUTPUT_MAX_BYTES + 1024)]) + )).toContain("managed read bound"); + + // The previous home is untouched, and — the property the wedge destroyed — the home can + // still be planned and re-rendered afterwards. + expect(statSync(join(targetHome, "AGENTS.md")).size).toBe(before); + const recovered = applySessionRender(planFor("codex", targetHome, [source("ok2", 2048)]), {}); + expect(recovered.applied).toBe(true); + expect(recovered.conflicts).toEqual([]); + }); +}); diff --git a/src/lib/session-render.ts b/src/lib/session-render.ts index be7d692..804b886 100644 --- a/src/lib/session-render.ts +++ b/src/lib/session-render.ts @@ -18,7 +18,10 @@ import { } from "./global-agent-rules-standard.js"; import { CODEWITH_SHARED_TODOS_STORAGE_STANDARD_SLUG } from "./codewith-shared-todos-storage-standard.js"; import { + SESSION_MANAGED_OUTPUT_MAX_BYTES, + SESSION_MANAGED_OUTPUT_WARN_BYTES, composeProjectContextSessionRender, + isSessionManagedOutputRelativePath, observeProjectContextSessionGuard, type ProjectContextSessionGuard, } from "./project-context.js"; @@ -1680,6 +1683,12 @@ export function planSessionRender(input: SessionRenderInput): SessionRenderPlan JSON.stringify(manifest, null, 2), orderedSources.map((source) => source.id), ); + // The manifest is checked alongside the entrypoints because it is allowlisted at the same + // raised bound and grows with the same corpus — a render that only guards the entrypoint + // moves the identical wedge onto the manifest and calls the job done. + const managedOutputs = [...files, manifestFile]; + rejectOversizedManagedOutputs(managedOutputs); + warnings.push(...managedOutputHeadroomWarnings(managedOutputs)); return { dryRun: true, @@ -1968,6 +1977,63 @@ function rejectDuplicateRenderPaths(files: SessionRenderFile[]): void { } } +/** + * Refuse a managed output the reader would later refuse. + * + * 0.4.23 raised the READ bound on these paths to SESSION_MANAGED_OUTPUT_MAX_BYTES and left the + * WRITE unbounded, so the two agreed only by headroom. Measured on origin/main @ 6091ba6: a + * render emitted an 8,389,869-byte AGENTS.md at rc=0, and every subsequent planSessionRender on + * that home threw PROJECT_CONTEXT_INPUT_TOO_LARGE from observeProjectContextSessionGuard — + * including a render that would have SHRUNK the file back under the bound. The home is then + * unrecoverable through this tool at all, because planning reads the oversized file before it + * can decide to replace it, and the failure is silent in the way that matters: the home simply + * stops updating. + * + * Refusing HERE, at plan time, is what makes that recoverable. planSessionRender writes nothing, + * so the previous home survives intact and merely stale, `session plan` and `--dry-run` predict + * the failure instead of discovering it, and the operator sees it while their hands are on the + * command. Refusing at the write instead would land mid-loop over per-file atomic replacements + * and leave the home half-new, half-old with a manifest describing neither. + * + * NOT truncation, deliberately: a silently shortened instruction home is a home that looks + * complete to every agent that reads it and is missing directives. A stale home is wrong in a + * way somebody notices; a truncated one is wrong in a way nobody can see. + */ +function rejectOversizedManagedOutputs(files: SessionRenderFile[]): void { + for (const file of files) { + if (!isSessionManagedOutputRelativePath(file.relativePath)) continue; + const bytes = Buffer.byteLength(file.content, "utf8"); + if (bytes <= SESSION_MANAGED_OUTPUT_MAX_BYTES) continue; + throw new Error( + `Session render output ${file.relativePath} is ${bytes} bytes and the managed read bound is ` + + `${SESSION_MANAGED_OUTPUT_MAX_BYTES}. Writing it would wedge this home: every later render ` + + `refuses to read it, including one that would shrink it back. Reduce the instruction corpus ` + + `for this profile, or raise SESSION_MANAGED_OUTPUT_MAX_BYTES — it is one constant and both ` + + `the reader and this check read it.` + ); + } +} + +/** + * Headroom warnings for the same set. The refusal above is a backstop that should never fire; + * this is the signal that stops it becoming the thing that discovers the problem. + */ +function managedOutputHeadroomWarnings(files: SessionRenderFile[]): string[] { + const warnings: string[] = []; + for (const file of files) { + if (!isSessionManagedOutputRelativePath(file.relativePath)) continue; + const bytes = Buffer.byteLength(file.content, "utf8"); + if (bytes <= SESSION_MANAGED_OUTPUT_WARN_BYTES) continue; + if (bytes > SESSION_MANAGED_OUTPUT_MAX_BYTES) continue; + warnings.push( + `Managed output ${file.relativePath} is ${bytes} bytes, past ${SESSION_MANAGED_OUTPUT_WARN_BYTES} ` + + `of a ${SESSION_MANAGED_OUTPUT_MAX_BYTES} byte bound. Renders refuse at the bound; raise ` + + `SESSION_MANAGED_OUTPUT_MAX_BYTES or reduce this profile's instruction corpus before then.` + ); + } + return warnings; +} + function rejectDuplicateSourceSlugs(sources: OrderedSessionInstructionSource[]): void { const seen = new Map(); for (const source of sources) {