diff --git a/CAPABILITIES.md b/CAPABILITIES.md index f123eb168..513a7683f 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -139,6 +139,8 @@ improvising or retrying when something is off. | Admit a record check only after a fault suite written to a store copy shows it catches every fault it claims, and sign its coverage into the receipt | On main | Synthetic (in-memory REST store, SQLite copy) | `openadapt_flow/fault_gate.py`, `scaffold-verifier --fault-gate`, `verify-run` `admission:` | | Check that the saved record matches its source document, not only the parameters: intake keeps each value's line, a second reading must agree before a guarded save, and a round trip compares the read-back with the document | On main | Synthetic (MockMed, text referral) | `openadapt_flow/provenance.py`, `intake`, `verify-run --provenance`, guarded save | | Guarded save: a safety layer any agent wraps around one write (check the record, hold for approval, read back) | On main ([#1258](https://github.com/OpenAdaptAI/OpenAdapt/pull/1258)) | Synthetic | `GUARDED_SAVE.md` | +| A person's approval in Desktop is signed with a key kept behind Touch ID on a Mac: one-prompt setup in the app, a prompt for every answer, and Flow never sees the key | On main ([#1500](https://github.com/OpenAdaptAI/OpenAdapt/pull/1500)) | Unit tests; opt-in test on a real Secure Enclave | `packages/desktop/openadapt_desktop/approver_keychain.py`, [APPROVAL_MODES.md](packages/flow/docs/APPROVAL_MODES.md#the-approver-key) | +| Review stops: every stop as one card with the evidence side by side (the item next to each possible match, field by field), answered with one key or tap on the computer or a paired phone, signed with the approver key, with batches for similar stops. The queue presents and collects; a changed or expired card changes nothing | On main ([#1519](https://github.com/OpenAdaptAI/OpenAdapt/pull/1519)) | Unit tests; a keystroke-level estimate on 20 soak stops (26.7 s to 8.2 s to decide, a model, not a person) | `packages/desktop/openadapt_desktop/stop_queue.py`, [benchmark/stop_queue](packages/desktop/benchmark/stop_queue/README.md) | | Draft a record check from the demonstration's write steps (`scaffold-verifier`) | Released | Synthetic | `openadapt-flow scaffold-verifier` (a person fills in the draft) | | Build a working record check from the reads the app made while the person viewed the saved record, and test it against injected bad writes before anyone reviews it | On main ([#1284](https://github.com/OpenAdaptAI/OpenAdapt/pull/1284)) | Synthetic (MockMed, OpenEMR fixture) | `openadapt-flow scaffold-verifier` | | Check a save on desktop, RDP or Citrix by looking the record up in a separate read-only session | On main ([#1280](https://github.com/OpenAdaptAI/OpenAdapt/pull/1280)) | Synthetic (WinForms, RDP and Citrix stand-ins) | `runtime/effects/` | diff --git a/llms-full.txt b/llms-full.txt index 92b612345..c83967312 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -536,6 +536,8 @@ improvising or retrying when something is off. | Admit a record check only after a fault suite written to a store copy shows it catches every fault it claims, and sign its coverage into the receipt | On main | Synthetic (in-memory REST store, SQLite copy) | `openadapt_flow/fault_gate.py`, `scaffold-verifier --fault-gate`, `verify-run` `admission:` | | Check that the saved record matches its source document, not only the parameters: intake keeps each value's line, a second reading must agree before a guarded save, and a round trip compares the read-back with the document | On main | Synthetic (MockMed, text referral) | `openadapt_flow/provenance.py`, `intake`, `verify-run --provenance`, guarded save | | Guarded save: a safety layer any agent wraps around one write (check the record, hold for approval, read back) | On main ([#1258](https://github.com/OpenAdaptAI/OpenAdapt/pull/1258)) | Synthetic | `GUARDED_SAVE.md` | +| A person's approval in Desktop is signed with a key kept behind Touch ID on a Mac: one-prompt setup in the app, a prompt for every answer, and Flow never sees the key | On main ([#1500](https://github.com/OpenAdaptAI/OpenAdapt/pull/1500)) | Unit tests; opt-in test on a real Secure Enclave | `packages/desktop/openadapt_desktop/approver_keychain.py`, [APPROVAL_MODES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/APPROVAL_MODES.md#the-approver-key) | +| Review stops: every stop as one card with the evidence side by side (the item next to each possible match, field by field), answered with one key or tap on the computer or a paired phone, signed with the approver key, with batches for similar stops. The queue presents and collects; a changed or expired card changes nothing | On main ([#1519](https://github.com/OpenAdaptAI/OpenAdapt/pull/1519)) | Unit tests; a keystroke-level estimate on 20 soak stops (26.7 s to 8.2 s to decide, a model, not a person) | `packages/desktop/openadapt_desktop/stop_queue.py`, [benchmark/stop_queue](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/desktop/benchmark/stop_queue/README.md) | | Draft a record check from the demonstration's write steps (`scaffold-verifier`) | Released | Synthetic | `openadapt-flow scaffold-verifier` (a person fills in the draft) | | Build a working record check from the reads the app made while the person viewed the saved record, and test it against injected bad writes before anyone reviews it | On main ([#1284](https://github.com/OpenAdaptAI/OpenAdapt/pull/1284)) | Synthetic (MockMed, OpenEMR fixture) | `openadapt-flow scaffold-verifier` | | Check a save on desktop, RDP or Citrix by looking the record up in a separate read-only session | On main ([#1280](https://github.com/OpenAdaptAI/OpenAdapt/pull/1280)) | Synthetic (WinForms, RDP and Citrix stand-ins) | `runtime/effects/` | diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index f2bc838c9..cc1414873 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,6 +1,31 @@ # CHANGELOG +## v0.24.0 (2026-10-24) + +### Features + +- **desktop**: set up the approver key in the app, behind Touch ID +- **desktop**: review stops as one-tap cards with the evidence side by side, + on the computer (keys 1-9, batches of similar stops) or a paired phone; every + answer is signed with the approver key +- **desktop**: re-arm "only ask about problems" from a stop card with one + Touch ID + +### Bug Fixes + +- **desktop**: a locked Mac isn't a Mac without a Secure Enclave +- **desktop**: the Touch ID setup signs the approver roster's root behind its one prompt and pins that root for Desktop's runs; a key rotation is signed by the old key, and a roster this Mac didn't start needs an approver to add the key +- **desktop**: re-register a stored approver key only after it opens behind Touch ID +- **desktop**: Flow checks each run's config against the digest Desktop computed (--config-sha256); Run it answers carry the Keychain's head; staged-config folders that are symlinks are refused; only the Keychain names Desktop's approver root (not OPENADAPT_APPROVER_ROOT, not a user-writable file); with no Keychain pin, no person's answer counts +- **desktop**: each run's deployment config also carries the Keychain's roster head and is staged outside the run directory; Run it answers name this Mac's pin with --approver-root +- **desktop**: the Keychain's approver pin wins over OPENADAPT_APPROVER_ROOT (a different value is refused); each run's deployment config carries the pin; the roster head is kept in the Keychain and an older roster put back is refused +- **desktop**: a Mac that can keep the key behind Touch ID never falls back to a terminal-made key +- **desktop**: a stop card that changes during the Touch ID prompt changes nothing +- **desktop**: a stop where nothing was written also offers "I did it by hand" + + + ## v0.23.0 (2026-10-24) ### Bug Fixes diff --git a/packages/desktop/benchmark/stop_queue/README.md b/packages/desktop/benchmark/stop_queue/README.md new file mode 100644 index 000000000..af54e317b --- /dev/null +++ b/packages/desktop/benchmark/stop_queue/README.md @@ -0,0 +1,119 @@ +# Seconds per stop: the stop queue on 20 recorded soak stops + +How long does a person take to decide one stop, before and after the stop +queue (Desktop's **Review stops**)? + +## The stops + +The 20 stops are the first 20 that the scripted operator handled in the +`after` arm of the production soak of 2026-10-24. That soak ran the +OpenEMR referral fixture with synthetic patients, and the files are in +`packages/flow/benchmark/lifecycle/results/production-2026-10-24/after/`. +The stops break down as follows: + +- 15 "Same person?" stops, where the duplicate check found a possible match: + - 8 matches are a different person, so the right answer is to add the patient; + - 7 are the same patient (a resend, a chart that already exists, or a chart + another clerk made a moment earlier), so the right answer is not to add it. +- 3 "Did it save?" stops, where the record check couldn't confirm the save: + - 2 saves went in wrong; + - 1 save is right. +- 2 stops on the item's inputs: one has no date of birth, and one is missing + an optional phone number. + +`build_drill.py` turns each stop into the card Desktop's `stop_queue` builds +for it, so the drill uses the same code as the app. The "possible match" rows +are reconstructed from the soak, because the soak did not keep the app's list. +A chart is listed when it existed at that moment and shares the item's last +name or date of birth, which is the rule the duplicate check applies. The +charts that existed are: + +- the starting charts; +- charts that earlier items made; +- a chart another clerk made just before the item. + +The ground truth gives the right answer for each card. A card is **hard** +when a quick glance would get it wrong: the possible match is a different +person, or the save went in wrong. Ten of the 20 are hard. + +```bash +cd packages/desktop +uv run python benchmark/stop_queue/build_drill.py \ + --arm ../flow/benchmark/lifecycle/results/production-2026-10-24/after \ + --out benchmark/stop_queue/drill-2026-10-24.json +``` + +## Estimate: keystroke-level model + +`klm.py` applies the keystroke-level model to the steps each stop takes. It +uses the operator times from Card, Moran and Newell (1983), as given by +Kieras (2001): + +| Operator | Seconds | +| --- | --- | +| Keystroke | 0.28 | +| Point | 1.1 | +| Click | 0.2 | +| Move hand between keyboard and mouse | 0.4 | +| Mental act (read, compare one value, decide) | 1.2 | + +It also assumes 1.5 s for the app's patient search and 1.0 s for a Touch ID +prompt. It counts deciding only, not entering an item by hand afterwards. +The steps in each arm are: + +- **Before** (Needs you today): + 1. Open the item. + 2. Read why it stopped. + 3. Remember the key values. + 4. Switch to the app, search the last name, and open and compare each + listed record. + 5. Switch back, tick "I looked at the record", and pick the action. +- **After** (the card): + 1. Read the question. + 2. Scan the marked comparison for each possible match. + 3. Decide, press one key, and confirm Touch ID. + +The results are in `klm-2026-10-24.json`: + +| Stops | Before, mean | After, mean | Before, median | After, median | +| --- | --- | --- | --- | --- | +| All 20 | 26.7 s | 8.2 s | 27.7 s | 5.3 s | +| 15 "Same person?" | 29.6 s | 5.5 s | 27.9 s | 5.3 s | +| 3 "Did it save?" | 25.6 s | 23.5 s | 25.5 s | 23.4 s | + +**This is a model, not a measurement of a person.** The model is what puts +"Same person?" cards in seconds: the card shows the item next to each +possible match, field by field, and marks each value Same or Different, so +no app lookup is needed. + +"Did it save?" cards barely change. Their cards don't carry the values the +record check read back, so the person still looks the record up in the app. +The next step is to add those read-back values to the halt. + +For scale, the soak's cost model charged 90 s for a stop plus a 30 s +interrupt; it is an assumption too. + +## Measuring a person + +The practice page runs the real **Review stops** screen on the 20 cards. +Nothing is signed or saved. It records each answer and how long its card was +on screen. + +```bash +cd packages/desktop +npx vite --port 1420 +# open http://localhost:1420/benchmark/stop_queue/practice.html +``` + +The score is accuracy only. It says how many stops were right and how many +hard ones the person caught. Time goes into the downloaded results for this +measurement; it is never scored or ranked. Answering blindly scores +accordingly: pressing 1 on every card gets 8 of 20 and catches none of the 10 +hard stops. + +For "before", time the same 20 stops through Needs you and the app, item by +item. In the product, every answer from **Review stops** is also logged with +its time on screen (`stop_decisions.jsonl`, `shown_ms`). Real operator times +can therefore replace the cost model's 90 s assumption. + +No person has run either arm yet. diff --git a/packages/desktop/benchmark/stop_queue/build_drill.py b/packages/desktop/benchmark/stop_queue/build_drill.py new file mode 100644 index 000000000..28c48c89d --- /dev/null +++ b/packages/desktop/benchmark/stop_queue/build_drill.py @@ -0,0 +1,167 @@ +"""Turn recorded soak stops into the stop-queue cards Desktop would show. + +Input: a lifecycle soak arm directory (``ledger.jsonl`` and ``workload.jsonl`` +from ``packages/flow/benchmark/lifecycle/results///``). Output: a +drill file with the first N stops the soak's operator handled, each as: + +* the card Desktop's ``stop_queue`` builds for it (the same code path the app + uses), from the item's values and the stop Flow reported; +* the possible matches the app's duplicate list held at that moment, + reconstructed from the soak: the charts that existed before the run + (``existing_chart``), the charts earlier items created, and a chart another + clerk created just before the item. A row is listed when it shares the + item's last name or date of birth, the rule the duplicate check applies; +* the answer the ground truth calls right, and what the soak's scripted + operator did and was charged. + +The patients are the soak's synthetic ones. Run from ``packages/desktop``:: + + uv run python benchmark/stop_queue/build_drill.py \\ + --arm PATH/TO/after --out benchmark/stop_queue/drill-2026-10-24.json +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +from openadapt_desktop import stop_queue +from openadapt_desktop.work_items import allowed_actions + +#: Columns the app's possible-duplicate list shows. +LIST_FIELDS = ("lname", "fname", "DOB", "sex", "street") + + +def _rows(path: Path) -> list[dict]: + return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] + + +def _transaction(reason: str) -> str: + return reason.split(":", 1)[0].strip() + + +def _expected(kind: str, *, chart_wanted: bool, final_right: bool) -> set[str]: + """The answers the ground truth calls right for one card.""" + + if kind == "same_record": + return {"different_by_hand"} if chart_wanted else {"same", "decline"} + if kind == "confirm_save": + return {"saved"} if final_right else {"fixed_by_hand"} + # A stop before saving that isn't a duplicate question: the inputs were + # refused (missing or malformed values). + return {"by_hand"} if chart_wanted else {"skip", "decline"} + + +def build(arm: Path, *, count: int = 20) -> dict[str, Any]: + ledger = _rows(arm / "ledger.jsonl") + workload = _rows(arm / "workload.jsonl") + items = {r["item_id"]: r for r in workload if r.get("type") == "item"} + existing = [r for r in workload if r.get("type") == "existing_chart"] + results = {r["item_id"]: r for r in ledger if r.get("type") == "item_result"} + stops = [ + r + for r in ledger + if r.get("type") == "operator" and r.get("kind") in {"stop", "reconcile"} + ][:count] + + out = [] + for stop in stops: + item = items[stop["item_id"]] + truth = item["truth"] + result = results[stop["item_id"]] + reason = str(stop.get("reason") or "") + transaction = _transaction(reason) + nothing_written = transaction in {"HALTED_BEFORE_EFFECT", "REJECTED_POLICY"} + # Charts in the app when this item ran: the starting set, every + # earlier item that made one, and another clerk's just before it. + charts = [dict(c) for c in existing] + for earlier in sorted(results.values(), key=lambda r: r["seq"]): + if earlier["seq"] >= item["seq"]: + break + made = (earlier.get("final") or {}).get("new_charts") or 0 + if made: + charts.append(items[earlier["item_id"]]["truth"]["person"]) + for event in truth.get("inject") or []: + if event.get("event") == "other_clerk_creates" and event.get("person"): + charts.append(event["person"]) + params = item["params"] + lname = str(params.get("lname") or "").strip().casefold() + dob = str(params.get("DOB") or "").strip() + candidates = [ + {f: str(c.get(f) or "") for f in LIST_FIELDS} + for c in charts + if (lname and str(c.get("lname") or "").strip().casefold() == lname) + or (dob and c.get("DOB") == dob) + ] + desktop_item = { + "item_id": stop["item_id"], + "source": "item", + "workflow_id": "openemr-referral", + "task_name": "New patient referral", + "label": f"Referral {stop['item_id']}", + # No retained frames from the soak: the card shows no screenshot. + "run_id": None, + "status": "open", + "nothing_written": nothing_written, + "result": "stopped_before_saving" if nothing_written else "check_record", + "approval": None, + "halt_kind": "screen_changed" if nothing_written else "save_not_confirmed", + "halt_reason": reason[:500], + "halt_step": "", + "expected": "", + "observed": "", + "field_diffs": [], + "inputs": {k: str(v) for k, v in params.items() if v is not None}, + "candidates": candidates if "no_duplicate_candidate" in reason else [], + "created_at": None, + } + desktop_item["actions"] = allowed_actions(desktop_item) + card = stop_queue.card_for_item(desktop_item) + if card is None: + continue + chart_wanted = bool(result.get("chart_wanted")) + final_right = (result.get("final") or {}).get("verdict") == "correct" + out.append( + { + "item_id": stop["item_id"], + "exception": truth.get("exception"), + "rationale": truth.get("rationale"), + "card": card, + "expected": sorted( + _expected(card["kind"], chart_wanted=chart_wanted, final_right=final_right) + ), + # A card a quick glance would get wrong: a possible match that + # is a different person, or a save that went in wrong. + "hard": card["kind"] == "same_record" and chart_wanted + or card["kind"] == "confirm_save" and not final_right, + "soak_operator": { + "action": stop.get("action"), + "rule": stop.get("rule"), + "charged_s": stop.get("charged_s"), + "cost_model": stop.get("cost_model"), + }, + } + ) + return { + "schema": "openadapt.stop-queue-drill/v1", + "source": str(arm), + "selection": f"the first {count} stops the soak's operator handled, in order", + "stops": out, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--arm", required=True, type=Path) + parser.add_argument("--out", required=True, type=Path) + parser.add_argument("--count", type=int, default=20) + args = parser.parse_args() + drill = build(args.arm, count=args.count) + args.out.write_text(json.dumps(drill, indent=2, sort_keys=True) + "\n") + print(f"wrote {len(drill['stops'])} stops to {args.out}") + + +if __name__ == "__main__": + main() diff --git a/packages/desktop/benchmark/stop_queue/drill-2026-10-24.json b/packages/desktop/benchmark/stop_queue/drill-2026-10-24.json new file mode 100644 index 000000000..27ecf63eb --- /dev/null +++ b/packages/desktop/benchmark/stop_queue/drill-2026-10-24.json @@ -0,0 +1,3225 @@ +{ + "schema": "openadapt.stop-queue-drill/v1", + "selection": "the first 20 stops the soak's operator handled, in order", + "source": "/private/tmp/claude-501/-Users-abrichr-oa-src-OpenAdapt--claude-worktrees-exciting-keller-2a43e5/9f57cffd-1c29-4ef9-8d69-39b3e53e9c48/scratchpad/soak/after", + "stops": [ + { + "card": { + "batch_key": "same_record:openemr-referral:duplicate", + "card_id": "sha256:cc33bf4e44d4081110ebcffeb42a8b4b30814ff1044b8f1d1e53e182987a26f8", + "choices": [ + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "same", + "label": "Same person: don't add it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person. OpenAdapt saves nothing.", + "id": "different_by_hand", + "label": "Different person: I entered it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [ + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": false, + "value": "1941-02-27" + }, + { + "field": "fname", + "label": "Fname", + "same": false, + "value": "Ana" + }, + { + "field": "lname", + "label": "Lname", + "same": true, + "value": "Fairwick" + }, + { + "field": "sex", + "label": "Sex", + "same": false, + "value": "Male" + }, + { + "field": "street", + "label": "Street", + "same": false, + "value": "276 QUARRY HILL DRIVE" + } + ], + "text": "1941-02-27, Ana, Fairwick, Male, 276 QUARRY HILL DRIVE" + } + ], + "differences": [], + "expected": "", + "observed": "", + "reason": "HALTED_BEFORE_EFFECT: Postconditions failed for step 'step_039' (click 'Create New Patient'): expected screen state not reached (semantic drift) \u2014 failed: no_duplicate_candidate (no listed row has this run's lname, DOB) \u2014 run aborted", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1957-03-08" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "bram.fairwick.2@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Bram" + }, + { + "field": "lname", + "label": "Lname", + "value": "Fairwick" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0162" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Female" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "339 STREET CEDAR ROW" + }, + { + "field": "title", + "label": "Title", + "value": "Ms." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "same_record", + "label": "Referral ref-00002", + "question": "Same person?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00002", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving: the app may already have this record. Compare the item with what the app shows.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "clean", + "expected": [ + "different_by_hand" + ], + "hard": true, + "item_id": "ref-00002", + "rationale": "a new referral; register it", + "soak_operator": { + "action": "do_by_hand", + "charged_s": 300.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "enter_by_hand" + } + }, + { + "card": { + "batch_key": "same_record:openemr-referral:duplicate", + "card_id": "sha256:66d2ee010980f83d0103ab6d43b6563b446a8840f43da3e47ca89926672894f9", + "choices": [ + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "same", + "label": "Same person: don't add it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person. OpenAdapt saves nothing.", + "id": "different_by_hand", + "label": "Different person: I entered it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [ + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": true, + "value": "1943-01-24" + }, + { + "field": "fname", + "label": "Fname", + "same": false, + "value": "Arlo" + }, + { + "field": "lname", + "label": "Lname", + "same": false, + "value": "Jesston" + }, + { + "field": "sex", + "label": "Sex", + "same": false, + "value": "Male" + }, + { + "field": "street", + "label": "Street", + "same": false, + "value": "361 BEACON TERRACE STATION" + } + ], + "text": "1943-01-24, Arlo, Jesston, Male, 361 BEACON TERRACE STATION" + } + ], + "differences": [], + "expected": "", + "observed": "", + "reason": "HALTED_BEFORE_EFFECT: Postconditions failed for step 'step_039' (click 'Create New Patient'): expected screen state not reached (semantic drift) \u2014 failed: no_duplicate_candidate (no listed row has this run's lname, DOB) \u2014 run aborted", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1943-01-24" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "rafael.caldley.5@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Rafael" + }, + { + "field": "lname", + "label": "Lname", + "value": "Caldley" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0100" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Female" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "176 WILLOW BEND ROAD" + }, + { + "field": "title", + "label": "Title", + "value": "Ms." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "same_record", + "label": "Referral ref-00005", + "question": "Same person?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00005", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving: the app may already have this record. Compare the item with what the app shows.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "shared_dob", + "expected": [ + "different_by_hand" + ], + "hard": true, + "item_id": "ref-00005", + "rationale": "born on the same day as an existing chart; a different person", + "soak_operator": { + "action": "do_by_hand", + "charged_s": 300.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "enter_by_hand" + } + }, + { + "card": { + "batch_key": "confirm_save:openemr-referral:save_not_confirmed", + "card_id": "sha256:3be6c34c3a8c4af90e44a06a4a0ea4f31fdf9398e8af45b878e3775f5f13dee5", + "choices": [ + { + "consequence": "The item closes as saved.", + "id": "saved", + "label": "I checked: it saved correctly" + }, + { + "consequence": "OpenAdapt runs the item again, with every check.", + "id": "not_saved_retry", + "label": "I checked: nothing saved, run it again" + }, + { + "consequence": "The item closes as done by a person.", + "id": "fixed_by_hand", + "label": "I fixed it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [], + "differences": [], + "expected": "", + "observed": "", + "reason": "RECONCILIATION_REQUIRED: System-of-record effect verification HALTED step 'step_040' (click 'Confirm Create New Patient'): exact_new_set refuted against the rest system of record (the screen indicated completion but the declared outcome is contradicted or unverifiable) \u2014 declared new record {DOB=04/", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "04/20/1944" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "priya.marwell.6@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Priya" + }, + { + "field": "lname", + "label": "Lname", + "value": "Marwell" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0144" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Female" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "194 LANTERN COURT FERRY" + }, + { + "field": "title", + "label": "Title", + "value": "Mrs." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "confirm_save", + "label": "Referral ref-00006", + "question": "Did it save?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00006", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "The save may have gone through, but OpenAdapt couldn't confirm it. Look at the record, then say what you found.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "malformed_dob", + "expected": [ + "fixed_by_hand" + ], + "hard": true, + "item_id": "ref-00006", + "rationale": "date of birth written MM/DD/YYYY; the true date is ISO", + "soak_operator": { + "action": "do_by_hand", + "charged_s": 330.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "fix_wrong_chart_by_hand" + } + }, + { + "card": { + "batch_key": "halted:openemr-referral:screen_changed", + "card_id": "sha256:e2bc55b85e96202eca674b94179f903c388eb0a4400f17b3daaa4f47cce49fa2", + "choices": [ + { + "consequence": "OpenAdapt runs the item again, with every check. Nothing was saved.", + "id": "retry", + "label": "Run it again" + }, + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "skip", + "label": "Skip it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person.", + "id": "by_hand", + "label": "I did it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [], + "differences": [], + "expected": "", + "observed": "", + "reason": "REJECTED_POLICY: ", + "record": [], + "screenshot": null, + "source": [ + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "bo.jessby.8@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Bo" + }, + { + "field": "lname", + "label": "Lname", + "value": "Jessby" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0188" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Male" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "107 AVENUE MILL ROAD" + }, + { + "field": "title", + "label": "Title", + "value": "Mr." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "halted", + "label": "Referral ref-00008", + "question": "What should happen to this item?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00008", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving. Nothing was written.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "missing_required", + "expected": [ + "decline", + "skip" + ], + "hard": false, + "item_id": "ref-00008", + "rationale": "no date of birth; return the referral to the sender", + "soak_operator": { + "action": "skip", + "charged_s": 120.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "skip_no_chart_wanted" + } + }, + { + "card": { + "batch_key": "same_record:openemr-referral:duplicate", + "card_id": "sha256:bed39c02e37d72fc597ee09cfcf63fc12bf3d10048c3f47bf936deee947eb77c", + "choices": [ + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "same", + "label": "Same person: don't add it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person. OpenAdapt saves nothing.", + "id": "different_by_hand", + "label": "Different person: I entered it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [ + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": true, + "value": "1979-06-03" + }, + { + "field": "fname", + "label": "Fname", + "same": true, + "value": "Anouk" + }, + { + "field": "lname", + "label": "Lname", + "same": true, + "value": "Langstead" + }, + { + "field": "sex", + "label": "Sex", + "same": true, + "value": "Male" + }, + { + "field": "street", + "label": "Street", + "same": true, + "value": "114 QUARRY HILL DRIVE" + } + ], + "text": "1979-06-03, Anouk, Langstead, Male, 114 QUARRY HILL DRIVE" + } + ], + "differences": [], + "expected": "", + "observed": "", + "reason": "HALTED_BEFORE_EFFECT: Postconditions failed for step 'step_039' (click 'Create New Patient'): expected screen state not reached (semantic drift) \u2014 failed: no_duplicate_candidate (no listed row has this run's lname, DOB) \u2014 run aborted", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1979-06-03" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "anouk.langstead.12@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Anouk" + }, + { + "field": "lname", + "label": "Lname", + "value": "Langstead" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0160" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Male" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "114 QUARRY HILL DRIVE" + }, + { + "field": "title", + "label": "Title", + "value": "Mr." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "same_record", + "label": "Referral ref-00012", + "question": "Same person?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00012", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving: the app may already have this record. Compare the item with what the app shows.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "concurrent_same", + "expected": [ + "decline", + "same" + ], + "hard": false, + "item_id": "ref-00012", + "rationale": "another clerk registered this patient moments earlier", + "soak_operator": { + "action": "skip", + "charged_s": 120.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "skip_no_chart_wanted" + } + }, + { + "card": { + "batch_key": "confirm_save:openemr-referral:save_not_confirmed", + "card_id": "sha256:08d47c4b1be98d4c1ae4c120ad5cd3349f1444b6698680ad3050084d33ded9f1", + "choices": [ + { + "consequence": "The item closes as saved.", + "id": "saved", + "label": "I checked: it saved correctly" + }, + { + "consequence": "OpenAdapt runs the item again, with every check.", + "id": "not_saved_retry", + "label": "I checked: nothing saved, run it again" + }, + { + "consequence": "The item closes as done by a person.", + "id": "fixed_by_hand", + "label": "I fixed it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [], + "differences": [], + "expected": "", + "observed": "", + "reason": "RECONCILIATION_REQUIRED: System-of-record effect verification HALTED step 'step_040' (click 'Confirm Create New Patient'): exact_new_set refuted against the rest system of record (the screen indicated completion but the declared outcome is contradicted or unverifiable) \u2014 declared new record {DOB=10/", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "10/01/1958" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "fergus.ashbrook.19@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Fergus" + }, + { + "field": "lname", + "label": "Lname", + "value": "Ashbrook" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0118" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Male" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "399 LANTERN COURT FERRY" + }, + { + "field": "title", + "label": "Title", + "value": "Mr." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "confirm_save", + "label": "Referral ref-00019", + "question": "Did it save?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00019", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "The save may have gone through, but OpenAdapt couldn't confirm it. Look at the record, then say what you found.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "malformed_dob", + "expected": [ + "fixed_by_hand" + ], + "hard": true, + "item_id": "ref-00019", + "rationale": "date of birth written MM/DD/YYYY; the true date is ISO", + "soak_operator": { + "action": "do_by_hand", + "charged_s": 330.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "fix_wrong_chart_by_hand" + } + }, + { + "card": { + "batch_key": "same_record:openemr-referral:duplicate", + "card_id": "sha256:83c816b5eade946084dc13f8dd058800130e5316063739ae9e0a0dc5538c8f34", + "choices": [ + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "same", + "label": "Same person: don't add it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person. OpenAdapt saves nothing.", + "id": "different_by_hand", + "label": "Different person: I entered it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [ + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": true, + "value": "1996-11-12" + }, + { + "field": "fname", + "label": "Fname", + "same": true, + "value": "Odile" + }, + { + "field": "lname", + "label": "Lname", + "same": true, + "value": "Quenwood" + }, + { + "field": "sex", + "label": "Sex", + "same": true, + "value": "Female" + }, + { + "field": "street", + "label": "Street", + "same": true, + "value": "411 QUARRY HILL DRIVE" + } + ], + "text": "1996-11-12, Odile, Quenwood, Female, 411 QUARRY HILL DRIVE" + } + ], + "differences": [], + "expected": "", + "observed": "", + "reason": "HALTED_BEFORE_EFFECT: Postconditions failed for step 'step_039' (click 'Create New Patient'): expected screen state not reached (semantic drift) \u2014 failed: no_duplicate_candidate (no listed row has this run's lname, DOB) \u2014 run aborted", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1996-11-12" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "odile.quenwood.13@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Odile" + }, + { + "field": "lname", + "label": "Lname", + "value": "Quenwood" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0149" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Female" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "411 QUARRY HILL DRIVE" + }, + { + "field": "title", + "label": "Title", + "value": "Ms." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "same_record", + "label": "Referral ref-00021", + "question": "Same person?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00021", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving: the app may already have this record. Compare the item with what the app shows.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "dup_resend", + "expected": [ + "decline", + "same" + ], + "hard": false, + "item_id": "ref-00021", + "rationale": "a resend of ref-00013", + "soak_operator": { + "action": "skip", + "charged_s": 120.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "skip_no_chart_wanted" + } + }, + { + "card": { + "batch_key": "same_record:openemr-referral:duplicate", + "card_id": "sha256:3e9b7dfe8fcfa8102d7ebe24ea3f7598adcf7d45f46fa4a05c422a0e2e901855", + "choices": [ + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "same", + "label": "Same person: don't add it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person. OpenAdapt saves nothing.", + "id": "different_by_hand", + "label": "Different person: I entered it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [ + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": false, + "value": "1937-08-12" + }, + { + "field": "fname", + "label": "Fname", + "same": true, + "value": "Ida" + }, + { + "field": "lname", + "label": "Lname", + "same": true, + "value": "Norwick" + }, + { + "field": "sex", + "label": "Sex", + "same": true, + "value": "Male" + }, + { + "field": "street", + "label": "Street", + "same": true, + "value": "88 QUARRY HILL DRIVE" + } + ], + "text": "1937-08-12, Ida, Norwick, Male, 88 QUARRY HILL DRIVE" + } + ], + "differences": [], + "expected": "", + "observed": "", + "reason": "HALTED_BEFORE_EFFECT: Postconditions failed for step 'step_039' (click 'Create New Patient'): expected screen state not reached (semantic drift) \u2014 failed: no_duplicate_candidate (no listed row has this run's lname, DOB) \u2014 run aborted", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1937-08-13" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "ida.norwick.9010@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Ida" + }, + { + "field": "lname", + "label": "Lname", + "value": "Norwick" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0107" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Male" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "88 QUARRY HILL DRIVE" + }, + { + "field": "title", + "label": "Title", + "value": "Mr." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "same_record", + "label": "Referral ref-00024", + "question": "Same person?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00024", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving: the app may already have this record. Compare the item with what the app shows.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "ambiguous_same_person", + "expected": [ + "decline", + "same" + ], + "hard": false, + "item_id": "ref-00024", + "rationale": "same person as an existing chart; the date of birth has a typo", + "soak_operator": { + "action": "skip", + "charged_s": 120.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "skip_no_chart_wanted" + } + }, + { + "card": { + "batch_key": "confirm_save:openemr-referral:save_not_confirmed", + "card_id": "sha256:588ff23b826be96c4ae4ba889511ff2f7cb24801a6a913acd922f455a2aef228", + "choices": [ + { + "consequence": "The item closes as saved.", + "id": "saved", + "label": "I checked: it saved correctly" + }, + { + "consequence": "OpenAdapt runs the item again, with every check.", + "id": "not_saved_retry", + "label": "I checked: nothing saved, run it again" + }, + { + "consequence": "The item closes as done by a person.", + "id": "fixed_by_hand", + "label": "I fixed it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [], + "differences": [], + "expected": "", + "observed": "", + "reason": "RECONCILIATION_REQUIRED: System-of-record effect verification HALTED step 'step_040' (click 'Save new patient'): record_written refuted against the rest system of record (the screen indicated completion but the declared outcome is contradicted or unverifiable) \u2014 0 records match the target selector, ", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1990-09-13" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "farida.hartbrook.26@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": " FARIDA " + }, + { + "field": "lname", + "label": "Lname", + "value": "HARTBROOK " + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0145" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Female" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "175 ORCHARD LANE HARBOR" + }, + { + "field": "title", + "label": "Title", + "value": "Ms." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "confirm_save", + "label": "Referral ref-00026", + "question": "Did it save?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00026", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "The save may have gone through, but OpenAdapt couldn't confirm it. Look at the record, then say what you found.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "malformed_case", + "expected": [ + "saved" + ], + "hard": false, + "item_id": "ref-00026", + "rationale": "names in capitals with stray spaces", + "soak_operator": { + "action": "confirm_saved", + "charged_s": 120.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "chart_already_correct" + } + }, + { + "card": { + "batch_key": "same_record:openemr-referral:duplicate", + "card_id": "sha256:d447a65bcf072b62f7618d6fb0b83f2dd2b8eda2850a49ca6c05ee49b200f085", + "choices": [ + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "same", + "label": "Same person: don't add it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person. OpenAdapt saves nothing.", + "id": "different_by_hand", + "label": "Different person: I entered it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [ + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": false, + "value": "1975-06-23" + }, + { + "field": "fname", + "label": "Fname", + "same": false, + "value": "Henrik" + }, + { + "field": "lname", + "label": "Lname", + "same": true, + "value": "Birchmore" + }, + { + "field": "sex", + "label": "Sex", + "same": true, + "value": "Female" + }, + { + "field": "street", + "label": "Street", + "same": false, + "value": "181 WAY" + } + ], + "text": "1975-06-23, Henrik, Birchmore, Female, 181 WAY" + } + ], + "differences": [], + "expected": "", + "observed": "", + "reason": "HALTED_BEFORE_EFFECT: Postconditions failed for step 'step_039' (click 'Create New Patient'): expected screen state not reached (semantic drift) \u2014 failed: no_duplicate_candidate (no listed row has this run's lname, DOB) \u2014 run aborted", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1978-09-15" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "jasper.birchmore.27@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Jasper" + }, + { + "field": "lname", + "label": "Lname", + "value": "Birchmore" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0142" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Female" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "227 WILLOW BEND ROAD" + }, + { + "field": "title", + "label": "Title", + "value": "Mrs." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "same_record", + "label": "Referral ref-00027", + "question": "Same person?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00027", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving: the app may already have this record. Compare the item with what the app shows.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "common_surname", + "expected": [ + "different_by_hand" + ], + "hard": true, + "item_id": "ref-00027", + "rationale": "shares a last name with an existing chart; a different person", + "soak_operator": { + "action": "do_by_hand", + "charged_s": 300.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "enter_by_hand" + } + }, + { + "card": { + "batch_key": "same_record:openemr-referral:duplicate", + "card_id": "sha256:4a65c4105db3317d4e87b97b29de96b00bf2f9c5c8e09f8d77f996cfc3997802", + "choices": [ + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "same", + "label": "Same person: don't add it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person. OpenAdapt saves nothing.", + "id": "different_by_hand", + "label": "Different person: I entered it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [ + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": true, + "value": "1986-03-18" + }, + { + "field": "fname", + "label": "Fname", + "same": true, + "value": "Wen" + }, + { + "field": "lname", + "label": "Lname", + "same": true, + "value": "Oakwell" + }, + { + "field": "sex", + "label": "Sex", + "same": true, + "value": "Male" + }, + { + "field": "street", + "label": "Street", + "same": true, + "value": "472 WAY" + } + ], + "text": "1986-03-18, Wen, Oakwell, Male, 472 WAY" + } + ], + "differences": [], + "expected": "", + "observed": "", + "reason": "HALTED_BEFORE_EFFECT: Postconditions failed for step 'step_039' (click 'Create New Patient'): expected screen state not reached (semantic drift) \u2014 failed: no_duplicate_candidate (no listed row has this run's lname, DOB) \u2014 run aborted", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1986-03-18" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "wen.oakwell.29@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Wen" + }, + { + "field": "lname", + "label": "Lname", + "value": "Oakwell" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0111" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Male" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "472 WAY" + }, + { + "field": "title", + "label": "Title", + "value": "Mr." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "same_record", + "label": "Referral ref-00029", + "question": "Same person?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00029", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving: the app may already have this record. Compare the item with what the app shows.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "concurrent_same", + "expected": [ + "decline", + "same" + ], + "hard": false, + "item_id": "ref-00029", + "rationale": "another clerk registered this patient moments earlier", + "soak_operator": { + "action": "skip", + "charged_s": 120.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "skip_no_chart_wanted" + } + }, + { + "card": { + "batch_key": "same_record:openemr-referral:duplicate", + "card_id": "sha256:f5afc01f6c4aea47875533fffd430555590c12be26cd50edd0027287c005f571", + "choices": [ + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "same", + "label": "Same person: don't add it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person. OpenAdapt saves nothing.", + "id": "different_by_hand", + "label": "Different person: I entered it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [ + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": false, + "value": "1943-01-24" + }, + { + "field": "fname", + "label": "Fname", + "same": false, + "value": "Arlo" + }, + { + "field": "lname", + "label": "Lname", + "same": true, + "value": "Jesston" + }, + { + "field": "sex", + "label": "Sex", + "same": true, + "value": "Male" + }, + { + "field": "street", + "label": "Street", + "same": false, + "value": "361 BEACON TERRACE STATION" + } + ], + "text": "1943-01-24, Arlo, Jesston, Male, 361 BEACON TERRACE STATION" + } + ], + "differences": [], + "expected": "", + "observed": "", + "reason": "HALTED_BEFORE_EFFECT: Postconditions failed for step 'step_039' (click 'Create New Patient'): expected screen state not reached (semantic drift) \u2014 failed: no_duplicate_candidate (no listed row has this run's lname, DOB) \u2014 run aborted", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1944-05-01" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "hollis.jesston.30@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Hollis" + }, + { + "field": "lname", + "label": "Lname", + "value": "Jesston" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0110" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Male" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "326 QUARRY HILL DRIVE" + }, + { + "field": "title", + "label": "Title", + "value": "Mr." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "same_record", + "label": "Referral ref-00030", + "question": "Same person?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00030", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving: the app may already have this record. Compare the item with what the app shows.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "common_surname", + "expected": [ + "different_by_hand" + ], + "hard": true, + "item_id": "ref-00030", + "rationale": "shares a last name with an existing chart; a different person", + "soak_operator": { + "action": "do_by_hand", + "charged_s": 300.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "enter_by_hand" + } + }, + { + "card": { + "batch_key": "halted:openemr-referral:screen_changed", + "card_id": "sha256:dde30b98d79e7a226be74039395fb601d98432dc7a9f0c449a36761e061a3535", + "choices": [ + { + "consequence": "OpenAdapt runs the item again, with every check. Nothing was saved.", + "id": "retry", + "label": "Run it again" + }, + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "skip", + "label": "Skip it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person.", + "id": "by_hand", + "label": "I did it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [], + "differences": [], + "expected": "", + "observed": "", + "reason": "REJECTED_POLICY: ", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1955-05-02" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "ilse.iverswick.32@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Ilse" + }, + { + "field": "lname", + "label": "Lname", + "value": "Iverswick" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Female" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "94 STREET CEDAR ROW" + }, + { + "field": "title", + "label": "Title", + "value": "Mrs." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "halted", + "label": "Referral ref-00032", + "question": "What should happen to this item?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00032", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving. Nothing was written.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "missing_optional", + "expected": [ + "by_hand" + ], + "hard": false, + "item_id": "ref-00032", + "rationale": "no home phone; register without one", + "soak_operator": { + "action": "do_by_hand", + "charged_s": 300.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "enter_by_hand" + } + }, + { + "card": { + "batch_key": "same_record:openemr-referral:duplicate", + "card_id": "sha256:bdab6627899547c780f0db6d81112ef283e3460d989977d4017dfa6592ce1238", + "choices": [ + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "same", + "label": "Same person: don't add it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person. OpenAdapt saves nothing.", + "id": "different_by_hand", + "label": "Different person: I entered it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [ + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": false, + "value": "1941-02-27" + }, + { + "field": "fname", + "label": "Fname", + "same": false, + "value": "Ana" + }, + { + "field": "lname", + "label": "Lname", + "same": true, + "value": "Fairwick" + }, + { + "field": "sex", + "label": "Sex", + "same": true, + "value": "Male" + }, + { + "field": "street", + "label": "Street", + "same": false, + "value": "276 QUARRY HILL DRIVE" + } + ], + "text": "1941-02-27, Ana, Fairwick, Male, 276 QUARRY HILL DRIVE" + }, + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": false, + "value": "1957-03-08" + }, + { + "field": "fname", + "label": "Fname", + "same": false, + "value": "Bram" + }, + { + "field": "lname", + "label": "Lname", + "same": true, + "value": "Fairwick" + }, + { + "field": "sex", + "label": "Sex", + "same": false, + "value": "Female" + }, + { + "field": "street", + "label": "Street", + "same": false, + "value": "339 STREET CEDAR ROW" + } + ], + "text": "1957-03-08, Bram, Fairwick, Female, 339 STREET CEDAR ROW" + } + ], + "differences": [], + "expected": "", + "observed": "", + "reason": "HALTED_BEFORE_EFFECT: Postconditions failed for step 'step_039' (click 'Create New Patient'): expected screen state not reached (semantic drift) \u2014 failed: no_duplicate_candidate (no listed row has this run's lname, DOB) \u2014 run aborted", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1999-11-06" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "odile.fairwick.33@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Odile" + }, + { + "field": "lname", + "label": "Lname", + "value": "Fairwick" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0132" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Male" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "140 AVENUE MILL ROAD" + }, + { + "field": "title", + "label": "Title", + "value": "Mr." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "same_record", + "label": "Referral ref-00033", + "question": "Same person?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00033", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving: the app may already have this record. Compare the item with what the app shows.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "common_surname", + "expected": [ + "different_by_hand" + ], + "hard": true, + "item_id": "ref-00033", + "rationale": "shares a last name with an existing chart; a different person", + "soak_operator": { + "action": "do_by_hand", + "charged_s": 300.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "enter_by_hand" + } + }, + { + "card": { + "batch_key": "same_record:openemr-referral:duplicate", + "card_id": "sha256:bcb25bbb2f544e033e11ad8f3f535138d85659ec38001e168f88dec9a1532e31", + "choices": [ + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "same", + "label": "Same person: don't add it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person. OpenAdapt saves nothing.", + "id": "different_by_hand", + "label": "Different person: I entered it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [ + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": true, + "value": "1937-08-12" + }, + { + "field": "fname", + "label": "Fname", + "same": true, + "value": "Ida" + }, + { + "field": "lname", + "label": "Lname", + "same": true, + "value": "Norwick" + }, + { + "field": "sex", + "label": "Sex", + "same": true, + "value": "Male" + }, + { + "field": "street", + "label": "Street", + "same": true, + "value": "88 QUARRY HILL DRIVE" + } + ], + "text": "1937-08-12, Ida, Norwick, Male, 88 QUARRY HILL DRIVE" + } + ], + "differences": [], + "expected": "", + "observed": "", + "reason": "HALTED_BEFORE_EFFECT: Postconditions failed for step 'step_039' (click 'Create New Patient'): expected screen state not reached (semantic drift) \u2014 failed: no_duplicate_candidate (no listed row has this run's lname, DOB) \u2014 run aborted", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1937-08-12" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "ida.norwick.9010@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Ida" + }, + { + "field": "lname", + "label": "Lname", + "value": "Norwick" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0107" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Male" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "88 QUARRY HILL DRIVE" + }, + { + "field": "title", + "label": "Title", + "value": "Mr." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "same_record", + "label": "Referral ref-00034", + "question": "Same person?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00034", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving: the app may already have this record. Compare the item with what the app shows.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "dup_existing", + "expected": [ + "decline", + "same" + ], + "hard": false, + "item_id": "ref-00034", + "rationale": "this patient already has a chart", + "soak_operator": { + "action": "skip", + "charged_s": 120.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "skip_no_chart_wanted" + } + }, + { + "card": { + "batch_key": "same_record:openemr-referral:duplicate", + "card_id": "sha256:ef330b5c8011fc21e0afa85da1f8c30ec89896e58f965b0d3b376978fb1a0eb8", + "choices": [ + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "same", + "label": "Same person: don't add it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person. OpenAdapt saves nothing.", + "id": "different_by_hand", + "label": "Different person: I entered it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [ + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": false, + "value": "1972-07-05" + }, + { + "field": "fname", + "label": "Fname", + "same": false, + "value": "Bram" + }, + { + "field": "lname", + "label": "Lname", + "same": true, + "value": "Birchfield" + }, + { + "field": "sex", + "label": "Sex", + "same": false, + "value": "Female" + }, + { + "field": "street", + "label": "Street", + "same": false, + "value": "278 QUARRY HILL DRIVE" + } + ], + "text": "1972-07-05, Bram, Birchfield, Female, 278 QUARRY HILL DRIVE" + } + ], + "differences": [], + "expected": "", + "observed": "", + "reason": "HALTED_BEFORE_EFFECT: Postconditions failed for step 'step_039' (click 'Create New Patient'): expected screen state not reached (semantic drift) \u2014 failed: no_duplicate_candidate (no listed row has this run's lname, DOB) \u2014 run aborted", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1958-03-09" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "dmitri.birchfield.38@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Dmitri" + }, + { + "field": "lname", + "label": "Lname", + "value": "Birchfield" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0146" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Male" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "230 WILLOW BEND ROAD" + }, + { + "field": "title", + "label": "Title", + "value": "Mr." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "same_record", + "label": "Referral ref-00038", + "question": "Same person?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00038", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving: the app may already have this record. Compare the item with what the app shows.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "slow_save", + "expected": [ + "different_by_hand" + ], + "hard": true, + "item_id": "ref-00038", + "rationale": "the save is slow", + "soak_operator": { + "action": "do_by_hand", + "charged_s": 300.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "enter_by_hand" + } + }, + { + "card": { + "batch_key": "same_record:openemr-referral:duplicate", + "card_id": "sha256:27e73f168b1b3a3edebd851bbd0ca9944ccb6104b5a998e33d20062810c4bda3", + "choices": [ + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "same", + "label": "Same person: don't add it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person. OpenAdapt saves nothing.", + "id": "different_by_hand", + "label": "Different person: I entered it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [ + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": true, + "value": "1950-04-21" + }, + { + "field": "fname", + "label": "Fname", + "same": false, + "value": "Rosalind" + }, + { + "field": "lname", + "label": "Lname", + "same": false, + "value": "Oakwood" + }, + { + "field": "sex", + "label": "Sex", + "same": true, + "value": "Female" + }, + { + "field": "street", + "label": "Street", + "same": false, + "value": "323 WILLOW BEND ROAD" + } + ], + "text": "1950-04-21, Rosalind, Oakwood, Female, 323 WILLOW BEND ROAD" + }, + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": false, + "value": "1990-09-09" + }, + { + "field": "fname", + "label": "Fname", + "same": false, + "value": "Rosalind" + }, + { + "field": "lname", + "label": "Lname", + "same": true, + "value": "Jesswood" + }, + { + "field": "sex", + "label": "Sex", + "same": true, + "value": "Female" + }, + { + "field": "street", + "label": "Street", + "same": false, + "value": "363 ORCHARD LANE HARBOR" + } + ], + "text": "1990-09-09, Rosalind, Jesswood, Female, 363 ORCHARD LANE HARBOR" + } + ], + "differences": [], + "expected": "", + "observed": "", + "reason": "HALTED_BEFORE_EFFECT: Postconditions failed for step 'step_039' (click 'Create New Patient'): expected screen state not reached (semantic drift) \u2014 failed: no_duplicate_candidate (no listed row has this run's lname, DOB) \u2014 run aborted", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1950-04-21" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "mateo.jesswood.40@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Mateo" + }, + { + "field": "lname", + "label": "Lname", + "value": "Jesswood" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0111" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Female" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "4 QUARRY HILL DRIVE" + }, + { + "field": "title", + "label": "Title", + "value": "Mrs." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "same_record", + "label": "Referral ref-00040", + "question": "Same person?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00040", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving: the app may already have this record. Compare the item with what the app shows.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "shared_dob", + "expected": [ + "different_by_hand" + ], + "hard": true, + "item_id": "ref-00040", + "rationale": "born on the same day as an existing chart; a different person", + "soak_operator": { + "action": "do_by_hand", + "charged_s": 300.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "enter_by_hand" + } + }, + { + "card": { + "batch_key": "same_record:openemr-referral:duplicate", + "card_id": "sha256:79812f6020f1722dc41eb3407614e681b595dff6921fe9cedf52791510b8e21f", + "choices": [ + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "same", + "label": "Same person: don't add it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person. OpenAdapt saves nothing.", + "id": "different_by_hand", + "label": "Different person: I entered it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [ + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": true, + "value": "1966-07-13" + }, + { + "field": "fname", + "label": "Fname", + "same": false, + "value": "Odile" + }, + { + "field": "lname", + "label": "Lname", + "same": false, + "value": "Jessmere" + }, + { + "field": "sex", + "label": "Sex", + "same": true, + "value": "Female" + }, + { + "field": "street", + "label": "Street", + "same": false, + "value": "471 WAY" + } + ], + "text": "1966-07-13, Odile, Jessmere, Female, 471 WAY" + } + ], + "differences": [], + "expected": "", + "observed": "", + "reason": "HALTED_BEFORE_EFFECT: Postconditions failed for step 'step_039' (click 'Create New Patient'): expected screen state not reached (semantic drift) \u2014 failed: no_duplicate_candidate (no listed row has this run's lname, DOB) \u2014 run aborted", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1966-07-13" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "ines.norwell.41@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Ines" + }, + { + "field": "lname", + "label": "Lname", + "value": "Norwell" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0184" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Female" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "121 QUARRY HILL DRIVE" + }, + { + "field": "title", + "label": "Title", + "value": "Ms." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "same_record", + "label": "Referral ref-00041", + "question": "Same person?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00041", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving: the app may already have this record. Compare the item with what the app shows.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "shared_dob", + "expected": [ + "different_by_hand" + ], + "hard": true, + "item_id": "ref-00041", + "rationale": "born on the same day as an existing chart; a different person", + "soak_operator": { + "action": "do_by_hand", + "charged_s": 300.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "enter_by_hand" + } + }, + { + "card": { + "batch_key": "same_record:openemr-referral:duplicate", + "card_id": "sha256:04cdb4c2055b08ff164a63ba73008aaec24dde4aff7c825d6150272d021b77b6", + "choices": [ + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "same", + "label": "Same person: don't add it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person. OpenAdapt saves nothing.", + "id": "different_by_hand", + "label": "Different person: I entered it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [ + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": false, + "value": "1958-10-01" + }, + { + "field": "fname", + "label": "Fname", + "same": true, + "value": "Fergus" + }, + { + "field": "lname", + "label": "Lname", + "same": true, + "value": "Ashbrook" + }, + { + "field": "sex", + "label": "Sex", + "same": true, + "value": "Male" + }, + { + "field": "street", + "label": "Street", + "same": true, + "value": "399 LANTERN COURT FERRY" + } + ], + "text": "1958-10-01, Fergus, Ashbrook, Male, 399 LANTERN COURT FERRY" + } + ], + "differences": [], + "expected": "", + "observed": "", + "reason": "HALTED_BEFORE_EFFECT: Postconditions failed for step 'step_039' (click 'Create New Patient'): expected screen state not reached (semantic drift) \u2014 failed: no_duplicate_candidate (no listed row has this run's lname, DOB) \u2014 run aborted", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "10/01/1958" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "fergus.ashbrook.19@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Fergus" + }, + { + "field": "lname", + "label": "Lname", + "value": "Ashbrook" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0118" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Male" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "399 LANTERN COURT FERRY" + }, + { + "field": "title", + "label": "Title", + "value": "Mr." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "same_record", + "label": "Referral ref-00044", + "question": "Same person?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00044", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving: the app may already have this record. Compare the item with what the app shows.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "dup_resend", + "expected": [ + "decline", + "same" + ], + "hard": false, + "item_id": "ref-00044", + "rationale": "a resend of ref-00019", + "soak_operator": { + "action": "skip", + "charged_s": 120.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "skip_no_chart_wanted" + } + }, + { + "card": { + "batch_key": "same_record:openemr-referral:duplicate", + "card_id": "sha256:84909b0fc3f1cb667a37051b52c2af0017eca4952335ccc9b3b47a6cb58629ed", + "choices": [ + { + "consequence": "Nothing is saved. The item closes as skipped.", + "id": "same", + "label": "Same person: don't add it" + }, + { + "consequence": "Nothing is saved. The item closes as declined.", + "id": "decline", + "label": "This item shouldn't be done" + }, + { + "consequence": "The item closes as done by a person. OpenAdapt saves nothing.", + "id": "different_by_hand", + "label": "Different person: I entered it by hand" + }, + { + "consequence": "Nothing changes. The stop stays in the queue.", + "id": "leave", + "label": "Not sure, leave it" + } + ], + "created_at": null, + "evidence": { + "candidates": [ + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": true, + "value": "1959-06-04" + }, + { + "field": "fname", + "label": "Fname", + "same": true, + "value": "Gideon" + }, + { + "field": "lname", + "label": "Lname", + "same": true, + "value": "Galwick" + }, + { + "field": "sex", + "label": "Sex", + "same": true, + "value": "Female" + }, + { + "field": "street", + "label": "Street", + "same": true, + "value": "282 QUARRY HILL DRIVE" + } + ], + "text": "1959-06-04, Gideon, Galwick, Female, 282 QUARRY HILL DRIVE" + }, + { + "fields": [ + { + "field": "DOB", + "label": "DOB", + "same": false, + "value": "1945-10-10" + }, + { + "field": "fname", + "label": "Fname", + "same": false, + "value": "Yuki" + }, + { + "field": "lname", + "label": "Lname", + "same": true, + "value": "Galwick" + }, + { + "field": "sex", + "label": "Sex", + "same": true, + "value": "Female" + }, + { + "field": "street", + "label": "Street", + "same": false, + "value": "270 WAY" + } + ], + "text": "1945-10-10, Yuki, Galwick, Female, 270 WAY" + } + ], + "differences": [], + "expected": "", + "observed": "", + "reason": "HALTED_BEFORE_EFFECT: Postconditions failed for step 'step_039' (click 'Create New Patient'): expected screen state not reached (semantic drift) \u2014 failed: no_duplicate_candidate (no listed row has this run's lname, DOB) \u2014 run aborted", + "record": [], + "screenshot": null, + "source": [ + { + "field": "DOB", + "label": "DOB", + "value": "1959-06-04" + }, + { + "field": "city", + "label": "City", + "value": "EXAMPLETON" + }, + { + "field": "country_code", + "label": "Country code", + "value": "USA" + }, + { + "field": "email", + "label": "Email", + "value": "gideon.galwick.9004@example.invalid" + }, + { + "field": "fname", + "label": "Fname", + "value": "Gideon" + }, + { + "field": "lname", + "label": "Lname", + "value": "Galwick" + }, + { + "field": "phone_home", + "label": "Phone home", + "value": "555-0179" + }, + { + "field": "postal_code", + "label": "Postal code", + "value": "02139" + }, + { + "field": "sex", + "label": "Sex", + "value": "Female" + }, + { + "field": "state_label", + "label": "State label", + "value": "Massachusetts" + }, + { + "field": "street", + "label": "Street", + "value": "282 QUARRY HILL DRIVE" + }, + { + "field": "title", + "label": "Title", + "value": "Ms." + } + ], + "step": "" + }, + "expires_at": null, + "kind": "same_record", + "label": "Referral ref-00045", + "question": "Same person?", + "schema": "openadapt.desktop-stop-card/v1", + "source": { + "id": "ref-00045", + "run_id": null, + "type": "item" + }, + "status": "open", + "summary": "OpenAdapt stopped before saving: the app may already have this record. Compare the item with what the app shows.", + "task_name": "New patient referral", + "workflow_id": "openemr-referral" + }, + "exception": "dup_existing", + "expected": [ + "decline", + "same" + ], + "hard": false, + "item_id": "ref-00045", + "rationale": "this patient already has a chart", + "soak_operator": { + "action": "skip", + "charged_s": 120.0, + "cost_model": "lifecycle-cost/2026-10-20.1", + "rule": "skip_no_chart_wanted" + } + } + ] +} diff --git a/packages/desktop/benchmark/stop_queue/klm-2026-10-24.json b/packages/desktop/benchmark/stop_queue/klm-2026-10-24.json new file mode 100644 index 000000000..2a239b4db --- /dev/null +++ b/packages/desktop/benchmark/stop_queue/klm-2026-10-24.json @@ -0,0 +1,203 @@ +{ + "schema": "openadapt.stop-queue-klm/v1", + "method": "keystroke-level model (Card, Moran, Newell 1983; Kieras 2001)", + "assumptions": { + "K": 0.28, + "P": 1.1, + "BB": 0.2, + "H": 0.4, + "M": 1.2, + "app_search_wait_s": 1.5, + "touch_id_s": 1.0, + "carried_fields": 3 + }, + "stops": [ + { + "item_id": "ref-00002", + "kind": "same_record", + "possible_matches": 1, + "before_s": 27.9, + "after_s": 5.3, + "soak_charged_s": 300.0, + "soak_action": "do_by_hand" + }, + { + "item_id": "ref-00005", + "kind": "same_record", + "possible_matches": 1, + "before_s": 27.7, + "after_s": 5.3, + "soak_charged_s": 300.0, + "soak_action": "do_by_hand" + }, + { + "item_id": "ref-00006", + "kind": "confirm_save", + "possible_matches": 0, + "before_s": 25.3, + "after_s": 23.1, + "soak_charged_s": 330.0, + "soak_action": "do_by_hand" + }, + { + "item_id": "ref-00008", + "kind": "halted", + "possible_matches": 0, + "before_s": 6.2, + "after_s": 5.3, + "soak_charged_s": 120.0, + "soak_action": "skip" + }, + { + "item_id": "ref-00012", + "kind": "same_record", + "possible_matches": 1, + "before_s": 28.2, + "after_s": 5.3, + "soak_charged_s": 120.0, + "soak_action": "skip" + }, + { + "item_id": "ref-00019", + "kind": "confirm_save", + "possible_matches": 0, + "before_s": 25.5, + "after_s": 23.4, + "soak_charged_s": 330.0, + "soak_action": "do_by_hand" + }, + { + "item_id": "ref-00021", + "kind": "same_record", + "possible_matches": 1, + "before_s": 27.9, + "after_s": 5.3, + "soak_charged_s": 120.0, + "soak_action": "skip" + }, + { + "item_id": "ref-00024", + "kind": "same_record", + "possible_matches": 1, + "before_s": 27.7, + "after_s": 5.3, + "soak_charged_s": 120.0, + "soak_action": "skip" + }, + { + "item_id": "ref-00026", + "kind": "confirm_save", + "possible_matches": 0, + "before_s": 26.1, + "after_s": 24.0, + "soak_charged_s": 120.0, + "soak_action": "confirm_saved" + }, + { + "item_id": "ref-00027", + "kind": "same_record", + "possible_matches": 1, + "before_s": 28.2, + "after_s": 5.3, + "soak_charged_s": 300.0, + "soak_action": "do_by_hand" + }, + { + "item_id": "ref-00029", + "kind": "same_record", + "possible_matches": 1, + "before_s": 27.7, + "after_s": 5.3, + "soak_charged_s": 120.0, + "soak_action": "skip" + }, + { + "item_id": "ref-00030", + "kind": "same_record", + "possible_matches": 1, + "before_s": 27.7, + "after_s": 5.3, + "soak_charged_s": 300.0, + "soak_action": "do_by_hand" + }, + { + "item_id": "ref-00032", + "kind": "halted", + "possible_matches": 0, + "before_s": 6.2, + "after_s": 5.3, + "soak_charged_s": 300.0, + "soak_action": "do_by_hand" + }, + { + "item_id": "ref-00033", + "kind": "same_record", + "possible_matches": 2, + "before_s": 36.7, + "after_s": 6.5, + "soak_charged_s": 300.0, + "soak_action": "do_by_hand" + }, + { + "item_id": "ref-00034", + "kind": "same_record", + "possible_matches": 1, + "before_s": 27.7, + "after_s": 5.3, + "soak_charged_s": 120.0, + "soak_action": "skip" + }, + { + "item_id": "ref-00038", + "kind": "same_record", + "possible_matches": 1, + "before_s": 28.5, + "after_s": 5.3, + "soak_charged_s": 300.0, + "soak_action": "do_by_hand" + }, + { + "item_id": "ref-00040", + "kind": "same_record", + "possible_matches": 2, + "before_s": 36.7, + "after_s": 6.5, + "soak_charged_s": 300.0, + "soak_action": "do_by_hand" + }, + { + "item_id": "ref-00041", + "kind": "same_record", + "possible_matches": 1, + "before_s": 27.7, + "after_s": 5.3, + "soak_charged_s": 300.0, + "soak_action": "do_by_hand" + }, + { + "item_id": "ref-00044", + "kind": "same_record", + "possible_matches": 1, + "before_s": 27.9, + "after_s": 5.3, + "soak_charged_s": 120.0, + "soak_action": "skip" + }, + { + "item_id": "ref-00045", + "kind": "same_record", + "possible_matches": 2, + "before_s": 36.5, + "after_s": 6.5, + "soak_charged_s": 120.0, + "soak_action": "skip" + } + ], + "summary": { + "stops": 20, + "before_mean_s": 26.7, + "after_mean_s": 8.2, + "before_median_s": 27.7, + "after_median_s": 5.3 + } +} diff --git a/packages/desktop/benchmark/stop_queue/klm.py b/packages/desktop/benchmark/stop_queue/klm.py new file mode 100644 index 000000000..ccc2f2a32 --- /dev/null +++ b/packages/desktop/benchmark/stop_queue/klm.py @@ -0,0 +1,146 @@ +"""Seconds per stop, before and after the stop queue: a keystroke-level model. + +This is an estimate, not a measurement of a person. It applies the +keystroke-level model (Card, Moran and Newell, 1983; operator times as +Kieras, "Using the Keystroke-Level Model to Estimate Execution Times", 2001) +to the steps a person takes for each of the drill's stops: + +* ``before``: Needs you as it is today. Open the item, read why it stopped, + switch to the app, search for the patient, compare each listed record with + the item's values, switch back, tick "I looked at the record" when the + action needs it, and pick the action. +* ``after``: the stop's card. Read the question, scan the marked comparison + for each possible match, press one key, confirm with Touch ID. + +Both arms count only deciding, not entering an item by hand afterwards. +Deciding is the part the queue changes. A person measures the real numbers +with the practice page (see README.md). + +Run from ``packages/desktop``:: + + uv run python benchmark/stop_queue/klm.py benchmark/stop_queue/drill-2026-10-24.json +""" + +from __future__ import annotations + +import json +import statistics +import sys +from pathlib import Path + +# Operator times in seconds (Kieras 2001). +K = 0.28 # one keystroke, average skilled typist +P = 1.1 # point with the mouse +BB = 0.2 # click (press and release) +H = 0.4 # move a hand between keyboard and mouse +M = 1.2 # mental act: read, decide, recall, compare one value +# System waits (assumptions, not measured): +APP_SEARCH_WAIT = 1.5 # the app's patient search returns +TOUCH_ID = 1.0 # a Touch ID prompt is confirmed +#: Item fields a person carries from Desktop to the app to compare. +CARRIED_FIELDS = 3 + + +def _before(card: dict) -> tuple[float, list[str]]: + lname = next( + (r["value"] for r in card["evidence"]["source"] if r["field"].lower() == "lname"), + "patient", + ) + candidates = card["evidence"]["candidates"] or [{"fields": []}] + compared = max(len(c["fields"]) for c in candidates) or CARRIED_FIELDS + steps = [ + ("open the item in Needs you", M + P + BB), + ("read why it stopped", M), + ] + if card["kind"] in {"same_record", "confirm_save"}: + steps += [ + ("remember the item's key values", CARRIED_FIELDS * M), + ("switch to the app", M + 2 * K), + ("open the patient search", P + BB + H), + ("type the last name and search", (len(lname) + 1) * K + APP_SEARCH_WAIT), + ] + for _ in candidates: + steps.append( + ("open one listed record and compare it", P + BB + APP_SEARCH_WAIT + compared * M) + ) + steps += [ + ("switch back to Desktop", 2 * K), + ('tick "I looked at the record"', P + BB), + ] + steps.append(("pick the action", M + P + BB)) + return sum(t for _, t in steps), [name for name, _ in steps] + + +def _after(card: dict) -> tuple[float, list[str]]: + candidates = [c for c in card["evidence"]["candidates"] if c["fields"]] + steps = [("read the question", M)] + if candidates: + # Each row is already marked Same or Different; scanning the marks + # of one possible match is one mental act. + steps += [("scan one marked match", M) for _ in candidates] + elif card["kind"] == "confirm_save": + # No read-back values on this card yet: the person still looks the + # record up in the app, as before, minus the inbox steps. + before, _ = _before(card) + steps.append(("look the record up in the app", before - 2 * (M + P + BB) - M)) + else: + steps.append(("read the reason", M)) + steps += [ + ("decide", M), + ("press the choice's key", K), + ("confirm with Touch ID", H + TOUCH_ID), + ] + return sum(t for _, t in steps), [name for name, _ in steps] + + +def estimate(drill: dict) -> dict: + rows = [] + for stop in drill["stops"]: + card = stop["card"] + before, _ = _before(card) + after, _ = _after(card) + rows.append( + { + "item_id": stop["item_id"], + "kind": card["kind"], + "possible_matches": len(card["evidence"]["candidates"]), + "before_s": round(before, 1), + "after_s": round(after, 1), + "soak_charged_s": stop["soak_operator"]["charged_s"], + "soak_action": stop["soak_operator"]["action"], + } + ) + before = [r["before_s"] for r in rows] + after = [r["after_s"] for r in rows] + return { + "schema": "openadapt.stop-queue-klm/v1", + "method": "keystroke-level model (Card, Moran, Newell 1983; Kieras 2001)", + "assumptions": { + "K": K, + "P": P, + "BB": BB, + "H": H, + "M": M, + "app_search_wait_s": APP_SEARCH_WAIT, + "touch_id_s": TOUCH_ID, + "carried_fields": CARRIED_FIELDS, + }, + "stops": rows, + "summary": { + "stops": len(rows), + "before_mean_s": round(statistics.mean(before), 1), + "after_mean_s": round(statistics.mean(after), 1), + "before_median_s": round(statistics.median(before), 1), + "after_median_s": round(statistics.median(after), 1), + }, + } + + +def main() -> None: + drill = json.loads(Path(sys.argv[1]).read_text()) + result = estimate(drill) + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/packages/desktop/benchmark/stop_queue/practice.html b/packages/desktop/benchmark/stop_queue/practice.html new file mode 100644 index 000000000..405f3ccce --- /dev/null +++ b/packages/desktop/benchmark/stop_queue/practice.html @@ -0,0 +1,12 @@ + + + + + + Stop queue practice + + +
+ + + diff --git a/packages/desktop/docs/OPERATOR_SCREENS.md b/packages/desktop/docs/OPERATOR_SCREENS.md index 88c180ff5..e1b394ca6 100644 --- a/packages/desktop/docs/OPERATOR_SCREENS.md +++ b/packages/desktop/docs/OPERATOR_SCREENS.md @@ -130,7 +130,16 @@ How it works: reaches Flow only through the child process's environment. Flow re-checks the answer, re-reads the form and runs the right-record check again before it saves. -- Set the key up once per computer, at a terminal. With +- On a Mac with a Secure Enclave (Apple silicon or a T2 chip), the + approval screen shows **Set up your approver key** in place of Approve + until the key exists. One click and one Touch ID (or login password) + prompt make the key, seal it to the Secure Enclave, keep it in the login + Keychain and register its public key under `approvers` with Flow's own + `register_principal`. Each answer prompts again, and Flow gets only the + signature (`--approver-signer stdio`). Settings > Approver key replaces + the key (the new one is registered before the old one is retired) or + removes it. See `openadapt_desktop/approver_keychain.py`. +- Elsewhere, set the key up once per computer, at a terminal. With `OPENADAPT_JIT_KEY_PASSPHRASE` set, run `openadapt-flow jit keygen --out ~/.openadapt/approver/desktop-approver.pem`, then start the signed approver roster with it as the root: `openadapt-flow jit approver init @@ -242,6 +251,98 @@ this computer"): already has one on this computer. 6. **No approve-and-save path** for an item the switch held for approval. +## Review stops + +Needs you has **Review one by one**, which opens Review stops: every stop as +one card (`openadapt_desktop/stop_queue.py`, screen `src/screens/Stops.tsx`). + +| Card | Question | Evidence | Choices | +| --- | --- | --- | --- | +| `same_record` | "Same person?" or "Same record?" | The item's values next to each possible match the app listed, each value marked Same or Different, when Flow recorded the rows. Otherwise the row text and the screen where the run stopped. | Same: don't add it (skip); this item shouldn't be done (decline); different: I entered it by hand; not sure, leave it | +| `confirm_save` | "Did it save?" | The item's values and the record check's differences | I checked: it saved; I checked: nothing saved, run it again; I fixed it by hand; leave it | +| `halted` | "What should happen to this item?" | The item's values and why it stopped | Run it again; skip; decline; I did it by hand; leave it | +| `approve_save` | "Approve this save?" | The item's values and the filled form | Approve and save; don't save (three reasons); leave it | +| `rearm_problems_only` | "Turn "only ask about problems" back on?" | Why it went off, when, and the items saved and checked in a row since (Flow offers it only after 10) | Turn it back on; leave it | + +How it answers: + +- **It never decides.** A card's id is a digest of everything it shows. The + engine rebuilds the cards before it acts, so a card that changed, expired + or is gone, or a choice it doesn't offer, changes nothing. +- **Signed.** An answer to a stopped item is a decision receipt that the + approver key signs (Touch ID on a Mac). Then the item's own action runs, + with its usual checks. A waiting save is answered through the signed + item approval. Receipts go to `stop_decisions.jsonl` with how long each + card was on screen (`shown_ms`). +- **Keys.** 1-9 answer, J and K (or the arrows) move between cards, X adds + a card to a batch, Shift+1-9 answers the batch, Esc clears it, and ? + lists the keys. +- **Batches.** Only stops with the same `batch_key` (same kind, task and + reason) batch together. A batch takes one choice and one Touch ID, and + each card is still checked on its own. One stale card stops the whole + batch. Save approvals never batch. +- **No default.** No choice is styled as the default; the evidence decides. +- **Re-arm.** Turning the switch back on is one Touch ID over Flow's own + re-arm bytes (`rearm_to_sign`) for exactly the request the card showed; + if another item finished since, the card changed and nothing happens. + Flow verifies the signature against the key registered under `approvers`, + never a key Desktop supplies for itself. Desktop's switch view honours a + re-arm signed by a registered approver. It is answered on the computer + only, and needs a Flow with signed re-arm (#1517). + +A stop where nothing was written now also offers "I did it by hand". For a +possible duplicate that is a different person, running it again would stop +at the same check. + +## Review stops on a paired phone + +A phone paired with the decision portal ([DECISION_PORTAL.md](DECISION_PORTAL.md)) +shows the same stop cards as Review stops, at `/stops` on the portal's +address. The Decisions page links to it. + +| Where | What the person sees | Routes (all need an approved device session) | +| --- | --- | --- | +| `/stops` | One card at a time: the question, the item's values next to what the app shows with the differences marked, the screen where the run stopped, and each choice with what it does. Similar stops have checkboxes; the picked stops get one answer, which takes a second tap to confirm. | `GET /api/portal/stops`, `GET /api/portal/stops//screenshot`, `POST /api/portal/stops//answer`, `POST /api/portal/stops/answer` (a batch: `card_ids` and one `choice`) | + +How a phone's answer counts: + +- **Signed with the portal's key.** The phone can't reach Touch ID on the + computer. Desktop signs the decision receipt with the decision portal's + approver key (route `portal`, which `openadapt-desktop approver-key setup` + creates; a person registers it as `decision-portal`). The receipt in `stop_decisions.jsonl` + records `"key": "portal"` and the device session (`device.id`, the id the + computer lists and revokes devices by, and its label). The window can't + ask for this key: the portal calls a dispatcher method that no command + registers. Without the key, the phone shows a warning and every answer + changes nothing (`approver_key_missing`). +- **Same checks as the window.** Desktop rebuilds every card before it acts. + A card that changed, expired or is gone, or a choice the card doesn't + offer, changes nothing and returns the dispatcher's code (`card_changed`, + `choice_not_offered`). A phone answer waits up to two seconds for a + command the window started when its answer runs an item again, then + returns `busy` and changes nothing. Closing a stop (skip, done by hand, + saved) doesn't wait, so it goes through while a run waits for a save. +- **View-only on the phone.** A waiting save (`approve_save`) is answered on + the Decisions page, through the portal's save approval, which the Flow + console signs with the same portal key. Turning "only ask about problems" + back on is answered on the computer. +- **Requests.** Answers need the portal's origin, the session's CSRF token + and a JSON body, like the portal's other decisions. Reads need the session + and are refused when `Origin` or `Sec-Fetch-Site` says another site sent + them. Every response is `no-store`, and the service worker caches none of + it. + +### Remote access + +The portal binds `127.0.0.1` by default, so only this computer can open it. +To answer stops from a phone elsewhere, use the existing customer ingress: +an HTTPS origin on the clinic's own reverse proxy, VPN or ZTNA +(`OPENADAPT_PORTAL_INGRESS_MODE=customer_ingress` with an explicit +`OPENADAPT_PORTAL_PUBLIC_ORIGIN`), then pair the phone with the one-use QR +code and confirm its code on the computer. Every route except the pairing +claim needs that pairing. OpenAdapt runs no relay for the portal: the cards, +screenshots and answers stay between the phone and the clinic's network. + ## Screenshots `docs/assets/operator/` holds the README's screenshots. They come from diff --git a/packages/desktop/openadapt_desktop/__init__.py b/packages/desktop/openadapt_desktop/__init__.py index 0f8185fea..f8eab681b 100644 --- a/packages/desktop/openadapt_desktop/__init__.py +++ b/packages/desktop/openadapt_desktop/__init__.py @@ -21,4 +21,4 @@ +-- backends/ Storage backend plugins """ -__version__ = "0.23.0" +__version__ = "0.24.0" diff --git a/packages/desktop/openadapt_desktop/approval_mode.py b/packages/desktop/openadapt_desktop/approval_mode.py index b7d39e8b3..d62755a48 100644 --- a/packages/desktop/openadapt_desktop/approval_mode.py +++ b/packages/desktop/openadapt_desktop/approval_mode.py @@ -394,9 +394,7 @@ def closed(reason: str, at: Any = None, *, here: bool = False) -> dict[str, Any] note = _read_turned_off(root) try: events = api["SwitchLog"](root / "switch.log").read() - from openadapt_flow.approval_mode.switch import summarize_events - - state = summarize_events(events) + state = _summarize(events) except api["SwitchLogError"]: if note is not None: return closed("turned_off", note.get("at"), here=True) @@ -502,6 +500,149 @@ def turn_off(data_dir: Path, bundle_id: str) -> dict[str, Any]: } +# ------------------------------------------------------------------- re-arm + +#: The cause a re-arm card shows and the person's key signs, per off reason. +#: Signed as shown: a different sentence fails verification. +REARM_CAUSES: dict[str, str] = { + "uncertain_result": "A save's result was uncertain.", + "wrong_item_found": "The right-record check found a different record.", + "too_many_problems": "Too many items needed a person.", + "screen_changed": "The app's screen changed.", + "record_check_lost": "The record check stopped working.", + "spot_checks_overdue": "Spot checks were overdue.", + "version_changed": "The task version changed.", + "environment_changed": "This computer's setup changed.", + "admission_lost": "The task's admission lapsed.", + "setup_lapsed": "The runner setup lapsed.", + "turned_off": "A person turned it off.", +} + + +def flow_rearm_api() -> dict[str, Any] | None: + """Flow's signed re-arm for a key held elsewhere, or None without it.""" + + try: + from openadapt_flow.approval_mode import switch + except ImportError: + return None + names = ("rearm_request", "rearm_to_sign", "rearm_evidence", "rearm", "clean_streak") + if not all(hasattr(switch, name) for name in names): + return None + return {name: getattr(switch, name) for name in names} | { + "SwitchLog": switch.SwitchLog, + "SwitchLogError": switch.SwitchLogError, + } + + +def registered_approver_keys() -> dict[str, str]: + """The approvers on the signed roster this Mac pins, as base64 keys. + + ``{name: base64 raw Ed25519 public key}``, from Flow's + ``approver_roster.approver_keys_b64`` with Desktop's pinned root. A signed + re-arm is verified against these. Empty when Flow, the roster or the pin + is unavailable, so a re-arm then counts for nothing. + """ + + try: + from openadapt_flow.approval_mode.approver_roster import approver_keys_b64 + from openadapt_flow.jit.lifecycle import default_key_dir + + from openadapt_desktop.approver_keychain import pinned_root + + return approver_keys_b64(default_key_dir(), pinned_root()) + except Exception: + return {} + + +def _summarize(events: Any) -> Any: + """Flow's switch state, honouring a re-arm signed by a registered approver.""" + + import inspect + + from openadapt_flow.approval_mode.switch import summarize_events + + if "approver_keys" in inspect.signature(summarize_events).parameters: + return summarize_events(events, approver_keys=registered_approver_keys()) + return summarize_events(events) + + +def rearm_offer(data_dir: Path, bundle_id: str) -> dict[str, Any] | None: + """What a person sees and signs to turn the switch back on, or None. + + None unless this Flow can re-arm with a key held elsewhere, the switch is + off, and enough consecutive items since then were verified (Flow decides). + """ + + api = flow_rearm_api() + if api is None: + return None + root = store_dir(data_dir, bundle_id) + log = api["SwitchLog"](root / "switch.log") + try: + events = log.read() + off = next((e for e in reversed(events) if e.kind == "switched_off"), None) + reason = off.reason if off is not None else "" + request = api["rearm_request"]( + log, cause=REARM_CAUSES.get(str(reason), "It turned off.") + ) + except (api["SwitchLogError"], OSError, ValueError): + return None + return {"request": request, "off_at": off.at if off is not None else None} + + +def rearm_available(data_dir: Path, bundle_id: str) -> bool: + return rearm_offer(data_dir, bundle_id) is not None + + +def rearm( + data_dir: Path, + bundle_id: str, + *, + shown: Mapping[str, Any], + approver_id: str, + approver_keys: Mapping[str, str], + sign: Any, +) -> dict[str, Any]: + """Turn the switch back on with the person's signed re-arm. + + ``shown`` is the request the card showed; if Flow's request now differs + (another item finished, another switch-off), nothing changes. ``sign`` + signs the exact bytes (Touch ID) and returns ``"ed25519:"``. + ``approver_keys`` maps registered approver names to base64 public keys; + Flow verifies the signature against them before it appends ``rearmed``. + """ + + import base64 + + api = flow_rearm_api() + offer = rearm_offer(data_dir, bundle_id) + if api is None or offer is None: + return {"ok": False, "error": "The switch can't be turned back on now."} + if dict(offer["request"]) != dict(shown): + return { + "ok": False, + "error": "What you saw has changed. Nothing changed; look at it again.", + } + root = store_dir(data_dir, bundle_id) + log = api["SwitchLog"](root / "switch.log") + fields, payload = api["rearm_to_sign"]( + log, offer["request"], approver_id=approver_id, key_id=approver_id + ) + signature = str(sign(payload)) + raw = bytes.fromhex(signature.removeprefix("ed25519:")) + evidence = api["rearm_evidence"](fields, base64.b64encode(raw).decode("ascii")) + try: + api["rearm"](log, evidence, approver_keys=dict(approver_keys)) + except api["SwitchLogError"] as exc: + return {"ok": False, "error": f"Flow didn't turn it back on: {exc}"} + note = root / "turned_off.json" + if note.exists(): + # The person's own switch-off note would keep the view off. + note.replace(root / f"turned_off.rearmed-{datetime.now(timezone.utc):%Y%m%dT%H%M%S}.json") + return {"ok": True} + + def stored_switch_ids(data_dir: Path) -> list[str]: """Bundle ids that have a switch store on this computer.""" diff --git a/packages/desktop/openadapt_desktop/approver_key.py b/packages/desktop/openadapt_desktop/approver_key.py index a10cc6350..d2b168fda 100644 --- a/packages/desktop/openadapt_desktop/approver_key.py +++ b/packages/desktop/openadapt_desktop/approver_key.py @@ -3,8 +3,9 @@ Flow counts an answer from Desktop's Run it, or from the paired phone through the decision portal, as a person's only when it carries an Ed25519 signature by a key on Flow's signed approver roster, and the run pins that roster's -root (``OPENADAPT_APPROVER_ROOT`` or the deployment config's -``approvers.root_public_key``; see Flow's ``approval_mode/approver_roster``). +root (the deployment config's ``approvers.root_public_key``, or +``--approver-root``; see Flow's ``approval_mode/approver_roster``). Desktop +pins only the root kept in this Mac's Keychain. Desktop keeps one such key for each route (``desktop`` for Run it, ``portal`` for the phone's answers, which the Flow console it starts signs). For each: @@ -185,8 +186,31 @@ def approver_key_environment( return path, {PASSPHRASE_ENV: passphrase} +def sign_with_file_key(message: bytes, route: Route = "desktop") -> tuple[str, str] | None: + """Sign one message with the terminal-made key, or ``None`` without one. + + Returns ``(public_key_hex, "ed25519:")``. The key is opened for this + one signature and dropped. + """ + + found = approver_key_environment(route) + if found is None: + return None + path, env = found + key = _open(path, env[PASSPHRASE_ENV]) + try: + return _public_hex(key), "ed25519:" + key.sign(message).hex() + finally: + del key + + def setup_instructions(route: Route = "desktop") -> str: - """The one-time terminal steps that make this computer's answers count.""" + """The one-time terminal steps that make this computer's answers count. + + For computers where the app can't keep the key behind a presence check + (Windows, Linux, a Mac without a Secure Enclave). On a Mac that can, the + key is set up in OpenAdapt and a file key isn't used. + """ path = approver_key_path(route) name = _ROUTES[route][2] @@ -195,10 +219,13 @@ def setup_instructions(route: Route = "desktop") -> str: f"{PASSPHRASE_ENV} set, run `openadapt-flow jit keygen --out {path}`, " "then start the approver roster with it: " f"`openadapt-flow jit approver init --principal {name} --key {path}`. " - "Start Desktop with OPENADAPT_APPROVER_ROOT set to the public key " - f"keygen printed, and the same {PASSPHRASE_ENV}; or run " + "Pin the public key keygen printed as approvers.root_public_key in " + "the deployment config you pass to `openadapt-flow run --config`, " + f"with the same {PASSPHRASE_ENV}; or run " "`openadapt-desktop approver-key setup` instead of keygen to keep the " - "passphrase in this computer's keychain." + "passphrase in this computer's keychain. Desktop counts a person's " + "answer only with a root pinned in this Mac's Keychain, which " + "OpenAdapt sets up behind Touch ID." ) @@ -224,4 +251,5 @@ def portal_approver_environment() -> dict[str, str]: "portal_approver_environment", "setup_approver_key", "setup_instructions", + "sign_with_file_key", ] diff --git a/packages/desktop/openadapt_desktop/approver_keychain.py b/packages/desktop/openadapt_desktop/approver_keychain.py new file mode 100644 index 000000000..d0287ce3b --- /dev/null +++ b/packages/desktop/openadapt_desktop/approver_keychain.py @@ -0,0 +1,946 @@ +"""This Mac's approver key, behind Touch ID. + +Flow counts an answer from Run it as a person's only when an Ed25519 key +registered under ``approvers`` signs it +(:mod:`openadapt_desktop.approver_key`). On a Mac, Desktop keeps that key +like this instead of in a file: + +* A Secure Enclave key (P-256) with a user-presence access control: every use + needs Touch ID or the login password, and the Secure Enclave enforces it, so + a program running as this user can't use the key without the person seeing + the prompt. The key never leaves the Secure Enclave; Desktop keeps only the + Secure Enclave's encrypted handle to it. +* The Ed25519 key, sealed to that Secure Enclave key (ECIES, AES-GCM). It + opens only on this Mac, and only after the prompt. +* Both, with the public key, as one item in the login Keychain. + +Setup (:meth:`ApproverKeychain.setup`) is one screen and one prompt: Desktop +makes both keys, opens the sealed key once to prove the round trip and that a +person is there, and registers the public key under ``approvers`` with Flow's +own :func:`openadapt_flow.jit.authority.register_principal`, the function +``openadapt-flow jit register`` calls. Each answer +(:meth:`ApproverKeychain.sign`) prompts again, opens the key, signs one +message and drops it. The key is never written to disk unsealed, never logged +and never handed to Flow: Flow asks for the signature over a pipe +(``--approver-signer stdio``). + +Everything fails closed (:class:`ApproverKeychainError`): no key, a cancelled +prompt, a Mac without a Secure Enclave or a Keychain error means no signature +and no answer. + +The Keychain's own access-control flags would do the same job without the +Secure Enclave, but macOS honours them only for a signed app with a +keychain-access-groups entitlement (``errSecMissingEntitlement`` otherwise), +and the engine runs as a Python sidecar. The Secure Enclave needs no +entitlement. + +Windows and Linux keep the terminal setup (``openadapt-desktop approver-key +setup``). The same design would need Windows Hello (``KeyCredentialManager``, +a TPM-backed key that asks for the PIN or face on each use) and, on Linux, a +Secret Service item plus a presence check, which Secret Service alone doesn't +give. +""" + +from __future__ import annotations + +import base64 +import json +import secrets +import sys +import threading +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Callable, Literal, Protocol + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +# Look the keyring helpers up on the module at call time, as approver_key +# does, so the test suite's in-memory keyring is used. +from openadapt_desktop.auth import store as _store + +#: The login Keychain item (service ``ai.openadapt.desktop``) for Run it. +ACCOUNT = "desktop-approver-enclave-v1" +DEFAULT_PRINCIPAL = "desktop-approver" +#: Flow signs a person's answer under this domain +#: (``openadapt_flow.approval_mode.human_attestation.ANSWER_DOMAIN``). +ANSWER_DOMAIN = b"openadapt-flow.approval.human-answer/v1\x00" +#: What setup signs to prove the key opens behind a presence prompt. +SETUP_CHALLENGE = b"openadapt-desktop.approver-setup/v1\x00" +_SCHEMA = 1 + +Code = Literal[ + "approver_key_missing", + "approver_presence_refused", + "approver_key_unusable", + "approver_keychain_unsupported", + "approver_payload_refused", +] + + +class ApproverKeychainError(RuntimeError): + """No signature: ``code`` says why, the message says what to do.""" + + def __init__(self, code: Code, message: str) -> None: + super().__init__(message) + self.code: Code = code + + +class Enclave(Protocol): + """The Secure Enclave operations Desktop needs (faked in tests).""" + + def available(self) -> bool: ... + + def create(self, *, require_presence: bool) -> tuple[bytes, bytes]: + """A new key: (its encrypted handle, its public key in X9.63).""" + ... + + def seal(self, public_key: bytes, plaintext: bytes) -> bytes: ... + + def open( + self, handle: bytes, sealed: bytes, *, reason: str, interactive: bool = True + ) -> bytearray: + """Unseal, asking for presence. Raises :class:`ApproverKeychainError`.""" + ... + + +@dataclass(frozen=True) +class StoredKey: + """The Keychain item: nothing in it opens without the Secure Enclave.""" + + public_key: str + principal: str + handle: bytes + sealed: bytes + presence: bool + created_at: str + + def to_json(self) -> str: + return json.dumps( + { + "schema": _SCHEMA, + "public_key": self.public_key, + "principal": self.principal, + "enclave_key": base64.b64encode(self.handle).decode("ascii"), + "sealed_key": base64.b64encode(self.sealed).decode("ascii"), + "presence": self.presence, + "created_at": self.created_at, + }, + sort_keys=True, + ) + + @classmethod + def from_json(cls, text: str) -> "StoredKey": + data = json.loads(text) + if not isinstance(data, dict) or data.get("schema") != _SCHEMA: + raise ValueError("unknown approver key format") + return cls( + public_key=str(data["public_key"]), + principal=str(data["principal"]), + handle=base64.b64decode(data["enclave_key"], validate=True), + sealed=base64.b64decode(data["sealed_key"], validate=True), + presence=bool(data["presence"]), + created_at=str(data["created_at"]), + ) + + +#: ``sign(message) -> "ed25519:"`` with an approver key, behind presence. +Sign = Callable[[bytes], str] +#: The login Keychain item holding the approver root this Mac pins. +ROOT_ACCOUNT = "desktop-approver-root-v1" +#: The roster head (seq, hash) after this Mac's last change to the roster. A +#: roster that doesn't extend it (an older copy put back) is refused. +HEAD_ACCOUNT = "desktop-approver-roster-head-v1" +ROOT_ENV = "OPENADAPT_APPROVER_ROOT" + + +class Roster(Protocol): + """Flow's signed approver roster, as Desktop uses it (faked in tests).""" + + def root(self) -> str | None: + """The root this Mac pins, or ``None``.""" + ... + + def is_empty(self) -> bool: ... + + def lookup(self, public_key: str) -> str | None: + """The approver name the pinned root vouches for this key under.""" + ... + + def init_root(self, principal: str, public_key: str, sign: Sign) -> None: ... + + def add(self, principal: str, public_key: str, signed_by: str, sign: Sign) -> None: ... + + def remove(self, principal: str, public_key: str, signed_by: str, sign: Sign) -> None: ... + + +def _flow_roster(): + try: + from openadapt_flow.approval_mode import approver_roster + from openadapt_flow.jit import lifecycle + except ImportError as exc: + raise ApproverKeychainError( + "approver_key_unusable", + "This Desktop's OpenAdapt Flow can't keep a signed approver roster. " + "Update Desktop.", + ) from exc + return approver_roster, lifecycle.default_key_dir() + + +class FlowRoster: + """The roster in Flow's key directory, pinned by this Mac. + + The pin is the root Desktop set up itself, behind Touch ID, kept in the + login Keychain; nothing else counts (not ``OPENADAPT_APPROVER_ROOT``, not + a file this user can write). An administrator's pin for a Mac that didn't + start the roster would have to live where this user can't write it (a + root-owned file or an MDM profile); that isn't built. + Desktop never adopts the root of a roster it finds: that would trust + whoever wrote the file first. The roster must also extend the head this + Mac recorded after its own last change. + """ + + def root(self) -> str | None: + """The pin in the login Keychain, or ``None``. + + Nothing else counts: not the environment (whoever starts a process + sets it) and not a file this user can write. A same-user process can + delete the Keychain items; then there is no pin and no person's + answer counts until someone sets the key up again behind Touch ID. + """ + + stored = _store._kr_get(_store._keyring(), ROOT_ACCOUNT) + return stored.strip().lower() if stored else None + + def _head(self) -> tuple[int, str] | None: + raw = _store._kr_get(_store._keyring(), HEAD_ACCOUNT) + try: + seq, digest = json.loads(raw) if raw else (None, None) + except (ValueError, TypeError): + raise ApproverKeychainError( + "approver_key_unusable", "The recorded approver roster head is unreadable." + ) from None + return (int(seq), str(digest)) if seq else None + + def _record_head(self) -> None: + roster, key_dir = _flow_roster() + seq, digest = roster.head(key_dir, self.root()) + if not _store._kr_set(_store._keyring(), HEAD_ACCOUNT, json.dumps([seq, digest])): + raise ApproverKeychainError( + "approver_key_unusable", "The login Keychain didn't save the roster head." + ) + + def is_empty(self) -> bool: + roster, key_dir = _flow_roster() + try: + return not roster.load(key_dir).entries + except roster.RosterError: + return False # unreadable: never start a new root over it + + def lookup(self, public_key: str) -> str | None: + try: + roster, key_dir = _flow_roster() + found = roster.verified_approvers(key_dir, self.root(), at_least=self._head()) + except (ApproverKeychainError, ValueError, OSError): + return None + return next((pid for pid, value in found.items() if value == public_key), None) + + def _call(self, name: str, *args: Any, **kwargs: Any) -> Any: + roster, key_dir = _flow_roster() + try: + return getattr(roster, name)(key_dir, *args, **kwargs) + except (roster.RosterError, OSError, ValueError) as exc: + raise ApproverKeychainError( + "approver_key_unusable", f"Flow refused the roster change: {exc}" + ) from exc + + def init_root(self, principal: str, public_key: str, sign: Sign) -> None: + if self.root(): + raise ApproverKeychainError( + "approver_root_conflict", + "This Mac already pins an approver root; it doesn't start another.", + ) + self._call("init_root", principal=principal, public_key=public_key, sign=sign) + # This Mac set up the root itself, behind presence: pin it. + if not _store._kr_set(_store._keyring(), ROOT_ACCOUNT, public_key): + raise ApproverKeychainError( + "approver_key_unusable", + "The login Keychain didn't save the approver root.", + ) + self._record_head() + + def add(self, principal: str, public_key: str, signed_by: str, sign: Sign) -> None: + self._call( + "add_approver", + self.root(), + principal=principal, + public_key=public_key, + signed_by=signed_by, + sign=sign, + at_least=self._head(), + ) + self._record_head() + + def remove(self, principal: str, public_key: str, signed_by: str, sign: Sign) -> None: + self._call( + "remove_approver", + self.root(), + principal=principal, + public_key=public_key, + signed_by=signed_by, + sign=sign, + at_least=self._head(), + ) + self._record_head() + + +def pinned_root() -> str | None: + """The approver root this Mac pins for its Flow runs, or ``None``. + + Only the Keychain's pin counts (see :meth:`FlowRoster.root`). With none, + Desktop removes any root or head from the config it hands Flow, so Flow + refuses every person's answer. + """ + + return FlowRoster().root() + + +def pinned_head() -> tuple[int, str] | None: + """The roster head this Mac recorded after its own last roster change.""" + + return FlowRoster()._head() + + +def registered_name(public_key: str) -> str | None: + """The approver name the pinned roster has for this key, or ``None``.""" + + return FlowRoster().lookup(public_key) + + +def _public_hex(key: Ed25519PrivateKey) -> str: + return ( + key.public_key() + .public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + .hex() + ) + + +def _wipe(buffer: bytearray) -> None: + for index in range(len(buffer)): + buffer[index] = 0 + + +class ApproverKeychain: + """Set up, use, rotate and remove this Mac's approver key. + + ``require_presence=False`` exists only for the real-Keychain test, which + can't touch the sensor. A key stored that way can't be used by an instance + that requires presence, which is every instance Desktop makes. + """ + + def __init__( + self, + enclave: Enclave | None = None, + *, + account: str = ACCOUNT, + require_presence: bool = True, + roster: Roster | None = None, + ) -> None: + self._enclave = enclave if enclave is not None else default_enclave() + self._account = account + self._presence = require_presence + self._roster: Roster = roster if roster is not None else FlowRoster() + self._lock = threading.Lock() + + # -- state ------------------------------------------------------------------ + + def supported(self) -> bool: + return self._enclave.available() + + def stored(self) -> StoredKey | None: + raw = _store._kr_get(_store._keyring(), self._account) + if not raw: + return None + try: + return StoredKey.from_json(raw) + except (ValueError, KeyError, TypeError): + return None + + def status(self) -> dict: + """What the setup screen shows. Never includes the key itself.""" + + supported = self.supported() + stored = self.stored() if supported else None + registered = ( + stored is not None and self._roster.lookup(stored.public_key) == stored.principal + ) + return { + "supported": supported, + "set_up": stored is not None and registered, + "stored": stored is not None, + "registered": registered, + "principal": stored.principal if stored else None, + "fingerprint": stored.public_key[:16] if stored else None, + } + + # -- setup, rotation, removal ------------------------------------------------- + + def setup(self, principal: str = DEFAULT_PRINCIPAL, *, replace: bool = False) -> StoredKey: + """Make the key and put it on the signed roster behind presence. + + * No roster yet: the new key signs the roster's root, behind the one + Touch ID prompt, and this Mac pins that root. The signature is the + proof that a person was present. + * ``replace`` (rotation): the old key signs the new one onto the + roster (a prompt each for the new and the old key), then the old + one is removed unless it is the root. + * A roster this Mac didn't start: an approver on it adds this key. + The key is kept, and the refusal says how. + """ + + with self._lock: + if not self.supported(): + raise ApproverKeychainError( + "approver_keychain_unsupported", + "This Mac has no Secure Enclave, so Desktop can't keep the " + "approver key behind Touch ID. Set it up at a terminal " + "instead.", + ) + old = self.stored() + if old is not None and not replace: + if self._roster.lookup(old.public_key) != old.principal: + # Stored but not on the roster. A Keychain item alone + # proves nothing (anything that can write it could have + # put its own key there): only a signature made behind a + # presence prompt starts a roster. + if self._presence and not old.presence: + raise ApproverKeychainError( + "approver_key_unusable", + "The stored approver key doesn't ask for Touch ID. " + "Set up a new one.", + ) + self._join(old, signer=None) + return old + new = self._make(principal if old is None else f"{principal}-{secrets.token_hex(3)}") + if not _store._kr_set(_store._keyring(), self._account, new.to_json()): + raise ApproverKeychainError( + "approver_key_unusable", + "The login Keychain didn't save the approver key. Nothing was set up.", + ) + signer = old if old is not None and self._roster.lookup(old.public_key) else None + try: + self._join(new, signer=signer) + except ApproverKeychainError as exc: + if exc.code != "approver_needs_adding": + if old is not None: + _store._kr_set(_store._keyring(), self._account, old.to_json()) + else: + _store._kr_delete(_store._keyring(), self._account) + raise + if signer is not None and self._roster.root() != signer.public_key: + # Retire the old key; the root can't be removed, it just stops + # being used here. + self._roster.remove( + signer.principal, + signer.public_key, + new.principal, + self._signer(new, "retire your old approver key"), + ) + return new + + def _make(self, principal: str) -> StoredKey: + handle, enclave_public = self._enclave.create(require_presence=self._presence) + key = Ed25519PrivateKey.generate() + public = _public_hex(key) + seed = bytearray( + key.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ) + ) + del key + try: + sealed = self._enclave.seal(enclave_public, bytes(seed)) + finally: + _wipe(seed) + return StoredKey( + public_key=public, + principal=principal, + handle=handle, + sealed=sealed, + presence=self._presence, + created_at=datetime.now(timezone.utc).isoformat(), + ) + + def _signer(self, stored: StoredKey, reason: str) -> Sign: + return lambda message: self._sign_with(stored, message, reason=reason) + + def _join(self, key: StoredKey, *, signer: StoredKey | None) -> None: + """Put ``key`` on the roster: as its root, or added by ``signer``.""" + + if self._roster.is_empty(): + # The one prompt: the key signs the roster's root, which proves it + # opens behind presence and that a person is here. + self._roster.init_root( + key.principal, key.public_key, self._signer(key, "set up your approver key") + ) + return + if signer is None: + root = self._roster.root() + raise ApproverKeychainError( + "approver_needs_adding", + "This computer's approver roster already has approvers, so one " + "of them adds this Mac's key: openadapt-flow jit approver add " + f"--principal {key.principal} --public-key {key.public_key} " + "--signer-key THEIR_KEY" + + (f" --approver-root {root}" if root else " --approver-root ROOT_HEX") + + ". Nothing was approved with it yet.", + ) + # Prove the new key opens behind presence, then the old key adds it. + self._sign_with( + key, + SETUP_CHALLENGE + key.public_key.encode(), + reason="set up your new approver key", + ) + self._roster.add( + key.principal, + key.public_key, + signer.principal, + self._signer(signer, "approve your new approver key"), + ) + + def remove(self) -> bool: + """Retire the key: take it off the roster (its own signature, behind + presence), then delete it from the Keychain. The roster's root can't + be removed; its key is only deleted here.""" + + with self._lock: + old = self.stored() + if old is None: + return False + if ( + self._roster.lookup(old.public_key) == old.principal + and self._roster.root() != old.public_key + ): + self._roster.remove( + old.principal, + old.public_key, + old.principal, + self._signer(old, "remove your approver key"), + ) + _store._kr_delete(_store._keyring(), self._account) + return True + + # -- signing ------------------------------------------------------------------ + + def sign(self, message: bytes, *, reason: str) -> tuple[str, str]: + """Sign one message after the presence prompt. + + Returns ``(public_key_hex, "ed25519:")``. Raises + :class:`ApproverKeychainError`. + """ + + with self._lock: + stored = self.stored() + if stored is None: + raise ApproverKeychainError( + "approver_key_missing", + "Set up your approver key to approve saves on this Mac.", + ) + if self._presence and not stored.presence: + raise ApproverKeychainError( + "approver_key_unusable", + "The stored approver key doesn't ask for Touch ID. Set up a new one.", + ) + return stored.public_key, self._sign_with(stored, message, reason=reason) + + def _sign_with(self, stored: StoredKey, message: bytes, *, reason: str) -> str: + seed = self._enclave.open(stored.handle, stored.sealed, reason=reason) + try: + if len(seed) != 32: + raise ApproverKeychainError( + "approver_key_unusable", "The approver key didn't open." + ) + key = Ed25519PrivateKey.from_private_bytes(seed) + finally: + _wipe(seed) + try: + if _public_hex(key) != stored.public_key: + raise ApproverKeychainError( + "approver_key_unusable", + "The approver key in the Keychain doesn't match its public key.", + ) + return "ed25519:" + key.sign(message).hex() + finally: + del key + + +_DEFAULT: list[ApproverKeychain] = [] + + +def default_keychain() -> ApproverKeychain: + """The engine's one approver keychain (one lock for every caller).""" + + if not _DEFAULT: + _DEFAULT.append(ApproverKeychain()) + return _DEFAULT[0] + + +# -- the Secure Enclave, through Security.framework -------------------------------- + + +def default_enclave() -> Enclave: + if sys.platform == "darwin": + return MacEnclave() + return NoEnclave() + + +class NoEnclave: + """Windows and Linux: no in-app key (see the module docstring).""" + + def available(self) -> bool: + return False + + def create(self, *, require_presence: bool) -> tuple[bytes, bytes]: + raise ApproverKeychainError( + "approver_keychain_unsupported", + "In-app approver keys are on macOS only. Set it up at a terminal.", + ) + + def seal(self, public_key: bytes, plaintext: bytes) -> bytes: + raise ApproverKeychainError("approver_keychain_unsupported", "not on this OS") + + def open( + self, handle: bytes, sealed: bytes, *, reason: str, interactive: bool = True + ) -> bytearray: + raise ApproverKeychainError("approver_keychain_unsupported", "not on this OS") + + +# LocalAuthentication and Security error codes that mean the person didn't +# confirm: cancelled, failed, locked out, no UI allowed, or no passcode. +_PRESENCE_REFUSED = frozenset({-1, -2, -4, -5, -6, -7, -8, -9, -1004, -128}) +#: errSecInteractionNotAllowed: the Mac is locked (or no prompt can show). +_INTERACTION_NOT_ALLOWED = -25308 + + +class MacEnclave: + """Security.framework through ctypes. + + PyObjC can't wrap a Secure Enclave key reference (its CF type lookup + raises inside the call), so the key calls go through ctypes and handle + Core Foundation objects as pointers. Every object this creates is + released; the unsealed key's buffer is zeroed before release. + """ + + _SEC = "/System/Library/Frameworks/Security.framework/Security" + _CF = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" + _OBJC = "/usr/lib/libobjc.A.dylib" + _LA = "/System/Library/Frameworks/LocalAuthentication.framework/LocalAuthentication" + _PRIVATE_KEY_USAGE = 1 << 30 + _USER_PRESENCE = 1 << 0 + + def __init__(self) -> None: + self._lib = None + self._available: bool | None = None + + # ctypes setup is lazy so importing this module costs nothing elsewhere. + def _l(self): + if self._lib is None: + self._lib = _MacLib(self._SEC, self._CF, self._OBJC, self._LA) + return self._lib + + def available(self) -> bool: + if self._available is None: + try: + handle, _public = self.create(require_presence=False) + self._available = bool(handle) + except ApproverKeychainError as exc: + if exc.code == "approver_presence_refused": + # The Mac is locked: the Secure Enclave is there but won't + # make a key now. Don't remember that; ask again later. + return True + self._available = False + except Exception: # no Secure Enclave, or Security.framework differs + self._available = False + return self._available + + def create(self, *, require_presence: bool) -> tuple[bytes, bytes]: + lib = self._l() + flags = self._PRIVATE_KEY_USAGE | (self._USER_PRESENCE if require_presence else 0) + with lib.scope() as keep: + access = keep( + lib.call( + "SecAccessControlCreateWithFlags", + None, + lib.const("kSecAttrAccessibleWhenUnlockedThisDeviceOnly"), + flags, + ) + ) + private = keep( + lib.dictionary( + [ + (lib.const("kSecAttrIsPermanent"), lib.false), + (lib.const("kSecAttrAccessControl"), access), + ] + ) + ) + attrs = keep( + lib.dictionary(lib.key_attrs(keep) + [(lib.const("kSecPrivateKeyAttrs"), private)]) + ) + key = keep(lib.call("SecKeyCreateRandomKey", attrs)) + info = keep(lib.raw("SecKeyCopyAttributes", key)) + handle = lib.data_bytes(lib.dict_get(info, keep(lib.string("toid")))) + public_ref = keep(lib.raw("SecKeyCopyPublicKey", key)) + public = lib.data_bytes(keep(lib.call("SecKeyCopyExternalRepresentation", public_ref))) + if not handle or not public: + raise ApproverKeychainError( + "approver_key_unusable", "The Secure Enclave didn't make a key." + ) + return handle, public + + def seal(self, public_key: bytes, plaintext: bytes) -> bytes: + lib = self._l() + with lib.scope() as keep: + attrs = keep( + lib.dictionary( + [ + ( + lib.const("kSecAttrKeyType"), + lib.const("kSecAttrKeyTypeECSECPrimeRandom"), + ), + (lib.const("kSecAttrKeyClass"), lib.const("kSecAttrKeyClassPublic")), + (lib.const("kSecAttrKeySizeInBits"), keep(lib.number(256))), + ] + ) + ) + public = keep(lib.call("SecKeyCreateWithData", keep(lib.data(public_key)), attrs)) + sealed = keep( + lib.call( + "SecKeyCreateEncryptedData", + public, + lib.algorithm, + keep(lib.data(plaintext)), + ) + ) + return lib.data_bytes(sealed) + + def open( + self, handle: bytes, sealed: bytes, *, reason: str, interactive: bool = True + ) -> bytearray: + lib = self._l() + with lib.scope() as keep: + context = lib.la_context(reason, interactive) + keep(context) + private = keep(lib.dictionary([(lib.const("kSecAttrIsPermanent"), lib.false)])) + attrs = keep( + lib.dictionary( + lib.key_attrs(keep) + + [ + (keep(lib.string("toid")), keep(lib.data(handle))), + (lib.const("kSecUseAuthenticationContext"), context), + (lib.const("kSecPrivateKeyAttrs"), private), + ] + ) + ) + key = keep(lib.call("SecKeyCreateRandomKey", attrs)) + plain = lib.call( + "SecKeyCreateDecryptedData", key, lib.algorithm, keep(lib.data(sealed)) + ) + try: + return bytearray(lib.data_bytes(plain)) + finally: + lib.wipe_data(plain) + lib.release(plain) + + +class _MacLib: + """The few Core Foundation, Security and Objective-C calls MacEnclave makes.""" + + def __init__(self, sec_path: str, cf_path: str, objc_path: str, la_path: str) -> None: + import ctypes + + self.ctypes = ctypes + vp = ctypes.c_void_p + self.sec = ctypes.CDLL(sec_path) + self.cf = ctypes.CDLL(cf_path) + self.objc = ctypes.CDLL(objc_path) + ctypes.CDLL(la_path) # registers LAContext with the runtime + err = ctypes.POINTER(vp) + self._sigs = { + "SecAccessControlCreateWithFlags": (self.sec, [vp, vp, ctypes.c_ulong, err]), + "SecKeyCreateRandomKey": (self.sec, [vp, err]), + "SecKeyCopyExternalRepresentation": (self.sec, [vp, err]), + "SecKeyCreateWithData": (self.sec, [vp, vp, err]), + "SecKeyCreateEncryptedData": (self.sec, [vp, vp, vp, err]), + "SecKeyCreateDecryptedData": (self.sec, [vp, vp, vp, err]), + "SecKeyCopyAttributes": (self.sec, [vp]), + "SecKeyCopyPublicKey": (self.sec, [vp]), + } + for name, (lib, args) in self._sigs.items(): + fn = getattr(lib, name) + fn.restype, fn.argtypes = vp, args + for name, res, args in ( + ("CFRelease", None, [vp]), + ("CFDataCreate", vp, [vp, ctypes.c_char_p, ctypes.c_long]), + ("CFDataGetLength", ctypes.c_long, [vp]), + ("CFDataGetBytePtr", vp, [vp]), + ("CFDictionaryCreateMutable", vp, [vp, ctypes.c_long, vp, vp]), + ("CFDictionarySetValue", None, [vp, vp, vp]), + ("CFDictionaryGetValue", vp, [vp, vp]), + ("CFNumberCreate", vp, [vp, ctypes.c_long, vp]), + ("CFStringCreateWithCString", vp, [vp, ctypes.c_char_p, ctypes.c_uint32]), + ("CFErrorGetCode", ctypes.c_long, [vp]), + ): + fn = getattr(self.cf, name) + fn.restype, fn.argtypes = res, args + self.objc.objc_getClass.restype = vp + self.objc.objc_getClass.argtypes = [ctypes.c_char_p] + self.objc.sel_registerName.restype = vp + self.objc.sel_registerName.argtypes = [ctypes.c_char_p] + address = ctypes.cast(self.objc.objc_msgSend, vp).value + self._send = ctypes.CFUNCTYPE(vp, vp, vp)(address) + self._send_bool = ctypes.CFUNCTYPE(None, vp, vp, ctypes.c_bool)(address) + self._send_ptr = ctypes.CFUNCTYPE(None, vp, vp, vp)(address) + self._keys = ctypes.addressof( + ctypes.c_char.in_dll(self.cf, "kCFTypeDictionaryKeyCallBacks") + ) + self._values = ctypes.addressof( + ctypes.c_char.in_dll(self.cf, "kCFTypeDictionaryValueCallBacks") + ) + self.false = vp.in_dll(self.cf, "kCFBooleanFalse").value + self.algorithm = self.const( + "kSecKeyAlgorithmECIESEncryptionCofactorVariableIVX963SHA256AESGCM" + ) + + def const(self, name: str): + return self.ctypes.c_void_p.in_dll(self.sec, name).value + + def release(self, ref) -> None: + if ref: + self.cf.CFRelease(ref) + + def scope(self): + lib = self + + class _Scope: + def __enter__(self): + self.refs: list = [] + + def keep(ref): + self.refs.append(ref) + return ref + + return keep + + def __exit__(self, *_exc): + for ref in reversed(self.refs): + lib.release(ref) + + return _Scope() + + def call(self, name: str, *args): + """A Security call that reports a CFError; raises on failure.""" + + error = self.ctypes.c_void_p() + result = getattr(self.sec, name)(*args, self.ctypes.byref(error)) + if not result: + code = self.cf.CFErrorGetCode(error) if error.value else 0 + self.release(error.value) + if code == _INTERACTION_NOT_ALLOWED: + raise ApproverKeychainError( + "approver_presence_refused", + "This Mac is locked or can't show the Touch ID prompt right " + "now. Unlock it and try again. Nothing changed.", + ) + if code in _PRESENCE_REFUSED: + raise ApproverKeychainError( + "approver_presence_refused", + "Touch ID or your password wasn't confirmed, so nothing was approved.", + ) + raise ApproverKeychainError( + "approver_key_unusable", + f"The Secure Enclave refused ({name}, error {code}).", + ) + return result + + def raw(self, name: str, *args): + result = getattr(self.sec, name)(*args) + if not result: + raise ApproverKeychainError( + "approver_key_unusable", f"The Secure Enclave refused ({name})." + ) + return result + + def key_attrs(self, keep) -> list: + return [ + (self.const("kSecAttrKeyType"), self.const("kSecAttrKeyTypeECSECPrimeRandom")), + (self.const("kSecAttrKeySizeInBits"), keep(self.number(256))), + (self.const("kSecAttrTokenID"), self.const("kSecAttrTokenIDSecureEnclave")), + ] + + def dictionary(self, pairs: list): + ref = self.cf.CFDictionaryCreateMutable(None, 0, self._keys, self._values) + for key, value in pairs: + self.cf.CFDictionarySetValue(ref, key, value) + return ref + + def dict_get(self, ref, key): + return self.cf.CFDictionaryGetValue(ref, key) + + def number(self, value: int): + number = self.ctypes.c_int32(value) + return self.cf.CFNumberCreate(None, 3, self.ctypes.byref(number)) + + def string(self, text: str): + return self.cf.CFStringCreateWithCString(None, text.encode("utf-8"), 0x08000100) + + def data(self, value: bytes): + return self.cf.CFDataCreate(None, value, len(value)) + + def data_bytes(self, ref) -> bytes: + if not ref: + return b"" + return self.ctypes.string_at(self.cf.CFDataGetBytePtr(ref), self.cf.CFDataGetLength(ref)) + + def wipe_data(self, ref) -> None: + if ref: + self.ctypes.memset(self.cf.CFDataGetBytePtr(ref), 0, self.cf.CFDataGetLength(ref)) + + def la_context(self, reason: str, interactive: bool): + """An LAContext with the prompt's reason; +1, released by the scope.""" + + sel = self.objc.sel_registerName + cls = self.objc.objc_getClass(b"LAContext") + context = self._send(self._send(cls, sel(b"alloc")), sel(b"init")) + if not context: + raise ApproverKeychainError( + "approver_key_unusable", "LocalAuthentication isn't available." + ) + self._send_bool(context, sel(b"setInteractionNotAllowed:"), not interactive) + text = self.string(reason) + self._send_ptr(context, sel(b"setLocalizedReason:"), text) + self.release(text) + return context + + +__all__ = [ + "ACCOUNT", + "ANSWER_DOMAIN", + "ApproverKeychain", + "ApproverKeychainError", + "DEFAULT_PRINCIPAL", + "Enclave", + "MacEnclave", + "NoEnclave", + "StoredKey", + "default_enclave", + "default_keychain", + "FlowRoster", + "HEAD_ACCOUNT", + "ROOT_ACCOUNT", + "Roster", + "pinned_head", + "pinned_root", + "registered_name", +] diff --git a/packages/desktop/openadapt_desktop/cli.py b/packages/desktop/openadapt_desktop/cli.py index 58c502777..15da3f802 100644 --- a/packages/desktop/openadapt_desktop/cli.py +++ b/packages/desktop/openadapt_desktop/cli.py @@ -527,9 +527,11 @@ def cmd_approver_key(args: argparse.Namespace, engine: types.SimpleNamespace) -> root = setup print( "A person runs these commands (with OPENADAPT_JIT_KEY_PASSPHRASE set to " - "the key's passphrase), then starts Desktop with " - f"OPENADAPT_APPROVER_ROOT={root.public_key if root else ''}. Until " - "then, Flow refuses answers from this computer." + "the key's passphrase), then pins the root " + f"{root.public_key if root else ''} as approvers.root_public_key " + "in the deployment config passed to `openadapt-flow run --config`. " + "Desktop counts a person's answer only with a root pinned in this " + "Mac's Keychain; until then, Flow refuses answers from Desktop." ) diff --git a/packages/desktop/openadapt_desktop/dispatch.py b/packages/desktop/openadapt_desktop/dispatch.py index 08c7cfa09..a330d58b4 100644 --- a/packages/desktop/openadapt_desktop/dispatch.py +++ b/packages/desktop/openadapt_desktop/dispatch.py @@ -66,8 +66,20 @@ #: Flow refusals that mean this computer's approver key isn't set up or #: registered. Desktop shows Flow's message, which says what to do. _APPROVER_KEY_SETUP_CODES = frozenset( - {"human_answer_unsigned", "human_approver_unregistered", "approver_key_unusable"} + { + "human_answer_unsigned", + "human_approver_unregistered", + "approver_key_unusable", + "approver_key_missing", + "approver_presence_refused", + "approver_keychain_unsupported", + "approver_payload_refused", + } ) +#: Stop cards a paired phone may answer through Desktop. A waiting save is +#: answered through the portal's own save approval; turning "only ask about +#: problems" back on is answered on the computer. +PHONE_ANSWERABLE_KINDS = frozenset({"same_record", "confirm_save", "halted"}) _TEACH_CORRECTION_SCHEMA = "openadapt.desktop-teach-correction/v1" _TEACH_CORRECTION_META_KEY = "teach_correction" @@ -249,6 +261,11 @@ def __init__( # waits (they never take the dispatch lock). self._approval_watches: dict[str, dict[str, Any]] = {} self._approval_lock = threading.Lock() + # The lock the local wires take around a command that isn't + # concurrent (set by ``IPCHandler``). The paired phone's answers to + # stops take it too, so an answer that runs an item again never + # overlaps a run started from the window. + self.serial_lock: Any = None self._coach: dict[str, Any] = {} # Shadow mode: built on first use, never from config or a remote # policy. Its commands are served on the Tauri window wire only. @@ -306,6 +323,16 @@ def _register(self) -> None: # two answer a waiting run while it runs. "get_item_approval": self.get_item_approval, "answer_item_approval": self.answer_item_approval, + # The approver key that signs those answers, behind Touch ID on a + # Mac. Only the window and the tray's own wire reach these; the + # agent socket doesn't. + # The stop-review queue: every stop as one card (stop_queue.py). + "list_stop_cards": self.list_stop_cards, + "answer_stop_cards": self.answer_stop_cards, + "answer_stop_approval": self.answer_stop_approval, + "get_approver_key": self.get_approver_key, + "set_up_approver_key": self.set_up_approver_key, + "remove_approver_key": self.remove_approver_key, # "Only ask a person about problems": read the switch, turn it off. # Nothing here turns it on; that happens in OpenAdapt Cloud. "get_approval_mode": self.get_approval_mode, @@ -1472,13 +1499,28 @@ def _replay_or_run( invocation_started = False log_redactions: tuple[str, ...] = () try: + from openadapt_desktop.approver_keychain import ( + ApproverKeychainError, + pinned_head, + pinned_root, + ) from openadapt_desktop.private_flow_config import ( prepare_flow_config, stage_private_text, stage_private_yaml, ) - prepared = prepare_flow_config(base_config, target) + try: + approver_root = pinned_root() + approver_head = pinned_head() if approver_root else None + except ApproverKeychainError as exc: + return self._pre_action_refusal(str(exc)) + prepared = prepare_flow_config( + base_config, + target, + approver_root=approver_root, + approver_head=approver_head, + ) # The signed-in browser can also come from the operator's own # config file (or the default deployment.json), not only from the # target. Decide from the exact snapshot Flow will receive. @@ -1492,7 +1534,12 @@ def _replay_or_run( if prepared is None: staged_context = nullcontext(None) else: - staged_context = stage_private_yaml(run_dir, prepared=prepared) + # Staged outside the run directory, which the run (and anything + # it drives) can write: the config carries the approver pin. + staged_context = stage_private_yaml( + ensure_private_dir(Path(self.config.data_dir) / "staged-configs"), + prepared=prepared, + ) if isinstance(item_inputs, dict): log_redactions = tuple(log_redactions) + tuple( str(value) @@ -1583,6 +1630,14 @@ def _replay_or_run( # A missing historical default stays fail-loud in Flow. config_path = staged_config or default_config run_kwargs: dict[str, Any] = {"out_dir": run_dir} + config_digest = ( + hashlib.sha256(prepared.payload.encode("utf-8")).hexdigest() + if staged_config is not None and prepared is not None + else None + ) + if config_digest: + # Flow parses only the bytes Desktop wrote. + run_kwargs["config_sha256"] = config_digest if params_file is not None: run_kwargs["params_file"] = params_file if bundle_env: @@ -1600,6 +1655,7 @@ def _replay_or_run( run_id=run_id, out_dir=run_dir, env_overrides=bundle_env, + config_sha256=config_digest, **case_kwargs, ) else: @@ -4349,10 +4405,15 @@ def answer_item_approval(self, **params: Any) -> dict: if not answer.get("ok"): code = str(answer.get("code") or "") error = "Flow refused the answer, so nothing was approved." + refused: dict[str, Any] = {"ok": False, "code": code} if code in _APPROVER_KEY_SETUP_CODES: - # The person needs to set up or register the approver key. + # The person needs to set up or register the approver key, or + # didn't confirm Touch ID. Flow's message says which. error = str(answer.get("message") or error) - return {"ok": False, "error": error, "code": code} + setup = answer.get("setup") or self._approver_key_setup_route(code) + if setup: + refused["setup"] = setup + return {**refused, "error": error} if answer.get("resume_required"): # The run stopped waiting before the answer arrived. Desktop # doesn't resume it: run the item again. @@ -4368,6 +4429,640 @@ def answer_item_approval(self, **params: Any) -> dict: "decision": "approved" if decision == "approve" else "declined", } + def _approver_key_setup_route(self, code: str) -> str | None: + """Where the person sets the key up after this refusal, if anywhere.""" + + if code not in {"human_answer_unsigned", "human_approver_unregistered"}: + return None + try: + supported = self.services.flow_bridge.approver_keychain.supported() + except Exception: + logger.exception("Could not check the approver keychain") + supported = False + return "in_app" if supported else "terminal" + + # -- the stop-review queue --------------------------------------------------- + + def _stop_cards(self) -> list[dict]: + """Every card the queue shows now, rebuilt from the stores.""" + + from openadapt_desktop import stop_queue + from openadapt_desktop.work_items import OPEN, TEACHING, public_item + + names = { + row.get("bundle_id"): row.get("workflow_name") or row.get("capture_id") or "" + for row in self.services.db.list_bundles(limit=500) + } + cards: list[dict] = [] + for row in self.services.db.list_work_items(statuses=(OPEN, TEACHING)): + item = public_item(row, include_values=True) + item["source"] = "item" + item["task_name"] = names.get(row["workflow_id"]) or row["workflow_id"] + card = stop_queue.card_for_item(item) + if card is not None: + cards.append(card) + for workflow_id in sorted( + {str(w.get("workflow_id") or "") for w in list(self._approval_watches.values())} + ): + state = self.get_item_approval(workflow_id=workflow_id) + if state.get("ok") and state.get("waiting"): + state["task_name"] = names.get(workflow_id) or workflow_id + card = stop_queue.card_for_approval(state) + if card is not None: + cards.append(card) + for switch in self._approval_switches(names): + switch["rearm"] = self._rearm_offer(switch["workflow_id"]) + card = stop_queue.card_for_rearm(switch) + if card is not None: + cards.append(card) + return stop_queue.sort_cards(cards) + + def _rearm_offer(self, workflow_id: str) -> dict | None: + """Flow's re-arm offer for a task's switch, with the clean items named.""" + + import hashlib + + from openadapt_desktop import approval_mode + + try: + offer = approval_mode.rearm_offer(self.config.data_dir, workflow_id) + except Exception: + logger.exception("Could not read whether the switch can be re-armed") + return None + if offer is None: + return None + # Flow names each verified item by the SHA-256 of its run id. + labels: dict[str, str] = {} + for row in self.services.db.list_work_items(workflow_id=workflow_id, limit=500): + run_id = str(row.get("run_id") or "") + if run_id: + ref = hashlib.sha256(run_id.encode("utf-8")).hexdigest() + labels[ref] = str(row.get("label") or row["item_id"]) + refs = offer["request"]["clean_run_refs"] + offer["clean_items"] = [labels.get(ref, ref[:8]) for ref in refs] + return offer + + def list_stop_cards(self, **_params: Any) -> dict: + """The stop-review queue: one card per stop, oldest first. + + Read-only. Served while a run waits, so a waiting save shows up. + """ + + try: + cards = self._stop_cards() + except Exception: + logger.exception("Could not build the stop queue") + return {"ok": False, "error": "Desktop couldn't read the stops."} + return {"ok": True, "cards": cards, "approver_key": self._approver_key_summary()} + + def _approver_key_summary(self) -> dict: + from openadapt_desktop.approver_key import approver_key_environment + + try: + keychain = self.services.flow_bridge.approver_keychain + in_app = keychain.supported() + stored = in_app and keychain.stored() is not None + except Exception: + in_app, stored = False, False + file_key = approver_key_environment() is not None + return { + "ready": stored or file_key, + "in_app": in_app, + "touch_id": stored, + } + + def _sign_stop_decision(self, message: bytes, *, count: int) -> dict: + """Sign one queue answer with this computer's approver key. + + Touch ID (or the login password) on a Mac that can keep the key in + the app; the terminal-made key only where it can't. Raises + ``ApproverKeychainError``. + """ + + from openadapt_desktop.approver_key import ApproverKeyError, sign_with_file_key + from openadapt_desktop.approver_keychain import ApproverKeychainError + + keychain = self.services.flow_bridge.approver_keychain + if keychain.supported() and keychain.stored() is None: + # As for Run it: a Mac that can keep the key behind Touch ID never + # falls back to a terminal-made key, which asks for no presence. + raise ApproverKeychainError( + "approver_key_missing", + "Set up your approver key in OpenAdapt to answer stops on this " + "Mac. Nothing changed.", + ) + if keychain.supported(): + reason = ( + "record your answer to this stop" + if count == 1 + else f"record your answer to {count} stops" + ) + public, signature = keychain.sign(message, reason=reason) + return {"public_key": public, "signature": signature, "key": "keychain"} + try: + signed = sign_with_file_key(message) + except ApproverKeyError as exc: + raise ApproverKeychainError("approver_key_unusable", str(exc)) from exc + if signed is None: + raise ApproverKeychainError( + "approver_key_missing", + "Set up your approver key to answer stops. Nothing changed.", + ) + return {"public_key": signed[0], "signature": signed[1], "key": "file"} + + @staticmethod + def _sign_stop_decision_portal(message: bytes, *, count: int) -> dict: + """Sign a phone's answer with the portal's approver key. + + The phone can't reach Touch ID on this computer. Its answers are + signed with the key registered for the decision portal, the same + one the Flow console signs the phone's save approvals with. + """ + + from openadapt_desktop.approver_key import ApproverKeyError, sign_with_file_key + from openadapt_desktop.approver_keychain import ApproverKeychainError + + del count # one signature per answer, no prompt to word + try: + signed = sign_with_file_key(message, route="portal") + except ApproverKeyError as exc: + raise ApproverKeychainError("approver_key_unusable", str(exc)) from exc + if signed is None: + raise ApproverKeychainError( + "approver_key_missing", + "Answers from a phone need the decision portal's approver key. " + "Set it up on the computer. Nothing changed.", + ) + return {"public_key": signed[0], "signature": signed[1], "key": "portal"} + + def stop_card_screenshot(self, card_id: str) -> bytes | None: + """The PNG a card shows as evidence, for the paired phone, or None. + + The card must be in the queue now. The picture is the one the window + shows: where the item's run stopped, or the form a waiting save is + about to save. + """ + + card = next((c for c in self._stop_cards() if c["card_id"] == card_id), None) + ref = (card or {}).get("evidence", {}).get("screenshot") + if not isinstance(ref, dict): + return None + data_url = None + if ref.get("item_id"): + frame = self.get_attention_screenshot(item_id=ref["item_id"]) + data_url = frame.get("data_url") if frame.get("ok") else None + elif ref.get("approval") and card is not None: + state = self.get_item_approval(workflow_id=card["workflow_id"]) + request = state.get("request") if isinstance(state, dict) else None + if isinstance(request, dict) and request.get("request_id") == ref["approval"]: + data_url = request.get("screenshot") + prefix = "data:image/png;base64," + if not isinstance(data_url, str) or not data_url.startswith(prefix): + return None + try: + data = base64.b64decode(data_url[len(prefix) :], validate=True) + except ValueError: + return None + return data if data.startswith(b"\x89PNG\r\n\x1a\n") else None + + def _record_stop_decision(self, receipt: dict) -> None: + """Append one answer to the local decision log (owner-only).""" + + import os + + path = Path(self.config.data_dir) / "stop_decisions.jsonl" + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + with os.fdopen(fd, "a", encoding="utf-8") as handle: + handle.write(json.dumps(receipt, sort_keys=True) + "\n") + + @staticmethod + def _timings(params: dict, card_ids: list[str]) -> dict[str, int]: + """How long each card was on screen before the answer, in ms.""" + + raw = params.get("shown_ms") + out: dict[str, int] = {} + if isinstance(raw, dict): + for card_id in card_ids: + value = raw.get(card_id) + if isinstance(value, (int, float)) and 0 <= value < 86_400_000: + out[card_id] = int(value) + return out + + def _current_cards(self, card_ids: list[str]) -> tuple[list[dict] | None, str]: + """The cards the person saw, if each is still exactly as shown.""" + + from openadapt_desktop import stop_queue + + if not card_ids or len(card_ids) > stop_queue.MAX_BATCH: + return None, f"Answer between 1 and {stop_queue.MAX_BATCH} stops at once." + if len(set(card_ids)) != len(card_ids): + return None, "A stop is listed twice. Nothing changed." + current = {card["card_id"]: card for card in self._stop_cards()} + cards = [current.get(card_id) for card_id in card_ids] + if any(card is None for card in cards): + return None, ( + "A stop changed or was answered since you saw it. Nothing " + "changed; look at it again." + ) + found = [card for card in cards if card is not None] + if any(stop_queue.expired(card) for card in found): + return None, "A stop expired before you answered. Nothing changed." + if len(found) > 1 and ( + found[0].get("batch_key") is None + or any(card.get("batch_key") != found[0]["batch_key"] for card in found) + ): + return None, "Only similar stops can be answered together. Nothing changed." + return found, "" + + def answer_stop_cards(self, **params: Any) -> dict: + """Answer one stop, or a batch of similar stops, with one choice. + + Rebuilds every card first: a card that changed, expired or doesn't + offer the choice changes nothing. Signs one decision receipt with the + approver key (one prompt for the batch), then applies each card's own + action with its usual checks. Save approvals go through + ``answer_stop_approval``. + + This is the window's command, and it always signs with this + computer's approver key. No parameter selects another key: the + paired phone's answers come in through + :meth:`answer_stop_cards_from_portal`, which no wire registers. + """ + + return self._answer_stop_cards( + [str(c) for c in params.get("card_ids") or []], + str(params.get("choice") or ""), + shown_ms=params.get("shown_ms"), + sign=self._sign_stop_decision, + device=None, + ) + + def answer_stop_cards_from_portal( + self, + *, + card_ids: list[str], + choice: str, + shown_ms: Any, + device: dict[str, str], + ) -> dict: + """Answer stops from the paired phone, signed with the portal's key. + + Called only by the decision portal, in this process, for a request + that carried an approved device session. It is deliberately not a + command: the window and the tray can't reach it, so neither can sign + with the portal's key. The receipt names the key (``portal``) and the + device session. A waiting save and a problems-only re-arm aren't + answered here: a save goes through the portal's own save approval, + and the re-arm is answered on the computer. + """ + + from openadapt_desktop import stop_queue + + ids = [str(c) for c in card_ids or []] + cards, problem = self._current_cards(ids) + if cards is None: + return {"ok": False, "code": "card_changed", "error": problem} + if any(stop_queue.choice_for(card, choice) is None for card in cards): + return { + "ok": False, + "code": "choice_not_offered", + "error": "That choice isn't on this stop. Nothing changed.", + } + if any(card["kind"] == "approve_save" for card in cards): + return { + "ok": False, + "code": "use_approval", + "error": "A waiting save is answered on its own. Nothing changed.", + } + if any(card["kind"] not in PHONE_ANSWERABLE_KINDS for card in cards): + return { + "ok": False, + "code": "answer_on_computer", + "error": "Answer this on the computer. Nothing changed.", + } + return self._answer_stop_cards( + ids, + str(choice or ""), + shown_ms=shown_ms, + sign=self._sign_stop_decision_portal, + device={ + "id": str(device.get("id") or ""), + "label": str(device.get("label") or "")[:120], + }, + ) + + def _answer_stop_cards( + self, + card_ids: list[str], + choice_id: str, + *, + shown_ms: Any, + sign: Callable[..., dict], + device: dict[str, str] | None, + ) -> dict: + from datetime import datetime, timezone + + from openadapt_desktop import stop_queue + from openadapt_desktop.approver_keychain import ApproverKeychainError + + params = {"shown_ms": shown_ms} + cards, problem = self._current_cards(card_ids) + if cards is None: + return {"ok": False, "code": "card_changed", "error": problem} + choices = [stop_queue.choice_for(card, choice_id) for card in cards] + if any(choice is None for choice in choices): + return { + "ok": False, + "code": "choice_not_offered", + "error": "That choice isn't on this stop. Nothing changed.", + } + if any(card["kind"] == "approve_save" for card in cards): + return { + "ok": False, + "code": "use_approval", + "error": "A waiting save is answered on its own. Nothing changed.", + } + if choice_id == "leave": + return {"ok": True, "results": [], "changed": 0} + if any(card["kind"] == "rearm_problems_only" for card in cards): + if len(cards) != 1 or device is not None: + return { + "ok": False, + "code": "answer_on_computer", + "error": "Turn the switch back on from the computer, on its own.", + } + return self._answer_rearm(cards[0], shown_ms=shown_ms) + decided_at = datetime.now(timezone.utc).isoformat() + message = stop_queue.decision_message(card_ids, choice_id, decided_at=decided_at) + try: + signature = sign(message, count=len(cards)) + except ApproverKeychainError as exc: + return {"ok": False, "code": exc.code, "error": str(exc)} + # The Touch ID prompt can stay up for a while. Check again that every + # card is still exactly what the person saw before anything changes. + again, problem = self._current_cards(card_ids) + if again is None: + return {"ok": False, "code": "card_changed", "error": problem} + results = [] + for card, choice in zip(cards, choices): + assert choice is not None + outcome = self.resolve_attention_item( + item_id=card["source"]["id"], + action=choice.action, + # The choice is the person's statement of what they found in + # the record (stop_queue.RECORD_CHECK_ACTIONS). + record_checked=choice.action in stop_queue.RECORD_CHECK_ACTIONS, + ) + results.append( + { + "card_id": card["card_id"], + "ok": bool(outcome.get("ok")), + "error": outcome.get("error"), + } + ) + self._record_stop_decision( + { + "schema": stop_queue.DECISION_SCHEMA, + "card_ids": sorted(card_ids), + "kinds": sorted({card["kind"] for card in cards}), + "choice": choice_id, + "decided_at": decided_at, + "public_key": signature["public_key"], + "signature": signature["signature"], + "key": signature["key"], + "shown_ms": self._timings(params, card_ids), + "results": results, + # The paired phone's device session, for a portal answer. + **({"device": device} if device is not None else {}), + } + ) + self.services.audit.log( + "stop_answered", + cards=len(cards), + choice=choice_id, + ok=sum(1 for r in results if r["ok"]), + key=signature["key"], + ) + return { + "ok": all(r["ok"] for r in results), + "results": results, + "changed": sum(1 for r in results if r["ok"]), + **( + {} + if all(r["ok"] for r in results) + else {"error": "Some stops weren't changed. They stay in the queue."} + ), + } + + def _answer_rearm(self, card: dict, *, shown_ms: Any) -> dict: + """Turn a task's switch back on with one Touch ID. + + The approver key signs Flow's own re-arm bytes for exactly the + request the card showed, so no separate decision receipt is signed. + Flow verifies the signature against the key registered under + ``approvers`` before it appends ``rearmed``. + """ + + import base64 + from datetime import datetime, timezone + + from openadapt_desktop import approval_mode, stop_queue + from openadapt_desktop.approver_keychain import ( + ApproverKeychainError, + ) + + keychain = self.services.flow_bridge.approver_keychain + stored = keychain.stored() if keychain.supported() else None + if stored is None: + return { + "ok": False, + "code": "approver_key_missing", + "error": ( + "Turning it back on needs your approver key behind Touch ID. " + "Set it up in Settings. Nothing changed." + ), + } + name = self._approver_lookup(stored.public_key) + if name is None: + return { + "ok": False, + "code": "human_approver_unregistered", + "error": "Your approver key isn't registered. Nothing changed.", + } + # Verified against Flow's approvers registry, never a key list built + # from the signer's own key. + keys = approval_mode.registered_approver_keys() + expected = base64.b64encode(bytes.fromhex(stored.public_key)).decode("ascii") + if keys.get(name) != expected: + return { + "ok": False, + "code": "human_approver_unregistered", + "error": "Your approver key isn't registered. Nothing changed.", + } + + def sign(payload: bytes) -> str: + return keychain.sign( + payload, reason='turn "only ask about problems" back on' + )[1] + + try: + outcome = approval_mode.rearm( + self.config.data_dir, + card["workflow_id"], + shown=card["source"]["request"], + approver_id=name, + approver_keys=keys, + sign=sign, + ) + except ApproverKeychainError as exc: + return {"ok": False, "code": exc.code, "error": str(exc)} + except Exception: + logger.exception("Could not re-arm the switch") + return {"ok": False, "error": "Desktop couldn't turn the switch back on."} + ok = bool(outcome.get("ok")) + self._record_stop_decision( + { + "schema": stop_queue.DECISION_SCHEMA, + "card_ids": [card["card_id"]], + "kinds": ["rearm_problems_only"], + "choice": "rearm", + "decided_at": datetime.now(timezone.utc).isoformat(), + # Flow's switch log holds the signed re-arm itself. + "key": "flow_rearm", + "public_key": stored.public_key, + "shown_ms": self._timings({"shown_ms": shown_ms}, [card["card_id"]]), + "results": [{"card_id": card["card_id"], "ok": ok}], + } + ) + if ok: + self.services.audit.log("switch_rearmed", workflow_id=card["workflow_id"]) + return { + "ok": True, + "results": [{"card_id": card["card_id"], "ok": True, "error": None}], + "changed": 1, + } + return {"ok": False, "error": outcome.get("error") or "Nothing changed."} + + @staticmethod + def _approver_lookup(public_key: str) -> str | None: + """The approver name Flow's registry has for this key (patched in tests).""" + + from openadapt_desktop.approver_keychain import registered_name + + return registered_name(public_key) + + def answer_stop_approval(self, **params: Any) -> dict: + """Answer one waiting save from its card (Flow signs the answer). + + Served while the run waits. The card must still be exactly as shown + and unexpired; the answer then goes through ``answer_item_approval``, + which Desktop's approver key signs (Touch ID on a Mac). + """ + + from datetime import datetime, timezone + + from openadapt_desktop import stop_queue + + card_id = str(params.get("card_id") or "") + choice_id = str(params.get("choice") or "") + cards, problem = self._current_cards([card_id]) + if cards is None: + return {"ok": False, "code": "card_changed", "error": problem} + (card,) = cards + choice = stop_queue.choice_for(card, choice_id) + if card["kind"] != "approve_save" or choice is None: + return { + "ok": False, + "code": "choice_not_offered", + "error": "That choice isn't on this stop. Nothing changed.", + } + if choice.action == "none": + return {"ok": True, "changed": 0} + source = card["source"] + answer = self.answer_item_approval( + workflow_id=source["workflow_id"], + decision=choice.action, + request_id=source["id"], + request_sha256=source["request_sha256"], + reason=choice.reason, + ) + self._record_stop_decision( + { + "schema": stop_queue.DECISION_SCHEMA, + "card_ids": [card_id], + "kinds": ["approve_save"], + "choice": choice_id, + "decided_at": datetime.now(timezone.utc).isoformat(), + # Flow's own answer carries the signature. + "key": "flow_item_approval", + "shown_ms": self._timings(params, [card_id]), + "results": [{"card_id": card_id, "ok": bool(answer.get("ok"))}], + } + ) + return {**answer, "changed": 1 if answer.get("ok") else 0} + + def get_approver_key(self, **_params: Any) -> dict: + """Whether this Mac's approver key is set up. Never the key itself.""" + + from openadapt_desktop.approver_key import setup_instructions + + try: + status = self.services.flow_bridge.approver_keychain.status() + except Exception: + logger.exception("Could not read the approver key") + return {"ok": False, "error": "Desktop couldn't read the approver key."} + if not status["supported"]: + status["instructions"] = setup_instructions() + return {"ok": True, **status} + + def set_up_approver_key(self, **params: Any) -> dict: + """Make the approver key behind Touch ID and register it (one prompt). + + ``replace=True`` rotates it: the new key is registered before the old + one is retired. + """ + + from openadapt_desktop.approver_keychain import ApproverKeychainError + + keychain = self.services.flow_bridge.approver_keychain + try: + stored = keychain.setup(replace=bool(params.get("replace"))) + except ApproverKeychainError as exc: + return {"ok": False, "code": exc.code, "error": str(exc)} + except Exception: + logger.exception("Could not set up the approver key") + return { + "ok": False, + "code": "approver_key_unusable", + "error": "Desktop couldn't set up the approver key. Nothing changed.", + } + self.services.audit.log( + "approver_key_setup", + route="desktop", + store="keychain", + replaced=bool(params.get("replace")), + fingerprint=stored.public_key[:16], + ) + return {"ok": True, **keychain.status()} + + def remove_approver_key(self, **_params: Any) -> dict: + """Unregister this Mac's approver key and delete it from the Keychain.""" + + from openadapt_desktop.approver_keychain import ApproverKeychainError + + keychain = self.services.flow_bridge.approver_keychain + try: + removed = keychain.remove() + except ApproverKeychainError as exc: + return {"ok": False, "code": exc.code, "error": str(exc)} + except Exception: + logger.exception("Could not remove the approver key") + return {"ok": False, "error": "Desktop couldn't remove the approver key."} + if removed: + self.services.audit.log("approver_key_removed", route="desktop") + return {"ok": True, "removed": removed, **keychain.status()} + def run_item(self, **params: Any) -> dict: """Run a task once on one item's input values and record the outcome. @@ -5548,8 +6243,11 @@ def _portal_service(self) -> Any: """Lazily build the runner-local decision portal service.""" if self.services.portal is None: from openadapt_desktop.portal.service import PortalService + from openadapt_desktop.portal.stops import DispatcherStopQueue - self.services.portal = PortalService(self.config) + self.services.portal = PortalService( + self.config, stops=DispatcherStopQueue(self) + ) return self.services.portal def portal_status(self, **params: Any) -> dict: diff --git a/packages/desktop/openadapt_desktop/flow_bridge.py b/packages/desktop/openadapt_desktop/flow_bridge.py index ae5932816..328eb0391 100644 --- a/packages/desktop/openadapt_desktop/flow_bridge.py +++ b/packages/desktop/openadapt_desktop/flow_bridge.py @@ -151,6 +151,47 @@ DECLINE_REASONS = frozenset({"wrong_values", "wrong_record", "screen_problem"}) _REQUEST_ID_RE = re.compile(r"^[0-9a-f]{32}$") _REQUEST_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +#: Flow's ``--approver-signer stdio`` lines +#: (``openadapt_flow.approval_mode.human_attestation``). +_SIGN_REQUEST_SCHEMA = "openadapt-flow.approver-sign-request/v1" +_SIGN_RESPONSE_SCHEMA = "openadapt-flow.approver-sign-response/v1" + + +def _sign_refusal(code: str, message: str) -> dict: + return {"schema": _SIGN_RESPONSE_SCHEMA, "refused": code, "message": message} + + +def _is_this_answer( + message: bytes, *, decision: str, request_id: str, request_sha256: str +) -> bool: + """Whether ``message`` is exactly a person's answer to this request. + + Flow signs the answer domain, then the answer as canonical JSON. Desktop + reads it back before Touch ID, so the prompt can only ever approve the + answer the person chose. + """ + + from openadapt_desktop.approver_keychain import ANSWER_DOMAIN + + if not message.startswith(ANSWER_DOMAIN): + return False + body = message[len(ANSWER_DOMAIN) :] + try: + payload = json.loads(body) + except ValueError: + return False + if not isinstance(payload, dict): + return False + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return ( + canonical == body + and payload.get("request_id") == request_id + and payload.get("request_sha256") == request_sha256 + and payload.get("decision") == decision + and payload.get("decided_by") == "human" + and payload.get("route") == ITEM_APPROVAL_ROUTE + ) + def _contract_counts(value: object) -> dict[str, int] | None: @@ -502,7 +543,13 @@ def _subprocess_env() -> dict[str, str]: that package's normal ``sys.executable -m playwright`` call in a freeze. """ - return dict(os.environ) + env = dict(os.environ) + # Flow never takes an approver root from the environment: a governed run + # takes it from the config snapshot Desktop writes, and the answer + # commands from --approver-root. Drop any inherited value so no Flow + # version or helper can read one. + env.pop("OPENADAPT_APPROVER_ROOT", None) + return env def _safe_command_for_log(cmd: list[str]) -> str: @@ -561,16 +608,28 @@ def __init__( flow_bin: str = FLOW_BIN, runner=subprocess.run, popen=subprocess.Popen, + approver_keychain=None, ) -> None: self.flow_bin = flow_bin self._runner = runner self._popen = popen + self._approver_keychain = approver_keychain self._run_auth_support: bool | None = None self._attached_browser_support: bool | None = None self._push_json_support: bool | None = None self._approval_gate_support: bool | None = None self._local_drill_support: bool | None = None + @property + def approver_keychain(self): + """This Mac's in-app approver key (:mod:`.approver_keychain`).""" + + if self._approver_keychain is None: + from openadapt_desktop.approver_keychain import default_keychain + + self._approver_keychain = default_keychain() + return self._approver_keychain + @staticmethod def hosted_runner_contract() -> ModuleType: """Load Flow's exact hosted-runner contract or fail before networking. @@ -926,9 +985,14 @@ def run( env_overrides: dict[str, str] | None = None, approval_mode: str | None = None, approval_wait_s: int | None = None, + config_sha256: str | None = None, ) -> FlowResult: """Run a bundle under a deployment config. + ``config_sha256`` is the digest of the config bytes Desktop wrote: + Flow reads the file once, refuses it if it differs, and parses only + those bytes (``--config-sha256``), so it can't be swapped mid-run. + ``authorization_file`` forwards a cloud-minted GovernedRunAuthorization JSON to ``openadapt-flow run --authorization-file`` -- pass it only when :meth:`run_supports_authorization` is True (the flag is a PROPOSED flow @@ -941,6 +1005,8 @@ def run( unless the caller passes the mode, so Desktop always passes it. """ args = ["run", str(bundle_dir), "--config", str(config)] + if config_sha256: + args += ["--config-sha256", config_sha256] if out_dir: args += ["--run-dir", str(out_dir)] if authorization_file: @@ -971,6 +1037,7 @@ def qualify_run_case( url: str | None = None, env_overrides: dict[str, str] | None = None, approval_wait_s: int | None = None, + config_sha256: str | None = None, ) -> FlowResult: """Run one qualification case through Flow-owned authorization. @@ -997,6 +1064,7 @@ def qualify_run_case( str(out_dir), "--config", str(config), + *(["--config-sha256", config_sha256] if config_sha256 else []), ] if url: args += ["--url", url] @@ -1081,10 +1149,18 @@ def item_approval_answer( """Answer one waiting request as the person at this computer. Flow counts the answer as a person's only when Desktop's approver key - signs it (:mod:`openadapt_desktop.approver_key`), so Desktop passes - ``--approver-key`` and puts the key's passphrase in the Flow child's - environment, never on its command line. Without a key, nothing is sent - and the answer is ``{"ok": False, "code": "human_answer_unsigned"}``. + signs it. On a Mac with the key set up in the app + (:mod:`openadapt_desktop.approver_keychain`), Flow asks Desktop for the + signature over a pipe and Desktop signs after Touch ID; Flow never sees + the key. Otherwise Desktop passes the terminal-made key + (:mod:`openadapt_desktop.approver_key`) with ``--approver-key`` and + puts its passphrase in the Flow child's environment, never on its + command line; only where the app can't keep the key (Windows, Linux, + a Mac without a Secure Enclave). Without a key, nothing is sent: the + answer is + ``{"ok": False, "code": "approver_key_missing", "setup": "in_app"}`` + where the app can set one up, else ``"human_answer_unsigned"`` with + the terminal steps. Desktop has no person-level sign-in (its Cloud credential belongs to the organization), so the answer carries no Cloud name (``--unauthenticated``); the signature names the registered approver. @@ -1119,16 +1195,57 @@ def item_approval_answer( args += ["--reason", str(reason)] elif reason is not None: raise ValueError("An approval has no reason") + from openadapt_desktop.approver_keychain import ( + ApproverKeychainError, + pinned_head, + pinned_root, + ) + + try: + root = pinned_root() + except ApproverKeychainError as exc: + return {"ok": False, "code": exc.code, "message": str(exc)} + if root: + # The reader brings its own pin; the run directory's record must + # match it, and is never trusted on its own. The head this Mac + # recorded goes with it, so Flow refuses a roster Desktop would. + args += ["--approver-root", root] + head = pinned_head() + if head is not None: + args += ["--approver-head", f"{head[0]}:{head[1]}"] from openadapt_desktop.approver_key import ( approver_key_environment, setup_instructions, ) + keychain = self.approver_keychain + in_app = keychain.supported() + if in_app and keychain.stored() is not None: + return self._answer_signed_in_app( + args, + keychain, + decision=decision, + request_id=request_id, + request_sha256=request_sha256, + ) + if in_app: + # A Mac that can keep the key behind Touch ID never falls back to + # a terminal-made file key, which asks for no presence. + return { + "ok": False, + "code": "approver_key_missing", + "setup": "in_app", + "message": ( + "Set up your approver key in OpenAdapt to approve saves on " + "this Mac. Nothing was approved." + ), + } key = approver_key_environment() if key is None: return { "ok": False, "code": "human_answer_unsigned", + "setup": "terminal", "message": ( "This computer has no approver key, so nothing was approved. " + setup_instructions() @@ -1137,8 +1254,12 @@ def item_approval_answer( key_path, key_env = key args += ["--approver-key", str(key_path)] result = self._run(args, timeout=60, env_overrides=key_env) + return self._read_answer(result.stdout, result.returncode) + + def _read_answer(self, stdout: str, returncode: int) -> dict: + lines = [line for line in (stdout or "").splitlines() if line.strip()] try: - answer = json.loads(result.stdout) + answer = json.loads(lines[-1]) if lines else None except json.JSONDecodeError: answer = None if not isinstance(answer, dict) or not isinstance(answer.get("ok"), bool): @@ -1147,7 +1268,7 @@ def item_approval_answer( "code": "unreadable", "message": "Flow's answer couldn't be read, so nothing was approved.", } - if answer["ok"] and result.returncode != 0: + if answer["ok"] and returncode != 0: return { "ok": False, "code": "inconsistent", @@ -1155,6 +1276,124 @@ def item_approval_answer( } return answer + def _answer_signed_in_app( + self, + args: list[str], + keychain, + *, + decision: str, + request_id: str, + request_sha256: str, + timeout: float = 180, + ) -> dict: + """Run the answer with Flow asking Desktop to sign over a pipe. + + Flow writes one sign request; Desktop checks that the bytes are this + answer (the request, the decision, a person at Run it), asks for + Touch ID, signs and writes one response. Any check or prompt that + fails is sent to Flow as a refusal, so Flow records nothing. + """ + + import threading + + from openadapt_desktop.approver_keychain import ApproverKeychainError + + stored = keychain.stored() + if stored is None: + return { + "ok": False, + "code": "approver_key_missing", + "setup": "in_app", + "message": "Set up your approver key to approve saves on this Mac.", + } + prefix = _flow_command(self.flow_bin) + if prefix is None: + raise FlowNotAvailableError( + f"openadapt-flow isn't available: {flow_problem(self.flow_bin)}" + ) + cmd = [ + *prefix, + *args, + "--approver-signer", + "stdio", + "--approver-public-key", + stored.public_key, + ] + logger.debug("flow: {cmd}", cmd=_safe_command_for_log(cmd)) + proc = self._popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + env=_subprocess_env(), + ) + out: list[str] = [] + + def respond(line: str) -> dict: + try: + sign_request = json.loads(line) + message = bytes.fromhex(str(sign_request["message_hex"])) + except (ValueError, KeyError, TypeError): + return _sign_refusal( + "approver_payload_refused", "Flow's sign request couldn't be read." + ) + if sign_request.get("public_key") != stored.public_key or not ( + _is_this_answer( + message, + decision=decision, + request_id=request_id, + request_sha256=request_sha256, + ) + ): + return _sign_refusal( + "approver_payload_refused", + "Flow asked Desktop to sign something other than your " + "answer, so nothing was signed.", + ) + reason = ( + "approve this save" + if decision == "approve" + else "record that this item shouldn't save" + ) + try: + _public, signature = keychain.sign(message, reason=reason) + except ApproverKeychainError as exc: + return _sign_refusal(exc.code, str(exc)) + return {"schema": _SIGN_RESPONSE_SCHEMA, "signature": signature} + + def talk() -> None: + assert proc.stdout is not None and proc.stdin is not None + first = proc.stdout.readline() + try: + is_request = json.loads(first).get("schema") == _SIGN_REQUEST_SCHEMA + except (ValueError, AttributeError): + is_request = False + if is_request: + proc.stdin.write(json.dumps(respond(first)) + "\n") + proc.stdin.flush() + else: + out.append(first) + out.append(proc.stdout.read()) + proc.stdin.close() + + worker = threading.Thread(target=talk, name="approver-sign", daemon=True) + worker.start() + worker.join(timeout) + if worker.is_alive(): + proc.kill() + return { + "ok": False, + "code": "timeout", + "message": "The approval took too long, so nothing was approved.", + } + try: + returncode = proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + returncode = -9 + return self._read_answer("".join(out), returncode) + def supports_approval_gate(self) -> bool: """Probe (once) whether Flow can stop before each save for a person. diff --git a/packages/desktop/openadapt_desktop/ipc.py b/packages/desktop/openadapt_desktop/ipc.py index c4631fced..6f64b43c4 100644 --- a/packages/desktop/openadapt_desktop/ipc.py +++ b/packages/desktop/openadapt_desktop/ipc.py @@ -30,10 +30,21 @@ from openadapt_desktop.dispatch import EngineDispatcher, EngineServices #: Commands served while another command runs. A run can stop before a save -#: and wait for a person; these two read and answer that wait, so they never -#: queue behind the run and never take the dispatch lock. They touch only the -#: dispatcher's approval watches and Flow's approval files. -CONCURRENT_COMMANDS = frozenset({"get_item_approval", "answer_item_approval"}) +#: and wait for a person; these read and answer that wait, and set up the +#: approver key the answer needs, so they never queue behind the run and never +#: take the dispatch lock. They touch only the dispatcher's approval watches, +#: Flow's approval files and the approver key (which has its own lock). +CONCURRENT_COMMANDS = frozenset( + { + "get_item_approval", + "answer_item_approval", + "get_approver_key", + "set_up_approver_key", + "remove_approver_key", + "list_stop_cards", + "answer_stop_approval", + } +) #: Sent by the Tauri shell when the app quits. Handled on the reader thread, #: ahead of queued commands, because the shell kills the engine shortly after. @@ -63,6 +74,9 @@ def __init__(self, config: EngineConfig, services: EngineServices | None = None) self.config = config self.dispatcher = EngineDispatcher(config, services=services, emit=self.send_event) self.dispatch_lock = threading.RLock() + # The paired phone's answers to stops take the same lock, so an + # answer that runs an item again waits its turn like a command. + self.dispatcher.serial_lock = self.dispatch_lock self._write_lock = threading.Lock() self._handlers: dict[str, Any] = {} self._register_handlers() diff --git a/packages/desktop/openadapt_desktop/portal/server.py b/packages/desktop/openadapt_desktop/portal/server.py index 4300903ce..e8831d3a4 100644 --- a/packages/desktop/openadapt_desktop/portal/server.py +++ b/packages/desktop/openadapt_desktop/portal/server.py @@ -33,6 +33,7 @@ ) from openadapt_desktop.portal.ingress import PortalIngress from openadapt_desktop.portal.pairing import DevicePairingStore, DeviceSession, PairingRefused +from openadapt_desktop.portal.stops import StopQueueBackend SHELL_DIR = Path(__file__).parent / "shell" @@ -50,6 +51,8 @@ "application/manifest+json; charset=utf-8", ), "/sw.js": ("shell/sw.js", "application/javascript; charset=utf-8"), + "/stops.js": ("shell/stops.js", "application/javascript; charset=utf-8"), + "/stops.css": ("shell/stops.css", "text/css; charset=utf-8"), } _MAX_BODY_BYTES = 16 * 1024 @@ -85,6 +88,30 @@ rf"/actions/(?P{_ACTION_ID})$" ) +# The stop-review queue (openadapt_desktop/portal/stops.py). A card id is +# ``sha256:``; the path carries the hex only. +_CARD_HEX = r"[0-9a-f]{64}" +_CARD_ID = re.compile(r"^sha256:[0-9a-f]{64}$") +_ROUTE_STOPS = re.compile(r"^/api/portal/stops$") +_ROUTE_STOPS_BATCH = re.compile(r"^/api/portal/stops/answer$") +_ROUTE_STOP_SCREENSHOT = re.compile( + rf"^/api/portal/stops/(?P{_CARD_HEX})/screenshot$" +) +_ROUTE_STOP_ANSWER = re.compile(rf"^/api/portal/stops/(?P{_CARD_HEX})/answer$") +_CHOICE_ID = re.compile(r"^[a-z][a-z_]{0,39}$") +_MAX_STOP_BATCH = 25 + +#: The dispatcher's refusal codes, as HTTP statuses. The body carries the code. +_STOP_REFUSAL_STATUS = { + "card_changed": 409, + "use_approval": 409, + "answer_on_computer": 409, + "choice_not_offered": 422, + "approver_key_missing": 503, + "approver_key_unusable": 503, + "busy": 503, +} + _SECURITY_HEADERS = { "Content-Security-Policy": ( "default-src 'self'; script-src 'self'; style-src 'self'; " @@ -121,10 +148,13 @@ def __init__( ingress: PortalIngress, pairings: DevicePairingStore, flow: FlowConsoleClient | None, + *, + stops: StopQueueBackend | None = None, ) -> None: self.ingress = ingress self.pairings = pairings self.flow = flow + self.stops = stops # -------------------------------------------------------------- responses @@ -154,11 +184,11 @@ def _asset(self, path: str) -> tuple[int, dict[str, str], bytes]: headers["Service-Worker-Allowed"] = "/" return 200, headers, body - def _shell(self) -> tuple[int, dict[str, str], bytes]: + def _shell(self, page: str = "index.html") -> tuple[int, dict[str, str], bytes]: headers = {"Content-Type": "text/html; charset=utf-8"} headers.update(_SECURITY_HEADERS) headers["Cache-Control"] = "no-cache" - return 200, headers, (SHELL_DIR / "index.html").read_bytes() + return 200, headers, (SHELL_DIR / page).read_bytes() # ----------------------------------------------------------------- policy @@ -219,6 +249,10 @@ def handle( if method == "GET" and path in ("/", "/pair"): return self._shell() + if method == "GET" and path == "/stops": + # The page only. Every card it shows comes from an authenticated + # route below. + return self._shell("stops.html") if method == "GET" and path in SHELL_ASSETS: return self._asset(path) @@ -245,6 +279,9 @@ def handle( }, ) + if path == "/api/portal/stops" or path.startswith("/api/portal/stops/"): + return self._stops_route(method, path, headers, body, session) + if method == "GET" and _ROUTE_TASKS.fullmatch(path): return self._relay("tasks") @@ -305,6 +342,115 @@ def handle( return self._refusal(404, "not_found", "No such page.") + # ------------------------------------------------------------ stop queue + + def _check_fetch_site(self, headers: Any) -> None: + """Refuse a read another site started. + + A browser leaves ``Origin`` off a same-origin GET, so a read can't + require it. When either header is present, though, it must say this + portal. + """ + origin = headers.get("origin") + if origin is not None and str(origin).rstrip("/") != self.ingress.public_origin: + raise PairingRefused("cross_origin", "That request came from another site.") + site = headers.get("sec-fetch-site") + if site is not None and str(site).lower() not in {"same-origin", "none"}: + raise PairingRefused("cross_origin", "That request came from another site.") + + def _mutation_payload( + self, headers: Any, body: bytes, session: DeviceSession + ) -> tuple[dict | None, tuple[int, dict[str, str], bytes] | None]: + """The JSON body of a same-origin, CSRF-checked mutation, or a refusal.""" + if len(body) > _MAX_BODY_BYTES: + return None, self._refusal(413, "bad_request", "Request body is too large.") + try: + self._check_origin(headers) + except PairingRefused as exc: + return None, self._refusal(403, exc.reason, str(exc)) + content_type = str(headers.get("content-type", "") or "").lower() + if not content_type.startswith("application/json"): + return None, self._refusal(415, "bad_request", "Answers must be JSON.") + if not self.pairings.verify_csrf( + session, headers.get("x-openadapt-portal-csrf", "") + ): + return None, self._refusal(403, "csrf", "This session needs to pair again.") + try: + payload = json.loads(body or b"{}") + except ValueError: + return None, self._refusal(400, "bad_request", "Invalid request.") + if not isinstance(payload, dict): + return None, self._refusal(400, "bad_request", "Invalid request.") + return payload, None + + def _stops_route( + self, method: str, path: str, headers: Any, body: bytes, session: DeviceSession + ): + """The stop-review queue. Every route here has a paired session.""" + if self.stops is None: + return self._refusal( + 503, + "stops_unavailable", + "The stop queue isn't available. Open OpenAdapt Desktop on the computer.", + ) + if method == "GET": + try: + self._check_fetch_site(headers) + except PairingRefused as exc: + return self._refusal(403, exc.reason, str(exc)) + if _ROUTE_STOPS.fullmatch(path): + return self._json(200, self.stops.list_cards()) + shot = _ROUTE_STOP_SCREENSHOT.fullmatch(path) + if shot: + data = self.stops.screenshot("sha256:" + shot.group("card")) + if data is None: + return self._refusal(404, "not_found", "This stop has no picture.") + image_headers = {"Content-Type": "image/png"} + image_headers.update(_SECURITY_HEADERS) + image_headers.update(_NO_STORE_HEADERS) + return 200, image_headers, data + return self._refusal(404, "not_found", "No such page.") + if method != "POST": + return self._refusal(404, "not_found", "No such page.") + single = _ROUTE_STOP_ANSWER.fullmatch(path) + if not single and not _ROUTE_STOPS_BATCH.fullmatch(path): + return self._refusal(404, "not_found", "No such page.") + payload, refused = self._mutation_payload(headers, body, session) + if refused is not None: + return refused + assert payload is not None + choice = payload.get("choice") + if not isinstance(choice, str) or not _CHOICE_ID.fullmatch(choice): + return self._refusal(400, "bad_request", "Choose one of the stop's answers.") + if single: + card_ids = ["sha256:" + single.group("card")] + raw_ms = payload.get("shown_ms") + shown_ms = ( + {card_ids[0]: raw_ms} if isinstance(raw_ms, (int, float)) else None + ) + else: + raw_ids = payload.get("card_ids") + if ( + not isinstance(raw_ids, list) + or not 1 <= len(raw_ids) <= _MAX_STOP_BATCH + or not all(isinstance(c, str) and _CARD_ID.fullmatch(c) for c in raw_ids) + ): + return self._refusal(400, "bad_request", "Invalid list of stops.") + card_ids = list(raw_ids) + shown_ms = payload.get("shown_ms") + result = self.stops.answer( + card_ids, + choice, + shown_ms=shown_ms if isinstance(shown_ms, dict) else None, + # Which paired device answered. The session id is the handle the + # computer lists and revokes devices by; never the bearer token. + device={"id": session.session_id, "label": session.device_label}, + ) + status = 200 + if not result.get("ok") and result.get("code"): + status = _STOP_REFUSAL_STATUS.get(str(result["code"]), 409) + return self._json(status, result) + @staticmethod def _safe_run_id(value: str | None) -> str | None: if not value or ".." in value or "/" in value: diff --git a/packages/desktop/openadapt_desktop/portal/service.py b/packages/desktop/openadapt_desktop/portal/service.py index 238f9e2e8..708676899 100644 --- a/packages/desktop/openadapt_desktop/portal/service.py +++ b/packages/desktop/openadapt_desktop/portal/service.py @@ -269,8 +269,12 @@ def __init__( *, popen: Callable[..., Any] = subprocess.Popen, clock: Callable[[], float] = time.monotonic, + stops: Any = None, ) -> None: self.config = config + # The stop-review queue the phone may read and answer + # (:mod:`openadapt_desktop.portal.stops`), or None. + self._stops = stops self._popen = popen self._clock = clock self._lock = threading.Lock() @@ -348,7 +352,9 @@ def start(self) -> dict[str, Any]: pairings = DevicePairingStore( runner_id=self.runner_id(), clock=self._clock ) - server = PortalServer(PortalApp(ingress, pairings, flow)) + server = PortalServer( + PortalApp(ingress, pairings, flow, stops=self._stops) + ) try: port = server.start() except OSError as exc: diff --git a/packages/desktop/openadapt_desktop/portal/shell/index.html b/packages/desktop/openadapt_desktop/portal/shell/index.html index aaa8bdc9e..2853739ae 100644 --- a/packages/desktop/openadapt_desktop/portal/shell/index.html +++ b/packages/desktop/openadapt_desktop/portal/shell/index.html @@ -16,6 +16,7 @@
OpenAdapt + Review stops
diff --git a/packages/desktop/openadapt_desktop/portal/shell/stops.css b/packages/desktop/openadapt_desktop/portal/shell/stops.css new file mode 100644 index 000000000..1cb257674 --- /dev/null +++ b/packages/desktop/openadapt_desktop/portal/shell/stops.css @@ -0,0 +1,124 @@ +/* The stop-review queue on a phone. Builds on styles.css and uses only its + colour tokens, so the palette stays in one place. The page scrolls: a stop + with its evidence is taller than one screen. */ + +body.stops-page { overflow: auto; } +.app.stops-app { height: auto; min-height: 100vh; min-height: 100dvh; } + +.stops-link { + color: var(--accent); + font-size: 15px; + text-decoration: none; + min-height: 44px; + display: inline-flex; + align-items: center; + padding: 0 4px; +} + +.stops-notice, .stops-error, .stops-warn { + margin: 12px 0 0; + padding: 10px 12px; + border-radius: 14px; + font-size: 14px; +} +.stops-notice { background: var(--accent-glow); color: var(--text); } +.stops-error { background: var(--danger-glow); color: var(--danger); } +.stops-warn { background: var(--warn-soft); color: var(--warn); } + +.stops-main { margin-top: 12px; padding-bottom: 24px; } + +.stops-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 24px; + padding: 20px; + box-shadow: var(--shadow); +} +.stops-where { margin: 0; font-size: 13px; color: var(--muted); } +.stops-q { margin: 10px 0 6px; font-size: 26px; line-height: 1.15; font-weight: 650; letter-spacing: -0.02em; } +.stops-sub { margin: 0; color: var(--muted); font-size: 14.5px; } + +.stops-evidence { display: grid; gap: 14px; margin-top: 18px; } +.stops-side h2, .stops-batch h2 { margin: 0 0 8px; font-size: 15px; font-weight: 650; } +.stops-side dl { margin: 0; border-top: 1px solid var(--line); } +.stops-row { padding: 10px 2px; border-bottom: 1px solid var(--line); } +.stops-row dt { font-size: 13px; color: var(--muted); display: flex; gap: 8px; align-items: center; } +.stops-row dd { margin: 2px 0 0; font-size: 16px; overflow-wrap: anywhere; } +.stops-same { background: color-mix(in srgb, var(--accent) 12%, transparent); border-radius: 10px; padding-left: 8px; padding-right: 8px; } +.stops-differs { background: var(--warn-soft); border-radius: 10px; padding-left: 8px; padding-right: 8px; } +.stops-flag { + font-size: 11.5px; + font-weight: 650; + color: var(--warn); + border: 1px solid var(--warn); + border-radius: 999px; + padding: 0 7px; +} +.stops-record { display: block; font-size: 13.5px; color: var(--muted); margin-top: 2px; } +.stops-candidates { margin: 0 0 10px; padding-left: 18px; font-size: 14px; color: var(--muted); } +.stops-reason { font-size: 13px; color: var(--faint); margin: 10px 0 0; overflow-wrap: anywhere; } + +.stops-shot { margin: 0; } +.stops-shot img { display: block; width: 100%; height: auto; border-radius: 12px; border: 1px solid var(--line); } +.stops-shot figcaption { font-size: 12.5px; color: var(--muted); margin-top: 6px; } + +.stops-choices { display: grid; gap: 10px; margin-top: 18px; } +.stops-choice { + min-height: 56px; + width: 100%; + text-align: left; + padding: 12px 16px; + border-radius: 16px; + border: 1px solid var(--line); + background: var(--panel-2); + cursor: pointer; + display: grid; + gap: 3px; +} +.stops-choice.primary { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; } +.stops-choice:disabled { opacity: 0.5; cursor: default; } +.stops-choice-label { font-size: 16.5px; font-weight: 600; } +.stops-choice-then { font-size: 13.5px; color: var(--muted); } + +.stops-viewonly { margin-top: 18px; padding: 14px; border-radius: 16px; border: 1px dashed var(--line); } +.stops-viewonly p { margin: 0 0 10px; } +.stops-button { + min-height: 48px; + display: inline-flex; + align-items: center; + padding: 0 18px; + border-radius: 14px; + background: var(--accent); + color: var(--accent-ink); + font-weight: 600; + text-decoration: none; + margin-top: 12px; +} + +.stops-batch { margin-top: 22px; padding-top: 16px; border-top: 1px solid var(--line); } +.stops-batch ul { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 6px; } +.stops-pick { + min-height: 48px; + display: flex; + align-items: center; + gap: 12px; + padding: 6px 4px; + font-size: 14.5px; + overflow-wrap: anywhere; +} +.stops-pick input { width: 24px; height: 24px; flex: none; accent-color: var(--accent); } + +.stops-nav { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 14px; } +.stops-nav-btn { + min-height: 52px; + border-radius: 16px; + border: 1px solid var(--line); + background: none; + font-weight: 600; + cursor: pointer; +} +.stops-nav-btn:disabled { opacity: 0.4; cursor: default; } + +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; animation: none !important; } +} diff --git a/packages/desktop/openadapt_desktop/portal/shell/stops.html b/packages/desktop/openadapt_desktop/portal/shell/stops.html new file mode 100644 index 000000000..9af939224 --- /dev/null +++ b/packages/desktop/openadapt_desktop/portal/shell/stops.html @@ -0,0 +1,27 @@ + + + + + + + + + + +OpenAdapt stops + + + + +
+
+ Review stops + Decisions +
+ + +
Loading…
+
+ + + diff --git a/packages/desktop/openadapt_desktop/portal/shell/stops.js b/packages/desktop/openadapt_desktop/portal/shell/stops.js new file mode 100644 index 000000000..a91656995 --- /dev/null +++ b/packages/desktop/openadapt_desktop/portal/shell/stops.js @@ -0,0 +1,452 @@ +// The stop-review queue on a paired phone: one stop at a time, answered with +// one tap, or a batch of similar stops answered with one choice. +// +// This page shows what Desktop sends and adds nothing. The question, the +// item's values next to what the app shows, the marked differences and the +// choices with their consequences all come from the card. Desktop rebuilds +// every card before it acts: a stop that changed, expired or is gone, or a +// choice the stop doesn't offer, changes nothing, and this page shows the +// refusal as it came. +// +// Answers are signed on the computer with the decision portal's approver +// key. Two kinds of stop are view-only here: a waiting save is answered on +// the Decisions page (the portal's own save approval), and turning "only ask +// about problems" back on is answered on the computer. +// +// Like the decision shell, this page keeps its pairing in the tab only +// (sessionStorage), sends every request with `cache: "no-store"`, and builds +// its markup from text nodes, never from the card's strings as HTML. + +const POLL_MS = 10000; +const MAX_BATCH = 25; +const PHONE_KINDS = new Set(["same_record", "confirm_save", "halted"]); + +const store = window.sessionStorage; +const state = { + cards: [], + index: 0, + batch: [], + armed: null, // a batch choice waiting for its confirming tap + busy: false, + keyReady: false, + shownAt: new Map(), + shot: null, // { cardId, url } +}; + +function $(id) { + return document.getElementById(id); +} + +function el(tag, options = {}, children = []) { + const node = document.createElement(tag); + if (options.className) node.className = options.className; + if (options.text !== undefined) node.textContent = String(options.text); + if (options.attrs) { + for (const [name, value] of Object.entries(options.attrs)) node.setAttribute(name, value); + } + for (const child of children) if (child) node.appendChild(child); + return node; +} + +function session() { + return { token: store.getItem("portal_token"), csrf: store.getItem("portal_csrf") }; +} + +async function api(path, options = {}) { + const { token, csrf } = session(); + const headers = { Accept: "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + if (options.method === "POST") { + headers["Content-Type"] = "application/json"; + if (csrf) headers["X-OpenAdapt-Portal-CSRF"] = csrf; + } + let response; + try { + response = await fetch(path, { + method: options.method || "GET", + body: options.body, + headers, + cache: "no-store", + credentials: "omit", + redirect: "error", + }); + } catch (error) { + return { status: 0, body: null }; + } + let body = null; + try { + body = await response.json(); + } catch (error) { + body = null; + } + return { status: response.status, body }; +} + +function cardHex(cardId) { + return String(cardId).replace(/^sha256:/, ""); +} + +function label(field) { + const text = String(field).replace(/_/g, " ").trim(); + return text.charAt(0).toUpperCase() + text.slice(1); +} + +function say(id, text) { + const node = $(id); + node.textContent = text || ""; + node.hidden = !text; +} + +function current() { + return state.cards[Math.min(state.index, Math.max(0, state.cards.length - 1))] || null; +} + +function similar(card) { + if (!card || !card.batch_key || !PHONE_KINDS.has(card.kind)) return []; + return state.cards.filter((c) => c.batch_key === card.batch_key); +} + +// -------------------------------------------------------------- loading + +function needsPairing(text) { + const main = $("main"); + main.replaceChildren( + el("section", { className: "stops-card" }, [ + el("h1", { className: "stops-q", text: "Pair this phone first" }), + el("p", { className: "stops-sub", text: text }), + el("a", { className: "stops-button", text: "Open Decisions", attrs: { href: "/" } }), + ]), + ); +} + +async function load({ quiet = false } = {}) { + if (!session().token) { + needsPairing("Scan the pairing code on the computer, then come back to Review stops."); + return; + } + const { status, body } = await api("/api/portal/stops"); + if (status === 401 || status === 202) { + needsPairing( + status === 202 + ? "Confirm the code on the computer, then come back." + : "This phone isn't paired, or its pairing ended.", + ); + return; + } + if (status !== 200 || !body || !body.ok) { + say("error", (body && (body.message || body.error)) || "The computer didn't answer. Nothing changed."); + return; + } + const keep = current() && current().card_id; + const cards = Array.isArray(body.cards) ? body.cards : []; + const same = + cards.length === state.cards.length && + cards.every((c, i) => c.card_id === state.cards[i].card_id) && + Boolean(body.portal_key && body.portal_key.ready) === state.keyReady; + // A poll that found the same stops leaves the page alone, so the person's + // place on it doesn't jump. + if (quiet && same) return; + state.cards = cards; + state.keyReady = Boolean(body.portal_key && body.portal_key.ready); + const ids = new Set(state.cards.map((c) => c.card_id)); + state.batch = state.batch.filter((id) => ids.has(id)); + if (state.armed && !state.batch.length) state.armed = null; + const found = state.cards.findIndex((c) => c.card_id === keep); + state.index = found >= 0 ? found : Math.min(state.index, Math.max(0, state.cards.length - 1)); + render(); +} + +// ------------------------------------------------------------ evidence + +async function loadShot(card) { + if (!card.evidence || !card.evidence.screenshot) return; + if (state.shot && state.shot.cardId === card.card_id) { + showShot(card); + return; + } + if (state.shot) URL.revokeObjectURL(state.shot.url); + state.shot = null; + const { token } = session(); + let response; + try { + response = await fetch(`/api/portal/stops/${cardHex(card.card_id)}/screenshot`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + credentials: "omit", + redirect: "error", + }); + } catch (error) { + return; + } + if (!response.ok) return; + const blob = await response.blob(); + state.shot = { cardId: card.card_id, url: URL.createObjectURL(blob) }; + if (current() && current().card_id === card.card_id) showShot(card); +} + +function showShot(card) { + const slot = $("shot"); + if (!slot || !state.shot || state.shot.cardId !== card.card_id) return; + const caption = card.kind === "approve_save" ? "The form, before saving" : "Where the run stopped"; + slot.replaceChildren( + el("figure", { className: "stops-shot" }, [ + el("img", { attrs: { src: state.shot.url, alt: caption } }), + el("figcaption", { text: caption }), + ]), + ); +} + +// One possible match the app listed: field by field, each value marked Same +// or Different from the item. A row Flow read only as text shows its text. +function candidate(row, index, count) { + const fields = (row && row.fields) || []; + if (!fields.length) return el("li", { text: (row && row.text) || String(row) }); + const title = count > 1 ? `Possible match ${index + 1}` : "Possible match in the app"; + return el("li", {}, [ + el("h3", { text: title }), + el( + "dl", + {}, + fields.map((f) => + el("div", { className: f.same ? "stops-row stops-same" : "stops-row stops-differs" }, [ + el("dt", { text: label(f.field) }), + el("dd", {}, [ + f.value === "" ? el("em", { text: "Empty" }) : document.createTextNode(f.value), + el("span", { className: "stops-flag", text: f.same ? "Same" : "Different" }), + ]), + ]), + ), + ), + ]); +} + +function evidence(card) { + const ev = card.evidence || {}; + const diffs = new Map((ev.differences || []).map((d) => [d.field, d])); + const rows = (ev.source || []).map((row) => { + const diff = diffs.get(row.field); + const differs = diff ? !diff.same : false; + const value = el("dd", {}, [ + row.value === "" ? el("em", { text: "Left empty" }) : document.createTextNode(row.value), + ]); + if (diff) { + value.appendChild( + el("span", { className: "stops-record" }, [ + document.createTextNode(differs ? "App shows: " : "App matches: "), + el("strong", { text: diff.record === "" ? "empty" : diff.record }), + ]), + ); + } + return el("div", { className: differs ? "stops-row stops-differs" : "stops-row" }, [ + el("dt", {}, [ + document.createTextNode(label(row.field)), + differs ? el("span", { className: "stops-flag", text: "Differs" }) : null, + ]), + value, + ]); + }); + const item = el("section", { className: "stops-side", attrs: { "aria-label": "The item" } }, [ + el("h2", { text: "The item" }), + rows.length ? el("dl", {}, rows) : el("p", { className: "stops-sub", text: "No values to compare." }), + ]); + const app = el("section", { className: "stops-side", attrs: { "aria-label": "What the app shows" } }, [ + el("h2", { text: card.kind === "approve_save" ? "The form" : "What the app shows" }), + ]); + if ((ev.candidates || []).length) { + app.appendChild( + el( + "ul", + { className: "stops-candidates", attrs: { "aria-label": "Possible matches the app listed" } }, + ev.candidates.map((row, i) => candidate(row, i, ev.candidates.length)), + ), + ); + } + if (ev.screenshot) { + app.appendChild(el("div", { attrs: { id: "shot" } }, [el("p", { className: "stops-sub", text: "Loading the screen…" })])); + } + if (ev.reason) app.appendChild(el("p", { className: "stops-reason", text: ev.reason })); + return el("div", { className: "stops-evidence" }, [item, app]); +} + +// --------------------------------------------------------------- answers + +function shownMs(ids) { + const now = Date.now(); + const out = {}; + for (const id of ids) out[id] = now - (state.shownAt.get(id) || now); + return out; +} + +async function answer(choiceId, ids) { + if (state.busy || !ids.length) return; + state.busy = true; + say("error", ""); + say("notice", ""); + render(); + const single = ids.length === 1; + const path = single + ? `/api/portal/stops/${cardHex(ids[0])}/answer` + : "/api/portal/stops/answer"; + const times = shownMs(ids); + const payload = single + ? { choice: choiceId, shown_ms: times[ids[0]] } + : { card_ids: ids, choice: choiceId, shown_ms: times }; + const { status, body } = await api(path, { method: "POST", body: JSON.stringify(payload) }); + state.busy = false; + state.armed = null; + if (status === 0) { + say("error", "The answer may not have reached the computer. Check the queue before answering again."); + } else if (!body || !body.ok) { + say("error", (body && (body.error || body.message)) || "Nothing changed."); + } else if (choiceId === "leave") { + state.index += 1; + } else { + say("notice", single ? "Answered." : `Answered ${body.changed ?? ids.length} stops.`); + state.batch = []; + } + await load(); +} + +function choices(card) { + if (card.kind === "approve_save") { + return el("div", { className: "stops-viewonly" }, [ + el("p", { text: "Answer this save on the Decisions page. It shows the exact form and signs your answer." }), + el("a", { className: "stops-button", text: "Open Decisions", attrs: { href: "/" } }), + ]); + } + if (!PHONE_KINDS.has(card.kind)) { + return el("div", { className: "stops-viewonly" }, [ + el("p", { text: "Answer this on the computer." }), + ]); + } + return el( + "div", + { className: "stops-choices" }, + card.choices.map((choice, i) => { + const button = el("button", { className: i === 0 ? "stops-choice primary" : "stops-choice", attrs: { type: "button" } }, [ + el("span", { className: "stops-choice-label", text: choice.label }), + el("span", { className: "stops-choice-then", text: choice.consequence }), + ]); + button.disabled = state.busy || !state.keyReady; + button.addEventListener("click", () => answer(choice.id, [card.card_id])); + return button; + }), + ); +} + +function batchBlock(card) { + const group = similar(card); + if (group.length < 2) return null; + const section = el("section", { className: "stops-batch", attrs: { "aria-label": "Similar stops" } }, [ + el("h2", { text: `${group.length} similar stops${state.batch.length ? ` · ${state.batch.length} picked` : ""}` }), + el("p", { + className: "stops-sub", + text: "Check each one before you pick it. A batch gets one answer, and each stop is still checked on its own.", + }), + ]); + const list = el("ul", {}); + for (const other of group.slice(0, MAX_BATCH)) { + const box = el("input", { attrs: { type: "checkbox" } }); + box.checked = state.batch.includes(other.card_id); + box.addEventListener("change", () => { + state.armed = null; + state.batch = box.checked + ? [...state.batch, other.card_id] + : state.batch.filter((id) => id !== other.card_id); + if (!state.shownAt.has(other.card_id)) state.shownAt.set(other.card_id, Date.now()); + render(); + }); + const values = (other.evidence.source || []).slice(0, 3).map((row) => row.value).join(", "); + list.appendChild( + el("li", {}, [ + el("label", { className: "stops-pick" }, [ + box, + el("span", { text: `${other.label || other.source.id}: ${values}` }), + ]), + ]), + ); + } + section.appendChild(list); + if (state.batch.length > 1) { + const actions = el("div", { className: "stops-choices" }); + for (const choice of card.choices.filter((c) => c.id !== "leave")) { + const armed = state.armed === choice.id; + const button = el("button", { className: armed ? "stops-choice primary" : "stops-choice", attrs: { type: "button" } }, [ + el("span", { + className: "stops-choice-label", + text: armed ? `Tap again: ${choice.label} for ${state.batch.length} stops` : `${choice.label} (${state.batch.length})`, + }), + el("span", { className: "stops-choice-then", text: choice.consequence }), + ]); + button.disabled = state.busy || !state.keyReady; + button.addEventListener("click", () => { + if (state.armed === choice.id) { + answer(choice.id, [...state.batch]); + } else { + state.armed = choice.id; + render(); + } + }); + actions.appendChild(button); + } + section.appendChild(actions); + } + return section; +} + +// ---------------------------------------------------------------- render + +function render() { + const main = $("main"); + const card = current(); + if (!card) { + main.replaceChildren( + el("section", { className: "stops-card" }, [ + el("h1", { className: "stops-q", text: "Nothing is waiting for you" }), + el("p", { className: "stops-sub", text: "New stops show up here on their own." }), + ]), + ); + return; + } + if (!state.shownAt.has(card.card_id)) state.shownAt.set(card.card_id, Date.now()); + const where = [`${state.index + 1} of ${state.cards.length}`, card.task_name, card.label] + .filter(Boolean) + .join(" · "); + const nav = el("nav", { className: "stops-nav" }); + const prev = el("button", { className: "stops-nav-btn", text: "Previous", attrs: { type: "button" } }); + prev.disabled = state.index <= 0; + prev.addEventListener("click", () => { + state.index = Math.max(0, state.index - 1); + render(); + }); + const next = el("button", { className: "stops-nav-btn", text: "Next", attrs: { type: "button" } }); + next.disabled = state.index >= state.cards.length - 1; + next.addEventListener("click", () => { + state.index = Math.min(state.cards.length - 1, state.index + 1); + render(); + }); + nav.append(prev, next); + main.replaceChildren( + el("article", { className: "stops-card", attrs: { "aria-labelledby": "stops-q" } }, [ + el("p", { className: "stops-where", text: where }), + el("h1", { className: "stops-q", text: card.question, attrs: { id: "stops-q" } }), + el("p", { className: "stops-sub", text: card.summary }), + state.keyReady || !PHONE_KINDS.has(card.kind) + ? null + : el("p", { + className: "stops-warn", + text: "Answers from this phone need the decision portal's approver key. Set it up on the computer. Until then, nothing here changes.", + }), + evidence(card), + choices(card), + batchBlock(card), + ]), + nav, + ); + loadShot(card); +} + +load(); +setInterval(() => { + if (!state.busy && !state.armed) load({ quiet: true }); +}, POLL_MS); diff --git a/packages/desktop/openadapt_desktop/portal/shell/styles.css b/packages/desktop/openadapt_desktop/portal/shell/styles.css index 811085b2f..9190c0ce9 100644 --- a/packages/desktop/openadapt_desktop/portal/shell/styles.css +++ b/packages/desktop/openadapt_desktop/portal/shell/styles.css @@ -87,6 +87,7 @@ button { font: inherit; color: inherit; } } .top { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 36px; flex: none; } +.top-link { color: var(--accent); font-size: 14px; text-decoration: none; min-height: 44px; display: inline-flex; align-items: center; margin-left: auto; } .brand { display: flex; align-items: center; gap: 9px; font-weight: 650; font-size: 15px; letter-spacing: -0.01em; } .mark { width: 18px; height: 18px; border-radius: 6px; background: var(--text); position: relative; } .mark::after { content: ""; position: absolute; inset: 5px; border-radius: 2px; background: var(--bg); } diff --git a/packages/desktop/openadapt_desktop/portal/stops.py b/packages/desktop/openadapt_desktop/portal/stops.py new file mode 100644 index 000000000..9c961d7c4 --- /dev/null +++ b/packages/desktop/openadapt_desktop/portal/stops.py @@ -0,0 +1,114 @@ +"""The stop-review queue on the paired phone. + +The portal serves Desktop's stop cards (:mod:`openadapt_desktop.stop_queue`) +to a paired phone and passes the phone's answers back. Like the rest of the +portal it decides nothing: the dispatcher rebuilds every card before it acts, +and a card that changed, expired or is gone, or a choice the card doesn't +offer, changes nothing. + +The phone can't reach Touch ID on this computer, so its answers are signed +with the decision portal's approver key (route ``portal``), and the receipt +names that key and the device session. Only this adapter, in this process, +can ask the dispatcher for that signature; no command on any wire can. + +Two kinds of card are view-only here: + +* ``approve_save``: a waiting save. The phone answers it through the portal's + existing save approval, which the Flow console signs with the same key. +* ``rearm_problems_only``: turning "only ask about problems" back on changes + how every later save is approved, so it is answered on the computer. +""" + +from __future__ import annotations + +from typing import Any, Protocol + +#: How long a phone's answer waits for a command the window started (a run, +#: for instance) before it gives up and changes nothing. +ANSWER_LOCK_TIMEOUT_S = 2.0 + + +class StopQueueBackend(Protocol): + """What the portal needs from the stop queue (faked in tests).""" + + def list_cards(self) -> dict[str, Any]: ... + + def screenshot(self, card_id: str) -> bytes | None: ... + + def answer( + self, + card_ids: list[str], + choice: str, + *, + shown_ms: Any, + device: dict[str, str], + ) -> dict[str, Any]: ... + + +class DispatcherStopQueue: + """The stop queue backed by the engine dispatcher in this process.""" + + def __init__(self, dispatcher: Any) -> None: + self._dispatcher = dispatcher + + def list_cards(self) -> dict[str, Any]: + from openadapt_desktop.approver_key import approver_key_environment + + listed = self._dispatcher.list_stop_cards() + if not listed.get("ok"): + return {"ok": False, "error": listed.get("error") or "No stops to show."} + return { + "ok": True, + "cards": listed["cards"], + # Whether the phone's answers can be signed. Never the key. + "portal_key": {"ready": approver_key_environment("portal") is not None}, + } + + def _runs_again(self, card_ids: list[str], choice: str) -> bool: + from openadapt_desktop import stop_queue + + cards = {c["card_id"]: c for c in self._dispatcher.list_stop_cards().get("cards", [])} + for card_id in card_ids: + card = cards.get(card_id) + found = stop_queue.choice_for(card, choice) if card else None + # Unknown card or choice: take the lock; the dispatcher refuses it. + if found is None or found.action in {"retry", "retry_after_check"}: + return True + return False + + def screenshot(self, card_id: str) -> bytes | None: + return self._dispatcher.stop_card_screenshot(card_id) + + def answer( + self, + card_ids: list[str], + choice: str, + *, + shown_ms: Any, + device: dict[str, str], + ) -> dict[str, Any]: + # Only an answer that runs an item again can overlap a run the window + # started, so only that waits for the window's lock. Closing a stop + # (skip, done by hand, saved) goes through while a run waits. + lock = ( + getattr(self._dispatcher, "serial_lock", None) + if self._runs_again(card_ids, choice) + else None + ) + if lock is not None and not lock.acquire(timeout=ANSWER_LOCK_TIMEOUT_S): + return { + "ok": False, + "code": "busy", + "error": "OpenAdapt is busy on the computer. Try again in a moment. " + "Nothing changed.", + } + try: + return self._dispatcher.answer_stop_cards_from_portal( + card_ids=card_ids, choice=choice, shown_ms=shown_ms, device=device + ) + finally: + if lock is not None: + lock.release() + + +__all__ = ["ANSWER_LOCK_TIMEOUT_S", "DispatcherStopQueue", "StopQueueBackend"] diff --git a/packages/desktop/openadapt_desktop/private_flow_config.py b/packages/desktop/openadapt_desktop/private_flow_config.py index 5bef5ab42..e3be17864 100644 --- a/packages/desktop/openadapt_desktop/private_flow_config.py +++ b/packages/desktop/openadapt_desktop/private_flow_config.py @@ -229,6 +229,9 @@ def collect(value: object, key: str = "") -> None: def prepare_flow_config( source: Path | None, target: ExecutionTarget | None, + *, + approver_root: str | None = None, + approver_head: tuple[int, str] | None = None, ) -> PreparedPrivateYaml | None: """Parse, merge, serialize, and derive redactions from one snapshot. @@ -241,6 +244,31 @@ def prepare_flow_config( if source is None and target is None: return None deployment = _merged_config(source, target) + approvers = deployment.get("approvers") + if approvers is not None and not isinstance(approvers, Mapping): + raise PrivateFlowConfigError("The deployment config's approvers section is invalid.") + if not approver_root: + # Without this Mac's Keychain pin there is no trusted root. The base + # config is a file the agent's user can write, so none of its + # approvers section reaches Flow (a section without a root is also + # unreadable to Flow, which would refuse the whole run). Flow runs + # normally and refuses only a person's answer (approver_root_unpinned). + deployment.pop("approvers", None) + if approver_root: + # A governed run takes its approver pin only from its deployment + # config: write this Mac's pin into the snapshot Flow receives. + existing = (approvers or {}).get("root_public_key") + if existing and str(existing).strip().lower() != approver_root: + raise PrivateFlowConfigError( + "The deployment config pins another approver root than this Mac. " + "Nothing was run." + ) + section = {**dict(approvers or {}), "root_public_key": approver_root} + if approver_head is not None: + # The roster head this Mac's Keychain recorded: Flow refuses a + # roster that doesn't extend it. + section["head"] = [int(approver_head[0]), str(approver_head[1])] + deployment["approvers"] = section remote_decisions, remote_decision_runner_id = _remote_decision_settings(deployment) return PreparedPrivateYaml( payload=yaml.safe_dump(deployment, sort_keys=False), @@ -373,7 +401,9 @@ def stage_private_text( os.fchmod(fd, 0o600) except (AttributeError, OSError): # pragma: no cover - Windows fallback os.chmod(path, 0o600) - with os.fdopen(fd, "w", encoding="utf-8") as handle: + # No newline translation: the bytes on disk are exactly + # payload.encode("utf-8"), so the digest Desktop passes matches. + with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle: fd = -1 handle.write(payload) handle.flush() diff --git a/packages/desktop/openadapt_desktop/private_fs.py b/packages/desktop/openadapt_desktop/private_fs.py index 5779cafb2..7f062875d 100644 --- a/packages/desktop/openadapt_desktop/private_fs.py +++ b/packages/desktop/openadapt_desktop/private_fs.py @@ -45,6 +45,9 @@ def ensure_private_dir(path: Path, *, root: Path | None = None) -> Path: path = Path(path) path.mkdir(parents=True, exist_ok=True, mode=PRIVATE_DIR_MODE) + if path.is_symlink() or not path.is_dir(): + # A symlink here could send owner-only files somewhere else. + raise OSError(f"{path} is not a plain folder; refusing to use it") if not _posix(): return path chain = [path] diff --git a/packages/desktop/openadapt_desktop/stop_queue.py b/packages/desktop/openadapt_desktop/stop_queue.py new file mode 100644 index 000000000..f51438331 --- /dev/null +++ b/packages/desktop/openadapt_desktop/stop_queue.py @@ -0,0 +1,558 @@ +"""The stop-review queue: every stop as one card a person answers in one tap. + +A run that stops for a person used to mean minutes: open the item, open the +app, look the record up, compare, then pick an action. A card puts the +question, the evidence side by side (the item's own values next to the record +or the form, with the differences marked) and the choices with what each one +does, in one place. + +The queue presents and collects; it never decides. Each card's id is a digest +of everything it shows. An answer names the card ids the person saw, and the +dispatcher rebuilds the cards from the stores before it acts: a card that +changed, expired or is gone, or a choice the card doesn't offer, changes +nothing. + +Cards come from three places: + +* ``approve_save``: a run waiting before a save (Flow's ``each_item`` gate). + The answer goes to Flow's ``item-approval`` with Desktop's approver key + (Touch ID on a Mac), as Run it always has. +* ``same_record``, ``confirm_save``, ``halted``: stopped items in Needs you. + The answer is the item's existing action (skip, done by hand, confirmed + saved, run again, ...), with the same evidence checks. Desktop signs a + decision receipt over the card ids and the choice with the approver key + first, so every answer names a registered approver. +* ``rearm_problems_only``: a task whose "only ask about problems" switch went + off, when a signed re-arm is available. + +Similar cards share a ``batch_key``. A batch is answered with one choice and +one signature, and each card in it is still checked on its own. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Iterable + +CARD_SCHEMA = "openadapt.desktop-stop-card/v1" +DECISION_SCHEMA = "openadapt.desktop-stop-decision/v1" +#: What the approver key signs for a queue answer (other than a save +#: approval, which Flow signs as its own answer). +DECISION_DOMAIN = b"openadapt-desktop.stop-decision/v1\x00" +#: The most cards one batch answer covers. +MAX_BATCH = 25 + +#: A duplicate check that found a possible match, as Flow words the failure. +_DUPLICATE_MARKERS = ("no_duplicate_candidate", "possible duplicate", "duplicate") + + +@dataclass(frozen=True) +class Choice: + id: str + label: str + #: What happens, in the words the card shows next to the button. + consequence: str + #: The work-item action, the approval decision, or "none" (changes nothing). + action: str + #: The decline reason an approval choice carries. + reason: str | None = None + + def public(self) -> dict[str, Any]: + return { + "id": self.id, + "label": self.label, + "consequence": self.consequence, + } + + +def _canonical(value: Any) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _digest(value: Any) -> str: + return "sha256:" + hashlib.sha256(_canonical(value)).hexdigest() + + +def _norm(value: Any) -> str: + return " ".join(str(value or "").split()).casefold() + + +def _label(name: str) -> str: + text = name.replace("_", " ").strip() + return text[:1].upper() + text[1:] + + +# -- choices ------------------------------------------------------------------------- + +_LEAVE = Choice( + "leave", + "Not sure, leave it", + "Nothing changes. The stop stays in the queue.", + "none", +) + +_ITEM_CHOICES: dict[str, dict[str, Choice]] = { + "same_record": { + "skip": Choice( + "same", + "Same record: don't add it", + "Nothing is saved. The item closes as skipped.", + "skip", + ), + "done_by_person": Choice( + "different_by_hand", + "Different record: I entered it by hand", + "The item closes as done by a person. OpenAdapt saves nothing.", + "done_by_person", + ), + "decline": Choice( + "decline", + "This item shouldn't be done", + "Nothing is saved. The item closes as declined.", + "decline", + ), + }, + "confirm_save": { + "confirmed_saved": Choice( + "saved", + "I checked: it saved correctly", + "The item closes as saved.", + "confirmed_saved", + ), + "retry_after_check": Choice( + "not_saved_retry", + "I checked: nothing saved, run it again", + "OpenAdapt runs the item again, with every check.", + "retry_after_check", + ), + "done_by_person": Choice( + "fixed_by_hand", + "I fixed it by hand", + "The item closes as done by a person.", + "done_by_person", + ), + }, + "halted": { + "retry": Choice( + "retry", + "Run it again", + "OpenAdapt runs the item again, with every check. Nothing was saved.", + "retry", + ), + "skip": Choice( + "skip", + "Skip it", + "Nothing is saved. The item closes as skipped.", + "skip", + ), + "decline": Choice( + "decline", + "This item shouldn't be done", + "Nothing is saved. The item closes as declined.", + "decline", + ), + "done_by_person": Choice( + "by_hand", + "I did it by hand", + "The item closes as done by a person.", + "done_by_person", + ), + }, +} + +_APPROVAL_CHOICES = ( + Choice( + "approve", + "Approve and save", + "OpenAdapt checks the form again, saves, then checks the record.", + "approve", + ), + Choice( + "wrong_values", + "Don't save: wrong values", + "Nothing is saved. The item stops for you.", + "decline", + "wrong_values", + ), + Choice( + "wrong_record", + "Don't save: wrong record", + "Nothing is saved. The item stops for you.", + "decline", + "wrong_record", + ), + Choice( + "screen_problem", + "Don't save: something's wrong on screen", + "Nothing is saved. The item stops for you.", + "decline", + "screen_problem", + ), +) + +#: Actions that also mean "I looked at the record": a card offers them only +#: as a statement of what the person found there. +RECORD_CHECK_ACTIONS = frozenset({"confirmed_saved", "retry_after_check", "done_by_person"}) + + +# -- cards --------------------------------------------------------------------------- + + +def _is_duplicate_stop(item: dict) -> bool: + reason = _norm(item.get("halt_reason")) + return item.get("halt_kind") == "wrong_record" or any( + marker in reason for marker in _DUPLICATE_MARKERS + ) + + +def item_kind(item: dict) -> str | None: + """Which card an open Needs-you item becomes, or None.""" + + actions = item.get("actions") or [] + if not actions or item.get("source") not in {None, "item"}: + return None + if not item.get("nothing_written"): + return "confirm_save" + if _is_duplicate_stop(item): + return "same_record" + return "halted" + + +def _differences(source: list[dict], record: list[dict]) -> list[dict]: + on_file = {row["field"]: row["value"] for row in record} + out = [] + for row in source: + if row["field"] not in on_file: + continue + same = _norm(row["value"]) == _norm(on_file[row["field"]]) + out.append( + { + "field": row["field"], + "label": _label(row["field"]), + "source": row["value"], + "record": on_file[row["field"]], + "same": same, + } + ) + return out + + +def _candidate(row: Any, source: list[dict]) -> dict[str, Any]: + """One possible match: its text, and per field whether it equals the item. + + A row Flow read as plain text has no fields; the card shows its text. + """ + + if not isinstance(row, dict): + return {"text": str(row)[:300], "fields": []} + values = {str(k): str(v) for k, v in row.items() if v is not None} + fields = [ + { + "field": entry["field"], + "label": entry["label"], + "value": values[entry["field"]], + "same": _norm(values[entry["field"]]) == _norm(entry["value"]), + } + for entry in source + if entry["field"] in values + ] + text = ", ".join(f["value"] for f in fields) or ", ".join(values.values()) + return {"text": text[:300], "fields": fields} + + +def _item_evidence(item: dict, kind: str) -> dict[str, Any]: + inputs = item.get("inputs") if isinstance(item.get("inputs"), dict) else {} + source = [ + {"field": str(k), "label": _label(str(k)), "value": str(v)} + for k, v in sorted(inputs.items()) + ] + record: list[dict] = [] + for diff in item.get("field_diffs") or []: + record.append({"field": diff["field"], "value": diff.get("on_file", "")}) + if not any(row["field"] == diff["field"] for row in source): + source.append( + { + "field": diff["field"], + "label": _label(diff["field"]), + "value": diff.get("typed", ""), + } + ) + candidates = item.get("candidates") if isinstance(item.get("candidates"), list) else [] + evidence: dict[str, Any] = { + "source": source, + "record": record, + "differences": _differences(source, record), + # Rows the app listed as possible matches, when Flow recorded them. + "candidates": [_candidate(row, source) for row in candidates[:10]], + "reason": str(item.get("halt_reason") or "")[:500], + "step": str(item.get("halt_step") or "")[:300], + "expected": str(item.get("expected") or "")[:300], + "observed": str(item.get("observed") or "")[:300], + "screenshot": {"item_id": item["item_id"]} if item.get("run_id") else None, + } + return evidence + + +_QUESTIONS = { + "same_record": "Same record?", + "confirm_save": "Did it save?", + "halted": "What should happen to this item?", +} + +_SUMMARIES = { + "same_record": ( + "OpenAdapt stopped before saving: the app may already have this record. " + "Compare the item with what the app shows." + ), + "confirm_save": ( + "The save may have gone through, but OpenAdapt couldn't confirm it. " + "Look at the record, then say what you found." + ), + "halted": "OpenAdapt stopped before saving. Nothing was written.", +} + + +def _seal(card: dict[str, Any]) -> dict[str, Any]: + body = {k: v for k, v in card.items() if k != "card_id"} + return {**body, "card_id": _digest(body)} + + +def card_for_item(item: dict) -> dict[str, Any] | None: + """One Needs-you item as a card, or None when it isn't a stop.""" + + kind = item_kind(item) + if kind is None: + return None + table = _ITEM_CHOICES[kind] + choices = [table[a] for a in item["actions"] if a in table] + if not choices: + return None + evidence = _item_evidence(item, kind) + question = _QUESTIONS[kind] + if kind == "same_record" and any( + "dob" in row["field"].lower() or "name" in row["field"].lower() + for row in evidence["source"] + ): + question = "Same person?" + public_choices = [c.public() for c in [*choices, _LEAVE]] + if question == "Same person?": + for choice in public_choices: + choice["label"] = choice["label"].replace(" record", " person") + card = { + "schema": CARD_SCHEMA, + "kind": kind, + "source": {"type": "item", "id": item["item_id"], "run_id": item.get("run_id")}, + "workflow_id": item.get("workflow_id") or "", + "task_name": item.get("task_name") or "", + "label": item.get("label") or "", + "question": question, + "summary": _SUMMARIES[kind], + "evidence": evidence, + "choices": public_choices, + "expires_at": None, + "created_at": item.get("created_at"), + "batch_key": f"{kind}:{item.get('workflow_id') or ''}:{_reason_class(item)}", + # The status the card was built from: an answer after it changed + # finds a different card id and does nothing. + "status": item.get("status"), + } + return _seal(card) + + +def _reason_class(item: dict) -> str: + if _is_duplicate_stop(item): + return "duplicate" + return str(item.get("halt_kind") or "other") + + +def card_for_approval(state: dict) -> dict[str, Any] | None: + """A run waiting before a save (``get_item_approval``) as a card.""" + + request = state.get("request") if isinstance(state, dict) else None + if not state.get("waiting") or not isinstance(request, dict): + return None + source = [ + { + "field": str(row.get("name")), + "label": _label(str(row.get("name"))), + "value": str(row.get("value") or ""), + } + for row in request.get("values") or [] + if isinstance(row, dict) + ] + can_approve = bool(request.get("screenshot")) + choices = [c for c in _APPROVAL_CHOICES if can_approve or c.action != "approve"] + card = { + "schema": CARD_SCHEMA, + "kind": "approve_save", + "source": { + "type": "approval", + "id": request.get("request_id"), + "request_sha256": request.get("request_sha256"), + "workflow_id": state.get("workflow_id"), + }, + "workflow_id": state.get("workflow_id") or "", + "task_name": state.get("task_name") or "", + "label": "Practice item" if state.get("kind") == "practice" else "", + "question": "Approve this save?", + "summary": ( + "OpenAdapt filled in the form and stopped before saving. Compare the " + "item's values with the form." + ), + "evidence": { + "source": source, + "record": [], + "differences": [], + "candidates": [], + "reason": "", + "step": str(request.get("step_intent") or "")[:300], + "expected": "", + "observed": "", + # The form about to be saved: the dispatcher's approval screenshot. + "screenshot": {"approval": request.get("request_id")} if can_approve else None, + }, + "choices": [c.public() for c in [*choices, _LEAVE]], + "expires_at": request.get("answer_by"), + "created_at": None, + # One run waits at a time; save approvals are answered one by one, + # each with its own signature. + "batch_key": None, + "status": "waiting", + } + return _seal(card) + + +def card_for_rearm(switch: dict) -> dict[str, Any] | None: + """A task whose problems-only switch went off, when Flow offers a re-arm. + + ``switch["rearm"]`` is Flow's offer (``approval_mode.rearm_offer``): the + exact request the person's key signs, plus the clean items by name. The + card shows all of it, and its id covers the request, so a re-arm can only + sign what the person saw. + """ + + offer = switch.get("rearm") + if switch.get("state") != "off" or not isinstance(offer, dict): + return None + request = offer["request"] + clean = [str(name) for name in offer.get("clean_items") or []] + card = { + "schema": CARD_SCHEMA, + "kind": "rearm_problems_only", + "source": {"type": "switch", "id": switch["workflow_id"], "request": request}, + "workflow_id": switch["workflow_id"], + "task_name": switch.get("task_name") or "", + "label": "", + "question": 'Turn "only ask about problems" back on?', + "summary": ( + f"It went off: {request['cause']} Since then, the last " + f"{len(request['clean_run_refs'])} items were saved and checked in a " + "row. Turn it back on only if the reason it went off is fixed." + ), + "evidence": { + "source": [ + {"field": "cause", "label": "Why it went off", "value": request["cause"]}, + {"field": "off_at", "label": "When", "value": str(offer.get("off_at") or "")}, + { + "field": "clean_items", + "label": "Checked items since", + "value": ", ".join(clean) or f"{len(request['clean_run_refs'])} items", + }, + ], + "record": [], + "differences": [], + "candidates": [], + "reason": str(request.get("off_reason") or ""), + "step": str(switch.get("off_step") or ""), + "expected": "", + "observed": "", + "screenshot": None, + }, + "choices": [ + Choice( + "rearm", + "Turn it back on", + "Saves without a problem go through without asking. Any problem " + "still comes to you, and turns it off again.", + "rearm", + ).public(), + _LEAVE.public(), + ], + "expires_at": None, + "created_at": offer.get("off_at"), + "batch_key": None, + "status": "off", + } + return _seal(card) + + +def choice_for(card: dict, choice_id: str) -> Choice | None: + """The full choice behind a card's button, or None if it doesn't offer it.""" + + if choice_id not in {c["id"] for c in card.get("choices") or []}: + return None + if choice_id == _LEAVE.id: + return _LEAVE + if card["kind"] == "approve_save": + return next((c for c in _APPROVAL_CHOICES if c.id == choice_id), None) + if card["kind"] == "rearm_problems_only": + return Choice("rearm", "", "", "rearm") if choice_id == "rearm" else None + table = _ITEM_CHOICES.get(card["kind"], {}) + return next((c for c in table.values() if c.id == choice_id), None) + + +def expired(card: dict, *, now: datetime | None = None) -> bool: + deadline = card.get("expires_at") + if not deadline: + return False + try: + at = datetime.fromisoformat(str(deadline).replace("Z", "+00:00")) + except ValueError: + return True # unreadable deadline: treat as expired, change nothing + if at.tzinfo is None: + return True + return (now or datetime.now(timezone.utc)) >= at + + +def decision_message(card_ids: Iterable[str], choice_id: str, *, decided_at: str) -> bytes: + """The bytes the approver key signs for one (batch) answer.""" + + return DECISION_DOMAIN + _canonical( + { + "schema": DECISION_SCHEMA, + "card_ids": sorted(card_ids), + "choice": choice_id, + "decided_at": decided_at, + } + ) + + +def sort_cards(cards: list[dict]) -> list[dict]: + """Waiting saves first (they expire), then the oldest stop first.""" + + order = {"approve_save": 0, "confirm_save": 1, "same_record": 2, "halted": 3} + return sorted( + cards, + key=lambda c: (order.get(c["kind"], 4), str(c.get("created_at") or "")), + ) + + +__all__ = [ + "CARD_SCHEMA", + "DECISION_DOMAIN", + "DECISION_SCHEMA", + "MAX_BATCH", + "RECORD_CHECK_ACTIONS", + "Choice", + "card_for_approval", + "card_for_item", + "card_for_rearm", + "choice_for", + "decision_message", + "expired", + "item_kind", + "sort_cards", +] diff --git a/packages/desktop/openadapt_desktop/work_items.py b/packages/desktop/openadapt_desktop/work_items.py index a9c0defca..4ec8c2ecd 100644 --- a/packages/desktop/openadapt_desktop/work_items.py +++ b/packages/desktop/openadapt_desktop/work_items.py @@ -62,7 +62,7 @@ COMPLETED_STATUSES = frozenset({DONE, DONE_BY_PERSON, CONFIRMED_SAVED}) # The actions a person may take, by what the evidence proves about the save. -NO_EFFECT_ACTIONS = frozenset({"retry", "skip", "decline", "teach"}) +NO_EFFECT_ACTIONS = frozenset({"retry", "skip", "decline", "teach", "done_by_person"}) UNCERTAIN_ACTIONS = frozenset({"confirmed_saved", "retry_after_check", "done_by_person"}) # A save the record check confirmed that still went to a person (the whole # run wasn't clean). Running it again would enter it twice. @@ -246,7 +246,10 @@ def allowed_actions(item: dict) -> list[str]: # time; running it again asks again.) return ["skip", "decline", "done_by_person"] if item.get("nothing_written"): - return ["retry", "skip", "decline", "teach"] + # "I did it by hand" covers a stop the person resolves in the app + # (for example a possible duplicate that turned out to be a different + # person): running it again would stop at the same check. + return ["retry", "skip", "decline", "teach", "done_by_person"] if item.get("result") == DONE_CHECKED: return ["confirmed_saved", "done_by_person"] return ["confirmed_saved", "retry_after_check", "done_by_person"] diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 204b3a435..5d5a9c5ba 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "openadapt-desktop", - "version": "0.23.0", + "version": "0.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openadapt-desktop", - "version": "0.23.0", + "version": "0.24.0", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-notification": "^2", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 3c1aec50e..145193b87 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "openadapt-desktop", - "version": "0.23.0", + "version": "0.24.0", "description": "Installed companion for OpenAdapt authoring, teaching, and local pairing", "private": true, "type": "module", diff --git a/packages/desktop/pyproject.toml b/packages/desktop/pyproject.toml index 01b901e62..d14ced2aa 100644 --- a/packages/desktop/pyproject.toml +++ b/packages/desktop/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "openadapt-desktop" -version = "0.23.0" +version = "0.24.0" description = "The OpenAdapt desktop app and local engine: record a data-entry task, run it on a list, and check each saved record" readme = "README.md" requires-python = ">=3.11" diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index d495d97b3..fb9f3b2d8 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2502,7 +2502,7 @@ dependencies = [ [[package]] name = "openadapt-desktop" -version = "0.23.0" +version = "0.24.0" dependencies = [ "hex", "reqwest", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 54f9d4a73..8c7680a00 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openadapt-desktop" -version = "0.23.0" +version = "0.24.0" description = "Installed companion for OpenAdapt authoring, teaching, and local pairing" authors = ["OpenAdapt AI"] license = "MIT" diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 32cb9de7b..f4b491d58 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicoverbruggen/tauri-v2-schema/main/schema.json", "productName": "OpenAdapt Desktop", - "version": "0.23.0", + "version": "0.24.0", "identifier": "ai.openadapt.desktop", "build": { "frontendDist": "../dist", diff --git a/packages/desktop/src/App.tsx b/packages/desktop/src/App.tsx index 995e90d14..bb0bfbc9f 100644 --- a/packages/desktop/src/App.tsx +++ b/packages/desktop/src/App.tsx @@ -30,6 +30,7 @@ import { Onboarding } from "./screens/Onboarding"; import { Welcome } from "./screens/Welcome"; import { Tasks } from "./screens/Tasks"; import { NeedsYou } from "./screens/NeedsYou"; +import { Stops } from "./screens/Stops"; import { Results } from "./screens/Results"; import { CheckIt } from "./screens/CheckIt"; import { RunIt } from "./screens/RunIt"; @@ -55,6 +56,7 @@ import { type Route = | { name: "tasks" } | { name: "needs" } + | { name: "stops" } | { name: "results" } | { name: "sample" } | { name: "check"; id: string; firstWorkflow?: boolean } @@ -111,6 +113,7 @@ function tabFor(route: Route): "tasks" | "needs" | "results" | null { case "teach": return "tasks"; case "needs": + case "stops": return "needs"; case "results": return "results"; @@ -928,6 +931,15 @@ export default function App() { setRoute({ name: "teach", id })} onChanged={() => void refreshAttention()} + onReviewStops={() => setRoute({ name: "stops" })} + /> + )} + {route.name === "stops" && ( + { + void refreshAttention(); + setRoute({ name: "needs" }); + }} /> )} {route.name === "results" && ( diff --git a/packages/desktop/src/lib/engine.ts b/packages/desktop/src/lib/engine.ts index 3b262849f..2a03af3b7 100644 --- a/packages/desktop/src/lib/engine.ts +++ b/packages/desktop/src/lib/engine.ts @@ -49,6 +49,15 @@ export const CMD = { // and answer it (they're served while the run is still going). GET_ITEM_APPROVAL: "get_item_approval", ANSWER_ITEM_APPROVAL: "answer_item_approval", + // The approver key that signs those answers (Touch ID on a Mac). Served + // while a run waits, so a person can set it up from the approval screen. + // The stop-review queue: every stop as one card (stop_queue.py). + LIST_STOP_CARDS: "list_stop_cards", + ANSWER_STOP_CARDS: "answer_stop_cards", + ANSWER_STOP_APPROVAL: "answer_stop_approval", + GET_APPROVER_KEY: "get_approver_key", + SET_UP_APPROVER_KEY: "set_up_approver_key", + REMOVE_APPROVER_KEY: "remove_approver_key", // "Only ask a person about problems": read the switch and turn it off. // Turning it on happens in OpenAdapt Cloud, never here. GET_APPROVAL_MODE: "get_approval_mode", diff --git a/packages/desktop/src/lib/types.ts b/packages/desktop/src/lib/types.ts index 1a9e4f5e8..d4a953d55 100644 --- a/packages/desktop/src/lib/types.ts +++ b/packages/desktop/src/lib/types.ts @@ -1214,4 +1214,71 @@ export interface ItemApprovalAnswer { decision?: "approved" | "declined"; error?: string; code?: string; + /** Where the approver key is set up after this refusal. */ + setup?: "in_app" | "terminal"; +} + +/** This computer's approver key. Never the key itself. */ +export interface ApproverKeyStatus { + ok: boolean; + error?: string; + code?: string; + /** The app can keep the key behind Touch ID (a Mac with a Secure Enclave). */ + supported?: boolean; + /** Stored and registered: approvals from this computer can count. */ + set_up?: boolean; + stored?: boolean; + registered?: boolean; + principal?: string | null; + fingerprint?: string | null; + /** The terminal steps, where the app can't set the key up. */ + instructions?: string; +} + +/** One stop in the review queue (openadapt.desktop-stop-card/v1). */ +export interface StopCard { + schema: "openadapt.desktop-stop-card/v1"; + card_id: string; + kind: "approve_save" | "same_record" | "confirm_save" | "halted" | "rearm_problems_only"; + source: { type: "item" | "approval" | "switch"; id: string; [key: string]: unknown }; + workflow_id: string; + task_name: string; + label: string; + question: string; + summary: string; + evidence: { + source: { field: string; label: string; value: string }[]; + record: { field: string; value: string }[]; + differences: { field: string; label: string; source: string; record: string; same: boolean }[]; + /** Possible matches the app listed; fields say which values equal the item's. */ + candidates: { + text: string; + fields: { field: string; label: string; value: string; same: boolean }[]; + }[]; + reason: string; + step: string; + expected: string; + observed: string; + screenshot: { item_id: string } | { approval: string } | null; + }; + choices: { id: string; label: string; consequence: string }[]; + expires_at: string | null; + created_at: string | null; + batch_key: string | null; + status: string; +} + +export interface StopCardsResponse { + ok: boolean; + cards: StopCard[]; + error?: string; + approver_key?: { ready: boolean; in_app: boolean; touch_id: boolean }; +} + +export interface StopAnswer { + ok: boolean; + error?: string; + code?: string; + changed?: number; + results?: { card_id: string; ok: boolean; error?: string | null }[]; } diff --git a/packages/desktop/src/practice/main.tsx b/packages/desktop/src/practice/main.tsx new file mode 100644 index 000000000..0f77068ac --- /dev/null +++ b/packages/desktop/src/practice/main.tsx @@ -0,0 +1,161 @@ +// Stop queue practice: the real Review stops screen on recorded soak stops. +// +// Dev-only (not in the app bundle). Serve the package with `npx vite` and open +// /benchmark/stop_queue/practice.html. Cards come from the drill file that +// benchmark/stop_queue/build_drill.py writes. Nothing is signed or sent: each +// answer is kept in this page with the seconds its card was on screen. +// +// The score is accuracy only, with the hard cards called out (a possible +// match that is a different person, a save that went in wrong). Time is +// recorded for the measurement and never scored or ranked. +import React, { useMemo, useState } from "react"; +import ReactDOM from "react-dom/client"; +import "@openadapt/program-graph/styles.css"; +import "../styles/app.css"; +import { Stops, type StopBackend } from "../screens/Stops"; +import type { StopCard } from "../lib/types"; + +interface DrillStop { + item_id: string; + card: StopCard; + expected: string[]; + hard: boolean; + exception: string; +} + +interface Answer { + item_id: string; + kind: string; + choice: string; + expected: string[]; + correct: boolean; + hard: boolean; + shown_ms: number; + batch: number; + answered_at: string; +} + +const DRILL_URL = new URLSearchParams(location.search).get("drill") ?? + "/benchmark/stop_queue/drill-2026-10-24.json"; + +function Results({ answers, stops }: { answers: Answer[]; stops: DrillStop[] }) { + const correct = answers.filter((a) => a.correct).length; + const hard = answers.filter((a) => a.hard); + const caught = hard.filter((a) => a.correct).length; + const misses = answers.filter((a) => !a.correct); + const blob = useMemo( + () => + URL.createObjectURL( + new Blob( + [ + JSON.stringify( + { + schema: "openadapt.stop-queue-practice/v1", + drill: DRILL_URL, + finished_at: new Date().toISOString(), + answers, + }, + null, + 2, + ), + ], + { type: "application/json" }, + ), + ), + [answers], + ); + return ( +
+
+
+

+ {correct} of {stops.length} right +

+

+ You caught {caught} of {hard.length} hard stops: a possible match that was a + different person, or a save that went in wrong. +

+
+
+ {misses.length > 0 && ( +
+

To look at again

+
    + {misses.map((a) => ( +
  • + {a.item_id}: you chose "{a.choice}"; right was {a.expected.join(" or ")}. +
  • + ))} +
+
+ )} +

+ + Download the results + +

+

+ The file holds each answer and how long its card was on screen, for the + seconds-per-stop measurement. +

+
+ ); +} + +function Practice() { + const [stops, setStops] = useState(null); + const [answers, setAnswers] = useState([]); + const [error, setError] = useState(""); + + React.useEffect(() => { + fetch(DRILL_URL) + .then((r) => r.json()) + .then((drill) => setStops(drill.stops as DrillStop[])) + .catch((e) => setError(String(e))); + }, []); + + const backend = useMemo(() => { + if (!stops) return null; + const answered = new Set(); + const byCard = new Map(stops.map((s) => [s.card.card_id, s])); + return { + list: async () => ({ + ok: true, + cards: stops.filter((s) => !answered.has(s.card.card_id)).map((s) => s.card), + approver_key: { ready: true, in_app: true, touch_id: true }, + }), + answer: async ({ cardIds, choice, shownMs }) => { + if (choice === "leave") return { ok: true, changed: 0 }; + const now = new Date().toISOString(); + const recorded = cardIds.map((id) => { + const stop = byCard.get(id) as DrillStop; + answered.add(id); + return { + item_id: stop.item_id, + kind: stop.card.kind, + choice, + expected: stop.expected, + correct: stop.expected.includes(choice), + hard: stop.hard, + shown_ms: shownMs[id] ?? 0, + batch: cardIds.length, + answered_at: now, + }; + }); + setAnswers((current) => [...current, ...recorded]); + return { ok: true, changed: cardIds.length }; + }, + }; + }, [stops]); + + if (error) return

The drill didn't load: {error}

; + if (!stops || !backend) return

Loading the drill…

; + if (answers.length >= stops.length) return ; + return ; +} + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + , +); diff --git a/packages/desktop/src/screens/NeedsYou.tsx b/packages/desktop/src/screens/NeedsYou.tsx index 95df31537..ef2518d7f 100644 --- a/packages/desktop/src/screens/NeedsYou.tsx +++ b/packages/desktop/src/screens/NeedsYou.tsx @@ -43,8 +43,22 @@ interface Notice { const FIELD_ACRONYMS = new Set(["dob", "dos", "mrn", "ssn", "npi", "id", "icd", "cpt", "zip"]); +// Short field names apps often use, in the words a person reads. +const FIELD_NAMES: Record = { + fname: "First name", + lname: "Last name", + mname: "Middle name", + dob: "DOB", + phone_home: "Home phone", + postal_code: "ZIP", + state_label: "State", + country_code: "Country", +}; + /** "dob" reads "DOB", "first_name" reads "First name". */ export function fieldLabel(field: string): string { + const known = FIELD_NAMES[field.toLowerCase()]; + if (known) return known; const words = field.replace(/[_-]+/g, " ").trim(); if (!words) return field; return words @@ -277,9 +291,12 @@ function AttentionCard({ export function NeedsYou({ onTeach, onChanged, + onReviewStops, }: { onTeach: (workflowId: string) => void; onChanged: () => void; + /** Open the one-card-at-a-time queue (Review stops). */ + onReviewStops?: () => void; }) { const [items, setItems] = useState(null); const [switches, setSwitches] = useState([]); @@ -380,6 +397,13 @@ export function NeedsYou({ ? `With “${SWITCH_LABEL}” on, only problems come here. Items that passed every check were saved and read back from the record without you.` : "OpenAdapt stopped on these items and is waiting for a person. None of them runs again until you decide." } + action={ + onReviewStops && items && items.length > 0 ? ( + + ) : undefined + } /> {switchedOff.map((entry) => (
diff --git a/packages/desktop/src/screens/Settings.tsx b/packages/desktop/src/screens/Settings.tsx index adc848451..76d6d2b54 100644 --- a/packages/desktop/src/screens/Settings.tsx +++ b/packages/desktop/src/screens/Settings.tsx @@ -23,6 +23,7 @@ import { Pill, SegControl, } from "../ui/primitives"; +import { ApproverKeyCard } from "../ui/ApproverKey"; import { OsWarning } from "../ui/OsWarning"; import { ShadowModeCard } from "../ui/ShadowMode"; import { @@ -250,6 +251,8 @@ export function Settings({ + + { + const original = await importOriginal(); + return { ...original, engineTry: vi.fn(), engineInvoke: vi.fn(), onEngineEvent: vi.fn() }; +}); + +import { CMD, engineInvoke, engineTry } from "../lib/engine"; +import type { StopCard, StopCardsResponse } from "../lib/types"; +import { Stops } from "./Stops"; + +// Synthetic referral values. +function card(id: string, lname: string, overrides: Partial = {}): StopCard { + return { + schema: "openadapt.desktop-stop-card/v1", + card_id: `sha256:${id}`, + kind: "same_record", + source: { type: "item", id: `item-${id}` }, + workflow_id: "wf-ref", + task_name: "New patient referral", + label: `${lname} referral`, + question: "Same person?", + summary: "OpenAdapt stopped before saving: the app may already have this record.", + evidence: { + source: [ + { field: "lname", label: "Lname", value: lname }, + { field: "DOB", label: "DOB", value: "1941-02-27" }, + ], + record: [{ field: "DOB", value: "1941-07-22" }], + differences: [ + { field: "DOB", label: "DOB", source: "1941-02-27", record: "1941-07-22", same: false }, + ], + candidates: [{ text: `${lname}, Ana, 1941-07-22`, fields: [] }], + reason: "no_duplicate_candidate", + step: "", + expected: "", + observed: "", + screenshot: null, + }, + choices: [ + { id: "same", label: "Same record: don't add it", consequence: "Nothing is saved." }, + { id: "different_by_hand", label: "Different record: I entered it by hand", consequence: "Closes." }, + { id: "leave", label: "Not sure, leave it", consequence: "Nothing changes." }, + ], + expires_at: null, + created_at: null, + batch_key: "same_record:wf-ref:duplicate", + status: "open", + ...overrides, + }; +} + +function listing(cards: StopCard[], ready = true): StopCardsResponse { + return { ok: true, cards, approver_key: { ready, in_app: true, touch_id: ready } }; +} + +beforeEach(() => { + vi.mocked(engineTry).mockImplementation(async (cmd) => + cmd === CMD.LIST_STOP_CARDS ? listing([card("a", "Fairwick"), card("b", "Caldley")]) : null, + ); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +it("shows the question, the item next to the record, and numbered choices", async () => { + render(); + + expect(await screen.findByRole("heading", { name: "Same person?" })).toBeTruthy(); + expect(screen.getByText("1 of 2 · New patient referral · Fairwick referral")).toBeTruthy(); + expect(screen.getByText("Fairwick, Ana, 1941-07-22")).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Same record/ })?.className).not.toMatch( + /choice-primary/, + ); + // The differing value is marked, with the record's value beside it. + expect(screen.getByText("1941-07-22", { selector: "strong" })).toBeTruthy(); + expect(screen.getByRole("button", { name: /1\. Same record: don't add it/ })).toBeTruthy(); +}); + +it("answers the current stop with one key and sends how long it was shown", async () => { + vi.mocked(engineInvoke).mockResolvedValue({ ok: true, changed: 1 }); + render(); + await screen.findByRole("heading", { name: "Same person?" }); + + await act(async () => { + fireEvent.keyDown(window, { key: "1", code: "Digit1" }); + }); + + await waitFor(() => expect(engineInvoke).toHaveBeenCalled()); + const [cmd, params] = vi.mocked(engineInvoke).mock.calls[0]; + expect(cmd).toBe(CMD.ANSWER_STOP_CARDS); + expect(params).toMatchObject({ card_ids: ["sha256:a"], choice: "same" }); + expect(typeof (params as { shown_ms: Record }).shown_ms["sha256:a"]).toBe( + "number", + ); +}); + +it("answers a batch of similar stops with Shift and a number", async () => { + vi.mocked(engineInvoke).mockResolvedValue({ ok: true, changed: 2 }); + render(); + await screen.findByText("2 similar stops"); + + for (const box of screen.getAllByRole("checkbox")) fireEvent.click(box); + await act(async () => { + fireEvent.keyDown(window, { key: "!", code: "Digit1", shiftKey: true }); + }); + + await waitFor(() => expect(engineInvoke).toHaveBeenCalled()); + expect(vi.mocked(engineInvoke).mock.calls[0][1]).toMatchObject({ + card_ids: ["sha256:a", "sha256:b"], + choice: "same", + }); + expect(await screen.findByText("Answered 2 stops.")).toBeTruthy(); +}); + +it("says nothing changed when a stop changed before the answer", async () => { + vi.mocked(engineInvoke).mockResolvedValue({ + ok: false, + code: "card_changed", + error: "A stop changed or was answered since you saw it. Nothing changed; look at it again.", + }); + render(); + await screen.findByRole("heading", { name: "Same person?" }); + + fireEvent.click(screen.getByRole("button", { name: /Same record/ })); + + expect((await screen.findByRole("alert")).textContent).toMatch(/Nothing changed/); +}); + +it("routes a waiting save to the signed item approval", async () => { + vi.mocked(engineTry).mockImplementation(async (cmd) => + cmd === CMD.LIST_STOP_CARDS + ? listing([ + card("s", "Fairwick", { + kind: "approve_save", + question: "Approve this save?", + batch_key: null, + choices: [{ id: "approve", label: "Approve and save", consequence: "Saves." }], + }), + ]) + : null, + ); + vi.mocked(engineInvoke).mockResolvedValue({ ok: true, changed: 1 }); + render(); + await screen.findByRole("heading", { name: "Approve this save?" }); + + fireEvent.click(screen.getByRole("button", { name: /Approve and save/ })); + + await waitFor(() => expect(engineInvoke).toHaveBeenCalled()); + expect(vi.mocked(engineInvoke).mock.calls[0][0]).toBe(CMD.ANSWER_STOP_APPROVAL); + expect(vi.mocked(engineInvoke).mock.calls[0][1]).toMatchObject({ + card_id: "sha256:s", + choice: "approve", + }); +}); + +it("offers the approver key setup when answers can't be signed yet", async () => { + vi.mocked(engineTry).mockImplementation(async (cmd) => + cmd === CMD.LIST_STOP_CARDS ? listing([card("a", "Fairwick")], false) : null, + ); + render(); + + expect(await screen.findByRole("button", { name: "Set up with Touch ID" })).toBeTruthy(); +}); + +it("puts the item next to each possible match, field by field", async () => { + vi.mocked(engineTry).mockImplementation(async (cmd) => + cmd === CMD.LIST_STOP_CARDS + ? listing([ + card("c", "Fairwick", { + evidence: { + ...card("c", "Fairwick").evidence, + candidates: [ + { + text: "Fairwick, Ana, 1941-07-22", + fields: [ + { field: "lname", label: "Lname", value: "Fairwick", same: true }, + { field: "DOB", label: "DOB", value: "1941-07-22", same: false }, + ], + }, + ], + }, + }), + ]) + : null, + ); + render(); + + const table = await screen.findByRole("table"); + expect(table.textContent).toContain("Last name"); + expect(screen.getByText("Different").closest("td")?.className).toBe("stop-differs"); + expect(screen.getByText("Same").closest("td")?.className).toBe("stop-same"); +}); diff --git a/packages/desktop/src/screens/Stops.tsx b/packages/desktop/src/screens/Stops.tsx new file mode 100644 index 000000000..0575f0b3e --- /dev/null +++ b/packages/desktop/src/screens/Stops.tsx @@ -0,0 +1,519 @@ +// Review stops: every stop as one card, answered with one key or tap. +// +// Each card shows the question, the item's own values next to the record or +// the form (differences marked), and the choices with what each one does. The +// engine rebuilds every card before it acts: a card that changed, expired or +// doesn't offer the choice changes nothing. Answers are signed with this +// computer's approver key (Touch ID on a Mac; one prompt per batch). +// +// Keys: 1-9 answer the current card, J/K or the arrow keys move, X adds the +// card to a batch of similar stops, Shift+1-9 answers the whole batch, Esc +// clears the batch, ? shows the keys. +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { CMD, engineInvoke, engineTry } from "../lib/engine"; +import type { + AttentionScreenshot, + ItemApprovalState, + StopCard, + StopCardsResponse, + StopAnswer, +} from "../lib/types"; +import { ApproverKeySetup } from "../ui/ApproverKey"; +import { Callout } from "../ui/primitives"; +import { ChoiceButton, Icon, PageHead, ScreenCrop, TechnicalDetails } from "../ui/simple"; +import { fieldLabel } from "./NeedsYou"; + +const POLL_MS = 3000; + +interface Shot { + dataUrl: string; + width: number; + height: number; + caption: string; +} + +function useScreenshot(card: StopCard | null): Shot | null { + const [shot, setShot] = useState(null); + const ref = card?.evidence.screenshot ?? null; + const key = ref ? JSON.stringify(ref) : ""; + useEffect(() => { + let live = true; + setShot(null); + if (!ref) return; + void (async () => { + if ("item_id" in ref) { + const frame = await engineTry( + CMD.GET_ATTENTION_SCREENSHOT, + { item_id: ref.item_id }, + { ok: false }, + ); + if (live && frame.ok && frame.data_url) { + setShot({ + dataUrl: frame.data_url, + width: frame.width ?? 0, + height: frame.height ?? 0, + caption: "Where the run stopped", + }); + } + } else if (card) { + const state = await engineTry( + CMD.GET_ITEM_APPROVAL, + { workflow_id: card.workflow_id }, + null, + ); + const url = state?.request?.request_id === ref.approval ? state.request.screenshot : null; + if (live && url) { + setShot({ dataUrl: url, width: 0, height: 0, caption: "The form, before saving" }); + } + } + })(); + return () => { + live = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + return shot; +} + +function Screen({ card }: { card: StopCard }) { + const shot = useScreenshot(card); + if (!shot) { + return card.evidence.screenshot ?

Loading the screen…

: null; + } + return shot.width > 0 ? ( + + ) : ( + {shot.caption} + ); +} + +/** The item next to each possible match, one row per field, differences marked. */ +function Comparison({ card }: { card: StopCard }) { + const matches = card.evidence.candidates.filter((c) => c.fields.length); + const compared = new Set(matches.flatMap((c) => c.fields.map((f) => f.field))); + const rows = card.evidence.source.filter((row) => compared.has(row.field)); + const rest = card.evidence.source.filter((row) => !compared.has(row.field)); + return ( +
+ + + + + + {matches.map((_, i) => ( + + ))} + + + + {rows.map((row) => ( + + + + {matches.map((match, i) => { + const cell = match.fields.find((f) => f.field === row.field); + if (!cell) return + ); + })} + + ))} + +
FieldThe item + {matches.length > 1 ? `Possible match ${i + 1}` : "Possible match in the app"} +
{fieldLabel(row.field)}{row.value === "" ? Empty : row.value}; + return ( + + {cell.value === "" ? Empty : cell.value} + {cell.same ? "Same" : "Different"} +
+ {rest.length > 0 && ( +
+ The item's other values ({rest.length}) +
+ {rest.map((row) => ( +
+
{fieldLabel(row.field)}
+
{row.value === "" ? Left empty : row.value}
+
+ ))} +
+
+ )} +
+ ); +} + +function Evidence({ card }: { card: StopCard }) { + const structured = card.evidence.candidates.some((c) => c.fields.length); + const differences = new Map(card.evidence.differences.map((d) => [d.field, d])); + return ( +
+ {structured ? ( + + ) : ( +
+

The item

+ {card.evidence.source.length ? ( +
+ {card.evidence.source.map((row) => { + const diff = differences.get(row.field); + const differs = diff ? !diff.same : false; + return ( +
+
{fieldLabel(row.field)}
+
+ {row.value === "" ? Left empty : row.value} + {diff && ( + + {differs ? "Record: " : "Record matches: "} + {diff.record === "" ? "empty" : diff.record} + + )} +
+
+ ); + })} +
+ ) : ( +

No values to compare.

+ )} +
+ )} + {(!structured || card.evidence.screenshot) && ( +
+

{card.kind === "approve_save" ? "The form" : "What the app shows"}

+ {card.evidence.candidates + .filter((c) => !c.fields.length) + .map((c, i) => ( +

+ {c.text} +

+ ))} + +
+ )} + {card.evidence.reason && ( + +

{card.evidence.reason}

+
+ )} +
+ ); +} + +/** Where the queue reads cards and sends answers: the engine, or a practice set. */ +export interface StopBackend { + list(): Promise; + answer(request: { + cardIds: string[]; + choice: string; + shownMs: Record; + approval: boolean; + }): Promise; +} + +export const ENGINE_BACKEND: StopBackend = { + list: () => + engineTry(CMD.LIST_STOP_CARDS, {}, { ok: false, cards: [] }), + answer: ({ cardIds, choice, shownMs, approval }) => + approval + ? engineInvoke(CMD.ANSWER_STOP_APPROVAL, { + card_id: cardIds[0], + choice, + shown_ms: shownMs, + }) + : engineInvoke(CMD.ANSWER_STOP_CARDS, { + card_ids: cardIds, + choice, + shown_ms: shownMs, + }), +}; + +export function Stops({ + onDone, + backend = ENGINE_BACKEND, + title = "Review stops", +}: { + onDone?: () => void; + backend?: StopBackend; + title?: string; +}) { + const [cards, setCards] = useState(null); + const [keyReady, setKeyReady] = useState(null); + const [index, setIndex] = useState(0); + const [batch, setBatch] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [help, setHelp] = useState(false); + // When each card was first shown, for the seconds-per-stop measure. + const shownAt = useRef(new Map()); + + const refresh = useCallback(async () => { + const response = await backend.list(); + if (!response.ok) return; + setCards(response.cards); + setKeyReady(response.approver_key ?? null); + const ids = new Set(response.cards.map((c) => c.card_id)); + setBatch((current) => current.filter((id) => ids.has(id))); + }, [backend]); + + useEffect(() => { + void refresh(); + const timer = setInterval(() => void refresh(), POLL_MS); + return () => clearInterval(timer); + }, [refresh]); + + const list = useMemo(() => cards ?? [], [cards]); + const current = list[Math.min(index, Math.max(0, list.length - 1))] ?? null; + useEffect(() => { + if (current && !shownAt.current.has(current.card_id)) { + shownAt.current.set(current.card_id, Date.now()); + } + }, [current]); + + const similar = useMemo( + () => + current?.batch_key + ? list.filter((c) => c.batch_key === current.batch_key) + : [], + [list, current], + ); + + const answer = useCallback( + async (choiceId: string, ids: string[]) => { + if (busy || !ids.length) return; + const targets = list.filter((c) => ids.includes(c.card_id)); + if (targets.length !== ids.length) return; + setBusy(true); + setError(""); + setNotice(""); + const now = Date.now(); + const shown_ms = Object.fromEntries( + ids.map((id) => [id, now - (shownAt.current.get(id) ?? now)]), + ); + try { + const approval = targets.length === 1 && targets[0].kind === "approve_save"; + const response = await backend.answer({ + cardIds: ids, + choice: choiceId, + shownMs: shown_ms, + approval, + }); + if (!response.ok) { + setError(response.error || "Nothing changed."); + } else if (choiceId !== "leave") { + setNotice( + ids.length > 1 ? `Answered ${response.changed ?? ids.length} stops.` : "Answered.", + ); + setBatch([]); + } else { + setIndex((i) => i + 1); + } + } catch (failure) { + setError(`Nothing changed. (${String(failure)})`); + } finally { + setBusy(false); + await refresh(); + } + }, + [backend, busy, list, refresh], + ); + + useEffect(() => { + function onKey(event: KeyboardEvent) { + const target = event.target as HTMLElement | null; + if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return; + if (!current) return; + if (event.key === "?") { + setHelp((h) => !h); + return; + } + if (event.key === "j" || event.key === "ArrowDown") { + setIndex((i) => Math.min(i + 1, list.length - 1)); + return; + } + if (event.key === "k" || event.key === "ArrowUp") { + setIndex((i) => Math.max(i - 1, 0)); + return; + } + if (event.key === "Escape") { + setBatch([]); + return; + } + if (event.key === "x" && current.batch_key) { + setBatch((ids) => + ids.includes(current.card_id) + ? ids.filter((id) => id !== current.card_id) + : ids.length && list.find((c) => c.card_id === ids[0])?.batch_key !== current.batch_key + ? [current.card_id] + : [...ids, current.card_id], + ); + return; + } + // Shift+digit arrives as "!", "@" ... on most layouts; read the code. + const digit = /^Digit([1-9])$/.exec(event.code)?.[1]; + if (!digit) return; + const choice = current.choices[Number(digit) - 1]; + if (!choice) return; + event.preventDefault(); + if (event.shiftKey && batch.length) { + void answer(choice.id, batch); + } else { + void answer(choice.id, [current.card_id]); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [answer, batch, current, list]); + + if (cards === null) { + return ( +
+ +
+ ); + } + + return ( +
+ c.choices.length), + )}. Press ? for all keys.` + : "Nothing is waiting for you." + } + action={ + onDone ? ( + + ) : undefined + } + /> + {keyReady && !keyReady.ready && list.length > 0 && ( + keyReady.in_app ? ( + + ) : ( + + Set it up once in Settings > Approver key. Until then, nothing here changes. + + ) + )} + {help && ( + + 1-9 answer this stop. J or ↓ next, K or ↑ previous. X adds this stop to a batch + of similar stops; Shift+1-9 answers the batch with one Touch ID. Esc clears the + batch. + + )} + {notice && ( +

+ + {notice} +

+ )} + {error && ( +

+ + {error} +

+ )} + {current && ( +
+
+

+ {index + 1} of {list.length} + {current.task_name ? ` · ${current.task_name}` : ""} + {current.label ? ` · ${current.label}` : ""} +

+

{current.question}

+

{current.summary}

+
+ +
+ {current.choices.map((choice, i) => ( + void answer(choice.id, [current.card_id])} + /> + ))} +
+ {similar.length > 1 && ( +
+

+ {similar.length} similar stops + {batch.length ? ` · ${batch.length} in this batch` : ""} +

+

+ Check each one before you add it. A batch is answered with one choice and + one Touch ID, and each stop is still checked on its own. +

+
    + {similar.map((card) => ( +
  • + +
  • + ))} +
+ {batch.length > 1 && ( +
+ {current.choices + .filter((choice) => choice.id !== "leave") + .map((choice, i) => ( + + ))} +
+ )} +
+ )} +
+ )} +
+ ); +} diff --git a/packages/desktop/src/styles/app.css b/packages/desktop/src/styles/app.css index 818813dcd..61c2f3555 100644 --- a/packages/desktop/src/styles/app.css +++ b/packages/desktop/src/styles/app.css @@ -2704,6 +2704,16 @@ select.input:focus { color: var(--text-strong); margin-bottom: var(--space-2); } +/* The approver key's one-step setup, in place of Approve. */ +.approver-setup { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.approver-setup h3 { + margin: 0; + color: var(--text-strong); +} /* "Check the record": what was typed and what the record check read. */ .field-diffs { @@ -2777,3 +2787,83 @@ select.input:focus { .check-step-fold[open] > summary .check-step-state::after { content: "Hide"; } + +/* Review stops: one card, the item next to what the app shows. */ +.stops .stop-card { + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.stop-head h2 { + margin: 0; + color: var(--text-strong); +} +.stop-evidence { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: var(--space-3); +} +.stop-side { + border: 1px solid var(--border); + border-radius: var(--radius-1); + padding: var(--space-3); +} +.stop-side h3 { + margin: 0 0 var(--space-2); +} +.stop-side dl > div { + display: grid; + grid-template-columns: 9rem 1fr; + gap: var(--space-2); + padding: 2px 0; +} +.stop-side dt { + color: var(--text-muted); +} +.stop-differs { + background: color-mix(in srgb, var(--attention) 18%, transparent); + border-radius: var(--radius-1); +} +.stop-same { + background: color-mix(in srgb, var(--ok) 12%, transparent); + border-radius: var(--radius-1); +} +.stop-record-value { + display: block; + font-size: 0.9em; + color: var(--text-muted); +} +.stop-evidence .technical, +.stop-evidence .stop-side:only-of-type { + grid-column: 1 / -1; +} +.stop-compare { + width: 100%; + border-collapse: collapse; +} +.stop-compare th, +.stop-compare td { + text-align: left; + padding: var(--space-1) var(--space-2); + border-bottom: 1px solid var(--border); + vertical-align: top; +} +.stop-compare thead th { + color: var(--text-muted); + font-weight: var(--fw-semibold); +} +.stop-shot { + max-width: 100%; + border: 1px solid var(--border); + border-radius: var(--radius-1); +} +.stop-choices { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--space-2); +} +.stop-batch ul { + list-style: none; + margin: 0; + padding: 0; +} diff --git a/packages/desktop/src/ui/ApproverKey.tsx b/packages/desktop/src/ui/ApproverKey.tsx new file mode 100644 index 000000000..10105f4ae --- /dev/null +++ b/packages/desktop/src/ui/ApproverKey.tsx @@ -0,0 +1,162 @@ +// The approver key: what makes "Approve and save" count as a person's answer. +// +// On a Mac the engine keeps the key behind Touch ID (or the login password): +// setup is this one block and one prompt, and each approval prompts again. +// Elsewhere the key is set up once at a terminal, and this shows the steps. +// The key itself never reaches the window; only its status does. +import { useCallback, useEffect, useState } from "react"; +import { CMD, engineInvoke, engineTry } from "../lib/engine"; +import type { ApproverKeyStatus } from "../lib/types"; +import { Button, Card, CardHead, Callout } from "./primitives"; +import { Icon } from "./simple"; + +export function useApproverKey(): { + status: ApproverKeyStatus | null; + refresh: () => Promise; +} { + const [status, setStatus] = useState(null); + const refresh = useCallback(async () => { + const next = await engineTry(CMD.GET_APPROVER_KEY, {}, null); + setStatus(next && next.ok ? next : null); + }, []); + useEffect(() => { + void refresh(); + }, [refresh]); + return { status, refresh }; +} + +async function setUp(replace: boolean): Promise { + try { + return await engineInvoke(CMD.SET_UP_APPROVER_KEY, { replace }); + } catch (failure) { + return { + ok: false, + error: `OpenAdapt couldn't set up the approver key. Nothing changed. (${String(failure)})`, + }; + } +} + +/** The one-screen setup shown in place of Approve until the key exists. */ +export function ApproverKeySetup({ onReady }: { onReady: () => void | Promise }) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + async function start() { + setBusy(true); + setError(""); + const result = await setUp(false); + setBusy(false); + if (!result.ok || !result.set_up) { + setError(result.error || "OpenAdapt couldn't set up the approver key. Nothing changed."); + return; + } + await onReady(); + } + + return ( +
+

Set up your approver key

+

+ On this Mac, each approval asks for your Touch ID or login password, so + only a person can approve a save. Set it up once. +

+ {error && ( +

+ + {error} +

+ )} +
+ +
+
+ ); +} + +/** Settings: see, replace or remove this computer's approver key. */ +export function ApproverKeyCard() { + const { status, refresh } = useApproverKey(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [confirmRemove, setConfirmRemove] = useState(false); + + if (!status) return null; + + async function replace() { + setBusy(true); + setError(""); + const result = await setUp(status?.set_up ?? false); + setBusy(false); + if (!result.ok) setError(result.error || "The approver key didn't change."); + await refresh(); + } + + async function remove() { + setBusy(true); + setError(""); + try { + const result = await engineInvoke(CMD.REMOVE_APPROVER_KEY, {}); + if (!result.ok) setError(result.error || "The approver key wasn't removed."); + } catch (failure) { + setError(`The approver key wasn't removed. (${String(failure)})`); + } + setBusy(false); + setConfirmRemove(false); + await refresh(); + } + + return ( + + + {!status.supported && status.instructions && ( +

{status.instructions}

+ )} + {status.supported && ( +
+ {status.set_up && status.fingerprint && ( + {status.fingerprint} + )} + + {status.stored && + (confirmRemove ? ( + <> + + + + ) : ( + + ))} +
+ )} + {confirmRemove && ( + + Approvals from this computer are refused until you set up a new key. + + )} + {error && ( + + {error} + + )} +
+ ); +} diff --git a/packages/desktop/src/ui/ItemApproval.test.tsx b/packages/desktop/src/ui/ItemApproval.test.tsx index e3b265bed..7c531bbcc 100644 --- a/packages/desktop/src/ui/ItemApproval.test.tsx +++ b/packages/desktop/src/ui/ItemApproval.test.tsx @@ -106,3 +106,50 @@ it("says plainly when the engine refused the answer", async () => { expect(await screen.findByText(/isn't waiting any more/)).toBeTruthy(); expect(answered).not.toHaveBeenCalled(); }); + +it("offers the one-step approver key setup instead of Approve on a Mac without a key", async () => { + const { engineTry } = await import("../lib/engine"); + vi.mocked(engineTry).mockResolvedValue({ ok: true, supported: true, set_up: false, stored: false }); + vi.mocked(engineInvoke).mockResolvedValue({ ok: true, supported: true, set_up: true, stored: true }); + render( {}} />); + + const setUp = await screen.findByRole("button", { name: "Set up with Touch ID" }); + expect(screen.queryByRole("button", { name: "Approve and save" })).toBeNull(); + expect(screen.getByText(/asks for your Touch ID or login password/)).toBeTruthy(); + + vi.mocked(engineTry).mockResolvedValue({ ok: true, supported: true, set_up: true, stored: true }); + fireEvent.click(setUp); + + await screen.findByRole("button", { name: "Approve and save" }); + expect(engineInvoke).toHaveBeenCalledWith(CMD.SET_UP_APPROVER_KEY, { replace: false }); +}); + +it("keeps Approve hidden when setup fails, and says why", async () => { + const { engineTry } = await import("../lib/engine"); + vi.mocked(engineTry).mockResolvedValue({ ok: true, supported: true, set_up: false }); + vi.mocked(engineInvoke).mockResolvedValue({ + ok: false, + code: "approver_presence_refused", + error: "Touch ID or your password wasn't confirmed, so nothing was approved.", + }); + render( {}} />); + + fireEvent.click(await screen.findByRole("button", { name: "Set up with Touch ID" })); + + expect((await screen.findByRole("alert")).textContent).toMatch(/wasn't confirmed/); + expect(screen.queryByRole("button", { name: "Approve and save" })).toBeNull(); +}); + +it("switches to setup when the engine says the key is missing", async () => { + vi.mocked(engineInvoke).mockResolvedValue({ + ok: false, + code: "approver_key_missing", + setup: "in_app", + error: "Set up your approver key to approve saves on this Mac.", + }); + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Approve and save" })); + + await screen.findByRole("button", { name: "Set up with Touch ID" }); +}); diff --git a/packages/desktop/src/ui/ItemApproval.tsx b/packages/desktop/src/ui/ItemApproval.tsx index a5d359038..5faf5cb8e 100644 --- a/packages/desktop/src/ui/ItemApproval.tsx +++ b/packages/desktop/src/ui/ItemApproval.tsx @@ -9,12 +9,15 @@ // record before it saves. // // Desktop has no person-level sign-in, so every approval is recorded without -// a name. The screen never offers Approve without the filled form to compare. +// a name; this computer's approver key signs it. On a Mac without that key +// yet, the screen offers its one-step setup instead of Approve. The screen +// never offers Approve without the filled form to compare. import { useCallback, useEffect, useRef, useState } from "react"; import { DECLINED } from "../lib/approvalMode"; import { CMD, EVT, engineInvoke, engineTry, onEngineEvent } from "../lib/engine"; import { humanizeInput, sentenceCase } from "../lib/outcomes"; import type { ItemApprovalAnswer, ItemApprovalState } from "../lib/types"; +import { ApproverKeySetup, useApproverKey } from "./ApproverKey"; import { Button } from "./primitives"; import { Icon } from "./simple"; @@ -90,6 +93,11 @@ export function ItemApprovalPanel({ const [reason, setReason] = useState(""); const [error, setError] = useState(""); const [sent, setSent] = useState<"approved" | "declined" | null>(null); + const { status: keyStatus, refresh: refreshKey } = useApproverKey(); + // The engine said the key is missing on a Mac that can set it up here. + const [needsKey, setNeedsKey] = useState(false); + const showSetup = + needsKey || Boolean(keyStatus?.supported && keyStatus.set_up === false); useEffect(() => { setBusy(false); @@ -117,6 +125,10 @@ export function ItemApprovalPanel({ ...(decision === "decline" ? { reason } : {}), }); if (!response.ok) { + if (response.setup === "in_app") { + setNeedsKey(true); + return; + } setError(response.error || "OpenAdapt couldn't send your answer. Nothing was saved."); return; } @@ -196,6 +208,13 @@ export function ItemApprovalPanel({ : "OpenAdapt won't save it."}

+ ) : showSetup ? ( + { + setNeedsKey(false); + await refreshKey(); + }} + /> ) : declining ? (
Why shouldn't it save? diff --git a/packages/desktop/tests/conftest.py b/packages/desktop/tests/conftest.py index d7e277cde..06a0a946c 100644 --- a/packages/desktop/tests/conftest.py +++ b/packages/desktop/tests/conftest.py @@ -40,6 +40,22 @@ def fake_native_recorder(monkeypatch): monkeypatch.setattr("openadapt_desktop.controller._load_capture_recorder", lambda: FakeRecorder) +@pytest.fixture(autouse=True) +def no_real_secure_enclave(monkeypatch): + """Keep tests off this Mac's Secure Enclave and the in-app approver key. + + The engine's default approver keychain would probe the Secure Enclave and + read the login Keychain. Tests get a computer without one (the terminal + path) unless they build an ``ApproverKeychain`` with a fake enclave. The + real-hardware test (tests/test_approver_keychain_real.py) makes its own. + """ + + from openadapt_desktop import approver_keychain + + monkeypatch.setattr(approver_keychain, "default_enclave", approver_keychain.NoEnclave) + monkeypatch.setattr(approver_keychain, "_DEFAULT", []) + + @pytest.fixture(autouse=True) def restore_umask(): """The engine entry points set a private umask; keep it from leaking between tests.""" diff --git a/packages/desktop/tests/test_approver_keychain_real.py b/packages/desktop/tests/test_approver_keychain_real.py new file mode 100644 index 000000000..7a2c7ab3c --- /dev/null +++ b/packages/desktop/tests/test_approver_keychain_real.py @@ -0,0 +1,122 @@ +"""The approver key on a real Mac: the Secure Enclave and the login Keychain. + +Opt-in, because it writes a throwaway item to the login Keychain and makes +Secure Enclave keys. It never shows a Touch ID prompt: + +* the full setup, sign, rotate and remove cycle uses a Secure Enclave key + without the presence flag (``require_presence=False``); +* the presence flag is checked by unsealing with a context that may not show + a prompt, which the Secure Enclave must refuse. + +Run it on a Mac with Touch ID or a T2 chip (Apple silicon or a T2 Intel Mac; +a macOS virtual machine, such as a hosted CI runner, has no Secure Enclave +and skips):: + + cd packages/desktop + OPENADAPT_REAL_KEYCHAIN_TESTS=1 uv run pytest tests/test_approver_keychain_real.py -v + +It removes its Keychain item when it finishes. To check by hand that a real +approval prompts, set up the key from Settings > Approver key in the app. +""" + +from __future__ import annotations + +import os +import secrets +import sys + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +pytestmark = [ + pytest.mark.skipif(sys.platform != "darwin", reason="macOS only"), + pytest.mark.skipif( + os.environ.get("OPENADAPT_REAL_KEYCHAIN_TESTS") != "1", + reason="set OPENADAPT_REAL_KEYCHAIN_TESTS=1 to use the real Keychain", + ), +] + + +@pytest.fixture +def enclave(): + from openadapt_desktop.approver_keychain import ApproverKeychainError, MacEnclave + + real = MacEnclave() + if not real.available(): + pytest.skip("this Mac has no Secure Enclave") + try: + real.create(require_presence=False) + except ApproverKeychainError as exc: + if exc.code == "approver_presence_refused": + pytest.skip("this Mac is locked; unlock it to run the real Secure Enclave tests") + raise + return real + + +@pytest.fixture +def account(): + import keyring + + from openadapt_desktop.auth.store import SERVICE_NAME + + name = f"approver-keychain-test-{secrets.token_hex(6)}" + yield name + try: + keyring.delete_password(SERVICE_NAME, name) + except Exception: + pass + + +def test_setup_sign_rotate_and_remove_with_the_real_keychain(enclave, account): + import keyring + + from openadapt_desktop.approver_keychain import ApproverKeychain, ApproverKeychainError + from openadapt_desktop.auth.store import SERVICE_NAME + from tests.test_engine.test_approver_keychain import FakeRegistry + + # The Secure Enclave and the login Keychain are real; the roster is the + # tests' signature-checking stand-in (Flow's own is tested in Flow). + roster = FakeRegistry() + keychain = ApproverKeychain( + enclave, account=account, require_presence=False, roster=roster + ) + + first = keychain.setup() + assert keyring.get_password(SERVICE_NAME, account) # in the login Keychain + _public, signature = keychain.sign(b"real message", reason="test") + Ed25519PublicKey.from_public_bytes(bytes.fromhex(first.public_key)).verify( + bytes.fromhex(signature.removeprefix("ed25519:")), b"real message" + ) + + second = keychain.setup(replace=True) + assert second.public_key != first.public_key + assert roster.approvers[second.principal] == second.public_key + + assert keychain.remove() is True + assert keyring.get_password(SERVICE_NAME, account) is None + with pytest.raises(ApproverKeychainError): + keychain.sign(b"real message", reason="test") + + +def test_the_secure_enclave_refuses_a_presence_key_without_a_prompt(enclave): + from openadapt_desktop.approver_keychain import ApproverKeychainError + + handle, public = enclave.create(require_presence=True) + sealed = enclave.seal(public, b"\x07" * 32) + + with pytest.raises(ApproverKeychainError) as raised: + enclave.open(handle, sealed, reason="test", interactive=False) + + assert raised.value.code == "approver_presence_refused" + + +def test_a_sealed_key_opens_only_with_its_own_enclave_key(enclave): + from openadapt_desktop.approver_keychain import ApproverKeychainError + + handle, public = enclave.create(require_presence=False) + other, _other_public = enclave.create(require_presence=False) + sealed = enclave.seal(public, b"\x07" * 32) + + assert bytes(enclave.open(handle, sealed, reason="test")) == b"\x07" * 32 + with pytest.raises(ApproverKeychainError): + enclave.open(other, sealed, reason="test") diff --git a/packages/desktop/tests/test_engine/test_approver_keychain.py b/packages/desktop/tests/test_engine/test_approver_keychain.py new file mode 100644 index 000000000..56040e6fb --- /dev/null +++ b/packages/desktop/tests/test_engine/test_approver_keychain.py @@ -0,0 +1,898 @@ +"""This Mac's approver key behind Touch ID, with a fake Secure Enclave. + +The fake keeps the unsealed key inside itself, as the Secure Enclave keeps +its own key, and answers each presence prompt the way the test says: confirm, +cancel or fail. The real Secure Enclave and Keychain are exercised by +tests/test_approver_keychain_real.py. +""" + +from __future__ import annotations + +import json +import secrets +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from openadapt_desktop.approver_keychain import ( + ACCOUNT, + ANSWER_DOMAIN, + ApproverKeychain, + ApproverKeychainError, +) +from openadapt_desktop.auth.store import SERVICE_NAME +from openadapt_desktop.flow_bridge import FlowBridge + +REQUEST_ID = "0" * 31 + "1" +REQUEST_SHA = "sha256:" + "a" * 64 + + +class FakeEnclave: + def __init__(self, *, available: bool = True) -> None: + self._available = available + self._keys: dict[bytes, bool] = {} # handle -> requires presence + self._sealed: dict[bytes, tuple[bytes, bytes]] = {} # sealed -> (handle, plain) + self.prompts: list[str] = [] + self.answer = "confirm" # or "cancel", "error" + + def available(self) -> bool: + return self._available + + def create(self, *, require_presence: bool) -> tuple[bytes, bytes]: + handle = secrets.token_bytes(16) + self._keys[handle] = require_presence + return handle, b"\x04public:" + handle + + def seal(self, public_key: bytes, plaintext: bytes) -> bytes: + handle = public_key.removeprefix(b"\x04public:") + sealed = secrets.token_bytes(24) + self._sealed[sealed] = (handle, bytes(plaintext)) + return sealed + + def open(self, handle, sealed, *, reason, interactive=True) -> bytearray: + if self._keys.get(handle): + self.prompts.append(reason) + if not interactive or self.answer == "cancel": + raise ApproverKeychainError( + "approver_presence_refused", + "Touch ID or your password wasn't confirmed, so nothing was approved.", + ) + if self.answer == "error": + raise ApproverKeychainError( + "approver_key_unusable", "The Secure Enclave refused (error -25300)." + ) + owner, plain = self._sealed[sealed] + if owner != handle: + raise ApproverKeychainError("approver_key_unusable", "wrong key") + return bytearray(plain) + + +class FakeRegistry: + """Flow's signed approver roster: a self-signed root, then changes signed + by an approver already on it. Every signature is checked.""" + + def __init__(self) -> None: + self.approvers: dict[str, str] = {} + self.root_key: str | None = None + self.fail = False + self.pin: str | None = None + + @staticmethod + def _check(public: str, message: bytes, signature: str) -> None: + Ed25519PublicKey.from_public_bytes(bytes.fromhex(public)).verify( + bytes.fromhex(signature.removeprefix("ed25519:")), message + ) + + def root(self) -> str | None: + return self.pin + + def is_empty(self) -> bool: + return self.root_key is None + + def lookup(self, public: str) -> str | None: + if self.pin is None or self.pin != self.root_key: + return None + return next((p for p, v in self.approvers.items() if v == public), None) + + def init_root(self, principal, public, sign) -> None: + if self.fail: + raise ApproverKeychainError("approver_key_unusable", "Flow is too old") + message = b"root:" + principal.encode() + public.encode() + self._check(public, message, sign(message)) + self.root_key = public + self.pin = public + self.approvers = {principal: public} + + def add(self, principal, public, signed_by, sign) -> None: + if self.fail: + raise ApproverKeychainError("approver_key_unusable", "Flow refused") + signer = self.approvers[signed_by] + message = b"add:" + principal.encode() + public.encode() + self._check(signer, message, sign(message)) + self.approvers[principal] = public + + def remove(self, principal, public, signed_by, sign) -> None: + signer = self.approvers[signed_by] + message = b"remove:" + principal.encode() + public.encode() + self._check(signer, message, sign(message)) + if public == self.root_key: + raise ApproverKeychainError("approver_key_unusable", "the root stays") + del self.approvers[principal] + + +@pytest.fixture +def enclave() -> FakeEnclave: + return FakeEnclave() + + +@pytest.fixture +def registry() -> FakeRegistry: + return FakeRegistry() + + +@pytest.fixture +def keychain(enclave, registry) -> ApproverKeychain: + return ApproverKeychain( + enclave, + roster=registry, + ) + + +def _verify(public_hex: str, message: bytes, signature: str) -> None: + Ed25519PublicKey.from_public_bytes(bytes.fromhex(public_hex)).verify( + bytes.fromhex(signature.removeprefix("ed25519:")), message + ) + + +# -- setup ------------------------------------------------------------------------- + + +def test_setup_is_one_prompt_and_registers_the_public_key( + keychain, enclave, registry, fake_keyring +): + stored = keychain.setup() + + assert enclave.prompts == ["set up your approver key"] + assert registry.approvers == {"desktop-approver": stored.public_key} + assert keychain.status() == { + "supported": True, + "set_up": True, + "stored": True, + "registered": True, + "principal": "desktop-approver", + "fingerprint": stored.public_key[:16], + } + item = json.loads(fake_keyring.get_password(SERVICE_NAME, ACCOUNT)) + assert item["presence"] is True + # Only sealed material is stored: nothing in the item opens without the + # (fake) Secure Enclave. + assert set(item) == { + "schema", + "public_key", + "principal", + "enclave_key", + "sealed_key", + "presence", + "created_at", + } + (plain,) = [plain for _handle, plain in enclave._sealed.values()] + assert plain.hex() not in json.dumps(item) + + +def test_setup_again_returns_the_same_key_without_a_prompt(keychain, enclave): + first = keychain.setup() + enclave.prompts.clear() + + assert keychain.setup().public_key == first.public_key + assert enclave.prompts == [] + + +def test_a_cancelled_setup_prompt_stores_and_registers_nothing( + keychain, enclave, registry, fake_keyring +): + enclave.answer = "cancel" + + with pytest.raises(ApproverKeychainError) as raised: + keychain.setup() + + assert raised.value.code == "approver_presence_refused" + assert fake_keyring.get_password(SERVICE_NAME, ACCOUNT) is None + assert registry.approvers == {} + assert keychain.status()["set_up"] is False + + +def test_a_keychain_that_wont_save_registers_nothing(keychain, registry, fake_keyring): + def refuse(*_args): + raise RuntimeError("keychain locked") + + fake_keyring.set_password = refuse # type: ignore[method-assign] + + with pytest.raises(ApproverKeychainError) as raised: + keychain.setup() + + assert raised.value.code == "approver_key_unusable" + assert registry.approvers == {} + + +def test_a_failed_registration_leaves_no_key_behind(keychain, registry, fake_keyring): + registry.fail = True + + with pytest.raises(ApproverKeychainError): + keychain.setup() + + assert fake_keyring.get_password(SERVICE_NAME, ACCOUNT) is None + + +def test_a_mac_without_a_secure_enclave_is_sent_to_the_terminal(registry): + keychain = ApproverKeychain(FakeEnclave(available=False), roster=registry) + + with pytest.raises(ApproverKeychainError) as raised: + keychain.setup() + + assert raised.value.code == "approver_keychain_unsupported" + assert keychain.status()["supported"] is False + + +def _wipe_roster(registry): + registry.approvers.clear() + registry.root_key = None + registry.pin = None + + +def test_a_stored_key_off_the_roster_starts_it_again_only_behind_a_prompt( + keychain, enclave, registry +): + stored = keychain.setup() + _wipe_roster(registry) # the roster file was removed + enclave.prompts.clear() + + assert keychain.status()["set_up"] is False + keychain.setup() + assert registry.approvers == {"desktop-approver": stored.public_key} + assert registry.pin == stored.public_key + # The root signature is made behind the one prompt: that is the proof. + assert enclave.prompts == ["set up your approver key"] + + +def test_a_planted_keychain_item_is_never_registered_without_presence( + keychain, enclave, registry, fake_keyring +): + """Something that can write the Keychain item plants its own key.""" + + from openadapt_desktop.approver_keychain import StoredKey + + keychain.setup() + _wipe_roster(registry) + planted = StoredKey.from_json(fake_keyring.get_password(SERVICE_NAME, ACCOUNT)) + # The planted item claims Touch ID but the person doesn't confirm. + enclave.answer = "cancel" + + with pytest.raises(ApproverKeychainError) as raised: + keychain.setup() + assert raised.value.code == "approver_presence_refused" + assert registry.approvers == {} and registry.pin is None + + # An item that admits it has no presence check is refused outright. + fake_keyring.set_password( + SERVICE_NAME, + ACCOUNT, + StoredKey(**{**planted.__dict__, "presence": False}).to_json(), + ) + enclave.answer = "confirm" + with pytest.raises(ApproverKeychainError) as raised: + keychain.setup() + assert raised.value.code == "approver_key_unusable" + assert registry.approvers == {} + + +def test_a_roster_this_mac_didnt_start_needs_an_approver_to_add_the_key( + keychain, enclave, registry, fake_keyring +): + """Desktop never adopts the root of a roster it finds.""" + + import secrets as _secrets + + other = _secrets.token_hex(32) + registry.root_key = other + registry.pin = other + registry.approvers = {"front-desk": other} + + with pytest.raises(ApproverKeychainError) as raised: + keychain.setup() + + assert raised.value.code == "approver_needs_adding" + assert "openadapt-flow jit approver add" in str(raised.value) + assert set(registry.approvers) == {"front-desk"} + # The key is kept, so the setup finishes once an approver adds it. + assert fake_keyring.get_password(SERVICE_NAME, ACCOUNT) is not None + assert keychain.status()["set_up"] is False + + +# -- signing ----------------------------------------------------------------------- + + +def test_each_signature_asks_for_presence(keychain, enclave): + stored = keychain.setup() + enclave.prompts.clear() + + for _ in range(2): + public, signature = keychain.sign(b"message", reason="approve this save") + assert public == stored.public_key + _verify(public, b"message", signature) + + assert enclave.prompts == ["approve this save", "approve this save"] + + +@pytest.mark.parametrize( + "answer, code", + [("cancel", "approver_presence_refused"), ("error", "approver_key_unusable")], +) +def test_no_presence_or_a_keychain_error_means_no_signature(keychain, enclave, answer, code): + keychain.setup() + enclave.answer = answer + + with pytest.raises(ApproverKeychainError) as raised: + keychain.sign(b"message", reason="approve this save") + + assert raised.value.code == code + + +def test_no_key_means_no_signature(keychain): + with pytest.raises(ApproverKeychainError) as raised: + keychain.sign(b"message", reason="approve this save") + assert raised.value.code == "approver_key_missing" + + +def test_a_key_stored_without_presence_isnt_used(enclave, registry, fake_keyring): + ApproverKeychain(enclave, require_presence=False, roster=registry).setup() + + with pytest.raises(ApproverKeychainError) as raised: + ApproverKeychain(enclave, roster=registry).sign(b"m", reason="r") + + assert raised.value.code == "approver_key_unusable" + + +# -- rotation and removal ---------------------------------------------------------- + + +def test_rotation_adds_the_new_key_with_the_old_keys_signature( + keychain, enclave, registry +): + old = keychain.setup() + enclave.prompts.clear() + + new = keychain.setup(replace=True) + + assert new.public_key != old.public_key + assert registry.approvers[new.principal] == new.public_key + # The root stays on the roster; it is no longer used here. + assert registry.approvers["desktop-approver"] == old.public_key + assert enclave.prompts == [ + "set up your new approver key", + "approve your new approver key", + ] + _public, signature = keychain.sign(b"m", reason="approve this save") + _verify(new.public_key, b"m", signature) + + +def test_rotating_again_retires_the_previous_non_root_key(keychain, registry): + keychain.setup() + second = keychain.setup(replace=True) + + third = keychain.setup(replace=True) + + assert second.principal not in registry.approvers + assert registry.approvers[third.principal] == third.public_key + + +def test_a_failed_rotation_keeps_the_old_key_working(keychain, registry): + old = keychain.setup() + registry.fail = True + + with pytest.raises(ApproverKeychainError): + keychain.setup(replace=True) + + assert keychain.stored().public_key == old.public_key + assert registry.approvers == {"desktop-approver": old.public_key} + + +def test_removing_a_non_root_key_takes_it_off_the_roster(keychain, enclave, registry, fake_keyring): + keychain.setup() + second = keychain.setup(replace=True) + enclave.prompts.clear() + + assert keychain.remove() is True + + assert second.principal not in registry.approvers + assert enclave.prompts == ["remove your approver key"] + assert fake_keyring.get_password(SERVICE_NAME, ACCOUNT) is None + assert keychain.remove() is False + with pytest.raises(ApproverKeychainError) as raised: + keychain.sign(b"m", reason="r") + assert raised.value.code == "approver_key_missing" + + +def test_removing_the_root_key_deletes_it_here_and_keeps_the_root( + keychain, registry, fake_keyring +): + root = keychain.setup() + + assert keychain.remove() is True + + assert registry.approvers == {"desktop-approver": root.public_key} + assert fake_keyring.get_password(SERVICE_NAME, ACCOUNT) is None + + +# -- Run it: Flow asks Desktop to sign over a pipe --------------------------------- + + +class FakeFlow: + """``item-approval ... --approver-signer stdio`` as Flow runs it.""" + + def __init__(self, cmd, *, payload: dict, registry: FakeRegistry, **kwargs) -> None: + self.cmd = cmd + self.env = kwargs.get("env") or {} + self.public = cmd[cmd.index("--approver-public-key") + 1] + self.message = ( + ANSWER_DOMAIN + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ) + self.registry = registry + self.response: dict | None = None + flow = self + + class Out: + def readline(self): + return ( + json.dumps( + { + "schema": "openadapt-flow.approver-sign-request/v1", + "public_key": flow.public, + "message_hex": flow.message.hex(), + } + ) + + "\n" + ) + + def read(self): + return json.dumps(flow.result()) + "\n" + + class In: + def write(self, text): + flow.response = json.loads(text) + + def flush(self): + pass + + def close(self): + pass + + self.stdout, self.stdin = Out(), In() + + def result(self) -> dict: + response = self.response or {} + if "signature" not in response: + return { + "ok": False, + "code": response.get("refused", "approver_signature_unavailable"), + "message": response.get("message", ""), + } + _verify(self.public, self.message, response["signature"]) + self.returncode = 0 + return { + "ok": True, + "request_id": REQUEST_ID, + "decision": "approve", + "answer_sha256": "sha256:" + "c" * 64, + "resume_required": False, + } + + def wait(self, timeout=None): + return 0 if (self.response or {}).get("signature") else 2 + + def kill(self): + pass + + +def _payload(**overrides) -> dict: + return { + "request_id": REQUEST_ID, + "request_sha256": REQUEST_SHA, + "decision": "approve", + "decided_by": "human", + "route": "desktop_run_it", + "approver_kind": "unauthenticated", + "auth_method": "none", + "answered_at": "2026-10-24T12:00:00Z", + **overrides, + } + + +def _bridge(monkeypatch, keychain, registry, payload, launched): + monkeypatch.setattr( + "openadapt_desktop.flow_bridge._flow_command", lambda _bin: ["openadapt-flow"] + ) + + def popen(cmd, **kwargs): + flow = FakeFlow(cmd, payload=payload, registry=registry, **kwargs) + launched.append(flow) + return flow + + def no_run(*_args, **_kwargs): + raise AssertionError("the in-app key never takes the terminal path") + + return FlowBridge(runner=no_run, popen=popen, approver_keychain=keychain) + + +def test_run_it_signs_in_app_after_touch_id(monkeypatch, tmp_path, keychain, enclave, registry): + stored = keychain.setup() + enclave.prompts.clear() + launched: list = [] + bridge = _bridge(monkeypatch, keychain, registry, _payload(), launched) + + answer = bridge.item_approval_answer( + tmp_path, decision="approve", request_id=REQUEST_ID, request_sha256=REQUEST_SHA + ) + + assert answer["ok"] is True + (flow,) = launched + assert flow.cmd[flow.cmd.index("--approver-signer") + 1] == "stdio" + assert flow.public == stored.public_key + assert "--approver-key" not in flow.cmd + assert "OPENADAPT_JIT_KEY_PASSPHRASE" not in flow.env + assert enclave.prompts == ["approve this save"] + + +@pytest.mark.parametrize( + "payload", + [ + _payload(request_id="0" * 31 + "2"), + _payload(decision="decline"), + _payload(decided_by="automation"), + _payload(route="mcp"), + ], +) +def test_desktop_signs_only_the_answer_the_person_chose( + monkeypatch, tmp_path, keychain, enclave, registry, payload +): + keychain.setup() + enclave.prompts.clear() + launched: list = [] + bridge = _bridge(monkeypatch, keychain, registry, payload, launched) + + answer = bridge.item_approval_answer( + tmp_path, decision="approve", request_id=REQUEST_ID, request_sha256=REQUEST_SHA + ) + + assert answer == { + "ok": False, + "code": "approver_payload_refused", + "message": ( + "Flow asked Desktop to sign something other than your answer, so nothing was signed." + ), + } + assert enclave.prompts == [] # never asked the person to approve it + + +def test_a_cancelled_prompt_sends_flow_a_refusal( + monkeypatch, tmp_path, keychain, enclave, registry +): + keychain.setup() + enclave.answer = "cancel" + launched: list = [] + bridge = _bridge(monkeypatch, keychain, registry, _payload(), launched) + + answer = bridge.item_approval_answer( + tmp_path, decision="approve", request_id=REQUEST_ID, request_sha256=REQUEST_SHA + ) + + assert answer["ok"] is False + assert answer["code"] == "approver_presence_refused" + assert launched[0].response["refused"] == "approver_presence_refused" + + +def test_no_key_on_a_mac_that_can_set_one_up_sends_nothing( + monkeypatch, tmp_path, keychain, registry +): + launched: list = [] + bridge = _bridge(monkeypatch, keychain, registry, _payload(), launched) + + answer = bridge.item_approval_answer( + tmp_path, decision="approve", request_id=REQUEST_ID, request_sha256=REQUEST_SHA + ) + + assert answer["ok"] is False + assert answer["code"] == "approver_key_missing" + assert answer["setup"] == "in_app" + assert launched == [] + + +# -- the window's commands --------------------------------------------------------- + + +def _dispatcher(tmp_path: Path, keychain): + from openadapt_desktop.config import EngineConfig + from openadapt_desktop.dispatch import EngineDispatcher, EngineServices + + class Audit: + def __init__(self): + self.events: list = [] + + def log(self, event, **fields): + self.events.append((event, fields)) + + audit = Audit() + config = EngineConfig(data_dir=tmp_path / "data") + services = EngineServices( + config, flow_bridge=FlowBridge(approver_keychain=keychain), audit=audit + ) + return EngineDispatcher(config, services=services), audit + + +def test_the_window_sets_up_rotates_and_removes_the_key(tmp_path, keychain, registry): + dispatcher, audit = _dispatcher(tmp_path, keychain) + + assert dispatcher.dispatch("get_approver_key", {})["set_up"] is False + first = dispatcher.dispatch("set_up_approver_key", {}) + assert first["ok"] is True and first["set_up"] is True + rotated = dispatcher.dispatch("set_up_approver_key", {"replace": True}) + assert rotated["fingerprint"] != first["fingerprint"] + removed = dispatcher.dispatch("remove_approver_key", {}) + assert removed["removed"] is True and removed["set_up"] is False + # The rotated-in key is off the roster; the root stays. + assert set(registry.approvers) == {"desktop-approver"} + assert [event for event, _ in audit.events] == [ + "approver_key_setup", + "approver_key_setup", + "approver_key_removed", + ] + # The audit names a fingerprint, never the key. + for _event, fields in audit.events: + assert all(len(str(value)) <= 16 for value in fields.values()) + + +def test_a_cancelled_setup_reaches_the_window(tmp_path, keychain, enclave): + enclave.answer = "cancel" + dispatcher, _audit = _dispatcher(tmp_path, keychain) + + result = dispatcher.dispatch("set_up_approver_key", {}) + + assert result["ok"] is False + assert result["code"] == "approver_presence_refused" + + +def test_another_computer_gets_the_terminal_steps(tmp_path, registry): + keychain = ApproverKeychain(FakeEnclave(available=False), roster=registry) + dispatcher, _audit = _dispatcher(tmp_path, keychain) + + status = dispatcher.dispatch("get_approver_key", {}) + + assert status["supported"] is False + assert "openadapt-flow jit approver init" in status["instructions"] + + +def test_the_key_commands_answer_while_a_run_waits_and_stay_off_the_agent_socket(): + from openadapt_desktop.ipc import CONCURRENT_COMMANDS + from openadapt_desktop.socket_server import _SOCKET_COMMANDS + + keys = {"get_approver_key", "set_up_approver_key", "remove_approver_key"} + assert keys <= CONCURRENT_COMMANDS + assert not keys & _SOCKET_COMMANDS + + +def test_a_mac_that_can_use_touch_id_never_falls_back_to_a_file_key( + monkeypatch, tmp_path, keychain, registry +): + """A terminal-made key asks for no presence; on such a Mac it doesn't count.""" + + from openadapt_desktop import approver_key + + monkeypatch.setattr(approver_key, "KEY_DIR", tmp_path / "approver") + assert approver_key.setup_approver_key().created is True + assert approver_key.approver_key_environment() is not None + launched: list = [] + bridge = _bridge(monkeypatch, keychain, registry, _payload(), launched) + + answer = bridge.item_approval_answer( + tmp_path, decision="approve", request_id=REQUEST_ID, request_sha256=REQUEST_SHA + ) + + assert answer["ok"] is False + assert answer["code"] == "approver_key_missing" + assert answer["setup"] == "in_app" + assert "in OpenAdapt" in answer["message"] + assert launched == [] + + +# -- the pin this Mac keeps (security review of #1519) ----------------------------- + + +class _FlowRosterModule: + """Flow's approver_roster as FlowRoster calls it, with a roster head.""" + + class RosterError(ValueError): + def __init__(self, code, message=""): + super().__init__(message) + self.code = code + + def __init__(self): + self.entries = [] # (principal, public) + self.seen_at_least = [] + + def load(self, key_dir): + return type("R", (), {"entries": list(self.entries)})() + + def head(self, key_dir, root): + return len(self.entries), f"sha256:{len(self.entries):064x}" + + def verified_approvers(self, key_dir, root, *, at_least=None): + self.seen_at_least.append(at_least) + if at_least is not None and at_least[0] > len(self.entries): + raise self.RosterError("approver_roster_rolled_back") + return dict(self.entries) + + def init_root(self, key_dir, *, principal, public_key, sign): + self.entries = [(principal, public_key)] + + def add_approver(self, key_dir, root, *, principal, public_key, signed_by, sign, at_least): + self.entries.append((principal, public_key)) + + +@pytest.fixture +def flow_roster(monkeypatch, tmp_path, fake_keyring): + from openadapt_desktop import approver_keychain + + module = _FlowRosterModule() + monkeypatch.setattr(approver_keychain, "_flow_roster", lambda: (module, tmp_path / "keys")) + monkeypatch.delenv("OPENADAPT_APPROVER_ROOT", raising=False) + return approver_keychain.FlowRoster(), module + + +def test_only_the_keychain_names_this_macs_root(flow_roster, monkeypatch, tmp_path): + """Not the environment, and not Desktop's own (user-writable) config.""" + + import json as _json + + from openadapt_desktop.approver_keychain import ROOT_ACCOUNT, pinned_root + + roster, _module = flow_roster + data = tmp_path / "data" + data.mkdir() + monkeypatch.setenv("OPENADAPT_DATA_DIR", str(data)) + (data / "deployment.json").write_text( + _json.dumps({"approvers": {"root_public_key": "c" * 64}}) + ) + monkeypatch.setenv("OPENADAPT_APPROVER_ROOT", "b" * 64) + assert pinned_root() is None + + fake = __import__("openadapt_desktop.auth.store", fromlist=["_keyring"])._keyring() + fake.set_password(SERVICE_NAME, ROOT_ACCOUNT, "a" * 64) + assert roster.root() == "a" * 64 + + # A same-user process deletes the Keychain pin: there is no pin at all. + fake.delete_password(SERVICE_NAME, ROOT_ACCOUNT) + assert roster.root() is None + + +def test_ensure_private_dir_refuses_a_symlinked_folder(tmp_path): + from openadapt_desktop.private_fs import ensure_private_dir + + real = tmp_path / "elsewhere" + real.mkdir() + (tmp_path / "staged").symlink_to(real) + with pytest.raises(OSError, match="not a plain folder"): + ensure_private_dir(tmp_path / "staged") + + +def test_the_staged_config_is_byte_exact_for_its_digest(tmp_path): + import hashlib + + from openadapt_desktop.private_flow_config import stage_private_text + + payload = "name: clinic\r\napprovers:\n root_public_key: " + "a" * 64 + "\n" + with stage_private_text(tmp_path, payload=payload, prefix=".d-", suffix=".yaml") as path: + assert hashlib.sha256(path.read_bytes()).hexdigest() == hashlib.sha256( + payload.encode("utf-8") + ).hexdigest() + + +def test_the_roster_head_is_kept_in_the_keychain_and_a_rollback_is_refused(flow_roster): + roster, module = flow_roster + roster.init_root("desktop-approver", "d" * 64, sign=lambda m: "ed25519:00") + roster.add("night-desk", "e" * 64, "desktop-approver", sign=lambda m: "ed25519:00") + assert roster.lookup("e" * 64) == "night-desk" + assert module.seen_at_least[-1] == (2, f"sha256:{2:064x}") + + module.entries = module.entries[:1] # an older roster put back + assert roster.lookup("d" * 64) is None + + +def test_a_mac_with_a_pin_never_starts_another_root(flow_roster): + roster, _module = flow_roster + roster.init_root("desktop-approver", "d" * 64, sign=lambda m: "ed25519:00") + with pytest.raises(ApproverKeychainError) as raised: + roster.init_root("desktop-approver", "f" * 64, sign=lambda m: "ed25519:00") + assert raised.value.code == "approver_root_conflict" + + +def test_each_run_config_carries_this_macs_pin(tmp_path): + import yaml + + from openadapt_desktop.private_flow_config import ( + PrivateFlowConfigError, + prepare_flow_config, + ) + + source = tmp_path / "deployment.yaml" + source.write_text("name: clinic\n") + prepared = prepare_flow_config(source, None, approver_root="a" * 64) + assert yaml.safe_load(prepared.payload)["approvers"] == {"root_public_key": "a" * 64} + + source.write_text(f"name: clinic\napprovers:\n root_public_key: {'b' * 64}\n") + with pytest.raises(PrivateFlowConfigError, match="another approver root"): + prepare_flow_config(source, None, approver_root="a" * 64) + + +def test_without_a_keychain_pin_the_config_names_no_root(tmp_path): + """Attack: delete the Keychain items, then write your own root and head + into /deployment.json, a file the agent's user can write. + Desktop must not hand Flow that root under a trusted digest.""" + import yaml + + from openadapt_desktop.private_flow_config import prepare_flow_config + + source = tmp_path / "deployment.json" + source.write_text( + '{"name": "clinic", "approvers": {"root_public_key": "%s", ' + '"head": [1, "sha256:%s"], "key_dir": "/tmp/attacker"}}' % ("b" * 64, "c" * 64) + ) + prepared = prepare_flow_config(source, None, approver_root=None) + payload = yaml.safe_load(prepared.payload) + # The whole section goes: Flow then runs as normal and refuses only a + # person's answer (approver_root_unpinned); a key_dir alone would make + # the config unreadable and refuse every run. + assert "approvers" not in payload + assert payload["name"] == "clinic" + assert "b" * 64 not in prepared.payload + assert "/tmp/attacker" not in prepared.payload + + +def test_flow_never_inherits_an_approver_root_from_the_environment(monkeypatch): + from openadapt_desktop.flow_bridge import _subprocess_env + + monkeypatch.setenv("OPENADAPT_APPROVER_ROOT", "b" * 64) + assert "OPENADAPT_APPROVER_ROOT" not in _subprocess_env() + + +def test_each_run_config_carries_this_macs_roster_head(tmp_path): + import yaml + + from openadapt_desktop.private_flow_config import prepare_flow_config + + source = tmp_path / "deployment.yaml" + source.write_text("name: clinic\n") + prepared = prepare_flow_config( + source, None, approver_root="a" * 64, approver_head=(3, "sha256:" + "c" * 64) + ) + assert yaml.safe_load(prepared.payload)["approvers"] == { + "root_public_key": "a" * 64, + "head": [3, "sha256:" + "c" * 64], + } + + +def test_run_it_answers_bring_this_macs_pin( + monkeypatch, tmp_path, keychain, registry, fake_keyring +): + from openadapt_desktop.approver_keychain import ROOT_ACCOUNT + + stored = keychain.setup() + fake_keyring.set_password(SERVICE_NAME, ROOT_ACCOUNT, stored.public_key) + launched: list = [] + bridge = _bridge(monkeypatch, keychain, registry, _payload(), launched) + + bridge.item_approval_answer( + tmp_path, decision="approve", request_id=REQUEST_ID, request_sha256=REQUEST_SHA + ) + + (flow,) = launched + assert flow.cmd[flow.cmd.index("--approver-root") + 1] == stored.public_key diff --git a/packages/desktop/tests/test_engine/test_dispatch.py b/packages/desktop/tests/test_engine/test_dispatch.py index 5740fde36..72a6f26f5 100644 --- a/packages/desktop/tests/test_engine/test_dispatch.py +++ b/packages/desktop/tests/test_engine/test_dispatch.py @@ -1518,7 +1518,15 @@ class Bridge: def supports_approval_gate(self) -> bool: return True - def run(self, target_bundle, config, out_dir, approval_mode, approval_wait_s): + def run( + self, + target_bundle, + config, + out_dir, + approval_mode, + approval_wait_s, + config_sha256=None, + ): from openadapt_desktop.flow_bridge import FlowResult # Every Desktop run asks Flow to stop before each save. diff --git a/packages/desktop/tests/test_engine/test_item_approval.py b/packages/desktop/tests/test_engine/test_item_approval.py index ceacc8564..c9c1f5671 100644 --- a/packages/desktop/tests/test_engine/test_item_approval.py +++ b/packages/desktop/tests/test_engine/test_item_approval.py @@ -595,7 +595,8 @@ def test_bridge_sends_no_answer_without_an_approver_key(monkeypatch, tmp_path: P assert f"openadapt-flow jit keygen --out {key_path}" in message assert "OPENADAPT_JIT_KEY_PASSPHRASE" in message assert "openadapt-flow jit approver init --principal" in message - assert "OPENADAPT_APPROVER_ROOT" in message + assert "approvers.root_public_key" in message + assert "OPENADAPT_APPROVER_ROOT" not in message assert calls == [] # never an unsigned answer diff --git a/packages/desktop/tests/test_engine/test_stop_queue.py b/packages/desktop/tests/test_engine/test_stop_queue.py new file mode 100644 index 000000000..13889095a --- /dev/null +++ b/packages/desktop/tests/test_engine/test_stop_queue.py @@ -0,0 +1,661 @@ +"""The stop-review queue: cards, signed answers, batches, and what never acts. + +Uses the real index database and a fake Secure Enclave, so the approver key's +prompt can be confirmed or cancelled. Item values are synthetic. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from openadapt_desktop import stop_queue +from openadapt_desktop.approver_keychain import ApproverKeychain +from openadapt_desktop.config import EngineConfig +from openadapt_desktop.db import IndexDB +from openadapt_desktop.dispatch import EngineDispatcher, EngineServices +from openadapt_desktop.flow_bridge import FlowBridge +from tests.test_engine.test_approver_keychain import FakeEnclave, FakeRegistry + +DUPLICATE = ( + "Postconditions failed for step 'step_039' (click 'Create New Patient'): " + "failed: no_duplicate_candidate (no listed row has this run's lname, DOB)" +) + + +class _Audit: + def __init__(self) -> None: + self.events: list = [] + + def log(self, event, **fields): + self.events.append((event, fields)) + + +@pytest.fixture +def enclave() -> FakeEnclave: + return FakeEnclave() + + +@pytest.fixture +def queue(tmp_path: Path, enclave): + config = EngineConfig(data_dir=tmp_path / ".openadapt", log_level="WARNING") + config.data_dir.mkdir(parents=True, exist_ok=True) + db = IndexDB(tmp_path / "index.db") + db.initialize() + db.insert_bundle("wf-ref", str(tmp_path / "bundle")) + registry = FakeRegistry() + keychain = ApproverKeychain( + enclave, + roster=registry, + ) + keychain.setup() + enclave.prompts.clear() + services = EngineServices( + config, db=db, audit=_Audit(), flow_bridge=FlowBridge(approver_keychain=keychain) + ) + disp = EngineDispatcher(config, services=services) + yield disp, db, keychain + db.close() + + +def _stop( + db, + item_id: str, + *, + lname: str, + transaction="HALTED_BEFORE_EFFECT", + reason=DUPLICATE, + execution="HALTED", + diffs=None, +) -> None: + db.insert_work_item( + item_id, + batch_id="batch-1", + workflow_id="wf-ref", + position=int(item_id[-2:]), + label=f"{lname} referral", + inputs_json=json.dumps({"fname": "Ana", "lname": lname, "DOB": "1941-02-27"}), + inputs_digest=item_id, + ) + db.update_work_item( + item_id, + status="open", + run_id=f"run-{item_id}", + execution_outcome=execution, + transaction_outcome=transaction, + halt_kind="screen_changed", + halt_step="click 'Create New Patient'", + halt_reason=reason, + field_diffs_json=json.dumps(diffs) if diffs else None, + finished_at="2026-10-24T12:00:00+00:00", + ) + + +def _cards(disp) -> list[dict]: + listed = disp.dispatch("list_stop_cards", {}) + assert listed["ok"] is True + return listed["cards"] + + +# -- cards --------------------------------------------------------------------- + + +def test_a_duplicate_stop_becomes_a_same_person_card(queue): + disp, db, _keychain = queue + _stop(db, "item-01", lname="Fairwick") + + (card,) = _cards(disp) + + assert card["kind"] == "same_record" + assert card["question"] == "Same person?" + assert card["batch_key"] == "same_record:wf-ref:duplicate" + assert {row["field"]: row["value"] for row in card["evidence"]["source"]} == { + "DOB": "1941-02-27", + "fname": "Ana", + "lname": "Fairwick", + } + assert card["evidence"]["screenshot"] == {"item_id": "item-01"} + assert [c["id"] for c in card["choices"]] == [ + "same", + "decline", + "different_by_hand", + "leave", + ] + # Every choice says what it does. + assert all(c["consequence"] for c in card["choices"]) + + +def test_record_differences_are_marked_side_by_side(queue): + disp, db, _keychain = queue + _stop( + db, + "item-02", + lname="Fairwick", + diffs=[ + {"field": "lname", "typed": "Fairwick", "on_file": "Fairwick"}, + {"field": "DOB", "typed": "1941-02-27", "on_file": "1941-07-22"}, + ], + ) + + (card,) = _cards(disp) + + by_field = {d["field"]: d for d in card["evidence"]["differences"]} + assert by_field["lname"]["same"] is True + assert by_field["DOB"] == { + "field": "DOB", + "label": "DOB", + "source": "1941-02-27", + "record": "1941-07-22", + "same": False, + } + + +def test_an_uncertain_save_asks_did_it_save(queue): + disp, db, _keychain = queue + _stop( + db, + "item-03", + lname="Caldley", + transaction="RECONCILIATION_REQUIRED", + reason="record_written refuted", + ) + + (card,) = _cards(disp) + + assert card["kind"] == "confirm_save" + assert [c["id"] for c in card["choices"]] == [ + "saved", + "not_saved_retry", + "fixed_by_hand", + "leave", + ] + + +def test_a_card_id_covers_everything_it_shows(): + item = { + "item_id": "item-04", + "source": "item", + "workflow_id": "wf", + "run_id": "r", + "status": "open", + "nothing_written": True, + "actions": ["retry", "skip", "decline", "teach"], + "halt_reason": "x", + "inputs": {"lname": "A"}, + } + first = stop_queue.card_for_item(item) + changed = stop_queue.card_for_item({**item, "inputs": {"lname": "B"}}) + + assert first is not None and changed is not None + assert first["card_id"] != changed["card_id"] + assert stop_queue.card_for_item(item)["card_id"] == first["card_id"] + + +# -- answers ------------------------------------------------------------------- + + +def test_an_answer_is_signed_after_touch_id_then_applied(queue, enclave): + disp, db, keychain = queue + _stop(db, "item-05", lname="Fairwick") + (card,) = _cards(disp) + + answer = disp.dispatch( + "answer_stop_cards", + {"card_ids": [card["card_id"]], "choice": "same", "shown_ms": {card["card_id"]: 4200}}, + ) + + assert answer == { + "ok": True, + "results": [{"card_id": card["card_id"], "ok": True, "error": None}], + "changed": 1, + } + assert enclave.prompts == ["record your answer to this stop"] + assert db.get_work_item("item-05")["status"] == "skipped" + assert _cards(disp) == [] + (receipt,) = [ + json.loads(line) + for line in (disp.config.data_dir / "stop_decisions.jsonl").read_text().splitlines() + ] + assert receipt["shown_ms"] == {card["card_id"]: 4200} + message = stop_queue.decision_message( + [card["card_id"]], "same", decided_at=receipt["decided_at"] + ) + Ed25519PublicKey.from_public_bytes(bytes.fromhex(keychain.stored().public_key)).verify( + bytes.fromhex(receipt["signature"].removeprefix("ed25519:")), message + ) + + +def test_a_cancelled_prompt_changes_nothing(queue, enclave): + disp, db, _keychain = queue + _stop(db, "item-06", lname="Fairwick") + (card,) = _cards(disp) + enclave.answer = "cancel" + + answer = disp.dispatch("answer_stop_cards", {"card_ids": [card["card_id"]], "choice": "same"}) + + assert answer["ok"] is False and answer["code"] == "approver_presence_refused" + assert db.get_work_item("item-06")["status"] == "open" + assert not (disp.config.data_dir / "stop_decisions.jsonl").exists() + + +def test_a_card_that_changed_since_it_was_shown_changes_nothing(queue, enclave): + disp, db, _keychain = queue + _stop(db, "item-07", lname="Fairwick") + (card,) = _cards(disp) + db.update_work_item("item-07", halt_reason="a different stop") + + answer = disp.dispatch("answer_stop_cards", {"card_ids": [card["card_id"]], "choice": "same"}) + + assert answer["code"] == "card_changed" + assert enclave.prompts == [] # never asked to sign + assert db.get_work_item("item-07")["status"] == "open" + + +@pytest.mark.parametrize("choice", ["approve", "saved", "", "teach"]) +def test_a_choice_the_card_doesnt_offer_changes_nothing(queue, enclave, choice): + disp, db, _keychain = queue + _stop(db, "item-08", lname="Fairwick") + (card,) = _cards(disp) + + answer = disp.dispatch("answer_stop_cards", {"card_ids": [card["card_id"]], "choice": choice}) + + assert answer["code"] == "choice_not_offered" + assert enclave.prompts == [] + assert db.get_work_item("item-08")["status"] == "open" + + +def test_not_sure_leaves_the_stop_without_a_signature(queue, enclave): + disp, db, _keychain = queue + _stop(db, "item-09", lname="Fairwick") + (card,) = _cards(disp) + + answer = disp.dispatch("answer_stop_cards", {"card_ids": [card["card_id"]], "choice": "leave"}) + + assert answer == {"ok": True, "results": [], "changed": 0} + assert enclave.prompts == [] + assert len(_cards(disp)) == 1 + + +def test_a_batch_of_similar_stops_is_one_prompt(queue, enclave): + disp, db, _keychain = queue + for index, name in enumerate(["Fairwick", "Caldley", "Jesswood"], start=10): + _stop(db, f"item-{index}", lname=name) + cards = _cards(disp) + assert len({c["batch_key"] for c in cards}) == 1 + + answer = disp.dispatch( + "answer_stop_cards", + {"card_ids": [c["card_id"] for c in cards], "choice": "same"}, + ) + + assert answer["ok"] is True and answer["changed"] == 3 + assert enclave.prompts == ["record your answer to 3 stops"] + assert _cards(disp) == [] + + +def test_a_batch_never_mixes_kinds(queue, enclave): + disp, db, _keychain = queue + _stop(db, "item-20", lname="Fairwick") + _stop(db, "item-21", lname="Caldley", transaction="RECONCILIATION_REQUIRED", reason="x") + cards = _cards(disp) + + answer = disp.dispatch( + "answer_stop_cards", + {"card_ids": [c["card_id"] for c in cards], "choice": "leave"}, + ) + + assert answer["code"] == "card_changed" + assert "similar" in answer["error"] + assert enclave.prompts == [] + + +def test_one_stale_card_stops_the_whole_batch(queue, enclave): + disp, db, _keychain = queue + _stop(db, "item-30", lname="Fairwick") + _stop(db, "item-31", lname="Caldley") + cards = _cards(disp) + db.update_work_item("item-31", status="skipped") + + answer = disp.dispatch( + "answer_stop_cards", + {"card_ids": [c["card_id"] for c in cards], "choice": "same"}, + ) + + assert answer["code"] == "card_changed" + assert db.get_work_item("item-30")["status"] == "open" + + +def test_without_an_approver_key_nothing_changes(tmp_path): + config = EngineConfig(data_dir=tmp_path / ".openadapt", log_level="WARNING") + config.data_dir.mkdir(parents=True) + db = IndexDB(tmp_path / "index.db") + db.initialize() + db.insert_bundle("wf-ref", str(tmp_path / "bundle")) + disp = EngineDispatcher(config, services=EngineServices(config, db=db, audit=_Audit())) + _stop(db, "item-40", lname="Fairwick") + listed = disp.dispatch("list_stop_cards", {}) + assert listed["approver_key"] == {"ready": False, "in_app": False, "touch_id": False} + + answer = disp.dispatch( + "answer_stop_cards", {"card_ids": [listed["cards"][0]["card_id"]], "choice": "same"} + ) + + assert answer["code"] == "approver_key_missing" + assert db.get_work_item("item-40")["status"] == "open" + db.close() + + +def test_a_record_check_choice_states_what_the_person_found(queue): + disp, db, _keychain = queue + _stop( + db, + "item-50", + lname="Caldley", + transaction="RECONCILIATION_REQUIRED", + reason="record_written refuted", + ) + (card,) = _cards(disp) + + answer = disp.dispatch("answer_stop_cards", {"card_ids": [card["card_id"]], "choice": "saved"}) + + assert answer["ok"] is True + assert db.get_work_item("item-50")["status"] == "confirmed_saved" + + +# -- save approvals -------------------------------------------------------------- + + +def _waiting_state(answer_by: str) -> dict: + return { + "ok": True, + "running": True, + "waiting": True, + "workflow_id": "wf-ref", + "kind": "item", + "request": { + "request_id": "0" * 31 + "1", + "request_sha256": "sha256:" + "a" * 64, + "step_intent": "Click 'Save new patient'", + "answer_by": answer_by, + "screenshot": "data:image/png;base64,AAAA", + "values": [{"name": "lname", "value": "Fairwick"}], + }, + } + + +def test_a_waiting_save_is_answered_through_the_signed_item_approval(queue, monkeypatch): + disp, _db, _keychain = queue + disp._approval_watches["run"] = {"workflow_id": "wf-ref"} + monkeypatch.setattr( + disp, "get_item_approval", lambda **_: _waiting_state("2999-01-01T00:00:00+00:00") + ) + sent: list = [] + monkeypatch.setattr( + disp, + "answer_item_approval", + lambda **params: sent.append(params) or {"ok": True, "decision": "approved"}, + ) + (card,) = _cards(disp) + assert card["kind"] == "approve_save" and card["batch_key"] is None + + answer = disp.dispatch( + "answer_stop_approval", {"card_id": card["card_id"], "choice": "approve"} + ) + + assert answer["ok"] is True + assert sent == [ + { + "workflow_id": "wf-ref", + "decision": "approve", + "request_id": "0" * 31 + "1", + "request_sha256": "sha256:" + "a" * 64, + "reason": None, + } + ] + # Batch answers never carry a save approval. + refused = disp.dispatch( + "answer_stop_cards", {"card_ids": [card["card_id"]], "choice": "approve"} + ) + assert refused["code"] == "use_approval" + + +def test_an_expired_save_card_changes_nothing(queue, monkeypatch): + disp, _db, _keychain = queue + disp._approval_watches["run"] = {"workflow_id": "wf-ref"} + monkeypatch.setattr( + disp, "get_item_approval", lambda **_: _waiting_state("2000-01-01T00:00:00+00:00") + ) + monkeypatch.setattr( + disp, "answer_item_approval", lambda **_: pytest.fail("sent an expired answer") + ) + (card,) = _cards(disp) + + answer = disp.dispatch( + "answer_stop_approval", {"card_id": card["card_id"], "choice": "approve"} + ) + + assert answer["ok"] is False and "expired" in answer["error"] + + +def test_the_queue_commands_answer_while_a_run_waits_and_stay_off_the_agent_socket(): + from openadapt_desktop.ipc import CONCURRENT_COMMANDS + from openadapt_desktop.socket_server import _SOCKET_COMMANDS + + assert {"list_stop_cards", "answer_stop_approval"} <= CONCURRENT_COMMANDS + # Batch answers can run an item again, so they wait their turn. + assert "answer_stop_cards" not in CONCURRENT_COMMANDS + assert not {"list_stop_cards", "answer_stop_cards", "answer_stop_approval"} & (_SOCKET_COMMANDS) + + +# -- re-arm "only ask about problems" -------------------------------------------- + + +class _FakeRearm: + """Flow's re-arm for a key held elsewhere, verifying as #1517's does.""" + + def __init__(self) -> None: + self.refs = [f"{i:064x}" for i in range(10)] + self.rearmed: list = [] + + def offer(self): + return { + "request": { + "off_seq": 7, + "off_sha256": "sha256:" + "e" * 64, + "off_reason": "screen_changed", + "cause": "The app's screen changed.", + "clean_run_refs": list(self.refs), + }, + "off_at": "2026-10-24T10:00:00+00:00", + } + + def payload(self, fields): + return ( + b"openadapt.approval-mode-rearm/v1\n" + + json.dumps( + {k: v for k, v in fields.items() if k != "signature"}, + sort_keys=True, + separators=(",", ":"), + ).encode() + ) + + def rearm(self, data_dir, bundle_id, *, shown, approver_id, approver_keys, sign): + import base64 + + offer = self.offer() + if dict(shown) != offer["request"]: + return {"ok": False, "error": "What you saw has changed."} + fields = {**offer["request"], "approver_id": approver_id, "key_id": approver_id} + signature = bytes.fromhex(sign(self.payload(fields)).removeprefix("ed25519:")) + public = base64.b64decode(approver_keys[approver_id]) + Ed25519PublicKey.from_public_bytes(public).verify(signature, self.payload(fields)) + self.rearmed.append((bundle_id, approver_id)) + return {"ok": True} + + +@pytest.fixture +def switched_off(queue, monkeypatch): + disp, db, keychain = queue + fake = _FakeRearm() + from openadapt_desktop import approval_mode + + monkeypatch.setattr(approval_mode, "rearm_offer", lambda _d, _b: fake.offer()) + monkeypatch.setattr(approval_mode, "rearm", fake.rearm) + import base64 + + registered = { + "desktop-approver": base64.b64encode(bytes.fromhex(keychain.stored().public_key)).decode( + "ascii" + ) + } + monkeypatch.setattr(approval_mode, "registered_approver_keys", lambda: dict(registered)) + monkeypatch.setattr( + disp, + "_approval_switches", + lambda names: [ + { + "workflow_id": "wf-ref", + "task_name": "New patient referral", + "state": "off", + "off_reason": "screen_changed", + "off_at": "2026-10-24T10:00:00+00:00", + "off_step": "", + } + ], + ) + monkeypatch.setattr( + type(disp), "_approver_lookup", staticmethod(lambda public: "desktop-approver") + ) + return disp, fake, keychain + + +def test_a_switched_off_task_offers_a_rearm_card_showing_what_is_signed(switched_off): + disp, fake, _keychain = switched_off + + (card,) = _cards(disp) + + assert card["kind"] == "rearm_problems_only" + assert card["source"]["request"] == fake.offer()["request"] + assert "The app's screen changed." in card["summary"] + assert [c["id"] for c in card["choices"]] == ["rearm", "leave"] + + +def test_rearm_is_one_touch_id_over_flows_own_bytes(switched_off, enclave): + disp, fake, _keychain = switched_off + (card,) = _cards(disp) + + answer = disp.dispatch("answer_stop_cards", {"card_ids": [card["card_id"]], "choice": "rearm"}) + + assert answer["ok"] is True + assert fake.rearmed == [("wf-ref", "desktop-approver")] + assert enclave.prompts == ['turn "only ask about problems" back on'] + + +def test_a_rearm_after_another_item_finished_changes_nothing(switched_off, enclave): + disp, fake, _keychain = switched_off + (card,) = _cards(disp) + fake.refs = fake.refs[1:] + ["f" * 64] # a newer verified item + + answer = disp.dispatch("answer_stop_cards", {"card_ids": [card["card_id"]], "choice": "rearm"}) + + assert answer["code"] == "card_changed" + assert fake.rearmed == [] and enclave.prompts == [] + + +def test_a_cancelled_rearm_prompt_changes_nothing(switched_off, enclave): + disp, fake, _keychain = switched_off + (card,) = _cards(disp) + enclave.answer = "cancel" + + answer = disp.dispatch("answer_stop_cards", {"card_ids": [card["card_id"]], "choice": "rearm"}) + + assert answer["ok"] is False and answer["code"] == "approver_presence_refused" + assert fake.rearmed == [] + + +def test_an_unregistered_key_cant_rearm(switched_off, monkeypatch, enclave): + disp, fake, _keychain = switched_off + monkeypatch.setattr(type(disp), "_approver_lookup", staticmethod(lambda public: None)) + (card,) = _cards(disp) + + answer = disp.dispatch("answer_stop_cards", {"card_ids": [card["card_id"]], "choice": "rearm"}) + + assert answer["code"] == "human_approver_unregistered" + assert fake.rearmed == [] and enclave.prompts == [] + + +def test_a_rearm_is_verified_against_the_registry_not_the_signers_key( + switched_off, monkeypatch, enclave +): + from openadapt_desktop import approval_mode + + disp, fake, _keychain = switched_off + # The registry holds another key under this name. + monkeypatch.setattr( + approval_mode, "registered_approver_keys", lambda: {"desktop-approver": "AAAA"} + ) + (card,) = _cards(disp) + + answer = disp.dispatch("answer_stop_cards", {"card_ids": [card["card_id"]], "choice": "rearm"}) + + assert answer["code"] == "human_approver_unregistered" + assert fake.rearmed == [] and enclave.prompts == [] + + +def test_a_card_that_changes_during_the_prompt_changes_nothing(queue, enclave): + """The Touch ID prompt can stay up; the cards are checked again after it.""" + + disp, db, keychain = queue + _stop(db, "item-60", lname="Fairwick") + (card,) = _cards(disp) + real_sign = keychain.sign + + def sign_while_another_window_answers(message, *, reason): + signed = real_sign(message, reason=reason) + db.update_work_item("item-60", status="skipped") # answered elsewhere + return signed + + keychain.sign = sign_while_another_window_answers # type: ignore[method-assign] + + answer = disp.dispatch( + "answer_stop_cards", {"card_ids": [card["card_id"]], "choice": "different_by_hand"} + ) + + assert answer["code"] == "card_changed" + assert db.get_work_item("item-60")["status"] == "skipped" + assert not (disp.config.data_dir / "stop_decisions.jsonl").exists() + + +def test_a_touch_id_mac_without_a_key_never_signs_with_a_file_key(tmp_path, monkeypatch, enclave): + from openadapt_desktop import approver_key + + monkeypatch.setattr(approver_key, "KEY_DIR", tmp_path / "approver") + approver_key.setup_approver_key() # a terminal-made key exists + config = EngineConfig(data_dir=tmp_path / ".openadapt", log_level="WARNING") + config.data_dir.mkdir(parents=True) + db = IndexDB(tmp_path / "index.db") + db.initialize() + db.insert_bundle("wf-ref", str(tmp_path / "bundle")) + registry = FakeRegistry() + keychain = ApproverKeychain( # supported, nothing set up + enclave, + roster=registry, + ) + disp = EngineDispatcher( + config, + services=EngineServices( + config, db=db, audit=_Audit(), flow_bridge=FlowBridge(approver_keychain=keychain) + ), + ) + _stop(db, "item-61", lname="Fairwick") + (card,) = _cards(disp) + + answer = disp.dispatch("answer_stop_cards", {"card_ids": [card["card_id"]], "choice": "same"}) + + assert answer["code"] == "approver_key_missing" + assert "in OpenAdapt" in answer["error"] + assert db.get_work_item("item-61")["status"] == "open" + db.close() diff --git a/packages/desktop/tests/test_engine/test_stop_queue_portal.py b/packages/desktop/tests/test_engine/test_stop_queue_portal.py new file mode 100644 index 000000000..9a96f8ea6 --- /dev/null +++ b/packages/desktop/tests/test_engine/test_stop_queue_portal.py @@ -0,0 +1,550 @@ +"""The stop-review queue on the paired phone. + +Every route needs an approved device session; answers also need the portal's +own origin and the session's CSRF token. The phone's answers are signed with +the decision portal's approver key, the receipt names that key and the device, +and no command on the window's wire can ask for that key. Uses the real index +database, the real dispatcher and a throwaway portal key. Item values are +synthetic. +""" + +from __future__ import annotations + +import json +import threading +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from openadapt_desktop import stop_queue +from openadapt_desktop.portal.ingress import resolve_ingress +from openadapt_desktop.portal.pairing import DevicePairingStore +from openadapt_desktop.portal.server import PortalApp +from openadapt_desktop.portal.stops import DispatcherStopQueue +from tests.test_engine.test_stop_queue import ( # noqa: F401 (fixture) + _stop, + enclave, # noqa: F401 (fixture) + queue, +) + +ORIGIN = "https://openadapt.clinic.example" + + +class Config: + portal_ingress_mode = "customer_ingress" + portal_public_origin = ORIGIN + portal_bind_host = "" + portal_ingress_acknowledged = True + portal_port = 8443 + + +class FakeStops: + """Records every call; the route tests check nothing reaches it.""" + + def __init__(self) -> None: + self.calls: list = [] + + def list_cards(self): + self.calls.append(("list",)) + return {"ok": True, "cards": [], "portal_key": {"ready": True}} + + def screenshot(self, card_id): + self.calls.append(("screenshot", card_id)) + return b"\x89PNG\r\n\x1a\nframe" + + def answer(self, card_ids, choice, *, shown_ms, device): + self.calls.append(("answer", card_ids, choice, shown_ms, device)) + return {"ok": True, "results": [], "changed": 0} + + +def _app(stops) -> tuple[PortalApp, DevicePairingStore]: + pairings = DevicePairingStore(runner_id="runner_test") + app = PortalApp(resolve_ingress(Config()), pairings, None, stops=stops) + return app, pairings + + +def _paired(pairings: DevicePairingStore) -> dict[str, str]: + pairing = pairings.create(ORIGIN, reachable_from_phone=True) + claim = pairings.claim(pairing.secret, "Ward phone") + pairings.approve(claim["pairing_id"], claim["confirm_code"]) + return { + "authorization": f"Bearer {claim['session_token']}", + "origin": ORIGIN, + "content-type": "application/json", + "x-openadapt-portal-csrf": claim["csrf_token"], + } + + +HEX = "a" * 64 +ROUTES = [ + ("GET", "/api/portal/stops", b""), + ("GET", f"/api/portal/stops/{HEX}/screenshot", b""), + ("POST", f"/api/portal/stops/{HEX}/answer", b'{"choice":"same"}'), + ( + "POST", + "/api/portal/stops/answer", + json.dumps({"card_ids": [f"sha256:{HEX}"], "choice": "same"}).encode(), + ), +] + + +# -- route auth -------------------------------------------------------------------- + + +@pytest.mark.parametrize(("method", "path", "body"), ROUTES) +def test_no_session_is_refused_on_every_stop_route(method, path, body): + stops = FakeStops() + app, _pairings = _app(stops) + + status, headers, raw = app.handle( + method, path, {"origin": ORIGIN, "content-type": "application/json"}, body + ) + + assert status == 401 + assert json.loads(raw)["reason"] == "unauthorized" + assert headers["Cache-Control"].startswith("no-store") + assert stops.calls == [] + + +@pytest.mark.parametrize(("method", "path", "body"), ROUTES) +def test_an_unapproved_phone_sees_no_stop(method, path, body): + stops = FakeStops() + app, pairings = _app(stops) + pairing = pairings.create(ORIGIN, reachable_from_phone=True) + claim = pairings.claim(pairing.secret) + headers = {"authorization": f"Bearer {claim['session_token']}", "origin": ORIGIN} + + status, _headers, _raw = app.handle(method, path, headers, body) + + assert status == 202 + assert stops.calls == [] + + +@pytest.mark.parametrize(("method", "path", "body"), ROUTES) +def test_another_sites_request_is_refused(method, path, body): + stops = FakeStops() + app, pairings = _app(stops) + headers = {**_paired(pairings), "origin": "https://evil.example"} + + status, _headers, raw = app.handle(method, path, headers, body) + + assert status == 403 + assert json.loads(raw)["reason"] == "cross_origin" + assert stops.calls == [] + + +def test_a_cross_site_read_without_an_origin_is_refused(): + stops = FakeStops() + app, pairings = _app(stops) + headers = {k: v for k, v in _paired(pairings).items() if k != "origin"} + + status, _h, _b = app.handle( + "GET", "/api/portal/stops", {**headers, "sec-fetch-site": "cross-site"}, b"" + ) + assert status == 403 + # A same-origin read leaves Origin off; that is served. + status, _h, _b = app.handle( + "GET", "/api/portal/stops", {**headers, "sec-fetch-site": "same-origin"}, b"" + ) + assert status == 200 + + +@pytest.mark.parametrize("drop", ["origin", "x-openadapt-portal-csrf", "content-type"]) +def test_an_answer_needs_the_origin_the_csrf_token_and_json(drop): + stops = FakeStops() + app, pairings = _app(stops) + headers = {k: v for k, v in _paired(pairings).items() if k != drop} + + status, _h, _b = app.handle( + "POST", f"/api/portal/stops/{HEX}/answer", headers, b'{"choice":"same"}' + ) + + assert status in {403, 415} + assert stops.calls == [] + + +def test_the_routes_pass_the_device_session_and_never_the_token(): + stops = FakeStops() + app, pairings = _app(stops) + headers = _paired(pairings) + + assert app.handle("GET", "/api/portal/stops", headers, b"")[0] == 200 + status, image_headers, image = app.handle( + "GET", f"/api/portal/stops/{HEX}/screenshot", headers, b"" + ) + assert status == 200 and image.startswith(b"\x89PNG") + assert image_headers["Content-Type"] == "image/png" + assert image_headers["Cache-Control"].startswith("no-store") + body = json.dumps({"choice": "same", "shown_ms": 1500}).encode() + assert app.handle("POST", f"/api/portal/stops/{HEX}/answer", headers, body)[0] == 200 + + (answer,) = [c for c in stops.calls if c[0] == "answer"] + _, card_ids, choice, shown_ms, device = answer + assert card_ids == [f"sha256:{HEX}"] and choice == "same" + assert shown_ms == {f"sha256:{HEX}": 1500} + (session,) = pairings.devices() + assert device == {"id": session["session_id"], "label": "Ward phone"} + assert headers["authorization"].split()[1] not in json.dumps(device) + + +@pytest.mark.parametrize( + "payload", + [ + {"card_ids": [], "choice": "same"}, + {"card_ids": ["not-a-card"], "choice": "same"}, + {"card_ids": [f"sha256:{HEX}"] * 26, "choice": "same"}, + {"card_ids": [f"sha256:{HEX}"], "choice": "Same!"}, + {"card_ids": f"sha256:{HEX}", "choice": "same"}, + ], +) +def test_a_malformed_batch_never_reaches_the_queue(payload): + stops = FakeStops() + app, pairings = _app(stops) + + status, _h, _b = app.handle( + "POST", "/api/portal/stops/answer", _paired(pairings), json.dumps(payload).encode() + ) + + assert status == 400 + assert stops.calls == [] + + +def test_the_phone_page_is_served_and_its_cards_are_not(): + app, _pairings = _app(FakeStops()) + for path in ("/stops", "/stops.js", "/stops.css"): + status, headers, body = app.handle("GET", path, {}, b"") + assert status == 200 and body + assert headers["Cache-Control"] == "no-cache" + + +def test_without_a_queue_the_routes_say_so(): + app, pairings = _app(None) + status, _h, raw = app.handle("GET", "/api/portal/stops", _paired(pairings), b"") + assert status == 503 and json.loads(raw)["reason"] == "stops_unavailable" + + +# -- signing with the portal key ---------------------------------------------------- + + +@pytest.fixture +def portal_key(monkeypatch, tmp_path: Path): + from openadapt_desktop import approver_key + + monkeypatch.setattr(approver_key, "KEY_DIR", tmp_path / "approver") + return approver_key.setup_approver_key("portal") + + +@pytest.fixture +def phone(queue): # noqa: F811 + disp, db, keychain = queue + disp.serial_lock = threading.RLock() + app, pairings = _app(DispatcherStopQueue(disp)) + return app, _paired(pairings), pairings, disp, db, keychain + + +def _phone_cards(app, headers) -> dict: + status, _h, raw = app.handle("GET", "/api/portal/stops", headers, b"") + assert status == 200 + return json.loads(raw) + + +def _receipts(disp) -> list[dict]: + path = disp.config.data_dir / "stop_decisions.jsonl" + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text().splitlines()] + + +def test_the_phone_lists_the_same_cards_as_the_window(phone, portal_key): + app, headers, _pairings, disp, db, _keychain = phone + _stop(db, "item-01", lname="Fairwick") + + listed = _phone_cards(app, headers) + + assert listed["cards"] == disp.dispatch("list_stop_cards", {})["cards"] + assert listed["portal_key"] == {"ready": True} + + +def test_the_phone_gets_the_cards_own_screenshot_and_nothing_else(phone, monkeypatch): + import base64 + + app, headers, _pairings, disp, db, _keychain = phone + _stop(db, "item-06", lname="Fairwick") + png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 + asked: list = [] + monkeypatch.setattr( + disp, + "get_attention_screenshot", + lambda **p: ( + asked.append(p) + or {"ok": True, "data_url": "data:image/png;base64," + base64.b64encode(png).decode()} + ), + ) + (card,) = _phone_cards(app, headers)["cards"] + + status, _h, body = app.handle( + "GET", + f"/api/portal/stops/{card['card_id'].removeprefix('sha256:')}/screenshot", + headers, + b"", + ) + assert status == 200 and body == png + assert asked == [{"item_id": "item-06"}] + # A card id that isn't in the queue shows nothing. + status, _h, _b = app.handle("GET", f"/api/portal/stops/{HEX}/screenshot", headers, b"") + assert status == 404 + + +def test_a_phone_answer_is_signed_with_the_portal_key(phone, portal_key, enclave): # noqa: F811 + app, headers, pairings, disp, db, keychain = phone + _stop(db, "item-02", lname="Fairwick") + (card,) = _phone_cards(app, headers)["cards"] + + status, _h, raw = app.handle( + "POST", + f"/api/portal/stops/{card['card_id'].removeprefix('sha256:')}/answer", + headers, + json.dumps({"choice": "same", "shown_ms": 3100}).encode(), + ) + + assert status == 200 and json.loads(raw)["ok"] is True + assert db.get_work_item("item-02")["status"] == "skipped" + # No Touch ID prompt on the computer: the phone can't answer one. + assert enclave.prompts == [] + (receipt,) = _receipts(disp) + assert receipt["key"] == "portal" + assert receipt["public_key"] == portal_key.public_key + assert receipt["public_key"] != keychain.stored().public_key + (session,) = pairings.devices() + assert receipt["device"] == {"id": session["session_id"], "label": "Ward phone"} + assert receipt["shown_ms"] == {card["card_id"]: 3100} + message = stop_queue.decision_message( + [card["card_id"]], "same", decided_at=receipt["decided_at"] + ) + Ed25519PublicKey.from_public_bytes(bytes.fromhex(portal_key.public_key)).verify( + bytes.fromhex(receipt["signature"].removeprefix("ed25519:")), message + ) + + +def test_the_window_cannot_choose_the_portal_key(queue, portal_key, enclave): # noqa: F811 + disp, db, keychain = queue + _stop(db, "item-03", lname="Fairwick") + (card,) = disp.dispatch("list_stop_cards", {})["cards"] + + answer = disp.dispatch( + "answer_stop_cards", + { + "card_ids": [card["card_id"]], + "choice": "same", + # None of these is a parameter; the window's answer is still + # signed with this computer's key, after Touch ID. + "key": "portal", + "route": "portal", + "sign": "portal", + "device": {"id": "forged", "label": "forged"}, + }, + ) + + assert answer["ok"] is True + assert enclave.prompts == ["record your answer to this stop"] + (receipt,) = _receipts(disp) + assert receipt["key"] == "keychain" + assert receipt["public_key"] == keychain.stored().public_key + assert "device" not in receipt + # The portal's entry point is no command on any wire. + assert "answer_stop_cards_from_portal" not in disp.commands + with pytest.raises(KeyError): + disp.dispatch("answer_stop_cards_from_portal", {}) + + +def test_a_stale_card_from_the_phone_changes_nothing(phone, portal_key): + app, headers, _pairings, disp, db, _keychain = phone + _stop(db, "item-04", lname="Fairwick") + (card,) = _phone_cards(app, headers)["cards"] + db.update_work_item("item-04", halt_reason="a different stop") + + status, _h, raw = app.handle( + "POST", + f"/api/portal/stops/{card['card_id'].removeprefix('sha256:')}/answer", + headers, + b'{"choice":"same"}', + ) + + assert status == 409 and json.loads(raw)["code"] == "card_changed" + assert db.get_work_item("item-04")["status"] == "open" + assert _receipts(disp) == [] + + +def test_a_choice_the_card_doesnt_offer_changes_nothing_from_the_phone(phone, portal_key): + app, headers, _pairings, disp, db, _keychain = phone + _stop(db, "item-05", lname="Fairwick") + (card,) = _phone_cards(app, headers)["cards"] + + status, _h, raw = app.handle( + "POST", + f"/api/portal/stops/{card['card_id'].removeprefix('sha256:')}/answer", + headers, + b'{"choice":"approve"}', + ) + + assert status == 422 and json.loads(raw)["code"] == "choice_not_offered" + assert db.get_work_item("item-05")["status"] == "open" + + +def test_a_batch_from_the_phone_is_one_portal_signature(phone, portal_key, enclave): # noqa: F811 + app, headers, _pairings, disp, db, _keychain = phone + for index, name in enumerate(["Fairwick", "Caldley", "Jesswood"], start=10): + _stop(db, f"item-{index}", lname=name) + cards = _phone_cards(app, headers)["cards"] + ids = [c["card_id"] for c in cards] + + status, _h, raw = app.handle( + "POST", + "/api/portal/stops/answer", + headers, + json.dumps({"card_ids": ids, "choice": "same", "shown_ms": {ids[0]: 900}}).encode(), + ) + + assert status == 200 + assert json.loads(raw)["changed"] == 3 + assert enclave.prompts == [] + (receipt,) = _receipts(disp) + assert receipt["key"] == "portal" and receipt["card_ids"] == sorted(ids) + message = stop_queue.decision_message(ids, "same", decided_at=receipt["decided_at"]) + Ed25519PublicKey.from_public_bytes(bytes.fromhex(portal_key.public_key)).verify( + bytes.fromhex(receipt["signature"].removeprefix("ed25519:")), message + ) + assert _phone_cards(app, headers)["cards"] == [] + + +def test_one_stale_card_stops_the_phones_whole_batch(phone, portal_key): + app, headers, _pairings, disp, db, _keychain = phone + _stop(db, "item-20", lname="Fairwick") + _stop(db, "item-21", lname="Caldley") + ids = [c["card_id"] for c in _phone_cards(app, headers)["cards"]] + db.update_work_item("item-21", status="skipped") + + status, _h, raw = app.handle( + "POST", + "/api/portal/stops/answer", + headers, + json.dumps({"card_ids": ids, "choice": "same"}).encode(), + ) + + assert status == 409 and json.loads(raw)["code"] == "card_changed" + assert db.get_work_item("item-20")["status"] == "open" + assert _receipts(disp) == [] + + +def test_without_the_portal_key_the_phone_changes_nothing(phone, monkeypatch, tmp_path): + from openadapt_desktop import approver_key + + monkeypatch.setattr(approver_key, "KEY_DIR", tmp_path / "no-keys") + app, headers, _pairings, disp, db, _keychain = phone + _stop(db, "item-30", lname="Fairwick") + listed = _phone_cards(app, headers) + assert listed["portal_key"] == {"ready": False} + (card,) = listed["cards"] + + status, _h, raw = app.handle( + "POST", + f"/api/portal/stops/{card['card_id'].removeprefix('sha256:')}/answer", + headers, + b'{"choice":"same"}', + ) + + assert status == 503 and json.loads(raw)["code"] == "approver_key_missing" + assert db.get_work_item("item-30")["status"] == "open" + + +def test_a_waiting_save_is_view_only_on_the_stop_queue(phone, portal_key, monkeypatch): + from tests.test_engine.test_stop_queue import _waiting_state + + app, headers, _pairings, disp, _db, _keychain = phone + disp._approval_watches["run"] = {"workflow_id": "wf-ref"} + monkeypatch.setattr( + disp, "get_item_approval", lambda **_: _waiting_state("2999-01-01T00:00:00+00:00") + ) + monkeypatch.setattr( + disp, "answer_item_approval", lambda **_: pytest.fail("answered a save from the queue") + ) + (card,) = _phone_cards(app, headers)["cards"] + assert card["kind"] == "approve_save" + + status, _h, raw = app.handle( + "POST", + f"/api/portal/stops/{card['card_id'].removeprefix('sha256:')}/answer", + headers, + b'{"choice":"approve"}', + ) + + assert status == 409 and json.loads(raw)["code"] == "use_approval" + assert _receipts(disp) == [] + + +def _while_the_window_is_busy(disp, answer): + held = threading.Event() + release = threading.Event() + + def hold(): + with disp.serial_lock: + held.set() + release.wait(5) + + worker = threading.Thread(target=hold) + worker.start() + held.wait(5) + try: + return answer() + finally: + release.set() + worker.join() + + +def test_an_answer_that_runs_an_item_again_waits_for_the_window(phone, portal_key): + app, headers, _pairings, disp, db, _keychain = phone + _stop(db, "item-40", lname="Fairwick", reason="a step's target wasn't found") + (card,) = _phone_cards(app, headers)["cards"] + assert any(c["id"] == "retry" for c in card["choices"]) + + status, _h, raw = _while_the_window_is_busy( + disp, + lambda: app.handle( + "POST", + f"/api/portal/stops/{card['card_id'].removeprefix('sha256:')}/answer", + headers, + b'{"choice":"retry"}', + ), + ) + + assert status == 503 and json.loads(raw)["code"] == "busy" + assert db.get_work_item("item-40")["status"] == "open" + + +def test_closing_a_stop_goes_through_while_the_window_is_busy(phone, portal_key): + app, headers, _pairings, disp, db, _keychain = phone + _stop(db, "item-41", lname="Fairwick") + (card,) = _phone_cards(app, headers)["cards"] + + status, _h, raw = _while_the_window_is_busy( + disp, + lambda: app.handle( + "POST", + f"/api/portal/stops/{card['card_id'].removeprefix('sha256:')}/answer", + headers, + b'{"choice":"same"}', + ), + ) + + assert status == 200, raw + assert db.get_work_item("item-41")["status"] == "skipped" + + +def test_the_ipc_handler_shares_its_lock_with_the_phone(tmp_path): + from openadapt_desktop.config import EngineConfig + from openadapt_desktop.ipc import IPCHandler + + config = EngineConfig(data_dir=tmp_path / ".openadapt", log_level="WARNING") + handler = IPCHandler(config=config) + assert handler.dispatcher.serial_lock is handler.dispatch_lock diff --git a/packages/desktop/tests/test_engine/test_work_items.py b/packages/desktop/tests/test_engine/test_work_items.py index dc77ff11d..79585abdd 100644 --- a/packages/desktop/tests/test_engine/test_work_items.py +++ b/packages/desktop/tests/test_engine/test_work_items.py @@ -398,7 +398,7 @@ def test_a_proven_no_write_stop_can_run_again_once(task) -> None: item = first["item"] assert item["halt_kind"] == "wrong_record" assert item["expected"] == "M-1" and item["observed"] == "M-9" - assert item["actions"] == ["retry", "skip", "decline", "teach"] + assert item["actions"] == ["retry", "skip", "decline", "teach", "done_by_person"] inbox = disp.dispatch("list_attention_items", {})["items"] assert [entry["item_id"] for entry in inbox] == [item["item_id"]] assert disp.dispatch("get_needs_attention", {})["count"] == 1 diff --git a/packages/desktop/tests/test_stop_queue_drill.py b/packages/desktop/tests/test_stop_queue_drill.py new file mode 100644 index 000000000..9093f5bf3 --- /dev/null +++ b/packages/desktop/tests/test_stop_queue_drill.py @@ -0,0 +1,41 @@ +"""The recorded drill stays consistent with the stop-queue code that shows it.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from openadapt_desktop import stop_queue + +DRILL = Path(__file__).resolve().parents[1] / "benchmark" / "stop_queue" / "drill-2026-10-24.json" + + +def _drill() -> dict: + return json.loads(DRILL.read_text()) + + +def test_the_drill_has_twenty_soak_stops_with_an_offered_right_answer(): + stops = _drill()["stops"] + + assert len(stops) == 20 + for stop in stops: + card = stop["card"] + offered = {choice["id"] for choice in card["choices"]} + assert set(stop["expected"]) & offered, stop["item_id"] + assert "leave" not in stop["expected"] + + +def test_each_card_id_is_the_digest_of_what_it_shows(): + for stop in _drill()["stops"]: + card = stop["card"] + body = {k: v for k, v in card.items() if k != "card_id"} + assert stop_queue._digest(body) == card["card_id"], stop["item_id"] + + +def test_blind_answers_catch_no_hard_stop(): + """Pressing the first choice every time misses every hard stop.""" + + stops = _drill()["stops"] + hard = [s for s in stops if s["hard"]] + assert hard + assert not any(s["card"]["choices"][0]["id"] in s["expected"] for s in hard) diff --git a/packages/desktop/uv.lock b/packages/desktop/uv.lock index 7deac3418..e70f75b66 100644 --- a/packages/desktop/uv.lock +++ b/packages/desktop/uv.lock @@ -1172,7 +1172,7 @@ wheels = [ [[package]] name = "openadapt-desktop" -version = "0.23.0" +version = "0.24.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, diff --git a/packages/flow/docs/APPROVAL_MODES.md b/packages/flow/docs/APPROVAL_MODES.md index cd8faec3d..4162b4389 100644 --- a/packages/flow/docs/APPROVAL_MODES.md +++ b/packages/flow/docs/APPROVAL_MODES.md @@ -162,6 +162,25 @@ including an answer file written around the command Each route signs with its own key: - **Desktop's Run it** signs with the Desktop approver key. + - On a Mac, a person sets it up in the app, from the approval screen or + Settings, with one Touch ID or login-password prompt. The key is sealed + to a Secure Enclave key that asks for presence on every use. Setup signs + the roster's root with it behind that prompt (or, when a roster exists, + an approver adds it). Flow never sees the key: Desktop runs + `item-approval approve|decline` with `--approver-signer stdio + --approver-public-key HEX`. Flow writes the exact bytes to sign on + stdout; Desktop checks that they are the person's answer, asks for + Touch ID, and writes the signature back on stdin. A cancelled prompt or + any other failure is a refusal, and Flow records nothing. + - On a Mac, Desktop's pin is the root it set up behind Touch ID, kept in + the login Keychain; nothing else names it (not the environment, not a + file the user can write). A process running as the same user can delete + the Keychain items; Desktop then has no pin, and no person's answer + counts until someone sets the key up again behind Touch ID. + - On Windows and Linux, `openadapt-desktop approver-key setup` creates it, + keeps the encrypted key in an owner-only file and its passphrase in the + OS keychain, and prints the `jit approver` commands that put it on the + roster. - **The decision portal** signs with the key at `OPENADAPT_PORTAL_APPROVER_KEY` after the person answered on their phone. Without it, the portal records no answer. Its key is added to the roster like any other approver. diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 8da534942..2d549ff28 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -4738,6 +4738,7 @@ def _cmd_item_approval(args: argparse.Namespace) -> int: """ import json + from openadapt_flow.approval_mode.human_attestation import AttestationRefused from openadapt_flow.approval_mode.item_approval import ( ItemApprovalRefused, ItemApprovalStore, @@ -4822,26 +4823,46 @@ def refuse(code: str, message: str) -> int: if delegate is None: from openadapt_flow.approval_mode.human_attestation import ( ROUTE_METHOD, - AttestationRefused, load_signer, + stdio_signer, ) method = ROUTE_METHOD.get(args.route) if method is None: return refuse("route_not_allowed", "a person answers from desktop_run_it") - if not getattr(args, "approver_key", None): + public_key = getattr(args, "approver_public_key", None) + external = getattr(args, "approver_signer", None) + if bool(public_key) != bool(external): + return refuse( + "approver_invalid", + "--approver-signer stdio and --approver-public-key go together", + ) + if public_key and getattr(args, "approver_key", None): + return refuse( + "approver_invalid", + "pass --approver-key or --approver-signer stdio, not both", + ) + if not public_key and not getattr(args, "approver_key", None): return refuse( "human_answer_unsigned", "a person's answer is signed with the Desktop approver key: pass " "--approver-key. --decided-by human on its own proves nothing", ) try: - signer = load_signer( - args.approver_key, - store.key_directory(), - method=method, - root=store.approver_root, - ) + if public_key: + signer = stdio_signer( + public_key, + store.key_directory(), + method=method, + root=store.approver_root, + ) + else: + signer = load_signer( + args.approver_key, + store.key_directory(), + method=method, + root=store.approver_root, + ) except AttestationRefused as exc: return refuse(exc.code, str(exc)) try: @@ -4873,7 +4894,7 @@ def refuse(code: str, message: str) -> int: signer=signer, ) store.write_answer(answer) - except ItemApprovalRefused as exc: + except (ItemApprovalRefused, AttestationRefused) as exc: return refuse(exc.code, str(exc)) resume_required = _answer_durable_item_pause(run_dir, request, answer) print( @@ -9648,6 +9669,21 @@ def build_parser() -> argparse.ArgumentParser: help="Desktop was signed out: record the answer without a name", ) _add_approver_key_flags(q) + q.add_argument( + "--approver-signer", + choices=["stdio"], + default=None, + help=( + "The process that started this one holds the approver key and " + "signs over stdin/stdout (Desktop's Keychain key). Flow writes " + "one sign request line and reads one response line" + ), + ) + q.add_argument( + "--approver-public-key", + default=None, + help="With --approver-signer: the registered approver key's public key (hex)", + ) q.add_argument( "--approver-head", default=None, @@ -12289,8 +12325,13 @@ def main(argv: Sequence[str] | None = None) -> int: if not getattr(args, "config", None): print("REFUSED: --config-sha256 needs --config", file=sys.stderr) return 2 + # Pin by the absolute path: a relative path plus a later chdir would + # otherwise name a different, unpinned file. + import os + from openadapt_flow.deployment import pin_deployment_bytes + args.config = os.path.abspath(args.config) try: # Read the config once, check it, and parse only those bytes. pin_deployment_bytes(args.config, args.config_sha256) diff --git a/packages/flow/openadapt_flow/approval_mode/human_attestation.py b/packages/flow/openadapt_flow/approval_mode/human_attestation.py index 77222357a..c4054a71d 100644 --- a/packages/flow/openadapt_flow/approval_mode/human_attestation.py +++ b/packages/flow/openadapt_flow/approval_mode/human_attestation.py @@ -9,6 +9,10 @@ for (:mod:`.approver_roster`). * Desktop's Run it signs with the Desktop approver key (``desktop_key``). + Desktop either hands Flow that key's encrypted file (``--approver-key``) or + keeps the key itself and signs over a pipe (``--approver-signer stdio``, + :func:`stdio_signer`): on macOS the key sits in the Keychain behind Touch + ID, and Flow never sees it. * The decision portal signs, after the person answered on their phone, with the portal's approver key (``portal_key``). * ``openadapt-flow approve`` signs a durable resume approval with the @@ -31,9 +35,12 @@ from __future__ import annotations +import json +import re +import sys from dataclasses import dataclass from pathlib import Path -from typing import Any, Literal, Mapping, Optional +from typing import IO, Any, Literal, Mapping, Optional, Protocol from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from pydantic import BaseModel, ConfigDict, Field @@ -49,6 +56,21 @@ ANSWER_DOMAIN = b"openadapt-flow.approval.human-answer/v1\x00" RESUME_DOMAIN = b"openadapt-flow.durable.resume-approval/v1\x00" _ID = r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$" +_HEX_KEY = re.compile(r"^[0-9a-f]{64}$") +#: The one line ``--approver-signer stdio`` writes to stdout, and the one line +#: it then reads from stdin (:class:`StdioSigning`). +SIGN_REQUEST_SCHEMA = "openadapt-flow.approver-sign-request/v1" +SIGN_RESPONSE_SCHEMA = "openadapt-flow.approver-sign-response/v1" +#: Why the process holding the key didn't sign. Anything else reads as +#: ``approver_signature_unavailable``. +SIGN_REFUSALS = frozenset( + { + "approver_presence_refused", + "approver_key_unusable", + "approver_key_missing", + "approver_payload_refused", + } +) #: ``desktop_key``: Desktop's Run it. ``portal_key``: the decision portal. #: ``person_key``: a person at a terminal (``openadapt-flow approve``). @@ -71,21 +93,32 @@ class HumanAttestation(BaseModel): signature: str = Field(min_length=1, max_length=256) +class _Signs(Protocol): + def sign(self, data: bytes, /) -> bytes: ... + + @dataclass(frozen=True) class ApproverSigner: - """An approver key ready to sign, and who the registry says it is.""" + """An approver key ready to sign, and who the registry says it is. + + ``signing`` is the key itself, or :class:`StdioSigning` when the process + that started this one holds it. + """ principal_id: str method: AttestationMethod - signing: Ed25519PrivateKey + signing: "Ed25519PrivateKey | _Signs" def attest(self, domain: bytes, payload: Any) -> HumanAttestation: - from openadapt_flow.jit.authority import _sign + from openadapt_flow.jit.authority import _canonical + # The same bytes and format as ``authority._sign``, for a key held + # here or by the process that started this one. + raw = self.signing.sign(domain + _canonical(payload)) return HumanAttestation( signer=self.principal_id, method=self.method, - signature=_sign(self.signing, domain, payload), + signature="ed25519:" + raw.hex(), ) @@ -166,6 +199,115 @@ def load_signer( return ApproverSigner(principal_id=found, method=method, signing=key) +class StdioSigning: + """Ask the process that started this one to sign one message. + + Writes one :data:`SIGN_REQUEST_SCHEMA` line to ``stdout`` with the exact + bytes to sign (the domain, then the canonical answer), and reads one + :data:`SIGN_RESPONSE_SCHEMA` line from ``stdin``: a signature, or a + refusal (a cancelled Touch ID prompt, a Keychain error). The signature is + checked against the registered public key before it is used. Anything + else is :class:`AttestationRefused`, so nothing unsigned is written. + """ + + def __init__( + self, + public_key_hex: str, + *, + stdin: Optional[IO[str]] = None, + stdout: Optional[IO[str]] = None, + ) -> None: + self._public = public_key_hex + self._stdin = stdin + self._stdout = stdout + + def sign(self, data: bytes, /) -> bytes: + from openadapt_flow.jit.authority import _public + + out = self._stdout or sys.stdout + out.write( + json.dumps( + { + "schema": SIGN_REQUEST_SCHEMA, + "public_key": self._public, + "message_hex": data.hex(), + }, + sort_keys=True, + ) + + "\n" + ) + out.flush() + line = (self._stdin or sys.stdin).readline(64 * 1024) + try: + response = json.loads(line) + except ValueError: + response = None + if not isinstance(response, dict) or response.get("schema") != ( + SIGN_RESPONSE_SCHEMA + ): + raise AttestationRefused( + "approver_signature_unavailable", + "the approver key's holder sent no signature, so nothing was recorded", + ) + signature = response.get("signature") + if not isinstance(signature, str): + code = str(response.get("refused") or "") + if code not in SIGN_REFUSALS: + code = "approver_signature_unavailable" + message = str(response.get("message") or "")[:500] + raise AttestationRefused( + code, message or "the approver key's holder didn't sign" + ) + try: + raw = bytes.fromhex(signature.removeprefix("ed25519:")) + _public(self._public).verify(raw, data) + except Exception as exc: + raise AttestationRefused( + "human_answer_signature_invalid", + "the signature the approver key's holder sent doesn't match " + "the registered key", + ) from exc + return raw + + +def stdio_signer( + public_key_hex: str, + key_dir: Path, + *, + method: AttestationMethod, + stdin: Optional[IO[str]] = None, + stdout: Optional[IO[str]] = None, + root: Optional[str] = None, +) -> ApproverSigner: + """A signer for an approver key another process holds. + + The principal is the name ``public_key_hex`` holds on the roster the + run's pinned root vouches for, as in :func:`load_signer`. Raises + :class:`AttestationRefused`. + """ + + public = str(public_key_hex or "").lower() + if not _HEX_KEY.fullmatch(public): + raise AttestationRefused( + "approver_key_unusable", "--approver-public-key is 64 hex characters" + ) + found = next( + (pid for pid, value in approvers(key_dir, root).items() if value == public), + None, + ) + if found is None: + raise AttestationRefused( + "human_approver_unregistered", + "this key isn't on the approver roster; set it up in Desktop, or an " + "approver adds it with openadapt-flow jit approver add", + ) + return ApproverSigner( + principal_id=found, + method=method, + signing=StdioSigning(public, stdin=stdin, stdout=stdout), + ) + + def refusal( attestation: Optional[HumanAttestation], *, diff --git a/packages/flow/openadapt_flow/jit/authority.py b/packages/flow/openadapt_flow/jit/authority.py index aefd788de..e179e16ac 100644 --- a/packages/flow/openadapt_flow/jit/authority.py +++ b/packages/flow/openadapt_flow/jit/authority.py @@ -296,6 +296,14 @@ def lookup(self, public_key_hex: str) -> Optional[tuple[Role, str]]: return None +_ROLE_FIELDS: dict[str, str] = { + "policy_signer": "policy_signers", + "admitter_key": "admitter_keys", + "agent": "agents", + "approver": "approvers", +} + + def register_principal( key_dir: Path, *, role: Role, principal_id: str, public_key_hex: str ) -> PrincipalRegistry: @@ -307,12 +315,7 @@ def register_principal( "one with openadapt-flow jit approver add" ) registry = PrincipalRegistry.load(key_dir) - field_name = { - "policy_signer": "policy_signers", - "admitter_key": "admitter_keys", - "agent": "agents", - "approver": "approvers", - }.get(role) + field_name = _ROLE_FIELDS.get(role) if field_name is None: raise AuthorityError(f"unknown role {role!r}") data = registry.model_dump(by_alias=True) @@ -325,6 +328,30 @@ def register_principal( return updated +def remove_principal( + key_dir: Path, *, role: Role, principal_id: str, public_key_hex: str +) -> bool: + """Remove one registered key, only if it is still the one registered. + + Returns whether anything was removed. A principal that has since been + registered with a different key is left alone, so retiring an old key + can't take away its replacement. + """ + + field_name = _ROLE_FIELDS.get(role) + if field_name is None: + raise AuthorityError(f"unknown role {role!r}") + registry = PrincipalRegistry.load(key_dir) + entries = dict(getattr(registry, field_name)) + if entries.get(principal_id) != public_key_hex.lower(): + return False + del entries[principal_id] + data = registry.model_dump(by_alias=True) + data[field_name] = entries + PrincipalRegistry.model_validate(data).save(key_dir) + return True + + # -- standard repairs ------------------------------------------------------------ #: How many new menu steps a standard repair may add before a moved control. diff --git a/packages/flow/tests/test_approver_roster.py b/packages/flow/tests/test_approver_roster.py index 52db678a8..d8344a733 100644 --- a/packages/flow/tests/test_approver_roster.py +++ b/packages/flow/tests/test_approver_roster.py @@ -946,3 +946,106 @@ def parse(self, argv=None, namespace=None): assert seen["pin"] == first finally: _PINNED_CONFIG_BYTES.clear() + + +def test_a_relative_config_path_stays_pinned_after_a_chdir(tmp_path, monkeypatch): + """Attack: pin a relative --config, then chdir to a directory holding a + different deployment.yaml; the later load must still see the pinned bytes.""" + import hashlib + import os + + from openadapt_flow import __main__ as cli + from openadapt_flow.deployment import _PINNED_CONFIG_BYTES, load_deployment + + _a, first = _key() + _b, agent = _key() + home = tmp_path / "home" + other = tmp_path / "other" + home.mkdir() + other.mkdir() + (home / "deployment.yaml").write_text(f"approvers:\n root_public_key: {first}\n") + (other / "deployment.yaml").write_text(f"approvers:\n root_public_key: {agent}\n") + digest = hashlib.sha256((home / "deployment.yaml").read_bytes()).hexdigest() + seen: dict = {} + + def fake_func(args): + os.chdir(other) + seen["config"] = args.config + seen["root"] = load_deployment(args.config).approvers.root_public_key + return 0 + + monkeypatch.chdir(home) + try: + original_parse = argparse.ArgumentParser.parse_args + + def parse(self, argv=None, namespace=None): + ns = original_parse(self, argv, namespace) + ns.func = fake_func + return ns + + monkeypatch.setattr(argparse.ArgumentParser, "parse_args", parse) + cli.main( + ["run", "bundle", "--config", "deployment.yaml", "--config-sha256", digest] + ) + assert seen["config"] == str(home / "deployment.yaml") + assert seen["root"] == first + finally: + _PINNED_CONFIG_BYTES.clear() + + +def test_a_config_without_approvers_runs_and_refuses_only_person_answers( + tmp_path, monkeypatch +): + """Desktop with no Keychain pin hands Flow a config with no approvers + section: the run starts as normal, and only a person's answer is refused.""" + import hashlib + + from openadapt_flow import __main__ as cli + from openadapt_flow.deployment import _PINNED_CONFIG_BYTES + + config = tmp_path / "deployment.yaml" + config.write_text("name: clinic\n") + digest = hashlib.sha256(config.read_bytes()).hexdigest() + seen: dict = {} + + def fake_func(args): + seen["pin"] = cli._approver_pin(args, config_only=True) + return 0 + + try: + original_parse = argparse.ArgumentParser.parse_args + + def parse(self, argv=None, namespace=None): + ns = original_parse(self, argv, namespace) + ns.func = fake_func + return ns + + monkeypatch.setattr(argparse.ArgumentParser, "parse_args", parse) + code = cli.main( + [ + "run", + str(tmp_path / "bundle"), + "--config", + str(config), + "--config-sha256", + digest, + ] + ) + assert code == 0 # a run with no gate proceeds + assert seen["pin"]["config_root"] is None + finally: + _PINNED_CONFIG_BYTES.clear() + + run = ItemApprovalStore(tmp_path / "run", config_root=None, config_only=True) + run.write_approver_root() + assert run.context().pin_refusal == "approver_root_unpinned" + + +def test_an_approvers_section_without_a_root_is_unreadable(tmp_path): + """Why Desktop drops the whole section: key_dir alone isn't a valid one.""" + from openadapt_flow.deployment import load_deployment + + config = tmp_path / "deployment.yaml" + config.write_text("approvers:\n key_dir: /tmp/keys\n") + with pytest.raises(ValueError): + load_deployment(str(config), allow_unresolved=True) diff --git a/packages/flow/tests/test_human_approval_stdio_signer.py b/packages/flow/tests/test_human_approval_stdio_signer.py new file mode 100644 index 000000000..798a0617a --- /dev/null +++ b/packages/flow/tests/test_human_approval_stdio_signer.py @@ -0,0 +1,285 @@ +"""Desktop signs a person's answer over a pipe; Flow never sees the key. + +``item-approval approve|decline --approver-signer stdio --approver-public-key +HEX`` writes one sign request line and reads one response line. The process +that started Flow (Desktop, with the key in the macOS Keychain behind Touch +ID) checks what it signs and answers with a signature or a refusal. These +tests play that process. +""" + +from __future__ import annotations + +import io +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from openadapt_flow.approval_mode.approver_roster import ( + ROOT_ENV, + add_approver, + file_key_signer, + init_root, + remove_approver, +) +from openadapt_flow.approval_mode.human_attestation import ( + ANSWER_DOMAIN, + SIGN_REQUEST_SCHEMA, + SIGN_RESPONSE_SCHEMA, + AttestationRefused, + StdioSigning, + stdio_signer, +) +from openadapt_flow.approval_mode.item_approval import ItemApprovalStore +from openadapt_flow.approval_mode.model import ApprovalMode +from openadapt_flow.jit.authority import public_hex +from openadapt_flow.jit.lifecycle import KEY_DIR_ENV + +FRAME = b"\x89PNG\r\n\x1a\nmade-up filled form" + + +def _request(store: ItemApprovalStore): + return store.open_request( + run_id="run-0001", + workflow_name="referral", + step_id="save", + step_index=3, + step_intent="Click 'Save'", + mode=ApprovalMode.EACH_ITEM, + bundle_content_digest="a" * 64, + runtime_inputs_digest="b" * 64, + frame_png=FRAME, + form_state=("dom", "sha256:" + "1" * 64), + ) + + +@pytest.fixture +def held_key(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """The roster's root approver key, held by 'Desktop'; the run pins it.""" + + key_dir = tmp_path / "keys" + monkeypatch.setenv(KEY_DIR_ENV, str(key_dir)) + key = Ed25519PrivateKey.generate() + init_root( + key_dir, + principal="front-desk", + public_key=public_hex(key), + sign=file_key_signer(key), + ) + monkeypatch.setenv(ROOT_ENV, public_hex(key)) + return key_dir, key + + +def _response(**fields) -> str: + return json.dumps({"schema": SIGN_RESPONSE_SCHEMA, **fields}) + "\n" + + +def _args(store, request, public: str, verb: str = "approve") -> list[str]: + args = [ + "item-approval", + verb, + str(store.run_dir), + "--request-id", + request.request_id, + "--request-sha256", + request.digest(), + "--decided-by", + "human", + "--route", + "desktop_run_it", + "--unauthenticated", + "--approver-signer", + "stdio", + "--approver-public-key", + public, + ] + if verb == "decline": + args += ["--reason", "wrong_values"] + return args + + +def _run_cli(args: list[str], answer) -> tuple[int, list[dict]]: + """Run Flow as Desktop does: read its request, write ``answer(request)``.""" + + env = {**os.environ} + proc = subprocess.Popen( + [sys.executable, "-m", "openadapt_flow", *args], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + assert proc.stdout is not None and proc.stdin is not None + lines: list[dict] = [] + first = json.loads(proc.stdout.readline()) + lines.append(first) + if first.get("schema") == SIGN_REQUEST_SCHEMA: + proc.stdin.write(answer(first)) + proc.stdin.flush() + rest = proc.stdout.read() + lines += [json.loads(line) for line in rest.splitlines() if line.strip()] + proc.stdin.close() + proc.wait(timeout=60) + return proc.returncode, lines + + +def test_desktop_signs_over_the_pipe_and_the_answer_counts(tmp_path, held_key): + key_dir, key = held_key + store = ItemApprovalStore(tmp_path / "run", key_dir=key_dir) + request = _request(store) + seen: list[bytes] = [] + + def sign(sign_request: dict) -> str: + message = bytes.fromhex(sign_request["message_hex"]) + seen.append(message) + assert sign_request["public_key"] == public_hex(key) + return _response(signature="ed25519:" + key.sign(message).hex()) + + code, lines = _run_cli(_args(store, request, public_hex(key)), sign) + + assert code == 0, lines + assert lines[-1]["ok"] is True and lines[-1]["decision"] == "approve" + # What Desktop signed is the domain, then the answer it can read and check. + assert seen[0].startswith(ANSWER_DOMAIN) + payload = json.loads(seen[0][len(ANSWER_DOMAIN) :]) + assert payload["request_id"] == request.request_id + assert payload["decision"] == "approve" + answer = store.read_answer(request.request_id) + assert answer is not None and answer.attestation is not None + assert answer.attestation.signer == "front-desk" + assert answer.attestation.method == "desktop_key" + + +def test_a_cancelled_touch_id_prompt_records_nothing(tmp_path, held_key): + key_dir, key = held_key + store = ItemApprovalStore(tmp_path / "run", key_dir=key_dir) + request = _request(store) + + code, lines = _run_cli( + _args(store, request, public_hex(key)), + lambda _req: _response( + refused="approver_presence_refused", message="Touch ID was cancelled." + ), + ) + + assert code == 2 + assert lines[-1] == { + "ok": False, + "code": "approver_presence_refused", + "message": "Touch ID was cancelled.", + } + assert store.read_answer(request.request_id) is None + + +def test_a_signature_by_another_key_is_refused(tmp_path, held_key): + key_dir, key = held_key + store = ItemApprovalStore(tmp_path / "run", key_dir=key_dir) + request = _request(store) + other = Ed25519PrivateKey.generate() + + code, lines = _run_cli( + _args(store, request, public_hex(key)), + lambda req: _response( + signature="ed25519:" + other.sign(bytes.fromhex(req["message_hex"])).hex() + ), + ) + + assert code == 2 and lines[-1]["code"] == "human_answer_signature_invalid" + assert store.read_answer(request.request_id) is None + + +def test_an_unregistered_public_key_never_asks_for_a_signature(tmp_path, held_key): + key_dir, _key = held_key + store = ItemApprovalStore(tmp_path / "run", key_dir=key_dir) + request = _request(store) + stranger = Ed25519PrivateKey.generate() + + code, lines = _run_cli( + _args(store, request, public_hex(stranger)), + lambda _req: pytest.fail("Flow asked an unregistered key to sign"), + ) + + assert code == 2 and lines[-1]["code"] == "human_approver_unregistered" + + +def test_a_removed_key_no_longer_signs(tmp_path, held_key): + key_dir, root = held_key + other = Ed25519PrivateKey.generate() + add_approver( + key_dir, + public_hex(root), + principal="night-desk", + public_key=public_hex(other), + signed_by="front-desk", + sign=file_key_signer(root), + ) + assert stdio_signer( + public_hex(other), key_dir, method="desktop_key", root=public_hex(root) + ) + remove_approver( + key_dir, + public_hex(root), + principal="night-desk", + public_key=public_hex(other), + signed_by="front-desk", + sign=file_key_signer(root), + ) + with pytest.raises(AttestationRefused) as raised: + stdio_signer( + public_hex(other), key_dir, method="desktop_key", root=public_hex(root) + ) + assert raised.value.code == "human_approver_unregistered" + + +def test_a_key_the_pinned_root_doesnt_vouch_for_never_signs( + tmp_path, held_key, monkeypatch +): + key_dir, root = held_key + other_root = public_hex(Ed25519PrivateKey.generate()) + + with pytest.raises(AttestationRefused) as raised: + stdio_signer(public_hex(root), key_dir, method="desktop_key", root=other_root) + assert raised.value.code == "approver_root_mismatch" + + +def test_signer_flags_go_together(tmp_path, held_key, capsys): + from openadapt_flow.__main__ import main + + key_dir, key = held_key + store = ItemApprovalStore(tmp_path / "run", key_dir=key_dir) + request = _request(store) + args = _args(store, request, public_hex(key)) + without_signer = [a for a in args if a not in {"--approver-signer", "stdio"}] + + assert main(without_signer) == 2 + assert json.loads(capsys.readouterr().out)["code"] == "approver_invalid" + + +@pytest.mark.parametrize( + "line, code", + [ + ("", "approver_signature_unavailable"), + ("not json\n", "approver_signature_unavailable"), + (json.dumps({"schema": "other"}) + "\n", "approver_signature_unavailable"), + (_response(refused="made_up_code"), "approver_signature_unavailable"), + (_response(refused="approver_key_unusable"), "approver_key_unusable"), + (_response(signature="ed25519:zz"), "human_answer_signature_invalid"), + ], +) +def test_anything_but_a_valid_signature_is_a_refusal(line, code): + key = Ed25519PrivateKey.generate() + out = io.StringIO() + signing = StdioSigning(public_hex(key), stdin=io.StringIO(line), stdout=out) + + with pytest.raises(AttestationRefused) as raised: + signing.sign(b"message") + + assert raised.value.code == code + request = json.loads(out.getvalue()) + assert request["schema"] == SIGN_REQUEST_SCHEMA + assert request["message_hex"] == b"message".hex()