maestro-browser-bridge 1.18.0__tar.gz

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 (25) hide show
  1. maestro_browser_bridge-1.18.0/PKG-INFO +118 -0
  2. maestro_browser_bridge-1.18.0/README.md +82 -0
  3. maestro_browser_bridge-1.18.0/maestro_browser_bridge/__init__.py +12 -0
  4. maestro_browser_bridge-1.18.0/maestro_browser_bridge/cli.py +269 -0
  5. maestro_browser_bridge-1.18.0/maestro_browser_bridge/config.py +83 -0
  6. maestro_browser_bridge-1.18.0/maestro_browser_bridge/daemon.py +356 -0
  7. maestro_browser_bridge-1.18.0/maestro_browser_bridge/handlers.py +330 -0
  8. maestro_browser_bridge-1.18.0/maestro_browser_bridge/hmac_sig.py +67 -0
  9. maestro_browser_bridge-1.18.0/maestro_browser_bridge/logging.py +88 -0
  10. maestro_browser_bridge-1.18.0/maestro_browser_bridge/playwright_session.py +262 -0
  11. maestro_browser_bridge-1.18.0/maestro_browser_bridge/protocol.py +148 -0
  12. maestro_browser_bridge-1.18.0/maestro_browser_bridge/security.py +186 -0
  13. maestro_browser_bridge-1.18.0/maestro_browser_bridge.egg-info/PKG-INFO +118 -0
  14. maestro_browser_bridge-1.18.0/maestro_browser_bridge.egg-info/SOURCES.txt +23 -0
  15. maestro_browser_bridge-1.18.0/maestro_browser_bridge.egg-info/dependency_links.txt +1 -0
  16. maestro_browser_bridge-1.18.0/maestro_browser_bridge.egg-info/entry_points.txt +2 -0
  17. maestro_browser_bridge-1.18.0/maestro_browser_bridge.egg-info/requires.txt +10 -0
  18. maestro_browser_bridge-1.18.0/maestro_browser_bridge.egg-info/top_level.txt +1 -0
  19. maestro_browser_bridge-1.18.0/pyproject.toml +60 -0
  20. maestro_browser_bridge-1.18.0/setup.cfg +4 -0
  21. maestro_browser_bridge-1.18.0/tests/test_cli.py +143 -0
  22. maestro_browser_bridge-1.18.0/tests/test_daemon_local_server.py +135 -0
  23. maestro_browser_bridge-1.18.0/tests/test_handlers.py +312 -0
  24. maestro_browser_bridge-1.18.0/tests/test_protocol.py +152 -0
  25. maestro_browser_bridge-1.18.0/tests/test_security.py +158 -0
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: maestro-browser-bridge
3
+ Version: 1.18.0
4
+ Summary: Local browser bridge for Remote AI Maestro — runs a Playwright-managed Chromium and exposes BROWSER_* commands over WebSocket.
5
+ Author-email: Shaun <scwj1210@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/shaunchew/remote-ai-maestro
8
+ Project-URL: Repository, https://github.com/shaunchew/remote-ai-maestro
9
+ Project-URL: Documentation, https://github.com/shaunchew/remote-ai-maestro#readme
10
+ Project-URL: Issues, https://github.com/shaunchew/remote-ai-maestro/issues
11
+ Keywords: remote,ai,browser,playwright,chromium,automation,maestro
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Libraries
24
+ Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ Requires-Dist: websockets>=13.0
28
+ Requires-Dist: click>=8.0
29
+ Requires-Dist: pydantic>=2
30
+ Requires-Dist: structlog>=24
31
+ Requires-Dist: playwright>=1.45
32
+ Provides-Extra: test
33
+ Requires-Dist: pytest>=8.0; extra == "test"
34
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
35
+ Requires-Dist: pytest-mock>=3.12; extra == "test"
36
+
37
+ # maestro-browser-bridge
38
+
39
+ Local browser bridge for [Remote AI Maestro](https://remote-ai-maestro.thesavvydeveloper.com).
40
+
41
+ The bridge is the companion app you install on the same laptop where you keep
42
+ your real browser. It launches a Playwright-managed Chromium in an *isolated*
43
+ profile and exposes it over a WebSocket so an AI agent running inside a Maestro
44
+ sandbox can drive it — navigate, click, type, screenshot, read DOM, capture
45
+ console logs.
46
+
47
+ This is the **Phase 2** counterpart to the simpler Phase 1 `maestro browser
48
+ open <url>` command, which only pops URLs in your already-open browser via the
49
+ frontend's `window.open`. The bridge gives Claude (or any agent) real browser
50
+ control without ever touching your logged-in Chrome profile.
51
+
52
+ ## Why a separate process
53
+
54
+ The AI agent runs on a remote machine (or an EC2 sandbox) but the browser to
55
+ be controlled lives on your local desktop. There is no other process that runs
56
+ on the user's actual laptop in a known-good location — the agent daemon runs
57
+ on whichever machine the sandbox is bound to, which may be EC2. So we ship a
58
+ dedicated bridge process that runs *on the user's laptop*, maintains an
59
+ outbound WebSocket to the relay, and translates `BROWSER_*` messages into
60
+ Playwright calls.
61
+
62
+ The bridge is per-user (one bridge per laptop) — distinct from the agent
63
+ daemon, which is per-machine. Relay routing maps `user_id → bridge`.
64
+
65
+ ## Install
66
+
67
+ ```bash
68
+ pip install maestro-browser-bridge
69
+
70
+ # Pre-download the Playwright Chromium binary (~200MB)
71
+ playwright install chromium
72
+
73
+ # Configure once
74
+ maestro-browser-bridge configure \
75
+ --relay-url wss://relay.thesavvydeveloper.com \
76
+ --user-id YOUR_USER_UUID \
77
+ --user-token YOUR_BRIDGE_TOKEN
78
+
79
+ # Start
80
+ maestro-browser-bridge start
81
+ ```
82
+
83
+ `--detach` is wired but not yet implemented; for now run the bridge in the
84
+ foreground (or wrap it in your own service manager — `launchd` / `systemd`
85
+ unit shipping is planned).
86
+
87
+ ## Status
88
+
89
+ This package is the **scaffold** for the Phase 2 work tracked in
90
+ `_reference/11_BROWSER_CONTROL.md` and `implementation_plan.md`'s
91
+ "BC — Browser Control (Phase 2)" section. The protocol models, dispatch
92
+ shell, and CLI surface are in place; the actual Playwright handlers raise
93
+ `NotImplementedError` and will be wired in subsequent commits (BC.1.2 in the
94
+ plan).
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ # Editable install with test deps (from the repo root)
100
+ pip install -e ./bridge[test]
101
+
102
+ # Run tests
103
+ cd bridge && pytest
104
+
105
+ # Smoke check the CLI
106
+ maestro-browser-bridge --version
107
+ maestro-browser-bridge --help
108
+ ```
109
+
110
+ ## References
111
+
112
+ - Architecture & wire protocol: `_reference/11_BROWSER_CONTROL.md`
113
+ - Milestone plan: `_reference/12_BROWSER_PHASE2_PLAN.md`
114
+ - Pip package contract: `implementation_plan.md` § BC.1
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,82 @@
1
+ # maestro-browser-bridge
2
+
3
+ Local browser bridge for [Remote AI Maestro](https://remote-ai-maestro.thesavvydeveloper.com).
4
+
5
+ The bridge is the companion app you install on the same laptop where you keep
6
+ your real browser. It launches a Playwright-managed Chromium in an *isolated*
7
+ profile and exposes it over a WebSocket so an AI agent running inside a Maestro
8
+ sandbox can drive it — navigate, click, type, screenshot, read DOM, capture
9
+ console logs.
10
+
11
+ This is the **Phase 2** counterpart to the simpler Phase 1 `maestro browser
12
+ open <url>` command, which only pops URLs in your already-open browser via the
13
+ frontend's `window.open`. The bridge gives Claude (or any agent) real browser
14
+ control without ever touching your logged-in Chrome profile.
15
+
16
+ ## Why a separate process
17
+
18
+ The AI agent runs on a remote machine (or an EC2 sandbox) but the browser to
19
+ be controlled lives on your local desktop. There is no other process that runs
20
+ on the user's actual laptop in a known-good location — the agent daemon runs
21
+ on whichever machine the sandbox is bound to, which may be EC2. So we ship a
22
+ dedicated bridge process that runs *on the user's laptop*, maintains an
23
+ outbound WebSocket to the relay, and translates `BROWSER_*` messages into
24
+ Playwright calls.
25
+
26
+ The bridge is per-user (one bridge per laptop) — distinct from the agent
27
+ daemon, which is per-machine. Relay routing maps `user_id → bridge`.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install maestro-browser-bridge
33
+
34
+ # Pre-download the Playwright Chromium binary (~200MB)
35
+ playwright install chromium
36
+
37
+ # Configure once
38
+ maestro-browser-bridge configure \
39
+ --relay-url wss://relay.thesavvydeveloper.com \
40
+ --user-id YOUR_USER_UUID \
41
+ --user-token YOUR_BRIDGE_TOKEN
42
+
43
+ # Start
44
+ maestro-browser-bridge start
45
+ ```
46
+
47
+ `--detach` is wired but not yet implemented; for now run the bridge in the
48
+ foreground (or wrap it in your own service manager — `launchd` / `systemd`
49
+ unit shipping is planned).
50
+
51
+ ## Status
52
+
53
+ This package is the **scaffold** for the Phase 2 work tracked in
54
+ `_reference/11_BROWSER_CONTROL.md` and `implementation_plan.md`'s
55
+ "BC — Browser Control (Phase 2)" section. The protocol models, dispatch
56
+ shell, and CLI surface are in place; the actual Playwright handlers raise
57
+ `NotImplementedError` and will be wired in subsequent commits (BC.1.2 in the
58
+ plan).
59
+
60
+ ## Development
61
+
62
+ ```bash
63
+ # Editable install with test deps (from the repo root)
64
+ pip install -e ./bridge[test]
65
+
66
+ # Run tests
67
+ cd bridge && pytest
68
+
69
+ # Smoke check the CLI
70
+ maestro-browser-bridge --version
71
+ maestro-browser-bridge --help
72
+ ```
73
+
74
+ ## References
75
+
76
+ - Architecture & wire protocol: `_reference/11_BROWSER_CONTROL.md`
77
+ - Milestone plan: `_reference/12_BROWSER_PHASE2_PLAN.md`
78
+ - Pip package contract: `implementation_plan.md` § BC.1
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,12 @@
1
+ """Maestro Browser Bridge — local Playwright-driven Chromium for the
2
+ Remote AI Maestro platform.
3
+
4
+ The bridge runs on the user's *local* laptop (not on a registered Maestro
5
+ "machine") and maintains an outbound WebSocket to the relay so AI agents
6
+ running inside remote sandboxes can drive a real browser the user can see
7
+ and intervene in.
8
+
9
+ See ``_reference/11_BROWSER_CONTROL.md`` for the design spec.
10
+ """
11
+
12
+ __version__ = "0.1.0"
@@ -0,0 +1,269 @@
1
+ """Click CLI for the ``maestro-browser-bridge`` package.
2
+
3
+ Subcommands mirror the shape of ``remotedev-agent``'s CLI so the install
4
+ experience is familiar:
5
+
6
+ - ``configure`` — write config to ``~/.maestro/bridge-config.json``.
7
+ - ``start`` — load config and run the daemon (``--detach`` stubbed).
8
+ - ``stop`` — placeholder.
9
+ - ``status`` — print version + config path.
10
+ - ``install-service`` — placeholder (launchd/systemd installer lands in BC.1.4).
11
+ - ``--version`` — read from package metadata.
12
+
13
+ Only the bits required by the acceptance criteria are wired today; the
14
+ ``daemon.py`` skeleton already documents the rest as TODOs.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
21
+
22
+ import click
23
+
24
+ from .config import CONFIG_FILE, DEFAULT_LOCAL_PORT, BridgeConfig
25
+ from .logging import configure_logging
26
+
27
+
28
+ def _package_version() -> str:
29
+ """Resolve the installed package version, falling back to ``__init__.__version__``.
30
+
31
+ Mirrors the pattern in ``agent/remotedev_agent/cli.py`` so source-checkout
32
+ runs (without dist-info) still produce something useful.
33
+ """
34
+ try:
35
+ return _pkg_version("maestro-browser-bridge")
36
+ except PackageNotFoundError:
37
+ from . import __version__
38
+ return __version__
39
+
40
+
41
+ @click.group()
42
+ @click.version_option(version=_package_version(), prog_name="maestro-browser-bridge")
43
+ def cli() -> None:
44
+ """Maestro Browser Bridge — local Playwright Chromium controller."""
45
+ # Configure structlog once per CLI invocation. Cheap (idempotent), and
46
+ # ensures even short-lived commands like ``status`` emit JSON in prod.
47
+ configure_logging()
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # configure
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ @cli.command()
56
+ @click.option(
57
+ "--relay-url",
58
+ required=True,
59
+ help="WebSocket URL of the Maestro relay (e.g. wss://relay.thesavvydeveloper.com).",
60
+ )
61
+ @click.option(
62
+ "--user-id",
63
+ required=True,
64
+ help="Maestro user UUID — determines relay routing (user_id → bridge).",
65
+ )
66
+ @click.option(
67
+ "--user-token",
68
+ required=True,
69
+ help="Per-user bridge token issued by the backend.",
70
+ )
71
+ @click.option(
72
+ "--port",
73
+ type=int,
74
+ default=DEFAULT_LOCAL_PORT,
75
+ show_default=True,
76
+ help="Local loopback port for the dev WS server.",
77
+ )
78
+ def configure(relay_url: str, user_id: str, user_token: str, port: int) -> None:
79
+ """Persist bridge configuration to ``~/.maestro/bridge-config.json``.
80
+
81
+ The file is written 0600 inside a 0700 directory — equivalent posture
82
+ to the agent's config, since the bridge token is approximately as
83
+ sensitive as a Maestro JWT.
84
+ """
85
+ config = BridgeConfig(
86
+ relay_url=relay_url,
87
+ user_id=user_id,
88
+ user_token=user_token,
89
+ port=port,
90
+ )
91
+ config.save()
92
+ click.echo(f"Config saved to {CONFIG_FILE}.")
93
+ click.echo("Start the bridge with: maestro-browser-bridge start")
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # start
98
+ # ---------------------------------------------------------------------------
99
+
100
+
101
+ @cli.command()
102
+ @click.option(
103
+ "--detach",
104
+ "-d",
105
+ is_flag=True,
106
+ help="Run in the background (not yet implemented — exits non-zero).",
107
+ )
108
+ def start(detach: bool) -> None:
109
+ """Launch the bridge daemon in the foreground.
110
+
111
+ ``--detach`` is wired so the flag is visible in ``--help`` but the
112
+ background-launch path (PID file, log rotation, daemonization) lands
113
+ in a later milestone — see ``agent/remotedev_agent/process_manager.py``
114
+ for the pattern we'll mirror.
115
+ """
116
+ if detach:
117
+ click.echo("detach not yet implemented", err=True)
118
+ raise SystemExit(1)
119
+
120
+ try:
121
+ config = BridgeConfig.load()
122
+ except FileNotFoundError as e:
123
+ click.echo(str(e), err=True)
124
+ raise SystemExit(1)
125
+
126
+ # Import inside the command so test collection (which doesn't run
127
+ # ``start``) doesn't pay the cost of pulling in websockets / playwright
128
+ # just to introspect ``--help``.
129
+ from .daemon import BridgeDaemon
130
+
131
+ click.echo(f"Starting bridge for user {config.user_id}...")
132
+ daemon = BridgeDaemon(config)
133
+ try:
134
+ asyncio.run(daemon.run())
135
+ except KeyboardInterrupt:
136
+ # Ctrl-C is a normal exit path during dev; don't print a stack
137
+ # trace at the user.
138
+ click.echo("Bridge stopped.")
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # stop
143
+ # ---------------------------------------------------------------------------
144
+
145
+
146
+ @cli.command("stop")
147
+ def cmd_stop() -> None:
148
+ """Stop a detached bridge daemon (placeholder).
149
+
150
+ Implementation lands alongside ``--detach`` in BC.1.4 — same
151
+ process-manager surface as the agent.
152
+ """
153
+ click.echo("stop not yet implemented — run the bridge in the foreground for now.")
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # status
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ @cli.command("status")
162
+ def cmd_status() -> None:
163
+ """Print version + config path. Loads the saved config when available."""
164
+ click.echo(f"maestro-browser-bridge {_package_version()}")
165
+ click.echo(f"Config file: {CONFIG_FILE}")
166
+ try:
167
+ config = BridgeConfig.load()
168
+ except FileNotFoundError:
169
+ click.echo("Config: NOT CONFIGURED (run 'maestro-browser-bridge configure ...').")
170
+ return
171
+ click.echo(f"Relay URL: {config.relay_url}")
172
+ click.echo(f"User ID: {config.user_id}")
173
+ click.echo(f"Local port: {config.port}")
174
+ # NB — never print user_token; it's roughly as sensitive as a session JWT.
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # install-service
179
+ # ---------------------------------------------------------------------------
180
+
181
+
182
+ @cli.command("install-service")
183
+ def install_service() -> None:
184
+ """Install as a background service (placeholder).
185
+
186
+ Plan: mirror ``agent/remotedev_agent/service/`` — launchd on macOS,
187
+ systemd on Linux. Tracked in BC.1.4.
188
+ """
189
+ click.echo("install-service not yet implemented — see BC.1.4 in implementation_plan.md.")
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Kill switch — pause / resume (BC.6.1)
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ def _send_bridge_control(action: str) -> dict:
198
+ """Send a BRIDGE_CONTROL command to the running bridge via 127.0.0.1.
199
+
200
+ Synchronous — uses raw asyncio.run + websockets.connect so the CLI
201
+ doesn't need to manage an event loop itself. Returns the parsed
202
+ reply dict, raises ``ClickException`` on connect / I/O failure.
203
+ """
204
+ import asyncio
205
+ import json as _json
206
+
207
+ try:
208
+ config = BridgeConfig.load()
209
+ except FileNotFoundError as e:
210
+ raise click.ClickException(str(e))
211
+
212
+ async def _go() -> dict:
213
+ # Import lazily so ``--help`` / ``status`` don't pay the websockets cost.
214
+ import websockets
215
+
216
+ url = f"ws://127.0.0.1:{config.port}"
217
+ async with websockets.connect(url, open_timeout=2.0) as ws:
218
+ await ws.send(_json.dumps({"type": "BRIDGE_CONTROL", "action": action}))
219
+ reply_raw = await ws.recv()
220
+ return _json.loads(reply_raw if isinstance(reply_raw, str) else reply_raw.decode("utf-8"))
221
+
222
+ try:
223
+ return asyncio.run(_go())
224
+ except (ConnectionRefusedError, OSError) as e:
225
+ raise click.ClickException(
226
+ f"Could not reach bridge on 127.0.0.1:{config.port} ({e}). "
227
+ "Is 'maestro-browser-bridge start' running?"
228
+ )
229
+ except Exception as e: # noqa: BLE001
230
+ raise click.ClickException(f"Bridge control failed: {e}")
231
+
232
+
233
+ @cli.command("pause")
234
+ def cmd_pause() -> None:
235
+ """Pause the bridge — every BROWSER_COMMAND will fail until ``resume``.
236
+
237
+ Use this as a kill switch if Claude is doing something you don't
238
+ want it to. State is in-memory only, so a daemon restart implicitly
239
+ resumes — that's intentional, so the recovery path is just
240
+ ``maestro-browser-bridge start``.
241
+ """
242
+ reply = _send_bridge_control("pause")
243
+ if not reply.get("ok"):
244
+ raise click.ClickException(reply.get("error") or "Unknown error")
245
+ click.echo("Bridge paused. Resume with 'maestro-browser-bridge resume'.")
246
+
247
+
248
+ @cli.command("resume")
249
+ def cmd_resume() -> None:
250
+ """Resume the bridge after a ``pause``."""
251
+ reply = _send_bridge_control("resume")
252
+ if not reply.get("ok"):
253
+ raise click.ClickException(reply.get("error") or "Unknown error")
254
+ click.echo("Bridge resumed.")
255
+
256
+
257
+ @cli.command("bridge-status")
258
+ def cmd_bridge_status() -> None:
259
+ """Print the running bridge's runtime state (paused / allowlist / rate)."""
260
+ reply = _send_bridge_control("status")
261
+ if not reply.get("ok"):
262
+ raise click.ClickException(reply.get("error") or "Unknown error")
263
+ click.echo(f"paused: {reply.get('paused')}")
264
+ click.echo(f"allowlist hosts: {reply.get('allowlist_size')}")
265
+ click.echo(f"rate limit / min: {reply.get('rate_limit_per_min')}")
266
+
267
+
268
+ if __name__ == "__main__":
269
+ cli()
@@ -0,0 +1,83 @@
1
+ """Bridge configuration — persisted as JSON in the user's home dir.
2
+
3
+ Mirrors ``agent/remotedev_agent/config.py`` so the file-on-disk semantics
4
+ (0600 mode, parent dir 0700) are uniform across both daemons. We store the
5
+ config in ``~/.maestro/`` rather than ``~/.remotedev/`` because the bridge
6
+ is a *user-scoped* service (one per laptop, not per machine) while the agent
7
+ is *machine-scoped*. Sharing a dir would conflate two different identities.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from dataclasses import asdict, dataclass, field
15
+ from pathlib import Path
16
+
17
+ # ``~/.maestro`` is the per-user config root for the bridge. The agent uses
18
+ # ``~/.remotedev`` instead — intentionally a different namespace so that an
19
+ # uninstall of one doesn't blow away the other's state.
20
+ CONFIG_DIR = Path.home() / ".maestro"
21
+ CONFIG_FILE = CONFIG_DIR / "bridge-config.json"
22
+
23
+ # Default port for the local WS server. ``127.0.0.1:7654`` is mentioned in
24
+ # the spec; bound to loopback only — never exposed on a public interface
25
+ # because the bridge can issue privileged Playwright commands.
26
+ DEFAULT_LOCAL_PORT = 7654
27
+
28
+
29
+ @dataclass
30
+ class BridgeConfig:
31
+ """Configuration for the local browser bridge.
32
+
33
+ Attributes:
34
+ relay_url: WebSocket URL of the Maestro relay (e.g.
35
+ ``wss://relay.thesavvydeveloper.com``). The bridge will dial
36
+ ``<relay_url>/ws/browser-bridge/<user_id>`` on start.
37
+ user_id: UUID of the Maestro user this bridge belongs to. Determines
38
+ relay routing — one bridge per user (vs. agents which are
39
+ per-machine).
40
+ user_token: Bridge token issued by the backend (single-use issuance,
41
+ see ``POST /browser-bridges`` in the BC.2 plan). Used both as the
42
+ ``X-Bridge-Token`` header on connect and as the HMAC signing key
43
+ for every WS message.
44
+ port: Local loopback port for the in-process WS server. Defaults to
45
+ 7654. Bound to ``127.0.0.1`` — never a public interface.
46
+ allowlist_extra: User-added hosts the bridge allows for navigate.
47
+ Merged with the default allowlist in ``security.py``.
48
+ rate_limit_per_min: Per-minute BROWSER_COMMAND budget. Defaults to
49
+ 60 — enough for an interactive user, low enough to bound a
50
+ runaway script.
51
+ """
52
+
53
+ relay_url: str
54
+ user_id: str
55
+ user_token: str
56
+ port: int = DEFAULT_LOCAL_PORT
57
+ allowlist_extra: list[str] = field(default_factory=list)
58
+ rate_limit_per_min: int = 60
59
+
60
+ def save(self) -> None:
61
+ """Persist config to ``~/.maestro/bridge-config.json`` with 0600 mode.
62
+
63
+ The 0600 / 0700 combo prevents other users on a shared machine from
64
+ reading the bridge token, which is equivalent in blast radius to the
65
+ user's Maestro JWT.
66
+ """
67
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
68
+ os.chmod(CONFIG_DIR, 0o700)
69
+ CONFIG_FILE.write_text(json.dumps(asdict(self), indent=2))
70
+ os.chmod(CONFIG_FILE, 0o600)
71
+
72
+ @classmethod
73
+ def load(cls) -> "BridgeConfig":
74
+ """Read the saved config, or raise ``FileNotFoundError`` with a hint."""
75
+ if not CONFIG_FILE.exists():
76
+ raise FileNotFoundError(
77
+ f"Config not found at {CONFIG_FILE}. "
78
+ "Run 'maestro-browser-bridge configure --relay-url ... --user-id ... --user-token ...' first."
79
+ )
80
+ data = json.loads(CONFIG_FILE.read_text())
81
+ # Tolerate older configs that don't carry ``port`` by letting the
82
+ # dataclass default kick in.
83
+ return cls(**data)