check-cross-client-sync.mjs

32.5 KB · raw

1import { existsSync, readFileSync, mkdtempSync } from "node:fs";2import { dirname, resolve } from "node:path";3import { fileURLToPath } from "node:url";4import { spawn } from "node:child_process";5import { tmpdir } from "node:os";67const browserRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");8const androidRoot = resolve(browserRoot, "../peach-android");9const localJavaHome = "/opt/homebrew/opt/openjdk@17";10const javaHome = process.env.JAVA_HOME ?? (existsSync(localJavaHome) ? localJavaHome : undefined);11const browserCoordinator = readFileSync(resolve(browserRoot, "src/background/sync-coordinator.ts"), "utf8");12const androidCoordinator = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt"), "utf8");13const browserSyncTypes = readFileSync(resolve(browserRoot, "src/shared/sync-types.ts"), "utf8");14const androidManifest = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt"), "utf8");15const androidSyncMessage = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncMessage.kt"), "utf8");16const browserPairedDevices = readFileSync(resolve(browserRoot, "src/background/paired-devices.ts"), "utf8");17const androidPairedDevices = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt"), "utf8");18const browserAdoption = readFileSync(resolve(browserRoot, "src/shared/adoption-protocol.ts"), "utf8");19const androidAdoption = [20  "AdoptionProtocol.kt",21  "VaultAdopt.kt",22].map((file) => readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync", file), "utf8")).join("\n");2324// Keep the source guards as cheap secondary checks. The real gate below crosses25// a process boundary and exercises each production coordinator and JSON codec.26if (browserCoordinator.includes('helloIn.version === localVersion')) throw new Error("Browser still contains the forbidden equal-version fast path");27if (!browserCoordinator.includes("helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION")) throw new Error("Browser does not negotiate device manifests from SYNC_HELLO");28if (!androidCoordinator.includes("val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)")) throw new Error("Android does not negotiate device manifests from SYNC_HELLO");29if (!androidCoordinator.includes("peerVersion == DEVICE_MANIFEST_VERSION")) throw new Error("Android does not require an exact device-manifest version match");3031const numericConstant = (source, declaration) => {32  const match = source.match(new RegExp(`${declaration}\\s*=\\s*(0x[0-9a-f]+|\\d+)`));33  if (!match) throw new Error(`Missing ${declaration} declaration`);34  return Number(match[1]);35};36if (numericConstant(browserSyncTypes, "DEVICE_MANIFEST_VERSION") !== numericConstant(androidManifest, "DEVICE_MANIFEST_VERSION")) {37  throw new Error("Browser and Android device-manifest versions differ");38}39if (numericConstant(browserSyncTypes, "DEVICE_REVOCATION_RECORD_VERSION") !== numericConstant(androidSyncMessage, "DEVICE_REVOCATION_RECORD_VERSION")) {40  throw new Error("Browser and Android revocation record versions differ");41}42// The canonical revocation object is signed field-by-field, so a renamed or43// dropped field silently stops the two clients verifying each other.44for (const field of ["recordVersion", "deviceId", "longTermPublicKeyHex", "revokedByDeviceId"]) {45  if (!browserSyncTypes.includes(field) || !androidSyncMessage.includes(field)) {46    throw new Error(`A client lacks revocation field ${field}`);47  }48}49if (browserSyncTypes.includes("issuerSigningPublicKeyHex") || androidSyncMessage.includes("issuerSigningKeyHex")) {50  throw new Error("A client still carries an issuer key inside the revocation record");51}52const syncProtocolVersion = (source, label) => {53  const match = source.match(/SYNC_PROTOCOL_VERSION\s*=\s*(\d+)/);54  if (!match) throw new Error(`Missing SYNC_PROTOCOL_VERSION declaration in ${label}`);55  return Number(match[1]);56};57const browserProtocolVersion = syncProtocolVersion(browserSyncTypes, "Browser");58const androidProtocolVersion = syncProtocolVersion(androidCoordinator, "Android");59if (browserProtocolVersion !== androidProtocolVersion) {60  throw new Error(`Browser and Android Sync protocol versions differ (${browserProtocolVersion} vs ${androidProtocolVersion})`);61}62if (numericConstant(browserAdoption, "ADOPTION_VERSION") !== numericConstant(androidAdoption, "ADOPTION_VERSION")) {63  throw new Error("Browser and Android adoption transcript versions differ");64}65for (const frame of [66  "ADOPT_REQUEST_V2",67  "ADOPT_CONFIRM_REQUIRED",68  "ADOPT_CONFIRM",69  "ADOPT_PREPARE",70  "ADOPT_READY",71  "ADOPT_GRANT",72  "ADOPT_APPLIED",73  "ADOPT_COMPLETE",74  "ADOPT_REJECT",75]) {76  if (!browserAdoption.includes(frame) || !androidAdoption.includes(frame)) {77    throw new Error(`A client lacks adoption v2 frame ${frame}`);78  }79}80// Both clients must require exact equality, never a "greater-or-equal" pass.81if (browserCoordinator.includes("protocolVersion ?? 1) < SYNC_PROTOCOL_VERSION") ||82    androidCoordinator.includes("< SYNC_PROTOCOL_VERSION")) {83  throw new Error("A client still accepts a peer protocol version below its own");84}85if (!browserSyncTypes.includes("signatureHex") || !androidSyncMessage.includes("signatureHex")) {86  throw new Error("A client lacks the revocation signature field");87}88if (!browserPairedDevices.includes("MAX_REVOCATIONS = 500") || !androidPairedDevices.includes("MAX_REVOCATION_RECORDS = 500")) throw new Error("Browser and Android revocation ledger caps differ");89if (!browserPairedDevices.includes("enqueueDeviceMutation") || !androidPairedDevices.includes("RegistryMutationGate")) throw new Error("A paired-device registry lacks whole-list mutation serialization");90if (!browserPairedDevices.includes("function capRevocations") || !androidPairedDevices.includes("fun capRevocationRecords")) throw new Error("A client lacks largest-issuer-first revocation retention");91const browserEndpoint = String.raw`92import { createInterface } from "node:readline";93import { createServer, createConnection } from "node:net";94const root = process.env.PEACH_BROWSER_ROOT;95const coordinator = await import(root + "/src/background/sync-coordinator.ts");96const syncTypes = await import(root + "/src/shared/sync-types.ts");97const padding = await import(root + "/src/shared/sync-padding.ts");98const mode = process.env.PEACH_SYNC_HARNESS_MODE;99const portFile = process.env.PEACH_SYNC_HARNESS_PORT_FILE;100const transcriptFile = process.env.PEACH_SYNC_HARNESS_TRANSCRIPT_FILE;101102// Scenario shape arrives entirely from the parent; this endpoint does not know103// what a scenario is. Both clients read the same knobs, so the matrix lives in104// one readable place rather than half here and half in the harness.105const knobs = {106  parity: process.env.PEACH_SYNC_HARNESS_MTIME_PARITY ?? "",107  localVersion: Number(process.env.PEACH_SYNC_HARNESS_LOCAL_VERSION ?? "7"),108  vaultId: process.env.PEACH_SYNC_HARNESS_VAULT_ID ?? "cross-client-vault",109  manifestSupport: process.env.PEACH_SYNC_HARNESS_MANIFEST_VERSION !== "3",110  entryCount: Number(process.env.PEACH_SYNC_HARNESS_ENTRY_COUNT ?? "1"),111  expectStatus: process.env.PEACH_SYNC_HARNESS_EXPECT_STATUS ?? "completed",112  drain: process.env.PEACH_SYNC_HARNESS_DRAIN === "1",113  // A locked session may hold the transport key and stage ciphertext, but it114  // never authors or applies a device manifest and never merges entries into115  // the live vault (PB-26-112 C).116  locked: process.env.PEACH_SYNC_HARNESS_LOCKED === "1",117  stopAfter: process.env.PEACH_SYNC_HARNESS_STOP_AFTER ?? "",118};119120const id = mode === "initiator" ? "browser" : "android";121const socket = await new Promise((resolve, reject) => {122  if (mode === "initiator") {123    const server = createServer((client) => {124      // Stop listening as soon as the one conformance peer connects. Leaving125      // this listener open keeps Bun alive after the coordinator completes.126      server.close();127      resolve(client);128    });129    server.once("error", reject);130    server.listen(0, "127.0.0.1", () => require("node:fs").writeFileSync(portFile, String(server.address().port)));131  } else {132    const client = createConnection({ host: "127.0.0.1", port: Number(process.env.PEACH_SYNC_HARNESS_PORT) });133    client.once("connect", () => resolve(client));134    client.once("error", reject);135  }136});137socket.setEncoding("utf8");138const lines = createInterface({ input: socket });139const iterator = lines[Symbol.asyncIterator]();140const events = [];141const typeOf = (message) => message.type;142let outcome = null;143let counted = { sent: 0, received: 0, applied: 0, staged: 0 };144const fs = require("node:fs");145const writeTranscript = (status) => fs.writeFileSync(transcriptFile, JSON.stringify({146  status,147  error: outcome?.error,148  entriesSent: counted.sent,149  entriesReceived: counted.received,150  applied: counted.applied,151  staged: counted.staged,152  events,153}));154const session = {155  remoteStaticPublicKey: new Uint8Array(32).fill(7),156  send(bytes) {157    const message = JSON.parse(new TextDecoder().decode(bytes));158    events.push("send:" + typeOf(message));159    // Simulated process death: the transcript is on disk before the process160    // goes, so the parent can still say what the peer had seen.161    if (knobs.stopAfter !== "" && knobs.stopAfter === typeOf(message)) {162      writeTranscript("DIED");163      process.exit(7);164    }165    socket.write(Buffer.from(padding.padSyncFrame(bytes)).toString("base64") + "\n");166  },167  async receive() {168    const next = await iterator.next();169    if (next.done) throw new Error("cross-client peer closed");170    const payload = padding.unpadSyncFrame(Uint8Array.from(Buffer.from(next.value.trim(), "base64")));171    events.push("recv:" + typeOf(JSON.parse(new TextDecoder().decode(payload))));172    return payload;173  },174  async close() { socket.end(); },175};176177// Entries are shared ids so a diff can actually flow. Parity decides which side178// is newer for a given index: the same parity on both sides means equal data179// versions (nothing to send), opposite parities means each side is newer for180// half the vault (entries flow both ways).181const ids = Array.from({ length: knobs.entryCount }, (_, index) => "shared-entry-" + index);182const mtimeFor = (index) =>183  knobs.parity === "" ? 10 : (index % 2 === Number(knobs.parity) ? 15 : 10);184const entries = Object.fromEntries(ids.map((entryId, index) => [entryId, mtimeFor(index)]));185const blobs = Object.fromEntries(ids.map((entryId, index) => [entryId, id + "-frame-" + (index % 8)]));186187const adapter = {188  async computeVaultId() { return knobs.vaultId; },189  async getOurDeviceId() { return id; },190  async getLocalVersion() { return knobs.localVersion; },191  async buildEntryManifest() { return { ...entries }; },192  async buildTombstoneManifest() { return {}; },193  async getEncryptedEntries(requested) {194    return Object.fromEntries(requested.filter((value) => value in blobs).map((value) => [value, blobs[value]]));195  },196  async applyEntries(entries) {197    if (knobs.locked) {198      counted.staged += Object.keys(entries).length;199      return;200    }201    counted.applied += Object.keys(entries).length;202  },203  async applyTombstones() {},204  // The coordinator reads capability off the adapter's shape, not a flag: a205  // manifest-v3 peer is one that does not expose the exchange at all. Modelling206  // it any other way would let this fixture advertise a version it cannot speak207  // — the same class of mistake the scenario is here to catch.208  ...(knobs.manifestSupport && !knobs.locked209    ? {210        async buildOutgoingDeviceManifest() {211          return { body: { manifestVersion: syncTypes.DEVICE_MANIFEST_VERSION, issuerDeviceId: id, issuedAt: 1700000000000, devices: [], revoked: [] }, signatureHex: "11".repeat(64) };212        },213        async applyIncomingDeviceManifest() {},214      }215    : {}),216};217218outcome = mode === "initiator"219  ? await coordinator.runSyncInitiator(session, adapter)220  : await coordinator.runSyncResponder(session, adapter);221counted.sent = outcome.entriesSent ?? 0;222counted.received = outcome.entriesReceived ?? 0;223224// A clean stop is only deterministic if our socket is still up when the peer225// writes its last frame, so hold it open until the peer goes away rather than226// exiting the instant the round ends.227if (knobs.drain) {228  await Promise.race([229    (async () => { for await (const _ of lines) { /* drain */ } })(),230    new Promise((resolve) => setTimeout(resolve, 2000)),231  ]);232}233234writeTranscript(outcome.status);235socket.end();236if (outcome.status !== knobs.expectStatus) {237  console.error("expected " + knobs.expectStatus + ", got " + outcome.status);238  process.exit(1);239}240`;241242const MAX_DIAGNOSTIC_BYTES = 12_000;243244function spawnProcess(command, args, env) {245  const child = spawn(command, args, { cwd: env.PEACH_PROCESS_ROOT ?? env.PEACH_BROWSER_ROOT ?? browserRoot, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] });246  const output = { stdout: "", stderr: "" };247  for (const [name, stream] of [["stdout", child.stdout], ["stderr", child.stderr]]) {248    stream.setEncoding("utf8");249    stream.on("data", (chunk) => {250      output[name] = (output[name] + chunk).slice(-MAX_DIAGNOSTIC_BYTES);251    });252  }253  child.diagnostics = output;254  return child;255}256257function waitForExit(child) {258  return new Promise((resolveExit, reject) => {259    child.once("error", reject);260    child.once("exit", (code, signal) => resolveExit({ code, signal, diagnostics: child.diagnostics }));261  });262}263264function terminate(child) {265  if (!child || child.exitCode !== null || child.signalCode !== null) return;266  child.kill("SIGTERM");267  setTimeout(() => {268    if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");269  }, 1_000).unref();270}271272function formatResult(result) {273  const output = result.diagnostics;274  return `code=${result.code ?? "none"} signal=${result.signal ?? "none"}; stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`;275}276277async function waitForFile(path, child) {278  const deadline = Date.now() + 30_000;279  while (!existsSync(path)) {280    if (child.exitCode !== null) throw new Error(`endpoint exited before publishing ${path} (${child.exitCode})`);281    if (Date.now() > deadline) throw new Error(`timed out waiting for ${path}`);282    await new Promise((resolve) => setTimeout(resolve, 50));283  }284  return readFileSync(path, "utf8").trim();285}286287function readTranscript(path) {288  return JSON.parse(readFileSync(path, "utf8"));289}290291const androidTask = ["--no-daemon", "--no-configuration-cache", "--rerun-tasks", "testDebugUnitTest", "--tests", "com.peachpasswords.sync.CrossClientSyncProcessTest.exchange_production_frames_with_browser_process"];292const gradleProperty = (name, value) => `-D${name.toLowerCase().replaceAll("_", ".")}=${value}`;293const KNOB_PREFIX = "PEACH_SYNC_HARNESS_";294const knobEnv = (knobs) =>295  Object.fromEntries(Object.entries(knobs).map(([name, value]) => [KNOB_PREFIX + name, String(value)]));296297/**298 * Run one scenario round. Both endpoints assert their own expected status, so a299 * non-zero exit means "my side did not behave as the matrix says it should" and300 * the scenario is free to describe a failure outcome without special-casing.301 */302async function runRound({ initiator, android = {}, browser = {}, timeoutMs = 180_000, label = "", expectFailure = [] }) {303  const dir = mkdtempSync(resolve(tmpdir(), "peach-cross-sync-"));304  const androidTranscript = resolve(dir, "android.json");305  const browserTranscript = resolve(dir, "browser.json");306  const androidPortFile = resolve(dir, "android.port");307  const browserPortFile = resolve(dir, "browser.port");308  const common = { PEACH_BROWSER_ROOT: browserRoot };309  const launchBrowser = (mode, port, transcript, portFile) => spawnProcess("bun", ["--eval", browserEndpoint], {310    ...common,311    ...knobEnv(browser),312    PEACH_SYNC_HARNESS_MODE: mode,313    PEACH_SYNC_HARNESS_PORT: port ?? "",314    PEACH_SYNC_HARNESS_PORT_FILE: portFile ?? "",315    PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: transcript,316  });317  const launchAndroid = (mode, port) => spawnProcess(resolve(androidRoot, "gradlew"), [318    gradleProperty("PEACH_SYNC_HARNESS_MODE", mode),319    ...androidTask,320  ], {321    ...common,322    ...knobEnv(android),323    PEACH_PROCESS_ROOT: androidRoot,324    PEACH_SYNC_HARNESS_MODE: mode,325    PEACH_SYNC_HARNESS_PORT: port ?? "",326    PEACH_SYNC_HARNESS_PORT_FILE: androidPortFile,327    PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: androidTranscript,328    ...(javaHome ? { JAVA_HOME: javaHome } : {}),329  });330331  let first;332  let second;333  let timeoutId;334  try {335    if (initiator === "android") {336      first = launchAndroid("initiator");337      const port = await waitForFile(androidPortFile, first);338      second = launchBrowser("responder", port, browserTranscript);339    } else {340      first = launchBrowser("initiator", undefined, browserTranscript, browserPortFile);341      const port = await waitForFile(browserPortFile, first);342      second = launchAndroid("responder", port);343    }344    const timeout = new Promise((_, reject) => {345      timeoutId = setTimeout(346        () => reject(new Error(`${label} timed out after ${Math.round(timeoutMs / 1000)}s`)),347        timeoutMs,348      );349    });350    const results = await Promise.race([351      Promise.all([waitForExit(first), waitForExit(second)]),352      timeout,353    ]);354    const [firstResult, secondResult] = results;355    // A scenario that deliberately kills an endpoint names that side here; the356    // other side must still behave, which is what makes the round meaningful.357    const initiatorSide = initiator;358    const responderSide = initiator === "android" ? "browser" : "android";359    const tolerate = (side, result) => expectFailure.includes(side) || result.code === 0;360    if (!tolerate(initiatorSide, firstResult) || !tolerate(responderSide, secondResult)) {361      throw new Error(`${label} failed: initiator(${initiatorSide}) ${formatResult(firstResult)}; responder(${responderSide}) ${formatResult(secondResult)}`);362    }363    return { android: readTranscript(androidTranscript), browser: readTranscript(browserTranscript) };364  } catch (error) {365    const details = [first, second].filter(Boolean).map((child) => child.diagnostics)366      .map((output) => `stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`).join("; ");367    throw new Error(`${error.message}; child diagnostics: ${details}`, { cause: error });368  } finally {369    clearTimeout(timeoutId);370    terminate(first);371    terminate(second);372  }373}374375/** The two halves of a completed round, as a function of what the scenario expects to happen. */376function expectedRound({ flow, manifests }) {377  return {378    initiator: [379      "send:SYNC_HELLO", "recv:SYNC_HELLO",380      "send:SYNC_MANIFEST", "recv:SYNC_MANIFEST",381      "send:SYNC_REQUEST", "recv:SYNC_REQUEST",382      "send:SYNC_ENTRIES", ...(flow ? ["recv:SYNC_ENTRIES"] : []),383      "send:SYNC_TOMBSTONES", "recv:SYNC_TOMBSTONES",384      ...(manifests ? ["send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DEVICE_MANIFEST"] : []),385      "send:SYNC_DONE", "recv:SYNC_DONE",386    ],387    responder: [388      "recv:SYNC_HELLO", "send:SYNC_HELLO",389      "recv:SYNC_MANIFEST", "send:SYNC_MANIFEST",390      "recv:SYNC_REQUEST", "send:SYNC_REQUEST",391      "recv:SYNC_ENTRIES", ...(flow ? ["send:SYNC_ENTRIES"] : []),392      "recv:SYNC_TOMBSTONES", "send:SYNC_TOMBSTONES",393      ...(manifests ? ["recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DEVICE_MANIFEST"] : []),394      "recv:SYNC_DONE", "send:SYNC_DONE",395    ],396  };397}398399const normalise = (status) => String(status).toLowerCase();400const allEvents = (round) => [...round.android.events, ...round.browser.events];401402function assertTranscript(actual, expected, label) {403  const got = JSON.stringify(actual.events);404  const want = JSON.stringify(expected);405  if (got !== want) throw new Error(`${label} transcript mismatch: expected ${want}, got ${got}`);406}407408function assertCompleted(round, { flow, manifests, label }) {409  const expected = expectedRound({ flow, manifests });410  for (const side of ["android", "browser"]) {411    const status = normalise(round[side].status);412    if (status !== "completed") throw new Error(`${label}: ${side} ended ${round[side].status} (${round[side].error ?? "no error"})`);413    const role = side === (round.initiatedBy === "android" ? "android" : "browser");414    assertTranscript(round[side], expected[role], `${label}: ${side}`);415  }416}417418/**419 * The §7.1 matrix. Each entry names the scenario it implements, configures both420 * clients with the same knob vocabulary, and states the observable result.421 */422const SCENARIOS = [423  {424    id: 1,425    name: "equal versions, both unlocked, both manifest-v5",426    initiator: "android",427    // Same parity on both sides means identical mtimes: equal data versions.428    android: { MTIME_PARITY: "0", EXPECT_STATUS: "COMPLETED" },429    browser: { MTIME_PARITY: "0", EXPECT_STATUS: "completed" },430    check(round, { completed, transcript }) {431      completed(round, { flow: false, manifests: true, label: "scenario 1" });432      // The equal-version fast path must not come back: the manifest exchange433      // runs in full even when neither side has anything to send.434      transcript(round, "scenario 1", (t) => t.events.includes("send:SYNC_MANIFEST"));435    },436  },437  {438    id: 2,439    name: "unequal data versions, both unlocked, both manifest-v5",440    initiator: "android",441    // Two entries so each side is newer for one of them: entries genuinely flow442    // in both directions rather than one side requesting nothing.443    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" },444    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" },445    check(round, { completed, expect }) {446      completed(round, { flow: true, manifests: true, label: "scenario 2" });447      expect(round.android.entriesSent > 0 && round.android.entriesReceived > 0, "scenario 2: entries must flow both ways");448      expect(round.browser.entriesSent > 0 && round.browser.entriesReceived > 0, "scenario 2: entries must flow both ways");449    },450  },451  {452    id: 3,453    name: "equal versions, one manifest-v5 and one manifest-v3",454    initiator: "android",455    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" },456    browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "completed" },457    check(round, { completed, expect }) {458      completed(round, { flow: false, manifests: false, label: "scenario 3" });459      expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 3: the manifest step must be skipped");460    },461  },462  {463    id: 4,464    name: "unequal data versions, one manifest-v5 and one manifest-v3",465    initiator: "browser",466    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "COMPLETED" },467    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" },468    check(round, { completed, expect }) {469      completed(round, { flow: true, manifests: false, label: "scenario 4" });470      expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 4: the manifest step must be skipped");471    },472  },473  {474    id: 9,475    name: "manifest negotiation: no peer sends SYNC_DEVICE_MANIFEST unilaterally",476    initiator: "android",477    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" },478    browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "completed" },479    check(round, { expect }) {480      // The capable side must not proceed on its own while the peer follows the481      // legacy sequence; that used to deadlock until the 30s session timeout.482      const events = allEvents(round);483      expect(!events.some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 9: a peer sent SYNC_DEVICE_MANIFEST while the other expected SYNC_DONE");484      expect(events.filter((event) => event.endsWith("SYNC_DONE")).length === 4, "scenario 9: both sides must still reach SYNC_DONE");485    },486  },487  {488    id: 13,489    name: "vault id mismatch stops cleanly",490    initiator: "browser",491    android: { MTIME_PARITY: "0", VAULT_ID: "vault-android", EXPECT_STATUS: "VAULT_MISMATCH", DRAIN: "1" },492    browser: { MTIME_PARITY: "0", VAULT_ID: "vault-browser", EXPECT_STATUS: "vault_mismatch", DRAIN: "1" },493    check(round, { expect, side }) {494      expect(normalise(round.android.status) === "vault_mismatch", `scenario 13: android ${round.android.status}`);495      expect(normalise(round.browser.status) === "vault_mismatch", `scenario 13: browser ${round.browser.status}`);496      // The initiator names the reason and stops; the responder stops without497      // reading another frame.498      side(round, "browser", "scenario 13: browser initiator", ["send:SYNC_HELLO", "recv:SYNC_HELLO", "send:SYNC_ERROR"]);499      side(round, "android", "scenario 13: android responder", ["recv:SYNC_HELLO", "send:SYNC_HELLO"]);500      expect(!allEvents(round).some((event) => event.endsWith("SYNC_MANIFEST")), "scenario 13: no data frames may follow a vault-id mismatch");501    },502  },503  {504    id: 5,505    name: "browser locked (spool active), android unlocked, unequal versions",506    initiator: "android",507    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" },508    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", LOCKED: "1", EXPECT_STATUS: "completed" },509    check(round, { completed, expect }) {510      completed(round, { flow: true, manifests: false, label: "scenario 5" });511      // The locked side stages rather than merging, and authors no manifest.512      expect(round.browser.staged > 0, "scenario 5: the locked side must stage what it receives");513      expect(round.browser.applied === 0, "scenario 5: a locked session must not merge into the live vault");514      expect(round.browser.entriesReceived === round.browser.staged, "scenario 5: staged must account for everything received");515      expect(round.android.applied > 0, "scenario 5: the unlocked side applies normally");516      expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 5: a locked session must not exchange a manifest");517    },518  },519  {520    id: 6,521    name: "android locked (listener running), browser unlocked",522    initiator: "browser",523    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "COMPLETED" },524    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", EXPECT_STATUS: "completed" },525    check(round, { completed, expect }) {526      completed(round, { flow: true, manifests: false, label: "scenario 6" });527      expect(round.android.staged > 0, "scenario 6: the locked side must stage what it receives");528      expect(round.android.applied === 0, "scenario 6: a locked session must not merge into the live vault");529      expect(round.browser.applied > 0, "scenario 6: the unlocked side applies normally");530      expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 6: a locked session must not exchange a manifest");531    },532  },533  {534    id: 8,535    name: "both sides locked",536    initiator: "android",537    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "COMPLETED" },538    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "completed" },539    check(round, { completed, expect }) {540      completed(round, { flow: true, manifests: false, label: "scenario 8" });541      for (const side of ["android", "browser"]) {542        expect(round[side].staged > 0, `scenario 8: ${side} must stage`);543        expect(round[side].applied === 0, `scenario 8: ${side} must not merge while locked`);544      }545      expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 8: neither locked side may exchange a manifest");546    },547  },548  {549    id: 10,550    name: "process death mid-session, next round completes cleanly",551    initiator: "android",552    // The initiator dies immediately after its own HELLO: the responder must553    // survive that as an error, and the next round must be byte-clean — no554    // stale frame from the dead round may leak into it.555    android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", STOP_AFTER: "SYNC_HELLO", EXPECT_STATUS: "COMPLETED" },556    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", EXPECT_STATUS: "errored", DRAIN: "1" },557    expectFailure: ["android"],558    check(round, { expect, side }) {559      expect(normalise(round.android.status) === "died", `scenario 10: android ended ${round.android.status}`);560      expect(normalise(round.browser.status) === "errored", `scenario 10: browser ended ${round.browser.status}`);561      side(round, "android", "scenario 10: the dead initiator", ["send:SYNC_HELLO"]);562    },563    async afterwards({ completed }, { runRound: run }) {564      const restarted = await run({565        initiator: "android",566        label: "scenario 10 (restarted)",567        android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" },568        browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" },569      });570      restarted.initiatedBy = "android";571      completed(restarted, { flow: true, manifests: true, label: "scenario 10 (restarted)" });572    },573  },574  {575    id: 15,576    name: "10,000-entry vault with padded frames",577    initiator: "android",578    android: { MTIME_PARITY: "0", ENTRY_COUNT: "10000", EXPECT_STATUS: "COMPLETED" },579    browser: { MTIME_PARITY: "1", ENTRY_COUNT: "10000", EXPECT_STATUS: "completed" },580    timeoutMs: 300_000,581    check(round, { completed, expect }) {582      completed(round, { flow: true, manifests: true, label: "scenario 15" });583      // Opposite parities, so each side is newer for exactly half the vault:584      // all 10,000 entries cross, 5,000 in each direction.585      for (const side of ["android", "browser"]) {586        const sent = round[side].entriesSent;587        const received = round[side].entriesReceived;588        expect(sent === 5000, `scenario 15: ${side} sent ${sent}`);589        expect(received === 5000, `scenario 15: ${side} received ${received}`);590      }591    },592  },593];594595const helpers = {596  expect(condition, message) {597    if (!condition) throw new Error(message);598  },599  completed(round, options) {600    if (options.flow === undefined) throw new Error("completed() needs an explicit flow expectation");601    const expected = expectedRound(options);602    for (const [side, role] of [["android", round.initiatedBy === "android" ? "initiator" : "responder"], ["browser", round.initiatedBy === "browser" ? "initiator" : "responder"]]) {603      const status = normalise(round[side].status);604      if (status !== "completed") throw new Error(`${options.label}: ${side} ended ${round[side].status} (${round[side].error ?? "no error"})`);605      assertTranscript(round[side], expected[role], `${options.label}: ${side} ${role}`);606    }607  },608  transcript(round, label, predicate) {609    for (const side of ["android", "browser"]) {610      if (!predicate(round[side])) throw new Error(`${label}: unexpected transcript for ${side}: ${JSON.stringify(round[side].events)}`);611    }612  },613  side(round, side, label, expected) {614    const got = JSON.stringify(round[side].events);615    const want = JSON.stringify(expected);616    if (got !== want) throw new Error(`${label}: expected ${want}, got ${got}`);617  },618};619620// PEACH_SYNC_HARNESS_ONLY=13,15 runs a subset. Debugging the matrix one full621// pass at a time is otherwise a four-minute loop.622const only = (process.env.PEACH_SYNC_HARNESS_ONLY ?? "").split(",").filter(Boolean).map(Number);623const selected = only.length > 0 ? SCENARIOS.filter((scenario) => only.includes(scenario.id)) : SCENARIOS;624625const results = [];626for (const scenario of selected) {627  const started = Date.now();628  const round = await runRound({ ...scenario, label: `scenario ${scenario.id}` });629  round.initiatedBy = scenario.initiator;630  scenario.check(round, helpers);631  if (scenario.afterwards) await scenario.afterwards(helpers, { runRound });632  const elapsed = Date.now() - started;633  results.push({ ...scenario, elapsed });634  console.log(`  ✓ ${String(scenario.id).padStart(2)} ${scenario.name} (${(elapsed / 1000).toFixed(1)}s)`);635}636637console.log("");638console.log(`Cross-client sync process conformance passed: ${results.length} of 15 §7.1 scenarios.`);639for (const result of results) {640  const timing = result.id === 15 ? ` — 10,000-entry round ${(result.elapsed / 1000).toFixed(1)}s wall clock` : "";641  console.log(`  #${result.id} ${result.name}${timing}`);642}643console.log("");644console.log("Not yet covered by this gate (see the campaign §7.1 table):");645console.log("  #7  android cold-locked — evidence class is unit + real-device, not a process transcript");646console.log("  #11 revocation then re-vouch, #12 excessive vouch depth, #14 freshness window —");647console.log("      these need a correctly signed adversarial manifest on the wire; the");648console.log("      signed-manifest fixture is not built yet");649console.log("  the adoption-v2 group, and the eight §16.3 manifest-v5 adversarial cases");