From b2401ae0d9717d0201a6294635e25335b6e0b8a3 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 00:15:05 +0300 Subject: [PATCH] fix(project-context): read managed session-render outputs at the bound the writer can emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session render wrote a home's instruction target with no size bound while the guard that reads it back capped at 256 KiB, so a home whose instruction corpus grew past the cap wedged permanently and silently: every later plan/apply for that home threw PROJECT_CONTEXT_INPUT_TOO_LARGE before it could do any work. observeProjectContextSessionGuard hashes every path in projectContextSessionGuardPaths, which includes paths.target — the very file session render writes. currentFileHash carved out session-render-manifest.json at 8 MiB and left the target at the 256 KiB default, and session-apply.ts and session-render.ts contain no size guard at all (0 hits for byteLength/maxBytes/ TOO_LARGE), so the writer was unbounded and the reader was not. Measured on station01 against a real 273,860-byte ~/.codex/AGENTS.md; only the binary differs: installed 0.4.19 session plan rc=1 PROJECT_CONTEXT_INPUT_TOO_LARGE: managed input exceeds 262144 bytes this build session plan rc=0 Full write-then-read cycle, clean home, 42 configs: apply writes 355,607 bytes rc=0; installed 0.4.19 refuses to re-plan rc=1; this build re-plans rc=0 and re-applies idempotently rc=0. Name the two bounds instead of repeating a magic number in three places. FOREIGN_INPUT_MAX_BYTES (256 KiB) keeps guarding input this tool did not author. SESSION_MANAGED_OUTPUT_MAX_BYTES applies to the files it does author, whose size is set by the corpus it is asked to write. managedObservationMaxBytes is now the single decision point, used by currentFileHash, anchoredFileObservation and both planProjectContext/composeProjectContextSessionRender reads of paths.target — which had the same latent wedge on the project-context path. This does not widen any content-parsing read: the project-context fragment keeps PROJECT_CONTEXT_MAX_RENDERED_BYTES (4 KiB) and json records keep their own bound. Tests fail without the fix (verified by reintroducing the defect: 2 fail, both new behavioural tests) and pass with it. The 5 pre-existing failures in src/cli/output.test.ts and src/cli/session.test.ts reproduce identically on unmodified ca63d8a and are untouched by this change. Agent: Silvanus --- src/lib/project-context.test.ts | 46 +++++++++++++++++++++++++++++++++ src/lib/project-context.ts | 41 ++++++++++++++++++++++------- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/lib/project-context.test.ts b/src/lib/project-context.test.ts index 64327a7..e9e8913 100644 --- a/src/lib/project-context.test.ts +++ b/src/lib/project-context.test.ts @@ -17,12 +17,17 @@ import { } from "node:fs"; import { join } from "node:path"; import { + FOREIGN_INPUT_MAX_BYTES, PROJECT_CONTEXT_FRAGMENT_PATH, PROJECT_CONTEXT_MANAGED_COMMENT, PROJECT_CONTEXT_MANIFEST_PATH, ProjectContextError, + SESSION_MANAGED_OUTPUT_MAX_BYTES, + SESSION_MANAGED_OUTPUT_PATHS, applyProjectContext, computeProjectContextSourceHash, + managedObservationMaxBytes, + observeProjectContextSessionGuard, parseProjectContextBundle, planProjectContext, removeProjectContextCoordinatedFile, @@ -1699,3 +1704,44 @@ describe("cache, revision, crash, and race safety", () => { } }); }); + +// Regression: todos b46ca2a3. Session render wrote AGENTS.md with no size bound while the +// guard that reads it back was capped at 256 KiB, so a home whose instruction corpus grew +// past that cap wedged permanently and silently — every later plan/apply for that home threw +// PROJECT_CONTEXT_INPUT_TOO_LARGE before it could do any work. Measured on station01 +// 2026-08-07 against a real 273,860-byte ~/.codex/AGENTS.md: clean home rc=0, same command +// with the oversized file present rc=1. +describe("managed output read bound", () => { + const OVERSIZED_BYTES = FOREIGN_INPUT_MAX_BYTES + 16 * 1024; + + test("the fixture exceeds the foreign-input bound, so these tests can fail", () => { + expect(OVERSIZED_BYTES).toBeGreaterThan(FOREIGN_INPUT_MAX_BYTES); + expect(FOREIGN_INPUT_MAX_BYTES).toBeLessThan(SESSION_MANAGED_OUTPUT_MAX_BYTES); + }); + + test("the session guard hashes a managed target larger than the foreign-input bound", () => { + const target = join(tmpRoot, "AGENTS.md"); + const body = "x".repeat(OVERSIZED_BYTES); + writeFileSync(target, body); + expect(statSync(target).size).toBeGreaterThan(FOREIGN_INPUT_MAX_BYTES); + + const guard = observeProjectContextSessionGuard({ tool: "codex", target_home: tmpRoot }); + expect(guard).not.toBeNull(); + const observed = guard?.observed_hashes.find((entry) => entry.path === target); + expect(observed).toBeDefined(); + expect(observed?.sha256).toBe(createHash("sha256").update(body).digest("hex")); + }); + + test("every managed session-render output gets the managed bound", () => { + expect(SESSION_MANAGED_OUTPUT_PATHS.length).toBeGreaterThan(0); + for (const relativePath of SESSION_MANAGED_OUTPUT_PATHS) { + expect(managedObservationMaxBytes(relativePath)).toBe(SESSION_MANAGED_OUTPUT_MAX_BYTES); + } + }); + + test("foreign input keeps the tight bound", () => { + expect(managedObservationMaxBytes("some/other/file.md")).toBe(FOREIGN_INPUT_MAX_BYTES); + expect(managedObservationMaxBytes(".hasna/instructions/01-global-fix-on-sight.md")).toBe(FOREIGN_INPUT_MAX_BYTES); + expect(managedObservationMaxBytes("opencode.json")).toBe(FOREIGN_INPUT_MAX_BYTES); + }); +}); diff --git a/src/lib/project-context.ts b/src/lib/project-context.ts index fddb43a..5b9a882 100644 --- a/src/lib/project-context.ts +++ b/src/lib/project-context.ts @@ -37,6 +37,30 @@ export const PROJECT_CONTEXT_SNAPSHOT_DIR = ".hasna/project-context-snapshots"; export const PROJECT_CONTEXT_CACHE_SCHEMA = "hasna.instructions.project-context-cache/v1" as const; export const PROJECT_CONTEXT_MANAGED_COMMENT = "Managed by @hasna/configs project context"; const SESSION_COMPATIBILITY_MANIFEST_MAX_BYTES = 8 * 1024 * 1024; +// Read bound for files this tool did NOT author. Foreign input is untrusted and its size +// is nobody's contract, so it stays tightly bounded. +export const FOREIGN_INPUT_MAX_BYTES = 256 * 1024; +// Read bound for files this tool AUTHORS into a managed home. Their size is set by the +// instruction corpus the renderer is asked to write, which grows without any bound the +// 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. +export const SESSION_MANAGED_OUTPUT_PATHS = [ + ".hasna/session-render-manifest.json", + ".codewith/.hasna/session-render-manifest.json", + "CLAUDE.md", + "AGENTS.md", + ".codewith/CODEWITH.md", + ".codewith/CODEWITH.override.md", +]; + +export function managedObservationMaxBytes(relativePath: string): number { + return SESSION_MANAGED_OUTPUT_PATHS.includes(relativePath) + ? SESSION_MANAGED_OUTPUT_MAX_BYTES + : FOREIGN_INPUT_MAX_BYTES; +} 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; @@ -493,7 +517,9 @@ export function planProjectContext(input: ProjectContextPlanInput): ProjectConte PROJECT_CONTEXT_MAX_RENDERED_BYTES - Math.max(320, inlineMarkerOverhead), PROJECT_CONTEXT_MAX_APPROX_TOKENS - Math.max(80, Math.ceil(inlineMarkerOverhead / 4)), ); - const previousTargetContent = existsSync(paths.target) ? readUtf8RegularFile(paths.target, workspaceRoot) : null; + const previousTargetContent = existsSync(paths.target) + ? readUtf8RegularFile(paths.target, workspaceRoot, managedObservationMaxBytes(relativePosix(workspaceRoot, paths.target))) + : null; const markerParse = parseManagedBlock(previousTargetContent ?? "", input.force === true); if (markerParse.block && markerParse.block.id !== bundle.project.id) { throw new ProjectContextError("MANAGED_BLOCK_CONFLICT", "managed block belongs to a different project"); @@ -599,7 +625,7 @@ export function composeProjectContextSessionRender( if (!existsSync(paths.target)) { throw new ProjectContextError("MANAGED_BLOCK_CONFLICT", "project-context provider target is missing while durable context is active"); } - const currentTarget = readUtf8RegularFile(paths.target, workspaceRoot); + const currentTarget = readUtf8RegularFile(paths.target, workspaceRoot, managedObservationMaxBytes(relativePosix(workspaceRoot, paths.target))); const currentMarkers = parseManagedBlock(currentTarget, false); if (!currentMarkers.block) { throw new ProjectContextError("MANAGED_BLOCK_CONFLICT", "project-context provider target lost its managed block"); @@ -2460,9 +2486,7 @@ function anchoredFileObservation(directory: AnchoredDirectory, name: string): An if (!stat.isFile()) throw new ProjectContextHashRace("managed output is not a regular file"); const relativePath = relativePosix(directory.workspaceRoot, join(directory.path, name)); const maxBytes = directory.maxObservedBytes === undefined - ? relativePath === ".hasna/session-render-manifest.json" || relativePath === ".codewith/.hasna/session-render-manifest.json" - ? SESSION_COMPATIBILITY_MANIFEST_MAX_BYTES - : 256 * 1024 + ? managedObservationMaxBytes(relativePath) : directory.maxObservedBytes; if (maxBytes !== null && stat.size > maxBytes) { throw new ProjectContextHashRace(`managed output exceeds the safe read limit: ${relativePath}`); @@ -3294,7 +3318,7 @@ function assertNoSymlinkAncestors(path: string): void { } } -function readUtf8RegularFile(path: string, workspaceRoot: string, maxBytes = 256 * 1024): string { +function readUtf8RegularFile(path: string, workspaceRoot: string, maxBytes = FOREIGN_INPUT_MAX_BYTES): string { assertNoSymlinkSegments(workspaceRoot, path); const stat = lstatSync(path); if (!stat.isFile()) throw new ProjectContextError("PROJECT_CONTEXT_PATH_INVALID", `managed path is not a regular file: ${path}`); @@ -3305,10 +3329,7 @@ function readUtf8RegularFile(path: string, workspaceRoot: string, maxBytes = 256 function currentFileHash(path: string, workspaceRoot: string): string | null { if (!existsSync(path)) return null; const relativePath = relativePosix(workspaceRoot, path); - const maxBytes = relativePath === ".hasna/session-render-manifest.json" || relativePath === ".codewith/.hasna/session-render-manifest.json" - ? SESSION_COMPATIBILITY_MANIFEST_MAX_BYTES - : 256 * 1024; - return sha256(readUtf8RegularFile(path, workspaceRoot, maxBytes)); + return sha256(readUtf8RegularFile(path, workspaceRoot, managedObservationMaxBytes(relativePath))); } function hashesStillMatch(expected: Map, workspaceRoot: string): boolean {