From ca21d6c7cc72d347fd175597248bc819206c28a7 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 18 Oct 2026 22:55:23 +0800 Subject: [PATCH 1/6] Add fail-closed Simulator acceptance identity preflight --- .../logseq-journal-ios-sim-testing/SKILL.md | 247 +--------- .agents/skills/testing-ios-simulator/SKILL.md | 36 ++ .github/workflows/ci.yml | 4 + AGENTS.md | 21 + docs/acceptance-capture.md | 149 ++++++ ...026-10-18-acceptance-identity-preflight.md | 71 +++ tool/capture_acceptance.py | 360 +++++++++++++++ tool/test_capture_acceptance.py | 436 ++++++++++++++++++ 8 files changed, 1090 insertions(+), 234 deletions(-) create mode 100644 docs/acceptance-capture.md create mode 100644 docs/agent-guide/implemented/process/2026-10-18-acceptance-identity-preflight.md create mode 100644 tool/capture_acceptance.py create mode 100644 tool/test_capture_acceptance.py diff --git a/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md b/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md index 10e275c2..7f349340 100644 --- a/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md +++ b/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md @@ -1,240 +1,19 @@ --- name: logseq-journal-ios-sim-testing -description: End-to-end test the logseq_journal app on the iOS Simulator — adhoc-signed build for working Amplify/keychain auth, E2EE graph unlock flow, inspecting persisted datoms in the mirror SQLite, and the V.Sheet render-loop workaround that otherwise blocks all modal UI on the simulator. +description: Compatibility redirect to the maintained Journal Simulator identity preflight and acceptance workflow. --- -# Testing logseq_journal end-to-end on the iOS Simulator +# Journal Simulator testing — unified workflow -The iOS Simulator exercises the same OCaml runtime as the macOS app and is the most -reliable path when no Apple Development certificate/team is available (the macOS -sandboxed build fails Amplify sign-in with keychain error -34018 without a paid cert). +Read and follow [testing-ios-simulator](../testing-ios-simulator/SKILL.md) and +[journal-build](../journal-build/SKILL.md). Use +`python3 tool/capture_acceptance.py --help` from the repository root for acceptance +capture and [the evidence contract](../../../docs/acceptance-capture.md). -## Build and install - -```bash -eval $(opam env --switch=logseq-journal --set-switch) -bonsai-swiftui build ios --simulator --profile debug -# --simulator builds may embed no keychain entitlements; rebuild adhoc-signed so -# Amplify session persistence and E2EE keychain work: -xcodebuild -project apple/BonsaiLogseqJournal.xcodeproj -scheme BonsaiLogseqJournal-iOS \ - -configuration Debug -destination 'generic/platform=iOS Simulator' \ - -derivedDataPath apple/DerivedData \ - -clonedSourcePackagesDirPath _build/bonsai-swiftui/dependencies/packages \ - -disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile -skipPackageUpdates \ - ARCHS=arm64 CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- build -xcrun simctl install booted apple/DerivedData/Build/Products/Debug-iphonesimulator/BonsaiLogseqJournal.app -xcrun simctl launch booted com.example.bonsaiFlutterLogseqJournalHost -``` - -Lui build path (journal_view.ml / Lui_elements): `JOURNAL_IOS_TEAM_ID=K378MFWK59 -dune build @ios-app` → flat bundle `_build/apple/ios-simulator/LogseqJournal.app`, -bundle id `com.logseq.journal`. The bundle embeds a `__TEXT,__entitlements` -section (verify: `otool -l | grep -A3 entitlements`) so keychain + Amplify -sign-in DO work on the sim even adhoc-signed — the old "sign-in impossible on sim" -note does not apply to this build. - -When several simulators are booted, `booted` is ambiguous — always use an -explicit UDID (`xcrun simctl list devices | grep Booted`) for install/launch/ -screenshot/get_app_container. - -Notes: -- The lock file may be missing the `ocaml-ios64-simulator` pin — - `bonsai-swiftui build ios` fails with "no matching definition" until you add - ` "ocaml-ios64-simulator" {= "5.1.1"}` next to the `ocaml-ios64` entry in - `logseq_journal.opam.locked`. -- iOS toolchain packages live in a separate switch `bonsai-swiftui-ios-simulator`. - `bonsai-swiftui toolchain install iossimulator` refuses if that switch already - exists; instead run `opam install -y --switch=bonsai-swiftui-ios-simulator - bonsai_swiftui_ios_simulator_runtime_sdk bonsai_swiftui_ios_simulator_sdk` then - `bonsai-swiftui toolchain verify iossimulator`. -- Needs several GiB of free disk (opam toolchains + DerivedData). -- After `simctl install`, the app's data container UUID can change — always resolve - it fresh: `xcrun simctl get_app_container booted com.example.bonsaiFlutterLogseqJournalHost data`. - -## Sign-in and E2EE unlock flow - -1. Sign-in sheet auto-presents: enter `${LOGSEQ_JOURNAL_USERNAME}` / - `${LOGSEQ_JOURNAL_PASSWORD}`. -2. Graph picker lists synced graphs (names may be ciphertext-looking for E2EE graphs — - match by position/recent). Pick one. -3. First open on a fresh container: `Load_and_unlock_graph_key` fails (keychain miss) - and the error screen shows a **Retry** button. Tap Retry — this routes to - `begin_e2ee_key_access` and presents the "Unlock your graph" password prompt. - Enter `${LOGSEQ_JOURNAL_E2EE_PASSWORD}`. -4. The graph then bootstraps: "Downloading graph" -> snapshot apply -> timeline. - On later launches the local mirror makes restore instant. -- If a graph stalls at "Downloading graph" with ~0% CPU (all fibers parked — - `Activate_snapshot` never runs), it will not recover; delete its mirror dir and - try a different graph. Small/empty graphs bootstrap fastest. - -## Inspecting persisted datoms (verifying block/title etc.) - -The local mirror is at: - -``` -/Library/Application Support/logseq-db-worker/synced-graphs//db.sqlite -``` - -- `kvs` is content-addressed (`addr`, `content`, `addresses` JSON). Local overlay - transactions store PLAINTEXT transit-encoded datoms; server-mirrored entities - may appear as E2EE ciphertext (`"~b..."` base64 pairs) — search both. -- Find a journal page: `sqlite3 db.sqlite "select content from kvs where content - like '%2026-0922%'"` — shows datoms like - `[203,"~:block/title","Sep 22nd, 2026",...],[203,"~:block/name","sep 22nd, 2026",...]`. - Deterministic journal-page uuid pattern: `00000001-YYYY-MMDD-0000-000000000000`. -- `sync_outbox` drains to 0 when mutations flush to server; - `overlay_terminal_batch_receipts` holds `acceptedBatch` receipts with - `server-cursor` values proving server acknowledgement. - -## V.Sheet render-loop bug (critical simulator blocker) - -Presenting any `V.Sheet` modal (Capture composer, Diagnostics, error info, status) -on the iOS simulator CAN wedge the app: the main thread enters a -`PresentationController.nativeVisible` didSet -> `RenderTree.commit` -> -`NativePresentationContent.body` -> `appeared` invalidation loop at ~100% CPU and -the sheet never displays; all subsequent input is dead. Native `Menu` -presentations (e.g. the "..." account menu) do NOT loop. - -Update (Lui_elements branch, iOS 26.5): the wedge did NOT reproduce — the sheet -presents full-screen at 0% CPU, the editor works, and edge-drag dismiss works -(grab the very top edge ~y148, not the title area). However the sheet's nav -chrome (title + toolbar items Close/Task/Save) does not render inside the -presented sheet — mount ops are emitted correctly; likely an iOS host -limitation. Verify sheet chrome on the macOS app if it matters. - -Workaround for testing capture/modal flows — temporarily render modals inline -(`app/application.ml`, where `V.Sheet.create` wraps `modal`): - -```ocaml -(match modal with - | None -> base - | Some content -> - let _ = status in - V.Navigation_stack.create ~title - ~on_path_change:(Ui.Event.Handler.create (fun _ -> ())) ~path:[] content) -``` - -This exercises identical reducer/runtime logic; only presentation differs. -Revert before finishing — never merge the patch. - -## Two-client sync testing (upload + download + live push) - -Boot a second sim device and install the same .app — a real second client exercises -the full bidirectional path: - -```bash -xcrun simctl boot # any second device, e.g. iPhone 17 Pro -xcrun simctl install # install takes ~1-2min on a fresh device -xcrun simctl launch com.example.bonsaiFlutterLogseqJournalHost -# sign in (same account), pick same graph, Retry -> E2EE password -> timeline -``` - -- A fresh device bootstrapping already proves the download path: the snapshot - includes all previously synced blocks (timeline shows them after unlock+apply). -- Live push: server sends `Changed{t}` -> client sends `Client.Pull{since}` -> - `Pull_ok{txs}` -> applied to mirror -> feed auto-refreshes (`Sync_refresh`). - A captured block on B reached A's mirror kvs + timeline in <10s untouched. -- Reconnect path: `Foreground_changed{foreground=true}` -> `start_websocket` -> - `websocket_opened` -> `Pull`. Cycle by launching another app on that device - (e.g. `xcrun simctl launch com.apple.Preferences`) then relaunching. - -### Idle websocket dies with no auto-reconnect (observed sync gap) - -An idle-foregrounded app's websocket gets closed ("WebSocket peer EOF" — visible -via the "!" error badge on the timeline -> Error info). `websocket_closed` marks -`sync_phase = Offline` but emits NO reconnect effect while the app stays -foregrounded (`lsof` shows zero TCP sockets). Remote changes stop propagating -until a background->foreground cycle or relaunch. Check socket liveness with -`lsof -nP -p | grep ESTABLISHED`. - -- iOS keyboard slide-to-type intro appears once per fresh device — tap Continue; - typed text usually still lands in the field. -- Watch disk space: booting a second sim device consumed several GiB; keep - swiftpm/Xcode-DerivedData caches cleared. -- Use `xcrun simctl io screenshot out.png` to capture a specific device's - screen when simulator windows overlap. - -## Known dead/inert UI paths (verified — do not misreport as regressions) - -- **Timeline/detail row actions are dead-wired.** `V.Native_list.vertical` - accepts `~on_row_event` but no call site passes it (application.ml ~1310, - ~1589, ~2261; journal_native_collection.ml ~172). Swipe "Status"/"Delete" and - context-menu "Change status"/"Delete block and descendants" presses DO reach - OCaml as `extension_event` (`type="row_event"` payload) but iterate over - `None` → emit_patch len=0 → the reducer never sees them. Consequences: - - Swipe-delete LOOKS like it works (Swift extension animates the row away) - but nothing persists — the row returns on relaunch. - - "Change status" taps are fully inert; the status sheet (the app's only - `V.Picker ~style:Inline` → radio_group path) is therefore unreachable via UI. - - Verified identical in the pre-refactor tree — pre-existing gap, not a - regression. To fix: wire a `~on_row_event` handler that decodes - `{"key":"status:"|"delete:","row":"block:"}` into the - `timeline-status:`/`timeline-delete:`/`detail-delete:` action strings. -- **Composer "Task" toggle is invisible.** `composer-task` (a `V.toggle - ~style:Button` in a Primary_action toolbar item) emits correctly (checked, - accessibility-label, width=40) but `set_leaf_label` cannot write an icon — - `Lui_protocol` does not support InlineIconName on kind `toggle` — so the - icon-only collapse yields an empty 40pt cell. Pre-existing; toggle is still - mounted/functional. -- **Overlay `tap_area` needs a spacer child to be hit-testable.** A - PressEnabled column sized via `~grow`/`~container_relative_frame` alone is a - zero-height strip — `grow` only fills width and `containerRelativeFrame` is a - no-op inside a non-scroll `V.Body.overlay` stack — so taps fall through to - rows (inert while `modal=Capture_sheet`) and no `lui_ocaml_press` is ever - emitted. Fixed by mounting `[ spacer ]` inside the column (e525e90). While the - composer is open the overlay swallows drag gestures: the timeline cannot - scroll until the composer collapses. - -## Debugging the lui emit/event pipeline (lui build path) - -`app/journal_lui_bridge.c` is the C boundary. Temporary fprintf probes that -proved events reach OCaml and showed exact patch ops (revert after use): - -```c -// in emit_patch (after caml_copy_string of the json): -fprintf(stderr, "[PATCH-DBG] %s len=%d: %s\n", tag, (int)caml_string_length(json), String_val(json)); -// in journal_ocaml_extension_event (top of fn): -fprintf(stderr, "[EVT-DBG] extension_event node=%d name=%s payload=%s\n", node, name, payload); -``` - -Console via `xcrun simctl launch --console-pty com.logseq.journal > log 2>&1` -(stderr is the only diag channel). `git checkout` the file to revert. - -## Devin Secrets Needed - -- `LOGSEQ_JOURNAL_USERNAME`, `LOGSEQ_JOURNAL_PASSWORD` — Cognito sign-in -- `LOGSEQ_JOURNAL_E2EE_PASSWORD` — graph encryption unlock - -## Other notes - -- Taps: screenshots are 1024x768; sim phone content ~x 380-650, y 130-650. Bottom - toolbar icons: calendar (~415,615), favorites star (~455,615), capture pencil - (~607,615); the account menu is a compact person-circle icon top-right - (~643,153) — a `menu-trigger`/`Menu` node whose tap opens a native UIMenu - anchored at the icon (entries: Attachment settings, Diagnostics, Switch - graph, Delete local graph copy destructive when Ready, Sign out). A - `Diagnostics` press opens the diagnostics sheet (verified presenting fine on - iOS 26.5 — no V.Sheet wedge, 0% CPU). Long-press a journal row for the - context menu (Change status / Delete block and descendants) — simulate via - move + left_mouse_down + ~1.3s wait + release; a plain click is not enough. -- The account menu mounts in TWO places: the floating chrome slot on - Journals, and the nav-bar `Primary_action` toolbar group on Favorites - (verify both — they exercise different backend paths). The "…" beside the - graph picker's refresh icon is the native `Secondary_action` overflow for - the shared `startup-diagnostics` item, not a menu — it is inert on the sim. -- `%cpu` from `ps` is cumulative since launch — a steady climb to ~100% after a - timeline load indicates the render loop; an idle healthy timeline sits ~3%. -- `xcrun simctl io recordVideo` fails with "Resource busy / Host recording - is already in progress" while the Devin session screen-recording is active — - they share the display recorder. For transient UI (loading rows, "Connecting" - indicators) that vanish faster than screenshot latency on a warm mirror, use - the `emit_patch` fprintf probe (see Debugging section) and grep stderr for the - expected message text + node kinds (e.g. `"Loading older journal days"` inside - a `row` with `spinner`+`text` ops) instead of trying to capture a frame. -- On a warm local mirror, pagination continuation rows - ("Loading more journal entries"/"Loading older journal days" at - day-continuation/feed-continuation slots) mount and resolve in <1s — they are - real but effectively unphotographable; verify via the emit probe. -- `confirm_dialog` Cancel+destructive "Delete local graph copy" → re-open of the - same graph skips the E2EE unlock (cached keychain key is retained per the - dialog copy) and re-downloads the snapshot — a quick full-cycle check. +This legacy entry point no longer carries separate build/install/graph-selection +or database inspection recipes. Explicit device identity, graph UUID, installed +binary/source identity, shared-owner coordination and privacy checks all belong +to the unified workflow. The external running-graph bridge is currently missing; +`runtime-identity-unavailable` blocks real acceptance. Never use similar graph +names, persisted selection, handwritten actual identity or direct DB/secret reads +as a substitute. Simulator host restamping does not establish phone/ABI acceptance. diff --git a/.agents/skills/testing-ios-simulator/SKILL.md b/.agents/skills/testing-ios-simulator/SKILL.md index 278a378e..03f2f1c0 100644 --- a/.agents/skills/testing-ios-simulator/SKILL.md +++ b/.agents/skills/testing-ios-simulator/SKILL.md @@ -5,6 +5,42 @@ description: How to build, sign, install, and drive the logseq_journal iOS app o # Testing logseq_journal on the iOS Simulator +## Required identity preflight and handoff + +Apply this checklist **before target-dependent UI actions and acceptance capture**: + +1. Coordinate the active owner; record the expected explicit Simulator UDID and + exact device type. `booted`, a device name, or a phone UDID is not sufficient. + Do not operate iPhone47, boot/create devices or change graphs without scope. +2. Obtain the expected stable graph UUID from authorized read-only catalog + evidence. Compare it with the running, foreground App's open graph through a + supported read-only observer; similar names, list position and disk selection + are not proof. Unknown/mismatch means stop and report the missing evidence. +3. Keep the successful maintained builder manifest and its original producer/link + records. Verify a clean source checkout at the expected commit, source byte + hashes, actual installed bundle/version/build and executable bytes. Resolve + the installed App container fresh, never reuse a previous installation path. +4. Use **`python3 tool/capture_acceptance.py`** as the single acceptance screenshot + entry point. Follow [the complete command and protocol](../../../docs/acceptance-capture.md). + There is currently **no external App runtime identity bridge**. The production + command returns `runtime-identity-unavailable`; do not substitute a hand-edited + JSON file, manual actual UUID, screenshots or a direct database/secret read. +5. Deliver the generated `acceptance.json` together with `screenshot.png` only + when the command succeeds. It binds expected and observed identities, source, + installed executable and manifest hashes, before/after runtime generations, + capture time and screenshot hash. A blocked run supplies a fixed reason and + produces no accepted screenshot. Keep private evidence outside the repository; + public tests/examples use synthetic identities, never graph content/credentials. +6. State the platform and limits explicitly: `experimental-host-restamp`, + `cross_abi_validated=false`, Simulator only. A stub, preflight-only build or + caller-supplied probe is not production runtime acceptance. None proves phone + or full iOS ABI acceptance. + +This checklist supersedes historical diagnostic recipes below. Raw `simctl` +screenshots/videos are troubleshooting artifacts, not accepted evidence. Existing +owner authorization is required for any install, launch, graph selection or +recovery operation described below. + ## Build environment Use [journal-build](../journal-build/SKILL.md) and its `scripts/build_journal_apple.sh` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77023313..658dd255 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,10 @@ jobs: OPAMPRECISETRACKING: 1 steps: - uses: actions/checkout@v4 + - name: Verify acceptance identity boundaries with synthetic fixtures + env: + PYTHONDONTWRITEBYTECODE: 1 + run: python3 tool/test_capture_acceptance.py - name: Verify Apple app icon packaging run: python3 .agents/skills/journal-build/scripts/test_apple_app_icon.py - name: Prepare native system libraries before cache lookup diff --git a/AGENTS.md b/AGENTS.md index 85ba3c55..8b6b11f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,3 +13,24 @@ ## Build Use `bash .agents/skills/journal-build/scripts/build_journal_apple.sh --help` as the Apple build entry point. Read [journal-build](.agents/skills/journal-build/SKILL.md) for target selection, prerequisites, cache/provenance, failure handling and acceptance limits. + +## UI operations and acceptance evidence + +Before operating a device or taking acceptance screenshots, verify the explicitly +expected device UDID/type and platform, stable graph UUID, and the installed App's +bundle/version/build, executable hash and source/build evidence. Re-resolve the +installed container each time. Names, graph-picker position, persisted selection, +old screenshots, manually filled actual fields and build labels alone are not +identity proof. Unknown or mismatched identity blocks acceptance and target-dependent +UI operations; do not switch graphs, reinstall, create devices or touch another +owner's device to work around a failed check. + +Use [testing-ios-simulator](.agents/skills/testing-ios-simulator/SKILL.md) for the +checklist and `python3 tool/capture_acceptance.py --help` as the unified Simulator +acceptance entry point. Read [the evidence contract](docs/acceptance-capture.md). +The current App lacks an external runtime graph-identity bridge: the tool blocks +with `runtime-identity-unavailable` and cannot yet produce real accepted screenshots. +Do not replace that boundary with constants or database/secret reads. Coordinate +shared Simulator ownership; Simulator/experimental host-restamp evidence never +proves real-device or cross-ABI acceptance. Keep evidence private and external; +public fixtures must contain only synthetic identities and no credentials/content. diff --git a/docs/acceptance-capture.md b/docs/acceptance-capture.md new file mode 100644 index 00000000..eb2a5ac1 --- /dev/null +++ b/docs/acceptance-capture.md @@ -0,0 +1,149 @@ +# Simulator acceptance capture + +`tool/capture_acceptance.py` is the unified acceptance entry point. It currently +**cannot accept real screenshots**: Journal has an internal Worker graph-state +query but no supported external bridge that proves the graph mounted by the +foreground process. The production observer fails with +`runtime-identity-unavailable` before creating an evidence directory or requesting +a screenshot. This is a blocking interface gap, not a completed runtime check. + +## Inputs and checks + +Use a successful `status: built` manifest from +[the maintained builder](../.agents/skills/journal-build/SKILL.md). Retain the App, +source checkout, original native producer records, native objects and final link +files. The tool reads the builder's current manifest format directly, without a +second manually maintained identity manifest. Supply expectations independently: + +```sh +python3 tool/capture_acceptance.py \ + --manifest /external/build/run-example/manifest.json \ + --source-root /path/to/clean/build-checkout \ + --output /external/private-evidence/new-capture \ + --udid 11111111-1111-4111-8111-111111111111 \ + --device-type com.apple.CoreSimulator.SimDeviceType.iPhone-17 \ + --bundle-id example.synthetic.journal --version 1.2.3 --build 42 \ + --source-sha aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + --graph-id 22222222-2222-4222-8222-222222222222 +``` + +All identities above are synthetic examples. Use the authorized actual expected +values locally; never infer graph identity from a name or picker position. +The command exits 1 with a fixed JSON reason when blocked and exits 0 only after +an accepted record is written. No `--actual-*`, observer executable, arbitrary +JSON response file, or skip-check flag exists. + +The preflight checks: + +- Exact available, booted iOS Simulator UDID and device type from `simctl list`. + Aliases and physical-device UDIDs are refused. It never boots, installs, + launches, changes a graph, accesses a database, or reads credentials. +- Clean Git checkout and actual source bytes, with complete source coverage in + the same scope as the builder; expected commit, bundle, version/build and build + input digest. Dirty builds require a clean committed rebuild for this workflow. +- Hash-bound original producer source/link records and retained native inputs; + the native source map must match current compiled source. Final native object, + target C, Swift object and link-map bytes must match the successful receipt. + Historical compiler/archive paths marked historical by the builder stay + historical; this tool does not replay compilation or execute recorded argv. +- Packaged App files and `Info.plist`, followed by a **fresh** + `simctl get_app_container UDID BUNDLE app`. Every installed packaged file and + the executable hash must match; version or source strings alone never suffice. + Actual installed Mach-O architecture/platform are read via `lipo`/`vtool`. +- Live observation before and after screenshot capture, with equal process + instance, graph and presentation generations. It rechecks device, container, + installed/build bytes and source after capture. Any failure discards the new + evidence directory. An existing output directory is never overwritten. + +Each system command has a 30-second timeout and captured output. Errors expose +fixed reason codes, never raw command stdout/stderr, manifest fields or local +paths. Successful records use an explicit allowlist, not a manifest/log dump. +Local builder receipts are trusted provenance records with rechecked bytes, not +signed attestations against a malicious local author. Do not forge or rewrite +receipts to turn a failed check into a pass. + +## Missing runtime interface: follow-up contract + +The production state owner is `Logseq_db_worker.graph_state`, exposed internally +by `Get_graph_state` in +[`logseq_db_worker_lui_service.mli`](../logseq_db_worker/lui/logseq_db_worker_lui_service.mli). +Its optional `graph_id` distinguishes open/unknown graph identity. The sync +catalog's persisted selected graph is a selection record, not proof of what is +currently rendered. Existing error-detail Graph ID labels describe an error +occurrence and are not a live observer either. + +A separate follow-up must implement and review an App-owned external read-only +bridge plus the CLI transport, without altering graph state or exposing graph +contents. No such bridge or transport is implemented by this PR. Minimum contract: + +1. The tool resolves the installed bundle and executable first, then sends a fresh + random challenge to that App's running process through a supported platform + channel. Caller-supplied response files/log snippets are not an endpoint. +2. The App answers from current Worker state **and mounted foreground presentation**. + Selection/loading/error/background states must block. An external transport + must verify process ownership, executable and foreground status against OS + evidence, rather than trusting an arbitrary process claiming a bundle ID. +3. The response has exactly the fields below. No token, user/email, graph name, + graph body, database location or diagnostic payload is permitted: + +```json +{ + "schema_version": 1, + "challenge": "fresh request UUID echoed by the App", + "udid": "11111111-1111-4111-8111-111111111111", + "bundle_id": "example.synthetic.journal", + "executable_sha256": "64 lowercase hexadecimal characters", + "source_sha": "40 lowercase hexadecimal characters", + "graph_id": "22222222-2222-4222-8222-222222222222", + "graph_generation": 7, + "presentation_generation": 7, + "phase": "open", + "foreground": true, + "pid": 123, + "process_instance": "33333333-3333-4333-8333-333333333333" +} +``` + +4. `process_instance` is fresh per process launch. Graph generation must increase + monotonically for every close/open/switch/loading transition, including a + switch away and back to the same graph; mounted presentation generation must + match it. Observer reads must be serialized with state/presentation commits. + Before/after equality therefore rejects transitions and restarts around the + screenshot. A transport unable to guarantee this must block; two graph UUID + strings alone cannot exclude stale presentation or a switch away and back. +5. The bridge returns unknown/error on timeout or missing facts. A future adapter + must include OS/process-authentication and real synthetic-Simulator integration + tests before enabling the currently blocked `Simulator.observe` method. + +The current Python test backend injects observations at that method solely to +exercise downstream validation/cleanup; these tests do not prove a live runtime +bridge, foreground authentication or actual Journal UI acceptance. Production +code has no injection option in its CLI. This keeps the follow-up scope explicit +without shipping a fake check or unrelated HTTP/UI feature. + +## Evidence and privacy + +A successful future production run produces `screenshot.png` and `acceptance.json` +in a newly created private external directory. The record binds expected and +observed identities, build/manifest/executable hashes, timestamp, process and graph +generations and screenshot SHA-256. It labels `platform: ios-simulator`, +`build_mode: experimental-host-restamp`, `cross_abi_validated: false`, and +`real_device_acceptance: false`. Stubs, probes, preflight-only manifests and +real-device Apps are refused. Simulator success never establishes phone or full +iOS ABI correctness. + +Screenshots can contain private graph content even though the record contains +only identities. Keep both private, review pixels before sharing, and never +commit generated evidence. Public tests use synthetic files and identities. + +Run the reusable tests without any Apple device: + +```sh +PYTHONDONTWRITEBYTECODE=1 python3 tool/test_capture_acceptance.py +``` + +They exercise real temporary files and Git repositories, mocked system commands, +and a CLI subprocess with a fixture `xcrun`. They cover stale containers, byte and +source mismatches, similar graph identities, runtime transitions, failed/timeout +capture, the production bridge blocker and sensitive-output suppression. Fixtures +are created outside the repository; CI runs this same suite. diff --git a/docs/agent-guide/implemented/process/2026-10-18-acceptance-identity-preflight.md b/docs/agent-guide/implemented/process/2026-10-18-acceptance-identity-preflight.md new file mode 100644 index 00000000..7072de1a --- /dev/null +++ b/docs/agent-guide/implemented/process/2026-10-18-acceptance-identity-preflight.md @@ -0,0 +1,71 @@ +# Acceptance Identity Preflight + +## Problem + +A screenshot taken on a similarly named graph, stale installed App or another device +cannot prove the requested change. Handwritten actual identity fields repeat an +assumption instead of observing the running application. + +## Decision + +Use tool/capture_acceptance.py as the sole acceptance capture entry point. Compare +explicit expected Simulator UDID, device type, bundle, version/build, source commit +and graph UUID with fresh system/build evidence. Verify the maintained builder's +successful manifest against source files, packaged files and the freshly resolved +installed bundle. Reject unknown or inconsistent identities and suppress raw +command output. Keep evidence outside the repository. + +The current App has an internal Get_graph_state query but no supported external +running-process bridge. The production observer therefore fails closed with +runtime-identity-unavailable. A documented challenge/response contract defines +that follow-up interface; fixture injection exists only in tests, never as a CLI +actual-identity option. This PR does not implement or claim real graph acceptance. +No App/HTTP/UI/protected spec or Dune changes belong to this work. + +## Alternatives considered + +### Graph name, manually supplied actual UUID, persisted selected graph + +Rejected: names collide, constants are unverified and disk selection need not be +the graph currently open in the foreground process. Direct database/secret reads +are outside the permitted observation boundary. + +## Acceptance criteria + +- Synthetic command/filesystem tests cover identities, stale installs, graph + transitions, failed/timed-out captures and privacy. Tests fail before implementation. +- Production capture blocks until a supported runtime observer is implemented. +- Guides require stable identity before UI operations and distinguish experimental + host restamping, Simulator acceptance and real-device acceptance. +- Independent review and formal separate PR; exact-head CI tracked to terminal state. + +## Consequences + +- Runtime bridge is deliberately absent: this tool cannot yet produce a real + accepted screenshot. The follow-up must bind Worker graph state to the mounted + foreground presentation, process instance and monotonically changing generation. +- Local build manifests are provenance receipts, not cryptographic attestations + against a malicious local author. Actual bytes and Git source are rechecked. +- Screen pixels can contain graph content. Evidence stays private unless reviewed; + public tests and examples use synthetic identities only. + +## Validation and scope + +- 30 synthetic command/filesystem/CLI tests pass, including explicit failing + tests before implementation and before the argument privacy/PNG fixes. +- The command boundary owns this preflight; App pure reducer tests cannot execute + installed-container or screenshot failures. No App state ownership was moved. +- The runtime observer remains deliberately unimplemented and blocks production + capture; no actual Simulator, graph or phone operation was performed. +- Independent read-only review found one argument-error privacy issue; a failing + regression preceded the fix. The reviewer reran all 30 tests and confirmed the + issue fixed with no new evidenced findings. +- New decision validation and diff whitespace checks pass. Full document checks + retain three existing invalid historical documents outside this change. + +- Resolved: user authorized this implementation and separate formal PR on + 2026-10-18, relayed with explicit evidence by the parent task. +- Resolved: the active status task owner retains exclusive Simulator + installation/UI access; this task runs fixtures only and never touches iPhone47. +- Resolved: missing external runtime graph observer is a reported follow-up scope, + not permission to accept a supplied actual UUID or read a database. diff --git a/tool/capture_acceptance.py b/tool/capture_acceptance.py new file mode 100644 index 00000000..bdb065e976 --- /dev/null +++ b/tool/capture_acceptance.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +"""Fail-closed Simulator acceptance capture. See docs/acceptance-capture.md. + +No external runtime identity bridge exists yet. The CLI deliberately blocks at +that boundary; the injected Simulator boundary is exercised with synthetic tests. +""" +import argparse +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import plistlib +import re +import shutil +import subprocess +import struct +import zlib +import uuid + +ROOT = Path(__file__).resolve().parents[1] +EXPECTED_KEYS = {'udid', 'device_type', 'bundle_id', 'version', 'build', 'source_sha', 'graph_id'} +OBSERVED_KEYS = {'schema_version', 'challenge', 'udid', 'bundle_id', 'executable_sha256', + 'source_sha', 'graph_id', 'graph_generation', 'presentation_generation', + 'phase', 'foreground', 'pid', 'process_instance'} +SOURCE_DIRS = {'app', 'swift', 'apple', 'assets', 'config', 'tool'} +BUILDER = '.agents/skills/journal-build/scripts' + + +class Rejected(Exception): + """A fixed public reason, never a command's output or an untrusted value.""" + def __init__(self, code): + self.code = code + super().__init__(code) + + +def require(condition, code): + if not condition: + raise Rejected(code) + + +def sha(path): + h = hashlib.sha256() + with path.open('rb') as stream: + for block in iter(lambda: stream.read(1024 * 1024), b''): + h.update(block) + return h.hexdigest() + + +def fingerprint(value): + return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest() + + +def fullmatch(pattern, value): + return isinstance(value, str) and re.fullmatch(pattern, value) is not None + + +def valid_uuid(value): + return fullmatch(r'[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}', value) + + +def command(argv): + try: + return subprocess.run(argv, capture_output=True, text=True, check=True, timeout=30).stdout.strip() + except subprocess.TimeoutExpired: + raise Rejected('command-timeout') from None + except (subprocess.CalledProcessError, OSError, UnicodeError): + raise Rejected('command-failed') from None + + +def safe_name(name): + return (isinstance(name, str) and bool(name) and not PurePosixPath(name).is_absolute() + and '..' not in PurePosixPath(name).parts and '\\' not in name + and str(PurePosixPath(name)) == name) + + +def tree_files(directory): + """Same source scope as journal-build, rejecting symbolic links outright.""" + ignored = {'.git', '.build', '_build', 'node_modules', '__pycache__'} + result = {} + require(not directory.is_symlink(), 'build-evidence-mismatch') + for parent, dirs, names in os.walk(directory): + dirs[:] = sorted(d for d in dirs if d not in ignored and not d.startswith('.ci-')) + for name in dirs + names: + if (Path(parent) / name).is_symlink(): + raise ValueError('symlink') + for name in names: + path = Path(parent) / name + if path.is_file() and not name.endswith('.pyc'): + result[path.relative_to(directory).as_posix()] = sha(path) + return result + + +def source_files(directory): + result = {} + for path in directory.iterdir(): + if path.name in SOURCE_DIRS or path.name.startswith('logseq_'): + if path.is_dir(): + result.update({path.name + '/' + n: h for n, h in tree_files(path).items()}) + elif path.is_file(): + result[path.name] = sha(path) + elif path.name in {'dune', 'dune-project', 'dune-workspace'} and path.is_file(): + result[path.name] = sha(path) + result.update({BUILDER + '/' + n: h for n, h in tree_files(directory / BUILDER).items()}) + return result + + +class Simulator: + def device(self, expected): + try: + devices = json.loads(command(['xcrun', 'simctl', 'list', 'devices', '--json']))['devices'] + matches = [(runtime, d) for runtime, rows in devices.items() for d in rows + if d.get('udid') == expected['udid']] + require(len(matches) == 1, 'device-mismatch') + runtime, device = matches[0] + require(runtime.startswith('com.apple.CoreSimulator.SimRuntime.iOS-') + and device['deviceTypeIdentifier'] == expected['device_type'] + and device['state'] == 'Booted' and device['isAvailable'] is True, 'device-mismatch') + return {'udid': device['udid'], 'device_type': device['deviceTypeIdentifier'], 'runtime': runtime} + except (KeyError, ValueError, TypeError): + raise Rejected('device-mismatch') from None + + def container(self, expected): + value = command(['xcrun', 'simctl', 'get_app_container', expected['udid'], expected['bundle_id'], 'app']) + path = Path(value) + require(path.is_absolute() and path.suffix == '.app' and path.is_dir() + and not path.is_symlink() and path == path.resolve(), 'installed-artifact-mismatch') + # Reject a container from another device or data-container namespace. + require('/Devices/' + expected['udid'] + '/data/Containers/Bundle/Application/' in str(path), + 'installed-artifact-mismatch') + return path + + def observe(self, identity, challenge): + # Do not read persisted selection, DBs, arbitrary JSON, log text, secrets or + # a caller-supplied actual UUID. A supported App-owned bridge is required. + raise Rejected('runtime-identity-unavailable') + + def screenshot(self, udid, path): + command(['xcrun', 'simctl', 'io', udid, 'screenshot', str(path)]) + + +def validate_expected(expected): + require(isinstance(expected, dict) and set(expected) == EXPECTED_KEYS, 'invalid-expectation') + require(valid_uuid(expected['udid']) and valid_uuid(expected['graph_id']) + and fullmatch(r'com\.apple\.CoreSimulator\.SimDeviceType\.[A-Za-z0-9-]+', expected['device_type']) + and fullmatch(r'[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+', expected['bundle_id']) + and all(fullmatch(r'[0-9]+(?:\.[0-9]+){0,2}', expected[k]) for k in ('version', 'build')) + and fullmatch(r'[0-9a-f]{40}', expected['source_sha']), 'invalid-expectation') + + +def build_evidence(manifest_path, source_root, expected): + code = 'build-evidence-mismatch' + try: + manifest_bytes = manifest_path.read_bytes() + m = json.loads(manifest_bytes) + require(m['status'] == 'built' and m['mode'] == 'experimental-host-restamp' + and m['cross_abi_validated'] is False and m['source_status'] == '' + and m['source_sha'] == expected['source_sha'] and m['sdk_name'] == 'iphonesimulator' + and fullmatch(r'arm64-apple-ios[0-9]+\.[0-9]+-simulator', m['target']) + and m['inputs']['target'] == m['target'], code) + require(m['cache_key'] == fingerprint(m['inputs']), code) + require(command(['git', '-C', str(source_root), 'rev-parse', '--show-toplevel']) == str(source_root) + and command(['git', '-C', str(source_root), 'rev-parse', 'HEAD']) == expected['source_sha'] + and command(['git', '-C', str(source_root), 'status', '--porcelain', '--untracked-files=all']) == '', code) + actual_source = source_files(source_root) + require('app/application.ml' in actual_source and BUILDER + '/journal_build.py' in actual_source + and actual_source == m['inputs']['source'], code) + artifact = m['artifact'] + app = Path(artifact['app']) + require(app.is_absolute() and app.is_dir() and app == app.resolve(), code) + files = artifact['files'] + require(files and all(safe_name(n) and fullmatch(r'[0-9a-f]{64}', h) for n, h in files.items()) + and tree_files(app) == files, code) + info = plistlib.loads((app / 'Info.plist').read_bytes()) + require(info == artifact['info'] and info['CFBundleExecutable'] == 'JournalApp' + and info['CFBundleIdentifier'] == expected['bundle_id'] + and info['CFBundleShortVersionString'] == expected['version'] + and info['CFBundleVersion'] == expected['build'] + and info['JournalSourceRevision'] == expected['source_sha'] + and info['JournalBuildInputDigest'] == m['cache_key'] + and info['JournalBuildMode'] == m['mode'] + and info['JournalCrossABIValidated'] is False + and info['JournalOCamlRuntimeOrigin'] == 'host-arm64-restamp' + and artifact['executable_sha256'] == files['JournalApp'], code) + native_evidence(m) + return {'files': files, 'executable_sha256': files['JournalApp'], + 'manifest_sha256': hashlib.sha256(manifest_bytes).hexdigest(), 'cache_key': m['cache_key'], + 'mode': m['mode']} + except (OSError, ValueError, KeyError, TypeError, plistlib.InvalidFileException): + raise Rejected(code) from None + + +def native_evidence(m): + """Recheck retained producer records and final link bytes, never execute argv.""" + code = 'build-evidence-mismatch' + deps = m['inputs']['dependencies'] + envelope = deps['producer'] + require(deps['schema_version'] == envelope['schema_version'] + and envelope['schema_version'] in (1, 2), code) + retained = deps['files'] + require(retained and all(Path(p).is_absolute() and fullmatch(r'[0-9a-f]{64}', h) + and sha(Path(p)) == h for p, h in retained.items()), code) + def record(name): + entry = envelope[name] + candidates = [Path(p) for p, h in retained.items() + if Path(p).name == Path(entry['path']).name and h == entry['sha256']] + require(len(candidates) == 1, code) + return json.loads(candidates[0].read_bytes()) + producer = record('source') + link = record('link') + require(m['native_source_sha'] == deps['source_sha'] == producer['commit'], code) + require(fullmatch(r'[0-9a-f]{40}', producer['commit']), code) + native_sources = dict(producer['native_source_sha256']) + for name, digest in link['device_c_source_sha256'].items(): + native_sources['logseq_sync/lib/effect_runner/platform/' + name] = digest + required = {name: h for name, h in m['inputs']['source'].items() + if Path(name).suffix in {'.ml', '.mli', '.c', '.h'} + and not {'tool', 'test', 'apple-tests'}.intersection(Path(name).parts)} + require(required == native_sources + and producer['production_native_object_sha256'] == link['production_host_sha256'] + and retained[producer['production_native_object']] == link['production_host_sha256'], code) + native = m['native_object'] + final = m['production_link'] + require(native['mode'] == m['mode'] and Path(native['path']).is_absolute() + and sha(Path(native['path'])) == native['sha256'] + and final['cross_abi_validated'] is False + and sha(Path(final['link_map'])) == final['link_map_sha256'] + and sha(Path(final['target_c']['path'])) == final['target_c']['sha256'] + and final['swift_objects'] + and all(sha(Path(p)) == h for p, h in final['swift_objects'].items()), code) + + +def installed_artifact(backend, expected, build): + path = backend.container(expected) + try: + require(tree_files(path) == build['files'], 'installed-artifact-mismatch') + except (OSError, ValueError): + raise Rejected('installed-artifact-mismatch') from None + require(command(['xcrun', 'lipo', '-archs', str(path / 'JournalApp')]) == 'arm64' + and re.search(r'platform\s+IOSSIMULATOR\b', + command(['xcrun', 'vtool', '-show-build', str(path / 'JournalApp')])), + 'installed-artifact-mismatch') + return path + + +def observation(backend, identity): + challenge = str(uuid.uuid4()) + value = backend.observe(identity, challenge) + code = 'runtime-identity-mismatch' + require(isinstance(value, dict) and set(value) == OBSERVED_KEYS, code) + require(value['schema_version'] == 1 and value['challenge'] == challenge + and all(value[k] == identity[k] for k in ('udid', 'bundle_id', 'executable_sha256', 'source_sha', 'graph_id')) + and value['phase'] == 'open' and value['foreground'] is True + and type(value['pid']) is int and value['pid'] > 0 + and valid_uuid(value['process_instance']) + and type(value['graph_generation']) is int and value['graph_generation'] >= 0 + and type(value['presentation_generation']) is int + and value['presentation_generation'] == value['graph_generation'], code) + return {k: v for k, v in value.items() if k != 'challenge'} + + +def valid_png(path): + """Reject partial/empty captures with PNG chunk framing and CRC checks.""" + if not path.is_file() or path.is_symlink() or not 32 < path.stat().st_size <= 64 * 1024 * 1024: + return False + data = path.read_bytes() + if data[:8] != b'\x89PNG\r\n\x1a\n': + return False + offset, image_data = 8, False + while offset + 12 <= len(data): + length = struct.unpack('>I', data[offset:offset + 4])[0] + end = offset + 12 + length + if end > len(data): + return False + kind = data[offset + 4:offset + 8] + payload = data[offset + 8:end - 4] + if zlib.crc32(kind + payload) != struct.unpack('>I', data[end - 4:end])[0]: + return False + if offset == 8 and (kind != b'IHDR' or length != 13 + or min(struct.unpack('>II', payload[:8])) == 0): + return False + image_data |= kind == b'IDAT' and length > 0 + if kind == b'IEND': + return length == 0 and end == len(data) and image_data + offset = end + return False + + +def capture(manifest_path, source_root, expected, output, *, backend=None): + """Capture only after system/build checks and a live observation on both sides. + + backend is a test seam, not a command-line provider or accepted evidence file. + No production implementation currently overrides Simulator.observe. + """ + validate_expected(expected) + output = Path(output).resolve() + source_root = Path(source_root).resolve() + require(output != ROOT and ROOT not in output.parents and output != source_root + and source_root not in output.parents and not output.exists(), 'unsafe-output') + build = build_evidence(Path(manifest_path), source_root, expected) + backend = backend or Simulator() + device = backend.device(expected) + container = installed_artifact(backend, expected, build) + identity = {**expected, 'executable_sha256': build['executable_sha256']} + before = observation(backend, identity) + created = False + try: + output.mkdir(parents=True, exist_ok=False, mode=0o700) + created = True + screenshot = output / 'screenshot.png' + backend.screenshot(expected['udid'], screenshot) + require(valid_png(screenshot), 'screenshot-failed') + after = observation(backend, identity) + require(after == before, 'runtime-identity-mismatch') + require(backend.device(expected) == device, 'device-mismatch') + require(installed_artifact(backend, expected, build) == container, 'installed-artifact-mismatch') + # Recheck retained bytes/source at the end so a changing build cannot be accepted. + require(build_evidence(Path(manifest_path), source_root, expected) == build, 'build-evidence-mismatch') + record = {'schema_version': 1, 'status': 'accepted', 'platform': 'ios-simulator', + 'captured_at': datetime.now(timezone.utc).isoformat(), 'expected': dict(expected), + 'observed': before, 'device': device, 'build_mode': build['mode'], + 'cross_abi_validated': False, 'real_device_acceptance': False, + 'manifest_sha256': build['manifest_sha256'], 'build_input_digest': build['cache_key'], + 'screenshot_sha256': sha(screenshot)} + (output / 'acceptance.json').write_text(json.dumps(record, indent=2) + '\n') + return record + except BaseException: + if created: + shutil.rmtree(output) + raise + + +class SafeParser(argparse.ArgumentParser): + def error(self, message): + # argparse's default includes unrecognized argv values in stderr. + raise Rejected('invalid-arguments') + + +def main(argv=None): + parser = SafeParser(description=__doc__) + for name in ('manifest', 'source-root', 'output'): + parser.add_argument('--' + name, required=True, type=Path) + for name in sorted(EXPECTED_KEYS): + parser.add_argument('--' + name.replace('_', '-'), required=True) + try: + args = parser.parse_args(argv) + capture(args.manifest, args.source_root, {k: getattr(args, k) for k in EXPECTED_KEYS}, args.output) + print(json.dumps({'status': 'accepted'})) + return 0 + except Rejected as error: + print(json.dumps({'status': 'blocked', 'reason': error.code})) + return 1 + except Exception: + # Never echo malformed manifests, command stderr, local paths or secrets. + print(json.dumps({'status': 'blocked', 'reason': 'capture-failed'})) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tool/test_capture_acceptance.py b/tool/test_capture_acceptance.py new file mode 100644 index 00000000..e833cac8 --- /dev/null +++ b/tool/test_capture_acceptance.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +"""Synthetic filesystem/command-boundary coverage; never operates Apple devices.""" +import contextlib +import copy +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import plistlib +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parents[1] +TOOL = ROOT / 'tool/capture_acceptance.py' +UDID = '11111111-1111-4111-8111-111111111111' +GRAPH = '22222222-2222-4222-8222-222222222222' +OTHER_GRAPH = '22222222-2222-4222-8222-222222222223' +DEVICE_TYPE = 'com.apple.CoreSimulator.SimDeviceType.iPhone-17' +SECRET = 'private-token-and-graph-body-never-print' + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +class AcceptanceTests(unittest.TestCase): + def setUp(self): + self.assertTrue(TOOL.is_file(), 'Missing acceptance tool: strict identity behavior is absent') + spec = importlib.util.spec_from_file_location('capture_acceptance', TOOL) + self.api = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.api) + self.temp = tempfile.TemporaryDirectory(prefix='journal-acceptance-test-') + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name).resolve() + self.source = self.base / 'source' + self.source.mkdir() + self.source_files = {'app/application.ml': b'(* synthetic source *)\n', + '.agents/skills/journal-build/scripts/journal_build.py': b'# synthetic builder\n'} + for name, data in self.source_files.items(): + path = self.source / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + for argv in (['init', '-q'], ['config', 'user.name', 'Synthetic Test'], + ['config', 'user.email', 'test@example.invalid'], ['add', '.'], + ['commit', '-qm', 'synthetic']): + subprocess.run(['git', *argv], cwd=self.source, check=True, capture_output=True) + sha = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=self.source, text=True).strip() + self.expected = dict(udid=UDID, device_type=DEVICE_TYPE, bundle_id='example.synthetic.journal', + version='1.2.3', build='42', source_sha=sha, graph_id=GRAPH) + self.app = self.base / 'built.app' + self.app.mkdir() + self.info = dict(CFBundleIdentifier=self.expected['bundle_id'], CFBundleExecutable='JournalApp', + CFBundleShortVersionString='1.2.3', CFBundleVersion='42', + JournalSourceRevision=sha, JournalBuildMode='experimental-host-restamp', + JournalCrossABIValidated=False, JournalOCamlRuntimeOrigin='host-arm64-restamp') + self.inputs = dict(source={n: digest(b) for n, b in self.source_files.items()}, + target='arm64-apple-ios26.0-simulator', flags={'mode': 'experimental-host-restamp'}) + self.native_input = self.base / 'production-host.o' + self.native_input.write_bytes(b'synthetic host object') + self.target_c = self.base / 'target-c.o' + self.target_c.write_bytes(b'synthetic target C') + self.linkmap = self.base / 'link-map.txt' + self.linkmap.write_bytes(b'synthetic final link map') + self.producer_source = self.base / 'producer-source.json' + self.producer_source.write_text(json.dumps(dict(commit=sha, + native_source_sha256={'app/application.ml': digest(self.source_files['app/application.ml'])}, + production_native_object=str(self.native_input), production_native_object_sha256=digest(self.native_input.read_bytes())))) + self.producer_link = self.base / 'producer-link.json' + self.producer_link.write_text(json.dumps(dict(production_host_sha256=digest(self.native_input.read_bytes()), device_c_source_sha256={}))) + self.inputs['dependencies'] = dict(source_sha=sha, schema_version=2, + producer=dict(schema_version=2, source={'path': self.producer_source.name, 'sha256': digest(self.producer_source.read_bytes())}, + link={'path': self.producer_link.name, 'sha256': digest(self.producer_link.read_bytes())}), + files={str(p): digest(p.read_bytes()) for p in (self.producer_source, self.producer_link, self.native_input)}) + self.cache_key = digest(json.dumps(self.inputs, sort_keys=True).encode()) + self.info['JournalBuildInputDigest'] = self.cache_key + (self.app / 'Info.plist').write_bytes(plistlib.dumps(self.info)) + (self.app / 'JournalApp').write_bytes(b'synthetic executable') + (self.app / 'Assets.car').write_bytes(b'synthetic resources') + self.installed = self.base / 'CoreSimulator/Devices' / UDID / 'data/Containers/Bundle/Application' / 'synthetic' / 'Journal.app' + self.installed.mkdir(parents=True) + for p in self.app.iterdir(): + (self.installed / p.name).write_bytes(p.read_bytes()) + self.manifest = dict(status='built', mode='experimental-host-restamp', cross_abi_validated=False, + source_sha=sha, source_status='', cache_key=self.cache_key, + sdk_name='iphonesimulator', target=self.inputs['target'], inputs=self.inputs, + artifact=dict(app=str(self.app), info=copy.deepcopy(self.info), + files={p.name: digest(p.read_bytes()) for p in self.app.iterdir()}, + executable_sha256=digest((self.app / 'JournalApp').read_bytes()))) + self.manifest.update(native_source_sha=sha, + native_object={'path': str(self.native_input), 'sha256': digest(self.native_input.read_bytes()), 'mode': 'experimental-host-restamp'}, + production_link=dict(link_map=str(self.linkmap), link_map_sha256=digest(self.linkmap.read_bytes()), + target_c={'path': str(self.target_c), 'sha256': digest(self.target_c.read_bytes())}, + swift_objects={str(self.target_c): digest(self.target_c.read_bytes())}, cross_abi_validated=False)) + self.binary_platform = 'IOSSIMULATOR' + self.manifest_path = self.base / 'manifest.json' + self.output = self.base / 'evidence' + self.device = dict(udid=UDID, state='Booted', isAvailable=True, + deviceTypeIdentifier=DEVICE_TYPE, name='Synthetic iPhone') + self.observations = [] + self.commands = [] + self.capture_action = None + self.command_failure = None + self.container_resolutions = 0 + fixture = self + + class FixtureSimulator(self.api.Simulator): + def observe(self, identity, challenge): + value = dict(schema_version=1, challenge=challenge, udid=UDID, + bundle_id=fixture.expected['bundle_id'], + executable_sha256=fixture.manifest['artifact']['executable_sha256'], + source_sha=fixture.expected['source_sha'], graph_id=GRAPH, + graph_generation=7, presentation_generation=7, phase='open', + foreground=True, pid=123, process_instance='33333333-3333-4333-8333-333333333333') + if fixture.observations: + value.update(fixture.observations.pop(0)) + return value + self.backend = FixtureSimulator() + + def command(self, argv, **kwargs): + self.commands.append(argv) + self.assertTrue(0 < kwargs['timeout'] <= 30) + self.assertTrue(kwargs.get('capture_output')) + if self.command_failure: + raise self.command_failure + args = argv[1:] + if args == ['simctl', 'list', 'devices', '--json']: + result = json.dumps({'devices': {'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [self.device]}}) + elif args == ['simctl', 'get_app_container', UDID, self.expected['bundle_id'], 'app']: + self.container_resolutions += 1 + result = str(self.installed) + '\n' + elif args[:2] == ['lipo', '-archs']: + result = 'arm64' + elif args[:2] == ['vtool', '-show-build']: + result = 'platform ' + self.binary_platform + elif args[:4] == ['simctl', 'io', UDID, 'screenshot']: + if self.capture_action: + self.capture_action(Path(args[-1])) + else: + # Real minimal PNG fixture, decoded and checked by the capture tool. + import base64 + Path(args[-1]).write_bytes(base64.b64decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=')) + result = '' + else: + self.fail('Unexpected system command: ' + repr(argv)) + return subprocess.CompletedProcess(argv, 0, result, '') + + def capture(self, backend=None): + self.manifest_path.write_text(json.dumps(self.manifest)) + with patch.object(self.api.subprocess, 'run', side_effect=self.command) as run: + # Git remains real and read-only; only xcrun is a synthetic boundary. + def dispatch(argv, **kwargs): + if argv[0] == 'xcrun': + return self.command(argv, **kwargs) + return ORIGINAL_RUN(argv, **kwargs) + run.side_effect = dispatch + return self.api.capture(self.manifest_path, self.source, self.expected, + self.output, backend=backend or self.backend) + + def rejects(self, code): + with self.assertRaises(self.api.Rejected) as raised: + self.capture() + self.assertEqual(raised.exception.code, code) + self.assertNotIn(SECRET, str(raised.exception)) + self.assertFalse((self.output / 'acceptance.json').exists()) + self.assertFalse(any(self.output.glob('*.png')) if self.output.exists() else False) + + def reseal(self): + # Keep cheap stamps internally consistent, forcing tests to exercise the + # deeper source/installed-byte boundary rather than only a stale digest. + self.manifest['cache_key'] = digest(json.dumps(self.manifest['inputs'], sort_keys=True).encode()) + self.info['JournalBuildInputDigest'] = self.manifest['cache_key'] + self.manifest['artifact']['info'] = copy.deepcopy(self.info) + for directory in (self.app, self.installed): + (directory / 'Info.plist').write_bytes(plistlib.dumps(self.info)) + self.manifest['artifact']['files']['Info.plist'] = digest((self.app / 'Info.plist').read_bytes()) + + def test_resealed_producer_source_must_still_match_actual_source(self): + value = json.loads(self.producer_source.read_text()) + value['native_source_sha256']['app/application.ml'] = 'a' * 64 + self.producer_source.write_text(json.dumps(value)) + new_hash = digest(self.producer_source.read_bytes()) + deps = self.manifest['inputs']['dependencies'] + deps['files'][str(self.producer_source)] = new_hash + deps['producer']['source']['sha256'] = new_hash + self.reseal() + self.rejects('build-evidence-mismatch') + + def test_capture_binds_bytes_graph_and_limits(self): + record = self.capture() + saved = json.loads((self.output / 'acceptance.json').read_text()) + self.assertEqual(record, saved) + self.assertEqual(record['expected'], self.expected) + self.assertEqual(record['observed']['graph_id'], GRAPH) + self.assertEqual(record['observed']['executable_sha256'], self.manifest['artifact']['executable_sha256']) + self.assertFalse(record['cross_abi_validated']) + self.assertEqual(record['platform'], 'ios-simulator') + self.assertEqual(record['screenshot_sha256'], digest((self.output / 'screenshot.png').read_bytes())) + self.assertGreaterEqual(self.container_resolutions, 2) + + def test_similar_graph_names_cannot_replace_uuid(self): + self.observations = [dict(graph_id=OTHER_GRAPH)] + self.rejects('runtime-identity-mismatch') + + def test_graph_name_or_unknown_uuid_is_rejected(self): + for value in ('Synthetic graph', '', None, 'booted'): + with self.subTest(value=value): + self.expected['graph_id'] = value + self.rejects('invalid-expectation') + + def test_device_udid_and_type_are_exact(self): + for changes in (dict(udid=OTHER_GRAPH), dict(deviceTypeIdentifier='com.apple.CoreSimulator.SimDeviceType.iPad'), + dict(state='Shutdown'), dict(isAvailable=False)): + with self.subTest(changes=changes): + self.device.update(changes) + self.rejects('device-mismatch') + self.device.update(udid=UDID, deviceTypeIdentifier=DEVICE_TYPE, state='Booted', isAvailable=True) + + def test_physical_device_and_ambiguous_alias_rejected(self): + for value in ('booted', '00008110-000000000000001E'): + with self.subTest(value=value): + self.expected['udid'] = value + self.rejects('invalid-expectation') + + def test_missing_expected_identity_is_rejected(self): + original = copy.deepcopy(self.expected) + for key in original: + with self.subTest(key=key): + self.expected = {k: v for k, v in original.items() if k != key} + self.rejects('invalid-expectation') + + def test_stale_install_binary_and_resources_rejected(self): + for filename in ('JournalApp', 'Assets.car', 'Info.plist'): + with self.subTest(filename=filename): + path = self.installed / filename + data = path.read_bytes() + path.write_bytes(b'stale ' + data) + self.rejects('installed-artifact-mismatch') + path.write_bytes(data) + + def test_changed_bundle_version_source_in_manifest_not_trusted(self): + for key in ('CFBundleIdentifier', 'CFBundleShortVersionString', 'CFBundleVersion', 'JournalSourceRevision'): + with self.subTest(key=key): + self.manifest['artifact']['info'][key] = 'forged' + self.rejects('build-evidence-mismatch') + self.manifest['artifact']['info'][key] = self.info[key] + + def test_missing_version_or_source_fails_closed(self): + for key in ('source_sha', 'source_status', 'inputs', 'artifact', 'cache_key'): + with self.subTest(key=key): + value = self.manifest.pop(key) + self.rejects('build-evidence-mismatch') + self.manifest[key] = value + + def test_wrong_or_unaccepted_build_target(self): + for key, values in {'status': ['preflight-passed', 'native-produced'], 'mode': ['link-only', 'probe-object'], + 'sdk_name': ['iphoneos', 'macosx'], 'target': ['arm64-apple-ios26.0']}.items(): + for value in values: + with self.subTest(key=key, value=value): + old = self.manifest[key] + self.manifest[key] = value + self.rejects('build-evidence-mismatch') + self.manifest[key] = old + + def test_source_strings_need_matching_git_and_files(self): + self.expected['source_sha'] = 'a' * 40 + self.manifest['source_sha'] = 'a' * 40 + self.rejects('build-evidence-mismatch') + + def test_source_files_and_manifest_coverage_cannot_be_omitted(self): + (self.source / 'app/application.ml').write_bytes(b'changed') + self.rejects('build-evidence-mismatch') + (self.source / 'app/application.ml').write_bytes(self.source_files['app/application.ml']) + self.manifest['inputs']['source'].pop('app/application.ml') + self.reseal() + self.rejects('build-evidence-mismatch') + + def test_package_missing_or_changed_rejected(self): + (self.app / 'JournalApp').write_bytes(b'old build') + self.rejects('build-evidence-mismatch') + + def test_manifest_paths_cannot_traverse_or_use_symlinks(self): + for name in ('../outside', '/absolute', 'nested/../../outside'): + with self.subTest(name=name): + self.manifest['artifact']['files'][name] = 'a' * 64 + self.rejects('build-evidence-mismatch') + del self.manifest['artifact']['files'][name] + (self.installed / 'JournalApp').unlink() + (self.installed / 'JournalApp').symlink_to(self.app / 'JournalApp') + self.rejects('installed-artifact-mismatch') + + def test_manual_actual_fields_and_unknown_fields_are_rejected(self): + self.expected['actual_graph_id'] = GRAPH + self.rejects('invalid-expectation') + + def test_runtime_protocol_rejects_missing_or_unbound_values(self): + for change in (dict(challenge='replay'), dict(udid=OTHER_GRAPH), dict(pid=0), + dict(source_sha='a' * 40), dict(executable_sha256='b' * 64), + dict(bundle_id='wrong'), dict(graph_id=None), dict(phase='opening'), + dict(foreground=False), dict(graph_generation=-1), + dict(presentation_generation=6), dict(schema_version=2), + dict(process_instance=''), dict(token=SECRET)): + with self.subTest(change=change): + self.observations = [change] + self.rejects('runtime-identity-mismatch') + + def test_graph_or_process_transition_during_capture_discards_pixels(self): + for change in (dict(graph_id=OTHER_GRAPH), dict(graph_generation=8, presentation_generation=8), + dict(pid=124), dict(process_instance='44444444-4444-4444-8444-444444444444')): + with self.subTest(change=change): + self.observations = [{}, change] + self.rejects('runtime-identity-mismatch') + + def test_reinstall_during_capture_is_detected(self): + previous = self.installed + def replace(path): + # Simulate a fresh container with identical bytes after preflight. + self.capture_action = None + self.command(['xcrun', 'simctl', 'io', UDID, 'screenshot', str(path)], timeout=30, capture_output=True) + self.installed = self.base / 'replacement.app' + self.installed.mkdir() + for p in previous.iterdir(): + (self.installed / p.name).write_bytes(p.read_bytes()) + self.capture_action = replace + self.rejects('installed-artifact-mismatch') + + def test_empty_or_non_png_capture_rejected(self): + for content in (b'', b'not a PNG', b'\x89PNG\r\n\x1a\n' + b'x' * 40): + with self.subTest(content=content): + self.capture_action = lambda p: p.write_bytes(content) + self.rejects('screenshot-failed') + + def test_failed_and_timed_out_capture_do_not_leave_evidence(self): + for error in (subprocess.CalledProcessError(1, ['xcrun'], output=SECRET, stderr=SECRET), + subprocess.TimeoutExpired(['xcrun'], 30, output=SECRET, stderr=SECRET)): + with self.subTest(error=type(error).__name__): + def fail_after_write(path): + path.write_bytes(b'partial capture') + raise error + self.capture_action = fail_after_write + self.rejects('command-failed' if isinstance(error, subprocess.CalledProcessError) else 'command-timeout') + + def test_command_output_never_leaks_sensitive_data(self): + self.command_failure = subprocess.CalledProcessError(1, ['xcrun'], output=SECRET, stderr=SECRET) + output = io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): + self.rejects('command-failed') + self.assertNotIn(SECRET, output.getvalue()) + + def test_manifest_secrets_and_graph_body_are_not_copied_to_record(self): + self.manifest['ignored_environment'] = {'credential': SECRET} + self.manifest['commands'] = [{'stdout': SECRET}] + self.capture() + self.assertNotIn(SECRET, (self.output / 'acceptance.json').read_text()) + + def test_native_producer_and_final_link_evidence_is_required(self): + for field in ('native_source_sha', 'native_object', 'production_link'): + with self.subTest(field=field): + self.output = self.base / ('evidence-' + field) + value = self.manifest.pop(field, None) + self.rejects('build-evidence-mismatch') + if value is not None: + self.manifest[field] = value + + def test_native_input_or_producer_source_change_is_rejected(self): + self.native_input.write_bytes(b'stale producer') + self.rejects('build-evidence-mismatch') + + def test_actual_macho_target_is_checked(self): + self.binary_platform = 'IOS' + self.rejects('installed-artifact-mismatch') + + def test_default_runtime_observer_blocks_without_capture(self): + with self.assertRaises(self.api.Rejected) as raised: + self.capture(backend=self.api.Simulator()) + self.assertEqual(raised.exception.code, 'runtime-identity-unavailable') + self.assertFalse(any('screenshot' in argv for argv in self.commands)) + self.assertFalse(self.output.exists()) + + def test_output_inside_repository_or_existing_output_is_rejected(self): + original = self.output + self.output = ROOT / 'forbidden-acceptance-output' + self.rejects('unsafe-output') + self.output = original + self.output.mkdir() + (self.output / 'keep.txt').write_text('keep') + self.rejects('unsafe-output') + self.assertEqual((self.output / 'keep.txt').read_text(), 'keep') + + def test_invalid_cli_arguments_never_echo_values(self): + for argv in (["--token", SECRET], ["--manifest", SECRET], ["--udid", SECRET]): + with self.subTest(argv=argv[:1]): + result = ORIGINAL_RUN([sys.executable, str(TOOL), *argv], capture_output=True, text=True) + self.assertEqual(result.returncode, 1) + self.assertEqual(json.loads(result.stdout)['reason'], 'invalid-arguments') + self.assertNotIn(SECRET, result.stdout + result.stderr) + + def test_cli_has_no_identity_bypass_and_reports_bridge_gap(self): + self.manifest_path.write_text(json.dumps(self.manifest)) + fakebin = self.base / 'bin' + fakebin.mkdir() + xcrun = fakebin / 'xcrun' + xcrun.write_text('#!' + sys.executable + '\nimport json, sys\n' + 'args=sys.argv[1:]\n' + 'if args == ["simctl","list","devices","--json"]:\n' + ' print(' + repr(json.dumps({'devices': {'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [self.device]}})) + ')\n' + 'elif args[:2] == ["simctl","get_app_container"]:\n' + ' print(' + repr(str(self.installed)) + ')\n' + 'elif args[:2] == ["lipo","-archs"]: print("arm64")\n' + 'elif args[:2] == ["vtool","-show-build"]: print("platform IOSSIMULATOR")\n' + 'else: raise SystemExit(99)\n') + xcrun.chmod(0o755) + argv = [sys.executable, str(TOOL), '--manifest', str(self.manifest_path), + '--source-root', str(self.source), '--output', str(self.output)] + for key, value in self.expected.items(): + argv += ['--' + key.replace('_', '-'), value] + env = dict(os.environ, PATH=str(fakebin) + os.pathsep + os.environ['PATH']) + result = ORIGINAL_RUN(argv, capture_output=True, text=True, env=env) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(json.loads(result.stdout)['reason'], 'runtime-identity-unavailable') + self.assertNotIn(SECRET, result.stdout + result.stderr) + self.assertFalse(self.output.exists()) + bypass = ORIGINAL_RUN(argv + ['--actual-graph-id', GRAPH, '--token', SECRET], capture_output=True, text=True, env=env) + self.assertNotEqual(bypass.returncode, 0) + self.assertNotIn(SECRET, bypass.stdout + bypass.stderr) + + +ORIGINAL_RUN = subprocess.run +if __name__ == '__main__': + unittest.main() From ca8dbe6e5a17cb519bbc70fca345e90745850373 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 18 Oct 2026 23:18:53 +0800 Subject: [PATCH 2/6] Add opt-in authenticated Simulator runtime identity bridge --- .../logseq-journal-ios-sim-testing/SKILL.md | 5 +- .agents/skills/testing-ios-simulator/SKILL.md | 8 +- .github/workflows/ci.yml | 18 ++ AGENTS.md | 6 +- app/application.ml | 49 ++++- app/journal_bridge.ml | 6 + app/journal_bridge.mli | 5 + app/journal_lui_bridge.c | 29 +++ app/journal_startup.ml | 35 +++ app/journal_startup.mli | 9 + .../acceptance-identity/ServerFixture.swift | 43 ++++ .../native-events/JournalLUIEventsTests.swift | 20 ++ apple-tests/native-events/bridge_fixture.ml | 4 + docs/acceptance-capture.md | 65 ++++-- ...10-18-simulator-runtime-identity-bridge.md | 87 ++++++++ ...026-10-18-acceptance-identity-preflight.md | 10 +- swift/JournalAcceptanceIdentity.swift | 207 ++++++++++++++++++ swift/JournalRuntime.swift | 146 ++++++++++++ swift/JournalRuntimeHost.swift | 2 + test/startup_test.ml | 37 ++++ tool/capture_acceptance.py | 65 +++++- tool/test_acceptance_ipc.py | 181 +++++++++++++++ 22 files changed, 1003 insertions(+), 34 deletions(-) create mode 100644 apple-tests/acceptance-identity/ServerFixture.swift create mode 100644 docs/agent-guide/implemented/architecture/2026-10-18-simulator-runtime-identity-bridge.md create mode 100644 swift/JournalAcceptanceIdentity.swift create mode 100644 tool/test_acceptance_ipc.py diff --git a/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md b/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md index 7f349340..a1ec3cda 100644 --- a/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md +++ b/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md @@ -13,7 +13,8 @@ capture and [the evidence contract](../../../docs/acceptance-capture.md). This legacy entry point no longer carries separate build/install/graph-selection or database inspection recipes. Explicit device identity, graph UUID, installed binary/source identity, shared-owner coordination and privacy checks all belong -to the unified workflow. The external running-graph bridge is currently missing; -`runtime-identity-unavailable` blocks real acceptance. Never use similar graph +to the unified workflow. Enable the Simulator-only runtime observer explicitly on the coordinated matching +App; `runtime-identity-unavailable` blocks acceptance if it is absent or unready. +Synthetic tests/CI do not prove real App capture integration. Never use similar graph names, persisted selection, handwritten actual identity or direct DB/secret reads as a substitute. Simulator host restamping does not establish phone/ABI acceptance. diff --git a/.agents/skills/testing-ios-simulator/SKILL.md b/.agents/skills/testing-ios-simulator/SKILL.md index 03f2f1c0..db17791a 100644 --- a/.agents/skills/testing-ios-simulator/SKILL.md +++ b/.agents/skills/testing-ios-simulator/SKILL.md @@ -22,9 +22,13 @@ Apply this checklist **before target-dependent UI actions and acceptance capture the installed App container fresh, never reuse a previous installation path. 4. Use **`python3 tool/capture_acceptance.py`** as the single acceptance screenshot entry point. Follow [the complete command and protocol](../../../docs/acceptance-capture.md). - There is currently **no external App runtime identity bridge**. The production - command returns `runtime-identity-unavailable`; do not substitute a hand-edited + The observer exists only in Simulator builds and requires explicit launch + environment `SIMCTL_CHILD_JOURNAL_ACCEPTANCE=1` after owner coordination. + Re-launching/installing a matching App needs the authorized device window; + this tool never does it automatically. An absent/unready or unauthenticated + observer returns `runtime-identity-unavailable`; do not substitute a hand-edited JSON file, manual actual UUID, screenshots or a direct database/secret read. + Real App/socket/presentation integration remains an acceptance gate beyond CI. 5. Deliver the generated `acceptance.json` together with `screenshot.png` only when the command succeeds. It binds expected and observed identities, source, installed executable and manifest hashes, before/after runtime generations, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 658dd255..11d8324b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,10 @@ jobs: env: PYTHONDONTWRITEBYTECODE: 1 run: python3 tool/test_capture_acceptance.py + - name: Verify Simulator identity IPC with synthetic Swift processes + env: + PYTHONDONTWRITEBYTECODE: 1 + run: python3 tool/test_acceptance_ipc.py - name: Verify Apple app icon packaging run: python3 .agents/skills/journal-build/scripts/test_apple_app_icon.py - name: Prepare native system libraries before cache lookup @@ -115,6 +119,20 @@ jobs: JOURNAL_TEST_SWITCH: ${{ github.workspace }} PYTHONDONTWRITEBYTECODE: 1 run: python3 .agents/skills/journal-build/scripts/test_journal_build.py + - name: Verify native identity callback and event bridge + env: + PYTHONDONTWRITEBYTECODE: 1 + run: | + python3 - <<'PYTHON' + import json, os, pathlib, subprocess + records = json.loads(pathlib.Path('.ci-reports/dependencies.json').read_text()) + lui = next(row for row in records if row['package'] == 'lui.dev') + environment = dict(os.environ, + JOURNAL_LUI_PACKAGE_PATH=str(pathlib.Path(lui['checkout']).resolve() / 'platform/apple'), + JOURNAL_LUI_TEST_SCRATCH=str(pathlib.Path(os.environ['RUNNER_TEMP']) / 'journal-native-identity')) + subprocess.run(['opam', 'exec', '--', 'python3', 'tool/test_lui_native_events.py'], + env=environment, check=True) + PYTHON - name: Record installed package versions if: always() run: opam list --installed --columns=name,installed-version --short --color=never > .ci-reports/packages.txt diff --git a/AGENTS.md b/AGENTS.md index 8b6b11f5..1352864a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,10 @@ owner's device to work around a failed check. Use [testing-ios-simulator](.agents/skills/testing-ios-simulator/SKILL.md) for the checklist and `python3 tool/capture_acceptance.py --help` as the unified Simulator acceptance entry point. Read [the evidence contract](docs/acceptance-capture.md). -The current App lacks an external runtime graph-identity bridge: the tool blocks -with `runtime-identity-unavailable` and cannot yet produce real accepted screenshots. +The runtime observer is Simulator-only and must be explicitly enabled with +`JOURNAL_ACCEPTANCE=1` on the authorized matching App. An absent, unauthenticated +or unready observer blocks with `runtime-identity-unavailable`; fixtures/green CI +do not establish a verified real capture. Do not replace that boundary with constants or database/secret reads. Coordinate shared Simulator ownership; Simulator/experimental host-restamp evidence never proves real-device or cross-ABI acceptance. Keep evidence private and external; diff --git a/app/application.ml b/app/application.ml index 714537be..f35af4d3 100644 --- a/app/application.ml +++ b/app/application.ml @@ -3727,6 +3727,15 @@ let start fun () -> send_action (Update transition) in let state_ref = ref initial_state in + let acceptance_owner = ref (Journal_startup.Acceptance.create ()) in + let acceptance_probe = ref None in + let acceptance_observe state = + acceptance_owner := Journal_startup.Acceptance.observe !acceptance_owner + {manager=state.manager;graph=state.graph_state;graph_ready=state.graph_ready; + feed_loaded=state.feed_loaded;password_pending=state.e2ee_submission_pending; + graph_error=Option.is_some state.graph_error} + ~presentation:state.startup_presentation + in let set_state_ref = ref None in set_state_ref := Some set_state; (* Platform requests are fire-and-forget: the host replies on the @@ -6352,6 +6361,11 @@ let start | Environment_changed snapshot -> prepare_presentation (sync_startup_presentation { model with environment = snapshot }) in + let update model action = + let next = update model action in + acceptance_observe next; + next + in (* These subscriptions use complete presentation dependencies. They compare persistent owners rather than walking retained rows on every input event. *) let same_actions left right = @@ -7099,10 +7113,42 @@ let start let context = { app; pump; client; send_action; apply_platform; running; release_staging } in + acceptance_observe (Lui_app.model app); + let acceptance_reply fresh = + let state = Lui_app.model app in + acceptance_observe state; + let epoch=Journal_startup.Acceptance.epoch !acceptance_owner in + match Journal_startup.Acceptance.sample !acceptance_owner ~fresh with + | Some identity -> Yojson.Safe.to_string (`Assoc + ["status",`String "ready";"epoch",`Int identity.epoch; + "graph_id",`String (Logseq_db_types.Graph_types.Uuid.to_string identity.graph_id); + "worker_generation",`Int identity.worker_generation; + "root_node",`Int (Lui_app.root_node app)]) + | None -> Yojson.Safe.to_string (`Assoc ["status",`String "unavailable";"epoch",`Int epoch]) + in + Journal_bridge.set_acceptance_reader (fun request -> + if not !running then "{\"status\":\"unavailable\"}" + else if request="snapshot" then acceptance_reply (Lui_app.model app).graph_state + else match Logseq_db_types.Graph_types.Uuid.of_string request with + | Error _ -> "{\"status\":\"unavailable\"}" + | Ok _ -> + match !acceptance_probe with + | Some (nonce,_,Some fresh) when nonce=request -> acceptance_reply fresh + | Some (nonce,_,None) when nonce=request -> "{\"status\":\"pending\"}" + | _ -> + match Worker.send client Graph_service.Get_graph_state with + | Accepted id -> acceptance_probe:=Some(request,id,None); "{\"status\":\"pending\"}" + | Full | Not_ready | Stopping -> "{\"status\":\"unavailable\"}"); current_app := Some context; ignore (dispatch_worker Graph_service.Get_graph_state : Worker.send_result); Worker.on_event client (fun event -> - Journal_pump.enqueue pump (fun () -> Effect.run (handle_worker_event event))); + Journal_pump.enqueue pump (fun () -> + Effect.run (handle_worker_event event); + match event,!acceptance_probe with + | Worker.Response {request_id;outcome=Completed(Graph_state fresh);_},Some(nonce,id,_) + when request_id=id -> acceptance_probe:=Some(nonce,id,Some fresh) + | Worker.Response {request_id;_},Some(_,id,_) when request_id=id -> acceptance_probe:=None + | _ -> ())); (* The worker fires the wakeup on its own domain whenever output lands; enqueueing hops through the host wakeup onto the UI thread. An OCaml Condition waiter thread would deadlock against the worker domain (the @@ -7258,6 +7304,7 @@ let create (match !current_app with | Some context -> context.running := false; + Journal_bridge.set_acceptance_reader (fun _ -> "{\"status\":\"unavailable\"}"); Worker.Private.request_stop context.client; context.release_staging (); ignore (Lui_app.dispose context.app); diff --git a/app/journal_bridge.ml b/app/journal_bridge.ml index 6786cf49..7da03bce 100644 --- a/app/journal_bridge.ml +++ b/app/journal_bridge.ml @@ -13,6 +13,11 @@ type hooks = external wakeup : unit -> unit = "journal_ml_wakeup" external platform_request : string -> unit = "journal_ml_platform_request" +let acceptance_reader = ref (fun _ -> "{\"status\":\"unavailable\"}") +let set_acceptance_reader reader = acceptance_reader := reader +let read_acceptance request = + try !acceptance_reader request with _ -> "{\"status\":\"unavailable\"}" + let current : hooks option ref = ref None let hooks () = @@ -83,6 +88,7 @@ let root_node () = (hooks ()).root_node () let register hooks = current := Some hooks; + Callback.register "journal_ocaml_acceptance_identity" read_acceptance; Callback.register "lui_ocaml_init" initialize; Callback.register "lui_ocaml_appear" appear; Callback.register "lui_ocaml_press" press; diff --git a/app/journal_bridge.mli b/app/journal_bridge.mli index f4696141..2f56e527 100644 --- a/app/journal_bridge.mli +++ b/app/journal_bridge.mli @@ -51,3 +51,8 @@ external wakeup : unit -> unit = "journal_ml_wakeup" (* Forwards one LJP2 request envelope to the host platform bridge (the host answers asynchronously through [platform_response]). *) external platform_request : string -> unit = "journal_ml_platform_request" + +(** Install the App-owned read-only identity query. Only the Simulator native + host enables an external transport. Replies are bounded metadata, never + graph content, persisted catalog state or credentials. *) +val set_acceptance_reader : (string -> string) -> unit diff --git a/app/journal_lui_bridge.c b/app/journal_lui_bridge.c index f5ed4e4c..6779dde9 100644 --- a/app/journal_lui_bridge.c +++ b/app/journal_lui_bridge.c @@ -397,3 +397,32 @@ CAMLprim value journal_ml_platform_request(value payload) { } return Val_unit; } + +/* Main-thread read-only identity query; never deliver metadata as a UI patch. + Own bytes across runtime release/reentry, and suppress exception details. */ +LUI_EXPORT int32_t journal_ocaml_read_acceptance( + const char *request, int32_t length, lui_patch_callback callback) { + if (!runtime_started || request == NULL || callback == NULL || length < 1 || length > 64) + return 0; + caml_leave_blocking_section(); + const value *reader = caml_named_value("journal_ocaml_acceptance_identity"); + if (reader == NULL) { caml_enter_blocking_section(); return 0; } + CAMLparam0(); + CAMLlocal2(input, result); + input = copy_bytes(request, length); + result = caml_callback_exn(*reader, input); + patch_response response = {0, NULL, callback}; + if (!Is_exception_result(result)) { + size_t size = caml_string_length(result); + if (size > 0 && size <= 4096) { + response.json = malloc(size + 1); + if (response.json != NULL) { + memcpy(response.json, String_val(result), size); + response.json[size] = '\0'; + response.accepted = 1; + } + } + } + CAMLdrop; + return release_and_deliver(response); +} diff --git a/app/journal_startup.ml b/app/journal_startup.ml index af046b85..39cca497 100644 --- a/app/journal_startup.ml +++ b/app/journal_startup.ml @@ -293,3 +293,38 @@ module Presentation = struct in if next=t then t else next end + +module Acceptance = struct + type identity = { graph_id : Logseq_db_types.Graph_types.Uuid.t; worker_generation : int; epoch : int } + type signature = + Logseq_db_types.Graph_types.Uuid.t option * int * Logseq_db_worker.graph_phase + * bool * bool * bool * bool * bool * Logseq_db_types.Graph_types.Uuid.t option * int + * Presentation.screen * bool + type t = { epoch : int; signature : signature option; current : identity option } + let create () = { epoch=0; signature=None; current=None } + let epoch t = t.epoch + let observe t (o : Presentation.observation) ~presentation = + let selected = Option.bind o.manager (fun m -> m.Graph_service.selected_graph) in + let generation = Option.fold ~none:(-1) + ~some:(fun m -> m.Graph_service.startup.graph_generation) o.manager in + let screen = Presentation.screen presentation in + let foreground = Presentation.foreground presentation in + let signature = (o.graph.graph_id,o.graph.generation,o.graph.phase,o.graph_ready, + o.feed_loaded,o.password_pending,o.graph_error,Option.is_some o.graph.error,selected,generation,screen,foreground) in + if t.signature=Some signature then t else + let epoch=t.epoch+1 in + let current = match o.manager,o.graph.graph_id with + | Some manager,Some graph_id + when screen=Presentation.Timeline && foreground && o.graph_ready && o.feed_loaded + && not o.password_pending && not o.graph_error && o.graph.error=None + && o.graph.phase=Logseq_db_worker.Graph_open && graph_matches manager o.graph -> + Some {graph_id;worker_generation=o.graph.generation;epoch} + | _ -> None in + {epoch;signature=Some signature;current} + let sample t ~(fresh : Logseq_db_worker.graph_state) = + match t.current with + | Some identity when fresh.graph_id=Some identity.graph_id + && fresh.generation=identity.worker_generation + && fresh.phase=Logseq_db_worker.Graph_open && fresh.error=None -> Some identity + | _ -> None +end diff --git a/app/journal_startup.mli b/app/journal_startup.mli index 2906b5c2..99926833 100644 --- a/app/journal_startup.mli +++ b/app/journal_startup.mli @@ -88,3 +88,12 @@ module Presentation : sig val equal_presentation : t -> t -> bool val step : t -> event -> t end + +module Acceptance : sig + type identity = { graph_id : Logseq_db_types.Graph_types.Uuid.t; worker_generation : int; epoch : int } + type t + val create : unit -> t + val observe : t -> Presentation.observation -> presentation:Presentation.t -> t + val epoch : t -> int + val sample : t -> fresh:Logseq_db_worker.graph_state -> identity option +end diff --git a/apple-tests/acceptance-identity/ServerFixture.swift b/apple-tests/acceptance-identity/ServerFixture.swift new file mode 100644 index 00000000..853a6491 --- /dev/null +++ b/apple-tests/acceptance-identity/ServerFixture.swift @@ -0,0 +1,43 @@ +import CryptoKit +import Darwin +import Foundation + +@main struct ServerFixture { + @MainActor static func main() async { + let path = CommandLine.arguments[1] + let mode = CommandLine.arguments[2] + if mode == "disabled" { + let udid = "11111111-1111-4111-8111-111111111111" + guard JournalAcceptanceServer.endpoint(environment: ["SIMULATOR_UDID": udid]) == nil, + JournalAcceptanceServer.endpoint(environment: ["JOURNAL_ACCEPTANCE": "1", "SIMULATOR_UDID": "invalid"]) == nil, + JournalAcceptanceServer.endpoint(environment: ["JOURNAL_ACCEPTANCE": "1", "SIMULATOR_UDID": udid]) != nil else { exit(2) } + return + } + let instance = UUID().uuidString + let server = JournalAcceptanceServer(path: path) { challenge in + if mode == "timeout" { try? await Task.sleep(for: .seconds(2)) } + if mode == "oversize" { return Data(repeating: 120, count: 9000) } + var value: [String: Any] = [ + "schema_version": 1, "challenge": mode == "replay" ? UUID().uuidString : challenge, + "udid": "11111111-1111-4111-8111-111111111111", "bundle_id": "example.synthetic.journal", + "source_sha": String(repeating: "a", count: 40), + "executable_sha256": try! SHA256.hash(data: Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[0]))).map { String(format: "%02x", $0) }.joined(), + "graph_id": "22222222-2222-4222-8222-222222222222", "graph_generation": 7, + "presentation_generation": 7, "phase": "open", "foreground": true, + "pid": mode == "wrong-pid" ? 1 : getpid(), "process_instance": instance] + if mode == "extra-field" { value["token"] = "synthetic-secret" } + return try? JSONSerialization.data(withJSONObject: value) + } + do { try server.start() } + catch { exit(1) } + print("ready"); fflush(stdout) + if mode == "stop-reading" { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + server.stop() + print("stopped"); fflush(stdout) + } + } + while true { try? await Task.sleep(for: .seconds(1)) } + } +} diff --git a/apple-tests/native-events/JournalLUIEventsTests.swift b/apple-tests/native-events/JournalLUIEventsTests.swift index 17324634..f356a695 100644 --- a/apple-tests/native-events/JournalLUIEventsTests.swift +++ b/apple-tests/native-events/JournalLUIEventsTests.swift @@ -26,6 +26,18 @@ private func platformResponse(_ bytes: UnsafePointer?, _ length: Int32) @_silgen_name("journal_ocaml_platform_failure") private func platformFailure(_ bytes: UnsafePointer?, _ length: Int32) +@_silgen_name("journal_ocaml_read_acceptance") +private func readAcceptance(_ challenge: UnsafePointer?, _ length: Int32, + _ callback: PatchCallback?) -> Int32 +nonisolated(unsafe) private var identityReply: String? +private let receiveIdentity: PatchCallback = { source in + guard let source else { return } + let before = String(cString: source) + if rootNode() != 42 { callbackFailure = "identity query held runtime during callback" } + identityReply = before + if String(cString: source) != before { callbackFailure = "identity bytes changed" } +} + nonisolated(unsafe) private var patches: [String] = [] nonisolated(unsafe) private var callbackFailure: String? nonisolated(unsafe) private var reenter = false @@ -59,6 +71,14 @@ private let receivePatch: PatchCallback = { source in static func main() throws { try check("{}".withCString { start(receivePatch, 2, 2, $0, 2) } == 1, "runtime failed to start") try check(patches.count == 1 && callbackFailure == nil, "startup callback failed") + let identityChallenge = "44444444-4444-4444-8444-444444444444" + let patchCount = patches.count + try check(identityChallenge.withCString { readAcceptance($0, Int32(identityChallenge.utf8.count), receiveIdentity) } == 1, + "read-only identity callback failed") + let identity = try JSONSerialization.jsonObject(with: Data((identityReply ?? "").utf8)) as? [String: String] + try check(identity?["challenge"] == identityChallenge && patches.count == patchCount && callbackFailure == nil, + "identity callback mutated UI or lost challenge") + try check(readAcceptance(nil, 36, receiveIdentity) == 0, "nil identity challenge was accepted") let node = (1 << 40) + 7 let payload = "{\"token\":11,\"files\":[{\"name\":\"日记📓.md\"}]}" let cases: [(LUIEvent, String)] = [ diff --git a/apple-tests/native-events/bridge_fixture.ml b/apple-tests/native-events/bridge_fixture.ml index 905f013f..8017cfc5 100644 --- a/apple-tests/native-events/bridge_fixture.ml +++ b/apple-tests/native-events/bridge_fixture.ml @@ -80,3 +80,7 @@ let () = ; root_node = (fun () -> 42) } ;; + +let () = Journal_bridge.set_acceptance_reader (fun challenge -> + Gc.full_major (); + Printf.sprintf "{\"challenge\":\"%s\"}" challenge) diff --git a/docs/acceptance-capture.md b/docs/acceptance-capture.md index eb2a5ac1..fa0a6a4f 100644 --- a/docs/acceptance-capture.md +++ b/docs/acceptance-capture.md @@ -1,11 +1,17 @@ # Simulator acceptance capture -`tool/capture_acceptance.py` is the unified acceptance entry point. It currently -**cannot accept real screenshots**: Journal has an internal Worker graph-state -query but no supported external bridge that proves the graph mounted by the -foreground process. The production observer fails with -`runtime-identity-unavailable` before creating an evidence directory or requesting -a screenshot. This is a blocking interface gap, not a completed runtime check. +`tool/capture_acceptance.py` is the unified acceptance entry point. The App's +read-only observer is compiled only for iOS Simulator and is off by default. +It must be enabled on an authorized matching App with launch environment +`SIMCTL_CHILD_JOURNAL_ACCEPTANCE=1` (the child receives `JOURNAL_ACCEPTANCE=1`). +Coordinate ownership before any launch/install; the tool never performs those +operations. Missing/unready observers fail with `runtime-identity-unavailable` +before screenshots or evidence directories are created. + +The current change has synthetic process/state/native-callback validation. +**Real Simulator socket, mounted-presentation and capture integration remain a +required acceptance gate until a coordinated synthetic run is recorded.** Green +CI alone does not prove this runtime success path. ## Inputs and checks @@ -62,7 +68,7 @@ Local builder receipts are trusted provenance records with rechecked bytes, not signed attestations against a malicious local author. Do not forge or rewrite receipts to turn a failed check into a pass. -## Missing runtime interface: follow-up contract +## Runtime identity interface and evidence contract The production state owner is `Logseq_db_worker.graph_state`, exposed internally by `Get_graph_state` in @@ -72,17 +78,34 @@ catalog's persisted selected graph is a selection record, not proof of what is currently rendered. Existing error-detail Graph ID labels describe an error occurrence and are not a live observer either. -A separate follow-up must implement and review an App-owned external read-only -bridge plus the CLI transport, without altering graph state or exposing graph -contents. No such bridge or transport is implemented by this PR. Minimum contract: +The App joins a fresh correlated `Get_graph_state` response to the pure +`Journal_startup.Acceptance` owner: selected graph UUID, ready feed, matching +Worker generation, startup presentation, foreground and error state. Every relevant +transition advances an epoch, including away-and-back to the same graph. The +read-only `Journal_bridge` C callback copies owned metadata and releases the OCaml +runtime before returning to Swift. It reads no database, catalog or secret. + +`JournalRuntime` requires a mounted root matching the applied backend, healthy +patch application and an active UIKit foreground scene. It brackets a native +two-display-tick barrier with fresh Worker samples and equal epochs; root/lifecycle +transitions also invalidate the response. The private observer is compiled only +for Simulator and explicitly enabled by environment. Its fixed same-user Unix +socket lives in `/tmp/lj-identity-UID-UDID/identity.sock` (directory 0700, socket +0600), is nonblocking and has bounded request/response deadlines. No TCP listener, +new entitlement or persistent diagnostic log is introduced. + +The tool authenticates Darwin `LOCAL_PEERPID`, `getpeereid`, and `proc_pidpath` +against the freshly installed executable path and hash before reading any reply. +Thus a JSON pid/bundle claim or handwritten response file is not trusted. Protocol: 1. The tool resolves the installed bundle and executable first, then sends a fresh random challenge to that App's running process through a supported platform channel. Caller-supplied response files/log snippets are not an endpoint. 2. The App answers from current Worker state **and mounted foreground presentation**. Selection/loading/error/background states must block. An external transport - must verify process ownership, executable and foreground status against OS - evidence, rather than trusting an arbitrary process claiming a bundle ID. + verifies kernel process ownership and executable path/hash. Foreground facts + come from UIKit in that authenticated App, rather than a JSON claim by an + arbitrary process. 3. The response has exactly the fields below. No token, user/email, graph name, graph body, database location or diagnostic payload is permitted: @@ -111,15 +134,15 @@ contents. No such bridge or transport is implemented by this PR. Minimum contrac Before/after equality therefore rejects transitions and restarts around the screenshot. A transport unable to guarantee this must block; two graph UUID strings alone cannot exclude stale presentation or a switch away and back. -5. The bridge returns unknown/error on timeout or missing facts. A future adapter - must include OS/process-authentication and real synthetic-Simulator integration - tests before enabling the currently blocked `Simulator.observe` method. +5. The bridge returns unavailable on timeout or missing facts. Real synthetic- + Simulator integration must verify the App sandbox and displayed graph alongside + the existing OS/process-authentication fixtures before claiming live acceptance. The current Python test backend injects observations at that method solely to -exercise downstream validation/cleanup; these tests do not prove a live runtime -bridge, foreground authentication or actual Journal UI acceptance. Production -code has no injection option in its CLI. This keeps the follow-up scope explicit -without shipping a fake check or unrelated HTTP/UI feature. +exercise downstream validation/cleanup. They do not prove real Simulator socket +access, displayed-frame synchronization or Journal UI acceptance. Production code +has no injection option in its CLI; real App integration is a separate evidence +gate. No unrelated HTTP listener or UI feature is part of the bridge. ## Evidence and privacy @@ -136,10 +159,12 @@ Screenshots can contain private graph content even though the record contains only identities. Keep both private, review pixels before sharing, and never commit generated evidence. Public tests use synthetic files and identities. -Run the reusable tests without any Apple device: +Run the reusable tests without any Apple device (local Unix IPC tests require +normal supported execution-sandbox approval when socket binding/connection is restricted): ```sh PYTHONDONTWRITEBYTECODE=1 python3 tool/test_capture_acceptance.py +PYTHONDONTWRITEBYTECODE=1 python3 tool/test_acceptance_ipc.py ``` They exercise real temporary files and Git repositories, mocked system commands, diff --git a/docs/agent-guide/implemented/architecture/2026-10-18-simulator-runtime-identity-bridge.md b/docs/agent-guide/implemented/architecture/2026-10-18-simulator-runtime-identity-bridge.md new file mode 100644 index 00000000..64a5b938 --- /dev/null +++ b/docs/agent-guide/implemented/architecture/2026-10-18-simulator-runtime-identity-bridge.md @@ -0,0 +1,87 @@ +# Simulator Runtime Identity Bridge + +## Problem + +PR95 currently blocks all real captures because the running App has no external +identity observer. Build provenance and synthetic capture tests cannot replace a +fresh Worker graph observation bound to the foreground native process. + +## Decision + +Add a Simulator-only, explicitly enabled same-user Unix socket observer. It returns +only a fresh nonce-bound identity, with no graph body, credentials, database or +catalog reads. Use Darwin peer credentials and proc_pidpath to authenticate the +running executable against the fresh installed container before trusting a reply. +No TCP listener, entitlement or system-permission change is part of this design. + +Journal_startup.Acceptance owns pure readiness/transition identity checks using the +existing Presentation and Worker graph types. Application joins a fresh correlated +Get_graph_state response to its current selected/ready presentation. A read-only +Journal_bridge callback and C trampoline deliver this on the native main thread. +Swift Runtime samples only after backend apply and mounted-root/foreground checks, +with a native display-turn barrier and unchanged transition stamp. A temporary +socket answers bounded requests only when JOURNAL_ACCEPTANCE=1 in a Simulator build. +The tool validates OS peer PID/uid, executable path/hash, then nonce/schema/identity; +its existing before/after capture checks remain mandatory. + +Files: app/journal_startup.ml/.mli, app/application.ml, app/journal_bridge.ml/.mli, +app/journal_lui_bridge.c, swift/JournalRuntime.swift, swift/JournalRuntimeHost.swift, +new swift/JournalAcceptanceIdentity.swift, tool/capture_acceptance.py, formal tests, +acceptance guides and CI. No spec/*.mli, Dune or external repository changes. + +## Alternatives considered + +### Caller-provided actual identity or response JSON + +Rejected: neither proves the running foreground App. Persisted catalog, database +reads and user-created actual constants are not observations. + +### HTTP listener or new entitlements + +Rejected: unnecessary exposure/permission scope. If the App cannot use the local +socket under its existing Simulator environment, stop and report that boundary. + +## Acceptance criteria + +- Write failing pure-owner, native transport and CLI tests before implementation. +- Authenticate same-user process/executable and fresh nonce; reject absent bridge, + stale response, wrong process, unmounted/background/loading and transitions. +- Production device and default builds do not enable the endpoint. +- Coordinate synthetic Simulator window before any build install/runtime test; + never operate original graph, iPhone47 or another owner's device concurrently. +- Independent review and exact-head CI; clearly retain real-runtime blocker until + a supported synthetic App success path has actually been verified. + +## Consequences + +- Simulator App sandbox access to a short private /tmp socket is still unverified. +- Display synchronization must not accept an old mounted graph after a transition. +- Host IPC tests need supported execution-sandbox approval; no settings are changed. + +## Verification so far + +- Default sandbox: `python3 /external/task/ipc_probe.py` fails at AF_UNIX bind + with PermissionError/EPERM; no reviewer had rejected an escalation. A normal + supported require_escalated retry passed peer PID/image/uid/nonce verification. + No network port, system configuration or entitlement changed. +- Pure startup identity tests failed on a blocked stub, then passed. An additional + graph-error-state/epoch regression failed before its fix. +- Native producer compilation and Swift/C/OCaml callback tests pass using the + coordinated prepared switch and immutable resolution records with jobs=1. +- 30 capture fixtures and 9 real Swift Unix-process tests pass. Independent review + found and verified fixes for a slow-client/close stall and DispatchSource fd + cancellation ordering. No real Simulator or graph has been operated. +- Full iOS Simulator App compilation, linking, target checking, icon packaging + and signing verification pass with status built. The compile-only artifact was + made before committing and is not itself accepted source-clean runtime evidence. +- Real synthetic capture remains pending the active owner releasing the device. + +## Questions + +- Resolved: parent explicitly authorized ordinary bridge implementation and local + Unix IPC validation on 2026-10-18, including the normal approval flow. +- Resolved: the original host bind was refused by the default execution sandbox, + not an approval reviewer. A supported escalated synthetic probe succeeded in + peer PID, executable lookup, uid and nonce checks; no policy bypass occurred. +- Resolved: active status owner retains the real Simulator window; request for a + later synthetic-device window has been sent, and fixtures may proceed meanwhile. diff --git a/docs/agent-guide/implemented/process/2026-10-18-acceptance-identity-preflight.md b/docs/agent-guide/implemented/process/2026-10-18-acceptance-identity-preflight.md index 7072de1a..765d7ddb 100644 --- a/docs/agent-guide/implemented/process/2026-10-18-acceptance-identity-preflight.md +++ b/docs/agent-guide/implemented/process/2026-10-18-acceptance-identity-preflight.md @@ -8,6 +8,12 @@ assumption instead of observing the running application. ## Decision +This records the initial preflight commit ca21d6c. The subsequently authorized +Simulator runtime bridge is specified separately in +`docs/agent-guide/implemented/architecture/2026-10-18-simulator-runtime-identity-bridge.md`. +The working implementation now adds that opt-in observer; real App/socket/display +integration remains unverified until a coordinated synthetic capture succeeds. + Use tool/capture_acceptance.py as the sole acceptance capture entry point. Compare explicit expected Simulator UDID, device type, bundle, version/build, source commit and graph UUID with fresh system/build evidence. Verify the maintained builder's @@ -15,7 +21,7 @@ successful manifest against source files, packaged files and the freshly resolve installed bundle. Reject unknown or inconsistent identities and suppress raw command output. Keep evidence outside the repository. -The current App has an internal Get_graph_state query but no supported external +At the initial commit, the App had an internal Get_graph_state query but no external running-process bridge. The production observer therefore fails closed with runtime-identity-unavailable. A documented challenge/response contract defines that follow-up interface; fixture injection exists only in tests, never as a CLI @@ -41,7 +47,7 @@ are outside the permitted observation boundary. ## Consequences -- Runtime bridge is deliberately absent: this tool cannot yet produce a real +- At that initial commit the runtime bridge was absent: this tool cannot yet produce a real accepted screenshot. The follow-up must bind Worker graph state to the mounted foreground presentation, process instance and monotonically changing generation. - Local build manifests are provenance receipts, not cryptographic attestations diff --git a/swift/JournalAcceptanceIdentity.swift b/swift/JournalAcceptanceIdentity.swift new file mode 100644 index 00000000..2417ecac --- /dev/null +++ b/swift/JournalAcceptanceIdentity.swift @@ -0,0 +1,207 @@ +#if (os(iOS) && targetEnvironment(simulator)) || JOURNAL_ACCEPTANCE_TESTING +import Darwin +import Foundation + +/// Simulator acceptance only. This type is absent from real-device/macOS builds. +/// The private same-user socket carries bounded identity metadata, never commands. +final class JournalAcceptanceServer: @unchecked Sendable { + typealias Reader = @MainActor @Sendable (String) async -> Data? + private let path: String + private let read: Reader + private let queue = DispatchQueue(label: "journal.acceptance.identity") + private var listener: Int32 = -1 + private var source: DispatchSourceRead? + private var active: Int32 = -1 + private var connectionSource: DispatchSourceRead? + private var requestBuffer = Data() + private var socketInode: ino_t? + + init(path: String, read: @escaping Reader) { self.path = path; self.read = read } + + static func endpoint(environment: [String: String]) -> String? { + guard environment["JOURNAL_ACCEPTANCE"] == "1", + let udid = environment["SIMULATOR_UDID"], UUID(uuidString: udid) != nil else { return nil } + return "/tmp/lj-identity-\(geteuid())-\(udid.lowercased())/identity.sock" + } + + private func address() throws -> sockaddr_un { + var value = sockaddr_un() + value.sun_family = sa_family_t(AF_UNIX) + guard path.utf8.count < MemoryLayout.size(ofValue: value.sun_path) else { throw Failure.unavailable } + value.sun_len = UInt8(MemoryLayout.size) + withUnsafeMutableBytes(of: &value.sun_path) { bytes in + bytes.initializeMemory(as: UInt8.self, repeating: 0) + bytes.copyBytes(from: path.utf8) + } + return value + } + + enum Failure: Error { case unavailable } + + func start() throws { + try queue.sync { + let directory = (path as NSString).deletingLastPathComponent + var info = stat() + if lstat(directory, &info) != 0 { + guard errno == ENOENT, mkdir(directory, 0o700) == 0 else { throw Failure.unavailable } + guard lstat(directory, &info) == 0 else { throw Failure.unavailable } + } + guard info.st_mode & S_IFMT == S_IFDIR, info.st_uid == geteuid(), + info.st_mode & 0o777 == 0o700 else { throw Failure.unavailable } + var address = try address() + if lstat(path, &info) == 0 { + guard info.st_mode & S_IFMT == S_IFSOCK, info.st_uid == geteuid() else { throw Failure.unavailable } + let probe = socket(AF_UNIX, SOCK_STREAM, 0) + guard probe >= 0 else { throw Failure.unavailable } + _ = fcntl(probe, F_SETFL, O_NONBLOCK) + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(probe, $0, socklen_t(MemoryLayout.size)) } + } + let refused = errno == ECONNREFUSED + close(probe) + guard result != 0, refused, unlink(path) == 0 else { throw Failure.unavailable } + } else if errno != ENOENT { throw Failure.unavailable } + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw Failure.unavailable } + var noSignal: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size)) + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, socklen_t(MemoryLayout.size)) } + } + guard bound == 0 else { close(fd); throw Failure.unavailable } + guard chmod(path, 0o600) == 0, listen(fd, 2) == 0, lstat(path, &info) == 0 else { + close(fd); unlink(path); throw Failure.unavailable + } + socketInode = info.st_ino + guard fcntl(fd, F_SETFL, O_NONBLOCK) == 0 else { close(fd); unlink(path); throw Failure.unavailable } + listener = fd + let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) + source.setEventHandler { [weak self] in self?.receive() } + source.setCancelHandler { close(fd) } + self.source = source + source.resume() + } + } + + func stop() { + queue.sync { + source?.cancel(); source = nil + listener = -1 // the listener cancellation handler owns close + if let connectionSource { + connectionSource.cancel() // its cancellation handler owns the read fd + } else if active >= 0 { close(active) } // unmonitored reply duplicate + connectionSource = nil; active = -1 + activeTicket = nil; requestBuffer = Data() + var info = stat() + if let inode = socketInode, lstat(path, &info) == 0, info.st_ino == inode { unlink(path) } + socketInode = nil + } + } + + private func receive() { + let fd = accept(listener, nil, nil) + guard fd >= 0 else { return } + guard active < 0 else { close(fd); return } + var uid: uid_t = 0, gid: gid_t = 0 + guard getpeereid(fd, &uid, &gid) == 0, uid == geteuid() else { close(fd); return } + var noSignal: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size)) + guard fcntl(fd, F_SETFL, O_NONBLOCK) == 0 else { close(fd); return } + active = fd + let ticket = UUID() + activeTicket = ticket + requestBuffer = Data() + let connection = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) + connection.setEventHandler { [weak self] in self?.readRequest(fd, ticket: ticket) } + connection.setCancelHandler { close(fd) } + connectionSource = connection + connection.resume() + // Absolute deadline: byte trickles cannot reset it or block stop(). + queue.asyncAfter(deadline: .now() + 1) { [weak self] in + guard let self, self.connectionSource != nil else { return } + self.finish(fd, ticket: ticket, data: nil) + } + } + + private func readRequest(_ fd: Int32, ticket: UUID) { + guard active == fd, activeTicket == ticket else { return } + var bytes = [UInt8](repeating: 0, count: 1025) + while requestBuffer.count <= 1024 { + let count = recv(fd, &bytes, min(bytes.count, 1025 - requestBuffer.count), 0) + if count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK) { return } + guard count > 0 else { finish(fd, ticket: ticket, data: nil); return } + requestBuffer.append(contentsOf: bytes.prefix(count)) + if requestBuffer.contains(10) { break } + } + guard requestBuffer.count <= 1024, requestBuffer.last == 10, + let object = try? JSONSerialization.jsonObject(with: requestBuffer) as? [String: Any], + Set(object.keys) == ["schema_version", "challenge"], + let schema = object["schema_version"] as? Int, schema == 1, + let challenge = object["challenge"] as? String, UUID(uuidString: challenge) != nil else { + finish(fd, ticket: ticket, data: nil); return + } + // libdispatch owns the monitored read descriptor until cancellation completes. + // A duplicate keeps the connected socket alive for the asynchronous reply. + let replyFD = dup(fd) + guard replyFD >= 0 else { finish(fd, ticket: ticket, data: nil); return } + connectionSource?.cancel(); connectionSource = nil + active = replyFD; requestBuffer = Data() + queue.asyncAfter(deadline: .now() + 2) { [weak self] in self?.finish(replyFD, ticket: ticket, data: nil) } + Task { @MainActor [weak self, read] in + let reply = await read(challenge) + self?.queue.async { [weak self] in self?.finish(replyFD, ticket: ticket, data: reply) } + } + } + + private var activeTicket: UUID? + private func finish(_ fd: Int32, ticket: UUID, data: Data?) { + guard active == fd, activeTicket == ticket else { return } + active = -1; activeTicket = nil + let monitored = connectionSource + connectionSource = nil; requestBuffer = Data() + // If still parsing, cancel only after the current handler has sent its fixed + // rejection; close occurs exactly once in the source cancellation handler. + defer { if let monitored { monitored.cancel() } else { close(fd) } } + var reply = data ?? Data("{\"status\":\"unavailable\"}".utf8) + guard reply.count <= 4096 else { return } + reply.append(10) + reply.withUnsafeBytes { buffer in + var offset = 0 + while offset < buffer.count { + let count = send(fd, buffer.baseAddress!.advanced(by: offset), buffer.count - offset, 0) + if count <= 0 { break } + offset += count + } + } + } +} +#endif + +#if os(iOS) && targetEnvironment(simulator) +import UIKit + +/// Two native display ticks allow one complete presentation turn after the +/// source/patch observation. Timeout or any intervening identity change blocks. +@MainActor final class JournalAcceptanceDisplayBarrier: NSObject { + private var continuation: CheckedContinuation? + private var link: CADisplayLink? + private var ticks = 0 + static func wait() async -> Bool { + await withCheckedContinuation { continuation in + let barrier = JournalAcceptanceDisplayBarrier() + barrier.continuation = continuation + let link = CADisplayLink(target: barrier, selector: #selector(advance)) + barrier.link = link + link.add(to: .main, forMode: .common) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak barrier] in barrier?.finish(false) } + } + } + @objc private func advance() { ticks += 1; if ticks >= 2 { finish(true) } } + private func finish(_ accepted: Bool) { + guard let continuation else { return } + self.continuation = nil + link?.invalidate(); link = nil + continuation.resume(returning: accepted) + } +} +#endif diff --git a/swift/JournalRuntime.swift b/swift/JournalRuntime.swift index 734d7502..3b5e10dd 100644 --- a/swift/JournalRuntime.swift +++ b/swift/JournalRuntime.swift @@ -1,6 +1,10 @@ import Foundation import LUIAppleBackend import Observation +#if os(iOS) && targetEnvironment(simulator) +import UIKit +import CryptoKit +#endif /// C bridge entries exported by app/journal_lui_bridge.c (see also /// platform/native/lui_ocaml_bridge.c in the lui repository). Every entry that @@ -35,6 +39,15 @@ private func journalOCamlSetWakeupCallback(_ callback: WakeupCallback?) @_silgen_name("journal_ocaml_set_platform_request_callback") private func journalOCamlSetPlatformRequestCallback(_ callback: PlatformRequestCallback?) +@_silgen_name("journal_ocaml_read_acceptance") +private func journalOCamlReadAcceptance(_ request: UnsafePointer?, _ length: Int32, + _ callback: PatchCallback?) -> Int32 +nonisolated(unsafe) private var identityReply: String? +private let receiveIdentity: PatchCallback = { pointer in + let reply = pointer.map { String(cString: $0) } + MainActor.assumeIsolated { identityReply = reply } +} + nonisolated(unsafe) private var activeRuntime: JournalRuntime? /// The C bridge invokes this on the main actor after releasing the runtime. @@ -76,6 +89,15 @@ private let platformRequest: PlatformRequestCallback = { data, length in /// Count of patch batches applied from the OCaml runtime (test visibility). private(set) var appliedPatches = 0 private var started = false + #if os(iOS) && targetEnvironment(simulator) + @ObservationIgnored private var acceptanceServer: JournalAcceptanceServer? + @ObservationIgnored private var acceptanceLifecycle: [NSObjectProtocol] = [] + @ObservationIgnored private var acceptanceInstance = UUID().uuidString + @ObservationIgnored private var acceptanceNativeEpoch: Int? + @ObservationIgnored private var acceptanceGeneration = 0 + @ObservationIgnored private var acceptanceMounted = false + @ObservationIgnored private var acceptanceHealthy = true + #endif init( platform: JournalApplicationPlatform, @@ -118,10 +140,13 @@ private let platformRequest: PlatformRequestCallback = { data, length in } started = true platform.connect(to: self) + startAcceptanceIdentity() + syncAcceptanceIdentity() } func stop() { guard started else { return } + stopAcceptanceIdentity() platform.disconnect() _ = luiOCamlStop() started = false @@ -134,6 +159,9 @@ private let platformRequest: PlatformRequestCallback = { data, length in rootID = backend.rootIDs.first appliedPatches += 1 } catch { + #if os(iOS) && targetEnvironment(simulator) + acceptanceHealthy = false + #endif assertionFailure("Invalid LUI patch: \(error)") } } @@ -141,6 +169,7 @@ private let platformRequest: PlatformRequestCallback = { data, length in func pump() { guard started else { return } _ = journalOCamlPump() + syncAcceptanceIdentity() } /// Marshals one LJP2 request onto the platform actor and ships its response @@ -177,5 +206,122 @@ private let platformRequest: PlatformRequestCallback = { data, length in private func handle(_ event: LUIEvent) { guard started else { return } _ = JournalLUIEvents.dispatch(event) + syncAcceptanceIdentity() + } +} + +private struct JournalNativeIdentity: Decodable, Equatable { + let status: String + let epoch: Int? + let graph_id: String? + let worker_generation: Int? + let root_node: Int? +} + +extension JournalRuntime { + private func nativeIdentity(_ request: String) -> JournalNativeIdentity? { + identityReply = nil + guard request.withCString({ journalOCamlReadAcceptance($0, Int32(request.utf8.count), receiveIdentity) }) == 1, + let value = identityReply, let data = value.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(JournalNativeIdentity.self, from: data) + } + + func setAcceptanceRootMounted(_ mounted: Bool) { + #if os(iOS) && targetEnvironment(simulator) + if acceptanceMounted != mounted { acceptanceGeneration += 1 } + acceptanceMounted = mounted + #endif + } + + private func syncAcceptanceIdentity() { + #if os(iOS) && targetEnvironment(simulator) + guard started, ProcessInfo.processInfo.environment["JOURNAL_ACCEPTANCE"] == "1", + let epoch = nativeIdentity("snapshot")?.epoch else { return } + if epoch != acceptanceNativeEpoch { acceptanceGeneration += 1 } + acceptanceNativeEpoch = epoch + #endif + } + + private func stopAcceptanceIdentity() { + #if os(iOS) && targetEnvironment(simulator) + acceptanceServer?.stop(); acceptanceServer = nil + for observer in acceptanceLifecycle { NotificationCenter.default.removeObserver(observer) } + acceptanceLifecycle.removeAll() + acceptanceMounted = false + acceptanceGeneration += 1 + #endif + } + + private func startAcceptanceIdentity() { + #if os(iOS) && targetEnvironment(simulator) + let environment = ProcessInfo.processInfo.environment + guard let endpoint = JournalAcceptanceServer.endpoint(environment: environment), + let executable = Bundle.main.executableURL, + let source = Bundle.main.object(forInfoDictionaryKey: "JournalSourceRevision") as? String, + let bundle = Bundle.main.bundleIdentifier, + let udid = environment["SIMULATOR_UDID"] else { return } + acceptanceInstance = UUID().uuidString + for name in [UIApplication.willResignActiveNotification, UIApplication.didBecomeActiveNotification] { + acceptanceLifecycle.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.acceptanceGeneration += 1 } + }) + } + let instance = acceptanceInstance + Task { [weak self] in + let hash = await Task.detached(priority: .utility) { () -> String? in + guard let stream = try? FileHandle(forReadingFrom: executable) else { return nil } + defer { try? stream.close() } + var digest = SHA256() + do { + while let data = try stream.read(upToCount: 1024 * 1024), !data.isEmpty { digest.update(data: data) } + return digest.finalize().map { String(format: "%02x", $0) }.joined() + } catch { return nil } + }.value + guard let self, self.started, self.acceptanceInstance == instance, let hash else { return } + let server = JournalAcceptanceServer(path: endpoint) { [weak self] challenge in + await self?.acceptanceIdentity(challenge: challenge, source: source, bundle: bundle, udid: udid, hash: hash) + } + do { try server.start(); self.acceptanceServer = server } + catch { self.acceptanceServer = nil } + } + #endif + } + + #if os(iOS) && targetEnvironment(simulator) + private var acceptanceForeground: Bool { + UIApplication.shared.applicationState == .active && + UIApplication.shared.connectedScenes.contains { $0.activationState == .foregroundActive } + } + + private func freshIdentity() async -> JournalNativeIdentity? { + let request = UUID().uuidString + for _ in 0..<50 { + guard started else { return nil } + guard let response = nativeIdentity(request) else { return nil } + if response.status != "pending" { return response.status == "ready" ? response : nil } + do { try await Task.sleep(for: .milliseconds(10)) } catch { return nil } + } + return nil + } + + private func acceptanceIdentity(challenge: String, source: String, bundle: String, + udid: String, hash: String) async -> Data? { + guard started, acceptanceMounted, acceptanceHealthy, acceptanceForeground, + let mountedRoot = rootID, mountedRoot > 0 else { return nil } + let generation = acceptanceGeneration + guard let before = await freshIdentity(), let epoch = before.epoch, epoch >= 0, + epoch == acceptanceNativeEpoch, before.root_node == mountedRoot, let graph = before.graph_id, + await JournalAcceptanceDisplayBarrier.wait(), + let after = await freshIdentity(), before == after, + after.epoch == acceptanceNativeEpoch, after.root_node == mountedRoot, rootID == mountedRoot, + generation == acceptanceGeneration, started, acceptanceMounted, + acceptanceHealthy, acceptanceForeground else { return nil } + return try? JSONSerialization.data(withJSONObject: [ + "schema_version": 1, "challenge": challenge, "udid": udid, "bundle_id": bundle, + "executable_sha256": hash, "source_sha": source, "graph_id": graph, + "graph_generation": generation, "presentation_generation": generation, + "phase": "open", "foreground": true, "pid": ProcessInfo.processInfo.processIdentifier, + "process_instance": acceptanceInstance]) } + #endif } diff --git a/swift/JournalRuntimeHost.swift b/swift/JournalRuntimeHost.swift index 68ccf04a..7c196e23 100644 --- a/swift/JournalRuntimeHost.swift +++ b/swift/JournalRuntimeHost.swift @@ -25,6 +25,8 @@ struct JournalRuntimeHost: View { Group { if let runtime, let rootID = runtime.rootID { LUISwiftUIRoot(backend: runtime.backend, rootID: rootID) + .onAppear { runtime.setAcceptanceRootMounted(true) } + .onDisappear { runtime.setAcceptanceRootMounted(false) } } else if failure != nil { ContentUnavailableView { Label("Unable to open journal", systemImage: "exclamationmark.folder") diff --git a/test/startup_test.ml b/test/startup_test.ml index a093c1de..e95fa82d 100644 --- a/test/startup_test.ml +++ b/test/startup_test.ml @@ -360,7 +360,44 @@ let test_e2ee_recovery_requires_a_prepared_challenge () = = Some expected) "E2EE recovery offers password without a prepared challenge") [false;true] ;; +let test_acceptance_runtime_identity () = + let module P = Journal_startup.Presentation in + let module A = Journal_startup.Acceptance in + let observation : P.observation = {manager=Some(startup_snapshot ());graph=graph_state (); + graph_ready=true;feed_loaded=true;password_pending=false;graph_error=false} in + let ready = P.step (P.create ()) (Observe observation) in + let opened = A.observe (A.create ()) observation ~presentation:ready in + let identity = A.sample opened ~fresh:(graph_state ()) in + require (Option.is_some identity) "matching fresh Worker and presented graph was rejected"; + let identity = Option.get identity in + require (identity.graph_id=graph_id && identity.worker_generation=7) + "acceptance identity lost stable Worker graph"; + let unchanged = A.observe opened observation ~presentation:ready in + require (A.epoch unchanged=A.epoch opened) "unchanged observations advance epoch"; + List.iter (fun fresh -> require (A.sample opened ~fresh=None) + "stale/unknown fresh Worker state was accepted") + [graph_state ~generation:8 ();graph_state ~phase:Graph_opening (); + {(graph_state ()) with graph_id=None}; + {(graph_state ()) with graph_id=Some(Logseq_db_types.Graph_types.Uuid.of_string + "20000000-0000-4000-8000-000000000003" |> Result.get_ok)}]; + List.iter (fun invalid -> + let blocked=A.observe opened invalid ~presentation:(P.step ready (Observe invalid)) in + require (A.sample blocked ~fresh:(graph_state ())=None) "unready presentation was accepted"; + let returned=A.observe blocked observation ~presentation:ready in + require (A.epoch returned>A.epoch opened) "away-and-back transition reused old identity epoch") + [{observation with graph_ready=false};{observation with feed_loaded=false}; + {observation with password_pending=true};{observation with graph_error=true}; + {observation with manager=None};{observation with graph=graph_state ~phase:Graph_closed ()}; + {observation with graph=graph_state ~error:(worker_error "synthetic failure") ()}; + {observation with manager=Some{(startup_snapshot ()) with selected_graph=None}}]; + let background=A.observe opened observation ~presentation:(P.step ready (Foreground false)) in + require (A.sample background ~fresh:(graph_state ())=None) "background identity was accepted"; + let foreground=A.observe background observation ~presentation:ready in + require (A.epoch foreground>A.epoch opened) "background/foreground reused old epoch" +;; + let () = + test_acceptance_runtime_identity (); test_e2ee_recovery_requires_a_prepared_challenge (); test_stable_startup_presentation (); test_noncurrent_deletion_keeps_active_graph_ready (); diff --git a/tool/capture_acceptance.py b/tool/capture_acceptance.py index bdb065e976..4b0f939a 100644 --- a/tool/capture_acceptance.py +++ b/tool/capture_acceptance.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 """Fail-closed Simulator acceptance capture. See docs/acceptance-capture.md. -No external runtime identity bridge exists yet. The CLI deliberately blocks at -that boundary; the injected Simulator boundary is exercised with synthetic tests. +The runtime observer is Simulator-only and explicitly enabled by its owner. +Absent, unready or unauthenticated observers block capture; no identity file or +caller-supplied actual UUID can substitute for the live process. """ import argparse +import ctypes from datetime import datetime, timezone import hashlib import json @@ -13,6 +15,8 @@ import plistlib import re import shutil +import socket +import stat import subprocess import struct import zlib @@ -131,14 +135,65 @@ def container(self, expected): return path def observe(self, identity, challenge): - # Do not read persisted selection, DBs, arbitrary JSON, log text, secrets or - # a caller-supplied actual UUID. A supported App-owned bridge is required. - raise Rejected('runtime-identity-unavailable') + executable = self.container(identity) / 'JournalApp' + path = Path('/tmp') / ('lj-identity-' + str(os.geteuid()) + '-' + identity['udid'].lower()) / 'identity.sock' + return query_identity_socket(path, executable, challenge) def screenshot(self, udid, path): command(['xcrun', 'simctl', 'io', udid, 'screenshot', str(path)]) +def query_identity_socket(path, executable, challenge, *, timeout=2): + """Authenticate the kernel peer before reading any identity claim.""" + try: + parent = path.parent.lstat() + endpoint = path.lstat() + require(stat.S_ISDIR(parent.st_mode) and parent.st_uid == os.geteuid() + and stat.S_IMODE(parent.st_mode) == 0o700 + and stat.S_ISSOCK(endpoint.st_mode) and endpoint.st_uid == os.geteuid() + and stat.S_IMODE(endpoint.st_mode) == 0o600, 'runtime-peer-mismatch') + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as peer: + peer.settimeout(timeout) + peer.connect(str(path)) + pid = struct.unpack('i', peer.getsockopt(0, 2, 4))[0] # Darwin SOL_LOCAL/LOCAL_PEERPID + uid, gid = ctypes.c_uint(), ctypes.c_uint() + libc = ctypes.CDLL(None) + libc.getpeereid.argtypes = [ctypes.c_int, ctypes.POINTER(ctypes.c_uint), ctypes.POINTER(ctypes.c_uint)] + libc.getpeereid.restype = ctypes.c_int + require(pid > 0 and libc.getpeereid(peer.fileno(), ctypes.byref(uid), ctypes.byref(gid)) == 0 + and uid.value == os.geteuid(), 'runtime-peer-mismatch') + libproc = ctypes.CDLL('/usr/lib/libproc.dylib') + libproc.proc_pidpath.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32] + libproc.proc_pidpath.restype = ctypes.c_int + image = ctypes.create_string_buffer(4096) + require(libproc.proc_pidpath(pid, image, len(image)) > 0 + and Path(os.fsdecode(image.value)).resolve() == executable.resolve(), 'runtime-peer-mismatch') + executable_hash = sha(executable) + peer.sendall(json.dumps({'schema_version': 1, 'challenge': challenge}).encode() + b'\n') + data = b'' + while len(data) <= 4096 and not data.endswith(b'\n'): + block = peer.recv(min(4097 - len(data), 1024)) + if not block: + break + data += block + require(len(data) <= 4096 and data.endswith(b'\n'), 'runtime-identity-mismatch') + value = json.loads(data) + if value == {'status': 'unavailable'}: + raise Rejected('runtime-identity-unavailable') + require(isinstance(value, dict) and set(value) == OBSERVED_KEYS + and value['pid'] == pid and value['challenge'] == challenge + and value['executable_sha256'] == executable_hash, 'runtime-identity-mismatch') + # Confirm that the authenticated process is still the same image. + again = ctypes.create_string_buffer(4096) + require(libproc.proc_pidpath(pid, again, len(again)) > 0 and again.value == image.value + and sha(executable) == executable_hash, 'runtime-peer-mismatch') + return value + except (FileNotFoundError, ConnectionRefusedError, TimeoutError, PermissionError): + raise Rejected('runtime-identity-unavailable') from None + except (OSError, ValueError, TypeError, UnicodeError): + raise Rejected('runtime-identity-mismatch') from None + + def validate_expected(expected): require(isinstance(expected, dict) and set(expected) == EXPECTED_KEYS, 'invalid-expectation') require(valid_uuid(expected['udid']) and valid_uuid(expected['graph_id']) diff --git a/tool/test_acceptance_ipc.py b/tool/test_acceptance_ipc.py new file mode 100644 index 00000000..a92028c3 --- /dev/null +++ b/tool/test_acceptance_ipc.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Real Unix peer/process boundary tests with a compiled synthetic Swift server. + +No Simulator, account, database or graph is opened. The boundary owner is the +native IPC adapter; an App reducer cannot execute peer credentials or sockets. +""" +import ctypes +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import socket +import select +import threading +import time +import stat +import subprocess +import sys +import tempfile +import unittest + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parents[1] +SERVER = ROOT / 'swift/JournalAcceptanceIdentity.swift' +FIXTURE = ROOT / 'apple-tests/acceptance-identity/ServerFixture.swift' + + +class IPCIdentityTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.external = tempfile.TemporaryDirectory(prefix='journal-identity-ipc-', dir='/tmp') + cls.base = Path(cls.external.name).resolve() + cls.binary = cls.base / 'identity-server' + cls.compile_result = None + if SERVER.exists() and FIXTURE.exists(): + cls.compile_result = subprocess.run(['xcrun', 'swiftc', '-swift-version', '6', + '-module-cache-path', str(cls.base / 'module-cache'), '-D', 'JOURNAL_ACCEPTANCE_TESTING', str(SERVER), str(FIXTURE), '-o', str(cls.binary)], + capture_output=True, text=True, timeout=60) + spec = importlib.util.spec_from_file_location('capture_acceptance', ROOT / 'tool/capture_acceptance.py') + cls.api = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.api) + + @classmethod + def tearDownClass(cls): + cls.external.cleanup() + + def setUp(self): + self.assertIsNotNone(self.compile_result, 'Simulator-only IPC implementation is missing') + self.assertEqual(self.compile_result.returncode, 0, self.compile_result.stderr) + self.temp = tempfile.TemporaryDirectory(prefix='case-', dir=self.base) + self.addCleanup(self.temp.cleanup) + self.directory = Path(self.temp.name) + self.path = self.directory / 'identity.sock' + self.process = None + self.challenge = '44444444-4444-4444-8444-444444444444' + + def start(self, mode='normal'): + self.process = subprocess.Popen([str(self.binary), str(self.path), mode], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + self.addCleanup(self.stop) + self.assertEqual(self.process.stdout.readline().strip(), 'ready', self.process.stderr.read() if self.process.poll() is not None else '') + + def stop(self): + if self.process and self.process.poll() is None: + self.process.terminate() + self.process.wait(timeout=5) + if self.process: + self.process.stdout.close();self.process.stderr.close() + self.process = None + + def query(self, **kwargs): + return self.api.query_identity_socket(self.path, self.binary, self.challenge, **kwargs) + + def test_real_peer_pid_uid_image_and_nonce(self): + self.start() + response = self.query() + self.assertEqual(response['pid'], self.process.pid) + self.assertEqual(response['challenge'], self.challenge) + self.assertEqual(response['executable_sha256'], hashlib.sha256(self.binary.read_bytes()).hexdigest()) + self.assertEqual(stat.S_IMODE(self.path.stat().st_mode), 0o600) + + def test_wrong_executable_rejected_even_if_json_claims_correct_identity(self): + self.start() + with self.assertRaises(self.api.Rejected) as error: + self.api.query_identity_socket(self.path, Path(sys.executable), self.challenge) + self.assertEqual(error.exception.code, 'runtime-peer-mismatch') + + def test_replay_wrong_pid_and_extra_secret_fields_rejected(self): + for mode in ('replay', 'wrong-pid', 'extra-field'): + with self.subTest(mode=mode): + self.start(mode) + with self.assertRaises(self.api.Rejected) as error: + self.query() + self.assertEqual(error.exception.code, 'runtime-identity-mismatch') + self.assertNotIn('synthetic-secret', str(error.exception)) + self.stop() + self.path.unlink(missing_ok=True) + + def test_missing_listener_unavailable_and_insecure_socket_rejected(self): + with self.assertRaises(self.api.Rejected) as error: + self.query() + self.assertEqual(error.exception.code, 'runtime-identity-unavailable') + self.start() + self.path.chmod(0o666) + with self.assertRaises(self.api.Rejected) as error: + self.query() + self.assertEqual(error.exception.code, 'runtime-peer-mismatch') + + def test_timeout_and_oversized_response_fail_without_raw_output(self): + for mode in ('timeout', 'oversize'): + with self.subTest(mode=mode): + self.start(mode) + with self.assertRaises(self.api.Rejected) as error: + self.query(timeout=0.2) + self.assertIn(error.exception.code, {'runtime-identity-unavailable', 'runtime-identity-mismatch'}) + self.assertNotIn('synthetic-secret', str(error.exception)) + self.stop() + self.path.unlink(missing_ok=True) + + def test_server_refuses_malformed_oversized_and_unknown_request_fields(self): + self.start() + for request in (b'not json\n', b'x' * 2048 + b'\n', + json.dumps({'schema_version': 1, 'challenge': self.challenge, 'token': 'synthetic-secret'}).encode()+b'\n'): + with self.subTest(size=len(request)): + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.settimeout(3);client.connect(str(self.path));client.sendall(request) + try: + response = client.recv(4096) + except ConnectionResetError: + response = b'' + self.assertNotIn(b'synthetic-secret', response) + self.assertNotIn(self.challenge.encode(), response) + + def test_start_refuses_symlink_and_live_socket_is_not_deleted(self): + target = self.directory / 'keep' + target.write_text('keep') + self.path.symlink_to(target) + result = subprocess.run([str(self.binary), str(self.path), 'normal'], capture_output=True, text=True, timeout=5) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(target.read_text(), 'keep') + self.path.unlink() + self.start() + other = subprocess.run([str(self.binary), str(self.path), 'normal'], capture_output=True, text=True, timeout=5) + self.assertNotEqual(other.returncode, 0) + self.assertEqual(self.query()['pid'], self.process.pid) + + def test_partial_request_has_total_deadline_and_stop_never_waits_for_peer(self): + self.start('stop-reading') + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.connect(str(self.path)) + client.sendall(b' ') + readable, _, _ = select.select([self.process.stdout], [], [], 0.7) + self.assertTrue(readable, 'main-actor stop blocked behind an incomplete peer request') + self.assertEqual(self.process.stdout.readline().strip(), 'stopped') + self.stop(); self.path.unlink(missing_ok=True) + self.start() + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.settimeout(2);client.connect(str(self.path)) + start = time.monotonic() + closed = False + for _ in range(8): + try: + client.sendall(b' ') + ready, _, _ = select.select([client], [], [], 0.2) + if ready: + client.recv(4096);closed = True;break + except (BrokenPipeError, ConnectionResetError): + closed = True;break + self.assertTrue(closed, 'slow peer exceeded absolute request deadline') + self.assertLess(time.monotonic() - start, 1.5) + + def test_disabled_mode_does_not_create_endpoint(self): + result = subprocess.run([str(self.binary), str(self.path), 'disabled'], capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0) + self.assertFalse(self.path.exists()) + + +if __name__ == '__main__': + unittest.main() From db2e4dd792905229b9c3e26d752a751032438aad Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 18 Oct 2026 23:26:41 +0800 Subject: [PATCH 3/6] Keep link validation stub aligned with identity callback --- .agents/skills/journal-build/scripts/journal_build.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.agents/skills/journal-build/scripts/journal_build.py b/.agents/skills/journal-build/scripts/journal_build.py index 529eb990..4f6e7d1d 100644 --- a/.agents/skills/journal-build/scripts/journal_build.py +++ b/.agents/skills/journal-build/scripts/journal_build.py @@ -1240,6 +1240,8 @@ def interrupted(signum, frame): int32_t journal_ocaml_extension_event(int64_t n, const char *name, const char *p) { (void)n; (void)name; (void)p; return 1; } int32_t journal_ocaml_pump(void) { return 1; } +int32_t journal_ocaml_read_acceptance(const char *d, int32_t l, patch_cb cb) + { (void)d; (void)l; (void)cb; return 0; } void journal_ocaml_platform_event(const char *d, int32_t l) { (void)d; (void)l; } void journal_ocaml_platform_response(const char *d, int32_t l) { (void)d; (void)l; } void journal_ocaml_platform_failure(const char *d, int32_t l) { (void)d; (void)l; } From c4f60b74298ddd0cfd50ded625a236a8cf75e3dd Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 18 Oct 2026 23:28:36 +0800 Subject: [PATCH 4/6] Record coordinated synthetic graph acceptance blocker --- ...10-18-simulator-runtime-identity-bridge.md | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/agent-guide/implemented/architecture/2026-10-18-simulator-runtime-identity-bridge.md b/docs/agent-guide/implemented/architecture/2026-10-18-simulator-runtime-identity-bridge.md index 64a5b938..baa3557f 100644 --- a/docs/agent-guide/implemented/architecture/2026-10-18-simulator-runtime-identity-bridge.md +++ b/docs/agent-guide/implemented/architecture/2026-10-18-simulator-runtime-identity-bridge.md @@ -2,7 +2,7 @@ ## Problem -PR95 currently blocks all real captures because the running App has no external +The initial PR95 preflight blocked all real captures without an external runtime identity observer. Build provenance and synthetic capture tests cannot replace a fresh Worker graph observation bound to the foreground native process. @@ -70,11 +70,22 @@ socket under its existing Simulator environment, stop and report that boundary. coordinated prepared switch and immutable resolution records with jobs=1. - 30 capture fixtures and 9 real Swift Unix-process tests pass. Independent review found and verified fixes for a slow-client/close stall and DispatchSource fd - cancellation ordering. No real Simulator or graph has been operated. + cancellation ordering. No App install/launch, graph change or screenshot was + performed; only approved read-only Simulator/container metadata was inspected. - Full iOS Simulator App compilation, linking, target checking, icon packaging - and signing verification pass with status built. The compile-only artifact was - made before committing and is not itself accepted source-clean runtime evidence. -- Real synthetic capture remains pending the active owner releasing the device. + and signing verification pass with status built, including a clean committed + source build. These artifacts have not been installed or accepted as runtime + screenshot evidence. +- The existing link-only stub symbol test caught a missing identity callback; + the stub now returns unavailable and the original failing test passes. +- The owner released the synthetic Simulator window. Approved read-only inventory + and fresh installed-container checks succeeded in this execution environment. + Its old visible component fixture is not a mounted Journal Worker graph, and + externally generated graph IDs have never been installed there. The owner + confirmed no independent expected ordinary-App synthetic graph UUID exists. + Adding or switching graphs is outside this task's scope, so actual App socket, + displayed-frame and successful capture integration remain blocked. No device + permission rejection or missing implementation is claimed as this blocker. ## Questions @@ -83,5 +94,6 @@ socket under its existing Simulator environment, stop and report that boundary. - Resolved: the original host bind was refused by the default execution sandbox, not an approval reviewer. A supported escalated synthetic probe succeeded in peer PID, executable lookup, uid and nonce checks; no policy bypass occurred. -- Resolved: active status owner retains the real Simulator window; request for a - later synthetic-device window has been sent, and fixtures may proceed meanwhile. +- Resolved: the synthetic-device window is released. A coordinated ordinary-App + synthetic graph with an independent expected UUID is still needed for the real + integration gate; the original graph and iPhone47 must remain untouched. From 7e7d3c26b1136f312644fbc94e5bea88a04f6ce2 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 18 Oct 2026 23:34:45 +0800 Subject: [PATCH 5/6] Resolve versioned LUI package for native identity CI --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11d8324b..ae6da43a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,7 +126,9 @@ jobs: python3 - <<'PYTHON' import json, os, pathlib, subprocess records = json.loads(pathlib.Path('.ci-reports/dependencies.json').read_text()) - lui = next(row for row in records if row['package'] == 'lui.dev') + matches = [row for row in records if row['package'].split('.', 1)[0] == 'lui'] + assert len(matches) == 1, 'expected one resolved LUI package' + lui = matches[0] environment = dict(os.environ, JOURNAL_LUI_PACKAGE_PATH=str(pathlib.Path(lui['checkout']).resolve() / 'platform/apple'), JOURNAL_LUI_TEST_SCRATCH=str(pathlib.Path(os.environ['RUNNER_TEMP']) / 'journal-native-identity')) From 5b237ca914bca9f5e88533facc26abc2788fe788 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Sun, 18 Oct 2026 23:41:01 +0800 Subject: [PATCH 6/6] Bind staged host build inputs to committed acceptance source --- docs/acceptance-capture.md | 2 ++ ...10-18-simulator-runtime-identity-bridge.md | 5 ++++- tool/capture_acceptance.py | 3 +++ tool/test_capture_acceptance.py | 19 +++++++++++++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/acceptance-capture.md b/docs/acceptance-capture.md index fa0a6a4f..d3ffb761 100644 --- a/docs/acceptance-capture.md +++ b/docs/acceptance-capture.md @@ -47,6 +47,8 @@ The preflight checks: - Clean Git checkout and actual source bytes, with complete source coverage in the same scope as the builder; expected commit, bundle, version/build and build input digest. Dirty builds require a clean committed rebuild for this workflow. + The actual Swift, source plist and entitlements input maps must also match the + committed checkout; a separately staged host cannot borrow its source identity. - Hash-bound original producer source/link records and retained native inputs; the native source map must match current compiled source. Final native object, target C, Swift object and link-map bytes must match the successful receipt. diff --git a/docs/agent-guide/implemented/architecture/2026-10-18-simulator-runtime-identity-bridge.md b/docs/agent-guide/implemented/architecture/2026-10-18-simulator-runtime-identity-bridge.md index baa3557f..6a6ce7af 100644 --- a/docs/agent-guide/implemented/architecture/2026-10-18-simulator-runtime-identity-bridge.md +++ b/docs/agent-guide/implemented/architecture/2026-10-18-simulator-runtime-identity-bridge.md @@ -68,7 +68,7 @@ socket under its existing Simulator environment, stop and report that boundary. graph-error-state/epoch regression failed before its fix. - Native producer compilation and Swift/C/OCaml callback tests pass using the coordinated prepared switch and immutable resolution records with jobs=1. -- 30 capture fixtures and 9 real Swift Unix-process tests pass. Independent review +- 31 capture fixtures and 9 real Swift Unix-process tests pass. Independent review found and verified fixes for a slow-client/close stall and DispatchSource fd cancellation ordering. No App install/launch, graph change or screenshot was performed; only approved read-only Simulator/container metadata was inspected. @@ -78,6 +78,9 @@ socket under its existing Simulator environment, stop and report that boundary. screenshot evidence. - The existing link-only stub symbol test caught a missing identity callback; the stub now returns unavailable and the original failing test passes. +- A regression reproduced acceptance of staged Swift/plist/entitlements with an + otherwise matching committed source map. Those separate builder inputs must now + match the checkout byte for byte; all three mismatches fail closed. - The owner released the synthetic Simulator window. Approved read-only inventory and fresh installed-container checks succeeded in this execution environment. Its old visible component fixture is not a mounted Journal Worker graph, and diff --git a/tool/capture_acceptance.py b/tool/capture_acceptance.py index 4b0f939a..3557f652 100644 --- a/tool/capture_acceptance.py +++ b/tool/capture_acceptance.py @@ -220,6 +220,9 @@ def build_evidence(manifest_path, source_root, expected): actual_source = source_files(source_root) require('app/application.ml' in actual_source and BUILDER + '/journal_build.py' in actual_source and actual_source == m['inputs']['source'], code) + require(tree_files(source_root / 'swift') == m['inputs']['swift'] + and sha(source_root / 'apple/Info.plist') == m['inputs']['plist'] + and tree_files(source_root / 'config/entitlements') == m['inputs']['entitlements'], code) artifact = m['artifact'] app = Path(artifact['app']) require(app.is_absolute() and app.is_dir() and app == app.resolve(), code) diff --git a/tool/test_capture_acceptance.py b/tool/test_capture_acceptance.py index e833cac8..b689c282 100644 --- a/tool/test_capture_acceptance.py +++ b/tool/test_capture_acceptance.py @@ -41,6 +41,9 @@ def setUp(self): self.source = self.base / 'source' self.source.mkdir() self.source_files = {'app/application.ml': b'(* synthetic source *)\n', + 'swift/App.swift': b'// synthetic Swift source\n', + 'apple/Info.plist': b'synthetic source plist', + 'config/entitlements/fixture.entitlements': b'synthetic entitlements', '.agents/skills/journal-build/scripts/journal_build.py': b'# synthetic builder\n'} for name, data in self.source_files.items(): path = self.source / name @@ -60,6 +63,9 @@ def setUp(self): JournalSourceRevision=sha, JournalBuildMode='experimental-host-restamp', JournalCrossABIValidated=False, JournalOCamlRuntimeOrigin='host-arm64-restamp') self.inputs = dict(source={n: digest(b) for n, b in self.source_files.items()}, + swift={'App.swift': digest(self.source_files['swift/App.swift'])}, + plist=digest(self.source_files['apple/Info.plist']), + entitlements={'fixture.entitlements': digest(self.source_files['config/entitlements/fixture.entitlements'])}, target='arm64-apple-ios26.0-simulator', flags={'mode': 'experimental-host-restamp'}) self.native_input = self.base / 'production-host.o' self.native_input.write_bytes(b'synthetic host object') @@ -192,6 +198,19 @@ def test_resealed_producer_source_must_still_match_actual_source(self): self.reseal() self.rejects('build-evidence-mismatch') + def test_staged_compile_inputs_must_match_committed_source(self): + for key, value in [('swift', {'App.swift': 'a' * 64}), + ('plist', 'a' * 64), + ('entitlements', {'fixture.entitlements': 'a' * 64})]: + with self.subTest(key=key): + self.output = self.base / ('evidence-' + key) + old = self.manifest['inputs'][key] + self.manifest['inputs'][key] = value + self.reseal() + self.rejects('build-evidence-mismatch') + self.manifest['inputs'][key] = old + self.reseal() + def test_capture_binds_bytes_graph_and_limits(self): record = self.capture() saved = json.loads((self.output / 'acceptance.json').read_text())