1import { existsSync, readFileSync, mkdtempSync } from "node:fs";2import { dirname, resolve } from "node:path";3import { fileURLToPath } from "node:url";4import { execFileSync, spawn } from "node:child_process";5import { createPrivateKey, createPublicKey, sign as signEd25519Node } from "node:crypto";6import { tmpdir } from "node:os";7import { generateKeyPairFromSeed } from "@stablelib/x25519";89const browserRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");10const androidRoot = resolve(browserRoot, "../peach-android");11const localJavaHome = "/opt/homebrew/opt/openjdk@17";12const javaHome = process.env.JAVA_HOME ?? (existsSync(localJavaHome) ? localJavaHome : undefined);13const browserCoordinator = readFileSync(resolve(browserRoot, "src/background/sync-coordinator.ts"), "utf8");14const androidCoordinator = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt"), "utf8");15const browserSyncTypes = readFileSync(resolve(browserRoot, "src/shared/sync-types.ts"), "utf8");16const androidManifest = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt"), "utf8");17const androidSyncMessage = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncMessage.kt"), "utf8");18const browserPairedDevices = readFileSync(resolve(browserRoot, "src/background/paired-devices.ts"), "utf8");19const androidPairedDevices = readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt"), "utf8");20const browserAdoption = readFileSync(resolve(browserRoot, "src/shared/adoption-protocol.ts"), "utf8");21const androidAdoption = [22 "AdoptionProtocol.kt",23 "VaultAdopt.kt",24].map((file) => readFileSync(resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync", file), "utf8")).join("\n");25262728if (browserCoordinator.includes('helloIn.version === localVersion')) throw new Error("Browser still contains the forbidden equal-version fast path");29if (!browserCoordinator.includes("helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION")) throw new Error("Browser does not negotiate device manifests from SYNC_HELLO");30if (!androidCoordinator.includes("val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)")) throw new Error("Android does not negotiate device manifests from SYNC_HELLO");31if (!androidCoordinator.includes("peerVersion == DEVICE_MANIFEST_VERSION")) throw new Error("Android does not require an exact device-manifest version match");3233const numericConstant = (source, declaration) => {34 const match = source.match(new RegExp(`${declaration}\\s*=\\s*(0x[0-9a-f]+|\\d+)`));35 if (!match) throw new Error(`Missing ${declaration} declaration`);36 return Number(match[1]);37};38if (numericConstant(browserSyncTypes, "DEVICE_MANIFEST_VERSION") !== numericConstant(androidManifest, "DEVICE_MANIFEST_VERSION")) {39 throw new Error("Browser and Android device-manifest versions differ");40}41if (numericConstant(browserSyncTypes, "DEVICE_REVOCATION_RECORD_VERSION") !== numericConstant(androidSyncMessage, "DEVICE_REVOCATION_RECORD_VERSION")) {42 throw new Error("Browser and Android revocation record versions differ");43}444546for (const field of ["recordVersion", "deviceId", "longTermPublicKeyHex", "revokedByDeviceId"]) {47 if (!browserSyncTypes.includes(field) || !androidSyncMessage.includes(field)) {48 throw new Error(`A client lacks revocation field ${field}`);49 }50}51if (browserSyncTypes.includes("issuerSigningPublicKeyHex") || androidSyncMessage.includes("issuerSigningKeyHex")) {52 throw new Error("A client still carries an issuer key inside the revocation record");53}54const syncProtocolVersion = (source, label) => {55 const match = source.match(/SYNC_PROTOCOL_VERSION\s*=\s*(\d+)/);56 if (!match) throw new Error(`Missing SYNC_PROTOCOL_VERSION declaration in ${label}`);57 return Number(match[1]);58};59const browserProtocolVersion = syncProtocolVersion(browserSyncTypes, "Browser");60const androidProtocolVersion = syncProtocolVersion(androidCoordinator, "Android");61if (browserProtocolVersion !== androidProtocolVersion) {62 throw new Error(`Browser and Android Sync protocol versions differ (${browserProtocolVersion} vs ${androidProtocolVersion})`);63}64if (numericConstant(browserAdoption, "ADOPTION_VERSION") !== numericConstant(androidAdoption, "ADOPTION_VERSION")) {65 throw new Error("Browser and Android adoption transcript versions differ");66}67for (const frame of [68 "ADOPT_REQUEST_V2",69 "ADOPT_CONFIRM_REQUIRED",70 "ADOPT_CONFIRM",71 "ADOPT_PREPARE",72 "ADOPT_READY",73 "ADOPT_GRANT",74 "ADOPT_APPLIED",75 "ADOPT_COMPLETE",76 "ADOPT_REJECT",77]) {78 if (!browserAdoption.includes(frame) || !androidAdoption.includes(frame)) {79 throw new Error(`A client lacks adoption v2 frame ${frame}`);80 }81}8283if (browserCoordinator.includes("protocolVersion ?? 1) < SYNC_PROTOCOL_VERSION") ||84 androidCoordinator.includes("< SYNC_PROTOCOL_VERSION")) {85 throw new Error("A client still accepts a peer protocol version below its own");86}87if (!browserSyncTypes.includes("signatureHex") || !androidSyncMessage.includes("signatureHex")) {88 throw new Error("A client lacks the revocation signature field");89}90if (!browserPairedDevices.includes("MAX_REVOCATIONS = 500") || !androidPairedDevices.includes("MAX_REVOCATION_RECORDS = 500")) throw new Error("Browser and Android revocation ledger caps differ");91if (!browserPairedDevices.includes("enqueueDeviceMutation") || !androidPairedDevices.includes("RegistryMutationGate")) throw new Error("A paired-device registry lacks whole-list mutation serialization");92if (!browserPairedDevices.includes("function capRevocations") || !androidPairedDevices.includes("fun capRevocationRecords")) throw new Error("A client lacks largest-issuer-first revocation retention");93const browserEndpoint = String.raw`94import { createInterface } from "node:readline";95import { createServer, createConnection } from "node:net";96const root = process.env.PEACH_BROWSER_ROOT;97const coordinator = await import(root + "/src/background/sync-coordinator.ts");98const syncTypes = await import(root + "/src/shared/sync-types.ts");99const padding = await import(root + "/src/shared/sync-padding.ts");100const mode = process.env.PEACH_SYNC_HARNESS_MODE;101const portFile = process.env.PEACH_SYNC_HARNESS_PORT_FILE;102const transcriptFile = process.env.PEACH_SYNC_HARNESS_TRANSCRIPT_FILE;103104// Scenario shape arrives entirely from the parent; this endpoint does not know105// what a scenario is. Both clients read the same knobs, so the matrix lives in106// one readable place rather than half here and half in the harness.107const knobs = {108 parity: process.env.PEACH_SYNC_HARNESS_MTIME_PARITY ?? "",109 localVersion: Number(process.env.PEACH_SYNC_HARNESS_LOCAL_VERSION ?? "7"),110 vaultId: process.env.PEACH_SYNC_HARNESS_VAULT_ID ?? "cross-client-vault",111 manifestSupport: process.env.PEACH_SYNC_HARNESS_MANIFEST_VERSION !== "3",112 entryCount: Number(process.env.PEACH_SYNC_HARNESS_ENTRY_COUNT ?? "1"),113 expectStatus: process.env.PEACH_SYNC_HARNESS_EXPECT_STATUS ?? "completed",114 drain: process.env.PEACH_SYNC_HARNESS_DRAIN === "1",115 // A locked session may hold the transport key and stage ciphertext, but it116 // never authors or applies a device manifest and never merges entries into117 // the live vault (PB-26-112 C).118 locked: process.env.PEACH_SYNC_HARNESS_LOCKED === "1",119 stopAfter: process.env.PEACH_SYNC_HARNESS_STOP_AFTER ?? "",120 // A manifest the harness signed: the wire shape IS the scenario, and one121 // signer (the harness, in Node) keeps both clients verifying the same bytes122 // rather than each signing its own.123 signedManifestJson: process.env.PEACH_SYNC_HARNESS_SIGNED_MANIFEST ?? "",124 trustedPubkeyHex: process.env.PEACH_SYNC_HARNESS_TRUSTED_PUBKEY ?? "",125};126127const id = mode === "initiator" ? "browser" : "android";128const socket = await new Promise((resolve, reject) => {129 if (mode === "initiator") {130 const server = createServer((client) => {131 // Stop listening as soon as the one conformance peer connects. Leaving132 // this listener open keeps Bun alive after the coordinator completes.133 server.close();134 resolve(client);135 });136 server.once("error", reject);137 server.listen(0, "127.0.0.1", () => require("node:fs").writeFileSync(portFile, String(server.address().port)));138 } else {139 const client = createConnection({ host: "127.0.0.1", port: Number(process.env.PEACH_SYNC_HARNESS_PORT) });140 client.once("connect", () => resolve(client));141 client.once("error", reject);142 }143});144socket.setEncoding("utf8");145const lines = createInterface({ input: socket });146const iterator = lines[Symbol.asyncIterator]();147const events = [];148const typeOf = (message) => message.type;149let outcome = null;150let counted = { sent: 0, received: 0, applied: 0, staged: 0 };151let manifestVerdict = null;152const fs = require("node:fs");153const writeTranscript = (status) => fs.writeFileSync(transcriptFile, JSON.stringify({154 status,155 error: outcome?.error,156 entriesSent: counted.sent,157 entriesReceived: counted.received,158 applied: counted.applied,159 staged: counted.staged,160 manifestAccepted: manifestVerdict === null ? null : manifestVerdict === "accepted",161 events,162}));163const session = {164 remoteStaticPublicKey: new Uint8Array(32).fill(7),165 send(bytes) {166 const message = JSON.parse(new TextDecoder().decode(bytes));167 events.push("send:" + typeOf(message));168 // Simulated process death: the transcript is on disk before the process169 // goes, so the parent can still say what the peer had seen.170 if (knobs.stopAfter !== "" && knobs.stopAfter === typeOf(message)) {171 writeTranscript("DIED");172 process.exit(7);173 }174 socket.write(Buffer.from(padding.padSyncFrame(bytes)).toString("base64") + "\n");175 },176 async receive() {177 const next = await iterator.next();178 if (next.done) throw new Error("cross-client peer closed");179 const payload = padding.unpadSyncFrame(Uint8Array.from(Buffer.from(next.value.trim(), "base64")));180 events.push("recv:" + typeOf(JSON.parse(new TextDecoder().decode(payload))));181 return payload;182 },183 async close() { socket.end(); },184};185186// Entries are shared ids so a diff can actually flow. Parity decides which side187// is newer for a given index: the same parity on both sides means equal data188// versions (nothing to send), opposite parities means each side is newer for189// half the vault (entries flow both ways).190const manifestApi = await import(root + "/src/shared/device-manifest.ts");191const hexBytes = (hex) => Uint8Array.from(hex.match(/../g).map((pair) => parseInt(pair, 16)));192const crafted = knobs.signedManifestJson === "" ? null : JSON.parse(knobs.signedManifestJson);193194const ids = Array.from({ length: knobs.entryCount }, (_, index) => "shared-entry-" + index);195const mtimeFor = (index) =>196 knobs.parity === "" ? 10 : (index % 2 === Number(knobs.parity) ? 15 : 10);197const entries = Object.fromEntries(ids.map((entryId, index) => [entryId, mtimeFor(index)]));198const blobs = Object.fromEntries(ids.map((entryId, index) => [entryId, id + "-frame-" + (index % 8)]));199200const adapter = {201 async computeVaultId() { return knobs.vaultId; },202 async getOurDeviceId() { return id; },203 async getLocalVersion() { return knobs.localVersion; },204 async buildEntryManifest() { return { ...entries }; },205 async buildTombstoneManifest() { return {}; },206 async getEncryptedEntries(requested) {207 return Object.fromEntries(requested.filter((value) => value in blobs).map((value) => [value, blobs[value]]));208 },209 async applyEntries(entries) {210 if (knobs.locked) {211 counted.staged += Object.keys(entries).length;212 return;213 }214 counted.applied += Object.keys(entries).length;215 },216 async applyTombstones() {},217 // The coordinator reads capability off the adapter's shape, not a flag: a218 // manifest-v3 peer is one that does not expose the exchange at all. Modelling219 // it any other way would let this fixture advertise a version it cannot speak220 // — the same class of mistake the scenario is here to catch.221 ...(knobs.manifestSupport && !knobs.locked222 ? {223 async buildOutgoingDeviceManifest() {224 return crafted ?? { body: { manifestVersion: syncTypes.DEVICE_MANIFEST_VERSION, issuerDeviceId: id, issuedAt: Date.now(), devices: [], revoked: [] }, signatureHex: "11".repeat(64) };225 },226 async applyIncomingDeviceManifest(incoming) {227 if (!incoming) return;228 if (knobs.trustedPubkeyHex === "") { manifestVerdict = "not-checked"; return; }229 try {230 await manifestApi.verifyDeviceManifest(incoming, hexBytes(knobs.trustedPubkeyHex), {});231 manifestVerdict = "accepted";232 } catch {233 manifestVerdict = "rejected";234 }235 },236 }237 : {}),238};239240outcome = mode === "initiator"241 ? await coordinator.runSyncInitiator(session, adapter)242 : await coordinator.runSyncResponder(session, adapter);243counted.sent = outcome.entriesSent ?? 0;244counted.received = outcome.entriesReceived ?? 0;245246// A clean stop is only deterministic if our socket is still up when the peer247// writes its last frame, so hold it open until the peer goes away rather than248// exiting the instant the round ends.249if (knobs.drain) {250 await Promise.race([251 (async () => { for await (const _ of lines) { /* drain */ } })(),252 new Promise((resolve) => setTimeout(resolve, 2000)),253 ]);254}255256writeTranscript(outcome.status);257socket.end();258if (outcome.status !== knobs.expectStatus) {259 console.error("expected " + knobs.expectStatus + ", got " + outcome.status);260 process.exit(1);261}262`;263264const browserAdoptionEndpoint = String.raw`265import { createInterface } from "node:readline";266import { createServer, createConnection } from "node:net";267import { generateKeyPairFromSeed } from "@stablelib/x25519";268const root = process.env.PEACH_BROWSER_ROOT;269const adoption = await import(root + "/src/shared/adoption-protocol.ts");270const noiseApi = await import(root + "/src/shared/noise-js.ts");271const syncIdentity = await import(root + "/src/shared/sync-identity.ts");272const role = process.env.PEACH_ADOPTION_HARNESS_ROLE;273const token = "AAECAwQFBgcICQoLDA0ODw";274const portFile = process.env.PEACH_SYNC_HARNESS_PORT_FILE;275const transcriptFile = process.env.PEACH_SYNC_HARNESS_TRANSCRIPT_FILE;276const socket = await new Promise((resolve, reject) => {277 if (role === "source") {278 const server = createServer((client) => { server.close(); resolve(client); });279 server.once("error", reject);280 server.listen(0, "127.0.0.1", () => require("node:fs").writeFileSync(portFile, String(server.address().port)));281 } else {282 const client = createConnection({ host: "127.0.0.1", port: Number(process.env.PEACH_SYNC_HARNESS_PORT) });283 client.once("connect", () => resolve(client));284 client.once("error", reject);285 }286});287socket.setEncoding("utf8");288const lines = createInterface({ input: socket });289const iterator = lines[Symbol.asyncIterator]();290const sendRaw = async (bytes) => socket.write(Buffer.from(bytes).toString("base64") + "\n");291const receiveRaw = async () => {292 const next = await iterator.next();293 if (next.done) throw new Error("cross-client peer closed");294 return Uint8Array.from(Buffer.from(next.value.trim(), "base64"));295};296const events = [];297const instrument = (session) => ({298 async send(bytes) {299 events.push("send:" + JSON.parse(new TextDecoder().decode(bytes)).type);300 await sendRaw(session.encrypt(bytes));301 },302 async receive() {303 const bytes = session.decrypt(await receiveRaw());304 events.push("recv:" + JSON.parse(new TextDecoder().decode(bytes)).type);305 return bytes;306 },307});308const identity = {309 deviceId: "browser-id", deviceName: "browser", deviceType: "extension",310 longTermPublicKeyHex: "ab".repeat(32), signingPublicKeyHex: "cd".repeat(32),311};312const rootKeyHex = Array.from({ length: 32 }, (_, i) => (i + 1).toString(16).padStart(2, "0")).join("");313let pending = false;314let rootAfterPending = false;315let promoted = false;316let collisionChecks = 0;317let rotations = 0;318let result;319if (role === "source") {320 const staticKeys = generateKeyPairFromSeed(new Uint8Array(32).fill(49));321 const hello = JSON.parse(new TextDecoder().decode(await receiveRaw()));322 if (hello.type !== "PEACH_HELLO" || hello.token !== token) throw new Error("invalid relay preamble");323 await sendRaw(new TextEncoder().encode(JSON.stringify({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: Buffer.from(staticKeys.publicKey).toString("hex") })));324 const noise = await noiseApi.noiseNKResponder(staticKeys.secretKey, staticKeys.publicKey, sendRaw, receiveRaw);325 const channel = instrument(noise);326 const request = JSON.parse(new TextDecoder().decode(await channel.receive()));327 result = await adoption.grantAdoptionV2(channel, request, {328 sourceIdentity: identity,329 async rootKeyHex() { rootAfterPending = pending; return rootKeyHex; },330 async vaultId() { return await syncIdentity.computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))); },331 async license() { return "PPK-cross-client"; },332 async applyLicense() {}, async onConsent() { return true; },333 async persistPendingIdentity() { pending = true; return true; },334 async completePendingIdentity() { promoted = true; },335 async hasDeviceIdentityCollision() { return collisionChecks++ === 0; }, async rotateSourceIdentity() { return identity; },336 async upsertPairedDevice() {}, snapshotCapable() { return false; },337 });338 noise.close();339} else {340 await sendRaw(new TextEncoder().encode(JSON.stringify({ type: "PEACH_HELLO", token })));341 const ack = JSON.parse(new TextDecoder().decode(await receiveRaw()));342 const staticKey = Uint8Array.from(Buffer.from(ack.ephemeralPublicKeyHex, "hex"));343 const noise = await noiseApi.noiseNKInitiator(staticKey, sendRaw, receiveRaw);344 const channel = instrument(noise);345 result = await adoption.requestAdoptionV2(channel, {346 deviceName: "browser", async localIdentity() { return identity; },347 async rotateLocalIdentity() { rotations += 1; return { ...identity, deviceId: "browser-rotated" }; },348 async license() { return "PPK-cross-client"; }, async confirmSas() { return true; }, async validateSource() {},349 async hasSourceIdentityCollision() { return false; },350 async createVaultFromRootKey(value) { return { vaultId: await syncIdentity.computeVaultId(Uint8Array.from(Buffer.from(value, "hex"))) }; },351 async applyLicense() {}, async upsertPairedDeviceAndClearRevocation() {}, onError(error) { throw new Error(error); },352 });353 noise.close();354}355require("node:fs").writeFileSync(transcriptFile, JSON.stringify({356 status: result ? "completed" : "rejected", pendingBeforeRoot: rootAfterPending,357 promotedAfterApplied: promoted, rotations, events,358}));359socket.end();360if (!result) process.exit(1);361`;362363364365366367368369370const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");371const trustedKey = createPrivateKey({372 key: Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from("11".repeat(32), "hex")]),373 format: "der",374 type: "pkcs8",375});376const TRUSTED_PUBKEY_HEX = createPublicKey(trustedKey)377 .export({ format: "der", type: "spki" })378 .subarray(-32)379 .toString("hex");380const UNTRUSTED_PUBKEY_HEX = createPublicKey(createPrivateKey({381 key: Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from("22".repeat(32), "hex")]),382 format: "der",383 type: "pkcs8",384})).export({ format: "der", type: "spki" }).subarray(-32).toString("hex");385386function canonicalBody(body) {387 return execFileSync("bun", [388 "--eval",389 `import { canonicalise } from ${JSON.stringify(resolve(browserRoot, "src/shared/device-manifest.ts"))}; process.stdout.write(canonicalise(JSON.parse(process.env.PEACH_MANIFEST_SPEC)));`,390 ], { env: { ...process.env, PEACH_MANIFEST_SPEC: JSON.stringify(body) } }).toString();391}392393394function signManifest({ issuerDeviceId = "crafted-issuer", nowOffsetMs = 0, devices = [], revoked = [], seed = "11".repeat(32) } = {}) {395 const body = {396 397 398 399 manifestVersion: numericConstant(browserSyncTypes, "DEVICE_MANIFEST_VERSION"),400 issuerDeviceId,401 issuedAt: Date.now() + nowOffsetMs,402 devices,403 revoked,404 };405 const key = createPrivateKey({406 key: Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from(seed, "hex")]),407 format: "der",408 type: "pkcs8",409 });410 const signature = signEd25519Node(null, Buffer.from(canonicalBody(body), "utf8"), key);411 return JSON.stringify({ body, signatureHex: signature.toString("hex") });412}413414const deviceEntry = (overrides = {}) => ({415 deviceId: "crafted-device",416 deviceName: "Crafted",417 deviceType: "android",418 longTermPublicKeyHex: "aa".repeat(32),419 signingPublicKeyHex: "bb".repeat(32),420 pairedAt: 1_700_000_000_000,421 vouchDepth: 0,422 ...overrides,423});424425const MAX_DIAGNOSTIC_BYTES = 12_000;426427function spawnProcess(command, args, env) {428 const child = spawn(command, args, { cwd: env.PEACH_PROCESS_ROOT ?? env.PEACH_BROWSER_ROOT ?? browserRoot, env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] });429 const output = { stdout: "", stderr: "" };430 for (const [name, stream] of [["stdout", child.stdout], ["stderr", child.stderr]]) {431 stream.setEncoding("utf8");432 stream.on("data", (chunk) => {433 output[name] = (output[name] + chunk).slice(-MAX_DIAGNOSTIC_BYTES);434 });435 }436 child.diagnostics = output;437 return child;438}439440function waitForExit(child) {441 return new Promise((resolveExit, reject) => {442 child.once("error", reject);443 child.once("exit", (code, signal) => resolveExit({ code, signal, diagnostics: child.diagnostics }));444 });445}446447function terminate(child) {448 if (!child || child.exitCode !== null || child.signalCode !== null) return;449 child.kill("SIGTERM");450 setTimeout(() => {451 if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");452 }, 1_000).unref();453}454455function formatResult(result) {456 const output = result.diagnostics;457 return `code=${result.code ?? "none"} signal=${result.signal ?? "none"}; stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`;458}459460async function waitForFile(path, child) {461 const deadline = Date.now() + 30_000;462 while (!existsSync(path)) {463 if (child.exitCode !== null) throw new Error(`endpoint exited before publishing ${path} (${child.exitCode})`);464 if (Date.now() > deadline) throw new Error(`timed out waiting for ${path}`);465 await new Promise((resolve) => setTimeout(resolve, 50));466 }467 return readFileSync(path, "utf8").trim();468}469470function readTranscript(path) {471 return JSON.parse(readFileSync(path, "utf8"));472}473474const androidTask = ["--no-daemon", "--no-configuration-cache", "--rerun-tasks", "testDebugUnitTest", "--tests", "com.peachpasswords.sync.CrossClientSyncProcessTest.exchange_production_frames_with_browser_process"];475const gradleProperty = (name, value) => `-D${name.toLowerCase().replaceAll("_", ".")}=${value}`;476const KNOB_PREFIX = "PEACH_SYNC_HARNESS_";477const knobEnv = (knobs) =>478 Object.fromEntries(Object.entries(knobs).map(([name, value]) => [KNOB_PREFIX + name, String(value)]));479480481482483484485async function runRound({ initiator, android = {}, browser = {}, timeoutMs = 180_000, label = "", expectFailure = [], manifest = null }) {486 487 488 489 490 491 const manifestKnobs = manifest492 ? { SIGNED_MANIFEST: typeof manifest === "function" ? manifest() : manifest, TRUSTED_PUBKEY: TRUSTED_PUBKEY_HEX }493 : {};494 const dir = mkdtempSync(resolve(tmpdir(), "peach-cross-sync-"));495 const androidTranscript = resolve(dir, "android.json");496 const browserTranscript = resolve(dir, "browser.json");497 const androidPortFile = resolve(dir, "android.port");498 const browserPortFile = resolve(dir, "browser.port");499 const common = { PEACH_BROWSER_ROOT: browserRoot };500 const launchBrowser = (mode, port, transcript, portFile) => spawnProcess("bun", ["--eval", browserEndpoint], {501 ...common,502 ...knobEnv({ ...manifestKnobs, ...browser }),503 PEACH_SYNC_HARNESS_MODE: mode,504 PEACH_SYNC_HARNESS_PORT: port ?? "",505 PEACH_SYNC_HARNESS_PORT_FILE: portFile ?? "",506 PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: transcript,507 });508 const launchAndroid = (mode, port) => spawnProcess(resolve(androidRoot, "gradlew"), [509 gradleProperty("PEACH_SYNC_HARNESS_MODE", mode),510 ...androidTask,511 ], {512 ...common,513 ...knobEnv({ ...manifestKnobs, ...android }),514 PEACH_PROCESS_ROOT: androidRoot,515 PEACH_SYNC_HARNESS_MODE: mode,516 PEACH_SYNC_HARNESS_PORT: port ?? "",517 PEACH_SYNC_HARNESS_PORT_FILE: androidPortFile,518 PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: androidTranscript,519 ...(javaHome ? { JAVA_HOME: javaHome } : {}),520 });521522 let first;523 let second;524 let timeoutId;525 try {526 if (initiator === "android") {527 first = launchAndroid("initiator");528 const port = await waitForFile(androidPortFile, first);529 second = launchBrowser("responder", port, browserTranscript);530 } else {531 first = launchBrowser("initiator", undefined, browserTranscript, browserPortFile);532 const port = await waitForFile(browserPortFile, first);533 second = launchAndroid("responder", port);534 }535 const timeout = new Promise((_, reject) => {536 timeoutId = setTimeout(537 () => reject(new Error(`${label} timed out after ${Math.round(timeoutMs / 1000)}s`)),538 timeoutMs,539 );540 });541 const results = await Promise.race([542 Promise.all([waitForExit(first), waitForExit(second)]),543 timeout,544 ]);545 const [firstResult, secondResult] = results;546 547 548 const initiatorSide = initiator;549 const responderSide = initiator === "android" ? "browser" : "android";550 const tolerate = (side, result) => expectFailure.includes(side) || result.code === 0;551 if (!tolerate(initiatorSide, firstResult) || !tolerate(responderSide, secondResult)) {552 throw new Error(`${label} failed: initiator(${initiatorSide}) ${formatResult(firstResult)}; responder(${responderSide}) ${formatResult(secondResult)}`);553 }554 return { android: readTranscript(androidTranscript), browser: readTranscript(browserTranscript) };555 } catch (error) {556 const details = [first, second].filter(Boolean).map((child) => child.diagnostics)557 .map((output) => `stdout=${JSON.stringify(output.stdout)} stderr=${JSON.stringify(output.stderr)}`).join("; ");558 throw new Error(`${error.message}; child diagnostics: ${details}`, { cause: error });559 } finally {560 clearTimeout(timeoutId);561 terminate(first);562 terminate(second);563 }564}565566async function runAdoptionRound(source) {567 const dir = mkdtempSync(resolve(tmpdir(), "peach-cross-adoption-"));568 const androidTranscript = resolve(dir, "android.json");569 const browserTranscript = resolve(dir, "browser.json");570 const portFile = resolve(dir, "endpoint.port");571 const browserStatic = generateKeyPairFromSeed(new Uint8Array(32).fill(49)).publicKey;572 const launchBrowser = (role, port) => spawnProcess("bun", ["--eval", browserAdoptionEndpoint], {573 PEACH_BROWSER_ROOT: browserRoot,574 PEACH_ADOPTION_HARNESS_ROLE: role,575 PEACH_SYNC_HARNESS_PORT: port ?? "",576 PEACH_SYNC_HARNESS_PORT_FILE: portFile,577 PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: browserTranscript,578 });579 const launchAndroid = (role, port) => spawnProcess(resolve(androidRoot, "gradlew"), [580 "--no-daemon", "--no-configuration-cache", "--rerun-tasks", "testDebugUnitTest",581 "--tests", "com.peachpasswords.sync.CrossClientSyncProcessTest.exchange_adoption_v2_with_browser_process",582 ], {583 PEACH_PROCESS_ROOT: androidRoot,584 PEACH_ADOPTION_HARNESS_ROLE: role,585 PEACH_SYNC_HARNESS_PORT: port ?? "",586 PEACH_SYNC_HARNESS_PORT_FILE: portFile,587 PEACH_SYNC_HARNESS_TRANSCRIPT_FILE: androidTranscript,588 PEACH_ADOPTION_HARNESS_STATIC: Buffer.from(browserStatic).toString("hex"),589 ...(javaHome ? { JAVA_HOME: javaHome } : {}),590 });591 let first;592 let second;593 try {594 if (source === "android") {595 first = launchAndroid("source");596 second = launchBrowser("receiver", await waitForFile(portFile, first));597 } else {598 first = launchBrowser("source");599 second = launchAndroid("receiver", await waitForFile(portFile, first));600 }601 const results = await Promise.all([waitForExit(first), waitForExit(second)]);602 if (results.some((result) => result.code !== 0)) {603 throw new Error(`${source} source failed: ${results.map(formatResult).join("; ")}`);604 }605 return { android: readTranscript(androidTranscript), browser: readTranscript(browserTranscript) };606 } finally {607 terminate(first);608 terminate(second);609 }610}611612613function expectedRound({ flow, manifests }) {614 return {615 initiator: [616 "send:SYNC_HELLO", "recv:SYNC_HELLO",617 "send:SYNC_MANIFEST", "recv:SYNC_MANIFEST",618 "send:SYNC_REQUEST", "recv:SYNC_REQUEST",619 "send:SYNC_ENTRIES", ...(flow ? ["recv:SYNC_ENTRIES"] : []),620 "send:SYNC_TOMBSTONES", "recv:SYNC_TOMBSTONES",621 ...(manifests ? ["send:SYNC_DEVICE_MANIFEST", "recv:SYNC_DEVICE_MANIFEST"] : []),622 "send:SYNC_DONE", "recv:SYNC_DONE",623 ],624 responder: [625 "recv:SYNC_HELLO", "send:SYNC_HELLO",626 "recv:SYNC_MANIFEST", "send:SYNC_MANIFEST",627 "recv:SYNC_REQUEST", "send:SYNC_REQUEST",628 "recv:SYNC_ENTRIES", ...(flow ? ["send:SYNC_ENTRIES"] : []),629 "recv:SYNC_TOMBSTONES", "send:SYNC_TOMBSTONES",630 ...(manifests ? ["recv:SYNC_DEVICE_MANIFEST", "send:SYNC_DEVICE_MANIFEST"] : []),631 "recv:SYNC_DONE", "send:SYNC_DONE",632 ],633 };634}635636const normalise = (status) => String(status).toLowerCase();637const allEvents = (round) => [...round.android.events, ...round.browser.events];638639function assertTranscript(actual, expected, label) {640 const got = JSON.stringify(actual.events);641 const want = JSON.stringify(expected);642 if (got !== want) throw new Error(`${label} transcript mismatch: expected ${want}, got ${got}`);643}644645function assertCompleted(round, { flow, manifests, label }) {646 const expected = expectedRound({ flow, manifests });647 for (const side of ["android", "browser"]) {648 const status = normalise(round[side].status);649 if (status !== "completed") throw new Error(`${label}: ${side} ended ${round[side].status} (${round[side].error ?? "no error"})`);650 const role = side === (round.initiatedBy === "android" ? "android" : "browser");651 assertTranscript(round[side], expected[role], `${label}: ${side}`);652 }653}654655656657658659const SCENARIOS = [660 {661 id: 1,662 name: "equal versions, both unlocked, both manifest-v5",663 initiator: "android",664 665 android: { MTIME_PARITY: "0", EXPECT_STATUS: "COMPLETED" },666 browser: { MTIME_PARITY: "0", EXPECT_STATUS: "completed" },667 check(round, { completed, transcript }) {668 completed(round, { flow: false, manifests: true, label: "scenario 1" });669 670 671 transcript(round, "scenario 1", (t) => t.events.includes("send:SYNC_MANIFEST"));672 },673 },674 {675 id: 2,676 name: "unequal data versions, both unlocked, both manifest-v5",677 initiator: "android",678 679 680 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" },681 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" },682 check(round, { completed, expect }) {683 completed(round, { flow: true, manifests: true, label: "scenario 2" });684 expect(round.android.entriesSent > 0 && round.android.entriesReceived > 0, "scenario 2: entries must flow both ways");685 expect(round.browser.entriesSent > 0 && round.browser.entriesReceived > 0, "scenario 2: entries must flow both ways");686 },687 },688 {689 id: 3,690 name: "equal versions, one manifest-v5 and one manifest-v3",691 initiator: "android",692 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" },693 browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "completed" },694 check(round, { completed, expect }) {695 completed(round, { flow: false, manifests: false, label: "scenario 3" });696 expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 3: the manifest step must be skipped");697 },698 },699 {700 id: 4,701 name: "unequal data versions, one manifest-v5 and one manifest-v3",702 initiator: "browser",703 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "COMPLETED" },704 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" },705 check(round, { completed, expect }) {706 completed(round, { flow: true, manifests: false, label: "scenario 4" });707 expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 4: the manifest step must be skipped");708 },709 },710 {711 id: 9,712 name: "manifest negotiation: no peer sends SYNC_DEVICE_MANIFEST unilaterally",713 initiator: "android",714 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" },715 browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", MANIFEST_VERSION: "3", EXPECT_STATUS: "completed" },716 check(round, { expect }) {717 718 719 const events = allEvents(round);720 expect(!events.some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 9: a peer sent SYNC_DEVICE_MANIFEST while the other expected SYNC_DONE");721 expect(events.filter((event) => event.endsWith("SYNC_DONE")).length === 4, "scenario 9: both sides must still reach SYNC_DONE");722 },723 },724 {725 id: 13,726 name: "vault id mismatch stops cleanly",727 initiator: "browser",728 android: { MTIME_PARITY: "0", VAULT_ID: "vault-android", EXPECT_STATUS: "VAULT_MISMATCH", DRAIN: "1" },729 browser: { MTIME_PARITY: "0", VAULT_ID: "vault-browser", EXPECT_STATUS: "vault_mismatch", DRAIN: "1" },730 check(round, { expect, side }) {731 expect(normalise(round.android.status) === "vault_mismatch", `scenario 13: android ${round.android.status}`);732 expect(normalise(round.browser.status) === "vault_mismatch", `scenario 13: browser ${round.browser.status}`);733 734 735 side(round, "browser", "scenario 13: browser initiator", ["send:SYNC_HELLO", "recv:SYNC_HELLO", "send:SYNC_ERROR"]);736 side(round, "android", "scenario 13: android responder", ["recv:SYNC_HELLO", "send:SYNC_HELLO"]);737 expect(!allEvents(round).some((event) => event.endsWith("SYNC_MANIFEST")), "scenario 13: no data frames may follow a vault-id mismatch");738 },739 },740 {741 id: 5,742 name: "browser locked (spool active), android unlocked, unequal versions",743 initiator: "android",744 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" },745 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", LOCKED: "1", EXPECT_STATUS: "completed" },746 check(round, { completed, expect }) {747 completed(round, { flow: true, manifests: false, label: "scenario 5" });748 749 expect(round.browser.staged > 0, "scenario 5: the locked side must stage what it receives");750 expect(round.browser.applied === 0, "scenario 5: a locked session must not merge into the live vault");751 expect(round.browser.entriesReceived === round.browser.staged, "scenario 5: staged must account for everything received");752 expect(round.android.applied > 0, "scenario 5: the unlocked side applies normally");753 expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 5: a locked session must not exchange a manifest");754 },755 },756 {757 id: 6,758 name: "android locked (listener running), browser unlocked",759 initiator: "browser",760 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "COMPLETED" },761 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", EXPECT_STATUS: "completed" },762 check(round, { completed, expect }) {763 completed(round, { flow: true, manifests: false, label: "scenario 6" });764 expect(round.android.staged > 0, "scenario 6: the locked side must stage what it receives");765 expect(round.android.applied === 0, "scenario 6: a locked session must not merge into the live vault");766 expect(round.browser.applied > 0, "scenario 6: the unlocked side applies normally");767 expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 6: a locked session must not exchange a manifest");768 },769 },770 {771 id: 8,772 name: "both sides locked",773 initiator: "android",774 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "COMPLETED" },775 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCKED: "1", EXPECT_STATUS: "completed" },776 check(round, { completed, expect }) {777 completed(round, { flow: true, manifests: false, label: "scenario 8" });778 for (const side of ["android", "browser"]) {779 expect(round[side].staged > 0, `scenario 8: ${side} must stage`);780 expect(round[side].applied === 0, `scenario 8: ${side} must not merge while locked`);781 }782 expect(!allEvents(round).some((event) => event.endsWith("SYNC_DEVICE_MANIFEST")), "scenario 8: neither locked side may exchange a manifest");783 },784 },785 {786 id: 10,787 name: "process death mid-session, next round completes cleanly",788 initiator: "android",789 790 791 792 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", STOP_AFTER: "SYNC_HELLO", EXPECT_STATUS: "COMPLETED" },793 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", EXPECT_STATUS: "errored", DRAIN: "1" },794 expectFailure: ["android"],795 check(round, { expect, side }) {796 expect(normalise(round.android.status) === "died", `scenario 10: android ended ${round.android.status}`);797 expect(normalise(round.browser.status) === "errored", `scenario 10: browser ended ${round.browser.status}`);798 side(round, "android", "scenario 10: the dead initiator", ["send:SYNC_HELLO"]);799 },800 async afterwards({ completed }, { runRound: run }) {801 const restarted = await run({802 initiator: "android",803 label: "scenario 10 (restarted)",804 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", LOCAL_VERSION: "7", EXPECT_STATUS: "COMPLETED" },805 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "2", LOCAL_VERSION: "9", EXPECT_STATUS: "completed" },806 });807 restarted.initiatedBy = "android";808 completed(restarted, { flow: true, manifests: true, label: "scenario 10 (restarted)" });809 },810 },811 {812 id: 12,813 name: "manifest with a device vouched too deeply is rejected",814 initiator: "android",815 manifest: () => signManifest({ devices: [deviceEntry({ vouchDepth: 99 })] }),816 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" },817 browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "completed" },818 check(round, { completed, expect, manifestVerdict }) {819 completed(round, { flow: false, manifests: true, label: "scenario 12" });820 manifestVerdict(round, "scenario 12", false);821 },822 },823 {824 id: 14,825 name: "manifest issued outside the freshness window is rejected",826 initiator: "android",827 828 manifest: () => signManifest({ nowOffsetMs: -10 * 60_000 }),829 android: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "COMPLETED" },830 browser: { MTIME_PARITY: "0", ENTRY_COUNT: "2", EXPECT_STATUS: "completed" },831 check(round, { completed, expect, manifestVerdict }) {832 completed(round, { flow: false, manifests: true, label: "scenario 14" });833 manifestVerdict(round, "scenario 14", false);834 },835 },836 {837 id: 15,838 name: "10,000-entry vault with padded frames",839 initiator: "android",840 android: { MTIME_PARITY: "0", ENTRY_COUNT: "10000", EXPECT_STATUS: "COMPLETED" },841 browser: { MTIME_PARITY: "1", ENTRY_COUNT: "10000", EXPECT_STATUS: "completed" },842 timeoutMs: 300_000,843 check(round, { completed, expect }) {844 completed(round, { flow: true, manifests: true, label: "scenario 15" });845 846 847 for (const side of ["android", "browser"]) {848 const sent = round[side].entriesSent;849 const received = round[side].entriesReceived;850 expect(sent === 5000, `scenario 15: ${side} sent ${sent}`);851 expect(received === 5000, `scenario 15: ${side} received ${received}`);852 }853 },854 },855];856857const helpers = {858 expect(condition, message) {859 if (!condition) throw new Error(message);860 },861 completed(round, options) {862 if (options.flow === undefined) throw new Error("completed() needs an explicit flow expectation");863 const expected = expectedRound(options);864 for (const [side, role] of [["android", round.initiatedBy === "android" ? "initiator" : "responder"], ["browser", round.initiatedBy === "browser" ? "initiator" : "responder"]]) {865 const status = normalise(round[side].status);866 if (status !== "completed") throw new Error(`${options.label}: ${side} ended ${round[side].status} (${round[side].error ?? "no error"})`);867 assertTranscript(round[side], expected[role], `${options.label}: ${side} ${role}`);868 }869 },870 transcript(round, label, predicate) {871 for (const side of ["android", "browser"]) {872 if (!predicate(round[side])) throw new Error(`${label}: unexpected transcript for ${side}: ${JSON.stringify(round[side].events)}`);873 }874 },875 manifestVerdict(round, label, expected) {876 for (const side of ["android", "browser"]) {877 const verdict = round[side].manifestAccepted;878 if (verdict !== expected) {879 throw new Error(`${label}: ${side} verdict was ${verdict}, expected ${expected}`);880 }881 }882 },883 side(round, side, label, expected) {884 const got = JSON.stringify(round[side].events);885 const want = JSON.stringify(expected);886 if (got !== want) throw new Error(`${label}: expected ${want}, got ${got}`);887 },888};889890891892893894895896const MANIFEST_CASES = [897 ["stale issuedAt", () => signManifest({ nowOffsetMs: -10 * 60_000 }), false],898 ["future issuedAt", () => signManifest({ nowOffsetMs: 10 * 60_000 }), false],899 ["vouchDepth above the absolute bound", () => signManifest({ devices: [deviceEntry({ vouchDepth: 99 })] }), false],900 ["device count above the bound", () => signManifest({ devices: Array.from({ length: 257 }, (_, i) => deviceEntry({ deviceId: `d-${i}` })) }), false],901 ["malformed device key", () => signManifest({ devices: [deviceEntry({ longTermPublicKeyHex: "aa".repeat(31) + "a" })] }), false],902 ["oversized issuer id", () => signManifest({ issuerDeviceId: "x".repeat(300) }), false],903 ["signature from an untrusted key", () => signManifest({ seed: "22".repeat(32) }), false],904 ["valid manifest (control)", () => signManifest({ devices: [deviceEntry()] }), true],905];906907908909const selection = process.env.PEACH_SYNC_HARNESS_ONLY ?? "";910const adoptionOnly = selection === "adoption";911const only = adoptionOnly ? [] : selection.split(",").filter(Boolean).map(Number);912const selected = only.length > 0 ? SCENARIOS.filter((scenario) => only.includes(scenario.id)) : SCENARIOS;913914const results = [];915for (const scenario of adoptionOnly ? [] : selected) {916 const started = Date.now();917 const round = await runRound({ ...scenario, label: `scenario ${scenario.id}` });918 round.initiatedBy = scenario.initiator;919 scenario.check(round, helpers);920 if (scenario.afterwards) await scenario.afterwards(helpers, { runRound });921 const elapsed = Date.now() - started;922 results.push({ ...scenario, elapsed });923 console.log(` ✓ ${String(scenario.id).padStart(2)} ${scenario.name} (${(elapsed / 1000).toFixed(1)}s)`);924}925926927928const manifestResults = [];929if (!adoptionOnly && (only.length === 0 || only.includes(-1))) {930 let index = 0;931 for (const [name, manifest, expected] of MANIFEST_CASES) {932 index += 1;933 const started = Date.now();934 const round = await runRound({935 initiator: "android",936 manifest,937 label: `§16.3 manifest case ${index} (${name})`,938 android: { MTIME_PARITY: "0", ENTRY_COUNT: "1", EXPECT_STATUS: "COMPLETED" },939 browser: { MTIME_PARITY: "0", ENTRY_COUNT: "1", EXPECT_STATUS: "completed" },940 });941 round.initiatedBy = "android";942 helpers.completed(round, { flow: false, manifests: true, label: `§16.3 case ${index}` });943 helpers.manifestVerdict(round, `§16.3 case ${index} (${name})`, expected);944 console.log(` ✓ 16.3.${index} ${name} → ${expected ? "accepted" : "rejected"} (${((Date.now() - started) / 1000).toFixed(1)}s)`);945 manifestResults.push({ name, expected });946 }947}948949if (adoptionOnly) {950 const expectedFrames = [951 "ADOPT_REQUEST_V2", "ADOPT_CONFIRM_REQUIRED", "ADOPT_CONFIRM", "ADOPT_REJECT",952 "ADOPT_REQUEST_V2", "ADOPT_PREPARE", "ADOPT_READY", "ADOPT_GRANT", "ADOPT_APPLIED", "ADOPT_COMPLETE",953 ];954 for (const source of ["android", "browser"]) {955 const round = await runAdoptionRound(source);956 for (const side of ["android", "browser"]) {957 if (round[side].status !== "completed") throw new Error(`${source} source: ${side} did not complete`);958 }959 const observer = round.browser.events.length > 0 ? round.browser : round.android;960 const frames = observer.events.map((event) => event.slice(5));961 if (JSON.stringify(frames) !== JSON.stringify(expectedFrames)) {962 throw new Error(`${source} source adoption transcript mismatch: ${JSON.stringify(observer.events)}`);963 }964 const sourceResult = round[source];965 if (!sourceResult.pendingBeforeRoot || !sourceResult.promotedAfterApplied) {966 throw new Error(`${source} source violated pending-holder ordering: ${JSON.stringify(sourceResult)}`);967 }968 const receiver = source === "android" ? round.browser : round.android;969 if (receiver.rotations !== 1) throw new Error(`${source} source: receiver rotated ${receiver.rotations} times`);970 console.log(` ✓ adoption v2 ${source} source → ${source === "android" ? "Browser" : "Android"} receiver`);971 }972 console.log("Cross-client adoption v2 process conformance passed: 2 directions.");973 process.exit(0);974}975976console.log("");977const scope = only.length > 0 ? ` (filtered to ${only.join(",")})` : "";978console.log(`Cross-client sync process conformance passed: ${results.length} of 15 §7.1 scenarios${scope}.`);979for (const result of results) {980 const timing = result.id === 15 ? ` — 10,000-entry round ${(result.elapsed / 1000).toFixed(1)}s wall clock` : "";981 console.log(` #${result.id} ${result.name}${timing}`);982}983console.log("");984for (const result of manifestResults) { }985console.log("");986console.log("Not yet covered by this gate (see the campaign §7.1 table):");987console.log(" #7 android cold-locked — evidence class is unit + real-device, NOT a process");988console.log(" transcript; the unit half is LockedSyncSpoolModelTest. Do not fake it here.");989console.log(" #11 revocation then re-vouch — the rule is entry ADMISSION, not manifest");990console.log(" validity: the Browser enforces it in sync-vault-adapter and Android in");991console.log(" PairedDeviceStore, both of which need real storage this endpoint has no");992console.log(" JVM-reachable path to. Its unit coverage exists on both clients; the");993console.log(" process class needs an adapter-level harness.");