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