Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/lib/project-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
41 changes: 31 additions & 10 deletions src/lib/project-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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}`);
Expand All @@ -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<string, string | null>, workspaceRoot: string): boolean {
Expand Down
Loading