real-browser-cli 0.14.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.
Files changed (85) hide show
  1. browser_cli/__init__.py +164 -0
  2. browser_cli/async_sdk.py +237 -0
  3. browser_cli/auth.py +263 -0
  4. browser_cli/cli.py +151 -0
  5. browser_cli/client/__init__.py +47 -0
  6. browser_cli/client/auth.py +63 -0
  7. browser_cli/client/core.py +200 -0
  8. browser_cli/client/messages.py +45 -0
  9. browser_cli/client/targets.py +95 -0
  10. browser_cli/command_security.py +119 -0
  11. browser_cli/commands/__init__.py +81 -0
  12. browser_cli/commands/auth.py +157 -0
  13. browser_cli/commands/clients.py +173 -0
  14. browser_cli/commands/completion.py +56 -0
  15. browser_cli/commands/doctor.py +90 -0
  16. browser_cli/commands/dom.py +191 -0
  17. browser_cli/commands/events.py +52 -0
  18. browser_cli/commands/extension.py +42 -0
  19. browser_cli/commands/extract.py +70 -0
  20. browser_cli/commands/groups.py +108 -0
  21. browser_cli/commands/install.py +121 -0
  22. browser_cli/commands/navigate.py +96 -0
  23. browser_cli/commands/page.py +26 -0
  24. browser_cli/commands/perf.py +47 -0
  25. browser_cli/commands/raw.py +23 -0
  26. browser_cli/commands/remote.py +68 -0
  27. browser_cli/commands/script.py +68 -0
  28. browser_cli/commands/search.py +79 -0
  29. browser_cli/commands/serve.py +117 -0
  30. browser_cli/commands/serve_http.py +115 -0
  31. browser_cli/commands/session.py +163 -0
  32. browser_cli/commands/storage.py +36 -0
  33. browser_cli/commands/tabs.py +252 -0
  34. browser_cli/commands/watch.py +60 -0
  35. browser_cli/commands/windows.py +87 -0
  36. browser_cli/commands/workspace.py +91 -0
  37. browser_cli/compat/__init__.py +4 -0
  38. browser_cli/compat/auth.py +44 -0
  39. browser_cli/compat/commands.py +43 -0
  40. browser_cli/constants.py +95 -0
  41. browser_cli/endpoints.py +55 -0
  42. browser_cli/errors.py +9 -0
  43. browser_cli/framing.py +83 -0
  44. browser_cli/local_transport.py +64 -0
  45. browser_cli/markdown/__init__.py +8 -0
  46. browser_cli/markdown/html.py +259 -0
  47. browser_cli/markdown/render.py +188 -0
  48. browser_cli/models.py +182 -0
  49. browser_cli/native/__init__.py +1 -0
  50. browser_cli/native/host.py +211 -0
  51. browser_cli/native/local_server.py +111 -0
  52. browser_cli/native/protocol.py +30 -0
  53. browser_cli/platform.py +34 -0
  54. browser_cli/registry.py +99 -0
  55. browser_cli/remote/__init__.py +1 -0
  56. browser_cli/remote/registry.py +53 -0
  57. browser_cli/remote/transport.py +230 -0
  58. browser_cli/sdk/__init__.py +48 -0
  59. browser_cli/sdk/base.py +116 -0
  60. browser_cli/sdk/browser_data.py +37 -0
  61. browser_cli/sdk/decorators.py +107 -0
  62. browser_cli/sdk/dom.py +169 -0
  63. browser_cli/sdk/extension.py +24 -0
  64. browser_cli/sdk/factories.py +103 -0
  65. browser_cli/sdk/groups.py +51 -0
  66. browser_cli/sdk/navigation.py +122 -0
  67. browser_cli/sdk/perf.py +23 -0
  68. browser_cli/sdk/routing.py +149 -0
  69. browser_cli/sdk/session.py +72 -0
  70. browser_cli/sdk/tabs.py +213 -0
  71. browser_cli/sdk/windows.py +26 -0
  72. browser_cli/sdk/workflow_decorators.py +200 -0
  73. browser_cli/serve/__init__.py +0 -0
  74. browser_cli/serve/auth.py +107 -0
  75. browser_cli/serve/control.py +59 -0
  76. browser_cli/serve/logging.py +16 -0
  77. browser_cli/serve/proxy.py +79 -0
  78. browser_cli/serve/runtime.py +196 -0
  79. browser_cli/transport.py +214 -0
  80. browser_cli/version_manager.py +17 -0
  81. real_browser_cli-0.14.2.dist-info/METADATA +87 -0
  82. real_browser_cli-0.14.2.dist-info/RECORD +85 -0
  83. real_browser_cli-0.14.2.dist-info/WHEEL +4 -0
  84. real_browser_cli-0.14.2.dist-info/entry_points.txt +2 -0
  85. real_browser_cli-0.14.2.dist-info/licenses/LICENSE +75 -0
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Native Messaging Host for browser-cli.
4
+
5
+ Chrome launches this process when extension calls connectNative().
6
+ It relays messages between extension (stdin/stdout Native Messaging protocol)
7
+ and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
8
+ """
9
+ import json
10
+ import math
11
+ import os
12
+ import queue
13
+ import socket
14
+ import sys
15
+ import threading
16
+ import uuid
17
+ from pathlib import Path
18
+
19
+ from browser_cli.native import local_server, protocol
20
+ from browser_cli.constants import DEFAULT_ALIAS, DEFAULT_PAGE_SIZE, PAGEABLE_COMMANDS
21
+ from browser_cli.platform import endpoint_for_alias, is_windows, registry_path, runtime_dir
22
+ from browser_cli.registry import update_registry
23
+
24
+ SOCKET_PATH: str = "" # set after hello handshake
25
+ PENDING: dict[str, queue.Queue] = {}
26
+ PENDING_LOCK = threading.Lock()
27
+ WRITE_LOCK = threading.Lock()
28
+ REGISTRY_PATH = registry_path()
29
+ PAGE_SIZE = int(os.environ.get("BROWSER_CLI_PAGE_SIZE", str(DEFAULT_PAGE_SIZE)))
30
+
31
+ # --- Registry helpers ---
32
+
33
+ def _registry_add(alias: str, sock_path: str) -> None:
34
+ try:
35
+ update_registry(alias, sock_path, REGISTRY_PATH)
36
+ except Exception:
37
+ pass
38
+
39
+ def _registry_remove(alias: str) -> None:
40
+ try:
41
+ update_registry(alias, None, REGISTRY_PATH)
42
+ except Exception:
43
+ pass
44
+
45
+ def _socket_path_for(alias: str) -> str:
46
+ return endpoint_for_alias(alias)
47
+
48
+ def _resolve_profile_alias(first_msg: dict | None) -> str:
49
+ """Return a unique alias when the extension did not provide one."""
50
+ if first_msg and first_msg.get("type") == "hello":
51
+ alias = first_msg.get("alias")
52
+ if alias and alias != DEFAULT_ALIAS:
53
+ return alias
54
+ return str(uuid.uuid4())
55
+
56
+ # --- Thread A: read messages from extension (stdin) ---
57
+
58
+ def stdin_reader(alias: str):
59
+ stdin = sys.stdin.buffer
60
+ while True:
61
+ msg = protocol.read_native_message(stdin)
62
+ if msg is None:
63
+ # Extension disconnected — clean up and exit
64
+ _cleanup(alias)
65
+ os._exit(0)
66
+
67
+ # Profile alias handshake
68
+ if msg.get("type") == "hello":
69
+ continue # already handled during startup
70
+ if msg.get("type") == "bye":
71
+ _cleanup(alias)
72
+ os._exit(0)
73
+
74
+ msg_id = msg.get("id")
75
+ if msg_id:
76
+ with PENDING_LOCK:
77
+ q = PENDING.get(msg_id)
78
+ if q:
79
+ q.put(msg)
80
+
81
+ # --- Thread B: accept CLI socket connections ---
82
+
83
+ def _json_response(result: dict) -> bytes:
84
+ return json.dumps(result).encode("utf-8")
85
+
86
+ def _error_response(exc: Exception) -> bytes:
87
+ return _json_response({"success": False, "error": str(exc)})
88
+
89
+ def _decode_cli_command(data: bytes) -> dict:
90
+ cmd = json.loads(data)
91
+ if "id" not in cmd:
92
+ cmd["id"] = str(uuid.uuid4())
93
+ return cmd
94
+
95
+ def _handle_cli_payload(data: bytes) -> bytes:
96
+ return _json_response(_handle_browser_command(_decode_cli_command(data)))
97
+
98
+ def _handle_browser_command(cmd: dict) -> dict:
99
+ command = cmd.get("command")
100
+ if command in PAGEABLE_COMMANDS:
101
+ return _collect_paged_browser_command(cmd)
102
+ return _send_browser_command(cmd)
103
+
104
+ def _send_browser_command(cmd: dict, timeout: int = 30) -> dict:
105
+ msg_id = cmd.get("id") or str(uuid.uuid4())
106
+ cmd["id"] = msg_id
107
+ response_queue: queue.Queue = queue.Queue()
108
+
109
+ with PENDING_LOCK:
110
+ PENDING[msg_id] = response_queue
111
+
112
+ try:
113
+ with WRITE_LOCK:
114
+ protocol.write_native_message(sys.stdout.buffer, cmd)
115
+
116
+ try:
117
+ return response_queue.get(timeout=timeout)
118
+ except queue.Empty:
119
+ return {"id": msg_id, "success": False, "error": "timeout waiting for browser response"}
120
+ finally:
121
+ with PENDING_LOCK:
122
+ PENDING.pop(msg_id, None)
123
+
124
+ def _collect_paged_browser_command(cmd: dict) -> dict:
125
+ original_id = cmd.get("id") or str(uuid.uuid4())
126
+ offset = 0
127
+ items = []
128
+ total = None
129
+ max_pages = math.ceil(10_000 / PAGE_SIZE)
130
+ pages_fetched = 0
131
+
132
+ while True:
133
+ if pages_fetched >= max_pages:
134
+ return {"id": original_id, "success": False, "error": f"paging loop exceeded {max_pages} pages — extension bug?"}
135
+ pages_fetched += 1
136
+ page_cmd = dict(cmd)
137
+ page_cmd["id"] = str(uuid.uuid4())
138
+ page_args = dict(cmd.get("args") or {})
139
+ page_args["__page"] = {"offset": offset, "limit": PAGE_SIZE}
140
+ page_cmd["args"] = page_args
141
+
142
+ result = _send_browser_command(page_cmd)
143
+ result["id"] = original_id
144
+ if not result.get("success", True):
145
+ return result
146
+
147
+ data = result.get("data")
148
+ if not isinstance(data, dict) or data.get("__browserCliPage") is not True:
149
+ return result
150
+
151
+ page_items = data.get("items") or []
152
+ if not isinstance(page_items, list):
153
+ return {"id": original_id, "success": False, "error": "invalid paged response from browser"}
154
+ items.extend(page_items)
155
+ total = data.get("total", total)
156
+ next_offset = data.get("nextOffset")
157
+ if next_offset is None:
158
+ break
159
+ offset = int(next_offset)
160
+
161
+ return {"id": original_id, "success": True, "data": items, "pageSize": PAGE_SIZE, "total": total}
162
+
163
+ # --- Socket helpers (length-prefixed framing) ---
164
+
165
+ def _cleanup(alias: str):
166
+ try:
167
+ if not is_windows():
168
+ Path(_socket_path_for(alias)).unlink(missing_ok=True)
169
+ except Exception:
170
+ pass
171
+ _registry_remove(alias)
172
+
173
+ def main():
174
+ stdin = sys.stdin.buffer
175
+
176
+ # Wait for the hello handshake to learn the profile alias
177
+ first_msg = protocol.read_native_message(stdin)
178
+ if first_msg and first_msg.get("type") == "hello":
179
+ alias = _resolve_profile_alias(first_msg)
180
+ else:
181
+ # No hello — use a generated alias; first_msg is dropped (no response path).
182
+ alias = str(uuid.uuid4())
183
+
184
+ runtime_dir().mkdir(mode=0o700, exist_ok=True)
185
+ sock_path = _socket_path_for(alias)
186
+
187
+ if not is_windows():
188
+ path = Path(sock_path)
189
+ if path.exists():
190
+ path.unlink()
191
+ bound_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
192
+ bound_sock.bind(sock_path)
193
+ os.chmod(sock_path, 0o600)
194
+ bound_sock.listen(16)
195
+ else:
196
+ bound_sock = None
197
+
198
+ _registry_add(alias, sock_path)
199
+
200
+ t = threading.Thread(
201
+ target=local_server.socket_server,
202
+ args=(sock_path, _handle_cli_payload, _error_response),
203
+ kwargs={"bound_sock": bound_sock},
204
+ daemon=True,
205
+ )
206
+ t.start()
207
+
208
+ stdin_reader(alias)
209
+
210
+ if __name__ == "__main__":
211
+ main()
@@ -0,0 +1,111 @@
1
+ """Local IPC server loops used by the native messaging host."""
2
+ import asyncio
3
+ import os
4
+ import socket
5
+ import threading
6
+ from collections.abc import Callable
7
+ from multiprocessing.connection import Listener
8
+ from pathlib import Path
9
+
10
+ from browser_cli import framing, local_transport
11
+ from browser_cli.platform import is_windows
12
+
13
+ PayloadHandler = Callable[[bytes], bytes]
14
+ ErrorHandler = Callable[[Exception], bytes]
15
+
16
+ async def async_socket_server(
17
+ sock_path: str,
18
+ handle_payload: PayloadHandler,
19
+ error_response: ErrorHandler,
20
+ *,
21
+ bound_sock: socket.socket | None = None,
22
+ ) -> None:
23
+ sock = bound_sock
24
+ if sock is None:
25
+ path = Path(sock_path)
26
+ if path.exists():
27
+ path.unlink()
28
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
29
+ sock.bind(sock_path)
30
+ os.chmod(sock_path, 0o600)
31
+ sock.listen(16)
32
+
33
+ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
34
+ await async_handle_cli_connection(reader, writer, handle_payload, error_response)
35
+
36
+ server = await asyncio.start_unix_server(handle, sock=sock)
37
+ async with server:
38
+ await server.serve_forever()
39
+
40
+ def socket_server(
41
+ sock_path: str,
42
+ handle_payload: PayloadHandler,
43
+ error_response: ErrorHandler,
44
+ *,
45
+ bound_sock: socket.socket | None = None,
46
+ ) -> None:
47
+ if is_windows():
48
+ windows_pipe_server(sock_path, handle_payload, error_response)
49
+ return
50
+ asyncio.run(async_socket_server(sock_path, handle_payload, error_response, bound_sock=bound_sock))
51
+
52
+ def windows_pipe_server(sock_path: str, handle_payload: PayloadHandler, error_response: ErrorHandler) -> None:
53
+ while True:
54
+ listener = None
55
+ try:
56
+ listener = Listener(sock_path, family="AF_PIPE")
57
+ conn = listener.accept()
58
+ except OSError:
59
+ if listener is not None:
60
+ try:
61
+ listener.close()
62
+ except Exception:
63
+ pass
64
+ break
65
+ threading.Thread(target=handle_cli_connection, args=(conn, handle_payload, error_response, listener), daemon=True).start()
66
+
67
+ async def async_handle_cli_connection(
68
+ reader: asyncio.StreamReader,
69
+ writer: asyncio.StreamWriter,
70
+ handle_payload: PayloadHandler,
71
+ error_response: ErrorHandler,
72
+ ) -> None:
73
+ try:
74
+ data = await local_transport.async_recv_all(reader)
75
+ if not data:
76
+ return
77
+ response = await asyncio.to_thread(handle_payload, data)
78
+ await local_transport.async_send_all(writer, response)
79
+ except Exception as exc:
80
+ try:
81
+ await local_transport.async_send_all(writer, error_response(exc))
82
+ except Exception:
83
+ pass
84
+ finally:
85
+ writer.close()
86
+ try:
87
+ await writer.wait_closed()
88
+ except Exception:
89
+ pass
90
+
91
+ def send_cli_response(conn, response: bytes) -> None:
92
+ if is_windows():
93
+ conn.send_bytes(response)
94
+ else:
95
+ framing.send_frame(conn, response)
96
+
97
+ def handle_cli_connection(conn, handle_payload: PayloadHandler, error_response: ErrorHandler, listener=None) -> None:
98
+ try:
99
+ data = conn.recv_bytes() if is_windows() else framing.recv_frame(conn, allow_eof=True)
100
+ if not data:
101
+ return
102
+ send_cli_response(conn, handle_payload(data))
103
+ except Exception as exc:
104
+ try:
105
+ send_cli_response(conn, error_response(exc))
106
+ except Exception:
107
+ pass
108
+ finally:
109
+ conn.close()
110
+ if listener is not None:
111
+ listener.close()
@@ -0,0 +1,30 @@
1
+ """Chrome Native Messaging stdio protocol helpers."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import struct
6
+
7
+ def read_exact_stream(stream, n: int) -> bytes | None:
8
+ buf = b""
9
+ while len(buf) < n:
10
+ chunk = stream.read(n - len(buf))
11
+ if not chunk:
12
+ return None
13
+ buf += chunk
14
+ return buf
15
+
16
+ def read_native_message(stream) -> dict | None:
17
+ raw_len = read_exact_stream(stream, 4)
18
+ if raw_len is None:
19
+ return None
20
+ msg_len = struct.unpack("<I", raw_len)[0]
21
+ data = read_exact_stream(stream, msg_len)
22
+ if data is None:
23
+ return None
24
+ return json.loads(data.decode("utf-8"))
25
+
26
+ def write_native_message(stream, msg: dict) -> None:
27
+ data = json.dumps(msg).encode("utf-8")
28
+ stream.write(struct.pack("<I", len(data)))
29
+ stream.write(data)
30
+ stream.flush()
@@ -0,0 +1,34 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from browser_cli.constants import APP_NAME, DEFAULT_ALIAS, RUNTIME_DIRNAME
6
+
7
+ def is_windows() -> bool:
8
+ return sys.platform.startswith("win")
9
+
10
+ def runtime_dir() -> Path:
11
+ if is_windows():
12
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
13
+ return base / APP_NAME
14
+ return Path("/tmp") / RUNTIME_DIRNAME
15
+
16
+ def registry_path() -> Path:
17
+ return runtime_dir() / "registry.json"
18
+
19
+ def install_base_dir() -> Path:
20
+ if is_windows():
21
+ return runtime_dir()
22
+ if sys.platform == "darwin":
23
+ return Path.home() / "Library" / "Application Support" / APP_NAME
24
+ return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / APP_NAME
25
+
26
+ def sanitize_alias(alias:str) -> str:
27
+ cleaned = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in alias.strip())
28
+ return cleaned or DEFAULT_ALIAS
29
+
30
+ def endpoint_for_alias(alias:str) -> str:
31
+ safe = sanitize_alias(alias)
32
+ if is_windows():
33
+ return rf"\\.\pipe\browser-cli-{safe}"
34
+ return str(runtime_dir() / f"{safe}.sock")
@@ -0,0 +1,99 @@
1
+ """Runtime registry helpers for active browser-cli native host endpoints."""
2
+ import contextlib
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Iterator
8
+
9
+ from browser_cli.platform import registry_path
10
+
11
+ REGISTRY_PATH = registry_path()
12
+
13
+ @contextlib.contextmanager
14
+ def _file_lock(path: Path) -> Iterator[None]:
15
+ """Best-effort cross-process lock for registry read/modify/write updates."""
16
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
17
+ lock_path = path.with_suffix(path.suffix + ".lock")
18
+ with lock_path.open("a+") as lock_file:
19
+ if os.name == "nt":
20
+ try:
21
+ import msvcrt
22
+
23
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
24
+ yield
25
+ finally:
26
+ try:
27
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
28
+ except OSError:
29
+ pass
30
+ else:
31
+ try:
32
+ import fcntl
33
+
34
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
35
+ yield
36
+ finally:
37
+ try:
38
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
39
+ except OSError:
40
+ pass
41
+
42
+ def _coerce_registry(data) -> dict[str, str]:
43
+ if not isinstance(data, dict):
44
+ return {}
45
+ return {str(alias): str(endpoint) for alias, endpoint in data.items() if alias and endpoint}
46
+
47
+ def load_registry(path: Path | None = None) -> dict[str, str]:
48
+ """Load the active browser registry.
49
+
50
+ Older native hosts wrote this file non-atomically, so tolerate trailing
51
+ garbage from interrupted/concurrent writes and keep the first valid JSON
52
+ object when possible.
53
+ """
54
+ registry = path or REGISTRY_PATH
55
+ if not registry.exists():
56
+ return {}
57
+ try:
58
+ text = registry.read_text(encoding="utf-8")
59
+ except OSError:
60
+ return {}
61
+ if not text.strip():
62
+ return {}
63
+ try:
64
+ return _coerce_registry(json.loads(text))
65
+ except json.JSONDecodeError:
66
+ try:
67
+ data, _ = json.JSONDecoder().raw_decode(text)
68
+ return _coerce_registry(data)
69
+ except json.JSONDecodeError:
70
+ return {}
71
+
72
+ def save_registry(data: dict[str, str], path: Path | None = None) -> None:
73
+ """Atomically write the active browser registry."""
74
+ registry = path or REGISTRY_PATH
75
+ registry.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
76
+ payload = json.dumps(_coerce_registry(data), sort_keys=True)
77
+ fd, tmp_name = tempfile.mkstemp(prefix=registry.name + ".", suffix=".tmp", dir=registry.parent)
78
+ try:
79
+ with os.fdopen(fd, "w", encoding="utf-8") as tmp:
80
+ tmp.write(payload)
81
+ tmp.flush()
82
+ os.fsync(tmp.fileno())
83
+ os.replace(tmp_name, registry)
84
+ finally:
85
+ try:
86
+ os.unlink(tmp_name)
87
+ except FileNotFoundError:
88
+ pass
89
+
90
+ def update_registry(alias: str, endpoint: str | None, path: Path | None = None) -> None:
91
+ """Add/update an alias, or remove it when endpoint is None."""
92
+ registry = path or REGISTRY_PATH
93
+ with _file_lock(registry):
94
+ data = load_registry(registry)
95
+ if endpoint is None:
96
+ data.pop(alias, None)
97
+ else:
98
+ data[alias] = endpoint
99
+ save_registry(data, registry)
@@ -0,0 +1 @@
1
+ """Client-side remote browser transport and registry."""
@@ -0,0 +1,53 @@
1
+ """Persistence for remembered remote browser endpoints and key specs."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from browser_cli.constants import CONFIG_DIR
9
+ from browser_cli.endpoints import _normalize_endpoint
10
+
11
+ REMOTE_REGISTRY_PATH = CONFIG_DIR / "remotes.json"
12
+
13
+ def load_remotes() -> dict[str, dict[str, str]]:
14
+ if not REMOTE_REGISTRY_PATH.exists():
15
+ return {}
16
+ try:
17
+ data = json.loads(REMOTE_REGISTRY_PATH.read_text(encoding="utf-8"))
18
+ except Exception:
19
+ return {}
20
+ if not isinstance(data, dict):
21
+ return {}
22
+ # Normalize keys so old entries stored as "domain:443" match current lookups.
23
+ return {_normalize_endpoint(str(endpoint)): cfg for endpoint, cfg in data.items() if isinstance(cfg, dict)}
24
+
25
+ def is_valid_key_spec(value: str) -> bool:
26
+ """Return True for 'agent', 'agent:<selector>', or a plausible key file path."""
27
+ return value == "agent" or value.startswith("agent:") or (
28
+ not value.startswith("<") and ("/" in value or Path(value).suffix in {".pem", ".key"})
29
+ )
30
+
31
+ def save_remote_key(endpoint: str, key_spec: str) -> None:
32
+ """Persist the key spec (e.g. 'agent' or a file path) for a remote endpoint."""
33
+ if not endpoint or not key_spec or not is_valid_key_spec(key_spec):
34
+ return
35
+ remotes = load_remotes()
36
+ current = remotes.get(endpoint, {})
37
+ current["key"] = key_spec
38
+ remotes[endpoint] = current
39
+ REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
40
+ fd = os.open(str(REMOTE_REGISTRY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
41
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
42
+ f.write(json.dumps(remotes, indent=2, sort_keys=True))
43
+
44
+ def key_for_remote(endpoint: str | None) -> str | None:
45
+ if not endpoint:
46
+ return None
47
+ cfg = load_remotes().get(endpoint) or {}
48
+ key = cfg.get("key")
49
+ if not key:
50
+ return None
51
+ key_str = str(key)
52
+ # Reject corrupted values (e.g. str(AgentKey(...)) saved by an older bug).
53
+ return key_str if is_valid_key_spec(key_str) else None