mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-07-27 01:22:16 +00:00
Settle the Playwright-grade reliability contract in agent-desktop-core before the Windows/Linux adapters are built, so they inherit it instead of redesigning it. Every command now observes, waits, verifies, and reports honestly instead of firing blindly. Highlights: capability-supertrait split of PlatformAdapter with not_supported() defaults; canonical role/state vocabulary with live `is --property visible`; display enumeration (`list-displays`) and honest `--screen` with scale factor; truthful Automation permission; `native_id` identity spine; window-id-first resolution; serializable `LocatorQuery` with live `find`; default-on auto-wait before every ref action; three-way `hit_test` occlusion gate; `scroll_into_view` in core; core accessible-name precedence; typed `ActionStep` delivery tier; `ProcessState` and `APP_UNRESPONSIVE`; `LaunchOptions`; baseline-diff desktop signals (`wait --event`); typed clipboard (`Text`/`Image`/`FileUrls`); mouse modifier chords and `mouse-wheel`. Hardened through a 35-reviewer pass with independent validation and a green live e2e gate (109/0), plus a head-vs-main performance comparison harness. BREAKING CHANGE: default-on auto-wait changes the timing of every previously-untouched ref-action call (bounded 5000 ms default; `--timeout-ms 0` restores single-shot). `ENVELOPE_VERSION` is now `2.1` (adds the `APP_UNRESPONSIVE` code and process state in error details). FFI ABI major is `3` (append-only struct evolution; `wait --event` is intentionally not exposed over FFI). The legacy string clipboard API is removed in favor of typed content. `key-down`/`key-up` fail closed until daemon-owned held input exists. `close-app` verifies termination and the osascript fallback path is removed. `--text` matching is subtree containment: `find --text X --first` returns the outermost matching container.
288 lines
12 KiB
Python
Executable file
288 lines
12 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
import json
|
|
import re
|
|
import sys
|
|
|
|
|
|
MAX_JSON_BYTES = 4 * 1024 * 1024
|
|
FIXTURE_WINDOW_TITLE = "AgentDesk Fixture"
|
|
SNAPSHOT_TOKEN = r"[A-Za-z0-9][A-Za-z0-9_-]{2,63}"
|
|
REF_PATTERN = re.compile(rf"^@(?:(?:{SNAPSHOT_TOKEN}):)?e[1-9][0-9]*$")
|
|
SNAPSHOT_PATTERN = re.compile(rf"^{SNAPSHOT_TOKEN}$")
|
|
SAFE_NAMED_TARGETS = {
|
|
("button", "primary-button"),
|
|
("textfield", "text-input"),
|
|
("checkbox", "toggle-box"),
|
|
}
|
|
SAFE_STATUS_IDS = {
|
|
("statictext", "click-status"),
|
|
("statictext", "text-echo"),
|
|
("statictext", "toggle-status"),
|
|
}
|
|
|
|
|
|
class SafetyError(ValueError):
|
|
pass
|
|
|
|
|
|
def _load_json_bytes(payload):
|
|
if len(payload) > MAX_JSON_BYTES:
|
|
raise SafetyError("JSON input exceeds the safety limit")
|
|
try:
|
|
value = json.loads(payload)
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
raise SafetyError(f"invalid JSON: {error}") from error
|
|
if not isinstance(value, dict) or value.get("ok") is not True:
|
|
raise SafetyError("command did not return an ok:true envelope")
|
|
if "data" not in value:
|
|
raise SafetyError("command envelope has no data")
|
|
return value["data"]
|
|
|
|
|
|
def load_envelope_stream(stream):
|
|
return _load_json_bytes(stream.buffer.read(MAX_JSON_BYTES + 1))
|
|
|
|
|
|
def load_envelope_file(path):
|
|
with open(path, "rb") as source:
|
|
return _load_json_bytes(source.read(MAX_JSON_BYTES + 1))
|
|
|
|
|
|
def _positive_pid(value, field):
|
|
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
raise SafetyError(f"{field} must be a positive PID")
|
|
return value
|
|
|
|
|
|
def _required_text(item, field):
|
|
value = item.get(field)
|
|
if not isinstance(value, str) or not value:
|
|
raise SafetyError(f"{field} must be a nonempty string")
|
|
return value
|
|
|
|
|
|
def frontmost_identity(data):
|
|
if not isinstance(data, list):
|
|
raise SafetyError("list-windows data must be an array")
|
|
focused = [window for window in data if window.get("is_focused") is True]
|
|
if len(focused) != 1:
|
|
raise SafetyError("frontmost window identity is missing or ambiguous")
|
|
window = focused[0]
|
|
if window.get("visible") is not True or window.get("minimized") is True:
|
|
raise SafetyError("frontmost window is not a visible, non-minimized surface")
|
|
return {
|
|
"pid": _positive_pid(window.get("pid"), "frontmost pid"),
|
|
"process_instance": _required_text(window, "process_instance"),
|
|
"window_id": _required_text(window, "id"),
|
|
}
|
|
|
|
|
|
def non_fixture_frontmost_identity(data, fixture_pid):
|
|
identity = frontmost_identity(data)
|
|
if identity["pid"] == fixture_pid:
|
|
raise SafetyError("owned fixture unexpectedly became frontmost")
|
|
return identity
|
|
|
|
|
|
def fixture_window_identity(windows_data, expected_app, expected_pid):
|
|
if not isinstance(windows_data, list):
|
|
raise SafetyError("list-windows data must be an array")
|
|
owned_windows = [
|
|
window
|
|
for window in windows_data
|
|
if isinstance(window.get("app_name"), str)
|
|
and window["app_name"].casefold() == expected_app.casefold()
|
|
and window.get("pid") == expected_pid
|
|
]
|
|
exact_windows = [
|
|
window
|
|
for window in owned_windows
|
|
if window.get("visible") is True and window.get("minimized") is not True
|
|
]
|
|
if len(exact_windows) != 1:
|
|
raise SafetyError("fixture actionable surface ownership is missing or ambiguous")
|
|
window = exact_windows[0]
|
|
if window.get("title") != FIXTURE_WINDOW_TITLE:
|
|
raise SafetyError("fixture window title does not match the owned test surface")
|
|
instance = _required_text(window, "process_instance")
|
|
if window.get("is_focused") is not False:
|
|
raise SafetyError("fixture unexpectedly became frontmost")
|
|
return {
|
|
"app_name": window["app_name"],
|
|
"pid": expected_pid,
|
|
"process_instance": instance,
|
|
"window_id": _required_text(window, "id"),
|
|
"window_title": window["title"],
|
|
}
|
|
|
|
|
|
def _validate_timeout(raw):
|
|
if not raw.isdecimal() or not 1 <= int(raw) <= 5_000:
|
|
raise SafetyError("action timeout must be between 1 and 5000 milliseconds")
|
|
|
|
|
|
def _validate_ref_action_tail(argv, positional_count):
|
|
expected_length = positional_count + 4
|
|
if len(argv) != expected_length:
|
|
raise SafetyError("ref action arguments do not match the safe contract")
|
|
if not REF_PATTERN.fullmatch(argv[1]):
|
|
raise SafetyError("ref action requires a qualified element ref")
|
|
tail = argv[positional_count:]
|
|
if tail[0] != "--snapshot" or not SNAPSHOT_PATTERN.fullmatch(tail[1]):
|
|
raise SafetyError("ref action requires an explicit snapshot ID")
|
|
if tail[2] != "--timeout-ms":
|
|
raise SafetyError("ref action requires an explicit bounded timeout")
|
|
_validate_timeout(tail[3])
|
|
|
|
|
|
def validate_command(argv, fixture_app, window_id, mutation_armed):
|
|
if not argv:
|
|
raise SafetyError("empty command")
|
|
if "--headed" in argv:
|
|
raise SafetyError("headed mode is forbidden in the safe semantic suite")
|
|
command = argv[0]
|
|
if command in {"version", "permissions"}:
|
|
if len(argv) != 1:
|
|
raise SafetyError(f"{command} accepts no arguments in this suite")
|
|
return
|
|
if command == "list-windows":
|
|
if argv not in (["list-windows"], ["list-windows", "--app", fixture_app]):
|
|
raise SafetyError("list-windows must be global or fixture-scoped")
|
|
return
|
|
if command == "find":
|
|
if not window_id:
|
|
raise SafetyError("find requires the exact owned fixture window")
|
|
if len(argv) != 12 or argv[:6] != [
|
|
"find", "--app", fixture_app, "--window-id", window_id, "--role",
|
|
] or argv[9:] != ["--exact", "--limit", "2"]:
|
|
raise SafetyError("find is not an exact, unique, fixture-owned safe target query")
|
|
target = (argv[6], argv[8])
|
|
if not (
|
|
argv[7] == "--name" and target in SAFE_NAMED_TARGETS
|
|
or argv[7] == "--native-id" and target in SAFE_STATUS_IDS
|
|
):
|
|
raise SafetyError("find target is outside the fixture-safe allowlist")
|
|
return
|
|
if command not in {"click", "type", "set-value", "toggle", "check", "uncheck"}:
|
|
raise SafetyError(f"command is forbidden in the safe semantic suite: {command}")
|
|
if not mutation_armed or not window_id:
|
|
raise SafetyError("mutation requires an armed ownership and focus checkpoint")
|
|
if command in {"click", "toggle", "check", "uncheck"}:
|
|
_validate_ref_action_tail(argv, 2)
|
|
return
|
|
if command in {"type", "set-value"}:
|
|
_validate_ref_action_tail(argv, 3)
|
|
value = argv[2]
|
|
if not re.fullmatch(r"safe-semantic-[A-Za-z0-9-]{1,48}", value):
|
|
raise SafetyError("set-value payload is outside the fixture-safe namespace")
|
|
return
|
|
|
|
|
|
def unique_target(data, expected_role, expected_name):
|
|
if not isinstance(data, dict) or not isinstance(data.get("matches"), list):
|
|
raise SafetyError("find data must contain a matches array")
|
|
if len(data["matches"]) != 1:
|
|
raise SafetyError("safe target lookup must resolve exactly one element")
|
|
match = data["matches"][0]
|
|
if match.get("role") != expected_role or match.get("name") != expected_name:
|
|
raise SafetyError("safe target identity does not match the requested fixture control")
|
|
if match.get("interactive") is not True:
|
|
raise SafetyError("safe mutation target is not interactive")
|
|
ref_id = _required_text(match, "ref_id")
|
|
snapshot_id = _required_text(data, "snapshot_id")
|
|
if not REF_PATTERN.fullmatch(ref_id) or not SNAPSHOT_PATTERN.fullmatch(snapshot_id):
|
|
raise SafetyError("safe target has an invalid ref namespace")
|
|
return ref_id, snapshot_id
|
|
|
|
|
|
def unique_value(data, expected_native_id):
|
|
if ("statictext", expected_native_id) not in SAFE_STATUS_IDS:
|
|
raise SafetyError("status identifier is outside the fixture-safe allowlist")
|
|
if not isinstance(data, dict) or not isinstance(data.get("matches"), list):
|
|
raise SafetyError("find data must contain a matches array")
|
|
if len(data["matches"]) != 1:
|
|
raise SafetyError("safe status lookup must resolve exactly one element")
|
|
match = data["matches"][0]
|
|
if match.get("role") != "statictext" or match.get("interactive") is not False:
|
|
raise SafetyError("safe status result is not a noninteractive fixture readout")
|
|
value = match.get("value")
|
|
if value is None:
|
|
return ""
|
|
if not isinstance(value, str):
|
|
raise SafetyError("fixture status has no string accessibility value")
|
|
return value
|
|
|
|
|
|
def validate_semantic_action(data, expected_action, expectation):
|
|
if not isinstance(data, dict):
|
|
raise SafetyError("action data must be an object")
|
|
action = _required_text(data, "action").replace("_", "-").casefold()
|
|
if action != expected_action.replace("_", "-").casefold():
|
|
raise SafetyError("action result does not match the requested command")
|
|
disposition = data.get("disposition")
|
|
if not isinstance(disposition, dict):
|
|
raise SafetyError("action result has no delivery disposition")
|
|
steps = data.get("steps")
|
|
if not isinstance(steps, list) or not steps:
|
|
raise SafetyError("action result has no auditable steps")
|
|
for step in steps:
|
|
if not isinstance(step, dict):
|
|
raise SafetyError("action result contains a malformed step")
|
|
mechanism = step.get("mechanism")
|
|
if mechanism not in (None, "semantic_api"):
|
|
raise SafetyError("action used a non-semantic mechanism")
|
|
delivery = disposition.get("delivery")
|
|
semantic_success = any(
|
|
step.get("outcome") == "succeeded" and step.get("mechanism") == "semantic_api"
|
|
for step in steps
|
|
)
|
|
if expectation == "changed":
|
|
if delivery not in {"delivered_unverified", "delivered_verified"} or not semantic_success:
|
|
raise SafetyError("state-changing action lacks semantic delivery evidence")
|
|
elif expectation == "noop":
|
|
if delivery != "not_delivered" or semantic_success:
|
|
raise SafetyError("idempotent action unexpectedly reported delivery")
|
|
else:
|
|
raise SafetyError("unknown action expectation")
|
|
|
|
|
|
def render_json(value):
|
|
print(json.dumps(value, separators=(",", ":"), sort_keys=True))
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
raise SafetyError("missing guard command")
|
|
command = sys.argv[1]
|
|
if command == "command":
|
|
if len(sys.argv) < 7 or sys.argv[5] != "--":
|
|
raise SafetyError("invalid command guard invocation")
|
|
validate_command(sys.argv[6:], sys.argv[2], sys.argv[3], sys.argv[4] == "1")
|
|
elif command == "frontmost":
|
|
render_json(frontmost_identity(load_envelope_stream(sys.stdin)))
|
|
elif command == "non-fixture-frontmost":
|
|
render_json(
|
|
non_fixture_frontmost_identity(load_envelope_stream(sys.stdin), int(sys.argv[2]))
|
|
)
|
|
elif command == "fixture-window":
|
|
windows = load_envelope_file(sys.argv[4])
|
|
render_json(fixture_window_identity(windows, sys.argv[2], int(sys.argv[3])))
|
|
elif command == "target":
|
|
ref_id, snapshot_id = unique_target(
|
|
load_envelope_stream(sys.stdin), sys.argv[2], sys.argv[3]
|
|
)
|
|
print(f"{ref_id}\t{snapshot_id}")
|
|
elif command == "value":
|
|
print(unique_value(load_envelope_stream(sys.stdin), sys.argv[2]))
|
|
elif command == "semantic-action":
|
|
validate_semantic_action(load_envelope_stream(sys.stdin), sys.argv[2], sys.argv[3])
|
|
else:
|
|
raise SafetyError(f"unknown guard command: {command}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except (SafetyError, IndexError, OSError, ValueError) as error:
|
|
print(f"safe semantic guard rejected input: {error}", file=sys.stderr)
|
|
raise SystemExit(2) from error
|