From 1630f98a9960735f746bc595921923d5074ba949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20T=C3=BCchler?= Date: Sat, 19 Sep 2026 16:28:25 +0000 Subject: [PATCH 1/6] feat: add portable mutation journal helper --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 17 +- README.md | 6 +- gemini-extension.json | 2 +- package-lock.json | 4 +- package.json | 4 +- release/v0.7.0.md | 31 ++ skill.json | 2 +- skills/webmcpify/SKILL.md | 15 +- skills/webmcpify/references/reverify.md | 9 +- skills/webmcpify/references/verify.md | 24 +- .../webmcpify/templates/mutation-journal.js | 378 ++++++++++++++ .../webmcpify/templates/mutation-journal.ts | 480 ++++++++++++++++++ skills/webmcpify/templates/webmcp.spec.ts | 106 +++- tests/manifest-lock.test.mjs | 405 +++++++++++---- 18 files changed, 1339 insertions(+), 152 deletions(-) create mode 100644 release/v0.7.0.md create mode 100644 skills/webmcpify/templates/mutation-journal.js create mode 100644 skills/webmcpify/templates/mutation-journal.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fef3463..a9f73f0 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "name": "webmcpify", "source": "./", "description": "WebMCP agent skill for curated core coverage or route-by-route parity — inventory an existing web app, integrate approved tools, then verify and heal them in a real browser.", - "version": "0.6.0" + "version": "0.7.0" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index beb7c53..9726a4c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "webmcpify", "description": "WebMCP agent skill for curated core coverage or route-by-route parity — inventory an existing web app, integrate approved tools, then verify and heal them in a real browser.", - "version": "0.6.0", + "version": "0.7.0", "author": { "name": "Jonas Tüchler" } diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index beb7c53..9726a4c 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "webmcpify", "description": "WebMCP agent skill for curated core coverage or route-by-route parity — inventory an existing web app, integrate approved tools, then verify and heal them in a real browser.", - "version": "0.6.0", + "version": "0.7.0", "author": { "name": "Jonas Tüchler" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c5cbc1..1fa423b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ permissions: jobs: check: - runs-on: ubuntu-latest + runs-on: [self-hosted, Linux, X64, twb-ci] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ca0de0..b875ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ reconstruct them from git history. ## [Unreleased] +## [0.7.0] — 2026-09-19 + +- Added a dependency-free host-side mutation journal for verification runners. + It acquires the permanent manifest sidecar through `flock(1)` or macOS/FreeBSD + `lockf(1) -k`, migrates missing journals, and fails closed when no supported + advisory lock is available. +- Wired the Playwright template's valid, invalid and cleanup mutation examples + through durable pre-dispatch and settlement hooks. Atomic manifest replacement + now writes and fsyncs a sibling temporary file, renames it, and fsyncs the + directory while the sidecar lock remains held. +- Added regressions for two-runner exclusion, cleanup linkage, pre-dispatch + persistence failure, process death with a retained `started` entry, canonical + argument fingerprints, and legacy-manifest migration. + ## [0.6.0] — 2026-09-19 - Tightened eval execution with reviewed versions, bounded runs and approved data; @@ -87,4 +101,5 @@ reconstruct them from git history. [0.5.0]: https://github.com/TueJon/webmcpify/compare/v0.4.0...v0.5.0 [0.5.1]: https://github.com/TueJon/webmcpify/compare/v0.5.0...v0.5.1 [0.6.0]: https://github.com/TueJon/webmcpify/compare/v0.5.1...v0.6.0 -[Unreleased]: https://github.com/TueJon/webmcpify/compare/v0.6.0...HEAD +[0.7.0]: https://github.com/TueJon/webmcpify/compare/v0.6.0...v0.7.0 +[Unreleased]: https://github.com/TueJon/webmcpify/compare/v0.7.0...HEAD diff --git a/README.md b/README.md index 961ca33..7cfcaca 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,10 @@ Every phase is a **loop over persistent state**, not a one-shot pass: examples recorded in the manifest. That includes mutating declarative forms, where Chrome pauses the execution until a real submit interaction — the harness performs that submit click mid-execution instead of faking the pass. +- **Crash-safe mutation checks** — a dependency-free host helper journals every + mutating dispatch and cleanup before execution, atomically settles verified + outcomes, and serializes runners with a permanent advisory-lock sidecar on + Linux (`flock`) and macOS/FreeBSD (`lockf -k`). - **Spec over scoreboard** — WebMCP checkers and inspector extensions grade pages against a mix of spec features, conventions, and invented checks. webmcpify classifies their findings instead of chasing them: it never emits @@ -161,7 +165,7 @@ Every phase is a **loop over persistent state**, not a one-shot pass: |---|---| | [`skills/webmcpify/SKILL.md`](skills/webmcpify/SKILL.md) | The pipeline (what your agent follows) | | [`skills/webmcpify/references/`](skills/webmcpify/references/) | Phase guides: inventory, integrate, Workbench, runtime, verify, heal, security, discovery | -| [`skills/webmcpify/templates/`](skills/webmcpify/templates/) | Vendorable runtime (TS + JS), temporary visual Workbench, ambient types, Playwright verification template, discovery manifest | +| [`skills/webmcpify/templates/`](skills/webmcpify/templates/) | Vendorable runtime (TS + JS), durable mutation journal, temporary visual Workbench, ambient types, Playwright verification template, discovery manifest | ## Status diff --git a/gemini-extension.json b/gemini-extension.json index beb7c53..9726a4c 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,7 +1,7 @@ { "name": "webmcpify", "description": "WebMCP agent skill for curated core coverage or route-by-route parity — inventory an existing web app, integrate approved tools, then verify and heal them in a real browser.", - "version": "0.6.0", + "version": "0.7.0", "author": { "name": "Jonas Tüchler" } diff --git a/package-lock.json b/package-lock.json index eb4d3fe..0dce18c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "webmcpify", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "webmcpify", - "version": "0.6.0", + "version": "0.7.0", "license": "MIT", "devDependencies": { "@playwright/test": "^1.54.0", diff --git a/package.json b/package.json index 923ff66..0184cf3 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "WebMCP agent skill for curated core coverage or route-by-route parity — inventory an existing web app, integrate approved tools, then verify and heal them in a real browser.", "type": "module", "license": "MIT", - "version": "0.6.0", + "version": "0.7.0", "publisher": "TueJon", "repository": { "url": "https://github.com/TueJon/webmcpify" @@ -26,7 +26,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.templates.json && tsc -p tsconfig.spec.json && tsc -p tsconfig.jsx.json", - "syntaxcheck": "node --check skills/webmcpify/templates/webmcpify.js && node --check skills/webmcpify/templates/webmcp-workbench.js && node --check skills/webmcpify/scripts/workbench.mjs", + "syntaxcheck": "node --check skills/webmcpify/templates/webmcpify.js && node --check skills/webmcpify/templates/webmcp-workbench.js && node --check skills/webmcpify/templates/mutation-journal.js && node --check skills/webmcpify/scripts/workbench.mjs", "test": "node --test \"tests/*.test.mjs\"", "check": "npm run typecheck && npm run syntaxcheck && npm test", "workbench:verify": "node tests/workbench-browser.mjs", diff --git a/release/v0.7.0.md b/release/v0.7.0.md new file mode 100644 index 0000000..5d23f37 --- /dev/null +++ b/release/v0.7.0.md @@ -0,0 +1,31 @@ +# webmcpify v0.7.0 + +Verification runners no longer have to invent their own crash-safety layer for +mutating tools. The skill now ships a dependency-free host-side journal helper +and wires its Playwright template through durable pre-dispatch, cleanup and +settlement hooks. + +## What changed + +- Acquires the never-replaced `.webmcpify/manifest.lock` sidecar with `flock(1)` + where present or macOS/FreeBSD `lockf(1) -k`, and fails closed if neither + advisory-lock command is available. +- Records a canonical-arguments fingerprint and a durable `started` entry before + each valid example, invalid example and mutating cleanup action. +- Settles an entry only after an independent read path establishes the effect or + its absence and required cleanup has completed. +- Migrates manifests without `mutationExecutions`, invalidating historical + verification evidence without inventing in-flight calls. +- Covers concurrent runners across atomic manifest replacement, persistence + failure before dispatch, abrupt process death and legacy-manifest migration. + +## Compatibility and evidence + +This is a backward-compatible verification-workflow expansion from v0.6.0. It +does not change the vendored browser runtime or the application-facing WebMCP +contract. The helper requires Node.js 20 and either `flock(1)` or `lockf(1)`; +mutation verification remains safely not-run on platforms without either. The +same portable regression runs on Linux and macOS and declares that prerequisite +as its only platform skip. + +Compare: https://github.com/TueJon/webmcpify/compare/v0.6.0...v0.7.0 diff --git a/skill.json b/skill.json index c1dda65..3f86be6 100644 --- a/skill.json +++ b/skill.json @@ -1,6 +1,6 @@ { "name": "webmcpify", - "version": "0.6.0", + "version": "0.7.0", "description": "WebMCP agent skill for curated core coverage or route-by-route parity — inventory an existing web app, integrate approved tools, then verify and heal them in a real browser.", "license": "MIT", "author": { diff --git a/skills/webmcpify/SKILL.md b/skills/webmcpify/SKILL.md index 4706f92..cef3ae2 100644 --- a/skills/webmcpify/SKILL.md +++ b/skills/webmcpify/SKILL.md @@ -28,8 +28,8 @@ DETECT ──▶ INVENTORY ──▶ [HUMAN GATE: manifest approval] ──▶ I Everything you need ships inside this skill directory: phase guides in `references/`, and vendorable code in `templates/` (runtime, ambient types, -JS variant, React JSX typings, verification spec + compat helper). Never assume files exist -outside the skill dir. +JS variant, React JSX typings, verification spec, compatibility helper and +durable mutation journal). Never assume files exist outside the skill dir. **Out of scope** (stop and say so): backend-only MCP servers (that's classic MCP, not WebMCP), automating third-party sites you don't control, and generic SEO work. @@ -185,7 +185,7 @@ Manifest schema (Webmcpify Manifest v4): }, "setup": { // PATHS created/modified per one-time setup step ([] = not done yet) "runtimeVendored": ["src/webmcp/webmcpify.ts", "src/webmcp/webmcp.d.ts"], - "harnessInstalled": [".webmcpify/webmcp.spec.ts", ".webmcpify/webmcp-compat.js"], + "harnessInstalled": [".webmcpify/webmcp.spec.ts", ".webmcpify/webmcp-compat.js", ".webmcpify/mutation-journal.ts", ".webmcpify/mutation-journal.js"], "originTrialNoted": ["README.md"] }, "discovery": null, // optional off-page layer (references/discovery.md). Stays null unless @@ -407,12 +407,13 @@ and removes it within the same inspection session. ## Phase 3 — VERIFY (loop) -Set up once from `templates/webmcp.spec.ts` per `references/verify.md` (real headed -Chrome; current production `document.modelContext.getTools()`/`executeTool()` surface). +Set up once from `templates/webmcp.spec.ts` plus the shipped host-side +`templates/mutation-journal.{ts,js}` per `references/verify.md` (real headed Chrome; +current production `document.modelContext.getTools()`/`executeTool()` surface). Before any execution, enforce the durable mutation journal in `references/reverify.md`: scan unresolved attempts, persist each mutation before -dispatch, and settle only after independent reconciliation and cleanup. Wire the -host-side hooks into the chosen runner; without them, mutations are blocked. +dispatch, and settle only after independent reconciliation and cleanup. Use its +hooks in the chosen runner; without them, mutations are blocked. Then loop over every `integrated` tool, using its manifest `route`, `auth`, `examples`, `expect`, and `annotations` fields: diff --git a/skills/webmcpify/references/reverify.md b/skills/webmcpify/references/reverify.md index 90309fc..f5a5414 100644 --- a/skills/webmcpify/references/reverify.md +++ b/skills/webmcpify/references/reverify.md @@ -42,8 +42,13 @@ the evidence even when frontend files did not change. Manifest v4 adds `mutationExecutions: []` on each tool. This is a required workflow journal for new mutation runs, not a browser API or an automatic feature -of the vendored runtime. Before using a runner, implement its Node/host-side -pre-dispatch and settlement hooks; a browser-only callback is not durable. +of the vendored runtime. Vendor `templates/mutation-journal.{ts,js}` next to the +spec and use its Node/host-side `openMutationJournal`, `beforeDispatch`, `settle` +and `close` operations; a browser-only callback is not durable. The helper prefers +`flock(1)`, falls back to macOS/FreeBSD `lockf(1) -k` (which keeps the sidecar +inode), and fails closed when neither advisory-lock command is available. Other +runners must adapt dispatch through these same hooks rather than reproduce the +locking and durability protocol. Each entry has `executionId`, `tool`, `contractRevision`, `origin`, `role`, `fixtureRevision`, `argumentsFingerprint` (SHA-256 of canonical JSON with sorted diff --git a/skills/webmcpify/references/verify.md b/skills/webmcpify/references/verify.md index 734b858..18df91a 100644 --- a/skills/webmcpify/references/verify.md +++ b/skills/webmcpify/references/verify.md @@ -109,7 +109,12 @@ as the expected property in the actual target Chrome build. ## Harness -Instantiate `templates/webmcp.spec.ts` + `templates/webmcp-compat.js` (bundled with this skill — vendor both together; the spec imports `parseInputSchema` from the helper on the Node side, and carries an inlined `normalizeResult` copy inside `page.evaluate` because the browser cannot close over imports) — Playwright, +Instantiate `templates/webmcp.spec.ts`, `templates/webmcp-compat.js` and the +`templates/mutation-journal.{ts,js}` pair (bundled with this skill — vendor all +of them together). The spec imports `parseInputSchema` from the compatibility +helper on the Node side, and carries an inlined `normalizeResult` copy inside +`page.evaluate` because the browser cannot close over imports. The mutation +journal is host-side and must never be copied into browser code. Use Playwright, headed persistent Chrome, one describe-block per tool generated from the manifest, with real assertions (never commented-out placeholders). Put the generated spec next to the repo's existing e2e tests. @@ -127,6 +132,7 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: process.env.WEBMCP_SPEC_DIR, // → /.webmcpify workers: 1, // one shared headed Chrome — never parallelize + retries: 0, // never replay a possibly-mutating call }); EOF WEBMCP_SPEC_DIR=/.webmcpify \ @@ -156,12 +162,16 @@ the page-context Playwright harness above, which was measured against Chrome 150 Before using the spec template, Puppeteer, Workbench, smoke or model runners, read [the durable journal protocol](reverify.md#durable-mutation-execution-journal). -The supplied browser/runtime helpers do not implement host persistence. Instrument -all mutation dispatches and cleanup with its atomic pre-dispatch/settlement hooks, -acquire its stable sidecar OS lock before the initial manifest scan, and disable -automatic retries. A runner without such hooks is read-only for this -workflow; report mutation checks not-run. Reconcile existing started entries -before selecting tools, including entries on verified/skipped tools. +Use the shipped `templates/mutation-journal.{ts,js}` helper instead of hand-rolling +the lock or write sequence: call `openMutationJournal` before the runner's first +manifest read, `beforeDispatch` immediately before every mutation (invalid examples +and cleanup included), and `settle` only after the effect or proven absence plus +cleanup has been independently established. Keep the journal open until the final +settlement and disable automatic retries. The helper uses `flock(1)` where present, +falls back to macOS/FreeBSD `lockf(1) -k` without replacing the sidecar, and fails +closed if neither command is available. A runner that cannot expose these hooks is +read-only for this workflow; report mutation checks not-run. Reconcile existing +started entries before selecting tools, including entries on verified/skipped tools. ## Agent evals (recommended; required evidence for SaaS-scale readiness claims) diff --git a/skills/webmcpify/templates/mutation-journal.js b/skills/webmcpify/templates/mutation-journal.js new file mode 100644 index 0000000..eb5e30d --- /dev/null +++ b/skills/webmcpify/templates/mutation-journal.js @@ -0,0 +1,378 @@ +/*! + * webmcpify durable mutation journal helper + * + * MIT License + * Copyright (c) 2026 Jonas Tüchler + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { open, readFile, realpath, rename, rm, stat } from "node:fs/promises"; +import { hostname } from "node:os"; +import { basename, dirname, join } from "node:path"; +const HOLDER_SOURCE = String.raw` +process.stdin.setEncoding('utf8'); +process.stdout.write('WEBMCPIFY_LOCK_READY\n'); +let pending = ''; +process.stdin.on('data', (chunk) => { + pending += chunk; + if (pending.includes('\n')) process.exit(pending.startsWith('RELEASE\n') ? 0 : 2); +}); +process.stdin.on('end', () => process.exit(0)); +`; +const LOCK_CANDIDATES = [ + { + command: "flock", + args: (lockPath) => ["--exclusive", lockPath, process.execPath, "--input-type=module", "--eval", HOLDER_SOURCE] + }, + { + command: "lockf", + // macOS/FreeBSD lockf removes a pathname on exit unless -k is used. The + // sidecar inode is permanent, so -k is a correctness requirement. + args: (lockPath) => ["-k", lockPath, process.execPath, "--input-type=module", "--eval", HOLDER_SOURCE] + } +]; +function requireText(value, label) { + if (!value.trim()) throw new Error(`${label} must be a non-empty string`); + return value; +} +function canonicalJson(value) { + const json = JSON.stringify(value); + if (json === void 0) throw new Error("mutation arguments must be JSON-serializable"); + const parsed = JSON.parse(json); + const sort = (item) => { + if (Array.isArray(item)) return item.map(sort); + if (item && typeof item === "object") { + return Object.fromEntries( + Object.entries(item).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, nested]) => [key, sort(nested)]) + ); + } + return item; + }; + return JSON.stringify(sort(parsed)); +} +function fingerprintArguments(value) { + return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; +} +async function syncDirectory(path) { + const handle = await open(path, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} +async function durableReplace(manifestPath, manifest) { + const directory = dirname(manifestPath); + const temporary = join(directory, `.${basename(manifestPath)}.${process.pid}.${randomUUID()}.tmp`); + const mode = (await stat(manifestPath)).mode & 511; + let handle; + try { + handle = await open(temporary, "wx", mode); + await handle.writeFile(`${JSON.stringify(manifest, null, 2)} +`, "utf8"); + await handle.sync(); + await handle.close(); + handle = void 0; + await rename(temporary, manifestPath); + await syncDirectory(directory); + } catch (error) { + await handle?.close().catch(() => void 0); + await rm(temporary, { force: true }).catch(() => void 0); + throw error; + } +} +async function readManifest(manifestPath) { + const value = JSON.parse(await readFile(manifestPath, "utf8")); + if (!Array.isArray(value.tools)) throw new Error(`manifest has no tools array: ${manifestPath}`); + return value; +} +function unresolvedEntries(manifest) { + return manifest.tools.flatMap( + (tool) => Array.isArray(tool.mutationExecutions) ? tool.mutationExecutions.filter((entry) => entry?.state === "started") : [] + ); +} +async function migrateManifest(manifestPath, manifest) { + let changed = false; + for (const tool of manifest.tools) { + if (!Array.isArray(tool.mutationExecutions)) { + tool.mutationExecutions = []; + tool.verifiedAgainst = null; + if (tool.status === "verified") tool.status = "integrated"; + changed = true; + } + } + if (changed) await durableReplace(manifestPath, manifest); + return manifest; +} +async function writeOwnerMetadata(lockPath, metadata) { + const handle = await open(lockPath, "r+"); + try { + await handle.truncate(0); + await handle.writeFile(`${JSON.stringify(metadata)} +`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } +} +async function startCandidate(candidate, lockPath, timeoutMs) { + const child = spawn(candidate.command, candidate.args(lockPath), { stdio: ["pipe", "pipe", "pipe"] }); + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + let settled = false; + const timer = timeoutMs === void 0 ? void 0 : setTimeout(() => { + child.kill("SIGTERM"); + finishReject(new Error(`timed out after ${timeoutMs}ms waiting for ${lockPath}`)); + }, timeoutMs); + const cleanup = () => { + if (timer) clearTimeout(timer); + child.off("error", onError); + child.off("exit", onExit); + child.stdout.off("data", onStdout); + child.stderr.off("data", onStderr); + }; + const finishReject = (error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const onError = (error) => finishReject(error); + const onExit = (code, signal) => { + finishReject(new Error( + `${candidate.command} exited before lock acquisition (${code ?? signal ?? "unknown"}): ${stderr.trim()}` + )); + }; + const onStdout = (chunk) => { + stdout += chunk.toString("utf8"); + if (!stdout.includes("WEBMCPIFY_LOCK_READY\n") || settled) return; + settled = true; + cleanup(); + resolve(child); + }; + const onStderr = (chunk) => { + stderr += chunk.toString("utf8"); + }; + child.once("error", onError); + child.once("exit", onExit); + child.stdout.on("data", onStdout); + child.stderr.on("data", onStderr); + }); +} +async function acquireLock(lockPath, timeoutMs) { + const unavailable = []; + for (const candidate of LOCK_CANDIDATES) { + try { + return await startCandidate(candidate, lockPath, timeoutMs); + } catch (error) { + if (error.code === "ENOENT") { + unavailable.push(candidate.command); + continue; + } + throw error; + } + } + throw new Error( + `mutation verification is not available: no supported advisory-lock command (${unavailable.join(", ")})` + ); +} +class MutationJournal { + manifestPath; + lockPath; + ownerToken; + #holder; + #closed = false; + #lockFailure; + #unresolved; + constructor(manifestPath, lockPath, ownerToken, holder, unresolved) { + this.manifestPath = manifestPath; + this.lockPath = lockPath; + this.ownerToken = ownerToken; + this.#holder = holder; + this.#unresolved = unresolved; + holder.once("error", (error) => { + if (!this.#closed) this.#lockFailure = new Error(`mutation journal lock holder failed: ${error.message}`); + }); + holder.once("exit", (code, signal) => { + if (!this.#closed) { + this.#lockFailure = new Error( + `mutation journal lock holder exited unexpectedly (${code ?? signal ?? "unknown"})` + ); + } + }); + } + static async open(options) { + const manifestPath = await realpath(options.manifestPath); + const manifestDirectory = dirname(manifestPath); + const lockPath = join(manifestDirectory, "manifest.lock"); + const lockFile = await open(lockPath, "a", 384); + await lockFile.close(); + const holder = await acquireLock(lockPath, options.timeoutMs); + const ownerToken = randomUUID(); + try { + await writeOwnerMetadata(lockPath, { + ownerToken, + host: hostname(), + pid: process.pid, + processStartedAt: new Date(Date.now() - process.uptime() * 1e3).toISOString(), + acquiredAt: (/* @__PURE__ */ new Date()).toISOString() + }); + const manifest = await migrateManifest(manifestPath, await readManifest(manifestPath)); + return new MutationJournal(manifestPath, lockPath, ownerToken, holder, unresolvedEntries(manifest)); + } catch (error) { + holder.stdin.end("RELEASE\n"); + throw error; + } + } + get unresolved() { + return structuredClone(this.#unresolved); + } + async beforeDispatch(input) { + this.#assertOpen(); + requireText(input.tool, "tool"); + requireText(input.origin, "origin"); + requireText(input.role, "role"); + requireText(input.fixtureRevision, "fixtureRevision"); + requireText(input.evidence, "evidence"); + if (!Number.isInteger(input.contractRevision) || input.contractRevision < 1) { + throw new Error("contractRevision must be a positive integer"); + } + const manifest = await readManifest(this.manifestPath); + const unresolved = unresolvedEntries(manifest); + const manifestTool = input.manifestTool ?? input.tool; + const owner = manifest.tools.find((tool) => tool.id === manifestTool); + if (!owner || !Array.isArray(owner.mutationExecutions)) { + throw new Error(`manifest tool has no mutation journal: ${manifestTool}`); + } + if (input.parentExecutionId) { + const parent = unresolved.find((entry) => entry.executionId === input.parentExecutionId); + const parentOwner = manifest.tools.find( + (tool) => tool.mutationExecutions?.some((entry) => entry.executionId === input.parentExecutionId) + ); + const unrelated = unresolved.filter((entry) => entry.executionId !== input.parentExecutionId); + if (!parent || parentOwner !== owner || unrelated.length > 0) { + throw new Error("cleanup dispatch is allowed only for its sole unresolved parent execution"); + } + } else if (unresolved.length > 0) { + throw new Error(`mutation dispatch blocked by ${unresolved.length} unresolved execution(s)`); + } + const execution = { + executionId: randomUUID(), + tool: input.tool, + contractRevision: input.contractRevision, + origin: input.origin, + role: input.role, + fixtureRevision: input.fixtureRevision, + argumentsFingerprint: fingerprintArguments(input.arguments), + startedAt: (/* @__PURE__ */ new Date()).toISOString(), + state: "started", + evidence: input.evidence, + ...input.parentExecutionId ? { parentExecutionId: input.parentExecutionId } : {} + }; + owner.mutationExecutions.push(execution); + await durableReplace(this.manifestPath, manifest); + const stored = await readManifest(this.manifestPath); + const persisted = stored.tools.find((tool) => tool.id === manifestTool)?.mutationExecutions?.find((entry) => entry.executionId === execution.executionId); + if (!persisted || persisted.state !== "started") { + throw new Error(`pre-dispatch journal entry was not durably persisted: ${execution.executionId}`); + } + this.#unresolved = unresolvedEntries(stored); + return structuredClone(execution); + } + async settle(executionId, input) { + this.#assertOpen(); + requireText(executionId, "executionId"); + requireText(input.outcome, "outcome"); + requireText(input.evidence, "evidence"); + const manifest = await readManifest(this.manifestPath); + const owner = manifest.tools.find( + (tool) => tool.mutationExecutions?.some((entry) => entry.executionId === executionId) + ); + const execution = owner?.mutationExecutions?.find((entry) => entry.executionId === executionId); + if (!execution) throw new Error(`mutation execution not found: ${executionId}`); + if (execution.state !== "started") throw new Error(`mutation execution is already ${execution.state}: ${executionId}`); + if (!execution.parentExecutionId) { + const unresolvedCleanup = owner?.mutationExecutions?.find( + (entry) => entry.parentExecutionId === executionId && entry.state === "started" + ); + if (unresolvedCleanup) { + throw new Error(`mutation execution has unresolved cleanup: ${unresolvedCleanup.executionId}`); + } + } + execution.state = "reconciled"; + execution.outcome = input.outcome; + execution.reconciledAt = input.reconciledAt ?? (/* @__PURE__ */ new Date()).toISOString(); + execution.evidence = input.evidence; + await durableReplace(this.manifestPath, manifest); + this.#unresolved = unresolvedEntries(await readManifest(this.manifestPath)); + return structuredClone(execution); + } + async close() { + if (this.#closed) return; + this.#closed = true; + const holder = this.#holder; + this.#holder = void 0; + if (!holder) return; + if (this.#lockFailure || holder.exitCode !== null || holder.signalCode !== null) { + throw this.#lockFailure ?? new Error("mutation journal lock holder exited before release"); + } + let syncError; + try { + await syncDirectory(dirname(this.manifestPath)); + } catch (error) { + syncError = error; + } + const exit = new Promise((resolve, reject) => { + holder.once("error", reject); + holder.once("exit", (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`lock holder exited while releasing (${code ?? signal ?? "unknown"})`)); + }); + }); + holder.stdin.end("RELEASE\n"); + await exit; + if (syncError) throw syncError; + } + #assertOpen() { + if (this.#closed || !this.#holder) throw new Error("mutation journal is closed"); + if (this.#lockFailure || this.#holder.exitCode !== null || this.#holder.signalCode !== null) { + throw this.#lockFailure ?? new Error("mutation journal lock ownership was lost"); + } + } +} +async function openMutationJournal(options) { + return MutationJournal.open(options); +} +async function withMutationJournal(options, callback) { + const journal = await openMutationJournal(options); + try { + return await callback(journal); + } finally { + await journal.close(); + } +} +export { + MutationJournal, + fingerprintArguments, + openMutationJournal, + withMutationJournal +}; diff --git a/skills/webmcpify/templates/mutation-journal.ts b/skills/webmcpify/templates/mutation-journal.ts new file mode 100644 index 0000000..2474fd4 --- /dev/null +++ b/skills/webmcpify/templates/mutation-journal.ts @@ -0,0 +1,480 @@ +/*! + * webmcpify durable mutation journal helper + * + * MIT License + * Copyright (c) 2026 Jonas Tüchler + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { open, readFile, realpath, rename, rm, stat } from 'node:fs/promises'; +import { hostname } from 'node:os'; +import { basename, dirname, join } from 'node:path'; + +type JsonObject = Record; + +export interface MutationExecution { + executionId: string; + tool: string; + contractRevision: number; + origin: string; + role: string; + fixtureRevision: string; + argumentsFingerprint: string; + startedAt: string; + state: 'started' | 'reconciled'; + evidence: string; + parentExecutionId?: string; + outcome?: string; + reconciledAt?: string; +} + +export interface BeforeDispatchInput { + /** Tool/action being dispatched. */ + tool: string; + /** Manifest tool that owns the journal; defaults to `tool`. */ + manifestTool?: string; + contractRevision: number; + origin: string; + role: string; + fixtureRevision: string; + arguments: unknown; + evidence: string; + /** Set only for a cleanup of this unresolved parent execution. */ + parentExecutionId?: string; +} + +export interface SettlementInput { + outcome: string; + evidence: string; + reconciledAt?: string; +} + +export interface OpenMutationJournalOptions { + manifestPath: string; + /** Optional bounded acquisition time; omitted means wait until ownership is available. */ + timeoutMs?: number; +} + +interface ManifestTool extends JsonObject { + id?: string; + status?: string; + verifiedAgainst?: unknown; + mutationExecutions?: MutationExecution[]; +} + +interface Manifest extends JsonObject { + tools: ManifestTool[]; +} + +interface LockCandidate { + command: string; + args(lockPath: string): string[]; +} + +const HOLDER_SOURCE = String.raw` +process.stdin.setEncoding('utf8'); +process.stdout.write('WEBMCPIFY_LOCK_READY\n'); +let pending = ''; +process.stdin.on('data', (chunk) => { + pending += chunk; + if (pending.includes('\n')) process.exit(pending.startsWith('RELEASE\n') ? 0 : 2); +}); +process.stdin.on('end', () => process.exit(0)); +`; + +const LOCK_CANDIDATES: LockCandidate[] = [ + { + command: 'flock', + args: (lockPath) => ['--exclusive', lockPath, process.execPath, '--input-type=module', '--eval', HOLDER_SOURCE], + }, + { + command: 'lockf', + // macOS/FreeBSD lockf removes a pathname on exit unless -k is used. The + // sidecar inode is permanent, so -k is a correctness requirement. + args: (lockPath) => ['-k', lockPath, process.execPath, '--input-type=module', '--eval', HOLDER_SOURCE], + }, +]; + +function requireText(value: string, label: string): string { + if (!value.trim()) throw new Error(`${label} must be a non-empty string`); + return value; +} + +function canonicalJson(value: unknown): string { + const json = JSON.stringify(value); + if (json === undefined) throw new Error('mutation arguments must be JSON-serializable'); + const parsed = JSON.parse(json) as unknown; + const sort = (item: unknown): unknown => { + if (Array.isArray(item)) return item.map(sort); + if (item && typeof item === 'object') { + return Object.fromEntries( + Object.entries(item as JsonObject) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, nested]) => [key, sort(nested)]), + ); + } + return item; + }; + return JSON.stringify(sort(parsed)); +} + +export function fingerprintArguments(value: unknown): string { + return `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`; +} + +async function syncDirectory(path: string): Promise { + const handle = await open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function durableReplace(manifestPath: string, manifest: Manifest): Promise { + const directory = dirname(manifestPath); + const temporary = join(directory, `.${basename(manifestPath)}.${process.pid}.${randomUUID()}.tmp`); + const mode = (await stat(manifestPath)).mode & 0o777; + let handle; + try { + handle = await open(temporary, 'wx', mode); + await handle.writeFile(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporary, manifestPath); + await syncDirectory(directory); + } catch (error) { + await handle?.close().catch(() => undefined); + await rm(temporary, { force: true }).catch(() => undefined); + throw error; + } +} + +async function readManifest(manifestPath: string): Promise { + const value = JSON.parse(await readFile(manifestPath, 'utf8')) as Partial; + if (!Array.isArray(value.tools)) throw new Error(`manifest has no tools array: ${manifestPath}`); + return value as Manifest; +} + +function unresolvedEntries(manifest: Manifest): MutationExecution[] { + return manifest.tools.flatMap((tool) => + Array.isArray(tool.mutationExecutions) + ? tool.mutationExecutions.filter((entry) => entry?.state === 'started') + : [], + ); +} + +async function migrateManifest(manifestPath: string, manifest: Manifest): Promise { + let changed = false; + for (const tool of manifest.tools) { + if (!Array.isArray(tool.mutationExecutions)) { + tool.mutationExecutions = []; + tool.verifiedAgainst = null; + if (tool.status === 'verified') tool.status = 'integrated'; + changed = true; + } + } + if (changed) await durableReplace(manifestPath, manifest); + return manifest; +} + +async function writeOwnerMetadata(lockPath: string, metadata: JsonObject): Promise { + const handle = await open(lockPath, 'r+'); + try { + await handle.truncate(0); + await handle.writeFile(`${JSON.stringify(metadata)}\n`, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function startCandidate( + candidate: LockCandidate, + lockPath: string, + timeoutMs?: number, +): Promise { + const child = spawn(candidate.command, candidate.args(lockPath), { stdio: ['pipe', 'pipe', 'pipe'] }); + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + let settled = false; + const timer = timeoutMs === undefined ? undefined : setTimeout(() => { + child.kill('SIGTERM'); + finishReject(new Error(`timed out after ${timeoutMs}ms waiting for ${lockPath}`)); + }, timeoutMs); + + const cleanup = () => { + if (timer) clearTimeout(timer); + child.off('error', onError); + child.off('exit', onExit); + child.stdout.off('data', onStdout); + child.stderr.off('data', onStderr); + }; + const finishReject = (error: Error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const onError = (error: NodeJS.ErrnoException) => finishReject(error); + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + finishReject(new Error( + `${candidate.command} exited before lock acquisition (${code ?? signal ?? 'unknown'}): ${stderr.trim()}`, + )); + }; + const onStdout = (chunk: Buffer) => { + stdout += chunk.toString('utf8'); + if (!stdout.includes('WEBMCPIFY_LOCK_READY\n') || settled) return; + settled = true; + cleanup(); + resolve(child); + }; + const onStderr = (chunk: Buffer) => { stderr += chunk.toString('utf8'); }; + + child.once('error', onError); + child.once('exit', onExit); + child.stdout.on('data', onStdout); + child.stderr.on('data', onStderr); + }); +} + +async function acquireLock(lockPath: string, timeoutMs?: number): Promise { + const unavailable: string[] = []; + for (const candidate of LOCK_CANDIDATES) { + try { + return await startCandidate(candidate, lockPath, timeoutMs); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + unavailable.push(candidate.command); + continue; + } + throw error; + } + } + throw new Error( + `mutation verification is not available: no supported advisory-lock command (${unavailable.join(', ')})`, + ); +} + +export class MutationJournal { + readonly manifestPath: string; + readonly lockPath: string; + readonly ownerToken: string; + #holder: ChildProcessWithoutNullStreams | undefined; + #closed = false; + #lockFailure: Error | undefined; + #unresolved: MutationExecution[]; + + private constructor( + manifestPath: string, + lockPath: string, + ownerToken: string, + holder: ChildProcessWithoutNullStreams, + unresolved: MutationExecution[], + ) { + this.manifestPath = manifestPath; + this.lockPath = lockPath; + this.ownerToken = ownerToken; + this.#holder = holder; + this.#unresolved = unresolved; + holder.once('error', (error) => { + if (!this.#closed) this.#lockFailure = new Error(`mutation journal lock holder failed: ${error.message}`); + }); + holder.once('exit', (code, signal) => { + if (!this.#closed) { + this.#lockFailure = new Error( + `mutation journal lock holder exited unexpectedly (${code ?? signal ?? 'unknown'})`, + ); + } + }); + } + + static async open(options: OpenMutationJournalOptions): Promise { + const manifestPath = await realpath(options.manifestPath); + const manifestDirectory = dirname(manifestPath); + const lockPath = join(manifestDirectory, 'manifest.lock'); + const lockFile = await open(lockPath, 'a', 0o600); + await lockFile.close(); + + const holder = await acquireLock(lockPath, options.timeoutMs); + const ownerToken = randomUUID(); + try { + await writeOwnerMetadata(lockPath, { + ownerToken, + host: hostname(), + pid: process.pid, + processStartedAt: new Date(Date.now() - process.uptime() * 1_000).toISOString(), + acquiredAt: new Date().toISOString(), + }); + const manifest = await migrateManifest(manifestPath, await readManifest(manifestPath)); + return new MutationJournal(manifestPath, lockPath, ownerToken, holder, unresolvedEntries(manifest)); + } catch (error) { + holder.stdin.end('RELEASE\n'); + throw error; + } + } + + get unresolved(): readonly MutationExecution[] { + return structuredClone(this.#unresolved); + } + + async beforeDispatch(input: BeforeDispatchInput): Promise { + this.#assertOpen(); + requireText(input.tool, 'tool'); + requireText(input.origin, 'origin'); + requireText(input.role, 'role'); + requireText(input.fixtureRevision, 'fixtureRevision'); + requireText(input.evidence, 'evidence'); + if (!Number.isInteger(input.contractRevision) || input.contractRevision < 1) { + throw new Error('contractRevision must be a positive integer'); + } + + const manifest = await readManifest(this.manifestPath); + const unresolved = unresolvedEntries(manifest); + const manifestTool = input.manifestTool ?? input.tool; + const owner = manifest.tools.find((tool) => tool.id === manifestTool); + if (!owner || !Array.isArray(owner.mutationExecutions)) { + throw new Error(`manifest tool has no mutation journal: ${manifestTool}`); + } + if (input.parentExecutionId) { + const parent = unresolved.find((entry) => entry.executionId === input.parentExecutionId); + const parentOwner = manifest.tools.find((tool) => + tool.mutationExecutions?.some((entry) => entry.executionId === input.parentExecutionId), + ); + const unrelated = unresolved.filter((entry) => entry.executionId !== input.parentExecutionId); + if (!parent || parentOwner !== owner || unrelated.length > 0) { + throw new Error('cleanup dispatch is allowed only for its sole unresolved parent execution'); + } + } else if (unresolved.length > 0) { + throw new Error(`mutation dispatch blocked by ${unresolved.length} unresolved execution(s)`); + } + + const execution: MutationExecution = { + executionId: randomUUID(), + tool: input.tool, + contractRevision: input.contractRevision, + origin: input.origin, + role: input.role, + fixtureRevision: input.fixtureRevision, + argumentsFingerprint: fingerprintArguments(input.arguments), + startedAt: new Date().toISOString(), + state: 'started', + evidence: input.evidence, + ...(input.parentExecutionId ? { parentExecutionId: input.parentExecutionId } : {}), + }; + owner.mutationExecutions.push(execution); + await durableReplace(this.manifestPath, manifest); + + const stored = await readManifest(this.manifestPath); + const persisted = stored.tools + .find((tool) => tool.id === manifestTool) + ?.mutationExecutions?.find((entry) => entry.executionId === execution.executionId); + if (!persisted || persisted.state !== 'started') { + throw new Error(`pre-dispatch journal entry was not durably persisted: ${execution.executionId}`); + } + this.#unresolved = unresolvedEntries(stored); + return structuredClone(execution); + } + + async settle(executionId: string, input: SettlementInput): Promise { + this.#assertOpen(); + requireText(executionId, 'executionId'); + requireText(input.outcome, 'outcome'); + requireText(input.evidence, 'evidence'); + + const manifest = await readManifest(this.manifestPath); + const owner = manifest.tools.find((tool) => + tool.mutationExecutions?.some((entry) => entry.executionId === executionId), + ); + const execution = owner?.mutationExecutions?.find((entry) => entry.executionId === executionId); + if (!execution) throw new Error(`mutation execution not found: ${executionId}`); + if (execution.state !== 'started') throw new Error(`mutation execution is already ${execution.state}: ${executionId}`); + if (!execution.parentExecutionId) { + const unresolvedCleanup = owner?.mutationExecutions?.find((entry) => + entry.parentExecutionId === executionId && entry.state === 'started', + ); + if (unresolvedCleanup) { + throw new Error(`mutation execution has unresolved cleanup: ${unresolvedCleanup.executionId}`); + } + } + + execution.state = 'reconciled'; + execution.outcome = input.outcome; + execution.reconciledAt = input.reconciledAt ?? new Date().toISOString(); + execution.evidence = input.evidence; + await durableReplace(this.manifestPath, manifest); + this.#unresolved = unresolvedEntries(await readManifest(this.manifestPath)); + return structuredClone(execution); + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + const holder = this.#holder; + this.#holder = undefined; + if (!holder) return; + if (this.#lockFailure || holder.exitCode !== null || holder.signalCode !== null) { + throw this.#lockFailure ?? new Error('mutation journal lock holder exited before release'); + } + + let syncError: unknown; + try { + await syncDirectory(dirname(this.manifestPath)); + } catch (error) { + syncError = error; + } + const exit = new Promise((resolve, reject) => { + holder.once('error', reject); + holder.once('exit', (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`lock holder exited while releasing (${code ?? signal ?? 'unknown'})`)); + }); + }); + holder.stdin.end('RELEASE\n'); + await exit; + if (syncError) throw syncError; + } + + #assertOpen(): void { + if (this.#closed || !this.#holder) throw new Error('mutation journal is closed'); + if (this.#lockFailure || this.#holder.exitCode !== null || this.#holder.signalCode !== null) { + throw this.#lockFailure ?? new Error('mutation journal lock ownership was lost'); + } + } +} + +export async function openMutationJournal(options: OpenMutationJournalOptions): Promise { + return MutationJournal.open(options); +} + +export async function withMutationJournal( + options: OpenMutationJournalOptions, + callback: (journal: MutationJournal) => Promise, +): Promise { + const journal = await openMutationJournal(options); + try { + return await callback(journal); + } finally { + await journal.close(); + } +} diff --git a/skills/webmcpify/templates/webmcp.spec.ts b/skills/webmcpify/templates/webmcp.spec.ts index 60f59b9..e9900b7 100644 --- a/skills/webmcpify/templates/webmcp.spec.ts +++ b/skills/webmcpify/templates/webmcp.spec.ts @@ -38,8 +38,12 @@ */ import { chromium, expect, test } from '@playwright/test'; import type { BrowserContext, Page } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; // @ts-expect-error — shared JS helper; inlined copies live inside page.evaluate (browser boundary) import { parseInputSchema } from './webmcp-compat.js'; +import { openMutationJournal, type MutationJournal } from './mutation-journal.js'; + +test.describe.configure({ mode: 'serial', retries: 0 }); function requiredEnv(name: 'WEBMCP_BASE_URL' | 'WEBMCP_PROFILE_DIR'): string { const value = process.env[name]?.trim(); @@ -49,23 +53,44 @@ function requiredEnv(name: 'WEBMCP_BASE_URL' | 'WEBMCP_PROFILE_DIR'): string { const BASE_URL = requiredEnv('WEBMCP_BASE_URL'); const PROFILE_DIR = requiredEnv('WEBMCP_PROFILE_DIR'); +const MANIFEST_PATH = fileURLToPath(new URL('./manifest.json', import.meta.url)); -let context: BrowserContext; +let context: BrowserContext | undefined; let page: Page; +let mutationJournal: MutationJournal | undefined; type ExecuteInputMode = 'object' | 'json-string'; let executeInputMode: ExecuteInputMode | undefined; test.beforeAll(async () => { - context = await chromium.launchPersistentContext(PROFILE_DIR, { - channel: 'chrome', - headless: false, - args: ['--enable-features=WebMCP,WebMCPTesting'], - }); - page = await context.newPage(); + // The helper acquires the permanent manifest.lock sidecar before its first + // manifest read and holds it through every dispatch, cleanup and settlement. + mutationJournal = await openMutationJournal({ manifestPath: MANIFEST_PATH }); + if (mutationJournal.unresolved.length > 0) { + const ids = mutationJournal.unresolved.map(({ executionId }) => executionId).join(', '); + await mutationJournal.close(); + mutationJournal = undefined; + throw new Error(`reconcile unresolved mutation executions before running the harness: ${ids}`); + } + try { + context = await chromium.launchPersistentContext(PROFILE_DIR, { + channel: 'chrome', + headless: false, + args: ['--enable-features=WebMCP,WebMCPTesting'], + }); + page = await context.newPage(); + } catch (error) { + await mutationJournal.close(); + mutationJournal = undefined; + throw error; + } }); test.afterAll(async () => { - await context.close(); + try { + await context?.close(); + } finally { + await mutationJournal?.close(); + } }); /** Enumerate registered tools; older native builds may stringify JSON Schema while current builds return objects — handle both. */ @@ -265,11 +290,23 @@ test.describe('send_contact_message', () => { test('executes via the concurrent submit-click pattern', async () => { expect(await waitForTool(page, 'send_contact_message')).toBe(true); - // 1. Start the execution WITHOUT awaiting it (Chrome pauses it at the form). - const pending = executeTool(page, 'send_contact_message', { + const args = { email: 'qa@example.test', // manifest: examples.valid message: '[webmcpify verification] harness test message', + }; + // Persist `started` BEFORE dispatch. Any exception after this line leaves it + // unresolved for authoritative read-path reconciliation on the next run. + const execution = await mutationJournal!.beforeDispatch({ + tool: 'send_contact_message', + contractRevision: 1, // manifest: contractRevision + origin: BASE_URL, // manifest: app.verificationOrigin + role: 'none', // manifest: auth fixture role + fixtureRevision: 'contact-seed-v1', // manifest: app.authFixtures fixture revision + arguments: args, + evidence: '.webmcpify/evidence/send-contact-message.json', }); + // 1. Start the execution WITHOUT awaiting it (Chrome pauses it at the form). + const pending = executeTool(page, 'send_contact_message', args); // 2. Wait until the agent-filled value is visible in the form. await expect(page.getByLabel('Email')).toHaveValue('qa@example.test'); // 3. Perform the real submit interaction that resumes the paused execution. @@ -284,8 +321,53 @@ test.describe('send_contact_message', () => { expect(out).not.toMatch(/^ERROR:/); expect(out).toContain('received'); // manifest: expect.result } - // manifest: cleanup — mutating:"server" tools MUST undo the side effect here - // (e.g. delete the test message via the UI's own admin path). + // manifest: cleanup — this UI action mutates too, so it gets its own durable + // entry linked to the still-started parent. Replace selectors with the + // manifest's concrete cleanup/read path when instantiating the template. + const cleanup = await mutationJournal!.beforeDispatch({ + tool: 'cleanup:send_contact_message', + manifestTool: 'send_contact_message', + contractRevision: 1, + origin: BASE_URL, + role: 'none', + fixtureRevision: 'contact-seed-v1', + arguments: { email: 'qa@example.test' }, + evidence: '.webmcpify/evidence/send-contact-message-cleanup.json', + parentExecutionId: execution.executionId, + }); + await page.goto(`${BASE_URL}/admin/messages`); // manifest: cleanup read path + const fixtureRow = page.getByRole('row', { name: /qa@example\.test/ }); + await fixtureRow.getByRole('button', { name: 'Delete' }).click(); + await expect(fixtureRow).toHaveCount(0); // independently prove cleanup + await mutationJournal!.settle(cleanup.executionId, { + outcome: 'fixture removed', + evidence: '.webmcpify/evidence/send-contact-message-cleanup-settled.json', + }); + await mutationJournal!.settle(execution.executionId, { + outcome: 'effect verified and cleanup reconciled', + evidence: '.webmcpify/evidence/send-contact-message-settled.json', + }); + }); + + test('rejects the invalid example without changing server state', async () => { + expect(await waitForTool(page, 'send_contact_message')).toBe(true); + const before = await page.getByRole('status', { name: 'Sent message count' }).innerText(); + const execution = await mutationJournal!.beforeDispatch({ + tool: 'send_contact_message', + contractRevision: 1, + origin: BASE_URL, + role: 'none', + fixtureRevision: 'contact-seed-v1', + arguments: {}, // manifest: examples.invalid + evidence: '.webmcpify/evidence/send-contact-message-invalid.json', + }); + await expect(executeTool(page, 'send_contact_message', {})).rejects.toThrow(); + const after = await page.getByRole('status', { name: 'Sent message count' }).innerText(); + expect(after).toBe(before); // independent read path proves absence of an effect + await mutationJournal!.settle(execution.executionId, { + outcome: 'validation rejected; no effect observed', + evidence: '.webmcpify/evidence/send-contact-message-invalid-settled.json', + }); }); }); diff --git a/tests/manifest-lock.test.mjs b/tests/manifest-lock.test.mjs index b881651..a00b12a 100644 --- a/tests/manifest-lock.test.mjs +++ b/tests/manifest-lock.test.mjs @@ -1,89 +1,65 @@ import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; -import { - access, - mkdtemp, - open, - readFile, - rename, - rm, - stat, - writeFile, -} from 'node:fs/promises'; +import { access, chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import test from 'node:test'; +import { transformSync } from 'esbuild'; +import { + fingerprintArguments, + openMutationJournal, +} from '../skills/webmcpify/templates/mutation-journal.js'; -const flockAvailable = process.platform === 'linux' - && spawnSync('flock', ['--version'], { stdio: 'ignore' }).status === 0; - -const worker = String.raw` - const { open, readFile, rename, writeFile } = await import('node:fs/promises'); - const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - const { MODE, MANIFEST, LOCK, HELD, RELEASE, SETTLED, ACQUIRED } = process.env; - - async function durableManifest(value) { - const temporary = MANIFEST + '.' + process.pid + '.tmp'; - await writeFile(temporary, JSON.stringify(value)); - const temporaryHandle = await open(temporary, 'r'); - await temporaryHandle.sync(); - await temporaryHandle.close(); - await rename(temporary, MANIFEST); - const directoryHandle = await open((await import('node:path')).dirname(MANIFEST), 'r'); - await directoryHandle.sync(); - await directoryHandle.close(); - } +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const helperUrl = pathToFileURL(join(root, 'skills/webmcpify/templates/mutation-journal.js')).href; +const hasCommand = (command) => spawnSync( + '/bin/sh', + ['-c', 'command -v "$1" >/dev/null 2>&1', 'sh', command], + { stdio: 'ignore' }, +).status === 0; +const commandPath = (command) => spawnSync( + '/bin/sh', + ['-c', 'command -v "$1"', 'sh', command], + { encoding: 'utf8' }, +).stdout.trim(); +const advisoryLockAvailable = process.platform !== 'win32' + && (hasCommand('flock') || hasCommand('lockf')); +const requiresAdvisoryLock = !advisoryLockAvailable + && 'requires flock(1) or macOS/FreeBSD lockf(1); mutation verification fails closed without either'; - await writeFile(LOCK, JSON.stringify({ owner: MODE, pid: process.pid })); - const lockHandle = await open(LOCK, 'r'); - await lockHandle.sync(); - await lockHandle.close(); - - if (MODE === 'a') { - const manifest = JSON.parse(await readFile(MANIFEST, 'utf8')); - manifest.state = 'started'; - await durableManifest(manifest); - await writeFile(HELD, 'locked-after-atomic-replace'); - while (true) { - try { - await readFile(RELEASE); - break; - } catch (error) { - if (error.code !== 'ENOENT') throw error; - await sleep(20); - } - } - manifest.state = 'reconciled'; - await durableManifest(manifest); - await writeFile(SETTLED, 'settled-before-unlock'); - await writeFile(LOCK, JSON.stringify({ owner: MODE, released: true })); - } else { - const manifest = JSON.parse(await readFile(MANIFEST, 'utf8')); - await writeFile(ACQUIRED, JSON.stringify({ state: manifest.state })); - } -`; - -function runLocked(mode, paths) { - return spawn('flock', [ - '--exclusive', - paths.lock, - process.execPath, - '--input-type=module', - '--eval', - worker, - ], { - env: { - ...process.env, - MODE: mode, - MANIFEST: paths.manifest, - LOCK: paths.lock, - HELD: paths.held, - RELEASE: paths.release, - SETTLED: paths.settled, - ACQUIRED: paths.acquired, - }, - stdio: ['ignore', 'ignore', 'pipe'], - }); +function manifest(tools = [{ + id: 'send_contact_message', + status: 'integrated', + contractRevision: 1, + mutationExecutions: [], + verifiedAgainst: null, +}]) { + return { version: 4, tools }; +} + +async function fixture(value = manifest()) { + const directory = await mkdtemp(join(tmpdir(), 'webmcpify-mutation-journal-')); + const manifestPath = join(directory, 'manifest.json'); + await writeFile(manifestPath, `${JSON.stringify(value, null, 2)}\n`); + return { directory, manifestPath, lockPath: join(directory, 'manifest.lock') }; +} + +function mutation(overrides = {}) { + return { + tool: 'send_contact_message', + contractRevision: 1, + origin: 'https://app.example.test', + role: 'member', + fixtureRevision: 'seed-v2', + arguments: { message: 'hello', email: 'qa@example.test' }, + evidence: '.webmcpify/evidence/contact-message.json', + ...overrides, + }; +} + +async function delay(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); } async function waitForFile(path, timeoutMs = 5_000) { @@ -94,7 +70,7 @@ async function waitForFile(path, timeoutMs = 5_000) { return; } catch (error) { if (error.code !== 'ENOENT') throw error; - await new Promise((resolve) => setTimeout(resolve, 20)); + await delay(20); } } throw new Error(`timed out waiting for ${path}`); @@ -104,43 +80,248 @@ async function waitForExit(child) { let stderr = ''; child.stderr.setEncoding('utf8'); child.stderr.on('data', (chunk) => { stderr += chunk; }); - const code = await new Promise((resolve) => child.once('exit', resolve)); - assert.equal(code, 0, stderr); + if (child.exitCode !== null || child.signalCode !== null) { + return { code: child.exitCode, signal: child.signalCode, stderr }; + } + const result = await new Promise((resolve) => child.once('exit', (code, signal) => resolve({ code, signal }))); + return { ...result, stderr }; } -test('runner B stays blocked while runner A atomically replaces manifest until A settles and releases ownership', { - skip: !flockAvailable && 'requires Linux util-linux flock', +test('argument fingerprints use recursively sorted canonical JSON', () => { + assert.equal( + fingerprintArguments({ z: 1, a: { y: 2, x: [3, { b: 2, a: 1 }] } }), + fingerprintArguments({ a: { x: [3, { a: 1, b: 2 }], y: 2 }, z: 1 }), + ); + assert.equal( + fingerprintArguments({ 'ä': 1, z: 2, A: 3 }), + fingerprintArguments({ A: 3, z: 2, 'ä': 1 }), + 'fingerprints must not depend on the host locale', + ); +}); + +test('the TypeScript and JavaScript helper twins stay byte-equivalent', async () => { + const templates = join(root, 'skills/webmcpify/templates'); + const generated = transformSync(await readFile(join(templates, 'mutation-journal.ts'), 'utf8'), { + loader: 'ts', + target: 'es2022', + format: 'esm', + legalComments: 'inline', + }).code; + assert.equal(await readFile(join(templates, 'mutation-journal.js'), 'utf8'), generated); +}); + +test('runner B cannot scan while runner A journals, cleans up, settles, and releases', { + skip: requiresAdvisoryLock, timeout: 10_000, }, async () => { - const directory = await mkdtemp(join(tmpdir(), 'webmcpify-manifest-lock-')); - const paths = Object.fromEntries( - ['manifest', 'lock', 'held', 'release', 'settled', 'acquired'] - .map((name) => [name, join(directory, name === 'manifest' ? 'manifest.json' : `manifest.${name}`)]), - ); + const paths = await fixture(); + let runnerA; + let runnerB; + try { + runnerA = await openMutationJournal({ manifestPath: paths.manifestPath }); + const stableLockInode = (await stat(paths.lockPath)).ino; + const parent = await runnerA.beforeDispatch(mutation()); + + let runnerBAcquired = false; + const runnerBPromise = openMutationJournal({ manifestPath: paths.manifestPath }) + .then((journal) => { + runnerBAcquired = true; + return journal; + }); + await delay(300); + assert.equal(runnerBAcquired, false, 'runner B must remain blocked before its initial manifest scan'); + const cleanup = await runnerA.beforeDispatch(mutation({ + tool: 'delete_contact_message_fixture', + manifestTool: 'send_contact_message', + arguments: { fixture: 'qa@example.test' }, + evidence: '.webmcpify/evidence/contact-cleanup.json', + parentExecutionId: parent.executionId, + })); + await assert.rejects( + runnerA.settle(parent.executionId, { + outcome: 'must not settle before cleanup', + evidence: '.webmcpify/evidence/contact-message-premature.json', + }), + /has unresolved cleanup/, + ); + await runnerA.settle(cleanup.executionId, { + outcome: 'fixture removed', + evidence: '.webmcpify/evidence/contact-cleanup-settled.json', + }); + await runnerA.settle(parent.executionId, { + outcome: 'message created, verified, and cleaned up', + evidence: '.webmcpify/evidence/contact-message-settled.json', + }); + await runnerA.close(); + runnerA = undefined; + + runnerB = await runnerBPromise; + assert.equal(runnerBAcquired, true); + assert.deepEqual(runnerB.unresolved, []); + const stored = JSON.parse(await readFile(paths.manifestPath, 'utf8')); + assert.deepEqual(stored.tools[0].mutationExecutions.map((entry) => entry.state), [ + 'reconciled', + 'reconciled', + ]); + assert.equal((await stat(paths.lockPath)).ino, stableLockInode, 'sidecar inode must never change'); + } finally { + await runnerA?.close(); + await runnerB?.close(); + await rm(paths.directory, { recursive: true, force: true }); + } +}); + +test('lockf -k fallback keeps the sidecar inode and excludes a second runner', { + skip: process.platform === 'win32' || (!hasCommand('lockf') && !hasCommand('flock')) + ? 'requires native lockf(1), or flock(1) for the Linux lockf compatibility shim' + : false, + timeout: 10_000, +}, async () => { + const paths = await fixture(); + const bin = join(paths.directory, 'bin'); + await mkdir(bin); + const nativeLockf = commandPath('lockf'); + if (nativeLockf) { + await symlink(nativeLockf, join(bin, 'lockf')); + } else { + const nativeFlock = commandPath('flock'); + const shim = `#!${process.execPath}\n` + + `import { spawnSync } from 'node:child_process';\n` + + `const [keep, lockPath, command, ...args] = process.argv.slice(2);\n` + + `if (keep !== '-k') process.exit(64);\n` + + `const result = spawnSync(${JSON.stringify(nativeFlock)}, ['--exclusive', lockPath, command, ...args], { stdio: 'inherit' });\n` + + `if (result.error) throw result.error;\n` + + `process.exit(result.status ?? 1);\n`; + await writeFile(join(bin, 'lockf'), shim, { mode: 0o700 }); + } + + const originalPath = process.env.PATH; + let runnerA; + let runnerB; + try { + process.env.PATH = bin; + runnerA = await openMutationJournal({ manifestPath: paths.manifestPath }); + const stableLockInode = (await stat(paths.lockPath)).ino; + const runnerBPromise = openMutationJournal({ manifestPath: paths.manifestPath }); + await delay(150); + await runnerA.close(); + runnerA = undefined; + runnerB = await runnerBPromise; + assert.equal((await stat(paths.lockPath)).ino, stableLockInode); + } finally { + process.env.PATH = originalPath; + await runnerA?.close(); + await runnerB?.close(); + await rm(paths.directory, { recursive: true, force: true }); + } +}); + +test('mutation verification fails closed when no advisory-lock command exists', async () => { + const paths = await fixture(); + const emptyBin = join(paths.directory, 'empty-bin'); + await mkdir(emptyBin); + const originalPath = process.env.PATH; + try { + process.env.PATH = emptyBin; + await assert.rejects( + openMutationJournal({ manifestPath: paths.manifestPath }), + /no supported advisory-lock command \(flock, lockf\)/, + ); + } finally { + process.env.PATH = originalPath; + await rm(paths.directory, { recursive: true, force: true }); + } +}); + +test('pre-dispatch persistence failure prevents dispatch and leaves no started entry', { + skip: requiresAdvisoryLock, +}, async () => { + const paths = await fixture(); + const journal = await openMutationJournal({ manifestPath: paths.manifestPath }); + let dispatched = false; try { - await writeFile(paths.manifest, JSON.stringify({ state: 'ready' })); - await writeFile(paths.lock, JSON.stringify({ owner: 'dead-runner', pid: -1 })); - const stableLockInode = (await stat(paths.lock)).ino; - - const runnerA = runLocked('a', paths); - await waitForFile(paths.held); - assert.equal(JSON.parse(await readFile(paths.manifest, 'utf8')).state, 'started'); - assert.equal(JSON.parse(await readFile(paths.lock, 'utf8')).owner, 'a'); - - const runnerB = runLocked('b', paths); - await new Promise((resolve) => setTimeout(resolve, 300)); - await assert.rejects(access(paths.acquired), { code: 'ENOENT' }); - assert.equal(runnerB.exitCode, null, 'runner B must still be waiting on the sidecar lock'); - - await writeFile(paths.release, 'release'); - await Promise.all([waitForExit(runnerA), waitForExit(runnerB)]); - - assert.equal(JSON.parse(await readFile(paths.acquired, 'utf8')).state, 'reconciled'); - assert.equal(JSON.parse(await readFile(paths.manifest, 'utf8')).state, 'reconciled'); - assert.equal((await stat(paths.lock)).ino, stableLockInode, 'sidecar inode must never change'); - await access(paths.settled); + await chmod(paths.directory, 0o500); + await assert.rejects(async () => { + await journal.beforeDispatch(mutation()); + dispatched = true; + }); + assert.equal(dispatched, false); + await chmod(paths.directory, 0o700); + const stored = JSON.parse(await readFile(paths.manifestPath, 'utf8')); + assert.deepEqual(stored.tools[0].mutationExecutions, []); } finally { - await rm(directory, { recursive: true, force: true }); + await chmod(paths.directory, 0o700); + await journal.close(); + await rm(paths.directory, { recursive: true, force: true }); } }); + +test('process crash releases the OS lock but leaves the durable started entry', { + skip: requiresAdvisoryLock, + timeout: 10_000, +}, async () => { + const paths = await fixture(); + const marker = join(paths.directory, 'started'); + const childSource = ` + import { writeFile } from 'node:fs/promises'; + import { openMutationJournal } from ${JSON.stringify(helperUrl)}; + const journal = await openMutationJournal({ manifestPath: process.env.MANIFEST }); + await journal.beforeDispatch(${JSON.stringify(mutation())}); + await writeFile(process.env.MARKER, 'started'); + process.kill(process.pid, 'SIGKILL'); + `; + const child = spawn(process.execPath, ['--input-type=module', '--eval', childSource], { + env: { ...process.env, MANIFEST: paths.manifestPath, MARKER: marker }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + + let recovery; + try { + await waitForFile(marker); + const result = await waitForExit(child); + assert.equal(result.signal, 'SIGKILL', result.stderr); + recovery = await openMutationJournal({ manifestPath: paths.manifestPath, timeoutMs: 3_000 }); + assert.equal(recovery.unresolved.length, 1); + assert.equal(recovery.unresolved[0].state, 'started'); + assert.equal(recovery.unresolved[0].tool, 'send_contact_message'); + } finally { + await recovery?.close(); + await rm(paths.directory, { recursive: true, force: true }); + } +}); + +test('opening a legacy manifest initializes journals and invalidates historical verification', { + skip: requiresAdvisoryLock, +}, async () => { + const paths = await fixture(manifest([{ + id: 'send_contact_message', + status: 'verified', + contractRevision: 1, + verifiedAgainst: { at: '2026-09-14T12:00:00Z' }, + }, { + id: 'search_tickets', + status: 'integrated', + contractRevision: 1, + }])); + const journal = await openMutationJournal({ manifestPath: paths.manifestPath }); + try { + const stored = JSON.parse(await readFile(paths.manifestPath, 'utf8')); + assert.equal(stored.tools[0].status, 'integrated'); + assert.equal(stored.tools[0].verifiedAgainst, null); + assert.deepEqual(stored.tools[0].mutationExecutions, []); + assert.equal(stored.tools[1].status, 'integrated'); + assert.equal(stored.tools[1].verifiedAgainst, null); + assert.deepEqual(stored.tools[1].mutationExecutions, []); + } finally { + await journal.close(); + await rm(paths.directory, { recursive: true, force: true }); + } +}); + +test('the portable helper retains both supported advisory-lock backends', async () => { + const source = await readFile(join(root, 'skills/webmcpify/templates/mutation-journal.js'), 'utf8'); + assert.match(source, /command: "flock"/); + assert.match(source, /command: "lockf"/); + assert.match(source, /\["-k", lockPath/); +}); From 82eca6322cce04a045b6daf79c690a9ee85b5755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20T=C3=BCchler?= Date: Sat, 19 Sep 2026 16:45:11 +0000 Subject: [PATCH 2/6] fix: terminate timed-out mutation lock waiters --- .github/workflows/ci.yml | 2 +- .../webmcpify/templates/mutation-journal.js | 12 +++++++-- .../webmcpify/templates/mutation-journal.ts | 15 +++++++++-- tests/manifest-lock.test.mjs | 25 +++++++++++++++++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fa423b..9591962 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ permissions: jobs: check: - runs-on: [self-hosted, Linux, X64, twb-ci] + runs-on: [self-hosted, Linux, X64, tuejon-ci] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/skills/webmcpify/templates/mutation-journal.js b/skills/webmcpify/templates/mutation-journal.js index eb5e30d..3cbb1b6 100644 --- a/skills/webmcpify/templates/mutation-journal.js +++ b/skills/webmcpify/templates/mutation-journal.js @@ -134,13 +134,21 @@ async function writeOwnerMetadata(lockPath, metadata) { } } async function startCandidate(candidate, lockPath, timeoutMs) { - const child = spawn(candidate.command, candidate.args(lockPath), { stdio: ["pipe", "pipe", "pipe"] }); + const child = spawn(candidate.command, candidate.args(lockPath), { + detached: true, + stdio: ["pipe", "pipe", "pipe"] + }); return new Promise((resolve, reject) => { let stdout = ""; let stderr = ""; let settled = false; const timer = timeoutMs === void 0 ? void 0 : setTimeout(() => { - child.kill("SIGTERM"); + try { + process.kill(-child.pid, "SIGTERM"); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + child.stdin.destroy(); finishReject(new Error(`timed out after ${timeoutMs}ms waiting for ${lockPath}`)); }, timeoutMs); const cleanup = () => { diff --git a/skills/webmcpify/templates/mutation-journal.ts b/skills/webmcpify/templates/mutation-journal.ts index 2474fd4..d8401a3 100644 --- a/skills/webmcpify/templates/mutation-journal.ts +++ b/skills/webmcpify/templates/mutation-journal.ts @@ -213,13 +213,24 @@ async function startCandidate( lockPath: string, timeoutMs?: number, ): Promise { - const child = spawn(candidate.command, candidate.args(lockPath), { stdio: ['pipe', 'pipe', 'pipe'] }); + // Give the advisory-lock utility and its holder command one process group. + // A timed-out waiter must terminate both; killing only the utility can leave + // its child alive to acquire and retain the lock after the caller has failed. + const child = spawn(candidate.command, candidate.args(lockPath), { + detached: true, + stdio: ['pipe', 'pipe', 'pipe'], + }); return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; let settled = false; const timer = timeoutMs === undefined ? undefined : setTimeout(() => { - child.kill('SIGTERM'); + try { + process.kill(-child.pid!, 'SIGTERM'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; + } + child.stdin.destroy(); finishReject(new Error(`timed out after ${timeoutMs}ms waiting for ${lockPath}`)); }, timeoutMs); diff --git a/tests/manifest-lock.test.mjs b/tests/manifest-lock.test.mjs index a00b12a..fe10afa 100644 --- a/tests/manifest-lock.test.mjs +++ b/tests/manifest-lock.test.mjs @@ -172,6 +172,31 @@ test('runner B cannot scan while runner A journals, cleans up, settles, and rele } }); +test('a bounded waiter times out without acquiring or leaking the lock later', { + skip: requiresAdvisoryLock, + timeout: 10_000, +}, async () => { + const paths = await fixture(); + let holder; + let successor; + try { + holder = await openMutationJournal({ manifestPath: paths.manifestPath }); + await assert.rejects( + openMutationJournal({ manifestPath: paths.manifestPath, timeoutMs: 100 }), + /timed out after 100ms/, + ); + await holder.close(); + holder = undefined; + + successor = await openMutationJournal({ manifestPath: paths.manifestPath, timeoutMs: 1_000 }); + assert.deepEqual(successor.unresolved, []); + } finally { + await holder?.close(); + await successor?.close(); + await rm(paths.directory, { recursive: true, force: true }); + } +}); + test('lockf -k fallback keeps the sidecar inode and excludes a second runner', { skip: process.platform === 'win32' || (!hasCommand('lockf') && !hasCommand('flock')) ? 'requires native lockf(1), or flock(1) for the Linux lockf compatibility shim' From 809daf2d9568e895678fd1a7ac12c508877aad66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20T=C3=BCchler?= Date: Sat, 19 Sep 2026 17:03:58 +0000 Subject: [PATCH 3/6] test: prove mutation effect before cleanup --- skills/webmcpify/templates/webmcp.spec.ts | 1 + tests/manifest-lock.test.mjs | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/skills/webmcpify/templates/webmcp.spec.ts b/skills/webmcpify/templates/webmcp.spec.ts index e9900b7..7d0f6af 100644 --- a/skills/webmcpify/templates/webmcp.spec.ts +++ b/skills/webmcpify/templates/webmcp.spec.ts @@ -337,6 +337,7 @@ test.describe('send_contact_message', () => { }); await page.goto(`${BASE_URL}/admin/messages`); // manifest: cleanup read path const fixtureRow = page.getByRole('row', { name: /qa@example\.test/ }); + await expect(fixtureRow).toHaveCount(1); // independent read path proves the mutation before cleanup await fixtureRow.getByRole('button', { name: 'Delete' }).click(); await expect(fixtureRow).toHaveCount(0); // independently prove cleanup await mutationJournal!.settle(cleanup.executionId, { diff --git a/tests/manifest-lock.test.mjs b/tests/manifest-lock.test.mjs index fe10afa..e931842 100644 --- a/tests/manifest-lock.test.mjs +++ b/tests/manifest-lock.test.mjs @@ -350,3 +350,12 @@ test('the portable helper retains both supported advisory-lock backends', async assert.match(source, /command: "lockf"/); assert.match(source, /\["-k", lockPath/); }); + +test('the Playwright template journals every mutating example and cleanup action', async () => { + const source = await readFile(join(root, 'skills/webmcpify/templates/webmcp.spec.ts'), 'utf8'); + assert.equal(source.match(/mutationJournal!\.beforeDispatch\(/g)?.length, 3); + assert.equal(source.match(/mutationJournal!\.settle\(/g)?.length, 3); + assert.match(source, /parentExecutionId: execution\.executionId/); + assert.match(source, /independent read path proves the mutation before cleanup/); + assert.match(source, /independent read path proves absence of an effect/); +}); From 302c409835efb5d4e4e2ad0c73d555c053e0b948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20T=C3=BCchler?= Date: Sat, 19 Sep 2026 17:09:48 +0000 Subject: [PATCH 4/6] fix: canonicalize numeric mutation argument keys --- skills/webmcpify/templates/mutation-journal.js | 12 +++++------- skills/webmcpify/templates/mutation-journal.ts | 17 ++++++++--------- tests/manifest-lock.test.mjs | 6 ++++++ 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/skills/webmcpify/templates/mutation-journal.js b/skills/webmcpify/templates/mutation-journal.js index 3cbb1b6..76977b4 100644 --- a/skills/webmcpify/templates/mutation-journal.js +++ b/skills/webmcpify/templates/mutation-journal.js @@ -57,16 +57,14 @@ function canonicalJson(value) { const json = JSON.stringify(value); if (json === void 0) throw new Error("mutation arguments must be JSON-serializable"); const parsed = JSON.parse(json); - const sort = (item) => { - if (Array.isArray(item)) return item.map(sort); + const serialize = (item) => { + if (Array.isArray(item)) return `[${item.map(serialize).join(",")}]`; if (item && typeof item === "object") { - return Object.fromEntries( - Object.entries(item).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, nested]) => [key, sort(nested)]) - ); + return `{${Object.keys(item).sort((left, right) => left < right ? -1 : left > right ? 1 : 0).map((key) => `${JSON.stringify(key)}:${serialize(item[key])}`).join(",")}}`; } - return item; + return JSON.stringify(item); }; - return JSON.stringify(sort(parsed)); + return serialize(parsed); } function fingerprintArguments(value) { return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; diff --git a/skills/webmcpify/templates/mutation-journal.ts b/skills/webmcpify/templates/mutation-journal.ts index d8401a3..f313ac8 100644 --- a/skills/webmcpify/templates/mutation-journal.ts +++ b/skills/webmcpify/templates/mutation-journal.ts @@ -122,18 +122,17 @@ function canonicalJson(value: unknown): string { const json = JSON.stringify(value); if (json === undefined) throw new Error('mutation arguments must be JSON-serializable'); const parsed = JSON.parse(json) as unknown; - const sort = (item: unknown): unknown => { - if (Array.isArray(item)) return item.map(sort); + const serialize = (item: unknown): string => { + if (Array.isArray(item)) return `[${item.map(serialize).join(',')}]`; if (item && typeof item === 'object') { - return Object.fromEntries( - Object.entries(item as JsonObject) - .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) - .map(([key, nested]) => [key, sort(nested)]), - ); + return `{${Object.keys(item as JsonObject) + .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)) + .map((key) => `${JSON.stringify(key)}:${serialize((item as JsonObject)[key])}`) + .join(',')}}`; } - return item; + return JSON.stringify(item); }; - return JSON.stringify(sort(parsed)); + return serialize(parsed); } export function fingerprintArguments(value: unknown): string { diff --git a/tests/manifest-lock.test.mjs b/tests/manifest-lock.test.mjs index e931842..bb5b8ec 100644 --- a/tests/manifest-lock.test.mjs +++ b/tests/manifest-lock.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { access, chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -97,6 +98,11 @@ test('argument fingerprints use recursively sorted canonical JSON', () => { fingerprintArguments({ A: 3, z: 2, 'ä': 1 }), 'fingerprints must not depend on the host locale', ); + assert.equal( + fingerprintArguments({ 2: 'two', 10: 'ten' }), + `sha256:${createHash('sha256').update('{"10":"ten","2":"two"}').digest('hex')}`, + 'integer-like keys must remain lexicographically sorted in the serialized bytes', + ); }); test('the TypeScript and JavaScript helper twins stay byte-equivalent', async () => { From 858f8074fffd231ecebdee73591c0bb388fd40eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20T=C3=BCchler?= Date: Sat, 19 Sep 2026 17:10:52 +0000 Subject: [PATCH 5/6] fix: verify the complete mutation journal entry --- skills/webmcpify/templates/mutation-journal.js | 2 +- skills/webmcpify/templates/mutation-journal.ts | 2 +- tests/manifest-lock.test.mjs | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/skills/webmcpify/templates/mutation-journal.js b/skills/webmcpify/templates/mutation-journal.js index 76977b4..1cc8896 100644 --- a/skills/webmcpify/templates/mutation-journal.js +++ b/skills/webmcpify/templates/mutation-journal.js @@ -298,7 +298,7 @@ class MutationJournal { await durableReplace(this.manifestPath, manifest); const stored = await readManifest(this.manifestPath); const persisted = stored.tools.find((tool) => tool.id === manifestTool)?.mutationExecutions?.find((entry) => entry.executionId === execution.executionId); - if (!persisted || persisted.state !== "started") { + if (!persisted || JSON.stringify(persisted) !== JSON.stringify(execution)) { throw new Error(`pre-dispatch journal entry was not durably persisted: ${execution.executionId}`); } this.#unresolved = unresolvedEntries(stored); diff --git a/skills/webmcpify/templates/mutation-journal.ts b/skills/webmcpify/templates/mutation-journal.ts index f313ac8..5e6e534 100644 --- a/skills/webmcpify/templates/mutation-journal.ts +++ b/skills/webmcpify/templates/mutation-journal.ts @@ -399,7 +399,7 @@ export class MutationJournal { const persisted = stored.tools .find((tool) => tool.id === manifestTool) ?.mutationExecutions?.find((entry) => entry.executionId === execution.executionId); - if (!persisted || persisted.state !== 'started') { + if (!persisted || JSON.stringify(persisted) !== JSON.stringify(execution)) { throw new Error(`pre-dispatch journal entry was not durably persisted: ${execution.executionId}`); } this.#unresolved = unresolvedEntries(stored); diff --git a/tests/manifest-lock.test.mjs b/tests/manifest-lock.test.mjs index bb5b8ec..3ee7570 100644 --- a/tests/manifest-lock.test.mjs +++ b/tests/manifest-lock.test.mjs @@ -127,6 +127,8 @@ test('runner B cannot scan while runner A journals, cleans up, settles, and rele runnerA = await openMutationJournal({ manifestPath: paths.manifestPath }); const stableLockInode = (await stat(paths.lockPath)).ino; const parent = await runnerA.beforeDispatch(mutation()); + const preDispatchStored = JSON.parse(await readFile(paths.manifestPath, 'utf8')); + assert.deepEqual(preDispatchStored.tools[0].mutationExecutions[0], parent); let runnerBAcquired = false; const runnerBPromise = openMutationJournal({ manifestPath: paths.manifestPath }) From e2aab82e5e8da028bed99f1f3f3cd094999e15ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20T=C3=BCchler?= Date: Sat, 19 Sep 2026 17:51:48 +0000 Subject: [PATCH 6/6] fix: harden mutation journal ownership --- CHANGELOG.md | 10 +- release/v0.7.0.md | 8 +- skills/webmcpify/SKILL.md | 2 + skills/webmcpify/references/reverify.md | 17 +- skills/webmcpify/references/verify.md | 5 +- .../webmcpify/templates/mutation-journal.js | 463 +++++++++++----- .../webmcpify/templates/mutation-journal.ts | 508 ++++++++++++------ tests/manifest-lock.test.mjs | 255 ++++++++- 8 files changed, 952 insertions(+), 316 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b875ab7..4b15e30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,17 @@ reconstruct them from git history. - Added a dependency-free host-side mutation journal for verification runners. It acquires the permanent manifest sidecar through `flock(1)` or macOS/FreeBSD - `lockf(1) -k`, migrates missing journals, and fails closed when no supported - advisory lock is available. + `lockf(1)` descriptor mode, migrates missing journals, and fails closed when no supported + advisory lock is available. A stable manifest/sidecar identity plus inode, + link and journal validation reject replacement or corrupt safety state. - Wired the Playwright template's valid, invalid and cleanup mutation examples through durable pre-dispatch and settlement hooks. Atomic manifest replacement now writes and fsyncs a sibling temporary file, renames it, and fsyncs the directory while the sidecar lock remains held. - Added regressions for two-runner exclusion, cleanup linkage, pre-dispatch - persistence failure, process death with a retained `started` entry, canonical - argument fingerprints, and legacy-manifest migration. + persistence failure, runner death with a retained `started` entry, + malformed journals, sidecar attacks, canonical argument fingerprints, and + legacy-manifest migration. ## [0.6.0] — 2026-09-19 diff --git a/release/v0.7.0.md b/release/v0.7.0.md index 5d23f37..f661d32 100644 --- a/release/v0.7.0.md +++ b/release/v0.7.0.md @@ -8,8 +8,11 @@ settlement hooks. ## What changed - Acquires the never-replaced `.webmcpify/manifest.lock` sidecar with `flock(1)` - where present or macOS/FreeBSD `lockf(1) -k`, and fails closed if neither + where present or macOS/FreeBSD `lockf(1)` descriptor mode, and fails closed if neither advisory-lock command is available. +- Binds the manifest to a stable sidecar identity, rejects symlinks, hard links, + inode swaps and malformed journals. The runner retains the locked descriptor, + removing a separately killable holder from the dispatch boundary. - Records a canonical-arguments fingerprint and a durable `started` entry before each valid example, invalid example and mutating cleanup action. - Settles an entry only after an independent read path establishes the effect or @@ -17,7 +20,8 @@ settlement hooks. - Migrates manifests without `mutationExecutions`, invalidating historical verification evidence without inventing in-flight calls. - Covers concurrent runners across atomic manifest replacement, persistence - failure before dispatch, abrupt process death and legacy-manifest migration. + failure before dispatch, runner death, corrupt safety state, sidecar + replacement and legacy-manifest migration. ## Compatibility and evidence diff --git a/skills/webmcpify/SKILL.md b/skills/webmcpify/SKILL.md index cef3ae2..cd26b13 100644 --- a/skills/webmcpify/SKILL.md +++ b/skills/webmcpify/SKILL.md @@ -159,6 +159,8 @@ Manifest schema (Webmcpify Manifest v4): ```jsonc { "webmcpify": 4, + // Omit until the helper's first execution-capable open; it then writes and preserves this UUID. + "mutationLockIdentity": "2d734e2d-1537-41a7-b8b5-0af6e932019e", "app": { "stack": "react-vite", "typescript": true, "entry": "src/main.tsx", "baseUrl": "https://app.example.test", "startCommand": "npm run dev", "verificationOrigin": "https://app.example.test", diff --git a/skills/webmcpify/references/reverify.md b/skills/webmcpify/references/reverify.md index f5a5414..7ad461b 100644 --- a/skills/webmcpify/references/reverify.md +++ b/skills/webmcpify/references/reverify.md @@ -45,11 +45,24 @@ workflow journal for new mutation runs, not a browser API or an automatic featur of the vendored runtime. Vendor `templates/mutation-journal.{ts,js}` next to the spec and use its Node/host-side `openMutationJournal`, `beforeDispatch`, `settle` and `close` operations; a browser-only callback is not durable. The helper prefers -`flock(1)`, falls back to macOS/FreeBSD `lockf(1) -k` (which keeps the sidecar -inode), and fails closed when neither advisory-lock command is available. Other +`flock(1)`, falls back to macOS/FreeBSD `lockf(1)` descriptor mode (which implies +`-k`), and fails closed when neither advisory-lock command is available. Other runners must adapt dispatch through these same hooks rather than reproduce the locking and durability protocol. +The helper opens the sidecar once without following symlinks, rejects non-regular +or multiply linked files, and passes that verified descriptor to the lock backend. +The short acquisition process exits while the runner retains the locked open-file +description itself, so there is no separately killable holder between a journal +write and dispatch. A competing descriptor verifies that the kernel lock survived +acquisition; device/inode checks at every durable I/O boundary fence pathname +replacement. Existing journals are validated in full before use; only an actually +absent `mutationExecutions` field is migrated, while malformed entries, duplicate +execution IDs and invalid cleanup-parent links fail closed without a rewrite. +On its first execution-capable open it also records one stable +`mutationLockIdentity` in the manifest and sidecar metadata; a missing or different +sidecar identity thereafter cannot become a second operational lock. + Each entry has `executionId`, `tool`, `contractRevision`, `origin`, `role`, `fixtureRevision`, `argumentsFingerprint` (SHA-256 of canonical JSON with sorted object keys), `startedAt`, `state` (`started` or `reconciled`), and local redacted diff --git a/skills/webmcpify/references/verify.md b/skills/webmcpify/references/verify.md index 18df91a..944aa43 100644 --- a/skills/webmcpify/references/verify.md +++ b/skills/webmcpify/references/verify.md @@ -168,10 +168,13 @@ manifest read, `beforeDispatch` immediately before every mutation (invalid examp and cleanup included), and `settle` only after the effect or proven absence plus cleanup has been independently established. Keep the journal open until the final settlement and disable automatic retries. The helper uses `flock(1)` where present, -falls back to macOS/FreeBSD `lockf(1) -k` without replacing the sidecar, and fails +falls back to macOS/FreeBSD `lockf(1)` descriptor mode without replacing the sidecar, and fails closed if neither command is available. A runner that cannot expose these hooks is read-only for this workflow; report mutation checks not-run. Reconcile existing started entries before selecting tools, including entries on verified/skipped tools. +Do not repair a malformed journal or sidecar in place: the helper deliberately +rejects corrupt entries, symlinks, hard links and lock-identity changes without +authorizing a mutation. ## Agent evals (recommended; required evidence for SaaS-scale readiness claims) diff --git a/skills/webmcpify/templates/mutation-journal.js b/skills/webmcpify/templates/mutation-journal.js index 1cc8896..2b88216 100644 --- a/skills/webmcpify/templates/mutation-journal.js +++ b/skills/webmcpify/templates/mutation-journal.js @@ -24,29 +24,28 @@ */ import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { open, readFile, realpath, rename, rm, stat } from "node:fs/promises"; +import { constants } from "node:fs"; +import { lstat, open, readFile, realpath, rename, rm, stat } from "node:fs/promises"; import { hostname } from "node:os"; import { basename, dirname, join } from "node:path"; -const HOLDER_SOURCE = String.raw` -process.stdin.setEncoding('utf8'); -process.stdout.write('WEBMCPIFY_LOCK_READY\n'); -let pending = ''; -process.stdin.on('data', (chunk) => { - pending += chunk; - if (pending.includes('\n')) process.exit(pending.startsWith('RELEASE\n') ? 0 : 2); -}); -process.stdin.on('end', () => process.exit(0)); -`; const LOCK_CANDIDATES = [ { command: "flock", - args: (lockPath) => ["--exclusive", lockPath, process.execPath, "--input-type=module", "--eval", HOLDER_SOURCE] + // fd 3 is inherited from the already verified parent handle. This prevents + // a pathname swap between validation and advisory-lock acquisition. flock's + // descriptor lock belongs to the shared open-file description, so it stays + // held by this runner after the short acquisition subprocess exits. + args: () => ["--exclusive", "3"], + probeArgs: () => ["--exclusive", "--nonblock", "3"], + busyExitCode: 1 }, { command: "lockf", - // macOS/FreeBSD lockf removes a pathname on exit unless -k is used. The - // sidecar inode is permanent, so -k is a correctness requirement. - args: (lockPath) => ["-k", lockPath, process.execPath, "--input-type=module", "--eval", HOLDER_SOURCE] + // macOS/FreeBSD lockf's descriptor form uses BSD flock(2) locking and + // implies -k, so it neither opens by pathname nor removes the sidecar. + args: () => ["-s", "3"], + probeArgs: () => ["-s", "-t", "0", "3"], + busyExitCode: 75 } ]; function requireText(value, label) { @@ -69,28 +68,28 @@ function canonicalJson(value) { function fingerprintArguments(value) { return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; } -async function syncDirectory(path) { - const handle = await open(path, "r"); +async function syncDirectory(path, step) { + const handle = await step(() => open(path, "r")); try { - await handle.sync(); + await step(() => handle.sync()); } finally { await handle.close(); } } -async function durableReplace(manifestPath, manifest) { +async function durableReplace(manifestPath, manifest, step) { const directory = dirname(manifestPath); const temporary = join(directory, `.${basename(manifestPath)}.${process.pid}.${randomUUID()}.tmp`); - const mode = (await stat(manifestPath)).mode & 511; + const mode = (await step(() => stat(manifestPath))).mode & 511; let handle; try { - handle = await open(temporary, "wx", mode); - await handle.writeFile(`${JSON.stringify(manifest, null, 2)} -`, "utf8"); - await handle.sync(); - await handle.close(); + handle = await step(() => open(temporary, "wx", mode)); + await step(() => handle.writeFile(`${JSON.stringify(manifest, null, 2)} +`, "utf8")); + await step(() => handle.sync()); + await step(() => handle.close()); handle = void 0; - await rename(temporary, manifestPath); - await syncDirectory(directory); + await step(() => rename(temporary, manifestPath)); + await syncDirectory(directory, step); } catch (error) { await handle?.close().catch(() => void 0); await rm(temporary, { force: true }).catch(() => void 0); @@ -103,92 +102,182 @@ async function readManifest(manifestPath) { return value; } function unresolvedEntries(manifest) { - return manifest.tools.flatMap( - (tool) => Array.isArray(tool.mutationExecutions) ? tool.mutationExecutions.filter((entry) => entry?.state === "started") : [] - ); + validateJournals(manifest); + return manifest.tools.flatMap((tool) => tool.mutationExecutions.filter((entry) => entry.state === "started")); } -async function migrateManifest(manifestPath, manifest) { - let changed = false; - for (const tool of manifest.tools) { +function validateJournals(manifest) { + const toolIds = /* @__PURE__ */ new Set(); + const executions = /* @__PURE__ */ new Map(); + for (const [toolIndex, tool] of manifest.tools.entries()) { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) { + throw new Error(`manifest tool ${toolIndex} must be an object`); + } + const toolId = requireText(typeof tool.id === "string" ? tool.id : "", `manifest tool ${toolIndex} id`); + if (toolIds.has(toolId)) throw new Error(`duplicate manifest tool id: ${toolId}`); + toolIds.add(toolId); if (!Array.isArray(tool.mutationExecutions)) { + throw new Error(`mutationExecutions must be an array for manifest tool: ${toolId}`); + } + for (const [entryIndex, candidate] of tool.mutationExecutions.entries()) { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) { + throw new Error(`mutation execution ${toolId}[${entryIndex}] must be an object`); + } + const entry = candidate; + const label = `mutation execution ${toolId}[${entryIndex}]`; + requireText(typeof entry.executionId === "string" ? entry.executionId : "", `${label} executionId`); + requireText(typeof entry.tool === "string" ? entry.tool : "", `${label} tool`); + if (!Number.isInteger(entry.contractRevision) || entry.contractRevision < 1) { + throw new Error(`${label} contractRevision must be a positive integer`); + } + for (const field of ["origin", "role", "fixtureRevision", "argumentsFingerprint", "startedAt", "evidence"]) { + requireText(typeof entry[field] === "string" ? entry[field] : "", `${label} ${field}`); + } + if (entry.state !== "started" && entry.state !== "reconciled") { + throw new Error(`${label} has unknown state: ${String(entry.state)}`); + } + if (entry.parentExecutionId !== void 0) { + requireText(typeof entry.parentExecutionId === "string" ? entry.parentExecutionId : "", `${label} parentExecutionId`); + } + if (entry.state === "reconciled") { + requireText(typeof entry.outcome === "string" ? entry.outcome : "", `${label} outcome`); + requireText(typeof entry.reconciledAt === "string" ? entry.reconciledAt : "", `${label} reconciledAt`); + } + if (executions.has(entry.executionId)) { + throw new Error(`duplicate mutation executionId: ${entry.executionId}`); + } + executions.set(entry.executionId, { entry, owner: tool }); + } + } + for (const { entry, owner } of executions.values()) { + if (!entry.parentExecutionId) continue; + const parent = executions.get(entry.parentExecutionId); + if (!parent || parent.owner !== owner || parent.entry.parentExecutionId) { + throw new Error(`invalid parentExecutionId for mutation execution: ${entry.executionId}`); + } + } +} +async function migrateManifest(manifestPath, manifest, lockIdentity, step) { + let changed = false; + if (!Object.hasOwn(manifest, "mutationLockIdentity")) { + manifest.mutationLockIdentity = lockIdentity; + changed = true; + } else if (manifest.mutationLockIdentity !== lockIdentity) { + throw new Error("manifest mutationLockIdentity does not match the locked sidecar"); + } + for (const [toolIndex, tool] of manifest.tools.entries()) { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) { + throw new Error(`manifest tool ${toolIndex} must be an object`); + } + if (!Object.hasOwn(tool, "mutationExecutions")) { tool.mutationExecutions = []; tool.verifiedAgainst = null; if (tool.status === "verified") tool.status = "integrated"; changed = true; } } - if (changed) await durableReplace(manifestPath, manifest); + validateJournals(manifest); + if (changed) await durableReplace(manifestPath, manifest, step); return manifest; } -async function writeOwnerMetadata(lockPath, metadata) { - const handle = await open(lockPath, "r+"); +async function writeOwnerMetadata(handle, metadata, step) { + const serialized = `${JSON.stringify(metadata)} +`; + await step(() => handle.truncate(0)); + await step(() => handle.write(serialized, 0, "utf8").then(() => void 0)); + await step(() => handle.sync()); +} +async function readOwnerMetadata(handle, step) { + const size = (await step(() => handle.stat())).size; + if (size === 0) return {}; + const buffer = Buffer.alloc(size); + const { bytesRead } = await step(() => handle.read(buffer, 0, size, 0)); + let value; try { - await handle.truncate(0); - await handle.writeFile(`${JSON.stringify(metadata)} -`, "utf8"); - await handle.sync(); - } finally { - await handle.close(); + value = JSON.parse(buffer.subarray(0, bytesRead).toString("utf8")); + } catch { + throw new Error("manifest.lock contains malformed owner metadata"); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("manifest.lock owner metadata must be an object"); } + return value; } -async function startCandidate(candidate, lockPath, timeoutMs) { - const child = spawn(candidate.command, candidate.args(lockPath), { +async function openLockFile(lockPath) { + const handle = await open(lockPath, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 384); + try { + const info = await handle.stat(); + if (!info.isFile() || info.nlink !== 1) { + throw new Error("manifest.lock must be a regular file with exactly one link"); + } + const pathInfo = await lstat(lockPath); + if (!pathInfo.isFile() || pathInfo.isSymbolicLink() || pathInfo.nlink !== 1 || pathInfo.dev !== info.dev || pathInfo.ino !== info.ino) { + throw new Error("manifest.lock identity changed while opening"); + } + return { handle, identity: { dev: info.dev, ino: info.ino } }; + } catch (error) { + await handle.close().catch(() => void 0); + throw error; + } +} +async function runLockCommand(candidate, args, lockHandle, lockPath, timeoutMs) { + const child = spawn(candidate.command, args, { detached: true, - stdio: ["pipe", "pipe", "pipe"] + stdio: ["ignore", "ignore", "pipe", lockHandle.fd] }); - return new Promise((resolve, reject) => { - let stdout = ""; - let stderr = ""; - let settled = false; - const timer = timeoutMs === void 0 ? void 0 : setTimeout(() => { - try { - process.kill(-child.pid, "SIGTERM"); - } catch (error) { - if (error.code !== "ESRCH") throw error; - } - child.stdin.destroy(); - finishReject(new Error(`timed out after ${timeoutMs}ms waiting for ${lockPath}`)); - }, timeoutMs); - const cleanup = () => { - if (timer) clearTimeout(timer); - child.off("error", onError); - child.off("exit", onExit); - child.stdout.off("data", onStdout); - child.stderr.off("data", onStderr); - }; - const finishReject = (error) => { - if (settled) return; - settled = true; - cleanup(); - reject(error); - }; - const onError = (error) => finishReject(error); - const onExit = (code, signal) => { - finishReject(new Error( - `${candidate.command} exited before lock acquisition (${code ?? signal ?? "unknown"}): ${stderr.trim()}` - )); - }; - const onStdout = (chunk) => { - stdout += chunk.toString("utf8"); - if (!stdout.includes("WEBMCPIFY_LOCK_READY\n") || settled) return; - settled = true; - cleanup(); - resolve(child); - }; - const onStderr = (chunk) => { - stderr += chunk.toString("utf8"); - }; - child.once("error", onError); - child.once("exit", onExit); - child.stdout.on("data", onStdout); - child.stderr.on("data", onStderr); + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk) => { + stderr += chunk; }); + const exit = waitForChildExit(child); + let timer; + const timedOut = Symbol("timed-out"); + const result = await (timeoutMs === void 0 ? exit : Promise.race([ + exit, + new Promise((resolve) => { + timer = setTimeout(() => resolve(timedOut), timeoutMs); + }) + ])); + if (timer) clearTimeout(timer); + if (result === timedOut) { + try { + process.kill(-child.pid, "SIGTERM"); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + await exit.catch(() => void 0); + throw new Error(`timed out after ${timeoutMs}ms waiting for ${lockPath}`); + } + return { ...result, stderr }; } -async function acquireLock(lockPath, timeoutMs) { +async function verifyLockHeld(candidate, lockPath, identity) { + const probe = await openLockFile(lockPath); + try { + if (probe.identity.dev !== identity.dev || probe.identity.ino !== identity.ino) { + throw new Error("manifest.lock identity changed after lock acquisition"); + } + const result = await runLockCommand(candidate, candidate.probeArgs(), probe.handle, lockPath); + if (result.code !== candidate.busyExitCode) { + throw new Error( + `${candidate.command} descriptor lock did not remain held by the runner (${result.code ?? result.signal ?? "unknown"}): ${result.stderr.trim()}` + ); + } + } finally { + await probe.handle.close().catch(() => void 0); + } +} +async function acquireLock(lockHandle, lockPath, lockIdentity, timeoutMs) { const unavailable = []; for (const candidate of LOCK_CANDIDATES) { try { - return await startCandidate(candidate, lockPath, timeoutMs); + const result = await runLockCommand(candidate, candidate.args(), lockHandle, lockPath, timeoutMs); + if (result.code !== 0) { + throw new Error( + `${candidate.command} exited before lock acquisition (${result.code ?? result.signal ?? "unknown"}): ${result.stderr.trim()}` + ); + } + await verifyLockHeld(candidate, lockPath, lockIdentity); + return candidate; } catch (error) { if (error.code === "ENOENT") { unavailable.push(candidate.command); @@ -201,51 +290,105 @@ async function acquireLock(lockPath, timeoutMs) { `mutation verification is not available: no supported advisory-lock command (${unavailable.join(", ")})` ); } +function waitForChildExit(child) { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); + } + return new Promise((resolve, reject) => { + const cleanup = () => { + child.off("error", onError); + child.off("exit", onExit); + }; + const onError = (error) => { + cleanup(); + reject(error); + }; + const onExit = (code, signal) => { + cleanup(); + resolve({ code, signal }); + }; + child.once("error", onError); + child.once("exit", onExit); + if (child.exitCode !== null || child.signalCode !== null) { + onExit(child.exitCode, child.signalCode); + } + }); +} class MutationJournal { manifestPath; lockPath; ownerToken; - #holder; + lockBackend; + #lockHandle; + #lockIdentity; + #stableLockIdentity = ""; #closed = false; - #lockFailure; #unresolved; - constructor(manifestPath, lockPath, ownerToken, holder, unresolved) { + constructor(manifestPath, lockPath, ownerToken, lockHandle, lockIdentity, lockBackend, unresolved) { this.manifestPath = manifestPath; this.lockPath = lockPath; this.ownerToken = ownerToken; - this.#holder = holder; + this.lockBackend = lockBackend; + this.#lockHandle = lockHandle; + this.#lockIdentity = lockIdentity; this.#unresolved = unresolved; - holder.once("error", (error) => { - if (!this.#closed) this.#lockFailure = new Error(`mutation journal lock holder failed: ${error.message}`); - }); - holder.once("exit", (code, signal) => { - if (!this.#closed) { - this.#lockFailure = new Error( - `mutation journal lock holder exited unexpectedly (${code ?? signal ?? "unknown"})` - ); - } - }); } static async open(options) { const manifestPath = await realpath(options.manifestPath); const manifestDirectory = dirname(manifestPath); const lockPath = join(manifestDirectory, "manifest.lock"); - const lockFile = await open(lockPath, "a", 384); - await lockFile.close(); - const holder = await acquireLock(lockPath, options.timeoutMs); + const { handle: lockHandle, identity: lockIdentity } = await openLockFile(lockPath); + let journal; const ownerToken = randomUUID(); try { - await writeOwnerMetadata(lockPath, { + const candidate = await acquireLock(lockHandle, lockPath, lockIdentity, options.timeoutMs); + journal = new MutationJournal( + manifestPath, + lockPath, + ownerToken, + lockHandle, + lockIdentity, + candidate.command, + [] + ); + const manifest = await journal.#ownedStep(() => readManifest(manifestPath)); + const ownerMetadata = await readOwnerMetadata(lockHandle, journal.#ownedStep); + const manifestIdentity = manifest.mutationLockIdentity; + const sidecarIdentity = ownerMetadata.lockIdentity; + if (manifestIdentity !== void 0 && (typeof manifestIdentity !== "string" || !manifestIdentity.trim())) { + throw new Error("manifest mutationLockIdentity must be a non-empty string"); + } + if (sidecarIdentity !== void 0 && (typeof sidecarIdentity !== "string" || !sidecarIdentity.trim())) { + throw new Error("manifest.lock lockIdentity must be a non-empty string"); + } + if (manifestIdentity && !sidecarIdentity) { + throw new Error("manifest.lock lost its stable identity; refusing a replacement sidecar"); + } + if (manifestIdentity && sidecarIdentity && manifestIdentity !== sidecarIdentity) { + throw new Error("manifest.lock stable identity does not match the manifest"); + } + journal.#stableLockIdentity = manifestIdentity ?? sidecarIdentity ?? randomUUID(); + await writeOwnerMetadata(lockHandle, { + lockIdentity: journal.#stableLockIdentity, ownerToken, host: hostname(), pid: process.pid, + lockBackend: candidate.command, processStartedAt: new Date(Date.now() - process.uptime() * 1e3).toISOString(), acquiredAt: (/* @__PURE__ */ new Date()).toISOString() - }); - const manifest = await migrateManifest(manifestPath, await readManifest(manifestPath)); - return new MutationJournal(manifestPath, lockPath, ownerToken, holder, unresolvedEntries(manifest)); + }, journal.#ownedStep); + const migrated = await migrateManifest( + manifestPath, + manifest, + journal.#stableLockIdentity, + journal.#ownedStep + ); + journal.#unresolved = unresolvedEntries(migrated); + await journal.#checkOwnership(); + return journal; } catch (error) { - holder.stdin.end("RELEASE\n"); + if (journal) await journal.#releaseLock().catch(() => void 0); + else await lockHandle.close().catch(() => void 0); throw error; } } @@ -253,7 +396,7 @@ class MutationJournal { return structuredClone(this.#unresolved); } async beforeDispatch(input) { - this.#assertOpen(); + await this.#checkOwnership(); requireText(input.tool, "tool"); requireText(input.origin, "origin"); requireText(input.role, "role"); @@ -262,7 +405,8 @@ class MutationJournal { if (!Number.isInteger(input.contractRevision) || input.contractRevision < 1) { throw new Error("contractRevision must be a positive integer"); } - const manifest = await readManifest(this.manifestPath); + const manifest = await this.#ownedStep(() => readManifest(this.manifestPath)); + validateJournals(manifest); const unresolved = unresolvedEntries(manifest); const manifestTool = input.manifestTool ?? input.tool; const owner = manifest.tools.find((tool) => tool.id === manifestTool); @@ -295,21 +439,24 @@ class MutationJournal { ...input.parentExecutionId ? { parentExecutionId: input.parentExecutionId } : {} }; owner.mutationExecutions.push(execution); - await durableReplace(this.manifestPath, manifest); - const stored = await readManifest(this.manifestPath); + await durableReplace(this.manifestPath, manifest, this.#ownedStep); + const stored = await this.#ownedStep(() => readManifest(this.manifestPath)); + validateJournals(stored); const persisted = stored.tools.find((tool) => tool.id === manifestTool)?.mutationExecutions?.find((entry) => entry.executionId === execution.executionId); if (!persisted || JSON.stringify(persisted) !== JSON.stringify(execution)) { throw new Error(`pre-dispatch journal entry was not durably persisted: ${execution.executionId}`); } this.#unresolved = unresolvedEntries(stored); + await this.#checkOwnership(); return structuredClone(execution); } async settle(executionId, input) { - this.#assertOpen(); + await this.#checkOwnership(); requireText(executionId, "executionId"); requireText(input.outcome, "outcome"); requireText(input.evidence, "evidence"); - const manifest = await readManifest(this.manifestPath); + const manifest = await this.#ownedStep(() => readManifest(this.manifestPath)); + validateJournals(manifest); const owner = manifest.tools.find( (tool) => tool.mutationExecutions?.some((entry) => entry.executionId === executionId) ); @@ -328,42 +475,66 @@ class MutationJournal { execution.outcome = input.outcome; execution.reconciledAt = input.reconciledAt ?? (/* @__PURE__ */ new Date()).toISOString(); execution.evidence = input.evidence; - await durableReplace(this.manifestPath, manifest); - this.#unresolved = unresolvedEntries(await readManifest(this.manifestPath)); + await durableReplace(this.manifestPath, manifest, this.#ownedStep); + const stored = await this.#ownedStep(() => readManifest(this.manifestPath)); + validateJournals(stored); + this.#unresolved = unresolvedEntries(stored); + await this.#checkOwnership(); return structuredClone(execution); } async close() { if (this.#closed) return; - this.#closed = true; - const holder = this.#holder; - this.#holder = void 0; - if (!holder) return; - if (this.#lockFailure || holder.exitCode !== null || holder.signalCode !== null) { - throw this.#lockFailure ?? new Error("mutation journal lock holder exited before release"); + let failure; + try { + if (!failure) { + await syncDirectory(dirname(this.manifestPath), this.#ownedStep); + await writeOwnerMetadata(this.#lockHandle, { + lockIdentity: this.#stableLockIdentity, + ownerToken: this.ownerToken, + host: hostname(), + pid: process.pid, + releasedAt: (/* @__PURE__ */ new Date()).toISOString() + }, this.#ownedStep); + } + } catch (error) { + failure = error; } - let syncError; try { - await syncDirectory(dirname(this.manifestPath)); + await this.#releaseLock(); } catch (error) { - syncError = error; + failure ??= error; } - const exit = new Promise((resolve, reject) => { - holder.once("error", reject); - holder.once("exit", (code, signal) => { - if (code === 0) resolve(); - else reject(new Error(`lock holder exited while releasing (${code ?? signal ?? "unknown"})`)); - }); - }); - holder.stdin.end("RELEASE\n"); - await exit; - if (syncError) throw syncError; + if (failure) throw failure; } - #assertOpen() { - if (this.#closed || !this.#holder) throw new Error("mutation journal is closed"); - if (this.#lockFailure || this.#holder.exitCode !== null || this.#holder.signalCode !== null) { - throw this.#lockFailure ?? new Error("mutation journal lock ownership was lost"); + #ownedStep = async (operation) => { + await this.#checkOwnership(); + const result = await operation(); + await this.#checkOwnership(); + return result; + }; + async #checkOwnership() { + if (this.#closed || !this.#lockHandle) { + throw new Error("mutation journal is closed"); + } + const [handleInfo, pathInfo] = await Promise.all([ + this.#lockHandle.stat(), + lstat(this.lockPath) + ]); + if (!handleInfo.isFile() || handleInfo.nlink !== 1 || !pathInfo.isFile() || pathInfo.isSymbolicLink() || pathInfo.nlink !== 1 || handleInfo.dev !== this.#lockIdentity.dev || handleInfo.ino !== this.#lockIdentity.ino || pathInfo.dev !== this.#lockIdentity.dev || pathInfo.ino !== this.#lockIdentity.ino) { + throw new Error("mutation journal lock identity changed"); } } + async #releaseLock() { + if (this.#closed) return; + const lockHandle = this.#lockHandle; + this.#lockHandle = void 0; + let failure; + await lockHandle?.close().catch((error) => { + failure ??= error; + }); + this.#closed = true; + if (failure) throw failure; + } } async function openMutationJournal(options) { return MutationJournal.open(options); diff --git a/skills/webmcpify/templates/mutation-journal.ts b/skills/webmcpify/templates/mutation-journal.ts index 5e6e534..8504742 100644 --- a/skills/webmcpify/templates/mutation-journal.ts +++ b/skills/webmcpify/templates/mutation-journal.ts @@ -22,9 +22,10 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { spawn, type ChildProcess } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; -import { open, readFile, realpath, rename, rm, stat } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { lstat, open, readFile, realpath, rename, rm, stat, type FileHandle } from 'node:fs/promises'; import { hostname } from 'node:os'; import { basename, dirname, join } from 'node:path'; @@ -81,35 +82,40 @@ interface ManifestTool extends JsonObject { } interface Manifest extends JsonObject { + mutationLockIdentity?: string; tools: ManifestTool[]; } interface LockCandidate { command: string; - args(lockPath: string): string[]; + args(): string[]; + probeArgs(): string[]; + busyExitCode: number; } -const HOLDER_SOURCE = String.raw` -process.stdin.setEncoding('utf8'); -process.stdout.write('WEBMCPIFY_LOCK_READY\n'); -let pending = ''; -process.stdin.on('data', (chunk) => { - pending += chunk; - if (pending.includes('\n')) process.exit(pending.startsWith('RELEASE\n') ? 0 : 2); -}); -process.stdin.on('end', () => process.exit(0)); -`; +interface LockIdentity { + dev: number; + ino: number; +} const LOCK_CANDIDATES: LockCandidate[] = [ { command: 'flock', - args: (lockPath) => ['--exclusive', lockPath, process.execPath, '--input-type=module', '--eval', HOLDER_SOURCE], + // fd 3 is inherited from the already verified parent handle. This prevents + // a pathname swap between validation and advisory-lock acquisition. flock's + // descriptor lock belongs to the shared open-file description, so it stays + // held by this runner after the short acquisition subprocess exits. + args: () => ['--exclusive', '3'], + probeArgs: () => ['--exclusive', '--nonblock', '3'], + busyExitCode: 1, }, { command: 'lockf', - // macOS/FreeBSD lockf removes a pathname on exit unless -k is used. The - // sidecar inode is permanent, so -k is a correctness requirement. - args: (lockPath) => ['-k', lockPath, process.execPath, '--input-type=module', '--eval', HOLDER_SOURCE], + // macOS/FreeBSD lockf's descriptor form uses BSD flock(2) locking and + // implies -k, so it neither opens by pathname nor removes the sidecar. + args: () => ['-s', '3'], + probeArgs: () => ['-s', '-t', '0', '3'], + busyExitCode: 75, }, ]; @@ -139,28 +145,30 @@ export function fingerprintArguments(value: unknown): string { return `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`; } -async function syncDirectory(path: string): Promise { - const handle = await open(path, 'r'); +type OwnedStep = (operation: () => Promise) => Promise; + +async function syncDirectory(path: string, step: OwnedStep): Promise { + const handle = await step(() => open(path, 'r')); try { - await handle.sync(); + await step(() => handle.sync()); } finally { await handle.close(); } } -async function durableReplace(manifestPath: string, manifest: Manifest): Promise { +async function durableReplace(manifestPath: string, manifest: Manifest, step: OwnedStep): Promise { const directory = dirname(manifestPath); const temporary = join(directory, `.${basename(manifestPath)}.${process.pid}.${randomUUID()}.tmp`); - const mode = (await stat(manifestPath)).mode & 0o777; + const mode = (await step(() => stat(manifestPath))).mode & 0o777; let handle; try { - handle = await open(temporary, 'wx', mode); - await handle.writeFile(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); - await handle.sync(); - await handle.close(); + handle = await step(() => open(temporary, 'wx', mode)); + await step(() => handle!.writeFile(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8')); + await step(() => handle!.sync()); + await step(() => handle!.close()); handle = undefined; - await rename(temporary, manifestPath); - await syncDirectory(directory); + await step(() => rename(temporary, manifestPath)); + await syncDirectory(directory, step); } catch (error) { await handle?.close().catch(() => undefined); await rm(temporary, { force: true }).catch(() => undefined); @@ -175,104 +183,211 @@ async function readManifest(manifestPath: string): Promise { } function unresolvedEntries(manifest: Manifest): MutationExecution[] { - return manifest.tools.flatMap((tool) => - Array.isArray(tool.mutationExecutions) - ? tool.mutationExecutions.filter((entry) => entry?.state === 'started') - : [], - ); + validateJournals(manifest); + return manifest.tools.flatMap((tool) => tool.mutationExecutions! + .filter((entry) => entry.state === 'started')); } -async function migrateManifest(manifestPath: string, manifest: Manifest): Promise { - let changed = false; - for (const tool of manifest.tools) { +function validateJournals(manifest: Manifest): void { + const toolIds = new Set(); + const executions = new Map(); + for (const [toolIndex, tool] of manifest.tools.entries()) { + if (!tool || typeof tool !== 'object' || Array.isArray(tool)) { + throw new Error(`manifest tool ${toolIndex} must be an object`); + } + const toolId = requireText(typeof tool.id === 'string' ? tool.id : '', `manifest tool ${toolIndex} id`); + if (toolIds.has(toolId)) throw new Error(`duplicate manifest tool id: ${toolId}`); + toolIds.add(toolId); if (!Array.isArray(tool.mutationExecutions)) { + throw new Error(`mutationExecutions must be an array for manifest tool: ${toolId}`); + } + for (const [entryIndex, candidate] of tool.mutationExecutions.entries()) { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) { + throw new Error(`mutation execution ${toolId}[${entryIndex}] must be an object`); + } + const entry = candidate as MutationExecution; + const label = `mutation execution ${toolId}[${entryIndex}]`; + requireText(typeof entry.executionId === 'string' ? entry.executionId : '', `${label} executionId`); + requireText(typeof entry.tool === 'string' ? entry.tool : '', `${label} tool`); + if (!Number.isInteger(entry.contractRevision) || entry.contractRevision < 1) { + throw new Error(`${label} contractRevision must be a positive integer`); + } + for (const field of ['origin', 'role', 'fixtureRevision', 'argumentsFingerprint', 'startedAt', 'evidence'] as const) { + requireText(typeof entry[field] === 'string' ? entry[field] : '', `${label} ${field}`); + } + if (entry.state !== 'started' && entry.state !== 'reconciled') { + throw new Error(`${label} has unknown state: ${String(entry.state)}`); + } + if (entry.parentExecutionId !== undefined) { + requireText(typeof entry.parentExecutionId === 'string' ? entry.parentExecutionId : '', `${label} parentExecutionId`); + } + if (entry.state === 'reconciled') { + requireText(typeof entry.outcome === 'string' ? entry.outcome : '', `${label} outcome`); + requireText(typeof entry.reconciledAt === 'string' ? entry.reconciledAt : '', `${label} reconciledAt`); + } + if (executions.has(entry.executionId)) { + throw new Error(`duplicate mutation executionId: ${entry.executionId}`); + } + executions.set(entry.executionId, { entry, owner: tool }); + } + } + for (const { entry, owner } of executions.values()) { + if (!entry.parentExecutionId) continue; + const parent = executions.get(entry.parentExecutionId); + if (!parent || parent.owner !== owner || parent.entry.parentExecutionId) { + throw new Error(`invalid parentExecutionId for mutation execution: ${entry.executionId}`); + } + } +} + +async function migrateManifest( + manifestPath: string, + manifest: Manifest, + lockIdentity: string, + step: OwnedStep, +): Promise { + let changed = false; + if (!Object.hasOwn(manifest, 'mutationLockIdentity')) { + manifest.mutationLockIdentity = lockIdentity; + changed = true; + } else if (manifest.mutationLockIdentity !== lockIdentity) { + throw new Error('manifest mutationLockIdentity does not match the locked sidecar'); + } + for (const [toolIndex, tool] of manifest.tools.entries()) { + if (!tool || typeof tool !== 'object' || Array.isArray(tool)) { + throw new Error(`manifest tool ${toolIndex} must be an object`); + } + if (!Object.hasOwn(tool, 'mutationExecutions')) { tool.mutationExecutions = []; tool.verifiedAgainst = null; if (tool.status === 'verified') tool.status = 'integrated'; changed = true; } } - if (changed) await durableReplace(manifestPath, manifest); + validateJournals(manifest); + if (changed) await durableReplace(manifestPath, manifest, step); return manifest; } -async function writeOwnerMetadata(lockPath: string, metadata: JsonObject): Promise { - const handle = await open(lockPath, 'r+'); +async function writeOwnerMetadata(handle: FileHandle, metadata: JsonObject, step: OwnedStep): Promise { + const serialized = `${JSON.stringify(metadata)}\n`; + await step(() => handle.truncate(0)); + await step(() => handle.write(serialized, 0, 'utf8').then(() => undefined)); + await step(() => handle.sync()); +} + +async function readOwnerMetadata(handle: FileHandle, step: OwnedStep): Promise { + const size = (await step(() => handle.stat())).size; + if (size === 0) return {}; + const buffer = Buffer.alloc(size); + const { bytesRead } = await step(() => handle.read(buffer, 0, size, 0)); + let value: unknown; try { - await handle.truncate(0); - await handle.writeFile(`${JSON.stringify(metadata)}\n`, 'utf8'); - await handle.sync(); - } finally { - await handle.close(); + value = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')); + } catch { + throw new Error('manifest.lock contains malformed owner metadata'); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('manifest.lock owner metadata must be an object'); + } + return value as JsonObject; +} + +async function openLockFile(lockPath: string): Promise<{ handle: FileHandle; identity: LockIdentity }> { + const handle = await open(lockPath, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600); + try { + const info = await handle.stat(); + if (!info.isFile() || info.nlink !== 1) { + throw new Error('manifest.lock must be a regular file with exactly one link'); + } + const pathInfo = await lstat(lockPath); + if (!pathInfo.isFile() || pathInfo.isSymbolicLink() || pathInfo.nlink !== 1 + || pathInfo.dev !== info.dev || pathInfo.ino !== info.ino) { + throw new Error('manifest.lock identity changed while opening'); + } + return { handle, identity: { dev: info.dev, ino: info.ino } }; + } catch (error) { + await handle.close().catch(() => undefined); + throw error; } } -async function startCandidate( +async function runLockCommand( candidate: LockCandidate, + args: string[], + lockHandle: FileHandle, lockPath: string, timeoutMs?: number, -): Promise { - // Give the advisory-lock utility and its holder command one process group. - // A timed-out waiter must terminate both; killing only the utility can leave - // its child alive to acquire and retain the lock after the caller has failed. - const child = spawn(candidate.command, candidate.args(lockPath), { +): Promise<{ code: number | null; signal: NodeJS.Signals | null; stderr: string }> { + const child = spawn(candidate.command, args, { detached: true, - stdio: ['pipe', 'pipe', 'pipe'], + stdio: ['ignore', 'ignore', 'pipe', lockHandle.fd], }); - return new Promise((resolve, reject) => { - let stdout = ''; - let stderr = ''; - let settled = false; - const timer = timeoutMs === undefined ? undefined : setTimeout(() => { - try { - process.kill(-child.pid!, 'SIGTERM'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; - } - child.stdin.destroy(); - finishReject(new Error(`timed out after ${timeoutMs}ms waiting for ${lockPath}`)); - }, timeoutMs); - - const cleanup = () => { - if (timer) clearTimeout(timer); - child.off('error', onError); - child.off('exit', onExit); - child.stdout.off('data', onStdout); - child.stderr.off('data', onStderr); - }; - const finishReject = (error: Error) => { - if (settled) return; - settled = true; - cleanup(); - reject(error); - }; - const onError = (error: NodeJS.ErrnoException) => finishReject(error); - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - finishReject(new Error( - `${candidate.command} exited before lock acquisition (${code ?? signal ?? 'unknown'}): ${stderr.trim()}`, - )); - }; - const onStdout = (chunk: Buffer) => { - stdout += chunk.toString('utf8'); - if (!stdout.includes('WEBMCPIFY_LOCK_READY\n') || settled) return; - settled = true; - cleanup(); - resolve(child); - }; - const onStderr = (chunk: Buffer) => { stderr += chunk.toString('utf8'); }; + let stderr = ''; + child.stderr?.setEncoding('utf8'); + child.stderr?.on('data', (chunk) => { stderr += chunk; }); + const exit = waitForChildExit(child); + let timer: NodeJS.Timeout | undefined; + const timedOut = Symbol('timed-out'); + const result = await (timeoutMs === undefined + ? exit + : Promise.race([ + exit, + new Promise((resolve) => { timer = setTimeout(() => resolve(timedOut), timeoutMs); }), + ])); + if (timer) clearTimeout(timer); + if (result === timedOut) { + try { + process.kill(-child.pid!, 'SIGTERM'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; + } + await exit.catch(() => undefined); + throw new Error(`timed out after ${timeoutMs}ms waiting for ${lockPath}`); + } + return { ...result, stderr }; +} - child.once('error', onError); - child.once('exit', onExit); - child.stdout.on('data', onStdout); - child.stderr.on('data', onStderr); - }); +async function verifyLockHeld( + candidate: LockCandidate, + lockPath: string, + identity: LockIdentity, +): Promise { + const probe = await openLockFile(lockPath); + try { + if (probe.identity.dev !== identity.dev || probe.identity.ino !== identity.ino) { + throw new Error('manifest.lock identity changed after lock acquisition'); + } + const result = await runLockCommand(candidate, candidate.probeArgs(), probe.handle, lockPath); + if (result.code !== candidate.busyExitCode) { + throw new Error( + `${candidate.command} descriptor lock did not remain held by the runner ` + + `(${result.code ?? result.signal ?? 'unknown'}): ${result.stderr.trim()}`, + ); + } + } finally { + await probe.handle.close().catch(() => undefined); + } } -async function acquireLock(lockPath: string, timeoutMs?: number): Promise { +async function acquireLock( + lockHandle: FileHandle, + lockPath: string, + lockIdentity: LockIdentity, + timeoutMs?: number, +): Promise { const unavailable: string[] = []; for (const candidate of LOCK_CANDIDATES) { try { - return await startCandidate(candidate, lockPath, timeoutMs); + const result = await runLockCommand(candidate, candidate.args(), lockHandle, lockPath, timeoutMs); + if (result.code !== 0) { + throw new Error( + `${candidate.command} exited before lock acquisition ` + + `(${result.code ?? result.signal ?? 'unknown'}): ${result.stderr.trim()}`, + ); + } + await verifyLockHeld(candidate, lockPath, lockIdentity); + return candidate; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { unavailable.push(candidate.command); @@ -286,60 +401,115 @@ async function acquireLock(lockPath: string, timeoutMs?: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); + } + return new Promise((resolve, reject) => { + const cleanup = () => { + child.off('error', onError); + child.off('exit', onExit); + }; + const onError = (error: Error) => { cleanup(); reject(error); }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + resolve({ code, signal }); + }; + child.once('error', onError); + child.once('exit', onExit); + if (child.exitCode !== null || child.signalCode !== null) { + onExit(child.exitCode, child.signalCode); + } + }); +} + export class MutationJournal { readonly manifestPath: string; readonly lockPath: string; readonly ownerToken: string; - #holder: ChildProcessWithoutNullStreams | undefined; + readonly lockBackend: string; + #lockHandle: FileHandle | undefined; + #lockIdentity: LockIdentity; + #stableLockIdentity = ''; #closed = false; - #lockFailure: Error | undefined; #unresolved: MutationExecution[]; private constructor( manifestPath: string, lockPath: string, ownerToken: string, - holder: ChildProcessWithoutNullStreams, + lockHandle: FileHandle, + lockIdentity: LockIdentity, + lockBackend: string, unresolved: MutationExecution[], ) { this.manifestPath = manifestPath; this.lockPath = lockPath; this.ownerToken = ownerToken; - this.#holder = holder; + this.lockBackend = lockBackend; + this.#lockHandle = lockHandle; + this.#lockIdentity = lockIdentity; this.#unresolved = unresolved; - holder.once('error', (error) => { - if (!this.#closed) this.#lockFailure = new Error(`mutation journal lock holder failed: ${error.message}`); - }); - holder.once('exit', (code, signal) => { - if (!this.#closed) { - this.#lockFailure = new Error( - `mutation journal lock holder exited unexpectedly (${code ?? signal ?? 'unknown'})`, - ); - } - }); } static async open(options: OpenMutationJournalOptions): Promise { const manifestPath = await realpath(options.manifestPath); const manifestDirectory = dirname(manifestPath); const lockPath = join(manifestDirectory, 'manifest.lock'); - const lockFile = await open(lockPath, 'a', 0o600); - await lockFile.close(); - - const holder = await acquireLock(lockPath, options.timeoutMs); + const { handle: lockHandle, identity: lockIdentity } = await openLockFile(lockPath); + let journal: MutationJournal | undefined; const ownerToken = randomUUID(); try { - await writeOwnerMetadata(lockPath, { + const candidate = await acquireLock(lockHandle, lockPath, lockIdentity, options.timeoutMs); + journal = new MutationJournal( + manifestPath, + lockPath, + ownerToken, + lockHandle, + lockIdentity, + candidate.command, + [], + ); + const manifest = await journal.#ownedStep(() => readManifest(manifestPath)); + const ownerMetadata = await readOwnerMetadata(lockHandle, journal.#ownedStep); + const manifestIdentity = manifest.mutationLockIdentity; + const sidecarIdentity = ownerMetadata.lockIdentity; + if (manifestIdentity !== undefined && (typeof manifestIdentity !== 'string' || !manifestIdentity.trim())) { + throw new Error('manifest mutationLockIdentity must be a non-empty string'); + } + if (sidecarIdentity !== undefined && (typeof sidecarIdentity !== 'string' || !sidecarIdentity.trim())) { + throw new Error('manifest.lock lockIdentity must be a non-empty string'); + } + if (manifestIdentity && !sidecarIdentity) { + throw new Error('manifest.lock lost its stable identity; refusing a replacement sidecar'); + } + if (manifestIdentity && sidecarIdentity && manifestIdentity !== sidecarIdentity) { + throw new Error('manifest.lock stable identity does not match the manifest'); + } + journal.#stableLockIdentity = manifestIdentity ?? sidecarIdentity as string | undefined ?? randomUUID(); + await writeOwnerMetadata(lockHandle, { + lockIdentity: journal.#stableLockIdentity, ownerToken, host: hostname(), pid: process.pid, + lockBackend: candidate.command, processStartedAt: new Date(Date.now() - process.uptime() * 1_000).toISOString(), acquiredAt: new Date().toISOString(), - }); - const manifest = await migrateManifest(manifestPath, await readManifest(manifestPath)); - return new MutationJournal(manifestPath, lockPath, ownerToken, holder, unresolvedEntries(manifest)); + }, journal.#ownedStep); + const migrated = await migrateManifest( + manifestPath, + manifest, + journal.#stableLockIdentity, + journal.#ownedStep, + ); + journal.#unresolved = unresolvedEntries(migrated); + await journal.#checkOwnership(); + return journal; } catch (error) { - holder.stdin.end('RELEASE\n'); + if (journal) await journal.#releaseLock().catch(() => undefined); + else await lockHandle.close().catch(() => undefined); throw error; } } @@ -349,7 +519,7 @@ export class MutationJournal { } async beforeDispatch(input: BeforeDispatchInput): Promise { - this.#assertOpen(); + await this.#checkOwnership(); requireText(input.tool, 'tool'); requireText(input.origin, 'origin'); requireText(input.role, 'role'); @@ -359,7 +529,8 @@ export class MutationJournal { throw new Error('contractRevision must be a positive integer'); } - const manifest = await readManifest(this.manifestPath); + const manifest = await this.#ownedStep(() => readManifest(this.manifestPath)); + validateJournals(manifest); const unresolved = unresolvedEntries(manifest); const manifestTool = input.manifestTool ?? input.tool; const owner = manifest.tools.find((tool) => tool.id === manifestTool); @@ -393,9 +564,10 @@ export class MutationJournal { ...(input.parentExecutionId ? { parentExecutionId: input.parentExecutionId } : {}), }; owner.mutationExecutions.push(execution); - await durableReplace(this.manifestPath, manifest); + await durableReplace(this.manifestPath, manifest, this.#ownedStep); - const stored = await readManifest(this.manifestPath); + const stored = await this.#ownedStep(() => readManifest(this.manifestPath)); + validateJournals(stored); const persisted = stored.tools .find((tool) => tool.id === manifestTool) ?.mutationExecutions?.find((entry) => entry.executionId === execution.executionId); @@ -403,16 +575,18 @@ export class MutationJournal { throw new Error(`pre-dispatch journal entry was not durably persisted: ${execution.executionId}`); } this.#unresolved = unresolvedEntries(stored); + await this.#checkOwnership(); return structuredClone(execution); } async settle(executionId: string, input: SettlementInput): Promise { - this.#assertOpen(); + await this.#checkOwnership(); requireText(executionId, 'executionId'); requireText(input.outcome, 'outcome'); requireText(input.evidence, 'evidence'); - const manifest = await readManifest(this.manifestPath); + const manifest = await this.#ownedStep(() => readManifest(this.manifestPath)); + validateJournals(manifest); const owner = manifest.tools.find((tool) => tool.mutationExecutions?.some((entry) => entry.executionId === executionId), ); @@ -432,45 +606,71 @@ export class MutationJournal { execution.outcome = input.outcome; execution.reconciledAt = input.reconciledAt ?? new Date().toISOString(); execution.evidence = input.evidence; - await durableReplace(this.manifestPath, manifest); - this.#unresolved = unresolvedEntries(await readManifest(this.manifestPath)); + await durableReplace(this.manifestPath, manifest, this.#ownedStep); + const stored = await this.#ownedStep(() => readManifest(this.manifestPath)); + validateJournals(stored); + this.#unresolved = unresolvedEntries(stored); + await this.#checkOwnership(); return structuredClone(execution); } async close(): Promise { if (this.#closed) return; - this.#closed = true; - const holder = this.#holder; - this.#holder = undefined; - if (!holder) return; - if (this.#lockFailure || holder.exitCode !== null || holder.signalCode !== null) { - throw this.#lockFailure ?? new Error('mutation journal lock holder exited before release'); + let failure: unknown; + try { + if (!failure) { + await syncDirectory(dirname(this.manifestPath), this.#ownedStep); + await writeOwnerMetadata(this.#lockHandle!, { + lockIdentity: this.#stableLockIdentity, + ownerToken: this.ownerToken, + host: hostname(), + pid: process.pid, + releasedAt: new Date().toISOString(), + }, this.#ownedStep); + } + } catch (error) { + failure = error; } - - let syncError: unknown; try { - await syncDirectory(dirname(this.manifestPath)); + await this.#releaseLock(); } catch (error) { - syncError = error; + failure ??= error; } - const exit = new Promise((resolve, reject) => { - holder.once('error', reject); - holder.once('exit', (code, signal) => { - if (code === 0) resolve(); - else reject(new Error(`lock holder exited while releasing (${code ?? signal ?? 'unknown'})`)); - }); - }); - holder.stdin.end('RELEASE\n'); - await exit; - if (syncError) throw syncError; + if (failure) throw failure; } - #assertOpen(): void { - if (this.#closed || !this.#holder) throw new Error('mutation journal is closed'); - if (this.#lockFailure || this.#holder.exitCode !== null || this.#holder.signalCode !== null) { - throw this.#lockFailure ?? new Error('mutation journal lock ownership was lost'); + readonly #ownedStep: OwnedStep = async (operation: () => Promise): Promise => { + await this.#checkOwnership(); + const result = await operation(); + await this.#checkOwnership(); + return result; + }; + + async #checkOwnership(): Promise { + if (this.#closed || !this.#lockHandle) { + throw new Error('mutation journal is closed'); + } + const [handleInfo, pathInfo] = await Promise.all([ + this.#lockHandle.stat(), + lstat(this.lockPath), + ]); + if (!handleInfo.isFile() || handleInfo.nlink !== 1 || !pathInfo.isFile() + || pathInfo.isSymbolicLink() || pathInfo.nlink !== 1 + || handleInfo.dev !== this.#lockIdentity.dev || handleInfo.ino !== this.#lockIdentity.ino + || pathInfo.dev !== this.#lockIdentity.dev || pathInfo.ino !== this.#lockIdentity.ino) { + throw new Error('mutation journal lock identity changed'); } } + + async #releaseLock(): Promise { + if (this.#closed) return; + const lockHandle = this.#lockHandle; + this.#lockHandle = undefined; + let failure: unknown; + await lockHandle?.close().catch((error) => { failure ??= error; }); + this.#closed = true; + if (failure) throw failure; + } } export async function openMutationJournal(options: OpenMutationJournalOptions): Promise { diff --git a/tests/manifest-lock.test.mjs b/tests/manifest-lock.test.mjs index 3ee7570..ebb0286 100644 --- a/tests/manifest-lock.test.mjs +++ b/tests/manifest-lock.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { access, chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { access, chmod, link, mkdir, mkdtemp, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -59,6 +59,22 @@ function mutation(overrides = {}) { }; } +function journalEntry(overrides = {}) { + return { + executionId: 'execution-parent', + tool: 'send_contact_message', + contractRevision: 1, + origin: 'https://app.example.test', + role: 'member', + fixtureRevision: 'seed-v2', + argumentsFingerprint: fingerprintArguments({ email: 'qa@example.test' }), + startedAt: '2026-09-19T12:00:00.000Z', + state: 'started', + evidence: '.webmcpify/evidence/contact-message.json', + ...overrides, + }; +} + async function delay(ms) { await new Promise((resolve) => setTimeout(resolve, ms)); } @@ -88,6 +104,20 @@ async function waitForExit(child) { return { ...result, stderr }; } +async function waitForOwnerMetadata(lockPath, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const metadata = JSON.parse(await readFile(lockPath, 'utf8')); + if (Number.isInteger(metadata.pid) && typeof metadata.lockBackend === 'string') return metadata; + } catch (error) { + if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error; + } + await delay(1); + } + throw new Error(`timed out waiting for owner metadata in ${lockPath}`); +} + test('argument fingerprints use recursively sorted canonical JSON', () => { assert.equal( fingerprintArguments({ z: 1, a: { y: 2, x: [3, { b: 2, a: 1 }] } }), @@ -205,7 +235,7 @@ test('a bounded waiter times out without acquiring or leaking the lock later', { } }); -test('lockf -k fallback keeps the sidecar inode and excludes a second runner', { +test('lockf descriptor fallback keeps the sidecar inode and excludes a second runner', { skip: process.platform === 'win32' || (!hasCommand('lockf') && !hasCommand('flock')) ? 'requires native lockf(1), or flock(1) for the Linux lockf compatibility shim' : false, @@ -221,11 +251,12 @@ test('lockf -k fallback keeps the sidecar inode and excludes a second runner', { const nativeFlock = commandPath('flock'); const shim = `#!${process.execPath}\n` + `import { spawnSync } from 'node:child_process';\n` - + `const [keep, lockPath, command, ...args] = process.argv.slice(2);\n` - + `if (keep !== '-k') process.exit(64);\n` - + `const result = spawnSync(${JSON.stringify(nativeFlock)}, ['--exclusive', lockPath, command, ...args], { stdio: 'inherit' });\n` + + `const args = process.argv.slice(2);\n` + + `const nonblock = args.includes('-t');\n` + + `if (args.at(-1) !== '3') process.exit(64);\n` + + `const result = spawnSync(${JSON.stringify(nativeFlock)}, ['--exclusive', ...(nonblock ? ['--nonblock'] : []), '3'], { stdio: ['inherit', 'inherit', 'inherit', 3] });\n` + `if (result.error) throw result.error;\n` - + `process.exit(result.status ?? 1);\n`; + + `process.exit(nonblock && result.status === 1 ? 75 : result.status ?? 1);\n`; await writeFile(join(bin, 'lockf'), shim, { mode: 0o700 }); } @@ -340,6 +371,7 @@ test('opening a legacy manifest initializes journals and invalidates historical const journal = await openMutationJournal({ manifestPath: paths.manifestPath }); try { const stored = JSON.parse(await readFile(paths.manifestPath, 'utf8')); + assert.equal(typeof stored.mutationLockIdentity, 'string'); assert.equal(stored.tools[0].status, 'integrated'); assert.equal(stored.tools[0].verifiedAgainst, null); assert.deepEqual(stored.tools[0].mutationExecutions, []); @@ -352,11 +384,220 @@ test('opening a legacy manifest initializes journals and invalidates historical } }); +test('present malformed journals fail closed without rewriting the manifest', { + skip: requiresAdvisoryLock, +}, async (t) => { + const cases = [ + ['non-array', { executionId: 'uncertain', state: 'started' }], + ['malformed entry', [{ executionId: 'uncertain', state: 'started' }]], + ['unknown state', [journalEntry({ state: 'unknown' })]], + ['duplicate execution IDs', [journalEntry(), journalEntry()]], + ['missing parent', [journalEntry({ executionId: 'cleanup', parentExecutionId: 'missing' })]], + ['cleanup parent owned by another tool', [journalEntry({ executionId: 'cleanup', parentExecutionId: 'other-parent' })]], + ]; + for (const [name, mutationExecutions] of cases) { + await t.test(name, async () => { + const tools = [{ + id: 'send_contact_message', + status: 'integrated', + contractRevision: 1, + mutationExecutions, + verifiedAgainst: null, + }]; + if (name === 'cleanup parent owned by another tool') { + tools.push({ + id: 'other_tool', + status: 'integrated', + contractRevision: 1, + mutationExecutions: [journalEntry({ executionId: 'other-parent', tool: 'other_tool' })], + verifiedAgainst: null, + }); + } + const paths = await fixture(manifest(tools)); + const before = await readFile(paths.manifestPath, 'utf8'); + try { + await assert.rejects(openMutationJournal({ manifestPath: paths.manifestPath })); + assert.equal(await readFile(paths.manifestPath, 'utf8'), before); + } finally { + await rm(paths.directory, { recursive: true, force: true }); + } + }); + } +}); + +test('the runner descriptor retains ownership through startup, dispatch, settlement and release', { + skip: requiresAdvisoryLock, + timeout: 10_000, +}, async () => { + const paths = await fixture(); + const journal = await openMutationJournal({ manifestPath: paths.manifestPath }); + let successor; + try { + const metadata = JSON.parse(await readFile(paths.lockPath, 'utf8')); + assert.equal(metadata.pid, process.pid); + assert.equal(typeof metadata.lockBackend, 'string'); + assert.equal(Object.hasOwn(metadata, 'lockHolderPid'), false, 'no killable holder owns the lock'); + + await assert.rejects( + openMutationJournal({ manifestPath: paths.manifestPath, timeoutMs: 50 }), + /timed out after 50ms/, + 'startup must remain excluded after the acquisition subprocess exits', + ); + const execution = await journal.beforeDispatch(mutation()); + await assert.rejects( + openMutationJournal({ manifestPath: paths.manifestPath, timeoutMs: 50 }), + /timed out after 50ms/, + 'dispatch must remain excluded', + ); + await journal.settle(execution.executionId, { outcome: 'verified', evidence: 'verified.json' }); + await assert.rejects( + openMutationJournal({ manifestPath: paths.manifestPath, timeoutMs: 50 }), + /timed out after 50ms/, + 'settlement must remain excluded', + ); + await journal.close(); + successor = await openMutationJournal({ manifestPath: paths.manifestPath, timeoutMs: 1_000 }); + assert.deepEqual(successor.unresolved, []); + } finally { + await journal.close().catch(() => undefined); + await successor?.close(); + await rm(paths.directory, { recursive: true, force: true }); + } +}); + +test('runner death during startup releases ownership without a partial manifest rewrite', { + skip: requiresAdvisoryLock, + timeout: 15_000, +}, async () => { + const tools = Array.from({ length: 150_000 }, (_, index) => ({ + id: `tool_${index}`, + status: 'verified', + contractRevision: 1, + verifiedAgainst: { at: '2026-09-14T12:00:00Z' }, + })); + const paths = await fixture(manifest(tools)); + const before = await readFile(paths.manifestPath, 'utf8'); + const childSource = ` + import { openMutationJournal } from ${JSON.stringify(helperUrl)}; + try { + const journal = await openMutationJournal({ manifestPath: process.env.MANIFEST }); + await journal.close(); + process.exit(2); + } catch (error) { + console.error(error.message); + process.exit(3); + } + `; + const child = spawn(process.execPath, ['--input-type=module', '--eval', childSource], { + env: { ...process.env, MANIFEST: paths.manifestPath }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let recovery; + try { + const metadata = await waitForOwnerMetadata(paths.lockPath); + process.kill(metadata.pid, 'SIGKILL'); + const result = await waitForExit(child); + assert.equal(result.signal, 'SIGKILL', result.stderr); + const current = await readFile(paths.manifestPath, 'utf8'); + assert.ok(current === before || JSON.parse(current).tools.every((tool) => Array.isArray(tool.mutationExecutions))); + recovery = await openMutationJournal({ manifestPath: paths.manifestPath, timeoutMs: 3_000 }); + assert.deepEqual(recovery.unresolved, []); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await recovery?.close(); + await rm(paths.directory, { recursive: true, force: true }); + } +}); + +test('symlink and multiply-linked sidecars are rejected without touching their targets', { + skip: process.platform === 'win32', +}, async (t) => { + await t.test('symlink', async () => { + const paths = await fixture(); + const victim = join(paths.directory, 'victim'); + await writeFile(victim, 'do not touch'); + await symlink(victim, paths.lockPath); + try { + await assert.rejects(openMutationJournal({ manifestPath: paths.manifestPath }), /ELOOP|symbolic link/); + assert.equal(await readFile(victim, 'utf8'), 'do not touch'); + } finally { + await rm(paths.directory, { recursive: true, force: true }); + } + }); + await t.test('hard link', async () => { + const paths = await fixture(); + const victim = join(paths.directory, 'victim'); + await writeFile(victim, 'do not touch'); + await link(victim, paths.lockPath); + try { + await assert.rejects(openMutationJournal({ manifestPath: paths.manifestPath }), /exactly one link/); + assert.equal(await readFile(victim, 'utf8'), 'do not touch'); + } finally { + await rm(paths.directory, { recursive: true, force: true }); + } + }); +}); + +test('a sidecar swap during acquisition cannot establish a second lock identity', { + skip: process.platform === 'win32' || !hasCommand('flock') ? 'requires flock(1)' : false, + timeout: 10_000, +}, async () => { + const paths = await fixture(); + const before = await readFile(paths.manifestPath, 'utf8'); + const nativeFlock = commandPath('flock'); + const bin = join(paths.directory, 'bin'); + await mkdir(bin); + const shim = '#!/bin/sh\n' + + '/bin/mv "$WEBMCPIFY_TEST_LOCK_PATH" "$WEBMCPIFY_TEST_LOCK_PATH.displaced"\n' + + ': > "$WEBMCPIFY_TEST_LOCK_PATH"\n' + + 'exec "$WEBMCPIFY_NATIVE_FLOCK" "$@"\n'; + await writeFile(join(bin, 'flock'), shim, { mode: 0o700 }); + const originalPath = process.env.PATH; + const originalLockPath = process.env.WEBMCPIFY_TEST_LOCK_PATH; + const originalFlock = process.env.WEBMCPIFY_NATIVE_FLOCK; + try { + process.env.PATH = bin; + process.env.WEBMCPIFY_TEST_LOCK_PATH = paths.lockPath; + process.env.WEBMCPIFY_NATIVE_FLOCK = nativeFlock; + await assert.rejects(openMutationJournal({ manifestPath: paths.manifestPath }), /identity changed/); + assert.equal(await readFile(paths.manifestPath, 'utf8'), before); + } finally { + process.env.PATH = originalPath; + if (originalLockPath === undefined) delete process.env.WEBMCPIFY_TEST_LOCK_PATH; + else process.env.WEBMCPIFY_TEST_LOCK_PATH = originalLockPath; + if (originalFlock === undefined) delete process.env.WEBMCPIFY_NATIVE_FLOCK; + else process.env.WEBMCPIFY_NATIVE_FLOCK = originalFlock; + await rm(paths.directory, { recursive: true, force: true }); + } +}); + +test('a live sidecar replacement cannot admit a second operational journal', { + skip: requiresAdvisoryLock, + timeout: 10_000, +}, async () => { + const paths = await fixture(); + const runnerA = await openMutationJournal({ manifestPath: paths.manifestPath }); + try { + await rename(paths.lockPath, `${paths.lockPath}.displaced`); + await writeFile(paths.lockPath, ''); + await assert.rejects( + openMutationJournal({ manifestPath: paths.manifestPath, timeoutMs: 1_000 }), + /lost its stable identity/, + ); + await assert.rejects(runnerA.beforeDispatch(mutation()), /lock identity changed/); + const stored = JSON.parse(await readFile(paths.manifestPath, 'utf8')); + assert.deepEqual(stored.tools[0].mutationExecutions, []); + } finally { + await runnerA.close().catch(() => undefined); + await rm(paths.directory, { recursive: true, force: true }); + } +}); + test('the portable helper retains both supported advisory-lock backends', async () => { const source = await readFile(join(root, 'skills/webmcpify/templates/mutation-journal.js'), 'utf8'); assert.match(source, /command: "flock"/); assert.match(source, /command: "lockf"/); - assert.match(source, /\["-k", lockPath/); + assert.match(source, /\["-s", "3"\]/); }); test('the Playwright template journals every mutating example and cleanup action', async () => {