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.
- r2flow_agent/__init__.py +5 -0
- r2flow_agent/client.py +350 -0
- r2flow_agent/config.py +133 -0
- r2flow_agent/executor.py +567 -0
- r2flow_agent/main.py +446 -0
- r2flow_agent/screenshot.py +78 -0
- r2flow_agent/service.py +379 -0
- r2flow_agent/streamer.py +123 -0
- r2flow_agent-0.3.2.dist-info/METADATA +106 -0
- r2flow_agent-0.3.2.dist-info/RECORD +13 -0
- r2flow_agent-0.3.2.dist-info/WHEEL +4 -0
- r2flow_agent-0.3.2.dist-info/entry_points.txt +3 -0
- r2flow_agent-0.3.2.dist-info/licenses/LICENSE +21 -0
r2flow_agent/__init__.py
ADDED
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
|
+
)
|