lyra-browser 0.1.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 (59) hide show
  1. lyra_browser/__init__.py +10 -0
  2. lyra_browser/__main__.py +85 -0
  3. lyra_browser/approval.py +82 -0
  4. lyra_browser/audit.py +80 -0
  5. lyra_browser/capture.py +129 -0
  6. lyra_browser/cdp_guard.py +752 -0
  7. lyra_browser/cdp_socket.py +285 -0
  8. lyra_browser/challenge.py +144 -0
  9. lyra_browser/config.py +624 -0
  10. lyra_browser/consent.py +416 -0
  11. lyra_browser/context.py +219 -0
  12. lyra_browser/dialogs.py +276 -0
  13. lyra_browser/downloads.py +716 -0
  14. lyra_browser/enforcement.py +707 -0
  15. lyra_browser/origin.py +137 -0
  16. lyra_browser/permission.py +297 -0
  17. lyra_browser/server.py +169 -0
  18. lyra_browser/session.py +1009 -0
  19. lyra_browser/tools/__init__.py +34 -0
  20. lyra_browser/tools/actionable.py +453 -0
  21. lyra_browser/tools/aftermath.py +136 -0
  22. lyra_browser/tools/collaboration.py +180 -0
  23. lyra_browser/tools/dialogs.py +98 -0
  24. lyra_browser/tools/downloads.py +37 -0
  25. lyra_browser/tools/forms.py +990 -0
  26. lyra_browser/tools/interaction.py +655 -0
  27. lyra_browser/tools/navigation.py +412 -0
  28. lyra_browser/tools/reading.py +608 -0
  29. lyra_browser/tools/scope.py +100 -0
  30. lyra_browser/tools/tabs.py +165 -0
  31. lyra_browser/tools/uat.py +146 -0
  32. lyra_browser/tools/waiting.py +225 -0
  33. lyra_browser/uat/__init__.py +24 -0
  34. lyra_browser/uat/__main__.py +11 -0
  35. lyra_browser/uat/batch.py +397 -0
  36. lyra_browser/uat/brains/__init__.py +42 -0
  37. lyra_browser/uat/brains/anthropic_api.py +184 -0
  38. lyra_browser/uat/brains/base.py +129 -0
  39. lyra_browser/uat/brains/claude_code.py +156 -0
  40. lyra_browser/uat/brains/codex.py +175 -0
  41. lyra_browser/uat/brains/harness.py +165 -0
  42. lyra_browser/uat/brains/openai_compat.py +222 -0
  43. lyra_browser/uat/brains/scripted.py +43 -0
  44. lyra_browser/uat/cli.py +251 -0
  45. lyra_browser/uat/demo_site.py +154 -0
  46. lyra_browser/uat/observe.py +196 -0
  47. lyra_browser/uat/procs.py +132 -0
  48. lyra_browser/uat/prompt.py +148 -0
  49. lyra_browser/uat/recorder.py +460 -0
  50. lyra_browser/uat/report.py +641 -0
  51. lyra_browser/uat/runner.py +223 -0
  52. lyra_browser/uat/server.py +236 -0
  53. lyra_browser/uat/spec.py +477 -0
  54. lyra_browser/vision.py +58 -0
  55. lyra_browser-0.1.2.dist-info/METADATA +975 -0
  56. lyra_browser-0.1.2.dist-info/RECORD +59 -0
  57. lyra_browser-0.1.2.dist-info/WHEEL +4 -0
  58. lyra_browser-0.1.2.dist-info/entry_points.txt +3 -0
  59. lyra_browser-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,10 @@
