webget-cli 0.7.3__tar.gz → 0.9.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.
- {webget_cli-0.7.3/webget_cli.egg-info → webget_cli-0.9.0}/PKG-INFO +32 -4
- {webget_cli-0.7.3 → webget_cli-0.9.0}/README.md +31 -3
- {webget_cli-0.7.3 → webget_cli-0.9.0}/pyproject.toml +1 -1
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_adversarial_http.py +21 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_adversarial_mcp.py +5 -5
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_adversarial_ssrf.py +3 -3
- webget_cli-0.9.0/tests/test_login_flow.py +180 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_mcp_leak_review.py +2 -2
- webget_cli-0.9.0/tests/test_mcp_profile.py +177 -0
- webget_cli-0.9.0/tests/test_mcp_server.py +201 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_security_review.py +6 -6
- webget_cli-0.9.0/tests/test_size_review.py +109 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_webget.py +172 -7
- {webget_cli-0.7.3 → webget_cli-0.9.0/webget_cli.egg-info}/PKG-INFO +32 -4
- {webget_cli-0.7.3 → webget_cli-0.9.0}/webget_cli.egg-info/SOURCES.txt +2 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/webget_cli.py +438 -64
- {webget_cli-0.7.3 → webget_cli-0.9.0}/webget_mcp.py +99 -0
- webget_cli-0.7.3/tests/test_mcp_server.py +0 -102
- webget_cli-0.7.3/tests/test_size_review.py +0 -80
- {webget_cli-0.7.3 → webget_cli-0.9.0}/LICENSE +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/setup.cfg +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_adversarial_auth.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_adversarial_cache.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_adversarial_concurrency.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_auth_review.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_browser_ssrf.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_cache_review.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_concurrency_review.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_extraction_markdown.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_firecrawl_policy.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_integration_ladder.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/tests/test_mcp_smoke.py +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/webget_cli.egg-info/dependency_links.txt +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/webget_cli.egg-info/entry_points.txt +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/webget_cli.egg-info/requires.txt +0 -0
- {webget_cli-0.7.3 → webget_cli-0.9.0}/webget_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webget-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys.
|
|
5
5
|
Author-email: David Tarigan <tarigansdavid@gmail.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -244,9 +244,37 @@ Register as a local MCP server in opencode:
|
|
|
244
244
|
Then prompt with `use webget` for search and scrape tasks. Run the server
|
|
245
245
|
standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
246
246
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
247
|
+
### Authenticated sessions (profiles)
|
|
248
|
+
|
|
249
|
+
MCP tools can use locally stored login sessions. Create one first with the
|
|
250
|
+
CLI, or let the agent create it via the MCP `login` tool:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
webget login https://portal.example.com --profile portal
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Then the agent can discover sessions and fetch authenticated pages:
|
|
257
|
+
|
|
258
|
+
- `list_profiles` - lists available sessions (name, last used, size,
|
|
259
|
+
status). Cookie values are never returned.
|
|
260
|
+
- `login(url, profile)` - opens a browser session (headful by default so a
|
|
261
|
+
human can log in), navigates to `url`, and persists the session once the
|
|
262
|
+
login handshake's cookies appear (or after `wait_seconds`, whichever
|
|
263
|
+
comes first). MCP stdin is the JSON-RPC stream, so there is no Enter
|
|
264
|
+
keypress; the flow polls for cookies instead.
|
|
265
|
+
- `fetch(..., profile="portal")` / `search_fetch(..., profile="portal")` -
|
|
266
|
+
scrape using that session.
|
|
267
|
+
|
|
268
|
+
```text
|
|
269
|
+
agent: "check my portal for new announcements"
|
|
270
|
+
1. list_profiles -> portal (authenticated)
|
|
271
|
+
2. login(https://portal.example.com, profile="portal") # if not listed
|
|
272
|
+
3. fetch(url, profile="portal")
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Invalid profile names and unknown profiles are hard errors (no silent
|
|
276
|
+
anonymous fallback). Sessions are local to the machine running the MCP
|
|
277
|
+
server.
|
|
250
278
|
|
|
251
279
|
## Development
|
|
252
280
|
|
|
@@ -210,9 +210,37 @@ Register as a local MCP server in opencode:
|
|
|
210
210
|
Then prompt with `use webget` for search and scrape tasks. Run the server
|
|
211
211
|
standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
212
212
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
### Authenticated sessions (profiles)
|
|
214
|
+
|
|
215
|
+
MCP tools can use locally stored login sessions. Create one first with the
|
|
216
|
+
CLI, or let the agent create it via the MCP `login` tool:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
webget login https://portal.example.com --profile portal
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Then the agent can discover sessions and fetch authenticated pages:
|
|
223
|
+
|
|
224
|
+
- `list_profiles` - lists available sessions (name, last used, size,
|
|
225
|
+
status). Cookie values are never returned.
|
|
226
|
+
- `login(url, profile)` - opens a browser session (headful by default so a
|
|
227
|
+
human can log in), navigates to `url`, and persists the session once the
|
|
228
|
+
login handshake's cookies appear (or after `wait_seconds`, whichever
|
|
229
|
+
comes first). MCP stdin is the JSON-RPC stream, so there is no Enter
|
|
230
|
+
keypress; the flow polls for cookies instead.
|
|
231
|
+
- `fetch(..., profile="portal")` / `search_fetch(..., profile="portal")` -
|
|
232
|
+
scrape using that session.
|
|
233
|
+
|
|
234
|
+
```text
|
|
235
|
+
agent: "check my portal for new announcements"
|
|
236
|
+
1. list_profiles -> portal (authenticated)
|
|
237
|
+
2. login(https://portal.example.com, profile="portal") # if not listed
|
|
238
|
+
3. fetch(url, profile="portal")
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Invalid profile names and unknown profiles are hard errors (no silent
|
|
242
|
+
anonymous fallback). Sessions are local to the machine running the MCP
|
|
243
|
+
server.
|
|
216
244
|
|
|
217
245
|
## Development
|
|
218
246
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webget-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.0"
|
|
8
8
|
description = "Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Adversarial HTTP tests: URL handling, status codes, redirects, bodies."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import time
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
7
|
|
|
@@ -63,6 +64,26 @@ class TestConnectionFailures:
|
|
|
63
64
|
res = asyncio.run(_fetch(server.url("/timeout?sec=5"), per_url_timeout=1))
|
|
64
65
|
assert _one(res)["status"] == "error"
|
|
65
66
|
|
|
67
|
+
def test_slow_drip_hits_wall_clock_deadline(self, fresh_cache):
|
|
68
|
+
"""A server that slow-drips the body must not exceed per_url_timeout.
|
|
69
|
+
|
|
70
|
+
httpx's own timeout bounds a single socket operation only, so a
|
|
71
|
+
drip of small chunks every 150ms (total ~6s for 40 chunks) would
|
|
72
|
+
keep the request alive far past a 2s budget. fetch_http enforces
|
|
73
|
+
an absolute deadline while streaming; this proves the URL errors
|
|
74
|
+
out in roughly the budget, not the full drip duration.
|
|
75
|
+
"""
|
|
76
|
+
server = fresh_cache
|
|
77
|
+
t0 = time.monotonic()
|
|
78
|
+
res = asyncio.run(
|
|
79
|
+
_fetch(server.url("/drip?n=40&delay=0.15"), per_url_timeout=2, strategy="http")
|
|
80
|
+
)
|
|
81
|
+
elapsed = time.monotonic() - t0
|
|
82
|
+
out = _one(res)
|
|
83
|
+
assert out["status"] == "error"
|
|
84
|
+
# Drip would take ~6s uncapped; deadline must cut it near 2s.
|
|
85
|
+
assert elapsed < 4.0, f"deadline not enforced: took {elapsed:.1f}s"
|
|
86
|
+
|
|
66
87
|
|
|
67
88
|
# ---------- status codes ----------
|
|
68
89
|
|
|
@@ -58,7 +58,7 @@ class TestMalformedArguments:
|
|
|
58
58
|
return res
|
|
59
59
|
|
|
60
60
|
res = _run(run())
|
|
61
|
-
assert res.
|
|
61
|
+
assert res.is_error
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
class TestInvalidURLs:
|
|
@@ -72,7 +72,7 @@ class TestInvalidURLs:
|
|
|
72
72
|
return res
|
|
73
73
|
|
|
74
74
|
res = _run(run())
|
|
75
|
-
assert "error" in res.content[0].text or res.
|
|
75
|
+
assert "error" in res.content[0].text or res.is_error
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
class TestRepeatedCalls:
|
|
@@ -86,7 +86,7 @@ class TestRepeatedCalls:
|
|
|
86
86
|
"fetch",
|
|
87
87
|
{"url": "https://example.com", "strategy": "http", "no_cache": True},
|
|
88
88
|
)
|
|
89
|
-
if res.
|
|
89
|
+
if res.is_error:
|
|
90
90
|
return "ERROR"
|
|
91
91
|
return "OK"
|
|
92
92
|
|
|
@@ -106,7 +106,7 @@ class TestRepeatedCalls:
|
|
|
106
106
|
for _ in range(5)
|
|
107
107
|
]
|
|
108
108
|
)
|
|
109
|
-
return [r.
|
|
109
|
+
return [r.is_error for r in results]
|
|
110
110
|
|
|
111
111
|
assert _run(run()) == [False] * 5
|
|
112
112
|
|
|
@@ -133,7 +133,7 @@ class TestInputCaps:
|
|
|
133
133
|
return res
|
|
134
134
|
|
|
135
135
|
res = _run(run())
|
|
136
|
-
assert "must be between" in res.content[0].text or res.
|
|
136
|
+
assert "must be between" in res.content[0].text or res.is_error
|
|
137
137
|
|
|
138
138
|
|
|
139
139
|
class TestToolFailureIsolation:
|
|
@@ -98,14 +98,14 @@ class TestSSRFBlocking:
|
|
|
98
98
|
the initial URL passed the guard (e.g. it was public)."""
|
|
99
99
|
import pytest
|
|
100
100
|
|
|
101
|
-
real = webget.
|
|
101
|
+
real = webget._private_ip_for
|
|
102
102
|
initial = server.url("/redirect-private")
|
|
103
103
|
|
|
104
104
|
def guarded(url):
|
|
105
105
|
# initial URL passes (as if public); every other URL checked for real
|
|
106
|
-
return real(url) if url != initial else
|
|
106
|
+
return real(url) if url != initial else None
|
|
107
107
|
|
|
108
|
-
monkeypatch.setattr(webget, "
|
|
108
|
+
monkeypatch.setattr(webget, "_private_ip_for", guarded)
|
|
109
109
|
with pytest.raises(webget.SSRFError):
|
|
110
110
|
asyncio.run(webget.fetch_http(initial, 2000, timeout=5))
|
|
111
111
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""MCP login tool + non-interactive login flow tests.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- _login_flow(interactive=False): polls browser context until a Set-Cookie
|
|
5
|
+
arrives (here: /set-cookie on the local test server), then persists a
|
|
6
|
+
storage_state.json the HTTP fast path can reuse.
|
|
7
|
+
- MCP login tool: validates URL/profile, runs the flow headless against
|
|
8
|
+
the local server, then fetch(profile=...) uses the stored session.
|
|
9
|
+
- login tool rejects bad URLs and unknown/invalid profiles.
|
|
10
|
+
|
|
11
|
+
The test server lives on 127.0.0.1, so WEBGET_ALLOW_PRIVATE=1 is required
|
|
12
|
+
for the subsequent authenticated fetch through the SSRF-guarded HTTP path.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
24
|
+
sys.path.insert(0, str(ROOT))
|
|
25
|
+
|
|
26
|
+
import webget_cli as webget
|
|
27
|
+
|
|
28
|
+
playwright = pytest.importorskip("playwright", reason="playwright not installed")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _profile_root(tmp_path):
|
|
32
|
+
return tmp_path / "profiles"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_login_flow_noninteractive_persists_session(server, tmp_path, monkeypatch):
|
|
36
|
+
"""Browser-less-enough path: headless Chromium navigates /set-cookie,
|
|
37
|
+
cookie polling sees the Set-Cookie side effect, storage_state persists,
|
|
38
|
+
and the HTTP fast path can reuse it on /cookie-gated."""
|
|
39
|
+
root = _profile_root(tmp_path)
|
|
40
|
+
monkeypatch.setattr(webget, "PROFILE_DIR", str(root))
|
|
41
|
+
monkeypatch.setenv("WEBGET_ALLOW_PRIVATE", "1")
|
|
42
|
+
|
|
43
|
+
asyncio.run(
|
|
44
|
+
webget._login_flow(
|
|
45
|
+
server.url("/set-cookie"),
|
|
46
|
+
"flowp",
|
|
47
|
+
headless=True,
|
|
48
|
+
wait_seconds=30,
|
|
49
|
+
interactive=False,
|
|
50
|
+
quiet=True,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
state_p = webget.profile_state_path("flowp")
|
|
55
|
+
assert os.path.exists(state_p)
|
|
56
|
+
state = json.loads(Path(state_p).read_text())
|
|
57
|
+
cookies = {c["name"]: c["value"] for c in state["cookies"]}
|
|
58
|
+
assert cookies.get("webget_session") == "1"
|
|
59
|
+
|
|
60
|
+
# HTTP fast path must now authenticate on the cookie-gated page.
|
|
61
|
+
res = asyncio.run(
|
|
62
|
+
webget.scrape_many(
|
|
63
|
+
[server.url("/cookie-gated")],
|
|
64
|
+
strategy="http",
|
|
65
|
+
no_cache=True,
|
|
66
|
+
profile="flowp",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
out = res[server.url("/cookie-gated")]
|
|
70
|
+
assert out["status"] == "success"
|
|
71
|
+
assert out["auth"]["authenticated"] is True
|
|
72
|
+
assert "you are authenticated" in out["markdown"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_login_flow_waits_until_cookie(server, tmp_path, monkeypatch):
|
|
76
|
+
"""A page that does NOT set a cookie still persists (best-effort), and a
|
|
77
|
+
slow Set-Cookie is picked up by polling before the deadline."""
|
|
78
|
+
root = _profile_root(tmp_path)
|
|
79
|
+
monkeypatch.setattr(webget, "PROFILE_DIR", str(root))
|
|
80
|
+
|
|
81
|
+
asyncio.run(
|
|
82
|
+
webget._login_flow(
|
|
83
|
+
server.url("/set-cookie"),
|
|
84
|
+
"slowp",
|
|
85
|
+
headless=True,
|
|
86
|
+
wait_seconds=30,
|
|
87
|
+
interactive=False,
|
|
88
|
+
quiet=True,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
state_p = webget.profile_state_path("slowp")
|
|
92
|
+
assert os.path.exists(state_p)
|
|
93
|
+
cookies = {c["name"] for c in json.loads(Path(state_p).read_text())["cookies"]}
|
|
94
|
+
assert "webget_session" in cookies
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _spawn_mcp(profile_root):
|
|
98
|
+
from mcp import StdioServerParameters
|
|
99
|
+
|
|
100
|
+
env = {
|
|
101
|
+
**os.environ,
|
|
102
|
+
"WEBGET_PROFILE_DIR": str(profile_root),
|
|
103
|
+
"WEBGET_ALLOW_PRIVATE": "1",
|
|
104
|
+
}
|
|
105
|
+
return StdioServerParameters(command=sys.executable, args=[str(ROOT / "webget_mcp.py")], env=env)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_mcp_login_tool_persists_and_fetch_uses_it(server, tmp_path):
|
|
109
|
+
"""End-to-end MCP: login(url, profile, headless=True) against the local
|
|
110
|
+
/set-cookie page persists a session, then fetch(profile=...) on the
|
|
111
|
+
cookie-gated page succeeds while anonymous fetch does not."""
|
|
112
|
+
from mcp import ClientSession
|
|
113
|
+
from mcp.client.stdio import stdio_client
|
|
114
|
+
|
|
115
|
+
root = _profile_root(tmp_path)
|
|
116
|
+
login_url = server.url("/set-cookie")
|
|
117
|
+
gated_url = server.url("/cookie-gated")
|
|
118
|
+
|
|
119
|
+
async def run():
|
|
120
|
+
async with stdio_client(_spawn_mcp(root)) as (read, write), ClientSession(read, write) as session:
|
|
121
|
+
await session.initialize()
|
|
122
|
+
res = await session.call_tool(
|
|
123
|
+
"login",
|
|
124
|
+
{"url": login_url, "profile": "mcplogin", "headless": True, "wait_seconds": 30},
|
|
125
|
+
)
|
|
126
|
+
authed = await session.call_tool(
|
|
127
|
+
"fetch", {"url": gated_url, "strategy": "http", "no_cache": True, "profile": "mcplogin"}
|
|
128
|
+
)
|
|
129
|
+
anon = await session.call_tool(
|
|
130
|
+
"fetch", {"url": gated_url, "strategy": "http", "no_cache": True}
|
|
131
|
+
)
|
|
132
|
+
return res, authed, anon
|
|
133
|
+
|
|
134
|
+
res, authed, anon = asyncio.run(asyncio.wait_for(run(), timeout=90))
|
|
135
|
+
res_text = res.content[0].text if res.content else ""
|
|
136
|
+
assert not res.is_error, res_text
|
|
137
|
+
assert '"status":"success"' in res_text, res_text
|
|
138
|
+
assert '"profile":"mcplogin"' in res_text
|
|
139
|
+
|
|
140
|
+
authed_text = authed.content[0].text if authed.content else ""
|
|
141
|
+
assert '"status":"success"' in authed_text, authed_text
|
|
142
|
+
assert "you are authenticated" in authed_text
|
|
143
|
+
assert '"authenticated":true' in authed_text
|
|
144
|
+
|
|
145
|
+
anon_text = anon.content[0].text if anon.content else ""
|
|
146
|
+
assert '"status":"success"' not in anon_text, f"anonymous fetch leaked: {anon_text}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_mcp_login_rejects_bad_url_and_profile(server, tmp_path):
|
|
150
|
+
"""login tool validates its inputs: bad URL and bad profile name are
|
|
151
|
+
clean errors, never a crash."""
|
|
152
|
+
from mcp import ClientSession
|
|
153
|
+
from mcp.client.stdio import stdio_client
|
|
154
|
+
|
|
155
|
+
root = _profile_root(tmp_path)
|
|
156
|
+
|
|
157
|
+
async def run():
|
|
158
|
+
async with stdio_client(_spawn_mcp(root)) as (read, write), ClientSession(read, write) as session:
|
|
159
|
+
await session.initialize()
|
|
160
|
+
bad_url = await session.call_tool(
|
|
161
|
+
"login", {"url": "not a url", "profile": "x", "headless": True, "wait_seconds": 5}
|
|
162
|
+
)
|
|
163
|
+
bad_name = await session.call_tool(
|
|
164
|
+
"login", {"url": server.url("/"), "profile": "../evil", "headless": True, "wait_seconds": 5}
|
|
165
|
+
)
|
|
166
|
+
bad_secs = await session.call_tool(
|
|
167
|
+
"login", {"url": server.url("/"), "profile": "x", "headless": True, "wait_seconds": 1}
|
|
168
|
+
)
|
|
169
|
+
return bad_url, bad_name, bad_secs
|
|
170
|
+
|
|
171
|
+
bad_url, bad_name, bad_secs = asyncio.run(asyncio.wait_for(run(), timeout=60))
|
|
172
|
+
assert "invalid site URL" in bad_url.content[0].text
|
|
173
|
+
assert "invalid profile name" in bad_name.content[0].text
|
|
174
|
+
assert "wait_seconds must be between 5 and 600" in bad_secs.content[0].text
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
import pytest
|
|
179
|
+
|
|
180
|
+
sys.exit(pytest.main([__file__, "-v"]))
|
|
@@ -64,7 +64,7 @@ class TestNoSecretLeakage:
|
|
|
64
64
|
return res
|
|
65
65
|
|
|
66
66
|
res = _run(run())
|
|
67
|
-
if res.
|
|
67
|
+
if res.is_error:
|
|
68
68
|
return # error path: no payload to leak, still fine
|
|
69
69
|
text = res.content[0].text
|
|
70
70
|
hits = _scan(text)
|
|
@@ -121,7 +121,7 @@ class TestServerRecovery:
|
|
|
121
121
|
bad = await session.call_tool(
|
|
122
122
|
"fetch", {"url": "https://example.com", "strategy": "bogus"}
|
|
123
123
|
)
|
|
124
|
-
assert not bad.
|
|
124
|
+
assert not bad.is_error or "error" in bad.content[0].text
|
|
125
125
|
good = await session.call_tool(
|
|
126
126
|
"fetch", {"url": "https://example.com", "strategy": "http", "no_cache": True}
|
|
127
127
|
)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Integration: MCP fetch with a real profile uses the stored session.
|
|
2
|
+
|
|
3
|
+
A cookie-gated local server returns 200 only when the request carries the
|
|
4
|
+
session cookie from a profile's storage_state.json:
|
|
5
|
+
- fetch(profile="testp") -> success (session used)
|
|
6
|
+
- fetch() (anonymous) -> blocked/login_required (no session)
|
|
7
|
+
|
|
8
|
+
Spawns webget_mcp.py as a stdio subprocess with WEBGET_PROFILE_DIR pointing
|
|
9
|
+
at a temp profile root and WEBGET_ALLOW_PRIVATE=1 (the test server lives on
|
|
10
|
+
127.0.0.1, which the SSRF guard blocks by default).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from mcp import ClientSession, StdioServerParameters
|
|
20
|
+
from mcp.client.stdio import stdio_client
|
|
21
|
+
|
|
22
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
23
|
+
MCP = ROOT / "webget_mcp.py"
|
|
24
|
+
sys.path.insert(0, str(ROOT))
|
|
25
|
+
|
|
26
|
+
from tests.http_server import TestServer
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _spawn(profile_root):
|
|
30
|
+
env = {
|
|
31
|
+
**os.environ,
|
|
32
|
+
"WEBGET_PROFILE_DIR": str(profile_root),
|
|
33
|
+
"WEBGET_ALLOW_PRIVATE": "1",
|
|
34
|
+
}
|
|
35
|
+
return StdioServerParameters(command=sys.executable, args=[str(MCP)], env=env)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _make_profile(root, name):
|
|
39
|
+
d = root / name
|
|
40
|
+
(d / "Default").mkdir(parents=True)
|
|
41
|
+
(d / "storage_state.json").write_text(
|
|
42
|
+
json.dumps(
|
|
43
|
+
{
|
|
44
|
+
"cookies": [
|
|
45
|
+
{
|
|
46
|
+
"name": "session",
|
|
47
|
+
"value": "1",
|
|
48
|
+
"domain": "127.0.0.1",
|
|
49
|
+
"path": "/",
|
|
50
|
+
"expires": 9999999999,
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_mcp_fetch_with_profile_uses_session(tmp_path):
|
|
59
|
+
server = TestServer().start()
|
|
60
|
+
try:
|
|
61
|
+
root = tmp_path / "profiles"
|
|
62
|
+
_make_profile(root, "testp")
|
|
63
|
+
url = server.url("/cookie-gated")
|
|
64
|
+
|
|
65
|
+
async def run():
|
|
66
|
+
async with (
|
|
67
|
+
stdio_client(_spawn(root)) as (read, write),
|
|
68
|
+
ClientSession(read, write) as session,
|
|
69
|
+
):
|
|
70
|
+
await session.initialize()
|
|
71
|
+
ok = await session.call_tool(
|
|
72
|
+
"fetch", {"url": url, "strategy": "http", "no_cache": True, "profile": "testp"}
|
|
73
|
+
)
|
|
74
|
+
anon = await session.call_tool(
|
|
75
|
+
"fetch", {"url": url, "strategy": "http", "no_cache": True}
|
|
76
|
+
)
|
|
77
|
+
return ok, anon
|
|
78
|
+
|
|
79
|
+
ok, anon = asyncio.run(asyncio.wait_for(run(), timeout=60))
|
|
80
|
+
ok_text = ok.content[0].text if ok.content else ""
|
|
81
|
+
anon_text = anon.content[0].text if anon.content else ""
|
|
82
|
+
assert not ok.is_error, ok_text
|
|
83
|
+
assert '"status":"success"' in ok_text, ok_text
|
|
84
|
+
assert "you are authenticated" in ok_text, ok_text
|
|
85
|
+
assert '"authenticated":true' in ok_text, ok_text # session was used
|
|
86
|
+
# anonymous fetch must NOT succeed on the gated page
|
|
87
|
+
assert ok_text != anon_text
|
|
88
|
+
assert '"status":"success"' not in anon_text, f"anonymous fetch leaked: {anon_text}"
|
|
89
|
+
finally:
|
|
90
|
+
server.stop()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_mcp_fetch_unknown_profile_is_hard_error(tmp_path):
|
|
94
|
+
"""Even with a profile root, a name that does not exist is an error
|
|
95
|
+
(never a silent anonymous fallback)."""
|
|
96
|
+
|
|
97
|
+
async def run():
|
|
98
|
+
env = {**os.environ, "WEBGET_PROFILE_DIR": str(tmp_path / "profiles")}
|
|
99
|
+
params = StdioServerParameters(command=sys.executable, args=[str(MCP)], env=env)
|
|
100
|
+
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
101
|
+
await session.initialize()
|
|
102
|
+
res = await session.call_tool(
|
|
103
|
+
"fetch", {"url": "https://example.com", "profile": "ghost", "no_cache": True}
|
|
104
|
+
)
|
|
105
|
+
return res
|
|
106
|
+
|
|
107
|
+
res = asyncio.run(asyncio.wait_for(run(), timeout=60))
|
|
108
|
+
assert not res.is_error
|
|
109
|
+
assert "profile 'ghost' not found" in res.content[0].text
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_mcp_fetch_enriched_output_present(tmp_path):
|
|
113
|
+
"""MCP fetch must include auth, attempts, and reasons in the response.
|
|
114
|
+
|
|
115
|
+
These fields give agents full provenance: whether the fetch used a
|
|
116
|
+
session, how many ladder steps were tried, and the full chain of
|
|
117
|
+
failures. Added 2026-08-31.
|
|
118
|
+
"""
|
|
119
|
+
server = TestServer().start()
|
|
120
|
+
try:
|
|
121
|
+
root = tmp_path / "profiles"
|
|
122
|
+
_make_profile(root, "testp")
|
|
123
|
+
gated = server.url("/cookie-gated")
|
|
124
|
+
|
|
125
|
+
async def run():
|
|
126
|
+
async with (
|
|
127
|
+
stdio_client(_spawn(root)) as (read, write),
|
|
128
|
+
ClientSession(read, write) as session,
|
|
129
|
+
):
|
|
130
|
+
await session.initialize()
|
|
131
|
+
# Authenticated fetch
|
|
132
|
+
ok = await session.call_tool(
|
|
133
|
+
"fetch",
|
|
134
|
+
{
|
|
135
|
+
"url": gated,
|
|
136
|
+
"strategy": "http",
|
|
137
|
+
"no_cache": True,
|
|
138
|
+
"profile": "testp",
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
# Anonymous fetch (blocked)
|
|
142
|
+
anon = await session.call_tool(
|
|
143
|
+
"fetch",
|
|
144
|
+
{"url": gated, "strategy": "http", "no_cache": True},
|
|
145
|
+
)
|
|
146
|
+
return ok, anon
|
|
147
|
+
|
|
148
|
+
ok, anon = asyncio.run(asyncio.wait_for(run(), timeout=60))
|
|
149
|
+
ok_text = ok.content[0].text if ok.content else ""
|
|
150
|
+
anon_text = anon.content[0].text if anon.content else ""
|
|
151
|
+
|
|
152
|
+
# Authenticated: standard assertions
|
|
153
|
+
assert not ok.is_error, ok_text
|
|
154
|
+
assert '"status":"success"' in ok_text
|
|
155
|
+
# auth
|
|
156
|
+
assert '"auth"' in ok_text
|
|
157
|
+
assert '"authenticated":true' in ok_text
|
|
158
|
+
assert '"attempts"' in ok_text
|
|
159
|
+
# Success carries an empty reasons list (consistent shape: consumers
|
|
160
|
+
# can always iterate reasons without a None check).
|
|
161
|
+
assert '"reasons":[]' in ok_text, "success should have empty reasons"
|
|
162
|
+
|
|
163
|
+
# Anonymous (blocked): must have provenance fields
|
|
164
|
+
assert '"status":"blocked"' in anon_text or '"status":"login_required"' in anon_text
|
|
165
|
+
assert '"auth"' in anon_text
|
|
166
|
+
assert '"authenticated":false' in anon_text or '"authenticated":null' in anon_text
|
|
167
|
+
assert '"attempts"' in anon_text
|
|
168
|
+
assert '"reasons"' in anon_text
|
|
169
|
+
assert '"state":"blocked"' in anon_text or '"state":"login_required"' in anon_text
|
|
170
|
+
finally:
|
|
171
|
+
server.stop()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
import pytest
|
|
176
|
+
|
|
177
|
+
sys.exit(pytest.main([__file__, "-v"]))
|