Compare commits
334
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
601404ba72 | ||
|
|
fd10766762 | ||
|
|
ba9b167ede | ||
|
|
c077593e99 | ||
|
|
02e23ebe11 | ||
|
|
d5cd9cd621 | ||
|
|
284a60a54c | ||
|
|
10d196b6eb | ||
|
|
5be01e292d | ||
|
|
a83d1b1df9 | ||
|
|
50b27ac0e0 | ||
|
|
d81bc01645 | ||
|
|
c667e0e704 | ||
|
|
08cb8dbeb9 | ||
|
|
fd2cdcde77 | ||
|
|
8e001e3d88 | ||
|
|
eb2bc343a0 | ||
|
|
d86c9c4be2 | ||
|
|
32c25a6627 | ||
|
|
a8ce3dd3f8 | ||
|
|
997373fd57 | ||
|
|
5a858af93f | ||
|
|
58dc02bfdc | ||
|
|
c47601bd7b | ||
|
|
0296bc7a88 | ||
|
|
32e203b908 | ||
|
|
fc51cd63ba | ||
|
|
f714c7920b | ||
|
|
1ac8ef7732 | ||
|
|
9f24e66033 | ||
|
|
70ab38d35f | ||
|
|
6830df50ea | ||
|
|
cd47ec43d0 | ||
|
|
2cd361817d | ||
|
|
42f47c49aa | ||
|
|
e29800df72 | ||
|
|
e7e849ea39 | ||
|
|
ebb02c65c8 | ||
|
|
4317db636f | ||
|
|
c99838a034 | ||
|
|
dc2aa4cade | ||
|
|
7085f3bf36 | ||
|
|
29815ff5f3 | ||
|
|
2a338d4c29 | ||
|
|
6fcf52db60 | ||
|
|
af8823b27b | ||
|
|
c85e3faa82 | ||
|
|
b25958946c | ||
|
|
d4ff49caa8 | ||
|
|
4e949fffbf | ||
|
|
af46490812 | ||
|
|
57c52d6517 | ||
|
|
2707ceb1c4 | ||
|
|
f7a657ac46 | ||
|
|
4e295ce139 | ||
|
|
3d82f11ff2 | ||
|
|
33269adc1a | ||
|
|
9ab8753b48 | ||
|
|
770708b8e6 | ||
|
|
7c594820da | ||
|
|
81d18bbd2e | ||
|
|
d99a223d23 | ||
|
|
4e7e80a596 | ||
|
|
9bf79a4242 | ||
|
|
5b4ffdb2bb | ||
|
|
62e7229b47 | ||
|
|
23ab4ce68f | ||
|
|
e272546b5c | ||
|
|
c7de19b099 | ||
|
|
6b9de10c73 | ||
|
|
63e0dd5921 | ||
|
|
c0ee65d0d8 | ||
|
|
7601919a04 | ||
|
|
345c0d62a2 | ||
|
|
0644fb2d0b | ||
|
|
1ea6b1a2c5 | ||
|
|
7bb50d54b3 | ||
|
|
e8864c96e2 | ||
|
|
8432f6cd69 | ||
|
|
a8089310a6 | ||
|
|
ab92d2590b | ||
|
|
c1417c3c70 | ||
|
|
7cb69bd444 | ||
|
|
fb27835ebc | ||
|
|
2859da7b7c | ||
|
|
9ba43e0cbd | ||
|
|
d740884299 | ||
|
|
db484f2ac9 | ||
|
|
b475038e25 | ||
|
|
545e2545b4 | ||
|
|
5f342e34a2 | ||
|
|
4106a151a1 | ||
|
|
eb60053183 | ||
|
|
2aa216dd7a | ||
|
|
b6febbef39 | ||
|
|
5addb94dc4 | ||
|
|
f76ed1ddf5 | ||
|
|
7ba82bc6cc | ||
|
|
61060486f4 | ||
|
|
b4c1707a01 | ||
|
|
266b610358 | ||
|
|
36f9b99549 | ||
|
|
0cf7de2dd6 | ||
|
|
658bf4226f | ||
|
|
37cd9b91e1 | ||
|
|
b2c4aa0004 | ||
|
|
ec8d01ef4c | ||
|
|
0e5409a81e | ||
|
|
3ded30c210 | ||
|
|
6e50f0ecab | ||
|
|
6f71f4e1ff | ||
|
|
31ef0d7e6a | ||
|
|
42560b56fc | ||
|
|
0c7534d9b2 | ||
|
|
ad4fb14ed9 | ||
|
|
a976287f03 | ||
|
|
649fa4ce94 | ||
|
|
3ac69e822a | ||
|
|
d7a0ed85f9 | ||
|
|
9eaa5495ae | ||
|
|
68734fcb36 | ||
|
|
4b33dbadb4 | ||
|
|
fc73ee6c90 | ||
|
|
28d3748c06 | ||
|
|
fa47a0b8e5 | ||
|
|
36c593631c | ||
|
|
b92757412d | ||
|
|
6ecda4d706 | ||
|
|
123510db2b | ||
|
|
abb65c632b | ||
|
|
e803bffbbb | ||
|
|
96ee2f9758 | ||
|
|
0a3d2a91a6 | ||
|
|
1e5dfd35cb | ||
|
|
b6b2ca56ca | ||
|
|
6d740093dc | ||
|
|
c884fb4f57 | ||
|
|
57ef011817 | ||
|
|
2c3bcb8f3d | ||
|
|
5a61a64559 | ||
|
|
1c2e594003 | ||
|
|
9bd6587278 | ||
|
|
df53b1a70e | ||
|
|
a6f0193779 | ||
|
|
bab58991fe | ||
|
|
c5d4c8908d | ||
|
|
9ac8bae981 | ||
|
|
3302762a32 | ||
|
|
73cf32edc8 | ||
|
|
85fd019f62 | ||
|
|
9b4d924e48 | ||
|
|
9ad011d93c | ||
|
|
ebd220274b | ||
|
|
7a4559ac96 | ||
|
|
bc9622994e | ||
|
|
5d202c06a6 | ||
|
|
0966c630a7 | ||
|
|
d1f574013d | ||
|
|
c3b8855252 | ||
|
|
a9ff0a3fea | ||
|
|
af50605a3b | ||
|
|
9b1f98b966 | ||
|
|
cf4c27d13d | ||
|
|
372eaf2ef6 | ||
|
|
dcefc729e8 | ||
|
|
f4a8f79a22 | ||
|
|
6cf74817d8 | ||
|
|
14ffd30417 | ||
|
|
17686fdbf8 | ||
|
|
22532d756c | ||
|
|
68e2e351b1 | ||
|
|
d95d32831e | ||
|
|
1a4c440d9e | ||
|
|
d1fbdaadeb | ||
|
|
839aaa5586 | ||
|
|
a7f9c24fdb | ||
|
|
42ade7b4e8 | ||
|
|
2dabed973e | ||
|
|
dd2deff06c | ||
|
|
340886293a | ||
|
|
fc1699a526 | ||
|
|
4bcfe74514 | ||
|
|
bb41c24c08 | ||
|
|
75bd1d21a7 | ||
|
|
06c75af46a | ||
|
|
312bb0d65b | ||
|
|
cff003c333 | ||
|
|
f2b0c2ea9b | ||
|
|
ea58bce19e | ||
|
|
afb68ded93 | ||
|
|
a6631cd7d8 | ||
|
|
4f630e29ad | ||
|
|
d232763ff7 | ||
|
|
85f4635358 | ||
|
|
726e9d4ea3 | ||
|
|
ff8a340269 | ||
|
|
c1fa237183 | ||
|
|
f6b21461e9 | ||
|
|
e8ef57bf00 | ||
|
|
9efcb56651 | ||
|
|
091a4ec02e | ||
|
|
6c0f5cbaa1 | ||
|
|
0d72e0d889 | ||
|
|
528de4230f | ||
|
|
7f672494c1 | ||
|
|
8a8106ad75 | ||
|
|
f9cc31d003 | ||
|
|
a7a3f924b0 | ||
|
|
7572c34229 | ||
|
|
06f5f9e8f1 | ||
|
|
5f50ca075c | ||
|
|
e7548c3eb5 | ||
|
|
b77a1e4568 | ||
|
|
7c499885e5 | ||
|
|
fc2621559b | ||
|
|
8b55c553e6 | ||
|
|
6b99d304b1 | ||
|
|
900a5b5cde | ||
|
|
ab9b8d96ca | ||
|
|
5c734c51b6 | ||
|
|
dc54855784 | ||
|
|
ed61be3359 | ||
|
|
54b61f4375 | ||
|
|
9ae82d620e | ||
|
|
8f67cff3e1 | ||
|
|
e70d841a94 | ||
|
|
6032deabd5 | ||
|
|
27dff19105 | ||
|
|
21d591ee65 | ||
|
|
a6b2f5a192 | ||
|
|
7a1ca90416 | ||
|
|
ad0fb424c3 | ||
|
|
f62e204038 | ||
|
|
6f4e63ba91 | ||
|
|
98622a7415 | ||
|
|
3d032f9e88 | ||
|
|
d027659571 | ||
|
|
44b6218ef9 | ||
|
|
e93acc68f8 | ||
|
|
d2a33cc005 | ||
|
|
c26afbaba6 | ||
|
|
ffb386e3af | ||
|
|
947d150561 | ||
|
|
06a29251a2 | ||
|
|
0eacec9b9f | ||
|
|
7159012173 | ||
|
|
1b3d41e579 | ||
|
|
dbf272ced7 | ||
|
|
64140879d5 | ||
|
|
d3bfd76c96 | ||
|
|
47dfe760be | ||
|
|
0db6604105 | ||
|
|
007fd1b27f | ||
|
|
3d1132af90 | ||
|
|
90ba44cd38 | ||
|
|
52f8ead0f2 | ||
|
|
ffa5bd63f6 | ||
|
|
926f08203c | ||
|
|
2b1a3c308a | ||
|
|
6c556e519d | ||
|
|
9e48b0757c | ||
|
|
e46232c496 | ||
|
|
a3d4711c61 | ||
|
|
86c4cff26e | ||
|
|
9202c1c919 | ||
|
|
9b56c07e33 | ||
|
|
6488aae458 | ||
|
|
016d60f293 | ||
|
|
76cfe75636 | ||
|
|
320bb61de3 | ||
|
|
81cdd3b216 | ||
|
|
7ee3d5fb94 | ||
|
|
77616a209c | ||
|
|
6addc80aa1 | ||
|
|
82eadcee41 | ||
|
|
c830d1b67d | ||
|
|
d33bdb36f3 | ||
|
|
3bb1d43f8b | ||
|
|
918d407411 | ||
|
|
7ada3384e2 | ||
|
|
57405f9361 | ||
|
|
cff12598bf | ||
|
|
717d1b09e1 | ||
|
|
14ece9b3ad | ||
|
|
4cc6ca40b7 | ||
|
|
1afcaa0e84 | ||
|
|
585d93a02b | ||
|
|
c201623710 | ||
|
|
67dc631977 | ||
|
|
c691b269cb | ||
|
|
4f9edf9337 | ||
|
|
19808d08f8 | ||
|
|
a884960806 | ||
|
|
dba382350b | ||
|
|
2e99293e80 | ||
|
|
b02e485a37 | ||
|
|
db29d5fead | ||
|
|
ddf6d6a2af | ||
|
|
50323499c8 | ||
|
|
2114bdf847 | ||
|
|
7c2ff0a2a6 | ||
|
|
71343069d2 | ||
|
|
fa043a496f | ||
|
|
e4e2fe8633 | ||
|
|
2164e71c30 | ||
|
|
6520e4123c | ||
|
|
6d05a9485d | ||
|
|
1a6ea17ed0 | ||
|
|
c4e0f9d367 | ||
|
|
b75fba130b | ||
|
|
eb15cc0894 | ||
|
|
7b3f826cbb | ||
|
|
1f8757b215 | ||
|
|
3896ed0d9d | ||
|
|
92d730e5fd | ||
|
|
77805ff4bc | ||
|
|
c3bbb15c5f | ||
|
|
131f229971 | ||
|
|
317e6869b6 | ||
|
|
fcb6615f5a | ||
|
|
c47756be9b | ||
|
|
44f37c92d3 | ||
|
|
9f51879012 | ||
|
|
1205e2ca9c | ||
|
|
9f8e518a46 | ||
|
|
354dd8b615 | ||
|
|
9b0205ef50 | ||
|
|
c69f611d78 | ||
|
|
2911d91ce3 | ||
|
|
5e33672d08 | ||
|
|
c976212db4 | ||
|
|
05d86fadf5 | ||
|
|
4b5ba9f245 | ||
|
|
c52d25d576 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "agent-browser",
|
||||
"name": "chrome-use",
|
||||
"description": "Browser automation for AI agents",
|
||||
"owner": {
|
||||
"name": "Vercel",
|
||||
@@ -8,11 +8,11 @@
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "agent-browser",
|
||||
"name": "chrome-use",
|
||||
"description": "Automates browser interactions for web testing, form filling, screenshots, and data extraction",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": ["./skills/agent-browser"],
|
||||
"skills": ["./skills/chrome-use"],
|
||||
"category": "development"
|
||||
}
|
||||
]
|
||||
|
||||
+53
-23
@@ -15,6 +15,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Check version sync
|
||||
run: node scripts/check-version-sync.js
|
||||
|
||||
@@ -48,6 +53,8 @@ jobs:
|
||||
name: Rust (${{ matrix.os }} - ${{ matrix.target }})
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ${{ matrix.os }}
|
||||
# Fail fast on a hung test instead of running to GitHub's 6h default.
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -80,6 +87,13 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
needs: rust
|
||||
# Fail fast on a hung e2e test instead of GitHub's 6h default.
|
||||
timeout-minutes: 30
|
||||
# This fork forbids headless by default (always-headed for stealth), but CI
|
||||
# runners have no display. Opt into the documented display-less escape so
|
||||
# launched Chrome can start; e2e tests exercise functionality, not stealth.
|
||||
env:
|
||||
AGENT_BROWSER_ALLOW_HEADLESS: "1"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -96,6 +110,9 @@ jobs:
|
||||
run: |
|
||||
cargo run --manifest-path cli/Cargo.toml -- install --with-deps
|
||||
|
||||
- name: Install ffmpeg
|
||||
run: sudo apt-get update && sudo apt-get install -y ffmpeg
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml e2e -- --ignored --test-threads=1
|
||||
|
||||
@@ -104,6 +121,10 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: windows-latest
|
||||
needs: rust-cross
|
||||
# Headless-forbidden fork on a headless CI runner — opt into the escape so
|
||||
# `chrome-use open` can launch Chrome.
|
||||
env:
|
||||
AGENT_BROWSER_ALLOW_HEADLESS: "1"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -124,13 +145,13 @@ jobs:
|
||||
|
||||
- name: Copy CLI binary to bin directory
|
||||
run: |
|
||||
Copy-Item cli/target/x86_64-pc-windows-msvc/release/agent-browser.exe bin/agent-browser-win32-x64.exe
|
||||
Copy-Item cli/target/x86_64-pc-windows-msvc/release/chrome-use.exe bin/chrome-use-win32-x64.exe
|
||||
|
||||
- name: Test agent-browser install command
|
||||
- name: Test chrome-use install command
|
||||
run: |
|
||||
$env:PATH = "$pwd\bin;$env:PATH"
|
||||
for ($i = 1; $i -le 3; $i++) {
|
||||
bin/agent-browser-win32-x64.exe install
|
||||
bin/chrome-use-win32-x64.exe install
|
||||
if ($LASTEXITCODE -eq 0) { exit 0 }
|
||||
Write-Host "Attempt $i failed, retrying in 10 seconds..."
|
||||
Start-Sleep -Seconds 10
|
||||
@@ -143,14 +164,17 @@ jobs:
|
||||
run: |
|
||||
$env:PATH = "$pwd\bin;$env:PATH"
|
||||
Write-Host "--- Opening page ---"
|
||||
bin/agent-browser-win32-x64.exe open https://example.com
|
||||
# --launch: spawn a standalone browser. Without it, `open` defaults to
|
||||
# auto-connect and looks for an existing Chrome on a debug port — which
|
||||
# a fresh CI runner doesn't have, so it errors "Could not connect".
|
||||
bin/chrome-use-win32-x64.exe --launch open https://example.com
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "open failed"; exit 1 }
|
||||
Write-Host "--- Taking snapshot ---"
|
||||
$snapshot = bin/agent-browser-win32-x64.exe snapshot
|
||||
$snapshot = bin/chrome-use-win32-x64.exe snapshot
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "snapshot failed"; exit 1 }
|
||||
Write-Host $snapshot
|
||||
Write-Host "--- Closing browser ---"
|
||||
bin/agent-browser-win32-x64.exe close
|
||||
bin/chrome-use-win32-x64.exe close
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "close failed"; exit 1 }
|
||||
Write-Host "--- Windows daemon lifecycle test passed ---"
|
||||
shell: pwsh
|
||||
@@ -166,13 +190,13 @@ jobs:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: agent-browser-linux-x64
|
||||
binary: chrome-use-linux-x64
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
binary: agent-browser-darwin-arm64
|
||||
binary: chrome-use-darwin-arm64
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: agent-browser-win32-x64.exe
|
||||
binary: chrome-use-win32-x64.exe
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -181,7 +205,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
@@ -198,23 +222,23 @@ jobs:
|
||||
|
||||
- name: Copy CLI binary to bin directory (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
run: cp cli/target/${{ matrix.target }}/release/agent-browser bin/${{ matrix.binary }}
|
||||
run: cp cli/target/${{ matrix.target }}/release/chrome-use bin/${{ matrix.binary }}
|
||||
|
||||
- name: Copy CLI binary to bin directory (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: Copy-Item cli/target/${{ matrix.target }}/release/agent-browser.exe bin/${{ matrix.binary }}
|
||||
run: Copy-Item cli/target/${{ matrix.target }}/release/chrome-use.exe bin/${{ matrix.binary }}
|
||||
|
||||
- name: Test npm global install
|
||||
run: |
|
||||
npm pack
|
||||
npm install -g agent-browser-*.tgz
|
||||
agent-browser --version
|
||||
npm install -g chrome-use-*.tgz
|
||||
chrome-use --version
|
||||
shell: bash
|
||||
|
||||
- name: Verify symlink points to native binary (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
SYMLINK=$(npm prefix -g)/bin/agent-browser
|
||||
SYMLINK=$(npm prefix -g)/bin/chrome-use
|
||||
TARGET=$(readlink "$SYMLINK")
|
||||
echo "Symlink: $SYMLINK"
|
||||
echo "Target: $TARGET"
|
||||
@@ -225,17 +249,23 @@ jobs:
|
||||
echo "Symlink correctly points to native binary"
|
||||
shell: bash
|
||||
|
||||
- name: Verify shim points to native binary (Windows)
|
||||
- name: Verify CLI works (and prefers the native shim) (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
$shimPath = "$(npm prefix -g)\agent-browser.cmd"
|
||||
$content = Get-Content $shimPath -Raw
|
||||
echo "Shim path: $shimPath"
|
||||
# The CLI must work. The native-shim rewrite is a best-effort speedup
|
||||
# (npm often creates the .cmd AFTER postinstall runs, so the rewrite
|
||||
# can't happen and the JS wrapper — which spawns the native binary — is
|
||||
# the valid fallback). Require functionality; prefer, but don't require,
|
||||
# the native shim.
|
||||
$ver = chrome-use --version
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "chrome-use --version failed"; exit 1 }
|
||||
echo "CLI version: $ver"
|
||||
$content = Get-Content "$(npm prefix -g)\chrome-use.cmd" -Raw
|
||||
echo "Shim content:"
|
||||
echo $content
|
||||
if ($content -notmatch "agent-browser-win32-x64\.exe") {
|
||||
echo "ERROR: Shim should point to native .exe, not JS wrapper"
|
||||
exit 1
|
||||
if ($content -match "chrome-use-win32-x64\.exe") {
|
||||
echo "OK: shim points directly to the native binary (zero overhead)"
|
||||
} else {
|
||||
echo "INFO: shim uses the JS wrapper fallback (functional; native-shim optimization not applied)"
|
||||
}
|
||||
echo "Shim correctly points to native binary"
|
||||
shell: pwsh
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
name: Release binaries
|
||||
|
||||
# Build per-platform binaries and attach them to the GitHub Release for the
|
||||
# pushed tag. No npm, no tokens — only the built-in GITHUB_TOKEN. Consumers
|
||||
# install with: curl -fsSL .../install.sh | sh
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Existing tag to (re)build binaries for, e.g. v0.27.0-fork.12'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency: release-binaries-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { name: Linux x64, os: ubuntu-latest, target: x86_64-unknown-linux-gnu, asset: chrome-use-linux-x64, use_zigbuild: true, ext: '' }
|
||||
- { name: Linux ARM64, os: ubuntu-latest, target: aarch64-unknown-linux-gnu, asset: chrome-use-linux-arm64, use_zigbuild: true, ext: '' }
|
||||
- { name: Linux musl x64, os: ubuntu-latest, target: x86_64-unknown-linux-musl, asset: chrome-use-linux-musl-x64, use_zigbuild: true, ext: '' }
|
||||
- { name: Linux musl ARM64, os: ubuntu-latest, target: aarch64-unknown-linux-musl, asset: chrome-use-linux-musl-arm64, use_zigbuild: true, ext: '' }
|
||||
- { name: Windows x64, os: ubuntu-latest, target: x86_64-pc-windows-gnu, asset: chrome-use-win32-x64, use_zigbuild: false, ext: '.exe' }
|
||||
- { name: macOS x64, os: macos-latest, target: x86_64-apple-darwin, asset: chrome-use-darwin-x64, use_zigbuild: false, ext: '' }
|
||||
- { name: macOS ARM64, os: macos-latest, target: aarch64-apple-darwin, asset: chrome-use-darwin-arm64, use_zigbuild: false, ext: '' }
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross-compilation tools (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu gcc-x86-64-linux-gnu mingw-w64
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: matrix.use_zigbuild
|
||||
run: |
|
||||
pip3 install ziglang
|
||||
cargo install cargo-zigbuild
|
||||
|
||||
- name: Configure Rust linkers
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
mkdir -p ~/.cargo
|
||||
cat >> ~/.cargo/config.toml << 'EOF'
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
EOF
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: cli
|
||||
|
||||
- name: Build (zigbuild)
|
||||
if: matrix.use_zigbuild
|
||||
run: cargo zigbuild --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
- name: Build (cargo)
|
||||
if: '!matrix.use_zigbuild'
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
- name: Package (.tar.gz + .sha256)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
src="cli/target/${{ matrix.target }}/release/chrome-use${{ matrix.ext }}"
|
||||
# The binary inside every archive is named `chrome-use` (or .exe);
|
||||
# install.sh extracts that fixed name regardless of platform.
|
||||
cp "$src" "dist/chrome-use${{ matrix.ext }}"
|
||||
chmod +x "dist/chrome-use${{ matrix.ext }}" || true
|
||||
( cd dist
|
||||
tar czf "${{ matrix.asset }}.tar.gz" "chrome-use${{ matrix.ext }}"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "${{ matrix.asset }}.tar.gz" > "${{ matrix.asset }}.tar.gz.sha256"
|
||||
else
|
||||
shasum -a 256 "${{ matrix.asset }}.tar.gz" > "${{ matrix.asset }}.tar.gz.sha256"
|
||||
fi
|
||||
)
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: dist/${{ matrix.asset }}.tar.gz*
|
||||
retention-days: 3
|
||||
|
||||
release:
|
||||
name: Attach binaries to GitHub Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
# The release job is separate from the build matrix and has no repo by
|
||||
# default — check it out (full history + tags) so the changelog step has a
|
||||
# git repo to diff. Without this, `git` failed with "not a git repository"
|
||||
# and the changelog came out empty.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: List assets
|
||||
run: ls -la dist
|
||||
|
||||
# Build the changelog from conventional-commit subjects since the previous
|
||||
# tag. GitHub's built-in generate_release_notes only lists merged PRs,
|
||||
# which is near-empty for this commit-to-main workflow — so we render the
|
||||
# commit log ourselves and every release shows what actually changed.
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
# fetch-depth:0 gets history, but the tag refs the changelog needs
|
||||
# aren't always present in a detached-HEAD tag checkout — pull them in.
|
||||
git fetch --tags --force --quiet origin 2>/dev/null || true
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
||||
RANGE="${TAG}"
|
||||
[ -n "$PREV" ] && RANGE="${PREV}..${TAG}"
|
||||
# Group commit subjects by conventional-commit type so the notes are
|
||||
# scannable ("what's new / what's fixed") instead of a flat dev log.
|
||||
LOG="$(git log "$RANGE" --no-merges --pretty='%s' | grep -v '^chore(release)' || true)"
|
||||
# NOTE: the job runs under `bash -e`. grep returning 1 (no match) and
|
||||
# the `[ -n "$body" ]` test returning 1 (empty section) must NOT abort
|
||||
# the script — otherwise a release whose commit range lacks a whole
|
||||
# category (e.g. only `feat`, no `fix`) dies before writing the closing
|
||||
# heredoc delimiter and the whole release step fails. `|| true` +
|
||||
# `return 0` keep section() always-succeeding.
|
||||
section() { # $1=header $2=grep-pattern
|
||||
local body; body="$(printf '%s\n' "$LOG" | grep -E "$2" | sed 's/^/- /' || true)"
|
||||
[ -n "$body" ] && printf '\n### %s\n%s\n' "$1" "$body"
|
||||
return 0
|
||||
}
|
||||
{
|
||||
echo "notes<<__NOTES_EOF__"
|
||||
echo "## What changed"
|
||||
section "✨ Features" '^feat'
|
||||
section "🐛 Fixes" '^fix'
|
||||
section "🔧 Other" '^(perf|refactor|docs|build|ci|test|style|revert)'
|
||||
if [ -n "$PREV" ]; then
|
||||
echo ""
|
||||
echo "**Full changelog**: https://github.com/${{ github.repository }}/compare/${PREV}...${TAG}"
|
||||
fi
|
||||
echo "__NOTES_EOF__"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Attach to release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
files: |
|
||||
dist/*.tar.gz
|
||||
dist/*.tar.gz.sha256
|
||||
fail_on_unmatched_files: true
|
||||
# The commit-based changelog so every release shows what changed. The
|
||||
# first matrix job to run creates the release with these notes;
|
||||
# append_body:false keeps later platform jobs from duplicating them.
|
||||
body: ${{ steps.changelog.outputs.notes }}
|
||||
append_body: false
|
||||
@@ -1,322 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
check-release:
|
||||
name: Check for new version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_release: ${{ steps.check.outputs.should_release }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compare package.json version to npm
|
||||
id: check
|
||||
run: |
|
||||
LOCAL_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "Local version: $LOCAL_VERSION"
|
||||
|
||||
NPM_VERSION=$(npm view agent-browser version 2>/dev/null || echo "0.0.0")
|
||||
echo "npm version: $NPM_VERSION"
|
||||
|
||||
if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then
|
||||
echo "Version changed: $NPM_VERSION -> $LOCAL_VERSION"
|
||||
echo "should_release=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Version unchanged, skipping release"
|
||||
echo "should_release=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "version=$LOCAL_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-binaries:
|
||||
name: Build ${{ matrix.name }}
|
||||
needs: check-release
|
||||
if: needs.check-release.outputs.should_release == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Linux x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: agent-browser-linux-x64
|
||||
use_zigbuild: true
|
||||
- name: Linux ARM64
|
||||
os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
binary: agent-browser-linux-arm64
|
||||
use_zigbuild: true
|
||||
- name: Linux musl x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
binary: agent-browser-linux-musl-x64
|
||||
use_zigbuild: true
|
||||
- name: Linux musl ARM64
|
||||
os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
binary: agent-browser-linux-musl-arm64
|
||||
use_zigbuild: true
|
||||
- name: Windows x64
|
||||
os: ubuntu-latest
|
||||
target: x86_64-pc-windows-gnu
|
||||
binary: agent-browser-win32-x64.exe
|
||||
use_zigbuild: false
|
||||
- name: macOS x64
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
binary: agent-browser-darwin-x64
|
||||
use_zigbuild: false
|
||||
- name: macOS ARM64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
binary: agent-browser-darwin-arm64
|
||||
use_zigbuild: false
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Sync version
|
||||
run: pnpm run version:sync
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross-compilation tools (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu gcc-x86-64-linux-gnu mingw-w64
|
||||
|
||||
- name: Install cargo-zigbuild
|
||||
if: matrix.use_zigbuild
|
||||
run: |
|
||||
pip3 install ziglang
|
||||
cargo install cargo-zigbuild
|
||||
|
||||
- name: Configure Rust linkers
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
mkdir -p ~/.cargo
|
||||
cat >> ~/.cargo/config.toml << 'EOF'
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
EOF
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: cli
|
||||
|
||||
- name: Build with zigbuild
|
||||
if: matrix.use_zigbuild
|
||||
run: cargo zigbuild --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
- name: Build with cargo
|
||||
if: '!matrix.use_zigbuild'
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
- name: Copy binary
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
if [[ "${{ matrix.target }}" == *"windows"* ]]; then
|
||||
cp cli/target/${{ matrix.target }}/release/agent-browser.exe artifacts/${{ matrix.binary }}
|
||||
else
|
||||
cp cli/target/${{ matrix.target }}/release/agent-browser artifacts/${{ matrix.binary }}
|
||||
chmod +x artifacts/${{ matrix.binary }}
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.binary }}
|
||||
path: artifacts/${{ matrix.binary }}
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: [check-release, build-binaries]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download all binary artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts/
|
||||
|
||||
- name: Move binaries to bin directory
|
||||
run: |
|
||||
mkdir -p bin
|
||||
find artifacts -type f -name 'agent-browser-*' -exec mv {} bin/ \;
|
||||
rm -rf artifacts
|
||||
chmod +x bin/agent-browser-* 2>/dev/null || true
|
||||
echo "Binaries in bin/:"
|
||||
ls -la bin/
|
||||
|
||||
- name: Verify all binaries exist
|
||||
run: |
|
||||
EXPECTED_BINARIES=(
|
||||
"agent-browser-linux-x64"
|
||||
"agent-browser-linux-arm64"
|
||||
"agent-browser-linux-musl-x64"
|
||||
"agent-browser-linux-musl-arm64"
|
||||
"agent-browser-win32-x64.exe"
|
||||
"agent-browser-darwin-x64"
|
||||
"agent-browser-darwin-arm64"
|
||||
)
|
||||
MIN_SIZE=100000
|
||||
ERRORS=0
|
||||
for binary in "${EXPECTED_BINARIES[@]}"; do
|
||||
if [ ! -f "bin/$binary" ]; then
|
||||
echo "ERROR: Missing bin/$binary"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
SIZE=$(stat -c%s "bin/$binary" 2>/dev/null || stat -f%z "bin/$binary")
|
||||
if [ "$SIZE" -lt "$MIN_SIZE" ]; then
|
||||
echo "ERROR: bin/$binary is too small ($SIZE bytes, expected >= $MIN_SIZE)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "OK: bin/$binary ($SIZE bytes)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "Error: $ERRORS binary issues found"
|
||||
exit 1
|
||||
fi
|
||||
echo "All 7 platform binaries present and valid"
|
||||
|
||||
- name: Publish to npm
|
||||
run: pnpm publish --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
|
||||
|
||||
github-release:
|
||||
name: Create GitHub Release
|
||||
needs: [check-release, publish]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts/
|
||||
|
||||
- name: Move binaries to bin directory
|
||||
run: |
|
||||
mkdir -p bin
|
||||
find artifacts -type f -name 'agent-browser-*' -exec mv {} bin/ \;
|
||||
rm -rf artifacts
|
||||
chmod +x bin/agent-browser-* 2>/dev/null || true
|
||||
ls -la bin/
|
||||
|
||||
- name: Verify binaries exist
|
||||
run: |
|
||||
BINARY_COUNT=$(ls bin/agent-browser-* 2>/dev/null | wc -l)
|
||||
if [ "$BINARY_COUNT" -lt 7 ]; then
|
||||
echo "Error: Expected 7 binaries, found $BINARY_COUNT"
|
||||
ls -la bin/
|
||||
exit 1
|
||||
fi
|
||||
echo "Found $BINARY_COUNT binaries"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build dashboard
|
||||
run: pnpm --filter dashboard build
|
||||
|
||||
- name: Create dashboard.zip
|
||||
run: cd packages/dashboard/out && zip -r ../../../dashboard.zip .
|
||||
|
||||
- name: Extract changelog entry
|
||||
run: |
|
||||
VERSION="${{ needs.check-release.outputs.version }}"
|
||||
awk '/<!-- release:start -->/{found=1; next} /<!-- release:end -->/{found=0} found{print}' CHANGELOG.md > /tmp/release-notes.md
|
||||
|
||||
LINES=$(wc -l < /tmp/release-notes.md | tr -d ' ')
|
||||
if [ "$LINES" -lt 2 ]; then
|
||||
echo "Error: No release notes found between <!-- release:start --> and <!-- release:end --> markers in CHANGELOG.md"
|
||||
exit 1
|
||||
fi
|
||||
echo "Extracted release notes for $VERSION ($LINES lines)"
|
||||
|
||||
- name: Create GitHub Release
|
||||
run: |
|
||||
VERSION="${{ needs.check-release.outputs.version }}"
|
||||
TAG="v$VERSION"
|
||||
|
||||
if gh release view "$TAG" &>/dev/null; then
|
||||
echo "Release $TAG already exists, uploading assets..."
|
||||
gh release upload "$TAG" bin/agent-browser-* dashboard.zip --clobber
|
||||
else
|
||||
echo "Creating release $TAG..."
|
||||
gh release create "$TAG" \
|
||||
--title "$TAG" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
bin/agent-browser-* dashboard.zip
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
+12
@@ -38,6 +38,10 @@ __pycache__/
|
||||
*.webm
|
||||
test/e2e/.dogfood-output/
|
||||
|
||||
# ...but these are real repo assets, not test artifacts — keep them tracked
|
||||
!assets/*.png
|
||||
!extensions/ab-connect/icons/*.png
|
||||
|
||||
# Package manager
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
@@ -61,6 +65,14 @@ docs/package-lock.json
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# next
|
||||
.next/
|
||||
out/
|
||||
|
||||
# extension signing key (never commit) + local-only id record
|
||||
.secrets/
|
||||
*.pem
|
||||
/cu-test-artifacts
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
if [ "${SKIP_CLAWHUB_SYNC:-0}" = "1" ]; then
|
||||
echo "Skipping ClawHub sync (SKIP_CLAWHUB_SYNC=1)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pnpm run clawhub:sync || {
|
||||
echo "ClawHub sync failed. Push continues. Run 'pnpm run clawhub:sync' manually after fixing login/network."
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -19,7 +19,7 @@ When adding or changing user-facing features (new flags, commands, behaviors, en
|
||||
|
||||
1. `cli/src/output.rs` — `--help` output (flags list, examples, environment variables)
|
||||
2. `README.md` — Options table, relevant feature sections, examples
|
||||
3. `skills/agent-browser/SKILL.md` — so AI agents know about the feature
|
||||
3. `skill-data/core/SKILL.md` (and its `references/`) — so AI agents know about the feature when they load the core skill. Edit `skill-data/core/SKILL.md` for overview/workflow changes; edit `skill-data/core/references/*.md` for detailed reference content. Do **not** put feature content in `skills/chrome-use/SKILL.md` — that file is an intentionally thin discovery stub for `npx skills add` and exists only to redirect agents to `chrome-use skills get core`.
|
||||
4. `docs/src/app/` — the Next.js docs site (MDX pages)
|
||||
5. Inline doc comments in the relevant source files
|
||||
|
||||
@@ -41,7 +41,7 @@ To prepare a release:
|
||||
1. Create a branch (e.g. `prepare-v0.24.0`)
|
||||
2. Bump `version` in `package.json`
|
||||
3. Run `pnpm version:sync` to update `cli/Cargo.toml`, `cli/Cargo.lock`, and `packages/dashboard/package.json`
|
||||
4. Write the changelog entry in `CHANGELOG.md` at the top, under a new `## <version>` heading, wrapped in `<!-- release:start -->` and `<!-- release:end -->` markers
|
||||
4. Write the changelog entry in `CHANGELOG.md` at the top, under a new `## <version>` heading, wrapped in `<!-- release:start -->` and `<!-- release:end -->` markers. Remove the `<!-- release:start -->` and `<!-- release:end -->` markers from the previous release entry so only the new release has markers.
|
||||
5. Add a matching entry to `docs/src/app/changelog/page.mdx` at the top (below the `# Changelog` heading)
|
||||
6. Open a PR and merge to `main`
|
||||
|
||||
@@ -51,16 +51,12 @@ When the PR merges, CI compares `package.json` version to what's on npm. If it d
|
||||
|
||||
Review the git log since the last release and write the entry in `CHANGELOG.md`. Follow the existing format and voice. Group changes under `### New Features`, `### Bug Fixes`, `### Improvements`, etc. Bold the feature/fix name, then describe it concisely. Reference PR numbers in parentheses.
|
||||
|
||||
Wrap the release notes (everything between the `## <version>` heading and the previous version) in markers so CI can extract them for the GitHub release:
|
||||
Wrap the release notes (everything between the `## <version>` heading and the previous version) in markers so CI can extract them for the GitHub release. Only the current release should have markers; remove the `<!-- release:start -->` and `<!-- release:end -->` markers from any previous release entry:
|
||||
|
||||
```markdown
|
||||
## 0.24.0
|
||||
## 0.24.1
|
||||
|
||||
<!-- release:start -->
|
||||
### New Features
|
||||
|
||||
- **Foo command** - Added `foo` command for bar (#1234)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed **baz** not working when qux is enabled (#1235)
|
||||
@@ -68,10 +64,13 @@ Wrap the release notes (everything between the `## <version>` heading and the pr
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @somecontributor
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.23.3
|
||||
## 0.24.0
|
||||
|
||||
### New Features
|
||||
|
||||
- **Foo command** - Added `foo` command for bar (#1234)
|
||||
```
|
||||
|
||||
Include a `### Contributors` section listing the GitHub usernames (with `@` prefix) of everyone who contributed to the release. Check the git log between the previous tag and HEAD to find them.
|
||||
@@ -168,13 +167,13 @@ Stop the instance when done (avoids cost):
|
||||
Run unit tests on Windows:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "cd C:\agent-browser && cargo test --manifest-path cli\Cargo.toml"
|
||||
./scripts/windows-debug/run.sh "cd C:\chrome-use && cargo test --manifest-path cli\Cargo.toml"
|
||||
```
|
||||
|
||||
Run e2e tests on Windows:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "cd C:\agent-browser && cargo test e2e --manifest-path cli\Cargo.toml -- --ignored --test-threads=1"
|
||||
./scripts/windows-debug/run.sh "cd C:\chrome-use && cargo test e2e --manifest-path cli\Cargo.toml -- --ignored --test-threads=1"
|
||||
```
|
||||
|
||||
Check bootstrap progress (first boot only):
|
||||
@@ -183,7 +182,7 @@ Check bootstrap progress (first boot only):
|
||||
./scripts/windows-debug/run.sh "Get-Content C:\bootstrap.log"
|
||||
```
|
||||
|
||||
The repo lives at `C:\agent-browser` on the instance. Rust, Git, and Chrome are pre-installed. The `run.sh` wrapper automatically adds cargo and git to PATH.
|
||||
The repo lives at `C:\chrome-use` on the instance. Rust, Git, and Chrome are pre-installed. The `run.sh` wrapper automatically adds cargo and git to PATH.
|
||||
|
||||
<!-- opensrc:start -->
|
||||
|
||||
|
||||
-753
@@ -1,753 +0,0 @@
|
||||
# agent-browser
|
||||
|
||||
## 0.24.0
|
||||
|
||||
<!-- release:start -->
|
||||
### New Features
|
||||
|
||||
- **AWS Bedrock AgentCore provider** - Added AWS Bedrock AgentCore as a cloud browser provider. Connect with `--provider agentcore` or `AGENT_BROWSER_PROVIDER=agentcore`. Uses lightweight manual SigV4 signing for authentication with support for the full AWS credential provider chain (environment variables, AWS CLI, SSO, IAM roles). Configure with `AGENTCORE_REGION`, `AGENTCORE_PROFILE_ID`, and `AGENTCORE_BROWSER_ID` environment variables. Returns session ID and Live View URL in the launch response (#397)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Added AgentCore provider page to docs site, README options table, SKILL.md, and dashboard provider icons (#1120)
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @pahud
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.23.4
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed **daemon hang on Linux** caused by a `waitpid(-1)` race condition in the SIGCHLD handler that stole exit statuses from Rust's `Child` handles, leaving the daemon in a broken state. Replaced the global signal handler with targeted crash detection via the existing drain interval (#1098)
|
||||
|
||||
## 0.23.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed **drag and drop** not working because `mouseMoved` events during the drag omitted the `buttons` bitmask, causing the browser to see `event.buttons === 0` and never fire `dragstart`/`dragover`/`drop` (#1087)
|
||||
|
||||
## 0.23.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3c942e2: ### New Features
|
||||
|
||||
- **Dashboard session creation** - Sessions can now be created directly from the dashboard UI. A new session dialog provides a unified selector grid for local engines (Chrome, Lightpanda) and cloud providers (Browserbase, Browserless, Browser Use, Kernel) with async creation, loading state, and error display (#1092)
|
||||
- **Dashboard provider icons** - The session sidebar now shows the provider or engine icon for each session, making it easy to identify which backend a session is using (#1092)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed **Browser Use** provider using an intermediate API call instead of connecting directly via WSS (`wss://connect.browser-use.com`), which caused connection failures (#1092)
|
||||
- Fixed **Browserbase** provider not sending an explicit JSON body and `Content-Type` header, causing session creation to fail (#1092)
|
||||
- Fixed **provider navigation** hanging because `wait_for_lifecycle` waited for page load events that remote providers may not emit. Navigation with `--provider` now automatically sets `waitUntil=none` (#1092)
|
||||
- Fixed **remote CDP connections** timing out by increasing the CDP connect timeout from 10s to 25s for cloud providers (#1092)
|
||||
- Fixed **zombie daemon processes** not being cleaned up when a provider connection fails during session creation from the dashboard (#1092)
|
||||
|
||||
## 0.23.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fbcab37: ### New Features
|
||||
|
||||
- **Auto-dismissal for alert and beforeunload dialogs** - JavaScript `alert()` and `beforeunload` dialogs are now automatically accepted to prevent the agent from blocking indefinitely. `confirm` and `prompt` dialogs still require explicit `dialog accept/dismiss` commands. Disable with `--no-auto-dialog` flag or `AGENT_BROWSER_NO_AUTO_DIALOG` environment variable (#1075)
|
||||
- **Puppeteer browser cache fallback** - Chrome discovery now searches `~/.cache/puppeteer/chrome/` (or `PUPPETEER_CACHE_DIR`) for Chrome binaries, so users with an existing Puppeteer installation can use agent-browser without a separate install step (#1088)
|
||||
- **Console output improvements** - `console.log` of objects now shows the actual object preview (e.g. `{userId: "abc", count: 42}`) instead of `"Object"`. JSON output includes a raw `args` array for programmatic access (#1040)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed **same-document navigation** (e.g. SPA hash routing) hanging forever because `wait_for_lifecycle` waited for a `Page.loadEventFired` that never fires on same-document navigations (#1059)
|
||||
- Fixed **save_state** only capturing cookies and localStorage for the current origin, silently dropping cross-domain data (e.g. SSO/CAS auth cookies). Now uses `Network.getAllCookies` and collects localStorage from all visited origins (#1064)
|
||||
- Fixed **externally opened tabs** not appearing in `tab list` when using `--cdp` mode. Tabs opened by the user or another CDP client are now detected and tracked (#1042)
|
||||
- Fixed **dashboard server** not picking up installed files without a restart. `dashboard install` now takes effect immediately on a running server (#1066)
|
||||
- Fixed **Windows Chrome extraction** failing because zip path normalization used forward slashes while the extraction code expected backslashes (#1088)
|
||||
|
||||
## 0.23.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 0f0f300: ### New Features
|
||||
|
||||
- **Observability dashboard** - Added a local web UI (`dashboard`) that shows live browser viewports, command activity feeds, console output, network requests, storage, and extensions for all sessions. Manage it with `dashboard start`, `dashboard stop`, and `dashboard install`. The dashboard runs as a standalone background process and all sessions stream to it automatically (#1034)
|
||||
- **Runtime stream management** - Added `stream enable`, `stream disable`, and `stream status` commands to control WebSocket streaming at runtime. Streaming is now always enabled by default; `AGENT_BROWSER_STREAM_PORT` overrides the port instead of toggling the feature (#951)
|
||||
- **Close all sessions** - Added `close --all` flag to close every active browser session at once
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed **Lightpanda engine** compatibility (#1050)
|
||||
- Fixed **Windows daemon TCP bind** failing when Hyper-V reserves the port by falling back to an OS-assigned port and writing it to a `.port` file (#1041)
|
||||
- Fixed **Windows dashboard relay** using Unix socket instead of TCP (#1038)
|
||||
- Fixed **radio/checkbox elements** being dropped from compact snapshot tree because the `ref=` check required a leading `[` that those elements lack (#1008)
|
||||
|
||||
## 0.22.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- eb64ca4: ### Bug Fixes
|
||||
|
||||
- **Re-apply download behavior on recording context** - Fixed an issue where downloads were silently dropped in recording contexts because `Browser.setDownloadBehavior` set at launch only applied to the default context. The download behavior is now re-applied when a new recording context is created (#1019)
|
||||
- **Reap zombie Chrome process and fast-detect crash for auto-restart** - Added a non-blocking process-exit check before attempting CDP connection checks. This prevents a 3-second CDP timeout when Chrome has already crashed or exited, enabling faster detection and auto-restart of the browser (#1023)
|
||||
- **Route keyboard `type` through text input** - Fixed keyboard `type` subaction to correctly route through the text input handler, and added support for an `insertText` subaction using `Input.insertText` (#1014)
|
||||
- **Handle `--clear` flag in `console` command** - Fixed the `console` command to accept and process a `clear` parameter, allowing console event history to be cleared (#1015)
|
||||
|
||||
## 0.22.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a098197: ### New Features
|
||||
|
||||
- **Dialog status command** - Added `dialog status` command to check whether a JavaScript dialog is currently open (#999)
|
||||
- **Dialog warning field** - Command responses now include a `warning` field when a JavaScript dialog is pending, indicating the dialog type and message (#999)
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Standard proxy environment variables** - The proxy setting now automatically falls back to standard environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and their lowercase variants), with `NO_PROXY`/`no_proxy` respected for bypass rules (#1000)
|
||||
- **Font packages for `--with-deps`** - Installing with `--with-deps` now includes CJK and emoji font packages on Linux (Debian, RPM, and yum-based distros) to prevent missing glyphs when rendering international content (#1002)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed `state show` always failing with "Missing 'path' parameter" due to a mismatched JSON field name (`filename` → `path`) (#994)
|
||||
- Fixed `console` command returning only `Done` due to a JSON field name mismatch in the response (#986)
|
||||
- Fixed browser-domain CDP events being dropped during downloads due to a `sessionId` mismatch (#998)
|
||||
- Fixed proxy authentication by handling credentials via the CDP `Fetch.authRequired` event rather than passing them inline (#1000)
|
||||
|
||||
## 0.22.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3a3317b: ### Bug Fixes
|
||||
|
||||
- Fixed **modifier key chords** (e.g. `Control+a`, `Shift+Enter`, `Control+Shift+a`) not being handled correctly when using `press`. Modifier keys (`Alt`, `Control`/`Ctrl`, `Meta`/`Cmd`, `Shift`) are now parsed and forwarded as CDP modifier bitmasks rather than treated as part of the key name (#980)
|
||||
- Fixed **query parameters being dropped** from `--cdp` HTTP URLs (e.g. `http://host:9222?mode=Hello`). Query strings are now preserved and forwarded to the remote CDP endpoint (#982)
|
||||
|
||||
## 0.22.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- be30bc9: ### New Features
|
||||
|
||||
- **Cross-origin iframe support** - Added support for snapshots and interactions within cross-origin iframes via `Target.setAutoAttach` (#949)
|
||||
- **Network request detail and filtering** - Added `network request <requestId>` command to view full request/response detail, and new filtering options for `network requests` including `--type` (e.g. `xhr,fetch`), `--method` (e.g. `POST`), and `--status` (e.g. `2xx`, `400-499`) (#935)
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Snapshot usability** - Reduced AI cognitive load by filtering semantic noise from snapshot output; cursor-interactive elements are now included by default, making the `-C` flag unnecessary (#968)
|
||||
- **Upgrade command** - Improved robustness of installation method detection in the upgrade command (#960)
|
||||
- **Target tracking** - Enhanced target tracking and page information handling for more reliable browser session management (#969)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed **viewport dimensions** being reported incorrectly in streaming status messages and screencast (#952)
|
||||
- Fixed **`find` command** flags such as `--exact` and `--name` leaking into fill values when used with fill actions (#955)
|
||||
- Fixed **state commands** incorrectly starting the daemon when no `session_name` is provided (#677, #964)
|
||||
- Fixed **auto-connect** triggering when the daemon is already running, preventing duplicate connections (#971)
|
||||
- Fixed **Enter key press** not working by adding a text field to `keyDown` events (#972)
|
||||
- Fixed **download command** to properly handle absolute paths and correctly click target elements (#970)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- The `-C` / `--cursor` flag for `snapshot` is deprecated; cursor-interactive elements are now included by default and the flag has no additional effect (#968)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Updated `README.md` with new `network requests` filtering options and `network request <requestId>` command usage
|
||||
- Removed references to the deprecated `-C` / `--cursor` snapshot flag from docs and command reference
|
||||
|
||||
## 0.21.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- aed466b: ### Bug Fixes
|
||||
|
||||
- **Auth login readiness** - `agent-browser auth login` now navigates with `load`, waits for usable login form selectors, and uses staged username detection (targeted email/username selectors first, then broad text-input fallback). This reduces SPA timing failures, avoids false matches on unrelated text fields, and prevents `networkidle` hangs on pages with continuous background requests.
|
||||
|
||||
## 0.21.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6daad22: ### Bug Fixes
|
||||
|
||||
- **WebSocket keepalive for remote browsers** - Added WebSocket Ping frames and TCP `SO_KEEPALIVE` to prevent CDP connections from being silently dropped by intermediate proxies (reverse proxies, load balancers, service meshes) during idle periods (#936)
|
||||
- **XPath selector support** - Fixed element resolution to correctly handle the `xpath=` selector prefix (#908)
|
||||
|
||||
### Performance
|
||||
|
||||
- **Fast-path for identical snapshots** - Short-circuits the Myers diff algorithm when comparing a snapshot to itself, avoiding unnecessary computation in retry and loop workloads where repeated identical snapshots are common (#922)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Migrated page metadata from MDX files to `layout.tsx` (#904)
|
||||
- Added search functionality and color improvements to docs (#927)
|
||||
- Fixed desktop browser list in the iOS comparison table (#926)
|
||||
- Created a new `providers/` section with dedicated provider pages (#928)
|
||||
|
||||
## 0.21.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 757626f: ### Bug Fixes
|
||||
|
||||
- **Deduplicate text content in snapshots** - Fixed an issue where duplicate text content appeared in page snapshots (#909)
|
||||
- **Native mouse drag state** - Fixed incorrect raw native mouse drag state not being properly tracked across `down`, `move`, and `up` events (#872)
|
||||
- **Chrome headless launch failures** - Fixed browser launch failures caused by the `--enable-unsafe-swiftshader` flag in Chrome headless mode (#915)
|
||||
- **Origin-scoped `--headers` persistence** - Restored correct persistence of origin-scoped headers set via `--headers` across navigation commands (#894)
|
||||
- **Relative URLs in WebSocket domain filter** - Fixed handling of relative URLs in the WebSocket domain filter script (#624)
|
||||
|
||||
## 0.21.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1e7619d: ### New Features
|
||||
|
||||
- **HAR 1.2 network capture** - Added commands to capture and export network traffic in HAR 1.2 format, including accurate request/response timing, headers, body sizes, and resource types sourced from Chrome DevTools Protocol events (#864)
|
||||
- **Built-in `upgrade` command** - Added `agent-browser upgrade` to self-update the CLI; automatically detects your installation method (npm, Homebrew, or Cargo) and runs the appropriate update command (#898)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Added `upgrade` command to the README command reference and installation guide
|
||||
- Added a dedicated **Updating** section to the README with usage instructions for `agent-browser upgrade`
|
||||
|
||||
## 0.21.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c6de80b: ### New Features
|
||||
|
||||
- **`batch` command** -- Execute multiple commands from stdin in a single invocation. Accepts a JSON array of string arrays and returns results sequentially. Supports `--bail` to stop on first error and `--json` for structured output (#865)
|
||||
- **iframe support** -- CLI interactions and snapshots now traverse into iframe content, enabling automation of cross-frame pages (#869)
|
||||
- **`network har start/stop` command** -- Capture and export network traffic in HAR 1.2 format (#874)
|
||||
- **WebSocket fallback for CDP discovery** -- When HTTP-based CDP endpoint discovery fails, the CLI now falls back to a WebSocket connection automatically (#873)
|
||||
|
||||
### Improvements
|
||||
|
||||
- **`--full`/`-f` refactored to command-level flag** -- Moved from a global flag to a per-command flag for clearer scoping (#877)
|
||||
- **Enhanced Chrome launch** -- Added `--user-data-dir` support and configurable launch timeout for more reliable browser startup (#852)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed `/json/list` fallback when `/json/version` endpoint is unavailable, improving compatibility with non-standard CDP implementations (#861)
|
||||
- Fixed daemon liveness detection for PID namespace isolation (e.g. `unshare`). Uses socket connectivity as the sole liveness check instead of `kill(pid, 0)`, which fails when the caller cannot see the daemon's PID (#879)
|
||||
- Fixed Ubuntu dependency install accidentally removing system packages (#884)
|
||||
|
||||
## 0.20.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c0d4cf6: ### New Features
|
||||
|
||||
- **Idle timeout for daemon auto-shutdown** - Added `--idle-timeout` CLI flag (and `AGENT_BROWSER_IDLE_TIMEOUT_MS` environment variable) to automatically shut down the daemon after a period of inactivity. Accepts human-friendly formats such as `10s`, `3m`, `1h`, or raw milliseconds (#856)
|
||||
- **Cursor-interactive elements in snapshot tree** - Cursor-interactive elements are now embedded directly into the snapshot tree for richer context (#855)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed **remote host support** in CDP discovery, enabling connection to browsers running on non-local hosts (#854)
|
||||
- Fixed **CDP flag propagation** to the daemon process, ensuring reliable CDP reconnection across sessions (#857)
|
||||
- Fixed **Windows auto-connect profiling** to correctly handle browser connection on Windows (#835, #840)
|
||||
- Fixed **Windows transient error detection** by recognising Windows-specific socket error codes (`os error 10061` connection refused, `os error 10054` connection reset) during daemon reconnection attempts
|
||||
|
||||
## 0.20.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- eda956b: ### Bug Fixes
|
||||
|
||||
- **Network idle detection for cached pages** - Fixed an issue where `poll_network_idle` could return immediately when no network events were observed (e.g. pages served from cache). The idle timer is now only satisfied after a consistent **500 ms idle period** has elapsed, preventing false-positive idle detection. The core polling logic has also been extracted into a standalone `poll_network_idle` function to improve testability (#847)
|
||||
|
||||
## 0.20.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5fa2396: ### Bug Fixes
|
||||
|
||||
- Fixed **`snapshot -C`** and **`screenshot --annotate`** hanging when connected over WSS (WebSocket Secure) due to sequential CDP round-trips per interactive element (#842)
|
||||
|
||||
### Performance
|
||||
|
||||
- **`snapshot -C` (cursor-interactive mode)** now batches CDP calls instead of issuing N×2 sequential round-trips per cursor-interactive element, preventing timeouts on high-latency WSS connections (#842)
|
||||
- **`screenshot --annotate`** now batches element queries, reducing completion time from potentially 20–40s (e.g. 50+ buttons over WSS) to within expected bounds (#842)
|
||||
|
||||
## 0.20.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4b5fc78: ### Bug Fixes
|
||||
|
||||
- **Material Design checkbox/radio parity** - Restored Playwright-parity behavior for `check`/`uncheck` actions on Material Design controls. These components hide the native `<input>` off-screen and use overlay elements that intercept coordinate-based clicks; the actions now detect this pattern and fall back to a JS `.click()` to correctly toggle state. Also improves `ischecked` to handle nested hidden inputs and ARIA-only checkboxes (#837)
|
||||
- **Punctuation handling in `type` command** - Fixed incorrect virtual key (VK) codes being used for punctuation characters (e.g. `.`, `@`) in the `type` action, which previously caused those characters to be dropped or mistyped (#836)
|
||||
|
||||
## 0.20.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a3d9662: ### Bug Fixes
|
||||
|
||||
- **Restored WebSocket streaming** - Fixed broken WebSocket streaming in the native daemon by keeping the **StreamServer** instance alive so the broadcast channel remains open, and ensuring CDP session IDs and connection status are correctly propagated to stream clients (#826)
|
||||
- **Filtered internal Chrome targets** - Fixed auto-connect discovery incorrectly attempting to attach to Chrome-internal pages (e.g. `chrome://`, `chrome-extension://`, `devtools://` URLs), which could cause unexpected connection failures (#827)
|
||||
|
||||
## 0.20.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 51d9ab4: ### Bug Fixes
|
||||
|
||||
- **Appium v3 iOS capabilities** - Added `appium:` vendor prefix to iOS capabilities (e.g., `appium:automationName`, `appium:deviceName`, `appium:platformVersion`) to comply with the Appium v3 WebDriver protocol requirements (#810)
|
||||
- **Snapshot `--selector` scoping** - Fixed `snapshot --selector` so that the output is properly scoped to the matched element's subtree rather than returning the full accessibility tree. The selector now resolves the target DOM node's backend IDs and filters the accessibility tree to only include nodes within that subtree (#825)
|
||||
|
||||
## 0.20.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- daf7263: ### Bug Fixes
|
||||
|
||||
- Fixed **video duration** being reported incorrectly when using real-time ffmpeg encoding for screen recording (#812)
|
||||
- Removed obsolete **`BrowserManager` TypeScript API** references that no longer reflect the current CLI-based usage model (#821)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Updated README to replace outdated **`BrowserManager` programmatic API** examples with the current CLI-based approach using `execSync` and `agent-browser` commands (#821)
|
||||
- Removed the **Programmatic API** section covering `BrowserManager` screencast and input injection methods, which are no longer part of the public API (#821)
|
||||
|
||||
## 0.20.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 25a1526: ### New Features
|
||||
|
||||
- **Brave Browser support** - Added auto-discovery of Brave Browser for CDP connections on macOS, Linux, and Windows. The agent will now automatically detect and connect to Brave alongside Chrome, Chromium, and Canary installations (#817)
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Postinstall message** - The post-install message now detects existing Chrome installations on the system. If a compatible browser is found, it confirms the path and notes it will be used automatically instead of prompting an install. If no browser is detected, the warning is clearer and mentions that installation can be skipped when using `--cdp`, `--provider`, `--engine`, or `--executable-path` (#815)
|
||||
|
||||
## 0.20.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fa91c22: ### Bug Fixes
|
||||
|
||||
- **Stale accessibility tree reference fallback** - Fixed an issue where interacting with an element whose **`backend_node_id`** had become stale (e.g. after the DOM was replaced) would fail with a `Could not compute box model` CDP error. Element resolution now re-queries the accessibility tree using role/name lookup to obtain a fresh node ID before retrying the operation (#806)
|
||||
|
||||
## 0.20.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fc091d2: ### Bug Fixes
|
||||
|
||||
- **Daemon panic on broken stderr pipe** - Replaced all `eprintln!` calls with `writeln!(std::io::stderr(), ...)` wrapped in `let _ =` to silently discard write errors, preventing the daemon from panicking when the parent process drops the stderr pipe during Chrome launch (#802)
|
||||
|
||||
## 0.20.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e2ebde2: ### Bug Fixes
|
||||
|
||||
- **Broadcast channel lag handling** - Fixed an issue where **broadcast channel lag** errors were incorrectly treated as stream closure, causing premature termination of event listeners in reload, response body, download, and navigation wait operations. Lagged messages are now skipped and the loop continues instead of breaking (#797)
|
||||
|
||||
### Improvements
|
||||
|
||||
- Removed unused **pnpm setup** steps from the `global-install` CI job, simplifying the workflow configuration (#798)
|
||||
|
||||
## 0.20.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e365909: ### Bug Fixes
|
||||
|
||||
- **Chrome launch retry** - Chrome will now retry launching up to 3 times with a 500ms delay between attempts, improving resilience against transient startup failures (#791)
|
||||
- **Remote CDP snapshot hang** - Resolved an issue where snapshots would hang indefinitely over remote CDP (WSS) connections by removing WebSocket message and frame size limits to accommodate large responses (e.g. `Accessibility.getFullAXTree`), accepting binary frames from remote proxies such as Browserless, and immediately clearing pending commands when the connection closes rather than waiting for the 30-second timeout (#792)
|
||||
|
||||
## 0.20.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 944fa01: ### New Features
|
||||
|
||||
- **Linux musl (Alpine) builds** - Added pre-built binaries for **linux-musl** targeting both **x64** and **arm64** architectures, enabling native support for Alpine Linux and other musl-based distributions without requiring glibc (#784)
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Consecutive `--auto-connect` commands** - Added support for issuing multiple consecutive `--auto-connect` commands without requiring a full browser relaunch; external connections are now correctly identified and reused (#786)
|
||||
- **External browser disconnect behavior** - When using `--auto-connect` or `--cdp`, closing the agent session now disconnects cleanly without shutting down the user's browser process
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Restored `refs` dict in `--json` snapshot output** - The `refs` map containing role and name metadata for referenced elements is now correctly included in JSON snapshot responses (#787)
|
||||
- Fixed e2e test assertions for `diff_snapshot` and `domain_filter` to correctly reflect expected behavior (#783)
|
||||
- Fixed Chrome temp-dir cleanup test failing on Windows (#766)
|
||||
|
||||
## 0.20.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- bd05917: ### Bug Fixes
|
||||
|
||||
- Fixed **AX tree deserialization** to accept integer `nodeId` and `childIds` values for compatibility with Lightpanda, which sends numeric IDs where Chrome sends strings (#775)
|
||||
- Fixed **misleading SIGPIPE comment** to accurately describe the default Rust SIGPIPE behavior and why it is reset to `SIG_DFL` (#776)
|
||||
- Fixed **WebM recording output** to use the VP9 codec (`libvpx-vp9`) instead of H.264, producing valid WebM files; also adds a padding filter to ensure even frame dimensions (#779)
|
||||
|
||||
## 0.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 235fa88: ### Full Native Rust
|
||||
|
||||
- **100% native Rust** -- Removed the entire Node.js/Playwright daemon. The Rust native daemon is now the only implementation. No Node.js runtime or Playwright dependency required. (#754)
|
||||
- **99x smaller install** -- Install size reduced from 710 MB to 7 MB by eliminating the Node.js dependency tree.
|
||||
- **18x less memory** -- Daemon memory usage reduced from 143 MB to 8 MB.
|
||||
- **1.6x faster cold start** -- Cold start time reduced from 1002ms to 617ms.
|
||||
- **Benchmarks** -- Added benchmark suite comparing native vs Node.js daemon performance.
|
||||
- **Chromium installer hardened** -- Fixed zip path traversal vulnerability in Chrome for Testing installer.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed `--headed false` flag not being respected in CLI (#757)
|
||||
- Fixed "not found" error pattern in `to_ai_friendly_error` incorrectly catching non-element errors (#759)
|
||||
- Fixed storage local key lookup parsing and text output (#761)
|
||||
- Fixed Lightpanda engine launch with release binaries (#760)
|
||||
- Hardened Lightpanda startup timeouts (#762)
|
||||
|
||||
## 0.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 56bb92b: ### New Features
|
||||
|
||||
- **Browserless.io provider** -- Added browserless.io as a browser provider, supported in both Node.js and native daemon paths. Connect to remote Browserless instances with `--provider browserless` or `AGENT_BROWSER_PROVIDER=browserless`. Configurable via `BROWSERLESS_API_KEY`, `BROWSERLESS_API_URL`, and `BROWSERLESS_BROWSER_TYPE` environment variables. (#502, #746)
|
||||
- **`clipboard` command** -- Read from and write to the browser clipboard. Supports `read`, `write <text>`, `copy` (simulates Ctrl+C), and `paste` (simulates Ctrl+V) operations. (#749)
|
||||
- **Screenshot output configuration** -- New global flags `--screenshot-dir`, `--screenshot-quality`, `--screenshot-format` and corresponding `AGENT_BROWSER_SCREENSHOT_DIR`, `AGENT_BROWSER_SCREENSHOT_QUALITY`, `AGENT_BROWSER_SCREENSHOT_FORMAT` environment variables for persistent screenshot settings. (#749)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed `wait --text` not working in native daemon path (#749)
|
||||
- Fixed `BrowserManager.navigate()` and package entry point (#748)
|
||||
- Fixed extensions not being loaded from `config.json` (#750)
|
||||
- Fixed scroll on page load (#747)
|
||||
- Fixed HTML retrieval by using `browser.getLocator()` for selector operations (#745)
|
||||
|
||||
## 0.18.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 942b8cd: ### New Features
|
||||
|
||||
- **`inspect` command** - Opens Chrome DevTools for the active page by launching a local proxy server that forwards the DevTools frontend to the browser's CDP WebSocket. Commands continue to work while DevTools is open. Implemented in both Node.js and native paths. (#736)
|
||||
- **`get cdp-url` subcommand** - Retrieve the Chrome DevTools Protocol WebSocket URL for the active page, useful for external debugging tools. (#736)
|
||||
- **Native screenshot annotate** - The `--annotate` flag for screenshots now works in the native Rust daemon, bringing parity with the Node.js path. (#706)
|
||||
|
||||
### Improvements
|
||||
|
||||
- **KERNEL_API_KEY now optional** - External credential injection no longer requires `KERNEL_API_KEY` to be set, making it easier to use Kernel with pre-configured environments. (#687)
|
||||
- **Browserbase simplified** - Removed the `BROWSERBASE_PROJECT_ID` requirement, reducing setup friction for Browserbase users. (#625)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed Browserbase API using incorrect endpoint to release sessions (#707)
|
||||
- Fixed CDP connect paths using hardcoded 10s timeout instead of `getDefaultTimeout()` (#704)
|
||||
- Fixed lone Unicode surrogates causing errors by sanitizing with `toWellFormed()` (#720)
|
||||
- Fixed CDP connection failure on IPv6-first systems (#717)
|
||||
- Fixed recordings not inheriting the current viewport settings (#718)
|
||||
|
||||
## 0.17.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 94cd888: Added support for device scale factor (retina display) in the viewport command via an optional scale parameter. Also added webview target type support for better Electron application compatibility, and the pages list now includes target type information.
|
||||
|
||||
## 0.17.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 94521e7: ### New Features
|
||||
|
||||
- **Lightpanda browser engine support** - Added `--engine <name>` flag to select the browser engine (`chrome` by default, or `lightpanda`), implying `--native` mode. Configurable via `AGENT_BROWSER_ENGINE` environment variable (#646)
|
||||
- **Dialog dismiss command** - Added support for `dismiss` subcommand in dialog command parsing (#605)
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Daemon startup error reporting** - Daemon startup errors are now surfaced directly instead of showing an opaque timeout message (#614)
|
||||
- **CDP port discovery** - Replaced broken hand-rolled HTTP client with `reqwest` for more reliable CDP port discovery (#619)
|
||||
- **Chrome extensions** - Extensions now load correctly by forcing headed mode when extensions are present (#652)
|
||||
- **Google Translate bar suppression** - Suppressed the Google Translate bar in native headless mode to avoid interference (#649)
|
||||
- **Auth cookie persistence** - Auth cookies are now persisted on browser close in native mode (#650)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed native auth login failing due to incompatible encryption format (#648)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Improved snapshot usage guidance and added reproducibility check (#630)
|
||||
- Added `--engine` flag to the README options table
|
||||
|
||||
### Performance
|
||||
|
||||
- Added benchmarks to the CLI codebase (#637)
|
||||
|
||||
## 0.16.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7d2c895: Fixed an issue where the --native flag was being passed to child processes even when not explicitly specified on the command line. The flag is now only forwarded when the user explicitly provides it, consistent with how other CLI flags like --allow-file-access and --download-path are handled.
|
||||
|
||||
## 0.16.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 01ac557: Added AGENT_BROWSER_HEADED environment variable support for running the browser in headed mode, and improved temporary profile cleanup when launching Chrome directly. Also includes documentation clarification that browser extensions work in both headed and headless modes.
|
||||
|
||||
## 0.16.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c4180c8: Improved Chrome launch reliability by automatically detecting containerized environments (Docker, Podman, Kubernetes) and enabling --no-sandbox when needed. Added support for discovering Playwright-installed Chromium browsers and enhanced error messages with helpful diagnostics when Chrome fails to launch.
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 05018b3: Added experimental native Rust daemon (`--native` flag, `AGENT_BROWSER_NATIVE=1` env, or `"native": true` in config). The native daemon communicates with Chrome directly via CDP, eliminating Node.js and Playwright dependencies. Supports 150+ commands with full parity to the default Node.js daemon. Includes WebDriver backend for Safari/iOS, CDP protocol codegen, request tracking, frame context management, and comprehensive e2e and parity tests.
|
||||
|
||||
## 0.15.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 62241b5: Fixed Windows compatibility issues including proper handling of extended-length path prefixes from canonicalize(), prevention of MSYS/Git Bash path translation that could mangle arguments, and improved daemon startup reliability. Also added ARM64 Windows support in postinstall shims and expanded CI testing with a full daemon lifecycle test on Windows.
|
||||
|
||||
## 0.15.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6aea316: Documentation site improvements and internal tooling updates including enhanced code blocks, mobile navigation, and docs chat components. CLI connection and output handling refinements. Skill creator reference documentation and scripts have been reorganized.
|
||||
|
||||
## 0.15.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7bd8ce9: Added support for chrome:// and chrome-extension:// URLs in navigation and recording commands. These special browser URLs are now preserved as-is instead of having https:// incorrectly prepended.
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 2e38882: - Added security hardening: authentication vault, content boundary markers, domain allowlist, action policy, action confirmation, and output length limits.
|
||||
- Added `--download-path` flag (and `AGENT_BROWSER_DOWNLOAD_PATH` env / `downloadPath` config key) to set a default download directory.
|
||||
- Added `--selector` flag to `scroll` command for scrolling within specific container elements.
|
||||
|
||||
## 0.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- b7665e5: - Added `keyboard` command for raw keyboard input -- type with real keystrokes, insert text, and press shortcuts at the currently focused element without needing a selector.
|
||||
- Added `--color-scheme` flag and `AGENT_BROWSER_COLOR_SCHEME` env var for persistent dark/light mode preference across browser sessions.
|
||||
- Fixed IPC EAGAIN errors (os error 35/11) by adding backpressure-aware socket writes, command serialization, and lowering the default Playwright timeout to 25s (configurable via `AGENT_BROWSER_DEFAULT_TIMEOUT`).
|
||||
- Fixed remote debugging (CDP) reconnection.
|
||||
- Fixed state load failing when no browser is running.
|
||||
- Fixed `--annotate` flag warning appearing when not explicitly passed via CLI.
|
||||
|
||||
## 0.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ebd8717: Added new diff commands for comparing snapshots, screenshots, and URLs between page states. You can now run visual pixel diffs against baseline images, compare accessibility tree snapshots with customizable depth and selectors, and diff two URLs side-by-side with optional screenshot comparison.
|
||||
|
||||
## 0.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 69ffad0: Add annotated screenshots with the new --annotate flag, which overlays numbered labels on interactive elements and prints a legend mapping each label to its element ref. This enables multimodal AI models to reason about visual layout while using the same @eN refs for subsequent interactions. The flag can also be set via the AGENT_BROWSER_ANNOTATE environment variable.
|
||||
|
||||
## 0.11.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c6fc7df: Added documentation for command chaining with && across README, CLI help output, docs, and skill files, explaining how to efficiently chain multiple agent-browser commands in a single shell invocation since the browser persists via a background daemon.
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 5dc40b4: Added configuration file support with automatic loading from user and project directories, new profiler commands for Chrome DevTools profiling, computed styles getter, browser extension loading, storage state management, and iOS device emulation. Expanded click command with new-tab option, improved find command with additional actions and filtering options, and enhanced CDP connection to accept WebSocket URLs. Documentation has been significantly expanded with new sections for configuration, profiling, and proxy support.
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 1112a16: Added session persistence with automatic save/restore of cookies and localStorage across browser restarts using --session-name flag, with optional AES-256-GCM encryption for saved state data. New state management commands allow listing, showing, renaming, clearing, and cleaning up old session files. Also added --new-tab option for click commands to open links in new tabs.
|
||||
|
||||
## 0.9.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 323b6cd: Fix all Clippy lint warnings in the Rust CLI: remove redundant import, use `.first()` instead of `.get(0)`, use `.copied()` instead of `.map(|s| *s)`, use `.contains()` instead of `.iter().any()`, use `then_some` instead of lazy `then`, and simplify redundant match guards.
|
||||
|
||||
## 0.9.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d03e238: Added support for custom executable path in CLI browser launch options. Documentation site received UI improvements including a new chat component with sheet-based interface and updated dependencies.
|
||||
|
||||
## 0.9.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 76d23db: Documentation site migrated to MDX for improved content authoring, added AI-powered docs chat feature, and updated README with Homebrew installation instructions for macOS users.
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ae34945: Added --allow-file-access flag to enable opening and interacting with local file:// URLs (PDFs, HTML files) by passing Chromium flags that allow JavaScript access to local files. Added -C/--cursor flag for snapshots to include cursor-interactive elements like divs with onclick handlers or cursor:pointer styles, which is useful for modern web apps using custom clickable elements.
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9d021bd: Add iOS Simulator and real device support for mobile Safari testing via Appium. New CLI commands include `device list` to show available simulators, `tap` and `swipe` for touch interactions, and the `--device` flag to specify which iOS device to use. Configure with `-p ios` provider flag or `AGENT_BROWSER_PROVIDER=ios` environment variable.
|
||||
|
||||
## 0.8.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 17dba8f: Add --stdin flag for eval command to read JavaScript from stdin, enabling heredoc usage for multiline scripts
|
||||
- daeede4: Add --stdin flag for the eval command to read JavaScript from stdin, enabling heredoc usage for multiline scripts. Also fix binary permission issues on macOS/Linux when postinstall scripts don't run (e.g., with bun).
|
||||
|
||||
## 0.8.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0dc36f2: Add --stdin flag for eval command to read JavaScript from stdin, enabling heredoc usage for multiline scripts
|
||||
|
||||
## 0.8.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2771588: Added base64 encoding support for the eval command with -b/--base64 flag to avoid shell escaping issues when executing JavaScript. Updated documentation with AI agent setup instructions and reorganized the docs structure by consolidating agent mode content into the installation page.
|
||||
|
||||
## 0.8.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d24f753: Fixed browser launch options not being passed correctly when using persistent profiles, ensuring args, userAgent, proxy, and ignoreHTTPSErrors settings now work properly. Added pre-flight checks for socket path length limits and directory write permissions to provide clearer error messages when daemon startup fails. Improved error handling to properly exit with failure status when browser launch fails.
|
||||
|
||||
## 0.8.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d75350a: Improved daemon connection reliability by adding automatic retry logic for transient errors like connection resets, broken pipes, and temporary resource unavailability. The CLI now cleans up stale socket and PID files before starting a new daemon, and includes better detection of daemon responsiveness to handle race conditions during shutdown.
|
||||
|
||||
## 0.8.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cb2f8c3: Fixed version synchronization to automatically update Cargo.lock alongside Cargo.toml during releases, and made the CLI binary executable. This ensures the Rust CLI version stays in sync with the npm package version.
|
||||
|
||||
## 0.8.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 759302e: Fixed "Daemon not found" error when running through AI agents (e.g., Claude Code) by resolving symlinks in the executable path. Previously, npm global bin symlinks weren't being resolved correctly, causing intermittent daemon discovery failures.
|
||||
|
||||
## 0.8.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4116a8a: Replaced shell-based CLI wrappers with a cross-platform Node.js wrapper to enable npx support on Windows. Added postinstall logic to patch npm's bin entry on global installs, allowing the native binary to be invoked directly with zero overhead. Added CI tests to verify global installation works correctly across all platforms.
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7e6336f: Fixed the Windows CMD wrapper to use the native binary directly instead of routing through Node.js, improving startup performance and reliability. Added retry logic to the CI install command to handle transient failures during browser installation.
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8eec634: Improved release workflow to validate binary file sizes and ensure binaries are executable after npm install. Updated documentation site with a new mobile navigation system and added v0.8.0 changelog entries. Reformatted CHANGELOG.md for better readability.
|
||||
|
||||
## v0.8.0
|
||||
|
||||
### New Features
|
||||
|
||||
- **Kernel cloud browser provider** - Connect to Kernel (https://kernel.sh) for remote browser infrastructure via `-p kernel` flag or `AGENT_BROWSER_PROVIDER=kernel`. Supports stealth mode, persistent profiles, and automatic profile find-or-create.
|
||||
- **Ignore HTTPS certificate errors** - New `--ignore-https-errors` flag for working with self-signed certificates and development environments
|
||||
- **Enhanced cookie management** - Extended `cookies set` command with `--url`, `--domain`, `--path`, `--httpOnly`, `--secure`, `--sameSite`, and `--expires` flags for setting cookies before page load
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed tab list command not recognizing new pages opened via clicks or `target="_blank"` links (#275)
|
||||
- Fixed `check` command hanging indefinitely (#272)
|
||||
- Fixed `set device` not applying deviceScaleFactor - HiDPI screenshots now work correctly (#270)
|
||||
- Fixed state load and profile persistence not working in v0.7.6 (#268)
|
||||
- Screenshots now save to temp directory when no path is provided (#247)
|
||||
|
||||
### Security
|
||||
|
||||
- Daemon and stream server now reject cross-origin connections (#274)
|
||||
|
||||
## 0.7.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a4d0c26: Allow null values for the screenshot selector field. Previously, passing a null selector would fail validation, but now it is properly handled as an optional value.
|
||||
|
||||
## 0.7.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8c2a6ec: Fix GitHub release workflow to handle existing releases. If a release already exists, binaries are uploaded to it instead of failing.
|
||||
|
||||
## 0.7.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 957b5e5: Fix binary permissions on install. npm doesn't preserve execute bits, so postinstall now ensures the native binary is executable.
|
||||
|
||||
## 0.7.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 161d8f5: Fix native binary distribution in npm package. Native binaries for all platforms (Linux x64/arm64, macOS x64/arm64, Windows x64) are now correctly included when publishing.
|
||||
|
||||
## 0.7.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6afede2: Fix native binary distribution in npm package
|
||||
|
||||
Native binaries for all platforms (Linux x64/arm64, macOS x64/arm64, Windows x64) are now included in the npm package. Previously, the release workflow published to npm before building binaries, causing "No binary found" errors on installation.
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix native binary distribution in npm package. Native binaries for all platforms (Linux x64/arm64, macOS x64/arm64, Windows x64) are now included in the npm package. Previously, the release workflow published to npm before building binaries, causing "No binary found" errors on installation.
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 316e649: ## New Features
|
||||
|
||||
- **Cloud browser providers** - Connect to Browserbase or Browser Use for remote browser infrastructure via `-p` flag or `AGENT_BROWSER_PROVIDER` env var
|
||||
- **Persistent browser profiles** - Store cookies, localStorage, and login sessions across browser restarts with `--profile`
|
||||
- **Remote CDP WebSocket URLs** - Connect to remote browser services via WebSocket URL (e.g., `--cdp "wss://..."`)
|
||||
- **Download commands** - New `download` command and `wait --download` for file downloads with ref support
|
||||
- **Browser launch configuration** - New `--args`, `--user-agent`, and `--proxy-bypass` flags for fine-grained browser control
|
||||
- **Enhanced skills** - Hierarchical structure with references and templates for Claude Code
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Screenshot command now supports refs and has improved error messages
|
||||
- WebSocket URLs work in `connect` command
|
||||
- Fixed socket file location (uses `~/.agent-browser` instead of TMPDIR)
|
||||
- Windows binary path fix (.exe extension)
|
||||
- State load and path-based actions now show correct output messages
|
||||
|
||||
## Documentation
|
||||
|
||||
- Added Claude Code marketplace plugin installation instructions
|
||||
- Updated skill documentation with references and templates
|
||||
- Improved error documentation
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
# chrome-use
|
||||
|
||||
[English](README.md) · **简体中文**
|
||||
|
||||

|
||||
|
||||
**chrome-use** 让任意 AI agent 直接操作你自己正在用的、已登录的 Chrome —— 复用你的登录态,对反爬/反自动化系统**完全不可检测**,因为它**就是**你的真实浏览器。属于 `*-use` 家族(iphone-use 驱动你的真实 iPhone,chrome-use 驱动你的真实 Chrome)。
|
||||
|
||||
<sub>最初基于 [vercel-labs/agent-browser](https://github.com/vercel-labs/agent-browser)(Apache-2.0);现已是独立项目 —— 隐身/扩展中继架构、反检测、humanize、多 agent 隔离与 CLI 都已大幅分化。</sub>
|
||||
|
||||
## 把你**已经登录好**的浏览器,交给你的 AI agent
|
||||
|
||||
**不用开新 Chrome。不用重新登录。不用跟"你是不是机器人"较劲。**
|
||||
|
||||
chrome-use 让**任意** agent(Claude Code、Cursor、Codex、你自己的脚本)直接操作你**已经登录了所有网站**的那个 Chrome。它在**你的窗口里**点击,你看着它干活,撞到 2FA / 验证码的瞬间你接管一下,它接着跑。因为它**就是你的真实浏览器**(一键装的扩展、原生消息、无调试端口),网站眼里它 100% 是人:**[CreepJS 实测 0% 机器人](#反检测)。**
|
||||
|
||||
**为什么不用……**
|
||||
|
||||
- **Playwright / Puppeteer / browser-use?** 它们开的是**空**浏览器 —— 每个登录你重做、每个验证码你硬扛、最后还被标成自动化。我们直接用你**现成的**会话。
|
||||
- **Claude 的 Chrome 插件?** 很好,但**只能给 Claude 用**。我们给**任意** agent / CLI 用。
|
||||
- **裸 `--remote-debugging-port`**(web-access 等)? Chrome 136+ **每次连都弹** "Allow remote debugging?"。我们**永不弹** —— 商店一键装,原生消息。
|
||||
|
||||
<details>
|
||||
<summary><b>完整对比矩阵</b>(要细节的看这里)</summary>
|
||||
|
||||
| | [Claude in Chrome](https://www.anthropic.com/claude/chrome) | web-access / 裸 CDP 端口 | Playwright · Puppeteer · browser-use | **chrome-use** |
|
||||
|---|:---:|:---:|:---:|:---:|
|
||||
| **任意** agent / CLI 都能用(不绑单一 app) | ❌ 仅 Claude | ✅ | ✅ | ✅ |
|
||||
| 驱动你**真实、已登录**的 Chrome | ✅ | ✅ | ❌ 全新空 profile | ✅ |
|
||||
| **不弹 "Allow remote debugging?"** | ✅ | ❌ 每次连都弹 | —(自带浏览器) | ✅ 原生消息 |
|
||||
| 真实浏览器指纹(CreepJS ~0%)¹ | ✅ | ✅ | ❌ 自动化特征 / headless | ✅ **已实测 0%** |
|
||||
| **无 `Runtime.enable` CDP 泄漏**(rebrowser)² | — | ❌ 泄漏 | ❌ 泄漏 | ✅ **默认关闭** |
|
||||
| 多 agent 共用**同一个**真实 Chrome、标签组隔离³ | ❌ 单 app | ⚠️ 共享 tab、无隔离 | ❌ 各开各的浏览器 | ✅ |
|
||||
| 权限面 | 16 个,含 `<all_urls>` | 完整 CDP | 完全控制 | **7 个,无 `<all_urls>`** |
|
||||
|
||||
<sub>¹ 三家"真实 Chrome"工具在 CreepJS 上都 ~0%(毕竟是真浏览器),我们的是实测过的。² rebrowser `runtimeEnableLeak` —— 我们的中继路径实测无泄漏;Claude in Chrome 未独立测试(—)。³ web-access 也能跑并行子 agent,但无每会话隔离;本工具每个 `--session` 拿到自己彩色、命令隔离的标签组。实测数字见 [反检测](#反检测)。</sub>
|
||||
|
||||
</details>
|
||||
|
||||
## 为什么选 chrome-use
|
||||
|
||||
<img src="assets/fingerprint.png" alt="真实但不可检测的指纹" width="300" align="right" />
|
||||
|
||||
**常规浏览器自动化**(Playwright / Puppeteer,或全新 `--launch`)启动的是空 profile 的全新浏览器:你得重新登录,网站也能看出是自动化。
|
||||
|
||||
**chrome-use** 连接你**现有**的 Chrome —— cookies、会话、浏览器指纹全是真的,因为它**就是**你的真实浏览器。
|
||||
|
||||
| | 常规自动化 | chrome-use |
|
||||
|---|---|---|
|
||||
| 浏览器 | 启动新 Chrome | 连接你的 Chrome |
|
||||
| 登录态 | 空,要重新登 | 你现有的会话 |
|
||||
| 指纹 | 带自动化标记 | 你的真实指纹 |
|
||||
| 协作 | 独立窗口 | 同一窗口,随时接管 |
|
||||
| 验证码 | Agent 卡住 | 你点一下,Agent 继续 |
|
||||
|
||||
## 工作原理
|
||||
|
||||

|
||||
|
||||
你的 **chrome-use CLI** 通过 Chrome **原生消息(native messaging)** 和一个小**浏览器扩展**通信 —— 这是本机进程间通道,**无网络端口、无 token、无远程服务器**。扩展用 `chrome.debugger` 驱动你指定的标签页(在你**已登录**的 Chrome 里),再把结果交还给 CLI。全程都在你本机。
|
||||
|
||||

|
||||
|
||||
每个 `--session` 拿到**自己的彩色标签组**,多个 agent 共用同一个真实浏览器、互不干扰,也不动你自己的标签页。
|
||||
|
||||
## 为什么用扩展(而非裸调试端口)
|
||||
|
||||
其他本地工具走裸 `--remote-debugging-port`(CDP)驱动 Chrome。从 **Chrome 136** 起,每次这样连接都会弹出一个阻塞式的 **"Allow remote debugging?"** 同意框 —— 而且端口得提前开好。我们的扩展改用原生消息:**装一次,之后零确认。**
|
||||
|
||||
| | **chrome-use**(本扩展) | web-access(裸 CDP 端口) | Claude in Chrome(chrome.debugger) |
|
||||
|---|---|---|---|
|
||||
| 连接方式 | 原生消息 —— 无端口、无 token | `--remote-debugging-port` | `chrome.debugger` |
|
||||
| **"Allow remote debugging?" 弹框** | **从不** ✅ | **每次连都弹** 🔴 | 无 |
|
||||
| 复用你的真实登录 | 是 | 是 | 是 |
|
||||
| `Runtime.enable`(CDP)泄漏¹ | **默认关闭 → 干净** ✅ | 域已启用 | 不适用 |
|
||||
| CreepJS 隐身分² | **0% stealth · 0% headless** ✅ | 真实 Chrome | 真实 Chrome |
|
||||
| 每会话标签组 / 并发 agent | **支持** ✅ | 无 | 无 |
|
||||
| 为 chrome-use CLI 打造 | 是 | 独立代理 | 单 app 助手 |
|
||||
|
||||
> ¹ 对 [rebrowser-bot-detector](https://bot-detector.rebrowser.net/) 实测:我们的中继报 `runtimeEnableLeak: 🟢 No leak`、`navigatorWebdriver: 🟢`。
|
||||
> ² 对 [CreepJS](https://abrahamjuliot.github.io/creepjs/) 在「连接真实 Chrome」路径上实测 —— 见 [反检测](#反检测)。
|
||||
>
|
||||
> 同意框不是假想:裸端口工具**每次** attach 都会弹(Chrome 136+ 安全策略)。扩展路径从不弹。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/leeguooooo/chrome-use/main/install.sh | sh
|
||||
```
|
||||
|
||||
从最新的 [GitHub Release](https://github.com/leeguooooo/chrome-use/releases) 下载对应平台的预编译二进制,安装 `chrome-use`(以及 `abs` 别名)。无需 npm,无需 token。
|
||||
|
||||
<details>
|
||||
<summary>其他安装方式</summary>
|
||||
|
||||
- **锁定版本:** `AGENT_BROWSER_VERSION=v0.27.0-fork.12 curl -fsSL https://raw.githubusercontent.com/leeguooooo/chrome-use/main/install.sh | sh`
|
||||
- **自定义路径:** `AGENT_BROWSER_BIN_DIR=$HOME/bin curl -fsSL … | sh`
|
||||
- **Windows:** 从 [Releases 页](https://github.com/leeguooooo/chrome-use/releases) 下载 `chrome-use-win32-x64.tar.gz`,把 `chrome-use.exe` 放进 PATH。
|
||||
- **npm(旧渠道):** `npm install -g chrome-use` —— 仍在发布,但 GitHub Releases 现在是主渠道。
|
||||
</details>
|
||||
|
||||
### 安装 AI agent skills
|
||||
|
||||
```bash
|
||||
npx skills add leeguooooo/chrome-use
|
||||
```
|
||||
|
||||
把 `skills/chrome-use` 拉进当前项目,让你的 AI agent 拿到正确的用法和预授权的 bash 权限。
|
||||
|
||||
## 命令名
|
||||
|
||||
`chrome-use`、`chrome-use`、`abs` 是**同一个二进制** —— `abs` 只是短别名。没有单独的「隐身可执行文件」;隐身是**运行时行为**(见下方 [反检测](#反检测)),根据你是连接真实 Chrome 还是 `--launch` 全新实例自动启用。
|
||||
|
||||
## 连接你的 Chrome
|
||||
|
||||
**推荐 —— 浏览器扩展(一键,无弹窗)。** 从 Chrome 应用商店安装 [**chrome-use** 扩展](https://chromewebstore.google.com/detail/chrome-use/knfcmbamhjmaonkfnjhldjedeobeafmk),再注册一次本地桥:
|
||||
|
||||
```bash
|
||||
chrome-use extension install # 注册原生消息 host(一次性)
|
||||
chrome-use open https://x.com/home
|
||||
```
|
||||
|
||||
之后 `chrome-use open` 就通过**原生消息**驱动你真实、已登录的 Chrome —— 无调试端口、无 token、**永远不弹 "Allow remote debugging?"**。扩展自动更新、重启不掉,零确认(适合无人值守 / agent 场景)。
|
||||
|
||||
<details>
|
||||
<summary>备选 —— 裸 remote-debugging 端口(会弹同意框)</summary>
|
||||
|
||||
不装扩展时,chrome-use 退回用 CDP 连接,而 Chrome 只在带 remote-debugging 端口启动时才暴露它:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
open -a "Google Chrome" --args --remote-debugging-port=9222
|
||||
# Linux
|
||||
google-chrome --remote-debugging-port=9222
|
||||
# Windows: 给 Chrome 快捷方式 target 加 --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
然后 `chrome-use open <url>` 自动发现端口。首次连接 **Chrome 136+ 会弹 "Allow remote debugging?"** —— 点一次 Allow(该 Chrome 会话内持续有效)。上面的扩展则完全避开这个框。
|
||||
</details>
|
||||
|
||||
## 用法
|
||||
|
||||
```bash
|
||||
# 连接你的 Chrome 并导航
|
||||
chrome-use open https://example.com
|
||||
|
||||
# 一切都在你已登录的浏览器里进行
|
||||
chrome-use click "Post"
|
||||
chrome-use fill "Title" "Hello World"
|
||||
chrome-use screenshot ./page.png
|
||||
```
|
||||
|
||||
Agent 在你的 Chrome 里操作 —— 你能实时看到开标签、加载、点击。任意时刻都能接管(比如手动过验证码),然后让 agent 继续。
|
||||
|
||||
### 独立模式(`--launch`)
|
||||
|
||||
```bash
|
||||
# 临时:全新空 profile —— 无 cookie 无登录(适合 CI / 测试)
|
||||
chrome-use --launch open https://example.com
|
||||
|
||||
# 保留登录:用你真实的 Chrome profile 启动
|
||||
chrome-use --launch --profile auto open https://x.com/home
|
||||
# 或显式指定:--profile Default / --profile "Profile 1"
|
||||
```
|
||||
|
||||
## 站点适配器 —— 把一个网站变成「结构化数据 CLI」
|
||||
|
||||
大多数「读 GitHub issue」「搜 Reddit」「拉我的 B 站动态」这类任务,根本不需要点击 +
|
||||
截图 —— 网站登录态背后本来就有 JSON 接口。**站点适配器**就是一小段 JS 函数,它在你
|
||||
**已登录的标签页内**调用那个接口(用你的 cookie、同源 `fetch`、网站自己的模块),返回
|
||||
干净的 JSON。网站分辨不出它和你的区别,因为它**就是你**。
|
||||
|
||||
chrome-use 本身不附带任何适配器 —— `site update` 会在运行时拉取社区的
|
||||
[**bb-sites**](https://github.com/epiral/bb-sites) 适配器包(就像包管理器拉依赖),
|
||||
然后在 chrome-use 的隐身通道上运行它们:
|
||||
|
||||
```bash
|
||||
chrome-use site update # 拉取适配器包(约 145 条命令)
|
||||
chrome-use site list # github/issues、reddit/search、bilibili/feed…
|
||||
chrome-use site info github/issues # 查看某个适配器的参数 + 域名
|
||||
|
||||
# 运行一个 —— 会导航到对应站点(已在该站点则复用当前标签页)并返回 JSON
|
||||
chrome-use site github/issues epiral/bb-browser --json
|
||||
chrome-use site reddit/search "rust async" --json
|
||||
chrome-use site bilibili/feed --json # 能用,因为走的是你的登录态
|
||||
```
|
||||
|
||||
位置参数按适配器声明的参数顺序填入;`--key value` 按名覆盖。适配器由 bb-sites 社区编写、
|
||||
版权归各自作者所有 —— chrome-use 只负责运行它们。
|
||||
|
||||
**自动同步 + 自动提示。** 你基本不用手动 `site update`:chrome-use 首次使用时自动拉取,
|
||||
之后每周后台刷新一次(`AGENT_BROWSER_SITES_TTL_DAYS` 调周期,`AGENT_BROWSER_SITES_NO_AUTO_UPDATE=1`
|
||||
关闭)。而当你 `open`/`snapshot` 一个有适配器的域名时,chrome-use 会在输出里直接把可用命令
|
||||
亮出来 —— 一行 `💡 site adapters for <域名>`,`--json` 下则是 `siteAdapters` 字段 —— 这样
|
||||
agent 会直接改用结构化适配器,而不是去扒 DOM:
|
||||
|
||||
```text
|
||||
$ chrome-use open https://github.com
|
||||
💡 site adapters for github.com — prefer these for structured data:
|
||||
github/issues, github/me, github/repo, …
|
||||
e.g. chrome-use site github/issues --json
|
||||
✓ GitHub
|
||||
```
|
||||
|
||||
## 自动化测试(`chrome-use test`)
|
||||
|
||||
把反复的「打开它、点一圈、看对不对」变成**可重跑的测试套件** —— 前端的单元测试。用 YAML 写用例;步骤复用 chrome-use 自己的命令,断言编译成一次检查:
|
||||
|
||||
```yaml
|
||||
# smoke.yaml
|
||||
suite: chatgpt smoke
|
||||
setup:
|
||||
- account: chatgpt/huayue # 注入一个 cookie-use 登录(可选)
|
||||
cases:
|
||||
- name: home loads logged in
|
||||
steps:
|
||||
- open: https://chatgpt.com/
|
||||
- wait: { load: networkidle }
|
||||
assert:
|
||||
- url: { contains: chatgpt.com }
|
||||
- visible: "#prompt-textarea"
|
||||
```
|
||||
|
||||
```bash
|
||||
chrome-use test smoke.yaml # 启动隔离浏览器,跑用例
|
||||
chrome-use test smoke.yaml --session default # …或对你已连接的 Chrome 跑
|
||||
```
|
||||
|
||||
```
|
||||
suite: chatgpt smoke (session cu-test)
|
||||
✓ home loads logged in 1.2s
|
||||
✗ composer takes text 0.8s
|
||||
assert text "#prompt-textarea" contains "hi" → got ""
|
||||
↳ cu-test-artifacts/composer-takes-text.png
|
||||
2 cases · 1 passed · 1 failed
|
||||
```
|
||||
|
||||
任一用例失败时退出码非零(可直接丢进 CI),失败用例会存截图。断言:`url` · `visible` · `hidden` · `text` · `count` · `eval`。步骤:`open` · `click` · `fill` · `type` · `press` · `wait` · `scroll` · `eval`。完整指南:`chrome-use skills get test`。发现回归?加个用例 —— 用得越多,套件越值钱。
|
||||
|
||||
## 反检测
|
||||
|
||||
连接你真实 Chrome 时,我们**零** JS 注入 —— 浏览器指纹完全是真的。指导原则是 **native CDP/Chrome 覆盖优先于 JS 谎言**:被重定义的 getter 本身可被检测,原生覆盖则不会。
|
||||
|
||||
- `navigator.webdriver = false` 走 `Emulation.setAutomationOverride`(原生,CreepJS 类说谎检测查不出)。
|
||||
- **`Runtime.enable` 默认关闭** —— 活着的 `Runtime` 域是可被检测的 CDP 信号(patchright/rebrowser 的 "runtime leak"),即便连的是你真实 Chrome。只在你主动开启 console/错误捕获时才启用。
|
||||
|
||||
**实测结果(连接真实 Chrome,中继路径):**
|
||||
|
||||
| 检测站 | 结果 |
|
||||
|---|---|
|
||||
| [CreepJS](https://abrahamjuliot.github.io/creepjs/) | **0% stealth · 0% headless**(零 override 痕迹) |
|
||||
| [bot.incolumitas.com](https://bot.incolumitas.com/) | 全部 OK(overflowTest / overrideTest / puppeteerExtraStealth / worker 一致性) |
|
||||
| [rebrowser-bot-detector](https://bot-detector.rebrowser.net/) | `runtimeEnableLeak` 🟢 · `pwInitScripts` 🟢 |
|
||||
| [bot.sannysoft.com](https://bot.sannysoft.com) | 全绿 |
|
||||
|
||||
CreepJS 上的 `0% stealth` 是关键数字:因为连接路径**什么都不打补丁**,根本没有可供说谎检测器抓的 override。(读 `navigator.languages` 顺序或 IP 地理位置的面板可能给个软性的「navigator」/「location」标记 —— 那反映的是*你真实 Chrome* 的语言列表和网络,不是自动化破绽。)
|
||||
|
||||
`--launch` 独立模式(全新浏览器)会改用一整套隐身补丁,也能过上述检测 —— 唯一例外:CreepJS 报 **~20% stealth**,因为 srcdoc-iframe 的 `contentWindow` 补丁触发了它的 `hasIframeProxy` 探测(用来藏自动化的 proxy 本身成了破绽)。其余全干净(`0% headless`、sannysoft/browserscan 全绿、Cloudflare 通过)。设 **`AGENT_BROWSER_DISABLE_IFRAME_PROXY=1`** 去掉那个补丁即可拿到干净的 **0% stealth**(代价是放弃小众的 srcdoc-iframe 遮蔽)。**扩展连接路径**(你的真实 Chrome)零 JS 注入、不受影响 —— 它才是货真价实的 0% 路径。
|
||||
|
||||
### 类人输入(行为隐身)
|
||||
|
||||
指纹隐身只是一半——最强的反爬厂商(Akamai、PerimeterX、DataDome)还会给**行为**打分。点击时光标瞬移到元素正中心、没有接近轨迹、按下即抬起,这本身就是破绽,**哪怕我们的 CDP 事件是 `isTrusted`**。
|
||||
|
||||
开启 humanize 后,光标像手在动:点击走带减速的贝塞尔曲线、落在元素内**偏离正中心**的抖动点;打字用变速的击键间隔;滚动分段缓动;拖拽走曲线。而且**自适应**——每次导航探测页面是否有已知反爬厂商(cookie/脚本/全局变量),命中就自动升到全套类人动作,普通站点保持瞬时(零开销)。
|
||||
|
||||
页面自己的 `mousemove` 流看到的(行为检测器分析的正是这个):
|
||||
|
||||
| | 轨迹 |
|
||||
|---|---|
|
||||
| **off**(默认) | 直线 · 死磕正中心 · 瞬时 |
|
||||
| **human** | 曲线 · 先慢后快再慢 · 落点偏移 |
|
||||
|
||||
用 `--humanize off\|fast\|human` 或 `AGENT_BROWSER_HUMANIZE` 控制。默认 `off`,自适应检测器按页面自动升档。
|
||||
|
||||
### 静默操作
|
||||
|
||||
操作你的真实 Chrome 不该打断你的工作。agent **全程在后台操作**:新标签后台打开(在自己的彩色会话标签组里),**从不强制把标签拽到前台**,并用 `Emulation.setFocusEmulationEnabled` 让每个 agent 标签照常渲染、`document.hasFocus()` / `visibilityState` 仍报 `visible`。于是截图正常、页面不被降频,"标签全程隐藏"也不会变成新的机器人信号。你在自己的标签里照常工作,agent 在旁边默默干活。(想置顶某个标签仍可显式调用命令。)
|
||||
|
||||
### 自己验证
|
||||
|
||||
别光听我们说 —— 把你连接的 Chrome 指向最硬的公开检测器,自己对比:
|
||||
|
||||
- **[CreepJS](https://abrahamjuliot.github.io/creepjs/)** —— 最全面的指纹 / 说谎检测器
|
||||
- **[bot.incolumitas.com](https://bot.incolumitas.com/)** —— 行为 + 指纹打分,方法公开
|
||||
- **[BrowserScan](https://www.browserscan.net/bot-detection)** —— Webdriver / User-Agent / CDP / Navigator
|
||||
- **[bot.sannysoft.com](https://bot.sannysoft.com)** —— 经典自动化特征清单
|
||||
- **[pixelscan.net](https://pixelscan.net/)** · **[iphey.com](https://iphey.com/)** —— 一致性与身份
|
||||
|
||||
我们故意**不自带 bot 检测器** —— 最强、最诚实的基准,就是拿市面上最好的检测器去测你的真实浏览器。
|
||||
|
||||
### 调参(环境变量)
|
||||
|
||||
| 变量 | 默认 | 作用 |
|
||||
|---|---|---|
|
||||
| `AGENT_BROWSER_CAPTURE_CONSOLE` | 关 | 启用 `Runtime` 域,让 `console` / `errors` 捕获页面输出。关闭可保持最隐身的画像。 |
|
||||
| `AGENT_BROWSER_HUMANIZE` | 关 | 类人输入动作:`off`(瞬时)、`fast`(轻量缓动轨迹)、`human`(全套曲线轨迹 + 落点抖动 + 击键节奏 + 缓动滚动/拖拽)。也可用 `--humanize`。默认 `off`;自适应检测器会把 Akamai/PerimeterX/DataDome 守护的页面自动升到 `human`。 |
|
||||
| `AGENT_BROWSER_TIMEZONE` | 未设 | 仅 `--launch`。IANA id(如 `Asia/Tokyo`)原生设置时区(Intl + Date 跟随,无 JS 谎言)以匹配代理;`auto` 按 locale 推导。 |
|
||||
| `AGENT_BROWSER_BLOCK_WEBRTC` | auto | 仅 `--launch`。设了代理时自动强制 WebRTC 走代理(不泄漏真实 IP)。`1` 无代理时也隐藏本地 IP;`0` 退出。 |
|
||||
| `AGENT_BROWSER_HIDE_CANVAS` | 关 | 仅 `--launch`。加入会话稳定的 canvas/audio 指纹噪声。默认关(噪声本身就是一种「谎言」)。 |
|
||||
| `AGENT_BROWSER_ADAPTIVE_REF` | 开 | 当保存的 `@ref` 移动且 role/name 重查失败时,按指纹相似度重定位(需高分 + 明显领先,否则明确报错)。`0` 关闭。 |
|
||||
| `AGENT_BROWSER_CLICK_MODE` | _(auto)_ | 点击策略。默认先滚动入视、派发坐标点击,若被浮层遮挡则回退 DOM `.click()`。`dom` 始终用 `.click()`(适合 blur 即关的自动补全/菜单项);`coord` 严格只用坐标(遮挡时硬失败)。 |
|
||||
|
||||
## chrome-use 的独特之处
|
||||
|
||||
- **默认 auto-connect** —— `chrome-use open` 连你现有的 Chrome 而非启新的
|
||||
- **扩展中继传输** —— 一键安装的 Chrome 商店扩展 + 原生消息,无调试端口、无 "Allow remote debugging?" 弹框
|
||||
- **CDP 原生隐身** —— 反检测走 Chrome/CDP 覆盖而非 JS 补丁;连真实 Chrome 零补丁,仅 `--launch` 用全补丁
|
||||
- **Humanize** —— 类人光标轨迹 + 自适应反爬处理
|
||||
- **多 agent 隔离** —— 多个 agent 通过 per-session 标签组共享同一个真实 Chrome,互不串扰
|
||||
- **静默运行** —— 后台操作,绝不抢你的前台标签
|
||||
|
||||
<sub>最初基于 [vercel-labs/agent-browser](https://github.com/vercel-labs/agent-browser)(Apache-2.0);两个项目已大幅分化。</sub>
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
||||
---
|
||||
|
||||
> 由 **leeguooooo** 打造 —— AI agent、逆向工程与 Cloudflare Workers 的实战笔记见 **[blog.misonote.com](https://blog.misonote.com)** · 关注 **[X @leeguooooo](https://x.com/leeguooooo)**
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1023 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 888 KiB |
@@ -1,4 +0,0 @@
|
||||
# Vercel Sandbox credentials
|
||||
SANDBOX_VERCEL_TOKEN=
|
||||
SANDBOX_VERCEL_TEAM_ID=
|
||||
SANDBOX_VERCEL_PROJECT_ID=
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules/
|
||||
results.json
|
||||
@@ -1,76 +0,0 @@
|
||||
# agent-browser Daemon Benchmarks
|
||||
|
||||
Compares command latency and system metrics between the **Node.js daemon** (published npm version) and the **Rust native daemon** (built from source), running inside a [Vercel Sandbox](https://vercel.com/docs/sandbox) microVM.
|
||||
|
||||
## What it measures
|
||||
|
||||
**Command latency** -- per-scenario timing with warmup, multiple iterations, and stddev:
|
||||
|
||||
- `navigate` -- page load round-trip
|
||||
- `snapshot` -- accessibility tree generation
|
||||
- `screenshot` -- viewport capture
|
||||
- `evaluate` -- JavaScript execution
|
||||
- `click` -- element interaction
|
||||
- `fill` -- form input
|
||||
- `agent-loop` -- snapshot/click/snapshot cycle (typical AI agent pattern)
|
||||
- `full-workflow` -- realistic 7-command sequence
|
||||
|
||||
**System metrics** -- collected while the daemon is running:
|
||||
|
||||
- Cold start time (daemon spawn + browser launch)
|
||||
- Binary size and total distribution size (including browser download)
|
||||
- Daemon RSS and peak RSS (separated from browser process memory)
|
||||
- Browser RSS (Chrome processes, same for both daemons)
|
||||
- Daemon CPU time
|
||||
- Process counts
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- pnpm
|
||||
- Vercel Sandbox credentials (token, team ID, project ID)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd benchmarks
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Fill in your Vercel Sandbox credentials in `.env`:
|
||||
|
||||
```
|
||||
SANDBOX_VERCEL_TOKEN=your_token
|
||||
SANDBOX_VERCEL_TEAM_ID=your_team_id
|
||||
SANDBOX_VERCEL_PROJECT_ID=your_project_id
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
pnpm bench # 10 iterations, 1 warmup, 8 vCPUs
|
||||
pnpm bench -- --iterations 20 # more iterations for tighter stats
|
||||
pnpm bench -- --warmup 2 # extra warmup iterations
|
||||
pnpm bench -- --json # write results.json
|
||||
pnpm bench -- --branch main # build native from a different branch
|
||||
pnpm bench -- --vcpus 16 # more vCPUs (faster Rust build)
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. Creates a Vercel Sandbox (Amazon Linux, configurable vCPUs)
|
||||
2. Installs Chromium system dependencies
|
||||
3. **Phase 1 -- Node.js daemon**: installs `agent-browser` from npm (last version with the Node daemon), runs all scenarios, collects metrics
|
||||
4. **Phase 2 -- Rust native daemon**: installs Rust toolchain, clones the repo, runs `cargo build --release`, replaces the binary, runs the same scenarios, collects metrics
|
||||
5. Prints comparison tables and optionally writes `results.json`
|
||||
|
||||
## Interpreting results
|
||||
|
||||
**Command latency** is dominated by Chrome (CDP round-trips), not the daemon. Both daemons are thin relays between the CLI and Chrome, so per-command speedups are typically small. The stddev column helps distinguish real differences from noise.
|
||||
|
||||
**Where the native daemon wins** is in cold start (no Node.js runtime to boot), daemon memory (single Rust binary vs V8 heap), and distribution size (no Playwright dependency).
|
||||
|
||||
The **daemon RSS** metric isolates the daemon process memory from Chrome. This is the apples-to-apples comparison -- both daemons talk to the same Chrome, but Node.js adds ~140 MB of V8 overhead while the Rust daemon uses ~7 MB.
|
||||
|
||||
**Distribution size** includes the daemon plus its browser download. The Node version includes the npm package + Playwright's bundled Chromium. The Rust version is just the binary + Chrome for Testing.
|
||||
@@ -1,900 +0,0 @@
|
||||
/**
|
||||
* Node.js Daemon vs Rust Native Daemon benchmark.
|
||||
*
|
||||
* Compares the last published npm version (Node.js daemon) against the
|
||||
* Rust-only build from a given branch, running real agent-browser commands
|
||||
* inside a Vercel Sandbox.
|
||||
*
|
||||
* Captures:
|
||||
* - Command latency (per-scenario, with warmup + measured iterations + stddev)
|
||||
* - Cold start time (first launch to daemon ready)
|
||||
* - Daemon memory (RSS, peak RSS) separated from browser memory
|
||||
* - Daemon CPU time
|
||||
* - Process tree (daemon + browser children)
|
||||
* - Binary and distribution size on disk
|
||||
*
|
||||
* Usage:
|
||||
* pnpm bench # default: 10 iterations, 1 warmup
|
||||
* pnpm bench -- --iterations 20 # override iterations
|
||||
* pnpm bench -- --warmup 2 # override warmup count
|
||||
* pnpm bench -- --json # write results.json
|
||||
* pnpm bench -- --branch my-branch # override native branch (default: ctate/native-2)
|
||||
* pnpm bench -- --vcpus 8 # sandbox vCPUs (default: 8, higher = faster Rust build)
|
||||
*/
|
||||
|
||||
import { Sandbox } from "@vercel/sandbox";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { scenarios, type Scenario } from "./scenarios.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Env
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadEnv() {
|
||||
try {
|
||||
const content = readFileSync(".env", "utf-8");
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = trimmed.slice(0, eq);
|
||||
let val = trimmed.slice(eq + 1);
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
process.env[key] = val;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
loadEnv();
|
||||
|
||||
const credentials = {
|
||||
token: process.env.SANDBOX_VERCEL_TOKEN!,
|
||||
teamId: process.env.SANDBOX_VERCEL_TEAM_ID!,
|
||||
projectId: process.env.SANDBOX_VERCEL_PROJECT_ID!,
|
||||
};
|
||||
|
||||
if (!credentials.token || !credentials.teamId || !credentials.projectId) {
|
||||
console.error(
|
||||
"Missing credentials. Set SANDBOX_VERCEL_TOKEN, SANDBOX_VERCEL_TEAM_ID, SANDBOX_VERCEL_PROJECT_ID in .env",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI args
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let iterations = 10;
|
||||
let warmup = 1;
|
||||
let json = false;
|
||||
let branch = "ctate/native-2";
|
||||
let vcpus = 8;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--iterations" && args[i + 1]) {
|
||||
iterations = parseInt(args[++i], 10);
|
||||
} else if (args[i] === "--warmup" && args[i + 1]) {
|
||||
warmup = parseInt(args[++i], 10);
|
||||
} else if (args[i] === "--json") {
|
||||
json = true;
|
||||
} else if (args[i] === "--branch" && args[i + 1]) {
|
||||
branch = args[++i];
|
||||
} else if (args[i] === "--vcpus" && args[i + 1]) {
|
||||
vcpus = parseInt(args[++i], 10);
|
||||
}
|
||||
}
|
||||
|
||||
return { iterations, warmup, json, branch, vcpus };
|
||||
}
|
||||
|
||||
const config = parseArgs();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const REPO_URL = "https://github.com/vercel-labs/agent-browser.git";
|
||||
|
||||
const CHROMIUM_SYSTEM_DEPS = [
|
||||
"nss",
|
||||
"nspr",
|
||||
"libxkbcommon",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"at-spi2-core",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"libXfixes",
|
||||
"libXcursor",
|
||||
"libXi",
|
||||
"libXtst",
|
||||
"libXScrnSaver",
|
||||
"libXext",
|
||||
"mesa-libgbm",
|
||||
"libdrm",
|
||||
"mesa-libGL",
|
||||
"mesa-libEGL",
|
||||
"cups-libs",
|
||||
"alsa-lib",
|
||||
"pango",
|
||||
"cairo",
|
||||
"gtk3",
|
||||
"dbus-libs",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sandbox helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SandboxInstance = InstanceType<typeof Sandbox>;
|
||||
|
||||
async function run(
|
||||
sandbox: SandboxInstance,
|
||||
cmd: string,
|
||||
args: string[],
|
||||
): Promise<string> {
|
||||
const result = await sandbox.runCommand(cmd, args);
|
||||
const stdout = await result.stdout();
|
||||
const stderr = await result.stderr();
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Command failed (exit ${result.exitCode}): ${cmd} ${args.join(" ")}\n${stderr || stdout}`,
|
||||
);
|
||||
}
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function shell(sandbox: SandboxInstance, script: string): Promise<string> {
|
||||
return run(sandbox, "sh", ["-c", script]);
|
||||
}
|
||||
|
||||
async function shellSafe(sandbox: SandboxInstance, script: string): Promise<string> {
|
||||
const result = await sandbox.runCommand("sh", ["-c", script]);
|
||||
return (await result.stdout()).trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Stats {
|
||||
avgMs: number;
|
||||
stddevMs: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
p50Ms: number;
|
||||
samples: number[];
|
||||
}
|
||||
|
||||
function computeStats(samples: number[]): Stats {
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
const sum = sorted.reduce((a, b) => a + b, 0);
|
||||
const avg = sum / sorted.length;
|
||||
const variance =
|
||||
sorted.reduce((acc, v) => acc + (v - avg) ** 2, 0) / sorted.length;
|
||||
return {
|
||||
avgMs: Math.round(avg),
|
||||
stddevMs: Math.round(Math.sqrt(variance)),
|
||||
minMs: sorted[0],
|
||||
maxMs: sorted[sorted.length - 1],
|
||||
p50Ms: sorted[Math.floor(sorted.length / 2)],
|
||||
samples: sorted,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metrics collection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ProcessMetrics {
|
||||
pid: number;
|
||||
rssKb: number;
|
||||
vszKb: number;
|
||||
cpuPercent: number;
|
||||
memPercent: number;
|
||||
cpuTimeSec: number;
|
||||
command: string;
|
||||
}
|
||||
|
||||
interface DaemonMetrics {
|
||||
coldStartMs: number;
|
||||
binarySizeBytes: number;
|
||||
distributionSizeBytes: number;
|
||||
daemonProcesses: ProcessMetrics[];
|
||||
browserProcesses: ProcessMetrics[];
|
||||
daemonRssKb: number;
|
||||
browserRssKb: number;
|
||||
daemonPeakRssKb: number;
|
||||
daemonCpuTimeSec: number;
|
||||
totalCpuTimeSec: number;
|
||||
}
|
||||
|
||||
async function findDaemonPids(
|
||||
sandbox: SandboxInstance,
|
||||
_session: string,
|
||||
): Promise<number[]> {
|
||||
// The daemon process name is "agent-browser" but session/daemon flags are
|
||||
// env vars, not command-line args, so we can't grep them from `ps`.
|
||||
// Instead, find all agent-browser processes that look like long-running daemons
|
||||
// (not short-lived CLI invocations -- those exit immediately).
|
||||
const raw = await shellSafe(
|
||||
sandbox,
|
||||
`pgrep -x agent-browser 2>/dev/null || true`,
|
||||
);
|
||||
if (!raw) {
|
||||
// Fallback: broader match on process name
|
||||
const fallback = await shellSafe(
|
||||
sandbox,
|
||||
`pgrep -f 'agent-browser' 2>/dev/null | head -5 || true`,
|
||||
);
|
||||
if (!fallback) return [];
|
||||
return fallback.split("\n").map(Number).filter(Boolean);
|
||||
}
|
||||
return raw.split("\n").map(Number).filter(Boolean);
|
||||
}
|
||||
|
||||
async function collectProcessMetrics(
|
||||
sandbox: SandboxInstance,
|
||||
pid: number,
|
||||
): Promise<ProcessMetrics | null> {
|
||||
const raw = await shellSafe(
|
||||
sandbox,
|
||||
`ps -p ${pid} -o pid=,rss=,vsz=,%cpu=,%mem=,cputime=,comm= 2>/dev/null || true`,
|
||||
);
|
||||
if (!raw) return null;
|
||||
|
||||
const parts = raw.trim().split(/\s+/);
|
||||
if (parts.length < 7) return null;
|
||||
|
||||
// Parse cputime "HH:MM:SS" or "MM:SS" to seconds
|
||||
const timeParts = parts[5].split(":").map(Number);
|
||||
let cpuTimeSec = 0;
|
||||
if (timeParts.length === 3) {
|
||||
cpuTimeSec = timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2];
|
||||
} else if (timeParts.length === 2) {
|
||||
cpuTimeSec = timeParts[0] * 60 + timeParts[1];
|
||||
}
|
||||
|
||||
return {
|
||||
pid: Number(parts[0]),
|
||||
rssKb: Number(parts[1]),
|
||||
vszKb: Number(parts[2]),
|
||||
cpuPercent: Number(parts[3]),
|
||||
memPercent: Number(parts[4]),
|
||||
cpuTimeSec,
|
||||
command: parts.slice(6).join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
async function getPeakRssKb(
|
||||
sandbox: SandboxInstance,
|
||||
pid: number,
|
||||
): Promise<number> {
|
||||
const raw = await shellSafe(
|
||||
sandbox,
|
||||
`cat /proc/${pid}/status 2>/dev/null | grep VmHWM | awk '{print $2}' || echo 0`,
|
||||
);
|
||||
return Number(raw) || 0;
|
||||
}
|
||||
|
||||
async function getChildPids(
|
||||
sandbox: SandboxInstance,
|
||||
pid: number,
|
||||
): Promise<number[]> {
|
||||
const raw = await shellSafe(
|
||||
sandbox,
|
||||
`pgrep -P ${pid} 2>/dev/null || true`,
|
||||
);
|
||||
if (!raw) return [];
|
||||
return raw.split("\n").map(Number).filter(Boolean);
|
||||
}
|
||||
|
||||
async function getAllDescendantPids(
|
||||
sandbox: SandboxInstance,
|
||||
pid: number,
|
||||
): Promise<number[]> {
|
||||
const all: number[] = [];
|
||||
const queue = [pid];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
all.push(current);
|
||||
const children = await getChildPids(sandbox, current);
|
||||
queue.push(...children);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async function collectDaemonMetrics(
|
||||
sandbox: SandboxInstance,
|
||||
session: string,
|
||||
coldStartMs: number,
|
||||
binarySizeBytes: number,
|
||||
distributionSizeBytes: number,
|
||||
): Promise<DaemonMetrics> {
|
||||
// Find daemon PIDs -- the agent-browser process itself
|
||||
const daemonPids = await findDaemonPids(sandbox, session);
|
||||
|
||||
// Also find the full process tree (daemon + Chrome children)
|
||||
let allPids: number[] = [];
|
||||
for (const pid of daemonPids) {
|
||||
const descendants = await getAllDescendantPids(sandbox, pid);
|
||||
allPids.push(...descendants);
|
||||
}
|
||||
allPids = [...new Set(allPids)];
|
||||
|
||||
// If no daemon PIDs found via pgrep, fall back to grabbing all
|
||||
// agent-browser and chrome processes for metrics
|
||||
if (allPids.length === 0) {
|
||||
const fallback = await shellSafe(
|
||||
sandbox,
|
||||
`ps -eo pid,comm | grep -E 'agent-browser|chrome' | grep -v grep | awk '{print $1}' || true`,
|
||||
);
|
||||
if (fallback) {
|
||||
allPids = fallback.split("\n").map(Number).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
const daemonProcs: ProcessMetrics[] = [];
|
||||
const browserProcs: ProcessMetrics[] = [];
|
||||
let daemonPeakRssKb = 0;
|
||||
|
||||
for (const pid of allPids) {
|
||||
const metrics = await collectProcessMetrics(sandbox, pid);
|
||||
if (!metrics) continue;
|
||||
|
||||
const isBrowser = /chrome|chromium/i.test(metrics.command);
|
||||
if (isBrowser) {
|
||||
browserProcs.push(metrics);
|
||||
} else {
|
||||
daemonProcs.push(metrics);
|
||||
const peak = await getPeakRssKb(sandbox, pid);
|
||||
daemonPeakRssKb = Math.max(daemonPeakRssKb, peak);
|
||||
}
|
||||
}
|
||||
|
||||
const daemonRssKb = daemonProcs.reduce((sum, p) => sum + p.rssKb, 0);
|
||||
const browserRssKb = browserProcs.reduce((sum, p) => sum + p.rssKb, 0);
|
||||
const daemonCpuTimeSec = daemonProcs.reduce((sum, p) => sum + p.cpuTimeSec, 0);
|
||||
const allProcs = [...daemonProcs, ...browserProcs];
|
||||
const totalCpuTimeSec = allProcs.reduce((sum, p) => sum + p.cpuTimeSec, 0);
|
||||
|
||||
return {
|
||||
coldStartMs,
|
||||
binarySizeBytes,
|
||||
distributionSizeBytes,
|
||||
daemonProcesses: daemonProcs,
|
||||
browserProcesses: browserProcs,
|
||||
daemonRssKb,
|
||||
browserRssKb,
|
||||
daemonPeakRssKb,
|
||||
daemonCpuTimeSec,
|
||||
totalCpuTimeSec,
|
||||
};
|
||||
}
|
||||
|
||||
async function getBinarySize(
|
||||
sandbox: SandboxInstance,
|
||||
): Promise<number> {
|
||||
// Follow symlinks to get the real binary/script size
|
||||
const raw = await shellSafe(
|
||||
sandbox,
|
||||
`stat -L -c %s "$(readlink -f "$(which agent-browser)")" 2>/dev/null || echo 0`,
|
||||
);
|
||||
return Number(raw) || 0;
|
||||
}
|
||||
|
||||
async function getDistributionSize(
|
||||
sandbox: SandboxInstance,
|
||||
mode: DaemonMode,
|
||||
): Promise<number> {
|
||||
if (mode === "node") {
|
||||
// Total size of the npm package + Playwright browser
|
||||
const npmPkg = await shellSafe(
|
||||
sandbox,
|
||||
`du -sb "$(npm root -g)/agent-browser" 2>/dev/null | awk '{print $1}' || echo 0`,
|
||||
);
|
||||
const pwBrowser = await shellSafe(
|
||||
sandbox,
|
||||
`du -sb "$HOME/.cache/ms-playwright" 2>/dev/null | awk '{print $1}' || echo 0`,
|
||||
);
|
||||
return (Number(npmPkg) || 0) + (Number(pwBrowser) || 0);
|
||||
} else {
|
||||
// Rust binary + Chrome for Testing (checks multiple possible cache paths)
|
||||
const binary = await shellSafe(
|
||||
sandbox,
|
||||
`stat -L -c %s "$(readlink -f "$(which agent-browser)")" 2>/dev/null || echo 0`,
|
||||
);
|
||||
const chrome = await shellSafe(
|
||||
sandbox,
|
||||
[
|
||||
`size=0`,
|
||||
`for d in "$HOME/.cache/agent-browser" "$HOME/.cache/ms-playwright" "$HOME/.agent-browser/chrome"; do`,
|
||||
` if [ -d "$d" ]; then size=$(du -sb "$d" 2>/dev/null | awk '{print $1}'); break; fi`,
|
||||
`done`,
|
||||
`echo $size`,
|
||||
].join("; "),
|
||||
);
|
||||
return (Number(binary) || 0) + (Number(chrome) || 0);
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function formatKb(kb: number): string {
|
||||
if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`;
|
||||
return `${kb} KB`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario runner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DaemonMode = "node" | "native";
|
||||
|
||||
function daemonEnv(mode: DaemonMode): Record<string, string> {
|
||||
return { AGENT_BROWSER_SESSION: `bench-${mode}` };
|
||||
}
|
||||
|
||||
async function agentBrowser(
|
||||
sandbox: SandboxInstance,
|
||||
args: string[],
|
||||
mode: DaemonMode,
|
||||
): Promise<void> {
|
||||
const result = await sandbox.runCommand({
|
||||
cmd: "agent-browser",
|
||||
args,
|
||||
env: daemonEnv(mode),
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
const stderr = await result.stderr();
|
||||
const stdout = await result.stdout();
|
||||
throw new Error(
|
||||
`agent-browser ${args.join(" ")} failed (exit ${result.exitCode}): ${stderr || stdout}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function timedAgentBrowser(
|
||||
sandbox: SandboxInstance,
|
||||
args: string[],
|
||||
mode: DaemonMode,
|
||||
): Promise<number> {
|
||||
const start = Date.now();
|
||||
const result = await sandbox.runCommand({
|
||||
cmd: "agent-browser",
|
||||
args,
|
||||
env: daemonEnv(mode),
|
||||
});
|
||||
const elapsed = Date.now() - start;
|
||||
if (result.exitCode !== 0) {
|
||||
const stderr = await result.stderr();
|
||||
const stdout = await result.stdout();
|
||||
throw new Error(
|
||||
`agent-browser ${args.join(" ")} failed (exit ${result.exitCode}): ${stderr || stdout}`,
|
||||
);
|
||||
}
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
interface ScenarioResult {
|
||||
name: string;
|
||||
description: string;
|
||||
stats: Stats;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function runScenario(
|
||||
sandbox: SandboxInstance,
|
||||
scenario: Scenario,
|
||||
mode: DaemonMode,
|
||||
iterations: number,
|
||||
warmup: number,
|
||||
): Promise<ScenarioResult> {
|
||||
try {
|
||||
if (scenario.setup) {
|
||||
for (const cmd of scenario.setup) {
|
||||
await agentBrowser(sandbox, cmd, mode);
|
||||
}
|
||||
}
|
||||
|
||||
for (let w = 0; w < warmup; w++) {
|
||||
for (const cmd of scenario.commands) {
|
||||
await agentBrowser(sandbox, cmd, mode);
|
||||
}
|
||||
}
|
||||
|
||||
const samples: number[] = [];
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
let totalMs = 0;
|
||||
for (const cmd of scenario.commands) {
|
||||
totalMs += await timedAgentBrowser(sandbox, cmd, mode);
|
||||
}
|
||||
samples.push(totalMs);
|
||||
}
|
||||
|
||||
if (scenario.teardown) {
|
||||
for (const cmd of scenario.teardown) {
|
||||
await agentBrowser(sandbox, cmd, mode);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: scenario.name,
|
||||
description: scenario.description,
|
||||
stats: computeStats(samples),
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
name: scenario.name,
|
||||
description: scenario.description,
|
||||
stats: { avgMs: -1, stddevMs: -1, minMs: -1, maxMs: -1, p50Ms: -1, samples: [] },
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark phases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DaemonResults {
|
||||
mode: DaemonMode;
|
||||
label: string;
|
||||
scenarios: ScenarioResult[];
|
||||
metrics: DaemonMetrics;
|
||||
}
|
||||
|
||||
async function benchmarkDaemon(
|
||||
sandbox: SandboxInstance,
|
||||
mode: DaemonMode,
|
||||
label: string,
|
||||
): Promise<DaemonResults> {
|
||||
console.log(`\n--- ${label} ---`);
|
||||
|
||||
// Measure sizes before launch
|
||||
const binarySizeBytes = await getBinarySize(sandbox);
|
||||
const distributionSizeBytes = await getDistributionSize(sandbox, mode);
|
||||
|
||||
// Cold start: time the first launch (daemon spawn + browser launch)
|
||||
const coldStartBegin = Date.now();
|
||||
await agentBrowser(sandbox, ["open", "about:blank"], mode);
|
||||
const coldStartMs = Date.now() - coldStartBegin;
|
||||
console.log(` Cold start: ${coldStartMs}ms`);
|
||||
console.log(` Binary size: ${formatBytes(binarySizeBytes)}`);
|
||||
console.log(` Distribution size: ${formatBytes(distributionSizeBytes)}`);
|
||||
|
||||
// Run all scenarios
|
||||
const results: ScenarioResult[] = [];
|
||||
for (const scenario of scenarios) {
|
||||
process.stdout.write(` ${scenario.name} `);
|
||||
const result = await runScenario(
|
||||
sandbox,
|
||||
scenario,
|
||||
mode,
|
||||
config.iterations,
|
||||
config.warmup,
|
||||
);
|
||||
if (result.error) {
|
||||
console.log(`FAILED: ${result.error.slice(0, 120)}`);
|
||||
} else {
|
||||
const dots = ".".repeat(Math.max(1, 30 - scenario.name.length));
|
||||
const s = result.stats;
|
||||
console.log(
|
||||
`${dots} ${s.avgMs}ms avg +/-${s.stddevMs}ms (p50: ${s.p50Ms}ms, min: ${s.minMs}ms, max: ${s.maxMs}ms)`,
|
||||
);
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Collect system metrics after scenarios (daemon is still running)
|
||||
const session = `bench-${mode}`;
|
||||
const metrics = await collectDaemonMetrics(
|
||||
sandbox,
|
||||
session,
|
||||
coldStartMs,
|
||||
binarySizeBytes,
|
||||
distributionSizeBytes,
|
||||
);
|
||||
|
||||
// Also grab a full process snapshot for context
|
||||
const psOutput = await shellSafe(
|
||||
sandbox,
|
||||
`ps aux --sort=-rss | head -20`,
|
||||
);
|
||||
console.log(`\n Process snapshot (top by RSS):`);
|
||||
for (const line of psOutput.split("\n").slice(0, 10)) {
|
||||
console.log(` ${line}`);
|
||||
}
|
||||
|
||||
console.log(`\n Daemon processes (${metrics.daemonProcesses.length}):`);
|
||||
console.log(` RSS: ${formatKb(metrics.daemonRssKb)} (peak: ${formatKb(metrics.daemonPeakRssKb)})`);
|
||||
console.log(` CPU time: ${metrics.daemonCpuTimeSec.toFixed(1)}s`);
|
||||
for (const p of metrics.daemonProcesses) {
|
||||
console.log(` PID ${p.pid}: ${p.command} (RSS: ${formatKb(p.rssKb)}, CPU: ${p.cpuPercent}%)`);
|
||||
}
|
||||
console.log(` Browser processes (${metrics.browserProcesses.length}):`);
|
||||
console.log(` RSS: ${formatKb(metrics.browserRssKb)}`);
|
||||
for (const p of metrics.browserProcesses) {
|
||||
console.log(` PID ${p.pid}: ${p.command} (RSS: ${formatKb(p.rssKb)}, CPU: ${p.cpuPercent}%)`);
|
||||
}
|
||||
|
||||
await agentBrowser(sandbox, ["close"], mode);
|
||||
console.log(` Browser closed.`);
|
||||
|
||||
return { mode, label, scenarios: results, metrics };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Install helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function installChromiumDeps(sandbox: SandboxInstance) {
|
||||
console.log("Installing Chromium system dependencies...");
|
||||
await shell(
|
||||
sandbox,
|
||||
`sudo dnf clean all 2>&1 && sudo dnf install -y --skip-broken ${CHROMIUM_SYSTEM_DEPS.join(" ")} 2>&1 && sudo ldconfig 2>&1`,
|
||||
);
|
||||
}
|
||||
|
||||
async function installNodeDaemon(sandbox: SandboxInstance) {
|
||||
console.log("Installing agent-browser from npm (Node.js daemon)...");
|
||||
await run(sandbox, "npm", ["install", "-g", "agent-browser"]);
|
||||
await run(sandbox, "npx", ["agent-browser", "install"]);
|
||||
const version = await shell(sandbox, "agent-browser --version 2>&1 || true");
|
||||
console.log(` version: ${version.trim()}`);
|
||||
}
|
||||
|
||||
async function installNativeDaemon(sandbox: SandboxInstance, branch: string) {
|
||||
console.log(`\nBuilding native daemon from ${branch}...`);
|
||||
|
||||
console.log(" Installing build tools and Rust toolchain...");
|
||||
const rustStart = Date.now();
|
||||
await shell(
|
||||
sandbox,
|
||||
"sudo dnf install -y gcc gcc-c++ make perl-core openssl-devel 2>&1",
|
||||
);
|
||||
await shell(
|
||||
sandbox,
|
||||
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 2>&1",
|
||||
);
|
||||
console.log(` Rust + build tools installed (${Math.round((Date.now() - rustStart) / 1000)}s)`);
|
||||
|
||||
console.log(` Cloning repo (branch: ${branch})...`);
|
||||
const cloneStart = Date.now();
|
||||
await shell(
|
||||
sandbox,
|
||||
`git clone --depth 1 --branch ${branch} ${REPO_URL} /tmp/agent-browser 2>&1`,
|
||||
);
|
||||
console.log(` Cloned (${Math.round((Date.now() - cloneStart) / 1000)}s)`);
|
||||
|
||||
console.log(" Building release binary (cargo build --release)...");
|
||||
const buildStart = Date.now();
|
||||
await shell(
|
||||
sandbox,
|
||||
"source $HOME/.cargo/env && cd /tmp/agent-browser/cli && cargo build --release 2>&1",
|
||||
);
|
||||
console.log(` Built (${Math.round((Date.now() - buildStart) / 1000)}s)`);
|
||||
|
||||
const npmBinPath = (await shell(sandbox, "which agent-browser")).trim();
|
||||
console.log(` Replacing ${npmBinPath} with native build...`);
|
||||
await shell(
|
||||
sandbox,
|
||||
`sudo cp /tmp/agent-browser/cli/target/release/agent-browser ${npmBinPath}`,
|
||||
);
|
||||
|
||||
const version = await shell(sandbox, "agent-browser --version 2>&1 || true");
|
||||
console.log(` version: ${version.trim()}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function printResults(node: DaemonResults, native: DaemonResults) {
|
||||
console.log("\n\n========== COMMAND LATENCY ==========\n");
|
||||
|
||||
const header =
|
||||
"Scenario".padEnd(20) + "| Node avg +/-sd | Rust avg +/-sd | Speedup";
|
||||
const sep = "-".repeat(20) + "|-----------------|-----------------|--------";
|
||||
console.log(header);
|
||||
console.log(sep);
|
||||
|
||||
for (let i = 0; i < node.scenarios.length; i++) {
|
||||
const n = node.scenarios[i];
|
||||
const r = native.scenarios[i];
|
||||
const name = n.name.padEnd(20);
|
||||
|
||||
if (n.error || r.error) {
|
||||
const nodeVal = n.error ? "FAILED".padEnd(15) : `${n.stats.avgMs}ms`.padEnd(15);
|
||||
const rustVal = r.error ? "FAILED".padEnd(15) : `${r.stats.avgMs}ms`.padEnd(15);
|
||||
console.log(`${name}| ${nodeVal} | ${rustVal} | --`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nodeVal = `${n.stats.avgMs} +/-${n.stats.stddevMs}ms`.padEnd(15);
|
||||
const rustVal = `${r.stats.avgMs} +/-${r.stats.stddevMs}ms`.padEnd(15);
|
||||
const speedup =
|
||||
r.stats.avgMs > 0
|
||||
? (n.stats.avgMs / r.stats.avgMs).toFixed(2) + "x"
|
||||
: "--";
|
||||
console.log(`${name}| ${nodeVal} | ${rustVal} | ${speedup.padStart(6)}`);
|
||||
}
|
||||
|
||||
console.log("\n\n========== SYSTEM METRICS ==========\n");
|
||||
|
||||
const nm = node.metrics;
|
||||
const rm = native.metrics;
|
||||
|
||||
function ratio(a: number, b: number): string {
|
||||
if (b <= 0) return "--";
|
||||
return (a / b).toFixed(2) + "x";
|
||||
}
|
||||
|
||||
const metricRows: [string, string, string, string][] = [
|
||||
[
|
||||
"Cold start",
|
||||
`${nm.coldStartMs}ms`,
|
||||
`${rm.coldStartMs}ms`,
|
||||
ratio(nm.coldStartMs, rm.coldStartMs),
|
||||
],
|
||||
[
|
||||
"Binary size",
|
||||
formatBytes(nm.binarySizeBytes),
|
||||
formatBytes(rm.binarySizeBytes),
|
||||
ratio(nm.binarySizeBytes, rm.binarySizeBytes),
|
||||
],
|
||||
[
|
||||
"Distribution size",
|
||||
formatBytes(nm.distributionSizeBytes),
|
||||
formatBytes(rm.distributionSizeBytes),
|
||||
ratio(nm.distributionSizeBytes, rm.distributionSizeBytes),
|
||||
],
|
||||
[
|
||||
"Daemon RSS",
|
||||
formatKb(nm.daemonRssKb),
|
||||
formatKb(rm.daemonRssKb),
|
||||
ratio(nm.daemonRssKb, rm.daemonRssKb),
|
||||
],
|
||||
[
|
||||
"Daemon peak RSS",
|
||||
formatKb(nm.daemonPeakRssKb),
|
||||
formatKb(rm.daemonPeakRssKb),
|
||||
ratio(nm.daemonPeakRssKb, rm.daemonPeakRssKb),
|
||||
],
|
||||
[
|
||||
"Browser RSS",
|
||||
formatKb(nm.browserRssKb),
|
||||
formatKb(rm.browserRssKb),
|
||||
ratio(nm.browserRssKb, rm.browserRssKb),
|
||||
],
|
||||
[
|
||||
"Daemon CPU time",
|
||||
`${nm.daemonCpuTimeSec.toFixed(1)}s`,
|
||||
`${rm.daemonCpuTimeSec.toFixed(1)}s`,
|
||||
ratio(nm.daemonCpuTimeSec, rm.daemonCpuTimeSec),
|
||||
],
|
||||
[
|
||||
"Daemon processes",
|
||||
String(nm.daemonProcesses.length),
|
||||
String(rm.daemonProcesses.length),
|
||||
"--",
|
||||
],
|
||||
[
|
||||
"Browser processes",
|
||||
String(nm.browserProcesses.length),
|
||||
String(rm.browserProcesses.length),
|
||||
"--",
|
||||
],
|
||||
];
|
||||
|
||||
const mHeader =
|
||||
"Metric".padEnd(20) + "| Node".padEnd(14) + "| Rust".padEnd(14) + "| Ratio";
|
||||
const mSep = "-".repeat(20) + "|" + "-".repeat(13) + "|" + "-".repeat(13) + "|--------";
|
||||
console.log(mHeader);
|
||||
console.log(mSep);
|
||||
for (const [metric, nodeVal, rustVal, ratio] of metricRows) {
|
||||
console.log(
|
||||
`${metric.padEnd(20)}| ${nodeVal.padEnd(12)}| ${rustVal.padEnd(12)}| ${ratio}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("agent-browser Daemon Benchmark (Node.js vs Rust Native)");
|
||||
console.log(`Branch: ${config.branch}`);
|
||||
console.log(`Iterations: ${config.iterations} (+ ${config.warmup} warmup)`);
|
||||
console.log(`vCPUs: ${config.vcpus}\n`);
|
||||
|
||||
console.log("Creating sandbox...");
|
||||
const sandbox = await Sandbox.create({
|
||||
...credentials,
|
||||
timeout: TIMEOUT_MS,
|
||||
runtime: "node22",
|
||||
networkPolicy: "allow-all" as const,
|
||||
resources: { vcpus: config.vcpus },
|
||||
});
|
||||
console.log(`Sandbox: ${sandbox.sandboxId}`);
|
||||
|
||||
try {
|
||||
await installChromiumDeps(sandbox);
|
||||
|
||||
// Phase 1: Node.js daemon (from published npm package)
|
||||
await installNodeDaemon(sandbox);
|
||||
const nodeResults = await benchmarkDaemon(
|
||||
sandbox,
|
||||
"node",
|
||||
"Node.js Daemon (npm)",
|
||||
);
|
||||
|
||||
// Phase 2: Rust native daemon (built from branch)
|
||||
await installNativeDaemon(sandbox, config.branch);
|
||||
const nativeResults = await benchmarkDaemon(
|
||||
sandbox,
|
||||
"native",
|
||||
`Rust Native Daemon (${config.branch})`,
|
||||
);
|
||||
|
||||
printResults(nodeResults, nativeResults);
|
||||
|
||||
if (config.json) {
|
||||
const output = {
|
||||
timestamp: new Date().toISOString(),
|
||||
branch: config.branch,
|
||||
vcpus: config.vcpus,
|
||||
iterations: config.iterations,
|
||||
warmup: config.warmup,
|
||||
node: {
|
||||
scenarios: nodeResults.scenarios.map((s) => ({
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
...s.stats,
|
||||
error: s.error,
|
||||
})),
|
||||
metrics: nodeResults.metrics,
|
||||
},
|
||||
native: {
|
||||
scenarios: nativeResults.scenarios.map((s) => ({
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
...s.stats,
|
||||
error: s.error,
|
||||
})),
|
||||
metrics: nativeResults.metrics,
|
||||
},
|
||||
};
|
||||
writeFileSync("results.json", JSON.stringify(output, null, 2));
|
||||
console.log("\nResults written to results.json");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`\nFatal error: ${message}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
try {
|
||||
await sandbox.stop();
|
||||
console.log("\nSandbox stopped.");
|
||||
} catch {
|
||||
console.warn("Warning: failed to stop sandbox.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "agent-browser-benchmarks",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"bench": "tsx bench.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vercel/sandbox": "^1.8.0",
|
||||
"tsx": "^4.19.0"
|
||||
}
|
||||
}
|
||||
Generated
-472
@@ -1,472 +0,0 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@vercel/sandbox':
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.1
|
||||
tsx:
|
||||
specifier: ^4.19.0
|
||||
version: 4.21.0
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.4':
|
||||
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.27.4':
|
||||
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.27.4':
|
||||
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.27.4':
|
||||
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.4':
|
||||
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.27.4':
|
||||
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.27.4':
|
||||
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.27.4':
|
||||
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.4':
|
||||
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.4':
|
||||
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.4':
|
||||
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.27.4':
|
||||
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.27.4':
|
||||
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.4':
|
||||
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.4':
|
||||
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.27.4':
|
||||
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.27.4':
|
||||
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.27.4':
|
||||
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@vercel/oidc@3.2.0':
|
||||
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@vercel/sandbox@1.8.1':
|
||||
resolution: {integrity: sha512-txohjI20aMxZiAzBL/KJi5EqTYsesBdOyIOtpTIyebPLTqYtDYfNhQ4OeYiUcPMUo0XBt8gSet/rIdLQEjj3/A==}
|
||||
|
||||
async-retry@1.3.3:
|
||||
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
|
||||
|
||||
b4a@1.8.0:
|
||||
resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==}
|
||||
peerDependencies:
|
||||
react-native-b4a: '*'
|
||||
peerDependenciesMeta:
|
||||
react-native-b4a:
|
||||
optional: true
|
||||
|
||||
bare-events@2.8.2:
|
||||
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
|
||||
peerDependencies:
|
||||
bare-abort-controller: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-abort-controller:
|
||||
optional: true
|
||||
|
||||
esbuild@0.27.4:
|
||||
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
events-universal@1.0.1:
|
||||
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
|
||||
|
||||
fast-fifo@1.3.2:
|
||||
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
get-tsconfig@4.13.6:
|
||||
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
|
||||
|
||||
jsonlines@0.1.1:
|
||||
resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
os-paths@4.4.0:
|
||||
resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==}
|
||||
engines: {node: '>= 6.0'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
retry@0.13.1:
|
||||
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
streamx@2.23.0:
|
||||
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
|
||||
|
||||
tar-stream@3.1.7:
|
||||
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
|
||||
|
||||
text-decoder@1.2.7:
|
||||
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
|
||||
|
||||
tsx@4.21.0:
|
||||
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
undici@7.24.1:
|
||||
resolution: {integrity: sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
xdg-app-paths@5.1.0:
|
||||
resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
xdg-portable@7.3.0:
|
||||
resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==}
|
||||
engines: {node: '>= 6.0'}
|
||||
|
||||
zod@3.24.4:
|
||||
resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@vercel/oidc@3.2.0': {}
|
||||
|
||||
'@vercel/sandbox@1.8.1':
|
||||
dependencies:
|
||||
'@vercel/oidc': 3.2.0
|
||||
async-retry: 1.3.3
|
||||
jsonlines: 0.1.1
|
||||
ms: 2.1.3
|
||||
picocolors: 1.1.1
|
||||
tar-stream: 3.1.7
|
||||
undici: 7.24.1
|
||||
xdg-app-paths: 5.1.0
|
||||
zod: 3.24.4
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
async-retry@1.3.3:
|
||||
dependencies:
|
||||
retry: 0.13.1
|
||||
|
||||
b4a@1.8.0: {}
|
||||
|
||||
bare-events@2.8.2: {}
|
||||
|
||||
esbuild@0.27.4:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.4
|
||||
'@esbuild/android-arm': 0.27.4
|
||||
'@esbuild/android-arm64': 0.27.4
|
||||
'@esbuild/android-x64': 0.27.4
|
||||
'@esbuild/darwin-arm64': 0.27.4
|
||||
'@esbuild/darwin-x64': 0.27.4
|
||||
'@esbuild/freebsd-arm64': 0.27.4
|
||||
'@esbuild/freebsd-x64': 0.27.4
|
||||
'@esbuild/linux-arm': 0.27.4
|
||||
'@esbuild/linux-arm64': 0.27.4
|
||||
'@esbuild/linux-ia32': 0.27.4
|
||||
'@esbuild/linux-loong64': 0.27.4
|
||||
'@esbuild/linux-mips64el': 0.27.4
|
||||
'@esbuild/linux-ppc64': 0.27.4
|
||||
'@esbuild/linux-riscv64': 0.27.4
|
||||
'@esbuild/linux-s390x': 0.27.4
|
||||
'@esbuild/linux-x64': 0.27.4
|
||||
'@esbuild/netbsd-arm64': 0.27.4
|
||||
'@esbuild/netbsd-x64': 0.27.4
|
||||
'@esbuild/openbsd-arm64': 0.27.4
|
||||
'@esbuild/openbsd-x64': 0.27.4
|
||||
'@esbuild/openharmony-arm64': 0.27.4
|
||||
'@esbuild/sunos-x64': 0.27.4
|
||||
'@esbuild/win32-arm64': 0.27.4
|
||||
'@esbuild/win32-ia32': 0.27.4
|
||||
'@esbuild/win32-x64': 0.27.4
|
||||
|
||||
events-universal@1.0.1:
|
||||
dependencies:
|
||||
bare-events: 2.8.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
get-tsconfig@4.13.6:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
jsonlines@0.1.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
os-paths@4.4.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
retry@0.13.1: {}
|
||||
|
||||
streamx@2.23.0:
|
||||
dependencies:
|
||||
events-universal: 1.0.1
|
||||
fast-fifo: 1.3.2
|
||||
text-decoder: 1.2.7
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
tar-stream@3.1.7:
|
||||
dependencies:
|
||||
b4a: 1.8.0
|
||||
fast-fifo: 1.3.2
|
||||
streamx: 2.23.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
text-decoder@1.2.7:
|
||||
dependencies:
|
||||
b4a: 1.8.0
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
tsx@4.21.0:
|
||||
dependencies:
|
||||
esbuild: 0.27.4
|
||||
get-tsconfig: 4.13.6
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
undici@7.24.1: {}
|
||||
|
||||
xdg-app-paths@5.1.0:
|
||||
dependencies:
|
||||
xdg-portable: 7.3.0
|
||||
|
||||
xdg-portable@7.3.0:
|
||||
dependencies:
|
||||
os-paths: 4.4.0
|
||||
|
||||
zod@3.24.4: {}
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Benchmark scenarios for comparing Node.js daemon vs Rust native daemon.
|
||||
*
|
||||
* Each scenario defines CLI commands run via `sandbox.runCommand("agent-browser", args)`.
|
||||
* Setup/teardown commands run once and are not timed.
|
||||
* The `commands` array is timed over N iterations.
|
||||
*/
|
||||
|
||||
export interface Scenario {
|
||||
name: string;
|
||||
description: string;
|
||||
setup?: string[][];
|
||||
commands: string[][];
|
||||
teardown?: string[][];
|
||||
}
|
||||
|
||||
const FORM_HTML = [
|
||||
"<html><head><title>Bench</title></head><body>",
|
||||
"<h1>Benchmark Page</h1>",
|
||||
"<input id='name' type='text' placeholder='Name'>",
|
||||
"<input id='email' type='email' placeholder='Email'>",
|
||||
"<select id='color'><option value='red'>Red</option><option value='blue'>Blue</option></select>",
|
||||
"<input id='agree' type='checkbox'>",
|
||||
"<textarea id='bio' placeholder='Bio'></textarea>",
|
||||
"<button id='submit'>Submit</button>",
|
||||
"<p id='status'>Ready</p>",
|
||||
"<a id='link' href='javascript:void(0)' onclick=\"document.getElementById('status').textContent='Clicked'\">Click me</a>",
|
||||
"<ul>",
|
||||
...Array.from({ length: 20 }, (_, i) => `<li class='item'>Item ${i + 1}</li>`),
|
||||
"</ul>",
|
||||
"</body></html>",
|
||||
].join("");
|
||||
|
||||
const INJECT_FORM_SCRIPT = `document.open(); document.write(${JSON.stringify(FORM_HTML)}); document.close(); 'ok'`;
|
||||
|
||||
const SETUP_PAGE: string[][] = [
|
||||
["open", "about:blank"],
|
||||
["eval", INJECT_FORM_SCRIPT],
|
||||
];
|
||||
|
||||
export const scenarios: Scenario[] = [
|
||||
{
|
||||
name: "navigate",
|
||||
description: "Page navigation (about:blank round-trip)",
|
||||
commands: [["open", "about:blank"]],
|
||||
},
|
||||
{
|
||||
name: "snapshot",
|
||||
description: "DOM snapshot (accessibility tree)",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [["snapshot"]],
|
||||
},
|
||||
{
|
||||
name: "screenshot",
|
||||
description: "Screenshot capture",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [["screenshot"]],
|
||||
},
|
||||
{
|
||||
name: "evaluate",
|
||||
description: "JavaScript evaluation",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [
|
||||
[
|
||||
"eval",
|
||||
"document.title + ' ' + document.querySelectorAll('li').length",
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "click",
|
||||
description: "Element click interaction",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [["click", "#link"]],
|
||||
},
|
||||
{
|
||||
name: "fill",
|
||||
description: "Form field fill",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [["fill", "#name", "Benchmark User"]],
|
||||
},
|
||||
{
|
||||
name: "agent-loop",
|
||||
description: "AI agent loop: snapshot -> click -> snapshot (typical agent cycle)",
|
||||
setup: SETUP_PAGE,
|
||||
commands: [["snapshot"], ["click", "#link"], ["snapshot"]],
|
||||
},
|
||||
{
|
||||
name: "full-workflow",
|
||||
description:
|
||||
"Realistic workflow: navigate, inject form, snapshot, click, fill, evaluate, screenshot",
|
||||
commands: [
|
||||
["open", "about:blank"],
|
||||
["eval", INJECT_FORM_SCRIPT],
|
||||
["snapshot"],
|
||||
["click", "#link"],
|
||||
["fill", "#name", "Agent User"],
|
||||
[
|
||||
"eval",
|
||||
"document.getElementById('name').value",
|
||||
],
|
||||
["screenshot"],
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Cross-platform CLI wrapper for agent-browser
|
||||
* Cross-platform CLI wrapper for chrome-use
|
||||
*
|
||||
* This wrapper enables npx support on Windows where shell scripts don't work.
|
||||
* For global installs, postinstall.js patches the shims to invoke the native
|
||||
@@ -62,7 +62,7 @@ function getBinaryName() {
|
||||
}
|
||||
|
||||
const ext = os === 'win32' ? '.exe' : '';
|
||||
return `agent-browser-${osKey}-${archKey}${ext}`;
|
||||
return `chrome-use-${osKey}-${archKey}${ext}`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
Generated
+250
-31
@@ -43,37 +43,6 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-browser"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"futures-util",
|
||||
"getrandom 0.2.17",
|
||||
"hex",
|
||||
"hmac",
|
||||
"image",
|
||||
"libc",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"similar",
|
||||
"socket2",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"windows-sys 0.52.0",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aligned"
|
||||
version = "0.4.3"
|
||||
@@ -241,6 +210,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "built"
|
||||
version = "0.8.0"
|
||||
@@ -277,6 +255,15 @@ version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.56"
|
||||
@@ -301,6 +288,46 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrome-use"
|
||||
version = "1.5.26"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"futures-util",
|
||||
"getrandom 0.2.17",
|
||||
"hex",
|
||||
"hmac",
|
||||
"image",
|
||||
"include_dir",
|
||||
"libc",
|
||||
"pbkdf2",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"similar",
|
||||
"socket2",
|
||||
"tempfile",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"windows-sys 0.52.0",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -529,6 +556,12 @@ dependencies = [
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "fax"
|
||||
version = "0.2.6"
|
||||
@@ -605,6 +638,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
@@ -635,9 +674,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
@@ -1031,6 +1072,25 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
|
||||
|
||||
[[package]]
|
||||
name = "include_dir"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
|
||||
dependencies = [
|
||||
"include_dir_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include_dir_macros"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
@@ -1049,6 +1109,7 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -1152,6 +1213,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.1"
|
||||
@@ -1333,6 +1400,16 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -1669,6 +1746,12 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-lite"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
@@ -1678,6 +1761,7 @@ dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -1697,12 +1781,14 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.5",
|
||||
]
|
||||
@@ -1727,12 +1813,59 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
|
||||
dependencies = [
|
||||
"rust-embed-impl",
|
||||
"rust-embed-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-impl"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rust-embed-utils",
|
||||
"syn",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-utils"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
@@ -1780,6 +1913,15 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
@@ -1841,6 +1983,19 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@@ -1965,6 +2120,19 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -2128,6 +2296,19 @@ dependencies = [
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -2252,6 +2433,12 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -2316,6 +2503,16 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
@@ -2430,6 +2627,19 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
@@ -2486,6 +2696,15 @@ version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
|
||||
+21
-6
@@ -1,28 +1,34 @@
|
||||
[package]
|
||||
name = "agent-browser"
|
||||
version = "0.24.0"
|
||||
name = "chrome-use"
|
||||
version = "1.5.26"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/vercel-labs/agent-browser"
|
||||
homepage = "https://agent-browser.dev"
|
||||
repository = "https://github.com/leeguooooo/chrome-use"
|
||||
homepage = "https://github.com/leeguooooo/chrome-use"
|
||||
readme = "../README.md"
|
||||
keywords = ["browser", "automation", "ai", "cdp", "chrome"]
|
||||
categories = ["command-line-utilities", "web-programming"]
|
||||
|
||||
[[bin]]
|
||||
name = "chrome-use"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
regex-lite = "0.1"
|
||||
dirs = "5.0"
|
||||
include_dir = "0.7"
|
||||
base64 = "0.22"
|
||||
getrandom = "0.2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal", "process"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "io-std", "time", "sync", "signal", "process"] }
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3"
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
image = "0.25"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "stream"] }
|
||||
sha2 = "0.10"
|
||||
aes-gcm = "0.10"
|
||||
async-trait = "0.1"
|
||||
@@ -32,8 +38,14 @@ zip = { version = "8.2.0", default-features = false, features = ["deflate"] }
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
|
||||
sha1 = "0.10"
|
||||
chrono = "0.4"
|
||||
urlencoding = "2"
|
||||
rust-embed = "8"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -41,6 +53,9 @@ libc = "0.2"
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.52", features = ["Win32_System_Threading", "Win32_Foundation"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[build-dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
@@ -3,7 +3,42 @@ use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Embed the version of the `ab-connect` extension this CLI ships alongside, so
|
||||
/// `doctor` can tell a connected extension "you're older than what this CLI
|
||||
/// expects, update it." Read from the extension manifest at build time so it
|
||||
/// stays in sync with whatever extension version is in the same checkout/release
|
||||
/// (the ext is on its own 0.4.x line, separate from the CLI version). Falls back
|
||||
/// to "unknown" if the manifest can't be read.
|
||||
fn embed_extension_version() {
|
||||
let manifest = Path::new("../extensions/ab-connect/manifest.json");
|
||||
println!("cargo:rerun-if-changed=../extensions/ab-connect/manifest.json");
|
||||
let version = fs::read_to_string(manifest)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.and_then(|v| v.get("version").and_then(|x| x.as_str()).map(String::from))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
println!("cargo:rustc-env=AB_CONNECT_VERSION={}", version);
|
||||
}
|
||||
|
||||
/// Ensure `packages/dashboard/out/` exists so `rust-embed` doesn't fail during
|
||||
/// Rust-only dev builds where the dashboard hasn't been built. The placeholder
|
||||
/// `index.html` is only written when the directory is completely absent.
|
||||
fn ensure_dashboard_dir() {
|
||||
let dashboard_out = Path::new("../packages/dashboard/out");
|
||||
println!("cargo:rerun-if-changed=../packages/dashboard/out");
|
||||
if !dashboard_out.join("index.html").exists() {
|
||||
let _ = fs::create_dir_all(dashboard_out);
|
||||
let _ = fs::write(
|
||||
dashboard_out.join("index.html"),
|
||||
"<!DOCTYPE html><html><body><p>Dashboard not built. Run: cd packages/dashboard && pnpm build</p></body></html>\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
ensure_dashboard_dir();
|
||||
embed_extension_version();
|
||||
|
||||
let protocol_dir = Path::new("cdp-protocol");
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
let out_path = Path::new(&out_dir).join("cdp_generated.rs");
|
||||
|
||||
+503
@@ -0,0 +1,503 @@
|
||||
use std::io::Write as _;
|
||||
use std::process::exit;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::color;
|
||||
use crate::flags::Flags;
|
||||
use crate::native::stream::chat;
|
||||
|
||||
const DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4.6";
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Verbosity {
|
||||
Quiet,
|
||||
Normal,
|
||||
Verbose,
|
||||
}
|
||||
|
||||
pub fn run_chat(flags: &Flags, message: Option<String>) {
|
||||
if !chat::is_chat_enabled() {
|
||||
if flags.json {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"success": false, "error": "AI_GATEWAY_API_KEY not set. Set the AI_GATEWAY_API_KEY environment variable to enable chat."})
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} AI_GATEWAY_API_KEY not set. Set the AI_GATEWAY_API_KEY environment variable to enable chat.",
|
||||
color::error_indicator()
|
||||
);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
let verbosity = if flags.quiet {
|
||||
Verbosity::Quiet
|
||||
} else if flags.verbose {
|
||||
Verbosity::Verbose
|
||||
} else {
|
||||
Verbosity::Normal
|
||||
};
|
||||
|
||||
let model = flags
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_MODEL.to_string());
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||
|
||||
let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdin());
|
||||
|
||||
match message {
|
||||
Some(msg) => {
|
||||
rt.block_on(run_single_turn(
|
||||
&flags.session,
|
||||
&model,
|
||||
&msg,
|
||||
verbosity,
|
||||
flags.json,
|
||||
));
|
||||
}
|
||||
None if !is_tty => {
|
||||
let mut input = String::new();
|
||||
if let Err(e) = std::io::stdin().read_line(&mut input) {
|
||||
if flags.json {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"success": false, "error": format!("Failed to read stdin: {}", e)})
|
||||
);
|
||||
} else {
|
||||
eprintln!("{} Failed to read stdin: {}", color::error_indicator(), e);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
if flags.json {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"success": false, "error": "No input provided"})
|
||||
);
|
||||
} else {
|
||||
eprintln!("{} No input provided", color::error_indicator());
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
rt.block_on(run_single_turn(
|
||||
&flags.session,
|
||||
&model,
|
||||
input,
|
||||
verbosity,
|
||||
flags.json,
|
||||
));
|
||||
}
|
||||
None => {
|
||||
rt.block_on(run_interactive(
|
||||
&flags.session,
|
||||
&model,
|
||||
verbosity,
|
||||
flags.json,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_single_turn(
|
||||
session: &str,
|
||||
model: &str,
|
||||
message: &str,
|
||||
verbosity: Verbosity,
|
||||
json_mode: bool,
|
||||
) {
|
||||
let mut openai_messages: Vec<Value> =
|
||||
vec![json!({"role": "system", "content": chat::get_system_prompt()})];
|
||||
openai_messages.push(json!({"role": "user", "content": message}));
|
||||
|
||||
let result = run_chat_turn(session, model, &mut openai_messages, verbosity, json_mode).await;
|
||||
if !result {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_interactive(session: &str, model: &str, verbosity: Verbosity, json_mode: bool) {
|
||||
let mut openai_messages: Vec<Value> =
|
||||
vec![json!({"role": "system", "content": chat::get_system_prompt()})];
|
||||
|
||||
let gateway_url = std::env::var("AI_GATEWAY_URL")
|
||||
.unwrap_or_else(|_| chat::DEFAULT_AI_GATEWAY_URL.to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let api_key = std::env::var("AI_GATEWAY_API_KEY").unwrap_or_default();
|
||||
let url = format!("{}/v1/chat/completions", gateway_url);
|
||||
let client = chat::http_client();
|
||||
|
||||
loop {
|
||||
if !json_mode {
|
||||
eprint!("{} ", color::cyan(">"));
|
||||
let _ = std::io::stderr().flush();
|
||||
}
|
||||
|
||||
let mut input = String::new();
|
||||
match std::io::stdin().read_line(&mut input) {
|
||||
Ok(0) => break,
|
||||
Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(input, "quit" | "exit" | "q") {
|
||||
break;
|
||||
}
|
||||
|
||||
openai_messages.push(json!({"role": "user", "content": input}));
|
||||
|
||||
// Compaction check
|
||||
let total_chars = chat::estimate_chars(&openai_messages);
|
||||
if total_chars > chat::COMPACT_THRESHOLD_CHARS
|
||||
&& openai_messages.len() > chat::KEEP_RECENT_MESSAGES + 2
|
||||
{
|
||||
let split = chat::find_safe_split(&openai_messages, chat::KEEP_RECENT_MESSAGES);
|
||||
let to_summarize = &openai_messages[1..split];
|
||||
if let Some(summary) =
|
||||
chat::summarize_for_compaction(client, &url, &api_key, model, to_summarize).await
|
||||
{
|
||||
let summary_msg = json!({
|
||||
"role": "system",
|
||||
"content": format!("[Conversation summary]\n{}", summary)
|
||||
});
|
||||
let recent = openai_messages[split..].to_vec();
|
||||
openai_messages = vec![openai_messages[0].clone(), summary_msg];
|
||||
openai_messages.extend(recent);
|
||||
}
|
||||
}
|
||||
|
||||
let success =
|
||||
run_chat_turn(session, model, &mut openai_messages, verbosity, json_mode).await;
|
||||
|
||||
if !success && !json_mode {
|
||||
// Continue the loop on error; don't exit interactive mode
|
||||
}
|
||||
|
||||
if !json_mode {
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one chat turn: sends messages to the gateway, streams text/tool calls,
|
||||
/// executes tools in a loop until the model is done. Appends assistant and tool
|
||||
/// messages to `openai_messages`. Returns true on success.
|
||||
async fn run_chat_turn(
|
||||
session: &str,
|
||||
model: &str,
|
||||
openai_messages: &mut Vec<Value>,
|
||||
verbosity: Verbosity,
|
||||
json_mode: bool,
|
||||
) -> bool {
|
||||
let gateway_url = std::env::var("AI_GATEWAY_URL")
|
||||
.unwrap_or_else(|_| chat::DEFAULT_AI_GATEWAY_URL.to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let api_key = match std::env::var("AI_GATEWAY_API_KEY") {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
if json_mode {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"success": false, "error": "AI_GATEWAY_API_KEY not set"})
|
||||
);
|
||||
} else {
|
||||
eprintln!("{} AI_GATEWAY_API_KEY not set", color::error_indicator());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let tools: Value = serde_json::from_str(chat::CHAT_TOOLS).unwrap();
|
||||
let url = format!("{}/v1/chat/completions", gateway_url);
|
||||
let client = chat::http_client();
|
||||
|
||||
let total_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(300);
|
||||
let tool_timeout = std::time::Duration::from_secs(60);
|
||||
|
||||
let mut all_text = String::new();
|
||||
let mut all_tool_calls: Vec<Value> = Vec::new();
|
||||
let mut had_text = false;
|
||||
|
||||
for _step in 0..50 {
|
||||
if tokio::time::Instant::now() >= total_deadline {
|
||||
if json_mode {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"success": false, "error": "Chat session timed out (5 minute limit)."})
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"\n{} Chat session timed out (5 minute limit).",
|
||||
color::error_indicator()
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let gateway_body = json!({
|
||||
"model": model,
|
||||
"messages": openai_messages,
|
||||
"tools": tools,
|
||||
"stream": true,
|
||||
});
|
||||
|
||||
let gw_response = match client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.body(gateway_body.to_string())
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if json_mode {
|
||||
println!(
|
||||
"{}",
|
||||
json!({"success": false, "error": format!("Gateway request failed: {}", e)})
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"\n{} Gateway request failed: {}",
|
||||
color::error_indicator(),
|
||||
e
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if !gw_response.status().is_success() {
|
||||
let body_text = gw_response.text().await.unwrap_or_default();
|
||||
if json_mode {
|
||||
println!("{}", json!({"success": false, "error": body_text}));
|
||||
} else {
|
||||
eprintln!("\n{} {}", color::error_indicator(), body_text);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let (text_chunks, tool_calls) =
|
||||
parse_gateway_stream(gw_response, verbosity, json_mode).await;
|
||||
|
||||
if !text_chunks.is_empty() {
|
||||
let text = text_chunks.join("");
|
||||
all_text.push_str(&text);
|
||||
if !json_mode {
|
||||
if !had_text && verbosity != Verbosity::Quiet {
|
||||
// Add blank line before text if we showed tool calls
|
||||
if !all_tool_calls.is_empty() {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
had_text = true;
|
||||
}
|
||||
|
||||
let mut content = json!(text);
|
||||
if let Some(last) = openai_messages.last() {
|
||||
if last.get("role").and_then(|r| r.as_str()) == Some("assistant")
|
||||
&& last.get("tool_calls").is_some()
|
||||
{
|
||||
content = json!(text);
|
||||
}
|
||||
}
|
||||
openai_messages.push(json!({"role": "assistant", "content": content}));
|
||||
}
|
||||
|
||||
if tool_calls.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let tc_values: Vec<Value> = tool_calls
|
||||
.iter()
|
||||
.map(|(id, name, args)| {
|
||||
json!({"id": id, "type": "function", "function": {"name": name, "arguments": args}})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if text_chunks.is_empty() {
|
||||
openai_messages.push(json!({"role": "assistant", "tool_calls": tc_values}));
|
||||
} else {
|
||||
// If we had both text and tool calls in the same response, merge them
|
||||
if let Some(last) = openai_messages.last_mut() {
|
||||
if last.get("role").and_then(|r| r.as_str()) == Some("assistant")
|
||||
&& last.get("tool_calls").is_none()
|
||||
{
|
||||
last["tool_calls"] = json!(tc_values);
|
||||
} else {
|
||||
openai_messages.push(json!({"role": "assistant", "tool_calls": tc_values}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (tc_id, _tc_name, tc_args) in &tool_calls {
|
||||
let input: Value = serde_json::from_str(tc_args).unwrap_or(json!({}));
|
||||
let command = input.get("command").and_then(|c| c.as_str()).unwrap_or("");
|
||||
|
||||
if !json_mode && verbosity != Verbosity::Quiet {
|
||||
eprintln!("{}", color::dim(&format!("> {}", command)));
|
||||
}
|
||||
|
||||
let result =
|
||||
match tokio::time::timeout(tool_timeout, chat::execute_chat_tool(session, command))
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(_) => "Tool execution timed out after 60 seconds.".to_string(),
|
||||
};
|
||||
|
||||
if !json_mode && verbosity == Verbosity::Verbose {
|
||||
for line in result.lines() {
|
||||
eprintln!(" {}", color::dim(line));
|
||||
}
|
||||
}
|
||||
|
||||
all_tool_calls.push(json!({
|
||||
"command": command,
|
||||
"output": result
|
||||
}));
|
||||
|
||||
openai_messages.push(json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc_id,
|
||||
"content": result
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if json_mode {
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"success": true,
|
||||
"text": all_text,
|
||||
"tool_calls": all_tool_calls
|
||||
})
|
||||
);
|
||||
} else if !had_text && !json_mode {
|
||||
// Model returned only tool calls with no final text; print newline for clean output
|
||||
println!();
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Parses the SSE stream from the AI gateway, printing text deltas to stdout in
|
||||
/// real-time. Returns (collected_text_chunks, tool_calls).
|
||||
async fn parse_gateway_stream(
|
||||
gw_response: reqwest::Response,
|
||||
verbosity: Verbosity,
|
||||
json_mode: bool,
|
||||
) -> (Vec<String>, Vec<(String, String, String)>) {
|
||||
use futures_util::StreamExt as _;
|
||||
|
||||
let mut text_chunks: Vec<String> = Vec::new();
|
||||
let mut tool_call_args: std::collections::HashMap<usize, (String, String, String)> =
|
||||
std::collections::HashMap::new();
|
||||
let mut byte_stream = gw_response.bytes_stream();
|
||||
let mut buffer = String::new();
|
||||
|
||||
while let Some(chunk_result) = byte_stream.next().await {
|
||||
let chunk = match chunk_result {
|
||||
Ok(c) => c,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
while let Some(newline_pos) = buffer.find('\n') {
|
||||
let line = buffer[..newline_pos].trim_end_matches('\r').to_string();
|
||||
buffer = buffer[newline_pos + 1..].to_string();
|
||||
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(data) = line.strip_prefix("data: ") else {
|
||||
continue;
|
||||
};
|
||||
if data == "[DONE]" {
|
||||
let tool_calls = collect_tool_calls(&mut tool_call_args);
|
||||
if !json_mode && !text_chunks.is_empty() {
|
||||
// End the streamed text line
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
return (text_chunks, tool_calls);
|
||||
}
|
||||
let Ok(sse_json) = serde_json::from_str::<Value>(data) else {
|
||||
continue;
|
||||
};
|
||||
let delta = sse_json
|
||||
.get("choices")
|
||||
.and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("delta"));
|
||||
let Some(delta) = delta else { continue };
|
||||
|
||||
if let Some(text) = delta.get("content").and_then(|c| c.as_str()) {
|
||||
if !text.is_empty() {
|
||||
text_chunks.push(text.to_string());
|
||||
if !json_mode && verbosity != Verbosity::Quiet {
|
||||
print!("{}", text);
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tcs) = delta.get("tool_calls").and_then(|t| t.as_array()) {
|
||||
for tc in tcs {
|
||||
let idx = tc.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = tool_call_args.entry(idx)
|
||||
{
|
||||
let id = tc
|
||||
.get("id")
|
||||
.and_then(|i| i.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let name = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
e.insert((id, name, String::new()));
|
||||
}
|
||||
if let Some(arg_delta) = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("arguments"))
|
||||
.and_then(|a| a.as_str())
|
||||
{
|
||||
let entry = tool_call_args.get_mut(&idx).unwrap();
|
||||
entry.2.push_str(arg_delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !json_mode && !text_chunks.is_empty() {
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
let tool_calls = collect_tool_calls(&mut tool_call_args);
|
||||
(text_chunks, tool_calls)
|
||||
}
|
||||
|
||||
fn collect_tool_calls(
|
||||
map: &mut std::collections::HashMap<usize, (String, String, String)>,
|
||||
) -> Vec<(String, String, String)> {
|
||||
let mut indices: Vec<usize> = map.keys().copied().collect();
|
||||
indices.sort();
|
||||
indices
|
||||
.into_iter()
|
||||
.filter_map(|idx| map.remove(&idx))
|
||||
.collect()
|
||||
}
|
||||
+20
-5
@@ -1,15 +1,30 @@
|
||||
//! Color output utilities respecting NO_COLOR environment variable.
|
||||
//! Color output utilities.
|
||||
//!
|
||||
//! When the NO_COLOR environment variable is present (regardless of value),
|
||||
//! all color formatting is disabled per https://no-color.org/
|
||||
//! Colors are off by default (agent-friendly). Enable with
|
||||
//! `AGENT_BROWSER_COLOR=1`. Setting `NO_COLOR` to any value disables
|
||||
//! colors per <https://no-color.org/>.
|
||||
|
||||
use std::env;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Returns true if color output is enabled (NO_COLOR is NOT set)
|
||||
fn env_is_truthy(name: &str) -> Option<bool> {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.map(|val| !matches!(val.to_lowercase().as_str(), "0" | "false" | "no"))
|
||||
}
|
||||
|
||||
/// Returns true if color output is enabled.
|
||||
///
|
||||
/// Priority: `NO_COLOR` (presence disables, per spec) >
|
||||
/// `AGENT_BROWSER_COLOR` (truthy enables) > default (off).
|
||||
pub fn is_enabled() -> bool {
|
||||
static COLORS_ENABLED: OnceLock<bool> = OnceLock::new();
|
||||
*COLORS_ENABLED.get_or_init(|| env::var("NO_COLOR").is_err())
|
||||
*COLORS_ENABLED.get_or_init(|| {
|
||||
if env::var_os("NO_COLOR").is_some() {
|
||||
return false;
|
||||
}
|
||||
env_is_truthy("AGENT_BROWSER_COLOR").unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Format text in red (errors)
|
||||
|
||||
+2391
-144
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,754 @@
|
||||
//! `chrome-use connect` — zero-confirmation control of the user's real,
|
||||
//! logged-in Chrome via the `ab-connect` MV3 extension over Chrome **native
|
||||
//! messaging** (no localhost port, no token; Chrome authenticates the extension
|
||||
//! to this host by id).
|
||||
//!
|
||||
//! Two pieces live here:
|
||||
//! - `run_connect` — `--install` writes the native-messaging host manifest (and
|
||||
//! a tiny launcher) so Chrome will spawn us; with no flag it reports status.
|
||||
//! - `run_nm_host` — the hidden `__nm-host` mode Chrome launches: it speaks the
|
||||
//! native-messaging stdio framing (4-byte little-endian length + JSON).
|
||||
//!
|
||||
//! This step wires the transport end-to-end (Chrome ⇄ host). Bridging the host
|
||||
//! to the daemon's relay + CdpClient is layered on next.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Native-messaging host name; must match `HOST_NAME` in the extension and the
|
||||
/// manifest filename. `com.agent_browser.connect` is the original name, used by
|
||||
/// every shipped extension up to ab-connect 0.4.2.
|
||||
pub const HOST_NAME: &str = "com.agent_browser.connect";
|
||||
|
||||
/// Alternate host name for the chrome-use rebrand era (ab-connect 0.5.0+). We
|
||||
/// install AND recognize both names so the relay works regardless of which
|
||||
/// extension version a user has — old (0.4.2) or new — with no forced
|
||||
/// re-install. See [`install_native_host`] / [`host_installed`].
|
||||
pub const HOST_NAME_ALT: &str = "com.leeguoo.chrome_use";
|
||||
|
||||
/// Every native-messaging host name this CLI installs and accepts.
|
||||
pub const HOST_NAMES: &[&str] = &[HOST_NAME, HOST_NAME_ALT];
|
||||
|
||||
/// Stable id of the `ab-connect` extension, pinned by the `key` in its
|
||||
/// manifest.json (and the signing key of the published `.crx`). Chrome only lets
|
||||
/// that extension talk to this host, and the force-install policy references it.
|
||||
pub const EXTENSION_ID: &str = "ciiljdlhdpfckdcfkphgmfalanpdejep";
|
||||
|
||||
/// The Chrome Web Store assigns its own id (the manifest "key" is stripped from
|
||||
/// store uploads), so the published build has a different origin than the local
|
||||
/// Load-unpacked one. Allow both to talk to the native-messaging host.
|
||||
pub const STORE_EXTENSION_ID: &str = "knfcmbamhjmaonkfnjhldjedeobeafmk";
|
||||
|
||||
/// Update URL the force-install policy points at. MUST be the Chrome Web Store
|
||||
/// endpoint: Chrome 149 tags any **off-Web-Store** force-installed extension
|
||||
/// `[BLOCKED]` on an unmanaged browser (verified on macOS — chrome://policy shows
|
||||
/// `[BLOCKED]…` / "Error, Warning"). Self-hosting a `.crx` therefore does NOT
|
||||
/// work on consumer Chrome; the extension must be published to the Web Store, and
|
||||
/// then this policy force-installs it silently (Web Store extensions are allowed).
|
||||
pub const UPDATE_URL: &str = "https://clients2.google.com/service/update2/crx";
|
||||
|
||||
/// Public Web Store listing — the guaranteed one-click "Add to Chrome" path,
|
||||
/// and the fallback when the force-install profile can't be approved headlessly.
|
||||
pub const STORE_URL: &str =
|
||||
"https://chromewebstore.google.com/detail/ciiljdlhdpfckdcfkphgmfalanpdejep";
|
||||
|
||||
/// Stable identifiers for the generated Chrome configuration profile, so a
|
||||
/// re-install replaces (rather than duplicates) it in System Settings.
|
||||
const PROFILE_ID: &str = "work.pwtk.chrome-use.ab-connect";
|
||||
const PROFILE_UUID: &str = "A1B2C3D4-AB00-4CCE-9E10-AAAABBBBCCCC";
|
||||
const PROFILE_PAYLOAD_UUID: &str = "A1B2C3D4-AB01-4CCE-9E10-DDDDEEEEFFFF";
|
||||
|
||||
/// `chrome-use extension <install|uninstall|status>` (local; no daemon).
|
||||
/// `args` is the cleaned argv including the leading "extension".
|
||||
pub fn run_connect(args: &[String], json: bool) {
|
||||
let install = args.iter().any(|a| a == "--install" || a == "install");
|
||||
let uninstall = args.iter().any(|a| a == "--uninstall" || a == "uninstall");
|
||||
|
||||
if uninstall {
|
||||
let removed = remove_host_manifests();
|
||||
let profile_removed = remove_force_install_profile();
|
||||
if json {
|
||||
report(
|
||||
json,
|
||||
true,
|
||||
&format!("removed {removed} native-host manifest(s)"),
|
||||
);
|
||||
} else {
|
||||
println!("✓ removed {removed} native-host manifest(s).");
|
||||
if profile_removed {
|
||||
println!("✓ removed ~/.chrome-use/ab-connect.mobileconfig");
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
println!(
|
||||
" To fully remove the extension, delete the \"chrome-use connect\" profile\n\
|
||||
in System Settings → Profiles (or run: profiles remove -identifier {PROFILE_ID})."
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if install {
|
||||
let no_open = args.iter().any(|a| a == "--no-open");
|
||||
match install_native_host() {
|
||||
Ok(paths) => {
|
||||
let profile = install_force_install_profile(no_open);
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"installed": paths,
|
||||
"extensionId": EXTENSION_ID,
|
||||
"profile": profile.as_ref().ok().map(|p| p.display().to_string()),
|
||||
"profileError": profile.as_ref().err(),
|
||||
"updateUrl": UPDATE_URL,
|
||||
}
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
println!("✓ native-messaging host installed:");
|
||||
for p in &paths {
|
||||
println!(" {p}");
|
||||
}
|
||||
match profile {
|
||||
Ok(path) => {
|
||||
println!(
|
||||
"\n✓ Chrome force-install profile written:\n {}",
|
||||
path.display()
|
||||
);
|
||||
if cfg!(target_os = "macos") {
|
||||
println!(
|
||||
"\nGet the extension into Chrome (one-time). Either:\n\
|
||||
A) One click: open {STORE_URL}\n and press \"Add to Chrome\".\n\
|
||||
B) Silent: approve the profile, then restart Chrome —\n \
|
||||
System Settings → General → Device Management → double-click\n \
|
||||
\"chrome-use connect\" → Install. Chrome then force-installs +\n \
|
||||
auto-updates it (no token, no per-use confirmation).\n\
|
||||
Both need the extension published to the Web Store; until then use\n \
|
||||
chrome://extensions → Developer mode → Load unpacked → extensions/ab-connect."
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("\n! could not write the force-install profile: {e}");
|
||||
println!(
|
||||
" Fallback: load extensions/ab-connect via chrome://extensions →\n\
|
||||
Developer mode → Load unpacked."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => report(json, false, &format!("install failed: {e}")),
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Status.
|
||||
let manifest = host_manifest_path_for_chrome();
|
||||
let installed = manifest.as_ref().map(|p| p.exists()).unwrap_or(false);
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"installed": installed,
|
||||
"manifest": manifest.as_ref().map(|p| p.display().to_string()),
|
||||
"extensionId": EXTENSION_ID,
|
||||
}
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
} else if installed {
|
||||
println!("✓ native-messaging host installed ({HOST_NAME}).");
|
||||
println!(" Load the ab-connect extension and it connects automatically.");
|
||||
} else {
|
||||
println!("✗ not installed. Run: chrome-use connect --install");
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the launcher script + native-messaging host manifest(s).
|
||||
fn install_native_host() -> Result<Vec<String>, String> {
|
||||
let home = dirs::home_dir().ok_or("no home dir")?;
|
||||
let ab_dir = home.join(".chrome-use");
|
||||
std::fs::create_dir_all(&ab_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
// Chrome execs the manifest `path` directly with the calling extension's
|
||||
// origin as argv[1]; a launcher lets us run the binary in __nm-host mode
|
||||
// regardless of how/where chrome-use is installed.
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let launcher = ab_dir.join("nm-host.sh");
|
||||
let script = format!(
|
||||
"#!/bin/sh\n# chrome-use native-messaging host launcher (auto-generated)\nexec \"{}\" __nm-host \"$@\"\n",
|
||||
exe.display()
|
||||
);
|
||||
std::fs::write(&launcher, script).map_err(|e| e.to_string())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
|
||||
// Write a manifest under EVERY accepted host name (both point to the same
|
||||
// launcher + allowed extensions), so any extension version's
|
||||
// `connectNative(<its host name>)` finds a matching host json.
|
||||
let mut written = Vec::new();
|
||||
for dir in native_messaging_dirs() {
|
||||
if let Some(parent) = dir.parent() {
|
||||
if !parent.exists() {
|
||||
continue; // that browser isn't installed
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
for host in HOST_NAMES {
|
||||
let manifest = serde_json::json!({
|
||||
"name": host,
|
||||
"description": "chrome-use connect — native messaging host",
|
||||
"path": launcher.display().to_string(),
|
||||
"type": "stdio",
|
||||
"allowed_origins": [
|
||||
format!("chrome-extension://{EXTENSION_ID}/"),
|
||||
format!("chrome-extension://{STORE_EXTENSION_ID}/"),
|
||||
],
|
||||
});
|
||||
let body = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{host}.json"));
|
||||
std::fs::write(&path, &body).map_err(|e| e.to_string())?;
|
||||
written.push(path.display().to_string());
|
||||
}
|
||||
}
|
||||
if written.is_empty() {
|
||||
return Err("no Chrome/Chromium NativeMessagingHosts directory found".into());
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Write a Chrome configuration profile that force-installs `ab-connect` from
|
||||
/// [`UPDATE_URL`], and (unless `no_open`) `open` it so the user approves it once
|
||||
/// in System Settings. Returns the profile path. macOS only — elsewhere it
|
||||
/// returns an error and the caller prints the manual fallback.
|
||||
fn install_force_install_profile(no_open: bool) -> Result<PathBuf, String> {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return Err("force-install profile is macOS-only; on Linux set Chrome's \
|
||||
ExtensionInstallForcelist policy JSON, or Load unpacked from chrome://extensions"
|
||||
.into());
|
||||
}
|
||||
let home = dirs::home_dir().ok_or("no home dir")?;
|
||||
let ab_dir = home.join(".chrome-use");
|
||||
std::fs::create_dir_all(&ab_dir).map_err(|e| e.to_string())?;
|
||||
let path = ab_dir.join("ab-connect.mobileconfig");
|
||||
std::fs::write(&path, force_install_mobileconfig()).map_err(|e| e.to_string())?;
|
||||
if !no_open {
|
||||
// `open` queues the profile in System Settings for one-time approval.
|
||||
let _ = std::process::Command::new("open").arg(&path).status();
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// The `.mobileconfig` payload: a user-scope Chrome policy that force-installs
|
||||
/// the extension from the Chrome Web Store. User scope installs without admin —
|
||||
/// just a one-time approval click. Must use the STORE id (the Web Store update
|
||||
/// server serves the published extension under the id it assigned, not the local
|
||||
/// Load-unpacked id).
|
||||
fn force_install_mobileconfig() -> String {
|
||||
let forcelist = format!("{STORE_EXTENSION_ID};{UPDATE_URL}");
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadType</key><string>com.google.Chrome</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
<key>PayloadIdentifier</key><string>{PROFILE_ID}.chrome</string>
|
||||
<key>PayloadUUID</key><string>{PROFILE_PAYLOAD_UUID}</string>
|
||||
<key>PayloadEnabled</key><true/>
|
||||
<key>PayloadDisplayName</key><string>chrome-use connect (Chrome)</string>
|
||||
<key>ExtensionInstallForcelist</key>
|
||||
<array>
|
||||
<string>{forcelist}</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadType</key><string>Configuration</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
<key>PayloadIdentifier</key><string>{PROFILE_ID}</string>
|
||||
<key>PayloadUUID</key><string>{PROFILE_UUID}</string>
|
||||
<key>PayloadDisplayName</key><string>chrome-use connect</string>
|
||||
<key>PayloadDescription</key><string>Force-installs the chrome-use connect extension so chrome-use can drive your logged-in Chrome. No token, no per-use confirmation.</string>
|
||||
<key>PayloadOrganization</key><string>chrome-use</string>
|
||||
<key>PayloadScope</key><string>User</string>
|
||||
<key>PayloadRemovalDisallowed</key><false/>
|
||||
</dict>
|
||||
</plist>
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Remove the generated `.mobileconfig` file (the profile itself is removed by
|
||||
/// the user from System Settings, or via `profiles remove`).
|
||||
fn remove_force_install_profile() -> bool {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".chrome-use").join("ab-connect.mobileconfig"))
|
||||
.filter(|p| p.exists())
|
||||
.map(|p| std::fs::remove_file(&p).is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn remove_host_manifests() -> usize {
|
||||
let mut n = 0;
|
||||
for dir in native_messaging_dirs() {
|
||||
for host in HOST_NAMES {
|
||||
let path = dir.join(format!("{host}.json"));
|
||||
if path.exists() && std::fs::remove_file(&path).is_ok() {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Per-OS NativeMessagingHosts directories for Chrome + Chromium-family browsers.
|
||||
fn native_messaging_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs_out = Vec::new();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(app_support) = dirs::config_dir() {
|
||||
for sub in [
|
||||
"Google/Chrome",
|
||||
"Google/Chrome Beta",
|
||||
"Google/Chrome Canary",
|
||||
"Chromium",
|
||||
"Microsoft Edge",
|
||||
"BraveSoftware/Brave-Browser",
|
||||
] {
|
||||
dirs_out.push(app_support.join(sub).join("NativeMessagingHosts"));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
if let Some(config) = dirs::config_dir() {
|
||||
for sub in [
|
||||
"google-chrome",
|
||||
"chromium",
|
||||
"microsoft-edge",
|
||||
"BraveSoftware/Brave-Browser",
|
||||
] {
|
||||
dirs_out.push(config.join(sub).join("NativeMessagingHosts"));
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs_out
|
||||
}
|
||||
|
||||
fn host_manifest_path_for_chrome() -> Option<PathBuf> {
|
||||
native_messaging_dirs()
|
||||
.into_iter()
|
||||
.flat_map(|d| HOST_NAMES.iter().map(move |h| d.join(format!("{h}.json"))))
|
||||
.find(|p| p.exists())
|
||||
.or_else(|| {
|
||||
native_messaging_dirs()
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|d| d.join(format!("{HOST_NAME}.json")))
|
||||
})
|
||||
}
|
||||
|
||||
/// True if the ab-connect native-messaging host manifest is present — i.e. the
|
||||
/// user has set up the extension path. When installed, auto-connect treats the
|
||||
/// dialog-free extension relay as the *intended* transport and refuses to fall
|
||||
/// back to a raw debug port (which would pop Chrome 136+'s "Allow remote
|
||||
/// debugging?" consent modal). The relay-url file comes and goes with the
|
||||
/// service worker; this manifest is the durable signal that the extension is
|
||||
/// the chosen path.
|
||||
pub fn host_installed() -> bool {
|
||||
native_messaging_dirs().into_iter().any(|d| {
|
||||
HOST_NAMES
|
||||
.iter()
|
||||
.any(|h| d.join(format!("{h}.json")).exists())
|
||||
})
|
||||
}
|
||||
|
||||
fn report(json: bool, ok: bool, msg: &str) {
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({ "success": ok, "error": if ok { serde_json::Value::Null } else { serde_json::json!(msg) }, "message": msg }))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
} else if ok {
|
||||
println!("✓ {msg}");
|
||||
} else {
|
||||
eprintln!("✗ {msg}");
|
||||
}
|
||||
if !ok {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- native messaging host (`__nm-host`) ----------------------------------
|
||||
|
||||
fn nm_log(line: &str) {
|
||||
let path = dirs::home_dir()
|
||||
.map(|h| h.join(".chrome-use").join("nm-host.log"))
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp/ab-nm-host.log"));
|
||||
if let Some(p) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(p);
|
||||
}
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
let _ = writeln!(f, "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
fn random_guid() -> String {
|
||||
let mut b = [0u8; 16];
|
||||
let _ = getrandom::getrandom(&mut b);
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Where the daemon/CLI reads the relay's CDP WebSocket URL (perms 600).
|
||||
///
|
||||
/// Cross-binary handoff: the native-messaging *host* writes it and the CLI reads
|
||||
/// it, but the two may be different binaries under different brand dirs after
|
||||
/// the agent-browser → chrome-use rename. Read from whichever brand dir actually
|
||||
/// has the file (an old `agent-browser` host writes `~/.agent-browser`; a
|
||||
/// `chrome-use` host writes `~/.chrome-use`); default to [`config_home`].
|
||||
fn relay_url_path() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
for base in [".chrome-use", ".agent-browser"] {
|
||||
let p = home.join(base).join("relay-cdp-url");
|
||||
if p.exists() {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return crate::connection::config_home().join("relay-cdp-url");
|
||||
}
|
||||
PathBuf::from("/tmp/ab-relay-cdp-url")
|
||||
}
|
||||
|
||||
/// The live relay CDP WebSocket URL, if the native-messaging host is running
|
||||
/// (it writes the file on connect and removes it on exit). Used by
|
||||
/// `chrome-use extension connect` to attach without the user copying a URL.
|
||||
pub fn relay_url() -> Option<String> {
|
||||
let s = std::fs::read_to_string(relay_url_path()).ok()?;
|
||||
let s = s.trim().to_string();
|
||||
if s.starts_with("ws://") {
|
||||
Some(s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a one-line record of how a CDP connection was established, to
|
||||
/// `~/.chrome-use/connect-mode.log`. This is the smoking-gun detector for the
|
||||
/// "Allow remote debugging?" consent modal: that modal ONLY appears on a raw
|
||||
/// remote-debugging attach / a browser we launched with a debug port — NEVER on
|
||||
/// the extension relay. When the modal reappears, this log says which session
|
||||
/// took which path and when, so we can tell a code regression (`raw-port` /
|
||||
/// `launched` while the relay was up) from Chrome's own extension-debugger
|
||||
/// consent UX. Low volume (one line per connection); best-effort, never fails a
|
||||
/// connection.
|
||||
pub fn log_connect_mode(ws_url: &str, launched: bool, session: &str) {
|
||||
let relay = relay_url();
|
||||
let relay_up = relay.is_some();
|
||||
let mode = if launched {
|
||||
"launched(debug-port)"
|
||||
} else if relay.as_deref() == Some(ws_url) {
|
||||
"relay"
|
||||
} else if ws_url.contains("127.0.0.1") || ws_url.contains("localhost") {
|
||||
"raw-port-attach"
|
||||
} else {
|
||||
"remote-ws"
|
||||
};
|
||||
// A raw-port attach or a self-launch while the relay was available is the
|
||||
// exact thing that pops the consent modal — flag it loudly in the line.
|
||||
let suspect = (mode == "raw-port-attach" || launched) && relay_up;
|
||||
let line = format!(
|
||||
"session={session} mode={mode} relay_up={relay_up}{} ws={ws_url}\n",
|
||||
if suspect { " CONSENT-MODAL-RISK" } else { "" }
|
||||
);
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let path = home.join(".chrome-use").join("connect-mode.log");
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
let _ = f.write_all(line.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sidecar recording the connected extension's version, written by the host when
|
||||
/// it receives the extension's `hello` (sibling of `relay-cdp-url`). Lets
|
||||
/// `doctor` surface which extension build is live without a CDP round-trip.
|
||||
fn relay_ext_version_path() -> PathBuf {
|
||||
relay_url_path().with_file_name("relay-ext-version")
|
||||
}
|
||||
|
||||
/// Version of the connected `ab-connect` extension, if the host learned it from
|
||||
/// the extension's `hello`. `None` when no extension has connected since the
|
||||
/// host started, or the extension predates version reporting.
|
||||
pub fn relay_ext_version() -> Option<String> {
|
||||
let s = std::fs::read_to_string(relay_ext_version_path())
|
||||
.ok()?
|
||||
.trim()
|
||||
.to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hidden `__nm-host` mode: launched by Chrome for the ab-connect extension.
|
||||
///
|
||||
/// Bridges the extension (native-messaging stdio, envelope protocol) to a local
|
||||
/// **CDP WebSocket endpoint** that chrome-use connects to like any Chrome.
|
||||
/// `relay::RelayState` translates envelope ⇄ raw CDP and emulates browser-level
|
||||
/// Target discovery. The ws URL carries an unguessable guid (written to a 600
|
||||
/// file) so only this user's chrome-use — not arbitrary local processes —
|
||||
/// can drive the browser. No token, no user interaction.
|
||||
pub fn run_nm_host() {
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
nm_log(&format!("[nm-host] runtime build failed: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(nm_host_main());
|
||||
}
|
||||
|
||||
async fn nm_host_main() {
|
||||
use crate::native::relay::{RelayOut, RelayState};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
/// client_id -> unbounded sender feeding that client's ws writer.
|
||||
type ClientMap = Arc<Mutex<HashMap<u64, mpsc::UnboundedSender<String>>>>;
|
||||
|
||||
nm_log(&format!(
|
||||
"[nm-host] start argv={:?}",
|
||||
std::env::args().skip(1).collect::<Vec<_>>()
|
||||
));
|
||||
|
||||
let listener = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
nm_log(&format!("[nm-host] bind failed: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let port = listener.local_addr().map(|a| a.port()).unwrap_or(0);
|
||||
let guid = random_guid();
|
||||
let url = format!("ws://127.0.0.1:{port}/{guid}");
|
||||
let url_path = relay_url_path();
|
||||
if let Some(p) = url_path.parent() {
|
||||
let _ = std::fs::create_dir_all(p);
|
||||
}
|
||||
if std::fs::write(&url_path, &url).is_ok() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&url_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
}
|
||||
nm_log(&format!("[nm-host] cdp endpoint {url}"));
|
||||
|
||||
let state = Arc::new(Mutex::new(RelayState::new()));
|
||||
let clients: ClientMap = Arc::new(Mutex::new(HashMap::new()));
|
||||
let next_client_id = Arc::new(AtomicU64::new(1));
|
||||
let (to_ext, mut to_ext_rx) = mpsc::channel::<Vec<u8>>(4096);
|
||||
|
||||
// Single writer to Chrome (extension) over stdout, native-messaging framed.
|
||||
tokio::spawn(async move {
|
||||
let mut out = tokio::io::stdout();
|
||||
while let Some(frame) = to_ext_rx.recv().await {
|
||||
let len = (frame.len() as u32).to_ne_bytes();
|
||||
if out.write_all(&len).await.is_err() || out.write_all(&frame).await.is_err() {
|
||||
break;
|
||||
}
|
||||
let _ = out.flush().await;
|
||||
}
|
||||
});
|
||||
|
||||
// Accept chrome-use CDP clients on the guid-scoped ws endpoint.
|
||||
{
|
||||
let state = state.clone();
|
||||
let clients = clients.clone();
|
||||
let next_client_id = next_client_id.clone();
|
||||
let to_ext = to_ext.clone();
|
||||
let guid = guid.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (stream, _) = match listener.accept().await {
|
||||
Ok(x) => x,
|
||||
Err(_) => break,
|
||||
};
|
||||
let st = state.clone();
|
||||
let client_id = next_client_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (ctx, crx) = mpsc::unbounded_channel::<String>();
|
||||
clients.lock().await.insert(client_id, ctx);
|
||||
let tx = to_ext.clone();
|
||||
let g = guid.clone();
|
||||
let cls = clients.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_cdp_client(stream, g, st, client_id, crx, tx, cls).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extension → host frames.
|
||||
let mut stdin = tokio::io::stdin();
|
||||
loop {
|
||||
let mut len_buf = [0u8; 4];
|
||||
if stdin.read_exact(&mut len_buf).await.is_err() {
|
||||
break;
|
||||
}
|
||||
let len = u32::from_ne_bytes(len_buf) as usize;
|
||||
let mut buf = vec![0u8; len];
|
||||
if stdin.read_exact(&mut buf).await.is_err() {
|
||||
break;
|
||||
}
|
||||
let v: serde_json::Value = match serde_json::from_slice(&buf) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// Extension version handshake: record it next to the relay URL so
|
||||
// `doctor` can report which extension build is live (and whether it's
|
||||
// behind). Best-effort; the message carries no CDP payload.
|
||||
if v.get("method").and_then(|m| m.as_str()) == Some("hello") {
|
||||
if let Some(ver) = v.get("version").and_then(|x| x.as_str()) {
|
||||
let _ = std::fs::write(relay_ext_version_path(), ver);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let outs = {
|
||||
let mut s = state.lock().await;
|
||||
s.handle_ext_message(&v, "")
|
||||
};
|
||||
for o in outs {
|
||||
match o {
|
||||
RelayOut::ToClient { to, msg } => {
|
||||
let text = msg.to_string();
|
||||
let cls = clients.lock().await;
|
||||
match to {
|
||||
// Command reply → only the client that issued it.
|
||||
Some(cid) => {
|
||||
if let Some(tx) = cls.get(&cid) {
|
||||
let _ = tx.send(text);
|
||||
}
|
||||
}
|
||||
// CDP event → fan out to every connected client.
|
||||
None => {
|
||||
for tx in cls.values() {
|
||||
let _ = tx.send(text.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RelayOut::ToExt(m) => {
|
||||
let _ = to_ext.send(m.to_string().into_bytes()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
nm_log("[nm-host] stdin EOF — Chrome closed the port");
|
||||
let _ = std::fs::remove_file(relay_url_path());
|
||||
let _ = std::fs::remove_file(relay_ext_version_path());
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
// The handshake-callback Result type is dictated by tokio-tungstenite's
|
||||
// accept_hdr_async contract; its Err variant (an http Response) can't be shrunk.
|
||||
#[allow(clippy::result_large_err)]
|
||||
async fn handle_cdp_client(
|
||||
stream: tokio::net::TcpStream,
|
||||
guid: String,
|
||||
state: std::sync::Arc<tokio::sync::Mutex<crate::native::relay::RelayState>>,
|
||||
client_id: u64,
|
||||
mut from_relay: tokio::sync::mpsc::UnboundedReceiver<String>,
|
||||
to_ext: tokio::sync::mpsc::Sender<Vec<u8>>,
|
||||
clients: std::sync::Arc<
|
||||
tokio::sync::Mutex<
|
||||
std::collections::HashMap<u64, tokio::sync::mpsc::UnboundedSender<String>>,
|
||||
>,
|
||||
>,
|
||||
) {
|
||||
use crate::native::relay::ClientRoute;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
let want_path = format!("/{guid}");
|
||||
let cb = |req: &tokio_tungstenite::tungstenite::handshake::server::Request,
|
||||
resp: tokio_tungstenite::tungstenite::handshake::server::Response| {
|
||||
if req.uri().path() == want_path {
|
||||
Ok(resp)
|
||||
} else {
|
||||
let mut reject = tokio_tungstenite::tungstenite::handshake::server::ErrorResponse::new(
|
||||
Some("forbidden".to_string()),
|
||||
);
|
||||
*reject.status_mut() = tokio_tungstenite::tungstenite::http::StatusCode::FORBIDDEN;
|
||||
Err(reject)
|
||||
}
|
||||
};
|
||||
let ws = match tokio_tungstenite::accept_hdr_async(stream, cb).await {
|
||||
Ok(ws) => ws,
|
||||
Err(_) => return,
|
||||
};
|
||||
nm_log("[nm-host] cdp client connected");
|
||||
// Ask the extension to (re)attach + announce every tab so this client
|
||||
// discovers the user's existing tabs instead of racing an empty list.
|
||||
let _ = to_ext.send(br#"{"method":"attachAll"}"#.to_vec()).await;
|
||||
let (mut tx, mut rx) = ws.split();
|
||||
loop {
|
||||
tokio::select! {
|
||||
relayed = from_relay.recv() => match relayed {
|
||||
Some(text) => { if tx.send(Message::Text(text)).await.is_err() { break } }
|
||||
None => break,
|
||||
},
|
||||
incoming = rx.next() => match incoming {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
let v: serde_json::Value = match serde_json::from_str(&text) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let route = { state.lock().await.route_client_command(client_id, &v) };
|
||||
match route {
|
||||
ClientRoute::Local(reply) => {
|
||||
if tx.send(Message::Text(reply.to_string())).await.is_err() { break }
|
||||
}
|
||||
ClientRoute::Forward(env) => {
|
||||
let _ = to_ext.send(env.to_string().into_bytes()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
// Unregister and forget this client's in-flight commands.
|
||||
clients.lock().await.remove(&client_id);
|
||||
state.lock().await.drop_client(client_id);
|
||||
nm_log("[nm-host] cdp client disconnected");
|
||||
}
|
||||
+545
-21
@@ -12,6 +12,11 @@ use std::time::Duration;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct Request {
|
||||
@@ -83,8 +88,39 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Brand-compat config directory basename. The project renamed
|
||||
/// `agent-browser` → `chrome-use`, but this dotfile dir is invisible internal
|
||||
/// plumbing: it's shared with the native-messaging host (the `relay-cdp-url`
|
||||
/// handoff) and holds saved auth/daemon state. Renaming it would break existing
|
||||
/// installs and re-pop the "Allow remote debugging?" dialog when the relay
|
||||
/// can't be located. So decide ONCE per run: prefer the new `.chrome-use`, but
|
||||
/// keep using an existing `.agent-browser` install if that's the only one
|
||||
/// present; fresh installs get `.chrome-use`. `dotted` picks the home-dir form
|
||||
/// (`.chrome-use`) vs the XDG/tmp subdir form (`chrome-use`); both agree.
|
||||
pub fn config_dir_basename(dotted: bool) -> &'static str {
|
||||
let prefer_old = dirs::home_dir()
|
||||
.map(|h| !h.join(".chrome-use").exists() && h.join(".agent-browser").exists())
|
||||
.unwrap_or(false);
|
||||
match (prefer_old, dotted) {
|
||||
(true, true) => ".agent-browser",
|
||||
(true, false) => "agent-browser",
|
||||
(false, true) => ".chrome-use",
|
||||
(false, false) => "chrome-use",
|
||||
}
|
||||
}
|
||||
|
||||
/// The home-based config dir (`~/.chrome-use`, or `~/.agent-browser` on an
|
||||
/// existing install — see [`config_dir_basename`]). Single source of truth so
|
||||
/// sockets, auth, and the relay handoff all agree within one run.
|
||||
pub fn config_home() -> PathBuf {
|
||||
match dirs::home_dir() {
|
||||
Some(home) => home.join(config_dir_basename(true)),
|
||||
None => env::temp_dir().join(config_dir_basename(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the base directory for socket/pid files.
|
||||
/// Priority: AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR > ~/.agent-browser > tmpdir
|
||||
/// Priority: AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR > config_home() > tmpdir
|
||||
pub fn get_socket_dir() -> PathBuf {
|
||||
// 1. Explicit override (ignore empty string)
|
||||
if let Ok(dir) = env::var("AGENT_BROWSER_SOCKET_DIR") {
|
||||
@@ -96,17 +132,17 @@ pub fn get_socket_dir() -> PathBuf {
|
||||
// 2. XDG_RUNTIME_DIR (Linux standard, ignore empty string)
|
||||
if let Ok(runtime_dir) = env::var("XDG_RUNTIME_DIR") {
|
||||
if !runtime_dir.is_empty() {
|
||||
return PathBuf::from(runtime_dir).join("agent-browser");
|
||||
return PathBuf::from(runtime_dir).join(config_dir_basename(false));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Home directory fallback (like Docker Desktop's ~/.docker/run/)
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(".agent-browser");
|
||||
if dirs::home_dir().is_some() {
|
||||
return config_home();
|
||||
}
|
||||
|
||||
// 4. Last resort: temp dir
|
||||
env::temp_dir().join("agent-browser")
|
||||
env::temp_dir().join(config_dir_basename(false))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -118,12 +154,31 @@ fn get_pid_path(session: &str) -> PathBuf {
|
||||
get_socket_dir().join(format!("{}.pid", session))
|
||||
}
|
||||
|
||||
fn get_version_path(session: &str) -> PathBuf {
|
||||
get_socket_dir().join(format!("{}.version", session))
|
||||
}
|
||||
|
||||
/// Path to the sidecar file that records the URL the previous daemon was on,
|
||||
/// used to restore navigation after a version-mismatch restart. Only written
|
||||
/// when the version-mismatch branch fires; cleared after the new daemon
|
||||
/// reads it. Manual `close` does not write this file, so a clean shutdown
|
||||
/// won't trigger surprise navigation.
|
||||
pub fn get_restore_url_path(session: &str) -> PathBuf {
|
||||
get_socket_dir().join(format!("{}.restore-url", session))
|
||||
}
|
||||
|
||||
/// Clean up stale socket and PID files for a session
|
||||
fn cleanup_stale_files(session: &str) {
|
||||
pub fn cleanup_stale_files(session: &str) {
|
||||
let pid_path = get_pid_path(session);
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let version_path = get_version_path(session);
|
||||
let _ = fs::remove_file(&version_path);
|
||||
let stream_path = get_socket_dir().join(format!("{}.stream", session));
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
// Note: the .restore-url sidecar is intentionally NOT removed here —
|
||||
// it lives across the brief window between killing the old daemon
|
||||
// and the new daemon reading it back. The new daemon deletes it after
|
||||
// restoring (see actions::auto_launch).
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -138,6 +193,186 @@ fn cleanup_stale_files(session: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether a process with the given PID is currently alive.
|
||||
///
|
||||
/// On unix, EPERM (process exists but we can't signal it) counts as alive
|
||||
/// so we don't mis-clean a live daemon owned by a different uid. Only ESRCH
|
||||
/// ("no such process") is treated as dead.
|
||||
pub fn is_pid_alive(pid: u32) -> bool {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
if libc::kill(pid as i32, 0) == 0 {
|
||||
return true;
|
||||
}
|
||||
std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
unsafe {
|
||||
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
|
||||
if handle != 0 {
|
||||
CloseHandle(handle);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A currently-running daemon session discovered by [`walk_daemons`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActiveSession {
|
||||
pub name: String,
|
||||
pub pid: u32,
|
||||
/// Contents of the session's `.version` file if present and non-empty.
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// Why a session's sidecar files were cleaned up during a walk.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CleanReason {
|
||||
/// The `.pid` file referenced a process that no longer exists.
|
||||
ProcessGone,
|
||||
/// The `.pid` file could not be parsed as a PID.
|
||||
UnreadablePidFile,
|
||||
/// A `.sock` file had no corresponding `.pid` file (unix only).
|
||||
OrphanedSocket,
|
||||
/// The `dashboard.pid` referenced a process that no longer exists.
|
||||
DashboardGone,
|
||||
}
|
||||
|
||||
/// A session whose sidecar files were removed as a side effect of a walk.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CleanedSession {
|
||||
pub name: String,
|
||||
pub reason: CleanReason,
|
||||
}
|
||||
|
||||
/// Information about the standalone dashboard process, if any.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct DashboardInfo {
|
||||
pub pid: u32,
|
||||
pub alive: bool,
|
||||
}
|
||||
|
||||
/// Snapshot of daemon state under [`get_socket_dir()`] after a walk. Stale
|
||||
/// sidecar files are cleaned up as a side effect and recorded in `cleaned`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DaemonInventory {
|
||||
pub sessions: Vec<ActiveSession>,
|
||||
pub cleaned: Vec<CleanedSession>,
|
||||
pub dashboard: Option<DashboardInfo>,
|
||||
}
|
||||
|
||||
/// Read the session's `.version` sidecar if present and non-empty.
|
||||
pub fn read_session_version(session: &str) -> Option<String> {
|
||||
let path = get_socket_dir().join(format!("{}.version", session));
|
||||
fs::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Walk the socket directory and classify each `.pid` / `.sock` entry.
|
||||
///
|
||||
/// - Live daemons go into `sessions` with their `.version` file contents.
|
||||
/// - Stale entries (process gone, unreadable pid, orphaned `.sock`) are
|
||||
/// cleaned via [`cleanup_stale_files`] and recorded in `cleaned`.
|
||||
/// - `dashboard.pid` lands in `dashboard` with liveness info; if the
|
||||
/// process is gone, the pid file is removed and a `DashboardGone` entry
|
||||
/// is added to `cleaned`.
|
||||
///
|
||||
/// If the socket directory doesn't exist, returns an empty inventory with
|
||||
/// no side effects.
|
||||
pub fn walk_daemons() -> DaemonInventory {
|
||||
let socket_dir = get_socket_dir();
|
||||
let mut inventory = DaemonInventory::default();
|
||||
|
||||
let entries = match fs::read_dir(&socket_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return inventory,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
if name == "dashboard.pid" {
|
||||
if let Ok(s) = fs::read_to_string(entry.path()) {
|
||||
if let Ok(pid) = s.trim().parse::<u32>() {
|
||||
let alive = is_pid_alive(pid);
|
||||
inventory.dashboard = Some(DashboardInfo { pid, alive });
|
||||
if !alive {
|
||||
let _ = fs::remove_file(entry.path());
|
||||
inventory.cleaned.push(CleanedSession {
|
||||
name: "dashboard".to_string(),
|
||||
reason: CleanReason::DashboardGone,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let session_name = match name.strip_suffix(".pid") {
|
||||
Some(s) if !s.is_empty() => s.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let pid = match fs::read_to_string(entry.path())
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u32>().ok())
|
||||
{
|
||||
Some(p) => p,
|
||||
None => {
|
||||
cleanup_stale_files(&session_name);
|
||||
inventory.cleaned.push(CleanedSession {
|
||||
name: session_name,
|
||||
reason: CleanReason::UnreadablePidFile,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !is_pid_alive(pid) {
|
||||
cleanup_stale_files(&session_name);
|
||||
inventory.cleaned.push(CleanedSession {
|
||||
name: session_name,
|
||||
reason: CleanReason::ProcessGone,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let version = read_session_version(&session_name);
|
||||
inventory.sessions.push(ActiveSession {
|
||||
name: session_name,
|
||||
pid,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
// Orphaned .sock files without a corresponding .pid (unix only).
|
||||
#[cfg(unix)]
|
||||
if let Ok(entries) = fs::read_dir(&socket_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if let Some(session_name) = name.strip_suffix(".sock") {
|
||||
if session_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let pid_path = socket_dir.join(format!("{}.pid", session_name));
|
||||
if !pid_path.exists() {
|
||||
cleanup_stale_files(session_name);
|
||||
inventory.cleaned.push(CleanedSession {
|
||||
name: session_name.to_string(),
|
||||
reason: CleanReason::OrphanedSocket,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inventory
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_port_path(session: &str) -> PathBuf {
|
||||
get_socket_dir().join(format!("{}.port", session))
|
||||
@@ -198,6 +433,8 @@ pub struct DaemonOptions<'a> {
|
||||
pub debug: bool,
|
||||
pub executable_path: Option<&'a str>,
|
||||
pub extensions: &'a [String],
|
||||
pub init_scripts: &'a [String],
|
||||
pub enable: &'a [String],
|
||||
pub args: Option<&'a str>,
|
||||
pub user_agent: Option<&'a str>,
|
||||
pub proxy: Option<&'a str>,
|
||||
@@ -206,6 +443,7 @@ pub struct DaemonOptions<'a> {
|
||||
pub proxy_password: Option<&'a str>,
|
||||
pub ignore_https_errors: bool,
|
||||
pub allow_file_access: bool,
|
||||
pub hide_scrollbars: bool,
|
||||
pub profile: Option<&'a str>,
|
||||
pub state: Option<&'a str>,
|
||||
pub provider: Option<&'a str>,
|
||||
@@ -217,7 +455,9 @@ pub struct DaemonOptions<'a> {
|
||||
pub confirm_actions: Option<&'a str>,
|
||||
pub engine: Option<&'a str>,
|
||||
pub auto_connect: bool,
|
||||
pub force_launch: bool,
|
||||
pub idle_timeout: Option<&'a str>,
|
||||
pub default_timeout: Option<u64>,
|
||||
pub cdp: Option<&'a str>,
|
||||
pub no_auto_dialog: bool,
|
||||
}
|
||||
@@ -238,6 +478,12 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
|
||||
if !opts.extensions.is_empty() {
|
||||
cmd.env("AGENT_BROWSER_EXTENSIONS", opts.extensions.join(","));
|
||||
}
|
||||
if !opts.init_scripts.is_empty() {
|
||||
cmd.env("AGENT_BROWSER_INIT_SCRIPTS", opts.init_scripts.join(","));
|
||||
}
|
||||
if !opts.enable.is_empty() {
|
||||
cmd.env("AGENT_BROWSER_ENABLE", opts.enable.join(","));
|
||||
}
|
||||
if let Some(a) = opts.args {
|
||||
cmd.env("AGENT_BROWSER_ARGS", a);
|
||||
}
|
||||
@@ -262,6 +508,10 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
|
||||
if opts.allow_file_access {
|
||||
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
||||
}
|
||||
cmd.env(
|
||||
"AGENT_BROWSER_HIDE_SCROLLBARS",
|
||||
if opts.hide_scrollbars { "1" } else { "0" },
|
||||
);
|
||||
if let Some(prof) = opts.profile {
|
||||
cmd.env("AGENT_BROWSER_PROFILE", prof);
|
||||
}
|
||||
@@ -295,9 +545,15 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
|
||||
if opts.auto_connect {
|
||||
cmd.env("AGENT_BROWSER_AUTO_CONNECT", "1");
|
||||
}
|
||||
if opts.force_launch {
|
||||
cmd.env("AGENT_BROWSER_FORCE_LAUNCH", "1");
|
||||
}
|
||||
if let Some(idle) = opts.idle_timeout {
|
||||
cmd.env("AGENT_BROWSER_IDLE_TIMEOUT_MS", idle);
|
||||
}
|
||||
if let Some(timeout) = opts.default_timeout {
|
||||
cmd.env("AGENT_BROWSER_DEFAULT_TIMEOUT", timeout.to_string());
|
||||
}
|
||||
if let Some(cdp) = opts.cdp {
|
||||
cmd.env("AGENT_BROWSER_CDP", cdp);
|
||||
}
|
||||
@@ -306,6 +562,102 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the running daemon's version matches this CLI binary.
|
||||
/// Returns false when the version file is missing — an unversioned daemon
|
||||
/// is most likely a stale leftover from before version tracking was added
|
||||
/// (or from the Node.js era), and silently reusing it is the exact bug
|
||||
/// this check exists to prevent. The one-time cost of an unnecessary
|
||||
/// restart on the first upgrade is preferable to silent failures.
|
||||
fn daemon_version_matches(session: &str) -> bool {
|
||||
let version_path = get_version_path(session);
|
||||
match fs::read_to_string(&version_path) {
|
||||
Ok(v) => v.trim() == env!("CARGO_PKG_VERSION"),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot socket query for the running daemon's current URL.
|
||||
/// Returns None on any kind of failure — caller must treat as best-effort.
|
||||
fn query_current_url(session: &str) -> Option<String> {
|
||||
let cmd = serde_json::json!({
|
||||
"id": format!("restore-url-probe-{}", std::process::id()),
|
||||
"action": "url",
|
||||
});
|
||||
let resp = send_command_once(&cmd, session).ok()?;
|
||||
if !resp.success {
|
||||
return None;
|
||||
}
|
||||
resp.data
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Kill a running daemon by reading its PID file and sending a kill signal.
|
||||
pub fn kill_stale_daemon(session: &str) {
|
||||
// Remove the socket first so no new connections reach the old daemon
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let socket_path = get_socket_path(session);
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
|
||||
let pid_path = get_pid_path(session);
|
||||
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
|
||||
if let Ok(pid) = pid_str.trim().parse::<u32>() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe {
|
||||
libc::kill(pid as i32, libc::SIGTERM);
|
||||
}
|
||||
// Wait up to 1s for graceful shutdown, then force-kill
|
||||
for _ in 0..10 {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
if unsafe { libc::kill(pid as i32, 0) } != 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Force-kill if still alive
|
||||
if unsafe { libc::kill(pid as i32, 0) } == 0 {
|
||||
unsafe {
|
||||
libc::kill(pid as i32, libc::SIGKILL);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up leftover files regardless
|
||||
cleanup_stale_files(session);
|
||||
}
|
||||
|
||||
/// Kill every per-session daemon worker (SIGTERM→SIGKILL + sidecar cleanup),
|
||||
/// leaving the Chrome-launched `__nm-host` native-messaging bridge alone — it's
|
||||
/// not a tracked session daemon, so the extension relay stays up. Returns the
|
||||
/// session names that were stopped. Powers `chrome-use daemon restart`, which
|
||||
/// clears corrupted/cross-leaked daemon state (e.g. after a version-mismatch
|
||||
/// restart) without the user resorting to `pgrep`/`kill` (issue #20).
|
||||
pub fn restart_all_daemons() -> Vec<String> {
|
||||
let inventory = walk_daemons();
|
||||
let mut stopped = Vec::new();
|
||||
for session in &inventory.sessions {
|
||||
kill_stale_daemon(&session.name);
|
||||
stopped.push(session.name.clone());
|
||||
}
|
||||
stopped
|
||||
}
|
||||
|
||||
pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult, String> {
|
||||
// Socket connectivity is the sole liveness check — no PID check — so
|
||||
// callers in a different PID namespace (e.g. unshare) can still reuse
|
||||
@@ -316,9 +668,33 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
|
||||
// (daemon has a 100ms shutdown delay, so we wait longer)
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
if daemon_ready(session) {
|
||||
return Ok(DaemonResult {
|
||||
already_running: true,
|
||||
});
|
||||
// Check version: if the running daemon is from a different CLI
|
||||
// version (e.g. after an upgrade), kill it and start a fresh one.
|
||||
if !daemon_version_matches(session) {
|
||||
eprintln!(
|
||||
"{} Daemon version mismatch detected, restarting... \
|
||||
In-memory context (active tab, refs, captured requests) is reset. \
|
||||
If the next read looks blank or lands on the wrong page, re-open \
|
||||
your target URL before retrying (issue #8.2).",
|
||||
crate::color::warning_indicator()
|
||||
);
|
||||
// Best-effort: ask the old daemon for its current URL so the
|
||||
// new daemon can restore navigation after auto-connect. If the
|
||||
// query fails (already shutting down, no browser, etc.) we
|
||||
// silently skip — the user just sees about:blank as before.
|
||||
if let Some(url) = query_current_url(session) {
|
||||
if !url.is_empty() && url != "about:blank" {
|
||||
let path = get_restore_url_path(session);
|
||||
let _ = fs::write(&path, &url);
|
||||
}
|
||||
}
|
||||
kill_stale_daemon(session);
|
||||
// Fall through to spawn a new daemon below
|
||||
} else {
|
||||
return Ok(DaemonResult {
|
||||
already_running: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +805,21 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
|
||||
let _ = stderr.read_to_string(&mut stderr_output);
|
||||
}
|
||||
let stderr_trimmed = stderr_output.trim();
|
||||
|
||||
// If the daemon failed because another instance won the bind
|
||||
// race ("Address already in use"), check whether that winner is
|
||||
// now accepting connections and piggyback on it.
|
||||
if stderr_trimmed.contains("Address already in use")
|
||||
|| stderr_trimmed.contains("Failed to bind")
|
||||
{
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
if daemon_ready(session) {
|
||||
return Ok(DaemonResult {
|
||||
already_running: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !stderr_trimmed.is_empty() {
|
||||
let msg = if stderr_trimmed.len() > 500 {
|
||||
let mut end = 500;
|
||||
@@ -480,7 +871,33 @@ fn connect(session: &str) -> Result<Connection, String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_command(cmd: Value, session: &str) -> Result<Response, String> {
|
||||
pub fn send_command(mut cmd: Value, session: &str) -> Result<Response, String> {
|
||||
// Forward per-invocation env to the daemon. The daemon's environment is
|
||||
// frozen at spawn, so settings like AGENT_BROWSER_CLICK_MODE /
|
||||
// AGENT_BROWSER_HUMANIZE (incl. the --humanize flag, which sets the latter)
|
||||
// are otherwise silently ignored on an already-running daemon. Carry them in
|
||||
// the envelope so they apply to THIS command.
|
||||
if let Some(obj) = cmd.as_object_mut() {
|
||||
if let Ok(m) = std::env::var("AGENT_BROWSER_CLICK_MODE") {
|
||||
obj.insert("_clickMode".to_string(), Value::String(m));
|
||||
}
|
||||
if let Ok(h) = std::env::var("AGENT_BROWSER_HUMANIZE") {
|
||||
// Only forward a recognized level; warn once (like the --humanize flag
|
||||
// does) when the env var is set to garbage, instead of silently
|
||||
// ignoring it.
|
||||
if crate::native::humanize::HumanizeLevel::parse(&h).is_some() {
|
||||
obj.insert("_humanize".to_string(), Value::String(h));
|
||||
} else {
|
||||
static WARNED: std::sync::Once = std::sync::Once::new();
|
||||
WARNED.call_once(|| {
|
||||
eprintln!(
|
||||
"warning: AGENT_BROWSER_HUMANIZE must be off|fast|human, got {h:?} (ignored)"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Retry logic for transient errors (EAGAIN/EWOULDBLOCK/connection issues)
|
||||
const MAX_RETRIES: u32 = 5;
|
||||
const RETRY_DELAY_MS: u64 = 200;
|
||||
@@ -579,9 +996,7 @@ mod tests {
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
assert!(get_socket_dir()
|
||||
.to_string_lossy()
|
||||
.ends_with(".agent-browser"));
|
||||
assert!(get_socket_dir().to_string_lossy().ends_with(".chrome-use"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -591,10 +1006,7 @@ mod tests {
|
||||
_guard.remove("AGENT_BROWSER_SOCKET_DIR");
|
||||
_guard.set("XDG_RUNTIME_DIR", "/run/user/1000");
|
||||
|
||||
assert_eq!(
|
||||
get_socket_dir(),
|
||||
PathBuf::from("/run/user/1000/agent-browser")
|
||||
);
|
||||
assert_eq!(get_socket_dir(), PathBuf::from("/run/user/1000/chrome-use"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -604,9 +1016,7 @@ mod tests {
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
_guard.set("XDG_RUNTIME_DIR", "");
|
||||
|
||||
assert!(get_socket_dir()
|
||||
.to_string_lossy()
|
||||
.ends_with(".agent-browser"));
|
||||
assert!(get_socket_dir().to_string_lossy().ends_with(".chrome-use"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -617,7 +1027,7 @@ mod tests {
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
let result = get_socket_dir();
|
||||
assert!(result.to_string_lossy().ends_with(".agent-browser"));
|
||||
assert!(result.to_string_lossy().ends_with(".chrome-use"));
|
||||
assert!(
|
||||
result.to_string_lossy().contains("home") || result.to_string_lossy().contains("Users")
|
||||
);
|
||||
@@ -739,4 +1149,118 @@ mod tests {
|
||||
assert_eq!(get_port_for_session("work"), 51184);
|
||||
assert_eq!(get_port_for_session(""), 49152);
|
||||
}
|
||||
|
||||
// === Daemon Version Mismatch Detection Tests ===
|
||||
|
||||
#[test]
|
||||
fn test_daemon_version_matches_same_version() {
|
||||
let dir = std::env::temp_dir().join("ab-test-version-match");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap());
|
||||
|
||||
let version_path = dir.join("test-session.version");
|
||||
let _ = fs::write(&version_path, env!("CARGO_PKG_VERSION"));
|
||||
|
||||
assert!(daemon_version_matches("test-session"));
|
||||
|
||||
let _ = fs::remove_file(&version_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_daemon_version_matches_different_version() {
|
||||
let dir = std::env::temp_dir().join("ab-test-version-mismatch");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap());
|
||||
|
||||
let version_path = dir.join("test-session.version");
|
||||
let _ = fs::write(&version_path, "0.0.0-old");
|
||||
|
||||
assert!(!daemon_version_matches("test-session"));
|
||||
|
||||
let _ = fs::remove_file(&version_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_daemon_version_matches_no_file() {
|
||||
let dir = std::env::temp_dir().join("ab-test-version-nofile");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap());
|
||||
|
||||
// No version file: treated as mismatch so stale pre-version-tracking
|
||||
// daemons (including Node.js era) are always restarted.
|
||||
assert!(!daemon_version_matches("test-session"));
|
||||
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restart_all_daemons_empty_dir() {
|
||||
let dir = std::env::temp_dir().join("ab-test-restart-empty");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap());
|
||||
|
||||
// No daemons registered → nothing to stop, and it must not blow up.
|
||||
assert!(restart_all_daemons().is_empty());
|
||||
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_restart_all_daemons_kills_live_session() {
|
||||
let dir = std::env::temp_dir().join("ab-test-restart-live");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap());
|
||||
|
||||
// Spawn a real, killable child and register it as a session daemon.
|
||||
let mut child = Command::new("sleep")
|
||||
.arg("30")
|
||||
.spawn()
|
||||
.expect("spawn sleep");
|
||||
let pid = child.id();
|
||||
let _ = fs::write(dir.join("rktest.pid"), pid.to_string());
|
||||
let _ = fs::write(get_socket_path("rktest"), b"");
|
||||
|
||||
let stopped = restart_all_daemons();
|
||||
assert!(
|
||||
stopped.contains(&"rktest".to_string()),
|
||||
"stopped: {:?}",
|
||||
stopped
|
||||
);
|
||||
|
||||
// Reap the killed child first — until the parent waits, it lingers as a
|
||||
// zombie that still answers `kill(pid, 0)`, so is_pid_alive would lie.
|
||||
let _ = child.wait();
|
||||
assert!(!is_pid_alive(pid));
|
||||
|
||||
// Sidecars are cleaned up.
|
||||
assert!(!dir.join("rktest.pid").exists());
|
||||
assert!(!get_socket_path("rktest").exists());
|
||||
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_stale_files_removes_version() {
|
||||
let dir = std::env::temp_dir().join("ab-test-cleanup-version");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", dir.to_str().unwrap());
|
||||
|
||||
let version_path = dir.join("test-session.version");
|
||||
let _ = fs::write(&version_path, "0.1.0");
|
||||
assert!(version_path.exists());
|
||||
|
||||
cleanup_stale_files("test-session");
|
||||
assert!(!version_path.exists());
|
||||
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Offline export of a Chrome profile's cookies.
|
||||
//!
|
||||
//! Reads a profile's on-disk cookie store, decrypts the values with the OS
|
||||
//! credential-store key, and returns CDP `Network.setCookie`-shaped objects —
|
||||
//! the same shape `cookies set --curl` accepts. This is what powers
|
||||
//! `cookies transfer`: it moves a logged-in session (whose auth cookies are
|
||||
//! httpOnly + secure and span several hosts) from one profile to another
|
||||
//! without the source profile being reachable over CDP, and without restarting
|
||||
//! Chrome.
|
||||
//!
|
||||
//! Currently macOS-only. There, value encryption uses the `v10` scheme:
|
||||
//! AES-128-CBC with a key derived (PBKDF2-HMAC-SHA1, 1003 iterations) from the
|
||||
//! "Chrome Safe Storage" Keychain entry, shared by every profile of one Chrome
|
||||
//! install. Other platforms return a clear error.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Resolve, read, and decrypt a Chrome profile's cookies.
|
||||
///
|
||||
/// `profile` accepts a directory name ("Default", "Profile 14"), a display name
|
||||
/// ("Davian", case-insensitive), or "auto" (last-used profile). `domain`, when
|
||||
/// set, is a comma-separated host-suffix filter (e.g. "claude.ai,anthropic.com")
|
||||
/// matched against `host_key`; pass `None` to export every cookie.
|
||||
pub fn export_cookies(profile: &str, domain: Option<&str>) -> Result<Vec<Value>, String> {
|
||||
let db = resolve_cookie_db(profile)?;
|
||||
let rows = read_cookie_rows(&db, domain)?;
|
||||
let key = safe_storage_key()?;
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for r in &rows {
|
||||
if let Some(value) = decrypt_value(&r.encrypted_value, &key) {
|
||||
out.push(to_cdp_cookie(r, value));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn resolve_cookie_db(profile: &str) -> Result<PathBuf, String> {
|
||||
use crate::native::cdp::chrome::{find_chrome_user_data_dir, resolve_chrome_profile};
|
||||
let udd = find_chrome_user_data_dir()
|
||||
.ok_or_else(|| "No Chrome user data directory found".to_string())?;
|
||||
let dir = resolve_chrome_profile(&udd, profile)?;
|
||||
let base = udd.join(&dir);
|
||||
// Chrome >=96 keeps cookies under Network/; older builds at the profile root.
|
||||
let net = base.join("Network").join("Cookies");
|
||||
if net.is_file() {
|
||||
return Ok(net);
|
||||
}
|
||||
let root = base.join("Cookies");
|
||||
if root.is_file() {
|
||||
return Ok(root);
|
||||
}
|
||||
Err(format!(
|
||||
"no cookie store found for profile \"{}\" (looked in {} and {})",
|
||||
profile,
|
||||
net.display(),
|
||||
root.display()
|
||||
))
|
||||
}
|
||||
|
||||
struct CookieRow {
|
||||
host_key: String,
|
||||
name: String,
|
||||
encrypted_value: Vec<u8>,
|
||||
path: String,
|
||||
is_secure: bool,
|
||||
is_httponly: bool,
|
||||
samesite: i64,
|
||||
expires_utc: i64,
|
||||
}
|
||||
|
||||
/// Removes a temp directory when dropped.
|
||||
struct TempGuard(PathBuf);
|
||||
impl Drop for TempGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_cookie_rows(db: &Path, domain: Option<&str>) -> Result<Vec<CookieRow>, String> {
|
||||
// Copy the store (plus any -wal/-shm) to a temp file so a running Chrome's
|
||||
// lock / hot journal can't block the read or be disturbed by it.
|
||||
let tmp_dir = std::env::temp_dir().join(format!("chrome-use-cookies-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&tmp_dir).map_err(|e| format!("temp dir: {}", e))?;
|
||||
let _guard = TempGuard(tmp_dir.clone());
|
||||
let tmp_db = tmp_dir.join("Cookies");
|
||||
copy_db(db, &tmp_db)?;
|
||||
|
||||
let where_clause = build_where(domain)?;
|
||||
let sql = format!(
|
||||
"SELECT json_group_array(json_object(\
|
||||
'h',host_key,'n',name,'e',hex(encrypted_value),'p',path,\
|
||||
'sec',is_secure,'ho',is_httponly,'ss',samesite,'x',expires_utc)) \
|
||||
FROM cookies{};",
|
||||
where_clause
|
||||
);
|
||||
let output = std::process::Command::new("sqlite3")
|
||||
.arg(tmp_db.to_string_lossy().to_string())
|
||||
.arg(&sql)
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"could not run sqlite3 (required to read the cookie store): {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"sqlite3 failed reading the cookie store: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let trimmed = stdout.trim();
|
||||
if trimmed.is_empty() || trimmed == "null" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let arr: Vec<Value> =
|
||||
serde_json::from_str(trimmed).map_err(|e| format!("parsing cookie rows: {}", e))?;
|
||||
let mut rows = Vec::with_capacity(arr.len());
|
||||
for v in arr {
|
||||
let enc_hex = v.get("e").and_then(|x| x.as_str()).unwrap_or("");
|
||||
let path = v.get("p").and_then(|x| x.as_str()).unwrap_or("/");
|
||||
rows.push(CookieRow {
|
||||
host_key: v
|
||||
.get("h")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
name: v
|
||||
.get("n")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
encrypted_value: hex::decode(enc_hex).unwrap_or_default(),
|
||||
path: if path.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
path.to_string()
|
||||
},
|
||||
is_secure: v.get("sec").and_then(|x| x.as_i64()).unwrap_or(0) != 0,
|
||||
is_httponly: v.get("ho").and_then(|x| x.as_i64()).unwrap_or(0) != 0,
|
||||
samesite: v.get("ss").and_then(|x| x.as_i64()).unwrap_or(-1),
|
||||
expires_utc: v.get("x").and_then(|x| x.as_i64()).unwrap_or(0),
|
||||
});
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn copy_db(src: &Path, dst: &Path) -> Result<(), String> {
|
||||
std::fs::copy(src, dst).map_err(|e| format!("copying cookie store: {}", e))?;
|
||||
for suffix in ["-wal", "-shm"] {
|
||||
let s = path_with_suffix(src, suffix);
|
||||
if s.is_file() {
|
||||
let _ = std::fs::copy(&s, path_with_suffix(dst, suffix));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn path_with_suffix(p: &Path, suffix: &str) -> PathBuf {
|
||||
let mut s = p.as_os_str().to_os_string();
|
||||
s.push(suffix);
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// Build a `WHERE host_key LIKE '%domain'` clause from a comma-separated filter.
|
||||
/// Domains are validated (alnum/./-) so they can be inlined without injection.
|
||||
fn build_where(domain: Option<&str>) -> Result<String, String> {
|
||||
let Some(domain) = domain else {
|
||||
return Ok(String::new());
|
||||
};
|
||||
let mut clauses = Vec::new();
|
||||
for d in domain.split(',') {
|
||||
let d = d.trim();
|
||||
if d.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !d
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
|
||||
{
|
||||
return Err(format!("invalid domain filter \"{}\"", d));
|
||||
}
|
||||
clauses.push(format!("host_key LIKE '%{}'", d));
|
||||
}
|
||||
if clauses.is_empty() {
|
||||
Ok(String::new())
|
||||
} else {
|
||||
Ok(format!(" WHERE {}", clauses.join(" OR ")))
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cdp_cookie(r: &CookieRow, value: String) -> Value {
|
||||
let mut o = serde_json::Map::new();
|
||||
o.insert("name".into(), json!(r.name));
|
||||
o.insert("value".into(), json!(value));
|
||||
o.insert("domain".into(), json!(r.host_key));
|
||||
o.insert("path".into(), json!(r.path));
|
||||
o.insert("secure".into(), json!(r.is_secure));
|
||||
o.insert("httpOnly".into(), json!(r.is_httponly));
|
||||
// Chrome SameSite: -1 unspecified, 0 None, 1 Lax, 2 Strict.
|
||||
let same_site = match r.samesite {
|
||||
0 => Some("None"),
|
||||
1 => Some("Lax"),
|
||||
2 => Some("Strict"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ss) = same_site {
|
||||
// CDP rejects SameSite=None without Secure; downgrade rather than fail.
|
||||
if ss == "None" && !r.is_secure {
|
||||
o.insert("sameSite".into(), json!("Lax"));
|
||||
} else {
|
||||
o.insert("sameSite".into(), json!(ss));
|
||||
}
|
||||
}
|
||||
if let Some(unix) = chrome_epoch_to_unix(r.expires_utc) {
|
||||
o.insert("expires".into(), json!(unix));
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Chrome stores `expires_utc` as microseconds since 1601-01-01 (0 = session
|
||||
/// cookie). CDP wants seconds since the Unix epoch. Returns None for session
|
||||
/// cookies and anything that converts to a non-positive time.
|
||||
fn chrome_epoch_to_unix(expires_utc: i64) -> Option<f64> {
|
||||
if expires_utc <= 0 {
|
||||
return None;
|
||||
}
|
||||
let unix = expires_utc as f64 / 1_000_000.0 - 11_644_473_600.0;
|
||||
if unix > 0.0 {
|
||||
Some(unix)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt a Chrome `v10` cookie value (AES-128-CBC, IV = 16 spaces, PKCS7).
|
||||
/// Returns None for unrecognized schemes or undecryptable values.
|
||||
fn decrypt_value(enc: &[u8], key: &[u8; 16]) -> Option<String> {
|
||||
if enc.len() < 3 || &enc[0..3] != b"v10" {
|
||||
return None;
|
||||
}
|
||||
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
|
||||
type Dec = cbc::Decryptor<aes::Aes128>;
|
||||
let iv = [0x20u8; 16];
|
||||
let mut buf = enc[3..].to_vec();
|
||||
let pt = Dec::new(key.into(), &iv.into())
|
||||
.decrypt_padded_mut::<Pkcs7>(&mut buf)
|
||||
.ok()?;
|
||||
// Chrome >=24 prepends a 32-byte SHA256(host) domain hash to the plaintext.
|
||||
match std::str::from_utf8(pt) {
|
||||
Ok(s) => Some(s.to_string()),
|
||||
Err(_) if pt.len() > 32 => Some(String::from_utf8_lossy(&pt[32..]).into_owned()),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn safe_storage_key() -> Result<[u8; 16], String> {
|
||||
use pbkdf2::pbkdf2_hmac;
|
||||
use sha1::Sha1;
|
||||
let out = std::process::Command::new("security")
|
||||
.args(["find-generic-password", "-ws", "Chrome Safe Storage"])
|
||||
.output()
|
||||
.map_err(|e| format!("could not read Keychain (security command): {}", e))?;
|
||||
if !out.status.success() {
|
||||
return Err(
|
||||
"could not read the 'Chrome Safe Storage' key from Keychain \
|
||||
(you may be prompted to allow access — approve it and retry)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let pw = String::from_utf8_lossy(&out.stdout);
|
||||
let pw = pw.trim_end_matches('\n');
|
||||
let mut key = [0u8; 16];
|
||||
pbkdf2_hmac::<Sha1>(pw.as_bytes(), b"saltysalt", 1003, &mut key);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn safe_storage_key() -> Result<[u8; 16], String> {
|
||||
Err("cookies export/transfer is currently supported on macOS only".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn where_clause_filters_and_validates() {
|
||||
assert_eq!(build_where(None).unwrap(), "");
|
||||
assert_eq!(
|
||||
build_where(Some("claude.ai")).unwrap(),
|
||||
" WHERE host_key LIKE '%claude.ai'"
|
||||
);
|
||||
assert_eq!(
|
||||
build_where(Some("claude.ai, anthropic.com")).unwrap(),
|
||||
" WHERE host_key LIKE '%claude.ai' OR host_key LIKE '%anthropic.com'"
|
||||
);
|
||||
assert!(build_where(Some("evil' OR 1=1 --")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_conversion() {
|
||||
assert_eq!(chrome_epoch_to_unix(0), None);
|
||||
assert_eq!(chrome_epoch_to_unix(-5), None);
|
||||
// 13380163200000000 us since 1601 == 2025-01-01T00:00:00Z (1735689600 unix)
|
||||
assert_eq!(
|
||||
chrome_epoch_to_unix(13_380_163_200_000_000),
|
||||
Some(1_735_689_600.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_cdp_downgrades_samesite_none_without_secure() {
|
||||
let row = CookieRow {
|
||||
host_key: ".claude.ai".into(),
|
||||
name: "x".into(),
|
||||
encrypted_value: vec![],
|
||||
path: "/".into(),
|
||||
is_secure: false,
|
||||
is_httponly: true,
|
||||
samesite: 0, // None
|
||||
expires_utc: 0,
|
||||
};
|
||||
let c = to_cdp_cookie(&row, "v".into());
|
||||
assert_eq!(c["sameSite"], "Lax");
|
||||
assert_eq!(c["httpOnly"], true);
|
||||
assert_eq!(c.get("expires"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Check the Chrome install: binary path, version, cache dirs, user-data
|
||||
//! dir, and the optional lightpanda engine.
|
||||
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::helpers::which_exists;
|
||||
use super::{Check, Status};
|
||||
|
||||
pub(super) fn check(checks: &mut Vec<Check>) {
|
||||
let category = "Chrome";
|
||||
|
||||
let chrome = crate::native::cdp::chrome::find_chrome();
|
||||
match chrome {
|
||||
Some(path) => {
|
||||
let label = path.display().to_string();
|
||||
match query_chrome_version(&path) {
|
||||
Some(version) => checks.push(Check::new(
|
||||
"chrome.installed",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("{} at {}", version, label),
|
||||
)),
|
||||
None => checks.push(Check::new(
|
||||
"chrome.installed",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("Chrome at {} (version unknown)", label),
|
||||
)),
|
||||
}
|
||||
}
|
||||
None => checks.push(
|
||||
Check::new(
|
||||
"chrome.installed",
|
||||
category,
|
||||
Status::Fail,
|
||||
"No Chrome binary found",
|
||||
)
|
||||
.with_fix("chrome-use install"),
|
||||
),
|
||||
}
|
||||
|
||||
let cache_dir = crate::install::get_browsers_dir();
|
||||
if cache_dir.exists() {
|
||||
checks.push(Check::new(
|
||||
"chrome.cache_dir",
|
||||
category,
|
||||
Status::Info,
|
||||
format!("Cache dir {}", cache_dir.display()),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(puppeteer_dir) = puppeteer_cache_dir() {
|
||||
if puppeteer_dir.exists() {
|
||||
checks.push(Check::new(
|
||||
"chrome.puppeteer_cache",
|
||||
category,
|
||||
Status::Info,
|
||||
format!(
|
||||
"Puppeteer cache also present: {} (will be used as a fallback)",
|
||||
puppeteer_dir.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(user_data_dir) = crate::native::cdp::chrome::find_chrome_user_data_dir() {
|
||||
let profiles = crate::native::cdp::chrome::list_chrome_profiles(&user_data_dir);
|
||||
let count = profiles.len();
|
||||
let dir_label = user_data_dir.display().to_string();
|
||||
if count == 0 {
|
||||
checks.push(Check::new(
|
||||
"chrome.user_data_dir",
|
||||
category,
|
||||
Status::Info,
|
||||
format!(
|
||||
"Chrome user data dir found ({}), no profiles parsed",
|
||||
dir_label
|
||||
),
|
||||
));
|
||||
} else {
|
||||
checks.push(Check::new(
|
||||
"chrome.user_data_dir",
|
||||
category,
|
||||
Status::Info,
|
||||
format!("{} Chrome profile(s) at {}", count, dir_label),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(engine) = env::var("AGENT_BROWSER_ENGINE") {
|
||||
if engine == "lightpanda" {
|
||||
// Best-effort PATH lookup; absence is FAIL only when the user
|
||||
// explicitly opted into the lightpanda engine.
|
||||
if which_exists("lightpanda") {
|
||||
checks.push(Check::new(
|
||||
"chrome.engine_lightpanda",
|
||||
category,
|
||||
Status::Pass,
|
||||
"Lightpanda binary on PATH",
|
||||
));
|
||||
} else {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"chrome.engine_lightpanda",
|
||||
category,
|
||||
Status::Fail,
|
||||
"AGENT_BROWSER_ENGINE=lightpanda but no lightpanda binary on PATH",
|
||||
)
|
||||
.with_fix("install lightpanda or unset AGENT_BROWSER_ENGINE"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn query_chrome_version(path: &Path) -> Option<String> {
|
||||
let output = std::process::Command::new(path)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn puppeteer_cache_dir() -> Option<PathBuf> {
|
||||
if let Ok(p) = env::var("PUPPETEER_CACHE_DIR") {
|
||||
return Some(PathBuf::from(p));
|
||||
}
|
||||
dirs::home_dir().map(|h| h.join(".cache").join("puppeteer"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_puppeteer_cache_dir_returns_sensible_default() {
|
||||
// When PUPPETEER_CACHE_DIR is unset, we fall back to
|
||||
// ~/.cache/puppeteer. Mutating env vars here would race with other
|
||||
// tests, so just verify the fallback path is shaped correctly.
|
||||
if env::var("PUPPETEER_CACHE_DIR").is_err() {
|
||||
let dir = puppeteer_cache_dir().expect("home dir should resolve in tests");
|
||||
let s = dir.to_string_lossy();
|
||||
assert!(s.contains(".cache"));
|
||||
assert!(s.ends_with("puppeteer"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Check user config files: `~/.chrome-use/config.json`,
|
||||
//! `./chrome-use.json`, and any file referenced by
|
||||
//! `AGENT_BROWSER_CONFIG`.
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::helpers::parse_json_file;
|
||||
use super::{Check, Status};
|
||||
|
||||
pub(super) fn check(checks: &mut Vec<Check>) {
|
||||
let category = "Config";
|
||||
|
||||
let user_path = dirs::home_dir().map(|d| d.join(".chrome-use").join("config.json"));
|
||||
if let Some(p) = user_path {
|
||||
if p.exists() {
|
||||
match parse_json_file(&p) {
|
||||
Ok(_) => checks.push(Check::new(
|
||||
"config.user",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("{} (valid JSON)", p.display()),
|
||||
)),
|
||||
Err(e) => checks.push(
|
||||
Check::new(
|
||||
"config.user",
|
||||
category,
|
||||
Status::Fail,
|
||||
format!("{}: {}", p.display(), e),
|
||||
)
|
||||
.with_fix(format!("edit {}", p.display())),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let project_path = PathBuf::from("chrome-use.json");
|
||||
if project_path.exists() {
|
||||
match parse_json_file(&project_path) {
|
||||
Ok(_) => checks.push(Check::new(
|
||||
"config.project",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("{} (valid JSON)", project_path.display()),
|
||||
)),
|
||||
Err(e) => checks.push(
|
||||
Check::new(
|
||||
"config.project",
|
||||
category,
|
||||
Status::Fail,
|
||||
format!("{}: {}", project_path.display(), e),
|
||||
)
|
||||
.with_fix(format!("edit {}", project_path.display())),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(custom) = env::var("AGENT_BROWSER_CONFIG") {
|
||||
let p = PathBuf::from(&custom);
|
||||
if !p.exists() {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"config.custom",
|
||||
category,
|
||||
Status::Fail,
|
||||
format!("AGENT_BROWSER_CONFIG points to missing file: {}", custom),
|
||||
)
|
||||
.with_fix("update or unset AGENT_BROWSER_CONFIG"),
|
||||
);
|
||||
} else {
|
||||
match parse_json_file(&p) {
|
||||
Ok(_) => checks.push(Check::new(
|
||||
"config.custom",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("AGENT_BROWSER_CONFIG: {} (valid JSON)", custom),
|
||||
)),
|
||||
Err(e) => checks.push(
|
||||
Check::new(
|
||||
"config.custom",
|
||||
category,
|
||||
Status::Fail,
|
||||
format!("AGENT_BROWSER_CONFIG: {}: {}", custom, e),
|
||||
)
|
||||
.with_fix(format!("edit {}", custom)),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Check running daemons: inventory of sessions, version match with the
|
||||
//! CLI, and stale sidecar files cleaned up as a side effect of the walk.
|
||||
|
||||
use super::{Check, Status};
|
||||
use crate::connection::{walk_daemons, CleanReason};
|
||||
|
||||
pub(super) fn check(checks: &mut Vec<Check>) {
|
||||
let category = "Daemons";
|
||||
let cli_version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
let inventory = walk_daemons();
|
||||
|
||||
for cleaned in &inventory.cleaned {
|
||||
let reason = match cleaned.reason {
|
||||
CleanReason::ProcessGone | CleanReason::DashboardGone => "process gone",
|
||||
CleanReason::UnreadablePidFile => "unreadable pid file",
|
||||
CleanReason::OrphanedSocket => "orphaned socket",
|
||||
};
|
||||
checks.push(Check::new(
|
||||
format!("daemon.cleaned.{}", cleaned.name),
|
||||
category,
|
||||
Status::Warn,
|
||||
format!("Cleaned stale files: {} ({})", cleaned.name, reason),
|
||||
));
|
||||
}
|
||||
|
||||
if inventory.sessions.is_empty() {
|
||||
checks.push(Check::new(
|
||||
"daemon.active",
|
||||
category,
|
||||
Status::Pass,
|
||||
"No active daemons",
|
||||
));
|
||||
} else {
|
||||
for session in &inventory.sessions {
|
||||
let version_match = session.version.as_deref() == Some(cli_version);
|
||||
let status = if version_match {
|
||||
Status::Pass
|
||||
} else {
|
||||
Status::Warn
|
||||
};
|
||||
let suffix = if version_match {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" (version mismatch with CLI {})", cli_version)
|
||||
};
|
||||
let mut check = Check::new(
|
||||
format!("daemon.session.{}", session.name),
|
||||
category,
|
||||
status,
|
||||
format!("Session {} (pid {}){}", session.name, session.pid, suffix),
|
||||
);
|
||||
if !version_match {
|
||||
check = check.with_fix(format!("chrome-use --session {} close", session.name));
|
||||
}
|
||||
checks.push(check);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(dashboard) = inventory.dashboard {
|
||||
if dashboard.alive {
|
||||
checks.push(Check::new(
|
||||
"daemon.dashboard",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("Dashboard server running (pid {})", dashboard.pid),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Check the local environment: CLI version, platform, state/socket dirs,
|
||||
//! and free disk space.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::helpers::{disk_free_bytes, human_size, is_writable_dir};
|
||||
use super::{Check, Status};
|
||||
use crate::connection::get_socket_dir;
|
||||
use crate::native::state::get_state_dir;
|
||||
|
||||
pub(super) fn check(checks: &mut Vec<Check>) {
|
||||
let category = "Environment";
|
||||
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let platform = format!("{} {}", std::env::consts::OS, std::env::consts::ARCH);
|
||||
checks.push(Check::new(
|
||||
"env.version",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("CLI version {} ({})", version, platform),
|
||||
));
|
||||
|
||||
match dirs::home_dir() {
|
||||
Some(home) => checks.push(Check::new(
|
||||
"env.home",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("Home directory {}", home.display()),
|
||||
)),
|
||||
None => checks.push(Check::new(
|
||||
"env.home",
|
||||
category,
|
||||
Status::Fail,
|
||||
"Could not determine home directory",
|
||||
)),
|
||||
}
|
||||
|
||||
let state_dir = get_state_dir();
|
||||
let socket_dir = get_socket_dir();
|
||||
|
||||
// Under the default setup, state and socket dirs are the same
|
||||
// (~/.chrome-use). Collapse to a single line when they match;
|
||||
// split when XDG_RUNTIME_DIR or AGENT_BROWSER_SOCKET_DIR diverts
|
||||
// sockets elsewhere.
|
||||
if state_dir == socket_dir {
|
||||
push_dir_check(
|
||||
checks,
|
||||
"env.state_dir",
|
||||
category,
|
||||
"State and socket directory",
|
||||
&state_dir,
|
||||
);
|
||||
} else {
|
||||
push_dir_check(
|
||||
checks,
|
||||
"env.state_dir",
|
||||
category,
|
||||
"State directory",
|
||||
&state_dir,
|
||||
);
|
||||
push_dir_check(
|
||||
checks,
|
||||
"env.socket_dir",
|
||||
category,
|
||||
"Socket directory",
|
||||
&socket_dir,
|
||||
);
|
||||
}
|
||||
|
||||
match disk_free_bytes(&state_dir) {
|
||||
Some(bytes) => {
|
||||
let mb = bytes / (1024 * 1024);
|
||||
let human = human_size(bytes);
|
||||
if mb < 500 {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"env.disk_free",
|
||||
category,
|
||||
Status::Warn,
|
||||
format!("Low disk space at state dir: {} free", human),
|
||||
)
|
||||
.with_fix("free up disk space; Chrome installs require ~500 MB"),
|
||||
);
|
||||
} else {
|
||||
checks.push(Check::new(
|
||||
"env.disk_free",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("{} free at state dir", human),
|
||||
));
|
||||
}
|
||||
}
|
||||
None => checks.push(Check::new(
|
||||
"env.disk_free",
|
||||
category,
|
||||
Status::Info,
|
||||
"Disk free check unavailable on this platform",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_dir_check(
|
||||
checks: &mut Vec<Check>,
|
||||
id: &'static str,
|
||||
category: &'static str,
|
||||
label: &str,
|
||||
dir: &Path,
|
||||
) {
|
||||
if dir.exists() {
|
||||
if is_writable_dir(dir) {
|
||||
checks.push(Check::new(
|
||||
id,
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("{} {}", label, dir.display()),
|
||||
));
|
||||
} else {
|
||||
checks.push(
|
||||
Check::new(
|
||||
id,
|
||||
category,
|
||||
Status::Fail,
|
||||
format!("{} not writable: {}", label, dir.display()),
|
||||
)
|
||||
.with_fix(format!("chmod u+rwx {}", dir.display())),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
checks.push(Check::new(
|
||||
id,
|
||||
category,
|
||||
Status::Info,
|
||||
format!(
|
||||
"{} does not exist yet (will be created on first use): {}",
|
||||
label,
|
||||
dir.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Destructive repair actions behind `--fix`: reinstall Chrome, close
|
||||
//! version-mismatched daemons, purge expired state files, and generate a
|
||||
//! missing encryption key.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::helpers::new_id;
|
||||
use super::{Check, Status};
|
||||
use crate::connection::{cleanup_stale_files, send_command, walk_daemons};
|
||||
use crate::native::state::{get_sessions_dir, get_state_dir};
|
||||
|
||||
pub(super) fn run(checks: &mut [Check], fixed: &mut Vec<String>) {
|
||||
// `close_all_sessions` is expensive and closes every session at once, so
|
||||
// only fire it on the first daemon.session.* Warn we encounter. Subsequent
|
||||
// daemon.session.* Warn checks piggy-back on the same result.
|
||||
let mut daemons_closed: Option<usize> = None;
|
||||
|
||||
for c in checks.iter_mut() {
|
||||
match c.id.as_str() {
|
||||
"chrome.installed" if c.status == Status::Fail => {
|
||||
let installed = attempt_chrome_install();
|
||||
if installed {
|
||||
fixed.push("Reinstalled Chrome".to_string());
|
||||
c.status = Status::Pass;
|
||||
c.message = format!("{} (fixed by --fix)", c.message);
|
||||
c.fix = None;
|
||||
}
|
||||
}
|
||||
id if id.starts_with("daemon.session.") && c.status == Status::Warn => {
|
||||
let killed = *daemons_closed.get_or_insert_with(|| {
|
||||
let n = close_all_sessions();
|
||||
if n > 0 {
|
||||
fixed.push(format!("Closed {} version-mismatched daemon(s)", n));
|
||||
}
|
||||
n
|
||||
});
|
||||
if killed > 0 {
|
||||
c.status = Status::Pass;
|
||||
c.message = format!("{} (fixed by --fix)", c.message);
|
||||
c.fix = None;
|
||||
}
|
||||
}
|
||||
"security.state_count" if c.status == Status::Warn => {
|
||||
let removed = purge_old_state();
|
||||
if removed > 0 {
|
||||
fixed.push(format!("Deleted {} expired state file(s)", removed));
|
||||
c.status = Status::Pass;
|
||||
c.message = format!("{} (fixed by --fix)", c.message);
|
||||
c.fix = None;
|
||||
}
|
||||
}
|
||||
"security.encryption_key" if c.status == Status::Info => {
|
||||
let generated = create_encryption_key();
|
||||
if generated {
|
||||
fixed.push("Generated encryption key".to_string());
|
||||
c.status = Status::Pass;
|
||||
c.message = format!("{} (fixed by --fix)", c.message);
|
||||
c.fix = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn attempt_chrome_install() -> bool {
|
||||
// run_install() uses process::exit on failure, so we shell out to ourselves
|
||||
// to avoid taking down the doctor process if the install fails.
|
||||
let exe = match std::env::current_exe() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return false,
|
||||
};
|
||||
std::process::Command::new(exe)
|
||||
.arg("install")
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn close_all_sessions() -> usize {
|
||||
let mut killed = 0;
|
||||
for session in &walk_daemons().sessions {
|
||||
let cmd = json!({ "id": new_id(), "action": "close" });
|
||||
if send_command(cmd, &session.name).is_ok() {
|
||||
killed += 1;
|
||||
}
|
||||
cleanup_stale_files(&session.name);
|
||||
}
|
||||
killed
|
||||
}
|
||||
|
||||
fn purge_old_state() -> usize {
|
||||
let dir = get_sessions_dir();
|
||||
let expire_days = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(30);
|
||||
let cutoff = SystemTime::now()
|
||||
.checked_sub(Duration::from_secs(expire_days * 86_400))
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
let mut removed = 0;
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
if let Ok(modified) = meta.modified() {
|
||||
if modified < cutoff && fs::remove_file(entry.path()).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
fn create_encryption_key() -> bool {
|
||||
create_encryption_key_at(&get_state_dir())
|
||||
}
|
||||
|
||||
fn create_encryption_key_at(dir: &Path) -> bool {
|
||||
if fs::create_dir_all(dir).is_err() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
let path = dir.join(".encryption-key");
|
||||
if path.exists() {
|
||||
return false;
|
||||
}
|
||||
let mut buf = [0u8; 32];
|
||||
if getrandom::getrandom(&mut buf).is_err() {
|
||||
return false;
|
||||
}
|
||||
let hex: String = buf.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
if fs::write(&path, format!("{}\n", hex)).is_err() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_create_encryption_key_at_writes_64_char_hex_key() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("state");
|
||||
|
||||
assert!(create_encryption_key_at(&dir));
|
||||
|
||||
let key = dir.join(".encryption-key");
|
||||
assert!(key.exists(), "key file should be created");
|
||||
|
||||
let contents = fs::read_to_string(&key).unwrap();
|
||||
let trimmed = contents.trim();
|
||||
assert_eq!(trimmed.len(), 64, "key should be 64 hex chars");
|
||||
assert!(
|
||||
trimmed.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"key should be all hex digits"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_encryption_key_at_is_idempotent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("state");
|
||||
assert!(create_encryption_key_at(&dir));
|
||||
|
||||
let original = fs::read_to_string(dir.join(".encryption-key")).unwrap();
|
||||
|
||||
// Second call returns false and must not overwrite the existing key.
|
||||
assert!(!create_encryption_key_at(&dir));
|
||||
let after = fs::read_to_string(dir.join(".encryption-key")).unwrap();
|
||||
assert_eq!(original, after);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_create_encryption_key_at_sets_0600_perms() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("state");
|
||||
|
||||
assert!(create_encryption_key_at(&dir));
|
||||
|
||||
let key = dir.join(".encryption-key");
|
||||
let mode = fs::metadata(&key).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "key file should be 0600, got {:o}", mode);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_run_fixes_generates_missing_encryption_key() {
|
||||
// Reaches the Info-status arm in run_fixes that was previously
|
||||
// unreachable due to an early-continue guard. Overrides HOME so
|
||||
// get_state_dir() resolves under a temp dir.
|
||||
let guard = crate::test_utils::EnvGuard::new(&["HOME"]);
|
||||
let tmp = TempDir::new().unwrap();
|
||||
guard.set("HOME", tmp.path().to_str().unwrap());
|
||||
|
||||
let mut checks = vec![Check::new(
|
||||
"security.encryption_key",
|
||||
"Security",
|
||||
Status::Info,
|
||||
"No encryption key set",
|
||||
)
|
||||
.with_fix("export AGENT_BROWSER_ENCRYPTION_KEY=...")];
|
||||
let mut fixed = Vec::new();
|
||||
|
||||
run(&mut checks, &mut fixed);
|
||||
|
||||
assert_eq!(
|
||||
checks[0].status,
|
||||
Status::Pass,
|
||||
"Info check should transition to Pass after --fix"
|
||||
);
|
||||
assert!(
|
||||
checks[0].fix.is_none(),
|
||||
"fix hint should be cleared after repair"
|
||||
);
|
||||
assert!(
|
||||
fixed.iter().any(|s| s.contains("encryption key")),
|
||||
"fixed summary should mention the key generation"
|
||||
);
|
||||
assert!(
|
||||
tmp.path().join(".chrome-use/.encryption-key").exists(),
|
||||
"key file should exist at ~/.chrome-use/.encryption-key"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Stateless helpers shared across doctor submodules.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
pub(super) fn is_writable_dir(path: &Path) -> bool {
|
||||
fs::metadata(path)
|
||||
.map(|m| !m.permissions().readonly())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(super) fn human_size(bytes: u64) -> String {
|
||||
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
|
||||
let mut value = bytes as f64;
|
||||
let mut unit = 0;
|
||||
while value >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
value /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 {
|
||||
format!("{} {}", bytes, UNITS[0])
|
||||
} else {
|
||||
format!("{:.1} {}", value, UNITS[unit])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(super) fn disk_free_bytes(path: &Path) -> Option<u64> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Walk up to the first existing ancestor (for fresh installs where the
|
||||
// state dir hasn't been created yet).
|
||||
let mut probe: PathBuf = path.to_path_buf();
|
||||
while !probe.exists() {
|
||||
match probe.parent() {
|
||||
Some(p) => probe = p.to_path_buf(),
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
let c_path = CString::new(probe.as_os_str().as_bytes()).ok()?;
|
||||
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(stat.f_bavail as u64 * stat.f_frsize)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(super) fn disk_free_bytes(_path: &Path) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(super) fn disk_free_bytes(_path: &Path) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn which_exists(name: &str) -> bool {
|
||||
let probe = if cfg!(target_os = "windows") {
|
||||
"where"
|
||||
} else {
|
||||
"which"
|
||||
};
|
||||
std::process::Command::new(probe)
|
||||
.arg(name)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(super) fn parse_json_file(path: &Path) -> Result<(), String> {
|
||||
let content = fs::read_to_string(path).map_err(|e| format!("read failed: {}", e))?;
|
||||
serde_json::from_str::<Value>(&content).map_err(|e| format!("invalid JSON: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a unique `doctor-<pid>-<micros>-<sequence>` id for JSON command envelopes.
|
||||
pub(super) fn new_id() -> String {
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
|
||||
let sequence = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
format!(
|
||||
"doctor-{}-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_micros())
|
||||
.unwrap_or(0),
|
||||
sequence
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_human_size_units() {
|
||||
assert_eq!(human_size(0), "0 B");
|
||||
assert_eq!(human_size(512), "512 B");
|
||||
assert_eq!(human_size(1024), "1.0 KB");
|
||||
assert_eq!(human_size(1024 * 1024), "1.0 MB");
|
||||
assert_eq!(human_size(1024 * 1024 * 1024), "1.0 GB");
|
||||
assert_eq!(human_size(1_500_000), "1.4 MB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disk_free_walks_up_to_existing_ancestor() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let nested = dir.path().join("a/b/c/d");
|
||||
let bytes = disk_free_bytes(&nested);
|
||||
if cfg!(unix) {
|
||||
assert!(bytes.is_some());
|
||||
assert!(bytes.unwrap() > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_writable_dir_matches_metadata() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
assert!(is_writable_dir(dir.path()));
|
||||
|
||||
let missing = dir.path().join("does-not-exist");
|
||||
assert!(!is_writable_dir(&missing));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_which_exists_matches_common_binaries() {
|
||||
// `sh` exists on every unix; `cmd` exists on windows.
|
||||
let probe = if cfg!(target_os = "windows") {
|
||||
"cmd"
|
||||
} else {
|
||||
"sh"
|
||||
};
|
||||
assert!(which_exists(probe));
|
||||
assert!(!which_exists(
|
||||
"chrome-use-this-does-not-exist-please-dont-install-it"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_json_file_valid_and_invalid() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let valid = dir.path().join("ok.json");
|
||||
fs::write(&valid, r#"{"k": 1}"#).unwrap();
|
||||
assert!(parse_json_file(&valid).is_ok());
|
||||
|
||||
let invalid = dir.path().join("bad.json");
|
||||
fs::write(&invalid, "{not json}").unwrap();
|
||||
let err = parse_json_file(&invalid).unwrap_err();
|
||||
assert!(err.contains("invalid JSON"));
|
||||
|
||||
let missing = dir.path().join("nope.json");
|
||||
let err = parse_json_file(&missing).unwrap_err();
|
||||
assert!(err.contains("read failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_json_file_accepts_arrays() {
|
||||
// The config parser rejects arrays at the Config type level, but
|
||||
// doctor only checks syntactic JSON validity so it should accept
|
||||
// both arrays and objects.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("arr.json");
|
||||
fs::write(&path, r#"[1, 2, 3]"#).unwrap();
|
||||
assert!(parse_json_file(&path).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_id_is_unique_per_call() {
|
||||
let a = new_id();
|
||||
let b = new_id();
|
||||
assert_ne!(a, b);
|
||||
assert!(a.starts_with("doctor-"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Live launch test: spawn a scratch daemon session, launch headless
|
||||
//! Chrome, navigate to `about:blank`, then close. Skipped under `--quick`.
|
||||
//!
|
||||
//! A `LaunchGuard` Drop impl ensures the scratch session is closed and its
|
||||
//! sidecar files cleaned even on panic or early return.
|
||||
|
||||
use std::env;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::helpers::new_id;
|
||||
use super::{Check, Status};
|
||||
use crate::connection::{cleanup_stale_files, ensure_daemon, send_command, DaemonOptions};
|
||||
|
||||
pub(super) fn check(checks: &mut Vec<Check>) {
|
||||
let category = "Launch test";
|
||||
|
||||
if env::var("AGENT_BROWSER_PROVIDER").is_ok() {
|
||||
checks.push(Check::new(
|
||||
"launch.skipped.provider",
|
||||
category,
|
||||
Status::Info,
|
||||
"Skipped (AGENT_BROWSER_PROVIDER is set; would consume cloud quota)",
|
||||
));
|
||||
return;
|
||||
}
|
||||
if env::var("AGENT_BROWSER_CDP").is_ok() {
|
||||
checks.push(Check::new(
|
||||
"launch.skipped.cdp",
|
||||
category,
|
||||
Status::Info,
|
||||
"Skipped (AGENT_BROWSER_CDP is set; would attach to a real browser)",
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
let session = format!(
|
||||
"doctor-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
|
||||
// Armed after `ensure_daemon` succeeds so we don't send a stray `close`
|
||||
// or delete sidecar files for a daemon that never started. On every early
|
||||
// return past the `Some(...)` assignment below, Drop runs one close and
|
||||
// one `cleanup_stale_files`.
|
||||
let mut _guard: Option<LaunchGuard> = None;
|
||||
|
||||
let opts = DaemonOptions {
|
||||
headed: false,
|
||||
debug: false,
|
||||
executable_path: None,
|
||||
extensions: &[],
|
||||
init_scripts: &[],
|
||||
enable: &[],
|
||||
args: None,
|
||||
user_agent: None,
|
||||
proxy: None,
|
||||
proxy_bypass: None,
|
||||
proxy_username: None,
|
||||
proxy_password: None,
|
||||
ignore_https_errors: false,
|
||||
allow_file_access: false,
|
||||
hide_scrollbars: true,
|
||||
profile: None,
|
||||
state: None,
|
||||
provider: None,
|
||||
device: None,
|
||||
session_name: None,
|
||||
download_path: None,
|
||||
allowed_domains: None,
|
||||
action_policy: None,
|
||||
confirm_actions: None,
|
||||
engine: None,
|
||||
auto_connect: false,
|
||||
force_launch: false,
|
||||
idle_timeout: None,
|
||||
default_timeout: None,
|
||||
cdp: None,
|
||||
no_auto_dialog: false,
|
||||
};
|
||||
|
||||
let started = Instant::now();
|
||||
if let Err(e) = ensure_daemon(&session, &opts) {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"launch.daemon",
|
||||
category,
|
||||
Status::Fail,
|
||||
format!("Could not start daemon: {}", e),
|
||||
)
|
||||
.with_fix("check Chrome install and re-run with --debug"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_guard = Some(LaunchGuard {
|
||||
session: session.clone(),
|
||||
});
|
||||
|
||||
let launch_cmd = json!({
|
||||
"id": new_id(),
|
||||
"action": "launch",
|
||||
"headless": true,
|
||||
});
|
||||
if let Err(e) = send_json(launch_cmd, &session) {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"launch.launch",
|
||||
category,
|
||||
Status::Fail,
|
||||
format!("Browser launch failed: {}", e),
|
||||
)
|
||||
.with_fix("chrome-use install # or check --debug output"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let open_cmd = json!({
|
||||
"id": new_id(),
|
||||
"action": "navigate",
|
||||
"url": "about:blank",
|
||||
});
|
||||
if let Err(e) = send_json(open_cmd, &session) {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"launch.navigate",
|
||||
category,
|
||||
Status::Fail,
|
||||
format!("Navigation to about:blank failed: {}", e),
|
||||
)
|
||||
.with_fix("re-run with --debug for full launch logs"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Close + stale-file cleanup happen exactly once via LaunchGuard::drop at
|
||||
// end of scope; no explicit close here.
|
||||
let elapsed = started.elapsed();
|
||||
let secs = elapsed.as_secs_f64();
|
||||
if elapsed > Duration::from_secs(5) {
|
||||
checks.push(Check::new(
|
||||
"launch.elapsed",
|
||||
category,
|
||||
Status::Warn,
|
||||
format!(
|
||||
"Headless launch + about:blank in {:.2}s (slow; expected < 5s)",
|
||||
secs
|
||||
),
|
||||
));
|
||||
} else {
|
||||
checks.push(Check::new(
|
||||
"launch.elapsed",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("Headless launch + about:blank in {:.2}s", secs),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn send_json(cmd: Value, session: &str) -> Result<(), String> {
|
||||
match send_command(cmd, session) {
|
||||
Ok(resp) => {
|
||||
if resp.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(resp.error.unwrap_or_else(|| "unknown error".to_string()))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort cleanup when the launch test panics or returns early.
|
||||
struct LaunchGuard {
|
||||
session: String,
|
||||
}
|
||||
|
||||
impl Drop for LaunchGuard {
|
||||
fn drop(&mut self) {
|
||||
let close_cmd = json!({ "id": new_id(), "action": "close" });
|
||||
let _ = send_command(close_cmd, &self.session);
|
||||
cleanup_stale_files(&self.session);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Diagnose an chrome-use installation.
|
||||
//!
|
||||
//! Runs a battery of checks across environment, Chrome install, daemon
|
||||
//! state, config files, encryption, providers, network reachability, and
|
||||
//! a live headless browser launch test.
|
||||
//!
|
||||
//! Auto-cleans stale daemon socket/pid/version sidecar files. Destructive
|
||||
//! repairs (reinstalling Chrome, purging old state files, generating a
|
||||
//! missing encryption key) are gated behind `--fix`.
|
||||
|
||||
mod chrome;
|
||||
mod config;
|
||||
mod daemon;
|
||||
mod environment;
|
||||
mod fix;
|
||||
mod helpers;
|
||||
mod launch;
|
||||
mod network;
|
||||
mod providers;
|
||||
mod security;
|
||||
mod versions;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::color;
|
||||
|
||||
#[derive(Default, Clone, Copy)]
|
||||
pub struct DoctorOptions {
|
||||
pub offline: bool,
|
||||
pub quick: bool,
|
||||
pub fix: bool,
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
#[repr(u8)]
|
||||
pub(crate) enum Status {
|
||||
Pass,
|
||||
Warn,
|
||||
Fail,
|
||||
Info,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Status::Pass => "pass",
|
||||
Status::Warn => "warn",
|
||||
Status::Fail => "fail",
|
||||
Status::Info => "info",
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
match self {
|
||||
Status::Pass => color::green("pass"),
|
||||
Status::Warn => color::yellow("warn"),
|
||||
Status::Fail => color::red("fail"),
|
||||
Status::Info => color::dim("info"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Check {
|
||||
pub id: String,
|
||||
pub category: &'static str,
|
||||
pub status: Status,
|
||||
pub message: String,
|
||||
pub fix: Option<String>,
|
||||
}
|
||||
|
||||
impl Check {
|
||||
fn new(
|
||||
id: impl Into<String>,
|
||||
category: &'static str,
|
||||
status: Status,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
category,
|
||||
status,
|
||||
message: message.into(),
|
||||
fix: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_fix(mut self, fix: impl Into<String>) -> Self {
|
||||
self.fix = Some(fix.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the doctor command. Returns the process exit code.
|
||||
pub fn run_doctor(opts: DoctorOptions) -> i32 {
|
||||
let mut checks: Vec<Check> = Vec::new();
|
||||
let mut fixed: Vec<String> = Vec::new();
|
||||
|
||||
environment::check(&mut checks);
|
||||
versions::check(&mut checks);
|
||||
chrome::check(&mut checks);
|
||||
daemon::check(&mut checks);
|
||||
config::check(&mut checks);
|
||||
security::check(&mut checks);
|
||||
providers::check(&mut checks);
|
||||
|
||||
if !opts.offline {
|
||||
network::check(&mut checks);
|
||||
}
|
||||
|
||||
if !opts.quick {
|
||||
launch::check(&mut checks);
|
||||
}
|
||||
|
||||
if opts.fix {
|
||||
fix::run(&mut checks, &mut fixed);
|
||||
}
|
||||
|
||||
let summary = summarize(&checks);
|
||||
let exit_code = if summary.fail > 0 { 1 } else { 0 };
|
||||
|
||||
if opts.json {
|
||||
print_json(&checks, &summary, &fixed, exit_code == 0);
|
||||
} else {
|
||||
print_text(&checks, &summary, &fixed, opts.fix);
|
||||
}
|
||||
|
||||
exit_code
|
||||
}
|
||||
|
||||
struct Summary {
|
||||
pass: usize,
|
||||
warn: usize,
|
||||
fail: usize,
|
||||
}
|
||||
|
||||
fn summarize(checks: &[Check]) -> Summary {
|
||||
let mut s = Summary {
|
||||
pass: 0,
|
||||
warn: 0,
|
||||
fail: 0,
|
||||
};
|
||||
for c in checks {
|
||||
match c.status {
|
||||
Status::Pass => s.pass += 1,
|
||||
Status::Warn => s.warn += 1,
|
||||
Status::Fail => s.fail += 1,
|
||||
Status::Info => {}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn print_text(checks: &[Check], summary: &Summary, fixed: &[String], fix_ran: bool) {
|
||||
println!("{}", color::bold("chrome-use doctor"));
|
||||
|
||||
let mut current_category = "";
|
||||
for c in checks {
|
||||
if c.category != current_category {
|
||||
current_category = c.category;
|
||||
println!();
|
||||
println!("{}", color::bold(current_category));
|
||||
}
|
||||
println!(" {} {}", c.status.label(), c.message);
|
||||
if let Some(fix) = &c.fix {
|
||||
println!(" {} {}", color::dim("fix:"), fix);
|
||||
}
|
||||
}
|
||||
|
||||
if !fixed.is_empty() {
|
||||
println!();
|
||||
println!("{}", color::bold("Fixed"));
|
||||
for line in fixed {
|
||||
println!(" {} {}", color::green("done"), line);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
let line = format!(
|
||||
"Summary: {} pass, {} warn, {} fail",
|
||||
summary.pass, summary.warn, summary.fail
|
||||
);
|
||||
if summary.fail > 0 {
|
||||
println!("{}", color::red(&line));
|
||||
} else if summary.warn > 0 {
|
||||
println!("{}", color::yellow(&line));
|
||||
} else {
|
||||
println!("{}", color::green(&line));
|
||||
}
|
||||
|
||||
if !fix_ran && checks.iter().any(|c| c.fix.is_some()) {
|
||||
println!();
|
||||
println!(
|
||||
"{} Run with {} to attempt repairs.",
|
||||
color::dim("tip:"),
|
||||
color::bold("--fix")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_json(checks: &[Check], summary: &Summary, fixed: &[String], success: bool) {
|
||||
let checks_json: Vec<Value> = checks
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut obj = json!({
|
||||
"id": c.id,
|
||||
"category": c.category,
|
||||
"status": c.status.as_str(),
|
||||
"message": c.message,
|
||||
});
|
||||
if let Some(fix) = &c.fix {
|
||||
obj["fix"] = json!(fix);
|
||||
}
|
||||
obj
|
||||
})
|
||||
.collect();
|
||||
|
||||
let payload = json!({
|
||||
"success": success,
|
||||
"summary": {
|
||||
"pass": summary.pass,
|
||||
"warn": summary.warn,
|
||||
"fail": summary.fail,
|
||||
},
|
||||
"checks": checks_json,
|
||||
"fixed": fixed,
|
||||
});
|
||||
println!("{}", payload);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_summary_counts_each_status() {
|
||||
let checks = vec![
|
||||
Check::new("a", "Cat", Status::Pass, "ok"),
|
||||
Check::new("b", "Cat", Status::Pass, "ok"),
|
||||
Check::new("c", "Cat", Status::Warn, "meh"),
|
||||
Check::new("d", "Cat", Status::Fail, "no"),
|
||||
Check::new("e", "Cat", Status::Info, "fyi"),
|
||||
];
|
||||
let s = summarize(&checks);
|
||||
assert_eq!(s.pass, 2);
|
||||
assert_eq!(s.warn, 1);
|
||||
assert_eq!(s.fail, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary_zeroes_when_only_info() {
|
||||
let checks = vec![Check::new("a", "Cat", Status::Info, "ignored")];
|
||||
let s = summarize(&checks);
|
||||
assert_eq!(s.pass, 0);
|
||||
assert_eq!(s.warn, 0);
|
||||
assert_eq!(s.fail, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_label_does_not_panic() {
|
||||
for s in &[Status::Pass, Status::Warn, Status::Fail, Status::Info] {
|
||||
assert!(!s.label().is_empty());
|
||||
assert!(!s.as_str().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_as_str_values() {
|
||||
assert_eq!(Status::Pass.as_str(), "pass");
|
||||
assert_eq!(Status::Warn.as_str(), "warn");
|
||||
assert_eq!(Status::Fail.as_str(), "fail");
|
||||
assert_eq!(Status::Info.as_str(), "info");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_new_and_with_fix() {
|
||||
let c = Check::new("id", "cat", Status::Warn, "msg").with_fix("do thing");
|
||||
assert_eq!(c.id, "id");
|
||||
assert_eq!(c.category, "cat");
|
||||
assert_eq!(c.status, Status::Warn);
|
||||
assert_eq!(c.message, "msg");
|
||||
assert_eq!(c.fix.as_deref(), Some("do thing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_new_no_fix_by_default() {
|
||||
let c = Check::new("id", "cat", Status::Pass, "msg");
|
||||
assert!(c.fix.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Probe reachability of the Chrome for Testing CDN, AI Gateway (if
|
||||
//! configured), and the currently-selected provider endpoint. Each probe
|
||||
//! has a 3-second timeout.
|
||||
|
||||
use std::env;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{Check, Status};
|
||||
|
||||
pub(super) fn check(checks: &mut Vec<Check>) {
|
||||
let category = "Network";
|
||||
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
checks.push(Check::new(
|
||||
"net.runtime",
|
||||
category,
|
||||
Status::Fail,
|
||||
format!("Could not start tokio runtime for probes: {}", e),
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.user_agent(format!("chrome-use/{}", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(Duration::from_secs(3))
|
||||
.connect_timeout(Duration::from_secs(3))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
checks.push(Check::new(
|
||||
"net.client",
|
||||
category,
|
||||
Status::Fail,
|
||||
format!("Could not build HTTP client: {}", e),
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let chrome_url =
|
||||
"https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
|
||||
probe_url(
|
||||
&rt,
|
||||
&client,
|
||||
checks,
|
||||
category,
|
||||
"net.chrome_cdn",
|
||||
chrome_url,
|
||||
"Chrome for Testing CDN",
|
||||
);
|
||||
|
||||
if env::var("AI_GATEWAY_API_KEY").is_ok() {
|
||||
let url = env::var("AI_GATEWAY_URL")
|
||||
.unwrap_or_else(|_| "https://ai-gateway.vercel.sh".to_string());
|
||||
probe_url(
|
||||
&rt,
|
||||
&client,
|
||||
checks,
|
||||
category,
|
||||
"net.ai_gateway",
|
||||
&url,
|
||||
"AI Gateway",
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(provider) = env::var("AGENT_BROWSER_PROVIDER") {
|
||||
let url: Option<String> = match provider.to_lowercase().as_str() {
|
||||
"browserbase" => Some("https://api.browserbase.com".to_string()),
|
||||
"browserless" => Some(
|
||||
env::var("BROWSERLESS_API_URL")
|
||||
.unwrap_or_else(|_| "https://production-sfo.browserless.io".to_string()),
|
||||
),
|
||||
"browseruse" | "browser-use" => Some("https://api.browser-use.com".to_string()),
|
||||
"kernel" => Some(
|
||||
env::var("KERNEL_ENDPOINT")
|
||||
.unwrap_or_else(|_| "https://api.onkernel.com".to_string()),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(url) = url {
|
||||
probe_url(
|
||||
&rt,
|
||||
&client,
|
||||
checks,
|
||||
category,
|
||||
"net.provider",
|
||||
&url,
|
||||
&format!("Provider {}", provider),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_url(
|
||||
rt: &tokio::runtime::Runtime,
|
||||
client: &reqwest::Client,
|
||||
checks: &mut Vec<Check>,
|
||||
category: &'static str,
|
||||
id: &'static str,
|
||||
url: &str,
|
||||
label: &str,
|
||||
) {
|
||||
let started = Instant::now();
|
||||
let result = rt.block_on(async { client.head(url).send().await });
|
||||
let elapsed_ms = started.elapsed().as_millis();
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status.is_success() || status.is_redirection() || status.as_u16() == 405 {
|
||||
checks.push(Check::new(
|
||||
id,
|
||||
category,
|
||||
Status::Pass,
|
||||
format!(
|
||||
"{} reachable ({}ms, HTTP {})",
|
||||
label,
|
||||
elapsed_ms,
|
||||
status.as_u16()
|
||||
),
|
||||
));
|
||||
} else {
|
||||
checks.push(Check::new(
|
||||
id,
|
||||
category,
|
||||
Status::Warn,
|
||||
format!(
|
||||
"{} returned HTTP {} after {}ms",
|
||||
label,
|
||||
status.as_u16(),
|
||||
elapsed_ms
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
checks.push(
|
||||
Check::new(
|
||||
id,
|
||||
category,
|
||||
Status::Fail,
|
||||
format!("{} unreachable after {}ms: {}", label, elapsed_ms, e),
|
||||
)
|
||||
.with_fix("check network connectivity / firewall / proxy settings"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Check remote browser providers: API key presence for Browserless,
|
||||
//! Browserbase, Browser Use, Kernel, AgentCore (AWS), Appium for iOS, and
|
||||
//! the AI Gateway chat key. Info-level unless the provider is selected
|
||||
//! via `AGENT_BROWSER_PROVIDER`.
|
||||
|
||||
use std::env;
|
||||
|
||||
use super::helpers::which_exists;
|
||||
use super::{Check, Status};
|
||||
|
||||
pub(super) fn check(checks: &mut Vec<Check>) {
|
||||
let category = "Providers";
|
||||
|
||||
let active = env::var("AGENT_BROWSER_PROVIDER").ok();
|
||||
let normalized = active
|
||||
.as_ref()
|
||||
.map(|s| s.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
let active_status = |provider: &str, ok: bool| -> Status {
|
||||
if normalized == provider {
|
||||
if ok {
|
||||
Status::Pass
|
||||
} else {
|
||||
Status::Fail
|
||||
}
|
||||
} else {
|
||||
Status::Info
|
||||
}
|
||||
};
|
||||
|
||||
let providers: &[(&str, &[&str], &str)] = &[
|
||||
("browserless", &["BROWSERLESS_API_KEY"], "Browserless"),
|
||||
("browserbase", &["BROWSERBASE_API_KEY"], "Browserbase"),
|
||||
("browseruse", &["BROWSER_USE_API_KEY"], "Browser Use"),
|
||||
("kernel", &["KERNEL_API_KEY"], "Kernel"),
|
||||
];
|
||||
|
||||
for (id, env_keys, label) in providers {
|
||||
let present = env_keys.iter().any(|k| env::var(k).is_ok());
|
||||
let provider_id = *id;
|
||||
let status = active_status(provider_id, present);
|
||||
let msg = if present {
|
||||
format!("{}: API key present", label)
|
||||
} else {
|
||||
format!("{}: {} not set", label, env_keys.join(" / "))
|
||||
};
|
||||
let mut check = Check::new(format!("providers.{}", provider_id), category, status, msg);
|
||||
if status == Status::Fail {
|
||||
check = check.with_fix(format!(
|
||||
"set {} (or unset AGENT_BROWSER_PROVIDER={})",
|
||||
env_keys.first().copied().unwrap_or(""),
|
||||
provider_id
|
||||
));
|
||||
}
|
||||
checks.push(check);
|
||||
}
|
||||
|
||||
let aws_present = env::var("AWS_ACCESS_KEY_ID").is_ok()
|
||||
|| env::var("AWS_PROFILE").is_ok()
|
||||
|| env::var("AWS_SESSION_TOKEN").is_ok();
|
||||
let agentcore_status = active_status("agentcore", aws_present);
|
||||
let mut agentcore_check = Check::new(
|
||||
"providers.agentcore",
|
||||
category,
|
||||
agentcore_status,
|
||||
if aws_present {
|
||||
"AgentCore: AWS credentials resolvable".to_string()
|
||||
} else {
|
||||
"AgentCore: no AWS credentials in env (AWS_ACCESS_KEY_ID / AWS_PROFILE)".to_string()
|
||||
},
|
||||
);
|
||||
if agentcore_status == Status::Fail {
|
||||
agentcore_check = agentcore_check
|
||||
.with_fix("export AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY or AWS_PROFILE");
|
||||
}
|
||||
checks.push(agentcore_check);
|
||||
|
||||
if normalized == "ios" {
|
||||
if which_exists("appium") {
|
||||
checks.push(Check::new(
|
||||
"providers.ios",
|
||||
category,
|
||||
Status::Pass,
|
||||
"iOS: appium binary on PATH",
|
||||
));
|
||||
} else {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"providers.ios",
|
||||
category,
|
||||
Status::Fail,
|
||||
"iOS: appium binary not found on PATH",
|
||||
)
|
||||
.with_fix("npm install -g appium && appium driver install xcuitest"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let chat_key_present = env::var("AI_GATEWAY_API_KEY").is_ok();
|
||||
if chat_key_present {
|
||||
checks.push(Check::new(
|
||||
"providers.chat",
|
||||
category,
|
||||
Status::Info,
|
||||
"AI_GATEWAY_API_KEY present (chat enabled)",
|
||||
));
|
||||
} else {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"providers.chat",
|
||||
category,
|
||||
Status::Info,
|
||||
"AI_GATEWAY_API_KEY not set (chat command disabled)",
|
||||
)
|
||||
.with_fix("export AI_GATEWAY_API_KEY=gw_..."),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(active) = active {
|
||||
checks.push(Check::new(
|
||||
"providers.active",
|
||||
category,
|
||||
Status::Info,
|
||||
format!("AGENT_BROWSER_PROVIDER = {}", active),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Check security posture: encryption key presence / permissions, saved
|
||||
//! state file age, and the optional action policy file.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use super::helpers::parse_json_file;
|
||||
use super::{Check, Status};
|
||||
use crate::native::state::{get_sessions_dir, get_state_dir};
|
||||
|
||||
pub(super) fn check(checks: &mut Vec<Check>) {
|
||||
let category = "Security";
|
||||
|
||||
let key_env = env::var("AGENT_BROWSER_ENCRYPTION_KEY").ok();
|
||||
let key_file = get_state_dir().join(".encryption-key");
|
||||
if let Some(hex) = &key_env {
|
||||
if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
checks.push(Check::new(
|
||||
"security.encryption_key",
|
||||
category,
|
||||
Status::Pass,
|
||||
"AGENT_BROWSER_ENCRYPTION_KEY set (64-char hex)",
|
||||
));
|
||||
} else {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"security.encryption_key",
|
||||
category,
|
||||
Status::Fail,
|
||||
"AGENT_BROWSER_ENCRYPTION_KEY is not a 64-char hex string",
|
||||
)
|
||||
.with_fix("export AGENT_BROWSER_ENCRYPTION_KEY=$(openssl rand -hex 32)"),
|
||||
);
|
||||
}
|
||||
} else if key_file.exists() {
|
||||
let mut msg = format!("Encryption key file present: {}", key_file.display());
|
||||
let mut status = Status::Pass;
|
||||
let mut fix: Option<String> = None;
|
||||
#[cfg(unix)]
|
||||
if let Ok(meta) = fs::metadata(&key_file) {
|
||||
let mode = meta.permissions().mode() & 0o777;
|
||||
if mode & 0o077 != 0 {
|
||||
status = Status::Warn;
|
||||
msg = format!(
|
||||
"Encryption key file is too permissive ({:o}): {}",
|
||||
mode,
|
||||
key_file.display()
|
||||
);
|
||||
fix = Some(format!("chmod 600 {}", key_file.display()));
|
||||
}
|
||||
}
|
||||
let mut check = Check::new("security.encryption_key", category, status, msg);
|
||||
if let Some(f) = fix {
|
||||
check = check.with_fix(f);
|
||||
}
|
||||
checks.push(check);
|
||||
} else {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"security.encryption_key",
|
||||
category,
|
||||
Status::Info,
|
||||
"No encryption key set (will be auto-generated on first auth save)",
|
||||
)
|
||||
.with_fix("export AGENT_BROWSER_ENCRYPTION_KEY=$(openssl rand -hex 32)"),
|
||||
);
|
||||
}
|
||||
|
||||
let sessions_dir = get_sessions_dir();
|
||||
if sessions_dir.exists() {
|
||||
let expire_days = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(30);
|
||||
let cutoff = SystemTime::now()
|
||||
.checked_sub(Duration::from_secs(expire_days * 86_400))
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
let mut total = 0usize;
|
||||
let mut old = 0usize;
|
||||
if let Ok(entries) = fs::read_dir(&sessions_dir) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
|
||||
total += 1;
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
if let Ok(modified) = meta.modified() {
|
||||
if modified < cutoff {
|
||||
old += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
checks.push(Check::new(
|
||||
"security.state_count",
|
||||
category,
|
||||
Status::Info,
|
||||
"No saved state files",
|
||||
));
|
||||
} else if old > 0 {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"security.state_count",
|
||||
category,
|
||||
Status::Warn,
|
||||
format!(
|
||||
"{} state file(s) older than {} days ({} total)",
|
||||
old, expire_days, total
|
||||
),
|
||||
)
|
||||
.with_fix(format!(
|
||||
"chrome-use state clean --older-than {}",
|
||||
expire_days
|
||||
)),
|
||||
);
|
||||
} else {
|
||||
checks.push(Check::new(
|
||||
"security.state_count",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("{} saved state file(s)", total),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(policy_path) = env::var("AGENT_BROWSER_ACTION_POLICY") {
|
||||
let p = PathBuf::from(&policy_path);
|
||||
if !p.exists() {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"security.action_policy",
|
||||
category,
|
||||
Status::Fail,
|
||||
format!(
|
||||
"AGENT_BROWSER_ACTION_POLICY points to missing file: {}",
|
||||
policy_path
|
||||
),
|
||||
)
|
||||
.with_fix("update or unset AGENT_BROWSER_ACTION_POLICY"),
|
||||
);
|
||||
} else {
|
||||
match parse_json_file(&p) {
|
||||
Ok(_) => checks.push(Check::new(
|
||||
"security.action_policy",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("Action policy: {}", policy_path),
|
||||
)),
|
||||
Err(e) => checks.push(
|
||||
Check::new(
|
||||
"security.action_policy",
|
||||
category,
|
||||
Status::Fail,
|
||||
format!("Action policy: {}: {}", policy_path, e),
|
||||
)
|
||||
.with_fix(format!("edit {}", policy_path)),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Version-coherence checks across all four moving parts: the CLI binary, the
|
||||
//! per-session daemons (covered by `daemon.rs`), the connected `ab-connect`
|
||||
//! extension, and the bundled skill. The extension was previously a black box —
|
||||
//! nothing reported which build was live — so a user could sit on an old
|
||||
//! extension with no signal. The extension now reports its version over the
|
||||
//! relay (`hello`), the host records it, and this surfaces it in one place.
|
||||
|
||||
use super::{Check, Status};
|
||||
use crate::{connect, upgrade};
|
||||
|
||||
pub(super) fn check(checks: &mut Vec<Check>) {
|
||||
let category = "Versions";
|
||||
let cli_version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
// CLI — compare against the latest seen by the background update check.
|
||||
match upgrade::cached_latest_version() {
|
||||
Some(latest) if upgrade::version_is_newer(&latest, cli_version) => {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"versions.cli",
|
||||
category,
|
||||
Status::Warn,
|
||||
format!("CLI {cli_version} (newer available: {latest})"),
|
||||
)
|
||||
.with_fix("chrome-use upgrade".to_string()),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
checks.push(Check::new(
|
||||
"versions.cli",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("CLI {cli_version}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Extension — the build this CLI shipped alongside (embedded at compile time
|
||||
// from the extension manifest) is what we expect to be running.
|
||||
let expected_ext = env!("AB_CONNECT_VERSION");
|
||||
match connect::relay_ext_version() {
|
||||
Some(ext) if upgrade::version_is_newer(expected_ext, &ext) => {
|
||||
checks.push(
|
||||
Check::new(
|
||||
"versions.extension",
|
||||
category,
|
||||
Status::Warn,
|
||||
format!("extension {ext} is behind the bundled {expected_ext}"),
|
||||
)
|
||||
.with_fix(
|
||||
"update ab-connect in Chrome: chrome://extensions \u{2192} reload \
|
||||
(or wait for the Web Store auto-update)"
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
}
|
||||
Some(ext) => {
|
||||
checks.push(Check::new(
|
||||
"versions.extension",
|
||||
category,
|
||||
Status::Pass,
|
||||
format!("extension {ext}"),
|
||||
));
|
||||
}
|
||||
None => {
|
||||
checks.push(Check::new(
|
||||
"versions.extension",
|
||||
category,
|
||||
Status::Info,
|
||||
format!(
|
||||
"extension not connected (or it predates version reporting — \
|
||||
expected {expected_ext})"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Skill — ships inside the same release artifact as the binary, so it's
|
||||
// version-locked here. Copies made elsewhere via `skills add` aren't.
|
||||
checks.push(Check::new(
|
||||
"versions.skill",
|
||||
category,
|
||||
Status::Info,
|
||||
format!(
|
||||
"skills bundled with this CLI ({cli_version}); copies made via `skills add` \
|
||||
elsewhere may be stale — re-run to refresh"
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! `find-url` — search the user's local Chrome/Edge **bookmarks** for pages they
|
||||
//! saved, by keyword. Borrowed from web-access's `find-url.mjs`; lets an agent
|
||||
//! locate an internal system or a previously-saved page that public search
|
||||
//! can't reach, without opening a browser.
|
||||
//!
|
||||
//! v1 covers bookmarks only (a zero-dependency JSON read). Visited-history lives
|
||||
//! in a locked SQLite DB and would need a SQLite dependency — not included yet.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::color;
|
||||
|
||||
struct Hit {
|
||||
name: String,
|
||||
url: String,
|
||||
folder: String,
|
||||
date_added: i64,
|
||||
}
|
||||
|
||||
/// Entry point for the `find-url` subcommand. `args` is the full cleaned argv
|
||||
/// (including the leading "find-url").
|
||||
pub fn run_find_url(args: &[String], json: bool) {
|
||||
// Parse flags out of args[1..]; everything else is a keyword.
|
||||
let mut browser = "chrome".to_string();
|
||||
let mut profile = "Default".to_string();
|
||||
let mut limit: usize = 20;
|
||||
let mut keywords: Vec<String> = Vec::new();
|
||||
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--browser" => {
|
||||
if let Some(v) = args.get(i + 1) {
|
||||
browser = v.to_lowercase();
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--profile" => {
|
||||
if let Some(v) = args.get(i + 1) {
|
||||
profile = v.clone();
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--limit" => {
|
||||
if let Some(v) = args.get(i + 1).and_then(|s| s.parse::<usize>().ok()) {
|
||||
limit = v;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--json" => {}
|
||||
other if other.starts_with("--") => {}
|
||||
other => keywords.push(other.to_lowercase()),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let path = match bookmarks_path(&browser, &profile) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
emit_error(
|
||||
json,
|
||||
&format!("Could not locate {browser} bookmarks for profile '{profile}'"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let raw = match std::fs::read_to_string(&path) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
emit_error(json, &format!("Failed to read {}: {e}", path.display()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let root: Value = match serde_json::from_str(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
emit_error(json, &format!("Failed to parse bookmarks JSON: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut hits: Vec<Hit> = Vec::new();
|
||||
if let Some(roots) = root.get("roots").and_then(|r| r.as_object()) {
|
||||
for node in roots.values() {
|
||||
walk(node, "", &keywords, &mut hits);
|
||||
}
|
||||
}
|
||||
|
||||
// Most-recently-added first (date_added is microseconds since 1601).
|
||||
hits.sort_by_key(|b| std::cmp::Reverse(b.date_added));
|
||||
hits.truncate(limit);
|
||||
|
||||
if json {
|
||||
let arr: Vec<Value> = hits
|
||||
.iter()
|
||||
.map(|h| {
|
||||
serde_json::json!({
|
||||
"name": h.name,
|
||||
"url": h.url,
|
||||
"folder": h.folder,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"success": true,
|
||||
"data": { "results": arr, "count": hits.len() },
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if hits.is_empty() {
|
||||
let kw = if keywords.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" matching {:?}", keywords.join(" "))
|
||||
};
|
||||
println!("No {browser} bookmarks found{kw}.");
|
||||
return;
|
||||
}
|
||||
for h in &hits {
|
||||
if h.folder.is_empty() {
|
||||
println!("{}\n {}", h.name, h.url);
|
||||
} else {
|
||||
println!("{} ({})\n {}", h.name, h.folder, h.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively walk a bookmark node, collecting URL entries that match every
|
||||
/// keyword (in name or url). Empty keyword list matches everything.
|
||||
fn walk(node: &Value, folder: &str, keywords: &[String], out: &mut Vec<Hit>) {
|
||||
match node.get("type").and_then(|t| t.as_str()) {
|
||||
Some("url") => {
|
||||
let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let url = node.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
// Skip non-navigable bookmarks: javascript: bookmarklets and data:
|
||||
// URIs aren't pages you can visit, and their bodies can be huge.
|
||||
if url.is_empty() || url.starts_with("javascript:") || url.starts_with("data:") {
|
||||
return;
|
||||
}
|
||||
let hay = format!("{} {}", name.to_lowercase(), url.to_lowercase());
|
||||
if keywords.iter().all(|k| hay.contains(k.as_str())) {
|
||||
let date_added = node
|
||||
.get("date_added")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
out.push(Hit {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
folder: folder.to_string(),
|
||||
date_added,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some("folder") => {
|
||||
let fname = node.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let child_folder = if folder.is_empty() {
|
||||
fname.to_string()
|
||||
} else {
|
||||
format!("{folder}/{fname}")
|
||||
};
|
||||
if let Some(children) = node.get("children").and_then(|c| c.as_array()) {
|
||||
for child in children {
|
||||
walk(child, &child_folder, keywords, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the Bookmarks file path for a browser + profile across platforms.
|
||||
fn bookmarks_path(browser: &str, profile: &str) -> Option<PathBuf> {
|
||||
let base = browser_user_data_dir(browser)?;
|
||||
let path = base.join(profile).join("Bookmarks");
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The "User Data" directory that holds per-profile folders, per OS/browser.
|
||||
fn browser_user_data_dir(browser: &str) -> Option<PathBuf> {
|
||||
let is_edge = browser == "edge" || browser == "msedge";
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let app_support = dirs::config_dir()?; // ~/Library/Application Support
|
||||
let sub = if is_edge {
|
||||
"Microsoft Edge"
|
||||
} else {
|
||||
"Google/Chrome"
|
||||
};
|
||||
Some(app_support.join(sub))
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let local = dirs::data_local_dir()?; // %LOCALAPPDATA%
|
||||
let sub = if is_edge {
|
||||
"Microsoft/Edge/User Data"
|
||||
} else {
|
||||
"Google/Chrome/User Data"
|
||||
};
|
||||
Some(local.join(sub))
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
let config = dirs::config_dir()?; // ~/.config
|
||||
let sub = if is_edge {
|
||||
"microsoft-edge"
|
||||
} else {
|
||||
"google-chrome"
|
||||
};
|
||||
Some(config.join(sub))
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_error(json: bool, msg: &str) {
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"success": false,
|
||||
"error": msg,
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
eprintln!("{} {msg}", color::error_indicator());
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
+205
-6
@@ -4,9 +4,9 @@ use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const CONFIG_DIR: &str = ".agent-browser";
|
||||
const CONFIG_DIR: &str = ".chrome-use";
|
||||
const CONFIG_FILENAME: &str = "config.json";
|
||||
const PROJECT_CONFIG_FILENAME: &str = "agent-browser.json";
|
||||
const PROJECT_CONFIG_FILENAME: &str = "chrome-use.json";
|
||||
|
||||
/// Parse idle timeout from user-friendly format.
|
||||
/// Supports: "10s" (seconds), "3m" (minutes), "1h" (hours), or raw milliseconds.
|
||||
@@ -60,6 +60,8 @@ pub struct Config {
|
||||
pub session_name: Option<String>,
|
||||
pub executable_path: Option<String>,
|
||||
pub extensions: Option<Vec<String>>,
|
||||
pub init_scripts: Option<Vec<String>>,
|
||||
pub enable: Option<Vec<String>>,
|
||||
pub profile: Option<String>,
|
||||
pub state: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
@@ -68,6 +70,7 @@ pub struct Config {
|
||||
pub user_agent: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub device: Option<String>,
|
||||
pub hide_scrollbars: Option<bool>,
|
||||
pub ignore_https_errors: Option<bool>,
|
||||
pub allow_file_access: Option<bool>,
|
||||
pub cdp: Option<String>,
|
||||
@@ -88,6 +91,7 @@ pub struct Config {
|
||||
pub screenshot_format: Option<String>,
|
||||
pub idle_timeout: Option<String>,
|
||||
pub no_auto_dialog: Option<bool>,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -106,6 +110,20 @@ impl Config {
|
||||
}
|
||||
(a, b) => b.or(a),
|
||||
},
|
||||
init_scripts: match (self.init_scripts, other.init_scripts) {
|
||||
(Some(mut a), Some(b)) => {
|
||||
a.extend(b);
|
||||
Some(a)
|
||||
}
|
||||
(a, b) => b.or(a),
|
||||
},
|
||||
enable: match (self.enable, other.enable) {
|
||||
(Some(mut a), Some(b)) => {
|
||||
a.extend(b);
|
||||
Some(a)
|
||||
}
|
||||
(a, b) => b.or(a),
|
||||
},
|
||||
profile: other.profile.or(self.profile),
|
||||
state: other.state.or(self.state),
|
||||
proxy: other.proxy.or(self.proxy),
|
||||
@@ -114,6 +132,7 @@ impl Config {
|
||||
user_agent: other.user_agent.or(self.user_agent),
|
||||
provider: other.provider.or(self.provider),
|
||||
device: other.device.or(self.device),
|
||||
hide_scrollbars: other.hide_scrollbars.or(self.hide_scrollbars),
|
||||
ignore_https_errors: other.ignore_https_errors.or(self.ignore_https_errors),
|
||||
allow_file_access: other.allow_file_access.or(self.allow_file_access),
|
||||
cdp: other.cdp.or(self.cdp),
|
||||
@@ -134,6 +153,7 @@ impl Config {
|
||||
screenshot_format: other.screenshot_format.or(self.screenshot_format),
|
||||
idle_timeout: other.idle_timeout.or(self.idle_timeout),
|
||||
no_auto_dialog: other.no_auto_dialog.or(self.no_auto_dialog),
|
||||
model: other.model.or(self.model),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,6 +189,12 @@ fn env_var_is_truthy(name: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn env_var_bool(name: &str) -> Option<bool> {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.map(|val| !matches!(val.to_lowercase().as_str(), "0" | "false" | "no" | ""))
|
||||
}
|
||||
|
||||
/// Parse an optional boolean value after a flag. Returns (value, consumed_next_arg).
|
||||
/// Recognizes "true" as true, "false" as false. Bare flag defaults to true.
|
||||
fn parse_bool_arg(args: &[String], i: usize) -> (bool, bool) {
|
||||
@@ -198,6 +224,8 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
"--executable-path",
|
||||
"--cdp",
|
||||
"--extension",
|
||||
"--init-script",
|
||||
"--enable",
|
||||
"--profile",
|
||||
"--state",
|
||||
"--proxy",
|
||||
@@ -219,6 +247,8 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
|
||||
"--screenshot-quality",
|
||||
"--screenshot-format",
|
||||
"--idle-timeout",
|
||||
"--model",
|
||||
"--humanize",
|
||||
];
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -274,6 +304,8 @@ pub struct Flags {
|
||||
pub executable_path: Option<String>,
|
||||
pub cdp: Option<String>,
|
||||
pub extensions: Vec<String>,
|
||||
pub init_scripts: Vec<String>,
|
||||
pub enable: Vec<String>,
|
||||
pub profile: Option<String>,
|
||||
pub state: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
@@ -283,8 +315,10 @@ pub struct Flags {
|
||||
pub provider: Option<String>,
|
||||
pub ignore_https_errors: bool,
|
||||
pub allow_file_access: bool,
|
||||
pub hide_scrollbars: bool,
|
||||
pub device: Option<String>,
|
||||
pub auto_connect: bool,
|
||||
pub force_launch: bool,
|
||||
pub session_name: Option<String>,
|
||||
pub annotate: bool,
|
||||
pub color_scheme: Option<String>,
|
||||
@@ -300,12 +334,18 @@ pub struct Flags {
|
||||
pub screenshot_quality: Option<u32>,
|
||||
pub screenshot_format: Option<String>,
|
||||
pub idle_timeout: Option<String>, // Canonical milliseconds string for AGENT_BROWSER_IDLE_TIMEOUT_MS
|
||||
pub default_timeout: Option<u64>, // AGENT_BROWSER_DEFAULT_TIMEOUT in ms
|
||||
pub no_auto_dialog: bool,
|
||||
pub model: Option<String>,
|
||||
pub verbose: bool,
|
||||
pub quiet: bool,
|
||||
|
||||
// Track which launch-time options were explicitly passed via CLI
|
||||
// (as opposed to being set only via environment variables)
|
||||
pub cli_executable_path: bool,
|
||||
pub cli_extensions: bool,
|
||||
pub cli_init_scripts: bool,
|
||||
pub cli_enable: bool,
|
||||
pub cli_profile: bool,
|
||||
pub cli_state: bool,
|
||||
pub cli_args: bool,
|
||||
@@ -313,6 +353,7 @@ pub struct Flags {
|
||||
pub cli_proxy: bool,
|
||||
pub cli_proxy_bypass: bool,
|
||||
pub cli_allow_file_access: bool,
|
||||
pub cli_hide_scrollbars: bool,
|
||||
pub cli_annotate: bool,
|
||||
pub cli_download_path: bool,
|
||||
pub cli_headed: bool,
|
||||
@@ -340,6 +381,38 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
config.extensions.unwrap_or_default()
|
||||
};
|
||||
|
||||
let init_scripts_env = env::var("AGENT_BROWSER_INIT_SCRIPTS")
|
||||
.ok()
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.map(|p| p.trim().to_string())
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let init_scripts = if !init_scripts_env.is_empty() {
|
||||
init_scripts_env
|
||||
} else {
|
||||
config.init_scripts.unwrap_or_default()
|
||||
};
|
||||
|
||||
let enable_env = env::var("AGENT_BROWSER_ENABLE")
|
||||
.ok()
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.map(|p| p.trim().to_string())
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let enable = if !enable_env.is_empty() {
|
||||
enable_env
|
||||
} else {
|
||||
config.enable.unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut flags = Flags {
|
||||
json: env_var_is_truthy("AGENT_BROWSER_JSON") || config.json.unwrap_or(false),
|
||||
headed: env_var_is_truthy("AGENT_BROWSER_HEADED") || config.headed.unwrap_or(false),
|
||||
@@ -354,6 +427,8 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
.or(config.executable_path),
|
||||
cdp: config.cdp,
|
||||
extensions,
|
||||
init_scripts,
|
||||
enable,
|
||||
profile: env::var("AGENT_BROWSER_PROFILE").ok().or(config.profile),
|
||||
state: env::var("AGENT_BROWSER_STATE").ok().or(config.state),
|
||||
proxy: env::var("AGENT_BROWSER_PROXY")
|
||||
@@ -379,9 +454,14 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
|| config.ignore_https_errors.unwrap_or(false),
|
||||
allow_file_access: env_var_is_truthy("AGENT_BROWSER_ALLOW_FILE_ACCESS")
|
||||
|| config.allow_file_access.unwrap_or(false),
|
||||
hide_scrollbars: env_var_bool("AGENT_BROWSER_HIDE_SCROLLBARS")
|
||||
.or(config.hide_scrollbars)
|
||||
.unwrap_or(true),
|
||||
device: env::var("AGENT_BROWSER_IOS_DEVICE").ok().or(config.device),
|
||||
auto_connect: env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
||||
|| config.auto_connect.unwrap_or(false),
|
||||
auto_connect: !env_var_is_truthy("AGENT_BROWSER_NO_AUTO_CONNECT")
|
||||
&& (env_var_is_truthy("AGENT_BROWSER_AUTO_CONNECT")
|
||||
|| config.auto_connect.unwrap_or(true)),
|
||||
force_launch: env_var_is_truthy("AGENT_BROWSER_FORCE_LAUNCH") || env::var("CI").is_ok(),
|
||||
session_name: env::var("AGENT_BROWSER_SESSION_NAME")
|
||||
.ok()
|
||||
.or(config.session_name),
|
||||
@@ -432,10 +512,18 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
"AGENT_BROWSER_IDLE_TIMEOUT_MS",
|
||||
)
|
||||
.or(config.idle_timeout),
|
||||
default_timeout: env::var("AGENT_BROWSER_DEFAULT_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok()),
|
||||
no_auto_dialog: env_var_is_truthy("AGENT_BROWSER_NO_AUTO_DIALOG")
|
||||
|| config.no_auto_dialog.unwrap_or(false),
|
||||
model: env::var("AI_GATEWAY_MODEL").ok().or(config.model),
|
||||
verbose: false,
|
||||
quiet: false,
|
||||
cli_executable_path: false,
|
||||
cli_extensions: false,
|
||||
cli_init_scripts: false,
|
||||
cli_enable: false,
|
||||
cli_profile: false,
|
||||
cli_state: false,
|
||||
cli_args: false,
|
||||
@@ -443,6 +531,7 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
cli_proxy: false,
|
||||
cli_proxy_bypass: false,
|
||||
cli_allow_file_access: false,
|
||||
cli_hide_scrollbars: false,
|
||||
cli_annotate: false,
|
||||
cli_download_path: false,
|
||||
cli_headed: false,
|
||||
@@ -512,6 +601,27 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--init-script" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.init_scripts.push(s.clone());
|
||||
flags.cli_init_scripts = true;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--enable" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
// Allow either repeated --enable foo --enable bar, or
|
||||
// a single --enable foo,bar comma-list for convenience.
|
||||
for item in s.split(',') {
|
||||
let trimmed = item.trim();
|
||||
if !trimmed.is_empty() {
|
||||
flags.enable.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
flags.cli_enable = true;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--cdp" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.cdp = Some(s.clone());
|
||||
@@ -581,6 +691,14 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--hide-scrollbars" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.hide_scrollbars = val;
|
||||
flags.cli_hide_scrollbars = true;
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--device" => {
|
||||
if let Some(d) = args.get(i + 1) {
|
||||
flags.device = Some(d.clone());
|
||||
@@ -590,10 +708,17 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
"--auto-connect" => {
|
||||
let (val, consumed) = parse_bool_arg(args, i);
|
||||
flags.auto_connect = val;
|
||||
if !val {
|
||||
flags.force_launch = true;
|
||||
}
|
||||
if consumed {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--launch" | "--new" => {
|
||||
flags.force_launch = true;
|
||||
flags.auto_connect = false;
|
||||
}
|
||||
"--session-name" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.session_name = Some(s.clone());
|
||||
@@ -672,6 +797,21 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--humanize" => {
|
||||
// Human-like input motion level (off|fast|human). Surface it as
|
||||
// AGENT_BROWSER_HUMANIZE so the daemon — spawned as a child that
|
||||
// inherits this process's env — picks it up and it overrides the
|
||||
// adaptive detector. Applies when the session's daemon launches.
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
match crate::native::humanize::HumanizeLevel::parse(s) {
|
||||
Some(_) => std::env::set_var("AGENT_BROWSER_HUMANIZE", s),
|
||||
None => eprintln!(
|
||||
"warning: --humanize must be off|fast|human, got {s:?} (ignored)"
|
||||
),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--screenshot-dir" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.screenshot_dir = Some(s.clone());
|
||||
@@ -715,6 +855,18 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"--model" => {
|
||||
if let Some(s) = args.get(i + 1) {
|
||||
flags.model = Some(s.clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"-v" | "--verbose" => {
|
||||
flags.verbose = true;
|
||||
}
|
||||
"-q" | "--quiet" => {
|
||||
flags.quiet = true;
|
||||
}
|
||||
"--config" => {
|
||||
// Already handled by load_config(); skip the value
|
||||
i += 1;
|
||||
@@ -737,11 +889,22 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--debug",
|
||||
"--ignore-https-errors",
|
||||
"--allow-file-access",
|
||||
"--hide-scrollbars",
|
||||
"--auto-connect",
|
||||
"--launch",
|
||||
"--new",
|
||||
"--annotate",
|
||||
"--content-boundaries",
|
||||
"--confirm-interactive",
|
||||
"--no-auto-dialog",
|
||||
"-v",
|
||||
"--verbose",
|
||||
"-q",
|
||||
"--quiet",
|
||||
// doctor-specific flags; harmless on other commands (ignored)
|
||||
"--offline",
|
||||
"--quick",
|
||||
"--fix",
|
||||
];
|
||||
// Global flags that always take a value (need to skip the next arg too)
|
||||
const GLOBAL_FLAGS_WITH_VALUE: &[&str] = &[
|
||||
@@ -750,6 +913,8 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--executable-path",
|
||||
"--cdp",
|
||||
"--extension",
|
||||
"--init-script",
|
||||
"--enable",
|
||||
"--profile",
|
||||
"--state",
|
||||
"--proxy",
|
||||
@@ -772,6 +937,8 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
"--screenshot-quality",
|
||||
"--screenshot-format",
|
||||
"--idle-timeout",
|
||||
"--model",
|
||||
"--humanize",
|
||||
];
|
||||
|
||||
let mut i = 0;
|
||||
@@ -805,6 +972,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
fn args(s: &str) -> Vec<String> {
|
||||
s.split_whitespace().map(String::from).collect()
|
||||
@@ -1048,6 +1216,7 @@ mod tests {
|
||||
"userAgent": "test-agent",
|
||||
"provider": "ios",
|
||||
"device": "iPhone 15",
|
||||
"hideScrollbars": false,
|
||||
"ignoreHttpsErrors": true,
|
||||
"allowFileAccess": true,
|
||||
"cdp": "9222",
|
||||
@@ -1073,6 +1242,7 @@ mod tests {
|
||||
assert_eq!(config.user_agent.as_deref(), Some("test-agent"));
|
||||
assert_eq!(config.provider.as_deref(), Some("ios"));
|
||||
assert_eq!(config.device.as_deref(), Some("iPhone 15"));
|
||||
assert_eq!(config.hide_scrollbars, Some(false));
|
||||
assert_eq!(config.ignore_https_errors, Some(true));
|
||||
assert_eq!(config.allow_file_access, Some(true));
|
||||
assert_eq!(config.cdp.as_deref(), Some("9222"));
|
||||
@@ -1174,7 +1344,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_load_config_missing_file_returns_none() {
|
||||
let result = read_config_file(&PathBuf::from("/nonexistent/agent-browser.json"));
|
||||
let result = read_config_file(&PathBuf::from("/nonexistent/chrome-use.json"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
@@ -1326,6 +1496,33 @@ mod tests {
|
||||
assert!(flags.cli_allow_file_access);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hide_scrollbars_default_true() {
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
|
||||
let flags = parse_flags(&args("open example.com"));
|
||||
assert!(flags.hide_scrollbars);
|
||||
assert!(!flags.cli_hide_scrollbars);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hide_scrollbars_false() {
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
|
||||
let flags = parse_flags(&args("--hide-scrollbars false open"));
|
||||
assert!(!flags.hide_scrollbars);
|
||||
assert!(flags.cli_hide_scrollbars);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hide_scrollbars_bare_defaults_true() {
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_HIDE_SCROLLBARS"]);
|
||||
guard.remove("AGENT_BROWSER_HIDE_SCROLLBARS");
|
||||
let flags = parse_flags(&args("--hide-scrollbars open"));
|
||||
assert!(flags.hide_scrollbars);
|
||||
assert!(flags.cli_hide_scrollbars);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_connect_false() {
|
||||
let flags = parse_flags(&args("--auto-connect false open"));
|
||||
@@ -1334,7 +1531,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_clean_args_removes_bool_flag_with_value() {
|
||||
let cleaned = clean_args(&args("--headed false --debug true open example.com"));
|
||||
let cleaned = clean_args(&args(
|
||||
"--headed false --debug true --hide-scrollbars false open example.com",
|
||||
));
|
||||
assert_eq!(cleaned, vec!["open", "example.com"]);
|
||||
}
|
||||
|
||||
|
||||
+281
-144
@@ -10,7 +10,7 @@ const LAST_KNOWN_GOOD_URL: &str =
|
||||
pub fn get_browsers_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".agent-browser")
|
||||
.join(".chrome-use")
|
||||
.join("browsers")
|
||||
}
|
||||
|
||||
@@ -183,9 +183,12 @@ fn platform_key() -> &'static str {
|
||||
}
|
||||
|
||||
async fn fetch_download_url() -> Result<(String, String), String> {
|
||||
let resp = reqwest::get(LAST_KNOWN_GOOD_URL)
|
||||
let client = http_client()?;
|
||||
let resp = client
|
||||
.get(LAST_KNOWN_GOOD_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch version info: {}", e))?;
|
||||
.map_err(|e| format!("Failed to fetch version info: {}", format_reqwest_error(&e)))?;
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
@@ -223,44 +226,110 @@ async fn fetch_download_url() -> Result<(String, String), String> {
|
||||
Ok((version, url))
|
||||
}
|
||||
|
||||
fn format_reqwest_error(e: &reqwest::Error) -> String {
|
||||
let mut msg = e.to_string();
|
||||
let mut source = std::error::Error::source(e);
|
||||
while let Some(cause) = source {
|
||||
msg.push_str(&format!(": {}", cause));
|
||||
source = std::error::Error::source(cause);
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
fn http_client() -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(format!("chrome-use/{}", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", format_reqwest_error(&e)))
|
||||
}
|
||||
|
||||
async fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
let resp = reqwest::get(url)
|
||||
.await
|
||||
.map_err(|e| format!("Download failed: {}", e))?;
|
||||
let client = http_client()?;
|
||||
let max_retries = 3;
|
||||
let mut last_err = String::new();
|
||||
|
||||
let total = resp.content_length();
|
||||
let mut bytes = Vec::new();
|
||||
let mut stream = resp;
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut last_pct: u64 = 0;
|
||||
for attempt in 0..max_retries {
|
||||
if attempt > 0 {
|
||||
eprintln!(
|
||||
" Retrying download (attempt {}/{})",
|
||||
attempt + 1,
|
||||
max_retries
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1 << attempt)).await;
|
||||
}
|
||||
|
||||
loop {
|
||||
let chunk = stream
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| format!("Download error: {}", e))?;
|
||||
match chunk {
|
||||
Some(data) => {
|
||||
downloaded += data.len() as u64;
|
||||
bytes.extend_from_slice(&data);
|
||||
let resp = match client.get(url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
last_err = format!("Download failed: {}", format_reqwest_error(&e));
|
||||
if e.is_connect() || e.is_timeout() {
|
||||
continue;
|
||||
}
|
||||
return Err(last_err);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(total) = total {
|
||||
let pct = (downloaded * 100) / total;
|
||||
if pct >= last_pct + 5 {
|
||||
last_pct = pct;
|
||||
let mb = downloaded as f64 / 1_048_576.0;
|
||||
let total_mb = total as f64 / 1_048_576.0;
|
||||
eprint!("\r {:.0}/{:.0} MB ({pct}%)", mb, total_mb);
|
||||
let _ = io::stderr().flush();
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
last_err = format!(
|
||||
"Download failed: server returned HTTP {} for {}",
|
||||
status, url
|
||||
);
|
||||
if status.is_server_error() {
|
||||
continue;
|
||||
}
|
||||
return Err(last_err);
|
||||
}
|
||||
|
||||
let total = resp.content_length();
|
||||
let mut bytes = Vec::new();
|
||||
let mut stream = resp;
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut last_pct: u64 = 0;
|
||||
|
||||
let mut chunk_err = None;
|
||||
loop {
|
||||
let chunk = stream
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| format!("Download error: {}", format_reqwest_error(&e)));
|
||||
match chunk {
|
||||
Ok(Some(data)) => {
|
||||
downloaded += data.len() as u64;
|
||||
bytes.extend_from_slice(&data);
|
||||
|
||||
if let Some(total) = total {
|
||||
let pct = (downloaded * 100) / total;
|
||||
if pct >= last_pct + 5 {
|
||||
last_pct = pct;
|
||||
let mb = downloaded as f64 / 1_048_576.0;
|
||||
let total_mb = total as f64 / 1_048_576.0;
|
||||
eprint!("\r {:.0}/{:.0} MB ({pct}%)", mb, total_mb);
|
||||
let _ = io::stderr().flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
chunk_err = Some(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
|
||||
if let Some(e) = chunk_err {
|
||||
last_err = e;
|
||||
continue;
|
||||
}
|
||||
|
||||
return Ok(bytes);
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
Ok(bytes)
|
||||
Err(last_err)
|
||||
}
|
||||
|
||||
fn extract_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
|
||||
@@ -337,7 +406,7 @@ pub fn run_install(with_deps: bool) {
|
||||
eprintln!(" Install Chromium from your system package manager instead:");
|
||||
eprintln!(" sudo apt install chromium-browser # Debian/Ubuntu");
|
||||
eprintln!(" sudo dnf install chromium # Fedora");
|
||||
eprintln!(" Then use: agent-browser --executable-path /usr/bin/chromium");
|
||||
eprintln!(" Then use: chrome-use --executable-path /usr/bin/chromium");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
@@ -351,7 +420,7 @@ pub fn run_install(with_deps: bool) {
|
||||
"{} Linux detected. If browser fails to launch, run:",
|
||||
color::warning_indicator()
|
||||
);
|
||||
println!(" agent-browser install --with-deps");
|
||||
println!(" chrome-use install --with-deps");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
@@ -417,7 +486,7 @@ pub fn run_install(with_deps: bool) {
|
||||
"{} If you see \"shared library\" errors when running, use:",
|
||||
color::yellow("Note:")
|
||||
);
|
||||
println!(" agent-browser install --with-deps");
|
||||
println!(" chrome-use install --with-deps");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -703,123 +772,191 @@ fn package_exists_apt(pkg: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dashboard install
|
||||
// ---------------------------------------------------------------------------
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
pub fn get_dashboard_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".agent-browser")
|
||||
.join("dashboard")
|
||||
}
|
||||
|
||||
const DASHBOARD_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
fn dashboard_download_url() -> String {
|
||||
format!(
|
||||
"https://github.com/vercel-labs/agent-browser/releases/download/v{}/dashboard.zip",
|
||||
DASHBOARD_VERSION
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run_dashboard_install() {
|
||||
println!("{}", color::cyan("Installing dashboard..."));
|
||||
|
||||
let dest = get_dashboard_dir();
|
||||
|
||||
if dest.join("index.html").exists() {
|
||||
println!(
|
||||
"{} Dashboard is already installed at {}",
|
||||
color::success_indicator(),
|
||||
dest.display()
|
||||
fn http_response(status: u16, reason: &str, body: &[u8]) -> Vec<u8> {
|
||||
let header = format!(
|
||||
"HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
status,
|
||||
reason,
|
||||
body.len()
|
||||
);
|
||||
return;
|
||||
let mut resp = header.into_bytes();
|
||||
resp.extend_from_slice(body);
|
||||
resp
|
||||
}
|
||||
|
||||
let url = dashboard_download_url();
|
||||
println!(" Downloading dashboard v{}", DASHBOARD_VERSION);
|
||||
println!(" {}", url);
|
||||
async fn accept_once(listener: &TcpListener, response: &[u8]) {
|
||||
let (mut s, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let _ = s.read(&mut buf).await;
|
||||
s.write_all(response).await.unwrap();
|
||||
}
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"{} Failed to create runtime: {}",
|
||||
color::error_indicator(),
|
||||
e
|
||||
);
|
||||
exit(1);
|
||||
async fn accept_with_ua_check(listener: &TcpListener, response: &[u8]) -> String {
|
||||
let (mut s, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = s.read(&mut buf).await.unwrap();
|
||||
let request = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
s.write_all(response).await.unwrap();
|
||||
request
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_bytes_returns_body_on_200() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let body = b"fake-zip-content";
|
||||
let resp = http_response(200, "OK", body);
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
accept_once(&listener, &resp).await;
|
||||
});
|
||||
|
||||
let bytes = match rt.block_on(download_bytes(&url)) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
eprintln!(" The dashboard may not be available for this version yet.");
|
||||
eprintln!(" You can build it locally: cd packages/dashboard && pnpm build");
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match extract_dashboard_zip(bytes, &dest) {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"{} Dashboard v{} installed successfully",
|
||||
color::success_indicator(),
|
||||
DASHBOARD_VERSION
|
||||
);
|
||||
println!(" Location: {}", dest.display());
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_dashboard_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
|
||||
let cursor = io::Cursor::new(bytes);
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
|
||||
|
||||
let enclosed = match file.enclosed_name() {
|
||||
Some(name) => name.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
let rel_path = enclosed.to_string_lossy().to_string();
|
||||
|
||||
if rel_path.is_empty() || file.is_dir() {
|
||||
if file.is_dir() {
|
||||
let out_dir = dest.join(&rel_path);
|
||||
let _ = fs::create_dir_all(&out_dir);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let out_path = dest.join(&rel_path);
|
||||
if !out_path.starts_with(dest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(parent) = out_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create parent dir {}: {}", parent.display(), e))?;
|
||||
}
|
||||
let mut out_file = fs::File::create(&out_path)
|
||||
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
|
||||
io::copy(&mut file, &mut out_file)
|
||||
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
|
||||
let url = format!("http://127.0.0.1:{}/test.zip", port);
|
||||
let result = download_bytes(&url).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), body);
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
#[tokio::test]
|
||||
async fn download_bytes_returns_error_on_404() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let resp = http_response(404, "Not Found", b"not found");
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
accept_once(&listener, &resp).await;
|
||||
});
|
||||
|
||||
let url = format!("http://127.0.0.1:{}/test.zip", port);
|
||||
let result = download_bytes(&url).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("HTTP 404"),
|
||||
"expected HTTP 404 in error, got: {}",
|
||||
err
|
||||
);
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_bytes_retries_on_500() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
// First two attempts: 500
|
||||
let r500 = http_response(500, "Internal Server Error", b"error");
|
||||
accept_once(&listener, &r500).await;
|
||||
accept_once(&listener, &r500).await;
|
||||
// Third attempt: 200
|
||||
let r200 = http_response(200, "OK", b"ok-data");
|
||||
accept_once(&listener, &r200).await;
|
||||
});
|
||||
|
||||
let url = format!("http://127.0.0.1:{}/test.zip", port);
|
||||
let result = download_bytes(&url).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"expected success after retries: {:?}",
|
||||
result
|
||||
);
|
||||
assert_eq!(result.unwrap(), b"ok-data");
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_bytes_gives_up_after_max_retries() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let r500 = http_response(500, "Internal Server Error", b"error");
|
||||
// All 3 attempts get 500
|
||||
accept_once(&listener, &r500).await;
|
||||
accept_once(&listener, &r500).await;
|
||||
accept_once(&listener, &r500).await;
|
||||
});
|
||||
|
||||
let url = format!("http://127.0.0.1:{}/test.zip", port);
|
||||
let result = download_bytes(&url).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("HTTP 500"),
|
||||
"expected HTTP 500 in error, got: {}",
|
||||
err
|
||||
);
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_bytes_does_not_retry_on_403() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let resp = http_response(403, "Forbidden", b"forbidden");
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
// Only one request should arrive (no retries for 4xx)
|
||||
accept_once(&listener, &resp).await;
|
||||
});
|
||||
|
||||
let url = format!("http://127.0.0.1:{}/test.zip", port);
|
||||
let result = download_bytes(&url).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("HTTP 403"));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_client_sends_user_agent() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let resp = http_response(200, "OK", b"ok");
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let req = accept_with_ua_check(&listener, &resp).await;
|
||||
req
|
||||
});
|
||||
|
||||
let client = http_client().unwrap();
|
||||
let url = format!("http://127.0.0.1:{}/test", port);
|
||||
let _ = client.get(&url).send().await;
|
||||
let request_text = server.await.unwrap();
|
||||
let expected_ua = format!("chrome-use/{}", env!("CARGO_PKG_VERSION"));
|
||||
assert!(
|
||||
request_text.contains(&expected_ua),
|
||||
"expected User-Agent '{}' in request:\n{}",
|
||||
expected_ua,
|
||||
request_text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_bytes_connection_refused_includes_details() {
|
||||
// Use a port that nothing is listening on
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let result = rt.block_on(download_bytes("http://127.0.0.1:1/test.zip"));
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
// The new code should include the root cause (connection refused)
|
||||
// not just the vague "error sending request for url"
|
||||
assert!(
|
||||
err.contains("Connection refused")
|
||||
|| err.contains("connection refused")
|
||||
|| err.contains("actively refused it"),
|
||||
"expected 'connection refused' in error, got: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+719
-158
File diff suppressed because it is too large
Load Diff
+2689
-278
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,373 @@
|
||||
//! Adaptive @ref relocation.
|
||||
//!
|
||||
//! When a saved `@ref`'s DOM node is gone (stale `backendNodeId`) and the
|
||||
//! role/name/nth re-query also fails, we score the current page's candidate
|
||||
//! elements against the ref's stored [`ElementFingerprint`] and relocate to the
|
||||
//! best match — but ONLY when confident: the best candidate must clear a high
|
||||
//! absolute threshold AND beat the runner-up by a clear margin. This matches the
|
||||
//! project's "fail loudly rather than mis-click" posture (see the identity and
|
||||
//! occlusion guards in `element.rs`).
|
||||
//!
|
||||
//! Everything in this module is pure and browser-free so the scoring can be
|
||||
//! unit-tested directly.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Minimum absolute similarity (0..1) for a relocation candidate to be accepted.
|
||||
pub const ADAPTIVE_THRESHOLD: f64 = 0.70;
|
||||
/// Minimum gap between the best and second-best candidate to avoid ambiguity.
|
||||
pub const ADAPTIVE_MARGIN: f64 = 0.15;
|
||||
|
||||
/// A structural/semantic fingerprint of an element, captured at snapshot time so
|
||||
/// a moved element can be re-identified after the page mutates.
|
||||
///
|
||||
/// Populated purely from the accessibility tree we already walk (`TreeNode`), so
|
||||
/// capturing it costs no extra CDP round-trips — `TreeNode` has no DOM tag or
|
||||
/// attributes (those would need an N×`DOM.describeNode` storm per snapshot), so
|
||||
/// `tag` holds the AX **role** and `attrs` holds discriminating AX properties
|
||||
/// (value/url/level/checked), not DOM `id`/`class`.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ElementFingerprint {
|
||||
/// AX role, e.g. "button" (used where a DOM tag would otherwise go).
|
||||
pub tag: String,
|
||||
/// Accessible name / visible text — the dominant identity signal.
|
||||
pub text: String,
|
||||
/// Discriminating AX properties: value, url, level, checked. Keyed by name.
|
||||
pub attrs: BTreeMap<String, String>,
|
||||
/// Ancestor role signatures from nearest to farthest, e.g. "form" / "list".
|
||||
pub ancestors: Vec<String>,
|
||||
/// Parent role.
|
||||
pub parent_tag: String,
|
||||
/// Parent accessible name / text.
|
||||
pub parent_text: String,
|
||||
/// Index among same-role siblings.
|
||||
pub sibling_index: u32,
|
||||
/// Count of same-role siblings.
|
||||
pub sibling_count: u32,
|
||||
}
|
||||
|
||||
/// Component weights. They sum to 1.0 so the total score lands in 0..1.
|
||||
/// Tuned for AX-derived fingerprints: the accessible name dominates, with role
|
||||
/// and tree structure carrying disambiguation when the name has changed (which
|
||||
/// is exactly when the exact role+name+nth fallback failed and we got here).
|
||||
const W_TAG: f64 = 0.20;
|
||||
const W_TEXT: f64 = 0.40;
|
||||
const W_ATTRS: f64 = 0.10;
|
||||
const W_ANCESTORS: f64 = 0.20;
|
||||
const W_PARENT_SIBLING: f64 = 0.10;
|
||||
|
||||
/// Per-attribute importance for the attribute-overlap score. Strong identity
|
||||
/// signals (a link's url) outweigh weak ones (heading level).
|
||||
fn attr_weight(name: &str) -> f64 {
|
||||
match name {
|
||||
"url" | "value" => 3.0,
|
||||
"checked" => 2.0,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Levenshtein-based string similarity in 0..1 (1.0 = identical). Two empty
|
||||
/// strings are treated as a perfect match (consistent absence of text).
|
||||
pub fn string_similarity(a: &str, b: &str) -> f64 {
|
||||
if a == b {
|
||||
return 1.0;
|
||||
}
|
||||
let a: Vec<char> = a.chars().collect();
|
||||
let b: Vec<char> = b.chars().collect();
|
||||
let max_len = a.len().max(b.len());
|
||||
if max_len == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
let dist = levenshtein(&a, &b);
|
||||
1.0 - (dist as f64 / max_len as f64)
|
||||
}
|
||||
|
||||
fn levenshtein(a: &[char], b: &[char]) -> usize {
|
||||
if a.is_empty() {
|
||||
return b.len();
|
||||
}
|
||||
if b.is_empty() {
|
||||
return a.len();
|
||||
}
|
||||
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||
let mut cur = vec![0usize; b.len() + 1];
|
||||
for (i, &ca) in a.iter().enumerate() {
|
||||
cur[0] = i + 1;
|
||||
for (j, &cb) in b.iter().enumerate() {
|
||||
let cost = if ca == cb { 0 } else { 1 };
|
||||
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
|
||||
}
|
||||
std::mem::swap(&mut prev, &mut cur);
|
||||
}
|
||||
prev[b.len()]
|
||||
}
|
||||
|
||||
/// Jaccard similarity over whitespace-separated tokens (used for `class`).
|
||||
fn token_jaccard(a: &str, b: &str) -> f64 {
|
||||
let sa: std::collections::BTreeSet<&str> = a.split_whitespace().collect();
|
||||
let sb: std::collections::BTreeSet<&str> = b.split_whitespace().collect();
|
||||
if sa.is_empty() && sb.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
let inter = sa.intersection(&sb).count() as f64;
|
||||
let union = sa.union(&sb).count() as f64;
|
||||
if union == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
inter / union
|
||||
}
|
||||
}
|
||||
|
||||
/// Length-ratio of the longest common subsequence over two ancestor sequences.
|
||||
fn lcs_ratio(a: &[String], b: &[String]) -> f64 {
|
||||
if a.is_empty() && b.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1];
|
||||
for i in 0..a.len() {
|
||||
for j in 0..b.len() {
|
||||
dp[i + 1][j + 1] = if a[i] == b[j] {
|
||||
dp[i][j] + 1
|
||||
} else {
|
||||
dp[i][j + 1].max(dp[i + 1][j])
|
||||
};
|
||||
}
|
||||
}
|
||||
let lcs = dp[a.len()][b.len()] as f64;
|
||||
(2.0 * lcs) / (a.len() + b.len()) as f64
|
||||
}
|
||||
|
||||
fn attr_score(base: &BTreeMap<String, String>, cand: &BTreeMap<String, String>) -> f64 {
|
||||
let mut names: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
|
||||
names.extend(base.keys().map(|s| s.as_str()));
|
||||
names.extend(cand.keys().map(|s| s.as_str()));
|
||||
if names.is_empty() {
|
||||
return 1.0; // no attributes on either side — neutral
|
||||
}
|
||||
let mut total = 0.0;
|
||||
let mut got = 0.0;
|
||||
for name in names {
|
||||
let w = attr_weight(name);
|
||||
total += w;
|
||||
// present on only one side → no credit
|
||||
if let (Some(a), Some(b)) = (base.get(name), cand.get(name)) {
|
||||
if name == "class" {
|
||||
got += w * token_jaccard(a, b);
|
||||
} else if a == b {
|
||||
got += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
if total == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
got / total
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_sibling_score(base: &ElementFingerprint, cand: &ElementFingerprint) -> f64 {
|
||||
// Split the 0.10 budget: parent tag 0.4, parent text 0.3, sibling pos 0.3.
|
||||
let parent_tag = if base.parent_tag == cand.parent_tag {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let parent_text = string_similarity(&base.parent_text, &cand.parent_text);
|
||||
let span = base.sibling_count.max(1) as f64;
|
||||
let delta = (base.sibling_index as i64 - cand.sibling_index as i64).unsigned_abs() as f64;
|
||||
let sibling = 1.0 - (delta / span).min(1.0);
|
||||
0.4 * parent_tag + 0.3 * parent_text + 0.3 * sibling
|
||||
}
|
||||
|
||||
/// Similarity score in 0..1 between a stored baseline and a candidate element.
|
||||
pub fn score(base: &ElementFingerprint, cand: &ElementFingerprint) -> f64 {
|
||||
let tag = if base.tag == cand.tag { 1.0 } else { 0.0 };
|
||||
let text = string_similarity(&base.text, &cand.text);
|
||||
let attrs = attr_score(&base.attrs, &cand.attrs);
|
||||
let ancestors = lcs_ratio(&base.ancestors, &cand.ancestors);
|
||||
let parent_sibling = parent_sibling_score(base, cand);
|
||||
|
||||
W_TAG * tag
|
||||
+ W_TEXT * text
|
||||
+ W_ATTRS * attrs
|
||||
+ W_ANCESTORS * ancestors
|
||||
+ W_PARENT_SIBLING * parent_sibling
|
||||
}
|
||||
|
||||
/// Why a relocation was rejected.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RejectReason {
|
||||
/// No candidates to score.
|
||||
NoCandidates,
|
||||
/// Best score below [`ADAPTIVE_THRESHOLD`].
|
||||
LowScore { best: f64 },
|
||||
/// Best score too close to the runner-up (below [`ADAPTIVE_MARGIN`]).
|
||||
Ambiguous { best: f64, second: f64 },
|
||||
}
|
||||
|
||||
/// A successful relocation decision.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Relocation {
|
||||
/// Chosen candidate's backend node id.
|
||||
pub backend_node_id: i64,
|
||||
/// Winning score.
|
||||
pub score: f64,
|
||||
/// Runner-up score (0.0 when there was only one candidate).
|
||||
pub second_score: f64,
|
||||
}
|
||||
|
||||
/// Pick the best candidate, accepting only when confident. `candidates` is a
|
||||
/// list of `(backend_node_id, fingerprint)` for the current page.
|
||||
pub fn pick_best(
|
||||
base: &ElementFingerprint,
|
||||
candidates: &[(i64, ElementFingerprint)],
|
||||
threshold: f64,
|
||||
margin: f64,
|
||||
) -> Result<Relocation, RejectReason> {
|
||||
if candidates.is_empty() {
|
||||
return Err(RejectReason::NoCandidates);
|
||||
}
|
||||
let mut scored: Vec<(i64, f64)> = candidates
|
||||
.iter()
|
||||
.map(|(id, fp)| (*id, score(base, fp)))
|
||||
.collect();
|
||||
// Highest score first; stable enough for deterministic ties.
|
||||
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let (best_id, best) = scored[0];
|
||||
let second = scored.get(1).map(|(_, s)| *s).unwrap_or(0.0);
|
||||
|
||||
if best < threshold {
|
||||
return Err(RejectReason::LowScore { best });
|
||||
}
|
||||
if best - second < margin {
|
||||
return Err(RejectReason::Ambiguous { best, second });
|
||||
}
|
||||
Ok(Relocation {
|
||||
backend_node_id: best_id,
|
||||
score: best,
|
||||
second_score: second,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fp(tag: &str, text: &str, attrs: &[(&str, &str)]) -> ElementFingerprint {
|
||||
ElementFingerprint {
|
||||
tag: tag.to_string(),
|
||||
text: text.to_string(),
|
||||
attrs: attrs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_fingerprints_score_one() {
|
||||
let a = fp(
|
||||
"button",
|
||||
"Submit",
|
||||
&[("id", "go"), ("class", "btn primary")],
|
||||
);
|
||||
assert!((score(&a, &a) - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_tag_caps_score_below_threshold() {
|
||||
let a = fp("button", "Submit", &[("id", "go")]);
|
||||
let b = fp("a", "Submit", &[("id", "go")]);
|
||||
// Same text + same attrs but different role: must lose the role weight
|
||||
// (W_TAG = 0.20), landing around 0.80 and below a perfect match.
|
||||
let s = score(&a, &b);
|
||||
assert!(s < 0.85 && s > 0.75, "got {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_similarity_basics() {
|
||||
assert_eq!(string_similarity("abc", "abc"), 1.0);
|
||||
assert_eq!(string_similarity("", ""), 1.0);
|
||||
assert!(string_similarity("Submit", "Submit now") > 0.5);
|
||||
assert!(string_similarity("Add post", "Post all") < 0.6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_uses_token_overlap() {
|
||||
let a = fp("div", "", &[("class", "card primary big")]);
|
||||
let b = fp("div", "", &[("class", "card primary")]);
|
||||
// partial class overlap should still score high (tag+text match, attrs partial)
|
||||
let s = score(&a, &b);
|
||||
assert!(s > 0.85, "got {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestors_lcs() {
|
||||
let mut a = fp("button", "OK", &[]);
|
||||
let mut b = fp("button", "OK", &[]);
|
||||
a.ancestors = vec!["form#f".into(), "div.col".into(), "body".into()];
|
||||
// b wrapped in an extra div — DOM path changed but mostly preserved
|
||||
b.ancestors = vec![
|
||||
"form#f".into(),
|
||||
"div.wrap".into(),
|
||||
"div.col".into(),
|
||||
"body".into(),
|
||||
];
|
||||
let s = score(&a, &b);
|
||||
assert!(s > 0.85, "got {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_best_accepts_clear_winner() {
|
||||
let base = fp("button", "Submit", &[("id", "go")]);
|
||||
let winner = fp("button", "Submit", &[("id", "go")]);
|
||||
let other = fp("a", "Home", &[("href", "/")]);
|
||||
let out = pick_best(
|
||||
&base,
|
||||
&[(10, other), (20, winner)],
|
||||
ADAPTIVE_THRESHOLD,
|
||||
ADAPTIVE_MARGIN,
|
||||
)
|
||||
.expect("should accept");
|
||||
assert_eq!(out.backend_node_id, 20);
|
||||
assert!(out.score > out.second_score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_best_rejects_ambiguous_twins() {
|
||||
let base = fp("button", "Delete", &[("class", "btn danger")]);
|
||||
// Two near-identical delete buttons — must refuse to guess.
|
||||
let twin_a = fp("button", "Delete", &[("class", "btn danger")]);
|
||||
let twin_b = fp("button", "Delete", &[("class", "btn danger")]);
|
||||
let err = pick_best(
|
||||
&base,
|
||||
&[(1, twin_a), (2, twin_b)],
|
||||
ADAPTIVE_THRESHOLD,
|
||||
ADAPTIVE_MARGIN,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, RejectReason::Ambiguous { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_best_rejects_low_score() {
|
||||
let base = fp("button", "Submit order", &[("id", "checkout")]);
|
||||
let junk = fp("span", "unrelated footer text", &[("class", "muted")]);
|
||||
let err = pick_best(&base, &[(1, junk)], ADAPTIVE_THRESHOLD, ADAPTIVE_MARGIN).unwrap_err();
|
||||
assert!(matches!(err, RejectReason::LowScore { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_best_no_candidates() {
|
||||
let base = fp("button", "x", &[]);
|
||||
assert_eq!(
|
||||
pick_best(&base, &[], ADAPTIVE_THRESHOLD, ADAPTIVE_MARGIN).unwrap_err(),
|
||||
RejectReason::NoCandidates
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,9 @@ fn validate_profile_name(name: &str) -> Result<(), String> {
|
||||
|
||||
fn get_auth_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("auth")
|
||||
home.join(".chrome-use").join("auth")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("auth")
|
||||
std::env::temp_dir().join("chrome-use").join("auth")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,9 +59,9 @@ const KEY_FILE_NAME: &str = ".encryption-key";
|
||||
|
||||
fn get_agent_browser_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser")
|
||||
home.join(".chrome-use")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser")
|
||||
std::env::temp_dir().join("chrome-use")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ fn parse_key_hex(hex_str: &str) -> Option<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// Read the encryption key from AGENT_BROWSER_ENCRYPTION_KEY env var or
|
||||
/// ~/.agent-browser/.encryption-key file (matching the Node.js implementation).
|
||||
/// ~/.chrome-use/.encryption-key file (matching the Node.js implementation).
|
||||
fn get_encryption_key() -> Result<Vec<u8>, String> {
|
||||
if let Ok(key_hex) = std::env::var(ENCRYPTION_KEY_ENV) {
|
||||
return parse_key_hex(&key_hex).ok_or_else(|| {
|
||||
@@ -140,7 +140,7 @@ fn ensure_encryption_key() -> Result<Vec<u8>, String> {
|
||||
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"[agent-browser] Auto-generated encryption key at {} -- back up this file or set {}",
|
||||
"[chrome-use] Auto-generated encryption key at {} -- back up this file or set {}",
|
||||
key_file.display(),
|
||||
ENCRYPTION_KEY_ENV
|
||||
);
|
||||
|
||||
+1986
-105
File diff suppressed because it is too large
Load Diff
+1321
-51
File diff suppressed because it is too large
Load Diff
@@ -87,8 +87,8 @@ impl CdpClient {
|
||||
let ws_tx = Arc::new(Mutex::new(ws_tx));
|
||||
|
||||
let pending: PendingMap = Arc::new(Mutex::new(HashMap::new()));
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
let (raw_tx, _) = broadcast::channel(512);
|
||||
let (event_tx, _) = broadcast::channel(4096);
|
||||
let (raw_tx, _) = broadcast::channel(4096);
|
||||
|
||||
let pending_clone = pending.clone();
|
||||
let event_tx_clone = event_tx.clone();
|
||||
|
||||
@@ -58,8 +58,12 @@ pub async fn discover_cdp_url_with_timeout(
|
||||
match discover_cdp_ws(host, port, timeout).await {
|
||||
Ok(ws_url) => Ok(append_query(&ws_url, query)),
|
||||
Err(ws_err) => Err(format!(
|
||||
"All CDP discovery methods failed for {}:{}: /json/version: {}; /json/list: {}; WebSocket: {}",
|
||||
host, port, version_err, list_err, ws_err
|
||||
"All CDP discovery methods failed for {host}:{port}. \
|
||||
Note: Chrome 136+ no longer serves the HTTP discovery endpoints \
|
||||
(/json/version, /json/list), so `--cdp <port>` cannot find the target — \
|
||||
use the default auto-connect (just `chrome-use open <url>`), which reads \
|
||||
DevToolsActivePort and attaches over WebSocket. \
|
||||
(details: /json/version: {version_err}; /json/list: {list_err}; WebSocket: {ws_err})"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +346,11 @@ mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
// Spawns a real child process and binds a TCP server with timing-based
|
||||
// readiness assumptions; flaky under CI load (intermittent "exited before
|
||||
// CDP became ready" / connection-refused races). Run locally with
|
||||
// `--ignored` when touching lightpanda startup.
|
||||
#[ignore = "process spawn + socket timing race, flaky in CI"]
|
||||
async fn waits_for_ready_without_logs() {
|
||||
let port = unused_port();
|
||||
tokio::spawn(serve_json_version_once_after_delay(
|
||||
|
||||
@@ -106,7 +106,13 @@ pub struct TargetInfo {
|
||||
pub target_id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub target_type: String,
|
||||
// Tolerate minimal targetInfo: the ab-connect relay's synthesized
|
||||
// Target.attachedToTarget (re-announce path) omits title/url, and real CDP
|
||||
// occasionally omits them too. Default to empty rather than fail the whole
|
||||
// Target.getTargets deserialize.
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
pub attached: Option<bool>,
|
||||
pub browser_context_id: Option<String>,
|
||||
@@ -141,6 +147,18 @@ pub struct SetDiscoverTargetsParams {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateTargetParams {
|
||||
pub url: String,
|
||||
/// Non-CDP hint consumed only by the `ab-connect` extension: the Chrome
|
||||
/// tab-group name to drop the new tab into (per-session grouping on the
|
||||
/// shared real Chrome). `None` on the normal CDP path so a strict real-Chrome
|
||||
/// endpoint never receives an unknown parameter.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_group: Option<String>,
|
||||
/// Create the tab in the background so opening it never steals the user's
|
||||
/// foreground tab (silent operation). Standard CDP param; the ab-connect
|
||||
/// extension creates its tabs `active: false` regardless, so this only
|
||||
/// affects the raw-CDP (no extension) path.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
+159
-28
@@ -9,7 +9,7 @@ use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::signal;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
use tokio::sync::{mpsc, Notify, RwLock};
|
||||
|
||||
use super::actions::{execute_command, DaemonState};
|
||||
use super::cdp::client::CdpClient;
|
||||
@@ -17,6 +17,20 @@ use super::state;
|
||||
use super::stream::StreamServer;
|
||||
|
||||
pub async fn run_daemon(session: &str) {
|
||||
// Record this daemon's session so tabs it opens on the shared real Chrome
|
||||
// (via the ab-connect extension) land in a per-session Chrome tab group.
|
||||
let _ = super::browser::DAEMON_SESSION.set(session.to_string());
|
||||
|
||||
// Bootstrap / refresh the site-adapter pack in the background (first-run +
|
||||
// periodic TTL). This populates ~/.chrome-use/sites/.index.json so navigation
|
||||
// can auto-suggest `site` commands for the page you land on, with zero added
|
||||
// latency to any command. Best-effort; offline is a no-op.
|
||||
if crate::site::needs_refresh() {
|
||||
tokio::spawn(async {
|
||||
let _ = crate::site::update().await;
|
||||
});
|
||||
}
|
||||
|
||||
let socket_dir = get_daemon_socket_dir();
|
||||
if !socket_dir.exists() {
|
||||
let _ = fs::create_dir_all(&socket_dir);
|
||||
@@ -41,11 +55,34 @@ pub async fn run_daemon(session: &str) {
|
||||
session
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Redirect stderr to /dev/null to prevent daemon crash when the
|
||||
// parent CLI drops the piped stderr handle after startup. Cloud
|
||||
// providers (AgentCore, Browserbase, etc.) may write to stderr
|
||||
// during connection setup; a broken pipe would kill the daemon.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::io::IntoRawFd;
|
||||
if let Ok(devnull) = fs::File::create("/dev/null") {
|
||||
let fd = devnull.into_raw_fd();
|
||||
unsafe {
|
||||
libc::dup2(fd, 2);
|
||||
libc::close(fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sweep temp Chrome profiles leaked by hard-killed daemons (Drop doesn't
|
||||
// run on kill -9). Only removes dirs no live process references.
|
||||
super::cdp::chrome::cleanup_orphaned_chrome_profiles();
|
||||
|
||||
let pid_path = socket_dir.join(format!("{}.pid", session));
|
||||
let _ = fs::write(&pid_path, process::id().to_string());
|
||||
|
||||
let version_path = socket_dir.join(format!("{}.version", session));
|
||||
let _ = fs::write(&version_path, env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// On Unix the daemon listens on a Unix domain socket; on Windows it uses
|
||||
// TCP, so there is no .sock file — only a .port file written by the server.
|
||||
let socket_path = socket_dir.join(format!("{}.sock", session));
|
||||
@@ -93,12 +130,21 @@ pub async fn run_daemon(session: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-shutdown the daemon after this many ms of inactivity (no commands received).
|
||||
// Disabled when unset or 0.
|
||||
let idle_timeout_ms = env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.filter(|&ms| ms > 0);
|
||||
// Auto-shutdown the daemon after this many ms of inactivity (no commands
|
||||
// received). On shutdown the daemon closes the tabs IT created (its per-session
|
||||
// tab group), so an agent that finishes a task and just stops — without ever
|
||||
// calling `close` — no longer leaves a pile of scratch tabs and a lingering
|
||||
// tab group in the user's Chrome. The timer resets on every command, so active
|
||||
// sessions are never interrupted; only genuinely-idle ones clean up.
|
||||
//
|
||||
// Defaults to 10 minutes. Set AGENT_BROWSER_IDLE_TIMEOUT_MS to override, or 0
|
||||
// to disable (keep the daemon alive forever — the old behaviour). Adopted
|
||||
// tabs (the user's own, via `adopt`) are never closed: only `created_targets`.
|
||||
const DEFAULT_IDLE_TIMEOUT_MS: u64 = 600_000;
|
||||
let idle_timeout_ms = match env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS") {
|
||||
Ok(s) => s.trim().parse::<u64>().ok().filter(|&ms| ms > 0),
|
||||
Err(_) => Some(DEFAULT_IDLE_TIMEOUT_MS),
|
||||
};
|
||||
|
||||
let result = run_socket_server(
|
||||
&socket_path,
|
||||
@@ -118,6 +164,7 @@ pub async fn run_daemon(session: &str) {
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.port", session)));
|
||||
}
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let _ = fs::remove_file(&version_path);
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.provider", session)));
|
||||
@@ -156,13 +203,18 @@ async fn run_socket_server(
|
||||
let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64);
|
||||
let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx));
|
||||
|
||||
let mut drain_interval = tokio::time::interval(Duration::from_millis(500));
|
||||
// Notifier used by handle_connection to signal the daemon loop to exit
|
||||
// after a "close" command, instead of calling process::exit() which skips
|
||||
// destructors and can leave Chrome processes orphaned (issue #1113).
|
||||
let close_notify = Arc::new(Notify::new());
|
||||
|
||||
let mut drain_interval = tokio::time::interval(Duration::from_millis(100));
|
||||
drain_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
|
||||
let mut sleep_pin = sleep_future.map(Box::pin);
|
||||
let idle_sleep = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
|
||||
let mut idle_sleep_pin = idle_sleep.map(Box::pin);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
accept_result = listener.accept() => {
|
||||
match accept_result {
|
||||
@@ -170,8 +222,9 @@ async fn run_socket_server(
|
||||
let state = state.clone();
|
||||
let reset_tx = reset_tx.clone();
|
||||
let sf = stream_file.clone();
|
||||
let cn = close_notify.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state, reset_tx, sf).await;
|
||||
handle_connection(stream, state, reset_tx, sf, cn).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -193,10 +246,9 @@ async fn run_socket_server(
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
if let Some(ref mut s) = sleep_pin {
|
||||
s.as_mut().await
|
||||
} else {
|
||||
std::future::pending::<()>().await
|
||||
match idle_sleep_pin {
|
||||
Some(ref mut s) => s.as_mut().await,
|
||||
None => std::future::pending::<()>().await,
|
||||
}
|
||||
}, if idle_timeout_ms.is_some() => {
|
||||
let mut s = state.lock().await;
|
||||
@@ -206,8 +258,16 @@ async fn run_socket_server(
|
||||
break;
|
||||
}
|
||||
_ = reset_rx.recv(), if idle_timeout_ms.is_some() => {
|
||||
idle_sleep_pin = idle_timeout_ms
|
||||
.map(|ms| Box::pin(tokio::time::sleep(Duration::from_millis(ms))));
|
||||
continue;
|
||||
}
|
||||
_ = close_notify.notified() => {
|
||||
// "close" command was handled; browser already closed by
|
||||
// handle_close(). Break to run cleanup and exit gracefully
|
||||
// so destructors fire.
|
||||
break;
|
||||
}
|
||||
_ = shutdown_signal() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
@@ -262,10 +322,12 @@ async fn run_socket_server(
|
||||
let (reset_tx, mut reset_rx) = mpsc::channel::<()>(64);
|
||||
let reset_tx = idle_timeout_ms.map(|_| Arc::new(reset_tx));
|
||||
|
||||
loop {
|
||||
let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
|
||||
let mut sleep_pin = sleep_future.map(Box::pin);
|
||||
let close_notify = Arc::new(Notify::new());
|
||||
|
||||
let idle_sleep = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms)));
|
||||
let mut idle_sleep_pin = idle_sleep.map(Box::pin);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
accept_result = listener.accept() => {
|
||||
match accept_result {
|
||||
@@ -273,8 +335,9 @@ async fn run_socket_server(
|
||||
let state = state.clone();
|
||||
let reset_tx = reset_tx.clone();
|
||||
let sf = stream_file.clone();
|
||||
let cn = close_notify.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state, reset_tx, sf).await;
|
||||
handle_connection(stream, state, reset_tx, sf, cn).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -283,10 +346,9 @@ async fn run_socket_server(
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
if let Some(ref mut s) = sleep_pin {
|
||||
s.as_mut().await
|
||||
} else {
|
||||
std::future::pending::<()>().await
|
||||
match idle_sleep_pin {
|
||||
Some(ref mut s) => s.as_mut().await,
|
||||
None => std::future::pending::<()>().await,
|
||||
}
|
||||
}, if idle_timeout_ms.is_some() => {
|
||||
let mut s = state.lock().await;
|
||||
@@ -297,8 +359,14 @@ async fn run_socket_server(
|
||||
break;
|
||||
}
|
||||
_ = reset_rx.recv(), if idle_timeout_ms.is_some() => {
|
||||
idle_sleep_pin = idle_timeout_ms
|
||||
.map(|ms| Box::pin(tokio::time::sleep(Duration::from_millis(ms))));
|
||||
continue;
|
||||
}
|
||||
_ = close_notify.notified() => {
|
||||
let _ = fs::remove_file(&port_path);
|
||||
break;
|
||||
}
|
||||
_ = shutdown_signal() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
@@ -318,6 +386,7 @@ async fn handle_connection<S>(
|
||||
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
|
||||
idle_reset_tx: Option<Arc<mpsc::Sender<()>>>,
|
||||
stream_file_cleanup: Option<PathBuf>,
|
||||
close_notify: Arc<Notify>,
|
||||
) where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
@@ -374,8 +443,12 @@ async fn handle_connection<S>(
|
||||
if let Some(ref path) = stream_file_cleanup {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
// Signal the daemon loop to exit gracefully instead of
|
||||
// calling process::exit(), which skips destructors and
|
||||
// can leave Chrome processes orphaned (issue #1113).
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
process::exit(0);
|
||||
close_notify.notify_one();
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
@@ -444,15 +517,15 @@ fn get_daemon_socket_dir() -> PathBuf {
|
||||
|
||||
if let Ok(xdg) = env::var("XDG_RUNTIME_DIR") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("agent-browser");
|
||||
return PathBuf::from(xdg).join("chrome-use");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(".agent-browser");
|
||||
return home.join(".chrome-use");
|
||||
}
|
||||
|
||||
std::env::temp_dir().join("agent-browser")
|
||||
std::env::temp_dir().join("chrome-use")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -534,6 +607,64 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test for #1101: idle timeout must fire even while the
|
||||
/// drain interval ticks every 500 ms. The bug was that `sleep_future`
|
||||
/// was created **inside** the loop, so each drain tick dropped the
|
||||
/// in-progress sleep and replaced it with a fresh one – the timer
|
||||
/// could never reach its deadline.
|
||||
#[tokio::test]
|
||||
async fn test_idle_timeout_fires_despite_drain_interval() {
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
let idle_timeout_ms: u64 = 1000;
|
||||
let mut drain_interval = tokio::time::interval(Duration::from_millis(500));
|
||||
drain_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
let (_reset_tx, mut reset_rx) = mpsc::channel::<()>(64);
|
||||
|
||||
let start = tokio::time::Instant::now();
|
||||
|
||||
let exited = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let mut idle_sleep_pin = Some(Box::pin(tokio::time::sleep(Duration::from_millis(
|
||||
idle_timeout_ms,
|
||||
))));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = drain_interval.tick() => {}
|
||||
_ = async {
|
||||
match idle_sleep_pin {
|
||||
Some(ref mut s) => s.as_mut().await,
|
||||
None => std::future::pending::<()>().await,
|
||||
}
|
||||
} => {
|
||||
break;
|
||||
}
|
||||
_ = reset_rx.recv() => {
|
||||
idle_sleep_pin = Some(Box::pin(
|
||||
tokio::time::sleep(Duration::from_millis(idle_timeout_ms)),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert!(
|
||||
exited.is_ok(),
|
||||
"idle timeout never fired – loop ran for >5 s (bug #1101)"
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(idle_timeout_ms + 500),
|
||||
"idle timeout took too long: {:?} (expected ~{} ms)",
|
||||
elapsed,
|
||||
idle_timeout_ms,
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that `ChromeProcess::has_exited()` (which uses `Child::try_wait()`)
|
||||
/// correctly detects a killed child, the same way the drain interval does
|
||||
/// in the fixed daemon code. This ensures crash detection works without
|
||||
|
||||
+1771
-24
File diff suppressed because it is too large
Load Diff
+834
-29
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,517 @@
|
||||
//! Human-like input behaviour for stealth.
|
||||
//!
|
||||
//! When chrome-use drives a real Chrome over CDP, the input events it
|
||||
//! dispatches are already `isTrusted` — but a click that teleports the cursor
|
||||
//! straight to an element's exact centre, with no approach path and zero delay
|
||||
//! between move/press/release, is a behavioural tell that advanced anti-bot
|
||||
//! vendors (Akamai, PerimeterX, DataDome) look for.
|
||||
//!
|
||||
//! This module produces **human-like motion plans** — curved, eased cursor
|
||||
//! trajectories and variable keystroke timing — as *pure data*. It performs no
|
||||
//! I/O and knows nothing about CDP: callers turn the returned steps into
|
||||
//! `Input.dispatchMouseEvent` / `dispatchKeyEvent` calls. Keeping the maths pure
|
||||
//! makes the easing/jitter/detection logic unit-testable and deterministic
|
||||
//! (every randomised value comes from a caller-supplied seed).
|
||||
//!
|
||||
//! Design (see brainstorm 2026-06-11):
|
||||
//! - Three levels: [`HumanizeLevel::Off`] (instant, today's behaviour),
|
||||
//! `Fast` (a few cheap eased steps), `Human` (full curved trajectory + jitter).
|
||||
//! - Baseline is `Off`; the daemon escalates a session to `Human` when
|
||||
//! [`detect_level`] spots a known anti-bot vendor on the page. `--humanize` /
|
||||
//! `AGENT_BROWSER_HUMANIZE` force a fixed level.
|
||||
//! - Humanization only changes *how* the cursor reaches a target, never *which*
|
||||
//! element is hit: the landing jitter stays inside the caller-provided bounds.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
// ---- daemon-wide runtime state -------------------------------------------
|
||||
//
|
||||
// The pure motion maths above are stateless. The daemon drives one active page
|
||||
// at a time, so we keep the *current* humanize level and last cursor position
|
||||
// in process-global slots rather than threading them through every call site.
|
||||
// (The adaptive detector flips the level per navigation; `dispatch_click` reads
|
||||
// the level + cursor here, so no signature in the click/type call graph has to
|
||||
// change.)
|
||||
|
||||
/// `AGENT_BROWSER_HUMANIZE` forces a fixed level, overriding the adaptive
|
||||
/// detector. Parsed once.
|
||||
fn env_override() -> Option<HumanizeLevel> {
|
||||
static OVERRIDE: OnceLock<Option<HumanizeLevel>> = OnceLock::new();
|
||||
*OVERRIDE.get_or_init(|| {
|
||||
std::env::var("AGENT_BROWSER_HUMANIZE")
|
||||
.ok()
|
||||
.and_then(|s| HumanizeLevel::parse(&s))
|
||||
})
|
||||
}
|
||||
|
||||
fn session_level() -> &'static Mutex<HumanizeLevel> {
|
||||
static LEVEL: OnceLock<Mutex<HumanizeLevel>> = OnceLock::new();
|
||||
LEVEL.get_or_init(|| Mutex::new(HumanizeLevel::Off))
|
||||
}
|
||||
|
||||
fn last_cursor_slot() -> &'static Mutex<(f64, f64)> {
|
||||
static CURSOR: OnceLock<Mutex<(f64, f64)>> = OnceLock::new();
|
||||
CURSOR.get_or_init(|| Mutex::new((0.0, 0.0)))
|
||||
}
|
||||
|
||||
/// The level that should apply right now: the env override if set, else the
|
||||
/// level the detector last chose for the active page.
|
||||
pub fn active_level() -> HumanizeLevel {
|
||||
env_override().unwrap_or_else(|| *session_level().lock().unwrap())
|
||||
}
|
||||
|
||||
/// Set by the adaptive detector after navigation. Ignored while an env override
|
||||
/// is in force (so `--humanize` always wins).
|
||||
pub fn set_detected_level(level: HumanizeLevel) {
|
||||
*session_level().lock().unwrap() = level;
|
||||
}
|
||||
|
||||
/// Where the virtual cursor currently sits, so the next move starts from there
|
||||
/// instead of teleporting.
|
||||
pub fn last_cursor() -> (f64, f64) {
|
||||
*last_cursor_slot().lock().unwrap()
|
||||
}
|
||||
|
||||
/// Record the cursor landing point after a move/click.
|
||||
pub fn set_last_cursor(p: (f64, f64)) {
|
||||
*last_cursor_slot().lock().unwrap() = p;
|
||||
}
|
||||
|
||||
/// A fresh seed per action so repeated clicks on the same point still vary,
|
||||
/// without touching the wall clock or a global RNG (both would break replay).
|
||||
pub fn next_seed() -> u64 {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0x1234_5678);
|
||||
COUNTER
|
||||
.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed)
|
||||
.rotate_left(17)
|
||||
}
|
||||
|
||||
/// How human-like input motion should be.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum HumanizeLevel {
|
||||
/// Instant: a single move to the exact point, no delays. Original behaviour.
|
||||
#[default]
|
||||
Off,
|
||||
/// A few eased steps with small delays — cheap cover for ordinary sites.
|
||||
Fast,
|
||||
/// Full curved, decelerating trajectory with landing jitter and press
|
||||
/// dwell — for pages guarded by behavioural anti-bot systems.
|
||||
Human,
|
||||
}
|
||||
|
||||
impl HumanizeLevel {
|
||||
/// Parse a user-supplied level (`--humanize` / `AGENT_BROWSER_HUMANIZE`).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"off" | "none" | "instant" | "0" => Some(Self::Off),
|
||||
"fast" | "light" | "low" => Some(Self::Fast),
|
||||
"human" | "full" | "high" | "max" => Some(Self::Human),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_off(self) -> bool {
|
||||
matches!(self, Self::Off)
|
||||
}
|
||||
}
|
||||
|
||||
/// One step of a humanized cursor move: dispatch `mouseMoved` to (`x`, `y`),
|
||||
/// then sleep for `delay` before the next step. The final step's point is where
|
||||
/// the press/release should land.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MoveStep {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub delay: Duration,
|
||||
}
|
||||
|
||||
/// Tiny deterministic PRNG (xorshift64*). Seeded by the caller so trajectories
|
||||
/// are reproducible in tests; we avoid pulling in the `rand` crate and never
|
||||
/// call a wall-clock/global RNG (which would also break workflow replay).
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn new(seed: u64) -> Self {
|
||||
// Avoid the zero state, which xorshift cannot escape.
|
||||
Rng(seed ^ 0x9E37_79B9_7F4A_7C15)
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
|
||||
/// Uniform in [0, 1).
|
||||
fn unit(&mut self) -> f64 {
|
||||
// Top 53 bits → f64 mantissa.
|
||||
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
/// Uniform in [-1, 1).
|
||||
fn signed(&mut self) -> f64 {
|
||||
self.unit() * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Smootherstep ease (zero velocity at both ends) — used to bias the per-step
|
||||
/// timing so the cursor accelerates away from the start and decelerates into
|
||||
/// the target, the way a hand does.
|
||||
fn ease(t: f64) -> f64 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
|
||||
}
|
||||
|
||||
/// Cubic Bézier point at parameter `t`.
|
||||
fn bezier(p0: (f64, f64), p1: (f64, f64), p2: (f64, f64), p3: (f64, f64), t: f64) -> (f64, f64) {
|
||||
let u = 1.0 - t;
|
||||
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
(
|
||||
a * p0.0 + b * p1.0 + c * p2.0 + d * p3.0,
|
||||
a * p0.1 + b * p1.1 + c * p2.1 + d * p3.1,
|
||||
)
|
||||
}
|
||||
|
||||
/// Pick a landing point inside `bbox` (`x`, `y`, `width`, `height`). `Off`
|
||||
/// returns the exact centre; `Fast`/`Human` jitter around the centre but stay
|
||||
/// well inside the element so the click still lands on it.
|
||||
pub fn landing_point(bbox: (f64, f64, f64, f64), level: HumanizeLevel, seed: u64) -> (f64, f64) {
|
||||
let (bx, by, bw, bh) = bbox;
|
||||
let cx = bx + bw / 2.0;
|
||||
let cy = by + bh / 2.0;
|
||||
if level.is_off() || bw <= 1.0 || bh <= 1.0 {
|
||||
return (cx, cy);
|
||||
}
|
||||
// Keep within the inner 60% so jitter never lands on a neighbouring element
|
||||
// or the element's padding/edge.
|
||||
let spread = match level {
|
||||
HumanizeLevel::Human => 0.30,
|
||||
_ => 0.15,
|
||||
};
|
||||
let mut rng = Rng::new(seed);
|
||||
(
|
||||
cx + rng.signed() * bw * spread,
|
||||
cy + rng.signed() * bh * spread,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the cursor path from `from` to `to`. The last [`MoveStep`] is the
|
||||
/// landing point. `Off` yields a single zero-delay step at `to` (today's
|
||||
/// teleport), so callers can use one code path for every level.
|
||||
pub fn move_path(
|
||||
from: (f64, f64),
|
||||
to: (f64, f64),
|
||||
level: HumanizeLevel,
|
||||
seed: u64,
|
||||
) -> Vec<MoveStep> {
|
||||
if level.is_off() {
|
||||
return vec![MoveStep {
|
||||
x: to.0,
|
||||
y: to.1,
|
||||
delay: Duration::ZERO,
|
||||
}];
|
||||
}
|
||||
|
||||
let dist = (to.0 - from.0).hypot(to.1 - from.1);
|
||||
if dist < 1.0 {
|
||||
return vec![MoveStep {
|
||||
x: to.0,
|
||||
y: to.1,
|
||||
delay: Duration::ZERO,
|
||||
}];
|
||||
}
|
||||
|
||||
let (steps, total_ms, arc) = match level {
|
||||
HumanizeLevel::Fast => {
|
||||
let s = ((dist / 120.0).round() as usize).clamp(3, 6);
|
||||
(s, (dist * 0.35).clamp(40.0, 130.0), 0.06)
|
||||
}
|
||||
// Off handled above.
|
||||
_ => {
|
||||
let s = ((dist / 45.0).round() as usize).clamp(8, 24);
|
||||
(s, (dist * 0.9).clamp(140.0, 650.0), 0.16)
|
||||
}
|
||||
};
|
||||
|
||||
let mut rng = Rng::new(seed);
|
||||
|
||||
// Two control points along the line, pushed perpendicular to it to bow the
|
||||
// path into a gentle, slightly asymmetric arc.
|
||||
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
|
||||
let (nx, ny) = (-dy / dist, dx / dist); // unit normal
|
||||
let bow = dist * arc * rng.signed();
|
||||
let ctrl = |frac: f64, jitter: f64, rng: &mut Rng| {
|
||||
let base = (from.0 + dx * frac, from.1 + dy * frac);
|
||||
let off = bow * (1.0 + jitter * rng.signed());
|
||||
(base.0 + nx * off, base.1 + ny * off)
|
||||
};
|
||||
let p1 = ctrl(0.33, 0.4, &mut rng);
|
||||
let p2 = ctrl(0.66, 0.4, &mut rng);
|
||||
|
||||
let mut out = Vec::with_capacity(steps);
|
||||
let mut prev_ease = 0.0;
|
||||
for i in 1..=steps {
|
||||
let t = i as f64 / steps as f64;
|
||||
// Ease maps wall-time progress so most points cluster near the ends
|
||||
// (slow start, slow finish, fast middle).
|
||||
let te = ease(t);
|
||||
let (x, y) = bezier(from, p1, p2, to, te);
|
||||
let frac = te - prev_ease;
|
||||
prev_ease = te;
|
||||
out.push(MoveStep {
|
||||
x,
|
||||
y,
|
||||
delay: Duration::from_micros((total_ms * frac * 1000.0).max(0.0) as u64),
|
||||
});
|
||||
}
|
||||
// Guarantee the final point is exactly the target.
|
||||
if let Some(last) = out.last_mut() {
|
||||
last.x = to.0;
|
||||
last.y = to.1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split a wheel scroll of (`total_dx`, `total_dy`) into eased segments. `Off`
|
||||
/// returns a single instant segment (today's one-shot scroll); `Fast`/`Human`
|
||||
/// break it into several accelerate-then-decelerate chunks with small,
|
||||
/// jittered inter-segment delays, the way a trackpad/wheel flick actually
|
||||
/// lands. The segment deltas always sum to the requested total.
|
||||
pub fn scroll_segments(
|
||||
total_dx: f64,
|
||||
total_dy: f64,
|
||||
level: HumanizeLevel,
|
||||
seed: u64,
|
||||
) -> Vec<(f64, f64, Duration)> {
|
||||
if level.is_off() {
|
||||
return vec![(total_dx, total_dy, Duration::ZERO)];
|
||||
}
|
||||
let (segs, base_ms) = match level {
|
||||
HumanizeLevel::Fast => (4usize, 18.0),
|
||||
_ => (9usize, 28.0),
|
||||
};
|
||||
let mut rng = Rng::new(seed);
|
||||
let mut out = Vec::with_capacity(segs);
|
||||
let mut prev = 0.0;
|
||||
for i in 1..=segs {
|
||||
let f = ease(i as f64 / segs as f64);
|
||||
let frac = f - prev;
|
||||
prev = f;
|
||||
let jitter = 1.0 + 0.3 * rng.signed();
|
||||
out.push((
|
||||
total_dx * frac,
|
||||
total_dy * frac,
|
||||
Duration::from_millis((base_ms * jitter).max(4.0) as u64),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Dwell between `mousePressed` and `mouseReleased` (a real click isn't
|
||||
/// instantaneous). Zero for `Off`.
|
||||
pub fn press_dwell(level: HumanizeLevel, seed: u64) -> Duration {
|
||||
match level {
|
||||
HumanizeLevel::Off => Duration::ZERO,
|
||||
HumanizeLevel::Fast => Duration::from_millis(20 + (seed % 30)),
|
||||
HumanizeLevel::Human => Duration::from_millis(50 + (seed % 90)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-character delays for typing `len` characters. `Off` is all-zero (use a
|
||||
/// single `Input.insertText`); `Fast`/`Human` produce variable inter-keystroke
|
||||
/// gaps with the occasional longer "think" pause, like a real typist.
|
||||
pub fn keystroke_delays(len: usize, level: HumanizeLevel, seed: u64) -> Vec<Duration> {
|
||||
if level.is_off() || len == 0 {
|
||||
return vec![Duration::ZERO; len];
|
||||
}
|
||||
let (mean, jitter, pause_chance, pause_extra) = match level {
|
||||
HumanizeLevel::Fast => (25.0, 15.0, 0.0, 0.0),
|
||||
_ => (95.0, 55.0, 0.06, 220.0),
|
||||
};
|
||||
let mut rng = Rng::new(seed);
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
let mut ms = (mean + rng.signed() * jitter).max(8.0);
|
||||
if pause_chance > 0.0 && rng.unit() < pause_chance {
|
||||
ms += rng.unit() * pause_extra;
|
||||
}
|
||||
Duration::from_millis(ms as u64)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Page signals sampled after navigation, used to decide whether to escalate a
|
||||
/// session to [`HumanizeLevel::Human`]. All strings are matched case-insensitively.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct DetectSignals {
|
||||
/// Cookie names present on the document (e.g. `_abck`, `datadome`).
|
||||
pub cookie_names: Vec<String>,
|
||||
/// `src` of loaded scripts.
|
||||
pub script_urls: Vec<String>,
|
||||
/// Names of suspicious globals on `window` (e.g. `_px`, `bmak`).
|
||||
pub window_globals: Vec<String>,
|
||||
}
|
||||
|
||||
/// Known behavioural anti-bot fingerprints: (substring, vendor). Matched against
|
||||
/// cookie names, script URLs, and window globals.
|
||||
const VENDOR_MARKERS: &[(&str, &str)] = &[
|
||||
("_abck", "akamai"),
|
||||
("bm_sz", "akamai"),
|
||||
("ak_bmsc", "akamai"),
|
||||
("bmak", "akamai"),
|
||||
("_px", "perimeterx"),
|
||||
("perimeterx", "perimeterx"),
|
||||
("px-cloud", "perimeterx"),
|
||||
("datadome", "datadome"),
|
||||
("kpsdk", "kasada"),
|
||||
("incap_ses", "imperva"),
|
||||
("visid_incap", "imperva"),
|
||||
("reese84", "imperva"),
|
||||
("__cf_bm", "cloudflare-bot-mgmt"),
|
||||
];
|
||||
|
||||
/// Decide the level for a page. Returns `Human` if any known anti-bot vendor is
|
||||
/// present, otherwise `baseline`. Misses just stay at baseline and false hits
|
||||
/// only cost a little latency, so matching is deliberately liberal.
|
||||
pub fn detect_level(signals: &DetectSignals, baseline: HumanizeLevel) -> HumanizeLevel {
|
||||
let hay: Vec<String> = signals
|
||||
.cookie_names
|
||||
.iter()
|
||||
.chain(signals.script_urls.iter())
|
||||
.chain(signals.window_globals.iter())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.collect();
|
||||
let matched = VENDOR_MARKERS
|
||||
.iter()
|
||||
.any(|(marker, _)| hay.iter().any(|h| h.contains(marker)));
|
||||
if matched {
|
||||
HumanizeLevel::Human
|
||||
} else {
|
||||
baseline
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_known_levels_and_rejects_junk() {
|
||||
assert_eq!(HumanizeLevel::parse("off"), Some(HumanizeLevel::Off));
|
||||
assert_eq!(HumanizeLevel::parse(" FAST "), Some(HumanizeLevel::Fast));
|
||||
assert_eq!(HumanizeLevel::parse("Human"), Some(HumanizeLevel::Human));
|
||||
assert_eq!(HumanizeLevel::parse("max"), Some(HumanizeLevel::Human));
|
||||
assert_eq!(HumanizeLevel::parse("wat"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_level_teleports_in_one_step() {
|
||||
let path = move_path((0.0, 0.0), (100.0, 50.0), HumanizeLevel::Off, 1);
|
||||
assert_eq!(path.len(), 1);
|
||||
assert_eq!((path[0].x, path[0].y), (100.0, 50.0));
|
||||
assert_eq!(path[0].delay, Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn humanized_path_is_multi_step_and_lands_exactly_on_target() {
|
||||
let to = (640.0, 480.0);
|
||||
let path = move_path((10.0, 10.0), to, HumanizeLevel::Human, 42);
|
||||
assert!(path.len() >= 8, "human path should have many steps");
|
||||
let last = path.last().unwrap();
|
||||
assert_eq!((last.x, last.y), to, "final point must equal the target");
|
||||
// Path must actually leave the straight line at some point (it's a curve).
|
||||
let straight = path.iter().all(|s| {
|
||||
let t = (s.x - 10.0) / (to.0 - 10.0);
|
||||
(s.y - (10.0 + t * (to.1 - 10.0))).abs() < 0.5
|
||||
});
|
||||
assert!(!straight, "human path should bow off the straight line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_path_is_shorter_than_human() {
|
||||
let fast = move_path((0.0, 0.0), (500.0, 500.0), HumanizeLevel::Fast, 7);
|
||||
let human = move_path((0.0, 0.0), (500.0, 500.0), HumanizeLevel::Human, 7);
|
||||
assert!(fast.len() < human.len());
|
||||
assert!((3..=6).contains(&fast.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_path_is_deterministic_for_a_seed() {
|
||||
let a = move_path((1.0, 2.0), (300.0, 400.0), HumanizeLevel::Human, 99);
|
||||
let b = move_path((1.0, 2.0), (300.0, 400.0), HumanizeLevel::Human, 99);
|
||||
assert_eq!(a, b);
|
||||
let c = move_path((1.0, 2.0), (300.0, 400.0), HumanizeLevel::Human, 100);
|
||||
assert_ne!(a, c, "different seeds should differ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landing_point_stays_inside_bounds_and_centres_when_off() {
|
||||
let bbox = (100.0, 100.0, 40.0, 20.0);
|
||||
assert_eq!(landing_point(bbox, HumanizeLevel::Off, 1), (120.0, 110.0));
|
||||
for seed in 0..200 {
|
||||
let (x, y) = landing_point(bbox, HumanizeLevel::Human, seed);
|
||||
assert!(x > 100.0 && x < 140.0, "x {x} escaped bbox");
|
||||
assert!(y > 100.0 && y < 120.0, "y {y} escaped bbox");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keystroke_delays_zero_when_off_and_positive_otherwise() {
|
||||
assert!(keystroke_delays(5, HumanizeLevel::Off, 1)
|
||||
.iter()
|
||||
.all(|d| *d == Duration::ZERO));
|
||||
let human = keystroke_delays(20, HumanizeLevel::Human, 3);
|
||||
assert_eq!(human.len(), 20);
|
||||
assert!(human.iter().all(|d| *d >= Duration::from_millis(8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_segments_sum_to_total_and_single_when_off() {
|
||||
let off = scroll_segments(0.0, 600.0, HumanizeLevel::Off, 1);
|
||||
assert_eq!(off.len(), 1);
|
||||
assert_eq!((off[0].0, off[0].1), (0.0, 600.0));
|
||||
assert_eq!(off[0].2, Duration::ZERO);
|
||||
|
||||
let human = scroll_segments(0.0, 600.0, HumanizeLevel::Human, 5);
|
||||
assert!(human.len() >= 5);
|
||||
let total_dy: f64 = human.iter().map(|s| s.1).sum();
|
||||
assert!(
|
||||
(total_dy - 600.0).abs() < 1e-6,
|
||||
"segments must sum to total"
|
||||
);
|
||||
assert!(human.iter().all(|s| s.2 >= Duration::from_millis(4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_escalates_on_known_vendor_else_baseline() {
|
||||
let mut s = DetectSignals::default();
|
||||
assert_eq!(detect_level(&s, HumanizeLevel::Off), HumanizeLevel::Off);
|
||||
|
||||
s.cookie_names = vec!["sessionid".into(), "_abck".into()];
|
||||
assert_eq!(detect_level(&s, HumanizeLevel::Off), HumanizeLevel::Human);
|
||||
|
||||
let s2 = DetectSignals {
|
||||
script_urls: vec!["https://cdn.example.com/DataDome-tags.js".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(detect_level(&s2, HumanizeLevel::Off), HumanizeLevel::Human);
|
||||
|
||||
let s3 = DetectSignals {
|
||||
window_globals: vec!["_pxAppId".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(detect_level(&s3, HumanizeLevel::Fast), HumanizeLevel::Human);
|
||||
|
||||
// Unknown signals keep the baseline.
|
||||
let s4 = DetectSignals {
|
||||
cookie_names: vec!["cart".into(), "theme".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(detect_level(&s4, HumanizeLevel::Fast), HumanizeLevel::Fast);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use super::cdp::client::InspectProxyHandle;
|
||||
/// Counter for unique attach IDs so concurrent connections don't collide.
|
||||
static ATTACH_ID: AtomicI64 = AtomicI64::new(-1000);
|
||||
|
||||
/// Lightweight HTTP + WebSocket server for `agent-browser inspect`.
|
||||
/// Lightweight HTTP + WebSocket server for `chrome-use inspect`.
|
||||
///
|
||||
/// Serves two purposes:
|
||||
/// - `GET /` redirects to Chrome's built-in DevTools frontend with `ws=` pointing to this server
|
||||
|
||||
+768
-64
@@ -4,7 +4,19 @@ use serde_json::Value;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::*;
|
||||
use super::element::{resolve_element_center, resolve_element_object_id, RefMap};
|
||||
use super::element::{parse_ref, resolve_element_center, resolve_element_object_id, RefMap};
|
||||
use super::humanize;
|
||||
|
||||
/// Whether a pointer interaction should be DOM-dispatched (invoke the event on
|
||||
/// the element in its own session) rather than dispatched at a viewport
|
||||
/// coordinate via `Input.dispatchMouseEvent`. True when the target is inside an
|
||||
/// iframe (an OOPIF element's box can't be mapped to a top-viewport point) or we
|
||||
/// drive over the extension relay (a coordinate Input event isn't confined to the
|
||||
/// target tab on a busy real Chrome — it drifts onto the foreground tab; issues
|
||||
/// #31/#36). DOM-dispatch always hits the right element in the right tab.
|
||||
fn prefer_dom_dispatch(ref_map: &RefMap, selector_or_ref: &str) -> bool {
|
||||
ref_map.ref_is_in_iframe(selector_or_ref) || crate::connect::relay_url().is_some()
|
||||
}
|
||||
|
||||
pub async fn click(
|
||||
client: &CdpClient,
|
||||
@@ -15,7 +27,240 @@ pub async fn click(
|
||||
click_count: i32,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (x, y, effective_session_id) = resolve_element_center(
|
||||
// AGENT_BROWSER_CLICK_MODE: "" (default) = coordinate click with a DOM
|
||||
// fallback; "coord" = strict coordinate only (no fallback); "dom" = always
|
||||
// dispatch through the DOM.
|
||||
let mode = std::env::var("AGENT_BROWSER_CLICK_MODE").unwrap_or_default();
|
||||
|
||||
// (A) Scroll the target into view first so the computed coordinates land
|
||||
// inside the viewport. Without this, an element below the fold (or revealed
|
||||
// after scroll/popup) yields off-viewport coordinates and the click lands on
|
||||
// whatever currently occupies that point. Best-effort: ignore failures.
|
||||
scroll_into_view_if_needed(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
|
||||
if mode == "dom" {
|
||||
return dom_click(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// An element INSIDE an iframe needs a TRUSTED activation: a DOM `.click()` is
|
||||
// `isTrusted:false`, which security-sensitive embedded forms reject — Google
|
||||
// Payments' enabled `保存` button silently no-ops on a synthetic click (issue
|
||||
// #39). A coordinate `Input.dispatchMouseEvent` can't help either: `getBoxModel`
|
||||
// for a sub-frame node returns frame-local coordinates that don't compose the
|
||||
// iframe's offset, so the click lands in the wrong place. The frame-agnostic
|
||||
// trusted path is keyboard activation — focus the element in its own frame, then
|
||||
// dispatch a real Enter on the page session; Chrome routes the key to the
|
||||
// focused element regardless of frame (same as `type --focused`), and Enter on a
|
||||
// focused button/link fires a trusted `click`. `coord` mode opts out.
|
||||
let in_iframe = ref_map.ref_is_in_iframe(selector_or_ref);
|
||||
if mode != "coord" && button == "left" && click_count == 1 && in_iframe {
|
||||
return dom_activate(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// On the relay (the user's real Chrome) a TOP-document coordinate click used to
|
||||
// drift onto the foreground tab; that root cause is fixed (#5: the agent drives
|
||||
// its own pinned tab), but DOM-dispatch stays the conservative default here.
|
||||
if mode != "coord"
|
||||
&& button == "left"
|
||||
&& click_count == 1
|
||||
&& crate::connect::relay_url().is_some()
|
||||
{
|
||||
return dom_click(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let resolved = resolve_element_center(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
|
||||
match resolved {
|
||||
Ok((cx, cy, w, h, effective_session_id)) => {
|
||||
// Occlusion guard for the CSS-selector path. `@ref` clicks are already
|
||||
// occlusion-checked in resolve_element_center, but a plain selector
|
||||
// resolves to coordinates without that check — so an overlay (modal
|
||||
// backdrop, sticky banner, the getByText located node sitting under a
|
||||
// full-screen layer) would make the coordinate click land on the
|
||||
// overlay and still report success. If the click point doesn't hit the
|
||||
// target, dispatch through the DOM instead (targets the element
|
||||
// directly). Skipped for strict `coord` mode and non-left/multi-clicks.
|
||||
if mode != "coord"
|
||||
&& button == "left"
|
||||
&& click_count == 1
|
||||
&& parse_ref(selector_or_ref).is_none()
|
||||
&& point_misses_element(client, &effective_session_id, selector_or_ref).await
|
||||
{
|
||||
eprintln!(
|
||||
"[click] target occluded at its click point; dispatching through \
|
||||
the DOM (set AGENT_BROWSER_CLICK_MODE=coord to disable)"
|
||||
);
|
||||
return dom_click(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// Land on a jittered point inside the element rather than its exact
|
||||
// centre (Fast/Human). Zero size or Off → exact centre.
|
||||
let (tx, ty) = humanize::landing_point(
|
||||
(cx - w / 2.0, cy - h / 2.0, w, h),
|
||||
humanize::active_level(),
|
||||
humanize::next_seed(),
|
||||
);
|
||||
dispatch_click(client, &effective_session_id, tx, ty, button, click_count).await
|
||||
}
|
||||
Err(e) => {
|
||||
// (B) The coordinate path failed — typically a persistent overlay
|
||||
// failing the occlusion guard, or coordinates that won't resolve.
|
||||
// Fall back to a DOM-dispatched `.click()` on the intended element,
|
||||
// which targets the element directly instead of a screen point.
|
||||
// Skipped for strict "coord" mode and for non-left / multi-clicks
|
||||
// (a DOM `.click()` can't express right/middle/double semantics).
|
||||
if mode == "coord" || button != "left" || click_count != 1 {
|
||||
return Err(e);
|
||||
}
|
||||
eprintln!(
|
||||
"[click] coordinate click failed ({e}); falling back to DOM dispatch \
|
||||
(set AGENT_BROWSER_CLICK_MODE=coord to disable)"
|
||||
);
|
||||
dom_click(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await
|
||||
.map_err(|dom_err| format!("{e}\n(DOM-dispatch fallback also failed: {dom_err})"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True if a coordinate click at the selector's centre would land on something
|
||||
/// OTHER than the element (an overlay on top), i.e. the element is occluded.
|
||||
/// `false` when not occluded, the element is missing, or the probe fails (so we
|
||||
/// never block a click on a flaky probe — the normal coordinate path runs).
|
||||
async fn point_misses_element(client: &CdpClient, session_id: &str, selector: &str) -> bool {
|
||||
let js = format!(
|
||||
r#"(() => {{
|
||||
const el = document.querySelector({sel});
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 || r.height === 0) return false;
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
if (!hit) return false;
|
||||
// Not occluded if the hit is the element, a descendant, or an ancestor
|
||||
// wrapper (clicking those still reaches the element's handlers).
|
||||
return !(hit === el || el.contains(hit) || hit.contains(el));
|
||||
}})()"#,
|
||||
sel = serde_json::to_string(selector).unwrap_or_default()
|
||||
);
|
||||
match client
|
||||
.send_command_typed::<_, EvaluateResult>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r.result.value.and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort scroll-into-view before a coordinate click. Uses Chrome's
|
||||
/// `scrollIntoViewIfNeeded` (only scrolls when not already fully visible),
|
||||
/// falling back to centered `scrollIntoView`. Resolution failures are ignored —
|
||||
/// the subsequent resolve will surface a real "not found" error.
|
||||
async fn scroll_into_view_if_needed(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) {
|
||||
let Ok((object_id, effective_session_id)) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let js = "function() { try { \
|
||||
if (typeof this.scrollIntoViewIfNeeded === 'function') { this.scrollIntoViewIfNeeded(true); } \
|
||||
else { this.scrollIntoView({ block: 'center', inline: 'center' }); } \
|
||||
} catch (e) {} }";
|
||||
let _ = client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: js.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await;
|
||||
// Let the scroll settle so the following getBoxModel sees final coordinates.
|
||||
wait_for_paint_settled(client, &effective_session_id).await;
|
||||
}
|
||||
|
||||
/// Dispatch a click through the DOM (`element.click()`) instead of via screen
|
||||
/// coordinates. Targets the intended element directly, so it works when a
|
||||
/// floating layer occludes the click point or the element sits in a portal that
|
||||
/// confuses `elementFromPoint`. Used as the fallback for `click` and when
|
||||
/// `AGENT_BROWSER_CLICK_MODE=dom`.
|
||||
async fn dom_click(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
@@ -23,7 +268,127 @@ pub async fn click(
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
dispatch_click(client, &effective_session_id, x, y, button, click_count).await
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { this.click(); }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
wait_for_paint_settled(client, &effective_session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trusted activation of an element inside an iframe (issue #39). Focuses the
|
||||
/// element in its own frame session, then dispatches a real Enter/Space on the
|
||||
/// page session — Chrome routes the key to the focused element across frames, and
|
||||
/// Enter/Space on a focused button/link/checkbox fires a `click` with
|
||||
/// `isTrusted: true`, which security-sensitive embedded forms (Google Payments
|
||||
/// `保存`) require. Non-activatable roles (a `div[onclick]`) can't be keyboard-
|
||||
/// activated, so they fall back to a DOM `.click()`.
|
||||
async fn dom_activate(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let role = parse_ref(selector_or_ref)
|
||||
.and_then(|r| ref_map.get(&r).map(|e| e.role.clone()))
|
||||
.unwrap_or_default();
|
||||
// Space toggles checkbox-like controls; Enter activates buttons/links/menus.
|
||||
let key = match role.as_str() {
|
||||
"checkbox" | "radio" | "switch" | "option" | "menuitemcheckbox" | "menuitemradio" => {
|
||||
Some("space")
|
||||
}
|
||||
"button" | "link" | "menuitem" | "tab" | "treeitem" => Some("enter"),
|
||||
_ => None,
|
||||
};
|
||||
let Some(key) = key else {
|
||||
// Not keyboard-activatable — best effort via DOM .click() (untrusted).
|
||||
return dom_click(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
};
|
||||
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
// Focus the element in its OWN frame session so the keystroke lands on it.
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { this.focus(); }".to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
// Trusted key on the page session — routed to the focused (in-frame) element.
|
||||
press_key(client, session_id, key).await?;
|
||||
wait_for_paint_settled(client, &effective_session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// DOM-dispatch a double-click on the element in its own session (no coordinates)
|
||||
/// — the relay/iframe-safe counterpart to a coordinate dblclick. Fires the full
|
||||
/// click,click,dblclick sequence so handlers bound to any of them respond.
|
||||
async fn dom_dblclick(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const opts = { bubbles: true, cancelable: true, view: window };
|
||||
this.dispatchEvent(new MouseEvent('click', opts));
|
||||
this.dispatchEvent(new MouseEvent('click', { ...opts, detail: 2 }));
|
||||
this.dispatchEvent(new MouseEvent('dblclick', opts));
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
wait_for_paint_settled(client, &effective_session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn dblclick(
|
||||
@@ -33,6 +398,20 @@ pub async fn dblclick(
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
// Same relay/iframe drift hazard as a single click — DOM-dispatch the
|
||||
// double-click there instead of a coordinate one (issues #31/#36).
|
||||
if std::env::var("AGENT_BROWSER_CLICK_MODE").as_deref() != Ok("coord")
|
||||
&& prefer_dom_dispatch(ref_map, selector_or_ref)
|
||||
{
|
||||
return dom_dblclick(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
click(
|
||||
client,
|
||||
session_id,
|
||||
@@ -45,6 +424,50 @@ pub async fn dblclick(
|
||||
.await
|
||||
}
|
||||
|
||||
/// DOM-dispatch a hover (pointer/mouse enter+move) on the element in its own
|
||||
/// session — reaches OOPIF elements and never drifts to the foreground tab over
|
||||
/// the relay, unlike a coordinate `mouseMoved` (issues #31/#36).
|
||||
async fn dom_hover(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const r = this.getBoundingClientRect();
|
||||
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
||||
const base = { bubbles: true, cancelable: true, view: window, clientX: cx, clientY: cy };
|
||||
this.dispatchEvent(new PointerEvent('pointerover', base));
|
||||
this.dispatchEvent(new PointerEvent('pointerenter', { ...base, bubbles: false }));
|
||||
this.dispatchEvent(new MouseEvent('mouseover', base));
|
||||
this.dispatchEvent(new MouseEvent('mouseenter', { ...base, bubbles: false }));
|
||||
this.dispatchEvent(new MouseEvent('mousemove', base));
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn hover(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
@@ -52,7 +475,19 @@ pub async fn hover(
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (x, y, effective_session_id) = resolve_element_center(
|
||||
// Coordinate `mouseMoved` drifts to the foreground tab over the relay and
|
||||
// can't reach an OOPIF — DOM-dispatch the hover there (issues #31/#36).
|
||||
if prefer_dom_dispatch(ref_map, selector_or_ref) {
|
||||
return dom_hover(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector_or_ref,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let (x, y, _w, _h, effective_session_id) = resolve_element_center(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
@@ -80,6 +515,63 @@ pub async fn hover(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// DOM-dispatch an HTML5 drag-and-drop from `source` to `target` in their shared
|
||||
/// session — the relay/iframe-safe counterpart to the coordinate drag, which
|
||||
/// drifts to the foreground tab over the relay and can't reach an OOPIF (issues
|
||||
/// #31/#36). Covers HTML5 DnD (sortable lists, file/card boards); pointer-driven
|
||||
/// drag (canvas, sliders) still needs the coordinate path. Errors if source and
|
||||
/// target live in different frames — a synthetic cross-frame DnD isn't reliable.
|
||||
pub async fn dom_drag(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
source: &str,
|
||||
target: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (src_obj, src_session) =
|
||||
resolve_element_object_id(client, session_id, ref_map, source, iframe_sessions).await?;
|
||||
let (tgt_obj, tgt_session) =
|
||||
resolve_element_object_id(client, session_id, ref_map, target, iframe_sessions).await?;
|
||||
if src_session != tgt_session {
|
||||
return Err(
|
||||
"drag source and target are in different frames; cross-frame drag-and-drop over the \
|
||||
relay isn't supported — drag within a single frame, or use a launched browser with \
|
||||
AGENT_BROWSER_CLICK_MODE=coord"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function(target) {
|
||||
const dt = new DataTransfer();
|
||||
const ev = (type, el) => el.dispatchEvent(
|
||||
new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt }));
|
||||
ev('dragstart', this);
|
||||
ev('drag', this);
|
||||
ev('dragenter', target);
|
||||
ev('dragover', target);
|
||||
ev('drop', target);
|
||||
ev('dragend', this);
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(src_obj),
|
||||
arguments: Some(vec![CallArgument {
|
||||
value: None,
|
||||
object_id: Some(tgt_obj),
|
||||
}]),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(&src_session),
|
||||
)
|
||||
.await?;
|
||||
wait_for_paint_settled(client, &src_session).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fill(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
@@ -87,7 +579,7 @@ pub async fn fill(
|
||||
selector_or_ref: &str,
|
||||
value: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<String, String> {
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
@@ -97,32 +589,81 @@ pub async fn fill(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Focus the element
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: "function() { this.focus(); }".to_string(),
|
||||
object_id: Some(object_id.clone()),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(&effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
// Emulate a real edit so framework-controlled inputs (React/Vue) and
|
||||
// site-side listeners actually see the change (issue #25): set the value
|
||||
// through the element's PROTOTYPE setter (which React's _valueTracker hooks),
|
||||
// then dispatch input → change → blur/focusout. Beyond plain inputs, detect
|
||||
// rich editors and use their own API/events (issue #41): CodeMirror 5 and
|
||||
// Monaco have a model that `.value`/`textContent` can't touch; ProseMirror /
|
||||
// contenteditable need `execCommand('insertText')` so beforeinput/input fire
|
||||
// (a raw `textContent =` corrupts PM's doc and skips React composers).
|
||||
// Returns the engine used so the caller can report it. `type <sel> <text>`
|
||||
// remains for sites that need per-keystroke events.
|
||||
let fill_js = format!(
|
||||
r#"function() {{
|
||||
const el = this;
|
||||
const v = {val};
|
||||
try {{ el.focus(); }} catch (e) {{}}
|
||||
const tag = el.tagName;
|
||||
const fire = (type, ctor) => el.dispatchEvent(new (ctor || Event)(type, {{ bubbles: true }}));
|
||||
|
||||
// Select all + delete to clear
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
// CodeMirror 5: a hidden <textarea> inside .CodeMirror with a live instance.
|
||||
const cm5 = el.closest && el.closest('.CodeMirror');
|
||||
if (cm5 && cm5.CodeMirror) {{ cm5.CodeMirror.setValue(v); return 'codemirror5'; }}
|
||||
|
||||
// Monaco: global `monaco`; prefer the editor whose DOM contains el.
|
||||
if (window.monaco && monaco.editor) {{
|
||||
try {{
|
||||
const eds = monaco.editor.getEditors ? monaco.editor.getEditors() : [];
|
||||
const ed = eds.find(e => e.getDomNode && e.getDomNode().contains(el)) || eds[0];
|
||||
if (ed) {{ ed.setValue(v); return 'monaco'; }}
|
||||
const models = monaco.editor.getModels ? monaco.editor.getModels() : [];
|
||||
if (models[0]) {{ models[0].setValue(v); return 'monaco'; }}
|
||||
}} catch (e) {{}}
|
||||
}}
|
||||
|
||||
if (tag === 'SELECT') {{ el.value = v; fire('input'); fire('change'); return 'select'; }}
|
||||
|
||||
if (el.isContentEditable) {{
|
||||
// ProseMirror / contenteditable: select-all then insertText fires
|
||||
// beforeinput/input that PM and React composers listen for.
|
||||
let ok = false;
|
||||
try {{
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
ok = document.execCommand('insertText', false, v);
|
||||
}} catch (e) {{}}
|
||||
if (!ok) {{ el.textContent = v; fire('input', window.InputEvent || Event); }}
|
||||
fire('change');
|
||||
try {{ el.blur(); }} catch (e) {{}}
|
||||
fire('focusout');
|
||||
return ok ? 'contenteditable' : 'contenteditable-fallback';
|
||||
}}
|
||||
|
||||
const proto = tag === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype;
|
||||
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||
const set = desc && desc.set ? (x) => desc.set.call(el, x) : (x) => {{ el.value = x; }};
|
||||
set(''); // reset the framework tracker
|
||||
fire('input', window.InputEvent || Event);
|
||||
set(v); // native setter → React/Vue registers
|
||||
fire('input', window.InputEvent || Event);
|
||||
fire('change');
|
||||
try {{ el.blur(); }} catch (e) {{}}
|
||||
fire('focusout'); // blur-triggered lookups/validation
|
||||
return 'input';
|
||||
}}"#,
|
||||
val = serde_json::to_string(value).unwrap_or_default()
|
||||
);
|
||||
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
this.select && this.select();
|
||||
this.value = '';
|
||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}"#
|
||||
.to_string(),
|
||||
function_declaration: fill_js,
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
@@ -132,18 +673,11 @@ pub async fn fill(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert text (keyboard input dispatched at page level, use parent session_id)
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.insertText",
|
||||
&InsertTextParams {
|
||||
text: value.to_string(),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_else(|| "input".to_string()))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -156,6 +690,7 @@ pub async fn type_text(
|
||||
clear: bool,
|
||||
delay_ms: Option<u64>,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
key_events: bool,
|
||||
) -> Result<(), String> {
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
@@ -202,7 +737,7 @@ pub async fn type_text(
|
||||
.await?;
|
||||
}
|
||||
|
||||
type_text_into_active_context(client, session_id, text, delay_ms).await
|
||||
type_text_into_active_context(client, session_id, text, delay_ms, key_events).await
|
||||
}
|
||||
|
||||
pub async fn type_text_into_active_context(
|
||||
@@ -210,10 +745,20 @@ pub async fn type_text_into_active_context(
|
||||
session_id: &str,
|
||||
text: &str,
|
||||
delay_ms: Option<u64>,
|
||||
key_events: bool,
|
||||
) -> Result<(), String> {
|
||||
let delay = delay_ms.unwrap_or(0);
|
||||
// Per-character timing: an explicit `delay_ms` wins (caller asked for a
|
||||
// fixed cadence); otherwise fall back to humanize — variable, human-like
|
||||
// inter-keystroke gaps at Fast/Human, all-zero (instant) at Off.
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let cadence: Vec<std::time::Duration> = match delay_ms {
|
||||
Some(d) => vec![std::time::Duration::from_millis(d); chars.len()],
|
||||
None => {
|
||||
humanize::keystroke_delays(chars.len(), humanize::active_level(), humanize::next_seed())
|
||||
}
|
||||
};
|
||||
|
||||
for ch in text.chars() {
|
||||
for (i, ch) in chars.into_iter().enumerate() {
|
||||
if matches!(ch, '\n' | '\r' | '\t') {
|
||||
let (key, code, key_code) = char_to_key_info(ch);
|
||||
let text_str = key_text(&key);
|
||||
@@ -250,6 +795,46 @@ pub async fn type_text_into_active_context(
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
} else if key_events {
|
||||
// Real keystrokes (keyDown+keyUp carrying `text`) for autocomplete /
|
||||
// combobox widgets that only react to key events and ignore the
|
||||
// `input` that `Input.insertText` fires — e.g. Google's address
|
||||
// postal-code → city/prefecture lookup (issue #36 / #4). The keyDown's
|
||||
// `text` still inserts the character, so the field also fills.
|
||||
let (key, code, key_code) = char_to_key_info(ch);
|
||||
let s = ch.to_string();
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyDown".to_string(),
|
||||
key: Some(key.clone()),
|
||||
code: Some(code.clone()),
|
||||
text: Some(s.clone()),
|
||||
unmodified_text: Some(s),
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: "keyUp".to_string(),
|
||||
key: Some(key),
|
||||
code: Some(code),
|
||||
text: None,
|
||||
unmodified_text: None,
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
// VS Code/Electron webviews reject repeated dispatchKeyEvent calls
|
||||
// carrying printable `text`. Insert printable characters directly
|
||||
@@ -265,8 +850,9 @@ pub async fn type_text_into_active_context(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if delay > 0 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
|
||||
let gap = cadence[i];
|
||||
if !gap.is_zero() {
|
||||
tokio::time::sleep(gap).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,6 +924,48 @@ pub async fn press_key_with_modifiers(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatch a SINGLE key event (`keyDown` or `keyUp`) carrying the full key
|
||||
/// descriptor — `key`, `code`, `windowsVirtualKeyCode`/`nativeVirtualKeyCode`,
|
||||
/// and (on key-down) printable `text`. Powers the `keydown`/`keyup` commands.
|
||||
///
|
||||
/// The previous implementation sent only `{key}`, so games and shortcut handlers
|
||||
/// that read `event.code` (e.g. `"KeyD"`, `"ArrowRight"`) or `event.keyCode` saw
|
||||
/// nothing — a held key set no movement flag and did nothing (dogfood: holding a
|
||||
/// direction in a canvas platformer barely nudged the player). Sending the same
|
||||
/// descriptor `press` uses makes hold-to-move work regardless of which field the
|
||||
/// page keys off.
|
||||
pub async fn dispatch_single_key(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
key: &str,
|
||||
event_type: &str,
|
||||
) -> Result<(), String> {
|
||||
let (key_name, code, key_code) = named_key_info(key);
|
||||
// Printable text is only meaningful on key-down; key-up never inserts.
|
||||
let text = if event_type == "keyDown" {
|
||||
key_text(&key_name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchKeyEvent",
|
||||
&DispatchKeyEventParams {
|
||||
event_type: event_type.to_string(),
|
||||
key: Some(key_name),
|
||||
code: Some(code),
|
||||
text: text.clone(),
|
||||
unmodified_text: text,
|
||||
windows_virtual_key_code: Some(key_code),
|
||||
native_virtual_key_code: Some(key_code),
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn scroll(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
@@ -850,7 +1478,7 @@ pub async fn tap_touch(
|
||||
selector_or_ref: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let (x, y, effective_session_id) = resolve_element_center(
|
||||
let (x, y, _w, _h, effective_session_id) = resolve_element_center(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
@@ -884,6 +1512,60 @@ pub async fn tap_touch(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// After a click is dispatched, give the page two animation frames + a
|
||||
/// microtask boundary to let React/Vue/Svelte commit any state update
|
||||
/// scheduled by the click handler. Without this wait, follow-up commands
|
||||
/// (e.g. `inserttext` against the textbox the click was supposed to mount)
|
||||
/// race the renderer and can land on stale or wrong elements.
|
||||
///
|
||||
/// The wait is bounded to ~33ms in the common case (two RAFs at 60fps) and
|
||||
/// returns immediately on any error — never an exception path.
|
||||
///
|
||||
/// Set `AGENT_BROWSER_CLICK_WAIT_STABLE=0` to disable for perf-sensitive
|
||||
/// scripts that don't drive SPA UIs.
|
||||
async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
|
||||
if std::env::var("AGENT_BROWSER_CLICK_WAIT_STABLE").as_deref() == Ok("0") {
|
||||
return;
|
||||
}
|
||||
let script = "new Promise(resolve => \
|
||||
requestAnimationFrame(() => \
|
||||
requestAnimationFrame(() => \
|
||||
queueMicrotask(() => resolve(true)))))";
|
||||
// Tight 500ms timeout. RAF normally fires at 16ms, two RAFs total ~33ms.
|
||||
// If the tab is hidden / throttled / page is doing something pathological
|
||||
// and RAF doesn't fire in 500ms, we'd rather return now than stall the
|
||||
// user's click. Without this cap, a stuck RAF inherited the default 30s
|
||||
// CDP timeout and was the main contributor to the "click hangs 5+ min"
|
||||
// user report.
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(500),
|
||||
client.send_command_typed::<_, Value>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: script.to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(true),
|
||||
},
|
||||
Some(session_id),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Click at a raw viewport coordinate, bypassing element/selector resolution
|
||||
/// (issue #8.4 first-class coordinate click). Honors the humanize trajectory and
|
||||
/// press dwell exactly like a selector click — it shares `dispatch_click`.
|
||||
pub async fn click_at_point(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
x: f64,
|
||||
y: f64,
|
||||
button: &str,
|
||||
click_count: i32,
|
||||
) -> Result<(), String> {
|
||||
dispatch_click(client, session_id, x, y, button, click_count).await
|
||||
}
|
||||
|
||||
async fn dispatch_click(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
@@ -892,24 +1574,38 @@ async fn dispatch_click(
|
||||
button: &str,
|
||||
click_count: i32,
|
||||
) -> Result<(), String> {
|
||||
// Move
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchMouseEvent",
|
||||
&DispatchMouseEventParams {
|
||||
event_type: "mouseMoved".to_string(),
|
||||
x,
|
||||
y,
|
||||
button: None,
|
||||
buttons: None,
|
||||
click_count: None,
|
||||
delta_x: None,
|
||||
delta_y: None,
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
// Move toward the target along a human-like path. At HumanizeLevel::Off this
|
||||
// is a single zero-delay step to (x, y) — identical to the old teleport — so
|
||||
// the default behaviour is unchanged. At Fast/Human it's a curved,
|
||||
// decelerating trajectory starting from where the cursor last landed, which
|
||||
// removes the "instant jump to exact centre, no prior movement" tell that
|
||||
// behavioural anti-bot systems flag.
|
||||
let level = humanize::active_level();
|
||||
let start = humanize::last_cursor();
|
||||
let seed = humanize::next_seed();
|
||||
for step in humanize::move_path(start, (x, y), level, seed) {
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Input.dispatchMouseEvent",
|
||||
&DispatchMouseEventParams {
|
||||
event_type: "mouseMoved".to_string(),
|
||||
x: step.x,
|
||||
y: step.y,
|
||||
button: None,
|
||||
buttons: None,
|
||||
click_count: None,
|
||||
delta_x: None,
|
||||
delta_y: None,
|
||||
modifiers: None,
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
if !step.delay.is_zero() {
|
||||
tokio::time::sleep(step.delay).await;
|
||||
}
|
||||
}
|
||||
humanize::set_last_cursor((x, y));
|
||||
|
||||
let button_value = match button {
|
||||
"right" => 2,
|
||||
@@ -936,6 +1632,13 @@ async fn dispatch_click(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Hold briefly before releasing — a real click isn't instantaneous. Zero at
|
||||
// HumanizeLevel::Off.
|
||||
let dwell = humanize::press_dwell(level, seed);
|
||||
if !dwell.is_zero() {
|
||||
tokio::time::sleep(dwell).await;
|
||||
}
|
||||
|
||||
// Release
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
@@ -955,6 +1658,7 @@ async fn dispatch_click(
|
||||
)
|
||||
.await?;
|
||||
|
||||
wait_for_paint_settled(client, session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#[allow(dead_code)]
|
||||
pub mod actions;
|
||||
#[allow(dead_code)]
|
||||
pub mod adaptive;
|
||||
#[allow(dead_code)]
|
||||
pub mod auth;
|
||||
#[allow(dead_code)]
|
||||
pub mod browser;
|
||||
@@ -15,6 +17,8 @@ pub mod diff;
|
||||
#[allow(dead_code)]
|
||||
pub mod element;
|
||||
#[allow(dead_code)]
|
||||
pub mod humanize;
|
||||
#[allow(dead_code)]
|
||||
pub mod inspect_server;
|
||||
#[allow(dead_code)]
|
||||
pub mod interaction;
|
||||
@@ -25,14 +29,20 @@ pub mod policy;
|
||||
#[allow(dead_code)]
|
||||
pub mod providers;
|
||||
#[allow(dead_code)]
|
||||
pub mod react;
|
||||
#[allow(dead_code)]
|
||||
pub mod recording;
|
||||
#[allow(dead_code)]
|
||||
pub mod relay;
|
||||
#[allow(dead_code)]
|
||||
pub mod screenshot;
|
||||
#[allow(dead_code)]
|
||||
pub mod snapshot;
|
||||
#[allow(dead_code)]
|
||||
pub mod state;
|
||||
#[allow(dead_code)]
|
||||
pub mod stealth;
|
||||
#[allow(dead_code)]
|
||||
pub mod storage;
|
||||
#[allow(dead_code)]
|
||||
pub mod stream;
|
||||
|
||||
@@ -425,7 +425,7 @@ mod agentcore {
|
||||
let url = format!("https://{}{}", host, path);
|
||||
|
||||
// Generate a unique session name
|
||||
let session_name = format!("agent-browser-{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
let session_name = format!("chrome-use-{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
|
||||
let mut body_json = json!({
|
||||
"name": session_name,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,31 @@
|
||||
//! React/web introspection primitives.
|
||||
//!
|
||||
//! Scripts and handlers for the `react` subcommands (tree, inspect, renders,
|
||||
//! suspense) plus the universal `vitals` verb and the generic `pushstate`
|
||||
//! SPA-navigation action. These primitives are framework-agnostic: React-side
|
||||
//! commands only require the `__REACT_DEVTOOLS_GLOBAL_HOOK__` to be installed,
|
||||
//! and `vitals` / `pushstate` are pure web-standard APIs.
|
||||
//!
|
||||
//! The React DevTools `installHook.js` is vendored from the React DevTools
|
||||
//! Chrome extension (MIT, facebook/react). It's registered via
|
||||
//! `addScriptToEvaluateOnNewDocument` before any page JS runs when the user
|
||||
//! passes `--enable react-devtools` at launch.
|
||||
|
||||
pub mod scripts;
|
||||
|
||||
mod renders;
|
||||
mod suspense;
|
||||
mod tree;
|
||||
mod vitals;
|
||||
|
||||
pub use renders::{format_renders_report, RendersData};
|
||||
pub use suspense::{format_suspense_report, Boundary};
|
||||
pub use tree::{format_tree, TreeNode};
|
||||
pub use vitals::{format_vitals_report, VitalsData};
|
||||
|
||||
/// React DevTools hook script (MIT, from facebook/react).
|
||||
/// Registered via `addScriptToEvaluateOnNewDocument` to install
|
||||
/// `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` before any page JS runs. React
|
||||
/// detects the hook on boot and registers its renderers against it, which
|
||||
/// enables every `react …` command.
|
||||
pub const INSTALL_HOOK_JS: &str = include_str!("installHook.js");
|
||||
@@ -0,0 +1,169 @@
|
||||
//! React fiber render profiler report formatter.
|
||||
//!
|
||||
//! Default output is the
|
||||
//! full agent-readable report (summary, FPS, component table, per-component
|
||||
//! "change details (prev -> next)"). `--json` emits the raw structured data
|
||||
//! instead.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct RendersData {
|
||||
pub elapsed: f64,
|
||||
pub fps: FpsStats,
|
||||
#[serde(rename = "totalRenders")]
|
||||
pub total_renders: i64,
|
||||
#[serde(rename = "totalMounts")]
|
||||
pub total_mounts: i64,
|
||||
#[serde(rename = "totalReRenders")]
|
||||
pub total_re_renders: i64,
|
||||
#[serde(rename = "totalComponents")]
|
||||
pub total_components: i64,
|
||||
pub components: Vec<Component>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct FpsStats {
|
||||
pub avg: i64,
|
||||
pub min: i64,
|
||||
pub max: i64,
|
||||
pub drops: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Component {
|
||||
pub name: String,
|
||||
pub count: i64,
|
||||
pub mounts: i64,
|
||||
#[serde(rename = "reRenders")]
|
||||
pub re_renders: i64,
|
||||
#[serde(rename = "instanceCount")]
|
||||
pub instance_count: i64,
|
||||
#[serde(rename = "totalTime")]
|
||||
pub total_time: f64,
|
||||
#[serde(rename = "selfTime")]
|
||||
pub self_time: f64,
|
||||
#[serde(rename = "domMutations")]
|
||||
pub dom_mutations: i64,
|
||||
pub changes: Vec<Change>,
|
||||
#[serde(rename = "changeSummary")]
|
||||
pub change_summary: std::collections::HashMap<String, i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Change {
|
||||
#[serde(rename = "type")]
|
||||
pub change_type: String,
|
||||
pub name: Option<String>,
|
||||
pub prev: Option<String>,
|
||||
pub next: Option<String>,
|
||||
}
|
||||
|
||||
pub fn format_renders_report(d: &RendersData) -> String {
|
||||
if d.components.is_empty() {
|
||||
return "(no renders captured)".to_string();
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push(format!("# Render Profile - {}s recording", d.elapsed));
|
||||
lines.push(format!(
|
||||
"# {} renders ({} mounts + {} re-renders) across {} components",
|
||||
d.total_renders, d.total_mounts, d.total_re_renders, d.total_components
|
||||
));
|
||||
lines.push(format!(
|
||||
"# FPS: avg {}, min {}, max {}, drops (<30fps): {}",
|
||||
d.fps.avg, d.fps.min, d.fps.max, d.fps.drops
|
||||
));
|
||||
lines.push(String::new());
|
||||
lines.push("## Components by total render time".to_string());
|
||||
|
||||
let top: Vec<&Component> = d.components.iter().take(50).collect();
|
||||
let name_w = top.iter().map(|c| c.name.len()).max().unwrap_or(9).max(9);
|
||||
|
||||
lines.push(format!(
|
||||
"| {:<name_w$} | Insts | Mounts | Re-renders | Total | Self | DOM | Top change reason |",
|
||||
"Component",
|
||||
name_w = name_w
|
||||
));
|
||||
lines.push(format!(
|
||||
"| {:-<name_w$} | ----- | ------ | ---------- | -------- | -------- | ----- | -------------------------- |",
|
||||
"",
|
||||
name_w = name_w
|
||||
));
|
||||
for c in &top {
|
||||
let total = if c.total_time > 0.0 {
|
||||
format!("{}ms", c.total_time)
|
||||
} else {
|
||||
"-".to_string()
|
||||
};
|
||||
let self_time = if c.self_time > 0.0 {
|
||||
format!("{}ms", c.self_time)
|
||||
} else {
|
||||
"-".to_string()
|
||||
};
|
||||
let dom = format!("{}/{}", c.dom_mutations, c.count);
|
||||
let top_change = c
|
||||
.change_summary
|
||||
.iter()
|
||||
.max_by_key(|(_, v)| *v)
|
||||
.map(|(k, _)| k.as_str())
|
||||
.unwrap_or("-");
|
||||
lines.push(format!(
|
||||
"| {:<name_w$} | {:>5} | {:>6} | {:>10} | {:>8} | {:>8} | {:>5} | {:<26} |",
|
||||
c.name,
|
||||
c.instance_count,
|
||||
c.mounts,
|
||||
c.re_renders,
|
||||
total,
|
||||
self_time,
|
||||
dom,
|
||||
top_change,
|
||||
name_w = name_w
|
||||
));
|
||||
}
|
||||
if d.components.len() > 50 {
|
||||
lines.push(format!("... and {} more", d.components.len() - 50));
|
||||
}
|
||||
|
||||
let detailed: Vec<&Component> = d
|
||||
.components
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
c.changes
|
||||
.iter()
|
||||
.any(|ch| ch.change_type != "mount" && ch.change_type != "parent")
|
||||
})
|
||||
.take(15)
|
||||
.collect();
|
||||
if !detailed.is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("## Change details (prev -> next)".to_string());
|
||||
for c in &detailed {
|
||||
lines.push(format!(" {}", c.name));
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for ch in &c.changes {
|
||||
if ch.change_type == "mount" || ch.change_type == "parent" {
|
||||
continue;
|
||||
}
|
||||
let name = ch.name.clone().unwrap_or_default();
|
||||
let key = format!("{}:{}", ch.change_type, name);
|
||||
if !seen.insert(key) {
|
||||
continue;
|
||||
}
|
||||
let label = match ch.change_type.as_str() {
|
||||
"props" => format!("props.{}", name),
|
||||
"state" => format!("state ({})", name),
|
||||
_ => format!("context ({})", name),
|
||||
};
|
||||
lines.push(format!(
|
||||
" {}: {} -> {}",
|
||||
label,
|
||||
ch.prev.clone().unwrap_or_else(|| "?".into()),
|
||||
ch.next.clone().unwrap_or_else(|| "?".into())
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
//! Browser-side evaluation scripts for React/web introspection.
|
||||
//!
|
||||
//! These are JavaScript strings evaluated in the page context via
|
||||
//! `Runtime.evaluate`. They assume the React DevTools hook is already
|
||||
//! installed (via `--enable react-devtools`) except for `VITALS_INIT` and
|
||||
//! `PUSHSTATE`, which only use standard Web APIs.
|
||||
//!
|
||||
//! Kept as raw strings rather than TS/JS files because the daemon is a single
|
||||
//! Rust binary with no filesystem vendor step at runtime.
|
||||
|
||||
/// Build a no-argument async IIFE page-eval that returns the component tree as
|
||||
/// JSON.
|
||||
pub const TREE_SNAPSHOT: &str = r#"
|
||||
(async () => {
|
||||
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
if (!hook) throw new Error("React DevTools hook not installed - relaunch with --enable react-devtools");
|
||||
const ri = hook.rendererInterfaces && hook.rendererInterfaces.get && hook.rendererInterfaces.get(1);
|
||||
if (!ri) throw new Error("No React renderer attached - the page has not booted React yet");
|
||||
|
||||
const batches = await new Promise((resolve) => {
|
||||
const out = [];
|
||||
const origEmit = hook.emit;
|
||||
hook.emit = function (event, payload) {
|
||||
if (event === "operations") out.push(Array.from(payload));
|
||||
return origEmit.apply(hook, arguments);
|
||||
};
|
||||
ri.flushInitialOperations();
|
||||
setTimeout(() => {
|
||||
hook.emit = origEmit;
|
||||
resolve(out);
|
||||
}, 50);
|
||||
});
|
||||
|
||||
const nodes = batches.flatMap((ops) => {
|
||||
let i = 2;
|
||||
const strings = [null];
|
||||
const tableEnd = ++i + ops[i - 1];
|
||||
while (i < tableEnd) {
|
||||
const len = ops[i++];
|
||||
strings.push(String.fromCodePoint(...ops.slice(i, i + len)));
|
||||
i += len;
|
||||
}
|
||||
const out = [];
|
||||
while (i < ops.length) {
|
||||
const op = ops[i];
|
||||
if (op === 1) {
|
||||
const id = ops[i + 1];
|
||||
const type = ops[i + 2];
|
||||
i += 3;
|
||||
if (type === 11) {
|
||||
out.push({ id, type, name: null, key: null, parent: 0 });
|
||||
i += 4;
|
||||
} else {
|
||||
out.push({
|
||||
id,
|
||||
type,
|
||||
name: strings[ops[i + 2]] || null,
|
||||
key: strings[ops[i + 3]] || null,
|
||||
parent: ops[i],
|
||||
});
|
||||
i += 5;
|
||||
}
|
||||
} else {
|
||||
i += skip(op, ops, i);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
function skip(op, ops, i) {
|
||||
if (op === 2) return 2 + ops[i + 1];
|
||||
if (op === 3) return 3 + ops[i + 2];
|
||||
if (op === 4) return 3;
|
||||
if (op === 5) return 4;
|
||||
if (op === 6) return 1;
|
||||
if (op === 7) return 3;
|
||||
if (op === 8) return 6 + rects(ops[i + 5]);
|
||||
if (op === 9) return 2 + ops[i + 1];
|
||||
if (op === 10) return 3 + ops[i + 2];
|
||||
if (op === 11) return 3 + rects(ops[i + 2]);
|
||||
if (op === 12) return suspenders(ops, i);
|
||||
if (op === 13) return 2;
|
||||
return 1;
|
||||
}
|
||||
function rects(n) {
|
||||
return n === -1 ? 0 : n * 4;
|
||||
}
|
||||
function suspenders(ops, i) {
|
||||
let j = i + 2;
|
||||
for (let c = 0; c < ops[i + 1]; c++) j += 5 + ops[j + 4];
|
||||
return j - i;
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify(nodes);
|
||||
})()
|
||||
"#;
|
||||
|
||||
/// Template for `inspect` — replace {{ID}} with the numeric fiber id.
|
||||
pub const TREE_INSPECT: &str = r#"
|
||||
(() => {
|
||||
const id = {{ID}};
|
||||
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
const ri = hook && hook.rendererInterfaces && hook.rendererInterfaces.get && hook.rendererInterfaces.get(1);
|
||||
if (!ri) throw new Error("No React renderer attached");
|
||||
if (!ri.hasElementWithId(id)) throw new Error("element " + id + " not found (page reloaded?)");
|
||||
const result = ri.inspectElement(1, id, null, true);
|
||||
if (!result || result.type !== "full-data") {
|
||||
throw new Error("inspect failed: " + (result && result.type));
|
||||
}
|
||||
const v = result.value;
|
||||
const name = ri.getDisplayNameForElementID(id);
|
||||
const lines = [name + " #" + id];
|
||||
if (v.key != null) lines.push("key: " + JSON.stringify(v.key));
|
||||
section("props", v.props);
|
||||
section("hooks", v.hooks);
|
||||
section("state", v.state);
|
||||
section("context", v.context);
|
||||
if (v.owners && v.owners.length) {
|
||||
lines.push("rendered by: " + v.owners.map((o) => o.displayName).join(" > "));
|
||||
}
|
||||
const source = Array.isArray(v.source)
|
||||
? [v.source[1], v.source[2], v.source[3]]
|
||||
: null;
|
||||
return JSON.stringify({ text: lines.join("\n"), source });
|
||||
|
||||
function section(label, payload) {
|
||||
const data = (payload && payload.data) || payload;
|
||||
if (data == null) return;
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length === 0) return;
|
||||
lines.push(label + ":");
|
||||
for (const h of data) lines.push(" " + hookLine(h));
|
||||
} else if (typeof data === "object") {
|
||||
const entries = Object.entries(data);
|
||||
if (entries.length === 0) return;
|
||||
lines.push(label + ":");
|
||||
for (const [k, val] of entries) lines.push(" " + k + ": " + preview(val));
|
||||
}
|
||||
}
|
||||
function hookLine(h) {
|
||||
const idx = h.id != null ? "[" + h.id + "] " : "";
|
||||
const sub = h.subHooks && h.subHooks.length ? " (" + h.subHooks.length + " sub)" : "";
|
||||
return idx + h.name + ": " + preview(h.value) + sub;
|
||||
}
|
||||
function preview(v) {
|
||||
if (v == null) return String(v);
|
||||
if (typeof v !== "object") return JSON.stringify(v);
|
||||
if (v.type === "undefined") return "undefined";
|
||||
if (v.preview_long) return v.preview_long;
|
||||
if (v.preview_short) return v.preview_short;
|
||||
if (Array.isArray(v)) return "[" + v.map(preview).join(", ") + "]";
|
||||
const entries = Object.entries(v).map((e) => e[0] + ": " + preview(e[1]));
|
||||
return "{" + entries.join(", ") + "}";
|
||||
}
|
||||
})()
|
||||
"#;
|
||||
|
||||
/// Fiber profiler init script. Registered via `addScriptToEvaluateOnNewDocument`
|
||||
/// so it survives navigations; also evaluated immediately on the current page
|
||||
/// by `react renders start`.
|
||||
pub const RENDERS_INIT: &str = r#"
|
||||
(() => {
|
||||
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
if (!hook || window.__AB_RENDERS_ACTIVE__) return;
|
||||
|
||||
const MAX_COMPONENTS = 200;
|
||||
const data = {};
|
||||
const fps = { frames: [], last: 0, rafId: 0 };
|
||||
|
||||
window.__AB_RENDERS__ = data;
|
||||
window.__AB_RENDERS_FPS__ = fps;
|
||||
window.__AB_RENDERS_START__ = performance.now();
|
||||
window.__AB_RENDERS_ACTIVE__ = true;
|
||||
|
||||
function fpsLoop(now) {
|
||||
if (fps.last > 0) fps.frames.push(now - fps.last);
|
||||
fps.last = now;
|
||||
fps.rafId = requestAnimationFrame(fpsLoop);
|
||||
}
|
||||
fps.rafId = requestAnimationFrame(fpsLoop);
|
||||
|
||||
const origOnCommit = hook.onCommitFiberRoot;
|
||||
window.__AB_RENDERS_ORIG_COMMIT__ = origOnCommit;
|
||||
|
||||
hook.onCommitFiberRoot = function (rendererID, root) {
|
||||
try { walkFiber(root.current); } catch {}
|
||||
if (typeof origOnCommit === "function") {
|
||||
return origOnCommit.apply(hook, arguments);
|
||||
}
|
||||
};
|
||||
|
||||
function getName(fiber) {
|
||||
if (!fiber.type || typeof fiber.type === "string") return null;
|
||||
return fiber.type.displayName || fiber.type.name || null;
|
||||
}
|
||||
|
||||
function brief(val) {
|
||||
if (val === undefined) return "undefined";
|
||||
if (val === null) return "null";
|
||||
if (typeof val === "function") return "fn()";
|
||||
if (typeof val === "string") return val.length > 60 ? '"' + val.slice(0, 57) + '..."' : '"' + val + '"';
|
||||
if (typeof val === "number" || typeof val === "boolean") return String(val);
|
||||
if (Array.isArray(val)) return "Array(" + val.length + ")";
|
||||
if (typeof val === "object") {
|
||||
try {
|
||||
const keys = Object.keys(val);
|
||||
return keys.length <= 3 ? "{" + keys.join(", ") + "}" : "{" + keys.slice(0, 3).join(", ") + ", ...}";
|
||||
} catch { return "{...}"; }
|
||||
}
|
||||
return String(val).slice(0, 40);
|
||||
}
|
||||
|
||||
function getChanges(fiber) {
|
||||
const changes = [];
|
||||
const alt = fiber.alternate;
|
||||
if (!alt) { changes.push({ type: "mount" }); return changes; }
|
||||
if (fiber.memoizedProps !== alt.memoizedProps) {
|
||||
const curr = fiber.memoizedProps || {};
|
||||
const prev = alt.memoizedProps || {};
|
||||
const allKeys = new Set([...Object.keys(curr), ...Object.keys(prev)]);
|
||||
for (const k of allKeys) {
|
||||
if (k !== "children" && curr[k] !== prev[k]) {
|
||||
changes.push({ type: "props", name: k, prev: brief(prev[k]), next: brief(curr[k]) });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fiber.memoizedState !== alt.memoizedState) {
|
||||
let curr = fiber.memoizedState;
|
||||
let prev = alt.memoizedState;
|
||||
let hookIdx = 0;
|
||||
while (curr || prev) {
|
||||
if ((curr && curr.memoizedState) !== (prev && prev.memoizedState)) {
|
||||
changes.push({
|
||||
type: "state",
|
||||
name: "hook #" + hookIdx,
|
||||
prev: brief(prev && prev.memoizedState),
|
||||
next: brief(curr && curr.memoizedState),
|
||||
});
|
||||
}
|
||||
curr = curr && curr.next;
|
||||
prev = prev && prev.next;
|
||||
hookIdx++;
|
||||
}
|
||||
}
|
||||
if (fiber.dependencies && fiber.dependencies.firstContext) {
|
||||
let ctx = fiber.dependencies.firstContext;
|
||||
let altCtx = alt.dependencies && alt.dependencies.firstContext;
|
||||
while (ctx) {
|
||||
if (!altCtx || ctx.memoizedValue !== (altCtx && altCtx.memoizedValue)) {
|
||||
const ctxName =
|
||||
(ctx.context && ctx.context.displayName) ||
|
||||
(ctx.context && ctx.context.Provider && ctx.context.Provider.displayName) ||
|
||||
"unknown";
|
||||
changes.push({
|
||||
type: "context",
|
||||
name: ctxName,
|
||||
prev: brief(altCtx && altCtx.memoizedValue),
|
||||
next: brief(ctx.memoizedValue),
|
||||
});
|
||||
}
|
||||
ctx = ctx.next;
|
||||
altCtx = altCtx && altCtx.next;
|
||||
}
|
||||
}
|
||||
if (changes.length === 0) {
|
||||
let parent = fiber.return;
|
||||
while (parent) {
|
||||
const pName = getName(parent);
|
||||
if (pName) {
|
||||
const suffix = !parent.alternate ? " (mount)" : "";
|
||||
changes.push({ type: "parent", name: pName + suffix });
|
||||
break;
|
||||
}
|
||||
parent = parent.return;
|
||||
}
|
||||
if (changes.length === 0) changes.push({ type: "parent", name: "unknown" });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function childrenTime(fiber) {
|
||||
let t = 0;
|
||||
let child = fiber.child;
|
||||
while (child) {
|
||||
if (typeof child.actualDuration === "number") t += child.actualDuration;
|
||||
child = child.sibling;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
function hasDomMutation(fiber) {
|
||||
if (!fiber.alternate) return true;
|
||||
let child = fiber.child;
|
||||
while (child) {
|
||||
if (typeof child.type === "string" && (child.flags & 6) > 0) return true;
|
||||
child = child.sibling;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function walkFiber(fiber) {
|
||||
if (!fiber) return;
|
||||
const tag = fiber.tag;
|
||||
if (tag === 0 || tag === 1 || tag === 2 || tag === 11 || tag === 15) {
|
||||
const didRender =
|
||||
fiber.alternate === null ||
|
||||
fiber.flags > 0 ||
|
||||
fiber.memoizedProps !== (fiber.alternate && fiber.alternate.memoizedProps) ||
|
||||
fiber.memoizedState !== (fiber.alternate && fiber.alternate.memoizedState);
|
||||
if (didRender) {
|
||||
const name = getName(fiber);
|
||||
if (name) {
|
||||
if (!(name in data) && Object.keys(data).length >= MAX_COMPONENTS) {
|
||||
// at cap - skip
|
||||
} else {
|
||||
if (!data[name]) {
|
||||
data[name] = {
|
||||
count: 0, mounts: 0, totalTime: 0, selfTime: 0,
|
||||
domMutations: 0, changes: [], _instances: new Set(),
|
||||
};
|
||||
}
|
||||
data[name].count++;
|
||||
if (!fiber.alternate) data[name].mounts++;
|
||||
if (!data[name]._instances.has(fiber)) {
|
||||
data[name]._instances.add(fiber);
|
||||
if (fiber.alternate) data[name]._instances.add(fiber.alternate);
|
||||
}
|
||||
if (typeof fiber.actualDuration === "number") {
|
||||
data[name].totalTime += fiber.actualDuration;
|
||||
data[name].selfTime += Math.max(0, fiber.actualDuration - childrenTime(fiber));
|
||||
}
|
||||
if (hasDomMutation(fiber)) data[name].domMutations++;
|
||||
const ch = getChanges(fiber);
|
||||
for (const c of ch) {
|
||||
if (data[name].changes.length < 50) data[name].changes.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walkFiber(fiber.child);
|
||||
walkFiber(fiber.sibling);
|
||||
}
|
||||
})()
|
||||
"#;
|
||||
|
||||
/// Stop script for fiber profiler. Returns the collected profile as JSON.
|
||||
pub const RENDERS_STOP: &str = r#"
|
||||
(() => {
|
||||
const active = window.__AB_RENDERS_ACTIVE__;
|
||||
if (!active) throw new Error("renders recording not active - run `react renders start` first");
|
||||
|
||||
const data = window.__AB_RENDERS__;
|
||||
const startTime = window.__AB_RENDERS_START__;
|
||||
const elapsed = performance.now() - startTime;
|
||||
|
||||
const fpsData = window.__AB_RENDERS_FPS__;
|
||||
let fpsStats = { avg: 0, min: 0, max: 0, drops: 0 };
|
||||
if (fpsData) {
|
||||
cancelAnimationFrame(fpsData.rafId);
|
||||
if (fpsData.frames.length > 0) {
|
||||
const fpsSamples = fpsData.frames.map((dt) => (dt > 0 ? 1000 / dt : 0));
|
||||
const sum = fpsSamples.reduce((a, b) => a + b, 0);
|
||||
fpsStats = {
|
||||
avg: Math.round(sum / fpsSamples.length),
|
||||
min: Math.round(Math.min(...fpsSamples)),
|
||||
max: Math.round(Math.max(...fpsSamples)),
|
||||
drops: fpsSamples.filter((f) => f < 30).length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
const orig = window.__AB_RENDERS_ORIG_COMMIT__;
|
||||
if (hook) hook.onCommitFiberRoot = orig || undefined;
|
||||
|
||||
delete window.__AB_RENDERS__;
|
||||
delete window.__AB_RENDERS_START__;
|
||||
delete window.__AB_RENDERS_ACTIVE__;
|
||||
delete window.__AB_RENDERS_ORIG_COMMIT__;
|
||||
delete window.__AB_RENDERS_FPS__;
|
||||
|
||||
if (!data) {
|
||||
return JSON.stringify({
|
||||
elapsed: 0, fps: fpsStats, totalRenders: 0, totalMounts: 0,
|
||||
totalReRenders: 0, totalComponents: 0, components: [],
|
||||
});
|
||||
}
|
||||
|
||||
const round = (n) => Math.round(n * 100) / 100;
|
||||
const components = Object.entries(data)
|
||||
.map(([name, entry]) => {
|
||||
const summary = {};
|
||||
for (const c of entry.changes) {
|
||||
const key = c.type === "props" ? "props." + c.name
|
||||
: c.type === "state" ? "state (" + c.name + ")"
|
||||
: c.type === "context" ? "context (" + c.name + ")"
|
||||
: c.type === "parent" ? "parent (" + c.name + ")"
|
||||
: c.type;
|
||||
summary[key] = (summary[key] || 0) + 1;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
count: entry.count,
|
||||
mounts: entry.mounts,
|
||||
reRenders: entry.count - entry.mounts,
|
||||
instanceCount: entry._instances.size,
|
||||
totalTime: round(entry.totalTime),
|
||||
selfTime: round(entry.selfTime),
|
||||
domMutations: entry.domMutations,
|
||||
changes: entry.changes,
|
||||
changeSummary: summary,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.totalTime - a.totalTime || b.count - a.count);
|
||||
|
||||
return JSON.stringify({
|
||||
elapsed: round(elapsed / 1000),
|
||||
fps: fpsStats,
|
||||
totalRenders: components.reduce((s, c) => s + c.count, 0),
|
||||
totalMounts: components.reduce((s, c) => s + c.mounts, 0),
|
||||
totalReRenders: components.reduce((s, c) => s + c.reRenders, 0),
|
||||
totalComponents: components.length,
|
||||
components,
|
||||
});
|
||||
})()
|
||||
"#;
|
||||
|
||||
/// Suspense boundary walker. Returns boundaries with suspendedBy metadata as JSON.
|
||||
pub const SUSPENSE_WALK: &str = r#"
|
||||
(async () => {
|
||||
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
if (!hook) throw new Error("React DevTools hook not installed - relaunch with --enable react-devtools");
|
||||
const ri = hook.rendererInterfaces && hook.rendererInterfaces.get && hook.rendererInterfaces.get(1);
|
||||
if (!ri) throw new Error("No React renderer attached");
|
||||
|
||||
const batches = await new Promise((resolve) => {
|
||||
const out = [];
|
||||
const origEmit = hook.emit;
|
||||
hook.emit = function (event, payload) {
|
||||
if (event === "operations") out.push(payload);
|
||||
return origEmit.apply(this, arguments);
|
||||
};
|
||||
ri.flushInitialOperations();
|
||||
setTimeout(() => {
|
||||
hook.emit = origEmit;
|
||||
resolve(out);
|
||||
}, 50);
|
||||
});
|
||||
|
||||
const boundaryMap = new Map();
|
||||
for (const ops of batches) decodeSuspenseOps(ops, boundaryMap);
|
||||
|
||||
const results = [];
|
||||
for (const b of boundaryMap.values()) {
|
||||
if (b.parentID === 0) continue;
|
||||
const boundary = {
|
||||
id: b.id,
|
||||
parentID: b.parentID,
|
||||
name: b.name,
|
||||
isSuspended: b.isSuspended,
|
||||
environments: b.environments,
|
||||
suspendedBy: [],
|
||||
unknownSuspenders: null,
|
||||
owners: [],
|
||||
jsxSource: null,
|
||||
};
|
||||
if (ri.hasElementWithId(b.id)) {
|
||||
const displayName = ri.getDisplayNameForElementID(b.id);
|
||||
if (displayName) boundary.name = displayName;
|
||||
const result = ri.inspectElement(1, b.id, null, true);
|
||||
if (result && result.type === "full-data") {
|
||||
parseInspection(boundary, result.value);
|
||||
}
|
||||
}
|
||||
results.push(boundary);
|
||||
}
|
||||
return JSON.stringify(results);
|
||||
|
||||
function decodeSuspenseOps(ops, map) {
|
||||
let i = 2;
|
||||
const strings = [null];
|
||||
const tableEnd = ++i + ops[i - 1];
|
||||
while (i < tableEnd) {
|
||||
const len = ops[i++];
|
||||
strings.push(String.fromCodePoint(...ops.slice(i, i + len)));
|
||||
i += len;
|
||||
}
|
||||
while (i < ops.length) {
|
||||
const op = ops[i];
|
||||
if (op === 1) {
|
||||
const type = ops[i + 2];
|
||||
i += 3 + (type === 11 ? 4 : 5);
|
||||
} else if (op === 2) {
|
||||
i += 2 + ops[i + 1];
|
||||
} else if (op === 3) {
|
||||
i += 3 + ops[i + 2];
|
||||
} else if (op === 4) {
|
||||
i += 3;
|
||||
} else if (op === 5) {
|
||||
i += 4;
|
||||
} else if (op === 6) {
|
||||
i++;
|
||||
} else if (op === 7) {
|
||||
i += 3;
|
||||
} else if (op === 8) {
|
||||
const id = ops[i + 1];
|
||||
const parentID = ops[i + 2];
|
||||
const nameStrID = ops[i + 3];
|
||||
const isSuspended = ops[i + 4] === 1;
|
||||
const numRects = ops[i + 5];
|
||||
i += 6;
|
||||
if (numRects !== -1) i += numRects * 4;
|
||||
map.set(id, { id, parentID, name: strings[nameStrID] || null, isSuspended, environments: [] });
|
||||
} else if (op === 9) {
|
||||
i += 2 + ops[i + 1];
|
||||
} else if (op === 10) {
|
||||
i += 3 + ops[i + 2];
|
||||
} else if (op === 11) {
|
||||
const numRects = ops[i + 2];
|
||||
i += 3;
|
||||
if (numRects !== -1) i += numRects * 4;
|
||||
} else if (op === 12) {
|
||||
i++;
|
||||
const changeLen = ops[i++];
|
||||
for (let c = 0; c < changeLen; c++) {
|
||||
const id = ops[i++];
|
||||
i++;
|
||||
i++;
|
||||
const isSuspended = ops[i++] === 1;
|
||||
const envLen = ops[i++];
|
||||
const envs = [];
|
||||
for (let e = 0; e < envLen; e++) {
|
||||
const n = strings[ops[i++]];
|
||||
if (n != null) envs.push(n);
|
||||
}
|
||||
const node = map.get(id);
|
||||
if (node) {
|
||||
node.isSuspended = isSuspended;
|
||||
for (const env of envs) {
|
||||
if (!node.environments.includes(env)) node.environments.push(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (op === 13) {
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseInspection(boundary, data) {
|
||||
const rawSuspendedBy = data.suspendedBy;
|
||||
const rawSuspenders = Array.isArray(rawSuspendedBy)
|
||||
? rawSuspendedBy
|
||||
: rawSuspendedBy && Array.isArray(rawSuspendedBy.data) ? rawSuspendedBy.data : null;
|
||||
if (rawSuspenders) {
|
||||
for (const entry of rawSuspenders) {
|
||||
const awaited = entry && entry.awaited;
|
||||
if (!awaited) continue;
|
||||
const desc = preview(awaited.description) || preview(awaited.value);
|
||||
boundary.suspendedBy.push({
|
||||
name: awaited.name || "unknown",
|
||||
description: desc,
|
||||
duration: awaited.end && awaited.start ? Math.round(awaited.end - awaited.start) : 0,
|
||||
env: awaited.env || (entry && entry.env) || null,
|
||||
ownerName: (awaited.owner && awaited.owner.displayName) || null,
|
||||
ownerStack: parseStack((awaited.owner && awaited.owner.stack) || awaited.stack),
|
||||
awaiterName: (entry && entry.owner && entry.owner.displayName) || null,
|
||||
awaiterStack: parseStack((entry && entry.owner && entry.owner.stack) || (entry && entry.stack)),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data.unknownSuspenders && data.unknownSuspenders !== 0) {
|
||||
const reasons = {
|
||||
1: "production build (no debug info)",
|
||||
2: "old React version (missing tracking)",
|
||||
3: "thrown Promise (library using throw instead of use())",
|
||||
};
|
||||
boundary.unknownSuspenders = reasons[data.unknownSuspenders] || "unknown reason";
|
||||
}
|
||||
if (Array.isArray(data.owners)) {
|
||||
for (const o of data.owners) {
|
||||
if (o && o.displayName) {
|
||||
const src = Array.isArray(o.stack) && o.stack.length > 0 && Array.isArray(o.stack[0])
|
||||
? [o.stack[0][1] || "(unknown)", o.stack[0][2], o.stack[0][3]]
|
||||
: null;
|
||||
boundary.owners.push({ name: o.displayName, env: o.env || null, source: src });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(data.stack) && data.stack.length > 0) {
|
||||
const frame = data.stack[0];
|
||||
if (Array.isArray(frame) && frame.length >= 4) {
|
||||
boundary.jsxSource = [frame[1] || "(unknown)", frame[2], frame[3]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseStack(raw) {
|
||||
if (!Array.isArray(raw) || raw.length === 0) return null;
|
||||
return raw
|
||||
.filter((f) => Array.isArray(f) && f.length >= 4)
|
||||
.map((f) => [f[0] || "", f[1] || "", f[2] || 0, f[3] || 0]);
|
||||
}
|
||||
|
||||
function preview(v) {
|
||||
if (v == null) return "";
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v !== "object") return String(v);
|
||||
if (typeof v.preview_long === "string") return v.preview_long;
|
||||
if (typeof v.preview_short === "string") return v.preview_short;
|
||||
if (typeof v.value === "string") return v.value;
|
||||
try {
|
||||
const s = JSON.stringify(v);
|
||||
return s.length > 80 ? s.slice(0, 77) + "..." : s;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
})()
|
||||
"#;
|
||||
|
||||
/// Init script for Core Web Vitals + React hydration timing capture. Installs
|
||||
/// PerformanceObservers for LCP/CLS and intercepts `console.timeStamp` to
|
||||
/// capture React's profiling reconciler timings. Idempotent.
|
||||
pub const VITALS_INIT: &str = r#"
|
||||
(() => {
|
||||
if (window.__AB_VITALS_INSTALLED__) return;
|
||||
window.__AB_VITALS_INSTALLED__ = true;
|
||||
|
||||
const cwv = { lcp: null, cls: 0, clsEntries: [], fcp: null, inp: null };
|
||||
window.__AB_VITALS__ = cwv;
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
if (entries.length > 0) {
|
||||
const last = entries[entries.length - 1];
|
||||
cwv.lcp = {
|
||||
startTime: Math.round(last.startTime * 100) / 100,
|
||||
size: last.size,
|
||||
element: last.element && last.element.tagName ? last.element.tagName.toLowerCase() : null,
|
||||
url: last.url || null,
|
||||
};
|
||||
}
|
||||
}).observe({ type: "largest-contentful-paint", buffered: true });
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (!entry.hadRecentInput) {
|
||||
cwv.cls += entry.value;
|
||||
cwv.clsEntries.push({
|
||||
value: Math.round(entry.value * 10000) / 10000,
|
||||
startTime: Math.round(entry.startTime * 100) / 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
}).observe({ type: "layout-shift", buffered: true });
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.name === "first-contentful-paint") {
|
||||
cwv.fcp = Math.round(entry.startTime * 100) / 100;
|
||||
}
|
||||
}
|
||||
}).observe({ type: "paint", buffered: true });
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
let worst = cwv.inp || 0;
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.duration > worst) worst = entry.duration;
|
||||
}
|
||||
if (worst > 0) cwv.inp = Math.round(worst * 100) / 100;
|
||||
}).observe({ type: "event", buffered: true, durationThreshold: 40 });
|
||||
} catch {}
|
||||
|
||||
// React profiling build emits console.timeStamp(label, start, end, track, trackGroup, color)
|
||||
// for reconciler phases and per-component hydration timing. Intercept and collect.
|
||||
const timing = [];
|
||||
window.__AB_REACT_TIMING__ = timing;
|
||||
const orig = console.timeStamp;
|
||||
console.timeStamp = function (label) {
|
||||
const args = arguments;
|
||||
if (typeof label === "string" && args.length >= 3 && typeof args[1] === "number") {
|
||||
timing.push({
|
||||
label,
|
||||
startTime: args[1],
|
||||
endTime: args[2],
|
||||
track: args[3] || "",
|
||||
trackGroup: args[4] || "",
|
||||
color: args[5] || "",
|
||||
});
|
||||
}
|
||||
return orig.apply(console, args);
|
||||
};
|
||||
})()
|
||||
"#;
|
||||
|
||||
/// Read script for vitals — collects observed metrics plus Navigation Timing
|
||||
/// TTFB and any React hydration phases. Returns JSON.
|
||||
pub const VITALS_READ: &str = r#"
|
||||
(() => {
|
||||
const cwv = window.__AB_VITALS__ || {};
|
||||
const timing = window.__AB_REACT_TIMING__ || [];
|
||||
const nav = performance.getEntriesByType("navigation")[0];
|
||||
const ttfb = nav
|
||||
? Math.round((nav.responseStart - nav.requestStart) * 100) / 100
|
||||
: null;
|
||||
return JSON.stringify({ cwv, timing, ttfb });
|
||||
})()
|
||||
"#;
|
||||
|
||||
/// SPA client-side navigation. Tries the framework router first so Next.js
|
||||
/// app/pages router triggers an RSC fetch (pure `history.pushState` would
|
||||
/// be shallow routing and bypass data loading). Falls back to
|
||||
/// `history.pushState` + popstate/navigate events for vanilla pages and
|
||||
/// routers that listen to history events (React Router, TanStack Router,
|
||||
/// Solid Router, Vue Router).
|
||||
pub const PUSHSTATE: &str = r#"
|
||||
((url) => {
|
||||
const before = location.href;
|
||||
const absolute = new URL(url, before).href;
|
||||
if (absolute === before) return before;
|
||||
|
||||
// Next.js pages + app router expose window.next.router with a `push`
|
||||
// method that triggers the RSC fetch and re-render pipeline.
|
||||
const r = typeof window.next === "object" && window.next && window.next.router;
|
||||
if (r && typeof r.push === "function") {
|
||||
try { r.push(url); return location.href; } catch {}
|
||||
}
|
||||
|
||||
history.pushState(null, "", absolute);
|
||||
try { dispatchEvent(new PopStateEvent("popstate", { state: null })); } catch {}
|
||||
try { dispatchEvent(new Event("navigate")); } catch {}
|
||||
return location.href;
|
||||
})({{URL}})
|
||||
"#;
|
||||
@@ -0,0 +1,633 @@
|
||||
//! React Suspense boundary introspection: walker data types, classifier, and
|
||||
//! human-readable report.
|
||||
//!
|
||||
//! The classifier labels and recommendations are React-Suspense-general —
|
||||
//! they describe what kind of thing is making a boundary suspend (`client-hook`,
|
||||
//! `request-api`, `server-fetch`, `cache`, `stream`, `framework`, `unknown`)
|
||||
//! and a high-level direction for fixing it. Framework-specific reasoning
|
||||
//! (e.g. Next.js PPR push vs goto semantics) is left to the caller.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub type StackFrame = (String, String, i64, i64);
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Boundary {
|
||||
pub id: i64,
|
||||
#[serde(rename = "parentID")]
|
||||
pub parent_id: i64,
|
||||
pub name: Option<String>,
|
||||
#[serde(rename = "isSuspended")]
|
||||
pub is_suspended: bool,
|
||||
pub environments: Vec<String>,
|
||||
#[serde(rename = "suspendedBy")]
|
||||
pub suspended_by: Vec<Suspender>,
|
||||
#[serde(rename = "unknownSuspenders")]
|
||||
pub unknown_suspenders: Option<String>,
|
||||
pub owners: Vec<Owner>,
|
||||
#[serde(rename = "jsxSource")]
|
||||
pub jsx_source: Option<(String, i64, i64)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Owner {
|
||||
pub name: String,
|
||||
pub env: Option<String>,
|
||||
pub source: Option<(String, i64, i64)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Suspender {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub duration: i64,
|
||||
pub env: Option<String>,
|
||||
#[serde(rename = "ownerName")]
|
||||
pub owner_name: Option<String>,
|
||||
#[serde(rename = "ownerStack")]
|
||||
pub owner_stack: Option<Vec<StackFrame>>,
|
||||
#[serde(rename = "awaiterName")]
|
||||
pub awaiter_name: Option<String>,
|
||||
#[serde(rename = "awaiterStack")]
|
||||
pub awaiter_stack: Option<Vec<StackFrame>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BlockerKind {
|
||||
ClientHook,
|
||||
RequestApi,
|
||||
ServerFetch,
|
||||
Stream,
|
||||
Cache,
|
||||
Framework,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl BlockerKind {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::ClientHook => "client-hook",
|
||||
Self::RequestApi => "request-api",
|
||||
Self::ServerFetch => "server-fetch",
|
||||
Self::Stream => "stream",
|
||||
Self::Cache => "cache",
|
||||
Self::Framework => "framework",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn weight(self) -> i32 {
|
||||
match self {
|
||||
Self::ClientHook => 7,
|
||||
Self::RequestApi => 6,
|
||||
Self::ServerFetch => 5,
|
||||
Self::Cache => 4,
|
||||
Self::Stream => 3,
|
||||
Self::Unknown => 2,
|
||||
Self::Framework => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn actionability(self) -> i32 {
|
||||
match self {
|
||||
Self::ClientHook => 90,
|
||||
Self::RequestApi => 88,
|
||||
Self::ServerFetch => 82,
|
||||
Self::Cache => 74,
|
||||
Self::Stream => 60,
|
||||
Self::Unknown => 35,
|
||||
Self::Framework => 18,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BoundaryKind {
|
||||
RouteSegment,
|
||||
ExplicitSuspense,
|
||||
Component,
|
||||
}
|
||||
|
||||
impl BoundaryKind {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::RouteSegment => "route-segment",
|
||||
Self::ExplicitSuspense => "explicit-suspense",
|
||||
Self::Component => "component",
|
||||
}
|
||||
}
|
||||
|
||||
fn weight(self) -> i32 {
|
||||
match self {
|
||||
Self::RouteSegment => 3,
|
||||
Self::ExplicitSuspense => 2,
|
||||
Self::Component => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActionableBlocker {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub kind: BlockerKind,
|
||||
pub env: Option<String>,
|
||||
pub description: String,
|
||||
pub owner_name: Option<String>,
|
||||
pub awaiter_name: Option<String>,
|
||||
pub source_frame: Option<StackFrame>,
|
||||
pub owner_frame: Option<StackFrame>,
|
||||
pub awaiter_frame: Option<StackFrame>,
|
||||
pub actionability: i32,
|
||||
pub suggestion: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BoundaryInsight {
|
||||
pub id: i64,
|
||||
pub name: Option<String>,
|
||||
pub boundary_kind: BoundaryKind,
|
||||
pub environments: Vec<String>,
|
||||
pub source: Option<(String, i64, i64)>,
|
||||
pub rendered_by: Vec<Owner>,
|
||||
pub primary_blocker: Option<ActionableBlocker>,
|
||||
pub blockers: Vec<ActionableBlocker>,
|
||||
pub unknown_suspenders: Option<String>,
|
||||
pub actionability: i32,
|
||||
pub recommendation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RootCauseGroup {
|
||||
pub kind: BlockerKind,
|
||||
pub name: String,
|
||||
pub source_frame: Option<StackFrame>,
|
||||
pub boundary_names: Vec<String>,
|
||||
pub count: usize,
|
||||
pub actionability: i32,
|
||||
pub suggestion: String,
|
||||
}
|
||||
|
||||
pub struct AnalysisReport {
|
||||
pub total_boundaries: usize,
|
||||
pub dynamic_hole_count: usize,
|
||||
pub static_count: usize,
|
||||
pub holes: Vec<BoundaryInsight>,
|
||||
pub statics: Vec<StaticBoundarySummary>,
|
||||
pub root_causes: Vec<RootCauseGroup>,
|
||||
pub files_to_read: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StaticBoundarySummary {
|
||||
pub name: Option<String>,
|
||||
pub source: Option<(String, i64, i64)>,
|
||||
pub rendered_by: Vec<Owner>,
|
||||
}
|
||||
|
||||
pub fn format_suspense_report(boundaries: &[Boundary], only_dynamic: bool) -> String {
|
||||
let report = analyze_boundaries(boundaries);
|
||||
format_report(&report, only_dynamic)
|
||||
}
|
||||
|
||||
fn analyze_boundaries(boundaries: &[Boundary]) -> AnalysisReport {
|
||||
let mut holes: Vec<&Boundary> = Vec::new();
|
||||
let mut statics_raw: Vec<&Boundary> = Vec::new();
|
||||
|
||||
for b in boundaries {
|
||||
if b.parent_id == 0 {
|
||||
continue;
|
||||
}
|
||||
let has_blocker = !b.suspended_by.is_empty() || b.unknown_suspenders.is_some();
|
||||
if b.is_suspended || has_blocker {
|
||||
holes.push(b);
|
||||
} else {
|
||||
statics_raw.push(b);
|
||||
}
|
||||
}
|
||||
|
||||
let mut hole_insights: Vec<BoundaryInsight> = holes.iter().map(|b| build_insight(b)).collect();
|
||||
hole_insights.sort_by(|a, b| {
|
||||
b.actionability.cmp(&a.actionability).then_with(|| {
|
||||
b.boundary_kind
|
||||
.weight()
|
||||
.cmp(&a.boundary_kind.weight())
|
||||
.then_with(|| b.blockers.len().cmp(&a.blockers.len()))
|
||||
.then_with(|| {
|
||||
a.name
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.cmp(b.name.as_deref().unwrap_or(""))
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
let static_summaries: Vec<StaticBoundarySummary> = statics_raw
|
||||
.iter()
|
||||
.map(|b| StaticBoundarySummary {
|
||||
name: b.name.clone(),
|
||||
source: b.jsx_source.clone(),
|
||||
rendered_by: b.owners.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let root_causes = build_root_causes(&hole_insights);
|
||||
let files_to_read = collect_files_to_read(&hole_insights, &root_causes);
|
||||
|
||||
AnalysisReport {
|
||||
total_boundaries: hole_insights.len() + static_summaries.len(),
|
||||
dynamic_hole_count: hole_insights.len(),
|
||||
static_count: static_summaries.len(),
|
||||
holes: hole_insights,
|
||||
statics: static_summaries,
|
||||
root_causes,
|
||||
files_to_read,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_insight(b: &Boundary) -> BoundaryInsight {
|
||||
let boundary_kind = infer_boundary_kind(b);
|
||||
let mut blockers: Vec<ActionableBlocker> = b
|
||||
.suspended_by
|
||||
.iter()
|
||||
.map(build_actionable_blocker)
|
||||
.collect();
|
||||
blockers.sort_by(|a, b| {
|
||||
b.actionability.cmp(&a.actionability).then_with(|| {
|
||||
b.kind
|
||||
.weight()
|
||||
.cmp(&a.kind.weight())
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
})
|
||||
});
|
||||
let primary = blockers.first().cloned();
|
||||
let recommendation = recommend_fix(
|
||||
boundary_kind,
|
||||
primary.as_ref(),
|
||||
b.unknown_suspenders.as_deref(),
|
||||
);
|
||||
let primary_action = primary.as_ref().map(|p| p.actionability).unwrap_or(0);
|
||||
let base_action = if boundary_kind == BoundaryKind::RouteSegment {
|
||||
55
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
BoundaryInsight {
|
||||
id: b.id,
|
||||
name: b.name.clone(),
|
||||
boundary_kind,
|
||||
environments: b.environments.clone(),
|
||||
source: b.jsx_source.clone(),
|
||||
rendered_by: b.owners.clone(),
|
||||
primary_blocker: primary,
|
||||
blockers,
|
||||
unknown_suspenders: b.unknown_suspenders.clone(),
|
||||
actionability: primary_action.max(base_action),
|
||||
recommendation,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_actionable_blocker(s: &Suspender) -> ActionableBlocker {
|
||||
let owner_frame = pick_preferred_frame(s.owner_stack.as_deref());
|
||||
let awaiter_frame = pick_preferred_frame(s.awaiter_stack.as_deref());
|
||||
let source_frame = owner_frame.clone().or_else(|| awaiter_frame.clone());
|
||||
let kind = classify_blocker(s, source_frame.as_ref());
|
||||
let suggestion = suggest_blocker_fix(kind);
|
||||
let mut actionability = kind.actionability();
|
||||
if let Some(ref frame) = source_frame {
|
||||
if !is_frameworkish_path(&frame.1) {
|
||||
actionability += 8;
|
||||
}
|
||||
}
|
||||
if s.owner_name.is_some() || s.awaiter_name.is_some() {
|
||||
actionability += 4;
|
||||
}
|
||||
if actionability > 100 {
|
||||
actionability = 100;
|
||||
}
|
||||
let key = build_blocker_key(&s.name, kind, source_frame.as_ref());
|
||||
|
||||
ActionableBlocker {
|
||||
key,
|
||||
name: s.name.clone(),
|
||||
kind,
|
||||
env: s.env.clone(),
|
||||
description: s.description.clone(),
|
||||
owner_name: s.owner_name.clone(),
|
||||
awaiter_name: s.awaiter_name.clone(),
|
||||
source_frame,
|
||||
owner_frame,
|
||||
awaiter_frame,
|
||||
actionability,
|
||||
suggestion,
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_boundary_kind(b: &Boundary) -> BoundaryKind {
|
||||
let owner_names: Vec<&str> = b.owners.iter().map(|o| o.name.as_str()).collect();
|
||||
let name_ends_slash = b.name.as_ref().is_some_and(|n| n.ends_with('/'));
|
||||
if name_ends_slash
|
||||
|| owner_names.contains(&"LoadingBoundary")
|
||||
|| owner_names.contains(&"OuterLayoutRouter")
|
||||
{
|
||||
return BoundaryKind::RouteSegment;
|
||||
}
|
||||
let name_has_suspense = b.name.as_ref().is_some_and(|n| n.contains("Suspense"));
|
||||
if name_has_suspense || owner_names.iter().any(|n| n.contains("Suspense")) {
|
||||
return BoundaryKind::ExplicitSuspense;
|
||||
}
|
||||
BoundaryKind::Component
|
||||
}
|
||||
|
||||
fn classify_blocker(s: &Suspender, source_frame: Option<&StackFrame>) -> BlockerKind {
|
||||
let name = s.name.to_lowercase();
|
||||
match name.as_str() {
|
||||
"usepathname"
|
||||
| "useparams"
|
||||
| "usesearchparams"
|
||||
| "useselectedlayoutsegments"
|
||||
| "useselectedlayoutsegment"
|
||||
| "userouter" => return BlockerKind::ClientHook,
|
||||
"cookies" | "headers" | "connection" | "params" | "searchparams" | "draftmode" => {
|
||||
return BlockerKind::RequestApi
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if name == "rsc stream" {
|
||||
return BlockerKind::Stream;
|
||||
}
|
||||
if name.contains("fetch") {
|
||||
return BlockerKind::ServerFetch;
|
||||
}
|
||||
if name.contains("cache") || s.description.to_lowercase().contains("cache") {
|
||||
return BlockerKind::Cache;
|
||||
}
|
||||
if name.starts_with("use") {
|
||||
return BlockerKind::ClientHook;
|
||||
}
|
||||
if let Some(frame) = source_frame {
|
||||
if is_frameworkish_path(&frame.1) {
|
||||
return BlockerKind::Framework;
|
||||
}
|
||||
}
|
||||
BlockerKind::Unknown
|
||||
}
|
||||
|
||||
fn suggest_blocker_fix(kind: BlockerKind) -> String {
|
||||
match kind {
|
||||
BlockerKind::ClientHook => "Move route hooks behind a smaller client Suspense or provide a real non-null loading fallback for this segment.",
|
||||
BlockerKind::RequestApi => "Push request-bound reads to a smaller server leaf, or cache around them so the parent shell can stay static.",
|
||||
BlockerKind::ServerFetch => "Split static shell content from data widgets, then push the fetch into smaller Suspense leaves or cache it.",
|
||||
BlockerKind::Cache => "This looks cache-related; check whether \"use cache\" or runtime prefetch can eliminate the suspension.",
|
||||
BlockerKind::Stream => "A stream is still pending here; extract static siblings outside the boundary and push the stream consumer deeper.",
|
||||
BlockerKind::Framework => "This currently looks framework-driven; find the nearest user-owned caller above it before changing code.",
|
||||
BlockerKind::Unknown => "Inspect the nearest user-owned owner/awaiter frame and verify whether this suspender really belongs at this boundary.",
|
||||
}.to_string()
|
||||
}
|
||||
|
||||
fn recommend_fix(
|
||||
boundary_kind: BoundaryKind,
|
||||
primary: Option<&ActionableBlocker>,
|
||||
unknown_suspenders: Option<&str>,
|
||||
) -> String {
|
||||
if boundary_kind == BoundaryKind::RouteSegment
|
||||
&& primary.is_some_and(|p| p.kind == BlockerKind::ClientHook)
|
||||
{
|
||||
return "This route segment is suspending on client hooks. Check loading.tsx first; if it is null or visually empty, fix the fallback before chasing deeper push-down work.".to_string();
|
||||
}
|
||||
if let Some(p) = primary {
|
||||
match p.kind {
|
||||
BlockerKind::ClientHook => {
|
||||
return "Push the hook-using client UI behind a smaller local Suspense boundary so the parent shell can prerender.".to_string();
|
||||
}
|
||||
BlockerKind::RequestApi | BlockerKind::ServerFetch => {
|
||||
return "Push the request-bound async work into a smaller leaf or split static siblings out of this boundary.".to_string();
|
||||
}
|
||||
BlockerKind::Cache => {
|
||||
return "Check whether caching or runtime prefetch can move this personalized content into the shell.".to_string();
|
||||
}
|
||||
BlockerKind::Stream => {
|
||||
return "Keep the stream behind Suspense, but extract any static shell content outside the boundary.".to_string();
|
||||
}
|
||||
BlockerKind::Framework => {
|
||||
return "The top blocker still looks framework-heavy. Find the nearest user-owned caller before changing boundary placement.".to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(reason) = unknown_suspenders {
|
||||
return format!(
|
||||
"React could not identify the suspender ({}). Investigate the nearest user-owned owner or awaiter frame.",
|
||||
reason
|
||||
);
|
||||
}
|
||||
"No primary blocker was identified. Inspect the boundary source and owner chain directly."
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn pick_preferred_frame(stack: Option<&[StackFrame]>) -> Option<StackFrame> {
|
||||
let s = stack?;
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
s.iter()
|
||||
.find(|f| !is_frameworkish_path(&f.1))
|
||||
.cloned()
|
||||
.or_else(|| s.first().cloned())
|
||||
}
|
||||
|
||||
fn is_frameworkish_path(file: &str) -> bool {
|
||||
file.contains("/node_modules/")
|
||||
}
|
||||
|
||||
fn build_blocker_key(name: &str, kind: BlockerKind, source_frame: Option<&StackFrame>) -> String {
|
||||
match source_frame {
|
||||
None => format!("{}:{}:unknown", kind.label(), name),
|
||||
Some(f) => format!("{}:{}:{}:{}", kind.label(), name, f.1, f.2),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_root_causes(holes: &[BoundaryInsight]) -> Vec<RootCauseGroup> {
|
||||
let mut groups: HashMap<String, RootCauseGroup> = HashMap::new();
|
||||
for hole in holes {
|
||||
let Some(blocker) = &hole.primary_blocker else {
|
||||
continue;
|
||||
};
|
||||
let display_name = hole
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("boundary-{}", hole.id));
|
||||
groups
|
||||
.entry(blocker.key.clone())
|
||||
.and_modify(|existing| {
|
||||
existing.boundary_names.push(display_name.clone());
|
||||
existing.count += 1;
|
||||
if blocker.actionability > existing.actionability {
|
||||
existing.actionability = blocker.actionability;
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| RootCauseGroup {
|
||||
kind: blocker.kind,
|
||||
name: blocker.name.clone(),
|
||||
source_frame: blocker.source_frame.clone(),
|
||||
boundary_names: vec![display_name],
|
||||
count: 1,
|
||||
actionability: blocker.actionability,
|
||||
suggestion: blocker.suggestion.clone(),
|
||||
});
|
||||
}
|
||||
let mut out: Vec<RootCauseGroup> = groups.into_values().collect();
|
||||
out.sort_by(|a, b| {
|
||||
let score_a = (a.count as i32) * a.actionability;
|
||||
let score_b = (b.count as i32) * b.actionability;
|
||||
score_b.cmp(&score_a).then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_files_to_read(holes: &[BoundaryInsight], root_causes: &[RootCauseGroup]) -> Vec<String> {
|
||||
let mut counts: HashMap<String, i32> = HashMap::new();
|
||||
let mut add = |f: Option<&str>| {
|
||||
if let Some(path) = f {
|
||||
if !path.is_empty() {
|
||||
*counts.entry(path.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
for hole in holes {
|
||||
add(hole.source.as_ref().map(|s| s.0.as_str()));
|
||||
if let Some(pb) = &hole.primary_blocker {
|
||||
add(pb.source_frame.as_ref().map(|f| f.1.as_str()));
|
||||
}
|
||||
for owner in &hole.rendered_by {
|
||||
add(owner.source.as_ref().map(|s| s.0.as_str()));
|
||||
}
|
||||
}
|
||||
for cause in root_causes {
|
||||
add(cause.source_frame.as_ref().map(|f| f.1.as_str()));
|
||||
}
|
||||
|
||||
let mut entries: Vec<(String, i32)> = counts.into_iter().collect();
|
||||
entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
entries.into_iter().take(12).map(|(f, _)| f).collect()
|
||||
}
|
||||
|
||||
fn escape_cell(s: &str) -> String {
|
||||
s.replace('|', "\\|")
|
||||
}
|
||||
|
||||
fn format_report(report: &AnalysisReport, only_dynamic: bool) -> String {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push("# Suspense Boundary Analysis".to_string());
|
||||
if only_dynamic {
|
||||
lines.push(format!(
|
||||
"# {} dynamic holes (static boundaries hidden; pass without --only-dynamic to see them)",
|
||||
report.dynamic_hole_count
|
||||
));
|
||||
} else {
|
||||
lines.push(format!(
|
||||
"# {} boundaries: {} dynamic holes, {} static",
|
||||
report.total_boundaries, report.dynamic_hole_count, report.static_count
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
|
||||
if !report.holes.is_empty() {
|
||||
lines.push("## Summary".to_string());
|
||||
if let Some(top) = report.holes.first() {
|
||||
if let Some(blocker) = &top.primary_blocker {
|
||||
lines.push(format!(
|
||||
"- Top actionable hole: {} - {} ({})",
|
||||
top.name.clone().unwrap_or_else(|| "(unnamed)".into()),
|
||||
blocker.name,
|
||||
blocker.kind.label()
|
||||
));
|
||||
lines.push(format!("- Suggested next step: {}", top.recommendation));
|
||||
}
|
||||
}
|
||||
if let Some(root) = report.root_causes.first() {
|
||||
lines.push(format!(
|
||||
"- Most common root cause: {} ({}) affecting {} boundar{}",
|
||||
root.name,
|
||||
root.kind.label(),
|
||||
root.count,
|
||||
if root.count == 1 { "y" } else { "ies" }
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
|
||||
lines.push("## Quick Reference".to_string());
|
||||
lines.push(
|
||||
"| Boundary | Type | Primary blocker | Source | Suggested next step |".to_string(),
|
||||
);
|
||||
lines.push("| --- | --- | --- | --- | --- |".to_string());
|
||||
for hole in &report.holes {
|
||||
let blocker = &hole.primary_blocker;
|
||||
let source = match blocker.as_ref().and_then(|b| b.source_frame.as_ref()) {
|
||||
Some(f) => format!("{}:{}", f.1, f.2),
|
||||
None => match &hole.source {
|
||||
Some((f, l, _)) => format!("{}:{}", f, l),
|
||||
None => "unknown".to_string(),
|
||||
},
|
||||
};
|
||||
let blocker_text = match blocker {
|
||||
Some(b) => format!("{} ({})", b.name, b.kind.label()),
|
||||
None => "unknown".to_string(),
|
||||
};
|
||||
lines.push(format!(
|
||||
"| {} | {} | {} | {} | {} |",
|
||||
escape_cell(hole.name.as_deref().unwrap_or("(unnamed)")),
|
||||
hole.boundary_kind.label(),
|
||||
escape_cell(&blocker_text),
|
||||
escape_cell(&source),
|
||||
escape_cell(&hole.recommendation),
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
|
||||
if !report.files_to_read.is_empty() {
|
||||
lines.push("## Files to Read".to_string());
|
||||
for file in &report.files_to_read {
|
||||
lines.push(format!("- {}", file));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
if !report.root_causes.is_empty() {
|
||||
lines.push("## Root Causes".to_string());
|
||||
for cause in &report.root_causes {
|
||||
let source = match &cause.source_frame {
|
||||
Some(f) => format!("{}:{}", f.1, f.2),
|
||||
None => "unknown".to_string(),
|
||||
};
|
||||
lines.push(format!(
|
||||
"- {} ({}) at {} - affects {} boundar{}",
|
||||
cause.name,
|
||||
cause.kind.label(),
|
||||
source,
|
||||
cause.count,
|
||||
if cause.count == 1 { "y" } else { "ies" }
|
||||
));
|
||||
lines.push(format!(" next step: {}", cause.suggestion));
|
||||
lines.push(format!(" boundaries: {}", cause.boundary_names.join(", ")));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
}
|
||||
|
||||
if !only_dynamic && !report.statics.is_empty() {
|
||||
lines.push("## Static (not suspended)".to_string());
|
||||
for b in &report.statics {
|
||||
let name = b.name.clone().unwrap_or_else(|| "(unnamed)".into());
|
||||
let src = match &b.source {
|
||||
Some(s) => format!(" at {}:{}:{}", s.0, s.1, s.2),
|
||||
None => String::new(),
|
||||
};
|
||||
lines.push(format!(" {}{}", name, src));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! React component tree snapshot and formatter.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TreeNode {
|
||||
pub id: i64,
|
||||
#[serde(rename = "type")]
|
||||
pub node_type: i64,
|
||||
pub name: Option<String>,
|
||||
pub key: Option<String>,
|
||||
pub parent: i64,
|
||||
}
|
||||
|
||||
const HEADER: &str = "# React component tree\n# Columns: depth id parent name [key=...]\n# Use `react inspect <id>` for props/hooks/state. IDs valid until next navigation.";
|
||||
|
||||
pub fn format_tree(nodes: &[TreeNode]) -> String {
|
||||
use std::collections::HashMap;
|
||||
let mut children: HashMap<i64, Vec<&TreeNode>> = HashMap::new();
|
||||
for n in nodes {
|
||||
children.entry(n.parent).or_default().push(n);
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = vec![HEADER.to_string()];
|
||||
if let Some(roots) = children.get(&0) {
|
||||
for root in roots {
|
||||
walk(root, 0, &children, &mut lines);
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn walk<'a>(
|
||||
node: &'a TreeNode,
|
||||
depth: usize,
|
||||
children: &std::collections::HashMap<i64, Vec<&'a TreeNode>>,
|
||||
lines: &mut Vec<String>,
|
||||
) {
|
||||
let name = node
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| type_name(node.node_type));
|
||||
let key = match &node.key {
|
||||
Some(k) => format!(" key={:?}", k),
|
||||
None => String::new(),
|
||||
};
|
||||
let parent = if node.parent == 0 {
|
||||
"-".to_string()
|
||||
} else {
|
||||
node.parent.to_string()
|
||||
};
|
||||
lines.push(format!("{} {} {} {}{}", depth, node.id, parent, name, key));
|
||||
if let Some(cs) = children.get(&node.id) {
|
||||
for c in cs {
|
||||
walk(c, depth + 1, children, lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn type_name(t: i64) -> String {
|
||||
match t {
|
||||
11 => "Root".to_string(),
|
||||
12 => "Suspense".to_string(),
|
||||
13 => "SuspenseList".to_string(),
|
||||
_ => format!("({})", t),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Core Web Vitals + React hydration timing report.
|
||||
//!
|
||||
//! Universal web-standard metrics (LCP/CLS/TTFB/FCP/INP) via PerformanceObserver
|
||||
//! and Navigation Timing. When the React profiling build is detected (via
|
||||
//! `console.timeStamp` entries), also reports hydration phases and per-component
|
||||
//! hydration timing.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct VitalsData {
|
||||
pub url: String,
|
||||
pub ttfb: Option<f64>,
|
||||
pub lcp: Option<Lcp>,
|
||||
pub cls: Cls,
|
||||
pub fcp: Option<f64>,
|
||||
pub inp: Option<f64>,
|
||||
pub hydration: Option<HydrationRange>,
|
||||
pub phases: Vec<Phase>,
|
||||
#[serde(rename = "hydratedComponents")]
|
||||
pub hydrated_components: Vec<HydratedComponent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Lcp {
|
||||
#[serde(rename = "startTime")]
|
||||
pub start_time: f64,
|
||||
pub size: Option<i64>,
|
||||
pub element: Option<String>,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Cls {
|
||||
pub score: f64,
|
||||
pub entries: Vec<ClsEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ClsEntry {
|
||||
pub value: f64,
|
||||
#[serde(rename = "startTime")]
|
||||
pub start_time: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct HydrationRange {
|
||||
#[serde(rename = "startTime")]
|
||||
pub start_time: f64,
|
||||
#[serde(rename = "endTime")]
|
||||
pub end_time: f64,
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Phase {
|
||||
pub label: String,
|
||||
#[serde(rename = "startTime")]
|
||||
pub start_time: f64,
|
||||
#[serde(rename = "endTime")]
|
||||
pub end_time: f64,
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct HydratedComponent {
|
||||
pub name: String,
|
||||
#[serde(rename = "startTime")]
|
||||
pub start_time: f64,
|
||||
#[serde(rename = "endTime")]
|
||||
pub end_time: f64,
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
pub fn format_vitals_report(d: &VitalsData) -> String {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push(format!("# Page Load Profile - {}", d.url));
|
||||
lines.push(String::new());
|
||||
lines.push("## Core Web Vitals".to_string());
|
||||
|
||||
let ttfb_str = match d.ttfb {
|
||||
Some(t) => format!("{}ms", t),
|
||||
None => "-".to_string(),
|
||||
};
|
||||
lines.push(format!(" TTFB {:>10}", ttfb_str));
|
||||
|
||||
match &d.lcp {
|
||||
Some(lcp) => {
|
||||
let label = match (&lcp.element, &lcp.url) {
|
||||
(Some(el), Some(url)) => {
|
||||
let url_trunc: String = url.chars().take(60).collect();
|
||||
format!(" ({}: {})", el, url_trunc)
|
||||
}
|
||||
(Some(el), None) => format!(" ({})", el),
|
||||
_ => String::new(),
|
||||
};
|
||||
lines.push(format!(
|
||||
" LCP {:>10}{}",
|
||||
format!("{}ms", lcp.start_time),
|
||||
label
|
||||
));
|
||||
}
|
||||
None => lines.push(" LCP -".to_string()),
|
||||
}
|
||||
|
||||
lines.push(format!(" CLS {:>10}", d.cls.score));
|
||||
|
||||
if let Some(fcp) = d.fcp {
|
||||
lines.push(format!(" FCP {:>10}", format!("{}ms", fcp)));
|
||||
}
|
||||
if let Some(inp) = d.inp {
|
||||
lines.push(format!(" INP {:>10}", format!("{}ms", inp)));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
match &d.hydration {
|
||||
Some(h) => lines.push(format!(
|
||||
"## React Hydration - {}ms ({}ms -> {}ms)",
|
||||
h.duration, h.start_time, h.end_time
|
||||
)),
|
||||
None => {
|
||||
lines.push("## React Hydration - no data (requires React profiling build)".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
if !d.phases.is_empty() {
|
||||
for p in &d.phases {
|
||||
lines.push(format!(
|
||||
" {:<28} {:>10} ({} -> {})",
|
||||
p.label,
|
||||
format!("{}ms", p.duration),
|
||||
p.start_time,
|
||||
p.end_time
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
if !d.hydrated_components.is_empty() {
|
||||
lines.push(format!(
|
||||
"## Hydrated components ({} total, sorted by duration)",
|
||||
d.hydrated_components.len()
|
||||
));
|
||||
for c in d.hydrated_components.iter().take(30) {
|
||||
lines.push(format!(
|
||||
" {:<40} {:>10}",
|
||||
c.name,
|
||||
format!("{}ms", c.duration)
|
||||
));
|
||||
}
|
||||
if d.hydrated_components.len() > 30 {
|
||||
lines.push(format!(
|
||||
" ... and {} more",
|
||||
d.hydrated_components.len() - 30
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
//! Relay between the `ab-connect` browser extension and the daemon's `CdpClient`.
|
||||
//!
|
||||
//! The extension speaks a small CDP-over-WebSocket "envelope" protocol (adapted
|
||||
//! from openclaw-browser-relay) and drives the user's real tabs via per-tab
|
||||
//! `chrome.debugger`. The daemon's `CdpClient`, however, expects a **browser-
|
||||
//! level** CDP endpoint (`Target.getTargets` / `Target.attachToTarget` → a
|
||||
//! `sessionId`, then per-session commands). This relay bridges the two: it
|
||||
//! tracks the targets the extension reports, answers the browser-level
|
||||
//! `Target.*` discovery commands LOCALLY, and forwards everything else to the
|
||||
//! extension as `forwardCDPCommand`. That keeps `CdpClient` and `browser.rs`
|
||||
//! unchanged.
|
||||
//!
|
||||
//! ## Multiple clients (concurrent agents on one shared browser)
|
||||
//!
|
||||
//! Several chrome-use daemons (one per `--session`) can connect to the same
|
||||
//! relay/Chrome at once. The extension is a single peer, so the relay must
|
||||
//! demultiplex: every forwarded command is re-keyed to a relay-global id mapped
|
||||
//! back to the originating client, and the extension's reply is routed to **only
|
||||
//! that client** (with its original id restored). Command ids from different
|
||||
//! clients therefore never collide, and one client never sees another's command
|
||||
//! replies. CDP *events* (no id) fan out to all clients, which ignore events for
|
||||
//! sessions they didn't attach.
|
||||
//!
|
||||
//! This module is the pure translation core (no I/O) so the protocol can be
|
||||
//! unit-tested; the tokio WebSocket server that drives it lives alongside.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Protocol version advertised in the connect handshake (matches the extension).
|
||||
pub const RELAY_PROTOCOL: i64 = 3;
|
||||
|
||||
/// Identifies one connected CDP client (chrome-use daemon) for routing.
|
||||
pub type ClientId = u64;
|
||||
|
||||
/// One target (tab) the extension has attached, as the relay tracks it.
|
||||
#[derive(Clone)]
|
||||
struct TargetEntry {
|
||||
session_id: String,
|
||||
target_info: Value,
|
||||
}
|
||||
|
||||
/// Relay translation state: the targets the extension exposes, plus the
|
||||
/// in-flight command map used to route extension replies back to the right
|
||||
/// client.
|
||||
#[derive(Default)]
|
||||
pub struct RelayState {
|
||||
/// targetId -> entry
|
||||
targets: HashMap<String, TargetEntry>,
|
||||
/// relay-global command id -> (client that sent it, its original id)
|
||||
pending: HashMap<i64, (ClientId, Value)>,
|
||||
/// monotonic source of relay-global command ids
|
||||
next_global_id: i64,
|
||||
/// Group-scoped isolation (issue #40). A tab group belongs to exactly one
|
||||
/// agent/session; the relay scopes `Target.getTargets` per client to its own
|
||||
/// group so the daemon can safely adopt new tabs (follow-popup, cross-session
|
||||
/// adopt) without ever seeing the user's or another agent's tabs.
|
||||
///
|
||||
/// clientId -> group name. A client that never announced a group (older
|
||||
/// daemon) is absent here and gets the full, UNSCOPED target list — so this
|
||||
/// is fully backward-compatible.
|
||||
client_groups: HashMap<ClientId, String>,
|
||||
/// targetId -> group name. Created tabs are tagged from `Target.createTarget`'s
|
||||
/// `agentGroup`; an explicitly adopted tab is tagged to the adopter; a pop-up
|
||||
/// inherits its opener's group (needs the extension to report `openerTargetId`).
|
||||
target_group: HashMap<String, String>,
|
||||
/// relay-global id of an in-flight `Target.createTarget` -> the `agentGroup`
|
||||
/// it carried, so the reply's `targetId` can be tagged with that group.
|
||||
pending_create: HashMap<i64, String>,
|
||||
}
|
||||
|
||||
/// What to do with a raw CDP command received from a `CdpClient`.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ClientRoute {
|
||||
/// Answer locally; the value is a raw CDP response `{id, result}` to send
|
||||
/// back to the originating client only.
|
||||
Local(Value),
|
||||
/// Forward to the extension; the value is a `forwardCDPCommand` envelope
|
||||
/// already re-keyed to a relay-global id.
|
||||
Forward(Value),
|
||||
}
|
||||
|
||||
/// An output the relay emits while handling an extension message.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum RelayOut {
|
||||
/// Send this raw CDP message to clients. `to = Some(id)` targets one client
|
||||
/// (a command reply); `to = None` broadcasts (a CDP event).
|
||||
ToClient { to: Option<ClientId>, msg: Value },
|
||||
/// Send this envelope message back to the extension.
|
||||
ToExt(Value),
|
||||
}
|
||||
|
||||
impl RelayState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The challenge the relay sends to the extension as soon as it connects,
|
||||
/// kicking off the connect handshake.
|
||||
pub fn connect_challenge(nonce: &str) -> Value {
|
||||
json!({ "type": "event", "event": "connect.challenge", "payload": { "nonce": nonce } })
|
||||
}
|
||||
|
||||
/// A keepalive ping for the extension.
|
||||
pub fn ping() -> Value {
|
||||
json!({ "method": "ping" })
|
||||
}
|
||||
|
||||
/// Forget a disconnected client's in-flight commands so its orphaned
|
||||
/// `pending` entries don't leak.
|
||||
pub fn drop_client(&mut self, client_id: ClientId) {
|
||||
self.pending.retain(|_, (cid, _)| *cid != client_id);
|
||||
self.client_groups.remove(&client_id);
|
||||
}
|
||||
|
||||
/// Route a raw CDP command `{id, method, params?, sessionId?}` from a
|
||||
/// `CdpClient`: answer browser-level `Target.*` discovery locally, forward
|
||||
/// the rest to the extension under a relay-global id keyed to `client_id`.
|
||||
pub fn route_client_command(&mut self, client_id: ClientId, raw: &Value) -> ClientRoute {
|
||||
let id = raw.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = raw.get("method").and_then(|m| m.as_str()).unwrap_or("");
|
||||
let params = raw.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let session_id = raw.get("sessionId").and_then(|s| s.as_str());
|
||||
|
||||
match method {
|
||||
// Browser-level command the daemon uses as its liveness probe
|
||||
// (`is_connection_alive` → `Browser.getVersion`). The extension only
|
||||
// speaks per-tab `chrome.debugger`, so forwarding it errors → the
|
||||
// daemon would deem the connection dead and reconnect+re-discover on
|
||||
// EVERY command, resetting the active tab (eval/screenshot drift).
|
||||
// Answer it locally so the relay connection reads as alive.
|
||||
"Browser.getVersion" => ClientRoute::Local(json!({
|
||||
"id": id,
|
||||
"result": {
|
||||
"protocolVersion": "1.3",
|
||||
"product": "Chrome/ab-connect-relay",
|
||||
"revision": "",
|
||||
"userAgent": "",
|
||||
"jsVersion": ""
|
||||
}
|
||||
})),
|
||||
// Non-CDP control message: a daemon announces which tab group
|
||||
// (session) it owns, so getTargets can be scoped to it (issue #40).
|
||||
"ABRelay.setGroup" => {
|
||||
if let Some(g) = params.get("group").and_then(|g| g.as_str()) {
|
||||
if !g.is_empty() {
|
||||
self.client_groups.insert(client_id, g.to_string());
|
||||
}
|
||||
}
|
||||
ClientRoute::Local(json!({ "id": id, "result": {} }))
|
||||
}
|
||||
// Discovery is best-effort and event-driven in real CDP; abs only
|
||||
// reads the getTargets result, so an empty ack is enough here.
|
||||
"Target.setDiscoverTargets" | "Target.setAutoAttach" => {
|
||||
ClientRoute::Local(json!({ "id": id, "result": {} }))
|
||||
}
|
||||
// Unscoped discovery for EXPLICIT cross-group adoption (`chrome-use
|
||||
// adopt`): returns every target the extension has attached, ignoring
|
||||
// group scoping, so an agent can find a specific pre-existing tab (the
|
||||
// user's, another session's) by URL/targetId and adopt it. Isolation
|
||||
// is preserved because the daemon only acts on the one tab it then
|
||||
// attaches (which the relay re-tags into the adopter's group).
|
||||
"ABRelay.getAllTargets" => {
|
||||
let infos: Vec<Value> = self
|
||||
.targets
|
||||
.values()
|
||||
.map(|t| t.target_info.clone())
|
||||
.collect();
|
||||
ClientRoute::Local(json!({ "id": id, "result": { "targetInfos": infos } }))
|
||||
}
|
||||
"Target.getTargets" => {
|
||||
// Scope to the client's own group when it announced one; an
|
||||
// un-announced (legacy) client gets the full list (back-compat).
|
||||
let scoped = self.client_groups.get(&client_id).cloned();
|
||||
let infos: Vec<Value> = self
|
||||
.targets
|
||||
.iter()
|
||||
.filter(|(tid, _)| match &scoped {
|
||||
Some(g) => self
|
||||
.target_group
|
||||
.get(*tid)
|
||||
.map(|tg| tg == g)
|
||||
.unwrap_or(false),
|
||||
None => true,
|
||||
})
|
||||
.map(|(_, t)| t.target_info.clone())
|
||||
.collect();
|
||||
ClientRoute::Local(json!({ "id": id, "result": { "targetInfos": infos } }))
|
||||
}
|
||||
"Target.attachToTarget" => {
|
||||
let target_id = params
|
||||
.get("targetId")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
match self.targets.get(target_id) {
|
||||
Some(entry) => {
|
||||
let session_id = entry.session_id.clone();
|
||||
// Explicitly adopting a target makes it this client's
|
||||
// (cross-session adopt, #21) — tag it into the adopter's
|
||||
// group so it stays in that client's scoped getTargets and
|
||||
// isn't churn-pruned.
|
||||
if let Some(g) = self.client_groups.get(&client_id).cloned() {
|
||||
self.target_group.insert(target_id.to_string(), g);
|
||||
}
|
||||
ClientRoute::Local(
|
||||
json!({ "id": id, "result": { "sessionId": session_id } }),
|
||||
)
|
||||
}
|
||||
None => ClientRoute::Local(json!({
|
||||
"id": id,
|
||||
"error": { "code": -32602, "message": format!("No such target {target_id}") }
|
||||
})),
|
||||
}
|
||||
}
|
||||
// Everything else goes to the extension's chrome.debugger. Re-key the
|
||||
// id so this client's reply can be routed back unambiguously.
|
||||
_ => {
|
||||
self.next_global_id += 1;
|
||||
let gid = self.next_global_id;
|
||||
self.pending.insert(gid, (client_id, id));
|
||||
// Remember the group a createTarget carries so the reply's
|
||||
// targetId can be tagged to the creating session (issue #40).
|
||||
if method == "Target.createTarget" {
|
||||
if let Some(g) = params.get("agentGroup").and_then(|g| g.as_str()) {
|
||||
if !g.is_empty() {
|
||||
self.pending_create.insert(gid, g.to_string());
|
||||
self.client_groups
|
||||
.entry(client_id)
|
||||
.or_insert_with(|| g.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ClientRoute::Forward(json!({
|
||||
"id": gid,
|
||||
"method": "forwardCDPCommand",
|
||||
"params": { "method": method, "params": params, "sessionId": session_id },
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle one decoded message from the extension. Updates target state and
|
||||
/// returns the messages to emit (routed to a client and/or back to the
|
||||
/// extension). `expected_token` is matched against the connect handshake.
|
||||
pub fn handle_ext_message(&mut self, msg: &Value, expected_token: &str) -> Vec<RelayOut> {
|
||||
// Connect handshake request from the extension.
|
||||
if msg.get("type").and_then(|t| t.as_str()) == Some("req")
|
||||
&& msg.get("method").and_then(|m| m.as_str()) == Some("connect")
|
||||
{
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let token = msg
|
||||
.get("params")
|
||||
.and_then(|p| p.get("auth"))
|
||||
.and_then(|a| a.get("token"))
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
let ok = !expected_token.is_empty() && token == expected_token;
|
||||
let mut res = json!({ "type": "res", "id": id, "ok": ok });
|
||||
if !ok {
|
||||
res["error"] = json!({ "message": "invalid relay token" });
|
||||
}
|
||||
return vec![RelayOut::ToExt(res)];
|
||||
}
|
||||
|
||||
// Keepalive.
|
||||
if msg.get("method").and_then(|m| m.as_str()) == Some("pong") {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// Response to a forwardCDPCommand we sent → route the raw CDP response
|
||||
// back to the client that issued it, with its original id restored.
|
||||
if msg.get("id").is_some()
|
||||
&& (msg.get("result").is_some() || msg.get("error").is_some())
|
||||
&& msg.get("method").is_none()
|
||||
{
|
||||
let gid = msg.get("id").and_then(|i| i.as_i64());
|
||||
// A createTarget reply: tag the new tab's targetId with the group the
|
||||
// command carried, so it lands in the creating session's scope (#40).
|
||||
if let Some(g) = gid.and_then(|g| self.pending_create.remove(&g)) {
|
||||
if let Some(tid) = msg
|
||||
.get("result")
|
||||
.and_then(|r| r.get("targetId"))
|
||||
.and_then(|t| t.as_str())
|
||||
{
|
||||
self.target_group.insert(tid.to_string(), g);
|
||||
}
|
||||
}
|
||||
let (to, orig_id) = match gid.and_then(|g| self.pending.remove(&g)) {
|
||||
Some((client_id, orig)) => (Some(client_id), orig),
|
||||
// No mapping (stale/unknown id) — fall back to broadcasting with
|
||||
// whatever id the extension echoed.
|
||||
None => (None, msg.get("id").cloned().unwrap_or(Value::Null)),
|
||||
};
|
||||
let mut out = json!({ "id": orig_id });
|
||||
if let Some(r) = msg.get("result") {
|
||||
out["result"] = r.clone();
|
||||
}
|
||||
if let Some(e) = msg.get("error") {
|
||||
// CdpClient expects an error object; wrap a bare string.
|
||||
out["error"] = match e {
|
||||
Value::String(s) => json!({ "code": -32000, "message": s }),
|
||||
other => other.clone(),
|
||||
};
|
||||
}
|
||||
return vec![RelayOut::ToClient { to, msg: out }];
|
||||
}
|
||||
|
||||
// CDP event forwarded from a tab.
|
||||
if msg.get("method").and_then(|m| m.as_str()) == Some("forwardCDPEvent") {
|
||||
let p = msg.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let inner_method = p.get("method").and_then(|m| m.as_str()).unwrap_or("");
|
||||
let inner_params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let session_id = p.get("sessionId").and_then(|s| s.as_str());
|
||||
|
||||
// Learn/forget targets from the extension's synthesized Target events.
|
||||
// We consume these to maintain state and do NOT forward them: abs
|
||||
// discovers targets by pulling getTargets, and forwarding a second
|
||||
// attachedToTarget would duplicate the one attachToTarget emits.
|
||||
match inner_method {
|
||||
"Target.attachedToTarget" => {
|
||||
if let Some(info) = inner_params.get("targetInfo") {
|
||||
if let Some(tid) = info.get("targetId").and_then(|t| t.as_str()) {
|
||||
let sid = inner_params
|
||||
.get("sessionId")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
// Attribute the tab to a group for scoping (issue #40),
|
||||
// unless we already know it (createTarget tag). An
|
||||
// explicit `abGroup` from the extension wins; otherwise a
|
||||
// pop-up inherits its opener's group via `openerTargetId`.
|
||||
if !self.target_group.contains_key(tid) {
|
||||
if let Some(g) = info
|
||||
.get("abGroup")
|
||||
.and_then(|g| g.as_str())
|
||||
.filter(|g| !g.is_empty())
|
||||
{
|
||||
self.target_group.insert(tid.to_string(), g.to_string());
|
||||
} else if let Some(opener) = info
|
||||
.get("openerTargetId")
|
||||
.and_then(|o| o.as_str())
|
||||
.filter(|o| !o.is_empty())
|
||||
{
|
||||
if let Some(g) = self.target_group.get(opener).cloned() {
|
||||
self.target_group.insert(tid.to_string(), g);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.targets.insert(
|
||||
tid.to_string(),
|
||||
TargetEntry {
|
||||
session_id: sid,
|
||||
target_info: info.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
"Target.detachedFromTarget" => {
|
||||
let gone = inner_params.get("sessionId").and_then(|s| s.as_str());
|
||||
if let Some(gone) = gone {
|
||||
let gone_tids: Vec<String> = self
|
||||
.targets
|
||||
.iter()
|
||||
.filter(|(_, e)| e.session_id == gone)
|
||||
.map(|(tid, _)| tid.clone())
|
||||
.collect();
|
||||
for tid in gone_tids {
|
||||
self.target_group.remove(&tid);
|
||||
}
|
||||
self.targets.retain(|_, e| e.session_id != gone);
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Regular CDP event → fan out to all clients (each filters by the
|
||||
// sessions it attached to).
|
||||
let mut ev = json!({ "method": inner_method, "params": inner_params });
|
||||
if let Some(sid) = session_id {
|
||||
ev["sessionId"] = json!(sid);
|
||||
}
|
||||
return vec![RelayOut::ToClient { to: None, msg: ev }];
|
||||
}
|
||||
|
||||
vec![]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn seed_target(&mut self, target_id: &str, session_id: &str) {
|
||||
self.targets.insert(
|
||||
target_id.to_string(),
|
||||
TargetEntry {
|
||||
session_id: session_id.to_string(),
|
||||
target_info: json!({
|
||||
"targetId": target_id,
|
||||
"type": "page",
|
||||
"title": "",
|
||||
"url": "about:blank",
|
||||
"attached": true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn attached_event(target_id: &str, session_id: &str) -> Value {
|
||||
json!({
|
||||
"method": "forwardCDPEvent",
|
||||
"params": {
|
||||
"sessionId": session_id,
|
||||
"method": "Target.attachedToTarget",
|
||||
"params": {
|
||||
"sessionId": session_id,
|
||||
"targetInfo": { "targetId": target_id, "type": "page", "url": "https://x", "title": "X" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn learns_target_from_attached_event_and_does_not_forward_it() {
|
||||
let mut s = RelayState::new();
|
||||
let out = s.handle_ext_message(&attached_event("T1", "cb-tab-1"), "tok");
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"attachedToTarget should be consumed, not forwarded"
|
||||
);
|
||||
// Now getTargets must report it.
|
||||
let route = s.route_client_command(1, &json!({ "id": 1, "method": "Target.getTargets" }));
|
||||
match route {
|
||||
ClientRoute::Local(v) => {
|
||||
let infos = v["result"]["targetInfos"].as_array().unwrap();
|
||||
assert_eq!(infos.len(), 1);
|
||||
assert_eq!(infos[0]["targetId"], "T1");
|
||||
}
|
||||
_ => panic!("getTargets must be local"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reattach_with_same_session_restores_target() {
|
||||
// Issue #17 recovery contract. A tab's chrome.debugger session is torn
|
||||
// down (cross-process nav, SW restart, …) then re-attached. The fix has
|
||||
// the extension reuse the SAME `cb-tab-<tabId>` id across that churn, so
|
||||
// after detach+reattach the relay must expose the NEW target under the
|
||||
// SAME session — which is exactly the session the daemon is still bound
|
||||
// to, so its eval/snapshot auto-follow the new page instead of going stale.
|
||||
let mut s = RelayState::new();
|
||||
s.handle_ext_message(&attached_event("T_old", "cb-tab-42"), "tok");
|
||||
s.handle_ext_message(
|
||||
&json!({
|
||||
"method": "forwardCDPEvent",
|
||||
"params": { "method": "Target.detachedFromTarget", "params": { "sessionId": "cb-tab-42" } }
|
||||
}),
|
||||
"tok",
|
||||
);
|
||||
s.handle_ext_message(&attached_event("T_new", "cb-tab-42"), "tok");
|
||||
|
||||
let route = s.route_client_command(1, &json!({ "id": 1, "method": "Target.getTargets" }));
|
||||
match route {
|
||||
ClientRoute::Local(v) => {
|
||||
let infos = v["result"]["targetInfos"].as_array().unwrap();
|
||||
assert_eq!(infos.len(), 1, "only the new target should remain");
|
||||
assert_eq!(infos[0]["targetId"], "T_new");
|
||||
}
|
||||
_ => panic!("getTargets must be local"),
|
||||
}
|
||||
// The daemon's existing session id still resolves — to the new target.
|
||||
let route = s.route_client_command(
|
||||
1,
|
||||
&json!({ "id": 2, "method": "Target.attachToTarget", "params": { "targetId": "T_new" } }),
|
||||
);
|
||||
assert_eq!(
|
||||
route,
|
||||
ClientRoute::Local(json!({ "id": 2, "result": { "sessionId": "cb-tab-42" } }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_get_version_is_answered_locally() {
|
||||
// Liveness probe must NOT be forwarded (the extension can't do
|
||||
// browser-level commands) — else the daemon reconnects on every command.
|
||||
let mut s = RelayState::new();
|
||||
let route = s.route_client_command(1, &json!({ "id": 7, "method": "Browser.getVersion" }));
|
||||
match route {
|
||||
ClientRoute::Local(v) => {
|
||||
assert_eq!(v["id"], 7);
|
||||
assert!(v["result"]["protocolVersion"].is_string());
|
||||
}
|
||||
_ => panic!("Browser.getVersion must be answered locally"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_to_target_returns_known_session() {
|
||||
let mut s = RelayState::new();
|
||||
s.seed_target("T1", "cb-tab-1");
|
||||
let route = s.route_client_command(
|
||||
7,
|
||||
&json!({ "id": 5, "method": "Target.attachToTarget", "params": { "targetId": "T1", "flatten": true } }),
|
||||
);
|
||||
assert_eq!(
|
||||
route,
|
||||
ClientRoute::Local(json!({ "id": 5, "result": { "sessionId": "cb-tab-1" } }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_to_unknown_target_errors_locally() {
|
||||
let mut s = RelayState::new();
|
||||
let route = s.route_client_command(
|
||||
1,
|
||||
&json!({ "id": 6, "method": "Target.attachToTarget", "params": { "targetId": "nope" } }),
|
||||
);
|
||||
match route {
|
||||
ClientRoute::Local(v) => assert!(v.get("error").is_some()),
|
||||
_ => panic!("should answer locally"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_commands_forward_under_global_id() {
|
||||
let mut s = RelayState::new();
|
||||
let route = s.route_client_command(
|
||||
42,
|
||||
&json!({ "id": 9, "method": "Page.navigate", "params": { "url": "https://x" }, "sessionId": "cb-tab-1" }),
|
||||
);
|
||||
match route {
|
||||
ClientRoute::Forward(v) => {
|
||||
assert_eq!(v["method"], "forwardCDPCommand");
|
||||
// id is re-keyed to a relay-global id (not the client's 9).
|
||||
assert_eq!(v["id"], 1);
|
||||
assert_eq!(v["params"]["method"], "Page.navigate");
|
||||
assert_eq!(v["params"]["sessionId"], "cb-tab-1");
|
||||
assert_eq!(v["params"]["params"]["url"], "https://x");
|
||||
}
|
||||
_ => panic!("Page.navigate must forward"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_routes_back_to_the_issuing_client_with_original_id() {
|
||||
let mut s = RelayState::new();
|
||||
// Two clients each send a command that happens to share original id 1.
|
||||
let r1 = s.route_client_command(
|
||||
100,
|
||||
&json!({ "id": 1, "method": "Page.navigate", "params": {} }),
|
||||
);
|
||||
let r2 = s.route_client_command(
|
||||
200,
|
||||
&json!({ "id": 1, "method": "Page.reload", "params": {} }),
|
||||
);
|
||||
let g1 = match r1 {
|
||||
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
|
||||
_ => panic!(),
|
||||
};
|
||||
let g2 = match r2 {
|
||||
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
|
||||
_ => panic!(),
|
||||
};
|
||||
assert_ne!(g1, g2, "global ids must be distinct across clients");
|
||||
|
||||
// Extension replies for g2 → must go to client 200 with original id 1.
|
||||
let out = s.handle_ext_message(&json!({ "id": g2, "result": { "ok": true } }), "tok");
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![RelayOut::ToClient {
|
||||
to: Some(200),
|
||||
msg: json!({ "id": 1, "result": { "ok": true } })
|
||||
}]
|
||||
);
|
||||
// And g1 → client 100.
|
||||
let out = s.handle_ext_message(&json!({ "id": g1, "result": { "ok": false } }), "tok");
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![RelayOut::ToClient {
|
||||
to: Some(100),
|
||||
msg: json!({ "id": 1, "result": { "ok": false } })
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_command_error_is_wrapped_and_routed() {
|
||||
let mut s = RelayState::new();
|
||||
let r = s.route_client_command(
|
||||
5,
|
||||
&json!({ "id": 3, "method": "Page.navigate", "params": {} }),
|
||||
);
|
||||
let gid = match r {
|
||||
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
|
||||
_ => panic!(),
|
||||
};
|
||||
let out = s.handle_ext_message(&json!({ "id": gid, "error": "boom" }), "tok");
|
||||
match &out[0] {
|
||||
RelayOut::ToClient { to, msg } => {
|
||||
assert_eq!(*to, Some(5));
|
||||
assert_eq!(msg["id"], 3);
|
||||
assert_eq!(msg["error"]["message"], "boom");
|
||||
}
|
||||
_ => panic!("expected ToClient"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_event_broadcasts_with_session() {
|
||||
let mut s = RelayState::new();
|
||||
let ev = json!({
|
||||
"method": "forwardCDPEvent",
|
||||
"params": { "sessionId": "cb-tab-1", "method": "Page.loadEventFired", "params": { "timestamp": 1.0 } }
|
||||
});
|
||||
let out = s.handle_ext_message(&ev, "tok");
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![RelayOut::ToClient {
|
||||
to: None,
|
||||
msg: json!({
|
||||
"method": "Page.loadEventFired",
|
||||
"params": { "timestamp": 1.0 },
|
||||
"sessionId": "cb-tab-1"
|
||||
})
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_client_clears_its_pending() {
|
||||
let mut s = RelayState::new();
|
||||
let r = s.route_client_command(
|
||||
9,
|
||||
&json!({ "id": 1, "method": "Page.navigate", "params": {} }),
|
||||
);
|
||||
let gid = match r {
|
||||
ClientRoute::Forward(v) => v["id"].as_i64().unwrap(),
|
||||
_ => panic!(),
|
||||
};
|
||||
s.drop_client(9);
|
||||
// Reply now has no mapping → broadcast fallback (to: None), echoed id.
|
||||
let out = s.handle_ext_message(&json!({ "id": gid, "result": {} }), "tok");
|
||||
match &out[0] {
|
||||
RelayOut::ToClient { to, .. } => assert_eq!(*to, None),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_handshake_validates_token() {
|
||||
let mut s = RelayState::new();
|
||||
let req = json!({ "type": "req", "id": "c1", "method": "connect", "params": { "auth": { "token": "good" } } });
|
||||
let ok = s.handle_ext_message(&req, "good");
|
||||
assert_eq!(
|
||||
ok,
|
||||
vec![RelayOut::ToExt(
|
||||
json!({ "type": "res", "id": "c1", "ok": true })
|
||||
)]
|
||||
);
|
||||
|
||||
let bad = s.handle_ext_message(&req, "different");
|
||||
match &bad[0] {
|
||||
RelayOut::ToExt(v) => {
|
||||
assert_eq!(v["ok"], false);
|
||||
assert!(v.get("error").is_some());
|
||||
}
|
||||
_ => panic!("expected ToExt"),
|
||||
}
|
||||
}
|
||||
|
||||
// === Group-scoped isolation (issue #40) ===
|
||||
|
||||
/// Drive the real create path: announce group, createTarget(agentGroup), feed
|
||||
/// the ext reply (tags target→group) + the attachedToTarget event (creates the
|
||||
/// entry). Returns nothing; mutates `s`.
|
||||
fn create_in_group(s: &mut RelayState, client: ClientId, group: &str, tid: &str, sid: &str) {
|
||||
s.route_client_command(
|
||||
client,
|
||||
&json!({ "id": 1, "method": "ABRelay.setGroup", "params": { "group": group } }),
|
||||
);
|
||||
let route = s.route_client_command(
|
||||
client,
|
||||
&json!({ "id": 2, "method": "Target.createTarget",
|
||||
"params": { "url": "about:blank", "agentGroup": group } }),
|
||||
);
|
||||
let gid = match route {
|
||||
ClientRoute::Forward(env) => env["id"].as_i64().unwrap(),
|
||||
_ => panic!("createTarget must forward"),
|
||||
};
|
||||
s.handle_ext_message(&json!({ "id": gid, "result": { "targetId": tid } }), "");
|
||||
s.handle_ext_message(
|
||||
&json!({ "method": "forwardCDPEvent", "params": {
|
||||
"method": "Target.attachedToTarget",
|
||||
"params": { "sessionId": sid, "targetInfo": {
|
||||
"targetId": tid, "type": "page", "url": "about:blank", "attached": true } } } }),
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
fn get_target_ids(s: &mut RelayState, client: ClientId) -> Vec<String> {
|
||||
match s.route_client_command(client, &json!({ "id": 9, "method": "Target.getTargets" })) {
|
||||
ClientRoute::Local(v) => v["result"]["targetInfos"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| t["targetId"].as_str().unwrap().to_string())
|
||||
.collect(),
|
||||
_ => panic!("getTargets must be local"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_targets_is_scoped_to_each_clients_group() {
|
||||
let mut s = RelayState::new();
|
||||
create_in_group(&mut s, 1, "agent-a", "ta", "sa");
|
||||
create_in_group(&mut s, 2, "agent-b", "tb", "sb");
|
||||
// Each client sees ONLY its own group's tab — never the other agent's.
|
||||
assert_eq!(get_target_ids(&mut s, 1), vec!["ta"]);
|
||||
assert_eq!(get_target_ids(&mut s, 2), vec!["tb"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_client_without_group_sees_all_targets() {
|
||||
let mut s = RelayState::new();
|
||||
create_in_group(&mut s, 1, "agent-a", "ta", "sa");
|
||||
create_in_group(&mut s, 2, "agent-b", "tb", "sb");
|
||||
// Client 3 never announced a group → full, unscoped list (back-compat).
|
||||
let mut all = get_target_ids(&mut s, 3);
|
||||
all.sort();
|
||||
assert_eq!(all, vec!["ta", "tb"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_inherits_opener_group_and_is_visible_to_that_client_only() {
|
||||
let mut s = RelayState::new();
|
||||
create_in_group(&mut s, 1, "agent-a", "ta", "sa");
|
||||
create_in_group(&mut s, 2, "agent-b", "tb", "sb");
|
||||
// A pop-up that agent-a's tab opened: extension reports openerTargetId=ta.
|
||||
s.handle_ext_message(
|
||||
&json!({ "method": "forwardCDPEvent", "params": {
|
||||
"method": "Target.attachedToTarget",
|
||||
"params": { "sessionId": "sp", "targetInfo": {
|
||||
"targetId": "tp", "type": "page", "url": "https://oauth.example/",
|
||||
"attached": true, "openerTargetId": "ta" } } } }),
|
||||
"",
|
||||
);
|
||||
// Only agent-a sees the pop-up; agent-b never does.
|
||||
let mut a = get_target_ids(&mut s, 1);
|
||||
a.sort();
|
||||
assert_eq!(a, vec!["ta", "tp"]);
|
||||
assert_eq!(get_target_ids(&mut s, 2), vec!["tb"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_attach_tags_target_into_adopter_group() {
|
||||
let mut s = RelayState::new();
|
||||
// A pre-existing, ungrouped tab the extension reported (e.g. user's tab).
|
||||
s.handle_ext_message(
|
||||
&json!({ "method": "forwardCDPEvent", "params": {
|
||||
"method": "Target.attachedToTarget",
|
||||
"params": { "sessionId": "su", "targetInfo": {
|
||||
"targetId": "tu", "type": "page", "url": "https://user.example/", "attached": true } } } }),
|
||||
"",
|
||||
);
|
||||
// Client 1 (group agent-a) explicitly adopts it by targetId (#21).
|
||||
s.route_client_command(
|
||||
1,
|
||||
&json!({ "id": 1, "method": "ABRelay.setGroup", "params": { "group": "agent-a" } }),
|
||||
);
|
||||
s.route_client_command(
|
||||
1,
|
||||
&json!({ "id": 2, "method": "Target.attachToTarget", "params": { "targetId": "tu" } }),
|
||||
);
|
||||
// Now it's in agent-a's scope and survives the scoped getTargets.
|
||||
assert_eq!(get_target_ids(&mut s, 1), vec!["tu"]);
|
||||
// A different agent still doesn't see it.
|
||||
s.route_client_command(
|
||||
2,
|
||||
&json!({ "id": 1, "method": "ABRelay.setGroup", "params": { "group": "agent-b" } }),
|
||||
);
|
||||
assert!(get_target_ids(&mut s, 2).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_all_targets_is_unscoped() {
|
||||
let mut s = RelayState::new();
|
||||
create_in_group(&mut s, 1, "agent-a", "ta", "sa");
|
||||
create_in_group(&mut s, 2, "agent-b", "tb", "sb");
|
||||
// Client 1's scoped getTargets sees only its own group...
|
||||
assert_eq!(get_target_ids(&mut s, 1), vec!["ta"]);
|
||||
// ...but ABRelay.getAllTargets returns EVERY target regardless of group
|
||||
// (for explicit cross-group adoption).
|
||||
let all = match s
|
||||
.route_client_command(1, &json!({ "id": 1, "method": "ABRelay.getAllTargets" }))
|
||||
{
|
||||
ClientRoute::Local(v) => {
|
||||
let mut ids: Vec<String> = v["result"]["targetInfos"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| t["targetId"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
_ => panic!("getAllTargets must be local"),
|
||||
};
|
||||
assert_eq!(all, vec!["ta", "tb"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detach_clears_target_group() {
|
||||
let mut s = RelayState::new();
|
||||
create_in_group(&mut s, 1, "agent-a", "ta", "sa");
|
||||
assert_eq!(get_target_ids(&mut s, 1), vec!["ta"]);
|
||||
s.handle_ext_message(
|
||||
&json!({ "method": "forwardCDPEvent", "params": {
|
||||
"method": "Target.detachedFromTarget", "params": { "sessionId": "sa" } } }),
|
||||
"",
|
||||
);
|
||||
assert!(get_target_ids(&mut s, 1).is_empty());
|
||||
assert!(!s.target_group.contains_key("ta"));
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,9 @@ pub struct ScreenshotOptions {
|
||||
pub quality: Option<i32>,
|
||||
pub annotate: bool,
|
||||
pub output_dir: Option<String>,
|
||||
/// Explicit pixel region (x, y, width, height) — `--clip` (issue #34). Takes
|
||||
/// precedence over selector/full_page.
|
||||
pub clip: Option<(f64, f64, f64, f64)>,
|
||||
}
|
||||
|
||||
impl Default for ScreenshotOptions {
|
||||
@@ -72,6 +75,7 @@ impl Default for ScreenshotOptions {
|
||||
quality: None,
|
||||
annotate: false,
|
||||
output_dir: None,
|
||||
clip: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,7 +191,16 @@ async fn capture_screenshot_base64(
|
||||
capture_beyond_viewport: if options.full_page { Some(true) } else { None },
|
||||
};
|
||||
|
||||
if options.full_page {
|
||||
if let Some((x, y, width, height)) = options.clip {
|
||||
// Explicit pixel region wins over selector/full_page (issue #34).
|
||||
params.clip = Some(Viewport {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
scale: 1.0,
|
||||
});
|
||||
} else if options.full_page {
|
||||
let metrics: Value = client
|
||||
.send_command_no_params("Page.getLayoutMetrics", Some(session_id))
|
||||
.await?;
|
||||
@@ -260,7 +273,7 @@ async fn collect_annotations(
|
||||
"DOM.resolveNode",
|
||||
Some(serde_json::json!({
|
||||
"backendNodeId": backend_node_id,
|
||||
"objectGroup": "agent-browser-annotate"
|
||||
"objectGroup": "chrome-use-annotate"
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
@@ -589,11 +602,9 @@ fn round(value: f64) -> i64 {
|
||||
|
||||
fn get_screenshot_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("screenshots")
|
||||
home.join(".chrome-use").join("tmp").join("screenshots")
|
||||
} else {
|
||||
std::env::temp_dir()
|
||||
.join("agent-browser")
|
||||
.join("screenshots")
|
||||
std::env::temp_dir().join("chrome-use").join("screenshots")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+443
-12
@@ -2,6 +2,7 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::adaptive::ElementFingerprint;
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::{
|
||||
AXNode, AXProperty, AXValue, EvaluateParams, EvaluateResult, GetFullAXTreeResult,
|
||||
@@ -80,6 +81,7 @@ pub struct SnapshotOptions {
|
||||
pub interactive: bool,
|
||||
pub compact: bool,
|
||||
pub depth: Option<usize>,
|
||||
pub urls: bool,
|
||||
}
|
||||
|
||||
struct TreeNode {
|
||||
@@ -98,7 +100,8 @@ struct TreeNode {
|
||||
has_ref: bool,
|
||||
ref_id: Option<String>,
|
||||
depth: usize,
|
||||
cursor_info: Option<CursorElementInfo>, // cursor-interactive information
|
||||
cursor_info: Option<CursorElementInfo>,
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
impl TreeNode {
|
||||
@@ -121,10 +124,10 @@ impl TreeNode {
|
||||
ref_id: None,
|
||||
depth: 0,
|
||||
cursor_info: None,
|
||||
url: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Clear node content
|
||||
fn clear(&mut self) {
|
||||
self.role = String::new();
|
||||
self.name = String::new();
|
||||
@@ -139,18 +142,161 @@ impl TreeNode {
|
||||
self.children.clear();
|
||||
self.parent_idx = None;
|
||||
self.has_ref = false;
|
||||
self.url = None;
|
||||
self.ref_id = None;
|
||||
self.depth = 0;
|
||||
self.cursor_info = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an AX fingerprint for a tree node, used by adaptive @ref relocation.
|
||||
/// Pulls only data already in the AX tree (no extra CDP calls): role as `tag`,
|
||||
/// accessible name as `text`, a few discriminating AX properties as `attrs`, and
|
||||
/// the ancestor/parent/sibling structure from the tree links.
|
||||
fn build_ax_fingerprint(tree_nodes: &[TreeNode], idx: usize) -> ElementFingerprint {
|
||||
let node = &tree_nodes[idx];
|
||||
|
||||
let mut attrs = std::collections::BTreeMap::new();
|
||||
if let Some(v) = &node.value_text {
|
||||
if !v.is_empty() {
|
||||
attrs.insert("value".to_string(), v.clone());
|
||||
}
|
||||
}
|
||||
if let Some(u) = &node.url {
|
||||
if !u.is_empty() {
|
||||
attrs.insert("url".to_string(), u.clone());
|
||||
}
|
||||
}
|
||||
if let Some(l) = node.level {
|
||||
attrs.insert("level".to_string(), l.to_string());
|
||||
}
|
||||
if let Some(c) = &node.checked {
|
||||
attrs.insert("checked".to_string(), c.clone());
|
||||
}
|
||||
|
||||
// Ancestor roles, nearest first, capped to keep the signature stable.
|
||||
let mut ancestors = Vec::new();
|
||||
let mut cur = node.parent_idx;
|
||||
while let Some(pidx) = cur {
|
||||
if ancestors.len() >= 6 {
|
||||
break;
|
||||
}
|
||||
let role = tree_nodes[pidx].role.clone();
|
||||
if !role.is_empty() {
|
||||
ancestors.push(role);
|
||||
}
|
||||
cur = tree_nodes[pidx].parent_idx;
|
||||
}
|
||||
|
||||
let (parent_tag, parent_text) = node
|
||||
.parent_idx
|
||||
.map(|pidx| (tree_nodes[pidx].role.clone(), tree_nodes[pidx].name.clone()))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Position among same-role siblings under the same parent.
|
||||
let (sibling_index, sibling_count) = match node.parent_idx {
|
||||
Some(pidx) => {
|
||||
let mut count = 0u32;
|
||||
let mut index = 0u32;
|
||||
for &child in &tree_nodes[pidx].children {
|
||||
if tree_nodes[child].role == node.role {
|
||||
if child == idx {
|
||||
index = count;
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
(index, count)
|
||||
}
|
||||
None => (0, 0),
|
||||
};
|
||||
|
||||
ElementFingerprint {
|
||||
tag: node.role.clone(),
|
||||
text: node.name.clone(),
|
||||
attrs,
|
||||
ancestors,
|
||||
parent_tag,
|
||||
parent_text,
|
||||
sibling_index,
|
||||
sibling_count,
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect AX fingerprints for every node that has a backend node id, used as the
|
||||
/// candidate set when relocating a stale @ref. Reuses the same extraction as the
|
||||
/// baseline so the two are scored in the same space.
|
||||
fn collect_fingerprints(tree_nodes: &[TreeNode]) -> Vec<(i64, ElementFingerprint)> {
|
||||
tree_nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, n)| {
|
||||
n.backend_node_id
|
||||
.map(|bid| (bid, build_ax_fingerprint(tree_nodes, idx)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fetch a fresh AX tree for the given frame and return `(backend_node_id,
|
||||
/// fingerprint)` for every node — the candidate set for adaptive @ref
|
||||
/// relocation. One `getFullAXTree` call, no per-element work.
|
||||
pub(super) async fn collect_current_fingerprints(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
frame_id: Option<&str>,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<Vec<(i64, ElementFingerprint)>, String> {
|
||||
let (ax_params, effective_session_id) =
|
||||
resolve_ax_session(frame_id, session_id, iframe_sessions);
|
||||
let _ = client
|
||||
.send_command_no_params("DOM.enable", Some(effective_session_id))
|
||||
.await;
|
||||
let _ = client
|
||||
.send_command_no_params("Accessibility.enable", Some(effective_session_id))
|
||||
.await;
|
||||
let ax_tree: GetFullAXTreeResult = client
|
||||
.send_command_typed(
|
||||
"Accessibility.getFullAXTree",
|
||||
&ax_params,
|
||||
Some(effective_session_id),
|
||||
)
|
||||
.await?;
|
||||
let (tree_nodes, _roots) = build_tree(&ax_tree.nodes);
|
||||
Ok(collect_fingerprints(&tree_nodes))
|
||||
}
|
||||
|
||||
/// The type of a hidden form input found inside a cursor-interactive element.
|
||||
#[derive(Clone, Copy)]
|
||||
enum HiddenInputKind {
|
||||
Radio,
|
||||
Checkbox,
|
||||
}
|
||||
|
||||
impl HiddenInputKind {
|
||||
fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"radio" => Some(Self::Radio),
|
||||
"checkbox" => Some(Self::Checkbox),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_role(&self) -> &str {
|
||||
match self {
|
||||
Self::Radio => "radio",
|
||||
Self::Checkbox => "checkbox",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about a cursor-interactive element (elements with cursor:pointer, onclick, tabindex, etc.)
|
||||
#[derive(Clone)]
|
||||
struct CursorElementInfo {
|
||||
kind: String, // "clickable", "focusable", "editable"
|
||||
hints: Vec<String>,
|
||||
text: String, // textContent from the DOM element (fallback when ARIA name is empty)
|
||||
hidden_input_kind: Option<HiddenInputKind>,
|
||||
hidden_input_checked: Option<String>, // "true", "false", or "mixed" (tristate)
|
||||
}
|
||||
|
||||
struct RoleNameTracker {
|
||||
@@ -184,6 +330,13 @@ impl RoleNameTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Max iframe nesting depth `take_snapshot` expands. Embedded payment/checkout
|
||||
/// widgets nest a few frames deep (e.g. AdSense → payments.google.com → an inner
|
||||
/// form frame); expanding past the first level is what gives those inner refs a
|
||||
/// `frame_id` so clicks resolve into the right frame (issue #36). Capped to keep
|
||||
/// a pathological frame tree from blowing up the snapshot.
|
||||
const MAX_IFRAME_DEPTH: usize = 3;
|
||||
|
||||
pub async fn take_snapshot(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
@@ -191,6 +344,28 @@ pub async fn take_snapshot(
|
||||
ref_map: &mut RefMap,
|
||||
frame_id: Option<&str>,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<String, String> {
|
||||
take_snapshot_at_depth(
|
||||
client,
|
||||
session_id,
|
||||
options,
|
||||
ref_map,
|
||||
frame_id,
|
||||
iframe_sessions,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn take_snapshot_at_depth(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
options: &SnapshotOptions,
|
||||
ref_map: &mut RefMap,
|
||||
frame_id: Option<&str>,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
depth: usize,
|
||||
) -> Result<String, String> {
|
||||
client
|
||||
.send_command_no_params("DOM.enable", Some(session_id))
|
||||
@@ -274,7 +449,7 @@ pub async fn take_snapshot(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (tree_nodes, root_indices) = build_tree(&ax_tree.nodes);
|
||||
let (mut tree_nodes, root_indices) = build_tree(&ax_tree.nodes);
|
||||
|
||||
// When a selector is given, find AX nodes whose backendDOMNodeId falls
|
||||
// within the target DOM subtree and pick the top-level ones as roots.
|
||||
@@ -320,6 +495,8 @@ pub async fn take_snapshot(
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
promote_hidden_inputs(&mut tree_nodes, &cursor_elements);
|
||||
|
||||
for (idx, node) in tree_nodes.iter().enumerate() {
|
||||
let role = node.role.as_str();
|
||||
let mut should_ref = if INTERACTIVE_ROLES.contains(&role) {
|
||||
@@ -346,7 +523,6 @@ pub async fn take_snapshot(
|
||||
|
||||
let duplicates = tracker.get_duplicates();
|
||||
|
||||
let mut tree_nodes = tree_nodes;
|
||||
for (idx, nth) in &nodes_with_refs {
|
||||
let node = &tree_nodes[*idx];
|
||||
let key = format!("{}:{}", node.role, node.name);
|
||||
@@ -367,6 +543,7 @@ pub async fn take_snapshot(
|
||||
actual_nth,
|
||||
frame_id,
|
||||
);
|
||||
ref_map.set_fingerprint(&ref_id, build_ax_fingerprint(&tree_nodes, *idx));
|
||||
|
||||
tree_nodes[*idx].has_ref = true;
|
||||
tree_nodes[*idx].ref_id = Some(ref_id);
|
||||
@@ -383,16 +560,86 @@ pub async fn take_snapshot(
|
||||
|
||||
ref_map.set_next_ref_num(next_ref);
|
||||
|
||||
if options.urls {
|
||||
let link_nodes: Vec<(usize, i64)> = tree_nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, n)| n.role == "link" && n.has_ref && n.backend_node_id.is_some())
|
||||
.filter_map(|(i, n)| n.backend_node_id.map(|bid| (i, bid)))
|
||||
.collect();
|
||||
|
||||
if !link_nodes.is_empty() {
|
||||
// CDP has no batch resolve API, so we parallelize individual calls.
|
||||
// Phase 1: resolve all backend node IDs to JS object IDs in parallel.
|
||||
let resolve_futs = link_nodes.iter().map(|&(idx, bid)| async move {
|
||||
let resolved = client
|
||||
.send_command(
|
||||
"DOM.resolveNode",
|
||||
Some(serde_json::json!({ "backendNodeId": bid })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
let obj_id = resolved.ok().and_then(|r| {
|
||||
r.get("object")
|
||||
.and_then(|o| o.get("objectId"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
(idx, obj_id)
|
||||
});
|
||||
let resolved: Vec<(usize, Option<String>)> =
|
||||
futures_util::future::join_all(resolve_futs).await;
|
||||
|
||||
// Phase 2: fetch hrefs for all resolved objects in parallel.
|
||||
let href_futs: Vec<_> = resolved
|
||||
.iter()
|
||||
.filter_map(|(idx, obj_id)| {
|
||||
let oid = obj_id.as_ref()?;
|
||||
Some(async move {
|
||||
let result = client
|
||||
.send_command(
|
||||
"Runtime.callFunctionOn",
|
||||
Some(serde_json::json!({
|
||||
"objectId": oid,
|
||||
"functionDeclaration": "function() { return this.href || ''; }",
|
||||
"returnByValue": true,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
let href = result.ok().and_then(|r| {
|
||||
r.get("result")
|
||||
.and_then(|r| r.get("value"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
(*idx, href)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let hrefs: Vec<(usize, Option<String>)> =
|
||||
futures_util::future::join_all(href_futs).await;
|
||||
|
||||
for (idx, href) in hrefs {
|
||||
if let Some(url) = href {
|
||||
tree_nodes[idx].url = Some(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = String::new();
|
||||
for &root_idx in &effective_roots {
|
||||
render_tree(&tree_nodes, root_idx, 0, &mut output, options);
|
||||
}
|
||||
|
||||
// Recurse into child iframes: for each Iframe node with a backend_node_id,
|
||||
// resolve the child frame ID and take a snapshot of its content.
|
||||
// We only recurse from the main frame (frame_id == None) to avoid
|
||||
// unbounded depth; nested iframes within iframes are not expanded.
|
||||
if frame_id.is_none() {
|
||||
// resolve the child frame ID and snapshot its content. Recurse to
|
||||
// MAX_IFRAME_DEPTH (not just the main frame) so refs inside nested
|
||||
// payment/checkout widgets get a `frame_id` and clicks resolve into the right
|
||||
// frame (issue #36); the cap bounds a pathological frame tree.
|
||||
if depth < MAX_IFRAME_DEPTH {
|
||||
let mut iframe_snapshots: Vec<(String, String)> = Vec::new(); // (ref_id, child_snapshot)
|
||||
for node in tree_nodes.iter() {
|
||||
if node.role != "Iframe" || !node.has_ref {
|
||||
@@ -405,13 +652,14 @@ pub async fn take_snapshot(
|
||||
if let Ok(child_fid) = resolve_iframe_frame_id(client, session_id, bid).await {
|
||||
// Snapshot the child frame; errors are silently ignored
|
||||
// (e.g. cross-origin iframes)
|
||||
if let Ok(child_text) = Box::pin(take_snapshot(
|
||||
if let Ok(child_text) = Box::pin(take_snapshot_at_depth(
|
||||
client,
|
||||
session_id,
|
||||
options,
|
||||
ref_map,
|
||||
Some(&child_fid),
|
||||
iframe_sessions,
|
||||
depth + 1,
|
||||
))
|
||||
.await
|
||||
{
|
||||
@@ -567,6 +815,23 @@ async fn find_cursor_interactive_elements(
|
||||
var rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) continue;
|
||||
|
||||
// Detect hidden radio/checkbox inputs inside this element (common pattern:
|
||||
// <label> wrapping a display:none <input type="radio"> styled as a card).
|
||||
// Note: we only check display/visibility/hidden, NOT opacity:0 or sr-only,
|
||||
// because those inputs remain in Chrome's AX tree and already appear as
|
||||
// role="radio" without promotion.
|
||||
var hiddenInputType = null;
|
||||
var hiddenInputChecked = null;
|
||||
var hiddenInput = el.querySelector('input[type="radio"], input[type="checkbox"]');
|
||||
if (hiddenInput) {
|
||||
var hiddenInputStyle = getComputedStyle(hiddenInput);
|
||||
var isInputHidden = hiddenInputStyle.display === 'none' || hiddenInputStyle.visibility === 'hidden' || hiddenInput.hidden;
|
||||
if (isInputHidden) {
|
||||
hiddenInputType = hiddenInput.type;
|
||||
hiddenInputChecked = hiddenInput.indeterminate ? 'mixed' : String(hiddenInput.checked);
|
||||
}
|
||||
}
|
||||
|
||||
el.setAttribute('data-__ab-ci', String(results.length));
|
||||
results.push({
|
||||
text: text,
|
||||
@@ -574,7 +839,9 @@ async fn find_cursor_interactive_elements(
|
||||
hasOnClick: hasOnClick,
|
||||
hasCursorPointer: hasCursorPointer,
|
||||
hasTabIndex: hasTabIndex,
|
||||
isEditable: isEditable
|
||||
isEditable: isEditable,
|
||||
hiddenInputType: hiddenInputType,
|
||||
hiddenInputChecked: hiddenInputChecked
|
||||
});
|
||||
}
|
||||
return results;
|
||||
@@ -691,7 +958,7 @@ async fn find_cursor_interactive_elements(
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("[agent-browser] Warning: failed to clean up data-__ab-ci attributes: {e}");
|
||||
eprintln!("[chrome-use] Warning: failed to clean up data-__ab-ci attributes: {e}");
|
||||
}
|
||||
|
||||
// Build the map
|
||||
@@ -747,6 +1014,15 @@ async fn find_cursor_interactive_elements(
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let hidden_input_kind = elem
|
||||
.get("hiddenInputType")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(HiddenInputKind::parse);
|
||||
let hidden_input_checked = elem
|
||||
.get("hiddenInputChecked")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Some(bid) = backend_node_id {
|
||||
map.insert(
|
||||
bid,
|
||||
@@ -754,6 +1030,8 @@ async fn find_cursor_interactive_elements(
|
||||
kind: kind.to_string(),
|
||||
hints,
|
||||
text,
|
||||
hidden_input_kind,
|
||||
hidden_input_checked,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -762,6 +1040,38 @@ async fn find_cursor_interactive_elements(
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Promote LabelText/generic nodes that wrap a hidden radio/checkbox input.
|
||||
/// When a `<label>` contains a `display:none` `<input type="radio">`, Chrome excludes
|
||||
/// the input from the AX tree entirely, leaving only the label with role="LabelText"
|
||||
/// and an empty name. We detect these via cursor-interactive scanning and promote
|
||||
/// the label to the correct input role so consumers see role="radio" in data.refs.
|
||||
fn promote_hidden_inputs(
|
||||
tree_nodes: &mut [TreeNode],
|
||||
cursor_elements: &HashMap<i64, CursorElementInfo>,
|
||||
) {
|
||||
for node in tree_nodes.iter_mut() {
|
||||
if !matches!(node.role.as_str(), "LabelText" | "generic") {
|
||||
continue;
|
||||
}
|
||||
let cursor_info = match node
|
||||
.backend_node_id
|
||||
.and_then(|bid| cursor_elements.get(&bid))
|
||||
{
|
||||
Some(info) => info,
|
||||
None => continue,
|
||||
};
|
||||
if let Some(input_kind) = cursor_info.hidden_input_kind {
|
||||
node.role = input_kind.as_role().to_string();
|
||||
if node.name.is_empty() && !cursor_info.text.is_empty() {
|
||||
node.name = cursor_info.text.clone();
|
||||
}
|
||||
if let Some(ref checked) = cursor_info.hidden_input_checked {
|
||||
node.checked = Some(checked.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tree(nodes: &[AXNode]) -> (Vec<TreeNode>, Vec<usize>) {
|
||||
let mut tree_nodes: Vec<TreeNode> = Vec::with_capacity(nodes.len());
|
||||
let mut id_to_idx: HashMap<String, usize> = HashMap::new();
|
||||
@@ -797,6 +1107,7 @@ fn build_tree(nodes: &[AXNode]) -> (Vec<TreeNode>, Vec<usize>) {
|
||||
ref_id: None,
|
||||
depth: 0,
|
||||
cursor_info: None,
|
||||
url: None,
|
||||
});
|
||||
id_to_idx.insert(node.node_id.clone(), i);
|
||||
}
|
||||
@@ -993,6 +1304,10 @@ fn render_tree(
|
||||
attrs.push(format!("ref={}", ref_id));
|
||||
}
|
||||
|
||||
if let Some(ref url) = node.url {
|
||||
attrs.push(format!("url={}", url));
|
||||
}
|
||||
|
||||
if !attrs.is_empty() {
|
||||
line.push_str(&format!(" [{}]", attrs.join(", ")));
|
||||
}
|
||||
@@ -1021,6 +1336,39 @@ fn render_tree(
|
||||
}
|
||||
}
|
||||
|
||||
/// True if a snapshot line names an interactive ARIA role. Compaction keeps
|
||||
/// these even without a `ref=`/`": "` marker, so a clickable control never gets
|
||||
/// dropped from `-c` output (the dogfood reports saw a button present in the full
|
||||
/// snapshot vanish from compact, leaving the agent clicking an empty ref).
|
||||
fn is_interactive_line(line: &str) -> bool {
|
||||
const ROLES: &[&str] = &[
|
||||
"button",
|
||||
"link",
|
||||
"textbox",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"combobox",
|
||||
"listbox",
|
||||
"menuitem",
|
||||
"menuitemcheckbox",
|
||||
"menuitemradio",
|
||||
"option",
|
||||
"switch",
|
||||
"slider",
|
||||
"spinbutton",
|
||||
"searchbox",
|
||||
"tab ",
|
||||
"clickable",
|
||||
"focusable",
|
||||
"editable",
|
||||
];
|
||||
let t = line.trim_start();
|
||||
// Lines look like `- button "Label" [ref=e1]`; match the role token after the
|
||||
// leading "- " marker.
|
||||
let t = t.strip_prefix("- ").unwrap_or(t);
|
||||
ROLES.iter().any(|r| t.starts_with(r))
|
||||
}
|
||||
|
||||
fn compact_tree(tree: &str, interactive: bool) -> String {
|
||||
let lines: Vec<&str> = tree.lines().collect();
|
||||
if lines.is_empty() {
|
||||
@@ -1030,7 +1378,7 @@ fn compact_tree(tree: &str, interactive: bool) -> String {
|
||||
let mut keep = vec![false; lines.len()];
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if line.contains("ref=") || line.contains(": ") {
|
||||
if line.contains("ref=") || line.contains(": ") || is_interactive_line(line) {
|
||||
keep[i] = true;
|
||||
// Mark ancestors
|
||||
let my_indent = count_indent(line);
|
||||
@@ -1334,4 +1682,87 @@ mod tests {
|
||||
assert_eq!(session, parent_session);
|
||||
assert_eq!(params, serde_json::json!({}));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// promote_hidden_inputs
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn make_node(role: &str, name: &str, backend_node_id: Option<i64>) -> TreeNode {
|
||||
let mut node = TreeNode::empty();
|
||||
node.role = role.to_string();
|
||||
node.name = name.to_string();
|
||||
node.backend_node_id = backend_node_id;
|
||||
node
|
||||
}
|
||||
|
||||
fn make_cursor_info(
|
||||
hidden_kind: Option<HiddenInputKind>,
|
||||
hidden_checked: Option<&str>,
|
||||
text: &str,
|
||||
) -> CursorElementInfo {
|
||||
CursorElementInfo {
|
||||
kind: "clickable".to_string(),
|
||||
hints: vec!["cursor:pointer".to_string()],
|
||||
text: text.to_string(),
|
||||
hidden_input_kind: hidden_kind,
|
||||
hidden_input_checked: hidden_checked.map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_promote_label_with_hidden_radio() {
|
||||
let mut nodes = vec![
|
||||
make_node("LabelText", "", Some(1)),
|
||||
make_node("LabelText", "", Some(2)),
|
||||
make_node("button", "Submit", Some(3)),
|
||||
];
|
||||
let mut cursor_elements = HashMap::new();
|
||||
cursor_elements.insert(
|
||||
1,
|
||||
make_cursor_info(Some(HiddenInputKind::Radio), Some("false"), "Option A"),
|
||||
);
|
||||
cursor_elements.insert(
|
||||
2,
|
||||
make_cursor_info(Some(HiddenInputKind::Radio), Some("true"), "Option B"),
|
||||
);
|
||||
|
||||
promote_hidden_inputs(&mut nodes, &cursor_elements);
|
||||
|
||||
assert_eq!(nodes[0].role, "radio");
|
||||
assert_eq!(nodes[0].name, "Option A");
|
||||
assert_eq!(nodes[0].checked, Some("false".to_string()));
|
||||
assert_eq!(nodes[1].role, "radio");
|
||||
assert_eq!(nodes[1].name, "Option B");
|
||||
assert_eq!(nodes[1].checked, Some("true".to_string()));
|
||||
// button should be untouched
|
||||
assert_eq!(nodes[2].role, "button");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_promote_preserves_existing_name() {
|
||||
// If AX tree already has a name, don't overwrite with textContent
|
||||
let mut nodes = vec![make_node("LabelText", "AX Name", Some(1))];
|
||||
let mut cursor_elements = HashMap::new();
|
||||
cursor_elements.insert(
|
||||
1,
|
||||
make_cursor_info(Some(HiddenInputKind::Radio), Some("false"), "Text Content"),
|
||||
);
|
||||
|
||||
promote_hidden_inputs(&mut nodes, &cursor_elements);
|
||||
|
||||
assert_eq!(nodes[0].role, "radio");
|
||||
assert_eq!(nodes[0].name, "AX Name"); // preserved, not overwritten
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_promote_skips_without_hidden_input() {
|
||||
// Cursor-interactive label WITHOUT a hidden input should not be promoted
|
||||
let mut nodes = vec![make_node("LabelText", "", Some(1))];
|
||||
let mut cursor_elements = HashMap::new();
|
||||
cursor_elements.insert(1, make_cursor_info(None, None, "Click me"));
|
||||
|
||||
promote_hidden_inputs(&mut nodes, &cursor_elements);
|
||||
|
||||
assert_eq!(nodes[0].role, "LabelText"); // unchanged
|
||||
}
|
||||
}
|
||||
|
||||
+16
-6
@@ -119,6 +119,9 @@ async fn collect_storage_via_temp_target(
|
||||
"Target.createTarget",
|
||||
&CreateTargetParams {
|
||||
url: "about:blank".to_string(),
|
||||
// Transient internal target (storage collection) — never grouped.
|
||||
agent_group: None,
|
||||
background: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
@@ -714,14 +717,21 @@ pub fn dispatch_state_command(cmd: &Value) -> Option<Result<Value, String>> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_sessions_dir() -> PathBuf {
|
||||
/// Return the chrome-use state root (`~/.chrome-use`, falling back to
|
||||
/// `<tempdir>/chrome-use` when the home directory can't be resolved).
|
||||
/// This is the parent of `sessions/`, auth storage, and the encryption key.
|
||||
pub fn get_state_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("sessions")
|
||||
home.join(".chrome-use")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("sessions")
|
||||
std::env::temp_dir().join("chrome-use")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_sessions_dir() -> PathBuf {
|
||||
get_state_dir().join("sessions")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -773,19 +783,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_state_show_nonexistent_file() {
|
||||
let result = state_show("/tmp/nonexistent-agent-browser-state-file.json");
|
||||
let result = state_show("/tmp/nonexistent-chrome-use-state-file.json");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_clear_nonexistent_file() {
|
||||
let result = state_clear(Some("/tmp/nonexistent-agent-browser-state-file.json"));
|
||||
let result = state_clear(Some("/tmp/nonexistent-chrome-use-state-file.json"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_rename_nonexistent() {
|
||||
let result = state_rename("/tmp/nonexistent-agent-browser-state-file.json", "new-name");
|
||||
let result = state_rename("/tmp/nonexistent-chrome-use-state-file.json", "new-name");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
//! Stealth anti-detection module.
|
||||
//!
|
||||
//! Injects browser-level patches to evade bot detection (creepjs, sannysoft,
|
||||
//! Cloudflare Turnstile, etc.) by normalizing fingerprint signals that betray
|
||||
//! headless or automated Chrome instances.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
/// Full stealth JS payload compiled at build time (for --launch mode).
|
||||
const STEALTH_SCRIPTS_RAW: &str = include_str!("stealth_scripts.js");
|
||||
|
||||
/// Minimal stealth script for CDP-attach mode (connecting to user's real Chrome).
|
||||
/// Only removes navigator.webdriver — the browser's own fingerprint is already real.
|
||||
/// Minimal stealth script for CDP-attach mode.
|
||||
/// Emulation.setAutomationOverride handles navigator.webdriver at the native
|
||||
/// level, so no JS patching is needed in CdpAttach mode. An empty script
|
||||
/// avoids creating any detectable lie-props artifacts.
|
||||
const MINIMAL_STEALTH_SCRIPT: &str = "";
|
||||
|
||||
/// Chrome launch arguments that reduce automation fingerprint surface.
|
||||
pub const STEALTH_CHROMIUM_ARGS: &[&str] = &[
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--use-gl=angle",
|
||||
"--use-angle=default",
|
||||
];
|
||||
|
||||
/// Connection mode determines which stealth patches to apply.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum StealthMode {
|
||||
/// Connected to user's real Chrome — minimal patches only (webdriver removal).
|
||||
/// The browser already has a real fingerprint; heavy patches would create detectable lies.
|
||||
CdpAttach,
|
||||
/// Launched a new Chrome instance — apply full stealth patches.
|
||||
FullLaunch,
|
||||
}
|
||||
|
||||
/// Build the stealth JS payload for the given mode and locale.
|
||||
pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
|
||||
if mode == StealthMode::CdpAttach {
|
||||
return MINIMAL_STEALTH_SCRIPT.to_string();
|
||||
}
|
||||
|
||||
// Full launch mode: inject all patches
|
||||
let locale = locale.unwrap_or("en-US");
|
||||
let base_lang = locale.split('-').next().unwrap_or(locale);
|
||||
let languages: Vec<&str> = if base_lang == locale {
|
||||
vec![locale]
|
||||
} else {
|
||||
vec![locale, base_lang]
|
||||
};
|
||||
let config_line = format!(
|
||||
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {}, disableIframeProxy: {} }};"#,
|
||||
locale,
|
||||
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
|
||||
hide_canvas_enabled(),
|
||||
canvas_noise_seed(),
|
||||
disable_iframe_proxy_enabled(),
|
||||
);
|
||||
|
||||
// NB: this prefix MUST match the first line of stealth_scripts.js verbatim,
|
||||
// otherwise the fallback below prepends a SECOND `const __abStealth`
|
||||
// declaration and the whole script dies with a redeclaration SyntaxError.
|
||||
if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
|
||||
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0, disableIframeProxy: false };"#,
|
||||
) {
|
||||
format!("{}{}", config_line, rest)
|
||||
} else {
|
||||
format!("{}\n{}", config_line, STEALTH_SCRIPTS_RAW)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether canvas/audio fingerprint noise is opted into (FullLaunch only).
|
||||
/// OFF by default: injecting noise is a deliberate "lie" that can itself be a
|
||||
/// tell, so it's reserved for users who explicitly want it via
|
||||
/// `AGENT_BROWSER_HIDE_CANVAS=1`.
|
||||
fn hide_canvas_enabled() -> bool {
|
||||
std::env::var("AGENT_BROWSER_HIDE_CANVAS")
|
||||
.ok()
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether to DROP the srcdoc-iframe `contentWindow` Proxy patch (FullLaunch).
|
||||
/// That patch masks automation in srcdoc iframes, but the JS `Proxy` is itself a
|
||||
/// fingerprintable tell (CreepJS `hasIframeProxy` → ~20% stealth). Off by default
|
||||
/// (keep the patch); `AGENT_BROWSER_DISABLE_IFRAME_PROXY=1` drops it for a clean
|
||||
/// 0% CreepJS at the cost of that niche srcdoc-iframe masking.
|
||||
fn disable_iframe_proxy_enabled() -> bool {
|
||||
std::env::var("AGENT_BROWSER_DISABLE_IFRAME_PROXY")
|
||||
.ok()
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A per-process seed so canvas/audio noise is STABLE within a session (a real
|
||||
/// device returns the same hash on repeated reads) but differs from the
|
||||
/// headless-stable default. 0 is avoided so the JS can treat it as "unset".
|
||||
fn canvas_noise_seed() -> u32 {
|
||||
use std::sync::OnceLock;
|
||||
static SEED: OnceLock<u32> = OnceLock::new();
|
||||
*SEED.get_or_init(|| {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0x9e3779b9);
|
||||
// mix the bits a little, then force non-zero
|
||||
let mixed = nanos ^ nanos.rotate_left(13).wrapping_mul(2654435761);
|
||||
mixed | 1
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply stealth patches to a browser session.
|
||||
///
|
||||
/// In `CdpAttach` mode (user's real Chrome): only removes `navigator.webdriver`.
|
||||
/// In `FullLaunch` mode (new Chrome): injects all 32 patches + UA override.
|
||||
pub async fn apply_stealth(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
mode: StealthMode,
|
||||
locale: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
// First: disable the automation flag at the CDP protocol level.
|
||||
// This tells Chrome to natively set navigator.webdriver = false,
|
||||
// which is undetectable by lie-detection systems like CreepJS.
|
||||
// Falls back gracefully on older Chrome versions that don't support this.
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Emulation.setAutomationOverride",
|
||||
Some(json!({ "enabled": false })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
|
||||
let script = build_stealth_script(mode, locale);
|
||||
|
||||
// Inject stealth scripts to run before page JS
|
||||
client
|
||||
.send_command(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
Some(json!({ "source": script })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// In full launch mode, also override UA to remove HeadlessChrome marker
|
||||
if mode == StealthMode::FullLaunch {
|
||||
let ua = get_browser_user_agent(client, session_id).await;
|
||||
if let Some(ua) = ua {
|
||||
let cleaned = ua.replace("HeadlessChrome", "Chrome");
|
||||
if cleaned != ua {
|
||||
client
|
||||
.send_command(
|
||||
"Emulation.setUserAgentOverride",
|
||||
Some(json!({
|
||||
"userAgent": cleaned,
|
||||
"acceptLanguage": locale.unwrap_or("en-US"),
|
||||
"platform": platform_string(),
|
||||
"userAgentMetadata": build_ua_metadata(&cleaned, locale),
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Align the timezone for fresh launches when explicitly requested.
|
||||
// Headless/launched Chrome often reports UTC (or the host's zone), which
|
||||
// can contradict a proxy's geolocation or a spoofed locale.
|
||||
// `Emulation.setTimezoneOverride` is a NATIVE override — Intl.DateTimeFormat
|
||||
// and Date both follow it with no detectable JS lie. Opt-in only:
|
||||
// AGENT_BROWSER_TIMEZONE=<IANA id> -> use that zone (e.g. align to proxy)
|
||||
// AGENT_BROWSER_TIMEZONE=auto -> derive a default from the locale
|
||||
// (unset) -> leave the real timezone untouched
|
||||
if let Some(tz) = resolve_timezone(locale) {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Emulation.setTimezoneOverride",
|
||||
Some(json!({ "timezoneId": tz })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the timezone to emulate for a fresh-launch session, if any.
|
||||
/// Controlled by `AGENT_BROWSER_TIMEZONE`: an explicit IANA id, or `auto` to
|
||||
/// derive a sensible default from the locale. Returns `None` (leave the real
|
||||
/// timezone) when unset, empty, or when `auto` can't map the locale.
|
||||
fn resolve_timezone(locale: Option<&str>) -> Option<String> {
|
||||
let raw = std::env::var("AGENT_BROWSER_TIMEZONE").ok()?;
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if raw.eq_ignore_ascii_case("auto") {
|
||||
return locale.and_then(locale_default_timezone).map(str::to_string);
|
||||
}
|
||||
Some(raw.to_string())
|
||||
}
|
||||
|
||||
/// Best-effort IANA timezone for a locale. Used only for
|
||||
/// `AGENT_BROWSER_TIMEZONE=auto`; unknown locales return `None` so the real
|
||||
/// timezone is left untouched rather than guessing a wrong one.
|
||||
fn locale_default_timezone(locale: &str) -> Option<&'static str> {
|
||||
let tz = match locale.to_ascii_lowercase().as_str() {
|
||||
"en-us" => "America/New_York",
|
||||
"en-ca" => "America/Toronto",
|
||||
"en-gb" => "Europe/London",
|
||||
"en-au" => "Australia/Sydney",
|
||||
"ja" | "ja-jp" => "Asia/Tokyo",
|
||||
"ko" | "ko-kr" => "Asia/Seoul",
|
||||
"zh-cn" | "zh-hans" | "zh-hans-cn" => "Asia/Shanghai",
|
||||
"zh-tw" | "zh-hant" | "zh-hant-tw" => "Asia/Taipei",
|
||||
"zh-hk" => "Asia/Hong_Kong",
|
||||
"de" | "de-de" => "Europe/Berlin",
|
||||
"fr" | "fr-fr" => "Europe/Paris",
|
||||
"es" | "es-es" => "Europe/Madrid",
|
||||
"it" | "it-it" => "Europe/Rome",
|
||||
"nl" | "nl-nl" => "Europe/Amsterdam",
|
||||
"pt-br" => "America/Sao_Paulo",
|
||||
"pt" | "pt-pt" => "Europe/Lisbon",
|
||||
"ru" | "ru-ru" => "Europe/Moscow",
|
||||
_ => return None,
|
||||
};
|
||||
Some(tz)
|
||||
}
|
||||
|
||||
/// Get the browser's User-Agent string via CDP.
|
||||
async fn get_browser_user_agent(client: &CdpClient, session_id: &str) -> Option<String> {
|
||||
let result = client
|
||||
.send_command(
|
||||
"Runtime.evaluate",
|
||||
Some(json!({ "expression": "navigator.userAgent", "returnByValue": true })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
result
|
||||
.get("result")
|
||||
.and_then(|r| r.get("value"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// Also run stealth script on the current page (for already-loaded pages after CDP attach).
|
||||
pub async fn apply_stealth_to_current_page(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
mode: StealthMode,
|
||||
locale: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let script = build_stealth_script(mode, locale);
|
||||
client
|
||||
.send_command(
|
||||
"Runtime.evaluate",
|
||||
Some(json!({
|
||||
"expression": script,
|
||||
"returnByValue": true,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Strip sourceURL comments from CDP expressions to avoid leaking
|
||||
/// automation-framework identifiers in stack traces.
|
||||
pub fn strip_source_url_labels(input: &str) -> String {
|
||||
// Remove //# sourceURL=... and //@ sourceURL=...
|
||||
let re_line = regex_lite::Regex::new(r"(?i)\n?\s*//[@#]\s*sourceURL=[^\n\r]*").unwrap();
|
||||
let output = re_line.replace_all(input, "");
|
||||
// Remove /*# sourceURL=...*/ block comments
|
||||
let re_block = regex_lite::Regex::new(r"(?is)\n?\s*/\*[@#]\s*sourceURL=[\s\S]*?\*/").unwrap();
|
||||
re_block.replace_all(&output, "").to_string()
|
||||
}
|
||||
|
||||
/// The legacy `navigator.platform` value (set via the CDP
|
||||
/// `Emulation.setUserAgentOverride` `platform` field). This is NOT the UA-CH
|
||||
/// platform (see `platform_hint`): real Chrome reports `MacIntel` on macOS and
|
||||
/// `Linux x86_64` on Linux, so emitting the UA-CH form ("macOS"/"Linux") here is
|
||||
/// a detectable mismatch against the UA's "Intel Mac OS X" / Linux strings.
|
||||
fn platform_string() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"MacIntel"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Win32"
|
||||
} else {
|
||||
"Linux x86_64"
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_hint() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Windows"
|
||||
} else {
|
||||
"Linux"
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_version_hint() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"14.0.0"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"10.0.0"
|
||||
} else {
|
||||
"6.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ua_metadata(ua: &str, locale: Option<&str>) -> serde_json::Value {
|
||||
// Extract Chrome version from UA string
|
||||
let chrome_version = ua
|
||||
.split("Chrome/")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split_whitespace().next())
|
||||
.unwrap_or("130.0.0.0");
|
||||
let major = chrome_version.split('.').next().unwrap_or("130");
|
||||
|
||||
let _lang = locale.unwrap_or("en-US");
|
||||
|
||||
json!({
|
||||
"brands": [
|
||||
{ "brand": "Chromium", "version": major },
|
||||
{ "brand": "Google Chrome", "version": major },
|
||||
{ "brand": "Not?A_Brand", "version": "99" },
|
||||
],
|
||||
"fullVersionList": [
|
||||
{ "brand": "Chromium", "version": chrome_version },
|
||||
{ "brand": "Google Chrome", "version": chrome_version },
|
||||
{ "brand": "Not?A_Brand", "version": "99.0.0.0" },
|
||||
],
|
||||
"fullVersion": chrome_version,
|
||||
"platform": platform_hint(),
|
||||
"platformVersion": platform_version_hint(),
|
||||
"architecture": if cfg!(target_arch = "aarch64") { "arm" } else { "x86" },
|
||||
"model": "",
|
||||
"mobile": false,
|
||||
"bitness": "64",
|
||||
"wow64": false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod timezone_tests {
|
||||
use super::{locale_default_timezone, resolve_timezone};
|
||||
|
||||
#[test]
|
||||
fn maps_common_locales_case_insensitively() {
|
||||
assert_eq!(locale_default_timezone("en-US"), Some("America/New_York"));
|
||||
assert_eq!(locale_default_timezone("ja-JP"), Some("Asia/Tokyo"));
|
||||
assert_eq!(locale_default_timezone("zh-CN"), Some("Asia/Shanghai"));
|
||||
assert_eq!(locale_default_timezone("ZH-TW"), Some("Asia/Taipei"));
|
||||
assert_eq!(locale_default_timezone("ja"), Some("Asia/Tokyo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_locale_returns_none() {
|
||||
assert_eq!(locale_default_timezone("xx-YY"), None);
|
||||
assert_eq!(locale_default_timezone(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_timezone_honors_env() {
|
||||
// Serialized via a single test to avoid cross-test env races on this key.
|
||||
std::env::remove_var("AGENT_BROWSER_TIMEZONE");
|
||||
assert_eq!(resolve_timezone(Some("en-US")), None);
|
||||
|
||||
std::env::set_var("AGENT_BROWSER_TIMEZONE", "Europe/Berlin");
|
||||
assert_eq!(resolve_timezone(None), Some("Europe/Berlin".to_string()));
|
||||
|
||||
std::env::set_var("AGENT_BROWSER_TIMEZONE", " ");
|
||||
assert_eq!(resolve_timezone(Some("en-US")), None);
|
||||
|
||||
std::env::set_var("AGENT_BROWSER_TIMEZONE", "auto");
|
||||
assert_eq!(
|
||||
resolve_timezone(Some("ja-JP")),
|
||||
Some("Asia/Tokyo".to_string())
|
||||
);
|
||||
assert_eq!(resolve_timezone(Some("xx-YY")), None);
|
||||
assert_eq!(resolve_timezone(None), None);
|
||||
|
||||
std::env::remove_var("AGENT_BROWSER_TIMEZONE");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,325 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{broadcast, watch, Mutex, RwLock};
|
||||
|
||||
use crate::native::cdp::client::CdpClient;
|
||||
use crate::native::network;
|
||||
|
||||
use super::timestamp_ms;
|
||||
|
||||
/// Background task that subscribes to CDP events and broadcasts screencast frames in real-time.
|
||||
/// Also handles auto-start/stop of screencast based on WebSocket client count.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn cdp_event_loop(
|
||||
frame_tx: broadcast::Sender<String>,
|
||||
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
|
||||
client_notify: Arc<tokio::sync::Notify>,
|
||||
screencasting: Arc<Mutex<bool>>,
|
||||
client_count: Arc<Mutex<usize>>,
|
||||
cdp_session_id: Arc<RwLock<Option<String>>>,
|
||||
viewport_width: Arc<Mutex<u32>>,
|
||||
viewport_height: Arc<Mutex<u32>>,
|
||||
last_frame: Arc<RwLock<Option<String>>>,
|
||||
last_tabs: Arc<RwLock<Vec<Value>>>,
|
||||
last_engine: Arc<RwLock<String>>,
|
||||
recording: Arc<Mutex<bool>>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
changed = shutdown_rx.changed() => {
|
||||
if changed.is_err() || *shutdown_rx.borrow() {
|
||||
let session_id = cdp_session_id.read().await.clone();
|
||||
if *screencasting.lock().await {
|
||||
if let Some(ref client) = *client_slot.read().await {
|
||||
let _ = client
|
||||
.send_command_no_params("Page.stopScreencast", session_id.as_deref())
|
||||
.await;
|
||||
}
|
||||
let mut sc = screencasting.lock().await;
|
||||
*sc = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ = client_notify.notified() => {}
|
||||
}
|
||||
|
||||
let count = *client_count.lock().await;
|
||||
let guard = client_slot.read().await;
|
||||
|
||||
if count > 0 {
|
||||
if let Some(ref client) = *guard {
|
||||
let mut event_rx = client.subscribe();
|
||||
let client_arc = Arc::clone(client);
|
||||
drop(guard);
|
||||
|
||||
let session_id = cdp_session_id.read().await.clone();
|
||||
|
||||
let vw = *viewport_width.lock().await;
|
||||
let vh = *viewport_height.lock().await;
|
||||
|
||||
let eng = last_engine.read().await.clone();
|
||||
let supports_screencast = eng == "chrome";
|
||||
|
||||
if supports_screencast {
|
||||
let _ = client_arc
|
||||
.send_command(
|
||||
"Page.startScreencast",
|
||||
Some(json!({
|
||||
"format": "jpeg",
|
||||
"quality": 80,
|
||||
"maxWidth": vw,
|
||||
"maxHeight": vh,
|
||||
"everyNthFrame": 1,
|
||||
})),
|
||||
session_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
{
|
||||
let mut sc = screencasting.lock().await;
|
||||
*sc = supports_screencast;
|
||||
}
|
||||
|
||||
let rec = *recording.lock().await;
|
||||
let status = json!({
|
||||
"type": "status",
|
||||
"connected": true,
|
||||
"screencasting": supports_screencast,
|
||||
"viewportWidth": vw,
|
||||
"viewportHeight": vh,
|
||||
"engine": eng,
|
||||
"recording": rec,
|
||||
});
|
||||
let _ = frame_tx.send(status.to_string());
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
changed = shutdown_rx.changed() => {
|
||||
if changed.is_err() || *shutdown_rx.borrow() {
|
||||
if supports_screencast {
|
||||
let session_id = cdp_session_id.read().await.clone();
|
||||
let _ = client_arc
|
||||
.send_command_no_params("Page.stopScreencast", session_id.as_deref())
|
||||
.await;
|
||||
}
|
||||
let mut sc = screencasting.lock().await;
|
||||
*sc = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
event = event_rx.recv() => {
|
||||
match event {
|
||||
Ok(evt) => {
|
||||
if evt.method == "Page.frameNavigated" {
|
||||
if let Some(frame) = evt.params.get("frame") {
|
||||
let is_main = frame
|
||||
.get("parentId")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_none_or(|s| s.is_empty());
|
||||
if is_main {
|
||||
if let Some(url) = frame.get("url").and_then(|v| v.as_str()) {
|
||||
{
|
||||
let mut tabs = last_tabs.write().await;
|
||||
for tab in tabs.iter_mut() {
|
||||
if tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
tab.as_object_mut().map(|o| o.insert("url".to_string(), json!(url)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let msg = json!({
|
||||
"type": "url",
|
||||
"url": url,
|
||||
"timestamp": timestamp_ms(),
|
||||
});
|
||||
let _ = frame_tx.send(msg.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if evt.method == "Page.screencastFrame" {
|
||||
if let Some(sid) = evt.params.get("sessionId").and_then(|v| v.as_i64()) {
|
||||
let _ = client_arc.send_command(
|
||||
"Page.screencastFrameAck",
|
||||
Some(json!({ "sessionId": sid })),
|
||||
evt.session_id.as_deref(),
|
||||
).await;
|
||||
}
|
||||
|
||||
if let Some(data) = evt.params.get("data").and_then(|v| v.as_str()) {
|
||||
let meta = evt.params.get("metadata");
|
||||
let msg = json!({
|
||||
"type": "frame",
|
||||
"data": data,
|
||||
"metadata": {
|
||||
"offsetTop": meta.and_then(|m| m.get("offsetTop")).and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"pageScaleFactor": meta.and_then(|m| m.get("pageScaleFactor")).and_then(|v| v.as_f64()).unwrap_or(1.0),
|
||||
"deviceWidth": vw,
|
||||
"deviceHeight": vh,
|
||||
"scrollOffsetX": meta.and_then(|m| m.get("scrollOffsetX")).and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"scrollOffsetY": meta.and_then(|m| m.get("scrollOffsetY")).and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"timestamp": meta.and_then(|m| m.get("timestamp")).and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
}
|
||||
});
|
||||
let msg_str = msg.to_string();
|
||||
{
|
||||
let mut lf = last_frame.write().await;
|
||||
*lf = Some(msg_str.clone());
|
||||
}
|
||||
let _ = frame_tx.send(msg_str);
|
||||
}
|
||||
} else if evt.method == "Runtime.consoleAPICalled" {
|
||||
let level = evt.params.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("log");
|
||||
let raw_args = evt.params.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let text = network::format_console_args(&raw_args);
|
||||
if !text.is_empty() {
|
||||
let mut msg = json!({
|
||||
"type": "console",
|
||||
"level": level,
|
||||
"text": text,
|
||||
"timestamp": timestamp_ms(),
|
||||
});
|
||||
if !raw_args.is_empty() {
|
||||
msg.as_object_mut().unwrap().insert(
|
||||
"args".to_string(),
|
||||
Value::Array(raw_args),
|
||||
);
|
||||
}
|
||||
let _ = frame_tx.send(msg.to_string());
|
||||
}
|
||||
} else if evt.method == "Runtime.exceptionThrown" {
|
||||
let text = evt.params.get("exceptionDetails")
|
||||
.and_then(|d| {
|
||||
d.get("exception")
|
||||
.and_then(|e| e.get("description").and_then(|v| v.as_str()))
|
||||
.or_else(|| d.get("text").and_then(|v| v.as_str()))
|
||||
})
|
||||
.unwrap_or("Unknown error");
|
||||
let line = evt.params.get("exceptionDetails")
|
||||
.and_then(|d| d.get("lineNumber").and_then(|v| v.as_i64()));
|
||||
let column = evt.params.get("exceptionDetails")
|
||||
.and_then(|d| d.get("columnNumber").and_then(|v| v.as_i64()));
|
||||
let msg = json!({
|
||||
"type": "page_error",
|
||||
"text": text,
|
||||
"line": line,
|
||||
"column": column,
|
||||
"timestamp": timestamp_ms(),
|
||||
});
|
||||
let _ = frame_tx.send(msg.to_string());
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
_ = client_notify.notified() => {
|
||||
let count = *client_count.lock().await;
|
||||
let new_session_id = cdp_session_id.read().await.clone();
|
||||
if count == 0 {
|
||||
if supports_screencast {
|
||||
let _ = client_arc
|
||||
.send_command_no_params("Page.stopScreencast", session_id.as_deref())
|
||||
.await;
|
||||
}
|
||||
let mut sc = screencasting.lock().await;
|
||||
*sc = false;
|
||||
break;
|
||||
}
|
||||
let client_changed = {
|
||||
let guard = client_slot.read().await;
|
||||
let same = guard
|
||||
.as_ref()
|
||||
.is_some_and(|c| Arc::ptr_eq(c, &client_arc));
|
||||
!same
|
||||
};
|
||||
let session_changed = new_session_id != session_id;
|
||||
let new_vw = *viewport_width.lock().await;
|
||||
let new_vh = *viewport_height.lock().await;
|
||||
let viewport_changed = new_vw != vw || new_vh != vh;
|
||||
if client_changed || session_changed || viewport_changed {
|
||||
if supports_screencast {
|
||||
let _ = client_arc
|
||||
.send_command_no_params("Page.stopScreencast", session_id.as_deref())
|
||||
.await;
|
||||
}
|
||||
let mut sc = screencasting.lock().await;
|
||||
*sc = false;
|
||||
client_notify.notify_one();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
drop(guard);
|
||||
}
|
||||
} else {
|
||||
let was_screencasting = *screencasting.lock().await;
|
||||
if was_screencasting {
|
||||
if let Some(ref client) = *guard {
|
||||
let session_id = cdp_session_id.read().await.clone();
|
||||
let _ = client
|
||||
.send_command_no_params("Page.stopScreencast", session_id.as_deref())
|
||||
.await;
|
||||
}
|
||||
let mut sc = screencasting.lock().await;
|
||||
*sc = false;
|
||||
}
|
||||
drop(guard);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_screencast(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
format: &str,
|
||||
quality: i32,
|
||||
max_width: i32,
|
||||
max_height: i32,
|
||||
) -> Result<(), String> {
|
||||
client
|
||||
.send_command(
|
||||
"Page.startScreencast",
|
||||
Some(json!({
|
||||
"format": format,
|
||||
"quality": quality,
|
||||
"maxWidth": max_width,
|
||||
"maxHeight": max_height,
|
||||
"everyNthFrame": 1,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop_screencast(client: &CdpClient, session_id: &str) -> Result<(), String> {
|
||||
client
|
||||
.send_command_no_params("Page.stopScreencast", Some(session_id))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ack_screencast_frame(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
screencast_session_id: i64,
|
||||
) -> Result<(), String> {
|
||||
client
|
||||
.send_command(
|
||||
"Page.screencastFrameAck",
|
||||
Some(json!({ "sessionId": screencast_session_id })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,970 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use super::http::cors_headers_for_origin;
|
||||
|
||||
pub(crate) const DEFAULT_AI_GATEWAY_URL: &str = "https://ai-gateway.vercel.sh";
|
||||
|
||||
static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
|
||||
pub(crate) fn http_client() -> &'static reqwest::Client {
|
||||
HTTP_CLIENT.get_or_init(reqwest::Client::new)
|
||||
}
|
||||
|
||||
pub(crate) fn is_chat_enabled() -> bool {
|
||||
std::env::var("AI_GATEWAY_API_KEY").is_ok()
|
||||
}
|
||||
|
||||
pub(super) fn chat_status_json() -> String {
|
||||
let enabled = is_chat_enabled();
|
||||
let mut obj = json!({ "enabled": enabled });
|
||||
if enabled {
|
||||
if let Ok(model) = std::env::var("AI_GATEWAY_MODEL") {
|
||||
obj["model"] = Value::String(model);
|
||||
}
|
||||
}
|
||||
obj.to_string()
|
||||
}
|
||||
|
||||
pub(super) async fn handle_models_request(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
origin: Option<&str>,
|
||||
) {
|
||||
let cors = cors_headers_for_origin(origin);
|
||||
let gateway_url = std::env::var("AI_GATEWAY_URL")
|
||||
.unwrap_or_else(|_| DEFAULT_AI_GATEWAY_URL.to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let api_key = match std::env::var("AI_GATEWAY_API_KEY") {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
let body = r#"{"data":[]}"#;
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{cors}\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(resp.as_bytes()).await;
|
||||
let _ = stream.write_all(body.as_bytes()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!("{}/v1/models", gateway_url);
|
||||
let client = http_client();
|
||||
let result = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let body = match result {
|
||||
Ok(r) if r.status().is_success() => r
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| r#"{"data":[]}"#.to_string()),
|
||||
_ => r#"{"data":[]}"#.to_string(),
|
||||
};
|
||||
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{cors}\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(resp.as_bytes()).await;
|
||||
let _ = stream.write_all(body.as_bytes()).await;
|
||||
}
|
||||
|
||||
const SKILL_NAMES: &[&str] = &["chrome-use", "slack", "electron", "dogfood", "agentcore"];
|
||||
|
||||
/// Locate the `skills/` directory by walking up from the executable.
|
||||
/// Works for npm installs (binary in `bin/`, skills at `../skills/`) and
|
||||
/// dev builds (binary deep in `cli/target/`, skills at repo root).
|
||||
fn find_skills_dir() -> Option<std::path::PathBuf> {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let real = exe.canonicalize().unwrap_or(exe);
|
||||
let mut dir = real.parent();
|
||||
while let Some(d) = dir {
|
||||
let candidate = d.join("skills");
|
||||
if candidate.join("chrome-use").join("SKILL.md").exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
dir = d.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn load_skills() -> Vec<(String, String)> {
|
||||
let Some(skills_dir) = find_skills_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
SKILL_NAMES
|
||||
.iter()
|
||||
.filter_map(|name| {
|
||||
let path = skills_dir.join(name).join("SKILL.md");
|
||||
let content = std::fs::read_to_string(&path).ok()?;
|
||||
Some((name.to_string(), content))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn strip_frontmatter(s: &str) -> &str {
|
||||
if !s.starts_with("---") {
|
||||
return s;
|
||||
}
|
||||
if let Some(end) = s[3..].find("---") {
|
||||
let after = &s[3 + end + 3..];
|
||||
after.trim_start_matches(['\n', '\r'])
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_system_prompt() -> &'static str {
|
||||
static PROMPT: OnceLock<String> = OnceLock::new();
|
||||
PROMPT.get_or_init(|| {
|
||||
let skills = load_skills();
|
||||
|
||||
let mut sections = String::new();
|
||||
for (name, content) in &skills {
|
||||
let body = strip_frontmatter(content);
|
||||
sections.push_str(&format!("\n\n<skill name=\"{}\">\n{}\n</skill>", name, body.trim()));
|
||||
}
|
||||
|
||||
format!(
|
||||
r#"You are an AI assistant that controls a browser through chrome-use. You have an active browser session, but you can also create new sessions.
|
||||
|
||||
RULES:
|
||||
- You MUST use the agent_browser tool for every browser action. NEVER claim you performed an action without calling the tool.
|
||||
- If the user asks you to do something, call the tool first, then describe the result.
|
||||
- If a request is outside your capabilities (e.g. system operations), say so honestly. Do not improvise or pretend.
|
||||
- One tool call per command. Do not chain with `&&` or `;`.
|
||||
- Do not add `--json`.
|
||||
- Do not run non-chrome-use programs.
|
||||
- Keep responses concise.
|
||||
- For screenshots, omit the path argument so they save to the default location (which will be displayed inline). Screenshots from tool calls are ALREADY shown to the user. Do NOT re-display them with markdown image syntax in your text response. Never use `![...]()` to reference screenshots.
|
||||
- To create a new session: add `--session <name>` to any command (e.g. `chrome-use --session my-session open https://example.com`). If the session does not exist, it will be created automatically.
|
||||
- To use a different browser engine: add `--engine <engine>` (e.g. `chrome-use --session lp-session --engine lightpanda open https://example.com`). Supported engines: chrome (default), lightpanda.
|
||||
|
||||
The following skill references describe chrome-use capabilities in detail. Use them when deciding which commands to run and how to approach tasks.
|
||||
{sections}"#,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) const CHAT_TOOLS: &str = r#"[{"type":"function","function":{"name":"agent_browser","description":"Execute an chrome-use command. Runs against the active session by default. Add --session <name> to target or create a different session, and --engine <engine> to choose a browser engine.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The command to execute, e.g. 'chrome-use open https://google.com' or 'chrome-use --session new-session open https://example.com' or 'chrome-use snapshot -i' or 'chrome-use click @e3'"}},"required":["command"]}}}]"#;
|
||||
|
||||
pub(crate) const COMPACT_THRESHOLD_CHARS: usize = 200_000;
|
||||
pub(crate) const KEEP_RECENT_MESSAGES: usize = 6;
|
||||
|
||||
pub(crate) fn estimate_chars(messages: &[Value]) -> usize {
|
||||
messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let content_len = m
|
||||
.get("content")
|
||||
.map(|c| {
|
||||
if let Some(s) = c.as_str() {
|
||||
s.len()
|
||||
} else {
|
||||
c.to_string().len()
|
||||
}
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let tc_len = m
|
||||
.get("tool_calls")
|
||||
.map(|t| t.to_string().len())
|
||||
.unwrap_or(0);
|
||||
content_len + tc_len
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub(crate) fn find_safe_split(messages: &[Value], keep_recent: usize) -> usize {
|
||||
if messages.len() <= keep_recent + 1 {
|
||||
return 1;
|
||||
}
|
||||
let desired = messages.len() - keep_recent;
|
||||
for i in (1..=desired).rev() {
|
||||
if messages[i].get("role").and_then(|r| r.as_str()) == Some("user") {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
desired.max(1)
|
||||
}
|
||||
|
||||
fn build_summary_text(messages: &[Value]) -> String {
|
||||
let mut text = String::new();
|
||||
for msg in messages {
|
||||
let role = msg
|
||||
.get("role")
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or("unknown");
|
||||
if let Some(content) = msg.get("content").and_then(|c| c.as_str()) {
|
||||
if !content.is_empty() {
|
||||
let truncated = if content.len() > 2000 {
|
||||
format!("{}...[truncated]", &content[..2000])
|
||||
} else {
|
||||
content.to_string()
|
||||
};
|
||||
text.push_str(&format!("[{}] {}\n\n", role, truncated));
|
||||
}
|
||||
}
|
||||
if let Some(tcs) = msg.get("tool_calls").and_then(|t| t.as_array()) {
|
||||
for tc in tcs {
|
||||
let name = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("");
|
||||
let args = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("arguments"))
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("");
|
||||
text.push_str(&format!("[assistant tool:{}] {}\n", name, args));
|
||||
}
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
pub(crate) async fn summarize_for_compaction(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
messages: &[Value],
|
||||
) -> Option<String> {
|
||||
let conversation = build_summary_text(messages);
|
||||
if conversation.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Summarize this browser automation conversation concisely. Preserve: URLs visited, actions performed, current page state, errors encountered, and user goals. Output only the summary."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": conversation
|
||||
}
|
||||
],
|
||||
"max_tokens": 1024,
|
||||
"stream": false,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body.to_string())
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let result: Value = resp.json().await.ok()?;
|
||||
result
|
||||
.get("choices")
|
||||
.and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("message"))
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
const SCREENSHOT_MAX_WIDTH: u32 = 1024;
|
||||
const SCREENSHOT_JPEG_QUALITY: u8 = 40;
|
||||
|
||||
fn compress_image_to_jpeg(raw_bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let img = image::load_from_memory(raw_bytes).ok()?;
|
||||
let img = if img.width() > SCREENSHOT_MAX_WIDTH {
|
||||
img.resize(
|
||||
SCREENSHOT_MAX_WIDTH,
|
||||
u32::MAX,
|
||||
image::imageops::FilterType::Triangle,
|
||||
)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
let encoder =
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, SCREENSHOT_JPEG_QUALITY);
|
||||
img.write_with_encoder(encoder).ok()?;
|
||||
Some(buf.into_inner())
|
||||
}
|
||||
|
||||
fn has_image_extension(s: &str) -> bool {
|
||||
let lower = s.to_lowercase();
|
||||
lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg")
|
||||
}
|
||||
|
||||
fn extract_image_path(text: &str) -> Option<String> {
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
// Whole line is a path (handles paths with spaces)
|
||||
if has_image_extension(trimmed) && std::path::Path::new(trimmed).exists() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
for suffix in [".png", ".jpg", ".jpeg"] {
|
||||
if let Some(pos) = trimmed.to_lowercase().rfind(suffix) {
|
||||
let end = pos + suffix.len();
|
||||
let candidate = &trimmed[..end];
|
||||
let start = candidate
|
||||
.rfind(|c: char| c.is_whitespace())
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0);
|
||||
let path = &candidate[start..];
|
||||
if !path.is_empty() && std::path::Path::new(path).exists() {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn enrich_tool_output(result: &str) -> String {
|
||||
let Some(path) = extract_image_path(result) else {
|
||||
return result.to_string();
|
||||
};
|
||||
|
||||
let Ok(raw_bytes) = std::fs::read(&path) else {
|
||||
return result.to_string();
|
||||
};
|
||||
|
||||
let (jpeg_bytes, mime) = match compress_image_to_jpeg(&raw_bytes) {
|
||||
Some(compressed) => (compressed, "image/jpeg"),
|
||||
None => {
|
||||
let lower = path.to_lowercase();
|
||||
(
|
||||
raw_bytes,
|
||||
if lower.ends_with(".png") {
|
||||
"image/png"
|
||||
} else {
|
||||
"image/jpeg"
|
||||
},
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &jpeg_bytes);
|
||||
let data_url = format!("data:{};base64,{}", mime, b64);
|
||||
|
||||
json!({
|
||||
"text": result,
|
||||
"image": data_url
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
const ALLOWED_COMMANDS: &[&str] = &[
|
||||
"open",
|
||||
"goto",
|
||||
"navigate",
|
||||
"back",
|
||||
"forward",
|
||||
"reload",
|
||||
"click",
|
||||
"dblclick",
|
||||
"fill",
|
||||
"type",
|
||||
"hover",
|
||||
"focus",
|
||||
"check",
|
||||
"uncheck",
|
||||
"select",
|
||||
"drag",
|
||||
"upload",
|
||||
"download",
|
||||
"press",
|
||||
"key",
|
||||
"keydown",
|
||||
"keyup",
|
||||
"keyboard",
|
||||
"scroll",
|
||||
"scrollintoview",
|
||||
"scrollinto",
|
||||
"wait",
|
||||
"screenshot",
|
||||
"pdf",
|
||||
"snapshot",
|
||||
"eval",
|
||||
"close",
|
||||
"quit",
|
||||
"exit",
|
||||
"inspect",
|
||||
"auth",
|
||||
"confirm",
|
||||
"deny",
|
||||
"connect",
|
||||
"cookies",
|
||||
"storage",
|
||||
"window",
|
||||
"frame",
|
||||
"dialog",
|
||||
"trace",
|
||||
"profiler",
|
||||
"record",
|
||||
"har",
|
||||
"network",
|
||||
"title",
|
||||
"url",
|
||||
"console",
|
||||
"errors",
|
||||
"highlight",
|
||||
"state",
|
||||
"emulate",
|
||||
"video",
|
||||
"tap",
|
||||
"swipe",
|
||||
"device",
|
||||
"batch",
|
||||
"diff",
|
||||
"find",
|
||||
"role",
|
||||
"text",
|
||||
"label",
|
||||
"placeholder",
|
||||
"alt",
|
||||
"testid",
|
||||
"first",
|
||||
"last",
|
||||
"nth",
|
||||
"mouse",
|
||||
"touchscreen",
|
||||
"attribute",
|
||||
"property",
|
||||
"set",
|
||||
"get",
|
||||
"is",
|
||||
"stream",
|
||||
"tab",
|
||||
"clipboard",
|
||||
"session",
|
||||
];
|
||||
|
||||
const ALLOWED_GLOBAL_FLAGS: &[&str] = &["--session", "--engine"];
|
||||
|
||||
pub(crate) async fn execute_chat_tool(session: &str, command: &str) -> String {
|
||||
let exe = match std::env::current_exe() {
|
||||
Ok(p) => p,
|
||||
Err(e) => return format!("Failed to resolve executable: {}", e),
|
||||
};
|
||||
|
||||
let single = command.split("&&").next().unwrap_or(command);
|
||||
let single = single.split(';').next().unwrap_or(single).trim();
|
||||
let stripped = single.strip_prefix("chrome-use ").unwrap_or(single);
|
||||
let words = crate::commands::shell_words_split(stripped);
|
||||
|
||||
let mut global_flags: Vec<String> = Vec::new();
|
||||
let mut cmd_words: Vec<String> = Vec::new();
|
||||
let mut has_session_flag = false;
|
||||
let mut i = 0;
|
||||
while i < words.len() {
|
||||
if ALLOWED_GLOBAL_FLAGS.contains(&words[i].as_str()) {
|
||||
if words[i] == "--session" {
|
||||
has_session_flag = true;
|
||||
}
|
||||
global_flags.push(words[i].clone());
|
||||
if i + 1 < words.len() {
|
||||
global_flags.push(words[i + 1].clone());
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
cmd_words.push(words[i].clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let first_cmd = cmd_words.first().map(|s| s.as_str()).unwrap_or("");
|
||||
if !ALLOWED_COMMANDS.contains(&first_cmd) {
|
||||
return format!(
|
||||
"Blocked: '{}' is not a valid chrome-use command.",
|
||||
first_cmd
|
||||
);
|
||||
}
|
||||
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
if !has_session_flag {
|
||||
args.push("--session".into());
|
||||
args.push(session.into());
|
||||
}
|
||||
args.extend(global_flags);
|
||||
args.extend(cmd_words);
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&exe);
|
||||
cmd.args(&args)
|
||||
.env_remove("AGENT_BROWSER_DASHBOARD")
|
||||
.env_remove("AGENT_BROWSER_DASHBOARD_PORT")
|
||||
.env_remove("AGENT_BROWSER_STREAM_PORT");
|
||||
|
||||
match cmd.output().await {
|
||||
Ok(output) => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if stdout.is_empty() && !stderr.is_empty() {
|
||||
stderr
|
||||
} else if stdout.is_empty() {
|
||||
"Command completed with no output.".to_string()
|
||||
} else {
|
||||
stdout
|
||||
}
|
||||
}
|
||||
Err(e) => format!("Failed to execute command: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_gateway_response(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
gw_response: reqwest::Response,
|
||||
) -> Vec<(String, String, String)> {
|
||||
use futures_util::StreamExt as _;
|
||||
|
||||
let mut text_part_id = uuid::Uuid::new_v4().to_string();
|
||||
let mut text_started = false;
|
||||
let mut tool_calls: Vec<(String, String, String)> = Vec::new();
|
||||
let mut tool_call_args: std::collections::HashMap<usize, (String, String, String)> =
|
||||
std::collections::HashMap::new();
|
||||
let mut byte_stream = gw_response.bytes_stream();
|
||||
let mut buffer = String::new();
|
||||
|
||||
while let Some(chunk_result) = byte_stream.next().await {
|
||||
let chunk = match chunk_result {
|
||||
Ok(c) => c,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
while let Some(newline_pos) = buffer.find('\n') {
|
||||
let line = buffer[..newline_pos].trim_end_matches('\r').to_string();
|
||||
buffer = buffer[newline_pos + 1..].to_string();
|
||||
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(data) = line.strip_prefix("data: ") else {
|
||||
continue;
|
||||
};
|
||||
if data == "[DONE]" {
|
||||
if text_started {
|
||||
let ev = format!("data: {}\n\n", json!({"type":"text-end","id":text_part_id}));
|
||||
let _ = stream.write_all(ev.as_bytes()).await;
|
||||
}
|
||||
let mut indices: Vec<usize> = tool_call_args.keys().copied().collect();
|
||||
indices.sort();
|
||||
for idx in indices {
|
||||
if let Some(tc) = tool_call_args.remove(&idx) {
|
||||
tool_calls.push(tc);
|
||||
}
|
||||
}
|
||||
return tool_calls;
|
||||
}
|
||||
let Ok(sse_json) = serde_json::from_str::<Value>(data) else {
|
||||
continue;
|
||||
};
|
||||
let delta = sse_json
|
||||
.get("choices")
|
||||
.and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("delta"));
|
||||
let Some(delta) = delta else { continue };
|
||||
|
||||
if let Some(text) = delta.get("content").and_then(|c| c.as_str()) {
|
||||
if !text.is_empty() {
|
||||
if !text_started {
|
||||
let ev = format!(
|
||||
"data: {}\n\n",
|
||||
json!({"type":"text-start","id":text_part_id})
|
||||
);
|
||||
if stream.write_all(ev.as_bytes()).await.is_err() {
|
||||
return tool_calls;
|
||||
}
|
||||
text_started = true;
|
||||
}
|
||||
let ev = format!(
|
||||
"data: {}\n\n",
|
||||
json!({"type":"text-delta","id":text_part_id,"delta":text})
|
||||
);
|
||||
if stream.write_all(ev.as_bytes()).await.is_err() {
|
||||
return tool_calls;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tcs) = delta.get("tool_calls").and_then(|t| t.as_array()) {
|
||||
if text_started {
|
||||
let ev = format!("data: {}\n\n", json!({"type":"text-end","id":text_part_id}));
|
||||
let _ = stream.write_all(ev.as_bytes()).await;
|
||||
text_started = false;
|
||||
text_part_id = uuid::Uuid::new_v4().to_string();
|
||||
}
|
||||
|
||||
for tc in tcs {
|
||||
let idx = tc.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = tool_call_args.entry(idx)
|
||||
{
|
||||
let id = tc
|
||||
.get("id")
|
||||
.and_then(|i| i.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let name = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let ev = format!(
|
||||
"data: {}\n\n",
|
||||
json!({"type":"tool-input-start","toolCallId":id,"toolName":name})
|
||||
);
|
||||
let _ = stream.write_all(ev.as_bytes()).await;
|
||||
e.insert((id, name, String::new()));
|
||||
}
|
||||
if let Some(arg_delta) = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("arguments"))
|
||||
.and_then(|a| a.as_str())
|
||||
{
|
||||
let entry = tool_call_args.get_mut(&idx).unwrap();
|
||||
entry.2.push_str(arg_delta);
|
||||
let ev = format!(
|
||||
"data: {}\n\n",
|
||||
json!({"type":"tool-input-delta","toolCallId":entry.0,"inputTextDelta":arg_delta})
|
||||
);
|
||||
let _ = stream.write_all(ev.as_bytes()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if text_started {
|
||||
let ev = format!("data: {}\n\n", json!({"type":"text-end","id":text_part_id}));
|
||||
let _ = stream.write_all(ev.as_bytes()).await;
|
||||
}
|
||||
let mut indices: Vec<usize> = tool_call_args.keys().copied().collect();
|
||||
indices.sort();
|
||||
for idx in indices {
|
||||
if let Some(tc) = tool_call_args.remove(&idx) {
|
||||
tool_calls.push(tc);
|
||||
}
|
||||
}
|
||||
tool_calls
|
||||
}
|
||||
|
||||
pub(super) async fn handle_chat_request(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
body: &str,
|
||||
origin: Option<&str>,
|
||||
) {
|
||||
let cors = cors_headers_for_origin(origin);
|
||||
let gateway_url = std::env::var("AI_GATEWAY_URL")
|
||||
.unwrap_or_else(|_| DEFAULT_AI_GATEWAY_URL.to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let api_key = match std::env::var("AI_GATEWAY_API_KEY") {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
let err = r#"{"error":"AI_GATEWAY_API_KEY not set. Set the AI_GATEWAY_API_KEY environment variable to enable AI chat."}"#;
|
||||
let resp = format!(
|
||||
"HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{cors}\r\n",
|
||||
err.len()
|
||||
);
|
||||
let _ = stream.write_all(resp.as_bytes()).await;
|
||||
let _ = stream.write_all(err.as_bytes()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let default_model = std::env::var("AI_GATEWAY_MODEL")
|
||||
.unwrap_or_else(|_| "anthropic/claude-sonnet-4.6".to_string());
|
||||
|
||||
let parsed: Value = match serde_json::from_str(body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let err = format!(r#"{{"error":"Invalid JSON: {}"}}"#, e);
|
||||
let resp = format!(
|
||||
"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{cors}\r\n",
|
||||
err.len()
|
||||
);
|
||||
let _ = stream.write_all(resp.as_bytes()).await;
|
||||
let _ = stream.write_all(err.as_bytes()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let messages = parsed.get("messages").cloned().unwrap_or(json!([]));
|
||||
let model = parsed
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&default_model)
|
||||
.to_string();
|
||||
let session = parsed
|
||||
.get("session")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("default")
|
||||
.to_string();
|
||||
|
||||
let mut openai_messages: Vec<Value> =
|
||||
vec![json!({"role": "system", "content": get_system_prompt()})];
|
||||
let mut frontend_boundaries: Vec<usize> = Vec::new();
|
||||
let frontend_arr = messages.as_array();
|
||||
let frontend_count = frontend_arr.map(|a| a.len()).unwrap_or(0);
|
||||
if let Some(arr) = frontend_arr {
|
||||
for msg in arr {
|
||||
frontend_boundaries.push(openai_messages.len());
|
||||
let Some(role) = msg.get("role").and_then(|r| r.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(parts) = msg.get("parts").and_then(|p| p.as_array()) {
|
||||
let mut content_parts: Vec<Value> = Vec::new();
|
||||
for part in parts {
|
||||
match part.get("type").and_then(|t| t.as_str()) {
|
||||
Some("text") => {
|
||||
if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
|
||||
if !text.is_empty() {
|
||||
content_parts.push(json!({"type": "text", "text": text}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("file") => {
|
||||
if let (Some(url), Some(media_type)) = (
|
||||
part.get("url").and_then(|u| u.as_str()),
|
||||
part.get("mediaType").and_then(|m| m.as_str()),
|
||||
) {
|
||||
if media_type.starts_with("image/") {
|
||||
content_parts.push(json!({
|
||||
"type": "image_url",
|
||||
"image_url": { "url": url }
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !content_parts.is_empty() {
|
||||
let content = if content_parts.len() == 1
|
||||
&& content_parts[0].get("type").and_then(|t| t.as_str()) == Some("text")
|
||||
{
|
||||
content_parts[0]["text"].clone()
|
||||
} else {
|
||||
json!(content_parts)
|
||||
};
|
||||
openai_messages.push(json!({"role": role, "content": content}));
|
||||
}
|
||||
} else if let Some(content) = msg.get("content").and_then(|c| c.as_str()) {
|
||||
openai_messages.push(json!({"role": role, "content": content}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tools: Value = serde_json::from_str(CHAT_TOOLS).unwrap();
|
||||
let url = format!("{}/v1/chat/completions", gateway_url);
|
||||
let client = http_client();
|
||||
|
||||
let total_chars = estimate_chars(&openai_messages);
|
||||
let mut compaction_summary: Option<String> = None;
|
||||
let mut compaction_failed = false;
|
||||
let mut keep_last_n: usize = frontend_count;
|
||||
|
||||
if total_chars > COMPACT_THRESHOLD_CHARS && openai_messages.len() > KEEP_RECENT_MESSAGES + 2 {
|
||||
let split = find_safe_split(&openai_messages, KEEP_RECENT_MESSAGES);
|
||||
let to_summarize = &openai_messages[1..split];
|
||||
|
||||
if let Some(summary) =
|
||||
summarize_for_compaction(client, &url, &api_key, &model, to_summarize).await
|
||||
{
|
||||
let summary_msg = json!({
|
||||
"role": "system",
|
||||
"content": format!("[Conversation summary]\n{}", summary)
|
||||
});
|
||||
let recent = openai_messages[split..].to_vec();
|
||||
openai_messages = vec![openai_messages[0].clone(), summary_msg];
|
||||
openai_messages.extend(recent);
|
||||
|
||||
let kept_frontend = frontend_boundaries
|
||||
.iter()
|
||||
.filter(|&&boundary| boundary >= split)
|
||||
.count();
|
||||
keep_last_n = kept_frontend;
|
||||
compaction_summary = Some(summary);
|
||||
} else {
|
||||
compaction_failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
let headers = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\nx-vercel-ai-ui-message-stream: v1\r\n{cors}\r\n"
|
||||
);
|
||||
if stream.write_all(headers.as_bytes()).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let message_id = uuid::Uuid::new_v4().to_string();
|
||||
let start_ev = format!(
|
||||
"data: {}\n\n",
|
||||
json!({"type":"start","messageId":message_id})
|
||||
);
|
||||
if stream.write_all(start_ev.as_bytes()).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(ref summary) = compaction_summary {
|
||||
let ev = format!(
|
||||
"data: {}\n\n",
|
||||
json!({
|
||||
"type": "message-metadata",
|
||||
"messageMetadata": {
|
||||
"compacted": true,
|
||||
"summary": summary,
|
||||
"keepLastN": keep_last_n
|
||||
}
|
||||
})
|
||||
);
|
||||
let _ = stream.write_all(ev.as_bytes()).await;
|
||||
} else if compaction_failed {
|
||||
let ev = format!(
|
||||
"data: {}\n\n",
|
||||
json!({
|
||||
"type": "message-metadata",
|
||||
"messageMetadata": {
|
||||
"compacted": false,
|
||||
"warning": "Conversation is large but compaction failed. Responses may be degraded."
|
||||
}
|
||||
})
|
||||
);
|
||||
let _ = stream.write_all(ev.as_bytes()).await;
|
||||
}
|
||||
|
||||
let total_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(300);
|
||||
const TOOL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
for _step in 0..50 {
|
||||
if tokio::time::Instant::now() >= total_deadline {
|
||||
let ev = format!(
|
||||
"data: {}\n\n",
|
||||
json!({"type":"error","errorText":"Chat session timed out (5 minute limit)."})
|
||||
);
|
||||
let _ = stream.write_all(ev.as_bytes()).await;
|
||||
break;
|
||||
}
|
||||
|
||||
let step_ev = "data: {\"type\":\"start-step\"}\n\n";
|
||||
if stream.write_all(step_ev.as_bytes()).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let gateway_body = json!({
|
||||
"model": model,
|
||||
"messages": openai_messages,
|
||||
"tools": tools,
|
||||
"stream": true,
|
||||
});
|
||||
|
||||
let gw_response = match client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.body(gateway_body.to_string())
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let ev = format!(
|
||||
"data: {}\n\n",
|
||||
json!({"type":"error","errorText":format!("Gateway request failed: {}", e)})
|
||||
);
|
||||
let _ = stream.write_all(ev.as_bytes()).await;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if !gw_response.status().is_success() {
|
||||
let body_text = gw_response.text().await.unwrap_or_default();
|
||||
let ev = format!(
|
||||
"data: {}\n\n",
|
||||
json!({"type":"error","errorText":body_text})
|
||||
);
|
||||
let _ = stream.write_all(ev.as_bytes()).await;
|
||||
break;
|
||||
}
|
||||
|
||||
let tool_calls = stream_gateway_response(stream, gw_response).await;
|
||||
|
||||
if tool_calls.is_empty() {
|
||||
let finish_step_ev = "data: {\"type\":\"finish-step\"}\n\n";
|
||||
let _ = stream.write_all(finish_step_ev.as_bytes()).await;
|
||||
break;
|
||||
}
|
||||
|
||||
let tc_values: Vec<Value> = tool_calls.iter().map(|(id, name, args)| {
|
||||
json!({"id": id, "type": "function", "function": {"name": name, "arguments": args}})
|
||||
}).collect();
|
||||
openai_messages.push(json!({"role": "assistant", "tool_calls": tc_values}));
|
||||
|
||||
for (tc_id, tc_name, tc_args) in &tool_calls {
|
||||
let input: Value = serde_json::from_str(tc_args).unwrap_or(json!({}));
|
||||
let command = input.get("command").and_then(|c| c.as_str()).unwrap_or("");
|
||||
|
||||
let ev = format!(
|
||||
"data: {}\n\n",
|
||||
json!({
|
||||
"type": "tool-input-available",
|
||||
"toolCallId": tc_id,
|
||||
"toolName": tc_name,
|
||||
"input": input
|
||||
})
|
||||
);
|
||||
let _ = stream.write_all(ev.as_bytes()).await;
|
||||
|
||||
let result = match tokio::time::timeout(
|
||||
TOOL_TIMEOUT,
|
||||
execute_chat_tool(&session, command),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(_) => "Tool execution timed out after 60 seconds.".to_string(),
|
||||
};
|
||||
|
||||
let frontend_output = enrich_tool_output(&result);
|
||||
let ev = format!(
|
||||
"data: {}\n\n",
|
||||
json!({
|
||||
"type": "tool-output-available",
|
||||
"toolCallId": tc_id,
|
||||
"output": frontend_output
|
||||
})
|
||||
);
|
||||
let _ = stream.write_all(ev.as_bytes()).await;
|
||||
|
||||
openai_messages.push(json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc_id,
|
||||
"content": result
|
||||
}));
|
||||
}
|
||||
|
||||
let finish_step_ev = "data: {\"type\":\"finish-step\"}\n\n";
|
||||
let _ = stream.write_all(finish_step_ev.as_bytes()).await;
|
||||
}
|
||||
|
||||
let finish_ev = "data: {\"type\":\"finish\"}\n\n";
|
||||
let _ = stream.write_all(finish_ev.as_bytes()).await;
|
||||
let done_ev = "data: [DONE]\n\n";
|
||||
let _ = stream.write_all(done_ev.as_bytes()).await;
|
||||
}
|
||||
@@ -0,0 +1,960 @@
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use crate::connection::get_socket_dir;
|
||||
|
||||
use super::chat::{chat_status_json, handle_chat_request, handle_models_request};
|
||||
use super::discovery::discover_sessions;
|
||||
use super::http::{serve_embedded_file, CORS_HEADERS};
|
||||
|
||||
/// Dashboard same-origin proxy endpoints for session metadata and streams.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SessionProxyEndpoint {
|
||||
Tabs,
|
||||
Status,
|
||||
Stream,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct DashboardProxyError {
|
||||
status: &'static str,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl DashboardProxyError {
|
||||
fn not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: "404 Not Found",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn bad_gateway(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: "502 Bad Gateway",
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PROXY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
const PROXY_MAX_RESPONSE_SIZE: u64 = 16 * 1024 * 1024;
|
||||
|
||||
fn build_json_error_body(error: &str) -> String {
|
||||
let escaped = serde_json::to_string(error).unwrap_or_else(|_| format!("\"{}\"", error));
|
||||
format!(r#"{{"success":false,"error":{escaped}}}"#)
|
||||
}
|
||||
|
||||
async fn write_http_response_inner(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
status: &str,
|
||||
content_type: &str,
|
||||
body: &[u8],
|
||||
include_cors: bool,
|
||||
) {
|
||||
let cors_headers = if include_cors { CORS_HEADERS } else { "" };
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n{cors_headers}\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.write_all(body).await;
|
||||
}
|
||||
|
||||
async fn write_http_response(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
status: &str,
|
||||
content_type: &str,
|
||||
body: &[u8],
|
||||
) {
|
||||
write_http_response_inner(stream, status, content_type, body, true).await;
|
||||
}
|
||||
|
||||
async fn write_http_response_no_cors(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
status: &str,
|
||||
content_type: &str,
|
||||
body: &[u8],
|
||||
) {
|
||||
write_http_response_inner(stream, status, content_type, body, false).await;
|
||||
}
|
||||
|
||||
async fn write_json_error_response_no_cors(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
status: &'static str,
|
||||
error: &str,
|
||||
) {
|
||||
let body = build_json_error_body(error);
|
||||
write_http_response_no_cors(
|
||||
stream,
|
||||
status,
|
||||
"application/json; charset=utf-8",
|
||||
body.as_bytes(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn parse_request_method_and_path(request: &str) -> (&str, &str) {
|
||||
let first_line = request.lines().next().unwrap_or("");
|
||||
let method = first_line.split_whitespace().next().unwrap_or("GET");
|
||||
let path = first_line.split_whitespace().nth(1).unwrap_or("/");
|
||||
(method, path)
|
||||
}
|
||||
|
||||
fn is_websocket_upgrade(request: &str) -> bool {
|
||||
request.lines().any(|line| {
|
||||
if let Some((name, value)) = line.split_once(':') {
|
||||
name.trim().eq_ignore_ascii_case("upgrade")
|
||||
&& value.trim().eq_ignore_ascii_case("websocket")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
|
||||
request.lines().find_map(|line| {
|
||||
let (header_name, value) = line.split_once(':')?;
|
||||
if header_name.trim().eq_ignore_ascii_case(name) {
|
||||
Some(value.trim())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_origin_authority(origin: &str) -> Option<String> {
|
||||
let url = url::Url::parse(origin).ok()?;
|
||||
let host = url.host_str()?.to_ascii_lowercase();
|
||||
let host = if host.contains(':') {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
host
|
||||
};
|
||||
Some(match url.port() {
|
||||
Some(port) => format!("{host}:{port}"),
|
||||
None => host,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_host_authority(host: &str) -> String {
|
||||
let host = host.trim().to_ascii_lowercase();
|
||||
|
||||
if let Some(bracket_end) = host.rfind(']') {
|
||||
if bracket_end == host.len() - 1 {
|
||||
return host;
|
||||
}
|
||||
|
||||
if host.as_bytes().get(bracket_end + 1) == Some(&b':') {
|
||||
let port = &host[bracket_end + 2..];
|
||||
if port == "80" || port == "443" {
|
||||
return host[..=bracket_end].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
if let Some((name, port)) = host.rsplit_once(':') {
|
||||
if !name.contains(':') && (port == "80" || port == "443") {
|
||||
return name.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
host
|
||||
}
|
||||
|
||||
fn header_matches_host(request: &str, header_name: &str) -> Option<bool> {
|
||||
let authority =
|
||||
request_header_value(request, header_name).and_then(normalize_origin_authority)?;
|
||||
let host = request_header_value(request, "host").map(normalize_host_authority)?;
|
||||
Some(authority == host)
|
||||
}
|
||||
|
||||
/// Validates that a proxied WebSocket request either has no Origin header or
|
||||
/// presents an Origin whose authority matches the request Host header.
|
||||
fn is_same_origin_ws_request(request: &str) -> bool {
|
||||
match header_matches_host(request, "origin") {
|
||||
Some(matches) => matches,
|
||||
None => request_header_value(request, "origin").is_none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that an HTTP session-proxy request came from a same-origin page.
|
||||
///
|
||||
/// For GET requests we require either a same-origin `Origin` or a same-origin
|
||||
/// `Referer` so browsers cannot hit the proxy routes via side-channel tags or
|
||||
/// arbitrary cross-origin fetches.
|
||||
fn is_same_origin_http_request(request: &str) -> bool {
|
||||
matches!(header_matches_host(request, "origin"), Some(true))
|
||||
|| matches!(header_matches_host(request, "referer"), Some(true))
|
||||
}
|
||||
|
||||
/// Parse a dashboard route of the form `/api/session/<port>/<endpoint>`.
|
||||
fn parse_session_proxy_route(path: &str) -> Result<(u16, SessionProxyEndpoint), &'static str> {
|
||||
if !path.starts_with("/api/session/") {
|
||||
return Err("Invalid session proxy route.");
|
||||
}
|
||||
|
||||
let mut parts = path.split('/');
|
||||
if parts.next() != Some("") || parts.next() != Some("api") || parts.next() != Some("session") {
|
||||
return Err("Invalid session proxy route.");
|
||||
}
|
||||
|
||||
let port_str = parts.next().ok_or("Missing session proxy port.")?;
|
||||
if port_str.is_empty() {
|
||||
return Err("Missing session proxy port.");
|
||||
}
|
||||
|
||||
let endpoint = match parts.next().ok_or("Missing session proxy endpoint.")? {
|
||||
"tabs" => SessionProxyEndpoint::Tabs,
|
||||
"status" => SessionProxyEndpoint::Status,
|
||||
"stream" => SessionProxyEndpoint::Stream,
|
||||
_ => return Err("Unknown session proxy endpoint."),
|
||||
};
|
||||
|
||||
if parts.next().is_some() {
|
||||
return Err("Unexpected path segments in session proxy route.");
|
||||
}
|
||||
|
||||
let port = port_str
|
||||
.parse::<u16>()
|
||||
.map_err(|_| "Session proxy port must be a valid TCP port.")?;
|
||||
if port == 0 {
|
||||
return Err("Session proxy port must be a valid TCP port.");
|
||||
}
|
||||
|
||||
Ok((port, endpoint))
|
||||
}
|
||||
|
||||
fn sessions_json_has_active_port(sessions_json: &str, port: u16) -> Result<bool, String> {
|
||||
let sessions: Vec<Value> = serde_json::from_str(sessions_json)
|
||||
.map_err(|e| format!("Failed to parse active sessions: {e}"))?;
|
||||
Ok(sessions.iter().any(|session| {
|
||||
session
|
||||
.get("port")
|
||||
.and_then(|value| value.as_u64())
|
||||
.map(|value| value == u64::from(port))
|
||||
.unwrap_or(false)
|
||||
}))
|
||||
}
|
||||
|
||||
fn require_active_session_port(port: u16) -> Result<(), DashboardProxyError> {
|
||||
let sessions_json = discover_sessions();
|
||||
let is_active = sessions_json_has_active_port(&sessions_json, port)
|
||||
.map_err(DashboardProxyError::bad_gateway)?;
|
||||
if is_active {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DashboardProxyError::not_found(format!(
|
||||
"No active session is listening on port {port}."
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn split_http_response(response: &[u8]) -> Result<(&[u8], &[u8]), String> {
|
||||
if let Some(header_end) = response.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
let body_start = header_end + 4;
|
||||
return Ok((&response[..header_end], &response[body_start..]));
|
||||
}
|
||||
|
||||
if let Some(header_end) = response.windows(2).position(|window| window == b"\n\n") {
|
||||
let body_start = header_end + 2;
|
||||
return Ok((&response[..header_end], &response[body_start..]));
|
||||
}
|
||||
|
||||
Err("Upstream response was missing an HTTP header terminator.".to_string())
|
||||
}
|
||||
|
||||
fn parse_upstream_http_response(response: &[u8]) -> Result<(String, String, Vec<u8>), String> {
|
||||
let (header_bytes, body) = split_http_response(response)?;
|
||||
let header_str = std::str::from_utf8(header_bytes)
|
||||
.map_err(|e| format!("Upstream response headers were not valid UTF-8: {e}"))?;
|
||||
|
||||
let mut lines = header_str.lines();
|
||||
let status_line = lines
|
||||
.next()
|
||||
.ok_or_else(|| "Upstream response was missing a status line.".to_string())?;
|
||||
let status = status_line
|
||||
.split_once(' ')
|
||||
.map(|(_, status)| status.trim().to_string())
|
||||
.filter(|status| !status.is_empty())
|
||||
.ok_or_else(|| "Upstream response status line was malformed.".to_string())?;
|
||||
let content_type = lines
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
if name.trim().eq_ignore_ascii_case("content-type") {
|
||||
Some(value.trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "application/json; charset=utf-8".to_string());
|
||||
|
||||
Ok((status, content_type, body.to_vec()))
|
||||
}
|
||||
|
||||
/// Proxy dashboard-origin HTTP requests for session tabs or status to the loopback session server.
|
||||
async fn proxy_session_http_route(
|
||||
port: u16,
|
||||
endpoint: SessionProxyEndpoint,
|
||||
) -> Result<(String, String, Vec<u8>), DashboardProxyError> {
|
||||
debug_assert!(matches!(
|
||||
endpoint,
|
||||
SessionProxyEndpoint::Tabs | SessionProxyEndpoint::Status
|
||||
));
|
||||
|
||||
require_active_session_port(port)?;
|
||||
|
||||
let upstream_path = match endpoint {
|
||||
SessionProxyEndpoint::Tabs => "/api/tabs",
|
||||
SessionProxyEndpoint::Status => "/api/status",
|
||||
SessionProxyEndpoint::Stream => unreachable!("stream routes use the WebSocket proxy"),
|
||||
};
|
||||
let request = format!(
|
||||
"GET {upstream_path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
|
||||
tokio::time::timeout(PROXY_TIMEOUT, async {
|
||||
let mut upstream = tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DashboardProxyError::bad_gateway(format!(
|
||||
"Failed to connect to session {port}: {e}"
|
||||
))
|
||||
})?;
|
||||
upstream.write_all(request.as_bytes()).await.map_err(|e| {
|
||||
DashboardProxyError::bad_gateway(format!(
|
||||
"Failed to proxy request to session {port}: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
(&mut upstream)
|
||||
.take(PROXY_MAX_RESPONSE_SIZE + 1)
|
||||
.read_to_end(&mut response)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DashboardProxyError::bad_gateway(format!(
|
||||
"Failed to read session {port} response: {e}"
|
||||
))
|
||||
})?;
|
||||
if response.len() as u64 > PROXY_MAX_RESPONSE_SIZE {
|
||||
return Err(DashboardProxyError::bad_gateway(format!(
|
||||
"Session {port} response exceeded {PROXY_MAX_RESPONSE_SIZE} bytes."
|
||||
)));
|
||||
}
|
||||
|
||||
parse_upstream_http_response(&response).map_err(DashboardProxyError::bad_gateway)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
DashboardProxyError::bad_gateway(format!(
|
||||
"Session {port} proxy request timed out after {}s.",
|
||||
PROXY_TIMEOUT.as_secs()
|
||||
))
|
||||
})?
|
||||
}
|
||||
|
||||
/// Bridge a dashboard-origin WebSocket upgrade to the loopback session stream.
|
||||
async fn proxy_session_stream(mut stream: tokio::net::TcpStream, port: u16) {
|
||||
let upstream_url = format!("ws://127.0.0.1:{port}");
|
||||
let (upstream_ws, _) = match tokio_tungstenite::connect_async(&upstream_url).await {
|
||||
Ok(ws) => ws,
|
||||
Err(error) => {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"502 Bad Gateway",
|
||||
&format!("Failed to connect to session {port}: {error}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let client_ws = match tokio_tungstenite::accept_async(stream).await {
|
||||
Ok(ws) => ws,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let (mut client_tx, mut client_rx) = client_ws.split();
|
||||
let (mut upstream_tx, mut upstream_rx) = upstream_ws.split();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
message = client_rx.next() => {
|
||||
match message {
|
||||
Some(Ok(message)) => {
|
||||
let is_close = matches!(message, Message::Close(_));
|
||||
if upstream_tx.send(message).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if is_close {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(_)) | None => {
|
||||
let _ = upstream_tx.send(Message::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
message = upstream_rx.next() => {
|
||||
match message {
|
||||
Some(Ok(message)) => {
|
||||
let is_close = matches!(message, Message::Close(_));
|
||||
if client_tx.send(message).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if is_close {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Err(_)) | None => {
|
||||
let _ = client_tx.send(Message::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_dashboard_server(port: u16) {
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
let listener = match TcpListener::bind(&addr).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to bind dashboard server on {}: {}", addr, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
let Ok((stream, _addr)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
handle_dashboard_connection(stream).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_dashboard_connection(mut stream: tokio::net::TcpStream) {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let peeked_len = match stream.peek(&mut buf).await {
|
||||
Ok(n) if n > 0 => n,
|
||||
_ => return,
|
||||
};
|
||||
let peeked_request = String::from_utf8_lossy(&buf[..peeked_len]);
|
||||
let (peeked_method, peeked_path) = parse_request_method_and_path(&peeked_request);
|
||||
|
||||
if peeked_path.starts_with("/api/session/") {
|
||||
let (port, endpoint) = match parse_session_proxy_route(peeked_path) {
|
||||
Ok(route) => route,
|
||||
Err(error) => {
|
||||
write_json_error_response_no_cors(&mut stream, "400 Bad Request", error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match endpoint {
|
||||
SessionProxyEndpoint::Stream => {
|
||||
if peeked_method != "GET" {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"400 Bad Request",
|
||||
"Session stream proxy only supports GET WebSocket upgrades.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if !is_websocket_upgrade(&peeked_request) {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"400 Bad Request",
|
||||
"Session stream proxy requires a WebSocket upgrade request.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if !is_same_origin_ws_request(&peeked_request) {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"403 Forbidden",
|
||||
"Origin does not match Host header.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if let Err(error) = require_active_session_port(port) {
|
||||
write_json_error_response_no_cors(&mut stream, error.status, &error.message)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
proxy_session_stream(stream, port).await;
|
||||
return;
|
||||
}
|
||||
SessionProxyEndpoint::Tabs | SessionProxyEndpoint::Status => {
|
||||
if peeked_method != "GET" {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"400 Bad Request",
|
||||
"Session proxy routes only support GET requests.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let n = match stream.read(&mut buf).await {
|
||||
Ok(n) if n > 0 => n,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let request = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
let (method, path) = parse_request_method_and_path(&request);
|
||||
let origin = request_header_value(&request, "origin").map(|value| value.to_string());
|
||||
|
||||
if method == "OPTIONS" {
|
||||
let response = format!(
|
||||
"HTTP/1.1 204 No Content\r\n{CORS_HEADERS}Access-Control-Max-Age: 86400\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
return;
|
||||
}
|
||||
|
||||
if method == "POST" && path == "/api/chat" {
|
||||
let body_str = read_post_body(&mut stream, &buf, n).await;
|
||||
handle_chat_request(&mut stream, &body_str, origin.as_deref()).await;
|
||||
return;
|
||||
}
|
||||
|
||||
if method == "GET" && path == "/api/models" {
|
||||
handle_models_request(&mut stream, origin.as_deref()).await;
|
||||
return;
|
||||
}
|
||||
|
||||
if method == "POST" && (path == "/api/sessions" || path == "/api/exec" || path == "/api/kill") {
|
||||
let body_str = read_post_body(&mut stream, &buf, n).await;
|
||||
let result = if path == "/api/exec" {
|
||||
exec_cli(&body_str).await
|
||||
} else if path == "/api/kill" {
|
||||
kill_session(&body_str).await
|
||||
} else {
|
||||
spawn_session(&body_str).await
|
||||
};
|
||||
let (status, resp_body) = match result {
|
||||
Ok(msg) => ("200 OK", msg),
|
||||
Err(e) => ("400 Bad Request", build_json_error_body(&e)),
|
||||
};
|
||||
write_http_response(
|
||||
&mut stream,
|
||||
status,
|
||||
"application/json; charset=utf-8",
|
||||
resp_body.as_bytes(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
if path.starts_with("/api/session/") {
|
||||
let (port, endpoint) = match parse_session_proxy_route(path) {
|
||||
Ok(route) => route,
|
||||
Err(error) => {
|
||||
write_json_error_response_no_cors(&mut stream, "400 Bad Request", error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match endpoint {
|
||||
SessionProxyEndpoint::Tabs | SessionProxyEndpoint::Status => {
|
||||
if !is_same_origin_http_request(&request) {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"403 Forbidden",
|
||||
"Origin or Referer does not match Host header.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
match proxy_session_http_route(port, endpoint).await {
|
||||
Ok((status, content_type, body)) => {
|
||||
write_http_response_no_cors(&mut stream, &status, &content_type, &body)
|
||||
.await;
|
||||
}
|
||||
Err(error) => {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
error.status,
|
||||
&error.message,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
SessionProxyEndpoint::Stream => {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"400 Bad Request",
|
||||
"Session stream proxy requires a WebSocket upgrade request.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (status, content_type, body): (&str, &str, Vec<u8>) = if path == "/api/sessions" {
|
||||
(
|
||||
"200 OK",
|
||||
"application/json; charset=utf-8",
|
||||
discover_sessions().into_bytes(),
|
||||
)
|
||||
} else if path == "/api/chat/status" {
|
||||
(
|
||||
"200 OK",
|
||||
"application/json; charset=utf-8",
|
||||
chat_status_json().into_bytes(),
|
||||
)
|
||||
} else {
|
||||
serve_embedded_file(path)
|
||||
};
|
||||
|
||||
write_http_response(&mut stream, status, content_type, &body).await;
|
||||
}
|
||||
|
||||
async fn read_post_body(stream: &mut tokio::net::TcpStream, initial: &[u8], n: usize) -> String {
|
||||
let header_end = initial[..n]
|
||||
.windows(4)
|
||||
.position(|w| w == b"\r\n\r\n")
|
||||
.map(|p| p + 4)
|
||||
.or_else(|| {
|
||||
initial[..n]
|
||||
.windows(2)
|
||||
.position(|w| w == b"\n\n")
|
||||
.map(|p| p + 2)
|
||||
});
|
||||
let Some(header_end) = header_end else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let header_str = String::from_utf8_lossy(&initial[..header_end]);
|
||||
let content_length: usize = header_str
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
if l.len() > 16 && l[..16].eq_ignore_ascii_case("content-length: ") {
|
||||
l[16..].trim().parse::<usize>().ok()
|
||||
} else {
|
||||
let lower = l.to_lowercase();
|
||||
lower
|
||||
.strip_prefix("content-length:")
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
}
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
if content_length == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let read_body = &initial[header_end..n];
|
||||
let already_read = read_body.len().min(content_length);
|
||||
|
||||
let mut body = Vec::with_capacity(content_length);
|
||||
body.extend_from_slice(&read_body[..already_read]);
|
||||
|
||||
let remaining = content_length - already_read;
|
||||
if remaining > 0 {
|
||||
let mut rest = vec![0u8; remaining];
|
||||
if stream.read_exact(&mut rest).await.is_ok() {
|
||||
body.extend_from_slice(&rest);
|
||||
}
|
||||
}
|
||||
|
||||
String::from_utf8(body).unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn exec_cli(body: &str) -> Result<String, String> {
|
||||
let parsed: Value = serde_json::from_str(body).map_err(|e| format!("Invalid JSON: {}", e))?;
|
||||
let args: Vec<String> = parsed
|
||||
.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or("Missing \"args\" array")?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
if args.is_empty() {
|
||||
return Err("Empty args array".to_string());
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().map_err(|e| format!("Cannot resolve executable: {}", e))?;
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&exe);
|
||||
cmd.args(&args)
|
||||
.arg("--json")
|
||||
.env_remove("AGENT_BROWSER_DASHBOARD")
|
||||
.env_remove("AGENT_BROWSER_DASHBOARD_PORT")
|
||||
.env_remove("AGENT_BROWSER_STREAM_PORT");
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to execute: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
|
||||
Ok(json!({
|
||||
"success": output.status.success(),
|
||||
"exit_code": output.status.code(),
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn kill_session(body: &str) -> Result<String, String> {
|
||||
let parsed: Value = serde_json::from_str(body).map_err(|e| format!("Invalid JSON: {}", e))?;
|
||||
let session = parsed
|
||||
.get("session")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing \"session\" field")?;
|
||||
|
||||
if session.is_empty() || session.len() > 64 {
|
||||
return Err("Session name must be 1-64 characters".to_string());
|
||||
}
|
||||
|
||||
let dir = get_socket_dir();
|
||||
let pid_path = dir.join(format!("{}.pid", session));
|
||||
|
||||
let pid_str = std::fs::read_to_string(&pid_path)
|
||||
.map_err(|_| format!("No PID file for session '{}'", session))?;
|
||||
let pid: u32 = pid_str
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid PID in file: {}", pid_str.trim()))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY: The PID came from the daemon-managed pidfile and is only used
|
||||
// to send standard termination signals to that process.
|
||||
unsafe {
|
||||
libc::kill(pid as i32, libc::SIGTERM);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
// SAFETY: A signal value of 0 performs an existence check on the same pid.
|
||||
if unsafe { libc::kill(pid as i32, 0) } == 0 {
|
||||
// SAFETY: The process still exists after SIGTERM, so escalate to SIGKILL.
|
||||
unsafe {
|
||||
libc::kill(pid as i32, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for ext in &["pid", "sock", "stream", "engine", "extensions"] {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{}.{}", session, ext)));
|
||||
}
|
||||
|
||||
Ok(json!({ "success": true, "killed_pid": pid }).to_string())
|
||||
}
|
||||
|
||||
pub(super) async fn spawn_session(body: &str) -> Result<String, String> {
|
||||
let parsed: Value = serde_json::from_str(body).map_err(|e| format!("Invalid JSON: {}", e))?;
|
||||
let session = parsed
|
||||
.get("session")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing \"session\" field")?;
|
||||
|
||||
if session.is_empty() || session.len() > 64 {
|
||||
return Err("Session name must be 1-64 characters".to_string());
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().map_err(|e| format!("Cannot resolve executable: {}", e))?;
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&exe);
|
||||
cmd.arg("open")
|
||||
.arg("about:blank")
|
||||
.arg("--session")
|
||||
.arg(session);
|
||||
|
||||
cmd.stdout(std::process::Stdio::null());
|
||||
cmd.stderr(std::process::Stdio::null());
|
||||
|
||||
let status = cmd
|
||||
.status()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to spawn session: {}", e))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(format!(
|
||||
r#"{{"success":true,"session":{}}}"#,
|
||||
serde_json::to_string(session).unwrap_or_default()
|
||||
))
|
||||
} else {
|
||||
Err(format!("Session process exited with {}", status))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_ws_request_matching() {
|
||||
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: localhost:4848\r\nOrigin: http://localhost:4848\r\nUpgrade: websocket\r\n\r\n";
|
||||
assert!(is_same_origin_ws_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_ws_request_proxied() {
|
||||
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: dashboard.chrome-use.localhost\r\nOrigin: https://dashboard.chrome-use.localhost\r\nUpgrade: websocket\r\n\r\n";
|
||||
assert!(is_same_origin_ws_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_origin_authority_https_without_port() {
|
||||
assert_eq!(
|
||||
normalize_origin_authority("https://dashboard.chrome-use.localhost"),
|
||||
Some("dashboard.chrome-use.localhost".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_ws_request_default_https_port() {
|
||||
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: dashboard.chrome-use.localhost:443\r\nOrigin: https://dashboard.chrome-use.localhost\r\nUpgrade: websocket\r\n\r\n";
|
||||
assert!(is_same_origin_ws_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_http_request_matching_origin() {
|
||||
let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: localhost:4848\r\nOrigin: http://localhost:4848\r\n\r\n";
|
||||
assert!(is_same_origin_http_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_http_request_matching_referer() {
|
||||
let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: dashboard.chrome-use.localhost:443\r\nReferer: https://dashboard.chrome-use.localhost/sessions\r\n\r\n";
|
||||
assert!(is_same_origin_http_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_http_request_rejects_missing_origin_and_referer() {
|
||||
let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: localhost:4848\r\n\r\n";
|
||||
assert!(!is_same_origin_http_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_http_request_rejects_cross_origin_referer() {
|
||||
let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: localhost:4848\r\nReferer: https://evil.com/path\r\n\r\n";
|
||||
assert!(!is_same_origin_http_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_origin_ws_request_coder() {
|
||||
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: workspace.coder.com\r\nOrigin: https://workspace.coder.com\r\nUpgrade: websocket\r\n\r\n";
|
||||
assert!(is_same_origin_ws_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_origin_ws_request_rejected() {
|
||||
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: localhost:4848\r\nOrigin: https://evil.com\r\nUpgrade: websocket\r\n\r\n";
|
||||
assert!(!is_same_origin_ws_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_origin_header_allowed() {
|
||||
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: localhost:4848\r\nUpgrade: websocket\r\n\r\n";
|
||||
assert!(is_same_origin_ws_request(req));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_session_proxy_route_valid() {
|
||||
assert_eq!(
|
||||
parse_session_proxy_route("/api/session/9222/tabs"),
|
||||
Ok((9222, SessionProxyEndpoint::Tabs))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_session_proxy_route("/api/session/1337/status"),
|
||||
Ok((1337, SessionProxyEndpoint::Status))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_session_proxy_route("/api/session/65535/stream"),
|
||||
Ok((65535, SessionProxyEndpoint::Stream))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_session_proxy_route_invalid() {
|
||||
assert!(parse_session_proxy_route("/api/session/0/tabs").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/not-a-port/tabs").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/70000/tabs").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/9222").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/9222/unknown").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/9222/tabs/extra").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_session_proxy_route_path_traversal() {
|
||||
assert!(parse_session_proxy_route("/api/session/9222/tabs/..").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/9222/tabs/../status").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/9222/../../etc/passwd").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/../session/9222/tabs").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_session_proxy_route_double_slashes() {
|
||||
assert!(parse_session_proxy_route("/api/session//9222/tabs").is_err());
|
||||
assert!(parse_session_proxy_route("/api//session/9222/tabs").is_err());
|
||||
assert!(parse_session_proxy_route("//api/session/9222/tabs").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_session_proxy_route_trailing_slash() {
|
||||
assert!(parse_session_proxy_route("/api/session/9222/tabs/").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/9222/status/").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/9222/stream/").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_session_proxy_route_encoded_paths() {
|
||||
assert!(parse_session_proxy_route("/api/session/9222/tabs%20extra").is_err());
|
||||
assert!(parse_session_proxy_route("/api/session/%39%32%32%32/tabs").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sessions_json_has_active_port() {
|
||||
let sessions_json = r#"[
|
||||
{"session":"alpha","port":9222,"engine":"chrome"},
|
||||
{"session":"beta","port":9333,"engine":"chrome"}
|
||||
]"#;
|
||||
|
||||
assert_eq!(sessions_json_has_active_port(sessions_json, 9222), Ok(true));
|
||||
assert_eq!(
|
||||
sessions_json_has_active_port(sessions_json, 9444),
|
||||
Ok(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sessions_json_has_active_port_invalid_json() {
|
||||
assert!(sessions_json_has_active_port("{", 9222).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_upstream_http_response() {
|
||||
let response = b"HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nConnection: close\r\n\r\n{\"ok\":true}";
|
||||
let parsed = parse_upstream_http_response(response).expect("response should parse");
|
||||
|
||||
assert_eq!(parsed.0, "200 OK");
|
||||
assert_eq!(parsed.1, "application/json; charset=utf-8");
|
||||
assert_eq!(parsed.2, b"{\"ok\":true}".to_vec());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::connection::get_socket_dir;
|
||||
|
||||
pub(super) fn discover_sessions() -> String {
|
||||
let dir = get_socket_dir();
|
||||
let mut sessions = Vec::new();
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if let Some(session) = name_str.strip_suffix(".stream") {
|
||||
if let Ok(port_str) = std::fs::read_to_string(entry.path()) {
|
||||
if let Ok(port) = port_str.trim().parse::<u16>() {
|
||||
let pid_path = dir.join(format!("{}.pid", session));
|
||||
if is_process_alive(&pid_path) {
|
||||
let engine_path = dir.join(format!("{}.engine", session));
|
||||
let engine = std::fs::read_to_string(&engine_path)
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or_else(|| "chrome".to_string());
|
||||
|
||||
let provider_path = dir.join(format!("{}.provider", session));
|
||||
let provider = std::fs::read_to_string(&provider_path)
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
|
||||
let extensions = read_extensions_metadata(&dir, session);
|
||||
|
||||
let mut entry = json!({
|
||||
"session": session,
|
||||
"port": port,
|
||||
"engine": engine.trim(),
|
||||
});
|
||||
if let Some(ref p) = provider {
|
||||
entry["provider"] = json!(p.trim());
|
||||
}
|
||||
if !extensions.is_empty() {
|
||||
entry["extensions"] = json!(extensions);
|
||||
}
|
||||
sessions.push(entry);
|
||||
} else {
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::to_string(&sessions).unwrap_or_else(|_| "[]".to_string())
|
||||
}
|
||||
|
||||
fn read_extensions_metadata(dir: &std::path::Path, session: &str) -> Vec<Value> {
|
||||
let ext_path = dir.join(format!("{}.extensions", session));
|
||||
let ext_str = match std::fs::read_to_string(&ext_path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
ext_str
|
||||
.split(',')
|
||||
.map(|p| p.trim())
|
||||
.filter(|p| !p.is_empty())
|
||||
.filter_map(|path| {
|
||||
let manifest_path = std::path::Path::new(path).join("manifest.json");
|
||||
let manifest_str = std::fs::read_to_string(&manifest_path).ok()?;
|
||||
let manifest: Value = serde_json::from_str(&manifest_str).ok()?;
|
||||
|
||||
let name = manifest
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let version = manifest
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let description = manifest
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let mut ext = json!({
|
||||
"name": name,
|
||||
"version": version,
|
||||
"path": path,
|
||||
});
|
||||
if let Some(desc) = description {
|
||||
ext["description"] = json!(desc);
|
||||
}
|
||||
Some(ext)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_process_alive(pid_path: &Path) -> bool {
|
||||
let pid_str = match std::fs::read_to_string(pid_path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let pid: u32 = match pid_str.trim().parse() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return false,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe { libc::kill(pid as i32, 0) == 0 }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = pid;
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
use rust_embed::Embed;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::connection::get_socket_dir;
|
||||
#[cfg(windows)]
|
||||
use crate::connection::resolve_port;
|
||||
|
||||
use super::chat::{chat_status_json, handle_chat_request, handle_models_request};
|
||||
use super::dashboard::spawn_session;
|
||||
use super::discovery::discover_sessions;
|
||||
|
||||
#[derive(Embed)]
|
||||
#[folder = "../packages/dashboard/out/"]
|
||||
struct DashboardAssets;
|
||||
|
||||
pub(super) const CORS_HEADERS: &str = "Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\n";
|
||||
|
||||
/// Build CORS headers that reflect the request origin only when it passes
|
||||
/// `is_allowed_origin`. Used for sensitive endpoints (chat, models) so the
|
||||
/// API key is not accessible from arbitrary web pages.
|
||||
pub(super) fn cors_headers_for_origin(origin: Option<&str>) -> String {
|
||||
let allowed_origin = match origin {
|
||||
Some(o) if super::is_allowed_origin(Some(o)) => o,
|
||||
_ => "http://localhost",
|
||||
};
|
||||
format!(
|
||||
"Access-Control-Allow-Origin: {}\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\n",
|
||||
allowed_origin
|
||||
)
|
||||
}
|
||||
|
||||
fn request_headers(request: &str) -> &str {
|
||||
request
|
||||
.find("\r\n\r\n")
|
||||
.or_else(|| request.find("\n\n"))
|
||||
.map(|header_end| &request[..header_end])
|
||||
.unwrap_or(request)
|
||||
}
|
||||
|
||||
fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
|
||||
request_headers(request).lines().find_map(|line| {
|
||||
let (header_name, value) = line.split_once(':')?;
|
||||
if header_name.trim().eq_ignore_ascii_case(name) {
|
||||
Some(value.trim())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_origin(peeked: &[u8]) -> Option<String> {
|
||||
let header_str = std::str::from_utf8(peeked).ok()?;
|
||||
request_header_value(header_str, "origin").map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn normalize_origin_authority(origin: &str) -> Option<String> {
|
||||
let url = url::Url::parse(origin).ok()?;
|
||||
let host = url.host_str()?.to_ascii_lowercase();
|
||||
let host = if host.contains(':') {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
host
|
||||
};
|
||||
let default_port = (url.scheme() == "http" && url.port() == Some(80))
|
||||
|| (url.scheme() == "https" && url.port() == Some(443));
|
||||
Some(match url.port() {
|
||||
Some(port) if !default_port => format!("{host}:{port}"),
|
||||
_ => host,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_host_authority(host: &str) -> String {
|
||||
let host = host.trim().to_ascii_lowercase();
|
||||
|
||||
if let Some(bracket_end) = host.rfind(']') {
|
||||
if bracket_end == host.len() - 1 {
|
||||
return host;
|
||||
}
|
||||
|
||||
if host.as_bytes().get(bracket_end + 1) == Some(&b':') {
|
||||
let port = &host[bracket_end + 2..];
|
||||
if port == "80" || port == "443" {
|
||||
return host[..=bracket_end].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
if let Some((name, port)) = host.rsplit_once(':') {
|
||||
if !name.contains(':') && (port == "80" || port == "443") {
|
||||
return name.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
host
|
||||
}
|
||||
|
||||
fn authority_host(authority: &str) -> &str {
|
||||
if let Some(stripped) = authority.strip_prefix('[') {
|
||||
if let Some(bracket_end) = stripped.find(']') {
|
||||
return &authority[..=bracket_end + 1];
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((host, _port)) = authority.rsplit_once(':') {
|
||||
if !host.contains(':') {
|
||||
return host;
|
||||
}
|
||||
}
|
||||
|
||||
authority
|
||||
}
|
||||
|
||||
fn is_loopback_authority(authority: &str) -> bool {
|
||||
matches!(
|
||||
authority_host(authority),
|
||||
"localhost" | "127.0.0.1" | "::1" | "[::1]"
|
||||
)
|
||||
}
|
||||
|
||||
fn header_authority_matches_host(request: &str, header_name: &str) -> bool {
|
||||
let Some(authority) =
|
||||
request_header_value(request, header_name).and_then(normalize_origin_authority)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(host) = request_header_value(request, "host").map(normalize_host_authority) else {
|
||||
return false;
|
||||
};
|
||||
authority == host && is_loopback_authority(&authority) && is_loopback_authority(&host)
|
||||
}
|
||||
|
||||
/// Protects the command relay by requiring same-origin browser metadata.
|
||||
fn is_same_origin_command_request(request: &str) -> bool {
|
||||
if request_header_value(request, "origin").is_some() {
|
||||
header_authority_matches_host(request, "origin")
|
||||
} else {
|
||||
header_authority_matches_host(request, "referer")
|
||||
}
|
||||
}
|
||||
|
||||
fn command_cors_headers(request: &str) -> String {
|
||||
match request_header_value(request, "origin") {
|
||||
Some(origin) if is_same_origin_command_request(request) => format!(
|
||||
"Access-Control-Allow-Origin: {origin}\r\nAccess-Control-Allow-Methods: POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\nVary: Origin\r\n"
|
||||
),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_json_error_response_no_cors(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
status: &str,
|
||||
error: &str,
|
||||
) {
|
||||
let body = format!(
|
||||
r#"{{"success":false,"error":{}}}"#,
|
||||
serde_json::to_string(error).unwrap_or_else(|_| format!("\"{}\"", error))
|
||||
);
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.write_all(body.as_bytes()).await;
|
||||
}
|
||||
|
||||
pub(super) async fn handle_http_request(
|
||||
mut stream: tokio::net::TcpStream,
|
||||
peeked: &[u8],
|
||||
last_tabs: &Arc<RwLock<Vec<Value>>>,
|
||||
last_engine: &Arc<RwLock<String>>,
|
||||
session_name: &str,
|
||||
) {
|
||||
let peeked_len = peeked.len();
|
||||
let mut discard = vec![0u8; peeked_len];
|
||||
let _ = stream.read_exact(&mut discard).await;
|
||||
|
||||
let request = String::from_utf8_lossy(peeked);
|
||||
let first_line = request.lines().next().unwrap_or("");
|
||||
let method = first_line.split_whitespace().next().unwrap_or("GET");
|
||||
let path = first_line.split_whitespace().nth(1).unwrap_or("/");
|
||||
let origin = parse_origin(peeked);
|
||||
|
||||
if method == "OPTIONS" {
|
||||
if path == "/api/command" {
|
||||
if !is_same_origin_command_request(&request) {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"403 Forbidden",
|
||||
"Origin or Referer does not match Host header.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let cors_headers = command_cors_headers(&request);
|
||||
let response = format!(
|
||||
"HTTP/1.1 204 No Content\r\n{cors_headers}Access-Control-Max-Age: 86400\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let response = format!(
|
||||
"HTTP/1.1 204 No Content\r\n{CORS_HEADERS}Access-Control-Max-Age: 86400\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
return;
|
||||
}
|
||||
|
||||
if method == "POST" {
|
||||
if path == "/api/command" && !is_same_origin_command_request(&request) {
|
||||
write_json_error_response_no_cors(
|
||||
&mut stream,
|
||||
"403 Forbidden",
|
||||
"Origin or Referer does not match Host header.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let full_body = read_full_body(&mut stream, peeked).await;
|
||||
if full_body.is_none()
|
||||
&& (path == "/api/chat" || path == "/api/sessions" || path == "/api/command")
|
||||
{
|
||||
let body = r#"{"error":"Request body too large"}"#;
|
||||
let cors_headers = if path == "/api/command" {
|
||||
command_cors_headers(&request)
|
||||
} else {
|
||||
CORS_HEADERS.to_string()
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 413 Payload Too Large\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{cors_headers}\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.write_all(body.as_bytes()).await;
|
||||
return;
|
||||
}
|
||||
let body_str = full_body.as_deref().unwrap_or("");
|
||||
|
||||
if path == "/api/sessions" {
|
||||
let result = spawn_session(body_str).await;
|
||||
let (status, resp_body) = match result {
|
||||
Ok(msg) => ("200 OK", msg),
|
||||
Err(e) => (
|
||||
"400 Bad Request",
|
||||
format!(
|
||||
r#"{{"success":false,"error":{}}}"#,
|
||||
serde_json::to_string(&e).unwrap_or_else(|_| format!("\"{}\"", e))
|
||||
),
|
||||
),
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n",
|
||||
resp_body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.write_all(resp_body.as_bytes()).await;
|
||||
return;
|
||||
}
|
||||
|
||||
if path == "/api/command" {
|
||||
let result = relay_command_to_daemon(session_name, body_str).await;
|
||||
let (status, resp_body) = match result {
|
||||
Ok(resp) => ("200 OK", resp),
|
||||
Err(e) => (
|
||||
"502 Bad Gateway",
|
||||
format!(
|
||||
r#"{{"success":false,"error":{}}}"#,
|
||||
serde_json::to_string(&e).unwrap_or_else(|_| format!("\"{}\"", e))
|
||||
),
|
||||
),
|
||||
};
|
||||
let cors_headers = command_cors_headers(&request);
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n{cors_headers}\r\n",
|
||||
resp_body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.write_all(resp_body.as_bytes()).await;
|
||||
return;
|
||||
}
|
||||
|
||||
if path == "/api/chat" {
|
||||
handle_chat_request(&mut stream, body_str, origin.as_deref()).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if method == "GET" && path == "/api/models" {
|
||||
handle_models_request(&mut stream, origin.as_deref()).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let (status, content_type, body): (&str, &str, Vec<u8>) = if path == "/api/sessions" {
|
||||
(
|
||||
"200 OK",
|
||||
"application/json; charset=utf-8",
|
||||
discover_sessions().into_bytes(),
|
||||
)
|
||||
} else if path == "/api/tabs" {
|
||||
let tabs = last_tabs.read().await;
|
||||
(
|
||||
"200 OK",
|
||||
"application/json; charset=utf-8",
|
||||
serde_json::to_string(&*tabs)
|
||||
.unwrap_or_else(|_| "[]".to_string())
|
||||
.into_bytes(),
|
||||
)
|
||||
} else if path == "/api/status" {
|
||||
let engine = last_engine.read().await;
|
||||
(
|
||||
"200 OK",
|
||||
"application/json; charset=utf-8",
|
||||
format!(r#"{{"engine":"{}"}}"#, *engine).into_bytes(),
|
||||
)
|
||||
} else if path == "/api/chat/status" {
|
||||
(
|
||||
"200 OK",
|
||||
"application/json; charset=utf-8",
|
||||
chat_status_json().into_bytes(),
|
||||
)
|
||||
} else {
|
||||
serve_embedded_file(path)
|
||||
};
|
||||
|
||||
let response = format!(
|
||||
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n",
|
||||
status,
|
||||
content_type,
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes()).await;
|
||||
let _ = stream.write_all(&body).await;
|
||||
}
|
||||
|
||||
fn find_header_end(buf: &[u8]) -> Option<usize> {
|
||||
buf.windows(4)
|
||||
.position(|w| w == b"\r\n\r\n")
|
||||
.map(|p| p + 4)
|
||||
.or_else(|| buf.windows(2).position(|w| w == b"\n\n").map(|p| p + 2))
|
||||
}
|
||||
|
||||
fn parse_content_length_bytes(headers: &[u8]) -> Option<usize> {
|
||||
let header_str = std::str::from_utf8(headers).ok()?;
|
||||
for line in header_str.lines() {
|
||||
if line.len() > 16 && line[..16].eq_ignore_ascii_case("content-length: ") {
|
||||
return line[16..].trim().parse().ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
const MAX_BODY_SIZE: usize = 10 * 1024 * 1024;
|
||||
|
||||
async fn read_full_body(stream: &mut tokio::net::TcpStream, peeked: &[u8]) -> Option<String> {
|
||||
let body_offset = find_header_end(peeked)?;
|
||||
let content_length = parse_content_length_bytes(&peeked[..body_offset])?;
|
||||
if content_length == 0 {
|
||||
return Some(String::new());
|
||||
}
|
||||
if content_length > MAX_BODY_SIZE {
|
||||
return None;
|
||||
}
|
||||
|
||||
let peeked_body = &peeked[body_offset..];
|
||||
let peeked_body_len = peeked_body.len().min(content_length);
|
||||
|
||||
let mut body = Vec::with_capacity(content_length);
|
||||
body.extend_from_slice(&peeked_body[..peeked_body_len]);
|
||||
|
||||
let remaining = content_length - peeked_body_len;
|
||||
if remaining > 0 {
|
||||
let mut rest = vec![0u8; remaining];
|
||||
if stream.read_exact(&mut rest).await.is_err() {
|
||||
return String::from_utf8(body).ok();
|
||||
}
|
||||
body.extend_from_slice(&rest);
|
||||
}
|
||||
|
||||
String::from_utf8(body).ok()
|
||||
}
|
||||
|
||||
pub(super) async fn relay_command_to_daemon(
|
||||
session_name: &str,
|
||||
body: &str,
|
||||
) -> Result<String, String> {
|
||||
let mut cmd: Value = serde_json::from_str(body).map_err(|e| format!("Invalid JSON: {}", e))?;
|
||||
|
||||
if cmd.get("id").is_none() {
|
||||
let id = format!(
|
||||
"dash-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
);
|
||||
cmd["id"] = json!(id);
|
||||
}
|
||||
|
||||
let mut json_str = serde_json::to_string(&cmd).map_err(|e| e.to_string())?;
|
||||
json_str.push('\n');
|
||||
|
||||
#[cfg(unix)]
|
||||
let stream = {
|
||||
let socket_path = get_socket_dir().join(format!("{}.sock", session_name));
|
||||
tokio::net::UnixStream::connect(&socket_path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect to daemon: {}", e))?
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
let stream = {
|
||||
let port = resolve_port(session_name);
|
||||
tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect to daemon: {}", e))?
|
||||
};
|
||||
|
||||
let (reader, mut writer) = tokio::io::split(stream);
|
||||
|
||||
writer
|
||||
.write_all(json_str.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send command: {}", e))?;
|
||||
|
||||
let mut buf_reader = tokio::io::BufReader::new(reader);
|
||||
let mut response_line = String::new();
|
||||
tokio::io::AsyncBufReadExt::read_line(&mut buf_reader, &mut response_line)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||
|
||||
Ok(response_line.trim().to_string())
|
||||
}
|
||||
|
||||
pub(super) fn serve_embedded_file(url_path: &str) -> (&'static str, &'static str, Vec<u8>) {
|
||||
let clean = url_path.trim_start_matches('/');
|
||||
let key = if clean.is_empty() {
|
||||
"index.html"
|
||||
} else {
|
||||
clean
|
||||
};
|
||||
|
||||
let file = DashboardAssets::get(key).or_else(|| DashboardAssets::get("index.html"));
|
||||
|
||||
match file {
|
||||
Some(content) => {
|
||||
let ext = key.rsplit('.').next().unwrap_or("");
|
||||
let ct = match ext {
|
||||
"html" => "text/html; charset=utf-8",
|
||||
"js" => "application/javascript; charset=utf-8",
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"json" => "application/json; charset=utf-8",
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"ico" => "image/x-icon",
|
||||
"woff2" => "font/woff2",
|
||||
"woff" => "font/woff",
|
||||
"txt" => "text/plain; charset=utf-8",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
("200 OK", ct, content.data.to_vec())
|
||||
}
|
||||
None => (
|
||||
"404 Not Found",
|
||||
"text/html; charset=utf-8",
|
||||
b"<html><body><p>404 Not Found</p></body></html>".to_vec(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
async fn send_request_to_handler(request: &str, session_name: &str) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let peeked = request.as_bytes().to_vec();
|
||||
let last_tabs = Arc::new(RwLock::new(Vec::new()));
|
||||
let last_engine = Arc::new(RwLock::new("chrome".to_string()));
|
||||
let session_name = session_name.to_string();
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
handle_http_request(stream, &peeked, &last_tabs, &last_engine, &session_name).await;
|
||||
});
|
||||
|
||||
let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
client.write_all(request.as_bytes()).await.unwrap();
|
||||
client.shutdown().await.unwrap();
|
||||
|
||||
let mut response = Vec::new();
|
||||
client.read_to_end(&mut response).await.unwrap();
|
||||
server.await.unwrap();
|
||||
|
||||
String::from_utf8(response).unwrap()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn spawn_fake_daemon(
|
||||
socket_dir: &std::path::Path,
|
||||
session_name: &str,
|
||||
) -> oneshot::Receiver<String> {
|
||||
let socket_path = socket_dir.join(format!("{session_name}.sock"));
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut reader = tokio::io::BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await.unwrap();
|
||||
|
||||
let mut stream = reader.into_inner();
|
||||
stream
|
||||
.write_all(br#"{"success":true,"data":{"ok":true}}"#)
|
||||
.await
|
||||
.unwrap();
|
||||
stream.write_all(b"\n").await.unwrap();
|
||||
let _ = tx.send(line);
|
||||
});
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cross_origin_command_post_is_rejected_without_relaying_to_daemon() {
|
||||
let temp_parent = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("target")
|
||||
.join("t");
|
||||
std::fs::create_dir_all(&temp_parent).unwrap();
|
||||
let socket_dir = tempfile::Builder::new()
|
||||
.prefix("ab-")
|
||||
.tempdir_in(temp_parent)
|
||||
.unwrap();
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
guard.set(
|
||||
"AGENT_BROWSER_SOCKET_DIR",
|
||||
socket_dir.path().to_str().unwrap(),
|
||||
);
|
||||
guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
let session_name = "x";
|
||||
let daemon_command = spawn_fake_daemon(socket_dir.path(), session_name).await;
|
||||
let body = r#"{"action":"tabs"}"#;
|
||||
let request = format!(
|
||||
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nOrigin: https://evil.example\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
|
||||
let response = send_request_to_handler(&request, session_name).await;
|
||||
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(50), daemon_command)
|
||||
.await
|
||||
.is_err(),
|
||||
"cross-origin request reached daemon command relay"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cross_origin_command_preflight_is_rejected_without_wildcard_cors() {
|
||||
let request = concat!(
|
||||
"OPTIONS /api/command HTTP/1.1\r\n",
|
||||
"Host: localhost:7777\r\n",
|
||||
"Origin: https://evil.example\r\n",
|
||||
"Access-Control-Request-Method: POST\r\n",
|
||||
"Access-Control-Request-Headers: content-type\r\n",
|
||||
"\r\n"
|
||||
);
|
||||
|
||||
let response = send_request_to_handler(request, "x").await;
|
||||
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
assert!(
|
||||
!response.contains("Access-Control-Allow-Origin: *"),
|
||||
"forbidden command preflight exposed wildcard CORS: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn command_post_without_origin_or_referer_is_rejected() {
|
||||
let body = r#"{"action":"tabs"}"#;
|
||||
let request = format!(
|
||||
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
|
||||
let response = send_request_to_handler(&request, "x").await;
|
||||
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
assert!(
|
||||
!response.contains("Access-Control-Allow-Origin: *"),
|
||||
"forbidden command response exposed wildcard CORS: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn command_post_with_dns_rebinding_host_is_rejected() {
|
||||
let body = r#"{"action":"tabs"}"#;
|
||||
let request = format!(
|
||||
"POST /api/command HTTP/1.1\r\nHost: attacker.example:7777\r\nOrigin: http://attacker.example:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
|
||||
let response = send_request_to_handler(&request, "x").await;
|
||||
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
assert!(
|
||||
!response.contains("Access-Control-Allow-Origin: *"),
|
||||
"forbidden command response exposed wildcard CORS: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn command_post_ignores_header_like_body_lines() {
|
||||
let body = "Referer: http://localhost:7777\r\n{\"action\":\"tabs\"}";
|
||||
let request = format!(
|
||||
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
|
||||
let response = send_request_to_handler(&request, "x").await;
|
||||
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 403 Forbidden"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
assert!(
|
||||
!response.contains("Access-Control-Allow-Origin: *"),
|
||||
"forbidden command response exposed wildcard CORS: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn same_origin_command_post_relays_without_wildcard_cors() {
|
||||
let temp_parent = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("target")
|
||||
.join("t");
|
||||
std::fs::create_dir_all(&temp_parent).unwrap();
|
||||
let socket_dir = tempfile::Builder::new()
|
||||
.prefix("ab-")
|
||||
.tempdir_in(temp_parent)
|
||||
.unwrap();
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
guard.set(
|
||||
"AGENT_BROWSER_SOCKET_DIR",
|
||||
socket_dir.path().to_str().unwrap(),
|
||||
);
|
||||
guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
let session_name = "x";
|
||||
let daemon_command = spawn_fake_daemon(socket_dir.path(), session_name).await;
|
||||
let body = r#"{"action":"tabs"}"#;
|
||||
let request = format!(
|
||||
"POST /api/command HTTP/1.1\r\nHost: localhost:7777\r\nOrigin: http://localhost:7777\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
|
||||
let response = send_request_to_handler(&request, session_name).await;
|
||||
|
||||
assert!(
|
||||
response.starts_with("HTTP/1.1 200 OK"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
assert!(
|
||||
response.contains("Access-Control-Allow-Origin: http://localhost:7777"),
|
||||
"same-origin command response did not reflect origin: {response}"
|
||||
);
|
||||
assert!(
|
||||
!response.contains("Access-Control-Allow-Origin: *"),
|
||||
"same-origin command response exposed wildcard CORS: {response}"
|
||||
);
|
||||
|
||||
let relayed = tokio::time::timeout(std::time::Duration::from_secs(1), daemon_command)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(relayed.contains(r#""action":"tabs""#), "{relayed}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
mod cdp_loop;
|
||||
pub(crate) mod chat;
|
||||
mod dashboard;
|
||||
mod discovery;
|
||||
mod http;
|
||||
mod websocket;
|
||||
|
||||
pub use cdp_loop::{ack_screencast_frame, start_screencast, stop_screencast};
|
||||
pub use dashboard::run_dashboard_server;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{broadcast, watch, Mutex, Notify, RwLock};
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
/// Frame metadata from CDP Page.screencastFrame events.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FrameMetadata {
|
||||
pub offset_top: f64,
|
||||
pub page_scale_factor: f64,
|
||||
pub device_width: u32,
|
||||
pub device_height: u32,
|
||||
pub scroll_offset_x: f64,
|
||||
pub scroll_offset_y: f64,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
impl Default for FrameMetadata {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
offset_top: 0.0,
|
||||
page_scale_factor: 1.0,
|
||||
device_width: 1280,
|
||||
device_height: 720,
|
||||
scroll_offset_x: 0.0,
|
||||
scroll_offset_y: 0.0,
|
||||
timestamp: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StreamServer {
|
||||
port: u16,
|
||||
session_name: String,
|
||||
frame_tx: broadcast::Sender<String>,
|
||||
client_count: Arc<Mutex<usize>>,
|
||||
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
|
||||
/// The active CDP page session ID (from Target.attachToTarget).
|
||||
cdp_session_id: Arc<RwLock<Option<String>>>,
|
||||
client_notify: Arc<Notify>,
|
||||
screencasting: Arc<Mutex<bool>>,
|
||||
viewport_width: Arc<Mutex<u32>>,
|
||||
viewport_height: Arc<Mutex<u32>>,
|
||||
last_tabs: Arc<RwLock<Vec<Value>>>,
|
||||
last_engine: Arc<RwLock<String>>,
|
||||
last_frame: Arc<RwLock<Option<String>>>,
|
||||
recording: Arc<Mutex<bool>>,
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
accept_task: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
cdp_task: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl StreamServer {
|
||||
pub async fn start(
|
||||
preferred_port: u16,
|
||||
client: Arc<CdpClient>,
|
||||
session_id: String,
|
||||
) -> Result<Self, String> {
|
||||
let client_slot = Arc::new(RwLock::new(Some(client)));
|
||||
let (server, _) = Self::start_inner(preferred_port, client_slot, session_id, true).await?;
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
/// Start the stream server without a CDP client.
|
||||
/// Returns the server and a shared slot to set the client when the browser launches.
|
||||
/// Input messages are ignored until the client is set.
|
||||
/// When `allow_port_fallback` is true, binding to an occupied port falls back to an
|
||||
/// OS-assigned port (used by daemon startup). When false, the error propagates
|
||||
/// (used by the runtime `stream_enable` command).
|
||||
pub async fn start_without_client(
|
||||
preferred_port: u16,
|
||||
session_id: String,
|
||||
allow_port_fallback: bool,
|
||||
) -> Result<(Self, Arc<RwLock<Option<Arc<CdpClient>>>>), String> {
|
||||
let client_slot = Arc::new(RwLock::new(None::<Arc<CdpClient>>));
|
||||
Self::start_inner(preferred_port, client_slot, session_id, allow_port_fallback).await
|
||||
}
|
||||
|
||||
/// Notify the background CDP listener that the client has changed (browser launched/closed).
|
||||
pub fn notify_client_changed(&self) {
|
||||
self.client_notify.notify_one();
|
||||
}
|
||||
|
||||
/// Update the active CDP page session ID used for screencast commands.
|
||||
pub async fn set_cdp_session_id(&self, session_id: Option<String>) {
|
||||
let mut guard = self.cdp_session_id.write().await;
|
||||
*guard = session_id;
|
||||
}
|
||||
|
||||
/// Check whether the server currently has active screencast running.
|
||||
pub async fn is_screencasting(&self) -> bool {
|
||||
*self.screencasting.lock().await
|
||||
}
|
||||
|
||||
/// Update the stored viewport dimensions and restart the active screencast (if any)
|
||||
/// so frames are captured at the new size.
|
||||
pub async fn set_viewport(&self, width: u32, height: u32) {
|
||||
let mut vw = self.viewport_width.lock().await;
|
||||
let mut vh = self.viewport_height.lock().await;
|
||||
if *vw == width && *vh == height {
|
||||
return;
|
||||
}
|
||||
*vw = width;
|
||||
*vh = height;
|
||||
drop(vw);
|
||||
drop(vh);
|
||||
self.client_notify.notify_one();
|
||||
}
|
||||
|
||||
/// Get the current viewport dimensions.
|
||||
pub async fn viewport(&self) -> (u32, u32) {
|
||||
let w = *self.viewport_width.lock().await;
|
||||
let h = *self.viewport_height.lock().await;
|
||||
(w, h)
|
||||
}
|
||||
|
||||
/// Override the cached screencast state for explicit CLI start/stop commands.
|
||||
pub async fn set_screencasting(&self, active: bool) {
|
||||
let mut guard = self.screencasting.lock().await;
|
||||
*guard = active;
|
||||
}
|
||||
|
||||
/// Update and broadcast the recording state.
|
||||
pub async fn set_recording(&self, active: bool, engine: &str) {
|
||||
*self.recording.lock().await = active;
|
||||
let connected = self.client_slot.read().await.is_some();
|
||||
let sc = *self.screencasting.lock().await;
|
||||
let (vw, vh) = self.viewport().await;
|
||||
self.broadcast_status(connected, sc, vw, vh, engine).await;
|
||||
}
|
||||
|
||||
/// Shut down the accept loop and background CDP listener, releasing the bound port.
|
||||
pub async fn shutdown(&self) {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
|
||||
if let Some(task) = self.accept_task.lock().await.take() {
|
||||
let _ = task.await;
|
||||
}
|
||||
if let Some(task) = self.cdp_task.lock().await.take() {
|
||||
let _ = task.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_inner(
|
||||
preferred_port: u16,
|
||||
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
|
||||
session_id: String,
|
||||
allow_port_fallback: bool,
|
||||
) -> Result<(Self, Arc<RwLock<Option<Arc<CdpClient>>>>), String> {
|
||||
let addr = format!("127.0.0.1:{}", preferred_port);
|
||||
let listener = match TcpListener::bind(&addr).await {
|
||||
Ok(l) => l,
|
||||
Err(_) if allow_port_fallback && preferred_port != 0 => {
|
||||
TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind stream server: {}", e))?
|
||||
}
|
||||
Err(e) => return Err(format!("Failed to bind stream server: {}", e)),
|
||||
};
|
||||
|
||||
let actual_addr = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get stream address: {}", e))?;
|
||||
let port = actual_addr.port();
|
||||
|
||||
let (frame_tx, _) = broadcast::channel::<String>(64);
|
||||
let client_count = Arc::new(Mutex::new(0usize));
|
||||
let client_notify = Arc::new(Notify::new());
|
||||
let screencasting = Arc::new(Mutex::new(false));
|
||||
let cdp_session_id = Arc::new(RwLock::new(None::<String>));
|
||||
let viewport_width = Arc::new(Mutex::new(1280u32));
|
||||
let viewport_height = Arc::new(Mutex::new(720u32));
|
||||
let last_tabs = Arc::new(RwLock::new(Vec::<Value>::new()));
|
||||
let last_engine = Arc::new(RwLock::new("chrome".to_string()));
|
||||
let last_frame = Arc::new(RwLock::new(None::<String>));
|
||||
let recording = Arc::new(Mutex::new(false));
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
|
||||
let frame_tx_clone = frame_tx.clone();
|
||||
let client_count_clone = client_count.clone();
|
||||
let client_slot_clone = client_slot.clone();
|
||||
let notify_clone = client_notify.clone();
|
||||
let screencasting_clone = screencasting.clone();
|
||||
let cdp_session_clone = cdp_session_id.clone();
|
||||
|
||||
let vw_clone = viewport_width.clone();
|
||||
let vh_clone = viewport_height.clone();
|
||||
let last_tabs_clone = last_tabs.clone();
|
||||
let last_engine_clone = last_engine.clone();
|
||||
let last_frame_clone = last_frame.clone();
|
||||
let recording_clone = recording.clone();
|
||||
let accept_shutdown_rx = shutdown_rx.clone();
|
||||
let session_name_clone = session_id.clone();
|
||||
let accept_task = tokio::spawn(async move {
|
||||
websocket::accept_loop(
|
||||
listener,
|
||||
frame_tx_clone,
|
||||
client_count_clone,
|
||||
client_slot_clone,
|
||||
notify_clone,
|
||||
screencasting_clone,
|
||||
cdp_session_clone,
|
||||
vw_clone,
|
||||
vh_clone,
|
||||
last_tabs_clone,
|
||||
last_engine_clone,
|
||||
last_frame_clone,
|
||||
recording_clone,
|
||||
accept_shutdown_rx,
|
||||
session_name_clone,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let frame_tx_bg = frame_tx.clone();
|
||||
let client_slot_bg = client_slot.clone();
|
||||
let client_notify_bg = client_notify.clone();
|
||||
let screencasting_bg = screencasting.clone();
|
||||
let client_count_bg = client_count.clone();
|
||||
let cdp_session_bg = cdp_session_id.clone();
|
||||
let vw_bg = viewport_width.clone();
|
||||
let vh_bg = viewport_height.clone();
|
||||
let last_frame_bg = last_frame.clone();
|
||||
let last_tabs_bg = last_tabs.clone();
|
||||
let last_engine_bg = last_engine.clone();
|
||||
let recording_bg = recording.clone();
|
||||
let cdp_task = tokio::spawn(async move {
|
||||
cdp_loop::cdp_event_loop(
|
||||
frame_tx_bg,
|
||||
client_slot_bg,
|
||||
client_notify_bg,
|
||||
screencasting_bg,
|
||||
client_count_bg,
|
||||
cdp_session_bg,
|
||||
vw_bg,
|
||||
vh_bg,
|
||||
last_frame_bg,
|
||||
last_tabs_bg,
|
||||
last_engine_bg,
|
||||
recording_bg,
|
||||
shutdown_rx,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
Ok((
|
||||
Self {
|
||||
port,
|
||||
session_name: session_id,
|
||||
frame_tx,
|
||||
client_count,
|
||||
client_slot: client_slot.clone(),
|
||||
cdp_session_id,
|
||||
client_notify,
|
||||
screencasting,
|
||||
viewport_width,
|
||||
viewport_height,
|
||||
last_tabs,
|
||||
last_engine,
|
||||
last_frame,
|
||||
recording,
|
||||
shutdown_tx,
|
||||
accept_task: Mutex::new(Some(accept_task)),
|
||||
cdp_task: Mutex::new(Some(cdp_task)),
|
||||
},
|
||||
client_slot,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Broadcast a raw frame string (legacy).
|
||||
pub fn broadcast_frame(&self, frame_json: &str) {
|
||||
let s = frame_json.to_string();
|
||||
if let Ok(mut lf) = self.last_frame.try_write() {
|
||||
*lf = Some(s.clone());
|
||||
}
|
||||
let _ = self.frame_tx.send(s);
|
||||
}
|
||||
|
||||
/// Broadcast a screencast frame with structured metadata.
|
||||
pub fn broadcast_screencast_frame(&self, base64_data: &str, metadata: &FrameMetadata) {
|
||||
let msg = json!({
|
||||
"type": "frame",
|
||||
"data": base64_data,
|
||||
"metadata": {
|
||||
"offsetTop": metadata.offset_top,
|
||||
"pageScaleFactor": metadata.page_scale_factor,
|
||||
"deviceWidth": metadata.device_width,
|
||||
"deviceHeight": metadata.device_height,
|
||||
"scrollOffsetX": metadata.scroll_offset_x,
|
||||
"scrollOffsetY": metadata.scroll_offset_y,
|
||||
"timestamp": metadata.timestamp,
|
||||
}
|
||||
});
|
||||
let s = msg.to_string();
|
||||
if let Ok(mut lf) = self.last_frame.try_write() {
|
||||
*lf = Some(s.clone());
|
||||
}
|
||||
let _ = self.frame_tx.send(s);
|
||||
}
|
||||
|
||||
/// Broadcast a status message to all connected clients.
|
||||
pub async fn broadcast_status(
|
||||
&self,
|
||||
connected: bool,
|
||||
screencasting: bool,
|
||||
viewport_width: u32,
|
||||
viewport_height: u32,
|
||||
engine: &str,
|
||||
) {
|
||||
{
|
||||
let mut guard = self.last_engine.write().await;
|
||||
*guard = engine.to_string();
|
||||
}
|
||||
let rec = *self.recording.lock().await;
|
||||
let msg = json!({
|
||||
"type": "status",
|
||||
"connected": connected,
|
||||
"screencasting": screencasting,
|
||||
"viewportWidth": viewport_width,
|
||||
"viewportHeight": viewport_height,
|
||||
"engine": engine,
|
||||
"recording": rec,
|
||||
});
|
||||
let _ = self.frame_tx.send(msg.to_string());
|
||||
}
|
||||
|
||||
/// Broadcast an error message to all connected clients.
|
||||
pub fn broadcast_error(&self, message: &str) {
|
||||
let msg = json!({
|
||||
"type": "error",
|
||||
"message": message,
|
||||
});
|
||||
let _ = self.frame_tx.send(msg.to_string());
|
||||
}
|
||||
|
||||
/// Broadcast a command event when a command begins executing.
|
||||
pub fn broadcast_command(&self, action: &str, id: &str, params: &Value) {
|
||||
let msg = json!({
|
||||
"type": "command",
|
||||
"action": action,
|
||||
"id": id,
|
||||
"params": params,
|
||||
"timestamp": timestamp_ms(),
|
||||
});
|
||||
let _ = self.frame_tx.send(msg.to_string());
|
||||
}
|
||||
|
||||
/// Broadcast a result event after a command finishes executing.
|
||||
pub fn broadcast_result(
|
||||
&self,
|
||||
id: &str,
|
||||
action: &str,
|
||||
success: bool,
|
||||
data: &Value,
|
||||
duration_ms: u64,
|
||||
) {
|
||||
let msg = json!({
|
||||
"type": "result",
|
||||
"id": id,
|
||||
"action": action,
|
||||
"success": success,
|
||||
"data": data,
|
||||
"duration_ms": duration_ms,
|
||||
"timestamp": timestamp_ms(),
|
||||
});
|
||||
let _ = self.frame_tx.send(msg.to_string());
|
||||
}
|
||||
|
||||
/// Broadcast a console event from the browser.
|
||||
pub fn broadcast_console(&self, level: &str, text: &str, args: &[Value]) {
|
||||
let mut msg = json!({
|
||||
"type": "console",
|
||||
"level": level,
|
||||
"text": text,
|
||||
"timestamp": timestamp_ms(),
|
||||
});
|
||||
if !args.is_empty() {
|
||||
msg.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("args".to_string(), Value::Array(args.to_vec()));
|
||||
}
|
||||
let _ = self.frame_tx.send(msg.to_string());
|
||||
}
|
||||
|
||||
/// Broadcast a page error (uncaught exception) from the browser.
|
||||
pub fn broadcast_page_error(&self, text: &str, line: Option<i64>, column: Option<i64>) {
|
||||
let msg = json!({
|
||||
"type": "page_error",
|
||||
"text": text,
|
||||
"line": line,
|
||||
"column": column,
|
||||
"timestamp": timestamp_ms(),
|
||||
});
|
||||
let _ = self.frame_tx.send(msg.to_string());
|
||||
}
|
||||
|
||||
/// Broadcast the current tab list so the dashboard can render a tab bar.
|
||||
/// Also caches the list so newly connected WebSocket clients receive it immediately.
|
||||
pub async fn broadcast_tabs(&self, tabs: &[Value]) {
|
||||
{
|
||||
let mut guard = self.last_tabs.write().await;
|
||||
*guard = tabs.to_vec();
|
||||
}
|
||||
let msg = json!({
|
||||
"type": "tabs",
|
||||
"tabs": tabs,
|
||||
"timestamp": timestamp_ms(),
|
||||
});
|
||||
let _ = self.frame_tx.send(msg.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn timestamp_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn is_allowed_origin(origin: Option<&str>) -> bool {
|
||||
match origin {
|
||||
None => true,
|
||||
Some(o) => {
|
||||
if o.starts_with("file://") {
|
||||
return true;
|
||||
}
|
||||
if let Ok(url) = url::Url::parse(o) {
|
||||
let host = url.host_str().unwrap_or("");
|
||||
host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_allowed_origin_none() {
|
||||
assert!(is_allowed_origin(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_origin_file() {
|
||||
assert!(is_allowed_origin(Some("file:///path/to/file")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_origin_localhost() {
|
||||
assert!(is_allowed_origin(Some("http://localhost:3000")));
|
||||
assert!(is_allowed_origin(Some("http://127.0.0.1:8080")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disallowed_origin() {
|
||||
assert!(!is_allowed_origin(Some("http://evil.com")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frame_metadata_default() {
|
||||
let meta = FrameMetadata::default();
|
||||
assert_eq!(meta.device_width, 1280);
|
||||
assert_eq!(meta.device_height, 720);
|
||||
assert_eq!(meta.page_scale_factor, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{broadcast, watch, Mutex, Notify, RwLock};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use crate::native::cdp::client::CdpClient;
|
||||
|
||||
use super::http::handle_http_request;
|
||||
use super::{is_allowed_origin, timestamp_ms};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn accept_loop(
|
||||
listener: TcpListener,
|
||||
frame_tx: broadcast::Sender<String>,
|
||||
client_count: Arc<Mutex<usize>>,
|
||||
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
|
||||
client_notify: Arc<Notify>,
|
||||
screencasting: Arc<Mutex<bool>>,
|
||||
cdp_session_id: Arc<RwLock<Option<String>>>,
|
||||
viewport_width: Arc<Mutex<u32>>,
|
||||
viewport_height: Arc<Mutex<u32>>,
|
||||
last_tabs: Arc<RwLock<Vec<Value>>>,
|
||||
last_engine: Arc<RwLock<String>>,
|
||||
last_frame: Arc<RwLock<Option<String>>>,
|
||||
recording: Arc<Mutex<bool>>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
session_name: String,
|
||||
) {
|
||||
let session_name: Arc<str> = Arc::from(session_name);
|
||||
loop {
|
||||
tokio::select! {
|
||||
changed = shutdown_rx.changed() => {
|
||||
if changed.is_err() || *shutdown_rx.borrow() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
accept_result = listener.accept() => {
|
||||
let Ok((stream, addr)) = accept_result else {
|
||||
break;
|
||||
};
|
||||
let frame_tx = frame_tx.clone();
|
||||
let client_count = client_count.clone();
|
||||
let client_slot = client_slot.clone();
|
||||
let client_notify = client_notify.clone();
|
||||
let screencasting = screencasting.clone();
|
||||
let cdp_session_id = cdp_session_id.clone();
|
||||
let vw = viewport_width.clone();
|
||||
let vh = viewport_height.clone();
|
||||
let lt = last_tabs.clone();
|
||||
let le = last_engine.clone();
|
||||
let lf = last_frame.clone();
|
||||
let rec = recording.clone();
|
||||
let shutdown_rx = shutdown_rx.clone();
|
||||
let sn = session_name.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
handle_connection(
|
||||
stream,
|
||||
addr,
|
||||
frame_tx,
|
||||
client_count,
|
||||
client_slot,
|
||||
client_notify,
|
||||
screencasting,
|
||||
cdp_session_id,
|
||||
vw,
|
||||
vh,
|
||||
lt,
|
||||
le,
|
||||
lf,
|
||||
rec,
|
||||
shutdown_rx,
|
||||
sn,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_websocket_upgrade(request: &str) -> bool {
|
||||
request.lines().any(|line| {
|
||||
if let Some((name, value)) = line.split_once(':') {
|
||||
name.trim().eq_ignore_ascii_case("upgrade")
|
||||
&& value.trim().eq_ignore_ascii_case("websocket")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Peek at the TCP stream to dispatch between WebSocket upgrade and plain HTTP.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_connection(
|
||||
stream: tokio::net::TcpStream,
|
||||
addr: SocketAddr,
|
||||
frame_tx: broadcast::Sender<String>,
|
||||
client_count: Arc<Mutex<usize>>,
|
||||
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
|
||||
client_notify: Arc<Notify>,
|
||||
screencasting: Arc<Mutex<bool>>,
|
||||
cdp_session_id: Arc<RwLock<Option<String>>>,
|
||||
viewport_width: Arc<Mutex<u32>>,
|
||||
viewport_height: Arc<Mutex<u32>>,
|
||||
last_tabs: Arc<RwLock<Vec<Value>>>,
|
||||
last_engine: Arc<RwLock<String>>,
|
||||
last_frame: Arc<RwLock<Option<String>>>,
|
||||
recording: Arc<Mutex<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
session_name: Arc<str>,
|
||||
) {
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = match stream.peek(&mut buf).await {
|
||||
Ok(n) => n,
|
||||
Err(_) => return,
|
||||
};
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
|
||||
if is_websocket_upgrade(&request) {
|
||||
let frame_rx = frame_tx.subscribe();
|
||||
handle_ws_client(
|
||||
stream,
|
||||
addr,
|
||||
frame_rx,
|
||||
client_count,
|
||||
client_slot,
|
||||
client_notify,
|
||||
screencasting,
|
||||
cdp_session_id,
|
||||
viewport_width,
|
||||
viewport_height,
|
||||
last_tabs,
|
||||
last_engine,
|
||||
last_frame,
|
||||
recording,
|
||||
shutdown_rx,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
handle_http_request(stream, &buf[..n], &last_tabs, &last_engine, &session_name).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err, clippy::too_many_arguments)]
|
||||
async fn handle_ws_client(
|
||||
stream: tokio::net::TcpStream,
|
||||
_addr: SocketAddr,
|
||||
mut frame_rx: broadcast::Receiver<String>,
|
||||
client_count: Arc<Mutex<usize>>,
|
||||
client_slot: Arc<RwLock<Option<Arc<CdpClient>>>>,
|
||||
client_notify: Arc<Notify>,
|
||||
screencasting: Arc<Mutex<bool>>,
|
||||
cdp_session_id: Arc<RwLock<Option<String>>>,
|
||||
viewport_width: Arc<Mutex<u32>>,
|
||||
viewport_height: Arc<Mutex<u32>>,
|
||||
last_tabs: Arc<RwLock<Vec<Value>>>,
|
||||
last_engine: Arc<RwLock<String>>,
|
||||
last_frame: Arc<RwLock<Option<String>>>,
|
||||
recording: Arc<Mutex<bool>>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) {
|
||||
let callback =
|
||||
|req: &tokio_tungstenite::tungstenite::handshake::server::Request,
|
||||
resp: tokio_tungstenite::tungstenite::handshake::server::Response| {
|
||||
let origin = req
|
||||
.headers()
|
||||
.get("origin")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
if !is_allowed_origin(origin.as_deref()) {
|
||||
let mut reject =
|
||||
tokio_tungstenite::tungstenite::handshake::server::ErrorResponse::new(Some(
|
||||
"Origin not allowed".to_string(),
|
||||
));
|
||||
*reject.status_mut() = tokio_tungstenite::tungstenite::http::StatusCode::FORBIDDEN;
|
||||
return Err(reject);
|
||||
}
|
||||
Ok(resp)
|
||||
};
|
||||
|
||||
let ws_stream = match tokio_tungstenite::accept_hdr_async(stream, callback).await {
|
||||
Ok(ws) => ws,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
{
|
||||
let mut count = client_count.lock().await;
|
||||
*count += 1;
|
||||
}
|
||||
|
||||
let (mut ws_tx, mut ws_rx) = ws_stream.split();
|
||||
|
||||
{
|
||||
let guard = client_slot.read().await;
|
||||
let connected = guard.is_some();
|
||||
let sc = *screencasting.lock().await;
|
||||
let vw = *viewport_width.lock().await;
|
||||
let vh = *viewport_height.lock().await;
|
||||
let eng = last_engine.read().await.clone();
|
||||
let rec = *recording.lock().await;
|
||||
let status = json!({
|
||||
"type": "status",
|
||||
"connected": connected,
|
||||
"screencasting": sc,
|
||||
"viewportWidth": vw,
|
||||
"viewportHeight": vh,
|
||||
"engine": eng,
|
||||
"recording": rec,
|
||||
});
|
||||
let _ = ws_tx.send(Message::Text(status.to_string())).await;
|
||||
|
||||
let tabs = last_tabs.read().await;
|
||||
if !tabs.is_empty() {
|
||||
let tabs_msg = json!({
|
||||
"type": "tabs",
|
||||
"tabs": *tabs,
|
||||
"timestamp": timestamp_ms(),
|
||||
});
|
||||
let _ = ws_tx.send(Message::Text(tabs_msg.to_string())).await;
|
||||
}
|
||||
|
||||
if let Some(ref cached) = *last_frame.read().await {
|
||||
let _ = ws_tx.send(Message::Text(cached.clone())).await;
|
||||
}
|
||||
}
|
||||
|
||||
client_notify.notify_one();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
changed = shutdown_rx.changed() => {
|
||||
if changed.is_err() || *shutdown_rx.borrow() {
|
||||
let _ = ws_tx.send(Message::Close(None)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
frame = frame_rx.recv() => {
|
||||
match frame {
|
||||
Ok(data) => {
|
||||
if ws_tx.send(Message::Text(data)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
continue;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
msg = ws_rx.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
let guard = client_slot.read().await;
|
||||
if let Some(ref client) = *guard {
|
||||
let sid = cdp_session_id.read().await;
|
||||
handle_client_message(&text, client.as_ref(), sid.as_deref()).await;
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut count = client_count.lock().await;
|
||||
*count = count.saturating_sub(1);
|
||||
}
|
||||
|
||||
client_notify.notify_one();
|
||||
}
|
||||
|
||||
async fn handle_client_message(msg: &str, client: &CdpClient, session_id: Option<&str>) {
|
||||
let parsed: Value = match serde_json::from_str(msg) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let msg_type = parsed.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
match msg_type {
|
||||
"input_mouse" => {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Input.dispatchMouseEvent",
|
||||
Some(json!({
|
||||
"type": parsed.get("eventType").and_then(|v| v.as_str()).unwrap_or("mouseMoved"),
|
||||
"x": parsed.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"y": parsed.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"button": parsed.get("button").and_then(|v| v.as_str()).unwrap_or("none"),
|
||||
"clickCount": parsed.get("clickCount").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
"deltaX": parsed.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"deltaY": parsed.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"modifiers": parsed.get("modifiers").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
})),
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
"input_keyboard" => {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Input.dispatchKeyEvent",
|
||||
Some(json!({
|
||||
"type": parsed.get("eventType").and_then(|v| v.as_str()).unwrap_or("keyDown"),
|
||||
"key": parsed.get("key"),
|
||||
"code": parsed.get("code"),
|
||||
"text": parsed.get("text"),
|
||||
"windowsVirtualKeyCode": parsed.get("windowsVirtualKeyCode").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
"modifiers": parsed.get("modifiers").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
})),
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
"input_touch" => {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Input.dispatchTouchEvent",
|
||||
Some(json!({
|
||||
"type": parsed.get("eventType").and_then(|v| v.as_str()).unwrap_or("touchStart"),
|
||||
"touchPoints": parsed.get("touchPoints").unwrap_or(&json!([])),
|
||||
"modifiers": parsed.get("modifiers").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
})),
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
"status" => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>iframe button probe</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>iframe button probe</h1>
|
||||
<iframe
|
||||
id="frame"
|
||||
width="320"
|
||||
height="140"
|
||||
srcdoc="
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body style='margin:24px'>
|
||||
<button id='b' style='padding:24px;font-size:22px'>save</button>
|
||||
<script>
|
||||
document.getElementById('b').addEventListener('click', function (e) {
|
||||
this.textContent = 'clicked:' + e.isTrusted;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"
|
||||
></iframe>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Upload Test</title></head>
|
||||
<body>
|
||||
<h1>Upload Test</h1>
|
||||
<label for="fileInput">Choose file:</label>
|
||||
<input type="file" id="fileInput" name="fileInput">
|
||||
<div id="result"></div>
|
||||
<script>
|
||||
document.getElementById('fileInput').addEventListener('change', function(e) {
|
||||
var file = e.target.files[0];
|
||||
if (file) {
|
||||
document.getElementById('result').textContent = 'uploaded:' + file.name;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -358,16 +358,16 @@ fn get_clock_domain() -> Option<&'static str> {
|
||||
|
||||
fn get_traces_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("traces")
|
||||
home.join(".chrome-use").join("tmp").join("traces")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("traces")
|
||||
std::env::temp_dir().join("chrome-use").join("traces")
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profiles_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("profiles")
|
||||
home.join(".chrome-use").join("tmp").join("profiles")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("profiles")
|
||||
std::env::temp_dir().join("chrome-use").join("profiles")
|
||||
}
|
||||
}
|
||||
|
||||
+1205
-467
File diff suppressed because it is too large
Load Diff
+465
@@ -0,0 +1,465 @@
|
||||
//! Site adapters: turn any website into a structured-data CLI by running a small
|
||||
//! per-command JS adapter inside your real, logged-in browser tab (it reuses the
|
||||
//! site's cookies / same-origin fetch / its own webpack modules — the site thinks
|
||||
//! it's you, because it is).
|
||||
//!
|
||||
//! The adapter format is the community **bb-sites** convention
|
||||
//! (<https://github.com/epiral/bb-sites>): one `.js` file per command, a
|
||||
//! `/* @meta {...} */` JSON header (name, description, domain, args), then an
|
||||
//! `async function(args){ ... return {...} }`. chrome-use ships none of those
|
||||
//! adapters — `chrome-use site update` fetches the upstream repo at runtime into
|
||||
//! `~/.chrome-use/sites` (like a package manager pulling a dependency), so the
|
||||
//! adapters stay the property of their authors. Running an adapter navigates to
|
||||
//! its `@meta.domain` and `eval`s the function in the site's own logged-in page.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const SITES_ZIP_URL: &str = "https://github.com/epiral/bb-sites/archive/refs/heads/main.zip";
|
||||
|
||||
/// `~/.chrome-use/sites` — where synced adapters live.
|
||||
pub fn sites_dir() -> Option<PathBuf> {
|
||||
dirs_home().map(|h| h.join(".chrome-use").join("sites"))
|
||||
}
|
||||
|
||||
fn dirs_home() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME").map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// Parsed adapter: its `@meta` JSON and the raw `async function(args){...}` source.
|
||||
pub struct Adapter {
|
||||
pub meta: Value,
|
||||
pub func_src: String,
|
||||
/// The adapter's declared `args` keys in DECLARATION order. Parsed from the
|
||||
/// raw @meta text because `serde_json` sorts object keys alphabetically, which
|
||||
/// would otherwise scramble positional-arg mapping for multi-arg adapters.
|
||||
pub arg_order: Vec<String>,
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
pub fn domain(&self) -> Option<&str> {
|
||||
self.meta.get("domain").and_then(|v| v.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Load `<sites>/<name>/<cmd>.js`, splitting the `/* @meta {...} */` header from
|
||||
/// the function body. `spec` is `name/cmd`.
|
||||
pub fn load_adapter(spec: &str) -> Result<Adapter, String> {
|
||||
let (name, cmd) = spec
|
||||
.split_once('/')
|
||||
.ok_or_else(|| format!("site: expected <name>/<command>, got `{spec}`"))?;
|
||||
if name.is_empty()
|
||||
|| cmd.is_empty()
|
||||
|| name.contains("..")
|
||||
|| cmd.contains("..")
|
||||
|| name.contains('/')
|
||||
|| cmd.contains('/')
|
||||
{
|
||||
return Err(format!("site: invalid adapter spec `{spec}`"));
|
||||
}
|
||||
let dir = sites_dir().ok_or("site: cannot resolve home dir")?;
|
||||
let path = dir.join(name).join(format!("{cmd}.js"));
|
||||
if !path.exists() {
|
||||
return Err(format!(
|
||||
"site: adapter `{spec}` not found. Run `chrome-use site update` to sync adapters, \
|
||||
or `chrome-use site list` to see what's installed."
|
||||
));
|
||||
}
|
||||
let raw = std::fs::read_to_string(&path).map_err(|e| format!("site: read {spec}: {e}"))?;
|
||||
parse_adapter(&raw, spec)
|
||||
}
|
||||
|
||||
/// Split the `@meta` JSON block and the function source from an adapter file.
|
||||
pub fn parse_adapter(raw: &str, spec: &str) -> Result<Adapter, String> {
|
||||
let start = raw
|
||||
.find("@meta")
|
||||
.and_then(|i| raw[i..].find('{').map(|j| i + j))
|
||||
.ok_or_else(|| format!("site: {spec} missing /* @meta {{...}} */ header"))?;
|
||||
// Find the matching close brace for the @meta object (brace-count, string-aware).
|
||||
let bytes = raw.as_bytes();
|
||||
let mut depth = 0i32;
|
||||
let mut in_str = false;
|
||||
let mut esc = false;
|
||||
let mut end = None;
|
||||
for (k, &b) in bytes.iter().enumerate().skip(start) {
|
||||
if in_str {
|
||||
if esc {
|
||||
esc = false;
|
||||
} else if b == b'\\' {
|
||||
esc = true;
|
||||
} else if b == b'"' {
|
||||
in_str = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match b {
|
||||
b'"' => in_str = true,
|
||||
b'{' => depth += 1,
|
||||
b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = Some(k + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let end = end.ok_or_else(|| format!("site: {spec} @meta header has no closing brace"))?;
|
||||
let meta: Value = serde_json::from_str(&raw[start..end])
|
||||
.map_err(|e| format!("site: {spec} @meta is not valid JSON: {e}"))?;
|
||||
// The function is everything after the meta comment's closing `*/`.
|
||||
let after = raw[end..].find("*/").map(|i| end + i + 2).unwrap_or(end);
|
||||
let func_src = raw[after..].trim().to_string();
|
||||
if func_src.is_empty() {
|
||||
return Err(format!("site: {spec} has no function body after @meta"));
|
||||
}
|
||||
let arg_order = arg_order_from_meta(&raw[start..end]);
|
||||
Ok(Adapter {
|
||||
meta,
|
||||
func_src,
|
||||
arg_order,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the `args` object's keys in DECLARATION order from the raw @meta JSON
|
||||
/// text (serde sorts them, losing order). Brace/string-aware: finds the `"args"`
|
||||
/// value object and collects only its top-level keys.
|
||||
fn arg_order_from_meta(meta_json: &str) -> Vec<String> {
|
||||
let bytes = meta_json.as_bytes();
|
||||
// Locate the `"args"` key, then the `{` that opens its value object.
|
||||
let Some(args_pos) = meta_json.find("\"args\"") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(brace_off) = meta_json[args_pos..].find('{') else {
|
||||
return Vec::new();
|
||||
};
|
||||
let open = args_pos + brace_off;
|
||||
let mut keys = Vec::new();
|
||||
let mut depth = 0i32;
|
||||
let mut in_str = false;
|
||||
let mut esc = false;
|
||||
let mut cur = String::new();
|
||||
let mut last_str: Option<String> = None;
|
||||
for &b in bytes.iter().skip(open) {
|
||||
if in_str {
|
||||
if esc {
|
||||
esc = false;
|
||||
} else if b == b'\\' {
|
||||
esc = true;
|
||||
} else if b == b'"' {
|
||||
in_str = false;
|
||||
last_str = Some(std::mem::take(&mut cur));
|
||||
} else {
|
||||
cur.push(b as char);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match b {
|
||||
b'"' => in_str = true,
|
||||
b'{' => depth += 1,
|
||||
b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break; // end of the args object
|
||||
}
|
||||
}
|
||||
// A `:` at depth 1 means the preceding string was a key of `args`.
|
||||
b':' if depth == 1 => {
|
||||
if let Some(k) = last_str.take() {
|
||||
keys.push(k);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// Build the JS to eval: `(<adapter function>)(<args JSON>)`. The adapter's
|
||||
/// `async function(args)` returns a promise; chrome-use's eval awaits it.
|
||||
pub fn build_eval(adapter: &Adapter, args: &Value) -> String {
|
||||
let args_json = serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string());
|
||||
format!("({})({})", adapter.func_src, args_json)
|
||||
}
|
||||
|
||||
/// List installed adapters as `name/cmd` strings (sorted).
|
||||
pub fn list_adapters() -> Result<Vec<String>, String> {
|
||||
let dir = sites_dir().ok_or("site: cannot resolve home dir")?;
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for site in std::fs::read_dir(&dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.flatten()
|
||||
{
|
||||
if !site.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = site.file_name().to_string_lossy().to_string();
|
||||
for cmd in std::fs::read_dir(site.path())
|
||||
.map_err(|e| e.to_string())?
|
||||
.flatten()
|
||||
{
|
||||
let p = cmd.path();
|
||||
if p.extension().and_then(|e| e.to_str()) == Some("js") {
|
||||
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
|
||||
out.push(format!("{name}/{stem}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Download the bb-sites repo zip and extract its adapters into `~/.chrome-use/sites`.
|
||||
pub async fn update() -> Result<usize, String> {
|
||||
let dir = sites_dir().ok_or("site: cannot resolve home dir")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("chrome-use")
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let bytes = client
|
||||
.get(SITES_ZIP_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("site update: download failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("site update: {e}"))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("site update: read body: {e}"))?;
|
||||
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let mut zip = zip::ZipArchive::new(cursor).map_err(|e| format!("site update: bad zip: {e}"))?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let mut count = 0usize;
|
||||
for i in 0..zip.len() {
|
||||
let mut f = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
let Some(enclosed) = f.enclosed_name() else {
|
||||
continue;
|
||||
};
|
||||
// Strip the top-level `bb-sites-main/` component from the archive path.
|
||||
let rel: PathBuf = enclosed.components().skip(1).collect();
|
||||
if rel.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let out = dir.join(&rel);
|
||||
if f.is_dir() {
|
||||
let _ = std::fs::create_dir_all(&out);
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = out.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
std::io::copy(&mut f, &mut buf).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&out, &buf).map_err(|e| e.to_string())?;
|
||||
if out.extension().and_then(|e| e.to_str()) == Some("js") {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
// Build the domain→adapters index and stamp the sync time so navigation can
|
||||
// suggest adapters (auto-trigger) and `needs_refresh` can pace re-syncs.
|
||||
write_domain_index(&dir);
|
||||
if let Some(p) = last_update_path() {
|
||||
let _ = std::fs::write(p, now_secs().to_string());
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// `~/.chrome-use/sites/.last_update` — unix-seconds marker of the last sync.
|
||||
fn last_update_path() -> Option<PathBuf> {
|
||||
sites_dir().map(|d| d.join(".last_update"))
|
||||
}
|
||||
|
||||
/// `~/.chrome-use/sites/.index.json` — `{ "github.com": ["github/issues", …], … }`,
|
||||
/// built on `update` so navigation can look up adapters by domain without parsing
|
||||
/// all ~145 adapter files on every command.
|
||||
fn index_path() -> Option<PathBuf> {
|
||||
sites_dir().map(|d| d.join(".index.json"))
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Parse every installed adapter and write the domain→adapters index. Within a
|
||||
/// domain, read-only adapters are listed first (then alphabetical) so the
|
||||
/// auto-suggested example leads with a safe read, not a write action.
|
||||
fn write_domain_index(dir: &std::path::Path) {
|
||||
let mut by_domain: std::collections::BTreeMap<String, Vec<(bool, String)>> = Default::default();
|
||||
for spec in list_adapters().unwrap_or_default() {
|
||||
if let Ok(a) = load_adapter(&spec) {
|
||||
if let Some(d) = a.domain() {
|
||||
let read_only = a
|
||||
.meta
|
||||
.get("readOnly")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
by_domain
|
||||
.entry(d.to_string())
|
||||
.or_default()
|
||||
.push((read_only, spec));
|
||||
}
|
||||
}
|
||||
}
|
||||
let ordered: std::collections::BTreeMap<String, Vec<String>> = by_domain
|
||||
.into_iter()
|
||||
.map(|(domain, mut v)| {
|
||||
// read-only (true) first, then by spec name
|
||||
v.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
|
||||
(domain, v.into_iter().map(|(_, s)| s).collect())
|
||||
})
|
||||
.collect();
|
||||
if let Ok(json) = serde_json::to_string(&ordered) {
|
||||
let _ = std::fs::write(dir.join(".index.json"), json);
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TTL_DAYS: u64 = 7;
|
||||
|
||||
/// Whether the adapter pack should be (re)synced: true on first use (nothing
|
||||
/// installed) or when the last sync is older than the TTL. Disabled by
|
||||
/// `AGENT_BROWSER_SITES_NO_AUTO_UPDATE=1`; TTL overridable via
|
||||
/// `AGENT_BROWSER_SITES_TTL_DAYS` (0 = always).
|
||||
pub fn needs_refresh() -> bool {
|
||||
if std::env::var_os("AGENT_BROWSER_SITES_NO_AUTO_UPDATE").is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(dir) = sites_dir() else {
|
||||
return false;
|
||||
};
|
||||
// First use: no adapters installed yet.
|
||||
if list_adapters().map(|l| l.is_empty()).unwrap_or(true) {
|
||||
let _ = &dir;
|
||||
return true;
|
||||
}
|
||||
let ttl_days = std::env::var("AGENT_BROWSER_SITES_TTL_DAYS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(DEFAULT_TTL_DAYS);
|
||||
let ttl = ttl_days.saturating_mul(86_400);
|
||||
match last_update_path().and_then(|p| std::fs::read_to_string(p).ok()) {
|
||||
Some(s) => match s.trim().parse::<u64>() {
|
||||
Ok(ts) => now_secs().saturating_sub(ts) >= ttl,
|
||||
Err(_) => true,
|
||||
},
|
||||
None => true, // no marker → treat as stale
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapters whose `@meta.domain` matches `host` (exact, or `host` is a subdomain
|
||||
/// of it) — for auto-suggesting `site` commands when you land on a known site.
|
||||
/// Reads the prebuilt `.index.json`; empty if the pack isn't synced yet.
|
||||
pub fn adapters_for_domain(host: &str) -> Vec<String> {
|
||||
let host = host.trim_start_matches("www.");
|
||||
let Some(raw) = index_path().and_then(|p| std::fs::read_to_string(p).ok()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(idx) = serde_json::from_str::<std::collections::BTreeMap<String, Vec<String>>>(&raw)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Preserve the index's per-domain ordering (read-only adapters first); just
|
||||
// dedup if a host somehow matches multiple domain keys.
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (domain, specs) in idx {
|
||||
let d = domain.trim_start_matches("www.");
|
||||
if host == d || host.ends_with(&format!(".{d}")) {
|
||||
for s in specs {
|
||||
if !out.contains(&s) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Map CLI args to the adapter's `args` object. Positional args fill the adapter's
|
||||
/// declared `args` keys in order; `--key value` overrides by name. The adapter
|
||||
/// validates required args itself.
|
||||
pub fn map_args(adapter: &Adapter, positional: &[String], named: &[(String, String)]) -> Value {
|
||||
let mut obj = serde_json::Map::new();
|
||||
// Positional args fill the adapter's declared args in DECLARATION order
|
||||
// (`arg_order`), not serde's alphabetized key order — otherwise a 2-arg
|
||||
// adapter like `{projectId, path}` would map positionals to `{path, projectId}`.
|
||||
for (i, val) in positional.iter().enumerate() {
|
||||
if let Some(k) = adapter.arg_order.get(i) {
|
||||
obj.insert(k.clone(), Value::String(val.clone()));
|
||||
}
|
||||
}
|
||||
for (k, v) in named {
|
||||
obj.insert(k.clone(), Value::String(v.clone()));
|
||||
}
|
||||
Value::Object(obj)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"/* @meta
|
||||
{
|
||||
"name": "github/issues",
|
||||
"domain": "github.com",
|
||||
"args": { "repo": {"required": true}, "state": {"required": false} }
|
||||
}
|
||||
*/
|
||||
|
||||
async function(args) { return { repo: args.repo }; }"#;
|
||||
|
||||
#[test]
|
||||
fn parses_meta_and_function() {
|
||||
let a = parse_adapter(SAMPLE, "github/issues").unwrap();
|
||||
assert_eq!(a.domain(), Some("github.com"));
|
||||
assert!(a.func_src.starts_with("async function(args)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_eval_wraps_and_passes_args() {
|
||||
let a = parse_adapter(SAMPLE, "github/issues").unwrap();
|
||||
let args = map_args(
|
||||
&a,
|
||||
&["owner/repo".into()],
|
||||
&[("state".into(), "closed".into())],
|
||||
);
|
||||
let js = build_eval(&a, &args);
|
||||
assert!(js.contains("async function(args)"));
|
||||
assert!(js.contains("\"repo\":\"owner/repo\""));
|
||||
assert!(js.contains("\"state\":\"closed\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_spec() {
|
||||
assert!(load_adapter("noslash").is_err());
|
||||
assert!(load_adapter("../etc/passwd").is_err());
|
||||
}
|
||||
|
||||
// Regression: positional args must follow DECLARATION order, not serde's
|
||||
// alphabetical key order. With `{projectId, path}` (not alphabetical),
|
||||
// `<uuid> <file>` must map projectId←uuid, path←file — not swapped.
|
||||
#[test]
|
||||
fn positional_args_follow_declaration_order_not_alphabetical() {
|
||||
let raw = r#"/* @meta
|
||||
{
|
||||
"name": "claude-design/get-file",
|
||||
"domain": "claude.ai",
|
||||
"args": { "projectId": {"required": true}, "path": {"required": true} }
|
||||
}
|
||||
*/
|
||||
async function(args){ return args; }"#;
|
||||
let a = parse_adapter(raw, "claude-design/get-file").unwrap();
|
||||
assert_eq!(a.arg_order, vec!["projectId", "path"]);
|
||||
let args = map_args(&a, &["the-uuid".into(), "misonote.dc.html".into()], &[]);
|
||||
assert_eq!(args["projectId"], "the-uuid");
|
||||
assert_eq!(args["path"], "misonote.dc.html");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
use include_dir::{include_dir, Dir};
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::exit;
|
||||
|
||||
use crate::color;
|
||||
|
||||
/// Skill content compiled into the binary so `skills get` works on a
|
||||
/// single-binary install (GitHub Release / install.sh), where there is no
|
||||
/// adjacent `skills/` or `skill-data/` on disk the way an npm install has.
|
||||
static EMBEDDED_SKILLS: Dir = include_dir!("$CARGO_MANIFEST_DIR/../skills");
|
||||
static EMBEDDED_SKILL_DATA: Dir = include_dir!("$CARGO_MANIFEST_DIR/../skill-data");
|
||||
|
||||
struct SkillInfo {
|
||||
name: String,
|
||||
description: String,
|
||||
dir: PathBuf,
|
||||
/// When true, the skill is omitted from `skills list` and `skills get --all`
|
||||
/// but can still be fetched by name via `skills get <name>`. Used for
|
||||
/// bootstrap stubs that exist for external tooling (e.g. `npx skills add`)
|
||||
/// but aren't the intended entry point for agents already inside the CLI.
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
/// Skill content is split across two directories:
|
||||
///
|
||||
/// - `skills/` — discovery stubs (picked up by `npx skills add`). Carry
|
||||
/// `hidden: true` so they don't show up in `skills list` or `skills get
|
||||
/// --all` inside the CLI, since they exist only to redirect external
|
||||
/// agents to `skills get core`.
|
||||
/// - `skill-data/` — runtime skill content served by the CLI (`core`,
|
||||
/// `electron`, `slack`, `dogfood`, etc.).
|
||||
///
|
||||
/// Both are shipped in the npm package and searched by `discover_skills`.
|
||||
const SKILL_DIRS: &[&str] = &["skills", "skill-data"];
|
||||
|
||||
/// Locate the package root that contains the skill directories.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. AGENT_BROWSER_SKILLS_DIR env var (points directly at a single directory)
|
||||
/// 2. ../ relative to the executable (npm installs: binary is in bin/)
|
||||
/// 3. Walk up from the executable to find a project root with skills/
|
||||
/// (dev builds where binary is in target/debug/ or target/release/)
|
||||
fn find_package_root() -> Option<PathBuf> {
|
||||
if let Ok(exe) = env::current_exe() {
|
||||
let exe = exe.canonicalize().unwrap_or(exe);
|
||||
if let Some(parent) = exe.parent() {
|
||||
// npm install layout: bin/chrome-use-* -> ../
|
||||
let candidate = parent.join("..");
|
||||
if candidate.join("skills").is_dir() {
|
||||
return Some(candidate.canonicalize().unwrap_or(candidate));
|
||||
}
|
||||
|
||||
// dev build layout: walk up from target/debug/ or target/release/
|
||||
let mut dir = parent;
|
||||
loop {
|
||||
if dir.join("skills").is_dir() {
|
||||
return Some(dir.to_path_buf());
|
||||
}
|
||||
match dir.parent() {
|
||||
Some(p) => dir = p,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the binary-embedded skill content to a per-version cache dir on
|
||||
/// first use, returning a package root that contains `skills/` and
|
||||
/// `skill-data/`. Fallback for single-binary installs (GitHub Release /
|
||||
/// install.sh) that have no on-disk skill directories. Version-stamped so an
|
||||
/// upgraded binary re-extracts fresh content.
|
||||
fn embedded_skills_root() -> Option<PathBuf> {
|
||||
let base = dirs::cache_dir()?
|
||||
.join("chrome-use")
|
||||
.join(concat!("skills-", env!("CARGO_PKG_VERSION")));
|
||||
let marker = base.join(".extracted");
|
||||
if !marker.exists() {
|
||||
let _ = fs::create_dir_all(base.join("skills"));
|
||||
let _ = fs::create_dir_all(base.join("skill-data"));
|
||||
if EMBEDDED_SKILLS.extract(base.join("skills")).is_err()
|
||||
|| EMBEDDED_SKILL_DATA
|
||||
.extract(base.join("skill-data"))
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let _ = fs::write(&marker, env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
base.join("skills").is_dir().then_some(base)
|
||||
}
|
||||
|
||||
/// Collect all skill directories to search, respecting the env var override.
|
||||
fn find_skills_dirs() -> Vec<PathBuf> {
|
||||
// Env var override: single directory, used as-is
|
||||
if let Ok(dir) = env::var("AGENT_BROWSER_SKILLS_DIR") {
|
||||
let p = PathBuf::from(dir);
|
||||
if p.is_dir() {
|
||||
return vec![p];
|
||||
}
|
||||
}
|
||||
|
||||
// On-disk package root (npm install layout, or dev build walking up to repo).
|
||||
if let Some(root) = find_package_root() {
|
||||
let dirs: Vec<PathBuf> = SKILL_DIRS
|
||||
.iter()
|
||||
.map(|d| root.join(d))
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
if !dirs.is_empty() {
|
||||
return dirs;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: skill content compiled into the binary (single-binary install).
|
||||
if let Some(root) = embedded_skills_root() {
|
||||
return SKILL_DIRS
|
||||
.iter()
|
||||
.map(|d| root.join(d))
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
}
|
||||
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Parse YAML frontmatter from a SKILL.md file. Returns (name, description, hidden).
|
||||
fn parse_frontmatter(content: &str) -> Option<(String, String, bool)> {
|
||||
let content = content.trim_start();
|
||||
if !content.starts_with("---") {
|
||||
return None;
|
||||
}
|
||||
let after_opening = &content[3..];
|
||||
let end = after_opening.find("\n---")?;
|
||||
let frontmatter = &after_opening[..end];
|
||||
|
||||
let mut name = None;
|
||||
let mut description = None;
|
||||
let mut hidden = false;
|
||||
|
||||
let lines: Vec<&str> = frontmatter.lines().collect();
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
let line = lines[i];
|
||||
if let Some(val) = line.strip_prefix("name:") {
|
||||
name = Some(val.trim().to_string());
|
||||
} else if let Some(val) = line.strip_prefix("description:") {
|
||||
let mut desc = val.trim().to_string();
|
||||
// Consume YAML continuation lines (indented with spaces or tab)
|
||||
while i + 1 < lines.len()
|
||||
&& (lines[i + 1].starts_with(" ") || lines[i + 1].starts_with('\t'))
|
||||
{
|
||||
i += 1;
|
||||
desc.push(' ');
|
||||
desc.push_str(lines[i].trim());
|
||||
}
|
||||
description = Some(desc);
|
||||
} else if let Some(val) = line.strip_prefix("hidden:") {
|
||||
hidden = matches!(val.trim(), "true" | "yes");
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
Some((name?, description.unwrap_or_default(), hidden))
|
||||
}
|
||||
|
||||
/// Discover all skills across the given directories.
|
||||
fn discover_skills(dirs: &[PathBuf]) -> Vec<SkillInfo> {
|
||||
let mut skills = Vec::new();
|
||||
|
||||
for skills_dir in dirs {
|
||||
let entries = match fs::read_dir(skills_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let skill_md = path.join("SKILL.md");
|
||||
if !skill_md.exists() {
|
||||
continue;
|
||||
}
|
||||
let content = match fs::read_to_string(&skill_md) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let Some((name, description, hidden)) = parse_frontmatter(&content) {
|
||||
skills.push(SkillInfo {
|
||||
name,
|
||||
description,
|
||||
dir: path,
|
||||
hidden,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
skills.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
skills
|
||||
}
|
||||
|
||||
fn truncate_description(desc: &str, max_len: usize) -> String {
|
||||
if desc.len() <= max_len {
|
||||
return desc.to_string();
|
||||
}
|
||||
let boundary = desc
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i <= max_len)
|
||||
.last()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(max_len);
|
||||
let end = desc[..boundary].rfind(' ').unwrap_or(boundary);
|
||||
format!("{}...", &desc[..end])
|
||||
}
|
||||
|
||||
/// Read the full SKILL.md content (including frontmatter).
|
||||
fn read_skill_full(skill_md: &Path) -> Option<String> {
|
||||
fs::read_to_string(skill_md).ok()
|
||||
}
|
||||
|
||||
/// Collect all supplementary files (references/, templates/) for a skill.
|
||||
fn collect_supplementary_files(skill_dir: &Path) -> Vec<(String, String)> {
|
||||
let mut files = Vec::new();
|
||||
for subdir_name in &["references", "templates"] {
|
||||
let subdir = skill_dir.join(subdir_name);
|
||||
if !subdir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let mut entries: Vec<_> = match fs::read_dir(&subdir) {
|
||||
Ok(e) => e.flatten().collect(),
|
||||
Err(_) => continue,
|
||||
};
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
let rel = format!(
|
||||
"{}/{}",
|
||||
subdir_name,
|
||||
path.file_name().unwrap_or_default().to_string_lossy()
|
||||
);
|
||||
files.push((rel, content));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
fn run_list(skills_dirs: &[PathBuf], json_mode: bool) {
|
||||
let skills: Vec<SkillInfo> = discover_skills(skills_dirs)
|
||||
.into_iter()
|
||||
.filter(|s| !s.hidden)
|
||||
.collect();
|
||||
if skills.is_empty() {
|
||||
if json_mode {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({ "success": true, "data": [] })).unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
println!("No skills found");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if json_mode {
|
||||
let items: Vec<serde_json::Value> = skills
|
||||
.iter()
|
||||
.map(|s| {
|
||||
json!({
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({ "success": true, "data": items })).unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
let max_name = skills.iter().map(|s| s.name.len()).max().unwrap_or(0);
|
||||
for s in &skills {
|
||||
println!(
|
||||
" {:<width$} {}",
|
||||
s.name,
|
||||
truncate_description(&s.description, 70),
|
||||
width = max_name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_get(skills_dirs: &[PathBuf], names: &[String], get_all: bool, full: bool, json_mode: bool) {
|
||||
let all_skills = discover_skills(skills_dirs);
|
||||
|
||||
let targets: Vec<&SkillInfo> = if get_all {
|
||||
all_skills.iter().filter(|s| !s.hidden).collect()
|
||||
} else {
|
||||
let mut targets = Vec::new();
|
||||
for name in names {
|
||||
if name.starts_with('-') {
|
||||
eprintln!(
|
||||
"{} Unknown flag ignored: {}",
|
||||
color::warning_indicator(),
|
||||
name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match all_skills.iter().find(|s| s.name == *name) {
|
||||
Some(s) => targets.push(s),
|
||||
None => {
|
||||
if json_mode {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({
|
||||
"success": false,
|
||||
"error": format!("Skill not found: {}", name),
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
eprintln!("{} Skill not found: {}", color::error_indicator(), name);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
targets
|
||||
};
|
||||
|
||||
if targets.is_empty() {
|
||||
if json_mode {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({
|
||||
"success": false,
|
||||
"error": "No skill name provided. Usage: chrome-use skills get <name>",
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} No skill name provided. Usage: chrome-use skills get <name>",
|
||||
color::error_indicator()
|
||||
);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if json_mode {
|
||||
let items: Vec<serde_json::Value> = targets
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let skill_md = s.dir.join("SKILL.md");
|
||||
let content = read_skill_full(&skill_md).unwrap_or_default();
|
||||
let mut obj = json!({
|
||||
"name": s.name,
|
||||
"content": content,
|
||||
});
|
||||
if full {
|
||||
let supplementary = collect_supplementary_files(&s.dir);
|
||||
if !supplementary.is_empty() {
|
||||
let files: Vec<serde_json::Value> = supplementary
|
||||
.iter()
|
||||
.map(|(path, content)| json!({ "path": path, "content": content }))
|
||||
.collect();
|
||||
obj["files"] = json!(files);
|
||||
}
|
||||
}
|
||||
obj
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({ "success": true, "data": items })).unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
for (i, s) in targets.iter().enumerate() {
|
||||
if i > 0 {
|
||||
println!("\n---\n");
|
||||
}
|
||||
let skill_md = s.dir.join("SKILL.md");
|
||||
if let Some(content) = read_skill_full(&skill_md) {
|
||||
print!("{}", content);
|
||||
if !content.ends_with('\n') {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
if full {
|
||||
let supplementary = collect_supplementary_files(&s.dir);
|
||||
for (path, content) in &supplementary {
|
||||
println!("\n--- {} ---\n", path);
|
||||
print!("{}", content);
|
||||
if !content.ends_with('\n') {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_path(skills_dirs: &[PathBuf], name: Option<&str>, json_mode: bool) {
|
||||
match name {
|
||||
Some(name) => {
|
||||
let all_skills = discover_skills(skills_dirs);
|
||||
match all_skills.iter().find(|s| s.name == name) {
|
||||
Some(s) => {
|
||||
let path = s.dir.to_string_lossy().to_string();
|
||||
if json_mode {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({
|
||||
"success": true,
|
||||
"data": { "name": s.name, "path": path },
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
println!("{}", path);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if json_mode {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({
|
||||
"success": false,
|
||||
"error": format!("Skill not found: {}", name),
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
eprintln!("{} Skill not found: {}", color::error_indicator(), name);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let paths: Vec<String> = skills_dirs
|
||||
.iter()
|
||||
.map(|d| d.to_string_lossy().to_string())
|
||||
.collect();
|
||||
if json_mode {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({
|
||||
"success": true,
|
||||
"data": { "paths": paths },
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
for p in &paths {
|
||||
println!("{}", p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_skills(args: &[String], json_mode: bool) {
|
||||
let skills_dirs = find_skills_dirs();
|
||||
if skills_dirs.is_empty() {
|
||||
if json_mode {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({
|
||||
"success": false,
|
||||
"error": "Skills directory not found. Set AGENT_BROWSER_SKILLS_DIR or reinstall via npm.",
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} Skills directory not found. Set AGENT_BROWSER_SKILLS_DIR or reinstall via npm.",
|
||||
color::error_indicator()
|
||||
);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
let subcommand = args.get(1).map(|s| s.as_str());
|
||||
|
||||
match subcommand {
|
||||
None | Some("list") => run_list(&skills_dirs, json_mode),
|
||||
Some("get") => {
|
||||
let names: Vec<String> = args[2..]
|
||||
.iter()
|
||||
.filter(|a| *a != "--full" && *a != "--all")
|
||||
.cloned()
|
||||
.collect();
|
||||
let full = args[2..].iter().any(|a| a == "--full");
|
||||
let get_all = args[2..].iter().any(|a| a == "--all");
|
||||
run_get(&skills_dirs, &names, get_all, full, json_mode);
|
||||
}
|
||||
Some("path") => {
|
||||
let name = args.get(2).map(|s| s.as_str());
|
||||
run_path(&skills_dirs, name, json_mode);
|
||||
}
|
||||
Some(unknown) => {
|
||||
if json_mode {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({
|
||||
"success": false,
|
||||
"error": format!("Unknown skills subcommand: {}", unknown),
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} Unknown skills subcommand: {}",
|
||||
color::error_indicator(),
|
||||
unknown
|
||||
);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn create_test_skill(dir: &Path, name: &str, description: &str) {
|
||||
let skill_dir = dir.join(name);
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
format!(
|
||||
"---\nname: {}\ndescription: {}\n---\n\n# {}\n\nContent here.\n",
|
||||
name, description, name
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_frontmatter_basic() {
|
||||
let content = "---\nname: test-skill\ndescription: A test skill.\n---\n\n# Test\n";
|
||||
let (name, desc, hidden) = parse_frontmatter(content).unwrap();
|
||||
assert_eq!(name, "test-skill");
|
||||
assert_eq!(desc, "A test skill.");
|
||||
assert!(!hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_frontmatter_multiline_description() {
|
||||
let content =
|
||||
"---\nname: test\ndescription: First line\n continued here\n and here\n---\n";
|
||||
let (name, desc, hidden) = parse_frontmatter(content).unwrap();
|
||||
assert_eq!(name, "test");
|
||||
assert_eq!(desc, "First line continued here and here");
|
||||
assert!(!hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_frontmatter_hidden_true() {
|
||||
let content = "---\nname: stub\ndescription: A bootstrap stub.\nhidden: true\n---\n";
|
||||
let (name, desc, hidden) = parse_frontmatter(content).unwrap();
|
||||
assert_eq!(name, "stub");
|
||||
assert_eq!(desc, "A bootstrap stub.");
|
||||
assert!(hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_frontmatter_hidden_false() {
|
||||
let content = "---\nname: visible\ndescription: Visible.\nhidden: false\n---\n";
|
||||
let (_, _, hidden) = parse_frontmatter(content).unwrap();
|
||||
assert!(!hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_frontmatter_no_frontmatter() {
|
||||
let content = "# Just a heading\n\nNo frontmatter here.\n";
|
||||
assert!(parse_frontmatter(content).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_frontmatter_missing_name() {
|
||||
let content = "---\ndescription: No name field\n---\n";
|
||||
assert!(parse_frontmatter(content).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_skills_single_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
create_test_skill(tmp.path(), "alpha", "Alpha skill");
|
||||
create_test_skill(tmp.path(), "beta", "Beta skill");
|
||||
|
||||
// Non-skill directory (no SKILL.md)
|
||||
fs::create_dir_all(tmp.path().join("not-a-skill")).unwrap();
|
||||
fs::write(tmp.path().join("not-a-skill").join("README.md"), "hi").unwrap();
|
||||
|
||||
let dirs = vec![tmp.path().to_path_buf()];
|
||||
let skills = discover_skills(&dirs);
|
||||
assert_eq!(skills.len(), 2);
|
||||
assert_eq!(skills[0].name, "alpha");
|
||||
assert_eq!(skills[1].name, "beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_skills_multiple_dirs() {
|
||||
let tmp1 = tempfile::tempdir().unwrap();
|
||||
let tmp2 = tempfile::tempdir().unwrap();
|
||||
create_test_skill(tmp1.path(), "alpha", "Alpha skill");
|
||||
create_test_skill(tmp2.path(), "beta", "Beta skill");
|
||||
create_test_skill(tmp2.path(), "gamma", "Gamma skill");
|
||||
|
||||
let dirs = vec![tmp1.path().to_path_buf(), tmp2.path().to_path_buf()];
|
||||
let skills = discover_skills(&dirs);
|
||||
assert_eq!(skills.len(), 3);
|
||||
assert_eq!(skills[0].name, "alpha");
|
||||
assert_eq!(skills[1].name, "beta");
|
||||
assert_eq!(skills[2].name, "gamma");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_description() {
|
||||
assert_eq!(truncate_description("short", 10), "short");
|
||||
assert_eq!(
|
||||
truncate_description("this is a longer description that should be truncated", 20),
|
||||
"this is a longer..."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_description_multibyte() {
|
||||
let desc = "Browse \u{00e9}l\u{00e9}ments and \u{65e5}\u{672c}\u{8a9e} pages quickly";
|
||||
let result = truncate_description(desc, 20);
|
||||
assert!(result.ends_with("..."));
|
||||
assert!(result.len() <= 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_supplementary_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let refs_dir = tmp.path().join("references");
|
||||
fs::create_dir_all(&refs_dir).unwrap();
|
||||
fs::write(refs_dir.join("auth.md"), "# Auth\n").unwrap();
|
||||
fs::write(refs_dir.join("commands.md"), "# Commands\n").unwrap();
|
||||
|
||||
let templates_dir = tmp.path().join("templates");
|
||||
fs::create_dir_all(&templates_dir).unwrap();
|
||||
fs::write(templates_dir.join("example.sh"), "#!/bin/bash\n").unwrap();
|
||||
|
||||
let files = collect_supplementary_files(tmp.path());
|
||||
assert_eq!(files.len(), 3);
|
||||
assert_eq!(files[0].0, "references/auth.md");
|
||||
assert_eq!(files[1].0, "references/commands.md");
|
||||
assert_eq!(files[2].0, "templates/example.sh");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
//! `chrome-use test <suite.yaml>` — a tiny, re-runnable browser test runner.
|
||||
//!
|
||||
//! Turns repetitive browser checks into unit-test-style suites for the frontend.
|
||||
//! A suite is a YAML file of cases; each case is a list of `steps` (which reuse
|
||||
//! chrome-use's own commands) followed by `assert`s (which compile to a single
|
||||
//! `eval` expression read back as a boolean). The runner drives the session by
|
||||
//! re-invoking the chrome-use binary per step, so it inherits every flag /
|
||||
//! launch / daemon / `@ref` semantic for free; the daemon stays up for the
|
||||
//! session, so each step is just a fast socket round-trip.
|
||||
//!
|
||||
//! ```yaml
|
||||
//! suite: chatgpt smoke
|
||||
//! setup:
|
||||
//! - account: chatgpt/huayue # cookie-use injects this login (optional)
|
||||
//! cases:
|
||||
//! - name: home loads logged in
|
||||
//! steps:
|
||||
//! - open: https://chatgpt.com/
|
||||
//! - wait: { load: networkidle }
|
||||
//! assert:
|
||||
//! - url: { contains: chatgpt.com }
|
||||
//! - visible: "#prompt-textarea"
|
||||
//! ```
|
||||
|
||||
use crate::flags::Flags;
|
||||
use serde_json::Value;
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
|
||||
pub fn run_test(suite_path: &str, flags: &Flags) -> i32 {
|
||||
let text = match std::fs::read_to_string(suite_path) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("{} cannot read suite '{}': {}", err(), suite_path, e);
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
// YAML deserializes straight into serde_json::Value (maps→objects, etc.).
|
||||
let suite: Value = match serde_yaml::from_str(&text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("{} invalid YAML in '{}': {}", err(), suite_path, e);
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
|
||||
let cases = match suite.get("cases").and_then(|c| c.as_array()) {
|
||||
Some(c) if !c.is_empty() => c.clone(),
|
||||
_ => {
|
||||
eprintln!("{} suite has no `cases`", err());
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
let suite_name = suite
|
||||
.get("suite")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("suite");
|
||||
|
||||
let exe = match std::env::current_exe() {
|
||||
Ok(p) => p.to_string_lossy().into_owned(),
|
||||
Err(e) => {
|
||||
eprintln!("{} cannot find own binary: {}", err(), e);
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
|
||||
// A dedicated launched browser by default (deterministic, re-runnable). If
|
||||
// the user named a --session, target that existing one instead.
|
||||
let (session, do_launch) = if flags.session == "default" {
|
||||
("cu-test".to_string(), true)
|
||||
} else {
|
||||
(flags.session.clone(), flags.force_launch)
|
||||
};
|
||||
let owns_session = session == "cu-test";
|
||||
|
||||
let mut base: Vec<String> = vec!["--session".into(), session.clone()];
|
||||
if do_launch {
|
||||
base.push("--launch".into());
|
||||
}
|
||||
if let Some(p) = &flags.profile {
|
||||
base.push("--profile".into());
|
||||
base.push(p.clone());
|
||||
}
|
||||
|
||||
let artifacts_dir = flags
|
||||
.download_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| "cu-test-artifacts".to_string());
|
||||
|
||||
let runner = Runner {
|
||||
exe,
|
||||
base,
|
||||
artifacts_dir,
|
||||
};
|
||||
|
||||
// --- setup (runs once) ---
|
||||
if let Some(setup) = suite.get("setup").and_then(|s| s.as_array()) {
|
||||
for item in setup {
|
||||
if let Err(e) = runner.run_setup_item(item, &session) {
|
||||
eprintln!("{} setup failed: {}", err(), e);
|
||||
if owns_session {
|
||||
runner.close();
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- cases ---
|
||||
println!("suite: {} (session {})", suite_name, session);
|
||||
let mut passed = 0usize;
|
||||
let mut failed = 0usize;
|
||||
for case in &cases {
|
||||
let name = case
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("(unnamed)");
|
||||
let start = Instant::now();
|
||||
let outcome = runner.run_case(case);
|
||||
let secs = start.elapsed().as_secs_f64();
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
passed += 1;
|
||||
println!(" {} {} {:.1}s", ok(), name, secs);
|
||||
}
|
||||
Err(failure) => {
|
||||
failed += 1;
|
||||
println!(" {} {} {:.1}s", cross(), name, secs);
|
||||
println!(" {}", failure.reason);
|
||||
if let Some(shot) = runner.capture_artifact(name) {
|
||||
println!(" ↳ {}", shot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if owns_session {
|
||||
runner.close();
|
||||
}
|
||||
|
||||
println!(
|
||||
"{} cases · {} passed · {} failed",
|
||||
cases.len(),
|
||||
passed,
|
||||
failed
|
||||
);
|
||||
i32::from(failed > 0)
|
||||
}
|
||||
|
||||
struct Failure {
|
||||
reason: String,
|
||||
}
|
||||
|
||||
struct Runner {
|
||||
exe: String,
|
||||
base: Vec<String>,
|
||||
artifacts_dir: String,
|
||||
}
|
||||
|
||||
impl Runner {
|
||||
/// Run one chrome-use sub-command. Returns the `data` object on success.
|
||||
fn cli(&self, args: &[String]) -> Result<Option<Value>, String> {
|
||||
let out = Command::new(&self.exe)
|
||||
.args(&self.base)
|
||||
.args(args)
|
||||
.arg("--json")
|
||||
.output()
|
||||
.map_err(|e| format!("spawning chrome-use: {}", e))?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
if let Ok(v) = serde_json::from_str::<Value>(stdout.trim()) {
|
||||
let success = v
|
||||
.get("success")
|
||||
.and_then(|b| b.as_bool())
|
||||
.unwrap_or(out.status.success());
|
||||
if !success {
|
||||
return Err(v
|
||||
.get("error")
|
||||
.and_then(|e| e.as_str())
|
||||
.unwrap_or("command failed")
|
||||
.to_string());
|
||||
}
|
||||
return Ok(v.get("data").cloned());
|
||||
}
|
||||
if out.status.success() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn close(&self) {
|
||||
let _ = self.cli(&["close".to_string()]);
|
||||
}
|
||||
|
||||
fn run_setup_item(&self, item: &Value, session: &str) -> Result<(), String> {
|
||||
// `account: <id>` injects a stored cookie-use login into this session.
|
||||
if let Some(acct) = item.get("account").and_then(|a| a.as_str()) {
|
||||
let target = format!("session:{}", session);
|
||||
let out = Command::new("cookie-use")
|
||||
.args(["use", acct, "--target", &target, "--no-open"])
|
||||
.output();
|
||||
return match out {
|
||||
Ok(o) if o.status.success() => Ok(()),
|
||||
Ok(o) => Err(format!(
|
||||
"cookie-use use {} failed: {}",
|
||||
acct,
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
)),
|
||||
Err(e) => Err(format!(
|
||||
"cookie-use not available ({}); skip `account:` or install it",
|
||||
e
|
||||
)),
|
||||
};
|
||||
}
|
||||
// Otherwise it's a normal step.
|
||||
let args = step_to_args(item)?;
|
||||
self.cli(&args).map(|_| ())
|
||||
}
|
||||
|
||||
fn run_case(&self, case: &Value) -> Result<(), Failure> {
|
||||
if let Some(steps) = case.get("steps").and_then(|s| s.as_array()) {
|
||||
for step in steps {
|
||||
let args = step_to_args(step).map_err(|e| Failure {
|
||||
reason: format!("bad step: {}", e),
|
||||
})?;
|
||||
self.cli(&args).map_err(|e| Failure {
|
||||
reason: format!(
|
||||
"step `{}` failed: {}",
|
||||
args.first().cloned().unwrap_or_default(),
|
||||
e
|
||||
),
|
||||
})?;
|
||||
}
|
||||
}
|
||||
if let Some(asserts) = case.get("assert").and_then(|a| a.as_array()) {
|
||||
for a in asserts {
|
||||
let (expr, describe) = assert_to_eval(a).map_err(|e| Failure {
|
||||
reason: format!("bad assert: {}", e),
|
||||
})?;
|
||||
let data = self.cli(&["eval".to_string(), expr]).map_err(|e| Failure {
|
||||
reason: format!("assert `{}` could not run: {}", describe, e),
|
||||
})?;
|
||||
let result = data.as_ref().and_then(|d| d.get("result"));
|
||||
if !is_truthy(result) {
|
||||
let got = result
|
||||
.map(value_short)
|
||||
.unwrap_or_else(|| "undefined".into());
|
||||
return Err(Failure {
|
||||
reason: format!("assert {} → got {}", describe, got),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort screenshot of the failing state. Returns the saved path.
|
||||
fn capture_artifact(&self, case_name: &str) -> Option<String> {
|
||||
let _ = std::fs::create_dir_all(&self.artifacts_dir);
|
||||
let path = format!("{}/{}.png", self.artifacts_dir, slug(case_name));
|
||||
match self.cli(&["screenshot".to_string(), path.clone()]) {
|
||||
Ok(Some(d)) => d
|
||||
.get("path")
|
||||
.and_then(|p| p.as_str())
|
||||
.map(String::from)
|
||||
.or(Some(path)),
|
||||
Ok(None) => Some(path),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a YAML step (a one-key object) to chrome-use CLI args.
|
||||
fn step_to_args(step: &Value) -> Result<Vec<String>, String> {
|
||||
let obj = step
|
||||
.as_object()
|
||||
.ok_or_else(|| "step must be a key: value mapping".to_string())?;
|
||||
let (key, val) = obj.iter().next().ok_or_else(|| "empty step".to_string())?;
|
||||
let s = |v: &Value| v.as_str().map(String::from);
|
||||
match key.as_str() {
|
||||
"open" | "goto" | "navigate" => {
|
||||
let url = s(val).ok_or("open: expected a URL string")?;
|
||||
Ok(vec!["open".into(), url])
|
||||
}
|
||||
"click" => Ok(vec![
|
||||
"click".into(),
|
||||
s(val).ok_or("click: expected a selector")?,
|
||||
]),
|
||||
"press" => Ok(vec!["press".into(), s(val).ok_or("press: expected a key")?]),
|
||||
"eval" => Ok(vec![
|
||||
"eval".into(),
|
||||
s(val).ok_or("eval: expected JS string")?,
|
||||
]),
|
||||
"fill" | "type" => {
|
||||
let sel = field(val, &["sel", "selector"]).ok_or("fill/type: need sel")?;
|
||||
let text = field(val, &["text", "value"]).ok_or("fill/type: need text")?;
|
||||
Ok(vec![key.clone(), sel, text])
|
||||
}
|
||||
"scroll" => {
|
||||
if let Some(dir) = s(val) {
|
||||
Ok(vec!["scroll".into(), dir])
|
||||
} else {
|
||||
let dir = field(val, &["dir", "direction"]).ok_or("scroll: need dir")?;
|
||||
let mut a = vec!["scroll".into(), dir];
|
||||
if let Some(px) = field(val, &["px", "pixels"]) {
|
||||
a.push(px);
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
}
|
||||
"wait" => {
|
||||
if let Some(n) = val.as_i64() {
|
||||
Ok(vec!["wait".into(), n.to_string()])
|
||||
} else if let Some(load) = field(val, &["load"]) {
|
||||
Ok(vec!["wait".into(), "--load".into(), load])
|
||||
} else if let Some(sel) = s(val) {
|
||||
Ok(vec!["wait".into(), sel])
|
||||
} else {
|
||||
Err("wait: expected ms, a selector, or { load: <state> }".into())
|
||||
}
|
||||
}
|
||||
other => Err(format!("unknown step `{}`", other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile a YAML assert (one-key object) into (js-bool-expr, human-describe).
|
||||
fn assert_to_eval(a: &Value) -> Result<(String, String), String> {
|
||||
let obj = a
|
||||
.as_object()
|
||||
.ok_or_else(|| "assert must be a key: value mapping".to_string())?;
|
||||
let (key, val) = obj
|
||||
.iter()
|
||||
.next()
|
||||
.ok_or_else(|| "empty assert".to_string())?;
|
||||
match key.as_str() {
|
||||
"url" => {
|
||||
let (op, want) = str_op(val).ok_or("url: need contains/equals/matches")?;
|
||||
Ok((
|
||||
cmp_expr("location.href", &op, &want),
|
||||
format!("url {} {:?}", op, want),
|
||||
))
|
||||
}
|
||||
"visible" => {
|
||||
let sel = val.as_str().ok_or("visible: expected a selector")?;
|
||||
Ok((visible_expr(sel), format!("visible {:?}", sel)))
|
||||
}
|
||||
"hidden" => {
|
||||
let sel = val.as_str().ok_or("hidden: expected a selector")?;
|
||||
Ok((
|
||||
format!("!({})", visible_expr(sel)),
|
||||
format!("hidden {:?}", sel),
|
||||
))
|
||||
}
|
||||
"text" => {
|
||||
let sel = field(val, &["sel", "selector"]).ok_or("text: need sel")?;
|
||||
let (op, want) = str_op(val).ok_or("text: need contains/equals/matches")?;
|
||||
let base = format!(
|
||||
"((document.querySelector({})||{{}}).textContent||\"\")",
|
||||
js(&sel)
|
||||
);
|
||||
Ok((
|
||||
cmp_expr(&base, &op, &want),
|
||||
format!("text {:?} {} {:?}", sel, op, want),
|
||||
))
|
||||
}
|
||||
"count" => {
|
||||
let sel = field(val, &["sel", "selector"]).ok_or("count: need sel")?;
|
||||
let n = val
|
||||
.get("eq")
|
||||
.or_else(|| val.get("equals"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or("count: need eq: <n>")?;
|
||||
Ok((
|
||||
format!("document.querySelectorAll({}).length==={}", js(&sel), n),
|
||||
format!("count {:?} == {}", sel, n),
|
||||
))
|
||||
}
|
||||
"eval" => {
|
||||
let expr = val.as_str().ok_or("eval: expected JS string")?;
|
||||
Ok((format!("!!({})", expr), format!("eval {:?}", expr)))
|
||||
}
|
||||
other => Err(format!("unknown assert `{}`", other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_expr(sel: &str) -> String {
|
||||
format!(
|
||||
"(function(){{var e=document.querySelector({});return !!(e&&(e.offsetWidth||e.offsetHeight||e.getClientRects().length));}})()",
|
||||
js(sel)
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract (op, want) from `{contains|equals|matches: <str>}`.
|
||||
fn str_op(val: &Value) -> Option<(String, String)> {
|
||||
for op in ["contains", "equals", "matches"] {
|
||||
if let Some(s) = val.get(op).and_then(|v| v.as_str()) {
|
||||
return Some((op.to_string(), s.to_string()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn cmp_expr(base: &str, op: &str, want: &str) -> String {
|
||||
match op {
|
||||
"equals" => format!("({})==={}", base, js(want)),
|
||||
"matches" => format!("new RegExp({}).test({})", js(want), base),
|
||||
_ => format!("({}).includes({})", base, js(want)), // contains
|
||||
}
|
||||
}
|
||||
|
||||
/// First present field among `keys`, as a string.
|
||||
fn field(val: &Value, keys: &[&str]) -> Option<String> {
|
||||
for k in keys {
|
||||
if let Some(v) = val.get(*k) {
|
||||
return match v {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// JSON-encode a string so it embeds safely as a JS literal.
|
||||
fn js(s: &str) -> String {
|
||||
serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into())
|
||||
}
|
||||
|
||||
fn is_truthy(v: Option<&Value>) -> bool {
|
||||
match v {
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(Value::Null) | None => false,
|
||||
Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
|
||||
Some(Value::String(s)) => !s.is_empty(),
|
||||
Some(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_short(v: &Value) -> String {
|
||||
let s = v.to_string();
|
||||
if s.len() > 60 {
|
||||
format!("{}…", &s[..60])
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn slug(name: &str) -> String {
|
||||
let s: String = name
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
s.trim_matches('-').to_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn step_mapping() {
|
||||
assert_eq!(
|
||||
step_to_args(&json!({"open": "https://x.com"})).unwrap(),
|
||||
vec!["open", "https://x.com"]
|
||||
);
|
||||
assert_eq!(
|
||||
step_to_args(&json!({"fill": {"sel": "#a", "text": "hi"}})).unwrap(),
|
||||
vec!["fill", "#a", "hi"]
|
||||
);
|
||||
assert_eq!(
|
||||
step_to_args(&json!({"wait": {"load": "networkidle"}})).unwrap(),
|
||||
vec!["wait", "--load", "networkidle"]
|
||||
);
|
||||
assert_eq!(
|
||||
step_to_args(&json!({"wait": 500})).unwrap(),
|
||||
vec!["wait", "500"]
|
||||
);
|
||||
assert!(step_to_args(&json!({"bogus": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assert_compilation() {
|
||||
let (e, _) = assert_to_eval(&json!({"url": {"contains": "x.com"}})).unwrap();
|
||||
assert!(e.contains("location.href") && e.contains(".includes("));
|
||||
let (e, _) = assert_to_eval(&json!({"count": {"sel": ".a", "eq": 3}})).unwrap();
|
||||
assert!(e.contains("querySelectorAll") && e.ends_with("===3"));
|
||||
let (e, _) = assert_to_eval(&json!({"hidden": "#x"})).unwrap();
|
||||
assert!(e.starts_with("!("));
|
||||
let (e, _) = assert_to_eval(&json!({"eval": "window.ok"})).unwrap();
|
||||
assert_eq!(e, "!!(window.ok)");
|
||||
assert!(assert_to_eval(&json!({"bogus": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truthiness() {
|
||||
assert!(is_truthy(Some(&json!(true))));
|
||||
assert!(!is_truthy(Some(&json!(false))));
|
||||
assert!(!is_truthy(None));
|
||||
assert!(!is_truthy(Some(&json!(""))));
|
||||
assert!(is_truthy(Some(&json!("x"))));
|
||||
assert!(!is_truthy(Some(&json!(0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_escaping() {
|
||||
// Selectors with quotes must embed safely.
|
||||
assert_eq!(js(r#"a"b"#), r#""a\"b""#);
|
||||
}
|
||||
}
|
||||
|
||||
fn ok() -> &'static str {
|
||||
"\x1b[32m✓\x1b[0m"
|
||||
}
|
||||
fn cross() -> &'static str {
|
||||
"\x1b[31m✗\x1b[0m"
|
||||
}
|
||||
fn err() -> &'static str {
|
||||
"\x1b[31merror:\x1b[0m"
|
||||
}
|
||||
+202
-254
@@ -1,284 +1,232 @@
|
||||
use crate::color;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{exit, Command, Stdio};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const NPM_REGISTRY_URL: &str = "https://registry.npmjs.org/agent-browser/latest";
|
||||
|
||||
enum InstallMethod {
|
||||
Npm,
|
||||
Pnpm,
|
||||
Yarn,
|
||||
Bun,
|
||||
Homebrew,
|
||||
Cargo,
|
||||
Unknown,
|
||||
/// Canonical installer for the stealth fork. `upgrade` just re-runs it, so the
|
||||
/// upgrade path and the install path are identical (GitHub Release, no npm).
|
||||
const INSTALL_URL: &str = "https://raw.githubusercontent.com/leeguooooo/chrome-use/main/install.sh";
|
||||
|
||||
/// GitHub API for the latest published release (used by the update check).
|
||||
const LATEST_RELEASE_API: &str =
|
||||
"https://api.github.com/repos/leeguooooo/chrome-use/releases/latest";
|
||||
|
||||
/// Re-check the latest version at most this often (seconds).
|
||||
const UPDATE_CHECK_INTERVAL_SECS: u64 = 86_400; // once a day
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
async fn fetch_latest_version() -> Result<String, String> {
|
||||
let resp = reqwest::get(NPM_REGISTRY_URL)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch version info: {}", e))?;
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse version info: {}", e))?;
|
||||
|
||||
body.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "No version field in registry response".to_string())
|
||||
fn update_cache_path() -> PathBuf {
|
||||
crate::connection::config_home().join("update-check.json")
|
||||
}
|
||||
|
||||
/// Parse the `.install-method` marker written by postinstall.js.
|
||||
fn read_install_method_marker(exe_dir: &Path) -> Option<InstallMethod> {
|
||||
let contents = std::fs::read_to_string(exe_dir.join(".install-method")).ok()?;
|
||||
match contents.trim() {
|
||||
"npm" => Some(InstallMethod::Npm),
|
||||
"pnpm" => Some(InstallMethod::Pnpm),
|
||||
"yarn" => Some(InstallMethod::Yarn),
|
||||
"bun" => Some(InstallMethod::Bun),
|
||||
_ => None,
|
||||
fn write_update_cache(checked_at: u64, latest: &str) {
|
||||
let path = update_cache_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let body = serde_json::json!({ "checked_at": checked_at, "latest": latest }).to_string();
|
||||
let _ = std::fs::write(&path, body);
|
||||
}
|
||||
|
||||
fn detect_install_method() -> InstallMethod {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
// Resolve symlinks to find the real binary location
|
||||
let real_path = exe.canonicalize().unwrap_or(exe);
|
||||
|
||||
// Preferred: read the marker file written at install time
|
||||
if let Some(dir) = real_path.parent() {
|
||||
if let Some(method) = read_install_method_marker(dir) {
|
||||
return method;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: infer from executable path
|
||||
let path_str = real_path.to_string_lossy();
|
||||
|
||||
if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
|
||||
return InstallMethod::Cargo;
|
||||
}
|
||||
|
||||
if path_str.contains("/Cellar/agent-browser/")
|
||||
|| path_str.contains("/homebrew/")
|
||||
|| path_str.contains("/linuxbrew/")
|
||||
{
|
||||
return InstallMethod::Homebrew;
|
||||
}
|
||||
|
||||
if path_str.contains("/pnpm/") || path_str.contains("/pnpm-global/") {
|
||||
return InstallMethod::Pnpm;
|
||||
}
|
||||
|
||||
if path_str.contains("/.yarn/") || path_str.contains("/yarn/global/") {
|
||||
return InstallMethod::Yarn;
|
||||
}
|
||||
|
||||
if path_str.contains("/.bun/") {
|
||||
return InstallMethod::Bun;
|
||||
}
|
||||
|
||||
if path_str.contains("node_modules/agent-browser")
|
||||
|| path_str.contains("node_modules\\agent-browser")
|
||||
{
|
||||
return InstallMethod::Npm;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: probe package managers via subprocess
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
{
|
||||
if command_succeeds("brew", &["list", "agent-browser"]) {
|
||||
return InstallMethod::Homebrew;
|
||||
}
|
||||
}
|
||||
|
||||
if command_output_contains(
|
||||
"pnpm",
|
||||
&["list", "-g", "agent-browser", "--depth=0"],
|
||||
"agent-browser",
|
||||
) {
|
||||
return InstallMethod::Pnpm;
|
||||
}
|
||||
|
||||
if command_output_contains("yarn", &["global", "list", "--depth=0"], "agent-browser") {
|
||||
return InstallMethod::Yarn;
|
||||
}
|
||||
|
||||
if command_output_contains("bun", &["pm", "ls", "-g"], "agent-browser") {
|
||||
return InstallMethod::Bun;
|
||||
}
|
||||
|
||||
if command_succeeds("npm", &["list", "-g", "agent-browser", "--depth=0"]) {
|
||||
return InstallMethod::Npm;
|
||||
}
|
||||
|
||||
InstallMethod::Unknown
|
||||
/// Parse a dotted version (`1.2.1`, `v1.2.1`, `1.2.1-fork.3`) into a comparable
|
||||
/// `(major, minor, patch)`, ignoring any pre-release/build suffix.
|
||||
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
|
||||
let core = v.trim().trim_start_matches('v');
|
||||
let core = core.split(['-', '+']).next().unwrap_or(core);
|
||||
let mut parts = core.split('.');
|
||||
let major = parts.next()?.parse().ok()?;
|
||||
let minor = parts.next().unwrap_or("0").parse().ok()?;
|
||||
let patch = parts.next().unwrap_or("0").parse().ok()?;
|
||||
Some((major, minor, patch))
|
||||
}
|
||||
|
||||
fn command_succeeds(cmd: &str, args: &[&str]) -> bool {
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
fn is_newer(latest: &str, current: &str) -> bool {
|
||||
matches!((parse_version(latest), parse_version(current)), (Some(l), Some(c)) if l > c)
|
||||
}
|
||||
|
||||
fn command_output_contains(cmd: &str, args: &[&str], needle: &str) -> bool {
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.stderr(Stdio::null())
|
||||
/// Public semver-ish comparison (`latest` strictly newer than `current`), so
|
||||
/// `doctor` can flag a stale extension/CLI without re-implementing parsing.
|
||||
pub fn version_is_newer(latest: &str, current: &str) -> bool {
|
||||
is_newer(latest, current)
|
||||
}
|
||||
|
||||
/// The latest CLI version recorded by the background update check, if any.
|
||||
/// `doctor` uses it to show "a newer chrome-use is available" without a network
|
||||
/// call (the `__update-check` worker refreshes the cache out of band).
|
||||
pub fn cached_latest_version() -> Option<String> {
|
||||
std::fs::read_to_string(update_cache_path())
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.and_then(|j| {
|
||||
j.get("latest")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Hidden `__update-check` subcommand: fetch the latest release tag and cache it.
|
||||
/// Spawned detached by [`maybe_notify_update`] so the network call never blocks a
|
||||
/// real command. Uses `curl` (no extra deps, matches `upgrade`).
|
||||
pub fn run_update_check() {
|
||||
let latest = Command::new("curl")
|
||||
.args([
|
||||
"-fsSL",
|
||||
"--max-time",
|
||||
"8",
|
||||
"-H",
|
||||
"User-Agent: chrome-use-update-check",
|
||||
LATEST_RELEASE_API,
|
||||
])
|
||||
.output()
|
||||
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains(needle))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn run_upgrade_command(method: &InstallMethod) -> bool {
|
||||
let (cmd, args, display): (&str, &[&str], &str) = match method {
|
||||
InstallMethod::Npm => (
|
||||
"npm",
|
||||
&["install", "-g", "agent-browser@latest"],
|
||||
"npm install -g agent-browser@latest",
|
||||
),
|
||||
InstallMethod::Pnpm => (
|
||||
"pnpm",
|
||||
&["add", "-g", "agent-browser@latest"],
|
||||
"pnpm add -g agent-browser@latest",
|
||||
),
|
||||
// NOTE: `yarn global` is Yarn Classic (v1) only; Yarn Berry (v2+) removed it.
|
||||
// Users on Yarn v2+ won't reach this path — detection falls through to Unknown.
|
||||
InstallMethod::Yarn => (
|
||||
"yarn",
|
||||
&["global", "add", "agent-browser@latest"],
|
||||
"yarn global add agent-browser@latest",
|
||||
),
|
||||
InstallMethod::Bun => (
|
||||
"bun",
|
||||
&["install", "-g", "agent-browser@latest"],
|
||||
"bun install -g agent-browser@latest",
|
||||
),
|
||||
InstallMethod::Homebrew => (
|
||||
"brew",
|
||||
&["upgrade", "agent-browser"],
|
||||
"brew upgrade agent-browser",
|
||||
),
|
||||
InstallMethod::Cargo => (
|
||||
"cargo",
|
||||
&["install", "agent-browser", "--force"],
|
||||
"cargo install agent-browser --force",
|
||||
),
|
||||
InstallMethod::Unknown => return false,
|
||||
};
|
||||
|
||||
println!("Running: {}", display);
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn run_upgrade() {
|
||||
let current = CURRENT_VERSION;
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"{} Failed to create runtime: {}",
|
||||
color::error_indicator(),
|
||||
e
|
||||
);
|
||||
exit(1);
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok())
|
||||
.and_then(|j| {
|
||||
j.get("tag_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim_start_matches('v').to_string())
|
||||
});
|
||||
if let Some(latest) = latest {
|
||||
write_update_cache(now_secs(), &latest);
|
||||
}
|
||||
}
|
||||
|
||||
let latest = match rt.block_on(fetch_latest_version()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Could not check latest version: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
if !latest.is_empty() && current == latest.as_str() {
|
||||
println!(
|
||||
"{} agent-browser is already at the latest version (v{})",
|
||||
color::success_indicator(),
|
||||
current
|
||||
);
|
||||
/// Non-blocking "update available" notice. Called once per command run:
|
||||
/// - prints a one-line hint to **stderr** (never stdout, so `--json` is clean)
|
||||
/// when a cached release is newer than the running binary;
|
||||
/// - refreshes the cached latest version at most once a day via a **detached**
|
||||
/// background process, so the current command never waits on the network.
|
||||
///
|
||||
/// Skipped for meta commands (upgrade/install/doctor/`__*`/--version/--help),
|
||||
/// in CI, in daemon mode, and when CHROME_USE_NO_UPDATE_CHECK /
|
||||
/// AGENT_BROWSER_NO_UPDATE_CHECK is set.
|
||||
pub fn maybe_notify_update() {
|
||||
if std::env::var_os("CHROME_USE_NO_UPDATE_CHECK").is_some()
|
||||
|| std::env::var_os("AGENT_BROWSER_NO_UPDATE_CHECK").is_some()
|
||||
|| std::env::var_os("CI").is_some()
|
||||
|| std::env::var_os("AGENT_BROWSER_DAEMON").is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let first = std::env::args().nth(1).unwrap_or_default();
|
||||
if first.starts_with("__")
|
||||
|| matches!(
|
||||
first.as_str(),
|
||||
"upgrade" | "install" | "doctor" | "dashboard" | "daemon"
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if std::env::args().any(|a| matches!(a.as_str(), "--version" | "-V" | "--help" | "-h")) {
|
||||
return;
|
||||
}
|
||||
|
||||
let method = detect_install_method();
|
||||
let (checked_at, latest) = std::fs::read_to_string(update_cache_path())
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.map(|j| {
|
||||
(
|
||||
j.get("checked_at").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
j.get("latest")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
.unwrap_or((0, String::new()));
|
||||
|
||||
let method_name = match &method {
|
||||
InstallMethod::Npm => "npm",
|
||||
InstallMethod::Pnpm => "pnpm",
|
||||
InstallMethod::Yarn => "yarn",
|
||||
InstallMethod::Bun => "bun",
|
||||
InstallMethod::Homebrew => "Homebrew",
|
||||
InstallMethod::Cargo => "Cargo",
|
||||
InstallMethod::Unknown => "",
|
||||
};
|
||||
|
||||
if matches!(method, InstallMethod::Unknown) {
|
||||
if is_newer(&latest, CURRENT_VERSION) {
|
||||
eprintln!(
|
||||
"{} Could not detect installation method.",
|
||||
color::error_indicator()
|
||||
);
|
||||
eprintln!(" To update manually, run one of:");
|
||||
eprintln!(" npm install -g agent-browser@latest # npm");
|
||||
eprintln!(" pnpm add -g agent-browser@latest # pnpm");
|
||||
eprintln!(" yarn global add agent-browser@latest # yarn");
|
||||
eprintln!(" bun install -g agent-browser@latest # bun");
|
||||
eprintln!(" brew upgrade agent-browser # Homebrew");
|
||||
eprintln!(" cargo install agent-browser --force # Cargo");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
println!("Detected installation via {}.", method_name);
|
||||
|
||||
if !latest.is_empty() {
|
||||
println!(
|
||||
"{}",
|
||||
color::cyan(&format!(
|
||||
"Upgrading agent-browser... v{} → v{}",
|
||||
current, latest
|
||||
))
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
color::cyan(&format!("Upgrading agent-browser (v{})...", current))
|
||||
"{} chrome-use {latest} is available (you have {CURRENT_VERSION}) — run `chrome-use upgrade`",
|
||||
color::warning_indicator()
|
||||
);
|
||||
}
|
||||
|
||||
let success = run_upgrade_command(&method);
|
||||
|
||||
if success {
|
||||
if !latest.is_empty() {
|
||||
println!(
|
||||
"{} Done! v{} → v{}",
|
||||
color::success_indicator(),
|
||||
current,
|
||||
latest
|
||||
);
|
||||
} else {
|
||||
println!("{} Done!", color::success_indicator());
|
||||
// Refresh in the background at most once a day. Bump the timestamp first
|
||||
// (keeping the last-known latest) so concurrent runs don't all spawn a
|
||||
// checker, then fire a detached child that does the network fetch.
|
||||
if now_secs().saturating_sub(checked_at) >= UPDATE_CHECK_INTERVAL_SECS {
|
||||
write_update_cache(now_secs(), &latest);
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let _ = Command::new(exe)
|
||||
.arg("__update-check")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upgrade to the latest GitHub Release.
|
||||
///
|
||||
/// The stealth fork ships as a prebuilt binary attached to a GitHub Release —
|
||||
/// NOT via the npm registry. Earlier this command (inherited from upstream)
|
||||
/// ran `npm/pnpm install -g chrome-use@latest`, which installed the
|
||||
/// UNRELATED upstream `chrome-use` package and clobbered the user's setup.
|
||||
/// Now `upgrade` simply re-runs install.sh into the same directory as the
|
||||
/// current binary, so it always tracks the freshest GitHub Release.
|
||||
pub fn run_upgrade() {
|
||||
println!(
|
||||
"{}",
|
||||
color::cyan(&format!(
|
||||
"Upgrading chrome-use (currently v{}) from the latest GitHub Release...",
|
||||
CURRENT_VERSION
|
||||
))
|
||||
);
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
eprintln!(
|
||||
"{} Automatic upgrade isn't supported on Windows.",
|
||||
color::warning_indicator()
|
||||
);
|
||||
eprintln!(" Download the latest chrome-use-win32-x64.tar.gz from:");
|
||||
eprintln!(" https://github.com/leeguooooo/chrome-use/releases/latest");
|
||||
eprintln!(" and replace chrome-use.exe on your PATH.");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// Install into the SAME directory as the running binary (in-place
|
||||
// upgrade), so we don't create a second copy elsewhere on PATH.
|
||||
let bin_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.canonicalize().ok())
|
||||
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
|
||||
|
||||
let install_cmd = format!("curl -fsSL {} | sh", INSTALL_URL);
|
||||
println!("Running: {}", install_cmd);
|
||||
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c").arg(&install_cmd);
|
||||
if let Some(ref dir) = bin_dir {
|
||||
cmd.env("AGENT_BROWSER_BIN_DIR", dir);
|
||||
}
|
||||
|
||||
let ok = cmd.status().map(|s| s.success()).unwrap_or(false);
|
||||
if ok {
|
||||
println!(
|
||||
"{} Upgrade complete — run `chrome-use --version` to confirm.",
|
||||
color::success_indicator()
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} Upgrade failed. Install manually:",
|
||||
color::error_indicator()
|
||||
);
|
||||
eprintln!(" curl -fsSL {} | sh", INSTALL_URL);
|
||||
exit(1);
|
||||
}
|
||||
} else {
|
||||
eprintln!("{} Upgrade failed.", color::error_indicator());
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Integration tests for `chrome-use doctor`.
|
||||
//!
|
||||
//! These tests spawn the real CLI binary via `env!("CARGO_BIN_EXE_*")` and
|
||||
//! verify the doctor command produces sane output. They override
|
||||
//! `AGENT_BROWSER_SOCKET_DIR` and `HOME` / `USERPROFILE` so the doctor
|
||||
//! inspects a throwaway directory and never touches the user's real state.
|
||||
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_chrome-use");
|
||||
|
||||
fn build_doctor_cmd(tmp: &TempDir, args: &[&str]) -> Command {
|
||||
let socket_dir = tmp.path().join("sockets");
|
||||
let home = tmp.path().join("home");
|
||||
std::fs::create_dir_all(&socket_dir).unwrap();
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
|
||||
let mut cmd = Command::new(BIN);
|
||||
cmd.args(args)
|
||||
.env("AGENT_BROWSER_SOCKET_DIR", &socket_dir)
|
||||
.env("HOME", &home)
|
||||
.env("USERPROFILE", &home)
|
||||
// Keep the launch test's skip-logic deterministic across hosts.
|
||||
.env_remove("AGENT_BROWSER_PROVIDER")
|
||||
.env_remove("AGENT_BROWSER_CDP")
|
||||
// Don't emit color codes into captured stdout.
|
||||
.env("NO_COLOR", "1");
|
||||
cmd
|
||||
}
|
||||
|
||||
// `doctor --offline --quick` runs the full check suite and, on Windows, does
|
||||
// not exit while its stdout is captured by `Command::output()` (the `--help`
|
||||
// variant below exits fine) — so the test would block forever. The 767-test
|
||||
// main suite passes on Windows; this is the one binary-spawning doctor check
|
||||
// that hangs there. Skip it on Windows until the Windows doctor exit/pipe
|
||||
// behavior is fixed; it still runs on Linux/macOS.
|
||||
#[cfg_attr(
|
||||
windows,
|
||||
ignore = "doctor --offline hangs on Windows under captured stdout"
|
||||
)]
|
||||
#[test]
|
||||
fn doctor_offline_quick_json_emits_valid_payload() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let output = build_doctor_cmd(&tmp, &["doctor", "--offline", "--quick", "--json"])
|
||||
.output()
|
||||
.expect("failed to invoke chrome-use doctor");
|
||||
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
|
||||
// Exit code 0 (all pass) or 1 (one or more fails) are both valid outcomes;
|
||||
// the doctor may legitimately report a failure on a host without Chrome.
|
||||
assert!(
|
||||
code == 0 || code == 1,
|
||||
"unexpected exit code {}\nstdout:\n{}\nstderr:\n{}",
|
||||
code,
|
||||
stdout,
|
||||
stderr,
|
||||
);
|
||||
|
||||
let payload: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.unwrap_or_else(|e| panic!("stdout was not JSON: {}\n---\n{}", e, stdout));
|
||||
|
||||
assert!(payload.get("success").is_some(), "missing success field");
|
||||
assert!(payload.get("summary").is_some(), "missing summary field");
|
||||
assert!(payload.get("fixed").is_some(), "missing fixed field");
|
||||
|
||||
let summary = &payload["summary"];
|
||||
assert!(summary["pass"].is_number());
|
||||
assert!(summary["warn"].is_number());
|
||||
assert!(summary["fail"].is_number());
|
||||
|
||||
let checks = payload["checks"]
|
||||
.as_array()
|
||||
.expect("checks should be an array");
|
||||
assert!(!checks.is_empty(), "checks array should not be empty");
|
||||
|
||||
// Every check must have a non-empty id / category / status / message.
|
||||
for c in checks {
|
||||
assert!(
|
||||
c["id"].as_str().is_some_and(|s| !s.is_empty()),
|
||||
"check missing id: {}",
|
||||
c
|
||||
);
|
||||
assert!(
|
||||
c["category"].as_str().is_some_and(|s| !s.is_empty()),
|
||||
"check missing category: {}",
|
||||
c
|
||||
);
|
||||
let status = c["status"].as_str().expect("status should be string");
|
||||
assert!(
|
||||
["pass", "warn", "fail", "info"].contains(&status),
|
||||
"unexpected status {:?}",
|
||||
status
|
||||
);
|
||||
assert!(
|
||||
c["message"].as_str().is_some_and(|s| !s.is_empty()),
|
||||
"check missing message: {}",
|
||||
c
|
||||
);
|
||||
}
|
||||
|
||||
// Check IDs must be unique now that providers / sessions / skipped-launch
|
||||
// states each carry their own ID suffix.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for c in checks {
|
||||
let id = c["id"].as_str().unwrap();
|
||||
assert!(
|
||||
seen.insert(id.to_string()),
|
||||
"duplicate check id in JSON output: {}\nfull payload:\n{}",
|
||||
id,
|
||||
stdout
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_help_describes_flags_and_examples() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let output = build_doctor_cmd(&tmp, &["doctor", "--help"])
|
||||
.output()
|
||||
.expect("failed to invoke chrome-use doctor --help");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"doctor --help should exit 0; got {:?}",
|
||||
output.status
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
|
||||
for needle in [
|
||||
"chrome-use doctor",
|
||||
"--offline",
|
||||
"--quick",
|
||||
"--fix",
|
||||
"--json",
|
||||
"Exit codes",
|
||||
] {
|
||||
assert!(
|
||||
stdout.contains(needle),
|
||||
"doctor --help output missing {:?}\n---\n{}",
|
||||
needle,
|
||||
stdout
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# Multi-platform Rust cross-compilation image
|
||||
FROM rust:1.85-bookworm
|
||||
FROM rust:1.94-bookworm
|
||||
|
||||
# Install cross-compilation toolchains
|
||||
RUN apt-get update && apt-get install -y \
|
||||
|
||||
+32
-15
@@ -1,4 +1,4 @@
|
||||
# Docker Compose for building agent-browser
|
||||
# Docker Compose for building chrome-use
|
||||
# Usage: docker compose -f docker/docker-compose.yml run build-linux
|
||||
# docker compose -f docker/docker-compose.yml run build-windows
|
||||
#
|
||||
@@ -19,18 +19,24 @@ services:
|
||||
echo "Building for Linux platforms (parallel)..."
|
||||
|
||||
# Build both targets in parallel
|
||||
(echo "→ Linux x64" && cargo zigbuild --release --target x86_64-unknown-linux-gnu && cp /build/target/x86_64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-x64 && chmod +x /output/agent-browser-linux-x64 && echo "✓ Linux x64 done") &
|
||||
PID1=$!
|
||||
(echo "→ Linux x64" && cargo zigbuild --release --target x86_64-unknown-linux-gnu && cp /build/target/x86_64-unknown-linux-gnu/release/chrome-use /output/chrome-use-linux-x64 && chmod +x /output/chrome-use-linux-x64 && echo "✓ Linux x64 done") &
|
||||
PID1=$$!
|
||||
|
||||
(echo "→ Linux ARM64" && cargo zigbuild --release --target aarch64-unknown-linux-gnu && cp /build/target/aarch64-unknown-linux-gnu/release/agent-browser /output/agent-browser-linux-arm64 && chmod +x /output/agent-browser-linux-arm64 && echo "✓ Linux ARM64 done") &
|
||||
PID2=$!
|
||||
(echo "→ Linux ARM64" && cargo zigbuild --release --target aarch64-unknown-linux-gnu && cp /build/target/aarch64-unknown-linux-gnu/release/chrome-use /output/chrome-use-linux-arm64 && chmod +x /output/chrome-use-linux-arm64 && echo "✓ Linux ARM64 done") &
|
||||
PID2=$$!
|
||||
|
||||
# Wait for both to complete
|
||||
wait $PID1 $PID2
|
||||
# Wait for both and check exit codes individually — without this
|
||||
# the outer script exits 0 even if one of the parallel builds
|
||||
# failed, silently leaving a stale binary in /output from the
|
||||
# previous release. Caused 0.27.0-fork.5 to ship with a stale
|
||||
# linux-x64 binary at the first publish attempt until caught
|
||||
# manually by checking the embedded version string.
|
||||
wait $$PID1 || { echo "✗ Linux x64 build failed"; exit 1; }
|
||||
wait $$PID2 || { echo "✗ Linux ARM64 build failed"; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "✓ Linux platforms built successfully!"
|
||||
ls -la /output/agent-browser-linux-*
|
||||
ls -la /output/chrome-use-linux-*
|
||||
'
|
||||
|
||||
# Build for Windows
|
||||
@@ -47,11 +53,11 @@ services:
|
||||
echo "Building for Windows x64..."
|
||||
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
cp /build/target/x86_64-pc-windows-gnu/release/agent-browser.exe /output/agent-browser-win32-x64.exe
|
||||
cp /build/target/x86_64-pc-windows-gnu/release/chrome-use.exe /output/chrome-use-win32-x64.exe
|
||||
|
||||
echo ""
|
||||
echo "✓ Windows build completed!"
|
||||
ls -la /output/agent-browser-win32-*
|
||||
ls -la /output/chrome-use-win32-*
|
||||
'
|
||||
|
||||
# Build for a single target (override with TARGET env var)
|
||||
@@ -64,11 +70,22 @@ services:
|
||||
- ../bin:/output
|
||||
environment:
|
||||
- TARGET=${TARGET:-x86_64-unknown-linux-gnu}
|
||||
- OUTPUT_NAME=${OUTPUT_NAME:-agent-browser-linux-x64}
|
||||
- OUTPUT_NAME=${OUTPUT_NAME:-chrome-use-linux-x64}
|
||||
# NOTE: $$ escapes a literal $ for the in-container shell. A single $ is
|
||||
# interpolated by docker compose at YAML parse time against the *host*
|
||||
# environment, which silently drops script-local variables like SRC
|
||||
# (caused 0.27.0-fork.7 to ship with a stale linux-arm64 binary because
|
||||
# the cp command resolved to `cp "" "/output/"` after compose ate $SRC
|
||||
# and $OUTPUT_NAME). $TARGET / $OUTPUT_NAME are set via `environment:`
|
||||
# below — those are also passed into the container, so $$TARGET and
|
||||
# $$OUTPUT_NAME read them at script time.
|
||||
command: |
|
||||
-c '
|
||||
cargo zigbuild --release --target $TARGET
|
||||
cp /build/target/$TARGET/release/agent-browser* /output/$OUTPUT_NAME
|
||||
chmod +x /output/$OUTPUT_NAME 2>/dev/null || true
|
||||
echo "✓ Built $OUTPUT_NAME"
|
||||
set -e
|
||||
cargo zigbuild --release --target $$TARGET
|
||||
SRC="/build/target/$$TARGET/release/chrome-use"
|
||||
if [ -f "$$SRC.exe" ]; then SRC="$$SRC.exe"; fi
|
||||
cp "$$SRC" "/output/$$OUTPUT_NAME"
|
||||
chmod +x /output/$$OUTPUT_NAME 2>/dev/null || true
|
||||
echo "✓ Built $$OUTPUT_NAME"
|
||||
'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user