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; } diff --git a/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md b/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md index 10e275c2..a1ec3cda 100644 --- a/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md +++ b/.agents/skills/logseq-journal-ios-sim-testing/SKILL.md @@ -1,240 +1,20 @@ --- 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 - -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). - -## 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. +# Journal Simulator testing — unified workflow + +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). + +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. 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 278a378e..db17791a 100644 --- a/.agents/skills/testing-ios-simulator/SKILL.md +++ b/.agents/skills/testing-ios-simulator/SKILL.md @@ -5,6 +5,46 @@ 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). + 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, + 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..ae6da43a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,14 @@ 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 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 @@ -111,6 +119,22 @@ 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()) + 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')) + 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 85ba3c55..1352864a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,3 +13,26 @@ ## 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 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; +public fixtures must contain only synthetic identities and no credentials/content. 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 new file mode 100644 index 00000000..d3ffb761 --- /dev/null +++ b/docs/acceptance-capture.md @@ -0,0 +1,176 @@ +# Simulator acceptance capture + +`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 + +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. + 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. + 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. + +## Runtime identity interface and evidence 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. + +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 + 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: + +```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 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. 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 + +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 (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, +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/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..6a6ce7af --- /dev/null +++ b/docs/agent-guide/implemented/architecture/2026-10-18-simulator-runtime-identity-bridge.md @@ -0,0 +1,102 @@ +# Simulator Runtime Identity Bridge + +## Problem + +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. + +## 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. +- 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. +- Full iOS Simulator App compilation, linking, target checking, icon packaging + 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. +- 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 + 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 + +- 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: 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. 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..765d7ddb --- /dev/null +++ b/docs/agent-guide/implemented/process/2026-10-18-acceptance-identity-preflight.md @@ -0,0 +1,77 @@ +# 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 + +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 +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. + +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 +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 + +- 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 + 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/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 new file mode 100644 index 00000000..3557f652 --- /dev/null +++ b/tool/capture_acceptance.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +"""Fail-closed Simulator acceptance capture. See docs/acceptance-capture.md. + +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 +import os +from pathlib import Path, PurePosixPath +import plistlib +import re +import shutil +import socket +import stat +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): + 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']) + 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) + 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) + 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_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() diff --git a/tool/test_capture_acceptance.py b/tool/test_capture_acceptance.py new file mode 100644 index 00000000..b689c282 --- /dev/null +++ b/tool/test_capture_acceptance.py @@ -0,0 +1,455 @@ +#!/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', + '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 + 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()}, + 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') + 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_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()) + 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()