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 ADDED
@@ -0,0 +1,3 @@
1
+ """Selenium-backed HTTP browser API for AI agents."""
2
+
3
+ __version__ = "0.1.0"
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("&", "&amp;")
196
+ .replace("<", "&lt;")
197
+ .replace(">", "&gt;")
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