From 9e51a85f2be1d0c0f10279edf17ca1ca29137642 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 01:45:45 +0300 Subject: [PATCH] fix(project-context): bridge v1 hashes and repair markers Accept only exact legacy v1 producer hashes, normalize persisted state to the canonical hash, and retain strict schema and hash requirements. Fail closed on malformed managed markers unless forced; forced repair preserves adjacent user bytes and creates a restorable before-image snapshot for every target mutation. Tasks: d8d5e195-561e-4e05-b83f-38e5a5c63877, ed31c2f5-61bc-4950-9f34-a0201ea35bd5. Incident: 687465. Agent: Quintilianus --- src/lib/project-context.test.ts | 372 +++++++++++++++++++++++++++++++- src/lib/project-context.ts | 256 ++++++++++++++++++++-- 2 files changed, 609 insertions(+), 19 deletions(-) diff --git a/src/lib/project-context.test.ts b/src/lib/project-context.test.ts index e9e8913..1f0501c 100644 --- a/src/lib/project-context.test.ts +++ b/src/lib/project-context.test.ts @@ -35,7 +35,7 @@ import { type ProjectContextRuntime, } from "./project-context"; import { CODEWITH_NATIVE_IMPORTS_ENV, planSessionRender, type SessionRenderTool } from "./session-render"; -import { applySessionRender } from "./session-apply"; +import { applySessionRender, restoreSessionRenderSnapshot } from "./session-apply"; import { makeTempRoot } from "./test-temp-root"; let tmpRoot = ""; @@ -114,6 +114,23 @@ function bundleJson(bundle = makeBundle()): string { return `${JSON.stringify(bundle)}\n`; } +function stableStringifyForTest(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringifyForTest).join(",")}]`; + if (value && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort((left, right) => left.localeCompare(right)) + .map((key) => `${JSON.stringify(key)}:${stableStringifyForTest(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function legacyProjectContextHash(bundle: ProjectContextBundleV1): string { + const { hash: _hash, ...legacyPayload } = bundle; + return `sha256:${createHash("sha256").update(stableStringifyForTest(legacyPayload)).digest("hex")}`; +} + function expectCode(fn: () => unknown, code: string): void { try { fn(); @@ -133,6 +150,49 @@ describe("project context bundle validation", () => { expect(parsed.commands).toHaveLength(2); }); + test("keeps the source hash stable across generation times but changes it for durable payload changes", () => { + const original = makeBundle(); + const regenerated = { + ...original, + generated_at: "2026-07-22T10:01:00.000Z", + }; + const changed = { + ...regenerated, + project: { + ...regenerated.project, + name: "Changed Durable Project Name", + }, + }; + + expect(computeProjectContextSourceHash(regenerated)).toBe( + computeProjectContextSourceHash(original), + ); + expect(computeProjectContextSourceHash(changed)).not.toBe( + computeProjectContextSourceHash(original), + ); + }); + + test("accepts canonical and exact legacy v1 hashes while rejecting tampered and hashless bundles", () => { + const canonical = makeBundle(); + const legacy: ProjectContextBundleV1 = { + ...canonical, + hash: legacyProjectContextHash(canonical), + }; + const tampered: ProjectContextBundleV1 = { + ...legacy, + project: { + ...legacy.project, + name: "Tampered Durable Project Name", + }, + }; + const hashless = { ...canonical, hash: "" }; + + expect(parseProjectContextBundle(canonical).hash).toBe(canonical.hash); + expect(parseProjectContextBundle(legacy).hash).toBe(canonical.hash); + expectCode(() => parseProjectContextBundle(tampered), "PROJECT_CONTEXT_HASH_MISMATCH"); + expectCode(() => parseProjectContextBundle(hashless), "PROJECT_CONTEXT_INVALID"); + }); + test("rejects additional properties, inconsistent hashes, bad enums, and too many argv commands", () => { const extra = { ...makeBundle(), metadata: { arbitrary: true } }; expectCode(() => parseProjectContextBundle(JSON.stringify(extra)), "PROJECT_CONTEXT_INVALID"); @@ -438,6 +498,77 @@ describe("project context adapters and managed edits", () => { expect(readFileSync(target, "utf8").match(/project context BEGIN/g)).toHaveLength(1); }); + test("fails a fused END marker without force and force-repairs it without consuming following user bytes", () => { + const targetHome = join(tmpRoot, ".codewith"); + const target = join(targetHome, "CODEWITH.md"); + const prefix = "Owner prefix stays byte-for-byte.\n\n"; + const suffix = "## Modus Operandi\n\nOwner suffix stays byte-for-byte.\n"; + mkdirSync(targetHome, { recursive: true }); + writeFileSync(target, prefix); + + const bundle = makeBundle(); + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(bundle), + source_path: join(tmpRoot, "bundle.json"), + now: new Date("2026-07-22T10:01:00.000Z"), + codewith_native_imports: true, + }); + + const rendered = readFileSync(target, "utf8"); + const endMarkerEnd = rendered.lastIndexOf(" -->") + " -->".length; + expect(endMarkerEnd).toBeGreaterThan(" -->".length); + const malformed = `${rendered.slice(0, endMarkerEnd)}${suffix}`; + writeFileSync(target, malformed); + + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(bundle), + source_path: join(tmpRoot, "bundle.json"), + now: new Date("2026-07-22T10:01:00.000Z"), + codewith_native_imports: true, + }), "MANAGED_BLOCK_INVALID"); + expect(readFileSync(target, "utf8")).toBe(malformed); + + const repaired = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "codewith", + bundle_json: bundleJson(bundle), + source_path: join(tmpRoot, "bundle.json"), + now: new Date("2026-07-22T10:01:00.000Z"), + codewith_native_imports: true, + force: true, + }); + expect(repaired.applied).toBe(true); + expect(repaired.snapshot_path).not.toBeNull(); + const repairedContent = readFileSync(target, "utf8"); + expect(repairedContent.startsWith(prefix)).toBe(true); + expect(repairedContent.endsWith(suffix)).toBe(true); + expect(repairedContent).toContain(" -->\n## Modus Operandi"); + + process.env[CODEWITH_NATIVE_IMPORTS_ENV] = "1"; + const sessionPlan = planSessionRender({ + tool: "codewith", + profile: "fused-marker-repair", + targetHome, + sources: [{ + id: "global-rules", + layer: "global", + content: "## Modus Operandi\n\nOwner suffix stays byte-for-byte.\n", + }], + }); + expect(sessionPlan.files.find((file) => file.path === target)?.content).toContain( + PROJECT_CONTEXT_MANAGED_COMMENT, + ); + + const restored = restoreSessionRenderSnapshot(repaired.snapshot_path!); + expect(restored.restored).toBe(true); + expect(restored.conflicts).toEqual([]); + expect(readFileSync(target, "utf8")).toBe(malformed); + }); + test("rejects a well-formed managed block for another project even with force", () => { const target = join(tmpRoot, "AGENTS.md"); writeFileSync(target, [ @@ -724,6 +855,90 @@ describe("legacy migration and compatibility", () => { } }); + test("keeps regenerated bundles idempotent across a managed Codewith render and two post-render applies", () => { + const root = join(tmpRoot, "codewith-regenerated-bundle"); + const targetHome = join(root, ".codewith"); + mkdirSync(targetHome, { recursive: true }); + const original = makeBundle(); + const legacyOriginal: ProjectContextBundleV1 = { + ...original, + hash: legacyProjectContextHash(original), + }; + + const initial = applyProjectContext({ + workspace_root: root, + runtime: "codewith", + bundle_json: bundleJson(legacyOriginal), + source_path: join(root, "bundle.json"), + now: new Date("2026-07-22T10:01:00.000Z"), + codewith_native_imports: true, + }); + expect(initial.applied).toBe(true); + expect(initial.hash).toBe(original.hash); + + process.env[CODEWITH_NATIVE_IMPORTS_ENV] = "1"; + const sessionPlan = planSessionRender({ + tool: "codewith", + profile: "live-codewith", + targetHome, + sources: [{ + id: "global-rules", + layer: "global", + content: "Managed Codewith rules.", + }], + }); + const sessionResult = applySessionRender(sessionPlan); + expect(sessionResult.applied).toBe(true); + expect(sessionResult.conflicts).toEqual([]); + const managedRules = sessionPlan.files.find((file) => file.content.includes("Managed Codewith rules.")); + expect(managedRules).toBeDefined(); + + const regenerated: ProjectContextBundleV1 = { + ...original, + generated_at: "2026-07-22T10:02:00.000Z", + hash: original.hash, + }; + expect(computeProjectContextSourceHash(regenerated)).toBe(original.hash); + + const firstPostRender = applyProjectContext({ + workspace_root: root, + runtime: "codewith", + bundle_json: bundleJson(regenerated), + source_path: join(root, "regenerated-bundle.json"), + now: new Date("2026-07-22T10:03:00.000Z"), + codewith_native_imports: true, + }); + expect(firstPostRender.applied).toBe(true); + expect(firstPostRender.snapshot_path).toBeNull(); + + const managedPaths = [ + join(targetHome, "CODEWITH.md"), + join(targetHome, ".hasna", "session-render-manifest.json"), + managedRules!.path, + join(root, ...PROJECT_CONTEXT_FRAGMENT_PATH.split("/")), + join(root, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")), + join(root, ".hasna", "project-context-cache.json"), + ]; + const afterFirstPostRender = managedPaths.map((path) => readFileSync(path, "utf8")); + + const secondPostRender = applyProjectContext({ + workspace_root: root, + runtime: "codewith", + bundle_json: bundleJson(regenerated), + source_path: join(root, "regenerated-bundle.json"), + now: new Date("2026-07-22T10:03:00.000Z"), + codewith_native_imports: true, + }); + expect(secondPostRender.applied).toBe(true); + expect(secondPostRender.snapshot_path).toBeNull(); + expect(managedPaths.map((path) => readFileSync(path, "utf8"))).toEqual(afterFirstPostRender); + + const rendered = readFileSync(join(targetHome, "CODEWITH.md"), "utf8"); + expect(readFileSync(managedRules!.path, "utf8")).toContain("Managed Codewith rules."); + expect(rendered).toContain(`@../${PROJECT_CONTEXT_FRAGMENT_PATH}`); + expect(rendered.match(/project context BEGIN/g)).toHaveLength(1); + }); + test("rejects a stale session plan instead of downgrading newer durable project context", () => { const first = planSessionRender({ tool: "codex", @@ -974,6 +1189,161 @@ describe("legacy migration and compatibility", () => { }); describe("cache, revision, crash, and race safety", () => { + test("rejects an unproven same-revision manifest hash even when cache, marker, and fragment agree", () => { + const bundle = makeBundle(); + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(bundle), + source_path: join(tmpRoot, "bundle.json"), + now: new Date("2026-07-22T10:00:30.000Z"), + }); + + const manifestPath = join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + projectContext: { hash: string }; + }; + manifest.projectContext.hash = `sha256:${"b".repeat(64)}`; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(bundle), + source_path: join(tmpRoot, "bundle.json"), + now: new Date("2026-07-22T10:00:30.000Z"), + }), "PROJECT_CONTEXT_REVISION_CONFLICT"); + }); + + test("migrates persisted legacy v1 hashes to canonical state and recovers an interrupted apply", () => { + const original = makeBundle(); + const canonicalHash = original.hash; + const legacyHash = legacyProjectContextHash(original); + const legacyBundle = { ...original, hash: legacyHash }; + expect(legacyHash).not.toBe(canonicalHash); + expect(parseProjectContextBundle(legacyBundle).hash).toBe(canonicalHash); + + applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(original), + source_path: join(tmpRoot, "bundle.json"), + now: new Date("2026-07-22T10:00:30.000Z"), + }); + + const targetPath = join(tmpRoot, "AGENTS.md"); + const fragmentPath = join(tmpRoot, ...PROJECT_CONTEXT_FRAGMENT_PATH.split("/")); + const cachePath = join(tmpRoot, ".hasna", "project-context-cache.json"); + const manifestPath = join(tmpRoot, ...PROJECT_CONTEXT_MANIFEST_PATH.split("/")); + const sessionManifestPath = join(tmpRoot, ".hasna", "session-render-manifest.json"); + for (const path of [targetPath, fragmentPath, cachePath]) { + writeFileSync(path, readFileSync(path, "utf8").replaceAll(canonicalHash, legacyHash)); + } + + const legacyRenderedPayloadSha256 = createHash("sha256") + .update(JSON.stringify(legacyBundle)) + .digest("hex"); + const rewriteManifest = (path: string, sourceHash: string): void => { + const manifest = JSON.parse( + readFileSync(path, "utf8").replaceAll(canonicalHash, legacyHash), + ) as { + sourceHash: string; + sources: Array<{ id: string; renderedPayloadSha256?: string }>; + files: Array<{ path: string; sha256: string }>; + }; + manifest.sourceHash = sourceHash; + for (const source of manifest.sources) { + if (source.id === "project-context-bundle") { + source.renderedPayloadSha256 = legacyRenderedPayloadSha256; + } + } + for (const file of manifest.files) { + file.sha256 = createHash("sha256").update(readFileSync(file.path, "utf8")).digest("hex"); + } + writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`); + }; + rewriteManifest(manifestPath, legacyHash); + rewriteManifest( + sessionManifestPath, + createHash("sha256") + .update(stableStringifyForTest({ previous: null, projectContext: legacyHash })) + .digest("hex"), + ); + + const regenerated: ProjectContextBundleV1 = { + ...original, + generated_at: "2026-07-22T10:01:00.000Z", + hash: canonicalHash, + }; + expect(computeProjectContextSourceHash(regenerated)).toBe(canonicalHash); + expect(legacyProjectContextHash(regenerated)).not.toBe(legacyHash); + + expect(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(regenerated), + source_path: join(tmpRoot, "regenerated-bundle.json"), + now: new Date("2026-07-22T10:02:00.000Z"), + test_hooks: { + before_manifest: () => { + throw new Error("simulated migration crash before final manifest"); + }, + }, + })).toThrow("simulated migration crash before final manifest"); + expect((JSON.parse(readFileSync(cachePath, "utf8")) as { hash: string }).hash).toBe(canonicalHash); + expect( + (JSON.parse(readFileSync(manifestPath, "utf8")) as { projectContext: { hash: string } }) + .projectContext.hash, + ).toBe(legacyHash); + + const changedAfterCrash: ProjectContextBundleV1 = { + ...regenerated, + project: { + ...regenerated.project, + name: "Changed Durable Project Name", + }, + hash: "", + }; + changedAfterCrash.hash = computeProjectContextSourceHash(changedAfterCrash); + expectCode(() => applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(changedAfterCrash), + source_path: join(tmpRoot, "changed-after-crash.json"), + now: new Date("2026-07-22T10:02:00.000Z"), + }), "MANAGED_BLOCK_CONFLICT"); + + const migrated = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(regenerated), + source_path: join(tmpRoot, "regenerated-bundle.json"), + now: new Date("2026-07-22T10:02:00.000Z"), + }); + expect(migrated.applied).toBe(true); + expect(migrated.snapshot_path).not.toBeNull(); + expect((JSON.parse(readFileSync(cachePath, "utf8")) as { hash: string }).hash).toBe(canonicalHash); + expect( + (JSON.parse(readFileSync(manifestPath, "utf8")) as { projectContext: { hash: string } }) + .projectContext.hash, + ).toBe(canonicalHash); + expect(readFileSync(targetPath, "utf8")).toContain(canonicalHash); + expect(readFileSync(targetPath, "utf8")).not.toContain(legacyHash); + + const managedPaths = [targetPath, fragmentPath, cachePath, manifestPath, sessionManifestPath]; + const afterMigration = managedPaths.map((path) => readFileSync(path, "utf8")); + const repeated = applyProjectContext({ + workspace_root: tmpRoot, + runtime: "agents", + bundle_json: bundleJson(regenerated), + source_path: join(tmpRoot, "regenerated-bundle.json"), + now: new Date("2026-07-22T10:02:00.000Z"), + }); + expect(repeated.applied).toBe(true); + expect(repeated.snapshot_path).toBeNull(); + expect(managedPaths.map((path) => readFileSync(path, "utf8"))).toEqual(afterMigration); + }); + test("uses only a compatible same-ID bounded stale cache with a visible age", () => { applyProjectContext({ workspace_root: tmpRoot, diff --git a/src/lib/project-context.ts b/src/lib/project-context.ts index b7002d5..19cfa88 100644 --- a/src/lib/project-context.ts +++ b/src/lib/project-context.ts @@ -21,7 +21,12 @@ import { basename, dirname, isAbsolute, join, parse, relative, resolve } from "n import { z } from "zod"; import { scanSecrets } from "./redact.js"; import type { SessionRenderFile, SessionRenderManifest, SessionRenderMode, SessionRenderTool } from "./session-render.js"; -import { CODEWITH_NATIVE_IMPORTS_ENV, SESSION_INSTRUCTION_LAYERS, SESSION_RENDER_SCHEMA } from "./session-render-contract.js"; +import { + CODEWITH_NATIVE_IMPORTS_ENV, + SESSION_INSTRUCTION_LAYERS, + SESSION_RENDER_SCHEMA, + SESSION_RENDER_SNAPSHOT_RELATIVE_DIR, +} from "./session-render-contract.js"; export const PROJECT_CONTEXT_SCHEMA = "hasna.projects.project_context_bundle.v1" as const; export const PROJECT_CONTEXT_MAX_INPUT_BYTES = 8 * 1024; @@ -235,6 +240,26 @@ const storedManifestObservationSchema = z.object({ } }); +const projectContextMetadataSnapshotSchema = z.object({ + schema: z.literal("hasna.configs.session-render-snapshot/v1"), + kind: z.literal("project-context-metadata"), + createdAt: isoTimestamp, + projectId: safeId, + revision: revisionSchema, + hash: hashSchema, + status: z.enum(["fresh", "stale-source", "stale-cache"]), + files: z.array(z.object({ + relativePath: z.enum([ + PROJECT_CONTEXT_FRAGMENT_PATH, + "CLAUDE.md", + ".codewith/CODEWITH.md", + "AGENTS.md", + ]), + role: z.enum(["fragment", "index"]), + sha256: z.string().regex(/^[a-f0-9]{64}$/), + }).strict()).min(1).max(2), +}).strict(); + const projectContextCacheSchema = z.object({ schema: z.literal(PROJECT_CONTEXT_CACHE_SCHEMA), cached_at: isoTimestamp, @@ -490,6 +515,14 @@ export function computeProjectContextSourceHash(value: unknown): string { } export function parseProjectContextBundle(input: string | unknown): ProjectContextBundleV1 { + return parseProjectContextBundleInternal(input, true, true); +} + +function parseProjectContextBundleInternal( + input: string | unknown, + allowLegacyHash: boolean, + normalizeLegacyHash = false, +): ProjectContextBundleV1 { let encoded: string; try { const serialized = typeof input === "string" ? input : JSON.stringify(input); @@ -527,10 +560,15 @@ export function parseProjectContextBundle(input: string | unknown): ProjectConte validateIdentityConsistency(bundle); rejectCredentialLikeBundle(bundle); const expected = computeProjectContextSourceHash(bundle); - if (bundle.hash !== expected) { + const matchesLegacyHash = bundle.hash !== expected + && allowLegacyHash + && bundle.hash === computeLegacyProjectContextSourceHash(bundle); + if (bundle.hash !== expected && !matchesLegacyHash) { throw new ProjectContextError("PROJECT_CONTEXT_HASH_MISMATCH", "bundle hash does not match its canonical allowlisted payload"); } - return bundle; + return matchesLegacyHash && normalizeLegacyHash + ? { ...bundle, hash: expected } + : bundle; } export function planProjectContext(input: ProjectContextPlanInput): ProjectContextPlan { @@ -841,6 +879,7 @@ export function applyProjectContext(options: ProjectContextApplyOptions): Projec path: runtimePaths(workspaceRoot, plan.runtime).sessionManifest, content: `${JSON.stringify(sessionManifest, null, 2)}\n`, }; + const manifestContent = `${JSON.stringify(buildManifest(plan, now), null, 2)}\n`; options.test_hooks?.before_compare?.({ attempt, plan }); if (!hashesStillMatch(plan.expected_hashes, workspaceRoot)) { if (attempt === 0) { @@ -853,7 +892,15 @@ export function applyProjectContext(options: ProjectContextApplyOptions): Projec assertWorkspaceLockHeld(lockPath, lock!, workspaceRoot); try { - snapshotPath = writeMetadataSnapshot(plan, now); + const metadataSnapshotPath = writeMetadataSnapshot(plan, now); + const rollbackSnapshotPath = writeProjectContextRollbackSnapshot(plan, now, [ + { path: plan.fragment_path, content: plan.fragment, role: "fragment" }, + { path: plan.target_path, content: plan.target_content, role: "index" }, + { path: plan.cache_path, content: cacheContent, role: "config" }, + { path: sessionOutput.path, content: sessionOutput.content, role: "manifest" }, + { path: plan.manifest_path, content: manifestContent, role: "manifest" }, + ]); + snapshotPath = rollbackSnapshotPath ?? metadataSnapshotPath; atomicWriteFile( plan.fragment_path, plan.fragment, @@ -910,10 +957,9 @@ export function applyProjectContext(options: ProjectContextApplyOptions): Projec options.test_hooks?.before_manifest?.({ attempt, plan }); assertWorkspaceLockHeld(lockPath, lock!, workspaceRoot); assertRenderedOutputsStable(plan, cacheContent, sessionOutput); - const manifest = buildManifest(plan, now); atomicWriteFile( plan.manifest_path, - `${JSON.stringify(manifest, null, 2)}\n`, + manifestContent, workspaceRoot, 0o600, expectedPlanHash(plan, plan.manifest_path), @@ -1028,10 +1074,14 @@ function resolveBundleForApply( if (cache.project_id !== options.expected_project_id || cache.bundle.project.id !== options.expected_project_id) { throw new ProjectContextError("PROJECT_CONTEXT_CACHE_ID_MISMATCH", "cached project context belongs to a different project"); } - const bundle = parseProjectContextBundle(cache.bundle); - if (bundle.revision !== cache.revision || bundle.hash !== cache.hash) { + const cachedBundle = cache.bundle; + if (cachedBundle.revision !== cache.revision || cachedBundle.hash !== cache.hash) { throw new ProjectContextError("PROJECT_CONTEXT_CACHE_INVALID", "cached revision or hash metadata is inconsistent"); } + const canonicalHash = computeProjectContextSourceHash(cachedBundle); + const bundle = cachedBundle.hash === canonicalHash + ? cachedBundle + : { ...cachedBundle, hash: canonicalHash }; const ageSeconds = Math.max( staleCacheAgeInSeconds(bundle.generated_at, now, "bundle generated_at"), staleCacheAgeInSeconds(cache.cached_at, now, "cache cached_at"), @@ -1145,11 +1195,13 @@ function parseManagedBlock(content: string, force: boolean): { block: ManagedBlo const structurallyInvalid = malformed || starts.length !== 1 || ends.length !== 1 || starts[0]!.start >= ends[0]!.start; if (structurallyInvalid) { if (!force) throw new ProjectContextError("MANAGED_BLOCK_INVALID", "managed project-context markers are duplicate, nested, malformed, or unbalanced"); + const firstMarkerRange = markerCommentRange(markerLines[0]!); + const lastMarkerRange = markerCommentRange(markerLines[markerLines.length - 1]!); return { block: null, forceRange: { - start: markerLines[0]!.start, - end: lineContentEnd(markerLines[markerLines.length - 1]!), + start: firstMarkerRange?.start ?? markerLines[0]!.start, + end: lastMarkerRange?.end ?? lineContentEnd(markerLines[markerLines.length - 1]!), }, }; } @@ -1180,6 +1232,20 @@ function parseManagedBlock(content: string, force: boolean): { block: ManagedBlo }; } +function markerCommentRange(line: { text: string; start: number }): { start: number; end: number } | null { + const canonicalMarker = line.text.indexOf(PROJECT_CONTEXT_MANAGED_COMMENT); + const legacyMarker = /@hasna\/configs project context/i.exec(line.text)?.index ?? -1; + const marker = canonicalMarker >= 0 ? canonicalMarker : legacyMarker; + if (marker < 0) return null; + const commentStart = line.text.lastIndexOf("", marker); + if (commentStart < 0 || commentEnd < 0) return null; + return { + start: line.start + commentStart, + end: line.start + commentEnd + "-->".length, + }; +} + function parseMarkerLine(text: string): { kind: "BEGIN" | "END"; id: string; revision: string; hash: string; legacy: boolean } | null { const line = text.replace(/[\r\n]+$/, ""); const canonical = line.match(/^$/); @@ -1206,7 +1272,14 @@ function replaceOrAppendManagedBlock( legacy: { start: number; end: number } | null, ): string { const range = parsed.block ?? parsed.forceRange ?? legacy; - if (range) return `${content.slice(0, range.start)}${block}${content.slice(range.end)}`; + if (range) { + const before = content.slice(0, range.start); + const after = content.slice(range.end); + const eol = preferredEol(content); + const beforeSeparator = before && !/[\r\n]$/.test(before) ? eol : ""; + const afterSeparator = after && !/^[\r\n]/.test(after) ? eol : ""; + return `${before}${beforeSeparator}${block}${afterSeparator}${after}`; + } if (!content) return `${block}\n`; const eol = preferredEol(content); const separator = content.endsWith("\n") || content.endsWith("\r") ? eol : `${eol}${eol}`; @@ -1250,13 +1323,40 @@ function findLegacyCodewithWorkspaceSection( function assertRevisionOrdering(plan: ProjectContextPlan, force: boolean): void { const observations: Array<{ source: string; id: string; revision: string; hash: string }> = []; + const cache = readProjectContextCache(plan.cache_path, plan.workspace_root); + const canonicalCacheHash = cache === null ? null : computeProjectContextSourceHash(cache.bundle); + const normalizePersistedHash = (revision: string, hash: string): string => ( + cache !== null && + canonicalCacheHash !== null && + revision === cache.revision && + hash === cache.hash + ) ? canonicalCacheHash : hash; const manifest = readProjectContextManifest(plan.manifest_path, plan.workspace_root); if (manifest) { + const manifestHashHasRecoveryProof = manifest.projectContext.hash === plan.bundle.hash + || metadataSnapshotMatchesManifest(plan, manifest); + const canonicalStateAlreadyInstalled = ( + cache !== null && + canonicalCacheHash === plan.bundle.hash && + cache.project_id === plan.bundle.project.id && + cache.revision === plan.bundle.revision && + plan.marker !== null && + plan.marker.id === plan.bundle.project.id && + plan.marker.revision === plan.bundle.revision && + plan.marker.hash === plan.bundle.hash && + existsSync(plan.fragment_path) && + fragmentMatchesBundle(plan.fragment_path, plan.bundle, plan.workspace_root) && + manifestHashHasRecoveryProof + ); observations.push({ source: "manifest", id: manifest.projectContext.projectId, revision: manifest.projectContext.revision, - hash: manifest.projectContext.hash, + hash: canonicalStateAlreadyInstalled && + manifest.projectContext.projectId === plan.bundle.project.id && + manifest.projectContext.revision === plan.bundle.revision + ? plan.bundle.hash + : normalizePersistedHash(manifest.projectContext.revision, manifest.projectContext.hash), }); const fragmentEntry = manifest.files.find((file) => file.relativePath === PROJECT_CONTEXT_FRAGMENT_PATH); if (fragmentEntry && existsSync(plan.fragment_path)) { @@ -1266,9 +1366,15 @@ function assertRevisionOrdering(plan: ProjectContextPlan, force: boolean): void } } } - const cache = readProjectContextCache(plan.cache_path, plan.workspace_root); - if (cache) observations.push({ source: "cache", id: cache.project_id, revision: cache.revision, hash: cache.hash }); - if (plan.marker) observations.push({ source: "marker", id: plan.marker.id, revision: plan.marker.revision, hash: plan.marker.hash }); + if (cache) observations.push({ source: "cache", id: cache.project_id, revision: cache.revision, hash: canonicalCacheHash! }); + if (plan.marker) { + observations.push({ + source: "marker", + id: plan.marker.id, + revision: plan.marker.revision, + hash: normalizePersistedHash(plan.marker.revision, plan.marker.hash), + }); + } for (const observation of observations) { if (observation.id !== plan.bundle.project.id) { @@ -1395,6 +1501,14 @@ function buildSessionCompatibilityManifest(plan: ProjectContextPlan, now: Date): }; const targetOwner = isRecord(existing["targetOwner"]) ? existing["targetOwner"] : {}; const adapterMode = plan.native_imports ? "native-imports" : "flattened-markdown"; + const existingProjectContext = isRecord(existing["projectContext"]) ? existing["projectContext"] : null; + const existingProjectContextHash = existingProjectContext === null + ? null + : safeLegacyMetadataString(existingProjectContext["hash"], null); + const existingSourceHash = typeof existing["sourceHash"] === "string" ? existing["sourceHash"] : null; + const sourceHash = existingProjectContextHash === plan.bundle.hash && existingSourceHash !== null + ? existingSourceHash + : sha256(stableStringify({ previous: existingSourceHash, projectContext: plan.bundle.hash })); return credentialSafeSessionManifest({ schema: SESSION_RENDER_SCHEMA, tool, @@ -1418,7 +1532,7 @@ function buildSessionCompatibilityManifest(plan: ProjectContextPlan, now: Date): blockers: [], generatedAt: now.toISOString(), env: sanitizeLegacyEnvironment(existing["env"]), - sourceHash: sha256(stableStringify({ previous: typeof existing["sourceHash"] === "string" ? existing["sourceHash"] : null, projectContext: plan.bundle.hash })), + sourceHash, sources, skippedSources: sanitizeLegacySkippedSources(existing["skippedSources"]), files: [...files.filter((file) => file["relativePath"] !== targetRelativePath), updatedTarget], @@ -1702,6 +1816,102 @@ function writeMetadataSnapshot(plan: ProjectContextPlan, now: Date): string | nu return snapshotPath; } +function metadataSnapshotMatchesManifest( + plan: ProjectContextPlan, + manifest: ProjectContextManifestObservation, +): boolean { + const snapshotDir = resolve(plan.workspace_root, ...PROJECT_CONTEXT_SNAPSHOT_DIR.split("/")); + const snapshotPath = resolve( + snapshotDir, + `${safeFilename(manifest.projectContext.revision)}-${manifest.projectContext.hash.slice(-12)}.json`, + ); + if (!existsSync(snapshotPath)) return false; + const record = readJsonRecord(snapshotPath, plan.workspace_root); + const result = projectContextMetadataSnapshotSchema.safeParse(record); + if (!result.success) return false; + if ( + result.data.projectId !== manifest.projectContext.projectId + || result.data.revision !== manifest.projectContext.revision + || result.data.hash !== manifest.projectContext.hash + || result.data.status !== manifest.projectContext.status + ) return false; + const sortFiles = (files: T[]): T[] => + [...files].sort((left, right) => left.relativePath.localeCompare(right.relativePath)); + const snapshotFiles = sortFiles(result.data.files); + const manifestFiles = sortFiles(manifest.files.map((file) => ({ + relativePath: file.relativePath, + role: file.role, + sha256: file.sha256, + }))); + return JSON.stringify(snapshotFiles) === JSON.stringify(manifestFiles); +} + +function writeProjectContextRollbackSnapshot( + plan: ProjectContextPlan, + now: Date, + outputs: Array<{ path: string; content: string; role: SessionRenderFile["role"] }>, +): string | null { + const targetExpectedHash = expectedPlanHash(plan, plan.target_path); + if (targetExpectedHash === sha256(plan.target_content)) return null; + + const files = outputs.flatMap((output) => { + const expectedHash = expectedPlanHash(plan, output.path); + if (expectedHash === null) return []; + const content = readUtf8RegularFile( + output.path, + plan.workspace_root, + managedObservationMaxBytes(relativePosix(plan.workspace_root, output.path)), + ); + if (sha256(content) !== expectedHash) { + throw new ProjectContextHashRace( + `managed path changed while creating rollback evidence: ${relativePosix(plan.workspace_root, output.path)}`, + ); + } + return [{ + path: output.path, + relativePath: relativePosix(plan.workspace_root, output.path), + role: output.role, + sha256: expectedHash, + content, + }]; + }); + const afterFiles = outputs.map((output) => { + const previousHash = expectedPlanHash(plan, output.path); + const nextHash = sha256(output.content); + return { + path: output.path, + relativePath: relativePosix(plan.workspace_root, output.path), + role: output.role, + action: previousHash === null ? "create" : previousHash === nextHash ? "unchanged" : "update", + sha256: nextHash, + }; + }); + const snapshotDir = resolve(plan.workspace_root, ...SESSION_RENDER_SNAPSHOT_RELATIVE_DIR.split("/")); + ensureSafeDirectory(snapshotDir, plan.workspace_root, 0o700); + const timestamp = now.toISOString().replace(/[:.]/g, "-"); + const snapshotPath = resolve(snapshotDir, `${timestamp}-${randomUUID()}.json`); + const snapshot = { + schema: "hasna.configs.session-render-snapshot/v2", + createdAt: now.toISOString(), + tool: manifestTool(plan.runtime), + profile: "project-context", + targetHome: plan.workspace_root, + targetKind: "project-root", + manifestPath: plan.manifest_path, + previousManifest: null, + files, + afterFiles, + }; + atomicWriteFile( + snapshotPath, + `${JSON.stringify(snapshot, null, 2)}\n`, + plan.workspace_root, + 0o600, + null, + ); + return snapshotPath; +} + function readProjectContextManifest(path: string, workspaceRoot: string): ProjectContextManifestObservation | null { if (!existsSync(path)) return null; const record = readJsonRecord(path, workspaceRoot); @@ -1724,7 +1934,7 @@ function readProjectContextCache(path: string, workspaceRoot: string): ProjectCo if (!result.success) { throw new ProjectContextError("PROJECT_CONTEXT_CACHE_INVALID", "cache is malformed or incompatible"); } - const bundle = parseProjectContextBundle(result.data.bundle); + const bundle = parseProjectContextBundleInternal(result.data.bundle, true); if ( result.data.project_id !== bundle.project.id || result.data.revision !== bundle.revision || @@ -3553,12 +3763,22 @@ function removeHashForFingerprint(value: unknown): unknown { if (!isRecord(value)) return value; const copy: Record = {}; for (const [key, item] of Object.entries(value)) { - if (key === "hash") continue; + if (key === "generated_at" || key === "hash") continue; copy[key] = item; } return copy; } +function computeLegacyProjectContextSourceHash(value: unknown): string { + if (!isRecord(value)) return `sha256:${sha256(stableStringify(value))}`; + const copy: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (key === "hash") continue; + copy[key] = item; + } + return `sha256:${sha256(stableStringify(copy))}`; +} + function sha256(content: string): string { return createHash("sha256").update(content).digest("hex"); }