cloudagent-daemon 0.2.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.
- cloudagent_daemon/__init__.py +1 -0
- cloudagent_daemon/__main__.py +4 -0
- cloudagent_daemon/claude_runner.py +1068 -0
- cloudagent_daemon/cli.py +833 -0
- cloudagent_daemon/config.py +197 -0
- cloudagent_daemon/doctor.py +534 -0
- cloudagent_daemon/frame_dispatch.py +216 -0
- cloudagent_daemon/http_outbound.py +80 -0
- cloudagent_daemon/jsonl_logging.py +98 -0
- cloudagent_daemon/login.py +34 -0
- cloudagent_daemon/main_loop.py +891 -0
- cloudagent_daemon/mcp_bridge.py +441 -0
- cloudagent_daemon/metrics.py +315 -0
- cloudagent_daemon/metrics_server.py +149 -0
- cloudagent_daemon/outbound_queue.py +289 -0
- cloudagent_daemon/paths.py +204 -0
- cloudagent_daemon/protocol.py +141 -0
- cloudagent_daemon/runtime_detect.py +135 -0
- cloudagent_daemon/service.py +355 -0
- cloudagent_daemon/spawn_outbox.py +443 -0
- cloudagent_daemon/spawn_queue.py +200 -0
- cloudagent_daemon/sse_client.py +439 -0
- cloudagent_daemon/transport.py +105 -0
- cloudagent_daemon/unix_socket.py +278 -0
- cloudagent_daemon/workspace.py +441 -0
- cloudagent_daemon/ws_client.py +431 -0
- cloudagent_daemon-0.2.1.dist-info/METADATA +322 -0
- cloudagent_daemon-0.2.1.dist-info/RECORD +31 -0
- cloudagent_daemon-0.2.1.dist-info/WHEEL +4 -0
- cloudagent_daemon-0.2.1.dist-info/entry_points.txt +2 -0
- cloudagent_daemon-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Inbound frame dispatcher for the cloudagent daemon.
|
|
2
|
+
|
|
3
|
+
Routes WebSocket frames received from the server to the appropriate
|
|
4
|
+
handler/callback. Actual spawn logic (Task 8), outbound queue (Task 7),
|
|
5
|
+
and workspace management (Task 11) are injected via callbacks so this
|
|
6
|
+
module stays dependency-free beyond DaemonState and stdlib.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from cloudagent_daemon.config import DaemonState
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from cloudagent_daemon.spawn_outbox import SpawnOutbox
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FrameDispatcher:
|
|
25
|
+
"""Route inbound frames to registered callbacks.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
state:
|
|
30
|
+
Live DaemonState; mutated on accepted deliver frames.
|
|
31
|
+
state_path:
|
|
32
|
+
Path passed to ``state.save()`` after each accepted deliver.
|
|
33
|
+
ack_callback:
|
|
34
|
+
Called as ``ack_callback(seq, message_id, status)`` immediately
|
|
35
|
+
after a deliver frame passes the idempotency check.
|
|
36
|
+
spawn_callback:
|
|
37
|
+
Called with the deliver payload dict for new (non-duplicate) frames.
|
|
38
|
+
tool_result_pending:
|
|
39
|
+
Shared dict mapping ``request_id`` → ``asyncio.Future``. The
|
|
40
|
+
dispatcher pops and resolves futures as ``tool_result`` frames arrive.
|
|
41
|
+
workspace_assign_callback:
|
|
42
|
+
Optional; called with the assign payload dict.
|
|
43
|
+
workspace_unassign_callback:
|
|
44
|
+
Optional; called with the unassign payload dict.
|
|
45
|
+
bye_callback:
|
|
46
|
+
Optional; called with the bye payload dict to trigger shutdown.
|
|
47
|
+
spawn_outbox:
|
|
48
|
+
Optional; durable journal of accepted deliver frames. When
|
|
49
|
+
provided, every new (non-duplicate) frame is persisted via
|
|
50
|
+
:meth:`SpawnOutbox.record_received` BEFORE the server-side ack
|
|
51
|
+
so a daemon crash between ack and spawn doesn't silently lose
|
|
52
|
+
the frame. See ``spawn_outbox.py`` for details (Bug 4).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
state: DaemonState,
|
|
59
|
+
state_path: Path,
|
|
60
|
+
ack_callback: Callable[[int, str, str], Awaitable[None]],
|
|
61
|
+
spawn_callback: Callable[[dict], Awaitable[None]],
|
|
62
|
+
tool_result_pending: dict[str, asyncio.Future],
|
|
63
|
+
workspace_assign_callback: Callable[[dict], Awaitable[None]] | None = None,
|
|
64
|
+
workspace_unassign_callback: Callable[[dict], Awaitable[None]] | None = None,
|
|
65
|
+
bye_callback: Callable[[dict], Awaitable[None]] | None = None,
|
|
66
|
+
spawn_outbox: "SpawnOutbox | None" = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.state = state
|
|
69
|
+
self.state_path = state_path
|
|
70
|
+
self.ack_callback = ack_callback
|
|
71
|
+
self.spawn_callback = spawn_callback
|
|
72
|
+
self.tool_result_pending = tool_result_pending
|
|
73
|
+
self.workspace_assign_callback = workspace_assign_callback
|
|
74
|
+
self.workspace_unassign_callback = workspace_unassign_callback
|
|
75
|
+
self.bye_callback = bye_callback
|
|
76
|
+
self.spawn_outbox = spawn_outbox
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# Public entry point
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
async def dispatch(self, frame: dict) -> None:
|
|
83
|
+
"""Dispatch *frame* to the appropriate handler."""
|
|
84
|
+
t = frame.get("type")
|
|
85
|
+
if t == "deliver":
|
|
86
|
+
await self._on_deliver(frame)
|
|
87
|
+
elif t == "tool_result":
|
|
88
|
+
self._on_tool_result(frame)
|
|
89
|
+
elif t == "assign":
|
|
90
|
+
await self._on_assign(frame)
|
|
91
|
+
elif t == "unassign":
|
|
92
|
+
await self._on_unassign(frame)
|
|
93
|
+
elif t == "bye":
|
|
94
|
+
await self._on_bye(frame)
|
|
95
|
+
elif t == "hello":
|
|
96
|
+
pass # WSClient already consumed this in __aenter__
|
|
97
|
+
else:
|
|
98
|
+
logger.warning("unknown frame type: %s", t)
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Handlers
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
async def _on_deliver(self, frame: dict) -> None:
|
|
105
|
+
seq = frame["seq"]
|
|
106
|
+
payload = frame["payload"]
|
|
107
|
+
msg_id = payload["message_id"]
|
|
108
|
+
# K1 §护栏 9 — server-stamped wake idempotency key. When set, dedupe
|
|
109
|
+
# by wake_id in addition to message_id so reconnect-catchup of a
|
|
110
|
+
# frame whose wake was already handled lands as ack-only. Frames
|
|
111
|
+
# without ``wake_id`` (test-chat / direct task creation) keep the
|
|
112
|
+
# legacy message_id-only path.
|
|
113
|
+
wake_id = payload.get("wake_id") if isinstance(payload, dict) else None
|
|
114
|
+
|
|
115
|
+
# Idempotency: already processed (by either key) → ack-only, skip spawn
|
|
116
|
+
if self.state.has_processed(msg_id):
|
|
117
|
+
logger.info(
|
|
118
|
+
"frame_dispatch: deliver duplicate (msg_id already processed) "
|
|
119
|
+
"agent=%s msg=%s seq=%d",
|
|
120
|
+
payload.get("agent_id"),
|
|
121
|
+
msg_id,
|
|
122
|
+
seq,
|
|
123
|
+
)
|
|
124
|
+
await self.ack_callback(seq, msg_id, "received")
|
|
125
|
+
return
|
|
126
|
+
if wake_id and self.state.has_processed(wake_id):
|
|
127
|
+
logger.info(
|
|
128
|
+
"frame_dispatch: deliver duplicate (wake_id already processed) "
|
|
129
|
+
"agent=%s msg=%s wake=%s seq=%d",
|
|
130
|
+
payload.get("agent_id"),
|
|
131
|
+
msg_id,
|
|
132
|
+
wake_id,
|
|
133
|
+
seq,
|
|
134
|
+
)
|
|
135
|
+
# Still record the message_id so further re-deliveries of the
|
|
136
|
+
# same row hit the cheap msg_id branch above.
|
|
137
|
+
self.state.add_processed(msg_id)
|
|
138
|
+
self.state.last_seq = max(self.state.last_seq, seq)
|
|
139
|
+
self.state.save(self.state_path)
|
|
140
|
+
await self.ack_callback(seq, msg_id, "received")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
# 1i Bug C — Phase 1 demo visibility for the spawn pipeline.
|
|
144
|
+
logger.info(
|
|
145
|
+
"frame_dispatch: dispatching deliver agent=%s msg=%s wake=%s seq=%d body_len=%d",
|
|
146
|
+
payload.get("agent_id"),
|
|
147
|
+
msg_id,
|
|
148
|
+
wake_id,
|
|
149
|
+
seq,
|
|
150
|
+
len(payload.get("body") or ""),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Bug 4 (2026-05-12) — persist the frame BEFORE the ack so a
|
|
154
|
+
# crash between ack and spawn doesn't silently lose it. The
|
|
155
|
+
# outbox is the authoritative replay source on next boot.
|
|
156
|
+
# If the outbox write fails we deliberately do NOT ack —
|
|
157
|
+
# server will redeliver on reconnect and we'll try again.
|
|
158
|
+
if self.spawn_outbox is not None:
|
|
159
|
+
try:
|
|
160
|
+
await self.spawn_outbox.record_received(payload)
|
|
161
|
+
except Exception:
|
|
162
|
+
logger.exception(
|
|
163
|
+
"frame_dispatch: spawn_outbox.record_received failed "
|
|
164
|
+
"agent=%s msg=%s — skipping ack, server will redeliver",
|
|
165
|
+
payload.get("agent_id"),
|
|
166
|
+
msg_id,
|
|
167
|
+
)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
self.state.add_processed(msg_id)
|
|
171
|
+
if wake_id:
|
|
172
|
+
self.state.add_processed(wake_id)
|
|
173
|
+
self.state.last_seq = max(self.state.last_seq, seq)
|
|
174
|
+
self.state.save(self.state_path)
|
|
175
|
+
|
|
176
|
+
await self.ack_callback(seq, msg_id, "received")
|
|
177
|
+
await self.spawn_callback(payload)
|
|
178
|
+
|
|
179
|
+
def _on_tool_result(self, frame: dict) -> None:
|
|
180
|
+
payload = frame["payload"]
|
|
181
|
+
rid = payload["request_id"]
|
|
182
|
+
fut = self.tool_result_pending.pop(rid, None)
|
|
183
|
+
# 1k #55 — observability for the multi-tool wake bug. Without this
|
|
184
|
+
# we cannot tell whether a tool_result reached the dispatcher at
|
|
185
|
+
# all, whether it matched a pending future, or whether it was a
|
|
186
|
+
# late arrival (future already done).
|
|
187
|
+
if fut is None:
|
|
188
|
+
logger.warning(
|
|
189
|
+
"frame_dispatch: tool_result rid=%s — NO PENDING FUTURE "
|
|
190
|
+
"(late arrival, duplicate, or unknown rid)",
|
|
191
|
+
rid,
|
|
192
|
+
)
|
|
193
|
+
return
|
|
194
|
+
if fut.done():
|
|
195
|
+
logger.warning(
|
|
196
|
+
"frame_dispatch: tool_result rid=%s — future ALREADY DONE",
|
|
197
|
+
rid,
|
|
198
|
+
)
|
|
199
|
+
return
|
|
200
|
+
logger.info(
|
|
201
|
+
"frame_dispatch: tool_result rid=%s ok=%s — resolving future",
|
|
202
|
+
rid, payload.get("ok"),
|
|
203
|
+
)
|
|
204
|
+
fut.set_result(payload)
|
|
205
|
+
|
|
206
|
+
async def _on_assign(self, frame: dict) -> None:
|
|
207
|
+
if self.workspace_assign_callback:
|
|
208
|
+
await self.workspace_assign_callback(frame["payload"])
|
|
209
|
+
|
|
210
|
+
async def _on_unassign(self, frame: dict) -> None:
|
|
211
|
+
if self.workspace_unassign_callback:
|
|
212
|
+
await self.workspace_unassign_callback(frame["payload"])
|
|
213
|
+
|
|
214
|
+
async def _on_bye(self, frame: dict) -> None:
|
|
215
|
+
if self.bye_callback:
|
|
216
|
+
await self.bye_callback(frame["payload"])
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""HTTP POST sender for daemon→server frames.
|
|
2
|
+
|
|
3
|
+
The SSE transport replaces the WebSocket's bidirectionality with an SSE
|
|
4
|
+
stream (server→daemon) plus per-frame-type POST endpoints (daemon→server).
|
|
5
|
+
``HTTPOutbound`` owns the shared httpx.AsyncClient and is reused across all
|
|
6
|
+
POST calls so connection pooling, HTTP/2 multiplexing, and TLS sessions
|
|
7
|
+
amortise.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import ssl
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Outbound POST timeout. Tight enough that a hung server doesn't block
|
|
19
|
+
# the daemon's SpawnQueue serialise; loose enough that a slow but healthy
|
|
20
|
+
# server (cold-start container, GC pause) doesn't get spuriously aborted.
|
|
21
|
+
_POST_TIMEOUT_SECONDS = 10.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HTTPOutbound:
|
|
25
|
+
"""Authorised POST sender backed by a single httpx.AsyncClient.
|
|
26
|
+
|
|
27
|
+
All POSTs share:
|
|
28
|
+
* Bearer authorisation (set as a default header).
|
|
29
|
+
* The same TLS verification policy (``insecure=True`` disables cert
|
|
30
|
+
validation — used for dev environments with self-signed certs).
|
|
31
|
+
* The base URL (``server_url``) so callers pass relative paths.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
server_url: str,
|
|
37
|
+
api_key: str,
|
|
38
|
+
*,
|
|
39
|
+
insecure: bool = False,
|
|
40
|
+
) -> None:
|
|
41
|
+
self._server_url = server_url.rstrip("/")
|
|
42
|
+
self._api_key = api_key
|
|
43
|
+
self._insecure = insecure
|
|
44
|
+
|
|
45
|
+
verify: ssl.SSLContext | bool = not insecure
|
|
46
|
+
self._client = httpx.AsyncClient(
|
|
47
|
+
base_url=self._server_url,
|
|
48
|
+
verify=verify,
|
|
49
|
+
timeout=httpx.Timeout(_POST_TIMEOUT_SECONDS),
|
|
50
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
51
|
+
trust_env=True,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
async def post(self, path: str, json_body: dict) -> dict:
|
|
55
|
+
"""POST ``json_body`` to ``path``; return parsed JSON or ``{}``.
|
|
56
|
+
|
|
57
|
+
Raises ``httpx.HTTPStatusError`` for any 4xx/5xx response so
|
|
58
|
+
the caller (Transport) can surface auth failures vs. transient.
|
|
59
|
+
"""
|
|
60
|
+
r = await self._client.post(path, json=json_body)
|
|
61
|
+
r.raise_for_status()
|
|
62
|
+
# Some endpoints reply 204 No Content (e.g. heartbeat tolerates it
|
|
63
|
+
# in testing fakes); handle that without choking on empty bodies.
|
|
64
|
+
if r.status_code == 204 or not r.content:
|
|
65
|
+
return {}
|
|
66
|
+
try:
|
|
67
|
+
return r.json()
|
|
68
|
+
except ValueError:
|
|
69
|
+
# Non-JSON 200 body — treat as empty rather than crash. Real
|
|
70
|
+
# backend always returns JSON; this is defensive for proxies
|
|
71
|
+
# that inject HTML error pages.
|
|
72
|
+
logger.warning(
|
|
73
|
+
"http_outbound: non-JSON 2xx body for %s (%d bytes)",
|
|
74
|
+
path,
|
|
75
|
+
len(r.content),
|
|
76
|
+
)
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
async def close(self) -> None:
|
|
80
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Wave C #3 — opt-in JSONL log formatter.
|
|
2
|
+
|
|
3
|
+
Default off (preserves the grep-based ops flow over launchd.err.log).
|
|
4
|
+
When ``CLOUDAGENT_DAEMON_JSON_LOG=1`` is exported BEFORE the daemon
|
|
5
|
+
starts, every log record is rendered as a single-line JSON object —
|
|
6
|
+
key tokens (``msg`` / ``level`` / ``name``) still appear so existing
|
|
7
|
+
``grep frame_dispatch`` -style searches keep working.
|
|
8
|
+
|
|
9
|
+
Schema (one object per line)::
|
|
10
|
+
|
|
11
|
+
{"ts": "2026-05-12T10:31:53Z", "level": "INFO",
|
|
12
|
+
"name": "cloudagent_daemon.main_loop",
|
|
13
|
+
"msg": "daemon ready: host_id=...", "pid": 51624,
|
|
14
|
+
"thread": "MainThread"}
|
|
15
|
+
|
|
16
|
+
If a log record carries ``extra={"event": ...}`` style fields, those
|
|
17
|
+
are merged into the top-level object (e.g. structured spawn events).
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import threading
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_ENV_VAR = "CLOUDAGENT_DAEMON_JSON_LOG"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class JsonlFormatter(logging.Formatter):
|
|
32
|
+
"""One-line JSON per record."""
|
|
33
|
+
|
|
34
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
35
|
+
# Parity with :class:`logging.Formatter.format` — set
|
|
36
|
+
# ``record.message`` so downstream tooling (pytest's caplog,
|
|
37
|
+
# third-party Sentry hooks) that reads the attribute keeps
|
|
38
|
+
# working after the formatter runs.
|
|
39
|
+
record.message = record.getMessage()
|
|
40
|
+
# Build the canonical envelope first so the order is stable
|
|
41
|
+
# across records — easier on humans tailing the log.
|
|
42
|
+
out: dict = {
|
|
43
|
+
"ts": datetime.now(UTC).isoformat(),
|
|
44
|
+
"level": record.levelname,
|
|
45
|
+
"name": record.name,
|
|
46
|
+
"msg": record.message,
|
|
47
|
+
"pid": os.getpid(),
|
|
48
|
+
"thread": threading.current_thread().name,
|
|
49
|
+
}
|
|
50
|
+
if record.exc_info:
|
|
51
|
+
out["exc"] = self.formatException(record.exc_info)
|
|
52
|
+
# Merge any structured extras carried on the record. ``record.__dict__``
|
|
53
|
+
# contains a fixed set of LogRecord fields + whatever ``extra=``
|
|
54
|
+
# caller passed; we keep only the latter.
|
|
55
|
+
reserved = {
|
|
56
|
+
"name", "msg", "args", "levelname", "levelno", "pathname",
|
|
57
|
+
"filename", "module", "exc_info", "exc_text", "stack_info",
|
|
58
|
+
"lineno", "funcName", "created", "msecs", "relativeCreated",
|
|
59
|
+
"thread", "threadName", "processName", "process", "message",
|
|
60
|
+
}
|
|
61
|
+
for key, value in record.__dict__.items():
|
|
62
|
+
if key in reserved or key in out:
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
json.dumps(value)
|
|
66
|
+
out[key] = value
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
# Non-JSON-able extras get a string repr — never drop
|
|
69
|
+
# them silently, an operator may need that hint.
|
|
70
|
+
out[key] = repr(value)
|
|
71
|
+
return json.dumps(out, ensure_ascii=False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def jsonl_logging_enabled() -> bool:
|
|
75
|
+
"""Return True when the env switch is set."""
|
|
76
|
+
return os.environ.get(_ENV_VAR, "").lower() in ("1", "true", "yes")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def configure_logging() -> None:
|
|
80
|
+
"""Apply :class:`JsonlFormatter` to the root logger if env enables it.
|
|
81
|
+
|
|
82
|
+
Called from ``cli.start`` AFTER ``logging.basicConfig`` so we can
|
|
83
|
+
safely overwrite the default formatter on the root logger's
|
|
84
|
+
first (and only) handler.
|
|
85
|
+
"""
|
|
86
|
+
if not jsonl_logging_enabled():
|
|
87
|
+
return
|
|
88
|
+
root = logging.getLogger()
|
|
89
|
+
fmt = JsonlFormatter()
|
|
90
|
+
for h in root.handlers:
|
|
91
|
+
h.setFormatter(fmt)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = [
|
|
95
|
+
"JsonlFormatter",
|
|
96
|
+
"configure_logging",
|
|
97
|
+
"jsonl_logging_enabled",
|
|
98
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Login verification logic for cloudagent-daemon.
|
|
2
|
+
|
|
3
|
+
Separated from cli.py for testability.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def verify_and_capture_host_id(
|
|
9
|
+
server_url: str,
|
|
10
|
+
api_key: str,
|
|
11
|
+
*,
|
|
12
|
+
insecure: bool = False,
|
|
13
|
+
) -> str:
|
|
14
|
+
"""Open a quick WS connection to verify key, capture host_id, then close.
|
|
15
|
+
|
|
16
|
+
Raises AuthFailed if 401/403; raises RuntimeError on other connect failures.
|
|
17
|
+
|
|
18
|
+
``insecure=True`` skips TLS certificate verification — only use for known
|
|
19
|
+
self-signed dev/staging hosts (e.g. fr pre-Wave-7).
|
|
20
|
+
"""
|
|
21
|
+
from .ws_client import WSClient
|
|
22
|
+
|
|
23
|
+
async with WSClient(server_url, api_key, insecure=insecure) as ws:
|
|
24
|
+
return ws.host_id
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_server_url(url: str) -> str:
|
|
28
|
+
"""Trim trailing /, strip /v1 suffix if present, validate http(s) scheme."""
|
|
29
|
+
url = url.rstrip("/")
|
|
30
|
+
if url.endswith("/v1"):
|
|
31
|
+
url = url[:-3]
|
|
32
|
+
if not url.startswith(("http://", "https://")):
|
|
33
|
+
raise ValueError(f"server URL must start with http:// or https://: {url!r}")
|
|
34
|
+
return url
|