jevway 0.0.0-stage → 0.1.0
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.
- package/INSTALL-FOR-AGENTS.md +138 -0
- package/LICENSE +11 -0
- package/LICENSE.jev-ultrafast +21 -0
- package/README.md +218 -2
- package/dist/browser/bot-wall.js +105 -0
- package/dist/browser/cdp.js +112 -0
- package/dist/browser/chrome.js +147 -0
- package/dist/browser/devices.js +20 -0
- package/dist/browser/frames.js +516 -0
- package/dist/browser/snapshot.js +611 -0
- package/dist/browser/tab.js +938 -0
- package/dist/browser/words.js +51 -0
- package/dist/cli.js +112 -0
- package/dist/jev/action-space.js +48 -0
- package/dist/jev/box.js +58 -0
- package/dist/jev/decide.js +405 -0
- package/dist/jev/instructions.js +67 -0
- package/dist/jev/model.js +189 -0
- package/dist/jev/replay.js +213 -0
- package/dist/jev/secrets.js +60 -0
- package/dist/journeys/diagnostics.js +85 -0
- package/dist/journeys/journey-doc.js +50 -0
- package/dist/journeys/journey-session.js +503 -0
- package/dist/journeys/pick-next-move.js +59 -0
- package/dist/journeys/render-viewer.js +153 -0
- package/dist/journeys/screen-boxes.js +49 -0
- package/dist/journeys/screen-judgments.js +149 -0
- package/dist/mcp.js +359 -0
- package/dist/setup/doctor-command.js +81 -0
- package/dist/setup/doctor.js +163 -0
- package/dist/setup/env-file.js +50 -0
- package/dist/task/checks.js +156 -0
- package/dist/task/evidence.js +96 -0
- package/dist/task/hosts.js +91 -0
- package/dist/task/look.js +17 -0
- package/dist/task/recording-page.js +310 -0
- package/dist/task/recording.js +232 -0
- package/dist/task/run-limit.js +66 -0
- package/dist/task/run-task.js +1607 -0
- package/dist/task/start-task.js +91 -0
- package/dist/task/upload-files.js +43 -0
- package/package.json +77 -4
- package/skill/jevway/SKILL.md +73 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Installing jevway (for agents)
|
|
2
|
+
|
|
3
|
+
You are an agent asked to install jevway for a person. jevway is an MCP server, `jevway-mcp`, plus a CLI, `jevway`.
|
|
4
|
+
It hands browser goals to TypeSafe Jev. Both come from the npm package `jevway`.
|
|
5
|
+
|
|
6
|
+
Read this whole file before you run anything. Run only these:
|
|
7
|
+
- `npx -y jevway doctor` (or `jevway doctor`);
|
|
8
|
+
- `npm install -g jevway`;
|
|
9
|
+
- the client's own `mcp add` command, or an edit to its MCP config file that the person approved.
|
|
10
|
+
|
|
11
|
+
Done means: `jevway doctor` exits 0, and the client lists the `jevway_*` tools.
|
|
12
|
+
|
|
13
|
+
## Rules
|
|
14
|
+
|
|
15
|
+
- **Show a diff before editing any MCP config file, and wait for approval.** Keep the person's other servers as they
|
|
16
|
+
are. When the client has an `mcp add` command, prefer it.
|
|
17
|
+
- **Never ask for the key in the chat, and never echo it.** The person puts it in `~/.config/jevway/env` themselves
|
|
18
|
+
(below), or in the client's env block. Don't write a key into config that is committed to git or synced between
|
|
19
|
+
machines.
|
|
20
|
+
- **Never relaunch, close or restart a browser you didn't start.** For the person's own Chrome, ask them to allow
|
|
21
|
+
remote debugging. If they'd rather keep it untouched, use `JEVWAY_BROWSER=launch` (a throwaway profile jevway starts
|
|
22
|
+
and closes).
|
|
23
|
+
- **Pass the key explicitly when the client filters the environment.** Codex, for example, gives MCP servers only a few
|
|
24
|
+
variables. The env file works on every client, because jevway reads it itself; the client only has to pass `HOME`.
|
|
25
|
+
Otherwise put `TYPESAFE_API_KEY` in that server's env block.
|
|
26
|
+
- **Stop and ask** when a step would change another browser tool's config, or when the doctor reports something this
|
|
27
|
+
file doesn't cover.
|
|
28
|
+
|
|
29
|
+
## 1. Check the machine
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx -y jevway doctor --plan
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
It prints the setup steps still missing, numbered. It needs no key to run and never prints one. It checks:
|
|
36
|
+
- Node 24 or later;
|
|
37
|
+
- a key, and which route it selects;
|
|
38
|
+
- the browser source;
|
|
39
|
+
- ffmpeg (optional: only for recordings).
|
|
40
|
+
|
|
41
|
+
If the first `npx` start is slow, or a client times out starting the server, install once with `npm install -g jevway`.
|
|
42
|
+
Then use `jevway-mcp` as the command instead of `npx -y -p jevway jevway-mcp`.
|
|
43
|
+
|
|
44
|
+
## 2. The key
|
|
45
|
+
|
|
46
|
+
jevway needs one of these:
|
|
47
|
+
- `TYPESAFE_API_KEY` (preferred): Jev on the TypeSafe API, with screenshots and Box.
|
|
48
|
+
- `OPENROUTER_API_KEY`: Jev, and the other models, through OpenRouter.
|
|
49
|
+
|
|
50
|
+
Ask the person to add the line themselves, in their own editor or terminal, never in this chat:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
mkdir -p ~/.config/jevway && touch ~/.config/jevway/env && chmod 600 ~/.config/jevway/env
|
|
54
|
+
# then, in an editor: TYPESAFE_API_KEY=<their key>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
How the file is read:
|
|
58
|
+
- `jevway` and `jevway-mcp` read it at start (`$XDG_CONFIG_HOME/jevway/env` when that is set).
|
|
59
|
+
- They take only the keys, `CHROME_PATH` and `JEVWAY_*` settings.
|
|
60
|
+
- A variable already in the environment wins.
|
|
61
|
+
|
|
62
|
+
Without a key the MCP server still starts and lists its tools. `jevway_look` works, and each other call answers with
|
|
63
|
+
the fix.
|
|
64
|
+
|
|
65
|
+
## 3. Register the MCP server
|
|
66
|
+
|
|
67
|
+
Use the server name `jevway`. Add `JEVWAY_BROWSER` only if the person chose something other than `auto`.
|
|
68
|
+
|
|
69
|
+
**Claude Code.** The plugin brings the server and the skill in one install. Run these inside Claude Code:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
/plugin marketplace add AbdallahAHO/jevway
|
|
73
|
+
/plugin install jevway@jevway
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Or the server alone:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
claude mcp add jevway -s user -- npx -y -p jevway jevway-mcp
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Codex**:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
codex mcp add jevway -- npx -y -p jevway jevway-mcp
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
That writes this to `~/.codex/config.toml`:
|
|
89
|
+
|
|
90
|
+
```toml
|
|
91
|
+
[mcp_servers.jevway]
|
|
92
|
+
command = "npx"
|
|
93
|
+
args = ["-y", "-p", "jevway", "jevway-mcp"]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Cursor**: in `~/.cursor/mcp.json`, add to `mcpServers` (diff first):
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
"jevway": { "command": "npx", "args": ["-y", "-p", "jevway", "jevway-mcp"] }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**OpenCode**: in `~/.config/opencode/opencode.json`, add to `mcp` (diff first):
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
"jevway": { "type": "local", "command": ["npx", "-y", "-p", "jevway", "jevway-mcp"], "enabled": true }
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Any other stdio MCP client**: command `npx`, args `["-y", "-p", "jevway", "jevway-mcp"]`.
|
|
109
|
+
|
|
110
|
+
## 4. The browser
|
|
111
|
+
|
|
112
|
+
`JEVWAY_BROWSER` (the server's env, or `--browser` for the CLI):
|
|
113
|
+
|
|
114
|
+
| Value | What it uses | What the person does |
|
|
115
|
+
| --- | --- | --- |
|
|
116
|
+
| `auto` (default) | their running Chrome, shared with `chrome-devtools-mcp --auto-connect` | open `chrome://inspect/#remote-debugging` and allow it; Chrome asks once more on jevway's first connection |
|
|
117
|
+
| `launch` / `launch-headless` | a throwaway profile jevway starts and closes | nothing (Chrome must be installed, or `CHROME_PATH` set) |
|
|
118
|
+
| `http://127.0.0.1:<port>` | a Chrome started with `--remote-debugging-port=<port>` and its own `--user-data-dir` | start that Chrome; never point it at their everyday profile |
|
|
119
|
+
|
|
120
|
+
## 5. The skill (clients other than the Claude Code plugin)
|
|
121
|
+
|
|
122
|
+
The skill says when to use `jevway_task` and when to use chrome-devtools. It is shipped in the package, at
|
|
123
|
+
`skill/jevway/SKILL.md`. Copy that folder into the client's skills folder if it has one, e.g.
|
|
124
|
+
`~/.claude/skills/jevway/` or `~/.codex/skills/jevway/`.
|
|
125
|
+
|
|
126
|
+
## 6. Check the outcome
|
|
127
|
+
|
|
128
|
+
1. `npx -y jevway doctor` exits 0. Every line is `ok` or `warn`, and the last line starts with `Ready`.
|
|
129
|
+
2. The client lists the tools: `jevway_task`, `jevway_look`, `jevway_end` and `jevway_journey_start`, `_step`,
|
|
130
|
+
`_autopilot`, `_annotate`, `_finish`.
|
|
131
|
+
- In Claude Code: `/mcp`.
|
|
132
|
+
- Elsewhere, the client's MCP list.
|
|
133
|
+
- Or the MCP Inspector:
|
|
134
|
+
`npx @modelcontextprotocol/inspector --cli npx -y -p jevway jevway-mcp --method tools/list`.
|
|
135
|
+
3. Optional, costs a fraction of a cent: `npx -y jevway doctor --live` makes one real Jev request and reports its
|
|
136
|
+
latency.
|
|
137
|
+
|
|
138
|
+
When a check fails, the doctor names the cause and the fix, and `--plan` lists the steps left. Re-run it after each fix.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abdallah Othman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
10
|
+
|
|
11
|
+
Portions (src/browser/snapshot.js and adapted loop/guard logic) come from browser-use/jev-ultrafast; see LICENSE.jev-ultrafast.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Browser Use
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,219 @@
|
|
|
1
|
-
#
|
|
1
|
+
# jevway
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Give your coding agent a browser goal. [TypeSafe Jev](https://docs.typesafe.ai) picks each click, type, and select in about 300 ms, and the agent gets back **one short summary** instead of making every decision itself.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
agent ──jevway_task(goal, values, done_when)──▶ jevway ──one Jev request per step──▶ Chrome
|
|
7
|
+
agent ◀── { status, url, steps, stuck? } ──────┘ (same Chrome chrome-devtools inspects)
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The only thing it needs is `TYPESAFE_API_KEY` or `OPENROUTER_API_KEY`. Other decision models, some of which read screenshots, are described [below](#models). No second LLM is involved: the agent passes the text values it already knows, and Jev *selects* which value goes into which field (a speculative `value_for_<field>` head in the same request).
|
|
11
|
+
|
|
12
|
+
Formerly jev-browse: its tools were `browser_task`, `browser_look` and `browser_task_end`, and its environment variables `JEV_*`; none of those names work any more.
|
|
13
|
+
|
|
14
|
+
Built on the loop, DOM snapshot, and guards from [browser-use/jev-ultrafast](https://github.com/browser-use/jev-ultrafast) (MIT).
|
|
15
|
+
|
|
16
|
+
## Install into any agent
|
|
17
|
+
|
|
18
|
+
**Claude Code plugin** (the MCP server and the skill in one install), inside Claude Code:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
/plugin marketplace add AbdallahAHO/jevway
|
|
22
|
+
/plugin install jevway@jevway
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
On Claude Code 2.1.275 or later this works in one step: `/plugin install jevway --marketplace AbdallahAHO/jevway`.
|
|
26
|
+
|
|
27
|
+
**Or the MCP server alone**, one line per client:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
claude mcp add jevway -s user -- npx -y -p jevway jevway-mcp # Claude Code
|
|
31
|
+
codex mcp add jevway -- npx -y -p jevway jevway-mcp # Codex
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Cursor (`~/.cursor/mcp.json`, under `mcpServers`), or any other stdio MCP client:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
"jevway": { "command": "npx", "args": ["-y", "-p", "jevway", "jevway-mcp"] }
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
OpenCode (`opencode.json`, under `mcp`):
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
"jevway": { "type": "local", "command": ["npx", "-y", "-p", "jevway", "jevway-mcp"], "enabled": true }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**The key** goes in `~/.config/jevway/env` (`$XDG_CONFIG_HOME/jevway/env` when that is set), so it never has to go
|
|
47
|
+
into a chat or a synced client config:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
mkdir -p ~/.config/jevway && touch ~/.config/jevway/env && chmod 600 ~/.config/jevway/env
|
|
51
|
+
# then add the line TYPESAFE_API_KEY=… (or OPENROUTER_API_KEY=…) in an editor
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `jevway` and `jevway-mcp` read the file at start. A variable already in the environment wins, so a client's `env`
|
|
55
|
+
block works too.
|
|
56
|
+
- Some clients pass servers a filtered environment; the file still works there.
|
|
57
|
+
- Without a key the server still starts and lists its tools: `jevway_look` works, and the other tools answer with the fix.
|
|
58
|
+
|
|
59
|
+
**Check the setup:**
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx -y jevway doctor # Node, key and route, browser, ffmpeg; exit 0 when ready; never prints the key
|
|
63
|
+
npx -y jevway doctor --plan # the steps still missing, numbered
|
|
64
|
+
npx -y jevway doctor --live # plus one real Jev request (a fraction of a cent)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Installing through an agent? Point it at [INSTALL-FOR-AGENTS.md](INSTALL-FOR-AGENTS.md): rules (a diff before any
|
|
68
|
+
config edit, no key in the chat, no relaunching a browser it didn't start), steps per client, and checks.
|
|
69
|
+
|
|
70
|
+
If a client times out on `npx`'s first download, run `npm install -g jevway` once and use `jevway-mcp` as the command.
|
|
71
|
+
|
|
72
|
+
`JEVWAY_BROWSER` (the server's `env`, or the env file) selects the browser:
|
|
73
|
+
|
|
74
|
+
- `auto` (default): your running Chrome, after you allow it at `chrome://inspect/#remote-debugging`. This is the same Chrome `chrome-devtools-mcp --auto-connect` uses, so Jev's tab is visible to chrome-devtools.
|
|
75
|
+
- `launch` or `launch-headless`: a throwaway profile.
|
|
76
|
+
- `http://127.0.0.1:9222`: an explicit debugging port.
|
|
77
|
+
|
|
78
|
+
Outside the Claude Code plugin, copy `skill/jevway/` (in the repository, or `node_modules/jevway/skill/jevway/`) into
|
|
79
|
+
your agent's skills, so it knows when to use Jev and when to use chrome-devtools.
|
|
80
|
+
|
|
81
|
+
From a checkout: `pnpm install && pnpm build`, then `node /absolute/path/to/jevway/dist/mcp.js` as the command.
|
|
82
|
+
|
|
83
|
+
For agents without MCP, use the CLI:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npx -y jevway --browser launch-headless \
|
|
87
|
+
--url https://en.wikipedia.org/wiki/Main_Page \
|
|
88
|
+
--goal "Open the Wikipedia article about the Rosetta Stone" \
|
|
89
|
+
--values '{"search_query":"Rosetta Stone"}' \
|
|
90
|
+
--done-when "The Rosetta Stone article is open"
|
|
91
|
+
# prints the JSON summary; exit 0 only when status is "done"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Models
|
|
95
|
+
|
|
96
|
+
`JEVWAY_MODEL` (or `--model`) picks the decision model. All of them speak the same System One API through the same SDK. With `TYPESAFE_API_KEY` set, Jev runs on the TypeSafe API as `jev-1.14.0`: screenshots go in as `assets` and Box questions are available. Everything else, and Jev without that key, goes through OpenRouter with `OPENROUTER_API_KEY`. OpenRouter drops TypeSafe's `assets` map, so Box only runs on the direct route.
|
|
97
|
+
|
|
98
|
+
| `JEVWAY_MODEL` | Model | Screenshots | Fixture + live web (`pnpm verify`) | Journeys |
|
|
99
|
+
| --- | --- | --- | --- | --- |
|
|
100
|
+
| `jev` (default) | `jev-1.14.0` (TypeSafe API: `assets` + Box) / `typesafe/jev-1.14` (OpenRouter) | `fallback` | direct: 7/7 `fallback` and `always`, 6/6 off, ~240 ms/request | 8/8 off and `always` |
|
|
101
|
+
| `pplx-decider` | `perplexity/pplx-decider-v1.1-27b` | `always` | 6/6 off and `always`, 0.4–1.3 s/request | 8/8 |
|
|
102
|
+
| `clef-omni` | `cloudflare/clef-omni` | `always` | 4/6 (3/6 off) | 7/8 (8/8 off) |
|
|
103
|
+
| `clef` | `cloudflare/clef` | none (images cost 16k tokens) | 6/6, 0.7–1.2 s/request | — |
|
|
104
|
+
| `clef-flash` | `cloudflare/clef-flash` | `always` | 1/6 | — |
|
|
105
|
+
| `gpt-6-luna` | `openai/gpt-6-luna-decisions` | `always` | 0–1/6: OpenAI refuses target questions (502) | — |
|
|
106
|
+
|
|
107
|
+
**Jev 1.14 is the fastest model that passes everything, and it reads screenshots. Perplexity's decider also passes everything with screenshots, but slower. Clef passes text-only, and is slower still.**
|
|
108
|
+
|
|
109
|
+
`JEVWAY_SCREENSHOTS` (or `--screenshots`) controls screenshots for the vision models:
|
|
110
|
+
|
|
111
|
+
- `always`: on every step.
|
|
112
|
+
- `fallback`: only when a text-only decision would stop as uncertain or blocked, has its DONE rejected, or follows an action that changed nothing.
|
|
113
|
+
- `off`: never.
|
|
114
|
+
|
|
115
|
+
Journeys send one with each screen's judgments whenever the mode isn't `off`. Each observed element index is drawn on the image as a numbered box, taken from the snapshot's own rects. Covered elements get no box, and the overlay is removed before any input. The model still only selects observed ids.
|
|
116
|
+
|
|
117
|
+
### Box (Jev 1.14 on the TypeSafe API)
|
|
118
|
+
|
|
119
|
+
On every request that carries a screenshot, Box questions run alongside the decision. Box answers always land back on an **observed element** (`src/jev/box.ts`), with one opt-in exception:
|
|
120
|
+
|
|
121
|
+
- **Cross-check:** Box asks where the next click belongs. If it confidently points at a different element than Jev chose, and the box doesn't also cover Jev's choice, the step hands back as `uncertain`.
|
|
122
|
+
- **Covered check:** for a chosen click, a follow-up asks whether the target is visibly unobstructed and boxes it. A clearly covered target is fed back as history ("covered; clear it first") up to twice, then hands back.
|
|
123
|
+
- **Rescue:** when Jev is BLOCKED, unsure, or claims DONE that `done_when` rejects, and the box confidently covers an observed element, jevway clicks that element. It is still gated by the box's own commit-risk score.
|
|
124
|
+
- **Coordinate clicks (opt-in):** `allow_coordinate_clicks` / `--allow-coordinate-clicks`. When Jev is stuck and the box matches nothing the DOM reader reported (canvas, or a frame that was skipped), jevway clicks the box centre after a hit-test. Irreversible-looking clicks still need `confirm_irreversible`.
|
|
125
|
+
- **Journeys:** each screen's primary action is boxed (a dashed outline in the viewer, `primary_action` in reports). A tappable control with no DOM element becomes a `control_not_in_dom` QA finding.
|
|
126
|
+
|
|
127
|
+
Every model judges commit risk **per click candidate by name**, in one follow-up request per step. Jev also keeps its page-level guess, and a click is held if either says so. Per-model thresholds, rules and results are in [docs/verification.md](docs/verification.md#decision-models-on-openrouter-2026-10-11).
|
|
128
|
+
|
|
129
|
+
## Tools
|
|
130
|
+
|
|
131
|
+
**`jevway_task`** takes `goal`, `url` | `tab`, `values`, `secret_keys`, `files`, `done_when`, `checks`, `session`, `confirm_irreversible`, `confirm_target`, `max_steps`, `max_seconds`, `allowed_hosts`, `blocked_hosts`, and, for new sessions only, `allow_coordinate_clicks` and `record_dir`. It returns:
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{ "session": "be63210d", "status": "needs_input", "reason": "Blocked: no supplied value for \"Email address\"",
|
|
135
|
+
"url": "…", "title": "…", "page_text": "…", "elapsed_ms": 899,
|
|
136
|
+
"steps": [{ "operation": "TYPE_TEXT", "element": "Full name", "value_key": "full_name", "page_changed": true, "confidence": 0.9 }],
|
|
137
|
+
"jev": { "requests": 3, "input_tokens": 7534, "median_latency_ms": 284, "screenshots": 0, "model": "jev-1.14.0", "est_cost_usd": 0.000316 },
|
|
138
|
+
"stuck": { "operation": "BLOCKED", "candidates": [] },
|
|
139
|
+
"missing": [{ "label": "Email address", "role": "textbox", "path": "/stays" }] }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`jev.model` is the model id the requests went to. `jev.est_cost_usd` estimates the call's cost from its input tokens (every request, Box and the done's evidence request included) at the model's published rate, rounded to 6 decimals: Jev on the TypeSafe API is priced at $0.042 per 1M input tokens (output is free); Jev through OpenRouter gets the same rate (OpenRouter's margin, if any, unchecked); a model with no rate written down gets `null`. It is an estimate, not billing.
|
|
143
|
+
|
|
144
|
+
`secret_keys` names the keys of `values` that are secrets (an API token, a one-time code). Jev sees `[secret:<key>]` in place of such a value and never reads it; it is typed as given, and wherever the page echoes it, `page_text`, `steps`, reasons, traces and `record_dir`'s `summary.json` and `index.html` show the marker instead. Recorded screenshots and video are pixels of the page and are not masked. Mark only real secrets: every occurrence of the value is replaced, even inside other words.
|
|
145
|
+
|
|
146
|
+
| Status | Meaning |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| `done` | Jev chose DONE **and** a separate `done_when` check passed, or that check alone scored ≥ 0.9 once or ≥ 0.75 on 3 decisions in a row. A rejected DONE waits and re-decides, up to 3 times. |
|
|
149
|
+
| `needs_input` | A field needs a value that isn't in `values`. Nothing is invented. `missing` lists each field (label, role, page path). |
|
|
150
|
+
| `needs_confirmation` | The next click looks irreversible (pay, send, delete, submit). `confirm_irreversible: true` allows exactly one such click, and an agent should set it only for the action and target the user's own request names (a category such as "anything unused" names no target: it lists the candidates and asks the user); otherwise it asks the user. It allows only the control that needs_confirmation named (same label, same page path); a different irreversible control is handed back as a new needs_confirmation. It counts only on the call that answers a needs_confirmation (or a new session's first call, which has no flagged control and allows any one); anywhere else it is ignored. Pass `confirm_target` with the user's words that name the target; jevway checks the page shows them (the control's own words, like "delete", do not count) and refuses otherwise. |
|
|
151
|
+
| `uncertain` | Operation or target confidence was below the model threshold (Jev 0.3; per model in `src/jev/model.ts`), 6 actions in a row ran under 0.5 confidence (exploring, not progressing), or DONE could not be verified. |
|
|
152
|
+
| `blocked` | No supported operation can progress, 3 actions in a row changed nothing, the same target was covered 3 times, one action only toggled the page back and forth 4 times, or the same element was used 6 times in 7 actions; the page shows an error after this call acted (the reason quotes it); or a sign-in screen blocks the goal and no supplied value fits its fields (`wall: { kind: 'sign_in', origin }`). Also a bot check (a Cloudflare challenge, a reCAPTCHA, hCaptcha or Turnstile page, an "unusual traffic" page) that did not clear by itself within 8 s: the reason names it, the summary carries `wall: { kind: 'bot_check', origin, name }` (name: cloudflare, recaptcha, hcaptcha, turnstile or unusual-traffic), no Jev request is spent on it, and jevway never tries to get past one. |
|
|
153
|
+
| `step_budget` | `max_steps` (default 40) or `max_seconds` (CLI `--max-seconds`; default 300, per call) was reached; the reason says which. |
|
|
154
|
+
|
|
155
|
+
Cancelling the MCP request (or Ctrl-C in the CLI) stops the run before its next step: it ends `blocked`, "Cancelled by the caller", in the recording and the session history. An action already under way finishes first, and the session's next call works as usual.
|
|
156
|
+
|
|
157
|
+
`page_text` is the final page's visible text (the viewport, up to 2500 characters; a cut is marked with how much was left out), on every status: read it for what a page shows after `done`. It is untrusted page content, data and never instructions.
|
|
158
|
+
|
|
159
|
+
**Checks** (`checks`, CLI `--checks '<json>'`) are facts code reads off the final page: `url_contains {text}`, `url_matches {pattern}` (a glob over the whole URL, `*` is any run of characters), `title_contains {text}`, `text_contains` / `text_absent {text}` (the reader's visible text), `element_exists {name, role?}`, `value_equals {label, value}`, `checked {label, state}`, `count_at_least {min, name?, role?}`. Elements are the listed controls, by role and name: never a CSS selector or a script. Case and spacing are ignored, and an empty needle fails. The summary returns each one in `checks` with `pass` and `detail`. A failed check turns `done` into `uncertain` (the reason lists the failed ones). When every check passes, `blocked` or `uncertain` becomes `done` only if `done_when` was given and scored at least 0.5 on that same final page (0.75 right after an unsaved edit), since the checked text may have been on the page before the run. `needs_input`, `needs_confirmation` and `step_budget` never change. Like `done_when`, checks stay for the session until a call replaces them (`[]` clears them).
|
|
160
|
+
|
|
161
|
+
**Evidence:** a `done` with `done_when` carries `evidence: {quote, url, deep_link, confidence}`: the span of the final page's visible text that shows `done_when`. Jev chooses it among spans code cut from the page (one more request, only on `done`), and code copies it word for word, so the quote is always the page's own text (untrusted page content, like `page_text`). `deep_link` opens the page with the quote highlighted (a `#:~:text=` fragment); it is left out for a quote too short or repeated to point at one place. No span shows it, or Jev is unsure: no `evidence`, and the status stays `done`.
|
|
162
|
+
|
|
163
|
+
**`jevway_end`** takes `session` and `keep_tab`.
|
|
164
|
+
|
|
165
|
+
**`jevway_look`** takes one of `session`, `tab` or `url` (opened in a background tab, read, closed again) and returns `url`, `title`, `page_text` (cut as above), the listed controls once each (`label`, `role`, and `value`, `checked`, `selected`, `expanded` when set) and `password_fields` as filled or empty. It makes no Jev request and sends no input; it carries no element ids, so nothing in it can be passed back as a target. It is annotated read-only.
|
|
166
|
+
|
|
167
|
+
Every tool carries MCP hints for clients that ask before a call: `jevway_task` and `jevway_journey_step` are destructive and open-world (they can submit, pay or delete on any site), `jevway_journey_start` and `jevway_journey_autopilot` are open-world but not destructive, `jevway_look` is read-only, and `jevway_end`, `jevway_journey_annotate` and `jevway_journey_finish` only touch jevway's own state.
|
|
168
|
+
|
|
169
|
+
**Recording:** `record_dir` (CLI `--record <dir>`) saves a screencast video (`run.mp4`, needs `ffmpeg` on the PATH, otherwise skipped), a 2× JPEG before every action with its target outlined, the final page, `summary.json`, and an `index.html` page that opens on the video with its steps on a timeline and a strip kept in sync, then the run's calls, reason, address and cost. These shots are for people; the model still gets its own 1024 px screenshots.
|
|
170
|
+
|
|
171
|
+
## Safety
|
|
172
|
+
|
|
173
|
+
- Model output only ever selects an observed element index. It never becomes a selector, script, or text, and it becomes a coordinate only through the opt-in coordinate click (stuck, a confident box matching no observed element, a hit-test, and the commit-risk gate).
|
|
174
|
+
- Typing uses real key events per character, so autocompletes register it. Enter is a separate fixed key, offered only right after typing into a search-like field, and pressed only while that field still has focus. Escape is offered only while a menu, popover or suggestion list is expanded, and pressed once automatically when a popup covers the chosen target (then a blur and a synthetic outside press, for lists that ignore Escape).
|
|
175
|
+
- Every input re-checks the page for freshness and for covered targets first (from jev-ultrafast).
|
|
176
|
+
- Page text is treated as untrusted data in every instruction.
|
|
177
|
+
- **Hosts:** `allowed_hosts` / `blocked_hosts` (CLI `--allowed-hosts`, `--blocked-hosts`; server-wide `JEVWAY_ALLOWED_HOSTS`, `JEVWAY_BLOCKED_HOSTS`) take exact hosts or `*.example.com` (subdomains only, not `example.com`). A call can only narrow the server's lists, and a later call only the session's. A start URL outside is never opened; the page URL is checked before every decision and after every step, and leaving ends the run `blocked` naming the host. The run never navigates back. `about:blank` and Chrome error pages always pass; `data:`, `file:` and other host-less pages are outside an allowed list. Only the top page's URL is checked: iframes, popup tabs and the page's own requests are not.
|
|
178
|
+
|
|
179
|
+
## Verify
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
pnpm test # offline: action space, request shape, answer validation, task-loop rules
|
|
183
|
+
pnpm verify # live: headless Chrome + real Jev, checked against independent ground truth
|
|
184
|
+
pnpm verify:real # live public sites, a fresh browser per run, 5 runs each (JEVWAY_REAL_RUNS, JEVWAY_REAL_ONLY)
|
|
185
|
+
pnpm verify:langfuse # 10 jobs in Langfuse's demo project, signed-in Chrome on :9333, recorded into report.html
|
|
186
|
+
pnpm report:render verify/out/<folder> # rebuild a suite's report.html (or a run folder's index.html), offline
|
|
187
|
+
pnpm verify:replay --record verify/out/replay/<name> # live: record every Jev answer of the fixture suites
|
|
188
|
+
pnpm verify:replay verify/out/replay/<name> # offline: replay them, no key, no credits
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`pnpm verify` runs:
|
|
192
|
+
|
|
193
|
+
- **Local booking fixture:** async autocomplete, native select, checkbox, async results, then open a result. Next, `needs_input` for a missing email, then `needs_confirmation` on "Confirm booking and pay", then the confirmed booking. All four scenarios share one session.
|
|
194
|
+
- **Real Wikipedia search.**
|
|
195
|
+
- **Canvas-only control** (Box profiles with screenshots): no click without the opt-in, and the game starts with it.
|
|
196
|
+
- **Wide table rows** (like Langfuse's traces table): rows wider than their scroll box are listed and opened, not their checkboxes. The run is recorded.
|
|
197
|
+
- **MCP stdio round trip.**
|
|
198
|
+
|
|
199
|
+
`pnpm verify:real` runs Wikipedia, MDN, GitHub, Hacker News, npm, Booking.com and Google Flights. Every task stops before login or payment, passes are checked against the final URL, and bot walls (Cloudflare, captchas) are reported apart from failures, judged by the same detector the run uses (`src/browser/bot-wall.ts`).
|
|
200
|
+
|
|
201
|
+
**Replay.** `pnpm verify:replay` re-runs the fixture suites (`verify`, `verify:gotchas`, `verify:redteam`, `verify:capabilities`) against recorded Jev answers, so a loop or guard change can be regression-tested offline and for free. `JEVWAY_REPLAY=record` saves each answer under the sha256 of its request (keys sorted, screenshots left out, local fixture ports normalised) in `JEVWAY_REPLAY_DIR`; `JEVWAY_REPLAY=replay` answers only from there, with no key and no network. A request never recorded is a miss that names the goal and step and shows what changed against the nearest recording, and any miss fails the suite. A change to what Jev is asked is meant to miss: record again after it. Gotchas replay their `normal` profile (the `churn` and `slow` pages change with timing), and the live Wikipedia scenario always misses.
|
|
202
|
+
|
|
203
|
+
Traces go to `verify/out/`. A jobs suite's `report.html` opens on an overview of its jobs, each with its featured run's step shots; a job's view starts with the run's video, its steps on a timeline and a strip that follow the video, every run one click away, then what Jev was given and how the run was judged. A step shot on the overview opens its run at that step (`#<job>-r<run>-s<step>`), and a phone recording sits in a frame of its own aspect. `verify:agent`'s page has the same layout: each chore's runs, with the agent's tool calls, their arguments and statuses, its final answer and the judgement. Each run's cost row shows its estimated cost (the sum of its calls' `est_cost_usd`), and the overview the median per run and the suite's total; a run saved before summaries carried a cost, or on a model without a price, shows "unknown". Results are in [docs/verification.md](docs/verification.md).
|
|
204
|
+
|
|
205
|
+
How jevway compares with other open-source Jev browser projects, and which of their ideas are planned or rejected: [docs/prior-art.md](docs/prior-art.md).
|
|
206
|
+
|
|
207
|
+
## Limits
|
|
208
|
+
|
|
209
|
+
- An element whose centre is off-screen (a row wider than its scroll box) is clicked at the centre of its visible part. A target clipped by a scrolling sidebar or table is scrolled into view just before the click.
|
|
210
|
+
- Also read: sortable column headers (including ones scrolled sideways), plain pointer-cursor items inside open popups (date-range menus), and contenteditable search bars. Rows are labelled by their visible text.
|
|
211
|
+
- The DOM reader covers common HTML/ARIA controls, including inside open shadow roots and iframes. Every visible frame, same-origin or cross-origin (out-of-process frames too), is read with the same reader: its controls continue the page's numbering, its text follows the page's under `[Frame: …]` within the same 6000 characters, and clicks, typing and every guard run inside the frame. A frame is skipped when it shows less than 16×16 px, sits outside an open modal, is still blank, takes over 1.5 s to read, or is past the caps (10 frames, 3 levels deep); controls a banner or sheet covers in the page around the frame aren't listed. CSS-transformed (scaled, rotated) iframes are placed as if untransformed.
|
|
212
|
+
- Closed shadow roots and canvas aren't read, and file inputs inside iframes aren't supported: they are not listed, and an upload is never set inside a frame. Box can see canvas controls (and frames skipped above), and click them with the opt-in; otherwise use chrome-devtools.
|
|
213
|
+
- Uploads take only files the caller declares in `files` (key → absolute path, checked before the run), never a path from `values`. Only then are file inputs listed (hidden ones through their label or styled button) and UPLOAD offered; Jev sees each key and file name, never the path, and a native file picker is intercepted. No declared file fitting a field is `needs_input`.
|
|
214
|
+
- A tab the session's own click opens (`target=_blank`, `window.open`) is followed, and a closed one falls back to the tab that opened it; tabs anyone else opens are never touched. `jevway_end` closes the tabs the session's clicks opened (and its first tab when it opened it) unless `keep_tab`. The new tab is set up when it appears, so on a page that loads within ~20 ms its first document's own scripts run before the click counter; that can only end a run `blocked`, never repeat a click. A dialog the new tab raises before it is set up freezes it; following then fails after 5 s and the run ends `blocked`. In `auto` mode Chrome shows the new tab in front. A `record_dir` video stays on the first tab.
|
|
215
|
+
- Only on-screen elements are listed; Jev scrolls to reveal more. On a page whose document doesn't scroll (an app shell), the scroll goes to the largest visible pane that does; a scroll that moved nothing is reported to Jev as such. Exception: an off-screen link, button or row containing one of the supplied `values` is listed too, and scrolled into view when clicked.
|
|
216
|
+
- The scroll aimed at an inner pane works on the main page only; inside an iframe, scrolling stays page-level.
|
|
217
|
+
- Confidence thresholds (per model, in `src/jev/model.ts`) and the 0.5 risk threshold are starting points, tuned on a small set of runs.
|
|
218
|
+
- Jev's commit-risk check is a speculative page-level judgment ("the click that best advances the goal") plus a per-element score for the chosen click; the higher one counts. Other models score each candidate only, and Box rescues use the box's own score.
|
|
219
|
+
- A click scoring 0.25–0.5 on risk gets a screenshot look before it runs, but only with screenshots on (`fallback` or `always`). With screenshots `off`, text alone under-reads some checkout pages (a bare "Continue" can score under 0.5), so keep screenshots on when a task can reach a payment step.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// A challenge page says it all at the top; comments and articles that discuss captchas sit below this.
|
|
2
|
+
const HEAD_CHARS = 600;
|
|
3
|
+
// Cloudflare's interstitial titles. Alone they prove nothing (an app may say "Just a moment..." while loading).
|
|
4
|
+
const CHALLENGE_TITLES = ['just a moment...', 'attention required! | cloudflare', 'please wait... | cloudflare'];
|
|
5
|
+
// Lines a challenge shows. A brand line is one an article quoting a challenge rarely carries along with two more.
|
|
6
|
+
const MARKERS = [
|
|
7
|
+
{ text: 'our systems have detected unusual traffic from your computer network', brand: 'Google' },
|
|
8
|
+
{ text: "this page checks to see if it's really you sending the requests" },
|
|
9
|
+
{ text: 'uses a security service to protect against malicious bots' },
|
|
10
|
+
{ text: 'needs to review the security of your connection' },
|
|
11
|
+
{ text: 'checking if the site connection is secure' },
|
|
12
|
+
{ text: 'enable javascript and cookies to continue' },
|
|
13
|
+
{ text: 'checking your browser before accessing' },
|
|
14
|
+
{ text: 'performance and security by cloudflare', brand: 'Cloudflare' },
|
|
15
|
+
{ text: 'performance & security by cloudflare', brand: 'Cloudflare' },
|
|
16
|
+
{ text: 'performing security verification' },
|
|
17
|
+
{ text: 'complete the security check' },
|
|
18
|
+
{ text: 'sorry, you have been blocked' },
|
|
19
|
+
{ text: 'verifies you are not a bot' },
|
|
20
|
+
{ text: 'verify you are not a bot' },
|
|
21
|
+
{ text: 'verifying you are human' },
|
|
22
|
+
{ text: 'verify you are human' },
|
|
23
|
+
{ text: 'unusual traffic' },
|
|
24
|
+
{ text: 'are you a robot' },
|
|
25
|
+
{ text: 'not a robot' },
|
|
26
|
+
{ text: 'ray id:', brand: 'Cloudflare' },
|
|
27
|
+
];
|
|
28
|
+
const MAX_EVIDENCE = 4;
|
|
29
|
+
const normalized = (text) => text.replace(/[‘’]/g, "'").replace(/\s+/g, ' ').trim().toLowerCase();
|
|
30
|
+
/**
|
|
31
|
+
* Whether a page is a bot wall, and which. A wall is a Cloudflare challenge title with at least one challenge line, or
|
|
32
|
+
* 3+ distinct challenge lines including a brand line (Cloudflare's Ray ID or footer, Google's unusual-traffic
|
|
33
|
+
* sentence), or a large visible challenge box with 2+ challenge lines. So an article quoting a line or two, a thread
|
|
34
|
+
* about captchas, or a sign-in page is never one. Only the title and the first 600 characters of the text count.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* detectBotWall({ title: 'Just a moment...', text: 'Performing security verification\nRay ID: …' });
|
|
38
|
+
* // { name: 'Cloudflare', provider: 'cloudflare', evidence: ['title "just a moment..."', 'ray id:', 'performing security verification'] }
|
|
39
|
+
*/
|
|
40
|
+
export const detectBotWall = (page, boxes = []) => {
|
|
41
|
+
const title = normalized(page.title);
|
|
42
|
+
const head = normalized(page.text).slice(0, HEAD_CHARS);
|
|
43
|
+
const titleHit = CHALLENGE_TITLES.find((candidate) => title.includes(candidate));
|
|
44
|
+
// Longest first, so a line counted once never counts again through a shorter marker inside it.
|
|
45
|
+
const matched = [];
|
|
46
|
+
for (const marker of MARKERS) {
|
|
47
|
+
if (head.includes(marker.text) && !matched.some((m) => m.text.includes(marker.text)))
|
|
48
|
+
matched.push(marker);
|
|
49
|
+
}
|
|
50
|
+
const brands = new Set(matched.flatMap((m) => (m.brand ? [m.brand] : [])));
|
|
51
|
+
const isWall = (titleHit && matched.length >= 1) || (matched.length >= 3 && brands.size > 0) || (boxes.length > 0 && matched.length >= 2);
|
|
52
|
+
if (!isWall)
|
|
53
|
+
return undefined;
|
|
54
|
+
const cloudflare = Boolean(titleHit) || brands.has('Cloudflare');
|
|
55
|
+
const name = cloudflare
|
|
56
|
+
? boxes.includes('Turnstile') ? 'Cloudflare Turnstile' : 'Cloudflare'
|
|
57
|
+
: (boxes[0] ?? '"unusual traffic" page');
|
|
58
|
+
// The brand lines say whose check it is, so they lead; the rest follow in page order.
|
|
59
|
+
const inPageOrder = [...matched].sort((a, b) => Number(!a.brand) - Number(!b.brand) || head.indexOf(a.text) - head.indexOf(b.text));
|
|
60
|
+
const evidence = [...(titleHit ? [`title "${titleHit}"`] : []), ...inPageOrder.map((m) => m.text)].slice(0, MAX_EVIDENCE);
|
|
61
|
+
const provider = cloudflare ? 'cloudflare' : boxes[0] ? PROVIDER_OF_BOX[boxes[0]] : 'unusual-traffic';
|
|
62
|
+
return { name, provider, evidence };
|
|
63
|
+
};
|
|
64
|
+
const PROVIDER_OF_BOX = { reCAPTCHA: 'recaptcha', hCaptcha: 'hcaptcha', Turnstile: 'turnstile' };
|
|
65
|
+
// In-page: the visible challenge boxes at least 16000 px² (a checkbox widget is ~300×78), never an invisible reCAPTCHA.
|
|
66
|
+
export const CHALLENGE_BOXES = `(() => {
|
|
67
|
+
const nameOf = (s) => /recaptcha/i.test(s) ? 'reCAPTCHA' : /h-?captcha/i.test(s) ? 'hCaptcha' : /challenges\\.cloudflare\\.com|turnstile/i.test(s) ? 'Turnstile' : '';
|
|
68
|
+
const shown = (e) => {
|
|
69
|
+
const r = e.getBoundingClientRect();
|
|
70
|
+
return r.width * r.height >= 16000 && e.checkVisibility?.({ checkOpacity: true, checkVisibilityCSS: true }) !== false;
|
|
71
|
+
};
|
|
72
|
+
const found = [];
|
|
73
|
+
for (const frame of document.querySelectorAll('iframe[src]')) {
|
|
74
|
+
const src = frame.getAttribute('src') || '';
|
|
75
|
+
if (nameOf(src) && !/size=invisible/.test(src) && shown(frame)) found.push(nameOf(src));
|
|
76
|
+
}
|
|
77
|
+
for (const box of document.querySelectorAll('.g-recaptcha,.h-captcha,.cf-turnstile')) {
|
|
78
|
+
const name = nameOf(box.getAttribute('class') || '');
|
|
79
|
+
if (name && shown(box)) found.push(name);
|
|
80
|
+
}
|
|
81
|
+
return [...new Set(found)];
|
|
82
|
+
})()`;
|
|
83
|
+
/**
|
|
84
|
+
* The bot wall on an observed page, if any. The page's text decides; only text that already looks like a challenge
|
|
85
|
+
* costs one more read (the challenge boxes, which sit in iframes the text can't see), and a failed read still finds the
|
|
86
|
+
* wall from its text.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* const wall = await findBotWall(tab, await tab.observe()); // { name: 'Cloudflare', provider: 'cloudflare', evidence: [...] } on npm's challenge
|
|
90
|
+
*/
|
|
91
|
+
export const findBotWall = async (tab, page) => {
|
|
92
|
+
const head = normalized(page.text).slice(0, HEAD_CHARS);
|
|
93
|
+
if (!MARKERS.some((marker) => head.includes(marker.text)))
|
|
94
|
+
return undefined;
|
|
95
|
+
let boxes = [];
|
|
96
|
+
try {
|
|
97
|
+
const read = await tab.evaluate(CHALLENGE_BOXES);
|
|
98
|
+
if (Array.isArray(read))
|
|
99
|
+
boxes = read;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// A page navigating away mid-read: the text alone still decides.
|
|
103
|
+
}
|
|
104
|
+
return detectBotWall(page, boxes);
|
|
105
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// A stuck renderer or dropped reply must not hang a run.
|
|
2
|
+
const CALL_TIMEOUT_MS = 30_000;
|
|
3
|
+
// A wedged browser, or a port held by something else, must not hang the connect.
|
|
4
|
+
const OPEN_TIMEOUT_MS = 15_000;
|
|
5
|
+
/**
|
|
6
|
+
* Minimal Chrome DevTools Protocol client over Node's global WebSocket.
|
|
7
|
+
* Flattened sessions let one socket drive the browser and every owned tab.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const cdp = await CdpConnection.open('ws://127.0.0.1:9222/devtools/browser/…');
|
|
11
|
+
* const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
|
|
12
|
+
*/
|
|
13
|
+
export class CdpConnection {
|
|
14
|
+
#socket;
|
|
15
|
+
#nextId = 1;
|
|
16
|
+
#pending = new Map();
|
|
17
|
+
#listeners = new Set();
|
|
18
|
+
#closed;
|
|
19
|
+
constructor(socket) {
|
|
20
|
+
this.#socket = socket;
|
|
21
|
+
socket.addEventListener('message', (event) => {
|
|
22
|
+
const message = JSON.parse(String(event.data));
|
|
23
|
+
if (message.method) {
|
|
24
|
+
for (const listener of this.#listeners)
|
|
25
|
+
listener(message.method, message.params, message.sessionId);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const pending = message.id && this.#pending.get(message.id);
|
|
29
|
+
if (!pending)
|
|
30
|
+
return;
|
|
31
|
+
this.#pending.delete(message.id);
|
|
32
|
+
clearTimeout(pending.timer);
|
|
33
|
+
if (message.error)
|
|
34
|
+
pending.reject(new CdpError(message.error.message));
|
|
35
|
+
else
|
|
36
|
+
pending.resolve(message.result);
|
|
37
|
+
});
|
|
38
|
+
socket.addEventListener('close', (event) => {
|
|
39
|
+
this.#closed = { code: event.code, reason: event.reason };
|
|
40
|
+
for (const { reject, timer } of this.#pending.values()) {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
reject(this.#closedError());
|
|
43
|
+
}
|
|
44
|
+
this.#pending.clear();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Builds a connection around an existing socket. Used only by tests, to drive the client with a fake socket.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* const cdp = CdpConnection.wrap(fakeSocket as unknown as WebSocket);
|
|
52
|
+
*/
|
|
53
|
+
static wrap(socket) {
|
|
54
|
+
return new CdpConnection(socket);
|
|
55
|
+
}
|
|
56
|
+
static open(endpoint, timeoutMs = OPEN_TIMEOUT_MS) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const socket = new WebSocket(endpoint);
|
|
59
|
+
const timer = setTimeout(() => {
|
|
60
|
+
// Reject first: closing a connecting socket fires its error listener, which would win with the vaguer message.
|
|
61
|
+
reject(new CdpError(`Cannot connect to ${endpoint}: no handshake within ${timeoutMs} ms`));
|
|
62
|
+
try {
|
|
63
|
+
socket.close();
|
|
64
|
+
}
|
|
65
|
+
catch { }
|
|
66
|
+
}, timeoutMs);
|
|
67
|
+
socket.addEventListener('open', () => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
resolve(new CdpConnection(socket));
|
|
70
|
+
}, { once: true });
|
|
71
|
+
socket.addEventListener('error', () => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
reject(new CdpError(`Cannot connect to ${endpoint}`));
|
|
74
|
+
}, { once: true });
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** Why the socket closed, or undefined while it is still open. */
|
|
78
|
+
get closed() {
|
|
79
|
+
return this.#closed;
|
|
80
|
+
}
|
|
81
|
+
/** Resolves with the reply; rejects if the socket is closed or no reply comes within `CALL_TIMEOUT_MS`. */
|
|
82
|
+
send(method, params = {}, sessionId) {
|
|
83
|
+
// A WebSocket silently discards send() once it is closing, so a call made then would never settle.
|
|
84
|
+
if (this.#socket.readyState !== WebSocket.OPEN)
|
|
85
|
+
return Promise.reject(this.#closedError());
|
|
86
|
+
const id = this.#nextId++;
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
this.#socket.send(JSON.stringify({ id, method, params, ...(sessionId && { sessionId }) }));
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
this.#pending.delete(id);
|
|
91
|
+
reject(new CdpError(`${method} timed out after ${CALL_TIMEOUT_MS} ms`));
|
|
92
|
+
}, CALL_TIMEOUT_MS);
|
|
93
|
+
this.#pending.set(id, { resolve, reject, timer });
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Subscribes to protocol events (all sessions); returns an unsubscribe function. */
|
|
97
|
+
onEvent(listener) {
|
|
98
|
+
this.#listeners.add(listener);
|
|
99
|
+
return () => this.#listeners.delete(listener);
|
|
100
|
+
}
|
|
101
|
+
close() {
|
|
102
|
+
this.#socket.close();
|
|
103
|
+
}
|
|
104
|
+
#closedError() {
|
|
105
|
+
if (!this.#closed)
|
|
106
|
+
return new CdpError('Browser connection closed');
|
|
107
|
+
const { code, reason } = this.#closed;
|
|
108
|
+
return new CdpError(`Browser connection closed (code ${code}${reason ? `: ${reason}` : ''})`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export class CdpError extends Error {
|
|
112
|
+
}
|