argus-app-testing 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- argus/__init__.py +17 -0
- argus/__main__.py +4 -0
- argus/actions.py +222 -0
- argus/adapters/__init__.py +19 -0
- argus/adapters/base.py +291 -0
- argus/adapters/browser_adapter.py +209 -0
- argus/adapters/cli_adapter.py +112 -0
- argus/adapters/linux_gui.py +252 -0
- argus/adapters/windows_gui.py +411 -0
- argus/adapters/windows_safe.py +589 -0
- argus/ates/__init__.py +124 -0
- argus/ates/artifacts.py +917 -0
- argus/ates/audit.py +1675 -0
- argus/ates/authority_guards.py +777 -0
- argus/ates/core.py +1238 -0
- argus/ates/evidence_validation.py +2006 -0
- argus/ates/finalization.py +618 -0
- argus/ates/finalization_io.py +504 -0
- argus/ates/finalization_types.py +42 -0
- argus/ates/ids.py +47 -0
- argus/ates/package.py +65 -0
- argus/ates/privacy.py +469 -0
- argus/ates/recovery_policy.py +64 -0
- argus/ates/reports.py +1360 -0
- argus/ates/status.py +160 -0
- argus/ates/store.py +1310 -0
- argus/ates/transaction_guards.py +578 -0
- argus/ates/trust_guards.py +822 -0
- argus/capsule/__init__.py +27 -0
- argus/capsule/base.py +218 -0
- argus/capsule/files.py +162 -0
- argus/capsule/guest.py +502 -0
- argus/capsule/guest_agent.py +719 -0
- argus/capsule/host_collect.py +102 -0
- argus/capsule/hyperv.py +369 -0
- argus/capsule/hyperv_isolated.py +314 -0
- argus/capsule/libvirt.py +1122 -0
- argus/capsule/safe_open.py +333 -0
- argus/capsule/safe_output.py +418 -0
- argus/capsule/secure_client.py +97 -0
- argus/capsule/secure_guest_agent.py +217 -0
- argus/cli.py +568 -0
- argus/config.py +509 -0
- argus/engine/__init__.py +0 -0
- argus/engine/agent.py +303 -0
- argus/engine/ates_artifacts.py +315 -0
- argus/engine/ates_runtime.py +1094 -0
- argus/engine/results.py +266 -0
- argus/engine/roam.py +86 -0
- argus/engine/roam_ates_impl.py +253 -0
- argus/engine/roam_impl.py +534 -0
- argus/engine/runner.py +66 -0
- argus/engine/runner_impl.py +788 -0
- argus/engine/spec.py +285 -0
- argus/execution/__init__.py +21 -0
- argus/execution/ates_collection.py +198 -0
- argus/execution/base.py +237 -0
- argus/execution/capsule.py +583 -0
- argus/execution/secure_capsule.py +291 -0
- argus/fleet/__init__.py +123 -0
- argus/fleet/ates_transport.py +704 -0
- argus/fleet/enrollment.py +594 -0
- argus/fleet/execution.py +326 -0
- argus/fleet/heartbeat.py +296 -0
- argus/fleet/heartbeat_impl.py +960 -0
- argus/fleet/identity.py +207 -0
- argus/fleet/placement.py +1308 -0
- argus/gui/__init__.py +0 -0
- argus/gui/__main__.py +4 -0
- argus/gui/app.py +308 -0
- argus/gui/web/app.js +295 -0
- argus/gui/web/index.html +402 -0
- argus/knowledge/__init__.py +177 -0
- argus/knowledge/base.py +128 -0
- argus/knowledge/docker_manager.py +162 -0
- argus/knowledge/embeddings.py +41 -0
- argus/knowledge/fingerprint.py +58 -0
- argus/knowledge/json_store.py +319 -0
- argus/knowledge/remote.py +353 -0
- argus/knowledge/store.py +359 -0
- argus/policy.py +44 -0
- argus/providers/__init__.py +10 -0
- argus/providers/anthropic_provider.py +109 -0
- argus/providers/base.py +77 -0
- argus/providers/ollama.py +147 -0
- argus/providers/openai_provider.py +175 -0
- argus/providers/registry.py +69 -0
- argus/serve/__init__.py +0 -0
- argus/serve/app.py +463 -0
- argus/tokens.py +177 -0
- argus_app_testing-0.1.1.dist-info/METADATA +484 -0
- argus_app_testing-0.1.1.dist-info/RECORD +96 -0
- argus_app_testing-0.1.1.dist-info/WHEEL +5 -0
- argus_app_testing-0.1.1.dist-info/entry_points.txt +3 -0
- argus_app_testing-0.1.1.dist-info/licenses/LICENSE +21 -0
- argus_app_testing-0.1.1.dist-info/top_level.txt +1 -0
argus/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Argus — universal application testing driven by multimodal LLMs.
|
|
2
|
+
|
|
3
|
+
Argus watches an application the way a person would — through screenshots,
|
|
4
|
+
the OS accessibility tree, terminal output — then drives it to satisfy tests
|
|
5
|
+
written as a mix of natural-language steps and structured assertions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("argus-app-testing")
|
|
12
|
+
except PackageNotFoundError:
|
|
13
|
+
# Source trees that have not been installed do not have distribution
|
|
14
|
+
# metadata. Release and packaged builds always install/build the project
|
|
15
|
+
# first, so this fallback is intentionally descriptive rather than a fake
|
|
16
|
+
# release version.
|
|
17
|
+
__version__ = "0+unknown"
|
argus/__main__.py
ADDED
argus/actions.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Validation and normalization for actions emitted by LLM providers.
|
|
2
|
+
|
|
3
|
+
The model-facing protocol is intentionally JSON/dict based, but adapters must
|
|
4
|
+
never receive arbitrary model output directly. This module defines the
|
|
5
|
+
supported action vocabulary and validates executable fields before execution
|
|
6
|
+
reaches an adapter or platform API.
|
|
7
|
+
|
|
8
|
+
Keyboard actions deliberately use an Argus-owned canonical grammar. Model
|
|
9
|
+
output is never allowed to contain pywinauto, X11/xdotool, Playwright, or other
|
|
10
|
+
backend-specific key syntax; adapters translate the validated canonical chord
|
|
11
|
+
to their native representation at the final execution boundary.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from typing import Any, Dict
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ActionValidationError(ValueError):
|
|
20
|
+
"""Raised when an action is malformed or outside Argus' action schema."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SUPPORTED_ACTIONS = {
|
|
24
|
+
"click",
|
|
25
|
+
"double_click",
|
|
26
|
+
"right_click",
|
|
27
|
+
"type",
|
|
28
|
+
"key",
|
|
29
|
+
"scroll",
|
|
30
|
+
"menu",
|
|
31
|
+
"wait",
|
|
32
|
+
"done",
|
|
33
|
+
"navigate",
|
|
34
|
+
"run",
|
|
35
|
+
"execute",
|
|
36
|
+
"report_bug",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Canonical key vocabulary. Intentionally excludes Windows/Super/Meta and any
|
|
40
|
+
# raw virtual-key/X11 spelling. Additions here must be translated by every
|
|
41
|
+
# adapter that advertises the ``key`` capability.
|
|
42
|
+
KEY_MODIFIERS = ("ctrl", "alt", "shift")
|
|
43
|
+
_KEY_MODIFIER_SET = set(KEY_MODIFIERS)
|
|
44
|
+
KEY_NAMED = {
|
|
45
|
+
"enter",
|
|
46
|
+
"tab",
|
|
47
|
+
"esc",
|
|
48
|
+
"space",
|
|
49
|
+
"backspace",
|
|
50
|
+
"delete",
|
|
51
|
+
"up",
|
|
52
|
+
"down",
|
|
53
|
+
"left",
|
|
54
|
+
"right",
|
|
55
|
+
"home",
|
|
56
|
+
"end",
|
|
57
|
+
"pageup",
|
|
58
|
+
"pagedown",
|
|
59
|
+
"insert",
|
|
60
|
+
"minus",
|
|
61
|
+
"equals",
|
|
62
|
+
"comma",
|
|
63
|
+
"period",
|
|
64
|
+
"slash",
|
|
65
|
+
"semicolon",
|
|
66
|
+
"quote",
|
|
67
|
+
"backquote",
|
|
68
|
+
"bracketleft",
|
|
69
|
+
"bracketright",
|
|
70
|
+
"backslash",
|
|
71
|
+
*{f"f{i}" for i in range(1, 13)},
|
|
72
|
+
}
|
|
73
|
+
KEY_ALIASES = {
|
|
74
|
+
"control": "ctrl",
|
|
75
|
+
"return": "enter",
|
|
76
|
+
"escape": "esc",
|
|
77
|
+
"pgup": "pageup",
|
|
78
|
+
"pgdn": "pagedown",
|
|
79
|
+
}
|
|
80
|
+
_KEY_TOKEN_RE = re.compile(r"^[a-z0-9]+$")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def canonicalize_key_chord(keys: str) -> str:
|
|
84
|
+
"""Validate and normalize an Argus key chord.
|
|
85
|
+
|
|
86
|
+
Grammar: zero or more modifiers from ``ctrl|alt|shift`` plus exactly one
|
|
87
|
+
key, separated by ``+``. The key must be a single ASCII letter/digit or a
|
|
88
|
+
named key from :data:`KEY_NAMED`. Raw backend syntax such as ``{VK_LWIN}``,
|
|
89
|
+
``Super_L``, bracketed expressions, whitespace-delimited backend commands,
|
|
90
|
+
and unknown tokens is rejected before any adapter can see it.
|
|
91
|
+
"""
|
|
92
|
+
if not isinstance(keys, str) or not keys.strip():
|
|
93
|
+
raise ActionValidationError("key requires a non-empty keys string")
|
|
94
|
+
|
|
95
|
+
raw_parts = [part.strip().lower() for part in keys.split("+")]
|
|
96
|
+
if not raw_parts or any(not part for part in raw_parts):
|
|
97
|
+
raise ActionValidationError("key chord contains an empty token")
|
|
98
|
+
if len(raw_parts) > len(KEY_MODIFIERS) + 1:
|
|
99
|
+
raise ActionValidationError("key chord may contain at most three modifiers and one key")
|
|
100
|
+
|
|
101
|
+
parts: list[str] = []
|
|
102
|
+
for raw in raw_parts:
|
|
103
|
+
if not _KEY_TOKEN_RE.fullmatch(raw):
|
|
104
|
+
raise ActionValidationError(
|
|
105
|
+
f"key token {raw!r} uses unsupported/raw backend syntax"
|
|
106
|
+
)
|
|
107
|
+
part = KEY_ALIASES.get(raw, raw)
|
|
108
|
+
parts.append(part)
|
|
109
|
+
|
|
110
|
+
if len(parts) != len(set(parts)):
|
|
111
|
+
raise ActionValidationError("key chord contains duplicate tokens")
|
|
112
|
+
|
|
113
|
+
modifiers = [part for part in parts if part in _KEY_MODIFIER_SET]
|
|
114
|
+
keys_only = [part for part in parts if part not in _KEY_MODIFIER_SET]
|
|
115
|
+
if len(keys_only) != 1:
|
|
116
|
+
raise ActionValidationError("key chord must contain exactly one non-modifier key")
|
|
117
|
+
|
|
118
|
+
key = keys_only[0]
|
|
119
|
+
if not ((len(key) == 1 and key.isascii() and key.isalnum()) or key in KEY_NAMED):
|
|
120
|
+
raise ActionValidationError(f"unsupported key token {key!r}")
|
|
121
|
+
|
|
122
|
+
ordered_modifiers = [modifier for modifier in KEY_MODIFIERS if modifier in modifiers]
|
|
123
|
+
return "+".join([*ordered_modifiers, key])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def validate_action(action: Dict[str, Any]) -> Dict[str, Any]:
|
|
127
|
+
"""Return a normalized copy of *action* or raise ActionValidationError.
|
|
128
|
+
|
|
129
|
+
Extra explanatory/model metadata (for example ``why``) is intentionally
|
|
130
|
+
preserved; only executable fields are normalized and constrained here.
|
|
131
|
+
"""
|
|
132
|
+
if not isinstance(action, dict):
|
|
133
|
+
raise ActionValidationError("action must be a JSON object")
|
|
134
|
+
|
|
135
|
+
normalized = dict(action)
|
|
136
|
+
kind = normalized.get("action")
|
|
137
|
+
if not isinstance(kind, str) or not kind.strip():
|
|
138
|
+
raise ActionValidationError("action field must be a non-empty string")
|
|
139
|
+
kind = kind.strip().lower()
|
|
140
|
+
normalized["action"] = kind
|
|
141
|
+
if kind not in SUPPORTED_ACTIONS:
|
|
142
|
+
raise ActionValidationError(
|
|
143
|
+
f"unknown action '{kind}' — supported: {', '.join(sorted(SUPPORTED_ACTIONS))}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if kind in {"click", "double_click", "right_click"}:
|
|
147
|
+
has_element = "element_id" in normalized
|
|
148
|
+
has_xy = "x" in normalized or "y" in normalized
|
|
149
|
+
if not has_element and not has_xy:
|
|
150
|
+
raise ActionValidationError(f"{kind} requires element_id or x/y coordinates")
|
|
151
|
+
if has_element:
|
|
152
|
+
try:
|
|
153
|
+
normalized["element_id"] = int(normalized["element_id"])
|
|
154
|
+
except (TypeError, ValueError) as exc:
|
|
155
|
+
raise ActionValidationError("element_id must be an integer") from exc
|
|
156
|
+
if has_xy:
|
|
157
|
+
if "x" not in normalized or "y" not in normalized:
|
|
158
|
+
raise ActionValidationError("coordinate actions require both x and y")
|
|
159
|
+
try:
|
|
160
|
+
normalized["x"] = int(normalized["x"])
|
|
161
|
+
normalized["y"] = int(normalized["y"])
|
|
162
|
+
except (TypeError, ValueError) as exc:
|
|
163
|
+
raise ActionValidationError("x and y must be integers") from exc
|
|
164
|
+
|
|
165
|
+
elif kind == "type":
|
|
166
|
+
if "text" not in normalized:
|
|
167
|
+
raise ActionValidationError("type requires text")
|
|
168
|
+
normalized["text"] = str(normalized["text"])
|
|
169
|
+
if "element_id" in normalized:
|
|
170
|
+
try:
|
|
171
|
+
normalized["element_id"] = int(normalized["element_id"])
|
|
172
|
+
except (TypeError, ValueError) as exc:
|
|
173
|
+
raise ActionValidationError("element_id must be an integer") from exc
|
|
174
|
+
|
|
175
|
+
elif kind == "key":
|
|
176
|
+
normalized["keys"] = canonicalize_key_chord(normalized.get("keys"))
|
|
177
|
+
|
|
178
|
+
elif kind == "scroll":
|
|
179
|
+
direction = str(normalized.get("direction", "down")).strip().lower()
|
|
180
|
+
if direction not in {"up", "down"}:
|
|
181
|
+
raise ActionValidationError("scroll direction must be 'up' or 'down'")
|
|
182
|
+
try:
|
|
183
|
+
amount = int(normalized.get("amount", 3))
|
|
184
|
+
except (TypeError, ValueError) as exc:
|
|
185
|
+
raise ActionValidationError("scroll amount must be an integer") from exc
|
|
186
|
+
if amount < 1 or amount > 100:
|
|
187
|
+
raise ActionValidationError("scroll amount must be between 1 and 100")
|
|
188
|
+
normalized["direction"] = direction
|
|
189
|
+
normalized["amount"] = amount
|
|
190
|
+
|
|
191
|
+
elif kind == "wait":
|
|
192
|
+
try:
|
|
193
|
+
seconds = float(normalized.get("seconds", 1.0))
|
|
194
|
+
except (TypeError, ValueError) as exc:
|
|
195
|
+
raise ActionValidationError("wait seconds must be numeric") from exc
|
|
196
|
+
if seconds < 0 or seconds > 30:
|
|
197
|
+
raise ActionValidationError("wait seconds must be between 0 and 30")
|
|
198
|
+
normalized["seconds"] = seconds
|
|
199
|
+
|
|
200
|
+
elif kind == "menu":
|
|
201
|
+
path = normalized.get("path")
|
|
202
|
+
if not isinstance(path, str) or not path.strip():
|
|
203
|
+
raise ActionValidationError("menu requires a non-empty path")
|
|
204
|
+
normalized["path"] = path.strip()
|
|
205
|
+
|
|
206
|
+
elif kind == "navigate":
|
|
207
|
+
url = normalized.get("url")
|
|
208
|
+
if not isinstance(url, str) or not url.strip():
|
|
209
|
+
raise ActionValidationError("navigate requires a non-empty url")
|
|
210
|
+
normalized["url"] = url.strip()
|
|
211
|
+
|
|
212
|
+
elif kind in {"run", "execute"}:
|
|
213
|
+
command = normalized.get("command")
|
|
214
|
+
if not isinstance(command, str) or not command.strip():
|
|
215
|
+
raise ActionValidationError(f"{kind} requires a non-empty command")
|
|
216
|
+
normalized["command"] = command.strip()
|
|
217
|
+
|
|
218
|
+
elif kind == "done" and "success" in normalized:
|
|
219
|
+
if not isinstance(normalized["success"], bool):
|
|
220
|
+
raise ActionValidationError("done.success must be true or false")
|
|
221
|
+
|
|
222
|
+
return normalized
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Target adapter API.
|
|
2
|
+
|
|
3
|
+
``create_adapter`` is the session-level compatibility factory. It now honors the
|
|
4
|
+
project's configured execution location, returning either a local environment or
|
|
5
|
+
a disposable Capsule. Low-level platform adapter construction remains available
|
|
6
|
+
from :mod:`argus.adapters.base` for the execution layer and guest agent.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from argus.adapters.base import Adapter, AdapterError, Observation, UIElement
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_adapter(adapter_type: str):
|
|
13
|
+
"""Create the configured execution environment for ``adapter_type``."""
|
|
14
|
+
from argus.config import load_config
|
|
15
|
+
|
|
16
|
+
return load_config().make_execution_environment(adapter_type)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["Adapter", "AdapterError", "Observation", "UIElement", "create_adapter"]
|
argus/adapters/base.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Adapter interface — how Argus connects to a *target* application.
|
|
2
|
+
|
|
3
|
+
An adapter knows how to:
|
|
4
|
+
* **launch** the target,
|
|
5
|
+
* **observe** it (screenshot + accessibility tree + window state), and
|
|
6
|
+
* **act** on it (click, type, press keys, …).
|
|
7
|
+
|
|
8
|
+
Adapters created through :func:`create_adapter` are wrapped in
|
|
9
|
+
:class:`PolicyAdapter`. This keeps the current engine API compatible while
|
|
10
|
+
ensuring every model-generated action is schema-validated, capability-authorized,
|
|
11
|
+
and policy-checked before it reaches platform input APIs.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import List, Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AdapterError(RuntimeError):
|
|
24
|
+
"""Raised when the target cannot be launched / observed / driven."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class UIElement:
|
|
29
|
+
"""One node of the accessibility (UIA) tree, with a stable id the LLM
|
|
30
|
+
can reference in actions."""
|
|
31
|
+
|
|
32
|
+
element_id: int
|
|
33
|
+
control_type: str
|
|
34
|
+
name: str
|
|
35
|
+
rect: tuple # (left, top, right, bottom)
|
|
36
|
+
enabled: bool = True
|
|
37
|
+
depth: int = 0
|
|
38
|
+
|
|
39
|
+
def describe(self) -> str:
|
|
40
|
+
name = self.name.strip() or "(unnamed)"
|
|
41
|
+
flags = "" if self.enabled else " [disabled]"
|
|
42
|
+
return (
|
|
43
|
+
f"{' ' * self.depth}[{self.element_id}] {self.control_type} "
|
|
44
|
+
f'"{name}"{flags} @({self.rect[0]},{self.rect[1]},{self.rect[2]},{self.rect[3]})'
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Observation:
|
|
50
|
+
"""One snapshot of the target application."""
|
|
51
|
+
|
|
52
|
+
window_title: str
|
|
53
|
+
elements: List[UIElement] = field(default_factory=list)
|
|
54
|
+
screenshot_png: Optional[bytes] = None
|
|
55
|
+
process_alive: bool = True
|
|
56
|
+
dialogs: List[str] = field(default_factory=list)
|
|
57
|
+
error: Optional[str] = None
|
|
58
|
+
stdout: Optional[str] = None
|
|
59
|
+
stderr: Optional[str] = None
|
|
60
|
+
exit_code: Optional[int] = None
|
|
61
|
+
url: Optional[str] = None
|
|
62
|
+
action_capabilities: Optional[dict] = None
|
|
63
|
+
|
|
64
|
+
def tree_text(self, max_elements: int = 120) -> str:
|
|
65
|
+
lines = [el.describe() for el in self.elements[:max_elements]]
|
|
66
|
+
if len(self.elements) > max_elements:
|
|
67
|
+
lines.append(f"... ({len(self.elements) - max_elements} more elements truncated)")
|
|
68
|
+
return "\n".join(lines) or "(no accessible elements found)"
|
|
69
|
+
|
|
70
|
+
def find_text(self, needle: str) -> bool:
|
|
71
|
+
needle = needle.lower()
|
|
72
|
+
if needle in self.window_title.lower():
|
|
73
|
+
return True
|
|
74
|
+
if self.stdout and needle in self.stdout.lower():
|
|
75
|
+
return True
|
|
76
|
+
if self.stderr and needle in self.stderr.lower():
|
|
77
|
+
return True
|
|
78
|
+
return any(needle in el.name.lower() for el in self.elements)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Adapter(ABC):
|
|
82
|
+
"""Abstract base for target adapters."""
|
|
83
|
+
|
|
84
|
+
type_name: str = "base"
|
|
85
|
+
|
|
86
|
+
@abstractmethod
|
|
87
|
+
def launch(self, target: str) -> None:
|
|
88
|
+
"""Start (or attach to) the application under test."""
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def observe(self, include_screenshot: bool = True) -> Observation:
|
|
92
|
+
"""Capture the current state of the target."""
|
|
93
|
+
|
|
94
|
+
def capabilities(self) -> dict:
|
|
95
|
+
"""Describe executable actions authorized for this adapter.
|
|
96
|
+
|
|
97
|
+
The base contract is intentionally minimal/fail-closed. Concrete
|
|
98
|
+
adapters must opt into every interactive capability they support so a
|
|
99
|
+
new adapter can never silently inherit mouse, keyboard, menu, or shell
|
|
100
|
+
powers that its ``act`` implementation happens to provide.
|
|
101
|
+
"""
|
|
102
|
+
return {
|
|
103
|
+
"actions": {
|
|
104
|
+
"wait": {},
|
|
105
|
+
"done": {},
|
|
106
|
+
},
|
|
107
|
+
"notes": ["This adapter has not declared interactive capabilities."],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
def _authorize_capability(self, action: dict) -> None:
|
|
111
|
+
"""Enforce this adapter's declared capability contract.
|
|
112
|
+
|
|
113
|
+
Capabilities are authorization, not merely prompt metadata. An action
|
|
114
|
+
kind must be declared before it can dispatch, and common target-shape
|
|
115
|
+
constraints are checked centrally so an adapter cannot accidentally
|
|
116
|
+
accept a broader form than it advertised.
|
|
117
|
+
"""
|
|
118
|
+
capabilities = self.capabilities() or {}
|
|
119
|
+
actions = capabilities.get("actions") or {}
|
|
120
|
+
kind = str(action.get("action") or "").lower()
|
|
121
|
+
|
|
122
|
+
if kind not in actions:
|
|
123
|
+
raise AdapterError(
|
|
124
|
+
f"action blocked: adapter '{self.type_name}' does not declare capability '{kind}'"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
spec = actions.get(kind) or {}
|
|
128
|
+
|
|
129
|
+
if kind in {"click", "double_click", "right_click"}:
|
|
130
|
+
has_element = "element_id" in action
|
|
131
|
+
has_coordinates = "x" in action or "y" in action
|
|
132
|
+
element_mode = spec.get("element_id", "optional")
|
|
133
|
+
|
|
134
|
+
if element_mode == "required" and not has_element:
|
|
135
|
+
raise AdapterError(
|
|
136
|
+
f"action blocked: adapter '{self.type_name}' requires element_id for '{kind}'"
|
|
137
|
+
)
|
|
138
|
+
if element_mode == "none" and has_element:
|
|
139
|
+
raise AdapterError(
|
|
140
|
+
f"action blocked: adapter '{self.type_name}' forbids element_id for '{kind}'"
|
|
141
|
+
)
|
|
142
|
+
if has_coordinates and not bool(spec.get("coordinates", False)):
|
|
143
|
+
raise AdapterError(
|
|
144
|
+
f"action blocked: adapter '{self.type_name}' forbids coordinates for '{kind}'"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if kind == "type":
|
|
148
|
+
has_element = "element_id" in action
|
|
149
|
+
element_mode = spec.get("element_id", "optional")
|
|
150
|
+
if element_mode == "required" and not has_element:
|
|
151
|
+
raise AdapterError(
|
|
152
|
+
f"action blocked: adapter '{self.type_name}' requires element_id for 'type'"
|
|
153
|
+
)
|
|
154
|
+
if element_mode == "none" and has_element:
|
|
155
|
+
raise AdapterError(
|
|
156
|
+
f"action blocked: adapter '{self.type_name}' forbids element_id for 'type'"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if kind in {"run", "execute"} and spec.get("command") == "required":
|
|
160
|
+
if "command" not in action:
|
|
161
|
+
raise AdapterError(
|
|
162
|
+
f"action blocked: adapter '{self.type_name}' requires command for '{kind}'"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def prepare_action(self, action: dict) -> dict:
|
|
166
|
+
"""Normalize and authorize *action* without performing its side effect.
|
|
167
|
+
|
|
168
|
+
The returned mapping is the exact action that is permitted to cross the
|
|
169
|
+
dispatch boundary. Splitting preparation from dispatch gives ATES a
|
|
170
|
+
durable commit point after all schema/policy/capability/platform checks
|
|
171
|
+
but before any target-visible mutation can begin.
|
|
172
|
+
"""
|
|
173
|
+
from argus.actions import ActionValidationError, validate_action
|
|
174
|
+
from argus.policy import ActionPolicyError, enforce_action_policy
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
normalized = validate_action(action)
|
|
178
|
+
enforce_action_policy(normalized)
|
|
179
|
+
except (ActionValidationError, ActionPolicyError) as exc:
|
|
180
|
+
raise AdapterError(f"action blocked: {exc}") from exc
|
|
181
|
+
|
|
182
|
+
self._authorize_capability(normalized)
|
|
183
|
+
self.validate_action(normalized)
|
|
184
|
+
return normalized
|
|
185
|
+
|
|
186
|
+
def dispatch_prepared_action(self, action: dict) -> str:
|
|
187
|
+
"""Execute an action already returned by :meth:`prepare_action`.
|
|
188
|
+
|
|
189
|
+
Callers must never use this as a validation bypass for untrusted model
|
|
190
|
+
output. It exists so a durable evidence writer can commit dispatch
|
|
191
|
+
intent between authorization and the actual side effect.
|
|
192
|
+
"""
|
|
193
|
+
return self.act(action)
|
|
194
|
+
|
|
195
|
+
def execute(self, action: dict) -> str:
|
|
196
|
+
"""Validate, authorize and execute one model-generated action."""
|
|
197
|
+
normalized = self.prepare_action(action)
|
|
198
|
+
return self.dispatch_prepared_action(normalized)
|
|
199
|
+
|
|
200
|
+
def validate_action(self, action: dict) -> None:
|
|
201
|
+
"""Platform-specific policy hook invoked immediately before dispatch."""
|
|
202
|
+
|
|
203
|
+
@abstractmethod
|
|
204
|
+
def act(self, action: dict) -> str:
|
|
205
|
+
"""Execute an already validated and authorized action; returns a short note."""
|
|
206
|
+
|
|
207
|
+
@abstractmethod
|
|
208
|
+
def close(self) -> None:
|
|
209
|
+
"""Tear down the target application."""
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class PolicyAdapter(Adapter):
|
|
213
|
+
"""Compatibility guard around an adapter used by the current engines.
|
|
214
|
+
|
|
215
|
+
The runner and roam engine historically call ``adapter.act`` directly.
|
|
216
|
+
Wrapping adapters here means those calls still pass through the inner
|
|
217
|
+
adapter's validation/authorization boundary without a broad engine rewrite.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(self, inner: Adapter) -> None:
|
|
221
|
+
self.inner = inner
|
|
222
|
+
self.type_name = inner.type_name
|
|
223
|
+
|
|
224
|
+
def launch(self, target: str) -> None:
|
|
225
|
+
self.inner.launch(target)
|
|
226
|
+
|
|
227
|
+
def observe(self, include_screenshot: bool = True) -> Observation:
|
|
228
|
+
obs = self.inner.observe(include_screenshot=include_screenshot)
|
|
229
|
+
obs.action_capabilities = self.capabilities()
|
|
230
|
+
return obs
|
|
231
|
+
|
|
232
|
+
def capabilities(self) -> dict:
|
|
233
|
+
return self.inner.capabilities()
|
|
234
|
+
|
|
235
|
+
def prepare_action(self, action: dict) -> dict:
|
|
236
|
+
return self.inner.prepare_action(action)
|
|
237
|
+
|
|
238
|
+
def dispatch_prepared_action(self, action: dict) -> str:
|
|
239
|
+
return self.inner.dispatch_prepared_action(action)
|
|
240
|
+
|
|
241
|
+
def act(self, action: dict) -> str:
|
|
242
|
+
normalized = self.prepare_action(action)
|
|
243
|
+
return self.dispatch_prepared_action(normalized)
|
|
244
|
+
|
|
245
|
+
def validate_action(self, action: dict) -> None:
|
|
246
|
+
self.inner.validate_action(action)
|
|
247
|
+
|
|
248
|
+
def close(self) -> None:
|
|
249
|
+
self.inner.close()
|
|
250
|
+
|
|
251
|
+
def __getattr__(self, name):
|
|
252
|
+
return getattr(self.inner, name)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _guard(adapter: Adapter) -> Adapter:
|
|
256
|
+
return adapter if isinstance(adapter, PolicyAdapter) else PolicyAdapter(adapter)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def create_adapter(adapter_type: str) -> Adapter:
|
|
260
|
+
adapter_type = (adapter_type or "").lower().strip()
|
|
261
|
+
if adapter_type in ("desktop-gui", "desktop", "gui"):
|
|
262
|
+
if sys.platform == "win32":
|
|
263
|
+
input_mode = (os.environ.get("ARGUS_INPUT_MODE") or "safe").lower().strip()
|
|
264
|
+
if input_mode in {"physical", "legacy"}:
|
|
265
|
+
from argus.adapters.windows_gui import WindowsGUIAdapter
|
|
266
|
+
return _guard(WindowsGUIAdapter())
|
|
267
|
+
if input_mode not in {"safe", "semantic"}:
|
|
268
|
+
raise AdapterError(
|
|
269
|
+
"ARGUS_INPUT_MODE must be 'safe'/'semantic' or explicit 'physical'"
|
|
270
|
+
)
|
|
271
|
+
from argus.adapters.windows_safe import SafeWindowsGUIAdapter
|
|
272
|
+
return _guard(SafeWindowsGUIAdapter())
|
|
273
|
+
if sys.platform.startswith("linux"):
|
|
274
|
+
from argus.adapters.linux_gui import LinuxGUIAdapter
|
|
275
|
+
return _guard(LinuxGUIAdapter())
|
|
276
|
+
raise AdapterError(
|
|
277
|
+
"the desktop-gui adapter supports Windows and Linux "
|
|
278
|
+
f"(this is {sys.platform}). macOS support is on the roadmap."
|
|
279
|
+
)
|
|
280
|
+
if adapter_type in ("cli", "terminal", "shell"):
|
|
281
|
+
from argus.adapters.cli_adapter import CLIAdapter
|
|
282
|
+
return _guard(CLIAdapter())
|
|
283
|
+
if adapter_type in ("browser", "web", "playwright"):
|
|
284
|
+
from argus.adapters.browser_adapter import BrowserAdapter
|
|
285
|
+
return _guard(BrowserAdapter())
|
|
286
|
+
if adapter_type in ("linux-gui", "linux_gui", "x11"):
|
|
287
|
+
from argus.adapters.linux_gui import LinuxGUIAdapter
|
|
288
|
+
return _guard(LinuxGUIAdapter())
|
|
289
|
+
raise AdapterError(
|
|
290
|
+
f"unknown adapter '{adapter_type}' — available: desktop-gui, cli, browser"
|
|
291
|
+
)
|