From a0209ee1029b8a95af4a6a92ee53e35e3c87fc2f Mon Sep 17 00:00:00 2001 From: mosaic-coder Date: Fri, 31 Jul 2026 17:49:24 -0500 Subject: [PATCH 1/7] fix: make checkout gates environment-aware --- .gitignore | 1 + .husky/pre-push | 2 +- .npmrc | 8 +- README.md | 7 + .../rm-01-reproducible-checkout.md | 20 ++ package.json | 9 +- scripts/clean-generated.mjs | 34 ++++ scripts/install-hooks.mjs | 182 ++++++++++++++++++ scripts/install-hooks.test.mjs | 148 ++++++++++++++ scripts/preflight.mjs | 127 ++++++++++++ scripts/preflight.test.mjs | 104 ++++++++++ 11 files changed, 634 insertions(+), 8 deletions(-) create mode 100644 docs/scratchpads/rm-01-reproducible-checkout.md create mode 100644 scripts/clean-generated.mjs create mode 100644 scripts/install-hooks.mjs create mode 100644 scripts/install-hooks.test.mjs create mode 100644 scripts/preflight.mjs create mode 100644 scripts/preflight.test.mjs diff --git a/.gitignore b/.gitignore index dd50108f..e86bc179 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage .env.local *.tsbuildinfo .pnpm-store +__pycache__/ docs/reports/ # Step-CA dev password — real file is gitignored; commit only the .example diff --git a/.husky/pre-push b/.husky/pre-push index 4a0f0e8a..aae2e7e1 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1 @@ -pnpm typecheck && pnpm lint && pnpm format:check +pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check diff --git a/.npmrc b/.npmrc index e72177a2..17a95ce4 100644 --- a/.npmrc +++ b/.npmrc @@ -1,5 +1,5 @@ @mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ -# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci), -# so the pipeline `pnpm install --prefer-offline` consumes the baked store -# instead of repopulating a fresh one. -store-dir=/root/.local/share/pnpm/store +# HOME resolves to /root in the ci-base image, preserving its warmed-store path. +# Non-root checkouts use their own HOME. Override without editing this file via +# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting). +store-dir=${HOME}/.local/share/pnpm/store diff --git a/README.md b/README.md index f095ee62..cefdc60d 100644 --- a/README.md +++ b/README.md @@ -201,8 +201,14 @@ git clone git@git.mosaicstack.dev:mosaicstack/stack.git cd stack # Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset. +# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without +# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed. pnpm install +# Verify dependencies and generated state before running source-quality gates. +# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43. +pnpm preflight + # Optional local queue service only. This does not start PostgreSQL. docker compose up -d valkey @@ -230,6 +236,7 @@ Gateway start command until KBN-101-02 makes that state fail closed. ### Quality Gates ```bash +pnpm preflight # Checkout/dependency/generated-state validation pnpm typecheck # TypeScript type checking (all packages) pnpm lint # ESLint (all packages) pnpm test # Vitest (all packages) diff --git a/docs/scratchpads/rm-01-reproducible-checkout.md b/docs/scratchpads/rm-01-reproducible-checkout.md new file mode 100644 index 00000000..a536c951 --- /dev/null +++ b/docs/scratchpads/rm-01-reproducible-checkout.md @@ -0,0 +1,20 @@ +# RM-01 — Reproducible checkout + +- Task/ref: RM-01 (`docs/remediation/TASKS.md`, internal mission tracking) +- Objective: make checkout/install/typecheck hooks fail on code rather than environmental residue, for root CI and non-root seats. +- Scope: pnpm store configuration, transactional Husky installation, dependency/generated-state preflight, checkout regression tests, developer documentation. +- Constraints: isolated worktree; no skip-switch fixes; no writes under `/root` or `/tmp`; workers do not edit `docs/remediation/TASKS.md`; author does not review or merge. +- Acceptance: AC1–AC8 from the orchestrator dispatch/addendum. +- Plan: + 1. Add RED-first tests for missing dependencies, stale/foreign `.next`, and interrupted hook installation. + 2. Implement environment-overridable HOME-based pnpm store defaults, deterministic preflight, and transactional hook installation. + 3. Run focused tests, install/build/baseline gates, and explicit AC negative controls. + 4. Obtain independent review, push after queue guard, open PR, and send evidence to `mos-remediation`. +- Budget: orchestrator estimate 6K/60K; no explicit hard token cap. Keep scope to RM-01 and avoid unrelated cleanup. +- Risks: 97%-full shared `/tmp`; native dependency install size; root-owned fixtures may require Docker for realistic verification. + +## Progress / evidence + +- Worktree created at `/home/hermes/agent-work/rm-01` from `origin/main` `06e0d403`. +- `/tmp` baseline: 28G used, 889M available (97%); worktree and planned store are on `/home`. +- Root causes confirmed from source: committed `.npmrc` pins `/root`; `prepare` invokes Husky directly; web typecheck includes generated `.next` types without validating ownership/freshness. diff --git a/package.json b/package.json index fb75bde1..f52dd9d6 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,14 @@ "build": "turbo run build", "dev": "turbo run dev", "lint": "turbo run lint", - "typecheck": "turbo run typecheck", - "test": "turbo run test", + "preflight": "node scripts/preflight.mjs", + "clean:generated": "node scripts/clean-generated.mjs", + "typecheck": "pnpm preflight && turbo run typecheck", + "test:checkout": "node --test scripts/*.test.mjs", + "test": "pnpm test:checkout && turbo run test", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", - "prepare": "husky" + "prepare": "node scripts/install-hooks.mjs" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0", diff --git a/scripts/clean-generated.mjs b/scripts/clean-generated.mjs new file mode 100644 index 00000000..54e42c84 --- /dev/null +++ b/scripts/clean-generated.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +import { access, mkdir, rename, rm } from 'node:fs/promises'; +import path from 'node:path'; + +const root = process.cwd(); +const generated = path.join(root, 'apps', 'web', '.next'); +const quarantineRoot = path.join(root, '.mosaic-test-work', 'generated-quarantine'); + +try { + await access(generated); +} catch (error) { + if (error.code === 'ENOENT') process.exit(0); + throw error; +} + +await mkdir(quarantineRoot, { recursive: true }); +const quarantine = path.join(quarantineRoot, `web-next-${Date.now()}-${process.pid}`); +try { + await rename(generated, quarantine); +} catch (error) { + console.error( + `MOSAIC_GENERATED_CLEAN_FAILED: could not quarantine apps/web/.next. Fix: sudo rm -rf '${generated}', then rerun pnpm preflight`, + ); + throw error; +} + +try { + await rm(quarantine, { recursive: true, force: true }); +} catch { + console.warn( + `Generated state was deactivated but could not be deleted; quarantined at ${quarantine}`, + ); +} diff --git a/scripts/install-hooks.mjs b/scripts/install-hooks.mjs new file mode 100644 index 00000000..8c9394ab --- /dev/null +++ b/scripts/install-hooks.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node + +import { access, lstat, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises'; +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const execFileAsync = promisify(execFile); + +function run(command, args, options) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, options); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(signal ? `husky terminated by ${signal}` : `husky exited ${code}`)); + }); + }); +} + +async function pathExists(target) { + try { + await access(target); + return true; + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } +} + +async function directorySnapshot(root) { + const snapshot = []; + async function walk(current) { + const children = await readdir(current, { withFileTypes: true }); + for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) { + const target = path.join(current, child.name); + const relative = path.relative(root, target); + const stats = await lstat(target); + if (child.isDirectory()) { + snapshot.push([relative, 'directory', stats.mode & 0o777]); + await walk(target); + } else { + snapshot.push([ + relative, + 'file', + stats.mode & 0o777, + (await readFile(target)).toString('base64'), + ]); + } + } + } + await walk(root); + return JSON.stringify(snapshot); +} + +async function directoriesMatch(left, right) { + return (await directorySnapshot(left)) === (await directorySnapshot(right)); +} + +export async function installHooks({ + root = process.cwd(), + disabled = process.env.HUSKY === '0', + quarantineRoot = path.join(root, '.mosaic-test-work', 'husky-quarantine'), + runHusky = async (_stagingHooks, stagingRepo) => { + await execFileAsync('git', ['init', '--quiet', stagingRepo]); + await run(path.join(root, 'node_modules', '.bin', 'husky'), ['.husky'], { + cwd: stagingRepo, + stdio: 'inherit', + }); + }, + activateHooks = async () => { + await run('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: root, stdio: 'inherit' }); + }, + readHooksPath = async () => { + try { + const { stdout } = await execFileAsync('git', ['config', '--get', 'core.hooksPath'], { + cwd: root, + }); + return stdout.trim() || null; + } catch (error) { + if (error.code === 1) return null; + throw error; + } + }, + restoreHooksPath = async (previous) => { + if (previous === null) { + try { + await execFileAsync('git', ['config', '--unset', 'core.hooksPath'], { cwd: root }); + } catch (error) { + if (error.code !== 5) throw error; + } + } else { + await execFileAsync('git', ['config', 'core.hooksPath', previous], { cwd: root }); + } + }, +} = {}) { + if (disabled) return; + + const huskyDir = path.join(root, '.husky'); + const active = path.join(huskyDir, '_'); + const nonce = `${Date.now()}-${process.pid}`; + const stagingRepo = path.join(root, '.mosaic-test-work', `husky-stage-${nonce}`); + const stagingHooks = path.join(stagingRepo, '.husky'); + const quarantined = path.join(quarantineRoot, `${path.basename(root)}-${nonce}`); + + const previousHooksPath = await readHooksPath(); + await mkdir(huskyDir, { recursive: true }); + await mkdir(quarantineRoot, { recursive: true }); + + const previousComplete = (await pathExists(active)) && (await pathExists(path.join(active, 'h'))); + let previousQuarantined = false; + try { + if ((await pathExists(active)) && !previousComplete) { + await rename(active, quarantined); + previousQuarantined = true; + } + await mkdir(stagingRepo, { recursive: true }); + await runHusky(stagingHooks, stagingRepo); + const staged = path.join(stagingHooks, '_'); + if (!(await pathExists(path.join(staged, 'h')))) { + throw new Error('husky did not produce its required h shim'); + } + if (previousComplete) { + if (!(await directoriesMatch(active, staged))) { + throw new Error('existing complete hook set differs from the installed Husky version'); + } + await rm(stagingRepo, { recursive: true, force: true }); + } else { + await rename(staged, active); + await rm(stagingRepo, { recursive: true, force: true }); + } + await activateHooks(); + if (previousQuarantined) { + try { + await rm(quarantined, { recursive: true, force: true }); + } catch { + console.warn( + `Previous hook state was deactivated but remains quarantined at ${quarantined}`, + ); + } + } + } catch (error) { + const cleanupFailures = []; + try { + if ((await pathExists(active)) && !previousComplete) { + await rename(active, `${quarantined}-failed`); + } + } catch (cleanupError) { + cleanupFailures.push(`active hooks: ${cleanupError.message}`); + } + try { + if (await pathExists(stagingRepo)) { + await rename(stagingRepo, `${quarantined}-staging`); + } + } catch (cleanupError) { + cleanupFailures.push(`staging hooks: ${cleanupError.message}`); + } + try { + await restoreHooksPath(previousHooksPath); + } catch (cleanupError) { + cleanupFailures.push(`core.hooksPath: ${cleanupError.message}`); + } + const cleanup = + cleanupFailures.length === 0 + ? 'No partial hook set was activated.' + : `Automatic cleanup was incomplete (${cleanupFailures.join('; ')}).`; + throw new Error( + `Hook installation failed: ${error.message}. ${cleanup} Fix: rm -rf .husky/_ && git config core.hooksPath .husky/_ && pnpm install --frozen-lockfile`, + { cause: error }, + ); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + await installHooks(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/install-hooks.test.mjs b/scripts/install-hooks.test.mjs new file mode 100644 index 00000000..1b5087dc --- /dev/null +++ b/scripts/install-hooks.test.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +import { installHooks } from './install-hooks.mjs'; + +const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `hooks-${process.pid}`); +const quarantineRoot = path.join(fixtureRoot, 'quarantine'); + +async function fixture(name) { + const root = path.join(fixtureRoot, name); + await mkdir(path.join(root, '.husky'), { recursive: true }); + return root; +} + +async function exists(target) { + try { + await access(target); + return true; + } catch { + return false; + } +} + +test.after(async () => { + await rm(fixtureRoot, { recursive: true, force: true }); +}); + +test('an interrupted install quarantines the partial active hook set and fails loudly', async () => { + const root = await fixture('interrupted'); + let restoredHooksPath = 'not-called'; + + await assert.rejects( + installHooks({ + root, + quarantineRoot, + runHusky: async (stagingHooks) => { + await mkdir(path.join(stagingHooks, '_'), { recursive: true }); + await writeFile(path.join(stagingHooks, '_', 'h'), 'partial'); + throw new Error('simulated interruption'); + }, + activateHooks: async () => {}, + readHooksPath: async () => null, + restoreHooksPath: async (value) => { + restoredHooksPath = value; + }, + }), + (error) => { + assert.match(error.message, /Hook installation failed/); + assert.match(error.message, /pnpm install --frozen-lockfile/); + return true; + }, + ); + + assert.equal(await exists(path.join(root, '.husky', '_')), false); + assert.equal(restoredHooksPath, null); + const quarantined = await readdir(quarantineRoot); + assert.equal(quarantined.length, 1); +}); + +test('a failed replacement restores a previously complete active hook set', async () => { + const root = await fixture('rollback'); + const activeShim = path.join(root, '.husky', '_', 'h'); + await mkdir(path.dirname(activeShim), { recursive: true }); + await writeFile(activeShim, 'previous-complete'); + let previousRemainedActiveDuringStaging = false; + + await assert.rejects( + installHooks({ + root, + quarantineRoot: path.join(fixtureRoot, 'rollback-quarantine'), + runHusky: async () => { + previousRemainedActiveDuringStaging = + (await readFile(activeShim, 'utf8')) === 'previous-complete'; + throw new Error('simulated replacement failure'); + }, + activateHooks: async () => {}, + readHooksPath: async () => '.husky/_', + restoreHooksPath: async () => {}, + }), + /Hook installation failed/, + ); + + assert.equal(previousRemainedActiveDuringStaging, true); + assert.equal(await readFile(activeShim, 'utf8'), 'previous-complete'); +}); + +test('a mismatched complete hook set fails loudly instead of reporting a stale install as current', async () => { + const root = await fixture('mismatch'); + const activeShim = path.join(root, '.husky', '_', 'h'); + await mkdir(path.dirname(activeShim), { recursive: true }); + await writeFile(activeShim, 'old-complete'); + + await assert.rejects( + installHooks({ + root, + quarantineRoot: path.join(fixtureRoot, 'mismatch-quarantine'), + runHusky: async (stagingHooks) => { + await mkdir(path.join(stagingHooks, '_'), { recursive: true }); + await writeFile(path.join(stagingHooks, '_', 'h'), 'new-complete'); + }, + activateHooks: async () => {}, + readHooksPath: async () => '.husky/_', + restoreHooksPath: async () => {}, + }), + /Hook installation failed.*pnpm install --frozen-lockfile/, + ); + + assert.equal(await readFile(activeShim, 'utf8'), 'old-complete'); +}); + +test('an explicit interactive HUSKY=0 opt-out preserves existing hooks without running installer', async () => { + const root = await fixture('disabled'); + const activeShim = path.join(root, '.husky', '_', 'h'); + await mkdir(path.dirname(activeShim), { recursive: true }); + await writeFile(activeShim, 'preserved'); + let ran = false; + + await installHooks({ + root, + disabled: true, + runHusky: async () => { + ran = true; + }, + }); + + assert.equal(ran, false); + assert.equal(await readFile(activeShim, 'utf8'), 'preserved'); +}); + +test('a successful install leaves a complete active hook set', async () => { + const root = await fixture('success'); + + await installHooks({ + root, + quarantineRoot, + runHusky: async (stagingHooks) => { + await mkdir(path.join(stagingHooks, '_'), { recursive: true }); + await writeFile(path.join(stagingHooks, '_', 'h'), 'complete'); + }, + activateHooks: async () => {}, + readHooksPath: async () => null, + restoreHooksPath: async () => {}, + }); + + assert.equal(await exists(path.join(root, '.husky', '_', 'h')), true); +}); diff --git a/scripts/preflight.mjs b/scripts/preflight.mjs new file mode 100644 index 00000000..7c2602e5 --- /dev/null +++ b/scripts/preflight.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node + +import { constants } from 'node:fs'; +import { access, lstat, readdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +export const MISSING_DEPS_EXIT = 42; +export const GENERATED_STATE_EXIT = 43; + +async function entries(root) { + const result = []; + async function walk(current) { + let children; + try { + children = await readdir(current, { withFileTypes: true }); + } catch (error) { + if (error.code === 'ENOENT') return; + throw error; + } + for (const child of children) { + const target = path.join(current, child.name); + result.push(target); + if (child.isDirectory() && !child.isSymbolicLink()) await walk(target); + } + } + await walk(root); + return result; +} + +async function fileMtimeRange(paths) { + let newest = 0; + let oldest = Number.POSITIVE_INFINITY; + for (const target of paths) { + const stats = await lstat(target); + if (stats.isFile()) { + newest = Math.max(newest, stats.mtimeMs); + oldest = Math.min(oldest, stats.mtimeMs); + } + } + return { newest, oldest: Number.isFinite(oldest) ? oldest : 0 }; +} + +export async function runPreflight({ root = process.cwd(), uid = process.getuid?.() } = {}) { + const binDir = path.join(root, 'node_modules', '.bin'); + const requiredBinaries = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest']; + const missingBinaries = []; + for (const binary of requiredBinaries) { + try { + await access(path.join(binDir, binary), constants.X_OK); + } catch { + missingBinaries.push(binary); + } + } + if (missingBinaries.length > 0) { + return { + code: MISSING_DEPS_EXIT, + message: `MOSAIC_PREFLIGHT_MISSING_DEPS: dependency installation is missing ${missingBinaries.join(', ')}; run pnpm install --frozen-lockfile`, + }; + } + + const nextDir = path.join(root, 'apps', 'web', '.next'); + let generated = []; + try { + await lstat(nextDir); + generated = [nextDir, ...(await entries(nextDir))]; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + + if (generated.length > 0) { + const foreign = []; + if (uid !== undefined) { + for (const target of generated) { + if ((await lstat(target)).uid !== uid) foreign.push(path.relative(root, target)); + } + } + + const sourceRoots = [ + path.join(root, 'apps', 'web', 'src'), + path.join(root, 'apps', 'web', 'next.config.ts'), + path.join(root, 'apps', 'web', 'package.json'), + path.join(root, 'apps', 'web', 'tsconfig.json'), + ]; + const source = []; + for (const sourceRoot of sourceRoots) { + try { + const stats = await lstat(sourceRoot); + source.push(sourceRoot); + if (stats.isDirectory()) source.push(...(await entries(sourceRoot))); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } + + const generatedTypes = generated.filter((target) => + target.startsWith(path.join(nextDir, 'types') + path.sep), + ); + const sourceMtime = await fileMtimeRange(source); + const generatedMtime = await fileMtimeRange(generatedTypes); + const stale = + source.length > 0 && generatedTypes.length > 0 && sourceMtime.newest > generatedMtime.oldest; + if (foreign.length > 0 || stale) { + const reasons = [ + foreign.length > 0 ? `foreign-owned paths: ${foreign.slice(0, 3).join(', ')}` : '', + stale ? 'generated output is older than web source/configuration' : '', + ].filter(Boolean); + return { + code: GENERATED_STATE_EXIT, + message: `MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next is not trustworthy (${reasons.join('; ')}); run pnpm clean:generated, then rerun the gate`, + }; + } + } + + return { code: 0, message: 'checkout preflight passed' }; +} + +async function main() { + const result = await runPreflight(); + const stream = result.code === 0 ? process.stdout : process.stderr; + stream.write(`${result.message}\n`); + process.exitCode = result.code; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/scripts/preflight.test.mjs b/scripts/preflight.test.mjs new file mode 100644 index 00000000..0d63fb3c --- /dev/null +++ b/scripts/preflight.test.mjs @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +import { runPreflight } from './preflight.mjs'; + +const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `preflight-${process.pid}`); + +const requiredBins = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest']; + +async function fixture(name) { + const root = path.join(fixtureRoot, name); + await mkdir(path.join(root, 'apps', 'web', 'src', 'app'), { recursive: true }); + await writeFile(path.join(root, 'apps', 'web', 'src', 'app', 'page.tsx'), 'export default 1;\n'); + return root; +} + +async function installRequiredBins(root) { + const binDir = path.join(root, 'node_modules', '.bin'); + await mkdir(binDir, { recursive: true }); + await Promise.all( + requiredBins.map(async (name) => { + const target = path.join(binDir, name); + await writeFile(target, ''); + await chmod(target, 0o755); + }), + ); +} + +test.after(async () => { + await rm(fixtureRoot, { recursive: true, force: true }); +}); + +test('missing dependencies have a dedicated exit code and install remediation', async () => { + const root = await fixture('missing-deps'); + const result = await runPreflight({ root }); + + assert.equal(result.code, 42); + assert.match(result.message, /MOSAIC_PREFLIGHT_MISSING_DEPS/); + assert.match(result.message, /run pnpm install/i); +}); + +test('a partial dependency install keeps the dedicated missing-deps result', async () => { + const root = await fixture('partial-deps'); + await mkdir(path.join(root, 'node_modules', '.bin'), { recursive: true }); + await writeFile(path.join(root, 'node_modules', '.bin', 'tsc'), '', { mode: 0o755 }); + + const result = await runPreflight({ root }); + assert.equal(result.code, 42); + assert.match(result.message, /turbo/); +}); + +test('a dangling required dependency shim keeps the dedicated missing-deps result', async () => { + const root = await fixture('dangling-deps'); + await installRequiredBins(root); + const turbo = path.join(root, 'node_modules', '.bin', 'turbo'); + await rm(turbo); + await symlink(path.join(root, 'node_modules', 'missing-turbo'), turbo); + + const result = await runPreflight({ root }); + assert.equal(result.code, 42); + assert.match(result.message, /turbo/); +}); + +test('installed dependencies pass when generated state is absent', async () => { + const root = await fixture('clean'); + await installRequiredBins(root); + + assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' }); +}); + +test('foreign-owned generated Next state is identified separately from source errors', async () => { + const root = await fixture('foreign-next'); + await installRequiredBins(root); + const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts'); + await mkdir(path.dirname(generated), { recursive: true }); + await writeFile(generated, 'generated output'); + + const result = await runPreflight({ root, uid: (process.getuid?.() ?? 0) + 1 }); + assert.equal(result.code, 43); + assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/); + assert.match(result.message, /foreign-owned/); +}); + +test('stale generated Next state is identified separately from source errors', async () => { + const root = await fixture('stale-next'); + await installRequiredBins(root); + const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts'); + await mkdir(path.dirname(generated), { recursive: true }); + await writeFile(generated, 'stale generated output'); + const old = new Date('2020-01-01T00:00:00Z'); + await utimes(generated, old, old); + const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts'); + await writeFile(fresh, 'fresh generated output'); + const future = new Date('2030-01-01T00:00:00Z'); + await utimes(fresh, future, future); + + const result = await runPreflight({ root }); + assert.equal(result.code, 43); + assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/); + assert.match(result.message, /apps\/web\/\.next/); + assert.match(result.message, /pnpm clean:generated/); +}); -- 2.54.0 From ece7653ca0012ecde2ab2942f64159d2fb08c4b8 Mon Sep 17 00:00:00 2001 From: mosaic-coder Date: Fri, 31 Jul 2026 17:50:49 -0500 Subject: [PATCH 2/7] test: capture remaining checkout race regressions --- .../rm-01-reproducible-checkout.md | 19 +++++++++++++ scripts/install-hooks.test.mjs | 28 +++++++++++++++++++ scripts/preflight.test.mjs | 28 +++++++++++++------ 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/docs/scratchpads/rm-01-reproducible-checkout.md b/docs/scratchpads/rm-01-reproducible-checkout.md index a536c951..75196840 100644 --- a/docs/scratchpads/rm-01-reproducible-checkout.md +++ b/docs/scratchpads/rm-01-reproducible-checkout.md @@ -18,3 +18,22 @@ - Worktree created at `/home/hermes/agent-work/rm-01` from `origin/main` `06e0d403`. - `/tmp` baseline: 28G used, 889M available (97%); worktree and planned store are on `/home`. - Root causes confirmed from source: committed `.npmrc` pins `/root`; `prepare` invokes Husky directly; web typecheck includes generated `.next` types without validating ownership/freshness. + +## Checkpoint evidence (c45e5e19) + +- AC1 IN PROGRESS: non-root `pnpm install --frozen-lockfile --store-dir "$HOME/.local/share/pnpm/store"` exited 0; `pnpm exec turbo run typecheck --force` exited 0 (45/45 uncached). Clean CI-container run not performed. +- AC2 DONE: with `node_modules` absent, `pnpm preflight` exited 42 with `MOSAIC_PREFLIGHT_MISSING_DEPS` and `run pnpm install`; after install it exited 0. +- AC3 DONE: appending `export const x: number = "s"` to `packages/types/src/index.ts` made `pnpm -w typecheck` exit 2 with TS2322; reverting made it exit 0. +- AC4 IN PROGRESS: local `pnpm -w build` exited 0 and `git status --porcelain` showed no generated residue beyond the intended RM-01 source changes. Fresh-clone proof not performed. +- AC5 DONE: non-root install exited 0; `pnpm store path` resolved `/home/hermes/.local/share/pnpm/store/v10`; no `/root` write was attempted. +- AC6 IN PROGRESS: focused failure/rollback tests passed, but final review found a concurrent-install race. Two installers can both observe `.husky/_` absent; after one installs successfully, the losing install's catch path can quarantine the winner's active hooks and restore stale Git config (`scripts/install-hooks.mjs`, activation/catch transaction). A RED regression is committed after the checkpoint. +- AC7 DONE: install/store/worktree were on `/home`; full `pnpm -w build` exited 0; `/tmp` usage changed by 4096 bytes during the build (23,805,173,760 → 23,805,177,856 bytes), not materially. +- AC8 DONE for the implemented path: store resolves under `$HOME`; test/quarantine/build state resolves under the worktree; no implemented component requires a writable path outside `$HOME` or the worktree. + +## Handoff + +1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation. +2. The latest automated review rejected oldest-generated-file mtime as a false positive for valid incremental Next output. Use a source-content fingerprint marker written only after successful `next build`; do not continue tuning mtimes. +3. For Husky, generation in an isolated temporary Git repo avoids mutating real `core.hooksPath` during staging. Preserve that design. Fix the losing concurrent process so it never removes a peer's completed hook set or restores stale config. +4. Codex review runs in a read-only sandbox, so its attempts to run the fixture-writing Node tests report opaque test-file failures. The same tests run normally in the worktree. +5. Full `pnpm test` is not green on this host: it exits 97 at the pre-existing Bash `BASH_LINENO` convention guard (#1003), after the changed checkout tests and package tests pass. Do not weaken that gate. diff --git a/scripts/install-hooks.test.mjs b/scripts/install-hooks.test.mjs index 1b5087dc..54e2c517 100644 --- a/scripts/install-hooks.test.mjs +++ b/scripts/install-hooks.test.mjs @@ -110,6 +110,34 @@ test('a mismatched complete hook set fails loudly instead of reporting a stale i assert.equal(await readFile(activeShim, 'utf8'), 'old-complete'); }); +test('a competing successful installer is not removed by the losing process', async () => { + const root = await fixture('concurrent'); + const activeShim = path.join(root, '.husky', '_', 'h'); + let restored = false; + + await assert.rejects( + installHooks({ + root, + quarantineRoot: path.join(fixtureRoot, 'concurrent-quarantine'), + runHusky: async (stagingHooks) => { + await mkdir(path.join(stagingHooks, '_'), { recursive: true }); + await writeFile(path.join(stagingHooks, '_', 'h'), 'ours'); + await mkdir(path.dirname(activeShim), { recursive: true }); + await writeFile(activeShim, 'peer'); + }, + activateHooks: async () => {}, + readHooksPath: async () => null, + restoreHooksPath: async () => { + restored = true; + }, + }), + /Hook installation failed/, + ); + + assert.equal(await readFile(activeShim, 'utf8'), 'peer'); + assert.equal(restored, false); +}); + test('an explicit interactive HUSKY=0 opt-out preserves existing hooks without running installer', async () => { const root = await fixture('disabled'); const activeShim = path.join(root, '.husky', '_', 'h'); diff --git a/scripts/preflight.test.mjs b/scripts/preflight.test.mjs index 0d63fb3c..a3c60f54 100644 --- a/scripts/preflight.test.mjs +++ b/scripts/preflight.test.mjs @@ -3,7 +3,7 @@ import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; -import { runPreflight } from './preflight.mjs'; +import { runPreflight, sourceFingerprint } from './preflight.mjs'; const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `preflight-${process.pid}`); @@ -83,18 +83,13 @@ test('foreign-owned generated Next state is identified separately from source er assert.match(result.message, /foreign-owned/); }); -test('stale generated Next state is identified separately from source errors', async () => { +test('a generated marker mismatch is identified separately from source errors', async () => { const root = await fixture('stale-next'); await installRequiredBins(root); const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts'); await mkdir(path.dirname(generated), { recursive: true }); await writeFile(generated, 'stale generated output'); - const old = new Date('2020-01-01T00:00:00Z'); - await utimes(generated, old, old); - const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts'); - await writeFile(fresh, 'fresh generated output'); - const future = new Date('2030-01-01T00:00:00Z'); - await utimes(fresh, future, future); + await writeFile(path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'), 'old-source'); const result = await runPreflight({ root }); assert.equal(result.code, 43); @@ -102,3 +97,20 @@ test('stale generated Next state is identified separately from source errors', a assert.match(result.message, /apps\/web\/\.next/); assert.match(result.message, /pnpm clean:generated/); }); + +test('a matching generation marker accepts incremental output with mixed mtimes', async () => { + const root = await fixture('incremental-next'); + await installRequiredBins(root); + const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts'); + await mkdir(path.dirname(generated), { recursive: true }); + await writeFile(generated, 'unchanged generated output'); + await utimes(generated, new Date('2020-01-01T00:00:00Z'), new Date('2020-01-01T00:00:00Z')); + const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts'); + await writeFile(fresh, 'fresh generated output'); + await writeFile( + path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'), + await sourceFingerprint(root), + ); + + assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' }); +}); -- 2.54.0 From 0f7061195b76d51363dfb2422a46a7cff9e7fefe Mon Sep 17 00:00:00 2001 From: f10-coder Date: Fri, 31 Jul 2026 18:25:20 -0500 Subject: [PATCH 3/7] fix: harden reproducible checkout races --- apps/web/package.json | 2 +- .../rm-01-reproducible-checkout.md | 8 + scripts/build-web.mjs | 116 ++++++++++++++ scripts/build-web.test.mjs | 112 ++++++++++++++ scripts/install-hooks.mjs | 36 ----- scripts/install-hooks.test.mjs | 34 ++++- scripts/preflight.mjs | 141 +++++++++++++----- scripts/preflight.test.mjs | 36 +++++ 8 files changed, 413 insertions(+), 72 deletions(-) create mode 100644 scripts/build-web.mjs create mode 100644 scripts/build-web.test.mjs diff --git a/apps/web/package.json b/apps/web/package.json index 519f64af..51ed3daa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,7 @@ "version": "0.0.2", "private": true, "scripts": { - "build": "next build", + "build": "node ../../scripts/build-web.mjs", "dev": "next dev", "lint": "eslint src", "typecheck": "tsc --noEmit", diff --git a/docs/scratchpads/rm-01-reproducible-checkout.md b/docs/scratchpads/rm-01-reproducible-checkout.md index 75196840..af893efb 100644 --- a/docs/scratchpads/rm-01-reproducible-checkout.md +++ b/docs/scratchpads/rm-01-reproducible-checkout.md @@ -30,6 +30,14 @@ - AC7 DONE: install/store/worktree were on `/home`; full `pnpm -w build` exited 0; `/tmp` usage changed by 4096 bytes during the build (23,805,173,760 → 23,805,177,856 bytes), not materially. - AC8 DONE for the implemented path: store resolves under `$HOME`; test/quarantine/build state resolves under the worktree; no implemented component requires a writable path outside `$HOME` or the worktree. +## Continuation evidence + +- AC6 DONE: the committed race reproducer was observed RED (`node --test --test-name-pattern='a competing successful installer is not removed by the losing process' scripts/install-hooks.test.mjs`, exit 1/ENOENT), then passed after cleanup became ownership-safe. The losing installer never removes an active hook set or restores Git configuration it did not activate. `pnpm test:checkout` passes 21/21, exit 0, including the original race and a post-rename peer-replacement regression. +- Generated-state remediation: replaced mtime inference with a source/build-input fingerprint, written only after a serialized successful Next build with unchanged inputs. Failed/interrupted/overlapping builds leave no trusted marker. The fingerprint uses Next's own environment loader, covers resolved `NEXT_PUBLIC_*` values, inherited TypeScript configuration, lock/workspace inputs, and rejects symlink inputs. +- Baseline: `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` each exit 0. Local `pnpm test` still exits 97 only at the pre-existing Bash `BASH_LINENO` convention guard (#973/#1003), after checkout tests and package tests pass; this is not reported as a green full-suite result. +- Automated review remediation: resolved findings for peer-hook ownership, stale/failed build markers, build-input changes, expanded environment inputs, inherited TypeScript config, symlink inputs, and overlapping build serialization. Independent PR review remains assigned to rev-974. +- AC1 and AC4 remain pending fresh-clone/container evidence at this checkpoint. + ## Handoff 1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation. diff --git a/scripts/build-web.mjs b/scripts/build-web.mjs new file mode 100644 index 00000000..0d9f51e3 --- /dev/null +++ b/scripts/build-web.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +import { sourceFingerprint } from './preflight.mjs'; + +const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function run(command, args, options) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, options); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else + reject( + new Error(signal ? `next build terminated by ${signal}` : `next build exited ${code}`), + ); + }); + }); +} + +const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +async function acquireBuildLock(root) { + const workRoot = path.join(root, '.mosaic-test-work'); + const lock = path.join(workRoot, 'web-build.lock'); + const nonce = randomUUID(); + const owner = JSON.stringify({ pid: process.pid, nonce }); + const deadline = Date.now() + 120_000; + await mkdir(workRoot, { recursive: true }); + + while (Date.now() < deadline) { + try { + await mkdir(lock); + await writeFile(path.join(lock, 'owner.json'), owner, { mode: 0o600 }); + return async () => { + const current = await readFile(path.join(lock, 'owner.json'), 'utf8'); + if (current !== owner) throw new Error('Web build lock ownership changed before release.'); + const released = `${lock}.released-${nonce}`; + await rename(lock, released); + await rm(released, { recursive: true, force: true }); + }; + } catch (error) { + if (error.code !== 'EEXIST') throw error; + let lockOwner; + try { + lockOwner = JSON.parse(await readFile(path.join(lock, 'owner.json'), 'utf8')); + } catch (ownerError) { + if (ownerError.code === 'ENOENT') { + await delay(25); + continue; + } + throw new Error(`Web build lock is unreadable at ${lock}.`, { cause: ownerError }); + } + try { + process.kill(lockOwner.pid, 0); + } catch (processError) { + if (processError.code !== 'ESRCH') throw processError; + const stale = `${lock}.stale-${nonce}`; + try { + await rename(lock, stale); + await rm(stale, { recursive: true, force: true }); + } catch (renameError) { + if (renameError.code !== 'ENOENT') throw renameError; + } + continue; + } + await delay(25); + } + } + throw new Error(`Timed out waiting for the web build lock at ${lock}.`); +} + +export async function buildWeb({ + root = scriptRoot, + fingerprint = sourceFingerprint, + runBuild = async (webDir) => + run(path.join(webDir, 'node_modules', '.bin', 'next'), ['build'], { + cwd: webDir, + stdio: 'inherit', + }), +} = {}) { + const releaseLock = await acquireBuildLock(root); + try { + const webDir = path.join(root, 'apps', 'web'); + const nextDir = path.join(webDir, '.next'); + const marker = path.join(nextDir, '.mosaic-source-hash'); + const temporary = `${marker}.${randomUUID()}.tmp`; + const before = await fingerprint(root); + + await rm(marker, { force: true }); + await runBuild(webDir); + + const after = await fingerprint(root); + if (after !== before) { + throw new Error( + 'Web build inputs changed during next build; generated output was not certified.', + ); + } + + await mkdir(nextDir, { recursive: true }); + await writeFile(temporary, `${before}\n`, { mode: 0o600 }); + await rename(temporary, marker); + } finally { + await releaseLock(); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await buildWeb(); +} diff --git a/scripts/build-web.test.mjs b/scripts/build-web.test.mjs new file mode 100644 index 00000000..c897638d --- /dev/null +++ b/scripts/build-web.test.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; + +import { buildWeb } from './build-web.mjs'; + +const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `build-web-${process.pid}`); + +async function fixture(name) { + const root = path.join(fixtureRoot, name); + await mkdir(path.join(root, 'apps', 'web', '.next'), { recursive: true }); + return root; +} + +async function exists(target) { + try { + await access(target); + return true; + } catch { + return false; + } +} + +test.after(async () => { + await rm(fixtureRoot, { recursive: true, force: true }); +}); + +test('a successful web build atomically publishes its source fingerprint', async () => { + const root = await fixture('success'); + const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + + await buildWeb({ root, fingerprint: async () => 'certified', runBuild: async () => {} }); + + assert.equal(await readFile(marker, 'utf8'), 'certified\n'); +}); + +test('a failed web build leaves no certification marker', async () => { + const root = await fixture('failure'); + const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + await writeFile(marker, 'stale\n'); + + await assert.rejects( + buildWeb({ + root, + fingerprint: async () => 'before', + runBuild: async () => { + throw new Error('build failed'); + }, + }), + /build failed/, + ); + + assert.equal(await exists(marker), false); +}); + +test('overlapping web builds are serialized while the marker remains absent', async () => { + const root = await fixture('overlap'); + const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + await writeFile(marker, 'stale\n'); + let releaseFirst; + let secondEntered = false; + const firstEntered = new Promise((resolve) => { + releaseFirst = resolve; + }); + let markFirstEntered; + const firstStarted = new Promise((resolve) => { + markFirstEntered = resolve; + }); + + const first = buildWeb({ + root, + fingerprint: async () => 'certified', + runBuild: async () => { + markFirstEntered(); + await firstEntered; + }, + }); + await firstStarted; + const second = buildWeb({ + root, + fingerprint: async () => 'certified', + runBuild: async () => { + secondEntered = true; + }, + }); + await new Promise((resolve) => setTimeout(resolve, 75)); + assert.equal(secondEntered, false); + assert.equal(await exists(marker), false); + + releaseFirst(); + await Promise.all([first, second]); + assert.equal(secondEntered, true); + assert.equal(await readFile(marker, 'utf8'), 'certified\n'); +}); + +test('inputs changed during a web build are not certified', async () => { + const root = await fixture('changed-inputs'); + const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + const fingerprints = ['before', 'after']; + + await assert.rejects( + buildWeb({ + root, + fingerprint: async () => fingerprints.shift(), + runBuild: async () => {}, + }), + /inputs changed during next build/, + ); + + assert.equal(await exists(marker), false); +}); diff --git a/scripts/install-hooks.mjs b/scripts/install-hooks.mjs index 8c9394ab..becf0511 100644 --- a/scripts/install-hooks.mjs +++ b/scripts/install-hooks.mjs @@ -72,28 +72,6 @@ export async function installHooks({ activateHooks = async () => { await run('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: root, stdio: 'inherit' }); }, - readHooksPath = async () => { - try { - const { stdout } = await execFileAsync('git', ['config', '--get', 'core.hooksPath'], { - cwd: root, - }); - return stdout.trim() || null; - } catch (error) { - if (error.code === 1) return null; - throw error; - } - }, - restoreHooksPath = async (previous) => { - if (previous === null) { - try { - await execFileAsync('git', ['config', '--unset', 'core.hooksPath'], { cwd: root }); - } catch (error) { - if (error.code !== 5) throw error; - } - } else { - await execFileAsync('git', ['config', 'core.hooksPath', previous], { cwd: root }); - } - }, } = {}) { if (disabled) return; @@ -103,8 +81,6 @@ export async function installHooks({ const stagingRepo = path.join(root, '.mosaic-test-work', `husky-stage-${nonce}`); const stagingHooks = path.join(stagingRepo, '.husky'); const quarantined = path.join(quarantineRoot, `${path.basename(root)}-${nonce}`); - - const previousHooksPath = await readHooksPath(); await mkdir(huskyDir, { recursive: true }); await mkdir(quarantineRoot, { recursive: true }); @@ -142,13 +118,6 @@ export async function installHooks({ } } catch (error) { const cleanupFailures = []; - try { - if ((await pathExists(active)) && !previousComplete) { - await rename(active, `${quarantined}-failed`); - } - } catch (cleanupError) { - cleanupFailures.push(`active hooks: ${cleanupError.message}`); - } try { if (await pathExists(stagingRepo)) { await rename(stagingRepo, `${quarantined}-staging`); @@ -156,11 +125,6 @@ export async function installHooks({ } catch (cleanupError) { cleanupFailures.push(`staging hooks: ${cleanupError.message}`); } - try { - await restoreHooksPath(previousHooksPath); - } catch (cleanupError) { - cleanupFailures.push(`core.hooksPath: ${cleanupError.message}`); - } const cleanup = cleanupFailures.length === 0 ? 'No partial hook set was activated.' diff --git a/scripts/install-hooks.test.mjs b/scripts/install-hooks.test.mjs index 54e2c517..512307b4 100644 --- a/scripts/install-hooks.test.mjs +++ b/scripts/install-hooks.test.mjs @@ -54,7 +54,7 @@ test('an interrupted install quarantines the partial active hook set and fails l ); assert.equal(await exists(path.join(root, '.husky', '_')), false); - assert.equal(restoredHooksPath, null); + assert.equal(restoredHooksPath, 'not-called'); const quarantined = await readdir(quarantineRoot); assert.equal(quarantined.length, 1); }); @@ -138,6 +138,38 @@ test('a competing successful installer is not removed by the losing process', as assert.equal(restored, false); }); +test("a competing installer that replaces this installer's active set is preserved", async () => { + const root = await fixture('concurrent-after-rename'); + const active = path.join(root, '.husky', '_'); + const activeShim = path.join(active, 'h'); + let restored = false; + + await assert.rejects( + installHooks({ + root, + quarantineRoot: path.join(fixtureRoot, 'concurrent-after-rename-quarantine'), + runHusky: async (stagingHooks) => { + await mkdir(path.join(stagingHooks, '_'), { recursive: true }); + await writeFile(path.join(stagingHooks, '_', 'h'), 'ours'); + }, + activateHooks: async () => { + await rm(active, { recursive: true, force: true }); + await mkdir(active, { recursive: true }); + await writeFile(activeShim, 'peer'); + throw new Error('our activation lost to peer'); + }, + readHooksPath: async () => null, + restoreHooksPath: async () => { + restored = true; + }, + }), + /Hook installation failed/, + ); + + assert.equal(await readFile(activeShim, 'utf8'), 'peer'); + assert.equal(restored, false); +}); + test('an explicit interactive HUSKY=0 opt-out preserves existing hooks without running installer', async () => { const root = await fixture('disabled'); const activeShim = path.join(root, '.husky', '_', 'h'); diff --git a/scripts/preflight.mjs b/scripts/preflight.mjs index 7c2602e5..e5d12268 100644 --- a/scripts/preflight.mjs +++ b/scripts/preflight.mjs @@ -1,13 +1,17 @@ #!/usr/bin/env node import { constants } from 'node:fs'; -import { access, lstat, readdir } from 'node:fs/promises'; +import { access, lstat, readFile, readdir } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; export const MISSING_DEPS_EXIT = 42; export const GENERATED_STATE_EXIT = 43; +const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + async function entries(root) { const result = []; async function walk(current) { @@ -28,17 +32,90 @@ async function entries(root) { return result; } -async function fileMtimeRange(paths) { - let newest = 0; - let oldest = Number.POSITIVE_INFINITY; - for (const target of paths) { - const stats = await lstat(target); - if (stats.isFile()) { - newest = Math.max(newest, stats.mtimeMs); - oldest = Math.min(oldest, stats.mtimeMs); +const webSourceRoots = (root) => [ + path.join(root, 'apps', 'web', 'src'), + path.join(root, 'apps', 'web', 'public'), + path.join(root, 'apps', 'web', 'next-env.d.ts'), + path.join(root, 'apps', 'web', 'next.config.ts'), + path.join(root, 'apps', 'web', 'postcss.config.mjs'), + path.join(root, 'apps', 'web', 'package.json'), + path.join(root, 'apps', 'web', 'tsconfig.json'), + path.join(root, 'packages', 'design-tokens', 'src'), + path.join(root, 'packages', 'design-tokens', 'package.json'), + path.join(root, 'packages', 'design-tokens', 'tsconfig.json'), + path.join(root, 'package.json'), + path.join(root, 'tsconfig.base.json'), + path.join(root, 'pnpm-lock.yaml'), + path.join(root, 'pnpm-workspace.yaml'), + path.join(root, 'turbo.json'), +]; + +// next.config.ts currently reads no server-only environment. Add any future +// server-side build inputs here; all resolved NEXT_PUBLIC_* inputs are automatic. +const serverBuildEnvironmentKeys = []; + +function publicBuildEnvironment(root) { + const webDir = path.join(root, 'apps', 'web'); + const requireFromWeb = createRequire(path.join(scriptRoot, 'apps', 'web', 'package.json')); + const requireFromNext = createRequire(requireFromWeb.resolve('next/package.json')); + const { loadEnvConfig, resetEnv, updateInitialEnv } = requireFromNext('@next/env'); + const originalEnvironment = { ...process.env }; + updateInitialEnv(originalEnvironment); + try { + const { combinedEnv } = loadEnvConfig(webDir, false, { info() {}, error() {} }, true); + return Object.fromEntries( + Object.entries(combinedEnv).filter( + ([key, value]) => + value !== undefined && + (key.startsWith('NEXT_PUBLIC_') || serverBuildEnvironmentKeys.includes(key)), + ), + ); + } finally { + resetEnv(); + } +} + +export async function sourceFingerprint(root = process.cwd()) { + const files = []; + for (const sourceRoot of webSourceRoots(root)) { + try { + const stats = await lstat(sourceRoot); + if (stats.isSymbolicLink()) { + throw new Error( + `Web build input must not be a symbolic link: ${path.relative(root, sourceRoot)}`, + ); + } + if (stats.isFile()) files.push(sourceRoot); + if (stats.isDirectory()) { + for (const target of await entries(sourceRoot)) { + const targetStats = await lstat(target); + if (targetStats.isSymbolicLink()) { + throw new Error( + `Web build input must not be a symbolic link: ${path.relative(root, target)}`, + ); + } + if (targetStats.isFile()) files.push(target); + } + } + } catch (error) { + if (error.code !== 'ENOENT') throw error; } } - return { newest, oldest: Number.isFinite(oldest) ? oldest : 0 }; + + const digest = createHash('sha256'); + for (const [key, value] of Object.entries(publicBuildEnvironment(root)).sort()) { + digest.update(`env:${key}\0${value.length}\0${value}\0`); + } + for (const target of files.sort()) { + const contents = await readFile(target); + digest.update(path.relative(root, target).split(path.sep).join('/')); + digest.update('\0'); + digest.update(String(contents.length)); + digest.update('\0'); + digest.update(contents); + digest.update('\0'); + } + return digest.digest('hex'); } export async function runPreflight({ root = process.cwd(), uid = process.getuid?.() } = {}) { @@ -59,6 +136,17 @@ export async function runPreflight({ root = process.cwd(), uid = process.getuid? }; } + const buildLock = path.join(root, '.mosaic-test-work', 'web-build.lock'); + try { + await lstat(buildLock); + return { + code: GENERATED_STATE_EXIT, + message: `MOSAIC_PREFLIGHT_GENERATED_STATE: web build is in progress or interrupted at ${buildLock}; wait for it to finish or rerun pnpm build to recover the stale lock`, + }; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + const nextDir = path.join(root, 'apps', 'web', '.next'); let generated = []; try { @@ -76,34 +164,19 @@ export async function runPreflight({ root = process.cwd(), uid = process.getuid? } } - const sourceRoots = [ - path.join(root, 'apps', 'web', 'src'), - path.join(root, 'apps', 'web', 'next.config.ts'), - path.join(root, 'apps', 'web', 'package.json'), - path.join(root, 'apps', 'web', 'tsconfig.json'), - ]; - const source = []; - for (const sourceRoot of sourceRoots) { - try { - const stats = await lstat(sourceRoot); - source.push(sourceRoot); - if (stats.isDirectory()) source.push(...(await entries(sourceRoot))); - } catch (error) { - if (error.code !== 'ENOENT') throw error; - } + let generatedFingerprint = null; + try { + generatedFingerprint = ( + await readFile(path.join(nextDir, '.mosaic-source-hash'), 'utf8') + ).trim(); + } catch (error) { + if (error.code !== 'ENOENT') throw error; } - - const generatedTypes = generated.filter((target) => - target.startsWith(path.join(nextDir, 'types') + path.sep), - ); - const sourceMtime = await fileMtimeRange(source); - const generatedMtime = await fileMtimeRange(generatedTypes); - const stale = - source.length > 0 && generatedTypes.length > 0 && sourceMtime.newest > generatedMtime.oldest; + const stale = generatedFingerprint !== (await sourceFingerprint(root)); if (foreign.length > 0 || stale) { const reasons = [ foreign.length > 0 ? `foreign-owned paths: ${foreign.slice(0, 3).join(', ')}` : '', - stale ? 'generated output is older than web source/configuration' : '', + stale ? 'generated source fingerprint does not match web source/configuration' : '', ].filter(Boolean); return { code: GENERATED_STATE_EXIT, diff --git a/scripts/preflight.test.mjs b/scripts/preflight.test.mjs index a3c60f54..f1018a3e 100644 --- a/scripts/preflight.test.mjs +++ b/scripts/preflight.test.mjs @@ -98,6 +98,42 @@ test('a generated marker mismatch is identified separately from source errors', assert.match(result.message, /pnpm clean:generated/); }); +test('the source fingerprint includes inherited TypeScript configuration', async () => { + const root = await fixture('inherited-typescript-config'); + const config = path.join(root, 'tsconfig.base.json'); + await writeFile(config, '{"compilerOptions":{"strict":true}}\n'); + const first = await sourceFingerprint(root); + await writeFile(config, '{"compilerOptions":{"strict":false}}\n'); + const second = await sourceFingerprint(root); + + assert.notEqual(first, second); +}); + +test('the source fingerprint rejects symbolic-link build inputs', async () => { + const root = await fixture('symbolic-source'); + await writeFile(path.join(root, 'outside.ts'), 'export default 1;\n'); + await symlink(path.join(root, 'outside.ts'), path.join(root, 'apps', 'web', 'src', 'linked.ts')); + + await assert.rejects(sourceFingerprint(root), /must not be a symbolic link/); +}); + +test('the source fingerprint includes expanded public web build environment', async () => { + const root = await fixture('public-build-environment'); + const envFile = path.join(root, 'apps', 'web', '.env.production'); + await writeFile( + envFile, + 'RM01_GATEWAY_URL=https://one.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n', + ); + const first = await sourceFingerprint(root); + await writeFile( + envFile, + 'RM01_GATEWAY_URL=https://two.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n', + ); + const second = await sourceFingerprint(root); + + assert.notEqual(first, second); +}); + test('a matching generation marker accepts incremental output with mixed mtimes', async () => { const root = await fixture('incremental-next'); await installRequiredBins(root); -- 2.54.0 From 7ae3f977892fa3a93a9f058542114f66a8a617eb Mon Sep 17 00:00:00 2001 From: f10-coder Date: Fri, 31 Jul 2026 18:30:16 -0500 Subject: [PATCH 4/7] docs: record RM-01 acceptance evidence --- docs/scratchpads/rm-01-reproducible-checkout.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/scratchpads/rm-01-reproducible-checkout.md b/docs/scratchpads/rm-01-reproducible-checkout.md index af893efb..ead67b94 100644 --- a/docs/scratchpads/rm-01-reproducible-checkout.md +++ b/docs/scratchpads/rm-01-reproducible-checkout.md @@ -36,7 +36,9 @@ - Generated-state remediation: replaced mtime inference with a source/build-input fingerprint, written only after a serialized successful Next build with unchanged inputs. Failed/interrupted/overlapping builds leave no trusted marker. The fingerprint uses Next's own environment loader, covers resolved `NEXT_PUBLIC_*` values, inherited TypeScript configuration, lock/workspace inputs, and rejects symlink inputs. - Baseline: `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` each exit 0. Local `pnpm test` still exits 97 only at the pre-existing Bash `BASH_LINENO` convention guard (#973/#1003), after checkout tests and package tests pass; this is not reported as a green full-suite result. - Automated review remediation: resolved findings for peer-hook ownership, stale/failed build markers, build-input changes, expanded environment inputs, inherited TypeScript config, symlink inputs, and overlapping build serialization. Independent PR review remains assigned to rev-974. -- AC1 and AC4 remain pending fresh-clone/container evidence at this checkpoint. +- AC1 DONE at `0f706119`: a clean clone created inside `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest` ran the exact acceptance sequence `pnpm install --frozen-lockfile && pnpm -w typecheck`; exit 0 with 45/45 uncached typecheck tasks successful. An earlier bind-mounted clone attempt exited 1 because root in the container rejected the host-owned Git directory; that failed attempt is not counted as evidence. +- AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0. +- Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`. ## Handoff -- 2.54.0 From df7530aeacc8d243d9da2302bc52e1da72b840d4 Mon Sep 17 00:00:00 2001 From: f10-coder Date: Fri, 31 Jul 2026 19:20:39 -0500 Subject: [PATCH 5/7] fix: certify generated symlink state --- README.md | 4 + .../rm-01-reproducible-checkout.md | 8 ++ scripts/build-web.mjs | 48 +++++-- scripts/build-web.test.mjs | 45 ++++++- scripts/preflight.mjs | 74 +++++++++-- scripts/preflight.test.mjs | 119 +++++++++++++++++- 6 files changed, 269 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index cefdc60d..a232db81 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,10 @@ pnpm install # Verify dependencies and generated state before running source-quality gates. # Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43. +# The web build certifies its exact standalone symlink manifest; added, removed, +# retargeted, or manifest-only-tampered generated links also exit 43. This detects +# accidental/independent drift, not a same-UID actor that can rewrite both records +# consistently (CWE-345); an external trust anchor is required for that boundary. pnpm preflight # Optional local queue service only. This does not start PostgreSQL. diff --git a/docs/scratchpads/rm-01-reproducible-checkout.md b/docs/scratchpads/rm-01-reproducible-checkout.md index ead67b94..ef250828 100644 --- a/docs/scratchpads/rm-01-reproducible-checkout.md +++ b/docs/scratchpads/rm-01-reproducible-checkout.md @@ -40,6 +40,14 @@ - AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0. - Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`. +## Review remediation — restated AC2 + +- Independent review correctly found that an added symlink under a successfully built `.next` tree passed preflight. The exact reviewer control, `ln -s /etc/hosts apps/web/.next/reviewer-symlink && pnpm preflight`, was observed passing before remediation. +- The original blanket symlink wording conflicts with AC4 because canonical Next `output: 'standalone'` emits legitimate pnpm dependency symlinks. The coordinator independently verified 42 such links and approved the operative restatement: `.next` itself must not be a symlink; descendant symlinks must exactly match the successful build's certified manifest. +- RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted. +- GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0. +- Threat-model ruling: the manifest detects accidental, independent, and stale mutation only. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. The residual belongs to the planned choke-point executor / PostgreSQL spine where verification can occur outside worktree authority. + ## Handoff 1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation. diff --git a/scripts/build-web.mjs b/scripts/build-web.mjs index 0d9f51e3..dde9d60c 100644 --- a/scripts/build-web.mjs +++ b/scripts/build-web.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { createHash, randomUUID } from 'node:crypto'; +import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; -import { sourceFingerprint } from './preflight.mjs'; +import { generatedSymlinkManifest, sourceFingerprint } from './preflight.mjs'; const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -26,6 +26,18 @@ function run(command, args, options) { const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +async function requireRealDirectory(target, { allowMissing = false } = {}) { + try { + const stats = await lstat(target); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error(`${target} must be a real directory, not a symbolic link.`); + } + } catch (error) { + if (allowMissing && error.code === 'ENOENT') return; + throw error; + } +} + async function acquireBuildLock(root) { const workRoot = path.join(root, '.mosaic-test-work'); const lock = path.join(workRoot, 'web-build.lock'); @@ -89,12 +101,19 @@ export async function buildWeb({ try { const webDir = path.join(root, 'apps', 'web'); const nextDir = path.join(webDir, '.next'); - const marker = path.join(nextDir, '.mosaic-source-hash'); - const temporary = `${marker}.${randomUUID()}.tmp`; + const certificationMarker = path.join(nextDir, '.mosaic-source-hash'); + const symlinkManifest = path.join(nextDir, '.mosaic-symlink-manifest'); + const certificationTemporary = `${certificationMarker}.${randomUUID()}.tmp`; + const manifestTemporary = `${symlinkManifest}.${randomUUID()}.tmp`; const before = await fingerprint(root); - await rm(marker, { force: true }); + await requireRealDirectory(nextDir, { allowMissing: true }); + await Promise.all([ + rm(certificationMarker, { force: true }), + rm(symlinkManifest, { force: true }), + ]); await runBuild(webDir); + await requireRealDirectory(nextDir); const after = await fingerprint(root); if (after !== before) { @@ -103,9 +122,20 @@ export async function buildWeb({ ); } - await mkdir(nextDir, { recursive: true }); - await writeFile(temporary, `${before}\n`, { mode: 0o600 }); - await rename(temporary, marker); + const manifestContents = await generatedSymlinkManifest(nextDir); + const certificationContents = `${JSON.stringify({ + version: 1, + sourceFingerprint: before, + symlinkManifestHash: createHash('sha256').update(manifestContents).digest('hex'), + })}\n`; + await Promise.all([ + writeFile(certificationTemporary, certificationContents, { mode: 0o600 }), + writeFile(manifestTemporary, manifestContents, { mode: 0o600 }), + ]); + // The certification marker is the commit point. Publishing the manifest first + // leaves interrupted builds untrusted because the marker remains absent. + await rename(manifestTemporary, symlinkManifest); + await rename(certificationTemporary, certificationMarker); } finally { await releaseLock(); } diff --git a/scripts/build-web.test.mjs b/scripts/build-web.test.mjs index c897638d..2a53a107 100644 --- a/scripts/build-web.test.mjs +++ b/scripts/build-web.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { access, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; @@ -26,19 +26,27 @@ test.after(async () => { await rm(fixtureRoot, { recursive: true, force: true }); }); -test('a successful web build atomically publishes its source fingerprint', async () => { +test('a successful web build atomically publishes its source and symlink certification', async () => { const root = await fixture('success'); const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest'); await buildWeb({ root, fingerprint: async () => 'certified', runBuild: async () => {} }); - assert.equal(await readFile(marker, 'utf8'), 'certified\n'); + assert.deepEqual(JSON.parse(await readFile(marker, 'utf8')), { + version: 1, + sourceFingerprint: 'certified', + symlinkManifestHash: '8a5a375cea6a55d24bd5f875856da63feba33adbefb15a92a0007719b84bcf11', + }); + assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n'); }); test('a failed web build leaves no certification marker', async () => { const root = await fixture('failure'); const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest'); await writeFile(marker, 'stale\n'); + await writeFile(manifest, 'stale\n'); await assert.rejects( buildWeb({ @@ -52,12 +60,15 @@ test('a failed web build leaves no certification marker', async () => { ); assert.equal(await exists(marker), false); + assert.equal(await exists(manifest), false); }); test('overlapping web builds are serialized while the marker remains absent', async () => { const root = await fixture('overlap'); const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest'); await writeFile(marker, 'stale\n'); + await writeFile(manifest, 'stale\n'); let releaseFirst; let secondEntered = false; const firstEntered = new Promise((resolve) => { @@ -87,16 +98,41 @@ test('overlapping web builds are serialized while the marker remains absent', as await new Promise((resolve) => setTimeout(resolve, 75)); assert.equal(secondEntered, false); assert.equal(await exists(marker), false); + assert.equal(await exists(manifest), false); releaseFirst(); await Promise.all([first, second]); assert.equal(secondEntered, true); - assert.equal(await readFile(marker, 'utf8'), 'certified\n'); + assert.equal(JSON.parse(await readFile(marker, 'utf8')).sourceFingerprint, 'certified'); + assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n'); +}); + +test('a build that replaces .next with a symbolic link cannot publish outside the checkout', async () => { + const root = await fixture('symbolic-next'); + const nextDir = path.join(root, 'apps', 'web', '.next'); + const outside = path.join(root, 'outside-generated'); + await mkdir(outside); + + await assert.rejects( + buildWeb({ + root, + fingerprint: async () => 'certified', + runBuild: async () => { + await rm(nextDir, { recursive: true }); + await symlink(outside, nextDir); + }, + }), + /must be a real directory/, + ); + + assert.equal(await exists(path.join(outside, '.mosaic-source-hash')), false); + assert.equal(await exists(path.join(outside, '.mosaic-symlink-manifest')), false); }); test('inputs changed during a web build are not certified', async () => { const root = await fixture('changed-inputs'); const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest'); const fingerprints = ['before', 'after']; await assert.rejects( @@ -109,4 +145,5 @@ test('inputs changed during a web build are not certified', async () => { ); assert.equal(await exists(marker), false); + assert.equal(await exists(manifest), false); }); diff --git a/scripts/preflight.mjs b/scripts/preflight.mjs index e5d12268..f3e21ae8 100644 --- a/scripts/preflight.mjs +++ b/scripts/preflight.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { constants } from 'node:fs'; -import { access, lstat, readFile, readdir } from 'node:fs/promises'; +import { access, lstat, readFile, readdir, readlink } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; @@ -32,6 +32,19 @@ async function entries(root) { return result; } +export async function generatedSymlinkManifest(nextDir) { + const links = []; + for (const target of (await entries(nextDir)).sort()) { + const stats = await lstat(target); + if (!stats.isSymbolicLink()) continue; + links.push({ + path: path.relative(nextDir, target).split(path.sep).join('/'), + target: await readlink(target), + }); + } + return `${JSON.stringify({ version: 1, links })}\n`; +} + const webSourceRoots = (root) => [ path.join(root, 'apps', 'web', 'src'), path.join(root, 'apps', 'web', 'public'), @@ -150,7 +163,14 @@ export async function runPreflight({ root = process.cwd(), uid = process.getuid? const nextDir = path.join(root, 'apps', 'web', '.next'); let generated = []; try { - await lstat(nextDir); + const nextStats = await lstat(nextDir); + if (nextStats.isSymbolicLink()) { + return { + code: GENERATED_STATE_EXIT, + message: + 'MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next contains a symbolic link and is not trustworthy; run pnpm clean:generated, then rerun the gate', + }; + } generated = [nextDir, ...(await entries(nextDir))]; } catch (error) { if (error.code !== 'ENOENT') throw error; @@ -158,25 +178,55 @@ export async function runPreflight({ root = process.cwd(), uid = process.getuid? if (generated.length > 0) { const foreign = []; - if (uid !== undefined) { - for (const target of generated) { - if ((await lstat(target)).uid !== uid) foreign.push(path.relative(root, target)); - } + for (const target of generated) { + const stats = await lstat(target); + if (uid !== undefined && stats.uid !== uid) foreign.push(path.relative(root, target)); } - let generatedFingerprint = null; + // Threat model: this detects accidental, independent, or stale generated-state + // mutation. It does NOT defend against an actor with same-UID write access to + // the generated tree: that actor can regenerate both the manifest and marker + // consistently (CWE-345). No local construction can without a trust anchor + // outside that actor's authority. + let certification = null; + let certifiedManifest = null; try { - generatedFingerprint = ( - await readFile(path.join(nextDir, '.mosaic-source-hash'), 'utf8') - ).trim(); + const [certificationContents, manifestContents] = await Promise.all([ + readFile(path.join(nextDir, '.mosaic-source-hash'), 'utf8'), + readFile(path.join(nextDir, '.mosaic-symlink-manifest'), 'utf8'), + ]); + try { + const parsed = JSON.parse(certificationContents); + if ( + parsed.version === 1 && + typeof parsed.sourceFingerprint === 'string' && + typeof parsed.symlinkManifestHash === 'string' + ) { + certification = parsed; + certifiedManifest = manifestContents; + } + } catch { + // Invalid certification is handled as untrusted generated state below. + } } catch (error) { if (error.code !== 'ENOENT') throw error; } - const stale = generatedFingerprint !== (await sourceFingerprint(root)); - if (foreign.length > 0 || stale) { + const stale = certification?.sourceFingerprint !== (await sourceFingerprint(root)); + const actualManifest = await generatedSymlinkManifest(nextDir); + const certifiedManifestHash = + certifiedManifest === null + ? null + : createHash('sha256').update(certifiedManifest).digest('hex'); + const changedSymlinks = + certification?.symlinkManifestHash !== certifiedManifestHash || + certifiedManifest !== actualManifest; + if (foreign.length > 0 || stale || changedSymlinks) { const reasons = [ foreign.length > 0 ? `foreign-owned paths: ${foreign.slice(0, 3).join(', ')}` : '', stale ? 'generated source fingerprint does not match web source/configuration' : '', + changedSymlinks + ? 'generated symbolic-link manifest does not match the certified build' + : '', ].filter(Boolean); return { code: GENERATED_STATE_EXIT, diff --git a/scripts/preflight.test.mjs b/scripts/preflight.test.mjs index f1018a3e..a035df4c 100644 --- a/scripts/preflight.test.mjs +++ b/scripts/preflight.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; @@ -28,6 +29,22 @@ async function installRequiredBins(root) { ); } +async function certifyGeneratedState(root, links = []) { + const nextDir = path.join(root, 'apps', 'web', '.next'); + await mkdir(nextDir, { recursive: true }); + const manifest = `${JSON.stringify({ version: 1, links })}\n`; + const manifestHash = createHash('sha256').update(manifest).digest('hex'); + await writeFile(path.join(nextDir, '.mosaic-symlink-manifest'), manifest); + await writeFile( + path.join(nextDir, '.mosaic-source-hash'), + `${JSON.stringify({ + version: 1, + sourceFingerprint: await sourceFingerprint(root), + symlinkManifestHash: manifestHash, + })}\n`, + ); +} + test.after(async () => { await rm(fixtureRoot, { recursive: true, force: true }); }); @@ -98,6 +115,103 @@ test('a generated marker mismatch is identified separately from source errors', assert.match(result.message, /pnpm clean:generated/); }); +test('generated-state symbolic links are accepted only when exactly build-certified', async (t) => { + await t.test('apps/web/.next itself is rejected when it is a symbolic link', async () => { + const root = await fixture('symbolic-next-root'); + await installRequiredBins(root); + await writeFile(path.join(root, 'outside-generated'), 'not a Next build\n'); + await symlink(path.join(root, 'outside-generated'), path.join(root, 'apps', 'web', '.next')); + + const result = await runPreflight({ root }); + assert.equal(result.code, 43); + assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/); + assert.match(result.message, /symbolic link/); + }); + + await t.test('an added descendant symlink is rejected', async () => { + const root = await fixture('symbolic-next-added'); + await installRequiredBins(root); + await certifyGeneratedState(root); + await symlink('/etc/hosts', path.join(root, 'apps', 'web', '.next', 'reviewer-symlink')); + + const result = await runPreflight({ root }); + assert.equal(result.code, 43); + assert.match(result.message, /symbolic-link manifest/); + }); + + await t.test('a removed certified descendant symlink is rejected', async () => { + const root = await fixture('symbolic-next-removed'); + await installRequiredBins(root); + const link = path.join(root, 'apps', 'web', '.next', 'dependency-link'); + await mkdir(path.dirname(link), { recursive: true }); + await symlink('../dependency-one', link); + await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]); + await rm(link); + + const result = await runPreflight({ root }); + assert.equal(result.code, 43); + assert.match(result.message, /symbolic-link manifest/); + }); + + await t.test('a retargeted certified descendant symlink is rejected', async () => { + const root = await fixture('symbolic-next-retargeted'); + await installRequiredBins(root); + const link = path.join(root, 'apps', 'web', '.next', 'dependency-link'); + await mkdir(path.dirname(link), { recursive: true }); + await symlink('../dependency-one', link); + await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]); + await rm(link); + await symlink('../dependency-two', link); + + const result = await runPreflight({ root }); + assert.equal(result.code, 43); + assert.match(result.message, /symbolic-link manifest/); + }); + + await t.test('a manifest edited to whitelist a rogue symlink is rejected', async () => { + const root = await fixture('symbolic-next-tampered-manifest'); + await installRequiredBins(root); + await certifyGeneratedState(root); + const nextDir = path.join(root, 'apps', 'web', '.next'); + await symlink('/etc/hosts', path.join(nextDir, 'reviewer-symlink')); + await writeFile( + path.join(nextDir, '.mosaic-symlink-manifest'), + `${JSON.stringify({ + version: 1, + links: [{ path: 'reviewer-symlink', target: '/etc/hosts' }], + })}\n`, + ); + + const result = await runPreflight({ root }); + assert.equal(result.code, 43); + assert.match(result.message, /symbolic-link manifest/); + }); + + await t.test('unchanged canonical-style descendant symlinks are accepted', async () => { + const root = await fixture('symbolic-next-certified'); + await installRequiredBins(root); + const link = path.join( + root, + 'apps', + 'web', + '.next', + 'standalone', + 'node_modules', + 'dependency', + ); + await mkdir(path.dirname(link), { recursive: true }); + await symlink('../.pnpm/dependency', link); + await certifyGeneratedState(root, [ + { path: 'standalone/node_modules/dependency', target: '../.pnpm/dependency' }, + ]); + + assert.deepEqual(await runPreflight({ root }), { + code: 0, + message: 'checkout preflight passed', + }); + }); +}); + test('the source fingerprint includes inherited TypeScript configuration', async () => { const root = await fixture('inherited-typescript-config'); const config = path.join(root, 'tsconfig.base.json'); @@ -143,10 +257,7 @@ test('a matching generation marker accepts incremental output with mixed mtimes' await utimes(generated, new Date('2020-01-01T00:00:00Z'), new Date('2020-01-01T00:00:00Z')); const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts'); await writeFile(fresh, 'fresh generated output'); - await writeFile( - path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'), - await sourceFingerprint(root), - ); + await certifyGeneratedState(root); assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' }); }); -- 2.54.0 From f710a8d7fbaf7f455c9ced641bfea03da6b546e2 Mon Sep 17 00:00:00 2001 From: f10-coder Date: Fri, 31 Jul 2026 19:23:02 -0500 Subject: [PATCH 6/7] docs: record scoped AC2 and AC8 evidence --- docs/scratchpads/rm-01-reproducible-checkout.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/scratchpads/rm-01-reproducible-checkout.md b/docs/scratchpads/rm-01-reproducible-checkout.md index ef250828..97a10779 100644 --- a/docs/scratchpads/rm-01-reproducible-checkout.md +++ b/docs/scratchpads/rm-01-reproducible-checkout.md @@ -47,6 +47,7 @@ - RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted. - GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0. - Threat-model ruling: the manifest detects accidental, independent, and stale mutation only. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. The residual belongs to the planned choke-point executor / PostgreSQL spine where verification can occur outside worktree authority. +- AC8 concrete proof at `df7530ae`: a clean clone ran in `ci-base:latest` with Docker `--read-only`; its only writable mounts were `/workspace` (the worktree) and `/home/ci` (`HOME`, with `NPM_CONFIG_STORE_DIR=/home/ci/store`). `pnpm install --frozen-lockfile && pnpm -w typecheck` exited 0 with 45/45 uncached tasks. This proves the implemented checkout path requires no writable location outside `$HOME` and the worktree. An initial fixture attempt failed only because Git required `/workspace` safe-directory setup; it is not counted as evidence. ## Handoff -- 2.54.0 From 98046a76a28fef390fa2cd215171d152b7751d8e Mon Sep 17 00:00:00 2001 From: f10-coder Date: Fri, 31 Jul 2026 19:25:44 -0500 Subject: [PATCH 7/7] fix: reject malformed generated roots --- README.md | 7 +++++-- .../scratchpads/rm-01-reproducible-checkout.md | 2 +- scripts/preflight.mjs | 18 +++++++++++------- scripts/preflight.test.mjs | 11 +++++++++++ 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a232db81..9bccfc60 100644 --- a/README.md +++ b/README.md @@ -209,8 +209,11 @@ pnpm install # Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43. # The web build certifies its exact standalone symlink manifest; added, removed, # retargeted, or manifest-only-tampered generated links also exit 43. This detects -# accidental/independent drift, not a same-UID actor that can rewrite both records -# consistently (CWE-345); an external trust anchor is required for that boundary. +# accidental, independent, stale, and foreign-residue mutation—the class exposed by +# a five-month-stale .next that produced 19 phantom TS2307 errors. +# It does NOT defend against a same-UID actor that can rewrite both manifest and +# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side +# trust anchor outside worktree authority. pnpm preflight # Optional local queue service only. This does not start PostgreSQL. diff --git a/docs/scratchpads/rm-01-reproducible-checkout.md b/docs/scratchpads/rm-01-reproducible-checkout.md index 97a10779..3c6feac0 100644 --- a/docs/scratchpads/rm-01-reproducible-checkout.md +++ b/docs/scratchpads/rm-01-reproducible-checkout.md @@ -46,7 +46,7 @@ - The original blanket symlink wording conflicts with AC4 because canonical Next `output: 'standalone'` emits legitimate pnpm dependency symlinks. The coordinator independently verified 42 such links and approved the operative restatement: `.next` itself must not be a symlink; descendant symlinks must exactly match the successful build's certified manifest. - RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted. - GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0. -- Threat-model ruling: the manifest detects accidental, independent, and stale mutation only. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. The residual belongs to the planned choke-point executor / PostgreSQL spine where verification can occur outside worktree authority. +- Threat-model ruling: the manifest detects accidental, independent, stale, and foreign-residue mutation—the class exposed by the five-month-stale `.next` that produced 19 phantom TS2307 errors. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. RM-59 tracks the residual: executor/spine-side attestation outside worktree authority, dependent on RM-12, RM-21, and RM-25. - AC8 concrete proof at `df7530ae`: a clean clone ran in `ci-base:latest` with Docker `--read-only`; its only writable mounts were `/workspace` (the worktree) and `/home/ci` (`HOME`, with `NPM_CONFIG_STORE_DIR=/home/ci/store`). `pnpm install --frozen-lockfile && pnpm -w typecheck` exited 0 with 45/45 uncached tasks. This proves the implemented checkout path requires no writable location outside `$HOME` and the worktree. An initial fixture attempt failed only because Git required `/workspace` safe-directory setup; it is not counted as evidence. ## Handoff diff --git a/scripts/preflight.mjs b/scripts/preflight.mjs index f3e21ae8..c1f5c99a 100644 --- a/scripts/preflight.mjs +++ b/scripts/preflight.mjs @@ -164,11 +164,11 @@ export async function runPreflight({ root = process.cwd(), uid = process.getuid? let generated = []; try { const nextStats = await lstat(nextDir); - if (nextStats.isSymbolicLink()) { + if (!nextStats.isDirectory() || nextStats.isSymbolicLink()) { return { code: GENERATED_STATE_EXIT, message: - 'MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next contains a symbolic link and is not trustworthy; run pnpm clean:generated, then rerun the gate', + 'MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next must be a real directory, not a symbolic link, and is not trustworthy; run pnpm clean:generated, then rerun the gate', }; } generated = [nextDir, ...(await entries(nextDir))]; @@ -183,11 +183,15 @@ export async function runPreflight({ root = process.cwd(), uid = process.getuid? if (uid !== undefined && stats.uid !== uid) foreign.push(path.relative(root, target)); } - // Threat model: this detects accidental, independent, or stale generated-state - // mutation. It does NOT defend against an actor with same-UID write access to - // the generated tree: that actor can regenerate both the manifest and marker - // consistently (CWE-345). No local construction can without a trust anchor - // outside that actor's authority. + // Detects accidental, independent, stale, and foreign-residue mutation of + // generated state: the class this check was born from was a five-month-stale + // .next whose validator referenced deleted pages and produced 19 phantom TS2307 + // errors indistinguishable from real type errors. + // + // Does NOT defend against an actor with same-UID write access to the generated + // tree, which can regenerate both the manifest and marker consistently + // (CWE-345). No local construction can, absent a trust anchor outside that + // actor's authority. RM-59 tracks executor/spine-side attestation. let certification = null; let certifiedManifest = null; try { diff --git a/scripts/preflight.test.mjs b/scripts/preflight.test.mjs index a035df4c..1e351912 100644 --- a/scripts/preflight.test.mjs +++ b/scripts/preflight.test.mjs @@ -128,6 +128,17 @@ test('generated-state symbolic links are accepted only when exactly build-certif assert.match(result.message, /symbolic link/); }); + await t.test('apps/web/.next is rejected when it is not a directory', async () => { + const root = await fixture('non-directory-next-root'); + await installRequiredBins(root); + await writeFile(path.join(root, 'apps', 'web', '.next'), 'not a Next build\n'); + + const result = await runPreflight({ root }); + assert.equal(result.code, 43); + assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/); + assert.match(result.message, /real directory/); + }); + await t.test('an added descendant symlink is rejected', async () => { const root = await fixture('symbolic-next-added'); await installRequiredBins(root); -- 2.54.0