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) {