Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion packages/agent/tests/test_approval_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,33 @@ def test_nobody_answers_needs_review_and_the_server_never_kills_the_wait(mockmed
assert _records(mockmed["base_url"]) == []


def test_a_person_approves_and_the_run_saves_once(mockmed):
def _approver_key(tmp_path, monkeypatch):
"""A person's approver key, registered in a throwaway key directory.

The run and the answer both read that directory, so only an answer this
key signed counts as the person's.
"""
from openadapt_flow.jit.authority import (
KEY_PASSPHRASE_ENV,
generate_encrypted_key,
register_principal,
)
from openadapt_flow.jit.lifecycle import KEY_DIR_ENV

key_dir = tmp_path / "keys"
monkeypatch.setenv(KEY_DIR_ENV, str(key_dir))
monkeypatch.setenv(KEY_PASSPHRASE_ENV, "synthetic")
key_path = key_dir / "front-desk.approver.pem"
public = generate_encrypted_key(key_path)
register_principal(key_dir, role="approver", principal_id="front-desk", public_key_hex=public)
return key_path


def test_a_person_approves_and_the_run_saves_once(mockmed, tmp_path, monkeypatch):
import urllib.request

key_path = _approver_key(tmp_path, monkeypatch)

urllib.request.urlopen(
urllib.request.Request(f"{mockmed['base_url']}api/reset", data=b"{}", method="POST"),
timeout=10,
Expand Down Expand Up @@ -190,6 +214,8 @@ def test_a_person_approves_and_the_run_saves_once(mockmed):
"--route",
"desktop_run_it",
"--unauthenticated",
"--approver-key",
str(key_path),
)
assert approved.returncode == 0, approved.stdout + approved.stderr
final = bridge.dispatch("get_run", {"run_id": result["run_id"], "wait_seconds": 120})
Expand Down
11 changes: 10 additions & 1 deletion packages/agent/tests/test_guard_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pytest

from openadapt_flow.agent.guard_tools import GuardBridge
from openadapt_flow.approval_mode.demo_approver import demo_approver
from openadapt_flow.agent.mcp import build_server
from openadapt_flow.guard import GUARD_TASK_SCHEMA, GuardedSave
from openadapt_flow.guard_demo import (
Expand Down Expand Up @@ -45,7 +46,10 @@ def _server(base, tmp_path, approval="each_item"):
)
)
guarded = GuardedSave(
parse_verify_config(verify_yaml(base)), tmp_path / "state", approval=approval
parse_verify_config(verify_yaml(base)),
tmp_path / "state",
approval=approval,
key_dir=tmp_path / "keys",
)
return build_server(guard=GuardBridge(guarded, tasks))

Expand All @@ -65,6 +69,9 @@ def test_guarded_save_over_mcp(mockmed, tmp_path, mcp_client):
base, db = mockmed
server = _server(base, tmp_path)
agent = FakeAgent(base)
# The test plays the person: a throwaway approver key registered in the
# guard's own key directory signs the answer.
signer = demo_approver(tmp_path / "keys")

async def call(client, name, args):
result = await client.call_tool(name, args)
Expand Down Expand Up @@ -92,6 +99,8 @@ async def flow():
waiting["approval_run_dir"],
waiting["approval_request_id"],
waiting["approval_request_sha256"],
signer=signer,
key_dir=tmp_path / "keys",
)
go = await call(client, "guard_commit", {"guard_id": begun["guard_id"]})
agent.click_save("p1", NOTE, fault="no_persist")
Expand Down
37 changes: 31 additions & 6 deletions packages/agent/tests/test_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from openadapt_flow.agent.ledger_demo import WORKFLOW, SyntheticReferralApp, measure
from openadapt_flow.agent.mcp import build_server
from openadapt_flow.agent.runner import RunnerConfig
from openadapt_flow.approval_mode.demo_approver import demo_approver
from openadapt_flow.approval_mode.item_approval import ItemApprovalStore, make_answer
from openadapt_flow.approval_mode.model import ITEM_APPROVAL_ROUTE
from openadapt_flow.console.save_approvals import (
Expand All @@ -37,14 +38,34 @@
]


#: The person's approver key for each test's runs folder. The tests play the
#: person, so a throwaway key registered in the test's own key directory
#: signs every answer; the ledger reads that directory.
_PERSON: dict = {}


def _person(runs):
"""(key_dir, signer) for this runs folder: the test's registered person."""
key_dir = runs.with_name(runs.name + "-approver-keys")
if runs not in _PERSON:
_PERSON[runs] = (key_dir, demo_approver(key_dir))
return _PERSON[runs]


@pytest.fixture
def world(tmp_path):
runs = tmp_path / "runs"
runs.mkdir()
app = SyntheticReferralApp()
bridge = AgentBridge(None, RunnerConfig(runs_dir=runs), sandbox=app, mode="sandbox")
ledger = TaskLedger(runs / "task-ledger")
return runs, app, build_server(bridge, ledger=ledger), ledger
key_dir, _signer = _person(runs)
ledger = TaskLedger(runs / "task-ledger", key_dir=key_dir)
yield runs, app, build_server(bridge, ledger=ledger), ledger
_PERSON.pop(runs, None)


def _store(runs, run_dir):
return ItemApprovalStore(run_dir, key_dir=_person(runs)[0])


