ai-browser-toolkit 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- abt/__init__.py +3 -0
- abt/__main__.py +10 -0
- abt/autostart.py +405 -0
- abt/browser.py +851 -0
- abt/cli.py +828 -0
- abt/diff.py +652 -0
- abt/doctor.py +220 -0
- abt/engine.py +241 -0
- abt/errors.py +34 -0
- abt/frames.py +134 -0
- abt/launch.py +64 -0
- abt/mcp.py +438 -0
- abt/messenger.py +782 -0
- abt/ops/__init__.py +306 -0
- abt/ops/control.py +165 -0
- abt/ops/inspect.py +228 -0
- abt/ops/interact.py +665 -0
- abt/ops/navigate.py +41 -0
- abt/ops/read.py +124 -0
- abt/ops/tabs.py +24 -0
- abt/paths.py +131 -0
- abt/proc.py +208 -0
- abt/pwdriver.py +1277 -0
- abt/recorder.py +235 -0
- abt/refs.py +91 -0
- abt/schema.py +450 -0
- abt/server.py +457 -0
- abt/shadow.py +127 -0
- abt/shots.py +148 -0
- abt/targeting.py +359 -0
- abt/viewer.py +453 -0
- ai_browser_toolkit-0.1.2.dist-info/METADATA +940 -0
- ai_browser_toolkit-0.1.2.dist-info/RECORD +36 -0
- ai_browser_toolkit-0.1.2.dist-info/WHEEL +4 -0
- ai_browser_toolkit-0.1.2.dist-info/entry_points.txt +2 -0
- ai_browser_toolkit-0.1.2.dist-info/licenses/LICENSE +201 -0
abt/__init__.py
ADDED
abt/__main__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""`python -m abt`, which the bundle's launcher shim invokes.
|
|
2
|
+
|
|
3
|
+
The shim cannot use the generated `abt` console script: that script carries an
|
|
4
|
+
absolute shebang pointing at whatever interpreter path existed when the wheel
|
|
5
|
+
was installed, which on a build runner is not a path that exists anywhere else.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .cli import app
|
|
9
|
+
|
|
10
|
+
app()
|
abt/autostart.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Start the server at login. Opt in, never installed by default.
|
|
2
|
+
|
|
3
|
+
The toolkit is only useful when it is already up: an agent that has to start a
|
|
4
|
+
server first pays for the start, and one that starts it wrongly wedges itself.
|
|
5
|
+
A logon entry removes that, so `GET /status` answers from the moment you sit
|
|
6
|
+
down.
|
|
7
|
+
|
|
8
|
+
**This is only safe because the server no longer launches a browser.** It
|
|
9
|
+
listens in about a second and waits for `browser_start`. Installed against the
|
|
10
|
+
older behaviour it would open Chrome on the persistent profile at every logon
|
|
11
|
+
and cost roughly two minutes of every boot -- which is why this was parked
|
|
12
|
+
behind the browser/server decoupling rather than merely after it.
|
|
13
|
+
|
|
14
|
+
## Three mechanisms, one shape
|
|
15
|
+
|
|
16
|
+
| Platform | What gets written |
|
|
17
|
+
|---|---|
|
|
18
|
+
| Windows | a Task Scheduler logon task, via `schtasks` |
|
|
19
|
+
| macOS | a launchd `LaunchAgent` plist under `~/Library/LaunchAgents` |
|
|
20
|
+
| Linux | a systemd **user** unit under `~/.config/systemd/user` |
|
|
21
|
+
|
|
22
|
+
A user-level entry throughout: no elevation, no system service, and the browser
|
|
23
|
+
profile stays in the account that owns the logins. A system service would run as
|
|
24
|
+
another user and find none of them.
|
|
25
|
+
|
|
26
|
+
## What the plan has to get right
|
|
27
|
+
|
|
28
|
+
Three things that are invisible until they bite, all of them observed:
|
|
29
|
+
|
|
30
|
+
* **An absolute interpreter and absolute paths.** A logon entry has no working
|
|
31
|
+
directory of its own and no venv on `PATH`. A relative `./profiles/default` resolves
|
|
32
|
+
against whatever the launcher's cwd happens to be -- on Windows that is
|
|
33
|
+
`C:\\Windows\\System32` -- so the server would quietly build a second, empty
|
|
34
|
+
profile there and none of your logins would be in it.
|
|
35
|
+
|
|
36
|
+
* **`--browser` stated explicitly.** `abt serve` prompts when the flag is
|
|
37
|
+
missing and stdin is a tty. A Task Scheduler task gets a console, so it
|
|
38
|
+
prompts, and then waits forever with nobody to answer. The only evidence is
|
|
39
|
+
one line in the log reading `Select browser to use [chrome]:`. Windows also
|
|
40
|
+
gets `< NUL` for the same reason, belt and braces.
|
|
41
|
+
|
|
42
|
+
* **Never `--start-browser`.** See above; this is the whole reason the feature
|
|
43
|
+
is sane.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import os
|
|
49
|
+
import platform
|
|
50
|
+
import shutil
|
|
51
|
+
import subprocess
|
|
52
|
+
import sys
|
|
53
|
+
from dataclasses import dataclass, field
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
|
|
56
|
+
from .proc import windows_command_line
|
|
57
|
+
|
|
58
|
+
# One name per platform, in that platform's convention. Stable, because
|
|
59
|
+
# uninstall finds the entry by name and a rename would orphan the old one.
|
|
60
|
+
WINDOWS_TASK = "AI Browser Toolkit server"
|
|
61
|
+
MACOS_LABEL = "com.aibrowsertoolkit.server"
|
|
62
|
+
LINUX_UNIT = "abt-server.service"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AutostartError(RuntimeError):
|
|
66
|
+
"""Something the caller can act on: a missing tool, a refused command."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Plan:
|
|
71
|
+
"""Exactly what `install` would do, decided without touching the system.
|
|
72
|
+
|
|
73
|
+
Separated from the doing so the hard part -- the command line, the paths,
|
|
74
|
+
the unit text -- is testable on any machine, including the two platforms
|
|
75
|
+
the test runner is not on.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
kind: str
|
|
79
|
+
name: str
|
|
80
|
+
argv: list[str]
|
|
81
|
+
path: Path | None = None
|
|
82
|
+
content: str | None = None
|
|
83
|
+
notes: list[str] = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def current_platform() -> str:
|
|
87
|
+
system = platform.system()
|
|
88
|
+
if system == "Windows":
|
|
89
|
+
return "windows"
|
|
90
|
+
if system == "Darwin":
|
|
91
|
+
return "macos"
|
|
92
|
+
if system == "Linux":
|
|
93
|
+
return "linux"
|
|
94
|
+
raise AutostartError(f"no autostart support for {system!r}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def executable() -> str:
|
|
98
|
+
"""The `abt` to run at logon, as an absolute path.
|
|
99
|
+
|
|
100
|
+
The console script is preferred because it is what the user types, but it
|
|
101
|
+
only exists for an installed package. Falling back to `python -m abt.cli`
|
|
102
|
+
keeps a source checkout working, and `sys.executable` is already absolute.
|
|
103
|
+
"""
|
|
104
|
+
found = shutil.which("abt")
|
|
105
|
+
if found:
|
|
106
|
+
return str(Path(found).resolve())
|
|
107
|
+
return sys.executable
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def serve_argv(
|
|
111
|
+
*,
|
|
112
|
+
port: int,
|
|
113
|
+
browser: str,
|
|
114
|
+
profile: Path,
|
|
115
|
+
log_dir: Path,
|
|
116
|
+
engine: str = "playwright",
|
|
117
|
+
headless: bool = False,
|
|
118
|
+
exe: str | None = None,
|
|
119
|
+
) -> list[str]:
|
|
120
|
+
"""The command the logon entry runs.
|
|
121
|
+
|
|
122
|
+
Every path is resolved here rather than at run time, because at run time
|
|
123
|
+
there is no cwd worth resolving against.
|
|
124
|
+
"""
|
|
125
|
+
exe = exe or executable()
|
|
126
|
+
argv = [exe]
|
|
127
|
+
# A source checkout has no console script, so the module has to be named.
|
|
128
|
+
if Path(exe).stem.lower() not in ("abt",):
|
|
129
|
+
argv += ["-m", "abt.cli"]
|
|
130
|
+
argv += [
|
|
131
|
+
"serve",
|
|
132
|
+
"--browser",
|
|
133
|
+
browser,
|
|
134
|
+
"--port",
|
|
135
|
+
str(port),
|
|
136
|
+
"--profile",
|
|
137
|
+
str(Path(profile).expanduser().resolve()),
|
|
138
|
+
"--log-dir",
|
|
139
|
+
str(Path(log_dir).expanduser().resolve()),
|
|
140
|
+
"--engine",
|
|
141
|
+
engine,
|
|
142
|
+
]
|
|
143
|
+
if headless:
|
|
144
|
+
argv.append("--headless")
|
|
145
|
+
# Deliberately no --start-browser. See the module docstring.
|
|
146
|
+
return argv
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# `KeepAlive` restarts the server if it dies; `RunAtLoad` starts it at login.
|
|
150
|
+
# `ProcessType: Background` keeps macOS from deprioritising it the way it does
|
|
151
|
+
# an idle GUI app.
|
|
152
|
+
_PLIST = """<?xml version="1.0" encoding="UTF-8"?>
|
|
153
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
154
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
155
|
+
<plist version="1.0">
|
|
156
|
+
<dict>
|
|
157
|
+
<key>Label</key><string>{label}</string>
|
|
158
|
+
<key>ProgramArguments</key>
|
|
159
|
+
<array>
|
|
160
|
+
{arguments}
|
|
161
|
+
</array>
|
|
162
|
+
<key>RunAtLoad</key><true/>
|
|
163
|
+
<key>KeepAlive</key><true/>
|
|
164
|
+
<key>ProcessType</key><string>Background</string>
|
|
165
|
+
<key>WorkingDirectory</key><string>{cwd}</string>
|
|
166
|
+
<key>StandardOutPath</key><string>{stdout}</string>
|
|
167
|
+
<key>StandardErrorPath</key><string>{stderr}</string>
|
|
168
|
+
</dict>
|
|
169
|
+
</plist>
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
# `default.target` rather than `multi-user.target`: this is a user unit, and
|
|
173
|
+
# user units have their own target graph. Restart=on-failure covers a crash
|
|
174
|
+
# without fighting an intentional shutdown, which exits 0.
|
|
175
|
+
_UNIT = """[Unit]
|
|
176
|
+
Description=AI Browser Toolkit server
|
|
177
|
+
After=default.target
|
|
178
|
+
|
|
179
|
+
[Service]
|
|
180
|
+
Type=simple
|
|
181
|
+
ExecStart={command}
|
|
182
|
+
WorkingDirectory={cwd}
|
|
183
|
+
Restart=on-failure
|
|
184
|
+
RestartSec=5
|
|
185
|
+
StandardOutput=append:{stdout}
|
|
186
|
+
StandardError=append:{stderr}
|
|
187
|
+
|
|
188
|
+
[Install]
|
|
189
|
+
WantedBy=default.target
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _quote_plist(value: str) -> str:
|
|
194
|
+
return (
|
|
195
|
+
value.replace("&", "&")
|
|
196
|
+
.replace("<", "<")
|
|
197
|
+
.replace(">", ">")
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def plan(
|
|
202
|
+
*,
|
|
203
|
+
port: int,
|
|
204
|
+
browser: str,
|
|
205
|
+
profile: Path,
|
|
206
|
+
log_dir: Path,
|
|
207
|
+
engine: str = "playwright",
|
|
208
|
+
headless: bool = False,
|
|
209
|
+
kind: str | None = None,
|
|
210
|
+
exe: str | None = None,
|
|
211
|
+
home: Path | None = None,
|
|
212
|
+
) -> Plan:
|
|
213
|
+
"""What would be installed, without installing it."""
|
|
214
|
+
kind = kind or current_platform()
|
|
215
|
+
home = Path(home) if home is not None else Path.home()
|
|
216
|
+
argv = serve_argv(
|
|
217
|
+
port=port,
|
|
218
|
+
browser=browser,
|
|
219
|
+
profile=profile,
|
|
220
|
+
log_dir=log_dir,
|
|
221
|
+
engine=engine,
|
|
222
|
+
headless=headless,
|
|
223
|
+
exe=exe,
|
|
224
|
+
)
|
|
225
|
+
cwd = Path(profile).expanduser().resolve().parent
|
|
226
|
+
out = Path(log_dir).expanduser().resolve() / "autostart.log"
|
|
227
|
+
err = Path(log_dir).expanduser().resolve() / "autostart.err"
|
|
228
|
+
|
|
229
|
+
if kind == "windows":
|
|
230
|
+
return Plan(
|
|
231
|
+
kind=kind,
|
|
232
|
+
name=WINDOWS_TASK,
|
|
233
|
+
argv=argv,
|
|
234
|
+
content=windows_command_line(argv, out, err),
|
|
235
|
+
notes=[
|
|
236
|
+
"Runs at logon for this account only, at normal privilege.",
|
|
237
|
+
"Remove it by hand with: schtasks /delete /tn "
|
|
238
|
+
f'"{WINDOWS_TASK}" /f',
|
|
239
|
+
],
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if kind == "macos":
|
|
243
|
+
arguments = "\n".join(
|
|
244
|
+
f" <string>{_quote_plist(part)}</string>" for part in argv
|
|
245
|
+
)
|
|
246
|
+
return Plan(
|
|
247
|
+
kind=kind,
|
|
248
|
+
name=MACOS_LABEL,
|
|
249
|
+
argv=argv,
|
|
250
|
+
path=home / "Library" / "LaunchAgents" / f"{MACOS_LABEL}.plist",
|
|
251
|
+
content=_PLIST.format(
|
|
252
|
+
label=MACOS_LABEL,
|
|
253
|
+
arguments=arguments,
|
|
254
|
+
cwd=_quote_plist(str(cwd)),
|
|
255
|
+
stdout=_quote_plist(str(out)),
|
|
256
|
+
stderr=_quote_plist(str(err)),
|
|
257
|
+
),
|
|
258
|
+
notes=["KeepAlive is on, so launchd restarts the server if it dies."],
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if kind == "linux":
|
|
262
|
+
return Plan(
|
|
263
|
+
kind=kind,
|
|
264
|
+
name=LINUX_UNIT,
|
|
265
|
+
argv=argv,
|
|
266
|
+
path=home / ".config" / "systemd" / "user" / LINUX_UNIT,
|
|
267
|
+
content=_UNIT.format(
|
|
268
|
+
command=" ".join(_shell_quote(part) for part in argv),
|
|
269
|
+
cwd=str(cwd),
|
|
270
|
+
stdout=str(out),
|
|
271
|
+
stderr=str(err),
|
|
272
|
+
),
|
|
273
|
+
notes=[
|
|
274
|
+
"A user unit starts at login and stops at logout. For a server "
|
|
275
|
+
"that survives logout, run: loginctl enable-linger $USER",
|
|
276
|
+
],
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
raise AutostartError(f"unknown platform {kind!r}")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _shell_quote(value: str) -> str:
|
|
283
|
+
"""systemd's ExecStart is not a shell, but it does split on spaces and
|
|
284
|
+
honour double quotes, so a path with a space still has to be quoted."""
|
|
285
|
+
if not value or any(ch.isspace() for ch in value) or '"' in value:
|
|
286
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
287
|
+
return value
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _run(argv: list[str], timeout: int = 60) -> subprocess.CompletedProcess:
|
|
291
|
+
try:
|
|
292
|
+
return subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
|
|
293
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
294
|
+
raise AutostartError(f"could not run {argv[0]!r}: {exc}") from exc
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def install(spec: Plan) -> dict:
|
|
298
|
+
"""Write the entry and enable it. Idempotent: re-installing replaces."""
|
|
299
|
+
if spec.kind == "windows":
|
|
300
|
+
done = _run(
|
|
301
|
+
[
|
|
302
|
+
"schtasks", "/create", "/tn", spec.name, "/tr", spec.content or "",
|
|
303
|
+
"/sc", "onlogon", "/rl", "limited", "/f",
|
|
304
|
+
]
|
|
305
|
+
)
|
|
306
|
+
if done.returncode != 0:
|
|
307
|
+
raise AutostartError(
|
|
308
|
+
f"schtasks refused to create the task: "
|
|
309
|
+
f"{(done.stderr or done.stdout).strip()}"
|
|
310
|
+
)
|
|
311
|
+
return {"installed": True, "kind": spec.kind, "name": spec.name}
|
|
312
|
+
|
|
313
|
+
if spec.path is None or spec.content is None:
|
|
314
|
+
raise AutostartError(f"nothing to write for {spec.kind!r}")
|
|
315
|
+
spec.path.parent.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
spec.path.write_text(spec.content, encoding="utf-8")
|
|
317
|
+
|
|
318
|
+
if spec.kind == "macos":
|
|
319
|
+
# `bootstrap` is the modern form; `load -w` is what older systems have.
|
|
320
|
+
# Try the new one and fall back rather than picking by version number.
|
|
321
|
+
target = f"gui/{os.getuid()}"
|
|
322
|
+
done = _run(["launchctl", "bootstrap", target, str(spec.path)])
|
|
323
|
+
if done.returncode != 0:
|
|
324
|
+
done = _run(["launchctl", "load", "-w", str(spec.path)])
|
|
325
|
+
if done.returncode != 0:
|
|
326
|
+
raise AutostartError(
|
|
327
|
+
f"launchctl refused the agent: "
|
|
328
|
+
f"{(done.stderr or done.stdout).strip()}"
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
_run(["systemctl", "--user", "daemon-reload"])
|
|
332
|
+
done = _run(["systemctl", "--user", "enable", "--now", spec.name])
|
|
333
|
+
if done.returncode != 0:
|
|
334
|
+
raise AutostartError(
|
|
335
|
+
f"systemctl refused the unit: "
|
|
336
|
+
f"{(done.stderr or done.stdout).strip()}"
|
|
337
|
+
)
|
|
338
|
+
return {
|
|
339
|
+
"installed": True,
|
|
340
|
+
"kind": spec.kind,
|
|
341
|
+
"name": spec.name,
|
|
342
|
+
"path": str(spec.path),
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def uninstall(kind: str | None = None, home: Path | None = None) -> dict:
|
|
347
|
+
"""Remove the entry. Not an error when there is nothing to remove."""
|
|
348
|
+
kind = kind or current_platform()
|
|
349
|
+
home = Path(home) if home is not None else Path.home()
|
|
350
|
+
|
|
351
|
+
if kind == "windows":
|
|
352
|
+
done = _run(["schtasks", "/delete", "/tn", WINDOWS_TASK, "/f"])
|
|
353
|
+
return {"removed": done.returncode == 0, "kind": kind, "name": WINDOWS_TASK}
|
|
354
|
+
|
|
355
|
+
if kind == "macos":
|
|
356
|
+
path = home / "Library" / "LaunchAgents" / f"{MACOS_LABEL}.plist"
|
|
357
|
+
if path.exists():
|
|
358
|
+
_run(["launchctl", "bootout", f"gui/{os.getuid()}/{MACOS_LABEL}"])
|
|
359
|
+
_run(["launchctl", "unload", "-w", str(path)])
|
|
360
|
+
path.unlink()
|
|
361
|
+
return {"removed": True, "kind": kind, "name": MACOS_LABEL}
|
|
362
|
+
return {"removed": False, "kind": kind, "name": MACOS_LABEL}
|
|
363
|
+
|
|
364
|
+
path = home / ".config" / "systemd" / "user" / LINUX_UNIT
|
|
365
|
+
if path.exists():
|
|
366
|
+
_run(["systemctl", "--user", "disable", "--now", LINUX_UNIT])
|
|
367
|
+
path.unlink()
|
|
368
|
+
_run(["systemctl", "--user", "daemon-reload"])
|
|
369
|
+
return {"removed": True, "kind": kind, "name": LINUX_UNIT}
|
|
370
|
+
return {"removed": False, "kind": kind, "name": LINUX_UNIT}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def status(kind: str | None = None, home: Path | None = None) -> dict:
|
|
374
|
+
"""Whether an entry exists, reported without starting anything."""
|
|
375
|
+
kind = kind or current_platform()
|
|
376
|
+
home = Path(home) if home is not None else Path.home()
|
|
377
|
+
|
|
378
|
+
if kind == "windows":
|
|
379
|
+
done = _run(["schtasks", "/query", "/tn", WINDOWS_TASK])
|
|
380
|
+
return {
|
|
381
|
+
"kind": kind,
|
|
382
|
+
"name": WINDOWS_TASK,
|
|
383
|
+
"installed": done.returncode == 0,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if kind == "macos":
|
|
387
|
+
path = home / "Library" / "LaunchAgents" / f"{MACOS_LABEL}.plist"
|
|
388
|
+
return {
|
|
389
|
+
"kind": kind,
|
|
390
|
+
"name": MACOS_LABEL,
|
|
391
|
+
"installed": path.exists(),
|
|
392
|
+
"path": str(path),
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
path = home / ".config" / "systemd" / "user" / LINUX_UNIT
|
|
396
|
+
info = {
|
|
397
|
+
"kind": kind,
|
|
398
|
+
"name": LINUX_UNIT,
|
|
399
|
+
"installed": path.exists(),
|
|
400
|
+
"path": str(path),
|
|
401
|
+
}
|
|
402
|
+
if path.exists():
|
|
403
|
+
done = _run(["systemctl", "--user", "is-active", LINUX_UNIT], timeout=15)
|
|
404
|
+
info["active"] = (done.stdout or "").strip() == "active"
|
|
405
|
+
return info
|