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.
Files changed (96) hide show
  1. argus/__init__.py +17 -0
  2. argus/__main__.py +4 -0
  3. argus/actions.py +222 -0
  4. argus/adapters/__init__.py +19 -0
  5. argus/adapters/base.py +291 -0
  6. argus/adapters/browser_adapter.py +209 -0
  7. argus/adapters/cli_adapter.py +112 -0
  8. argus/adapters/linux_gui.py +252 -0
  9. argus/adapters/windows_gui.py +411 -0
  10. argus/adapters/windows_safe.py +589 -0
  11. argus/ates/__init__.py +124 -0
  12. argus/ates/artifacts.py +917 -0
  13. argus/ates/audit.py +1675 -0
  14. argus/ates/authority_guards.py +777 -0
  15. argus/ates/core.py +1238 -0
  16. argus/ates/evidence_validation.py +2006 -0
  17. argus/ates/finalization.py +618 -0
  18. argus/ates/finalization_io.py +504 -0
  19. argus/ates/finalization_types.py +42 -0
  20. argus/ates/ids.py +47 -0
  21. argus/ates/package.py +65 -0
  22. argus/ates/privacy.py +469 -0
  23. argus/ates/recovery_policy.py +64 -0
  24. argus/ates/reports.py +1360 -0
  25. argus/ates/status.py +160 -0
  26. argus/ates/store.py +1310 -0
  27. argus/ates/transaction_guards.py +578 -0
  28. argus/ates/trust_guards.py +822 -0
  29. argus/capsule/__init__.py +27 -0
  30. argus/capsule/base.py +218 -0
  31. argus/capsule/files.py +162 -0
  32. argus/capsule/guest.py +502 -0
  33. argus/capsule/guest_agent.py +719 -0
  34. argus/capsule/host_collect.py +102 -0
  35. argus/capsule/hyperv.py +369 -0
  36. argus/capsule/hyperv_isolated.py +314 -0
  37. argus/capsule/libvirt.py +1122 -0
  38. argus/capsule/safe_open.py +333 -0
  39. argus/capsule/safe_output.py +418 -0
  40. argus/capsule/secure_client.py +97 -0
  41. argus/capsule/secure_guest_agent.py +217 -0
  42. argus/cli.py +568 -0
  43. argus/config.py +509 -0
  44. argus/engine/__init__.py +0 -0
  45. argus/engine/agent.py +303 -0
  46. argus/engine/ates_artifacts.py +315 -0
  47. argus/engine/ates_runtime.py +1094 -0
  48. argus/engine/results.py +266 -0
  49. argus/engine/roam.py +86 -0
  50. argus/engine/roam_ates_impl.py +253 -0
  51. argus/engine/roam_impl.py +534 -0
  52. argus/engine/runner.py +66 -0
  53. argus/engine/runner_impl.py +788 -0
  54. argus/engine/spec.py +285 -0
  55. argus/execution/__init__.py +21 -0
  56. argus/execution/ates_collection.py +198 -0
  57. argus/execution/base.py +237 -0
  58. argus/execution/capsule.py +583 -0
  59. argus/execution/secure_capsule.py +291 -0
  60. argus/fleet/__init__.py +123 -0
  61. argus/fleet/ates_transport.py +704 -0
  62. argus/fleet/enrollment.py +594 -0
  63. argus/fleet/execution.py +326 -0
  64. argus/fleet/heartbeat.py +296 -0
  65. argus/fleet/heartbeat_impl.py +960 -0
  66. argus/fleet/identity.py +207 -0
  67. argus/fleet/placement.py +1308 -0
  68. argus/gui/__init__.py +0 -0
  69. argus/gui/__main__.py +4 -0
  70. argus/gui/app.py +308 -0
  71. argus/gui/web/app.js +295 -0
  72. argus/gui/web/index.html +402 -0
  73. argus/knowledge/__init__.py +177 -0
  74. argus/knowledge/base.py +128 -0
  75. argus/knowledge/docker_manager.py +162 -0
  76. argus/knowledge/embeddings.py +41 -0
  77. argus/knowledge/fingerprint.py +58 -0
  78. argus/knowledge/json_store.py +319 -0
  79. argus/knowledge/remote.py +353 -0
  80. argus/knowledge/store.py +359 -0
  81. argus/policy.py +44 -0
  82. argus/providers/__init__.py +10 -0
  83. argus/providers/anthropic_provider.py +109 -0
  84. argus/providers/base.py +77 -0
  85. argus/providers/ollama.py +147 -0
  86. argus/providers/openai_provider.py +175 -0
  87. argus/providers/registry.py +69 -0
  88. argus/serve/__init__.py +0 -0
  89. argus/serve/app.py +463 -0
  90. argus/tokens.py +177 -0
  91. argus_app_testing-0.1.1.dist-info/METADATA +484 -0
  92. argus_app_testing-0.1.1.dist-info/RECORD +96 -0
  93. argus_app_testing-0.1.1.dist-info/WHEEL +5 -0
  94. argus_app_testing-0.1.1.dist-info/entry_points.txt +3 -0
  95. argus_app_testing-0.1.1.dist-info/licenses/LICENSE +21 -0
  96. 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
@@ -0,0 +1,4 @@
1
+ from argus.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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
+ )