1
+ """lyra-browser — a headful, collaborative browser MCP server for VEGA."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ from .config import Config
8
+ from .server import build_server
9
+
10
+ __all__ = ["Config", "build_server", "__version__"]
@@ -0,0 +1,85 @@
1
+ """CLI entry point: ``lyra-browser`` launches the MCP server over stdio.
2
+
3
+ VEGA (mcp.json) and Hermes (config.yaml ``mcp_servers``) spawn this as a stdio
4
+ MCP server. Pass ``--http`` to instead serve over Streamable HTTP for
5
+ remote/dev use.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from pathlib import Path
12
+
13
+ from .config import CLIENTS, Config
14
+ from .server import build_server
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(prog="lyra-browser", description=__doc__)
19
+ parser.add_argument("--http", action="store_true", help="Serve over HTTP instead of stdio.")
20
+ parser.add_argument("--host", default="127.0.0.1", help="HTTP host (with --http).")
21
+ parser.add_argument("--port", type=int, default=8765, help="HTTP port (with --http).")
22
+ parser.add_argument(
23
+ "--headless",
24
+ action=argparse.BooleanOptionalAction,
25
+ default=None,
26
+ help=(
27
+ "Run without a visible window. Nobody can watch or take over, so the "
28
+ "collaboration tools refuse with 'unattended'. Omit to follow "
29
+ "LYRA_BROWSER_HEADLESS, else the client's default (headless for "
30
+ "hermes, and on Linux with no display)."
31
+ ),
32
+ )
33
+ parser.add_argument(
34
+ "--client",
35
+ choices=sorted(CLIENTS),
36
+ default=None,
37
+ help=(
38
+ "The agent harness spawning this server. Picks where the profile and "
39
+ "captures live and whether a window is expected. Omit to follow "
40
+ "LYRA_BROWSER_CLIENT, else detect (VEGA_DATA_DIR -> vega, "
41
+ "HERMES_HOME -> hermes)."
42
+ ),
43
+ )
44
+ parser.add_argument(
45
+ "--uat-run",
46
+ type=Path,
47
+ default=None,
48
+ metavar="RUN_JSON",
49
+ help=(
50
+ "Serve one UAT persona run over stdio: the run.json that `lyra-uat run` "
51
+ "wrote. Configuration comes from that file, not from the environment or "
52
+ "the other flags. Used by harness brains (Claude Code, Codex)."
53
+ ),
54
+ )
55
+ return parser
56
+
57
+
58
+ def config_from_args(args: argparse.Namespace) -> Config:
59
+ """Build the runtime config: environment first, explicit flags on top.
60
+
61
+ A flag beats the environment so a developer can force either mode without
62
+ editing the VEGA install's mcp.json env block.
63
+ """
64
+ cfg = Config.from_env(client=args.client)
65
+ if args.headless is not None:
66
+ cfg.headless = args.headless
67
+ return cfg
68
+
69
+
70
+ def main() -> None:
71
+ args = build_parser().parse_args()
72
+ if args.uat_run is not None:
73
+ from .uat.server import serve_stdio
74
+
75
+ serve_stdio(args.uat_run)
76
+ return
77
+ mcp = build_server(config_from_args(args))
78
+ if args.http:
79
+ mcp.run(transport="http", host=args.host, port=args.port)
80
+ else:
81
+ mcp.run()
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
@@ -0,0 +1,82 @@
1
+ """Risk gating and collaboration state for human-in-the-loop control.
2
+
3
+ Two mechanisms live here:
4
+
5
+ 1. **Risk gate** — high-impact actions (navigating to a new origin, submitting a
6
+ form, clicking a destructive control) require an explicit ``confirm=True`` when
7
+ ``Config.require_approval`` is on. A gated call returns a structured
8
+ ``needs_approval`` envelope instead of acting, so VEGA can ask the user first.
9
+
10
+ 2. **Takeover state** — the user can grab the wheel. While a takeover is active,
11
+ agent-driven mutation tools refuse to act and tell the model to wait.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+
19
+ class ApprovalRequired(Exception):
20
+ """Raised by gated tools when the user has not yet confirmed the action."""
21
+
22
+ def __init__(self, action: str, reason: str, asked: bool = False) -> None:
23
+ super().__init__(reason)
24
+ self.action = action
25
+ self.reason = reason
26
+ self.asked = asked
27
+
28
+ def envelope(self) -> dict[str, str]:
29
+ if self.asked:
30
+ # The client put the question to a person and this is their answer.
31
+ # "Re-call with confirm=true" would only ask them again — on Hermes
32
+ # that is another approval prompt in their chat — so say so.
33
+ hint = (
34
+ "The user was asked and did not approve. Do not re-call to get "
35
+ "past this; tell them it was refused and retry only if they ask."
36
+ )
37
+ else:
38
+ hint = "Re-call with confirm=true to proceed once the user agrees."
39
+ return {
40
+ "status": "needs_approval",
41
+ "action": self.action,
42
+ "reason": self.reason,
43
+ "hint": hint,
44
+ }
45
+
46
+
47
+ class TakeoverActive(Exception):
48
+ """Raised when the user holds the session and the agent tries to mutate it."""
49
+
50
+ def __init__(self, reason: str) -> None:
51
+ super().__init__(reason)
52
+ self.reason = reason
53
+
54
+ def envelope(self) -> dict[str, str]:
55
+ return {
56
+ "status": "takeover_active",
57
+ "reason": self.reason,
58
+ "hint": "The user is driving. Call resume_after_takeover when they hand back.",
59
+ }
60
+
61
+
62
+ @dataclass(slots=True)
63
+ class CollaborationState:
64
+ """Shared, in-process control state between the agent and the user."""
65
+
66
+ require_approval: bool = True
67
+ takeover: bool = False
68
+ takeover_reason: str = ""
69
+
70
+ def gate(self, action: str, reason: str, confirm: bool) -> None:
71
+ """Enforce the risk gate for a high-impact action.
72
+
73
+ Raises ApprovalRequired unless approval is disabled or confirm is True.
74
+ """
75
+ if not self.require_approval or confirm:
76
+ return
77
+ raise ApprovalRequired(action, reason)
78
+
79
+ def assert_agent_may_act(self) -> None:
80
+ """Block agent-driven mutations while the user holds a takeover."""
81
+ if self.takeover:
82
+ raise TakeoverActive(self.takeover_reason or "User has taken over the session.")
lyra_browser/audit.py ADDED
@@ -0,0 +1,80 @@
1
+ """Append-only audit trail.
2
+
3
+ Every tool invocation is recorded as one JSON line so a user can review exactly
4
+ what the agent did in their browser. This is a hard requirement for a tool that
5
+ acts inside a session sharing the user's logins.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ class AuditLog:
18
+ def __init__(self, path: Path, *, mirror_stderr: bool = False) -> None:
19
+ self._path = path
20
+ # Each record is also written, unprefixed, as one JSON line on stderr —
21
+ # for hosts whose disk is ephemeral but whose stderr is collected.
22
+ self._mirror_stderr = mirror_stderr
23
+
24
+ def record(
25
+ self,
26
+ tool: str,
27
+ args: dict[str, Any] | None = None,
28
+ status: str = "ok",
29
+ detail: str | None = None,
30
+ origin: str | None = None,
31
+ initiator: str | None = None,
32
+ ) -> None:
33
+ """Append a single audit entry. Best-effort: never raises into a tool call.
34
+
35
+ ``origin`` is where the action points and ``initiator`` is where it came
36
+ from. Reconstructing an incident needs both: a request to a bank is
37
+ ordinary from the bank's own page and alarming from anywhere else.
38
+ """
39
+ entry: dict[str, Any] = {
40
+ "ts": datetime.now(UTC).isoformat(),
41
+ "tool": tool,
42
+ "args": _redact(args or {}),
43
+ "status": status,
44
+ "detail": detail,
45
+ }
46
+ # Only present when known, so existing entries keep their shape.
47
+ if origin is not None:
48
+ entry["origin"] = origin
49
+ if initiator is not None:
50
+ entry["initiator"] = initiator
51
+ line = json.dumps(entry, ensure_ascii=False)
52
+ try:
53
+ self._path.parent.mkdir(parents=True, exist_ok=True)
54
+ with self._path.open("a", encoding="utf-8") as fh:
55
+ fh.write(line + "\n")
56
+ except OSError:
57
+ # Auditing must not break the agent loop; surfacing happens elsewhere.
58
+ pass
59
+ if self._mirror_stderr:
60
+ try:
61
+ print(line, file=sys.stderr, flush=True)
62
+ except (OSError, ValueError):
63
+ pass
64
+
65
+
66
+ # Argument keys whose values should never hit the on-disk log verbatim.
67
+ # ``post_data`` is here because the enforcement layer sees raw form bodies, and
68
+ # that is exactly where a submitted password lives.
69
+ _SENSITIVE_KEYS = {"password", "secret", "token", "value", "post_data"}
70
+
71
+
72
+ def _redact(args: dict[str, Any]) -> dict[str, Any]:
73
+ """Redact values that may carry credentials typed into form fields."""
74
+ out: dict[str, Any] = {}
75
+ for key, val in args.items():
76
+ if key.lower() in _SENSITIVE_KEYS and isinstance(val, str):
77
+ out[key] = f"<redacted:{len(val)} chars>"
78
+ else:
79
+ out[key] = val
80
+ return out
@@ -0,0 +1,129 @@
1
+ """Captures: screenshots and element images written to disk for the model.
2
+
3
+ A capture is handed over as a *file*, not as base64 inside the tool result.
4
+ Measured on VEGA: the client concatenates the text blocks of a tool result and
5
+ shows the model that string, so a base64 PNG arrives as tens of thousands of
6
+ tokens of prose and no picture. VEGA does have an image path — a local file
7
+ under its uploads root is attached to the next model turn as an image block —
8
+ and the in-app browser runs on the same machine, so a file on disk is the one
9
+ handoff both surfaces can use without a protocol in between.
10
+
11
+ Files are named so they sort by time, written atomically so a reader never
12
+ sees a partial PNG, and pruned oldest-first so a long session cannot fill the
13
+ disk one screenshot per turn.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import os
20
+ import struct
21
+ import uuid
22
+ from collections.abc import Awaitable, Callable
23
+ from datetime import UTC, datetime
24
+ from pathlib import Path
25
+
26
+ from .config import Config
27
+
28
+ MIME_PNG = "image/png"
29
+ _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
30
+
31
+ # Chrome answers Page.captureScreenshot with this while the renderer has not
32
+ # presented a frame yet. Measured with Chrome 152 headless on a fresh context:
33
+ # the first capture after a navigation to example.com fails, and the same call
34
+ # ~1s later succeeds. It is a transient, not a broken page, so it is retried.
35
+ _NO_FRAME_YET = "Unable to capture screenshot"
36
+ _RETRY_DELAY_S = 0.15
37
+ _RETRY_BUDGET_S = 3.0
38
+
39
+
40
+ async def take(shoot: Callable[[], Awaitable[bytes]]) -> bytes:
41
+ """Run ``shoot`` and retry the no-frame-yet transient within a small budget.
42
+
43
+ Any other failure — element detached, page closed — is raised as it is;
44
+ only the one message Chrome uses for "nothing painted yet" is retried, and
45
+ even that gives up after ``_RETRY_BUDGET_S`` so a page that never paints
46
+ still errors instead of hanging the tool.
47
+ """
48
+ deadline = asyncio.get_running_loop().time() + _RETRY_BUDGET_S
49
+ while True:
50
+ try:
51
+ return await shoot()
52
+ except Exception as exc: # noqa: BLE001 — filtered on the message below
53
+ if _NO_FRAME_YET not in str(exc) or asyncio.get_running_loop().time() >= deadline:
54
+ raise
55
+ await asyncio.sleep(_RETRY_DELAY_S)
56
+
57
+
58
+ def png_size(png: bytes) -> tuple[int, int] | None:
59
+ """Width and height from the IHDR chunk, or None when this is not a PNG.
60
+
61
+ The IHDR chunk is mandatory and always first, so the dimensions sit at a
62
+ fixed offset: signature (8) + length (4) + type (4) = 16.
63
+ """
64
+ if len(png) < 24 or not png.startswith(_PNG_SIGNATURE) or png[12:16] != b"IHDR":
65
+ return None
66
+ width, height = struct.unpack(">II", png[16:24])
67
+ return width, height
68
+
69
+
70
+ def save_capture(config: Config, png: bytes, kind: str) -> Path:
71
+ """Write ``png`` under the capture dir and return its path.
72
+
73
+ ``kind`` names what was captured (``page``, ``element``) and leads the file
74
+ name so a directory listing reads as a log. The write goes through a temp
75
+ file and ``os.replace`` so a concurrent reader — VEGA attaching the image
76
+ while we are still writing — cannot observe a truncated file.
77
+ """
78
+ directory = config.capture_dir
79
+ directory.mkdir(parents=True, exist_ok=True)
80
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
81
+ final = directory / f"{kind}-{stamp}-{uuid.uuid4().hex[:8]}.png"
82
+ tmp = final.with_suffix(".png.part")
83
+ with tmp.open("wb") as handle:
84
+ handle.write(png)
85
+ os.replace(tmp, final)
86
+ _prune(directory, keep=config.capture_keep)
87
+ return final
88
+
89
+
90
+ def _prune(directory: Path, keep: int) -> None:
91
+ """Delete the oldest captures beyond ``keep``. ``keep <= 0`` keeps everything.
92
+
93
+ Ordered by name, not mtime: names carry a UTC timestamp, and mtime is what
94
+ a copy or a backup restore rewrites.
95
+ """
96
+ if keep <= 0:
97
+ return
98
+ files = sorted(p for p in directory.glob("*.png") if p.is_file())
99
+ for stale in files[: max(0, len(files) - keep)]:
100
+ try:
101
+ stale.unlink()
102
+ except OSError:
103
+ # Someone else's problem — a capture we cannot delete is not a
104
+ # reason to fail the one we just took.
105
+ pass
106
+
107
+
108
+ def envelope(path: Path, png: bytes, *, url: str, inline: bool) -> dict:
109
+ """The tool result for a capture.
110
+
111
+ The path is the handoff. ``inline`` adds the base64 for a client with no
112
+ access to this filesystem — it is opt-in because on the client that does
113
+ (VEGA) the string would be shown to the model as text.
114
+ """
115
+ result: dict = {
116
+ "status": "ok",
117
+ "url": url,
118
+ "image_path": str(path),
119
+ "mime_type": MIME_PNG,
120
+ "bytes": len(png),
121
+ }
122
+ size = png_size(png)
123
+ if size is not None:
124
+ result["width"], result["height"] = size
125
+ if inline:
126
+ import base64
127
+
128
+ result["base64"] = base64.b64encode(png).decode("ascii")
129
+ return result