From ff5d7ecb4dd28b42de02d47dd62d3986a9c91a95 Mon Sep 17 00:00:00 2001 From: Malcolm Jones Date: Thu, 12 Nov 2026 13:07:40 +0100 Subject: [PATCH] test(updates): capture deferred LaunchServices observations --- .../updates/packaged-desktop-helper-fault.mjs | 194 +++++++++++++ ...ackaged-desktop-helper-fault.node-test.mjs | 261 ++++++++++++++++++ .../packaged-desktop-helper-preload.py | 77 +++++- 3 files changed, 530 insertions(+), 2 deletions(-) diff --git a/scripts/jones/updates/packaged-desktop-helper-fault.mjs b/scripts/jones/updates/packaged-desktop-helper-fault.mjs index e3b770179d..761a8d5a0f 100644 --- a/scripts/jones/updates/packaged-desktop-helper-fault.mjs +++ b/scripts/jones/updates/packaged-desktop-helper-fault.mjs @@ -13,6 +13,25 @@ export const packagedDesktopFaultPoints = Object.freeze([ const sha = (bytes) => NodeCrypto.createHash("sha256").update(bytes).digest("hex"); +function exactKeys(record, keys) { + NodeAssert.ok(record && typeof record === "object" && !Array.isArray(record)); + NodeAssert.deepEqual(Object.keys(record).sort(), [...keys].sort()); +} + +function immutable(record) { + if (record && typeof record === "object") { + for (const value of Object.values(record)) immutable(value); + Object.freeze(record); + } + return record; +} + +const launchObservationNames = Object.freeze([ + "launch-services.json", + "launch-return.json", + "launch-observation-failure.json", +]); + async function privatePath(root, filename, directory = false) { NodeAssert.equal(NodePath.resolve(filename), filename); NodeAssert.ok(filename.startsWith(root + NodePath.sep) || filename === root); @@ -219,6 +238,7 @@ export async function preparePackagedDesktopHelperFault({ await NodeFSP.mkdir(preload, { mode: 0o700 }); const planBytes = Buffer.from(JSON.stringify(plan)); const planPath = NodePath.join(preload, "fault-plan.json"); + const launchInputs = new Map(); await writeExclusive( NodePath.join(preload, "sitecustomize.py"), await NodeFSP.readFile(new URL("./packaged-desktop-helper-preload.py", import.meta.url)), @@ -339,8 +359,182 @@ export async function preparePackagedDesktopHelperFault({ NodeAssert.ok(receipt.state && typeof receipt.state === "object"); return receipt.state; }, + // These are helper observations, not live handles or authority to clean a + // launchd-born tree. Native custody must be acquired before launch effects. + async readLaunchObservation() { + NodeAssert.ok(deferred && observeRelaunch && point === "before-pair"); + await NodeAssert.rejects( + NodeFSP.lstat(NodePath.join(preload, "launch-observation-failure.json")), + { code: "ENOENT" }, + ); + const inputs = new Map(); + const read = async (filename, maximum = 16384) => { + const bytes = await readPrivate(fixtureRoot, filename, maximum); + const hash = sha(bytes); + if (launchInputs.has(filename)) NodeAssert.equal(hash, launchInputs.get(filename)); + inputs.set(filename, hash); + return bytes; + }; + NodeAssert.equal(sha(await read(planPath)), sha(planBytes)); + const fault = await this.readReceipt(); + exactKeys(fault, [ + "protocol", + "kind", + "pid", + "point", + "function", + "phase", + "step", + "transactionId", + "planSha256", + "helperSha256", + "manifestSha256", + "intentSha256", + "journalSha256", + "beforePairStateSha256", + ]); + await read(NodePath.join(preload, "fault-receipt.json")); + const beforeRelaunch = await this.readRelaunchReceipt(); + exactKeys(beforeRelaunch, [...Object.keys(fault), "faultReceiptSha256", "state"]); + const beforeBytes = await read(NodePath.join(preload, "before-relaunch.json"), 1024 * 1024); + NodeAssert.deepEqual(JSON.parse(beforeBytes), beforeRelaunch); + const quiet = JSON.parse( + await read(NodePath.join(preload, "before-pair-state.json"), 1024 * 1024), + ); + exactKeys(quiet, [ + "protocol", + "kind", + "processesStopped", + "state", + "pid", + "planSha256", + "transactionId", + "intentSha256", + "journalSha256", + ]); + NodeAssert.equal(quiet.protocol, 1); + const intentPath = NodePath.join( + active.home, + "runtime/jones-updates/transactions", + fault.transactionId, + "intent.json", + ); + const actual = JSON.parse(await read(intentPath, 65536)); + NodeAssert.equal(sha(await read(manifest, 65536)), plan.manifestSha256); + NodeAssert.equal(sha(await read(helper, 2 * 1024 * 1024)), helperSha256); + NodeAssert.equal(sha(await read(selection, 131072)), plan.selectionSha256); + NodeAssert.equal(sha(await read(intentPath, 65536)), fault.intentSha256); + const transaction = NodePath.dirname(intentPath); + NodeAssert.equal(actual.continuationReceipt, NodePath.join(transaction, "continuation.json")); + const claim = { + protocol: 1, + preparationClaimProtocol: 3, + transactionId: fault.transactionId, + stagedHandle: actual.staged.handle, + selectionPath: selection, + selectionSha256: plan.selectionSha256, + ...Object.fromEntries( + ["home", "profile", "databasePath", "environmentId"].map((key) => [key, active[key]]), + ), + }; + NodeAssert.deepEqual(JSON.parse(await read(actual.continuationReceipt, 65536)), { + ...claim, + prepared: true, + }); + for (const name of ["prepare-intent.json", "prepare-dispatched.json"]) + NodeAssert.deepEqual( + JSON.parse(await read(NodePath.join(transaction, name), 65536)), + claim, + ); + const servicesBytes = await read(NodePath.join(preload, "launch-services.json")); + const services = JSON.parse(servicesBytes); + const returned = JSON.parse(await read(NodePath.join(preload, "launch-return.json"))); + for (const [record, kind, sequence, fields] of [ + [services, "packaged-desktop-launch-services", 1, ["reply", "failure"]], + [ + returned, + "packaged-desktop-launch-return", + 2, + ["launchServicesSha256", "identity", "failure"], + ], + ]) { + exactKeys(record, [...Object.keys(fault), "sequence", "beforeRelaunchSha256", ...fields]); + for (const [key, value] of Object.entries(fault)) + NodeAssert.deepEqual(record[key], key === "kind" ? kind : value); + NodeAssert.equal(record.sequence, sequence); + NodeAssert.equal(record.beforeRelaunchSha256, sha(beforeBytes)); + } + NodeAssert.equal(returned.launchServicesSha256, sha(servicesBytes)); + const reply = services.reply; + if (reply === null) { + NodeAssert.equal(services.failure, "launch-services-return-unproved"); + } else { + NodeAssert.equal(services.failure, null); + NodeAssert.equal(reply.protocol, 1); + if (reply.status === "launched") { + exactKeys(reply, ["protocol", "status", "pid", "bundlePath", "executablePath"]); + NodeAssert.ok( + Number.isSafeInteger(reply.pid) && reply.pid > 0 && reply.pid <= 2147483647, + ); + NodeAssert.notEqual(reply.pid, fault.pid); + for (const [key, expected] of [ + ["bundlePath", active.appPath], + ["executablePath", active.executablePath], + ]) { + NodeAssert.equal(reply[key], expected); + NodeAssert.equal(NodePath.resolve(expected), expected); + NodeAssert.ok(expected.startsWith(fixtureRoot + NodePath.sep)); + NodeAssert.equal(await NodeFSP.realpath(expected), expected); + } + } else if (reply.status === "failed") { + exactKeys(reply, ["protocol", "status", "domain", "code"]); + NodeAssert.match(reply.domain, /^[A-Za-z0-9_.-]{0,128}$/); + NodeAssert.ok(Number.isSafeInteger(reply.code)); + } else { + exactKeys(reply, ["protocol", "status"]); + NodeAssert.equal(reply.status, "unavailable"); + } + } + if (returned.identity === null) { + NodeAssert.equal( + returned.failure, + reply === null ? "launch-services-unproved" : "launch-validation-unproved", + ); + } else { + NodeAssert.equal(returned.failure, null); + NodeAssert.equal(reply?.status, "launched"); + exactKeys(returned.identity, ["pid", "startSec", "startUsec", "executable"]); + const identity = returned.identity; + NodeAssert.equal(identity.pid, reply.pid); + NodeAssert.equal(identity.executable, active.executablePath); + NodeAssert.ok(Number.isSafeInteger(identity.startSec) && identity.startSec > 0); + NodeAssert.ok( + Number.isSafeInteger(identity.startUsec) && + identity.startUsec >= 0 && + identity.startUsec < 1000000, + ); + } + for (const [filename, hash] of inputs) { + NodeAssert.equal(sha(await readPrivate(fixtureRoot, filename, 2 * 1024 * 1024)), hash); + launchInputs.set(filename, hash); + } + await NodeAssert.rejects( + NodeFSP.lstat(NodePath.join(preload, "launch-observation-failure.json")), + { code: "ENOENT" }, + ); + return immutable({ + beforeRelaunch, + launchServices: services, + launchReturn: returned, + externalDescendantSettlementRequired: true, + activationAllowed: false, + packagedDesktopMatrixPassed: false, + }); + }, async assertNoRelaunch() { NodeAssert.equal(observeRelaunch, true); + for (const name of launchObservationNames) + await NodeAssert.rejects(NodeFSP.lstat(NodePath.join(preload, name)), { code: "ENOENT" }); await NodeAssert.rejects(NodeFSP.lstat(NodePath.join(preload, "before-relaunch.json")), { code: "ENOENT", }); diff --git a/scripts/jones/updates/packaged-desktop-helper-fault.node-test.mjs b/scripts/jones/updates/packaged-desktop-helper-fault.node-test.mjs index 3a3ab0e882..c05507dd37 100644 --- a/scripts/jones/updates/packaged-desktop-helper-fault.node-test.mjs +++ b/scripts/jones/updates/packaged-desktop-helper-fault.node-test.mjs @@ -251,6 +251,7 @@ NodeTest.test( NodeAssert.equal(result.code, 0, result.stderr); const fault = await prepared.readReceipt(); const before = await prepared.readRelaunchReceipt(); + await NodeAssert.rejects(prepared.readLaunchObservation()); NodeAssert.equal(before.pid, fault.pid); NodeAssert.equal( before.state[f.active.databasePath].sha256, @@ -343,6 +344,266 @@ async function installSelection(f) { }; } +function launchServicesControl(mode = "returned") { + return relaunchControl(false) + .replace( + "def kernel_process_identity(*args): return True", + "def kernel_process_identity(*args): return False", + ) + .replace( + "def pair_state", + "def valid_process_proofs(proofs): return proofs == [123]\ndef pair_state", + ) + .replace( + " preparation_step = 'storage'", + " captured_processes = [123]\n preparation_step = 'storage'", + ) + .replace( + "def launch(active, descriptor=None):", + `def launch_services(request): + ${mode === "services-failed" ? "raise ValueError('private service failure')" : "pass"} + ${mode === "write-failed" ? "durable(pathlib.Path(__import__('os').environ['JONES_PACKAGED_DESKTOP_FAULT_PLAN']).with_name('launch-services.json'), {})" : "pass"} + ${mode === "refused" ? "return {'protocol': 1, 'status': 'failed', 'domain': 'NSCocoaErrorDomain', 'code': 256}" : "pass"} + ${mode === "unavailable" ? "return {'protocol': 1, 'status': 'unavailable'}" : "pass"} + return {'protocol': 1, 'status': 'launched', 'pid': 321, + 'bundlePath': intent['expected']['appPath'], 'executablePath': intent['expected']['executablePath']} + +def launch(active, descriptor=None): + reply = launch_services({'appPath': active['appPath'], 'environment': {}}) + if reply.get('status') != 'launched': raise RuntimeError('unproved control launch') + ${mode === "duplicate" ? "reply = launch_services({'appPath': active['appPath'], 'environment': {}})" : "pass"} + ${mode === "failed" ? "raise RuntimeError('private failure text must not escape')" : "pass"}`, + ) + .replace( + " (pathlib.Path(active['profile']) / 'started').write_text('started')", + " (pathlib.Path(active['profile']) / 'started').write_text('started')\n return {'pid': reply['pid'], 'startSec': 456, 'startUsec': 789, 'executable': active['executablePath']}", + ) + .replace( + " relaunch_previous()", + mode === "skipped" ? " pass" : " relaunch_previous()", + ); +} + +async function deferredLaunchFixture(body, mode = "returned") { + await fixture(async (f) => { + const appPath = NodePath.join(f.root, "Previous.app"); + const executablePath = NodePath.join(appPath, "Contents/MacOS/previous"); + await NodeFSP.mkdir(NodePath.dirname(executablePath), { recursive: true, mode: 0o700 }); + await NodeFSP.writeFile(executablePath, "not an executable app", { mode: 0o600 }); + f.active = { ...f.active, appPath, executablePath }; + const selected = await installSelection(f); + const prepared = await preparePackagedDesktopHelperFault({ + fixtureRoot: f.root, + helper: f.helper, + manifest: f.manifest, + selection: selected.selection, + point: "before-pair", + observeRelaunch: true, + }); + await selected.prepare(); + const result = await execute(f, prepared, [ + f.helper, + "--manifest", + f.manifest, + "--activate", + f.intent, + ]); + await body({ ...f, prepared, result }); + }, launchServicesControl(mode)); +} + +NodeTest.test( + "deferred instrumentation retains the LaunchServices reply after launch validation throws", + async () => { + await deferredLaunchFixture(async ({ prepared, result, active }) => { + NodeAssert.equal(result.code, 1); + NodeAssert.match(result.stderr, /private failure text must not escape/); + const replyPath = NodePath.join(NodePath.dirname(prepared.planPath), "launch-services.json"); + const reply = JSON.parse(await NodeFSP.readFile(replyPath)); + NodeAssert.equal(reply.reply.pid, 321); + NodeAssert.equal(reply.reply.executablePath, active.executablePath); + const observed = await prepared.readLaunchObservation(); + NodeAssert.equal(observed.launchReturn.failure, "launch-validation-unproved"); + NodeAssert.equal(observed.launchReturn.identity, null); + NodeAssert.equal(JSON.stringify(observed).includes("private failure text"), false); + NodeAssert.equal(observed.externalDescendantSettlementRequired, true); + NodeAssert.equal(observed.activationAllowed, false); + }, "failed"); + }, +); + +NodeTest.test( + "deferred launch observations bind source, order, exact reply paths and birth without granting custody", + async () => { + await deferredLaunchFixture(async ({ prepared, result, active, tx }) => { + NodeAssert.equal(result.code, 0, result.stderr); + const preload = NodePath.dirname(prepared.planPath); + const servicesPath = NodePath.join(preload, "launch-services.json"); + const returnPath = NodePath.join(preload, "launch-return.json"); + const originalServices = await NodeFSP.readFile(servicesPath); + const originalReturn = await NodeFSP.readFile(returnPath); + const services = JSON.parse(originalServices); + const returned = JSON.parse(originalReturn); + for (const path of [servicesPath, returnPath]) { + const original = await NodeFSP.readFile(path); + await NodeFSP.unlink(path); + await NodeAssert.rejects(prepared.readLaunchObservation(), { code: "ENOENT" }); + await NodeFSP.writeFile(path, original, { mode: 0o600 }); + } + for (const mutate of [ + (s) => { + s.protocol = true; + }, + (s) => { + s.transactionId = "0".repeat(64); + }, + (s) => { + s.helperSha256 = "0".repeat(64); + }, + (s) => { + s.sequence = 2; + }, + (s) => { + s.beforeRelaunchSha256 = "0".repeat(64); + }, + (s) => { + s.reply.pid = "321"; + }, + (s) => { + s.reply.executablePath += ".foreign"; + }, + (s) => { + s.reply.bundlePath += ".foreign"; + }, + (s) => { + s.reply.status = "available"; + }, + (s) => { + s.unboundedData = "unexpected"; + }, + (_s, r) => { + r.identity.pid += 1; + }, + (_s, r) => { + r.identity.startUsec = 1000000; + }, + (_s, r) => { + r.identity.executable += ".foreign"; + }, + (_s, r) => { + r.failure = "raw exception"; + }, + ]) { + const changedServices = structuredClone(services); + const changedReturn = structuredClone(returned); + mutate(changedServices, changedReturn); + const bytes = JSON.stringify(changedServices); + changedReturn.launchServicesSha256 = NodeCrypto.createHash("sha256") + .update(bytes) + .digest("hex"); + await NodeFSP.writeFile(servicesPath, bytes); + await NodeFSP.writeFile(returnPath, JSON.stringify(changedReturn)); + await NodeAssert.rejects(prepared.readLaunchObservation()); + } + await NodeFSP.writeFile(servicesPath, originalServices); + await NodeFSP.writeFile(returnPath, originalReturn); + const dispatchedPath = NodePath.join(tx, "prepare-dispatched.json"); + const dispatched = await NodeFSP.readFile(dispatchedPath); + await NodeFSP.writeFile( + dispatchedPath, + JSON.stringify({ ...JSON.parse(dispatched), preparationClaimProtocol: 2 }), + ); + await NodeAssert.rejects(prepared.readLaunchObservation()); + await NodeFSP.writeFile(dispatchedPath, dispatched); + const observed = await prepared.readLaunchObservation(); + NodeAssert.equal(observed.launchServices.reply.pid, observed.launchReturn.identity.pid); + NodeAssert.equal(observed.launchReturn.identity.executable, active.executablePath); + NodeAssert.equal(observed.launchReturn.failure, null); + NodeAssert.equal(observed.activationAllowed, false); + NodeAssert.equal(observed.externalDescendantSettlementRequired, true); + NodeAssert.ok(Object.isFrozen(observed.launchServices.reply)); + NodeAssert.equal( + await NodeFSP.readFile(NodePath.join(active.profile, "started"), "utf8"), + "started", + ); + await NodeFSP.appendFile(servicesPath, " "); + await NodeAssert.rejects(prepared.readLaunchObservation()); + }); + }, +); + +NodeTest.test( + "duplicate or failed post-effect observation preserves helper behavior and rejects custody evidence", + async () => { + for (const mode of ["duplicate", "write-failed"]) { + await deferredLaunchFixture(async ({ prepared, result, active }) => { + NodeAssert.equal(result.code, 0, result.stderr); + NodeAssert.equal( + await NodeFSP.readFile(NodePath.join(active.profile, "started"), "utf8"), + "started", + ); + const marker = JSON.parse( + await NodeFSP.readFile( + NodePath.join(NodePath.dirname(prepared.planPath), "launch-observation-failure.json"), + ), + ); + NodeAssert.equal( + marker.failure, + mode === "duplicate" ? "duplicate-observation" : "observation-failed", + ); + await NodeAssert.rejects(prepared.readLaunchObservation()); + }, mode); + } + }, +); + +NodeTest.test( + "failed LaunchServices return has a finite category and preserves the original exception", + async () => { + await deferredLaunchFixture(async ({ prepared, result }) => { + NodeAssert.equal(result.code, 1); + NodeAssert.match(result.stderr, /private service failure/); + const observed = await prepared.readLaunchObservation(); + NodeAssert.equal(observed.launchServices.reply, null); + NodeAssert.equal(observed.launchServices.failure, "launch-services-return-unproved"); + NodeAssert.equal(observed.launchReturn.failure, "launch-services-unproved"); + NodeAssert.equal(JSON.stringify(observed).includes("private service failure"), false); + }, "services-failed"); + for (const mode of ["refused", "unavailable"]) { + await deferredLaunchFixture(async ({ prepared, result }) => { + NodeAssert.equal(result.code, 1); + NodeAssert.match(result.stderr, /unproved control launch/); + const observed = await prepared.readLaunchObservation(); + NodeAssert.equal( + observed.launchServices.reply.status, + mode === "refused" ? "failed" : "unavailable", + ); + NodeAssert.equal(observed.launchReturn.failure, "launch-validation-unproved"); + NodeAssert.equal(observed.launchReturn.identity, null); + }, mode); + } + }, +); + +NodeTest.test("no-relaunch proof requires every launch observation absent", async () => { + await deferredLaunchFixture(async ({ prepared, result }) => { + NodeAssert.equal(result.code, 0, result.stderr); + await prepared.assertNoRelaunch(); + for (const name of [ + "launch-services.json", + "launch-return.json", + "launch-observation-failure.json", + ]) { + const path = NodePath.join(NodePath.dirname(prepared.planPath), name); + await NodeFSP.writeFile(path, "{}", { mode: 0o600 }); + await NodeAssert.rejects(prepared.assertNoRelaunch()); + await NodeFSP.unlink(path); + } + await prepared.assertNoRelaunch(); + await NodeAssert.rejects(prepared.readLaunchObservation(), { code: "ENOENT" }); + }, "skipped"); +}); + NodeTest.test( "deferred before-pair snapshots quiet state after live writes and rejects changes before reopen", async () => { diff --git a/scripts/jones/updates/packaged-desktop-helper-preload.py b/scripts/jones/updates/packaged-desktop-helper-preload.py index ab7fb3d706..6a99d944dc 100644 --- a/scripts/jones/updates/packaged-desktop-helper-preload.py +++ b/scripts/jones/updates/packaged-desktop-helper-preload.py @@ -118,6 +118,8 @@ def read_file(path, limit): assert not os.path.lexists(receipt_path) assert not os.path.lexists(plan_path.with_name("before-relaunch.json")) assert not os.path.lexists(plan_path.with_name("before-pair-state.json")) + for name in ("launch-services.json", "launch-return.json", "launch-observation-failure.json"): + assert not os.path.lexists(plan_path.with_name(name)) plan_sha = hashlib.sha256(read_file(plan_path, 16384)).hexdigest() @@ -171,8 +173,72 @@ def visit(path, depth=0): fault_record = None quiet_state = None + launch_frame = None + services_frame = None + services_sha = None + services_returned = False + before_relaunch_sha = None + + def launch_record(name, kind, **fields): + record = dict(fault_record, kind=kind, beforeRelaunchSha256=before_relaunch_sha, **fields) + raw = json.dumps(record, sort_keys=True).encode() + assert len(raw) <= 16384 + fd = os.open(plan_path.with_name(name), os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + fd = os.open(plan_path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fsync(fd) + finally: + os.close(fd) + return hashlib.sha256(raw).hexdigest() + + def observation_failed(category): + # After launch effects, observation failure must not replace the helper's + # result or exception. A missing terminal receipt or this marker holds custody. + try: + launch_record("launch-observation-failure.json", "packaged-desktop-launch-observation-failure", + failure=category) + except BaseException: + pass + + def bounded_result(value, keys): + if type(value) is not dict or not set(value) <= keys: + return None + if any(type(item) not in (str, int, type(None)) for item in value.values()): + return None + raw = json.dumps(value, sort_keys=True).encode() + return json.loads(raw) if len(raw) <= 8192 else None def observe_relaunch(frame, event, arg): + nonlocal launch_frame, services_frame, services_sha, services_returned, before_relaunch_sha + if deferred and fault_record is not None and plan["observeRelaunch"]: + try: + if (event == "call" and frame.f_code.co_filename == str(helper) + and frame.f_code.co_name == "launch_services" and frame.f_back is launch_frame): + if services_frame is not None: + observation_failed("duplicate-observation") + else: + services_frame = frame + return + if event == "return" and frame is services_frame: + reply = bounded_result(arg, {"protocol", "status", "pid", "bundlePath", "executablePath", "domain", "code"}) + services_sha = launch_record("launch-services.json", "packaged-desktop-launch-services", + sequence=1, reply=reply, failure=None if reply is not None else "launch-services-return-unproved") + services_returned = reply is not None + return + if event == "return" and frame is launch_frame: + identity = bounded_result(arg, {"pid", "startSec", "startUsec", "executable"}) + launch_record("launch-return.json", "packaged-desktop-launch-return", sequence=2, + launchServicesSha256=services_sha, identity=identity, + failure=None if identity is not None else ( + "launch-validation-unproved" if services_returned else "launch-services-unproved")) + return + except BaseException: + observation_failed("observation-failed") + return if (not plan["observeRelaunch"] or fault_record is None or event != "call" or frame.f_code.co_filename != str(helper) or frame.f_code.co_name != "launch"): return @@ -181,7 +247,11 @@ def observe_relaunch(frame, event, arg): if (parent is None or parent.f_code.co_filename != str(helper) or parent.f_code.co_name != "relaunch_previous" or activation is None or activation.f_code.co_filename != str(helper) or activation.f_code.co_name != "activate"): return - sys.setprofile(None) + if deferred and launch_frame is not None: + observation_failed("duplicate-observation") + return + if not deferred: + sys.setprofile(None) try: local = activation.f_locals assert local.get("transaction_id") == plan["transactionId"] @@ -217,6 +287,9 @@ def observe_relaunch(frame, event, arg): os.fsync(fd) finally: os.close(fd) + if deferred: + before_relaunch_sha = hashlib.sha256(raw).hexdigest() + launch_frame = frame except BaseException: os.write(2, b"RH-F2 pre-relaunch observation failed; owned evidence retained.\n") os._exit(125) @@ -267,7 +340,7 @@ def trace(frame, event, arg): alive = frame.f_globals["alive"] assert valid(proofs) and all(not alive(proof) for proof in proofs) quiet_state = snapshot_state() - snapshot = dict(kind="packaged-desktop-before-pair-state", processesStopped=True, + snapshot = dict(protocol=1, kind="packaged-desktop-before-pair-state", processesStopped=True, state=quiet_state, **{key: receipt[key] for key in ("pid", "planSha256", "transactionId", "intentSha256", "journalSha256")}) raw = json.dumps(snapshot, sort_keys=True).encode()