diff --git a/package-lock.json b/package-lock.json index aae3ec002..e5e7f408b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -719,6 +719,10 @@ "node": ">=20" } }, + "node_modules/@diffexai/diffex-harness-state": { + "resolved": "packages/harness-state", + "link": true + }, "node_modules/@earendil-works/diffex-agent-core": { "resolved": "packages/agent", "link": true @@ -5710,6 +5714,20 @@ "dev": true, "license": "MIT" }, + "packages/harness-state": { + "name": "@diffexai/diffex-harness-state", + "version": "0.2.0", + "dependencies": { + "typebox": "1.3.7" + }, + "devDependencies": { + "shx": "0.4.0", + "vitest": "4.1.9" + }, + "engines": { + "node": ">=22.19.0" + } + }, "packages/protocol": { "name": "@earendil-works/diffex-protocol", "version": "0.2.0", diff --git a/package.json b/package.json index 1b3a4c8a3..e4cbe1721 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ ], "scripts": { "clean": "npm run clean --workspaces", - "build": "cd packages/tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", - "build:offline": "cd packages/tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", + "build": "cd packages/harness-state && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", + "build:offline": "cd packages/harness-state && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", "check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke", "check:browser-smoke": "node scripts/check-browser-smoke.mjs", "check:pinned-deps": "node scripts/check-pinned-deps.mjs", diff --git a/packages/harness-state/README.md b/packages/harness-state/README.md new file mode 100644 index 000000000..7690bb4e6 --- /dev/null +++ b/packages/harness-state/README.md @@ -0,0 +1,37 @@ +# `@diffexai/diffex-harness-state` + +Private workspace package for the Diffex harness-state schema and directory-bundle persistence. + +## State and bundle format + +A logical state contains only Diffex's evolving memory: + +```ts +{ + memory: string; +} +``` + +The memory consists of notes collected throughout the evolution process to improve Diffex's behavior. It must not start with U+FEFF and must contain a non-whitespace character. A portable bundle root is a direct directory, not a symbolic link, with exactly these direct regular files and exact casing: + +```text +state-directory/ +|-- manifest.json # SHA-256 identity and base-version metadata +`-- EVOLVE.md # memory notes as UTF-8 bytes, preserved verbatim +``` + +Both files must be fatal, BOM-free UTF-8. The manifest must contain only `contentHash` and `base`, each formatted as `sha256:` followed by 64 lowercase hexadecimal characters. Loading verifies that `contentHash` matches the exact `EVOLVE.md` bytes, including line endings and trailing whitespace. `base` identifies the predecessor's `contentHash`; a root harness conventionally uses its own `contentHash` as `base`. + +## API + +- `HarnessStateSchema`, `HarnessStateManifestSchema`, and `HarnessStateBundleSchema` are strict TypeBox schemas. Their inferred types expose readonly fields. +- `loadHarnessState(bundlePath)` validates a bundle, verifies its content hash, and returns `{ state, manifest }` without normalizing the memory. +- `saveHarnessState(state, destinationPath, options?)` derives the content hash, stages a canonical two-file bundle beside the destination, and returns `{ state, manifest }`. Passing `options.base` identifies the predecessor; omitting it creates a self-based root. The parent must already be a directory, any existing destination entry is rejected, and a failed save attempts to remove its owned staging directory. + +The package does not include or select a default harness. Callers must provide an explicit bundle path or state. + +Invalid state, layout, encoding, manifest, or memory data rejects with an ordinary descriptive `Error`. JSON syntax failures retain the original syntax error as their cause. Other filesystem errors propagate unchanged. + +## Limitations + +The state stores only the evolving memory notes. The manifest records one base hash but does not resolve predecessors, verify their existence, detect cycles, or otherwise manage lineage. The package has no migration or compatibility behavior and does not represent tools, models, context, sessions, or provenance. Persistence does not provide cross-process coordination, retries, or durability guarantees. diff --git a/packages/harness-state/package.json b/packages/harness-state/package.json new file mode 100644 index 000000000..8fd03034e --- /dev/null +++ b/packages/harness-state/package.json @@ -0,0 +1,34 @@ +{ + "name": "@diffexai/diffex-harness-state", + "version": "0.2.0", + "description": "Persistent harness state schema and bundle storage for Diffex", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "clean": "shx rm -rf dist", + "build": "tsgo -p tsconfig.build.json && shx rm -rf dist/default", + "test": "vitest --run" + }, + "engines": { + "node": ">=22.19.0" + }, + "dependencies": { + "typebox": "1.3.7" + }, + "devDependencies": { + "shx": "0.4.0", + "vitest": "4.1.9" + } +} diff --git a/packages/harness-state/src/index.ts b/packages/harness-state/src/index.ts new file mode 100644 index 000000000..c7ec00108 --- /dev/null +++ b/packages/harness-state/src/index.ts @@ -0,0 +1,150 @@ +import { createHash } from "node:crypto"; +import { lstat, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; +import { TextDecoder, TextEncoder } from "node:util"; +import Type, { type Static } from "typebox"; +import { Check } from "typebox/value"; + +const EvolutionMemorySchema = Type.Readonly(Type.String({ pattern: "^(?!\\uFEFF)(?=[\\s\\S]*\\S)[\\s\\S]*$" })); +const ContentHashSchema = Type.Readonly(Type.String({ pattern: "^sha256:[0-9a-f]{64}$" })); + +export const HarnessStateSchema = Type.Object({ memory: EvolutionMemorySchema }, { additionalProperties: false }); +export type HarnessState = Static; + +const HarnessStateSaveOptionsSchema = Type.Object( + { base: Type.Optional(ContentHashSchema) }, + { additionalProperties: false }, +); +export type HarnessStateSaveOptions = Static; + +export const HarnessStateManifestSchema = Type.Object( + { contentHash: ContentHashSchema, base: ContentHashSchema }, + { additionalProperties: false }, +); +export type HarnessStateManifest = Static; + +export const HarnessStateBundleSchema = Type.Object( + { + state: Type.Readonly(HarnessStateSchema), + manifest: Type.Readonly(HarnessStateManifestSchema), + }, + { additionalProperties: false }, +); +export type HarnessStateBundle = Static; + +const MANIFEST_NAME = "manifest.json"; +const EVOLUTION_MEMORY_NAME = "EVOLVE.md"; +const BUNDLE_ENTRY_NAMES = [MANIFEST_NAME, EVOLUTION_MEMORY_NAME] as const; +const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +const UTF8_ENCODER = new TextEncoder(); + +function computeContentHash(bytes: Uint8Array): string { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +function decodeBundleFile(bytes: Uint8Array, filePath: string): string { + let value: string; + try { + value = UTF8_DECODER.decode(bytes); + } catch (cause) { + throw new Error(`Invalid UTF-8 in harness state file "${filePath}"`, { cause }); + } + if (value.startsWith("\uFEFF")) throw new Error(`Harness state file "${filePath}" must not start with a UTF-8 BOM`); + return value; +} + +function parseManifest(value: string, manifestPath: string): HarnessStateManifest { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (cause) { + throw new Error(`Invalid JSON in harness state manifest "${manifestPath}"`, { cause }); + } + if (!Check(HarnessStateManifestSchema, parsed)) { + throw new Error(`Invalid harness state manifest at "${manifestPath}"`); + } + return parsed; +} + +async function inspectBundleDirectory(bundlePath: string): Promise { + const bundleStats = await lstat(bundlePath); + if (!bundleStats.isDirectory() || bundleStats.isSymbolicLink()) { + throw new Error(`Harness state bundle "${bundlePath}" must be a direct directory`); + } + + const entries = await readdir(bundlePath, { withFileTypes: true }); + const expectedNames = new Set(BUNDLE_ENTRY_NAMES); + if (entries.length !== BUNDLE_ENTRY_NAMES.length || entries.some((entry) => !expectedNames.has(entry.name))) { + throw new Error(`Harness state bundle "${bundlePath}" must contain exactly manifest.json and EVOLVE.md`); + } + for (const name of BUNDLE_ENTRY_NAMES) { + const entry = entries.find((candidate) => candidate.name === name); + if (!entry?.isFile() || entry.isSymbolicLink()) { + throw new Error(`Harness state bundle entry "${join(bundlePath, name)}" must be a direct regular file`); + } + } +} + +export async function loadHarnessState(bundlePath: string): Promise { + await inspectBundleDirectory(bundlePath); + const manifestPath = join(bundlePath, MANIFEST_NAME); + const memoryPath = join(bundlePath, EVOLUTION_MEMORY_NAME); + const manifestText = decodeBundleFile(await readFile(manifestPath), manifestPath); + const manifest = parseManifest(manifestText, manifestPath); + const memoryBytes = await readFile(memoryPath); + const memory = decodeBundleFile(memoryBytes, memoryPath); + const state = { memory }; + if (!Check(HarnessStateSchema, state)) throw new Error(`Invalid harness state memory at "${memoryPath}"`); + if (manifest.contentHash !== computeContentHash(memoryBytes)) { + throw new Error(`Harness state content hash mismatch at "${memoryPath}"`); + } + return { state, manifest }; +} + +async function assertDestinationAbsent(destinationPath: string): Promise { + try { + await lstat(destinationPath); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return; + throw error; + } + throw new Error(`Harness state destination "${destinationPath}" already exists`); +} + +export async function saveHarnessState( + state: unknown, + destinationPath: string, + options: HarnessStateSaveOptions = {}, +): Promise { + if (!Check(HarnessStateSchema, state)) throw new Error("Invalid harness state to save"); + if (!Check(HarnessStateSaveOptionsSchema, options)) throw new Error("Invalid harness state save options"); + const memoryBytes = UTF8_ENCODER.encode(state.memory); + const contentHash = computeContentHash(memoryBytes); + const manifest = { contentHash, base: options.base ?? contentHash }; + const manifestText = `${JSON.stringify(manifest)}\n`; + const absoluteDestinationPath = resolve(destinationPath); + const parentPath = dirname(absoluteDestinationPath); + const parentStats = await stat(parentPath); + if (!parentStats.isDirectory()) { + throw new Error(`Harness state destination parent "${parentPath}" must be a directory`); + } + await assertDestinationAbsent(absoluteDestinationPath); + + let temporaryPath: string | undefined; + try { + temporaryPath = await mkdtemp(join(parentPath, `.${basename(absoluteDestinationPath)}.harness-state.tmp-`)); + await writeFile(join(temporaryPath, MANIFEST_NAME), manifestText, "utf8"); + await writeFile(join(temporaryPath, EVOLUTION_MEMORY_NAME), memoryBytes); + await assertDestinationAbsent(absoluteDestinationPath); + await rename(temporaryPath, absoluteDestinationPath); + temporaryPath = undefined; + } catch (error) { + if (temporaryPath !== undefined) { + try { + await rm(temporaryPath, { recursive: true, force: true }); + } catch {} + } + throw error; + } + return { state, manifest }; +} diff --git a/packages/harness-state/test/harness-state.test.ts b/packages/harness-state/test/harness-state.test.ts new file mode 100644 index 000000000..124b6ad7d --- /dev/null +++ b/packages/harness-state/test/harness-state.test.ts @@ -0,0 +1,360 @@ +import { createHash } from "node:crypto"; +import { lstat, mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { Check } from "typebox/value"; +import { afterEach, describe, expect, test } from "vitest"; +import { + type HarnessState, + type HarnessStateBundle, + HarnessStateBundleSchema, + type HarnessStateManifest, + HarnessStateManifestSchema, + type HarnessStateSaveOptions, + HarnessStateSchema, + loadHarnessState, + saveHarnessState, +} from "../src/index.ts"; + +const EXTERNAL_BASE = `sha256:${"0".repeat(64)}`; +const temporaryRoots: string[] = []; + +function computeContentHash(memory: string): string { + return `sha256:${createHash("sha256").update(memory, "utf8").digest("hex")}`; +} + +function createManifest(memory: string, base = computeContentHash(memory)): string { + return `${JSON.stringify({ contentHash: computeContentHash(memory), base })}\n`; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function createTemporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "diffex-harness-state-test-")); + temporaryRoots.push(root); + return root; +} + +async function createBundle( + root: string, + name = "state", + manifest: string | Uint8Array = createManifest("Prefer focused changes.\n"), + memory: string | Uint8Array = "Prefer focused changes.\n", +): Promise { + const bundlePath = join(root, name); + await mkdir(bundlePath); + await writeFile(join(bundlePath, "manifest.json"), manifest); + await writeFile(join(bundlePath, "EVOLVE.md"), memory); + return bundlePath; +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return false; + throw error; + } +} + +function assertReadonlyTypes( + state: HarnessState, + manifest: HarnessStateManifest, + options: HarnessStateSaveOptions, + bundle: HarnessStateBundle, +): void { + // @ts-expect-error HarnessState fields are readonly. + state.memory = "changed"; + // @ts-expect-error HarnessStateManifest fields are readonly. + manifest.contentHash = "sha256:changed"; + // @ts-expect-error HarnessStateManifest fields are readonly. + manifest.base = "sha256:changed"; + // @ts-expect-error HarnessStateSaveOptions fields are readonly. + options.base = "sha256:changed"; + // @ts-expect-error HarnessStateBundle fields are readonly. + bundle.state = state; + // @ts-expect-error HarnessStateBundle fields are readonly. + bundle.manifest = manifest; +} +void assertReadonlyTypes; + +describe("schemas", () => { + test("exports strict schemas with readonly inferred fields", () => { + const state = { memory: "Focused changes produced better results." }; + const manifest = { contentHash: computeContentHash(state.memory), base: EXTERNAL_BASE }; + expect(Check(HarnessStateSchema, state)).toBe(true); + expect(Check(HarnessStateManifestSchema, manifest)).toBe(true); + expect(Check(HarnessStateBundleSchema, { state, manifest })).toBe(true); + expect(Check(HarnessStateSchema, { memory: "Prefer focused changes.", extra: true })).toBe(false); + expect(Check(HarnessStateManifestSchema, { ...manifest, extra: true })).toBe(false); + expect(Check(HarnessStateBundleSchema, { state, manifest, extra: true })).toBe(false); + }); + + test.each([ + ["blank memory", { memory: "" }], + ["whitespace-only memory", { memory: " \t\r\n" }], + ["leading U+FEFF memory", { memory: "\uFEFFObservation." }], + ] as const)("rejects %s", (_label, state) => { + expect(Check(HarnessStateSchema, state)).toBe(false); + }); + + test.each([ + ["wrong algorithm", { contentHash: "sha1:0123456789abcdef0123456789abcdef01234567" }], + ["short digest", { contentHash: "sha256:0123456789abcdef" }], + ["uppercase digest", { contentHash: "sha256:0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF" }], + ["missing content hash", {}], + ] as const)("rejects %s", (_label, manifest) => { + expect(Check(HarnessStateManifestSchema, { base: EXTERNAL_BASE, ...manifest })).toBe(false); + }); + + test.each([ + ["missing base", { contentHash: computeContentHash("Work.") }], + ["wrong base algorithm", { contentHash: computeContentHash("Work."), base: "sha1:0123456789abcdef" }], + ["short base", { contentHash: computeContentHash("Work."), base: "sha256:0123456789abcdef" }], + ["uppercase base", { contentHash: computeContentHash("Work."), base: `sha256:${"A".repeat(64)}` }], + ] as const)("rejects a manifest with %s", (_label, manifest) => { + expect(Check(HarnessStateManifestSchema, manifest)).toBe(false); + }); +}); + +describe("saving and round trips", () => { + test.each([ + ["Unicode", "Inspect caf\u00e9, \u6c34, and \u{10151}."], + ["LF", "First line\nSecond line\n"], + ["CRLF", "First line\r\nSecond line\r\n"], + ["multiline", "One\n\nThree\nFour"], + ["trailing whitespace", "Keep this exactly. \n\t "], + ] as const)("preserves %s memory content verbatim", async (_label, memory) => { + const root = await createTemporaryRoot(); + const destination = join(root, "saved-state"); + const state = { memory }; + const manifest = { contentHash: computeContentHash(memory), base: EXTERNAL_BASE }; + const saved = await saveHarnessState(state, destination, { base: EXTERNAL_BASE }); + + expect(await readFile(join(destination, "manifest.json"), "utf8")).toBe(createManifest(memory, EXTERNAL_BASE)); + expect(await readFile(join(destination, "EVOLVE.md"), "utf8")).toBe(memory); + expect(saved).toEqual({ state, manifest }); + expect(await loadHarnessState(destination)).toEqual(saved); + }); + + test("uses the computed content hash as the base for a root state", async () => { + const root = await createTemporaryRoot(); + const destination = join(root, "root-state"); + const state = { memory: "Initial evolution notes.\n" }; + const contentHash = computeContentHash(state.memory); + + const saved = await saveHarnessState(state, destination); + + expect(saved).toEqual({ state, manifest: { contentHash, base: contentHash } }); + expect(await readFile(join(destination, "manifest.json"), "utf8")).toBe(createManifest(state.memory)); + expect(await loadHarnessState(destination)).toEqual(saved); + }); + + test("validates state before filesystem access", async () => { + const root = await createTemporaryRoot(); + const destination = join(root, "missing-parent", "state"); + await expect(saveHarnessState({ memory: " " }, destination)).rejects.toThrow(/invalid harness state/i); + expect(await pathExists(destination)).toBe(false); + }); + + test("validates an explicit base before filesystem access", async () => { + const root = await createTemporaryRoot(); + const destination = join(root, "missing-parent", "state"); + await expect( + saveHarnessState({ memory: "Valid observation." }, destination, { base: "sha256:short" }), + ).rejects.toThrow(/invalid harness state save options/i); + expect(await pathExists(destination)).toBe(false); + }); + + test("requires an existing directory parent", async () => { + const root = await createTemporaryRoot(); + const parentFile = join(root, "parent-file"); + await writeFile(parentFile, "not a directory"); + await expect(saveHarnessState({ memory: "Valid observation." }, join(parentFile, "state"))).rejects.toThrow(); + }); + + test.each(["file", "directory"] as const)("refuses to overwrite an existing %s destination", async (kind) => { + const root = await createTemporaryRoot(); + const destination = join(root, "state"); + if (kind === "file") await writeFile(destination, "original"); + else await mkdir(destination); + + await expect(saveHarnessState({ memory: "Replacement notes." }, destination)).rejects.toThrow(/already exists/i); + if (kind === "file") expect(await readFile(destination, "utf8")).toBe("original"); + else expect(await readdir(destination)).toEqual([]); + }); + + test("refuses a dangling linked destination", async () => { + const root = await createTemporaryRoot(); + const destination = join(root, "state"); + await symlink(join(root, "missing-target"), destination, process.platform === "win32" ? "junction" : "dir"); + await expect(saveHarnessState({ memory: "Replacement notes." }, destination)).rejects.toThrow(/already exists/i); + }); + + test("cleans its staged directory after a late failure without exposing a partial bundle", async () => { + const root = await createTemporaryRoot(); + const destination = join(root, "state"); + const temporaryPrefix = `.${basename(destination)}.harness-state.tmp-`; + const saving = saveHarnessState({ memory: "x".repeat(16 * 1024 * 1024) }, destination).then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }), + ); + + let stagedPath: string | undefined; + for (let attempt = 0; attempt < 1_000; attempt++) { + const stagedName = (await readdir(root)).find((entry) => entry.startsWith(temporaryPrefix)); + if (stagedName !== undefined) { + stagedPath = join(root, stagedName); + break; + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 1)); + } + expect(stagedPath).toBeDefined(); + + await mkdir(destination); + await writeFile(join(destination, "unrelated"), "preserved"); + const result = await saving; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected the concurrent destination to make saving fail"); + expect(result.error).toBeInstanceOf(Error); + expect(await readdir(destination)).toEqual(["unrelated"]); + expect((await readdir(root)).filter((entry) => entry.startsWith(temporaryPrefix))).toEqual([]); + }); +}); + +describe("bundle layout", () => { + test("rejects a missing root", async () => { + const root = await createTemporaryRoot(); + await expect(loadHarnessState(join(root, "missing"))).rejects.toThrow(); + }); + + test("rejects a non-directory root", async () => { + const root = await createTemporaryRoot(); + const bundlePath = join(root, "state"); + await writeFile(bundlePath, "not a directory"); + await expect(loadHarnessState(bundlePath)).rejects.toThrow(/direct directory/i); + }); + + test("rejects a linked root", async () => { + const root = await createTemporaryRoot(); + const target = await createBundle(root, "target"); + const bundlePath = join(root, "linked-state"); + await symlink(target, bundlePath, process.platform === "win32" ? "junction" : "dir"); + await expect(loadHarnessState(bundlePath)).rejects.toThrow(/direct directory/i); + }); + + test.each(["manifest.json", "EVOLVE.md"] as const)("rejects a missing %s entry", async (missingName) => { + const root = await createTemporaryRoot(); + const bundlePath = await createBundle(root); + await rm(join(bundlePath, missingName)); + await expect(loadHarnessState(bundlePath)).rejects.toThrow(/exactly manifest\.json and EVOLVE\.md/i); + }); + + test("rejects additional entries", async () => { + const root = await createTemporaryRoot(); + const bundlePath = await createBundle(root); + await writeFile(join(bundlePath, "extra.txt"), "extra"); + await expect(loadHarnessState(bundlePath)).rejects.toThrow(/exactly manifest\.json and EVOLVE\.md/i); + }); + + test("rejects mis-cased entries", async () => { + const root = await createTemporaryRoot(); + const bundlePath = join(root, "state"); + await mkdir(bundlePath); + await writeFile(join(bundlePath, "MANIFEST.json"), createManifest("Valid.")); + await writeFile(join(bundlePath, "EVOLVE.md"), "Valid."); + await expect(loadHarnessState(bundlePath)).rejects.toThrow(/exactly manifest\.json and EVOLVE\.md/i); + }); + + test.each(["manifest.json", "EVOLVE.md"] as const)("rejects a linked %s entry", async (linkedName) => { + const root = await createTemporaryRoot(); + const bundlePath = await createBundle(root); + await rm(join(bundlePath, linkedName)); + const linkTarget = join(root, `${linkedName}-target`); + await mkdir(linkTarget); + await symlink(linkTarget, join(bundlePath, linkedName), process.platform === "win32" ? "junction" : "dir"); + await expect(loadHarnessState(bundlePath)).rejects.toThrow(/direct regular file/i); + }); + + test.each(["manifest.json", "EVOLVE.md"] as const)("rejects a non-regular %s entry", async (entryName) => { + const root = await createTemporaryRoot(); + const bundlePath = await createBundle(root); + await rm(join(bundlePath, entryName)); + await mkdir(join(bundlePath, entryName)); + await expect(loadHarnessState(bundlePath)).rejects.toThrow(/direct regular file/i); + }); +}); + +describe("bundle data validation", () => { + test.each(["manifest.json", "EVOLVE.md"] as const)("rejects malformed UTF-8 in %s", async (entryName) => { + const root = await createTemporaryRoot(); + const bundlePath = await createBundle(root); + await writeFile(join(bundlePath, entryName), new Uint8Array([0xc3, 0x28])); + await expect(loadHarnessState(bundlePath)).rejects.toThrow(new RegExp(entryName.replace(".", "\\."), "i")); + }); + + test.each(["manifest.json", "EVOLVE.md"] as const)("rejects a UTF-8 BOM in %s", async (entryName) => { + const root = await createTemporaryRoot(); + const bundlePath = await createBundle(root); + const validContent = + entryName === "manifest.json" ? createManifest("Prefer focused changes.\n") : "Valid memory."; + await writeFile( + join(bundlePath, entryName), + Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(validContent)]), + ); + await expect(loadHarnessState(bundlePath)).rejects.toThrow(/BOM/i); + }); + + test("wraps malformed manifest JSON with its path and syntax error cause", async () => { + const root = await createTemporaryRoot(); + const bundlePath = await createBundle(root, "state", "{not-json}\n"); + const manifestPath = join(bundlePath, "manifest.json"); + let thrown: unknown; + try { + await loadHarnessState(bundlePath); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toContain(manifestPath); + expect((thrown as Error).cause).toBeInstanceOf(SyntaxError); + }); + + test.each([ + [ + "wrong algorithm", + `{"contentHash":"sha1:0123456789abcdef0123456789abcdef01234567","base":"${EXTERNAL_BASE}"}\n`, + ], + ["unknown field", `${createManifest("Act carefully.\n").trimEnd().slice(0, -1)},"extra":true}\n`], + ["missing content hash", `{"base":"${EXTERNAL_BASE}"}\n`], + ["missing base", `{"contentHash":"${computeContentHash("Act carefully.\n")}"}\n`], + [ + "wrong base algorithm", + `{"contentHash":"${computeContentHash("Act carefully.\n")}","base":"sha1:0123456789abcdef"}\n`, + ], + ["non-object", "[]\n"], + ] as const)("rejects a manifest with %s", async (_label, manifest) => { + const root = await createTemporaryRoot(); + const bundlePath = await createBundle(root, "state", manifest); + await expect(loadHarnessState(bundlePath)).rejects.toThrow(/manifest/i); + }); + + test.each([ + ["blank", ""], + ["whitespace-only", " \t\r\n"], + ["leading U+FEFF", "\uFEFFObservation."], + ] as const)("rejects a %s memory", async (_label, memory) => { + const root = await createTemporaryRoot(); + const bundlePath = await createBundle(root, "state", createManifest(String(memory)), memory); + await expect(loadHarnessState(bundlePath)).rejects.toThrow(/EVOLVE\.md/i); + }); + + test("rejects memory content that does not match the manifest hash", async () => { + const root = await createTemporaryRoot(); + const bundlePath = await createBundle(root, "state", createManifest("Original."), "Changed."); + await expect(loadHarnessState(bundlePath)).rejects.toThrow(/content hash mismatch/i); + }); +}); diff --git a/packages/harness-state/tsconfig.build.json b/packages/harness-state/tsconfig.build.json new file mode 100644 index 000000000..695dd9ad2 --- /dev/null +++ b/packages/harness-state/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] +} diff --git a/packages/harness-state/tsconfig.test.json b/packages/harness-state/tsconfig.test.json new file mode 100644 index 000000000..e1276ec0b --- /dev/null +++ b/packages/harness-state/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node", "vitest"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/harness-state/vitest.config.ts b/packages/harness-state/vitest.config.ts new file mode 100644 index 000000000..157f467a0 --- /dev/null +++ b/packages/harness-state/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"], + }, +}); diff --git a/tsconfig.json b/tsconfig.json index 13b2a0956..e0593e724 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "moduleResolution": "NodeNext", "paths": { "*": ["./*"], + "@diffexai/diffex-harness-state": ["./packages/harness-state/src/index.ts"], "@earendil-works/diffex-telemetry": ["./packages/telemetry/src/index.ts"], "@earendil-works/diffex-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"], "@earendil-works/diffex-ai": ["./packages/ai/src/index.ts"],