ghost-api-cli 0.2.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.
- ghost_api/__init__.py +3 -0
- ghost_api/__main__.py +5 -0
- ghost_api/adopt.py +173 -0
- ghost_api/agent/__init__.py +0 -0
- ghost_api/agent/orchestrator.py +413 -0
- ghost_api/agent/prompts.py +149 -0
- ghost_api/agent/tools.py +84 -0
- ghost_api/analysis/__init__.py +0 -0
- ghost_api/analysis/analyzer.py +130 -0
- ghost_api/analysis/detectors.py +188 -0
- ghost_api/analysis/exposure.py +80 -0
- ghost_api/analysis/graphql.py +67 -0
- ghost_api/analysis/models.py +64 -0
- ghost_api/analysis/schema.py +109 -0
- ghost_api/analysis/url_grouping.py +217 -0
- ghost_api/api.py +264 -0
- ghost_api/audit.py +78 -0
- ghost_api/browser/__init__.py +0 -0
- ghost_api/browser/auth_state.py +172 -0
- ghost_api/browser/driver.py +273 -0
- ghost_api/browser/egress.py +183 -0
- ghost_api/browser/gate.py +127 -0
- ghost_api/browser/install.py +28 -0
- ghost_api/browser/snapshot.py +221 -0
- ghost_api/capture/__init__.py +0 -0
- ghost_api/capture/filters.py +255 -0
- ghost_api/capture/har.py +148 -0
- ghost_api/capture/models.py +41 -0
- ghost_api/capture/personal.py +188 -0
- ghost_api/capture/recorder.py +181 -0
- ghost_api/cli/__init__.py +44 -0
- ghost_api/cli/_common.py +219 -0
- ghost_api/cli/client.py +487 -0
- ghost_api/cli/data.py +492 -0
- ghost_api/cli/explore.py +379 -0
- ghost_api/cli/manage.py +437 -0
- ghost_api/config.py +132 -0
- ghost_api/drift.py +344 -0
- ghost_api/drive.py +211 -0
- ghost_api/errors.py +106 -0
- ghost_api/events.py +213 -0
- ghost_api/exitcodes.py +21 -0
- ghost_api/export/__init__.py +71 -0
- ghost_api/export/confidence.py +45 -0
- ghost_api/export/html.py +41 -0
- ghost_api/export/openapi.py +201 -0
- ghost_api/export/report.py +227 -0
- ghost_api/export/site_data.py +196 -0
- ghost_api/export/tabular.py +88 -0
- ghost_api/export/viewer/report.html +286 -0
- ghost_api/generator/__init__.py +221 -0
- ghost_api/generator/cassettes.py +80 -0
- ghost_api/generator/models_gen.py +144 -0
- ghost_api/generator/naming.py +174 -0
- ghost_api/generator/package.py +197 -0
- ghost_api/generator/python_gen.py +172 -0
- ghost_api/generator/spec.py +486 -0
- ghost_api/generator/templates/client.py.j2 +518 -0
- ghost_api/generator/templates/client.test.ts.j2 +106 -0
- ghost_api/generator/templates/client.ts.j2 +339 -0
- ghost_api/generator/templates/models.py.j2 +45 -0
- ghost_api/generator/templates/package_readme.md.j2 +111 -0
- ghost_api/generator/templates/pyproject.toml.j2 +22 -0
- ghost_api/generator/templates/test_client.py.j2 +105 -0
- ghost_api/generator/ts_gen.py +232 -0
- ghost_api/integrations/__init__.py +0 -0
- ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
- ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
- ghost_api/integrations/setup.py +84 -0
- ghost_api/llm/__init__.py +0 -0
- ghost_api/llm/doctor.py +60 -0
- ghost_api/llm/gateway.py +476 -0
- ghost_api/llm/providers.py +124 -0
- ghost_api/memory/__init__.py +0 -0
- ghost_api/memory/knowledge.py +157 -0
- ghost_api/memory/store.py +883 -0
- ghost_api/net.py +235 -0
- ghost_api/netguard.py +203 -0
- ghost_api/pipeline.py +745 -0
- ghost_api/probe/__init__.py +0 -0
- ghost_api/probe/prober.py +311 -0
- ghost_api/probe/replay.py +313 -0
- ghost_api/project.py +186 -0
- ghost_api/py.typed +0 -0
- ghost_api/runtime.py +397 -0
- ghost_api/serve/__init__.py +101 -0
- ghost_api/serve/explorer_mcp.py +305 -0
- ghost_api/serve/mcp.py +121 -0
- ghost_api/serve/mock.py +130 -0
- ghost_api/serve/rest.py +132 -0
- ghost_api/site.py +84 -0
- ghost_api/specdiff.py +122 -0
- ghost_api/ui/__init__.py +0 -0
- ghost_api/ui/keys.py +134 -0
- ghost_api/ui/repl.py +453 -0
- ghost_api/ui/terminal.py +573 -0
- ghost_api/verify/__init__.py +0 -0
- ghost_api/verify/classify.py +84 -0
- ghost_api/verify/repair.py +40 -0
- ghost_api/verify/runner.py +110 -0
- ghost_api/verify/verifier.py +291 -0
- ghost_api/watch.py +313 -0
- ghost_api/web/__init__.py +1 -0
- ghost_api/web/server.py +690 -0
- ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
- ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
- ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
- ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
- ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
- ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
- ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
- ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
- ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
- ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
- ghost_api/web/static/favicon.svg +1 -0
- ghost_api/web/static/index.html +26 -0
- ghost_api/workflows.py +203 -0
- ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
- ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
- ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
- ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
ghost_api/__init__.py
ADDED
ghost_api/__main__.py
ADDED
ghost_api/adopt.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""`ghost-api adopt PATH`: find hand-written calls to undocumented APIs in a codebase.
|
|
2
|
+
|
|
3
|
+
Scans source files for HTTP calls with a literal URL (fetch, axios, requests, httpx,
|
|
4
|
+
urllib, aiohttp...), groups them by host and, for hosts Ghost API has explored, matches each
|
|
5
|
+
call to the generated client method that replaces it. The result is a replacement map a
|
|
6
|
+
developer (or an AI assistant) can apply; nothing is edited here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
from ghost_api.analysis.graphql import split_template
|
|
18
|
+
from ghost_api.generator import NothingToGenerate, site_spec
|
|
19
|
+
from ghost_api.memory.store import Store
|
|
20
|
+
from ghost_api.site import SiteAmbiguous, resolve_site
|
|
21
|
+
|
|
22
|
+
SOURCE_SUFFIXES = {".py", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".vue", ".svelte"}
|
|
23
|
+
SKIP_DIRS = {
|
|
24
|
+
"node_modules", ".git", ".venv", "venv", "env", "dist", "build", "__pycache__", ".next",
|
|
25
|
+
"out", "output", "coverage", ".tox", ".mypy_cache", ".ruff_cache", "site-packages",
|
|
26
|
+
} # fmt: skip
|
|
27
|
+
MAX_FILE_BYTES = 1_000_000
|
|
28
|
+
|
|
29
|
+
# A call that sends a request, then the first string literal URL in its arguments.
|
|
30
|
+
CALL = re.compile(
|
|
31
|
+
r"""(?P<call>
|
|
32
|
+
\bfetch\s*\(
|
|
33
|
+
| \baxios(?:\.(?P<axios>get|post|put|patch|delete))?\s*\(
|
|
34
|
+
| \b(?:requests|httpx|session|client|aiohttp\.ClientSession\(\)|ky|got|superagent)
|
|
35
|
+
\.(?P<verb>get|post|put|patch|delete|request)\s*\(
|
|
36
|
+
| \burlopen\s*\(
|
|
37
|
+
| \$\.(?:get|post|ajax|getJSON)\s*\(
|
|
38
|
+
)\s*(?:["'`](?P<method_arg>GET|POST|PUT|PATCH|DELETE)["'`]\s*,\s*)?
|
|
39
|
+
[fF]?["'`](?P<url>https?://[^"'`\s]+)["'`]""",
|
|
40
|
+
re.VERBOSE,
|
|
41
|
+
)
|
|
42
|
+
METHOD_OPTION = re.compile(r"""method\s*[:=]\s*["'`](GET|POST|PUT|PATCH|DELETE)["'`]""", re.I)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Call:
|
|
47
|
+
file: str
|
|
48
|
+
line: int
|
|
49
|
+
method: str
|
|
50
|
+
url: str
|
|
51
|
+
code: str # the line, trimmed
|
|
52
|
+
replacement: str | None = None # "client.get_quotes(page=2)"
|
|
53
|
+
endpoint: str | None = None # "GET /api/quotes"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class HostReport:
|
|
58
|
+
host: str
|
|
59
|
+
site: str | None # the explored site that covers it, if any
|
|
60
|
+
calls: list[Call] = field(default_factory=list)
|
|
61
|
+
client_class: str | None = None
|
|
62
|
+
next_step: str = ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def scan(root: Path) -> list[Call]:
|
|
66
|
+
"""Every HTTP call with a literal http(s) URL under `root`."""
|
|
67
|
+
calls: list[Call] = []
|
|
68
|
+
for path in sorted(_source_files(root)):
|
|
69
|
+
try:
|
|
70
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
71
|
+
except OSError:
|
|
72
|
+
continue
|
|
73
|
+
for match in CALL.finditer(text):
|
|
74
|
+
line = text.count("\n", 0, match.start()) + 1
|
|
75
|
+
window = text[match.end() : match.end() + 300]
|
|
76
|
+
method = (
|
|
77
|
+
match.group("method_arg")
|
|
78
|
+
or (match.group("verb") or match.group("axios") or "").upper()
|
|
79
|
+
or _method_option(window)
|
|
80
|
+
or "GET"
|
|
81
|
+
)
|
|
82
|
+
if method == "REQUEST":
|
|
83
|
+
method = _method_option(window) or "GET"
|
|
84
|
+
code = text.splitlines()[line - 1].strip()[:160]
|
|
85
|
+
calls.append(Call(str(path.relative_to(root)), line, method,
|
|
86
|
+
match.group("url").rstrip(").,;"), code)) # fmt: skip
|
|
87
|
+
return calls
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _method_option(window: str) -> str | None:
|
|
91
|
+
found = METHOD_OPTION.search(window.split(")", 1)[0])
|
|
92
|
+
return found.group(1).upper() if found else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _source_files(root: Path) -> list[Path]:
|
|
96
|
+
files = []
|
|
97
|
+
for path in root.rglob("*"):
|
|
98
|
+
if any(part in SKIP_DIRS for part in path.relative_to(root).parts):
|
|
99
|
+
continue
|
|
100
|
+
small = path.is_file() and path.stat().st_size <= MAX_FILE_BYTES
|
|
101
|
+
if path.suffix in SOURCE_SUFFIXES and small:
|
|
102
|
+
files.append(path)
|
|
103
|
+
return files
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def adopt(root: Path, store: Store | None) -> list[HostReport]:
|
|
107
|
+
"""The calls grouped by host, matched to explored sites' client methods."""
|
|
108
|
+
by_host: dict[str, list[Call]] = {}
|
|
109
|
+
for call in scan(root):
|
|
110
|
+
host = (urlsplit(call.url).hostname or "").lower()
|
|
111
|
+
if host and host not in ("localhost", "127.0.0.1"):
|
|
112
|
+
by_host.setdefault(host, []).append(call)
|
|
113
|
+
known = sorted({r.domain for r in store.list_runs()}) if store is not None else []
|
|
114
|
+
reports = []
|
|
115
|
+
for host, calls in sorted(by_host.items(), key=lambda kv: -len(kv[1])):
|
|
116
|
+
site = _site_for(host, calls, known)
|
|
117
|
+
report = HostReport(host, site, calls)
|
|
118
|
+
if site is None or store is None:
|
|
119
|
+
origin = f"https://{host}"
|
|
120
|
+
report.next_step = (
|
|
121
|
+
f'ghost-api run {origin} --goal "..." (or record it in your browser and '
|
|
122
|
+
"ghost-api import FILE.har)"
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
_match(store, site, report)
|
|
126
|
+
reports.append(report)
|
|
127
|
+
return reports
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _site_for(host: str, calls: list[Call], known: list[str]) -> str | None:
|
|
131
|
+
candidates = [s for s in known if s.split("/")[0] == host]
|
|
132
|
+
if not candidates:
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
return resolve_site(host, set(candidates))
|
|
136
|
+
except SiteAmbiguous: # several sections: the one most calls' paths start with
|
|
137
|
+
paths = [urlsplit(c.url).path for c in calls]
|
|
138
|
+
return max(candidates, key=lambda s: sum(p.startswith("/" + s.partition("/")[2])
|
|
139
|
+
for p in paths)) # fmt: skip
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _match(store: Store, site: str, report: HostReport) -> None:
|
|
143
|
+
try:
|
|
144
|
+
spec = site_spec(store, site)
|
|
145
|
+
except NothingToGenerate:
|
|
146
|
+
report.next_step = f"ghost-api refresh {site} (nothing usable stored yet)"
|
|
147
|
+
return
|
|
148
|
+
report.client_class = spec.class_name
|
|
149
|
+
patterns = []
|
|
150
|
+
for method in spec.methods:
|
|
151
|
+
path, _ = split_template(method.path)
|
|
152
|
+
regex = "^" + re.sub(r"\\\{[^}]*\\\}", "[^/]+", re.escape(path)) + "/?$"
|
|
153
|
+
patterns.append((method, re.compile(regex)))
|
|
154
|
+
unmatched = 0
|
|
155
|
+
for call in report.calls:
|
|
156
|
+
parts = urlsplit(call.url)
|
|
157
|
+
found = next(
|
|
158
|
+
(m for m, rx in patterns if m.http_method == call.method and rx.match(parts.path)),
|
|
159
|
+
None,
|
|
160
|
+
)
|
|
161
|
+
if found is None:
|
|
162
|
+
unmatched += 1
|
|
163
|
+
continue
|
|
164
|
+
call.endpoint = f"{found.http_method} {found.path}"
|
|
165
|
+
args = ", ".join(f"{p.name}=..." for p in found.required)
|
|
166
|
+
call.replacement = f"api.{found.name}({args})"
|
|
167
|
+
report.next_step = f"ghost-api generate {site} then use {spec.class_name}" + (
|
|
168
|
+
f"; {unmatched} call(s) not mapped yet: ghost-api refresh {site}" if unmatched else ""
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def to_json(reports: list[HostReport]) -> list[dict[str, Any]]:
|
|
173
|
+
return [asdict(r) for r in reports]
|
|
File without changes
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""The exploration loop: observe -> decide -> act -> learn (Stage 4).
|
|
2
|
+
|
|
3
|
+
Stopping rules (any one ends the loop):
|
|
4
|
+
- the agent finishes (goal covered, or the site has no API for it)
|
|
5
|
+
- the step budget is used up
|
|
6
|
+
- the LLM call budget is used up, or no provider can answer
|
|
7
|
+
- no new endpoint for NO_PROGRESS_STEPS steps in a row
|
|
8
|
+
- the time limit is reached
|
|
9
|
+
- the user presses Ctrl+C (partial results are kept)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from functools import partial
|
|
18
|
+
from typing import Protocol
|
|
19
|
+
from urllib.parse import urljoin, urlsplit
|
|
20
|
+
|
|
21
|
+
from ghost_api.agent.prompts import SYSTEM_PROMPT, HistoryItem, step_prompt
|
|
22
|
+
from ghost_api.agent.tools import Decision, is_typeable
|
|
23
|
+
from ghost_api.analysis.analyzer import analyze, endpoint_keys
|
|
24
|
+
from ghost_api.analysis.detectors import find_items, is_bot_challenge
|
|
25
|
+
from ghost_api.analysis.models import Endpoint
|
|
26
|
+
from ghost_api.browser.driver import ActionError
|
|
27
|
+
from ghost_api.browser.snapshot import PageSnapshot
|
|
28
|
+
from ghost_api.capture.filters import is_same_site, redact_text
|
|
29
|
+
from ghost_api.capture.models import CapturedRequest, RawRequest
|
|
30
|
+
from ghost_api.llm.gateway import BudgetExceeded, LLMReply, LLMUnavailable
|
|
31
|
+
from ghost_api.net import RobotsPolicy
|
|
32
|
+
from ghost_api.probe.replay import Replayer, ReplayRefused, apply_params, is_replayable
|
|
33
|
+
|
|
34
|
+
NO_PROGRESS_STEPS = 8
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Browser(Protocol):
|
|
38
|
+
"""What the orchestrator needs from a browser (BrowserSession, or a fake in tests)."""
|
|
39
|
+
|
|
40
|
+
popup_urls: list[str]
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def url(self) -> str: ...
|
|
44
|
+
|
|
45
|
+
def snapshot(self, target_host: str, robots: RobotsPolicy | None = None) -> PageSnapshot: ...
|
|
46
|
+
def goto(self, url: str) -> list[CapturedRequest]: ...
|
|
47
|
+
def click(self, element_id: int) -> list[CapturedRequest]: ...
|
|
48
|
+
def type_text(self, element_id: int, text: str, submit: bool) -> list[CapturedRequest]: ...
|
|
49
|
+
def scroll(self, direction: str = "down") -> list[CapturedRequest]: ...
|
|
50
|
+
def go_back(self) -> list[CapturedRequest]: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LLM(Protocol):
|
|
54
|
+
def ask(
|
|
55
|
+
self,
|
|
56
|
+
messages: list[dict[str, str]],
|
|
57
|
+
response_model: type[Decision] | None = None,
|
|
58
|
+
temperature: float = 0.0,
|
|
59
|
+
) -> LLMReply[Decision]: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class StepRecord:
|
|
64
|
+
step: int
|
|
65
|
+
thought: str
|
|
66
|
+
action: str
|
|
67
|
+
args: dict[str, object]
|
|
68
|
+
result: str
|
|
69
|
+
new_endpoints: list[str] = field(default_factory=list)
|
|
70
|
+
requests: int = 0
|
|
71
|
+
screenshot: str | None = None # name of the screenshot taken after this step
|
|
72
|
+
box: dict[str, float] | None = None # the element clicked or typed into
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class AgentResult:
|
|
77
|
+
status: str # goal-covered | finished | max-steps | no-progress | time-limit |
|
|
78
|
+
# llm-budget | llm-unavailable | bot-protection | interrupted
|
|
79
|
+
reason: str
|
|
80
|
+
steps: list[StepRecord]
|
|
81
|
+
endpoints: list[Endpoint]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Explorer:
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
browser: Browser,
|
|
88
|
+
llm: LLM | None,
|
|
89
|
+
captured: Callable[[], list[CapturedRequest]],
|
|
90
|
+
goal: str,
|
|
91
|
+
start_url: str,
|
|
92
|
+
target_host: str,
|
|
93
|
+
max_steps: int = 40,
|
|
94
|
+
max_minutes: float = 15.0,
|
|
95
|
+
robots: RobotsPolicy | None = None,
|
|
96
|
+
on_step: Callable[[StepRecord], None] | None = None,
|
|
97
|
+
clock: Callable[[], float] = time.monotonic,
|
|
98
|
+
replayer: Replayer | None = None,
|
|
99
|
+
raw_requests: Callable[[], list[RawRequest | None]] | None = None,
|
|
100
|
+
add_sample: Callable[[CapturedRequest, RawRequest], object] | None = None,
|
|
101
|
+
ask_user: Callable[[str], str] | None = None,
|
|
102
|
+
memory: str = "",
|
|
103
|
+
screenshot: Callable[[str], str | None] | None = None,
|
|
104
|
+
should_stop: Callable[[], bool] | None = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
self.browser = browser
|
|
107
|
+
self.llm = llm
|
|
108
|
+
self.captured = captured
|
|
109
|
+
self.goal = goal
|
|
110
|
+
self.start_url = start_url
|
|
111
|
+
self.host = target_host
|
|
112
|
+
self.max_steps = max_steps
|
|
113
|
+
self.deadline_s = max_minutes * 60
|
|
114
|
+
self.robots = robots
|
|
115
|
+
self.on_step = on_step
|
|
116
|
+
self.clock = clock
|
|
117
|
+
self.replayer = replayer
|
|
118
|
+
self.raw_requests = raw_requests
|
|
119
|
+
self.add_sample = add_sample
|
|
120
|
+
self.ask_user = ask_user
|
|
121
|
+
self.memory = memory # notes from earlier runs on this site (Stage 8)
|
|
122
|
+
# Saves a screenshot under the given name and returns it (for the live view).
|
|
123
|
+
self.screenshot = screenshot
|
|
124
|
+
self.should_stop = should_stop # e.g. the dashboard's Stop button
|
|
125
|
+
self.steps: list[StepRecord] = []
|
|
126
|
+
self.history: list[HistoryItem] = []
|
|
127
|
+
self._known: set[str] = set()
|
|
128
|
+
|
|
129
|
+
# --- main loop ---
|
|
130
|
+
|
|
131
|
+
def run(self) -> AgentResult:
|
|
132
|
+
started = self.clock()
|
|
133
|
+
try:
|
|
134
|
+
self.browser.goto(self.start_url)
|
|
135
|
+
self._shoot("start")
|
|
136
|
+
known = {e.key for e in self._endpoints()}
|
|
137
|
+
since_progress = 0
|
|
138
|
+
for step in range(1, self.max_steps + 1):
|
|
139
|
+
if self.clock() - started > self.deadline_s:
|
|
140
|
+
return self._result("time-limit", "time limit reached")
|
|
141
|
+
if self.should_stop is not None and self.should_stop():
|
|
142
|
+
return self._result("interrupted", "stopped by user")
|
|
143
|
+
snapshot = self.browser.snapshot(self.host, self.robots)
|
|
144
|
+
if is_bot_challenge(snapshot.title, snapshot.text):
|
|
145
|
+
# Never try to get past bot protection (NFR-19).
|
|
146
|
+
return self._result(
|
|
147
|
+
"bot-protection", f"bot-protection page detected: {snapshot.title!r}"
|
|
148
|
+
)
|
|
149
|
+
try:
|
|
150
|
+
decision = self._decide(step, snapshot)
|
|
151
|
+
except BudgetExceeded as exc:
|
|
152
|
+
return self._result("llm-budget", str(exc))
|
|
153
|
+
except LLMUnavailable as exc:
|
|
154
|
+
return self._result("llm-unavailable", str(exc))
|
|
155
|
+
|
|
156
|
+
if decision.action == "finish":
|
|
157
|
+
self._record(step, decision, "finished", [], 0)
|
|
158
|
+
# "Covered" only when the agent names nothing missing and found something.
|
|
159
|
+
covered = decision.goal_covered and not decision.missing.strip() and bool(known)
|
|
160
|
+
status = "goal-covered" if covered else "finished"
|
|
161
|
+
return self._result(status, decision.thought)
|
|
162
|
+
|
|
163
|
+
box = self._box(decision)
|
|
164
|
+
result, new_requests = self._execute(decision, snapshot)
|
|
165
|
+
endpoints = self._endpoints()
|
|
166
|
+
new = [self._label(e) for e in endpoints if e.key not in known]
|
|
167
|
+
known |= {e.key for e in endpoints}
|
|
168
|
+
shot = self._shoot(f"step-{step}")
|
|
169
|
+
self._record(step, decision, result, new, len(new_requests), shot, box)
|
|
170
|
+
|
|
171
|
+
since_progress = 0 if new else since_progress + 1
|
|
172
|
+
if since_progress >= NO_PROGRESS_STEPS:
|
|
173
|
+
return self._result(
|
|
174
|
+
"no-progress", f"no new endpoint in {NO_PROGRESS_STEPS} steps"
|
|
175
|
+
)
|
|
176
|
+
return self._result("max-steps", f"used all {self.max_steps} steps")
|
|
177
|
+
except KeyboardInterrupt:
|
|
178
|
+
return self._result("interrupted", "stopped by user")
|
|
179
|
+
|
|
180
|
+
# --- driven step by step from outside (an assistant through MCP: serve/explorer_mcp) ---
|
|
181
|
+
|
|
182
|
+
def begin(self) -> None:
|
|
183
|
+
"""Open the start page; then call `act` once per decision."""
|
|
184
|
+
self.browser.goto(self.start_url)
|
|
185
|
+
self._shoot("start")
|
|
186
|
+
self._known = {e.key for e in self._endpoints()}
|
|
187
|
+
|
|
188
|
+
def act(self, decision: Decision) -> StepRecord:
|
|
189
|
+
"""Run one action decided elsewhere, with every rule of `run` (same-site only, no
|
|
190
|
+
destructive elements, robots.txt, read-only endpoint tests). Raises BotProtection
|
|
191
|
+
on a bot-protection page: nothing is done there."""
|
|
192
|
+
snapshot = self.browser.snapshot(self.host, self.robots)
|
|
193
|
+
if is_bot_challenge(snapshot.title, snapshot.text):
|
|
194
|
+
raise BotProtection(snapshot.title)
|
|
195
|
+
step = len(self.steps) + 1
|
|
196
|
+
box = self._box(decision)
|
|
197
|
+
result, new_requests = self._execute(decision, snapshot)
|
|
198
|
+
endpoints = self._endpoints()
|
|
199
|
+
new = [self._label(e) for e in endpoints if e.key not in self._known]
|
|
200
|
+
self._known |= {e.key for e in endpoints}
|
|
201
|
+
shot = self._shoot(f"step-{step}")
|
|
202
|
+
self._record(step, decision, result, new, len(new_requests), shot, box)
|
|
203
|
+
return self.steps[-1]
|
|
204
|
+
|
|
205
|
+
# --- decide ---
|
|
206
|
+
|
|
207
|
+
def _decide(self, step: int, snapshot: PageSnapshot) -> Decision:
|
|
208
|
+
assert self.llm is not None, "run() needs an LLM; act() is for outside decisions"
|
|
209
|
+
prompt = step_prompt(
|
|
210
|
+
self.goal,
|
|
211
|
+
step,
|
|
212
|
+
self.max_steps,
|
|
213
|
+
snapshot,
|
|
214
|
+
self._endpoints(),
|
|
215
|
+
self.host,
|
|
216
|
+
self.history,
|
|
217
|
+
self.memory,
|
|
218
|
+
)
|
|
219
|
+
reply = self.llm.ask(
|
|
220
|
+
[{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt}],
|
|
221
|
+
response_model=Decision,
|
|
222
|
+
)
|
|
223
|
+
assert reply.parsed is not None # the gateway validates structured replies
|
|
224
|
+
return reply.parsed
|
|
225
|
+
|
|
226
|
+
# --- act ---
|
|
227
|
+
|
|
228
|
+
def _execute(
|
|
229
|
+
self, decision: Decision, snapshot: PageSnapshot
|
|
230
|
+
) -> tuple[str, list[CapturedRequest]]:
|
|
231
|
+
"""Run one action. Returns a short result for the agent and the new requests."""
|
|
232
|
+
if decision.action == "call_endpoint":
|
|
233
|
+
return self._call_endpoint(decision)
|
|
234
|
+
if decision.action == "ask_user":
|
|
235
|
+
return self._ask(decision), []
|
|
236
|
+
try:
|
|
237
|
+
new = self._dispatch(decision, snapshot)
|
|
238
|
+
except _Rejected as exc:
|
|
239
|
+
return f"rejected: {exc}", []
|
|
240
|
+
except ActionError as exc:
|
|
241
|
+
return f"failed: {exc}", []
|
|
242
|
+
|
|
243
|
+
notes: list[str] = []
|
|
244
|
+
# A link that tried to open a new tab: follow it in this tab if it's allowed.
|
|
245
|
+
while self.browser.popup_urls:
|
|
246
|
+
popup = self.browser.popup_urls.pop(0)
|
|
247
|
+
if self._allowed_url(popup):
|
|
248
|
+
new += self._safe(partial(self.browser.goto, popup))
|
|
249
|
+
notes.append("opened link in same tab")
|
|
250
|
+
if not is_same_site(self.browser.url, self.host):
|
|
251
|
+
new += self._safe(self.browser.go_back)
|
|
252
|
+
notes.append("left the site, went back")
|
|
253
|
+
return _describe(new, self.browser.url, notes), new
|
|
254
|
+
|
|
255
|
+
def _dispatch(self, d: Decision, snapshot: PageSnapshot) -> list[CapturedRequest]:
|
|
256
|
+
if d.action in ("click", "type"):
|
|
257
|
+
assert d.element_id is not None
|
|
258
|
+
el = snapshot.element(d.element_id)
|
|
259
|
+
if el is None:
|
|
260
|
+
raise _Rejected(f"[{d.element_id}] is not in the element list")
|
|
261
|
+
if d.action == "click":
|
|
262
|
+
return self.browser.click(d.element_id)
|
|
263
|
+
if not is_typeable(el.kind):
|
|
264
|
+
raise _Rejected(f"[{d.element_id}] is a {el.kind}, not a text field")
|
|
265
|
+
assert d.text is not None
|
|
266
|
+
return self.browser.type_text(d.element_id, d.text, d.submit)
|
|
267
|
+
if d.action == "scroll":
|
|
268
|
+
return self.browser.scroll(d.direction or "down")
|
|
269
|
+
if d.action == "navigate":
|
|
270
|
+
assert d.url is not None
|
|
271
|
+
url = urljoin(self.browser.url, d.url)
|
|
272
|
+
if not self._allowed_url(url):
|
|
273
|
+
raise _Rejected(f"{d.url} is off-site, not http(s), or disallowed by robots.txt")
|
|
274
|
+
return self.browser.goto(url)
|
|
275
|
+
if d.action == "go_back":
|
|
276
|
+
return self.browser.go_back()
|
|
277
|
+
raise _Rejected(f"unknown action {d.action}")
|
|
278
|
+
|
|
279
|
+
def _call_endpoint(self, d: Decision) -> tuple[str, list[CapturedRequest]]:
|
|
280
|
+
if self.replayer is None or self.raw_requests is None:
|
|
281
|
+
return "rejected: calling endpoints directly is not enabled", []
|
|
282
|
+
endpoints = self._endpoints()
|
|
283
|
+
target = _find_endpoint(d.endpoint or "", endpoints, self.host)
|
|
284
|
+
if target is None:
|
|
285
|
+
return f"rejected: no endpoint named {d.endpoint!r}; use a name as listed", []
|
|
286
|
+
keys = endpoint_keys(self.captured())
|
|
287
|
+
raws = self.raw_requests()
|
|
288
|
+
samples = [r for k, r in zip(keys, raws, strict=False) if k == target.key and r]
|
|
289
|
+
sample = next((s for s in reversed(samples) if is_replayable(s)), None)
|
|
290
|
+
if sample is None:
|
|
291
|
+
return "rejected: this endpoint can't be called safely (not a read-only request)", []
|
|
292
|
+
params = d.params or {}
|
|
293
|
+
request = apply_params(
|
|
294
|
+
sample, target.path_template, {p.name for p in target.path_params}, params
|
|
295
|
+
)
|
|
296
|
+
try:
|
|
297
|
+
result = self.replayer.send(request)
|
|
298
|
+
except ReplayRefused as exc:
|
|
299
|
+
return f"rejected: {exc}", []
|
|
300
|
+
if result.captured is None:
|
|
301
|
+
return f"failed: {result.error}", []
|
|
302
|
+
if self.add_sample:
|
|
303
|
+
self.add_sample(result.captured, request)
|
|
304
|
+
items = find_items(result.body) if result.ok else None
|
|
305
|
+
summary = f"HTTP {result.status}"
|
|
306
|
+
if result.error:
|
|
307
|
+
summary += f", {result.error}"
|
|
308
|
+
elif items is not None:
|
|
309
|
+
summary += f", {len(items)} records"
|
|
310
|
+
return f"called {target.label} with {params}: {summary}", [result.captured]
|
|
311
|
+
|
|
312
|
+
def _ask(self, d: Decision) -> str:
|
|
313
|
+
if self.ask_user is None:
|
|
314
|
+
return "rejected: no human is available (run with --headed in a terminal)"
|
|
315
|
+
answer = self.ask_user(d.question or "")
|
|
316
|
+
return f"user answered: {redact_text(answer)[:200] or '(no answer)'}"
|
|
317
|
+
|
|
318
|
+
def _allowed_url(self, url: str) -> bool:
|
|
319
|
+
if urlsplit(url).scheme not in ("http", "https") or not is_same_site(url, self.host):
|
|
320
|
+
return False
|
|
321
|
+
return self.robots is None or self.robots.allows(url)
|
|
322
|
+
|
|
323
|
+
def _safe(self, action: Callable[[], list[CapturedRequest]]) -> list[CapturedRequest]:
|
|
324
|
+
try:
|
|
325
|
+
return action()
|
|
326
|
+
except ActionError:
|
|
327
|
+
return []
|
|
328
|
+
|
|
329
|
+
# --- learn / record ---
|
|
330
|
+
|
|
331
|
+
def _label(self, ep: Endpoint) -> str:
|
|
332
|
+
return ep.label if ep.host == self.host else f"{ep.method} {ep.host}{ep.path_template}"
|
|
333
|
+
|
|
334
|
+
def _endpoints(self) -> list[Endpoint]:
|
|
335
|
+
return analyze(self.captured())
|
|
336
|
+
|
|
337
|
+
def _shoot(self, name: str) -> str | None:
|
|
338
|
+
if self.screenshot is None:
|
|
339
|
+
return None
|
|
340
|
+
try:
|
|
341
|
+
return self.screenshot(name)
|
|
342
|
+
except Exception: # noqa: BLE001 - a missing screenshot must not stop the run
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
def _box(self, d: Decision) -> dict[str, float] | None:
|
|
346
|
+
locate = getattr(self.browser, "element_box", None)
|
|
347
|
+
if d.action not in ("click", "type") or d.element_id is None or locate is None:
|
|
348
|
+
return None
|
|
349
|
+
try:
|
|
350
|
+
box: dict[str, float] | None = locate(d.element_id)
|
|
351
|
+
except Exception: # noqa: BLE001
|
|
352
|
+
return None
|
|
353
|
+
return box
|
|
354
|
+
|
|
355
|
+
def _record(
|
|
356
|
+
self,
|
|
357
|
+
step: int,
|
|
358
|
+
d: Decision,
|
|
359
|
+
result: str,
|
|
360
|
+
new_endpoints: list[str],
|
|
361
|
+
requests: int,
|
|
362
|
+
screenshot: str | None = None,
|
|
363
|
+
box: dict[str, float] | None = None,
|
|
364
|
+
) -> None:
|
|
365
|
+
if new_endpoints:
|
|
366
|
+
result += f"; NEW ENDPOINT: {', '.join(new_endpoints)}"
|
|
367
|
+
rec = StepRecord(
|
|
368
|
+
step, d.thought, d.action, d.args(), result, new_endpoints, requests, screenshot, box
|
|
369
|
+
)
|
|
370
|
+
self.steps.append(rec)
|
|
371
|
+
self.history.append(HistoryItem(step, d.action, d.args(), result))
|
|
372
|
+
if self.on_step:
|
|
373
|
+
self.on_step(rec)
|
|
374
|
+
|
|
375
|
+
def _result(self, status: str, reason: str) -> AgentResult:
|
|
376
|
+
return AgentResult(status, reason, self.steps, self._endpoints())
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class _Rejected(Exception):
|
|
380
|
+
pass
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class BotProtection(Exception):
|
|
384
|
+
"""The page is a bot-protection challenge; Ghost API never tries to get past it."""
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _find_endpoint(name: str, endpoints: list[Endpoint], host: str) -> Endpoint | None:
|
|
388
|
+
"""Match 'GET /api/x', 'GET host/api/x' or the prompt's '... [third-party: host]'."""
|
|
389
|
+
wanted = " ".join(name.split("[", 1)[0].split()).lower()
|
|
390
|
+
for ep in endpoints:
|
|
391
|
+
names = {ep.label.lower(), f"{ep.method} {ep.host}{ep.path_template}".lower()}
|
|
392
|
+
if wanted in names:
|
|
393
|
+
return ep
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _describe(new: list[CapturedRequest], url: str, notes: list[str]) -> str:
|
|
398
|
+
if new:
|
|
399
|
+
shown = ", ".join(f"{r.method} {_path(r.url)}" for r in new[:4])
|
|
400
|
+
more = f" (+{len(new) - 4} more)" if len(new) > 4 else ""
|
|
401
|
+
text = f"{len(new)} new request{'s' if len(new) != 1 else ''}: {shown}{more}"
|
|
402
|
+
else:
|
|
403
|
+
text = "no new requests"
|
|
404
|
+
text += f"; page is now {_path(url)}"
|
|
405
|
+
if notes:
|
|
406
|
+
text += f" ({'; '.join(notes)})"
|
|
407
|
+
return text
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _path(url: str) -> str:
|
|
411
|
+
parts = urlsplit(url)
|
|
412
|
+
path = (parts.path or "/") + (f"?{parts.query}" if parts.query else "")
|
|
413
|
+
return path if len(path) <= 60 else path[:57] + "..."
|