def _inputs(item="R01", clinic="north", **extra):
Expand All @@ -61,7 +82,7 @@ def answer(runs, decision="approve", *, decided_by="human", route=ITEM_APPROVAL_
"""A person answers every waiting question, as the phone or Run it does."""
answered = 0
for run_dir, request in _waiting(runs):
store = ItemApprovalStore(run_dir)
store = _store(runs, run_dir)
store.write_answer(
make_answer(
request,
Expand All @@ -72,6 +93,7 @@ def answer(runs, decision="approve", *, decided_by="human", route=ITEM_APPROVAL_
approver_id=None,
auth_method="none",
policy=store.context(),
signer=_person(runs)[1] if decided_by == "human" else None,
)
)
answered += 1
Expand Down Expand Up @@ -293,7 +315,7 @@ async def script(call):
answer(runs, route="local_cli")
# A forged answer file is read and refused by the ledger's own check.
((run_dir, request),) = _waiting(runs)
store = ItemApprovalStore(run_dir)
store = _store(runs, run_dir)
forged = make_answer(
request,
decision="approve",
Expand All @@ -302,6 +324,7 @@ async def script(call):
approver_id=None,
auth_method="none",
policy=store.context(),
signer=_person(runs)[1],
).model_copy(update={"decided_by": "automation"})
store.write_answer(forged)
view = ledger.get_answer(job["job_id"], asked["question_id"], 0)
Expand Down Expand Up @@ -371,7 +394,7 @@ async def script(call):
assert len(list_save_approvals(runs)) == 2
# The person says R11 saved and R26 didn't.
for run_dir, request in _waiting(runs):
store = ItemApprovalStore(run_dir)
store = _store(runs, run_dir)
decision = "approve" if "R11" in request.step_intent else "decline"
store.write_answer(
make_answer(
Expand All @@ -383,6 +406,7 @@ async def script(call):
approver_id=None,
auth_method="none",
policy=store.context(),
signer=_person(runs)[1],
)
)
after = ledger.summary(job["job_id"])
Expand Down Expand Up @@ -598,7 +622,7 @@ def test_polling_a_stopped_run_asks_only_once(world):


def test_an_unanswered_question_keeps_the_item_waiting_for_a_person(tmp_path):
ledger = TaskLedger(tmp_path / "task-ledger", question_ttl_s=1)
ledger = TaskLedger(tmp_path / "task-ledger", question_ttl_s=1, key_dir=_person(tmp_path)[0])
job = ledger.open_job("Synthetic work.", [], [{"item_id": "R01"}])
ledger.ask_user(job["job_id"], "R01: north or south?", "R01", 0)
import time
Expand All @@ -614,3 +638,4 @@ def test_an_unanswered_question_keeps_the_item_waiting_for_a_person(tmp_path):
assert ledger.get_answer(job["job_id"], asked["question_id"], 0)["status"] == "yes"
ledger.check_run({"request_id": job["job_id"] + ".R01", "inputs": {}})
assert ledger.summary(job["job_id"])["counts"] == {"pending": 1}
_PERSON.pop(tmp_path, None)
1 change: 1 addition & 0 deletions packages/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ setting.
| `record`, `list`, `info` | Record a task and inspect recordings |
| `compile`, `qualify`, `replay`, `run` | Compile a recording, fill in its checks from the recording, replay it, or run it with a deployment settings file |
| `scrub`, `review`, `approve`, `dismiss` | Remove personal data from a recording and review it before anything leaves the computer |
| `approver-key setup` | Create the keys that sign your answers to saves in Run it and on the paired phone, and print the command a person runs once to register each one |
| `login`, `credential`, `rotate`, `push`, `report-break` | Sign in to OpenAdapt Cloud, manage the stored sign-in, send a recording or task, or report a stopped run |
| `storage`, `health`, `cleanup`, `config` | Inspect and maintain local storage and settings |
| `upload`, `backends` | Older upload commands. `upload --backend hosted_ingest` runs `push`; upload to your own S3 storage is turned off in this release. |
Expand Down
21 changes: 17 additions & 4 deletions packages/desktop/docs/OPERATOR_SCREENS.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,23 @@ How it works:
holds the command queue (`openadapt_desktop/ipc.py` `CONCURRENT_COMMANDS`). They read
and answer only a run this engine started and hasn't finished, and only a
request that is waiting now, for the exact digest the person saw.
- Desktop has no person-level sign-in (its Cloud credential belongs to the
organization), so each answer is recorded with `--unauthenticated` and
`--route desktop_run_it`. Flow re-checks the answer, re-reads the form and
runs the right-record check again before it saves.
- Desktop signs each answer with this computer's approver key, and Flow
counts it as a person's only because of that signature. Desktop has no
person-level sign-in (its Cloud credential belongs to the organization),
so the answer names no Cloud user (`--unauthenticated`, `--route
desktop_run_it`); it passes `--approver-key`, and the key's passphrase
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
`OPENADAPT_JIT_KEY_PASSPHRASE` set, run `openadapt-flow jit keygen --out
~/.openadapt/approver/desktop-approver.pem`, then a person registers the
public key it prints: `openadapt-flow jit register --role approver
--principal NAME --public-key HEX`. Desktop then needs the same
passphrase in its environment. `openadapt-desktop approver-key setup` does
the keygen step instead and keeps a random passphrase in the OS keychain;
the register step is the same. Until a key is set up and registered,
Desktop sends no answer, and Run it shows these steps.
- A late answer (the run already stopped waiting) is never turned into a
save: Desktop doesn't resume the run, and says to run the item again.
- Before the practice item runs, Desktop calls `openadapt-flow qualify drills
Expand Down
Loading
Loading