+ Synthetic observations. No backup or guest-agent command is sent. +
++ Synthetic {transport} observations. No guest commands are sent. +
++ {item.label} live reading: {item.deferred?.message} +
+ )} +Published fixture ${version}
+ ` : ` + + `}`); + }); + server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); }); + return { server, counts: () => ({ ...counters }), close: async () => { + for (const timer of timers) clearTimeout(timer); + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(resolve)); + } }; +} + +async function runControls(outputDir) { + pureControls(); + const browser = await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] }); + const results = []; + try { + const controls = [ + ['success', 'upgrade', true, 1, 1], ['success', 'recovery', true, 0, 1], + ['anonymous-probes', 'upgrade', true, 1, 1], ['restart-gap', 'upgrade', true, 1, 1], + ['wrong-preview', 'upgrade', false, 0, 1], ['silent-stream', 'upgrade', false, 1, 1], + ['stuck-modal', 'upgrade', false, 1, 1], + ['cleanup-401', 'upgrade', false, 1, 1], ['cleanup-403', 'upgrade', false, 1, 1], + ]; + for (const endpoint of ['navigation', 'authenticated-navigation', 'login', 'initial-version', + 'authenticated-api', 'check', 'apply', 'stream', 'version', 'health', 'read']) { + for (const status of [401, 403]) { + controls.push([`${endpoint}-${status}`, 'upgrade', false, + ['apply', 'stream', 'version', 'health', 'read'].includes(endpoint) ? 1 : 0, + endpoint === 'navigation' ? 0 : 1]); + } + } + for (const [kind, mode, expectedPass, expectedApplies, expectedLogins] of controls) { + const target = fixture(kind); + await new Promise(resolve => target.server.listen(0, '127.0.0.1', resolve)); + const folder = path.resolve(outputDir, `${kind}-${mode}`); fs.mkdirSync(folder, { recursive: true }); + let passed = false; + try { + // A real denied HTTP response deliberately arrives during cleanup, + // after the main checks. The observer must not leave a passed receipt. + // Only this fixture delays context disposal; no response is fabricated. + const testBrowser = kind.startsWith('cleanup-') ? { + version: () => browser.version(), + newContext: async options => { + const context = await browser.newContext(options); + const close = context.close.bind(context); + context.close = async () => { + await context.pages()[0].evaluate(() => fetch('/api/cleanup-control')); + await close(); + }; + return context; + }, + } : browser; + await runJourney(testBrowser, { origin: `http://127.0.0.1:${target.server.address().port}`, mode, from, to, + expected: mode === 'upgrade' ? to : from, auth: { username: 'synthetic', password: 'synthetic' }, + timeout: 1500, readinessTimeout: 3000, outputDir: folder }); + passed = true; + } catch (_) { /* observer must reject each deliberately adverse fixture */ } + finally { await target.close(); } + assert.equal(passed, expectedPass, `${kind}/${mode} observer verdict`); + const counts = target.counts(); + assert.equal(counts.applies, expectedApplies, 'one apply, no replay'); + assert.equal(counts.logins, expectedLogins, 'no login after navigation denial'); + assert.equal(counts.requests_after_denial, 0, 'page stops after denial, including SSE reconnect'); + const receipt = JSON.parse(fs.readFileSync(path.join(folder, `browser-${mode}.json`))); + assert.ok(!JSON.stringify(receipt).includes('PRIVATE MESSAGE')); + assert.ok(!JSON.stringify(receipt).includes('synthetic')); + assert.equal(receipt.context_closed, true, 'browser context cleaned up'); + const refusal = /-(401|403)$/.test(kind); + assert.equal(receipt.access_refused, refusal, 'only actual access refusal stops later access'); + if (refusal) { + assert.equal(receipt.access_refusals[0].status, Number(kind.slice(-3))); + assert.equal(counts.denials, 1, 'denied request is not repeated'); + assert.ok(!JSON.stringify(receipt).includes('PRIVATE DENIAL')); + } + if (kind.startsWith('read-')) assert.equal(receipt.protected_read_status, Number(kind.slice(-3))); + if (kind === 'anonymous-probes') assert.equal(counts.anonymous_probes, 2, 'normal pre-login denial controls ran'); + if (kind === 'restart-gap') assert.equal(counts.transport_gaps, 1, 'restart transport control ran'); + results.push({ fixture: kind, mode, expected_rejection: !expectedPass, observed_status: receipt.status, + active_step: receipt.active_step, ...counts, receipt }); + } + fs.writeFileSync(path.join(outputDir, 'observer-controls.json'), JSON.stringify({ schema_version: 1, scope: 'offline adversarial observer controls only', + browser_version: browser.version(), client_version: require('playwright/package.json').version, results }, null, 2) + '\n'); + console.log(JSON.stringify({ observer_controls: results.length, scope: 'fixtures, not native qualification', results })); + } finally { await browser.close(); } +} +module.exports = { runControls, pureControls }; +if (require.main === module) runControls(path.resolve(process.argv[2] || 'tmp/browser-journey-proof')).catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/qualification/guest-agent-backup/README.md b/tests/qualification/guest-agent-backup/README.md index ca0eff9002..b4018d2d61 100644 --- a/tests/qualification/guest-agent-backup/README.md +++ b/tests/qualification/guest-agent-backup/README.md @@ -121,7 +121,17 @@ Obtain them from the existing read-state, memory diagnostics and History APIs; normalise only the target guest's fixed fields, without raw snapshots in public evidence. Locked values/History cannot be renewed as fresh; CPU must progress. Resumption needs available/agent memory, no disk deferral and fresh post-task -memory/disk History. **Internal cache timestamp preservation is source-tested; +memory/disk History. The complete command trace must also contain successful +`get-fsinfo` and `file-read` reads newly started after task completion and its +clock-uncertainty interval, and completed by the resumed readback within the +recorded clock allowance. Fresh History timestamps, independent filesystem +writes, the pre-lock overlap or metadata-only reads cannot substitute for those +post-task guest reads. Later polling cannot support an earlier resumed snapshot. +This is a consistency check: independently reconcile the raw command trace and +returned payloads with the target guest and History observations before judging +resumption. Do not send extra guest probes to make a record pass; observe normal +polling and retain an incomplete window as inconclusive. +**Internal cache timestamp preservation is source-tested; these public readbacks do not directly inspect those internal timestamps.** ```sh @@ -146,8 +156,11 @@ python3 -m unittest discover -s tests/qualification/guest-agent-backup -v go test -race ./tests/qualification/guest-agent-backup/guest-writer -count=1 ``` -Use the assigned source-proof VM for worker execution. The real local writer -smoke is ordinary synthetic fsync/readback, **not** a filesystem-freeze test. +Use the assigned source-proof VM for full source suites, builds and the Go +writer checks. The focused Python record/CLI controls use only synthetic local +files and can run locally without a native target or listener. They do not +validate the runtime lock guard or restore an interrupted executor attempt. +The real local writer smoke is ordinary synthetic fsync/readback, **not** a filesystem-freeze test. Do not replay unchanged guard/app suites to disguise the missing native target. Delivery's next material result is verified native inputs and a completed (or diff --git a/tests/qualification/guest-agent-backup/check.py b/tests/qualification/guest-agent-backup/check.py index 076e86262c..f7788fba46 100644 --- a/tests/qualification/guest-agent-backup/check.py +++ b/tests/qualification/guest-agent-backup/check.py @@ -187,6 +187,16 @@ def check_record(record, root, expected_source): resumed["memory_source"] == "guest-agent-meminfo" and resumed["disk_reason"] == "", "guest did not resume") require(all(resumed[k] > backup["completed_ms"] + skew for k in ("disk_history_latest_ms", "memory_history_latest_ms", "cpu_latest_ms")), "fresh post-backup History absent") + # A fresh timestamp is not evidence of a new guest read. The complete + # native trace must include both kinds of reads supporting this resumed + # snapshot, not just the pre-lock in-flight command or an old cached result. + # Allow the recorded clock error when comparing completion with readback; + # do not accept a start inside the post-task uncertainty interval. + for method in ("get-fsinfo", "file-read"): + require(any(command["method"] == method and + command["start_ms"] > backup["completed_ms"] + skew and + command["end_ms"] <= resumed["at_ms"] + skew for command in commands), + "post-task " + method + " read supporting resumption missing") witnesses = record["witnesses"] require(type(witnesses) is list and 1 <= len(witnesses) <= 16, "filesystem witness bound") diff --git a/tests/qualification/guest-agent-backup/test_check.py b/tests/qualification/guest-agent-backup/test_check.py index 8dc9a98fd6..7f7919bc72 100644 --- a/tests/qualification/guest-agent-backup/test_check.py +++ b/tests/qualification/guest-agent-backup/test_check.py @@ -59,7 +59,11 @@ def record(): "lock_end_ms": 106200, "completed_ms": 107000, "status": "OK", "trace_sha256": HASH}, "coverage": {"start_ms": 100000, "end_ms": 120000, "dropped_events": 0, "trace_sha256": HASH}, "commands": [{"id": "request-01", "method": "get-fsinfo", "start_ms": 103100, - "end_ms": 103500, "result": "ok"}], + "end_ms": 103500, "result": "ok"}, + {"id": "resume-disk", "method": "get-fsinfo", "start_ms": 108100, + "end_ms": 108200, "result": "ok"}, + {"id": "resume-memory", "method": "file-read", "start_ms": 108300, + "end_ms": 108400, "result": "ok"}], "snapshots": [baseline, locked, {**locked, "at_ms": 105500, "cpu_latest_ms": 105000}, resumed], "witnesses": []} @@ -109,6 +113,90 @@ class BackupRecordTests(unittest.TestCase): with self.assertRaisesRegex(CHECK.InvalidRecord, "post-task fsync"): self.check() + def test_resumed_history_and_writes_need_both_post_task_guest_reads(self): + original = copy.deepcopy(self.record["commands"]) + for missing in (("resume-disk",), ("resume-memory",), ("resume-disk", "resume-memory")): + with self.subTest(missing=missing): + self.record["commands"] = [c for c in original if c["id"] not in missing] + with self.assertRaisesRegex(CHECK.InvalidRecord, "read supporting resumption missing"): + self.check() + + def test_metadata_reads_cannot_support_resumed_memory_or_filesystems(self): + original = copy.deepcopy(self.record["commands"]) + for index in (1, 2): + for method in ("info", "get-osinfo", "network-get-interfaces"): + with self.subTest(index=index, method=method): + self.record["commands"] = copy.deepcopy(original) + self.record["commands"][index]["method"] = method + with self.assertRaisesRegex(CHECK.InvalidRecord, "read supporting resumption missing"): + self.check() + + def test_old_or_task_uncertain_reads_cannot_support_resumed_history(self): + original = copy.deepcopy(self.record["commands"]) + # These all start after the lock clears, so the lock-dispatch oracle + # cannot reject them. Only a genuinely new post-task read may qualify. + completed = self.record["backup"]["completed_ms"] + skew = self.record["window"]["clock_error_ms"] + for method in ("get-fsinfo", "file-read"): + for began in (completed - 100, completed, completed + skew): + with self.subTest(method=method, began=began): + self.record["commands"] = [copy.deepcopy(original[0])] + self.record["commands"].extend([ + {"id": "old-read", "method": method, "start_ms": began, + "end_ms": completed + 100, "result": "ok"}, + {"id": "new-other-read", "method": "file-read" if method == "get-fsinfo" else "get-fsinfo", + "start_ms": 108100, "end_ms": 108200, "result": "ok"}, + ]) + with self.assertRaisesRegex(CHECK.InvalidRecord, "read supporting resumption missing"): + self.check() + + def test_future_reads_cannot_support_an_earlier_resumed_snapshot(self): + original = copy.deepcopy(self.record["commands"]) + at = self.record["snapshots"][-1]["at_ms"] + skew = self.record["window"]["clock_error_ms"] + for index in (1, 2): + for began, finished in ((at - 100, at + skew + 1), (at + 100, at + 200)): + with self.subTest(index=index, began=began, finished=finished): + self.record["commands"] = copy.deepcopy(original) + self.record["commands"][index].update(start_ms=began, end_ms=finished) + with self.assertRaisesRegex(CHECK.InvalidRecord, "read supporting resumption missing"): + self.check() + + def test_post_task_read_clock_boundaries_and_later_polling_remain_valid(self): + original = copy.deepcopy(self.record) + completed = self.record["backup"]["completed_ms"] + at = self.record["snapshots"][-1]["at_ms"] + for skew in (0, 10, 1000): + with self.subTest(skew=skew): + self.record = copy.deepcopy(original) + self.record["window"]["clock_error_ms"] = skew + # Give the maximum-skew control enough independent margins + # for baseline, overlap, lock-time snapshots and liveness too. + if skew == 1000: + self.record["backup"].update(lock_start_ms=103000, freeze_ms=103100, + thaw_request_ms=106500, lock_end_ms=106900) + self.record["commands"][0].update(start_ms=101100, end_ms=104100) + self.record["snapshots"][0]["at_ms"] = 101500 + self.record["snapshots"][1]["at_ms"] = 104100 + self.record["snapshots"][2]["at_ms"] = 105800 + self.record["commands"][1].update(start_ms=completed + skew + 1, end_ms=completed + skew + 2) + self.record["commands"][2].update(start_ms=at - 10, end_ms=at + skew) + self.record["commands"].append({"id": "later-poll", "method": "file-read", "start_ms": 115000, + "end_ms": 115100, "result": "ok"}) + self.assertTrue(self.check()["record_checks_passed"]) + + def test_cli_rejects_unsupported_resumption_without_native_acceptance_claim(self): + self.record["commands"] = self.record["commands"][:1] + path = self.root / "record.json" + path.write_text(json.dumps(self.record)) + result = subprocess.run([sys.executable, str(Path(__file__).with_name("check.py")), + "--record", str(path), "--expected-source", SOURCE], capture_output=True, text=True) + self.assertEqual(result.returncode, 1) + failure = json.loads(result.stderr) + self.assertFalse(failure["record_checks_passed"]) + self.assertFalse(failure["native_acceptance_complete"]) + self.assertEqual(failure["failure"], "post-task get-fsinfo read supporting resumption missing") + def test_wrong_id_timeout_and_incomplete_native_commands_stay_adverse(self): for result in ("wrong-command-id", "timeout", "response-incomplete", "permission-denied"): with self.subTest(result=result):