codeaway 0.1.0__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.
- codeaway/__init__.py +4 -0
- codeaway/__main__.py +5 -0
- codeaway/agents.py +427 -0
- codeaway/cli.py +221 -0
- codeaway/config.py +157 -0
- codeaway/desktop.py +644 -0
- codeaway/server.py +673 -0
- codeaway/web/__init__.py +1 -0
- codeaway/web/app.js +747 -0
- codeaway/web/index.html +41 -0
- codeaway/web/setup.html +57 -0
- codeaway/web/style.css +280 -0
- codeaway-0.1.0.dist-info/METADATA +113 -0
- codeaway-0.1.0.dist-info/RECORD +17 -0
- codeaway-0.1.0.dist-info/WHEEL +4 -0
- codeaway-0.1.0.dist-info/entry_points.txt +2 -0
- codeaway-0.1.0.dist-info/licenses/LICENSE +21 -0
codeaway/server.py
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from http.server import BaseHTTPRequestHandler
|
|
11
|
+
from importlib import resources
|
|
12
|
+
from io import BytesIO
|
|
13
|
+
from numbers import Real
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Mapping
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
from .agents import (
|
|
19
|
+
AgentBackend,
|
|
20
|
+
AgentRegistry,
|
|
21
|
+
AgentTarget,
|
|
22
|
+
ClickAction,
|
|
23
|
+
NavigationAction,
|
|
24
|
+
SurfaceMap,
|
|
25
|
+
TargetUnavailable,
|
|
26
|
+
)
|
|
27
|
+
from .config import AppConfig, _parse_config, save_config
|
|
28
|
+
from .desktop import (
|
|
29
|
+
AccessibilityUnavailable,
|
|
30
|
+
DesktopBackend,
|
|
31
|
+
FractionalRegion,
|
|
32
|
+
InputUnavailable,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_MAX_JSON_BODY = 65_536
|
|
37
|
+
_STATIC_RESOURCES = {
|
|
38
|
+
"/": ("index.html", "text/html; charset=utf-8"),
|
|
39
|
+
"/setup": ("setup.html", "text/html; charset=utf-8"),
|
|
40
|
+
"/app.js": ("app.js", "text/javascript; charset=utf-8"),
|
|
41
|
+
"/style.css": ("style.css", "text/css; charset=utf-8"),
|
|
42
|
+
}
|
|
43
|
+
_SCREENSHOT_SURFACES = frozenset({"window", "sidebar", "conversation"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Response:
|
|
48
|
+
status: int
|
|
49
|
+
content_type: str
|
|
50
|
+
body: bytes
|
|
51
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class AppState:
|
|
56
|
+
config: AppConfig
|
|
57
|
+
target: AgentTarget | None
|
|
58
|
+
revision: int = 0
|
|
59
|
+
state_lock: threading.RLock = field(default_factory=threading.RLock)
|
|
60
|
+
action_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
61
|
+
conversation_png: bytes | None = None
|
|
62
|
+
conversation_digest: bytes | None = None
|
|
63
|
+
conversation_token: tuple[object, ...] | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Application:
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
state: AppState,
|
|
70
|
+
registry: AgentRegistry,
|
|
71
|
+
agents: Mapping[str, AgentBackend],
|
|
72
|
+
desktop: DesktopBackend,
|
|
73
|
+
config_path: str | Path,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.state = state
|
|
76
|
+
self.registry = registry
|
|
77
|
+
self.agents = dict(agents)
|
|
78
|
+
self.desktop = desktop
|
|
79
|
+
self.config_path = Path(config_path)
|
|
80
|
+
self._discovered_targets: dict[str, AgentTarget] = {}
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _json_response(status: int, value: Any) -> Response:
|
|
84
|
+
return Response(
|
|
85
|
+
status,
|
|
86
|
+
"application/json; charset=utf-8",
|
|
87
|
+
json.dumps(value, separators=(",", ":")).encode("utf-8"),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def _error(cls, status: int, code: str, message: str) -> Response:
|
|
92
|
+
return cls._json_response(
|
|
93
|
+
status, {"error": {"code": code, "message": message}}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _headers(headers: Mapping[str, str]) -> dict[str, str]:
|
|
98
|
+
return {name.casefold(): value for name, value in headers.items()}
|
|
99
|
+
|
|
100
|
+
def _request_json(
|
|
101
|
+
self, headers: Mapping[str, str], body: bytes
|
|
102
|
+
) -> tuple[dict[str, Any] | None, Response | None]:
|
|
103
|
+
normalized = self._headers(headers)
|
|
104
|
+
content_type = normalized.get("content-type", "")
|
|
105
|
+
content_type_parts = [part.strip() for part in content_type.split(";")]
|
|
106
|
+
media_type = content_type_parts[0].casefold()
|
|
107
|
+
parameters = content_type_parts[1:]
|
|
108
|
+
charset_valid = not parameters
|
|
109
|
+
if len(parameters) == 1:
|
|
110
|
+
name, separator, value = parameters[0].partition("=")
|
|
111
|
+
charset_valid = (
|
|
112
|
+
separator == "="
|
|
113
|
+
and name.strip().casefold() == "charset"
|
|
114
|
+
and bool(value.strip())
|
|
115
|
+
)
|
|
116
|
+
if media_type != "application/json" or not charset_valid:
|
|
117
|
+
return None, self._error(
|
|
118
|
+
415, "unsupported_media_type", "Content-Type must be application/json."
|
|
119
|
+
)
|
|
120
|
+
if len(body) > _MAX_JSON_BODY:
|
|
121
|
+
return None, self._error(
|
|
122
|
+
413, "body_too_large", "JSON request body exceeds 65,536 bytes."
|
|
123
|
+
)
|
|
124
|
+
with self.state.state_lock:
|
|
125
|
+
expected_authority = (
|
|
126
|
+
f"{self.state.config.bind_ip}:{self.state.config.port}"
|
|
127
|
+
).casefold()
|
|
128
|
+
host = normalized.get("host", "").strip().casefold()
|
|
129
|
+
if host != expected_authority:
|
|
130
|
+
return None, self._error(
|
|
131
|
+
403,
|
|
132
|
+
"host_mismatch",
|
|
133
|
+
"Request Host must match the configured CodeAway address.",
|
|
134
|
+
)
|
|
135
|
+
origin = normalized.get("origin")
|
|
136
|
+
if origin is not None:
|
|
137
|
+
try:
|
|
138
|
+
parsed = urlsplit(origin)
|
|
139
|
+
except ValueError:
|
|
140
|
+
return None, self._error(
|
|
141
|
+
403,
|
|
142
|
+
"origin_mismatch",
|
|
143
|
+
"Request Origin must match the CodeAway Host.",
|
|
144
|
+
)
|
|
145
|
+
if (
|
|
146
|
+
parsed.scheme.casefold() != "http"
|
|
147
|
+
or not parsed.netloc
|
|
148
|
+
or parsed.netloc.casefold() != expected_authority
|
|
149
|
+
or parsed.path not in {"", "/"}
|
|
150
|
+
or parsed.query
|
|
151
|
+
or parsed.fragment
|
|
152
|
+
):
|
|
153
|
+
return None, self._error(
|
|
154
|
+
403,
|
|
155
|
+
"origin_mismatch",
|
|
156
|
+
"Request Origin must match the CodeAway Host.",
|
|
157
|
+
)
|
|
158
|
+
try:
|
|
159
|
+
value = json.loads(body)
|
|
160
|
+
except (ValueError, RecursionError):
|
|
161
|
+
return None, self._error(400, "invalid_json", "Request body is not valid JSON.")
|
|
162
|
+
if not isinstance(value, dict):
|
|
163
|
+
return None, self._error(400, "invalid_request", "JSON body must be an object.")
|
|
164
|
+
return value, None
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def _surface_values(surface: FractionalRegion) -> list[int | float]:
|
|
168
|
+
return [surface.x, surface.y, surface.width, surface.height]
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def _surfaces_value(cls, surfaces: SurfaceMap) -> dict[str, list[int | float]]:
|
|
172
|
+
return {
|
|
173
|
+
"sidebar": cls._surface_values(surfaces.sidebar),
|
|
174
|
+
"conversation": cls._surface_values(surfaces.conversation),
|
|
175
|
+
"composer": cls._surface_values(surfaces.composer),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
def _fixed_resource(self, path: str) -> Response:
|
|
179
|
+
resource = _STATIC_RESOURCES.get(path)
|
|
180
|
+
if resource is None:
|
|
181
|
+
return self._error(404, "not_found", "Resource not found.")
|
|
182
|
+
filename, content_type = resource
|
|
183
|
+
try:
|
|
184
|
+
body = resources.files("codeaway.web").joinpath(filename).read_bytes()
|
|
185
|
+
except (ModuleNotFoundError, FileNotFoundError, TypeError):
|
|
186
|
+
return self._error(404, "not_found", "Resource not found.")
|
|
187
|
+
return Response(200, content_type, body)
|
|
188
|
+
|
|
189
|
+
def _resolve_runtime_target(self, selected: AgentTarget) -> AgentTarget | None:
|
|
190
|
+
backend = self._backend(selected)
|
|
191
|
+
if backend is None:
|
|
192
|
+
return None
|
|
193
|
+
process_path = os.path.normcase(selected.window.process_path)
|
|
194
|
+
window = next(
|
|
195
|
+
(
|
|
196
|
+
candidate
|
|
197
|
+
for candidate in self.desktop.list_windows()
|
|
198
|
+
if candidate.native_handle == selected.window.native_handle
|
|
199
|
+
and os.path.normcase(candidate.process_path) == process_path
|
|
200
|
+
and backend.matches(candidate)
|
|
201
|
+
),
|
|
202
|
+
None,
|
|
203
|
+
)
|
|
204
|
+
if window is None:
|
|
205
|
+
return None
|
|
206
|
+
return AgentTarget(selected.agent_id, window, selected.surfaces)
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _selection_token(target: AgentTarget | None) -> tuple[object, ...] | None:
|
|
210
|
+
if target is None:
|
|
211
|
+
return None
|
|
212
|
+
return (
|
|
213
|
+
target.agent_id,
|
|
214
|
+
target.window.native_handle,
|
|
215
|
+
os.path.normcase(target.window.process_path),
|
|
216
|
+
target.surfaces,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _same_window_identity(first: AgentTarget, second: AgentTarget) -> bool:
|
|
221
|
+
return (
|
|
222
|
+
first.agent_id == second.agent_id
|
|
223
|
+
and first.window.native_handle == second.window.native_handle
|
|
224
|
+
and os.path.normcase(os.path.normpath(first.window.process_path))
|
|
225
|
+
== os.path.normcase(os.path.normpath(second.window.process_path))
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def _current_target(self) -> AgentTarget | None:
|
|
229
|
+
with self.state.state_lock:
|
|
230
|
+
selected = self.state.target
|
|
231
|
+
selection_token = self._selection_token(selected)
|
|
232
|
+
if selected is None:
|
|
233
|
+
return None
|
|
234
|
+
current = self._resolve_runtime_target(selected)
|
|
235
|
+
with self.state.state_lock:
|
|
236
|
+
if self._selection_token(self.state.target) != selection_token:
|
|
237
|
+
return None
|
|
238
|
+
self.state.target = current
|
|
239
|
+
return current
|
|
240
|
+
|
|
241
|
+
def _clear_conversation_cache_locked(self) -> None:
|
|
242
|
+
self.state.conversation_png = None
|
|
243
|
+
self.state.conversation_digest = None
|
|
244
|
+
self.state.conversation_token = None
|
|
245
|
+
|
|
246
|
+
def _capture_png(self, region) -> bytes:
|
|
247
|
+
image = self.desktop.capture(region)
|
|
248
|
+
output = BytesIO()
|
|
249
|
+
image.save(output, format="PNG")
|
|
250
|
+
return output.getvalue()
|
|
251
|
+
|
|
252
|
+
def _observe_conversation_locked(self, target: AgentTarget) -> None:
|
|
253
|
+
region = target.surfaces.conversation.resolve(target.window.region)
|
|
254
|
+
try:
|
|
255
|
+
png = self._capture_png(region)
|
|
256
|
+
except Exception:
|
|
257
|
+
return
|
|
258
|
+
digest = hashlib.sha256(png).digest()
|
|
259
|
+
token = self._selection_token(target)
|
|
260
|
+
with self.state.state_lock:
|
|
261
|
+
if self._selection_token(self.state.target) != token:
|
|
262
|
+
return
|
|
263
|
+
if (
|
|
264
|
+
self.state.conversation_token == token
|
|
265
|
+
and self.state.conversation_digest is not None
|
|
266
|
+
and self.state.conversation_digest != digest
|
|
267
|
+
):
|
|
268
|
+
self.state.revision += 1
|
|
269
|
+
self.state.conversation_png = png
|
|
270
|
+
self.state.conversation_digest = digest
|
|
271
|
+
self.state.conversation_token = token
|
|
272
|
+
|
|
273
|
+
def _backend(self, target: AgentTarget) -> AgentBackend | None:
|
|
274
|
+
return self.agents.get(target.agent_id)
|
|
275
|
+
|
|
276
|
+
def _status(self) -> Response:
|
|
277
|
+
with self.state.action_lock:
|
|
278
|
+
target = self._current_target()
|
|
279
|
+
if target is None:
|
|
280
|
+
with self.state.state_lock:
|
|
281
|
+
self._clear_conversation_cache_locked()
|
|
282
|
+
else:
|
|
283
|
+
self._observe_conversation_locked(target)
|
|
284
|
+
return self._status_locked()
|
|
285
|
+
|
|
286
|
+
def _status_locked(self) -> Response:
|
|
287
|
+
with self.state.state_lock:
|
|
288
|
+
config = self.state.config
|
|
289
|
+
target = self.state.target
|
|
290
|
+
value = {
|
|
291
|
+
"bind_ip": config.bind_ip,
|
|
292
|
+
"port": config.port,
|
|
293
|
+
"ready": config.setup_complete and target is not None,
|
|
294
|
+
"revision": self.state.revision,
|
|
295
|
+
"setup_complete": config.setup_complete,
|
|
296
|
+
"target": (
|
|
297
|
+
None
|
|
298
|
+
if target is None
|
|
299
|
+
else {"agent_id": target.agent_id, "title": target.window.title}
|
|
300
|
+
),
|
|
301
|
+
}
|
|
302
|
+
return self._json_response(200, value)
|
|
303
|
+
|
|
304
|
+
def _windows(self) -> Response:
|
|
305
|
+
targets = self.registry.discover(self.desktop)
|
|
306
|
+
with self.state.state_lock:
|
|
307
|
+
current = self.state.target
|
|
308
|
+
presented_targets = [
|
|
309
|
+
AgentTarget(target.agent_id, target.window, current.surfaces)
|
|
310
|
+
if current is not None
|
|
311
|
+
and self._same_window_identity(target, current)
|
|
312
|
+
else target
|
|
313
|
+
for target in targets
|
|
314
|
+
]
|
|
315
|
+
self._discovered_targets = {
|
|
316
|
+
target.window.id: target for target in presented_targets
|
|
317
|
+
}
|
|
318
|
+
return self._json_response(
|
|
319
|
+
200,
|
|
320
|
+
{
|
|
321
|
+
"windows": [
|
|
322
|
+
{
|
|
323
|
+
"agent_id": target.agent_id,
|
|
324
|
+
"current": (
|
|
325
|
+
current is not None
|
|
326
|
+
and self._same_window_identity(target, current)
|
|
327
|
+
),
|
|
328
|
+
"id": target.window.id,
|
|
329
|
+
"process_path": target.window.process_path,
|
|
330
|
+
"surfaces": self._surfaces_value(target.surfaces),
|
|
331
|
+
"title": target.window.title,
|
|
332
|
+
}
|
|
333
|
+
for target in presented_targets
|
|
334
|
+
]
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def _select(self, value: dict[str, Any]) -> Response:
|
|
339
|
+
with self.state.action_lock:
|
|
340
|
+
return self._select_locked(value)
|
|
341
|
+
|
|
342
|
+
def _select_locked(self, value: dict[str, Any]) -> Response:
|
|
343
|
+
window_id = value.get("window_id")
|
|
344
|
+
if not isinstance(window_id, str):
|
|
345
|
+
return self._error(400, "invalid_request", "window_id must be a string.")
|
|
346
|
+
with self.state.state_lock:
|
|
347
|
+
selected = self._discovered_targets.get(window_id)
|
|
348
|
+
if selected is None:
|
|
349
|
+
discovered = self.registry.discover(self.desktop)
|
|
350
|
+
with self.state.state_lock:
|
|
351
|
+
self._discovered_targets = {
|
|
352
|
+
candidate.window.id: candidate for candidate in discovered
|
|
353
|
+
}
|
|
354
|
+
selected = next(
|
|
355
|
+
(candidate for candidate in discovered if candidate.window.id == window_id),
|
|
356
|
+
None,
|
|
357
|
+
)
|
|
358
|
+
if selected is None:
|
|
359
|
+
return self._error(409, "target_unavailable", "Selected window is unavailable.")
|
|
360
|
+
target = self._resolve_runtime_target(selected)
|
|
361
|
+
if target is None:
|
|
362
|
+
return self._error(409, "target_unavailable", "Selected window is unavailable.")
|
|
363
|
+
with self.state.state_lock:
|
|
364
|
+
self.state.target = target
|
|
365
|
+
self._clear_conversation_cache_locked()
|
|
366
|
+
self.state.revision += 1
|
|
367
|
+
revision = self.state.revision
|
|
368
|
+
return self._json_response(200, {"revision": revision})
|
|
369
|
+
|
|
370
|
+
def _screenshot(self, surface_name: str) -> Response:
|
|
371
|
+
with self.state.action_lock:
|
|
372
|
+
return self._screenshot_locked(surface_name)
|
|
373
|
+
|
|
374
|
+
def _screenshot_locked(self, surface_name: str) -> Response:
|
|
375
|
+
if surface_name not in _SCREENSHOT_SURFACES:
|
|
376
|
+
return self._error(404, "not_found", "Screenshot surface not found.")
|
|
377
|
+
target = self._current_target()
|
|
378
|
+
if target is None:
|
|
379
|
+
return self._error(409, "target_unavailable", "Selected window is unavailable.")
|
|
380
|
+
region = (
|
|
381
|
+
target.window.region
|
|
382
|
+
if surface_name == "window"
|
|
383
|
+
else getattr(target.surfaces, surface_name).resolve(target.window.region)
|
|
384
|
+
)
|
|
385
|
+
token = self._selection_token(target)
|
|
386
|
+
if surface_name == "conversation":
|
|
387
|
+
with self.state.state_lock:
|
|
388
|
+
if (
|
|
389
|
+
self.state.conversation_token == token
|
|
390
|
+
and self.state.conversation_png is not None
|
|
391
|
+
):
|
|
392
|
+
return Response(
|
|
393
|
+
200,
|
|
394
|
+
"image/png",
|
|
395
|
+
self.state.conversation_png,
|
|
396
|
+
{"Cache-Control": "no-store"},
|
|
397
|
+
)
|
|
398
|
+
try:
|
|
399
|
+
png = self._capture_png(region)
|
|
400
|
+
except Exception:
|
|
401
|
+
return self._error(503, "screenshot_failed", "Screenshot capture failed.")
|
|
402
|
+
if surface_name == "conversation":
|
|
403
|
+
with self.state.state_lock:
|
|
404
|
+
if self._selection_token(self.state.target) == token:
|
|
405
|
+
self.state.conversation_png = png
|
|
406
|
+
self.state.conversation_digest = hashlib.sha256(png).digest()
|
|
407
|
+
self.state.conversation_token = token
|
|
408
|
+
return Response(200, "image/png", png, {"Cache-Control": "no-store"})
|
|
409
|
+
|
|
410
|
+
def _navigator(self) -> Response:
|
|
411
|
+
with self.state.action_lock:
|
|
412
|
+
return self._navigator_locked()
|
|
413
|
+
|
|
414
|
+
def _navigator_locked(self) -> Response:
|
|
415
|
+
target = self._current_target()
|
|
416
|
+
if target is None:
|
|
417
|
+
return self._error(409, "target_unavailable", "Selected window is unavailable.")
|
|
418
|
+
backend = self._backend(target)
|
|
419
|
+
if backend is None:
|
|
420
|
+
return self._error(409, "target_unavailable", "Selected agent is unavailable.")
|
|
421
|
+
try:
|
|
422
|
+
snapshot = backend.inspect(self.desktop, target)
|
|
423
|
+
except AccessibilityUnavailable:
|
|
424
|
+
return self._json_response(
|
|
425
|
+
200,
|
|
426
|
+
{
|
|
427
|
+
"available": False,
|
|
428
|
+
"source": "accessibility",
|
|
429
|
+
"projects": [],
|
|
430
|
+
"captured_at": datetime.now(timezone.utc).isoformat(),
|
|
431
|
+
"error": "The accessibility navigator is unavailable; screenshots and direct controls remain available.",
|
|
432
|
+
},
|
|
433
|
+
)
|
|
434
|
+
except TargetUnavailable:
|
|
435
|
+
return self._error(409, "target_unavailable", "Selected window is unavailable.")
|
|
436
|
+
return self._json_response(200, asdict(snapshot))
|
|
437
|
+
|
|
438
|
+
def _calibration(self, value: dict[str, Any]) -> Response:
|
|
439
|
+
with self.state.action_lock:
|
|
440
|
+
return self._calibration_locked(value)
|
|
441
|
+
|
|
442
|
+
def _calibration_locked(self, value: dict[str, Any]) -> Response:
|
|
443
|
+
surfaces = value.get("surfaces")
|
|
444
|
+
if not isinstance(surfaces, dict) or set(surfaces) != {
|
|
445
|
+
"sidebar",
|
|
446
|
+
"conversation",
|
|
447
|
+
"composer",
|
|
448
|
+
}:
|
|
449
|
+
return self._error(
|
|
450
|
+
400,
|
|
451
|
+
"invalid_calibration",
|
|
452
|
+
"Calibration requires sidebar, conversation, and composer surfaces.",
|
|
453
|
+
)
|
|
454
|
+
target = self._current_target()
|
|
455
|
+
if target is None:
|
|
456
|
+
return self._error(409, "target_unavailable", "Selected window is unavailable.")
|
|
457
|
+
with self.state.state_lock:
|
|
458
|
+
current = self.state.config
|
|
459
|
+
try:
|
|
460
|
+
config = _parse_config(
|
|
461
|
+
{
|
|
462
|
+
"bind_ip": current.bind_ip,
|
|
463
|
+
"port": current.port,
|
|
464
|
+
"selected_agent": target.agent_id,
|
|
465
|
+
"selected_window": {
|
|
466
|
+
"process_path": target.window.process_path,
|
|
467
|
+
"title_hint": target.window.title,
|
|
468
|
+
},
|
|
469
|
+
"surfaces": surfaces,
|
|
470
|
+
}
|
|
471
|
+
)
|
|
472
|
+
except (KeyError, TypeError, ValueError):
|
|
473
|
+
return self._error(400, "invalid_calibration", "Calibration is invalid.")
|
|
474
|
+
calibrated_target = AgentTarget(target.agent_id, target.window, config.surfaces)
|
|
475
|
+
with self.state.state_lock:
|
|
476
|
+
try:
|
|
477
|
+
save_config(self.config_path, config)
|
|
478
|
+
except OSError:
|
|
479
|
+
return self._error(500, "config_save_failed", "Configuration could not be saved.")
|
|
480
|
+
self.state.config = config
|
|
481
|
+
self.state.target = calibrated_target
|
|
482
|
+
self._clear_conversation_cache_locked()
|
|
483
|
+
self.state.revision += 1
|
|
484
|
+
revision = self.state.revision
|
|
485
|
+
return self._json_response(200, {"revision": revision})
|
|
486
|
+
|
|
487
|
+
@staticmethod
|
|
488
|
+
def _number(value: Any, name: str) -> float:
|
|
489
|
+
if (
|
|
490
|
+
isinstance(value, bool)
|
|
491
|
+
or not isinstance(value, Real)
|
|
492
|
+
or not math.isfinite(value)
|
|
493
|
+
or not 0 <= value <= 1
|
|
494
|
+
):
|
|
495
|
+
raise ValueError(f"{name} must be a number between 0 and 1")
|
|
496
|
+
return float(value)
|
|
497
|
+
|
|
498
|
+
def _perform_action(
|
|
499
|
+
self, backend: AgentBackend, target: AgentTarget, value: dict[str, Any]
|
|
500
|
+
) -> None:
|
|
501
|
+
kind = value.get("kind")
|
|
502
|
+
if kind == "scroll":
|
|
503
|
+
amount = value.get("amount")
|
|
504
|
+
if (
|
|
505
|
+
isinstance(amount, bool)
|
|
506
|
+
or not isinstance(amount, int)
|
|
507
|
+
or amount == 0
|
|
508
|
+
or not -12 <= amount <= 12
|
|
509
|
+
):
|
|
510
|
+
raise ValueError("amount must be a nonzero integer between -12 and 12")
|
|
511
|
+
backend.scroll(self.desktop, target, amount)
|
|
512
|
+
return
|
|
513
|
+
if kind == "send":
|
|
514
|
+
text = value.get("text")
|
|
515
|
+
if not isinstance(text, str):
|
|
516
|
+
raise ValueError("text must be a string")
|
|
517
|
+
backend.send(self.desktop, target, text)
|
|
518
|
+
return
|
|
519
|
+
if kind == "click":
|
|
520
|
+
surface = value.get("surface")
|
|
521
|
+
if surface not in {"sidebar", "conversation"}:
|
|
522
|
+
raise ValueError("surface must be sidebar or conversation")
|
|
523
|
+
backend.click(
|
|
524
|
+
self.desktop,
|
|
525
|
+
target,
|
|
526
|
+
ClickAction(
|
|
527
|
+
surface,
|
|
528
|
+
self._number(value.get("x"), "x"),
|
|
529
|
+
self._number(value.get("y"), "y"),
|
|
530
|
+
),
|
|
531
|
+
)
|
|
532
|
+
return
|
|
533
|
+
if kind == "navigate":
|
|
534
|
+
navigation_kind = value.get("target")
|
|
535
|
+
project = value.get("project")
|
|
536
|
+
if navigation_kind not in {"project", "task"} or not isinstance(project, str):
|
|
537
|
+
raise ValueError("navigate requires a target and project")
|
|
538
|
+
title = value.get("title")
|
|
539
|
+
expanded = value.get("expanded")
|
|
540
|
+
if navigation_kind == "task" and not isinstance(title, str):
|
|
541
|
+
raise ValueError("task navigation requires a title")
|
|
542
|
+
if navigation_kind == "project" and not isinstance(expanded, bool):
|
|
543
|
+
raise ValueError("project navigation requires expanded")
|
|
544
|
+
backend.navigate(
|
|
545
|
+
self.desktop,
|
|
546
|
+
target,
|
|
547
|
+
NavigationAction(navigation_kind, project, title=title, expanded=expanded),
|
|
548
|
+
)
|
|
549
|
+
return
|
|
550
|
+
raise ValueError("unknown action kind")
|
|
551
|
+
|
|
552
|
+
def _action(self, value: dict[str, Any]) -> Response:
|
|
553
|
+
with self.state.action_lock:
|
|
554
|
+
target = self._current_target()
|
|
555
|
+
if target is None:
|
|
556
|
+
return self._error(409, "target_unavailable", "Selected window is unavailable.")
|
|
557
|
+
backend = self._backend(target)
|
|
558
|
+
if backend is None:
|
|
559
|
+
return self._error(409, "target_unavailable", "Selected agent is unavailable.")
|
|
560
|
+
try:
|
|
561
|
+
self._perform_action(backend, target, value)
|
|
562
|
+
except ValueError as error:
|
|
563
|
+
return self._error(400, "invalid_action", str(error))
|
|
564
|
+
except (InputUnavailable, TargetUnavailable):
|
|
565
|
+
return self._error(409, "target_unavailable", "Selected window is unavailable.")
|
|
566
|
+
with self.state.state_lock:
|
|
567
|
+
self._clear_conversation_cache_locked()
|
|
568
|
+
self.state.revision += 1
|
|
569
|
+
revision = self.state.revision
|
|
570
|
+
return self._json_response(200, {"revision": revision})
|
|
571
|
+
|
|
572
|
+
def _dispatch(
|
|
573
|
+
self,
|
|
574
|
+
method: str,
|
|
575
|
+
path: str,
|
|
576
|
+
headers: Mapping[str, str],
|
|
577
|
+
body: bytes,
|
|
578
|
+
) -> Response:
|
|
579
|
+
route_path = urlsplit(path).path
|
|
580
|
+
if method == "GET":
|
|
581
|
+
if route_path == "/api/status":
|
|
582
|
+
return self._status()
|
|
583
|
+
if route_path == "/api/windows":
|
|
584
|
+
return self._windows()
|
|
585
|
+
if route_path == "/api/navigator":
|
|
586
|
+
return self._navigator()
|
|
587
|
+
prefix = "/api/screenshot/"
|
|
588
|
+
if route_path.startswith(prefix):
|
|
589
|
+
return self._screenshot(route_path[len(prefix) :])
|
|
590
|
+
return self._fixed_resource(route_path)
|
|
591
|
+
|
|
592
|
+
if method in {"POST", "PUT"}:
|
|
593
|
+
value, error = self._request_json(headers, body)
|
|
594
|
+
if error is not None:
|
|
595
|
+
return error
|
|
596
|
+
assert value is not None
|
|
597
|
+
if method == "POST" and route_path == "/api/select":
|
|
598
|
+
return self._select(value)
|
|
599
|
+
if method == "POST" and route_path == "/api/action":
|
|
600
|
+
return self._action(value)
|
|
601
|
+
if method == "PUT" and route_path == "/api/calibration":
|
|
602
|
+
return self._calibration(value)
|
|
603
|
+
return self._error(404, "not_found", "Resource not found.")
|
|
604
|
+
|
|
605
|
+
def dispatch(
|
|
606
|
+
self,
|
|
607
|
+
method: str,
|
|
608
|
+
path: str,
|
|
609
|
+
headers: Mapping[str, str],
|
|
610
|
+
body: bytes,
|
|
611
|
+
) -> Response:
|
|
612
|
+
try:
|
|
613
|
+
return self._dispatch(method, path, headers, body)
|
|
614
|
+
except OSError:
|
|
615
|
+
return self._error(503, "backend_error", "Backend operation failed.")
|
|
616
|
+
except Exception:
|
|
617
|
+
return self._error(500, "internal_error", "Internal server error.")
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def make_handler(application: Application, logger: Any | None = None):
|
|
621
|
+
class Handler(BaseHTTPRequestHandler):
|
|
622
|
+
def _dispatch(self) -> None:
|
|
623
|
+
try:
|
|
624
|
+
content_length = int(self.headers.get("Content-Length", "0"))
|
|
625
|
+
if content_length < 0:
|
|
626
|
+
raise ValueError
|
|
627
|
+
except ValueError:
|
|
628
|
+
response = application._error(
|
|
629
|
+
400, "invalid_request", "Content-Length must be a non-negative integer."
|
|
630
|
+
)
|
|
631
|
+
else:
|
|
632
|
+
if content_length > _MAX_JSON_BODY:
|
|
633
|
+
body = b"\0" * (_MAX_JSON_BODY + 1)
|
|
634
|
+
self.close_connection = True
|
|
635
|
+
else:
|
|
636
|
+
body = self.rfile.read(content_length)
|
|
637
|
+
try:
|
|
638
|
+
response = application.dispatch(
|
|
639
|
+
self.command,
|
|
640
|
+
self.path,
|
|
641
|
+
dict(self.headers.items()),
|
|
642
|
+
body,
|
|
643
|
+
)
|
|
644
|
+
except Exception:
|
|
645
|
+
if logger is not None:
|
|
646
|
+
logger.exception("Unhandled CodeAway request failure")
|
|
647
|
+
response = Application._error(
|
|
648
|
+
500, "internal_error", "Internal server error."
|
|
649
|
+
)
|
|
650
|
+
self.send_response(response.status)
|
|
651
|
+
self.send_header("Content-Type", response.content_type)
|
|
652
|
+
self.send_header("Content-Length", str(len(response.body)))
|
|
653
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
654
|
+
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
|
655
|
+
for name, value in response.headers.items():
|
|
656
|
+
self.send_header(name, value)
|
|
657
|
+
self.end_headers()
|
|
658
|
+
self.wfile.write(response.body)
|
|
659
|
+
|
|
660
|
+
def do_GET(self) -> None:
|
|
661
|
+
self._dispatch()
|
|
662
|
+
|
|
663
|
+
def do_POST(self) -> None:
|
|
664
|
+
self._dispatch()
|
|
665
|
+
|
|
666
|
+
def do_PUT(self) -> None:
|
|
667
|
+
self._dispatch()
|
|
668
|
+
|
|
669
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
670
|
+
if logger is not None:
|
|
671
|
+
logger.info(format, *args)
|
|
672
|
+
|
|
673
|
+
return Handler
|
codeaway/web/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Static browser resources for the local CodeAway server."""
|