dryhack-mcp 2.2.0__tar.gz → 2.3.1__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. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/PKG-INFO +20 -10
  2. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/README.md +18 -9
  3. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp/__init__.py +1 -1
  4. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp/config.py +16 -0
  5. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp/tools/browser_tool.py +82 -11
  6. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp.egg-info/PKG-INFO +20 -10
  7. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp.egg-info/requires.txt +1 -0
  8. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/pyproject.toml +4 -1
  9. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/tests/test_browser.py +71 -2
  10. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/LICENSE +0 -0
  11. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp/__main__.py +0 -0
  12. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp/server.py +0 -0
  13. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp/tools/__init__.py +0 -0
  14. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp/tools/authorize.py +0 -0
  15. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp/tools/curl_tool.py +0 -0
  16. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp/tools/exec.py +0 -0
  17. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp/tools/python_tool.py +0 -0
  18. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp/tools/scope.py +0 -0
  19. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp/tools/shell_tool.py +0 -0
  20. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp.egg-info/SOURCES.txt +0 -0
  21. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp.egg-info/dependency_links.txt +0 -0
  22. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp.egg-info/entry_points.txt +0 -0
  23. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/dryhack_mcp.egg-info/top_level.txt +0 -0
  24. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/setup.cfg +0 -0
  25. {dryhack_mcp-2.2.0 → dryhack_mcp-2.3.1}/tests/test_authorize.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dryhack-mcp
3
- Version: 2.2.0
3
+ Version: 2.3.1
4
4
  Summary: DryHack-MCP - an MCP server providing offensive-security tooling (curl, python, shell, Playwright browser) and a scope-gated authorize engine for authorized penetration testing.
5
5
  Author: Afrizal F.A
6
6
  License: MIT License
@@ -36,6 +36,7 @@ Requires-Python: >=3.9
36
36
  Description-Content-Type: text/markdown
37
37
  License-File: LICENSE
38
38
  Requires-Dist: mcp<2,>=1.2.0
39
+ Requires-Dist: playwright>=1.40
39
40
  Provides-Extra: dev
40
41
  Requires-Dist: pytest>=7.0; extra == "dev"
41
42
  Requires-Dist: ruff>=0.1; extra == "dev"
@@ -88,16 +89,21 @@ python3 -m pip install -e ".[dev]"
88
89
 
89
90
  ### Browser tool (Playwright)
90
91
 
91
- The `browser` tool needs the optional `browser` extra plus the Playwright
92
- browser binaries:
92
+ Playwright is a **core dependency**, so a plain `pip install dryhack-mcp` /
93
+ `uvx dryhack-mcp` already ships it — no extra needed. The browser *binaries*
94
+ (Chromium/Firefox/WebKit) are not distributed via pip, so the first time the
95
+ `browser` tool launches an engine it **auto-runs `playwright install <browser>`**
96
+ if the binary is missing.
97
+
98
+ To pre-install the binaries (optional, avoids the one-time first-use download):
93
99
 
94
100
  ```bash
95
- python3 -m pip install 'dryhack-mcp[browser]'
96
- playwright install # or: playwright install chromium
101
+ playwright install # all engines
102
+ playwright install chromium # just chromium
97
103
  ```
98
104
 
99
- If Playwright is not installed, the `browser` tool returns an install hint
100
- instead of failing — the rest of the server keeps working.
105
+ Auto-install is controlled by `DRYHACK_BROWSER_AUTO_INSTALL` (default on); set it
106
+ to `0` to disable and manage browsers yourself.
101
107
 
102
108
  ## Run
103
109
 
@@ -181,6 +187,8 @@ Start the server separately with `dryhack-mcp --transport http`.
181
187
  | `DRYHACK_COMMAND_TIMEOUT` | `120` | Per-command timeout (seconds) |
182
188
  | `DRYHACK_WORKDIR` | cwd | Working directory for commands |
183
189
  | `DRYHACK_OUTPUT_LIMIT` | `65536` | Max stdout/stderr bytes captured |
190
+ | `DRYHACK_BROWSER_AUTO_INSTALL` | `1` | Auto-download the browser binary on first use (`browser` tool) |
191
+ | `DRYHACK_BROWSER_INSTALL_TIMEOUT` | `900` | Timeout (s) for the on-demand `playwright install` download |
184
192
  | `DRYHACK_HTTP_HOST` | `127.0.0.1` | Default HTTP bind host |
