agent-desktop/tests/e2e/test_safe_semantic_guard.py
Lahfir 3f322728b4
feat!: implement Playwright-grade foundation contract
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.
2026-07-20 00:21:38 -07:00

290 lines
10 KiB
Python

#!/usr/bin/env python3
import unittest
from safe_semantic_guard import (
SafetyError,
fixture_window_identity,
frontmost_identity,
non_fixture_frontmost_identity,
unique_target,
unique_value,
validate_command,
validate_semantic_action,
)
APP = "AgentDeskFixture"
WINDOW = "w-fixture"
PID = 4242
INSTANCE = "4242:fixture-generation"
def fixture_windows(**overrides):
window = {
"id": WINDOW,
"title": "AgentDesk Fixture",
"app_name": APP,
"pid": PID,
"process_instance": INSTANCE,
"is_focused": False,
"visible": True,
"minimized": False,
}
window.update(overrides)
return [window]
def action_data(action="click", delivery="delivered_verified", mechanism="semantic_api"):
return {
"action": action,
"disposition": {"delivery": delivery, "retry": "unsafe"},
"steps": [
{
"label": "AXPress",
"outcome": "succeeded",
"mechanism": mechanism,
"verified": True,
}
],
}
class CommandPolicyTests(unittest.TestCase):
def test_allows_only_exact_fixture_scoped_observations(self):
validate_command(["version"], APP, "", False)
validate_command(["permissions"], APP, "", False)
validate_command(["list-windows"], APP, "", False)
validate_command(["list-windows", "--app", APP], APP, "", False)
validate_command(
[
"find", "--app", APP, "--window-id", WINDOW,
"--role", "button", "--name", "primary-button",
"--exact", "--limit", "2",
],
APP,
WINDOW,
False,
)
validate_command(
[
"find", "--app", APP, "--window-id", WINDOW,
"--role", "statictext", "--native-id", "click-status",
"--exact", "--limit", "2",
],
APP,
WINDOW,
False,
)
def test_rejects_unscoped_or_non_allowlisted_find(self):
with self.assertRaises(SafetyError):
validate_command(
[
"find", "--app", APP, "--role", "button",
"--name", "primary-button", "--exact", "--limit", "2",
],
APP,
WINDOW,
False,
)
with self.assertRaises(SafetyError):
validate_command(
[
"find", "--app", APP, "--window-id", WINDOW,
"--role", "button", "--name", "delete-button",
"--exact", "--limit", "2",
],
APP,
WINDOW,
False,
)
def test_rejects_every_banned_command_class(self):
banned = [
["list-apps", "--app", APP],
["focus-window", "--app", APP],
["launch", APP],
["close-app", APP],
["press", "cmd+a"],
["mouse-click", "--xy", "1,1"],
["hover", "@snap:e1"],
["drag", "@snap:e1", "@snap:e2"],
["clipboard-get"],
["clipboard-set", "secret"],
["screenshot", "--app", APP],
["resize-window", WINDOW, "800", "600"],
["move-window", WINDOW, "0", "0"],
["batch", "commands.json"],
]
for argv in banned:
with self.subTest(argv=argv), self.assertRaises(SafetyError):
validate_command(argv, APP, WINDOW, True)
def test_rejects_headed_trace_wait_and_unknown_flags(self):
variants = [
["list-windows", "--headed"],
["list-windows", "--trace", "/tmp/trace"],
["list-windows", "--wait-for", "button:x"],
["list-windows", "--verbose"],
]
for argv in variants:
with self.subTest(argv=argv), self.assertRaises(SafetyError):
validate_command(argv, APP, WINDOW, False)
def test_mutations_require_arming_ref_snapshot_and_timeout(self):
click = ["click", "@snap-1:e1", "--snapshot", "snap-1", "--timeout-ms", "1500"]
validate_command(click, APP, WINDOW, True)
for armed, window, argv in [
(False, WINDOW, click),
(True, "", click),
(True, WINDOW, ["click", "@snap-1:e1"]),
(True, WINDOW, ["click", "@snap-1:e1", "--snapshot", "snap-1", "--timeout-ms", "9000"]),
]:
with self.assertRaises(SafetyError):
validate_command(argv, APP, window, armed)
def test_text_payloads_are_narrowly_bounded(self):
for command in ["type", "set-value"]:
validate_command(
[
command, "@snap-1:e2", "safe-semantic-4242",
"--snapshot", "snap-1", "--timeout-ms", "1500",
],
APP,
WINDOW,
True,
)
unsafe = [[
"set-value", "@snap-1:e2", "arbitrary user text",
"--snapshot", "snap-1", "--timeout-ms", "1500",
]]
for argv in unsafe:
with self.subTest(argv=argv), self.assertRaises(SafetyError):
validate_command(argv, APP, WINDOW, True)
class IdentityGuardTests(unittest.TestCase):
def test_frontmost_identity_returns_opaque_process_and_window_tokens_only(self):
identity = frontmost_identity(
[
{
"id": "private-window",
"title": "Private Document Title",
"app_name": "Private App Name",
"pid": 99,
"process_instance": "99:generation",
"is_focused": True,
"visible": True,
"minimized": False,
}
]
)
self.assertEqual(
identity,
{"pid": 99, "process_instance": "99:generation", "window_id": "private-window"},
)
self.assertNotIn("Private", str(identity))
def test_frontmost_identity_fails_closed_on_ambiguity_or_missing_generation(self):
base = fixture_windows(is_focused=True)
with self.assertRaises(SafetyError):
frontmost_identity(base + fixture_windows(id="another", is_focused=True))
base[0].pop("process_instance")
with self.assertRaises(SafetyError):
frontmost_identity(base)
def test_frontmost_may_change_between_user_apps_but_never_to_fixture(self):
other = fixture_windows(
pid=99,
process_instance="99:generation",
is_focused=True,
)
identity = non_fixture_frontmost_identity(other, PID)
self.assertEqual(identity["pid"], 99)
with self.assertRaises(SafetyError):
non_fixture_frontmost_identity(fixture_windows(is_focused=True), PID)
def test_fixture_identity_binds_pid_generation_and_one_unfocused_window(self):
identity = fixture_window_identity(fixture_windows(), APP, PID)
self.assertEqual(identity["pid"], PID)
self.assertEqual(identity["process_instance"], INSTANCE)
self.assertEqual(identity["window_id"], WINDOW)
def test_fixture_identity_ignores_owned_hidden_companion_window(self):
hidden = fixture_windows(
id="hidden-companion",
title="Hidden Companion",
visible=False,
minimized=False,
)
identity = fixture_window_identity(fixture_windows() + hidden, APP, PID)
self.assertEqual(identity["window_id"], WINDOW)
def test_fixture_identity_rejects_focus_wrong_pid_and_surface_ambiguity(self):
cases = [
(fixture_windows(is_focused=True), PID),
(fixture_windows(), PID + 1),
(fixture_windows() + fixture_windows(id="second", visible=True), PID),
]
for windows, pid in cases:
with self.subTest(pid=pid, windows=len(windows)), self.assertRaises(SafetyError):
fixture_window_identity(windows, APP, pid)
class ResultGuardTests(unittest.TestCase):
def test_unique_target_and_status_reject_ambiguous_results(self):
target = {
"snapshot_id": "snap-1",
"matches": [
{
"role": "button",
"name": "primary-button",
"interactive": True,
"ref_id": "@snap-1:e1",
}
],
}
self.assertEqual(unique_target(target, "button", "primary-button"), ("@snap-1:e1", "snap-1"))
with self.assertRaises(SafetyError):
unique_target({**target, "matches": target["matches"] * 2}, "button", "primary-button")
value = {
"matches": [
{
"role": "statictext",
"name": "dynamic-value",
"interactive": False,
"value": None,
}
]
}
self.assertEqual(unique_value(value, "text-echo"), "")
with self.assertRaises(SafetyError):
unique_value({"matches": [{**value["matches"][0], "interactive": True}]}, "text-echo")
def test_changed_action_requires_a_semantic_successful_delivery(self):
validate_semantic_action(action_data(), "click", "changed")
for bad in [
action_data(mechanism="physical_synthetic"),
action_data(delivery="not_delivered"),
{**action_data(), "steps": []},
]:
with self.assertRaises(SafetyError):
validate_semantic_action(bad, "click", "changed")
def test_noop_action_requires_explicit_not_delivered_evidence(self):
noop = {
"action": "check",
"disposition": {"delivery": "not_delivered", "retry": "safe"},
"steps": [{"label": "AlreadyInState", "outcome": "skipped", "verified": True}],
}
validate_semantic_action(noop, "check", "noop")
with self.assertRaises(SafetyError):
validate_semantic_action(action_data(action="check"), "check", "noop")
if __name__ == "__main__":
unittest.main()