r2flow-agent 0.3.2__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.
@@ -0,0 +1,5 @@
1
+ """R2Flow Cloud Agent — runs on each server and communicates with the orchestrator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.3.2"
r2flow_agent/client.py ADDED
@@ -0,0 +1,350 @@
1
+ """HTTP client for communicating with the R2Flow orchestrator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ import urllib.parse
9
+ from collections.abc import Callable
10
+ from importlib.metadata import PackageNotFoundError
11
+ from importlib.metadata import version as _pkg_version
12
+ from typing import Any, cast
13
+
14
+ import httpx
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ POLL_INTERVAL_SECONDS = 5
19
+ HEARTBEAT_INTERVAL_SECONDS = 30
20
+ REAUTH_BACKOFF_S = 60.0
21
+ MAX_PACK_BYTES = 200 * 1024 * 1024
22
+ MAX_ARTIFACT_BYTES = 6 * 1024 * 1024
23
+
24
+
25
+ def _redact_for_log(text: str, *, limit: int = 500) -> str:
26
+ """Truncate server-echo text for logs and mask obvious secrets."""
27
+ import re as _re
28
+
29
+ snippet = text[:limit]
30
+ snippet = _re.sub(r"(?i)(bearer\s+)[A-Za-z0-9._~-]+", r"\1***", snippet)
31
+ snippet = _re.sub(r"(?i)(token\s*[:=]\s*)['\"]?[^'\"\s,}]+", r"\1***", snippet)
32
+ snippet = _re.sub(r"(?i)(password\s*[:=]\s*)['\"]?[^'\"\s,}]+", r"\1***", snippet)
33
+ return snippet
34
+
35
+
36
+ def agent_version() -> str:
37
+ """Installed r2flow-agent version, "unknown" when not resolvable."""
38
+ try:
39
+ return _pkg_version("r2flow-agent")
40
+ except PackageNotFoundError:
41
+ return "unknown"
42
+
43
+
44
+ def engine_version() -> str | None:
45
+ """Installed r2flow-engine version (None when not in this venv).
46
+
47
+ The engine rides in the *deployed process* venv, not the agent's own;
48
+ this reports whatever ``r2flow`` is importable from the agent venv —
49
+ good enough for fleet monitoring until per-run stamping lands.
50
+ """
51
+ try:
52
+ return _pkg_version("r2flow")
53
+ except PackageNotFoundError:
54
+ return None
55
+
56
+
57
+ class OrchestratorError(Exception):
58
+ """Raised when the orchestrator returns an unexpected response."""
59
+
60
+
61
+ class OrchestratorClient:
62
+ """Async HTTP client wrapping all orchestrator API calls."""
63
+
64
+ def __init__(
65
+ self,
66
+ orchestrator_url: str,
67
+ agent_name: str,
68
+ agent_url: str,
69
+ *,
70
+ join_token: str | None = None,
71
+ agent_id: str | None = None,
72
+ agent_secret: str | None = None,
73
+ on_credentials: Callable[[str, str], None] | None = None,
74
+ ) -> None:
75
+ self.orchestrator_url = orchestrator_url.rstrip("/")
76
+ self.agent_name = agent_name
77
+ self.agent_url = agent_url
78
+ self.join_token = join_token
79
+ self.agent_id: str | None = agent_id
80
+ self._secret: str | None = agent_secret
81
+ # Called with (agent_id, secret) whenever the orchestrator issues a
82
+ # new secret so the caller can persist it and survive restarts
83
+ # without re-registering (re-registration rotates the secret and a
84
+ # join token alone can no longer rotate an existing agent's secret).
85
+ self._on_credentials = on_credentials
86
+ self._reauth_lock = asyncio.Lock()
87
+ self._last_reauth_attempt = 0.0
88
+ self._http = httpx.AsyncClient(
89
+ base_url=self.orchestrator_url,
90
+ timeout=httpx.Timeout(30.0),
91
+ )
92
+
93
+ # ------------------------------------------------------------------
94
+ # Lifecycle
95
+ # ------------------------------------------------------------------
96
+
97
+ async def register(self, join_token: str | None = None) -> None:
98
+ """Register (or re-register) this agent with the orchestrator.
99
+
100
+ When the client already holds an agent id + secret they are sent
101
+ along to prove ownership of the existing agent entry; the server
102
+ answers with a fresh secret either way.
103
+ """
104
+ self.join_token = join_token or self.join_token
105
+ headers = {"Authorization": f"Bearer {self.join_token}"} if self.join_token else None
106
+ body: dict[str, Any] = {
107
+ "name": self.agent_name,
108
+ "url": self.agent_url,
109
+ "version": agent_version(),
110
+ }
111
+ if self.agent_id and self._secret:
112
+ body["agent_secret"] = self._secret
113
+ resp = await self._http.post("/api/agents", json=body, headers=headers)
114
+ resp.raise_for_status()
115
+ data = resp.json()
116
+ self.agent_id = data["id"]
117
+ self._secret = data.get("secret")
118
+ if self._on_credentials is not None and self.agent_id and self._secret:
119
+ try:
120
+ self._on_credentials(self.agent_id, self._secret)
121
+ except Exception: # noqa: BLE001 - persistence must never kill the agent
122
+ logger.exception("Could not persist agent credentials")
123
+ logger.info("Registered with orchestrator — agent id=%s", self.agent_id)
124
+
125
+ async def _re_authenticate(self) -> None:
126
+ """Recover from 401s: re-register once (single-flight, with backoff).
127
+
128
+ A rotated secret (e.g. after an accidental double registration or a
129
+ cloud-side reset) used to leave the agent 401-ing forever; the
130
+ join token in the config lets it recover without a restart. Failed
131
+ attempts back off so a rejected agent does not hammer the server
132
+ on every poll cycle.
133
+ """
134
+ if time.monotonic() - self._last_reauth_attempt < REAUTH_BACKOFF_S:
135
+ raise OrchestratorError("Re-registration attempted recently — backing off")
136
+ async with self._reauth_lock:
137
+ if time.monotonic() - self._last_reauth_attempt < REAUTH_BACKOFF_S:
138
+ raise OrchestratorError("Re-registration attempted recently — backing off")
139
+ if not self.join_token:
140
+ raise OrchestratorError(
141
+ "Unauthorized and no join token available for re-registration"
142
+ )
143
+ self._last_reauth_attempt = time.monotonic()
144
+ logger.warning("401 from orchestrator — re-registering")
145
+ await self.register()
146
+
147
+ async def close(self) -> None:
148
+ """Shut down the HTTP client."""
149
+ await self._http.aclose()
150
+
151
+ # ------------------------------------------------------------------
152
+ # Heartbeat
153
+ # ------------------------------------------------------------------
154
+
155
+ async def heartbeat(self) -> None:
156
+ """Send a single heartbeat to the orchestrator (with our versions)."""
157
+ body: dict[str, Any] = {"status": "online", "version": agent_version()}
158
+ engine = engine_version()
159
+ if engine is not None:
160
+ body["engine_version"] = engine
161
+ await self._post(f"/api/agents/{self._agent_id}/heartbeat", json=body)
162
+
163
+ # ------------------------------------------------------------------
164
+ # Assets (credentials)
165
+ # ------------------------------------------------------------------
166
+
167
+ async def get_assets(self) -> list[dict[str, Any]]:
168
+ """Fetch assets for processes: text values + decrypted credentials."""
169
+ resp = await self._get(f"/api/agents/{self._agent_id}/assets")
170
+ if resp.status_code == 401:
171
+ await self._re_authenticate()
172
+ resp = await self._get(f"/api/agents/{self._agent_id}/assets")
173
+ resp.raise_for_status()
174
+ return cast(list[dict[str, Any]], resp.json())
175
+
176
+ # ------------------------------------------------------------------
177
+ # Packs (bot delivery units)
178
+ # ------------------------------------------------------------------
179
+
180
+ async def fetch_pack(self, name: str, version: str) -> bytes:
181
+ """Download a pinned pack zip from the orchestrator.
182
+
183
+ Cut, byte-identical ``GET /api/packs/{name}/versions/{version}.zip``;
184
+ the agent secret authenticates the request (agents are pack-readable).
185
+ Returns the raw archive — extraction and manifest verification happen
186
+ in :class:`~r2flow_agent.executor.ProcessExecutor`.
187
+ """
188
+ quoted_name = urllib.parse.quote(name, safe="")
189
+ quoted_version = urllib.parse.quote(version, safe="")
190
+ path = f"/api/packs/{quoted_name}/versions/{quoted_version}.zip"
191
+ resp = await self._get(path)
192
+ if resp.status_code == 401:
193
+ await self._re_authenticate()
194
+ resp = await self._get(path)
195
+ resp.raise_for_status()
196
+ length = resp.headers.get("content-length")
197
+ if length is not None and length.isdigit() and int(length) > MAX_PACK_BYTES:
198
+ raise OrchestratorError(f"pack too large ({length} bytes)")
199
+ data = resp.content
200
+ if len(data) > MAX_PACK_BYTES:
201
+ raise OrchestratorError(f"pack too large ({len(data)} bytes)")
202
+ return data
203
+
204
+ # ------------------------------------------------------------------
205
+ # Polling
206
+ # ------------------------------------------------------------------
207
+
208
+ async def poll(self) -> list[dict[str, Any]]:
209
+ """Poll the orchestrator for pending commands.
210
+
211
+ On a 401 (rotated/stale secret) the agent re-registers once and
212
+ retries, instead of warning forever and never working again.
213
+
214
+ Returns a list of command dicts. Each command has at least a ``type``
215
+ key, e.g.::
216
+
217
+ {"type": "run", "run_id": "...", "process": {…}}
218
+ """
219
+ resp = await self._get(f"/api/agents/{self._agent_id}/poll")
220
+ if resp.status_code == 401:
221
+ await self._re_authenticate()
222
+ resp = await self._get(f"/api/agents/{self.agent_id}/poll")
223
+ if resp.status_code == 204:
224
+ return []
225
+ resp.raise_for_status()
226
+ return cast(list[dict[str, Any]], resp.json())
227
+
228
+ # ------------------------------------------------------------------
229
+ # Logs & status
230
+ # ------------------------------------------------------------------
231
+
232
+ async def push_logs(self, run_id: str, logs: list[dict[str, Any]]) -> None:
233
+ """Push a batch of log entries to the orchestrator."""
234
+ if not logs:
235
+ return
236
+ await self._post(
237
+ f"/api/agents/{self._agent_id}/logs",
238
+ json={"run_id": run_id, "logs": logs},
239
+ retry=True,
240
+ )
241
+
242
+ async def report_status(
243
+ self,
244
+ run_id: str,
245
+ status: str,
246
+ *,
247
+ error: str | None = None,
248
+ ) -> None:
249
+ """Report a run status change to the orchestrator."""
250
+ payload: dict[str, Any] = {"run_id": run_id, "status": status}
251
+ if error is not None:
252
+ payload["error"] = error
253
+ await self._post(f"/api/agents/{self._agent_id}/status", json=payload, retry=True)
254
+
255
+ async def ack_deployment(
256
+ self,
257
+ deployment_id: str,
258
+ status: str,
259
+ *,
260
+ error: str | None = None,
261
+ ) -> None:
262
+ """Confirm a deployment result (deployed/failed) to the orchestrator."""
263
+ payload: dict[str, Any] = {"status": status}
264
+ if error is not None:
265
+ payload["error"] = error
266
+ await self._post(
267
+ f"/api/agents/{self._agent_id}/deployments/{deployment_id}/ack",
268
+ json=payload,
269
+ )
270
+
271
+ async def push_artifact(
272
+ self,
273
+ run_id: str,
274
+ filename: str,
275
+ content_type: str,
276
+ data: bytes,
277
+ ) -> None:
278
+ """Upload a binary artifact for a run (e.g. a failure screenshot)."""
279
+ import base64
280
+
281
+ if len(data) > MAX_ARTIFACT_BYTES:
282
+ raise OrchestratorError(f"artifact too large ({len(data)} bytes)")
283
+ payload: dict[str, Any] = {
284
+ "run_id": run_id,
285
+ "filename": filename,
286
+ "content_type": content_type,
287
+ "data_base64": base64.b64encode(data).decode("ascii"),
288
+ }
289
+ await self._post(
290
+ f"/api/agents/{self._agent_id}/runs/{run_id}/artifacts",
291
+ json=payload,
292
+ retry=True,
293
+ )
294
+
295
+ # ------------------------------------------------------------------
296
+ # Internals
297
+ # ------------------------------------------------------------------
298
+
299
+ @property
300
+ def _agent_id(self) -> str:
301
+ if self.agent_id is None:
302
+ raise OrchestratorError("Agent not registered — call register() first")
303
+ return self.agent_id
304
+
305
+ @property
306
+ def agent_secret(self) -> str | None:
307
+ """Current agent secret (for injecting into deployed processes)."""
308
+ return self._secret
309
+
310
+ @property
311
+ def _auth_headers(self) -> dict[str, str]:
312
+ if self._secret is None:
313
+ return {}
314
+ return {"Authorization": f"Bearer {self._secret}"}
315
+
316
+ async def _get(self, path: str) -> httpx.Response:
317
+ logger.debug("GET %s", path)
318
+ return await self._http.get(path, headers=self._auth_headers)
319
+
320
+ async def _post(self, path: str, *, json: Any = None, retry: bool = False) -> httpx.Response:
321
+ import random as _random
322
+
323
+ logger.debug("POST %s", path)
324
+ # Status/log pushes are critical: transient 5xx/network blips are
325
+ # retried with backoff+jitter so runs don't stick in RUNNING forever
326
+ # and the fleet doesn't retry in lockstep.
327
+ attempts = 3 if retry else 1
328
+ delay = 0.5
329
+ resp: httpx.Response | None = None
330
+ for attempt in range(attempts):
331
+ try:
332
+ resp = await self._http.post(path, json=json, headers=self._auth_headers)
333
+ except httpx.HTTPError:
334
+ logger.warning("POST %s attempt %d failed (network)", path, attempt + 1)
335
+ resp = None
336
+ else:
337
+ if resp.status_code < 500:
338
+ break
339
+ logger.warning("POST %s attempt %d -> %s", path, attempt + 1, resp.status_code)
340
+ if attempt + 1 < attempts:
341
+ await asyncio.sleep(delay * (0.5 + _random.random()))
342
+ delay *= 2.0
343
+ if resp is None:
344
+ raise OrchestratorError(f"POST {path} failed after {attempts} attempts (network)")
345
+ if resp.status_code >= 400:
346
+ # 4xx is terminal (auth/validation) — warn but don't raise so the
347
+ # agent loop survives; 5xx after retries also only warns.
348
+ # Never log raw server echo: it may contain assets/secrets.
349
+ logger.warning("POST %s -> %s: %s", path, resp.status_code, _redact_for_log(resp.text))
350
+ return resp
r2flow_agent/config.py ADDED
@@ -0,0 +1,133 @@
1
+ """Agent configuration file: everything the service needs to (re)start.
2
+
3
+ Stored at ``%LOCALAPPDATA%\\r2flow_agent\\config.json`` (per-user: the agent
4
+ runs inside the user's interactive session, which UI automation requires).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ CONFIG_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "r2flow_agent"
16
+ CONFIG_PATH = CONFIG_DIR / "config.json"
17
+
18
+ DEFAULT_AGENT_PORT = 8001
19
+
20
+
21
+ def load_config() -> dict[str, Any]:
22
+ """Read the config file; empty dict when absent."""
23
+ try:
24
+ return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
25
+ except (OSError, json.JSONDecodeError):
26
+ return {}
27
+
28
+
29
+ def save_config(
30
+ orchestrator_url: str,
31
+ agent_name: str,
32
+ agent_url: str,
33
+ join_token: str | None = None,
34
+ log_level: str = "INFO",
35
+ agent_id: str | None = None,
36
+ agent_secret: str | None = None,
37
+ ) -> Path:
38
+ """Write (or merge into) the config file.
39
+
40
+ Merging matters: the service loop updates agent_id/agent_secret while
41
+ running and must not lose the rest of the config.
42
+ """
43
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
44
+ payload: dict[str, Any] = {}
45
+ try:
46
+ existing = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
47
+ if isinstance(existing, dict):
48
+ payload.update(existing)
49
+ except (OSError, json.JSONDecodeError):
50
+ pass
51
+ payload.update(
52
+ {
53
+ "orchestrator_url": orchestrator_url,
54
+ "agent_name": agent_name,
55
+ "agent_url": agent_url,
56
+ "log_level": log_level,
57
+ }
58
+ )
59
+ if join_token:
60
+ payload["join_token"] = join_token
61
+ if agent_id:
62
+ payload["agent_id"] = agent_id
63
+ if agent_secret:
64
+ payload["agent_secret"] = agent_secret
65
+ # Atomic write + owner-only permissions: config holds bearer secrets.
66
+ import os
67
+ import tempfile
68
+
69
+ text = json.dumps(payload, indent=2) + "\n"
70
+ fd, tmp_name = tempfile.mkstemp(dir=str(CONFIG_DIR), prefix=".config-", suffix=".tmp")
71
+ try:
72
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
73
+ fh.write(text)
74
+ with contextlib.suppress(OSError):
75
+ os.chmod(tmp_name, 0o600)
76
+ Path(tmp_name).replace(CONFIG_PATH)
77
+ with contextlib.suppress(OSError):
78
+ os.chmod(CONFIG_PATH, 0o600)
79
+ finally:
80
+ with contextlib.suppress(OSError):
81
+ Path(tmp_name).unlink()
82
+ return CONFIG_PATH
83
+
84
+
85
+ def delete_config() -> bool:
86
+ try:
87
+ CONFIG_PATH.unlink()
88
+ return True
89
+ except OSError:
90
+ return False
91
+
92
+
93
+ def detect_local_ip(orchestrator_url: str) -> str:
94
+ """Local IP the orchestrator would see this machine at (no packets sent)."""
95
+ import socket
96
+ from urllib.parse import urlparse
97
+
98
+ parsed = urlparse(orchestrator_url)
99
+ host = parsed.hostname or "localhost"
100
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
101
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
102
+ try:
103
+ sock.connect((host, port))
104
+ return str(sock.getsockname()[0])
105
+ except OSError:
106
+ return "127.0.0.1"
107
+ finally:
108
+ sock.close()
109
+
110
+
111
+ def agent_url_for(orchestrator_url: str, port: int = DEFAULT_AGENT_PORT) -> str:
112
+ """Default public URL: http://<this-machine-ip>:<port>."""
113
+ return f"http://{detect_local_ip(orchestrator_url)}:{port}"
114
+
115
+
116
+ def check_orchestrator_url(url: str) -> None:
117
+ """Refuse cleartext http orchestrator URLs for non-loopback hosts.
118
+
119
+ The join token and agent secret travel as Bearer credentials — plain
120
+ http would expose them. Loopback stays allowed for local development.
121
+ """
122
+ from urllib.parse import urlparse
123
+
124
+ parsed = urlparse(url)
125
+ if parsed.scheme not in ("http", "https"):
126
+ raise ValueError("orchestrator URL must be http(s)")
127
+ if parsed.scheme == "http":
128
+ host = (parsed.hostname or "").lower()
129
+ if host not in ("localhost", "127.0.0.1", "::1"):
130
+ raise ValueError(
131
+ "orchestrator URL must use https:// for non-loopback hosts "
132
+ "(tokens would travel in cleartext)"
133
+ )