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.
- browser_cli/__init__.py +164 -0
- browser_cli/async_sdk.py +237 -0
- browser_cli/auth.py +263 -0
- browser_cli/cli.py +151 -0
- browser_cli/client/__init__.py +47 -0
- browser_cli/client/auth.py +63 -0
- browser_cli/client/core.py +200 -0
- browser_cli/client/messages.py +45 -0
- browser_cli/client/targets.py +95 -0
- browser_cli/command_security.py +119 -0
- browser_cli/commands/__init__.py +81 -0
- browser_cli/commands/auth.py +157 -0
- browser_cli/commands/clients.py +173 -0
- browser_cli/commands/completion.py +56 -0
- browser_cli/commands/doctor.py +90 -0
- browser_cli/commands/dom.py +191 -0
- browser_cli/commands/events.py +52 -0
- browser_cli/commands/extension.py +42 -0
- browser_cli/commands/extract.py +70 -0
- browser_cli/commands/groups.py +108 -0
- browser_cli/commands/install.py +121 -0
- browser_cli/commands/navigate.py +96 -0
- browser_cli/commands/page.py +26 -0
- browser_cli/commands/perf.py +47 -0
- browser_cli/commands/raw.py +23 -0
- browser_cli/commands/remote.py +68 -0
- browser_cli/commands/script.py +68 -0
- browser_cli/commands/search.py +79 -0
- browser_cli/commands/serve.py +117 -0
- browser_cli/commands/serve_http.py +115 -0
- browser_cli/commands/session.py +163 -0
- browser_cli/commands/storage.py +36 -0
- browser_cli/commands/tabs.py +252 -0
- browser_cli/commands/watch.py +60 -0
- browser_cli/commands/windows.py +87 -0
- browser_cli/commands/workspace.py +91 -0
- browser_cli/compat/__init__.py +4 -0
- browser_cli/compat/auth.py +44 -0
- browser_cli/compat/commands.py +43 -0
- browser_cli/constants.py +95 -0
- browser_cli/endpoints.py +55 -0
- browser_cli/errors.py +9 -0
- browser_cli/framing.py +83 -0
- browser_cli/local_transport.py +64 -0
- browser_cli/markdown/__init__.py +8 -0
- browser_cli/markdown/html.py +259 -0
- browser_cli/markdown/render.py +188 -0
- browser_cli/models.py +182 -0
- browser_cli/native/__init__.py +1 -0
- browser_cli/native/host.py +211 -0
- browser_cli/native/local_server.py +111 -0
- browser_cli/native/protocol.py +30 -0
- browser_cli/platform.py +34 -0
- browser_cli/registry.py +99 -0
- browser_cli/remote/__init__.py +1 -0
- browser_cli/remote/registry.py +53 -0
- browser_cli/remote/transport.py +230 -0
- browser_cli/sdk/__init__.py +48 -0
- browser_cli/sdk/base.py +116 -0
- browser_cli/sdk/browser_data.py +37 -0
- browser_cli/sdk/decorators.py +107 -0
- browser_cli/sdk/dom.py +169 -0
- browser_cli/sdk/extension.py +24 -0
- browser_cli/sdk/factories.py +103 -0
- browser_cli/sdk/groups.py +51 -0
- browser_cli/sdk/navigation.py +122 -0
- browser_cli/sdk/perf.py +23 -0
- browser_cli/sdk/routing.py +149 -0
- browser_cli/sdk/session.py +72 -0
- browser_cli/sdk/tabs.py +213 -0
- browser_cli/sdk/windows.py +26 -0
- browser_cli/sdk/workflow_decorators.py +200 -0
- browser_cli/serve/__init__.py +0 -0
- browser_cli/serve/auth.py +107 -0
- browser_cli/serve/control.py +59 -0
- browser_cli/serve/logging.py +16 -0
- browser_cli/serve/proxy.py +79 -0
- browser_cli/serve/runtime.py +196 -0
- browser_cli/transport.py +214 -0
- browser_cli/version_manager.py +17 -0
- real_browser_cli-0.14.2.dist-info/METADATA +87 -0
- real_browser_cli-0.14.2.dist-info/RECORD +85 -0
- real_browser_cli-0.14.2.dist-info/WHEEL +4 -0
- real_browser_cli-0.14.2.dist-info/entry_points.txt +2 -0
- 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()
|
browser_cli/platform.py
ADDED
|
@@ -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")
|
browser_cli/registry.py
ADDED
|
@@ -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
|