185
193
  | `DRYHACK_HTTP_PORT` | `8000` | Default HTTP bind port |
186
194
 
@@ -236,9 +244,11 @@ browser(action="goto", url="https://target.example/",
236
244
  init_script="Object.defineProperty(navigator,'webdriver',{get:()=>false})")
237
245
  ```
238
246
 
239
- Playwright runs in a dedicated worker thread (the MCP server owns the asyncio
240
- loop, and Playwright's sync API cannot run inside a live loop). The same
241
- authorized-use scope rules as the other tools apply.
247
+ Playwright ships with dryhack-mcp and runs in a dedicated worker thread (the MCP
248
+ server owns the asyncio loop, and Playwright's sync API cannot run inside a live
249
+ loop). The browser binary auto-installs on first use if missing
250
+ (`DRYHACK_BROWSER_AUTO_INSTALL`, default on). The same authorized-use scope
251
+ rules as the other tools apply.
242
252
 
243
253
  ## authorize (per-call scope)
244
254
 
@@ -43,16 +43,21 @@ python3 -m pip install -e ".[dev]"
43
43
 
44
44
  ### Browser tool (Playwright)
45
45
 
46
- The `browser` tool needs the optional `browser` extra plus the Playwright
47
- browser binaries:
46
+ Playwright is a **core dependency**, so a plain `pip install dryhack-mcp` /
47
+ `uvx dryhack-mcp` already ships it — no extra needed. The browser *binaries*
48
+ (Chromium/Firefox/WebKit) are not distributed via pip, so the first time the
49
+ `browser` tool launches an engine it **auto-runs `playwright install <browser>`**
50
+ if the binary is missing.
51
+
52
+ To pre-install the binaries (optional, avoids the one-time first-use download):
48
53
 
49
54
  ```bash
50
- python3 -m pip install 'dryhack-mcp[browser]'
51
- playwright install # or: playwright install chromium
55
+ playwright install # all engines
56
+ playwright install chromium # just chromium
52
57
  ```
53
58
 
54
- If Playwright is not installed, the `browser` tool returns an install hint
55
- instead of failing — the rest of the server keeps working.
59
+ Auto-install is controlled by `DRYHACK_BROWSER_AUTO_INSTALL` (default on); set it
60
+ to `0` to disable and manage browsers yourself.
56
61
 
57
62
  ## Run
58
63
 
@@ -136,6 +141,8 @@ Start the server separately with `dryhack-mcp --transport http`.
136
141
  | `DRYHACK_COMMAND_TIMEOUT` | `120` | Per-command timeout (seconds) |
137
142
  | `DRYHACK_WORKDIR` | cwd | Working directory for commands |
138
143
  | `DRYHACK_OUTPUT_LIMIT` | `65536` | Max stdout/stderr bytes captured |
144
+ | `DRYHACK_BROWSER_AUTO_INSTALL` | `1` | Auto-download the browser binary on first use (`browser` tool) |
145
+ | `DRYHACK_BROWSER_INSTALL_TIMEOUT` | `900` | Timeout (s) for the on-demand `playwright install` download |
139
146
  | `DRYHACK_HTTP_HOST` | `127.0.0.1` | Default HTTP bind host |
140
147
  | `DRYHACK_HTTP_PORT` | `8000` | Default HTTP bind port |
141
148
 
@@ -191,9 +198,11 @@ browser(action="goto", url="https://target.example/",
191
198
  init_script="Object.defineProperty(navigator,'webdriver',{get:()=>false})")
192
199
  ```
193
200
 
