browser-agent-server 1.0.4__tar.gz → 1.0.5__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.
Files changed (40) hide show
  1. browser_agent_server-1.0.5/.github/dependabot.yml +17 -0
  2. browser_agent_server-1.0.5/.github/workflows/ci.yml +56 -0
  3. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/.github/workflows/release.yml +1 -2
  4. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/AGENTS.md +1 -1
  5. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/CHANGELOG.md +39 -1
  6. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/PKG-INFO +1 -4
  7. browser_agent_server-1.0.5/docs/multi-project-cd.md +154 -0
  8. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/pyproject.toml +2 -2
  9. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/src/browser_agent/chrome.py +6 -5
  10. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/src/browser_agent/cli.py +9 -4
  11. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/src/browser_agent/server.py +71 -6
  12. browser_agent_server-1.0.5/tests/test_cli_state_home.py +63 -0
  13. browser_agent_server-1.0.5/tests/test_http_edges.py +104 -0
  14. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/uv.lock +8 -7
  15. browser_agent_server-1.0.4/.github/workflows/ci.yml +0 -38
  16. browser_agent_server-1.0.4/docs/multi-project-cd.md +0 -99
  17. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  18. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  19. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  20. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/.github/workflows/deploy-ssh.yml +0 -0
  21. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/.gitignore +0 -0
  22. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/LICENSE +0 -0
  23. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/README.md +0 -0
  24. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/docs/api.md +0 -0
  25. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/docs/architecture.md +0 -0
  26. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/docs/deployment.md +0 -0
  27. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/docs/publishing.md +0 -0
  28. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/docs/security.md +0 -0
  29. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/docs/troubleshooting.md +0 -0
  30. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/src/browser_agent/__init__.py +0 -0
  31. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/src/browser_agent/data/browser-agent.service +0 -0
  32. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/src/browser_agent/vision_agent.py +0 -0
  33. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/src/browser_agent/x11_input.py +0 -0
  34. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/tests/test_bezier.py +0 -0
  35. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/tests/test_chrome_state_dir.py +0 -0
  36. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/tests/test_cli.py +0 -0
  37. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/tests/test_client.py +0 -0
  38. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/tests/test_cloudflare.py +0 -0
  39. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/tests/test_http_api.py +0 -0
  40. {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/tests/test_ws_frames.py +0 -0
@@ -0,0 +1,17 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: "/"
5
+ schedule:
6
+ interval: weekly
7
+ groups:
8
+ python-deps:
9
+ patterns: ["*"]
10
+
11
+ - package-ecosystem: github-actions
12
+ directory: "/"
13
+ schedule:
14
+ interval: weekly
15
+ groups:
16
+ github-actions:
17
+ patterns: ["*"]
@@ -0,0 +1,56 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ schedule:
8
+ - cron: "17 5 * * 1" # Mondays 05:17 UTC — catch bit-rotting deps early
9
+ workflow_dispatch:
10
+
11
+ concurrency:
12
+ group: ${{ github.workflow }}-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ python:
17
+ strategy:
18
+ fail-fast: false
19
+ matrix:
20
+ python: ["3.12", "3.13"]
21
+ runs-on: ubuntu-latest
22
+ timeout-minutes: 15
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: astral-sh/setup-uv@v5
26
+ - uses: actions/setup-python@v5
27
+ with:
28
+ python-version: ${{ matrix.python }}
29
+ # setup-uv restores the uv cache (keyed on uv.lock/pyproject.toml); never pass
30
+ # --no-cache here — it defeats that restore. `uv sync` installs the project
31
+ # itself (editable) together with the `dev` group, so `uv run pytest` resolves
32
+ # to the venv's interpreter rather than silently falling back to a system one.
33
+ - name: Install
34
+ run: uv sync --python ${{ matrix.python }}
35
+ # Lint is Python-version independent: run it once, on one leg, instead of
36
+ # duplicating it (and a second install) on every leg.
37
+ - name: Ruff
38
+ if: matrix.python == '3.13'
39
+ run: uv run ruff check src tests
40
+ - name: Tests
41
+ run: uv run pytest -q
42
+
43
+ gitleaks:
44
+ runs-on: ubuntu-latest
45
+ timeout-minutes: 10
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+ with:
49
+ fetch-depth: 0
50
+ # GITHUB_TOKEN is required to scan pull_request events: the action posts
51
+ # findings as PR comments and refuses to run without it. Without this env
52
+ # the check fails on every PR while still passing on pushes to main, which
53
+ # is exactly the "green on main, red on the PR" split that hides problems.
54
+ - uses: gitleaks/gitleaks-action@v2
55
+ env:
56
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -15,8 +15,7 @@ jobs:
15
15
  python-version: "3.12"
16
16
  - name: Verify
17
17
  run: |
18
- uv venv
19
- uv pip install -e ".[dev]" --no-cache
18
+ uv sync
20
19
  uv run ruff check src tests
21
20
  uv run pytest -q
22
21
  - name: Build
@@ -6,7 +6,7 @@
6
6
 
7
7
  - PyPI dist: `browser-agent-server` · import: `browser_agent` · CLI: `browser-agent`
8
8
  - Python ≥ 3.12, src-layout, hatchling, **one runtime dependency** (`trafilatura`). That is deliberate (1 GB RAM target hosts) — do not add deps without an issue discussion.
9
- - Toolchain: `uv` (`uv pip install -e ".[dev]"`), ruff (E/F/I/UP/B, line 100), pytest.
9
+ - Toolchain: `uv` (`uv sync` builds the venv, installs the project itself editable and the `dev` group; then `uv run pytest` / `uv run ruff`), ruff (E/F/I/UP/B, line 100), pytest.
10
10
  - Tests: unit only in CI (`pytest`); real-Chrome tests are opt-in: `BROWSER_AGENT_IT=1 pytest -m integration`.
11
11
 
12
12
  ## Invariants — do not break these
@@ -3,7 +3,45 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
- ## [Unreleased]
6
+ ## [1.0.5] - 2026-10-10
7
+
8
+ ### Fixed
9
+
10
+ - `stop()` no longer globs `/tmp/Crashpad*` and deletes it. That directory is shared by
11
+ every Chrome on the host, so stopping one session wiped other sessions' crash dumps.
12
+ - Malformed action payloads are now rejected *before* the browser is launched. A `goto`
13
+ without a `url`, a `move` without coordinates, a `click` with only one of `x`/`y`, a `key`
14
+ without a `key` or a `scroll` with a bad `direction` previously paid the full Chrome/Xvfb
15
+ startup and then failed with a browser error that hid the real cause.
16
+ - GET and POST routing strips the query string, so `GET /status?verbose=1` and
17
+ `POST /action?x=1` no longer return 404.
18
+ - `browser-agent doctor` now reports the state dir it actually probes: `STATE_HOME` comes
19
+ from `default_state_dir()` and therefore honours `BROWSER_AGENT_STATE_DIR` and
20
+ `XDG_STATE_HOME` instead of silently ignoring both.
21
+ - The X-lock check in `doctor` derives the display from `DEFAULT_DISPLAY` rather than a
22
+ hardcoded `:99`, so the check cannot drift from the display the daemon actually uses.
23
+
24
+ ### Changed
25
+
26
+ - HTTP responses are HTTP/1.1 with keep-alive. Every response already sent an accurate
27
+ `Content-Length`, so the connection-per-request (and thread-per-connection) cost was
28
+ avoidable.
29
+ - `_default_state_dir()` is public `default_state_dir()` — the CLI needs the same source of
30
+ truth as the daemon.
31
+
32
+ ### CI
33
+
34
+ - `ci.yml` gained `concurrency` cancellation and per-job `timeout-minutes`, so superseded
35
+ runs stop consuming runners and hung checks cannot block the queue.
36
+ - `--no-cache` removed from the `uv pip install` step — it was defeating the uv cache that
37
+ `astral-sh/setup-uv` restores (keyed on `uv.lock`/`pyproject.toml`).
38
+ - `ruff check` now runs once, on a single matrix leg, instead of being duplicated on every
39
+ Python version; the lint result cannot differ per version, and a second install step was
40
+ avoided by not splitting it into its own job.
41
+
42
+ ### Added
43
+
44
+ - `.github/dependabot.yml` — weekly grouped updates for pip and github-actions.
7
45
 
8
46
  ## [1.0.4] - 2026-10-10
9
47
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: browser-agent-server
3
- Version: 1.0.4
3
+ Version: 1.0.5
4
4
  Summary: Real headful Chrome on Xvfb with a hardware-level X11 mouse, Cloudflare Turnstile auto-solve and zero-leak CDP. Localhost HTTP API + CLI + Python SDK.
5
5
  Project-URL: Homepage, https://github.com/vernikr/browser-agent
6
6
  Project-URL: Repository, https://github.com/vernikr/browser-agent
@@ -16,9 +16,6 @@ Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
16
16
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
17
  Requires-Python: >=3.12
18
18
  Requires-Dist: trafilatura<3,>=1.12
19
- Provides-Extra: dev
20
- Requires-Dist: pytest>=8.0; extra == 'dev'
21
- Requires-Dist: ruff>=0.6; extra == 'dev'
22
19
  Description-Content-Type: text/markdown
23
20
 
24
21
  # browser-agent
@@ -0,0 +1,154 @@
1
+ # Multi-project CD: push every release to production from CI
2
+
3
+ The deploy used for `browser-agent` generalizes to any number of projects on the same
4
+ server. This page is the one-stop playbook — give it (or the prompt at the bottom) to any
5
+ agent to wire up a new project in ~20 minutes.
6
+
7
+ ## Architecture (once per server)
8
+
9
+ ```
10
+ GitHub release.yml (tag push)
11
+ build → publish (OIDC, no tokens)
12
+ → deploy job → reusable workflow deploy-ssh.yml
13
+ └─ ssh <ci-key>@host "deploy <app> <ver>"
14
+ └─ authorized_keys forced command → /usr/local/bin/deploy-tools
15
+ └─ /etc/deploy-tools.d/<app>.conf
16
+ └─ install exact version → restart unit → healthcheck → rollback
17
+ ```
18
+
19
+ - **One CI key serves all projects.** The key is pinned by a forced command to the
20
+ dispatcher; it cannot open a shell, forward ports, or run anything else.
21
+ - **The whitelist of deployable apps is the server itself**: an app exists iff a
22
+ root-owned `/etc/deploy-tools.d/<app>.conf` exists. Unknown names are rejected.
23
+ - **No public endpoint, no firewall changes** — push arrives over the SSH port that is
24
+ already open for admin access.
25
+
26
+ Server-side artifacts (already installed on 38.244.152.2; copy to a new host as-is):
27
+
28
+ - `/usr/local/bin/deploy-tools` — the generic dispatcher (protocol: `deploy <app> <semver>`;
29
+ legacy `deploy <semver>` works while exactly one app is configured).
30
+ - `/etc/deploy-tools.d/browser-agent.conf` — the reference app config.
31
+ - backups under `/root/bak/`.
32
+
33
+ ## Step 0 (fallback) — manually, if the agent has no GitHub token / SSH (~5 min)
34
+
35
+ The agent normally does ALL of this from the prompt below — you hand agents the GitHub token and server SSH anyway (see server-spec), so it can go end-to-end (that is exactly how browser-agent got wired). Do it manually only if you do not want to grant those.
36
+
37
+ **1. Generate a fresh keypair per repo.** Why not reuse an existing key? GitHub secrets are
38
+ write-only — a private key already stored in another repo cannot be read back, and no local
39
+ copy should survive. A per-repo key is also cleaner to rotate/revoke: drop one
40
+ `authorized_keys` line and that repo loses access.
41
+
42
+ > Note: for `browser-agent` itself this Step 0 was performed by the setup agent during the
43
+ > initial CD bootstrap (it generated the keypair, installed the pubkey, and created the repo
44
+ > secrets via the GitHub API) — which is exactly why that key is unrecoverable and the rule
45
+ > above exists.
46
+
47
+ ```bash
48
+ ssh-keygen -t ed25519 -f ci-deploy-<app> -N "" -C ci-deploy-<app>
49
+
50
+ # append the pubkey as ANOTHER line (never touch existing command="..." lines):
51
+ ssh root@<host> "echo 'command=\"/usr/local/bin/deploy-tools\",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding '$(cat ci-deploy-<app>.pub) >> /root/.ssh/authorized_keys"
52
+
53
+ # if smoke shows an AUTH failure (asks for password / hangs), the appended line is
54
+ # malformed — remove it by its comment and retry:
55
+ # ssh root@<host> "sed -i '/ci-deploy-<app>$/d' /root/.ssh/authorized_keys"
56
+ # smoke BEFORE deleting the private key — expect a REJECT, not an auth failure:
57
+ ssh -i ci-deploy-<app> root@<host> "id" # -> [deploy] ERROR: unsupported command ...
58
+ ```
59
+
60
+ **2. Set three repo secrets** (Settings → Secrets and variables → Actions, or gh CLI, which
61
+ encrypts for you):
62
+
63
+ ```bash
64
+ gh secret set DEPLOY_SSH_KEY --repo vernikr/<repo> < ci-deploy-<app>
65
+ gh secret set DEPLOY_HOST --repo vernikr/<repo> --body <host>
66
+ gh secret set DEPLOY_USER --repo vernikr/<repo> --body root
67
+ rm ci-deploy-<app> # keep no local copy of the private key
68
+ ```
69
+
70
+ ## Per-project checklist (~20 min)
71
+
72
+ Prerequisites: the project is a package published from CI to a registry with OIDC
73
+ trusted publishing (same one-time registry setup as in `docs/publishing.md`), and it runs
74
+ on the server as a systemd unit with some health signal.
75
+
76
+ 1. **Systemd unit** — the service must come up healthy after `systemctl restart`. Health
77
+ signal: either a JSON endpoint answering `"version": "X.Y.Z"` or a CLI
78
+ `myapp --version`. No JSON endpoint? Put `HEALTH_CMD=...` in the config.
79
+ 2. **Server config** — create `/etc/deploy-tools.d/<app>.conf` (app name: `[a-z0-9-]+`):
80
+
81
+ ```bash
82
+ INSTALLER=uv # uv (Python) | npm-global (Node)
83
+ PKG=<registry package name> # e.g. my-tool-server / @scope/my-tool
84
+ BIN=<executable name> # for uv tools (for --version); optional otherwise
85
+ UNIT=<systemd unit name>
86
+ STATUS_URL=http://127.0.0.1:PORT/status # OR: HEALTH_CMD='...'
87
+ HEALTH_FIELD=version
88
+ ```
89
+
90
+ Test on the host:
91
+
92
+ ```bash
93
+ SSH_ORIGINAL_COMMAND="deploy <app> <current-version>" /usr/local/bin/deploy-tools
94
+ # expect: "already on <ver> and healthy — nothing to do"
95
+ ```
96
+
97
+ 3. **Registry/CI side already solved**: publish from `release.yml` exactly like this repo
98
+ (`pypi`/`npm` job first, so deploy only installs what already exists in the registry).
99
+ 4. **Deploy job** in the project's `release.yml`:
100
+
101
+ ```yaml
102
+ deploy:
103
+ needs: <publish-job>
104
+ uses: vernikr/browser-agent/.github/workflows/deploy-ssh.yml@main
105
+ with:
106
+ app: <app>
107
+ tag: ${{ github.ref_name }}
108
+ secrets: inherit
109
+ ```
110
+
111
+ 5. **Key and repo secrets** — done by the owner in Step 0 (fresh keypair per repo; existing
112
+ secret values can't be read back — GitHub secrets are write-only).
113
+ 6. **Smoke-release**: tag `vX.Y.Z+1`, watch the Actions tab — deploy should be green and
114
+ the unit should report the new version on its health endpoint.
115
+
116
+ Failure semantics: unreadable registry → retries; bad health → automatic reinstall of the
117
+ previous version, restart, re-check, and the CI job turns red. Everything is logged in the
118
+ GitHub job — no `journalctl` digging needed for routine failures.
119
+
120
+ ## Agent prompt (copy-paste; agent needs your GitHub token + server SSH)
121
+
122
+ > Wire the `<PROJECT>` repo for push-style CD to our production server `38.244.152.2`,
123
+ > per `vernikr/browser-agent/docs/multi-project-cd.md`. Given: a GitHub token (repo secrets
124
+ > write) and SSH root@38.244.152.2 (see server-spec). Do everything end-to-end:
125
+ >
126
+ > **(1) Deploy key.** `ssh-keygen -t ed25519 -f /tmp/ci-deploy-<app> -N "" -C ci-deploy-<app>`
127
+ > (keys ONLY in /tmp — never in project files or git). Append the pubkey as a NEW line in
128
+ > `/root/.ssh/authorized_keys` with forced command
129
+ > `command="/usr/local/bin/deploy-tools",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding`
130
+ > (back up the file to `/root/bak/` first). Smoke:
131
+ > `ssh -i /tmp/ci-deploy-<app> root@38.244.152.2 "id"` → expect a REJECT
132
+ > ("unsupported command"); on an AUTH failure the line is malformed —
133
+ > `sed -i '/ci-deploy-<app>$/d' /root/.ssh/authorized_keys` and retry.
134
+ >
135
+ > **(2) Repo secrets** in `<PROJECT>`: `DEPLOY_SSH_KEY` (private key contents),
136
+ > `DEPLOY_HOST=38.244.152.2`, `DEPLOY_USER=root` — via `gh secret set` or the Secrets REST API
137
+ > (encrypt with a libsodium sealed box using the repo's public key; never plaintext-commit).
138
+ > Then `rm -f /tmp/ci-deploy-<app>*`.
139
+ >
140
+ > **(3) Server config.** `/etc/deploy-tools.d/<app>.conf`:
141
+ > `INSTALLER=uv|npm-global`, `PKG=<PKG>`, `BIN=<exe>` (uv), `UNIT=<UNIT>`,
142
+ > `STATUS_URL=http://127.0.0.1:<PORT>/status` (or `HEALTH_CMD`), `HEALTH_FIELD=version`.
143
+ > Smoke: `SSH_ORIGINAL_COMMAND="deploy <app> <current-version>" /usr/local/bin/deploy-tools`
144
+ > → "already on … and healthy".
145
+ >
146
+ > **(4) CI job.** In `<PROJECT>`'s `release.yml`: `deploy` needing the publish job, calling
147
+ > `vernikr/browser-agent/.github/workflows/deploy-ssh.yml@main` with
148
+ > `with: { app: <app>, tag: github.ref_name }` and `secrets: inherit`.
149
+ >
150
+ > **(5) Smoke release**: tag a patch → all jobs green → the unit reports the new version.
151
+ >
152
+ > Hard rules: never touch other services, firewall, or VPN units on the server; never write
153
+ > private keys/secrets into git or project files; back up to `/root/bak/` before editing
154
+ > `authorized_keys` or the dispatcher.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "browser-agent-server"
3
- version = "1.0.4"
3
+ version = "1.0.5"
4
4
  description = "Real headful Chrome on Xvfb with a hardware-level X11 mouse, Cloudflare Turnstile auto-solve and zero-leak CDP. Localhost HTTP API + CLI + Python SDK."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -16,7 +16,7 @@ classifiers = [
16
16
  ]
17
17
  dependencies = ["trafilatura>=1.12,<3"]
18
18
 
19
- [project.optional-dependencies]
19
+ [dependency-groups]
20
20
  dev = ["pytest>=8.0", "ruff>=0.6"]
21
21
 
22
22
  [project.urls]
@@ -33,6 +33,9 @@ CF_CHALLENGE_MARKERS = (
33
33
  # Forbidden CDP domains that trigger Cloudflare / DataDome / PerimeterX detection
34
34
  FORBIDDEN_CDP_METHODS = frozenset({"Runtime.enable", "Console.enable", "Debugger.enable"})
35
35
 
36
+ #: X11 display the daemon starts Xvfb on unless a session overrides it.
37
+ DEFAULT_DISPLAY = ":99"
38
+
36
39
 
37
40
  class StealthCDPConnection:
38
41
  """Minimal RFC-6455 WebSocket client for Chrome DevTools Protocol without Runtime.enable leaks."""
@@ -180,7 +183,7 @@ def is_cloudflare_challenge(html: str, title: str = "") -> bool:
180
183
  return any(m in hay for m in CF_CHALLENGE_MARKERS)
181
184
 
182
185
 
183
- def _default_state_dir() -> Path:
186
+ def default_state_dir() -> Path:
184
187
  """XDG state directory for the agent (profile, logs). Override with BROWSER_AGENT_STATE_DIR."""
185
188
  if os.environ.get("BROWSER_AGENT_STATE_DIR"):
186
189
  return Path(os.environ["BROWSER_AGENT_STATE_DIR"])
@@ -205,12 +208,12 @@ class RealChromeSession:
205
208
  if user_data_dir is None:
206
209
  # Chrome profile with persisted logins. Set BROWSER_AGENT_PROFILE_DIR to keep
207
210
  # a pre-existing profile (e.g. /tmp/browser-agent-profile on legacy installs).
208
- user_data_dir = os.environ.get("BROWSER_AGENT_PROFILE_DIR") or str(_default_state_dir() / "profile")
211
+ user_data_dir = os.environ.get("BROWSER_AGENT_PROFILE_DIR") or str(default_state_dir() / "profile")
209
212
  self.user_data_dir = Path(user_data_dir)
210
213
  self.proxy_url = proxy_url
211
214
  self._active_proxy: str | None = None
212
215
  self._chrome_proc: subprocess.Popen | None = None
213
- self._stderr_path = _default_state_dir() / "chrome.err"
216
+ self._stderr_path = default_state_dir() / "chrome.err"
214
217
  self._stderr_file = None
215
218
  self.last_used: float = time.time()
216
219
  # Typical Chrome top UI bar height (tabs + address bar) in openbox
@@ -362,8 +365,6 @@ class RealChromeSession:
362
365
  def stop(self) -> None:
363
366
  self._stop_chrome_only()
364
367
  self.x11.stop()
365
- for p in Path("/tmp").glob("Crashpad*"):
366
- shutil.rmtree(p, ignore_errors=True)
367
368
 
368
369
  def get_html_and_title(self) -> tuple[str, str, str]:
369
370
  """Return (current_url, page_title, outer_html) using DOM.getOuterHTML (zero Runtime.enable)."""
@@ -28,13 +28,13 @@ from importlib import resources
28
28
  from pathlib import Path
29
29
 
30
30
  from . import __version__
31
- from .chrome import find_chrome_binary, is_warp_proxy_ready
31
+ from .chrome import DEFAULT_DISPLAY, default_state_dir, find_chrome_binary, is_warp_proxy_ready
32
32
  from .server import API_VERSION, DEFAULT_HOST, DEFAULT_IDLE_TIMEOUT, DEFAULT_PORT, serve
33
33
 
34
34
  log = logging.getLogger("browser_agent.cli")
35
35
 
36
36
  APT_PACKAGES = ("xvfb", "openbox", "xdotool", "scrot", "x11vnc")
37
- STATE_HOME = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "browser-agent"
37
+ STATE_HOME = default_state_dir()
38
38
 
39
39
 
40
40
  # ---------------------------------------------------------------------------
@@ -200,8 +200,13 @@ def cmd_doctor(args: argparse.Namespace) -> int:
200
200
  except FileNotFoundError as e:
201
201
  checks.append(_check("chrome-binary", False, str(e).split(".")[0]))
202
202
 
203
- lock = Path("/tmp/.X99-lock")
204
- checks.append(_check("display :99 free", not lock.exists(), "stale /tmp/.X99-lock present" if lock.exists() else "no lock", False))
203
+ lock = Path(f"/tmp/.X{DEFAULT_DISPLAY.lstrip(':')}-lock")
204
+ checks.append(_check(
205
+ f"display {DEFAULT_DISPLAY} free",
206
+ not lock.exists(),
207
+ f"stale {lock} present" if lock.exists() else "no lock",
208
+ False,
209
+ ))
205
210
 
206
211
  try:
207
212
  STATE_HOME.mkdir(parents=True, exist_ok=True)
@@ -40,6 +40,61 @@ DEFAULT_PORT = int(os.environ.get("BROWSER_AGENT_PORT", "8765"))
40
40
  DEFAULT_IDLE_TIMEOUT = int(os.environ.get("BROWSER_AGENT_IDLE_SEC", "60"))
41
41
 
42
42
 
43
+ _KNOWN_ACTIONS = frozenset(
44
+ {
45
+ "goto",
46
+ "move",
47
+ "click",
48
+ "type",
49
+ "key",
50
+ "scroll",
51
+ "screenshot",
52
+ "html",
53
+ }
54
+ )
55
+ _SCROLL_DIRECTIONS = frozenset({"up", "down"})
56
+
57
+
58
+ def _coord(payload: dict, name: str) -> int | None:
59
+ raw = payload.get(name)
60
+ return None if raw is None else int(raw)
61
+
62
+
63
+ def _validate_action(act: str, payload: dict) -> None:
64
+ """Reject a malformed action *before* the browser is asked to start.
65
+
66
+ Every failure here is a caller bug: the payload is already known to be
67
+ unusable, so launching Chrome to fail a few seconds later would only
68
+ replace the real cause with a "browser failed to start" message. Kept
69
+ proportional — only the shapes that would otherwise fail silently or
70
+ deep inside a driver are checked.
71
+ """
72
+ if act not in _KNOWN_ACTIONS:
73
+ raise ValueError(
74
+ f"Unknown action: {act!r}. Known actions: "
75
+ + ", ".join(sorted(_KNOWN_ACTIONS | {"stop", "vnc_start", "vnc_stop"}))
76
+ )
77
+ if act == "goto" and not str(payload.get("url") or "").strip():
78
+ raise ValueError("action 'goto' requires a non-empty 'url'")
79
+ if act == "move" and (payload.get("x") is None or payload.get("y") is None):
80
+ raise ValueError("action 'move' requires both integer 'x' and 'y'")
81
+ if (payload.get("x") is None) != (payload.get("y") is None):
82
+ raise ValueError(
83
+ f"action {act!r} got only one of 'x'/'y' — pass both to click a point, "
84
+ "or neither to use the current cursor position"
85
+ )
86
+ if act == "key" and not str(payload.get("key") or "").strip():
87
+ raise ValueError("action 'key' requires a non-empty 'key'")
88
+ if act == "scroll":
89
+ if str(payload.get("direction", "down")).strip().lower() not in _SCROLL_DIRECTIONS:
90
+ raise ValueError(
91
+ f"action 'scroll' direction must be one of "
92
+ f"{', '.join(sorted(_SCROLL_DIRECTIONS))}"
93
+ )
94
+ if int(payload.get("clicks", 4)) < 1:
95
+ raise ValueError("action 'scroll' requires 'clicks' >= 1")
96
+
97
+
43
98
  class BrowserCoordinator:
44
99
  """Thread-safe single-tab Chrome + X11 manager with automatic idle hibernation."""
45
100
 
@@ -110,6 +165,8 @@ class BrowserCoordinator:
110
165
  self.session.x11.stop_vnc()
111
166
  return {"ok": True}
112
167
 
168
+ _validate_action(act, payload)
169
+
113
170
  self.session.start(use_proxy=payload.get("use_proxy"))
114
171
 
115
172
  if act == "goto":
@@ -131,8 +188,8 @@ class BrowserCoordinator:
131
188
 
132
189
  if act == "click":
133
190
  pos = self.session.x11.click(
134
- x=int(payload["x"]) if payload.get("x") is not None else None,
135
- y=int(payload["y"]) if payload.get("y") is not None else None,
191
+ x=_coord(payload, "x"),
192
+ y=_coord(payload, "y"),
136
193
  button=int(payload.get("button", 1)),
137
194
  double=bool(payload.get("double", False)),
138
195
  )
@@ -150,8 +207,8 @@ class BrowserCoordinator:
150
207
  self.session.x11.scroll(
151
208
  clicks=int(payload.get("clicks", 4)),
152
209
  direction=str(payload.get("direction", "down")),
153
- x=int(payload["x"]) if payload.get("x") is not None else None,
154
- y=int(payload["y"]) if payload.get("y") is not None else None,
210
+ x=_coord(payload, "x"),
211
+ y=_coord(payload, "y"),
155
212
  )
156
213
  return {"ok": True}
157
214
 
@@ -187,9 +244,17 @@ class BrowserCoordinator:
187
244
 
188
245
  def make_handler(coord: BrowserCoordinator):
189
246
  class Handler(BaseHTTPRequestHandler):
247
+ # Keep-alive: every path already sends an accurate Content-Length, so
248
+ # HTTP/1.0's connection-per-request (and thread-per-connection) is avoidable.
249
+ protocol_version = "HTTP/1.1"
250
+
190
251
  def log_message(self, fmt: str, *args) -> None:
191
252
  log.info("%s - %s", self.address_string(), fmt % args)
192
253
 
254
+ def _route(self) -> str:
255
+ """Path without a query string — `curl /status?x=1` must not 404."""
256
+ return self.path.split("?", 1)[0].rstrip("/")
257
+
193
258
  def _send_json(self, code: int, data: dict) -> None:
194
259
  body = json.dumps(data, ensure_ascii=False).encode("utf-8")
195
260
  self.send_response(code)
@@ -205,7 +270,7 @@ def make_handler(coord: BrowserCoordinator):
205
270
  return json.loads(self.rfile.read(length).decode("utf-8"))
206
271
 
207
272
  def do_GET(self) -> None: # noqa: N802
208
- if self.path.rstrip("/") in ("", "/health", "/status"):
273
+ if self._route() in ("", "/health", "/status"):
209
274
  self._send_json(200, coord.status())
210
275
  else:
211
276
  self._send_json(404, {"ok": False, "error": "Not found"})
@@ -213,7 +278,7 @@ def make_handler(coord: BrowserCoordinator):
213
278
  def do_POST(self) -> None: # noqa: N802
214
279
  try:
215
280
  payload = self._read_json()
216
- path = self.path.rstrip("/")
281
+ path = self._route()
217
282
  if path == "/fetch":
218
283
  self._send_json(200, coord.fetch(payload))
219
284
  elif path == "/action":
@@ -0,0 +1,63 @@
1
+ """The CLI's state dir must come from the same source the daemon uses.
2
+
3
+ `browser-agent doctor` prints the state dir it is about to check. If the CLI
4
+ ever hardcodes a path, doctor reports one location while the daemon writes to
5
+ another — the diagnostic then blames a writable directory for a failure that
6
+ happened somewhere else entirely. One test, reloading the module, keeps the
7
+ two locked together.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import os
14
+ import subprocess
15
+ import sys
16
+
17
+ import browser_agent.cli as cli_mod
18
+
19
+
20
+ def _reload_cli():
21
+ return importlib.reload(cli_mod)
22
+
23
+
24
+ def test_cli_state_home_tracks_env_override(monkeypatch, tmp_path):
25
+ home = tmp_path / "custom-state"
26
+ monkeypatch.setenv("XDG_STATE_HOME", str(home))
27
+ monkeypatch.delenv("BROWSER_AGENT_STATE_DIR", raising=False)
28
+ try:
29
+ mod = _reload_cli()
30
+ assert mod.STATE_HOME == home / "browser-agent"
31
+ finally:
32
+ monkeypatch.delenv("XDG_STATE_HOME")
33
+ monkeypatch.delenv("BROWSER_AGENT_STATE_DIR", raising=False)
34
+ _reload_cli()
35
+
36
+
37
+ def test_cli_state_home_tracks_explicit_override(monkeypatch, tmp_path):
38
+ explicit = tmp_path / "elsewhere"
39
+ monkeypatch.setenv("BROWSER_AGENT_STATE_DIR", str(explicit))
40
+ try:
41
+ mod = _reload_cli()
42
+ assert mod.STATE_HOME == explicit
43
+ finally:
44
+ monkeypatch.delenv("BROWSER_AGENT_STATE_DIR")
45
+ _reload_cli()
46
+
47
+
48
+ def test_doctor_reports_the_state_dir_it_checked(tmp_path):
49
+ """End-to-end: the path in the report is the one that was probed."""
50
+ env = {
51
+ **os.environ,
52
+ "BROWSER_AGENT_STATE_DIR": str(tmp_path / "state"),
53
+ }
54
+ proc = subprocess.run(
55
+ [sys.executable, "-m", "browser_agent.cli", "doctor"],
56
+ capture_output=True,
57
+ text=True,
58
+ timeout=60,
59
+ env=env,
60
+ )
61
+ assert proc.returncode in (0, 1)
62
+ assert "state dir writable" in proc.stdout + proc.stderr
63
+ assert str(tmp_path / "state") in proc.stdout + proc.stderr
@@ -0,0 +1,104 @@
1
+ """HTTP contract details: routing, keep-alive, and validation error messages.
2
+
3
+ Existing coverage lives in test_http_api.py; this file targets the edge cases
4
+ that reach an agent as a confusing failure.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import http.client
9
+ import json
10
+
11
+ import pytest
12
+
13
+ from browser_agent.server import ( # noqa: F401
14
+ API_VERSION,
15
+ BrowserCoordinator,
16
+ ThreadingHTTPServer,
17
+ make_handler,
18
+ )
19
+
20
+
21
+ @pytest.fixture()
22
+ def http_server():
23
+ coord = BrowserCoordinator(idle_timeout_sec=60)
24
+ srv = ThreadingHTTPServer(("127.0.0.1", 0), make_handler(coord))
25
+ t = __import__("threading").Thread(target=srv.serve_forever, daemon=True)
26
+ t.start()
27
+ host, port = srv.server_address
28
+ yield host, port, coord
29
+ srv.shutdown()
30
+ coord.shutdown()
31
+
32
+
33
+ def _conn(host, port) -> http.client.HTTPConnection:
34
+ return http.client.HTTPConnection(host, port, timeout=10)
35
+
36
+
37
+ def _post(conn, path, payload):
38
+ body = json.dumps(payload)
39
+ conn.request("POST", path, body=body, headers={"Content-Type": "application/json"})
40
+ r = conn.getresponse()
41
+ return r.status, json.loads(r.read().decode())
42
+
43
+
44
+ def test_query_string_does_not_break_routing(http_server):
45
+ host, port, _ = http_server
46
+ c = _conn(host, port)
47
+ try:
48
+ for path in ("/status?verbose=1", "/status/", "/health?x=1", "/?probe=1"):
49
+ c.request("GET", path)
50
+ r = c.getresponse()
51
+ data = json.loads(r.read().decode())
52
+ assert r.status == 200, f"{path} should route to /status"
53
+ assert data["api_version"] == API_VERSION
54
+ finally:
55
+ c.close()
56
+
57
+
58
+ def test_keep_alive_serves_several_requests_on_one_connection(http_server):
59
+ host, port, _ = http_server
60
+ c = _conn(host, port)
61
+ try:
62
+ for _ in range(4):
63
+ c.request("GET", "/status")
64
+ r = c.getresponse()
65
+ assert r.status == 200
66
+ assert json.loads(r.read().decode())["ok"] is True
67
+ # The connection must still be usable — HTTP/1.0 would have closed it.
68
+ assert c.sock is not None
69
+ finally:
70
+ c.close()
71
+
72
+
73
+ def test_move_without_coordinates_returns_a_named_error(http_server):
74
+ host, port, _ = http_server
75
+ c = _conn(host, port)
76
+ try:
77
+ code, data = _post(c, "/action", {"action": "move"})
78
+ assert code in (400, 500)
79
+ assert "x" in data["error"] and "y" in data["error"]
80
+ finally:
81
+ c.close()
82
+
83
+
84
+ def test_goto_without_url_returns_a_named_error(http_server):
85
+ host, port, _ = http_server
86
+ c = _conn(host, port)
87
+ try:
88
+ for payload in ({"action": "goto"}, {"action": "goto", "url": " "}):
89
+ code, data = _post(c, "/action", payload)
90
+ assert code in (400, 500)
91
+ assert "url" in data["error"].lower()
92
+ finally:
93
+ c.close()
94
+
95
+
96
+ def test_unknown_action_still_names_the_action(http_server):
97
+ host, port, _ = http_server
98
+ c = _conn(host, port)
99
+ try:
100
+ code, data = _post(c, "/action", {"action": "teleport"})
101
+ assert code in (400, 500)
102
+ assert "teleport" in data["error"]
103
+ finally:
104
+ c.close()
@@ -13,25 +13,26 @@ wheels = [
13
13
 
14
14
  [[package]]
15
15
  name = "browser-agent-server"
16
- version = "1.0.4"
16
+ version = "1.0.5"
17
17
  source = { editable = "." }
18
18
  dependencies = [
19
19
  { name = "trafilatura" },
20
20
  ]
21
21
 
22
- [package.optional-dependencies]
22
+ [package.dev-dependencies]
23
23
  dev = [
24
24
  { name = "pytest" },
25
25
  { name = "ruff" },
26
26
  ]
27
27
 
28
28
  [package.metadata]
29
- requires-dist = [
30
- { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
31
- { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" },
32
- { name = "trafilatura", specifier = ">=1.12,<3" },
29
+ requires-dist = [{ name = "trafilatura", specifier = ">=1.12,<3" }]
30
+
31
+ [package.metadata.requires-dev]
32
+ dev = [
33
+ { name = "pytest", specifier = ">=8.0" },
34
+ { name = "ruff", specifier = ">=0.6" },
33
35
  ]
34
- provides-extras = ["dev"]
35
36
 
36
37
  [[package]]
37
38
  name = "certifi"
@@ -1,38 +0,0 @@
1
- name: ci
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- schedule:
8
- - cron: "17 5 * * 1" # Mondays 05:17 UTC — catch bit-rotting deps early
9
- workflow_dispatch:
10
-
11
- jobs:
12
- python:
13
- strategy:
14
- matrix:
15
- python: ["3.12", "3.13"]
16
- runs-on: ubuntu-latest
17
- steps:
18
- - uses: actions/checkout@v4
19
- - uses: astral-sh/setup-uv@v5
20
- - uses: actions/setup-python@v5
21
- with:
22
- python-version: ${{ matrix.python }}
23
- - name: Install
24
- run: |
25
- uv venv
26
- uv pip install -e ".[dev]" --no-cache
27
- - name: Ruff
28
- run: uv run ruff check src tests
29
- - name: Tests
30
- run: uv run pytest -q
31
-
32
- gitleaks:
33
- runs-on: ubuntu-latest
34
- steps:
35
- - uses: actions/checkout@v4
36
- with:
37
- fetch-depth: 0
38
- - uses: gitleaks/gitleaks-action@v2
@@ -1,99 +0,0 @@
1
- # Multi-project CD: push every release to production from CI
2
-
3
- The deploy used for `browser-agent` generalizes to any number of projects on the same
4
- server. This page is the one-stop playbook — give it (or the prompt at the bottom) to any
5
- agent to wire up a new project in ~20 minutes.
6
-
7
- ## Architecture (once per server)
8
-
9
- ```
10
- GitHub release.yml (tag push)
11
- build → publish (OIDC, no tokens)
12
- → deploy job → reusable workflow deploy-ssh.yml
13
- └─ ssh <ci-key>@host "deploy <app> <ver>"
14
- └─ authorized_keys forced command → /usr/local/bin/deploy-tools
15
- └─ /etc/deploy-tools.d/<app>.conf
16
- └─ install exact version → restart unit → healthcheck → rollback
17
- ```
18
-
19
- - **One CI key serves all projects.** The key is pinned by a forced command to the
20
- dispatcher; it cannot open a shell, forward ports, or run anything else.
21
- - **The whitelist of deployable apps is the server itself**: an app exists iff a
22
- root-owned `/etc/deploy-tools.d/<app>.conf` exists. Unknown names are rejected.
23
- - **No public endpoint, no firewall changes** — push arrives over the SSH port that is
24
- already open for admin access.
25
-
26
- Server-side artifacts (already installed on 38.244.152.2; copy to a new host as-is):
27
-
28
- - `/usr/local/bin/deploy-tools` — the generic dispatcher (protocol: `deploy <app> <semver>`;
29
- legacy `deploy <semver>` works while exactly one app is configured).
30
- - `/etc/deploy-tools.d/browser-agent.conf` — the reference app config.
31
- - backups under `/root/bak/`.
32
-
33
- ## Per-project checklist (~20 min)
34
-
35
- Prerequisites: the project is a package published from CI to a registry with OIDC
36
- trusted publishing (same one-time registry setup as in `docs/publishing.md`), and it runs
37
- on the server as a systemd unit with some health signal.
38
-
39
- 1. **Systemd unit** — the service must come up healthy after `systemctl restart`. Health
40
- signal: either a JSON endpoint answering `"version": "X.Y.Z"` or a CLI
41
- `myapp --version`. No JSON endpoint? Put `HEALTH_CMD=...` in the config.
42
- 2. **Server config** — create `/etc/deploy-tools.d/<app>.conf` (app name: `[a-z0-9-]+`):
43
-
44
- ```bash
45
- INSTALLER=uv # uv (Python) | npm-global (Node)
46
- PKG=<registry package name> # e.g. my-tool-server / @scope/my-tool
47
- BIN=<executable name> # for uv tools (for --version); optional otherwise
48
- UNIT=<systemd unit name>
49
- STATUS_URL=http://127.0.0.1:PORT/status # OR: HEALTH_CMD='...'
50
- HEALTH_FIELD=version
51
- ```
52
-
53
- Test on the host:
54
-
55
- ```bash
56
- SSH_ORIGINAL_COMMAND="deploy <app> <current-version>" /usr/local/bin/deploy-tools
57
- # expect: "already on <ver> and healthy — nothing to do"
58
- ```
59
-
60
- 3. **Registry/CI side already solved**: publish from `release.yml` exactly like this repo
61
- (`pypi`/`npm` job first, so deploy only installs what already exists in the registry).
62
- 4. **Deploy job** in the project's `release.yml`:
63
-
64
- ```yaml
65
- deploy:
66
- needs: <publish-job>
67
- uses: vernikr/browser-agent/.github/workflows/deploy-ssh.yml@main
68
- with:
69
- app: <app>
70
- tag: ${{ github.ref_name }}
71
- secrets: inherit
72
- ```
73
-
74
- 5. **Repo secrets** (Settings → Secrets and variables → Actions): `DEPLOY_SSH_KEY`,
75
- `DEPLOY_HOST`, `DEPLOY_USER` — the same values as in this repo (reusing the same
76
- single-purpose key across repos is fine and simplifies rotation; rotate by replacing
77
- the one `authorized_keys` line + the secret values).
78
- 6. **Smoke-release**: tag `vX.Y.Z+1`, watch the Actions tab — deploy should be green and
79
- the unit should report the new version on its health endpoint.
80
-
81
- Failure semantics: unreadable registry → retries; bad health → automatic reinstall of the
82
- previous version, restart, re-check, and the CI job turns red. Everything is logged in the
83
- GitHub job — no `journalctl` digging needed for routine failures.
84
-
85
- ## Agent prompt (copy-paste to configure a new project)
86
-
87
- > Wire the `<PROJECT>` repo for push-style CD to our production server, following
88
- > `vernikr/browser-agent/docs/multi-project-cd.md`: (1) on the server create
89
- > `/etc/deploy-tools.d/<app>.conf` for unit `<UNIT>`, package `<PKG>` (`INSTALLER=uv|npm-global`),
90
- > health via `STATUS_URL=<...>` or `HEALTH_CMD`, then smoke-test with
91
- > `SSH_ORIGINAL_COMMAND="deploy <app> <current-version>" /usr/local/bin/deploy-tools`;
92
- > (2) add a `deploy` job to `<PROJECT>`'s `release.yml` that `needs:` the publish job and
93
- > calls `vernikr/browser-agent/.github/workflows/deploy-ssh.yml@main` with
94
- > `with: { app: <app>, tag: github.ref_name }` and `secrets: inherit`;
95
- > (3) add the repo secrets `DEPLOY_SSH_KEY` / `DEPLOY_HOST` / `DEPLOY_USER` (same values as
96
- > in `vernikr/browser-agent`); (4) ship a patch release and confirm the deploy job is green
97
- > and the unit reports the new version. Do not touch other services, firewall, or VPN units
98
- > on the server; before modifying `authorized_keys` or the dispatcher, back the file up to
99
- > `/root/bak/`.