agent-webview 0.1.0__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.
@@ -0,0 +1,3 @@
1
+ """Agent 控制的 pywebview 调试服务。"""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from agent_webview.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
agent_webview/auth.py ADDED
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from collections.abc import Callable
5
+
6
+ from fastapi import Header, HTTPException, status
7
+
8
+
9
+ def bearer_auth(expected_token: str) -> Callable[..., None]:
10
+ def authenticate(authorization: str | None = Header(default=None)) -> None:
11
+ scheme, _, token = (authorization or "").partition(" ")
12
+ if scheme.lower() != "bearer" or not secrets.compare_digest(token, expected_token):
13
+ raise HTTPException(
14
+ status_code=status.HTTP_401_UNAUTHORIZED,
15
+ detail="令牌缺失或无效",
16
+ headers={"WWW-Authenticate": "Bearer"},
17
+ )
18
+
19
+ return authenticate
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from threading import Event, Lock
5
+ from typing import Any
6
+ from uuid import uuid4
7
+
8
+ from agent_webview.errors import (
9
+ BrowserCommandError,
10
+ BrowserCommandTimeoutError,
11
+ BrowserNotReadyError,
12
+ )
13
+ from agent_webview.events import EventBuffer
14
+ from agent_webview.js import command_script
15
+
16
+
17
+ @dataclass
18
+ class _PendingResult:
19
+ event: Event
20
+ payload: dict[str, Any] | None = None
21
+
22
+
23
+ class AgentBridge:
24
+ def __init__(self, events: EventBuffer) -> None:
25
+ self.events = events
26
+ self.window: Any = None
27
+ self.ready = Event()
28
+ self._command_lock = Lock()
29
+ self._pending_lock = Lock()
30
+ self._pending: dict[str, _PendingResult] = {}
31
+
32
+ def attach_window(self, window: Any) -> None:
33
+ self.window = window
34
+
35
+ def execute(self, code: str, *, expression: bool, timeout: float) -> Any:
36
+ if not self.window or not self.ready.wait(min(timeout, 5)):
37
+ raise BrowserNotReadyError("页面尚未就绪")
38
+
39
+ request_id = uuid4().hex
40
+ pending = _PendingResult(event=Event())
41
+ with self._pending_lock:
42
+ self._pending[request_id] = pending
43
+
44
+ try:
45
+ with self._command_lock:
46
+ try:
47
+ self.window.run_js(
48
+ command_script(request_id, code, expression=expression)
49
+ )
50
+ except Exception as error:
51
+ raise BrowserCommandError(
52
+ "脚本发送失败",
53
+ details={"name": type(error).__name__},
54
+ ) from error
55
+ if not pending.event.wait(timeout):
56
+ raise BrowserCommandTimeoutError(
57
+ f"脚本执行超过 {timeout:g} 秒"
58
+ )
59
+ payload = pending.payload or {}
60
+ if not payload.get("ok"):
61
+ error_details = payload.get("error") or {}
62
+ raise BrowserCommandError(
63
+ error_details.get("message", "脚本执行失败"),
64
+ details=error_details,
65
+ )
66
+ return payload.get("value")
67
+ finally:
68
+ with self._pending_lock:
69
+ self._pending.pop(request_id, None)
70
+
71
+ def agent_result(self, request_id: str, payload: dict[str, Any]) -> bool:
72
+ with self._pending_lock:
73
+ pending = self._pending.get(request_id)
74
+ if not pending:
75
+ return False
76
+ pending.payload = payload
77
+ pending.event.set()
78
+ return True
79
+
80
+ def agent_event(self, payload: dict[str, Any]) -> bool:
81
+ kind = str(payload.get("kind") or "page")
82
+ self.events.append(kind, payload)
83
+ return True
84
+
85
+
86
+ class AgentApi:
87
+ def __init__(self, bridge: AgentBridge) -> None:
88
+ self._bridge = bridge
89
+
90
+ def agent_result(self, request_id: str, payload: dict[str, Any]) -> bool:
91
+ return self._bridge.agent_result(request_id, payload)
92
+
93
+ def agent_event(self, payload: dict[str, Any]) -> bool:
94
+ return self._bridge.agent_event(payload)
agent_webview/cli.py ADDED
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import ipaddress
5
+ import os
6
+ import secrets
7
+ import signal
8
+ from collections.abc import Iterator, Sequence
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import structlog
14
+ import uvicorn
15
+ from filelock import FileLock, Timeout
16
+ from platformdirs import PlatformDirs
17
+
18
+ from agent_webview.controller_api import create_controller_app
19
+ from agent_webview.cookies import SnapshotStore
20
+ from agent_webview.files import (
21
+ ensure_private_parent,
22
+ restrict_file_permissions,
23
+ write_private_json,
24
+ )
25
+ from agent_webview.logging import configure_logging
26
+ from agent_webview.sessions import SessionManager
27
+
28
+ log = structlog.get_logger(__name__)
29
+
30
+
31
+ class RuntimeFileInUseError(RuntimeError):
32
+ """运行信息文件已被其他控制器占用。"""
33
+
34
+
35
+ def _port(value: str) -> int:
36
+ try:
37
+ port = int(value)
38
+ except ValueError as error:
39
+ raise argparse.ArgumentTypeError("端口必须是整数") from error
40
+ if not 1 <= port <= 65535:
41
+ raise argparse.ArgumentTypeError("端口必须在 1 到 65535 之间")
42
+ return port
43
+
44
+
45
+ def _is_loopback_host(host: str) -> bool:
46
+ normalized = host.strip().strip("[]").rstrip(".").lower()
47
+ if normalized == "localhost":
48
+ return True
49
+ try:
50
+ return ipaddress.ip_address(normalized).is_loopback
51
+ except ValueError:
52
+ return False
53
+
54
+
55
+ def _base_url(host: str, port: int) -> str:
56
+ normalized = host.strip().strip("[]")
57
+ try:
58
+ address = ipaddress.ip_address(normalized)
59
+ formatted = f"[{normalized}]" if address.version == 6 else normalized
60
+ except ValueError:
61
+ formatted = normalized
62
+ return f"http://{formatted}:{port}"
63
+
64
+
65
+ @contextmanager
66
+ def _claim_runtime_file(path: Path) -> Iterator[None]:
67
+ ensure_private_parent(path.parent)
68
+ lock = FileLock(f"{path}.lock")
69
+ try:
70
+ lock.acquire(timeout=0)
71
+ except Timeout as error:
72
+ raise RuntimeFileInUseError("运行信息文件已被其他服务占用") from error
73
+ restrict_file_permissions(Path(lock.lock_file))
74
+ try:
75
+ yield
76
+ finally:
77
+ lock.release()
78
+
79
+
80
+ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
81
+ directories = PlatformDirs("agent-webview", appauthor=False)
82
+ runtime_path = directories.user_runtime_path
83
+ parser = argparse.ArgumentParser(description="Agent Webview 调试服务")
84
+ parser.add_argument("--host", default="127.0.0.1")
85
+ parser.add_argument("--port", type=_port, default=8765)
86
+ parser.add_argument("--token", default=os.getenv("AGENT_WEBVIEW_TOKEN"))
87
+ parser.add_argument(
88
+ "--data-dir",
89
+ type=Path,
90
+ default=directories.user_data_path,
91
+ )
92
+ parser.add_argument(
93
+ "--runtime-dir",
94
+ type=Path,
95
+ default=runtime_path / "sessions",
96
+ )
97
+ parser.add_argument(
98
+ "--runtime-file",
99
+ type=Path,
100
+ default=runtime_path / "controller.json",
101
+ )
102
+ parser.add_argument(
103
+ "--print-runtime-file",
104
+ action="store_true",
105
+ help="输出运行信息文件路径后退出",
106
+ )
107
+ parser.add_argument(
108
+ "--allow-remote",
109
+ action="store_true",
110
+ help="允许监听非本机地址",
111
+ )
112
+ parser.add_argument("--log-level", default="INFO")
113
+ parser.add_argument("--json-logs", action="store_true")
114
+ args = parser.parse_args(argv)
115
+ if not _is_loopback_host(args.host) and not args.allow_remote:
116
+ parser.error("监听非本机地址时必须显式指定 --allow-remote")
117
+ return args
118
+
119
+
120
+ def _write_runtime_file(
121
+ path: Path,
122
+ *,
123
+ host: str,
124
+ port: int,
125
+ token: str,
126
+ ) -> None:
127
+ write_private_json(
128
+ path,
129
+ {
130
+ "pid": os.getpid(),
131
+ "base_url": _base_url(host, port),
132
+ "token": token,
133
+ "docs_url": f"{_base_url(host, port)}/docs",
134
+ },
135
+ )
136
+
137
+
138
+ def _serve(args: argparse.Namespace, token: str, runtime_file: Path) -> None:
139
+ data_dir = args.data_dir.resolve()
140
+ manager = SessionManager(
141
+ runtime_dir=args.runtime_dir.resolve(),
142
+ snapshot_store=SnapshotStore(data_dir / "cookie-snapshots"),
143
+ log_level=args.log_level,
144
+ json_logs=args.json_logs,
145
+ )
146
+ original_handlers: dict[int, Any] = {}
147
+ try:
148
+ app = create_controller_app(manager, token)
149
+ _write_runtime_file(
150
+ runtime_file,
151
+ host=args.host,
152
+ port=args.port,
153
+ token=token,
154
+ )
155
+ log.info(
156
+ "服务已启动",
157
+ url=_base_url(args.host, args.port),
158
+ runtime_file=str(runtime_file),
159
+ )
160
+ server = uvicorn.Server(
161
+ uvicorn.Config(
162
+ app,
163
+ host=args.host,
164
+ port=args.port,
165
+ log_config=None,
166
+ log_level="critical",
167
+ access_log=False,
168
+ )
169
+ )
170
+
171
+ def defer_exit(signum: int, _: Any) -> None:
172
+ # 让 Uvicorn 返回后再清理敏感运行文件。
173
+ server.should_exit = True
174
+
175
+ handled_signals: list[int] = [int(signal.SIGINT), int(signal.SIGTERM)]
176
+ if hasattr(signal, "SIGBREAK"):
177
+ handled_signals.append(int(signal.SIGBREAK))
178
+ for handled_signal in handled_signals:
179
+ original_handlers[handled_signal] = signal.signal(
180
+ handled_signal,
181
+ defer_exit,
182
+ )
183
+ server.run()
184
+ finally:
185
+ try:
186
+ manager.close()
187
+ finally:
188
+ runtime_file.unlink(missing_ok=True)
189
+ for handled_signal, handler in original_handlers.items():
190
+ signal.signal(handled_signal, handler)
191
+ log.info("服务已停止")
192
+
193
+
194
+ def main() -> None:
195
+ args = _parse_args()
196
+ if args.print_runtime_file:
197
+ print(args.runtime_file.resolve())
198
+ return
199
+ configure_logging(args.log_level, json_logs=args.json_logs)
200
+ token = args.token or secrets.token_urlsafe(32)
201
+ runtime_file = args.runtime_file.resolve()
202
+ try:
203
+ with _claim_runtime_file(runtime_file):
204
+ _serve(args, token, runtime_file)
205
+ except RuntimeFileInUseError:
206
+ log.error("运行信息文件已被其他服务占用", runtime_file=str(runtime_file))
207
+ raise SystemExit(2) from None
208
+
209
+
210
+ if __name__ == "__main__":
211
+ main()
@@ -0,0 +1,319 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import asynccontextmanager
4
+ from typing import Any
5
+
6
+ import httpx
7
+ import structlog
8
+ from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query
9
+ from fastapi.responses import JSONResponse
10
+
11
+ from agent_webview import __version__
12
+ from agent_webview.auth import bearer_auth
13
+ from agent_webview.http_errors import install_validation_handler
14
+ from agent_webview.models import (
15
+ CookieRestoreRequest,
16
+ CookieSnapshot,
17
+ CookieSnapshotCreateRequest,
18
+ CookieSnapshotDeleteResponse,
19
+ CookieSnapshotListResponse,
20
+ DomClickRequest,
21
+ DomDispatchRequest,
22
+ DomInputRequest,
23
+ DomListenerRequest,
24
+ DomQueryRequest,
25
+ HealthResponse,
26
+ InstrumentationRequest,
27
+ JavaScriptRequest,
28
+ NavigateRequest,
29
+ SessionCreateRequest,
30
+ SessionDescription,
31
+ SessionDestroyResponse,
32
+ SessionListResponse,
33
+ WindowStatus,
34
+ )
35
+ from agent_webview.sessions import SessionManager, WorkerResponseError
36
+
37
+ log = structlog.get_logger(__name__)
38
+
39
+
40
+ def create_controller_app(manager: SessionManager, token: str) -> FastAPI:
41
+ @asynccontextmanager
42
+ async def lifespan(_: FastAPI):
43
+ yield
44
+ manager.close()
45
+
46
+ app = FastAPI(
47
+ title="Agent Webview",
48
+ version=__version__,
49
+ description="为 Agent 提供隔离的 pywebview 调试会话。",
50
+ lifespan=lifespan,
51
+ )
52
+ install_validation_handler(app)
53
+ router = APIRouter(prefix="/v1", dependencies=[Depends(bearer_auth(token))])
54
+
55
+ @app.get("/health", response_model=HealthResponse)
56
+ def health() -> dict[str, bool]:
57
+ return {"ok": True}
58
+
59
+ @app.exception_handler(KeyError)
60
+ async def missing_handler(_, __: KeyError) -> JSONResponse:
61
+ return JSONResponse(status_code=404, content={"detail": "会话不存在"})
62
+
63
+ @app.exception_handler(FileNotFoundError)
64
+ async def snapshot_handler(_, __: FileNotFoundError) -> JSONResponse:
65
+ return JSONResponse(status_code=404, content={"detail": "快照不存在"})
66
+
67
+ @app.exception_handler(ProcessLookupError)
68
+ async def process_handler(_, error: ProcessLookupError) -> JSONResponse:
69
+ return JSONResponse(status_code=410, content={"detail": str(error)})
70
+
71
+ @app.exception_handler(WorkerResponseError)
72
+ async def worker_handler(_, error: WorkerResponseError) -> JSONResponse:
73
+ return JSONResponse(status_code=error.status_code, content=error.detail)
74
+
75
+ @app.exception_handler(httpx.HTTPError)
76
+ async def network_handler(_, __: httpx.HTTPError) -> JSONResponse:
77
+ return JSONResponse(status_code=502, content={"detail": "子进程接口不可用"})
78
+
79
+ @app.exception_handler(Exception)
80
+ async def unknown_handler(_, error: Exception) -> JSONResponse:
81
+ log.error("接口执行失败", error_type=type(error).__name__)
82
+ return JSONResponse(status_code=500, content={"detail": "接口执行失败"})
83
+
84
+ def call(
85
+ session_id: str,
86
+ method: str,
87
+ path: str,
88
+ *,
89
+ params: Any = None,
90
+ body: Any = None,
91
+ timeout: float | None = None,
92
+ ) -> Any:
93
+ return manager.request(
94
+ manager.get(session_id),
95
+ method,
96
+ path,
97
+ params=params,
98
+ json_body=body,
99
+ timeout=timeout,
100
+ )
101
+
102
+ @router.get("/sessions", response_model=SessionListResponse)
103
+ def list_sessions() -> dict[str, Any]:
104
+ return {"sessions": manager.list()}
105
+
106
+ @router.post("/sessions", status_code=201, response_model=SessionDescription)
107
+ def create_session(request: SessionCreateRequest) -> dict[str, Any]:
108
+ return manager.create(request)
109
+
110
+ @router.get("/sessions/{session_id}", response_model=SessionDescription)
111
+ def get_session(session_id: str) -> dict[str, Any]:
112
+ return manager.describe(manager.get(session_id))
113
+
114
+ @router.delete("/sessions/{session_id}", response_model=SessionDestroyResponse)
115
+ def destroy_session(session_id: str) -> dict[str, Any]:
116
+ return manager.destroy(session_id)
117
+
118
+ @router.post("/sessions/{session_id}/window/show", response_model=WindowStatus)
119
+ def show_window(session_id: str) -> dict[str, Any]:
120
+ return call(session_id, "POST", "/v1/window/show")
121
+
122
+ @router.post("/sessions/{session_id}/window/hide", response_model=WindowStatus)
123
+ def hide_window(session_id: str) -> dict[str, Any]:
124
+ return call(session_id, "POST", "/v1/window/hide")
125
+
126
+ @router.post("/sessions/{session_id}/navigate")
127
+ def navigate(session_id: str, request: NavigateRequest) -> dict[str, Any]:
128
+ return call(session_id, "POST", "/v1/navigate", body=request.model_dump())
129
+
130
+ @router.post("/sessions/{session_id}/javascript/evaluate")
131
+ def evaluate(
132
+ session_id: str,
133
+ request: JavaScriptRequest,
134
+ ) -> dict[str, Any]:
135
+ return call(
136
+ session_id,
137
+ "POST",
138
+ "/v1/javascript/evaluate",
139
+ body=request.model_dump(),
140
+ timeout=request.timeout + 5,
141
+ )
142
+
143
+ @router.post("/sessions/{session_id}/javascript/execute")
144
+ def execute(
145
+ session_id: str,
146
+ request: JavaScriptRequest,
147
+ ) -> dict[str, Any]:
148
+ return call(
149
+ session_id,
150
+ "POST",
151
+ "/v1/javascript/execute",
152
+ body=request.model_dump(),
153
+ timeout=request.timeout + 5,
154
+ )
155
+
156
+ @router.post("/sessions/{session_id}/dom/query")
157
+ def dom_query(session_id: str, request: DomQueryRequest) -> dict[str, Any]:
158
+ return call(
159
+ session_id,
160
+ "POST",
161
+ "/v1/dom/query",
162
+ body=request.model_dump(),
163
+ )
164
+
165
+ @router.post("/sessions/{session_id}/dom/click")
166
+ def dom_click(session_id: str, request: DomClickRequest) -> dict[str, Any]:
167
+ return call(
168
+ session_id,
169
+ "POST",
170
+ "/v1/dom/click",
171
+ body=request.model_dump(),
172
+ timeout=request.timeout + 5,
173
+ )
174
+
175
+ @router.post("/sessions/{session_id}/dom/input")
176
+ def dom_input(session_id: str, request: DomInputRequest) -> dict[str, Any]:
177
+ return call(
178
+ session_id,
179
+ "POST",
180
+ "/v1/dom/input",
181
+ body=request.model_dump(),
182
+ timeout=request.timeout + 5,
183
+ )
184
+
185
+ @router.post("/sessions/{session_id}/dom/dispatch")
186
+ def dom_dispatch(
187
+ session_id: str,
188
+ request: DomDispatchRequest,
189
+ ) -> dict[str, Any]:
190
+ return call(
191
+ session_id,
192
+ "POST",
193
+ "/v1/dom/dispatch",
194
+ body=request.model_dump(),
195
+ timeout=request.timeout + 5,
196
+ )
197
+
198
+ @router.post("/sessions/{session_id}/dom/listeners")
199
+ def add_listener(
200
+ session_id: str,
201
+ request: DomListenerRequest,
202
+ ) -> dict[str, Any]:
203
+ return call(
204
+ session_id,
205
+ "POST",
206
+ "/v1/dom/listeners",
207
+ body=request.model_dump(),
208
+ )
209
+
210
+ @router.delete("/sessions/{session_id}/dom/listeners/{listener_id}")
211
+ def remove_listener(session_id: str, listener_id: str) -> dict[str, Any]:
212
+ return call(
213
+ session_id,
214
+ "DELETE",
215
+ f"/v1/dom/listeners/{listener_id}",
216
+ )
217
+
218
+ @router.put("/sessions/{session_id}/instrumentation")
219
+ def instrumentation(
220
+ session_id: str,
221
+ request: InstrumentationRequest,
222
+ ) -> dict[str, Any]:
223
+ return call(
224
+ session_id,
225
+ "PUT",
226
+ "/v1/instrumentation",
227
+ body=request.model_dump(),
228
+ )
229
+
230
+ @router.get("/sessions/{session_id}/events")
231
+ def events(
232
+ session_id: str,
233
+ after: int = Query(default=0, ge=0),
234
+ limit: int = Query(default=200, ge=1, le=1000),
235
+ kinds: str | None = None,
236
+ timeout: float = Query(default=0, ge=0, le=30),
237
+ ) -> dict[str, Any]:
238
+ return call(
239
+ session_id,
240
+ "GET",
241
+ "/v1/events",
242
+ params={
243
+ "after": after,
244
+ "limit": limit,
245
+ "kinds": kinds,
246
+ "timeout": timeout,
247
+ },
248
+ timeout=timeout + 5,
249
+ )
250
+
251
+ @router.delete("/sessions/{session_id}/events")
252
+ def clear_events(session_id: str) -> dict[str, Any]:
253
+ return call(session_id, "DELETE", "/v1/events")
254
+
255
+ @router.get("/sessions/{session_id}/cookies")
256
+ def get_cookies(session_id: str) -> dict[str, Any]:
257
+ return call(session_id, "GET", "/v1/cookies")
258
+
259
+ @router.delete("/sessions/{session_id}/cookies")
260
+ def clear_cookies(session_id: str) -> dict[str, Any]:
261
+ return call(session_id, "DELETE", "/v1/cookies")
262
+
263
+ @router.post("/sessions/{session_id}/cookies/restore")
264
+ def restore_cookies(
265
+ session_id: str,
266
+ request: CookieRestoreRequest,
267
+ ) -> dict[str, Any]:
268
+ return call(
269
+ session_id,
270
+ "POST",
271
+ "/v1/cookies/restore",
272
+ body=request.model_dump(),
273
+ timeout=request.timeout + 5,
274
+ )
275
+
276
+ @router.post(
277
+ "/sessions/{session_id}/cookie-snapshots",
278
+ status_code=201,
279
+ response_model=CookieSnapshot,
280
+ )
281
+ def create_snapshot(
282
+ session_id: str,
283
+ request: CookieSnapshotCreateRequest,
284
+ ) -> dict[str, Any]:
285
+ result = call(session_id, "GET", "/v1/cookies")
286
+ return manager.snapshot_store.save(
287
+ session_id=session_id,
288
+ url=result.get("url"),
289
+ cookies=result.get("cookies", []),
290
+ name=request.name,
291
+ )
292
+
293
+ @router.get("/cookie-snapshots", response_model=CookieSnapshotListResponse)
294
+ def list_snapshots() -> dict[str, Any]:
295
+ return {"snapshots": manager.snapshot_store.list()}
296
+
297
+ @router.get(
298
+ "/cookie-snapshots/{snapshot_id}",
299
+ response_model=CookieSnapshot,
300
+ )
301
+ def get_snapshot(snapshot_id: str) -> dict[str, Any]:
302
+ try:
303
+ return manager.snapshot_store.get(snapshot_id)
304
+ except FileNotFoundError as error:
305
+ raise HTTPException(status_code=404, detail="快照不存在") from error
306
+
307
+ @router.delete(
308
+ "/cookie-snapshots/{snapshot_id}",
309
+ response_model=CookieSnapshotDeleteResponse,
310
+ )
311
+ def delete_snapshot(snapshot_id: str) -> dict[str, Any]:
312
+ try:
313
+ manager.snapshot_store.delete(snapshot_id)
314
+ except FileNotFoundError as error:
315
+ raise HTTPException(status_code=404, detail="快照不存在") from error
316
+ return {"deleted": True, "snapshot_id": snapshot_id}
317
+
318
+ app.include_router(router)
319
+ return app