194
- Playwright runs in a dedicated worker thread (the MCP server owns the asyncio
195
- loop, and Playwright's sync API cannot run inside a live loop). The same
196
- authorized-use scope rules as the other tools apply.
201
+ Playwright ships with dryhack-mcp and runs in a dedicated worker thread (the MCP
202
+ server owns the asyncio loop, and Playwright's sync API cannot run inside a live
203
+ loop). The browser binary auto-installs on first use if missing
204
+ (`DRYHACK_BROWSER_AUTO_INSTALL`, default on). The same authorized-use scope
205
+ rules as the other tools apply.
197
206
 
198
207
  ## authorize (per-call scope)
199
208
 
@@ -1,3 +1,3 @@
1
1
  """DryHack-MCP: offensive-security MCP server for authorized penetration testing."""
2
2
 
3
- __version__ = "2.2.0"
3
+ __version__ = "2.3.1"
@@ -23,6 +23,22 @@ WORKDIR = _get("DRYHACK_WORKDIR", os.getcwd())
23
23
  OUTPUT_BYTE_LIMIT = int(_get("DRYHACK_OUTPUT_LIMIT", "65536") or "65536")
24
24
 
25
25
 
26
+ # ---- Browser tool (Playwright) -----------------------------------------------
27
+ # Auto-download the requested browser binary on first use if it is missing
28
+ # (the binaries are not shipped via pip). Disable by setting to 0/false/no.
29
+ BROWSER_AUTO_INSTALL = _get("DRYHACK_BROWSER_AUTO_INSTALL", "1").lower() not in (
30
+ "0",
31
+ "false",
32
+ "no",
33
+ "off",
34
+ )
35
+
36
+ # Timeout (seconds) for the on-demand `playwright install <browser>` download.
37
+ BROWSER_INSTALL_TIMEOUT = int(
38
+ _get("DRYHACK_BROWSER_INSTALL_TIMEOUT", "900") or "900"
39
+ )
40
+
41
+
26
42
  # ---- Transport ---------------------------------------------------------------
27
43
  # Default HTTP bind address when running in http mode.
28
44
  HTTP_HOST = _get("DRYHACK_HTTP_HOST", "127.0.0.1")
@@ -6,10 +6,13 @@ and exploitation: navigation, DOM interaction, arbitrary JavaScript injection
6
6
  file upload, cookie/header/storage manipulation, screenshots, PDF capture and
7
7
  multi-step action chains — all in a single MCP call.
8
8
 
9
- Playwright is an *optional* dependency (installed via the ``browser`` extra:
10
- ``pip install 'dryhack-mcp[browser]'`` followed by ``playwright install``). It is
11
- imported lazily so the core server keeps depending only on ``mcp``; if it is not
12
- installed the tool returns an actionable install hint instead of crashing.
9
+ Playwright is a bundled runtime dependency, so a plain ``pip install
10
+ dryhack-mcp`` / ``uvx dryhack-mcp`` already ships it. The browser *binaries*
11
+ (Chromium/Firefox/WebKit) are not distributed via pip, so on first use the tool
12
+ auto-runs ``playwright install <browser>`` when the requested binary is missing
13
+ (controlled by ``DRYHACK_BROWSER_AUTO_INSTALL``, default on). Playwright is
14
+ still imported lazily and, if it is somehow absent, the tool returns an
15
+ actionable install hint instead of crashing.
13
16
 
14
17
  Playwright's sync API cannot run inside a live asyncio event loop (the MCP
15
18
  server runs one), so every browser session is executed in a dedicated worker
@@ -424,6 +427,65 @@ def _build_context_options(session: Dict[str, Any]) -> Dict[str, Any]:
424
427
  return opts
425
428
 
426
429
 
