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.
- browser_agent_server-1.0.5/.github/dependabot.yml +17 -0
- browser_agent_server-1.0.5/.github/workflows/ci.yml +56 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/.github/workflows/release.yml +1 -2
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/AGENTS.md +1 -1
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/CHANGELOG.md +39 -1
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/PKG-INFO +1 -4
- browser_agent_server-1.0.5/docs/multi-project-cd.md +154 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/pyproject.toml +2 -2
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/src/browser_agent/chrome.py +6 -5
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/src/browser_agent/cli.py +9 -4
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/src/browser_agent/server.py +71 -6
- browser_agent_server-1.0.5/tests/test_cli_state_home.py +63 -0
- browser_agent_server-1.0.5/tests/test_http_edges.py +104 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/uv.lock +8 -7
- browser_agent_server-1.0.4/.github/workflows/ci.yml +0 -38
- browser_agent_server-1.0.4/docs/multi-project-cd.md +0 -99
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/.github/workflows/deploy-ssh.yml +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/.gitignore +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/LICENSE +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/README.md +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/docs/api.md +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/docs/architecture.md +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/docs/deployment.md +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/docs/publishing.md +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/docs/security.md +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/docs/troubleshooting.md +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/src/browser_agent/__init__.py +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/src/browser_agent/data/browser-agent.service +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/src/browser_agent/vision_agent.py +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/src/browser_agent/x11_input.py +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/tests/test_bezier.py +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/tests/test_chrome_state_dir.py +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/tests/test_cli.py +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/tests/test_client.py +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/tests/test_cloudflare.py +0 -0
- {browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/tests/test_http_api.py +0 -0
- {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 }}
|
|
@@ -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
|
|
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
|
-
## [
|
|
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.
|
|
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.
|
|
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
|
-
[
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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/.
|
|
204
|
-
checks.append(_check(
|
|
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=
|
|
135
|
-
y=
|
|
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=
|
|
154
|
-
y=
|
|
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.
|
|
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.
|
|
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.
|
|
16
|
+
version = "1.0.5"
|
|
17
17
|
source = { editable = "." }
|
|
18
18
|
dependencies = [
|
|
19
19
|
{ name = "trafilatura" },
|
|
20
20
|
]
|
|
21
21
|
|
|
22
|
-
[package.
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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/`.
|
{browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/.github/ISSUE_TEMPLATE/bug_report.yml
RENAMED
|
File without changes
|
|
File without changes
|
{browser_agent_server-1.0.4 → browser_agent_server-1.0.5}/.github/ISSUE_TEMPLATE/feature_request.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|