430
+ def _auto_install_browser(browser_name: str) -> Tuple[bool, str]:
431
+ """Best-effort ``playwright install <browser>`` for the current interpreter.
432
+
433
+ The browser binaries are not shipped via pip, so the first time a given
434
+ engine is used we download it on demand. Returns (ok, log_tail).
435
+ """
436
+ import subprocess
437
+ import sys
438
+
439
+ try:
440
+ proc = subprocess.run(
441
+ [sys.executable, "-m", "playwright", "install", browser_name],
442
+ capture_output=True,
443
+ text=True,
444
+ timeout=config.BROWSER_INSTALL_TIMEOUT,
445
+ )
446
+ log = ((proc.stdout or "") + (proc.stderr or "")).strip()
447
+ return proc.returncode == 0, log[-1500:]
448
+ except Exception as exc: # noqa: BLE001 - report, don't crash
449
+ return False, f"{type(exc).__name__}: {exc}"
450
+
451
+
452
+ def _launch_browser(
453
+ browser_type,
454
+ browser_name: str,
455
+ launch_kwargs: Dict[str, Any],
456
+ session: Optional[Dict[str, Any]] = None,
457
+ ):
458
+ """Launch the browser, auto-installing its binary on first use if missing.
459
+
460
+ On a successful on-demand install, records a human-readable note in
461
+ ``session["_install_note"]`` so the caller can surface the one-time download
462
+ in the tool output (otherwise the first call looks like a silent hang).
463
+ """
464
+ try:
465
+ return browser_type.launch(**launch_kwargs)
466
+ except Exception as exc: # noqa: BLE001
467
+ msg = str(exc)
468
+ binary_missing = (
469
+ "Executable doesn't exist" in msg
470
+ or "playwright install" in msg.lower()
471
+ or "please run the following command" in msg.lower()
472
+ )
473
+ if binary_missing and config.BROWSER_AUTO_INSTALL:
474
+ ok, log = _auto_install_browser(browser_name)
475
+ if ok:
476
+ if session is not None:
477
+ session["_install_note"] = (
478
+ f"auto-installed the '{browser_name}' browser binary on "
479
+ "first use (one-time download)"
480
+ )
481
+ return browser_type.launch(**launch_kwargs)
482
+ raise RuntimeError(
483
+ f"{browser_name} binary missing and auto-install failed. "
484
+ f"Run `playwright install {browser_name}` manually.\n{log}"
485
+ ) from exc
486
+ raise
487
+
488
+
427
489
  def _run_session(
428
490
  steps: List[Dict[str, Any]],
429
491
  session: Dict[str, Any],
@@ -434,16 +496,19 @@ def _run_session(
434
496
  except ImportError:
435
497
  return (
436
498
  [],
437
- "playwright is not installed. Install the optional extra:\n"
438
- " pip install 'dryhack-mcp[browser]'\n"
439
- " playwright install",
499
+ "playwright is not importable (it ships with dryhack-mcp). "
500
+ "Reinstall with:\n"
501
+ " pip install --force-reinstall dryhack-mcp\n"
502
+ "or install directly:\n"
503
+ " pip install 'playwright>=1.40' && playwright install",
440
504
  )
441
505
 
442
506
  results: List[Dict[str, Any]] = []
443
507
  default_timeout = session.get("timeout")
444
508
 
445
509
  with sync_playwright() as pw:
446
- browser_type = getattr(pw, session.get("browser_type", "chromium"))
510
+ browser_name = session.get("browser_type", "chromium")
511
+ browser_type = getattr(pw, browser_name)
447
512
  launch_kwargs: Dict[str, Any] = {"headless": session.get("headless", True)}
448
513
  if session.get("slow_mo") is not None:
449
514
  launch_kwargs["slow_mo"] = session["slow_mo"]
@@ -454,7 +519,7 @@ def _run_session(
454
519
  if session.get("proxy") is not None:
455
520
  launch_kwargs["proxy"] = session["proxy"]
456
521
 
457
- browser = browser_type.launch(**launch_kwargs)
522
+ browser = _launch_browser(browser_type, browser_name, launch_kwargs, session)
458
523
  try:
459
524
  context = browser.new_context(**_build_context_options(session))
460
525
  if default_timeout is not None:
@@ -510,8 +575,11 @@ def _run_session_threaded(
510
575
 
511
576
  thread = threading.Thread(target=_worker, daemon=True)
512
577
  thread.start()
513
- # Generous ceiling so a per-step Playwright timeout fires first.
514
- thread.join(timeout=(session.get("timeout") or config.COMMAND_TIMEOUT * 1000) / 1000 + 60)
578
+ # Generous ceiling so a per-step Playwright timeout fires first. The extra
579
+ # allowance covers a possible first-use `playwright install` download.
580
+ base = (session.get("timeout") or config.COMMAND_TIMEOUT * 1000) / 1000
581
+ install_allowance = config.BROWSER_INSTALL_TIMEOUT if config.BROWSER_AUTO_INSTALL else 60
582
+ thread.join(timeout=base + install_allowance)
515
583
  if thread.is_alive():
516
584
  return [], "browser session exceeded the overall time budget"
517
585
  return box.get("results", []), box.get("error")
@@ -637,6 +705,9 @@ def browser(
637
705
  f"$ browser[{session['browser_type']}, "
638
706
  f"headless={session['headless']}] {len(normalized)} step(s)"
639
707
  )
708
+ install_note = session.get("_install_note")
709
+ if install_note:
710
+ header = f"{header}\n[setup] {install_note}"
640
711
  if error:
641
712
  return f"{header}\n[error] {error}"
642
713
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dryhack-mcp
3
- Version: 2.2.0
3
+ Version: 2.3.1
4
4
  Summary: DryHack-MCP - an MCP server providing offensive-security tooling (curl, python, shell, Playwright browser) and a scope-gated authorize engine for authorized penetration testing.
5
5
  Author: Afrizal F.A
6
6
  License: MIT License
@@ -36,6 +36,7 @@ Requires-Python: >=3.9
36
36
  Description-Content-Type: text/markdown
37
37
  License-File: LICENSE
38
38
  Requires-Dist: mcp<2,>=1.2.0
39
+ Requires-Dist: playwright>=1.40
39
40
  Provides-Extra: dev
40
41
  Requires-Dist: pytest>=7.0; extra == "dev"
41
42
  Requires-Dist: ruff>=0.1; extra == "dev"
@@ -88,16 +89,21 @@ python3 -m pip install -e ".[dev]"
88
89
 
89
90
  ### Browser tool (Playwright)
90
91
 
91
- The `browser` tool needs the optional `browser` extra plus the Playwright
92
- browser binaries:
92
+ Playwright is a **core dependency**, so a plain `pip install dryhack-mcp` /
93
+ `uvx dryhack-mcp` already ships it — no extra needed. The browser *binaries*
94
+ (Chromium/Firefox/WebKit) are not distributed via pip, so the first time the
95
+ `browser` tool launches an engine it **auto-runs `playwright install <browser>`**
96
+ if the binary is missing.
97
+
98
+ To pre-install the binaries (optional, avoids the one-time first-use download):
93
99
 
94
100
  ```bash
95
- python3 -m pip install 'dryhack-mcp[browser]'
96
- playwright install # or: playwright install chromium
101
+ playwright install # all engines
102
+ playwright install chromium # just chromium
97
103
  ```
98
104
 
99
- If Playwright is not installed, the `browser` tool returns an install hint
100
- instead of failing — the rest of the server keeps working.
105
+ Auto-install is controlled by `DRYHACK_BROWSER_AUTO_INSTALL` (default on); set it
106
+ to `0` to disable and manage browsers yourself.
101
107
 
102
108
  ## Run
103
109
 
@@ -181,6 +187,8 @@ Start the server separately with `dryhack-mcp --transport http`.
181
187
  | `DRYHACK_COMMAND_TIMEOUT` | `120` | Per-command timeout (seconds) |
182
188
  | `DRYHACK_WORKDIR` | cwd | Working directory for commands |
183
189
  | `DRYHACK_OUTPUT_LIMIT` | `65536` | Max stdout/stderr bytes captured |
190
+ | `DRYHACK_BROWSER_AUTO_INSTALL` | `1` | Auto-download the browser binary on first use (`browser` tool) |
191
+ | `DRYHACK_BROWSER_INSTALL_TIMEOUT` | `900` | Timeout (s) for the on-demand `playwright install` download |
184
192
  | `DRYHACK_HTTP_HOST` | `127.0.0.1` | Default HTTP bind host |
185
193
  | `DRYHACK_HTTP_PORT` | `8000` | Default HTTP bind port |
186
194
 
@@ -236,9 +244,11 @@ browser(action="goto", url="https://target.example/",
236
244
  init_script="Object.defineProperty(navigator,'webdriver',{get:()=>false})")
237
245
  ```
238
246
 
239
- Playwright runs in a dedicated worker thread (the MCP server owns the asyncio
240
- loop, and Playwright's sync API cannot run inside a live loop). The same
241
- authorized-use scope rules as the other tools apply.
247
+ Playwright ships with dryhack-mcp and runs in a dedicated worker thread (the MCP
248
+ server owns the asyncio loop, and Playwright's sync API cannot run inside a live
249
+ loop). The browser binary auto-installs on first use if missing
250
+ (`DRYHACK_BROWSER_AUTO_INSTALL`, default on). The same authorized-use scope
251
+ rules as the other tools apply.
242
252
 
243
253
  ## authorize (per-call scope)
244
254
 
@@ -1,4 +1,5 @@
1
1
  mcp<2,>=1.2.0
2
+ playwright>=1.40
2
3
 
3
4
  [browser]
4
5
  playwright>=1.40
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dryhack-mcp"
7
- version = "2.2.0"
7
+ version = "2.3.1"
8
8
  description = "DryHack-MCP - an MCP server providing offensive-security tooling (curl, python, shell, Playwright browser) and a scope-gated authorize engine for authorized penetration testing."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -19,10 +19,13 @@ classifiers = [
19
19
  ]
20
20
  dependencies = [
21
21
  "mcp>=1.2.0,<2",
22
+ "playwright>=1.40",
22
23
  ]
23
24
 
24
25
  [project.optional-dependencies]
25
26
  dev = ["pytest>=7.0", "ruff>=0.1"]
27
+ # Kept as a back-compat alias; Playwright is now a core dependency, so the
28
+ # `browser` tool works out of the box with a plain install.
26
29
  browser = ["playwright>=1.40"]
27
30
 
28
31
  [project.scripts]
@@ -36,8 +36,8 @@ class BrowserToolTests(unittest.TestCase):
36
36
  result = browser(action="goto", url="https://example.com")
37
37
  finally:
38
38
  builtins.__import__ = real_import
39
- self.assertIn("playwright is not installed", result)
40
- self.assertIn("dryhack-mcp[browser]", result)
39
+ self.assertIn("playwright is not importable", result)
40
+ self.assertIn("playwright", result.lower())
41
41
 
42
42
  def test_single_action_maps_convenience_params(self):
43
43
  captured = {}
@@ -93,5 +93,74 @@ class BrowserToolTests(unittest.TestCase):
93
93
  )
94
94
 
95
95
 
96
+ def test_launch_auto_installs_missing_binary_then_retries(self):
97
+ calls = {"launch": 0, "install": 0}
98
+
99
+ class FakeBrowserType:
100
+ def launch(self, **kwargs):
101
+ calls["launch"] += 1
102
+ if calls["launch"] == 1:
103
+ raise RuntimeError(
104
+ "Executable doesn't exist at /path/chrome. "
105
+ "Please run the following command: playwright install"
106
+ )
107
+ return "browser-handle"
108
+
109
+ def fake_install(browser_name):
110
+ calls["install"] += 1
111
+ return True, "ok"
112
+
113
+ original = browser_tool._auto_install_browser
114
+ browser_tool._auto_install_browser = fake_install
115
+ original_flag = browser_tool.config.BROWSER_AUTO_INSTALL
116
+ browser_tool.config.BROWSER_AUTO_INSTALL = True
117
+ try:
118
+ handle = browser_tool._launch_browser(FakeBrowserType(), "chromium", {})
119
+ finally:
120
+ browser_tool._auto_install_browser = original
121
+ browser_tool.config.BROWSER_AUTO_INSTALL = original_flag
122
+
123
+ self.assertEqual(handle, "browser-handle")
124
+ self.assertEqual(calls["launch"], 2) # failed once, retried once
125
+ self.assertEqual(calls["install"], 1)
126
+
127
+ def test_launch_records_install_note_in_session(self):
128
+ state = {"launch": 0}
129
+
130
+ class FakeBrowserType:
131
+ def launch(self, **kwargs):
132
+ state["launch"] += 1
133
+ if state["launch"] == 1:
134
+ raise RuntimeError("Executable doesn't exist; playwright install")
135
+ return "handle"
136
+
137
+ session = {}
138
+ original = browser_tool._auto_install_browser
139
+ browser_tool._auto_install_browser = lambda name: (True, "ok")
140
+ original_flag = browser_tool.config.BROWSER_AUTO_INSTALL
141
+ browser_tool.config.BROWSER_AUTO_INSTALL = True
142
+ try:
143
+ browser_tool._launch_browser(FakeBrowserType(), "chromium", {}, session)
144
+ finally:
145
+ browser_tool._auto_install_browser = original
146
+ browser_tool.config.BROWSER_AUTO_INSTALL = original_flag
147
+
148
+ self.assertIn("_install_note", session)
149
+ self.assertIn("chromium", session["_install_note"])
150
+
151
+ def test_launch_does_not_autoinstall_when_disabled(self):
152
+ class FakeBrowserType:
153
+ def launch(self, **kwargs):
154
+ raise RuntimeError("Executable doesn't exist; playwright install")
155
+
156
+ original_flag = browser_tool.config.BROWSER_AUTO_INSTALL
157
+ browser_tool.config.BROWSER_AUTO_INSTALL = False
158
+ try:
159
+ with self.assertRaises(RuntimeError):
160
+ browser_tool._launch_browser(FakeBrowserType(), "chromium", {})
161
+ finally:
162
+ browser_tool.config.BROWSER_AUTO_INSTALL = original_flag
163
+
164
+
96
165
  if __name__ == "__main__":
97
166
  unittest.main()
File without changes
File without changes