Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d44a9fb590 |
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "agent-browser",
|
||||
"description": "Browser automation for AI agents",
|
||||
"owner": {
|
||||
"name": "Vercel",
|
||||
"email": "support@vercel.com"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "agent-browser",
|
||||
"description": "Automates browser interactions for web testing, form filling, screenshots, and data extraction",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": ["./skills/agent-browser"],
|
||||
"category": "development"
|
||||
}
|
||||
]
|
||||
}
|
||||
+79
-163
@@ -5,52 +5,53 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
version-sync:
|
||||
name: Version Sync Check
|
||||
typescript:
|
||||
name: TypeScript (Node ${{ matrix.node-version }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check version sync
|
||||
run: node scripts/check-version-sync.js
|
||||
|
||||
rust:
|
||||
name: Rust
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
version: 9
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
workspaces: cli
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --manifest-path cli/Cargo.toml -- --check
|
||||
run: pnpm format:check
|
||||
|
||||
- name: Clippy check
|
||||
run: cargo clippy --manifest-path cli/Cargo.toml -- -D warnings
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
|
||||
rust-cross:
|
||||
rust:
|
||||
name: Rust (${{ matrix.os }} - ${{ matrix.target }})
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- os: macos-latest
|
||||
@@ -67,61 +68,72 @@ jobs:
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
workspaces: cli
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
cli/target/
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('cli/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ matrix.target }}-
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
native-e2e:
|
||||
name: Native E2E Tests
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
needs: rust
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: cli
|
||||
|
||||
- name: Install Chrome
|
||||
run: |
|
||||
cargo run --manifest-path cli/Cargo.toml -- install --with-deps
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cargo test --profile ci --manifest-path cli/Cargo.toml e2e -- --ignored --test-threads=1
|
||||
run: cargo test --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
windows-integration:
|
||||
name: Windows Integration Test
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: windows-latest
|
||||
needs: rust-cross
|
||||
needs: rust
|
||||
|
||||
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: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Cache Cargo dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
workspaces: cli
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
cli/target/
|
||||
key: windows-cargo-x86_64-pc-windows-msvc-${{ hashFiles('cli/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
windows-cargo-x86_64-pc-windows-msvc-
|
||||
|
||||
- name: Build Rust CLI
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target x86_64-pc-windows-msvc
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build TypeScript
|
||||
run: pnpm build
|
||||
|
||||
- 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
|
||||
@@ -129,113 +141,17 @@ jobs:
|
||||
- name: Test agent-browser install command
|
||||
run: |
|
||||
$env:PATH = "$pwd\bin;$env:PATH"
|
||||
for ($i = 1; $i -le 3; $i++) {
|
||||
bin/agent-browser-win32-x64.exe install
|
||||
if ($LASTEXITCODE -eq 0) { exit 0 }
|
||||
Write-Host "Attempt $i failed, retrying in 10 seconds..."
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
exit 1
|
||||
bin/agent-browser-win32-x64.exe install
|
||||
shell: pwsh
|
||||
timeout-minutes: 10
|
||||
|
||||
- name: Test daemon lifecycle (open, snapshot, close)
|
||||
- name: Verify Chromium was installed
|
||||
run: |
|
||||
$env:PATH = "$pwd\bin;$env:PATH"
|
||||
Write-Host "--- Opening page ---"
|
||||
bin/agent-browser-win32-x64.exe 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
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "snapshot failed"; exit 1 }
|
||||
Write-Host $snapshot
|
||||
Write-Host "--- Closing browser ---"
|
||||
bin/agent-browser-win32-x64.exe close
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "close failed"; exit 1 }
|
||||
Write-Host "--- Windows daemon lifecycle test passed ---"
|
||||
shell: pwsh
|
||||
timeout-minutes: 5
|
||||
|
||||
global-install:
|
||||
name: Global Install (${{ matrix.os }})
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: rust-cross
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: agent-browser-linux-x64
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
binary: agent-browser-darwin-arm64
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: agent-browser-win32-x64.exe
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: cli
|
||||
|
||||
- name: Build Rust CLI
|
||||
run: cargo build --release --manifest-path cli/Cargo.toml --target ${{ matrix.target }}
|
||||
|
||||
- name: Copy CLI binary to bin directory (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
run: cp cli/target/${{ matrix.target }}/release/agent-browser 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 }}
|
||||
|
||||
- name: Test npm global install
|
||||
run: |
|
||||
npm pack
|
||||
npm install -g agent-browser-*.tgz
|
||||
agent-browser --version
|
||||
shell: bash
|
||||
|
||||
- name: Verify symlink points to native binary (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
SYMLINK=$(npm prefix -g)/bin/agent-browser
|
||||
TARGET=$(readlink "$SYMLINK")
|
||||
echo "Symlink: $SYMLINK"
|
||||
echo "Target: $TARGET"
|
||||
if [[ "$TARGET" != *"${{ matrix.binary }}"* ]]; then
|
||||
echo "ERROR: Symlink should point to native binary, not JS wrapper"
|
||||
exit 1
|
||||
fi
|
||||
echo "Symlink correctly points to native binary"
|
||||
shell: bash
|
||||
|
||||
- name: Verify shim points to native binary (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
$shimPath = "$(npm prefix -g)\agent-browser.cmd"
|
||||
$content = Get-Content $shimPath -Raw
|
||||
echo "Shim path: $shimPath"
|
||||
echo "Shim content:"
|
||||
echo $content
|
||||
if ($content -notmatch "agent-browser-win32-x64\.exe") {
|
||||
echo "ERROR: Shim should point to native .exe, not JS wrapper"
|
||||
$playwrightPath = "$env:LOCALAPPDATA\ms-playwright"
|
||||
if (Test-Path $playwrightPath) {
|
||||
Write-Host "Playwright browsers installed at: $playwrightPath"
|
||||
Get-ChildItem $playwrightPath -Recurse -Depth 2 | Select-Object -First 20
|
||||
} else {
|
||||
Write-Error "Playwright browsers not found!"
|
||||
exit 1
|
||||
}
|
||||
echo "Shim correctly points to native binary"
|
||||
shell: pwsh
|
||||
|
||||
@@ -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 }}
|
||||
-22
@@ -6,7 +6,6 @@ dist/
|
||||
|
||||
# Native binaries (keep the launcher scripts)
|
||||
bin/agent-browser-*
|
||||
bin/.install-method
|
||||
!bin/agent-browser
|
||||
!bin/agent-browser.cmd
|
||||
|
||||
@@ -28,15 +27,10 @@ npm-debug.log*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
||||
# Test artifacts
|
||||
*.png
|
||||
*.jpeg
|
||||
*.jpg
|
||||
*.webm
|
||||
test/e2e/.dogfood-output/
|
||||
|
||||
# Package manager
|
||||
package-lock.json
|
||||
@@ -46,21 +40,5 @@ yarn.lock
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Windows debug instance config
|
||||
scripts/windows-debug/.instance
|
||||
|
||||
# opensrc - source code for packages
|
||||
opensrc/
|
||||
|
||||
# Docs site
|
||||
docs/node_modules/
|
||||
docs/.next/
|
||||
docs/out/
|
||||
docs/package-lock.json
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
|
||||
# next
|
||||
.next/
|
||||
out/
|
||||
|
||||
+1
-2
@@ -1,2 +1 @@
|
||||
node scripts/sync-version.js
|
||||
git add cli/Cargo.toml cli/Cargo.lock
|
||||
pnpm lint-staged
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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."
|
||||
}
|
||||
@@ -2,188 +2,9 @@
|
||||
|
||||
Instructions for AI coding agents working with this codebase.
|
||||
|
||||
## Package Manager
|
||||
|
||||
This project uses **pnpm**. Always use `pnpm` instead of `npm` or `yarn` for installing dependencies, running scripts, etc. (e.g., `pnpm install`, `pnpm run build`).
|
||||
|
||||
## Code Style
|
||||
|
||||
- Do not use emojis in code, output, or documentation. Unicode symbols (✓, ✗, →, ⚠) are acceptable.
|
||||
- In documentation and markdown, never use double hyphens (`--`) as a dash. Use an emdash (—) sparingly when needed. Prefer rewriting the sentence to avoid dashes entirely.
|
||||
- CLI colored output uses `cli/src/color.rs`. This module respects the `NO_COLOR` environment variable. Never use hardcoded ANSI color codes.
|
||||
- CLI flags must always use kebab-case (e.g., `--auto-connect`, `--allow-file-access`). Never use camelCase for flags (e.g., `--autoConnect` is wrong).
|
||||
|
||||
## Documentation
|
||||
|
||||
When adding or changing user-facing features (new flags, commands, behaviors, environment variables, etc.), update **all** of the following:
|
||||
|
||||
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
|
||||
4. `docs/src/app/` — the Next.js docs site (MDX pages)
|
||||
5. Inline doc comments in the relevant source files
|
||||
|
||||
This applies to changes that either human users or AI agents would need to know about. Do not skip any of these locations.
|
||||
|
||||
In the `docs/src/app/` MDX files, always use HTML `<table>` syntax for tables (not markdown pipe tables). This matches the existing convention across the docs site.
|
||||
|
||||
## Dashboard (packages/dashboard)
|
||||
|
||||
- Never use native browser dialogs (`alert`, `confirm`, `prompt`). Use shadcn/ui components (`Dialog`, `AlertDialog`, etc.) instead.
|
||||
- Use param-case (kebab-case) for all file and folder names (e.g., `session-tree.tsx`, not `SessionTree.tsx`). The `ui/` directory follows shadcn conventions which already uses param-case.
|
||||
|
||||
## Releasing
|
||||
|
||||
Releases are manual, single-PR affairs. There is no changesets automation. The maintainer controls the changelog voice and format.
|
||||
|
||||
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
|
||||
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`
|
||||
|
||||
When the PR merges, CI compares `package.json` version to what's on npm. If it differs, it builds all 7 platform binaries, publishes to npm, and creates the GitHub release automatically. The GitHub release body is extracted from the content between the `<!-- release:start -->` and `<!-- release:end -->` markers in `CHANGELOG.md`.
|
||||
|
||||
### Writing the changelog
|
||||
|
||||
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:
|
||||
|
||||
```markdown
|
||||
## 0.24.0
|
||||
|
||||
<!-- release:start -->
|
||||
### New Features
|
||||
|
||||
- **Foo command** - Added `foo` command for bar (#1234)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed **baz** not working when qux is enabled (#1235)
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @somecontributor
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.23.3
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Do not prefix entries with commit hashes. Do not use the changesets `### Patch Changes` / `### Minor Changes` headings. Use descriptive section names instead.
|
||||
|
||||
### Docs changelog
|
||||
|
||||
The docs changelog at `docs/src/app/changelog/page.mdx` mirrors `CHANGELOG.md` but uses a slightly different format. Each entry uses:
|
||||
|
||||
- A `v` prefix on the version (e.g. `## v0.24.0`)
|
||||
- A date line with the full date: `<p className="text-[#888] text-sm">March 30, 2026</p>`
|
||||
- A `---` separator between entries
|
||||
|
||||
Match the existing style in that file.
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a Rust codebase. The browser automation daemon lives in `cli/src/native/` (daemon, actions, browser, CDP client, snapshot, state). The `--engine` flag selects Chrome vs Lightpanda. The `install` command downloads Chrome from Chrome for Testing directly.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
cd cli && cargo test
|
||||
```
|
||||
|
||||
Runs all unit tests (~320 tests). These are fast and don't require Chrome.
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
```bash
|
||||
cd cli && cargo test e2e -- --ignored --test-threads=1
|
||||
```
|
||||
|
||||
Runs 18 e2e tests that launch real headless Chrome instances and exercise the full native daemon command pipeline. Requirements:
|
||||
|
||||
- Chrome must be installed
|
||||
- Must run serially (`--test-threads=1`) to avoid Chrome instance contention
|
||||
- Tests are `#[ignore]`'d so they don't run during normal `cargo test`
|
||||
|
||||
The e2e tests live in `cli/src/native/e2e_tests.rs` and cover: launch/close, navigation, snapshots, screenshots, form interaction, cookies, storage, tabs, element queries, viewport/emulation, domain filtering, diff, state management, error handling, and Phase 8 commands.
|
||||
|
||||
### Linting and Formatting
|
||||
|
||||
```bash
|
||||
cd cli && cargo fmt -- --check # Check formatting
|
||||
cd cli && cargo clippy # Lint
|
||||
```
|
||||
|
||||
## Windows Debugging
|
||||
|
||||
A remote Windows Server 2022 EC2 instance is available for debugging Windows-specific issues. It uses AWS Systems Manager (SSM) with no SSH or open ports. Commands run via `aws ssm send-command` and return stdout/stderr.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The instance must be provisioned first (one-time, by a human):
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/provision.sh
|
||||
```
|
||||
|
||||
Requires: AWS CLI v2 configured with `ec2:*`, `iam:CreateRole`, `iam:AttachRolePolicy`, `ssm:SendCommand`, `ssm:GetCommandInvocation` permissions and a default VPC.
|
||||
|
||||
### Usage
|
||||
|
||||
Start the instance (if stopped):
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/start.sh
|
||||
```
|
||||
|
||||
Run a command on Windows:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "<powershell-command>"
|
||||
```
|
||||
|
||||
Sync the current git branch and rebuild:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/sync.sh
|
||||
```
|
||||
|
||||
Stop the instance when done (avoids cost):
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/stop.sh
|
||||
```
|
||||
|
||||
### Common Workflows
|
||||
|
||||
Run unit tests on Windows:
|
||||
|
||||
```bash
|
||||
./scripts/windows-debug/run.sh "cd C:\agent-browser && 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"
|
||||
```
|
||||
|
||||
Check bootstrap progress (first boot only):
|
||||
|
||||
```bash
|
||||
./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.
|
||||
|
||||
<!-- opensrc:start -->
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
node "%SCRIPT_DIR%..\dist\index.js" %*
|
||||
exit /b %errorlevel%
|
||||
@@ -1 +0,0 @@
|
||||
/Users/leo/github.com/agent-browser/cli/target/release/agent-browser: /Users/leo/github.com/agent-browser/cli/build.rs /Users/leo/github.com/agent-browser/cli/cdp-protocol/browser_protocol.json /Users/leo/github.com/agent-browser/cli/cdp-protocol/js_protocol.json /Users/leo/github.com/agent-browser/cli/src/color.rs /Users/leo/github.com/agent-browser/cli/src/commands.rs /Users/leo/github.com/agent-browser/cli/src/connection.rs /Users/leo/github.com/agent-browser/cli/src/flags.rs /Users/leo/github.com/agent-browser/cli/src/install.rs /Users/leo/github.com/agent-browser/cli/src/main.rs /Users/leo/github.com/agent-browser/cli/src/output.rs /Users/leo/github.com/agent-browser/cli/src/validation.rs
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Cross-platform CLI wrapper for agent-browser
|
||||
*
|
||||
* 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
|
||||
* binary directly (zero overhead).
|
||||
*/
|
||||
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { existsSync, accessSync, chmodSync, constants } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { platform, arch } from 'os';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Detect if the system uses musl libc (e.g. Alpine Linux)
|
||||
function isMusl() {
|
||||
if (platform() !== 'linux') return false;
|
||||
try {
|
||||
const result = execSync('ldd --version 2>&1 || true', { encoding: 'utf8' });
|
||||
return result.toLowerCase().includes('musl');
|
||||
} catch {
|
||||
return existsSync('/lib/ld-musl-x86_64.so.1') || existsSync('/lib/ld-musl-aarch64.so.1');
|
||||
}
|
||||
}
|
||||
|
||||
// Map Node.js platform/arch to binary naming convention
|
||||
function getBinaryName() {
|
||||
const os = platform();
|
||||
const cpuArch = arch();
|
||||
|
||||
let osKey;
|
||||
switch (os) {
|
||||
case 'darwin':
|
||||
osKey = 'darwin';
|
||||
break;
|
||||
case 'linux':
|
||||
osKey = isMusl() ? 'linux-musl' : 'linux';
|
||||
break;
|
||||
case 'win32':
|
||||
osKey = 'win32';
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
let archKey;
|
||||
switch (cpuArch) {
|
||||
case 'x64':
|
||||
case 'x86_64':
|
||||
archKey = 'x64';
|
||||
break;
|
||||
case 'arm64':
|
||||
case 'aarch64':
|
||||
archKey = 'arm64';
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const ext = os === 'win32' ? '.exe' : '';
|
||||
return `agent-browser-${osKey}-${archKey}${ext}`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const binaryName = getBinaryName();
|
||||
|
||||
if (!binaryName) {
|
||||
console.error(`Error: Unsupported platform: ${platform()}-${arch()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const binaryPath = join(__dirname, binaryName);
|
||||
|
||||
if (!existsSync(binaryPath)) {
|
||||
console.error(`Error: No binary found for ${platform()}-${arch()}`);
|
||||
console.error(`Expected: ${binaryPath}`);
|
||||
console.error('');
|
||||
console.error('Run "npm run build:native" to build for your platform,');
|
||||
console.error('or reinstall the package to trigger the postinstall download.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure binary is executable (fixes EACCES on macOS/Linux when postinstall didn't run,
|
||||
// e.g., when using bun which blocks lifecycle scripts by default)
|
||||
if (platform() !== 'win32') {
|
||||
try {
|
||||
accessSync(binaryPath, constants.X_OK);
|
||||
} catch {
|
||||
// Binary exists but isn't executable - fix it
|
||||
try {
|
||||
chmodSync(binaryPath, 0o755);
|
||||
} catch (chmodErr) {
|
||||
console.error(`Error: Cannot make binary executable: ${chmodErr.message}`);
|
||||
console.error('Try running: chmod +x ' + binaryPath);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn the native binary with inherited stdio
|
||||
const child = spawn(binaryPath, process.argv.slice(2), {
|
||||
stdio: 'inherit',
|
||||
windowsHide: false,
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.error(`Error executing binary: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
Generated
+12
-2879
File diff suppressed because it is too large
Load Diff
+2
-42
@@ -1,44 +1,13 @@
|
||||
[package]
|
||||
name = "agent-browser-stealth"
|
||||
version = "0.24.0-fork.2"
|
||||
name = "agent-browser"
|
||||
version = "0.4.3"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/leeguooooo/agent-browser-stealth"
|
||||
homepage = "https://github.com/leeguooooo/agent-browser-stealth"
|
||||
readme = "../README.md"
|
||||
keywords = ["browser", "automation", "ai", "cdp", "chrome"]
|
||||
categories = ["command-line-utilities", "web-programming"]
|
||||
|
||||
[[bin]]
|
||||
name = "agent-browser"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
regex-lite = "0.1"
|
||||
dirs = "5.0"
|
||||
base64 = "0.22"
|
||||
getrandom = "0.2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "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"] }
|
||||
sha2 = "0.10"
|
||||
aes-gcm = "0.10"
|
||||
async-trait = "0.1"
|
||||
socket2 = "0.6"
|
||||
similar = "2"
|
||||
zip = { version = "8.2.0", default-features = false, features = ["deflate"] }
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
chrono = "0.4"
|
||||
urlencoding = "2"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -46,17 +15,8 @@ libc = "0.2"
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.52", features = ["Win32_System_Threading", "Win32_Foundation"] }
|
||||
|
||||
[build-dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
||||
[profile.ci]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
|
||||
-481
@@ -1,481 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
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");
|
||||
|
||||
let browser_path = protocol_dir.join("browser_protocol.json");
|
||||
let js_path = protocol_dir.join("js_protocol.json");
|
||||
|
||||
if !browser_path.exists() && !js_path.exists() {
|
||||
fs::write(
|
||||
&out_path,
|
||||
"// No protocol JSON files found in cdp-protocol/\n",
|
||||
)
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
let mut all_domains: Vec<Domain> = Vec::new();
|
||||
|
||||
for path in [&browser_path, &js_path] {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
let protocol: ProtocolSpec = match serde_json::from_str(&content) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("cargo:warning=Failed to parse {}: {}", path.display(), e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
all_domains.extend(protocol.domains);
|
||||
}
|
||||
|
||||
// Collect all known type IDs per domain for cross-domain resolution
|
||||
let mut domain_types: std::collections::HashMap<String, HashSet<String>> =
|
||||
std::collections::HashMap::new();
|
||||
for domain in &all_domains {
|
||||
let mut types = HashSet::new();
|
||||
for td in &domain.types {
|
||||
types.insert(td.id.clone());
|
||||
}
|
||||
domain_types.insert(domain.domain.clone(), types);
|
||||
}
|
||||
|
||||
// Known recursive struct fields that need Box wrapping
|
||||
let recursive_fields: HashSet<(&str, &str, &str)> = [
|
||||
("DOM", "Node", "contentDocument"),
|
||||
("DOM", "Node", "templateContent"),
|
||||
("DOM", "Node", "importedDocument"),
|
||||
("Accessibility", "AXNode", "sources"),
|
||||
("Runtime", "StackTrace", "parent"),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut output = String::new();
|
||||
output.push_str("use serde::{Deserialize, Serialize};\n\n");
|
||||
|
||||
for domain in &all_domains {
|
||||
generate_domain(domain, &domain_types, &recursive_fields, &mut output);
|
||||
}
|
||||
|
||||
fs::write(&out_path, &output).unwrap();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ProtocolSpec {
|
||||
domains: Vec<Domain>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Domain {
|
||||
domain: String,
|
||||
#[serde(default)]
|
||||
types: Vec<TypeDef>,
|
||||
#[serde(default)]
|
||||
commands: Vec<Command>,
|
||||
#[serde(default)]
|
||||
events: Vec<Event>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct TypeDef {
|
||||
id: String,
|
||||
#[serde(rename = "type", default)]
|
||||
type_kind: String,
|
||||
#[serde(default)]
|
||||
properties: Vec<Property>,
|
||||
#[serde(rename = "enum", default)]
|
||||
enum_values: Vec<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Command {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
parameters: Vec<Property>,
|
||||
#[serde(default)]
|
||||
returns: Vec<Property>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Event {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
parameters: Vec<Property>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct Property {
|
||||
name: String,
|
||||
#[serde(rename = "type", default)]
|
||||
type_kind: Option<String>,
|
||||
#[serde(rename = "$ref", default)]
|
||||
ref_type: Option<String>,
|
||||
#[serde(default)]
|
||||
optional: bool,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
items: Option<Box<ItemType>>,
|
||||
#[serde(rename = "enum", default)]
|
||||
enum_values: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Clone)]
|
||||
struct ItemType {
|
||||
#[serde(rename = "type", default)]
|
||||
type_kind: Option<String>,
|
||||
#[serde(rename = "$ref", default)]
|
||||
ref_type: Option<String>,
|
||||
}
|
||||
|
||||
fn to_pascal_case(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut capitalize = true;
|
||||
for c in s.chars() {
|
||||
if c == '_' || c == '-' || c == '.' {
|
||||
capitalize = true;
|
||||
} else if capitalize {
|
||||
result.push(c.to_ascii_uppercase());
|
||||
capitalize = false;
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn to_snake_case(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
if c.is_uppercase() && i > 0 {
|
||||
// Only insert underscore at transitions from lowercase to uppercase,
|
||||
// or when an uppercase sequence ends (e.g. "DOM" -> "dom", not "d_o_m")
|
||||
let prev_upper = chars[i - 1].is_uppercase();
|
||||
let next_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
|
||||
if !prev_upper || next_lower {
|
||||
result.push('_');
|
||||
}
|
||||
}
|
||||
result.push(c.to_ascii_lowercase());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolve a $ref type reference. Cross-domain refs like "Page.FrameId" become
|
||||
/// `super::cdp_page::FrameId`. Same-domain refs are used directly.
|
||||
fn resolve_ref(
|
||||
r: &str,
|
||||
current_domain: &str,
|
||||
domain_types: &std::collections::HashMap<String, HashSet<String>>,
|
||||
) -> String {
|
||||
let parts: Vec<&str> = r.split('.').collect();
|
||||
if parts.len() == 2 {
|
||||
let ref_domain = parts[0];
|
||||
let ref_type = parts[1];
|
||||
if ref_domain == current_domain {
|
||||
to_pascal_case(ref_type)
|
||||
} else {
|
||||
// Check if this type actually exists in the referenced domain
|
||||
if domain_types
|
||||
.get(ref_domain)
|
||||
.is_some_and(|t| t.contains(ref_type))
|
||||
{
|
||||
format!(
|
||||
"super::cdp_{}::{}",
|
||||
to_snake_case(ref_domain),
|
||||
to_pascal_case(ref_type)
|
||||
)
|
||||
} else {
|
||||
// Fall back to serde_json::Value for unknown cross-domain refs
|
||||
"serde_json::Value".to_string()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
to_pascal_case(r)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_type_in_domain(
|
||||
prop: &Property,
|
||||
current_domain: &str,
|
||||
domain_types: &std::collections::HashMap<String, HashSet<String>>,
|
||||
) -> String {
|
||||
if let Some(ref r) = prop.ref_type {
|
||||
let type_name = resolve_ref(r, current_domain, domain_types);
|
||||
if prop.optional {
|
||||
format!("Option<{}>", type_name)
|
||||
} else {
|
||||
type_name
|
||||
}
|
||||
} else if let Some(ref t) = prop.type_kind {
|
||||
let base = match t.as_str() {
|
||||
"string" => "String".to_string(),
|
||||
"integer" => "i64".to_string(),
|
||||
"number" => "f64".to_string(),
|
||||
"boolean" => "bool".to_string(),
|
||||
"object" => "serde_json::Value".to_string(),
|
||||
"any" => "serde_json::Value".to_string(),
|
||||
"array" => {
|
||||
if let Some(ref items) = prop.items {
|
||||
let inner = if let Some(ref r) = items.ref_type {
|
||||
resolve_ref(r, current_domain, domain_types)
|
||||
} else {
|
||||
match items.type_kind.as_deref().unwrap_or("any") {
|
||||
"string" => "String".to_string(),
|
||||
"integer" => "i64".to_string(),
|
||||
"number" => "f64".to_string(),
|
||||
"boolean" => "bool".to_string(),
|
||||
_ => "serde_json::Value".to_string(),
|
||||
}
|
||||
};
|
||||
format!("Vec<{}>", inner)
|
||||
} else {
|
||||
"Vec<serde_json::Value>".to_string()
|
||||
}
|
||||
}
|
||||
_ => "serde_json::Value".to_string(),
|
||||
};
|
||||
if prop.optional {
|
||||
format!("Option<{}>", base)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
} else if prop.optional {
|
||||
"Option<serde_json::Value>".to_string()
|
||||
} else {
|
||||
"serde_json::Value".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_rust_keyword(s: &str) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
"type"
|
||||
| "self"
|
||||
| "Self"
|
||||
| "super"
|
||||
| "move"
|
||||
| "ref"
|
||||
| "fn"
|
||||
| "mod"
|
||||
| "use"
|
||||
| "pub"
|
||||
| "let"
|
||||
| "mut"
|
||||
| "const"
|
||||
| "static"
|
||||
| "if"
|
||||
| "else"
|
||||
| "for"
|
||||
| "while"
|
||||
| "loop"
|
||||
| "match"
|
||||
| "return"
|
||||
| "break"
|
||||
| "continue"
|
||||
| "as"
|
||||
| "in"
|
||||
| "impl"
|
||||
| "trait"
|
||||
| "struct"
|
||||
| "enum"
|
||||
| "where"
|
||||
| "async"
|
||||
| "await"
|
||||
| "dyn"
|
||||
| "box"
|
||||
| "yield"
|
||||
| "override"
|
||||
| "crate"
|
||||
| "extern"
|
||||
)
|
||||
}
|
||||
|
||||
fn generate_domain(
|
||||
domain: &Domain,
|
||||
domain_types: &std::collections::HashMap<String, HashSet<String>>,
|
||||
recursive_fields: &HashSet<(&str, &str, &str)>,
|
||||
output: &mut String,
|
||||
) {
|
||||
let mod_name = to_snake_case(&domain.domain);
|
||||
output.push_str(&format!(
|
||||
"#[allow(dead_code, non_snake_case, non_camel_case_types, clippy::enum_variant_names)]\npub mod cdp_{} {{\n",
|
||||
mod_name
|
||||
));
|
||||
output.push_str(" use super::*;\n\n");
|
||||
|
||||
for type_def in &domain.types {
|
||||
if !type_def.enum_values.is_empty() {
|
||||
// Deduplicate enum variants (some CDP enums have duplicated PascalCase forms)
|
||||
let mut seen_variants = HashSet::new();
|
||||
output.push_str(" #[derive(Debug, Clone, Serialize, Deserialize)]\n");
|
||||
output.push_str(&format!(" pub enum {} {{\n", type_def.id));
|
||||
for val in &type_def.enum_values {
|
||||
let mut variant = to_pascal_case(val);
|
||||
if variant == "Self" {
|
||||
variant = "SelfValue".to_string();
|
||||
}
|
||||
if variant.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
variant = format!("V{}", variant);
|
||||
}
|
||||
if seen_variants.insert(variant.clone()) {
|
||||
output.push_str(&format!(
|
||||
" #[serde(rename = \"{}\")]\n {},\n",
|
||||
val, variant
|
||||
));
|
||||
}
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
} else if type_def.type_kind == "object" && !type_def.properties.is_empty() {
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {} {{\n", type_def.id));
|
||||
for prop in &type_def.properties {
|
||||
let field_name = to_snake_case(&prop.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let mut rust_type = map_type_in_domain(prop, &domain.domain, domain_types);
|
||||
|
||||
// Wrap recursive fields in Box
|
||||
if recursive_fields.contains(&(
|
||||
domain.domain.as_str(),
|
||||
type_def.id.as_str(),
|
||||
prop.name.as_str(),
|
||||
)) {
|
||||
if rust_type.starts_with("Option<") {
|
||||
let inner = &rust_type[7..rust_type.len() - 1];
|
||||
rust_type = format!("Option<Box<{}>>", inner);
|
||||
} else {
|
||||
rust_type = format!("Box<{}>", rust_type);
|
||||
}
|
||||
}
|
||||
|
||||
if prop.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
} else if type_def.type_kind == "object" && type_def.properties.is_empty() {
|
||||
output.push_str(&format!(
|
||||
" pub type {} = serde_json::Value;\n\n",
|
||||
type_def.id
|
||||
));
|
||||
} else if type_def.type_kind == "array" {
|
||||
output.push_str(&format!(
|
||||
" pub type {} = Vec<serde_json::Value>;\n\n",
|
||||
type_def.id
|
||||
));
|
||||
} else if type_def.type_kind == "string" && type_def.enum_values.is_empty() {
|
||||
output.push_str(&format!(" pub type {} = String;\n\n", type_def.id));
|
||||
} else if type_def.type_kind == "integer" {
|
||||
output.push_str(&format!(" pub type {} = i64;\n\n", type_def.id));
|
||||
} else if type_def.type_kind == "number" {
|
||||
output.push_str(&format!(" pub type {} = f64;\n\n", type_def.id));
|
||||
}
|
||||
}
|
||||
|
||||
for cmd in &domain.commands {
|
||||
let pascal_name = to_pascal_case(&cmd.name);
|
||||
|
||||
if !cmd.parameters.is_empty() {
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {}Params {{\n", pascal_name));
|
||||
for param in &cmd.parameters {
|
||||
let field_name = to_snake_case(¶m.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let rust_type = map_type_in_domain(param, &domain.domain, domain_types);
|
||||
if param.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
}
|
||||
|
||||
if !cmd.returns.is_empty() {
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {}Result {{\n", pascal_name));
|
||||
for ret in &cmd.returns {
|
||||
let field_name = to_snake_case(&ret.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let rust_type = map_type_in_domain(ret, &domain.domain, domain_types);
|
||||
if ret.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
for event in &domain.events {
|
||||
if !event.parameters.is_empty() {
|
||||
let pascal_name = to_pascal_case(&event.name);
|
||||
output.push_str(
|
||||
" #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(rename_all = \"camelCase\")]\n",
|
||||
);
|
||||
output.push_str(&format!(" pub struct {}Event {{\n", pascal_name));
|
||||
for param in &event.parameters {
|
||||
let field_name = to_snake_case(¶m.name);
|
||||
let field_name = if is_rust_keyword(&field_name) {
|
||||
format!("r#{}", field_name)
|
||||
} else {
|
||||
field_name
|
||||
};
|
||||
let rust_type = map_type_in_domain(param, &domain.domain, domain_types);
|
||||
if param.optional {
|
||||
output
|
||||
.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
|
||||
}
|
||||
output.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
|
||||
}
|
||||
output.push_str(" }\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
output.push_str("}\n\n");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,158 +0,0 @@
|
||||
//! Color output utilities respecting NO_COLOR environment variable.
|
||||
//!
|
||||
//! When the NO_COLOR environment variable is present (regardless of value),
|
||||
//! all color formatting is disabled per https://no-color.org/
|
||||
|
||||
use std::env;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Returns true if color output is enabled (NO_COLOR is NOT set)
|
||||
pub fn is_enabled() -> bool {
|
||||
static COLORS_ENABLED: OnceLock<bool> = OnceLock::new();
|
||||
*COLORS_ENABLED.get_or_init(|| env::var("NO_COLOR").is_err())
|
||||
}
|
||||
|
||||
/// Format text in red (errors)
|
||||
pub fn red(text: &str) -> String {
|
||||
if is_enabled() {
|
||||
format!("\x1b[31m{}\x1b[0m", text)
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format text in green (success)
|
||||
pub fn green(text: &str) -> String {
|
||||
if is_enabled() {
|
||||
format!("\x1b[32m{}\x1b[0m", text)
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format text in yellow (warnings)
|
||||
pub fn yellow(text: &str) -> String {
|
||||
if is_enabled() {
|
||||
format!("\x1b[33m{}\x1b[0m", text)
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format text in cyan (info/progress)
|
||||
pub fn cyan(text: &str) -> String {
|
||||
if is_enabled() {
|
||||
format!("\x1b[36m{}\x1b[0m", text)
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format text in bold
|
||||
pub fn bold(text: &str) -> String {
|
||||
if is_enabled() {
|
||||
format!("\x1b[1m{}\x1b[0m", text)
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format text in dim
|
||||
pub fn dim(text: &str) -> String {
|
||||
if is_enabled() {
|
||||
format!("\x1b[2m{}\x1b[0m", text)
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Red X error indicator
|
||||
pub fn error_indicator() -> &'static str {
|
||||
static INDICATOR: OnceLock<String> = OnceLock::new();
|
||||
INDICATOR.get_or_init(|| {
|
||||
if is_enabled() {
|
||||
"\x1b[31m✗\x1b[0m".to_string()
|
||||
} else {
|
||||
"✗".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Green checkmark success indicator
|
||||
pub fn success_indicator() -> &'static str {
|
||||
static INDICATOR: OnceLock<String> = OnceLock::new();
|
||||
INDICATOR.get_or_init(|| {
|
||||
if is_enabled() {
|
||||
"\x1b[32m✓\x1b[0m".to_string()
|
||||
} else {
|
||||
"✓".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Yellow warning indicator
|
||||
pub fn warning_indicator() -> &'static str {
|
||||
static INDICATOR: OnceLock<String> = OnceLock::new();
|
||||
INDICATOR.get_or_init(|| {
|
||||
if is_enabled() {
|
||||
"\x1b[33m⚠\x1b[0m".to_string()
|
||||
} else {
|
||||
"⚠".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Get console log color prefix by level
|
||||
pub fn console_level_prefix(level: &str) -> String {
|
||||
if !is_enabled() {
|
||||
return format!("[{}]", level);
|
||||
}
|
||||
|
||||
let color = match level {
|
||||
"error" => "\x1b[31m",
|
||||
"warning" => "\x1b[33m",
|
||||
"info" => "\x1b[36m",
|
||||
_ => "",
|
||||
};
|
||||
if color.is_empty() {
|
||||
format!("[{}]", level)
|
||||
} else {
|
||||
format!("{}[{}]\x1b[0m", color, level)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_red_contains_ansi_codes() {
|
||||
// Test the format structure (actual color depends on NO_COLOR env)
|
||||
let formatted = format!("\x1b[31m{}\x1b[0m", "error");
|
||||
assert!(formatted.contains("\x1b[31m"));
|
||||
assert!(formatted.contains("\x1b[0m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_green_contains_ansi_codes() {
|
||||
let formatted = format!("\x1b[32m{}\x1b[0m", "success");
|
||||
assert!(formatted.contains("\x1b[32m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_console_level_prefix_contains_level() {
|
||||
// Regardless of color state, the level text should be present
|
||||
assert!(console_level_prefix("error").contains("error"));
|
||||
assert!(console_level_prefix("warning").contains("warning"));
|
||||
assert!(console_level_prefix("info").contains("info"));
|
||||
assert!(console_level_prefix("log").contains("log"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_indicators_contain_symbols() {
|
||||
// Regardless of color state, symbols should be present
|
||||
assert!(error_indicator().contains('✗'));
|
||||
assert!(success_indicator().contains('✓'));
|
||||
assert!(warning_indicator().contains('⚠'));
|
||||
}
|
||||
}
|
||||
+191
-3238
File diff suppressed because it is too large
Load Diff
+93
-557
@@ -26,8 +26,6 @@ pub struct Response {
|
||||
pub success: bool,
|
||||
pub data: Option<Value>,
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -83,98 +81,70 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the base directory for socket/pid files.
|
||||
/// Priority: AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR > ~/.agent-browser > tmpdir
|
||||
pub fn get_socket_dir() -> PathBuf {
|
||||
// 1. Explicit override (ignore empty string)
|
||||
if let Ok(dir) = env::var("AGENT_BROWSER_SOCKET_DIR") {
|
||||
if !dir.is_empty() {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Home directory fallback (like Docker Desktop's ~/.docker/run/)
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(".agent-browser");
|
||||
}
|
||||
|
||||
// 4. Last resort: temp dir
|
||||
env::temp_dir().join("agent-browser")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn get_socket_path(session: &str) -> PathBuf {
|
||||
get_socket_dir().join(format!("{}.sock", session))
|
||||
let tmp = env::temp_dir();
|
||||
tmp.join(format!("agent-browser-{}.sock", session))
|
||||
}
|
||||
|
||||
fn get_pid_path(session: &str) -> PathBuf {
|
||||
get_socket_dir().join(format!("{}.pid", session))
|
||||
}
|
||||
|
||||
/// Clean up stale socket and PID files for a session
|
||||
fn cleanup_stale_files(session: &str) {
|
||||
let pid_path = get_pid_path(session);
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let stream_path = get_socket_dir().join(format!("{}.stream", session));
|
||||
let _ = fs::remove_file(&stream_path);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let socket_path = get_socket_path(session);
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let port_path = get_port_path(session);
|
||||
let _ = fs::remove_file(&port_path);
|
||||
}
|
||||
let tmp = env::temp_dir();
|
||||
tmp.join(format!("agent-browser-{}.pid", session))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_port_path(session: &str) -> PathBuf {
|
||||
get_socket_dir().join(format!("{}.port", session))
|
||||
let tmp = env::temp_dir();
|
||||
tmp.join(format!("agent-browser-{}.port", session))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn get_port_for_session(session: &str) -> u16 {
|
||||
fn get_port_for_session(session: &str) -> u16 {
|
||||
let mut hash: i32 = 0;
|
||||
for c in session.chars() {
|
||||
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32);
|
||||
}
|
||||
// Correct logic: first take absolute modulo, then cast to u16
|
||||
// Using unsigned_abs() to safely handle i32::MIN
|
||||
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
|
||||
49152 + ((hash.abs() as u16) % 16383)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_daemon_running(session: &str) -> bool {
|
||||
let pid_path = get_pid_path(session);
|
||||
if !pid_path.exists() {
|
||||
return false;
|
||||
}
|
||||
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
|
||||
if let Ok(pid) = pid_str.trim().parse::<i32>() {
|
||||
unsafe {
|
||||
return libc::kill(pid, 0) == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Read the actual daemon port from the `.port` file written by the daemon.
|
||||
/// Falls back to the hash-derived port if the file does not exist or is
|
||||
/// unreadable (e.g. daemon has not started yet).
|
||||
#[cfg(windows)]
|
||||
pub fn resolve_port(session: &str) -> u16 {
|
||||
let port_path = get_port_path(session);
|
||||
fs::read_to_string(&port_path)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u16>().ok())
|
||||
.unwrap_or_else(|| get_port_for_session(session))
|
||||
fn is_daemon_running(session: &str) -> bool {
|
||||
let pid_path = get_pid_path(session);
|
||||
if !pid_path.exists() {
|
||||
return false;
|
||||
}
|
||||
let port = get_port_for_session(session);
|
||||
TcpStream::connect_timeout(
|
||||
&format!("127.0.0.1:{}", port).parse().unwrap(),
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub fn daemon_ready(session: &str) -> bool {
|
||||
fn daemon_ready(session: &str) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let socket_path = get_socket_path(session);
|
||||
UnixStream::connect(&socket_path).is_ok()
|
||||
get_socket_path(session).exists()
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let port = resolve_port(session);
|
||||
let port = get_port_for_session(session);
|
||||
TcpStream::connect_timeout(
|
||||
&format!("127.0.0.1:{}", port).parse().unwrap(),
|
||||
Duration::from_millis(50),
|
||||
@@ -183,288 +153,93 @@ pub fn daemon_ready(session: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of ensure_daemon indicating whether a new daemon was started
|
||||
pub struct DaemonResult {
|
||||
/// True if we connected to an existing daemon, false if we started a new one
|
||||
pub already_running: bool,
|
||||
}
|
||||
|
||||
/// Options forwarded to the daemon process as environment variables.
|
||||
/// Note: `confirm_interactive` is intentionally absent -- it is a CLI-side
|
||||
/// UX concern (prompting the user on stdin) and not a daemon configuration.
|
||||
/// The daemon only needs `confirm_actions` to gate action categories.
|
||||
pub struct DaemonOptions<'a> {
|
||||
pub headed: bool,
|
||||
pub debug: bool,
|
||||
pub executable_path: Option<&'a str>,
|
||||
pub extensions: &'a [String],
|
||||
pub args: Option<&'a str>,
|
||||
pub user_agent: Option<&'a str>,
|
||||
pub proxy: Option<&'a str>,
|
||||
pub proxy_bypass: Option<&'a str>,
|
||||
pub proxy_username: Option<&'a str>,
|
||||
pub proxy_password: Option<&'a str>,
|
||||
pub ignore_https_errors: bool,
|
||||
pub allow_file_access: bool,
|
||||
pub profile: Option<&'a str>,
|
||||
pub state: Option<&'a str>,
|
||||
pub provider: Option<&'a str>,
|
||||
pub device: Option<&'a str>,
|
||||
pub session_name: Option<&'a str>,
|
||||
pub download_path: Option<&'a str>,
|
||||
pub allowed_domains: Option<&'a [String]>,
|
||||
pub action_policy: Option<&'a str>,
|
||||
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 cdp: Option<&'a str>,
|
||||
pub no_auto_dialog: bool,
|
||||
}
|
||||
|
||||
fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
|
||||
cmd.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
|
||||
if opts.headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
}
|
||||
if opts.debug {
|
||||
cmd.env("AGENT_BROWSER_DEBUG", "1");
|
||||
}
|
||||
if let Some(path) = opts.executable_path {
|
||||
cmd.env("AGENT_BROWSER_EXECUTABLE_PATH", path);
|
||||
}
|
||||
if !opts.extensions.is_empty() {
|
||||
cmd.env("AGENT_BROWSER_EXTENSIONS", opts.extensions.join(","));
|
||||
}
|
||||
if let Some(a) = opts.args {
|
||||
cmd.env("AGENT_BROWSER_ARGS", a);
|
||||
}
|
||||
if let Some(ua) = opts.user_agent {
|
||||
cmd.env("AGENT_BROWSER_USER_AGENT", ua);
|
||||
}
|
||||
if let Some(p) = opts.proxy {
|
||||
cmd.env("AGENT_BROWSER_PROXY", p);
|
||||
}
|
||||
if let Some(pb) = opts.proxy_bypass {
|
||||
cmd.env("AGENT_BROWSER_PROXY_BYPASS", pb);
|
||||
}
|
||||
if let Some(pu) = opts.proxy_username {
|
||||
cmd.env("AGENT_BROWSER_PROXY_USERNAME", pu);
|
||||
}
|
||||
if let Some(pp) = opts.proxy_password {
|
||||
cmd.env("AGENT_BROWSER_PROXY_PASSWORD", pp);
|
||||
}
|
||||
if opts.ignore_https_errors {
|
||||
cmd.env("AGENT_BROWSER_IGNORE_HTTPS_ERRORS", "1");
|
||||
}
|
||||
if opts.allow_file_access {
|
||||
cmd.env("AGENT_BROWSER_ALLOW_FILE_ACCESS", "1");
|
||||
}
|
||||
if let Some(prof) = opts.profile {
|
||||
cmd.env("AGENT_BROWSER_PROFILE", prof);
|
||||
}
|
||||
if let Some(st) = opts.state {
|
||||
cmd.env("AGENT_BROWSER_STATE", st);
|
||||
}
|
||||
if let Some(p) = opts.provider {
|
||||
cmd.env("AGENT_BROWSER_PROVIDER", p);
|
||||
}
|
||||
if let Some(d) = opts.device {
|
||||
cmd.env("AGENT_BROWSER_IOS_DEVICE", d);
|
||||
}
|
||||
if let Some(sn) = opts.session_name {
|
||||
cmd.env("AGENT_BROWSER_SESSION_NAME", sn);
|
||||
}
|
||||
if let Some(dp) = opts.download_path {
|
||||
cmd.env("AGENT_BROWSER_DOWNLOAD_PATH", dp);
|
||||
}
|
||||
if let Some(ad) = opts.allowed_domains {
|
||||
cmd.env("AGENT_BROWSER_ALLOWED_DOMAINS", ad.join(","));
|
||||
}
|
||||
if let Some(ap) = opts.action_policy {
|
||||
cmd.env("AGENT_BROWSER_ACTION_POLICY", ap);
|
||||
}
|
||||
if let Some(ca) = opts.confirm_actions {
|
||||
cmd.env("AGENT_BROWSER_CONFIRM_ACTIONS", ca);
|
||||
}
|
||||
if let Some(engine) = opts.engine {
|
||||
cmd.env("AGENT_BROWSER_ENGINE", engine);
|
||||
}
|
||||
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(cdp) = opts.cdp {
|
||||
cmd.env("AGENT_BROWSER_CDP", cdp);
|
||||
}
|
||||
if opts.no_auto_dialog {
|
||||
cmd.env("AGENT_BROWSER_NO_AUTO_DIALOG", "1");
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// an existing daemon they can reach over the socket.
|
||||
if daemon_ready(session) {
|
||||
// Double-check it's actually responsive by waiting and checking again
|
||||
// This handles the race condition where daemon is shutting down
|
||||
// (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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any stale socket/pid files before starting fresh
|
||||
cleanup_stale_files(session);
|
||||
|
||||
// Ensure socket directory exists
|
||||
let socket_dir = get_socket_dir();
|
||||
if !socket_dir.exists() {
|
||||
fs::create_dir_all(&socket_dir)
|
||||
.map_err(|e| format!("Failed to create socket directory: {}", e))?;
|
||||
}
|
||||
|
||||
// Pre-flight check: Validate socket path length (Unix limit is 104 bytes including null terminator)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let socket_path = get_socket_path(session);
|
||||
let path_len = socket_path.as_os_str().len();
|
||||
if path_len > 103 {
|
||||
return Err(format!(
|
||||
"Session name '{}' is too long. Socket path would be {} bytes (max 103).\n\
|
||||
Use a shorter session name or set AGENT_BROWSER_SOCKET_DIR to a shorter path.",
|
||||
session, path_len
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight check: Verify socket directory is writable
|
||||
{
|
||||
let test_file = socket_dir.join(".write_test");
|
||||
match fs::write(&test_file, b"") {
|
||||
Ok(_) => {
|
||||
let _ = fs::remove_file(&test_file);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!(
|
||||
"Socket directory '{}' is not writable: {}",
|
||||
socket_dir.display(),
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
pub fn ensure_daemon(session: &str, headed: bool) -> Result<(), String> {
|
||||
if is_daemon_running(session) && daemon_ready(session) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let exe_path = env::current_exe().map_err(|e| e.to_string())?;
|
||||
let exe_path = exe_path.canonicalize().unwrap_or(exe_path);
|
||||
let exe_dir = exe_path.parent().unwrap();
|
||||
|
||||
#[allow(unused_assignments)]
|
||||
let mut daemon_child: Option<std::process::Child> = None;
|
||||
let daemon_paths = [
|
||||
exe_dir.join("daemon.js"),
|
||||
exe_dir.join("../dist/daemon.js"),
|
||||
PathBuf::from("dist/daemon.js"),
|
||||
];
|
||||
|
||||
let daemon_path = daemon_paths
|
||||
.iter()
|
||||
.find(|p| p.exists())
|
||||
.ok_or("Daemon not found. Run from project directory or ensure daemon.js is alongside binary.")?;
|
||||
|
||||
// Spawn daemon as a fully detached background process
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let mut cmd = Command::new("node");
|
||||
cmd.arg(daemon_path)
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
|
||||
let mut cmd = Command::new(&exe_path);
|
||||
cmd.env("AGENT_BROWSER_DAEMON", "1");
|
||||
apply_daemon_env(&mut cmd, session, opts);
|
||||
if headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
}
|
||||
|
||||
// Create new process group and session to fully detach
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
// Create new session (detach from terminal)
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
daemon_child = Some(
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?,
|
||||
);
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
// On Windows, use cmd.exe to run node to ensure proper PATH resolution.
|
||||
// This handles cases where node.exe isn't directly in PATH but node.cmd is.
|
||||
// Pass the entire command as a single string to /c to handle paths with spaces.
|
||||
let cmd_string = format!("node \"{}\"", daemon_path.display());
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.arg("/c")
|
||||
.arg(&cmd_string)
|
||||
.env("AGENT_BROWSER_DAEMON", "1")
|
||||
.env("AGENT_BROWSER_SESSION", session);
|
||||
|
||||
let mut cmd = Command::new(&exe_path);
|
||||
cmd.env("AGENT_BROWSER_DAEMON", "1");
|
||||
apply_daemon_env(&mut cmd, session, opts);
|
||||
if headed {
|
||||
cmd.env("AGENT_BROWSER_HEADED", "1");
|
||||
}
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
|
||||
daemon_child = Some(
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?,
|
||||
);
|
||||
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start daemon: {}", e))?;
|
||||
}
|
||||
|
||||
for _ in 0..50 {
|
||||
if daemon_ready(session) {
|
||||
return Ok(DaemonResult {
|
||||
already_running: false,
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Detect early daemon exit and surface the real error from stderr
|
||||
if let Some(ref mut child) = daemon_child {
|
||||
if let Ok(Some(_)) = child.try_wait() {
|
||||
let mut stderr_output = String::new();
|
||||
if let Some(mut stderr) = child.stderr.take() {
|
||||
let _ = stderr.read_to_string(&mut stderr_output);
|
||||
}
|
||||
let stderr_trimmed = stderr_output.trim();
|
||||
if !stderr_trimmed.is_empty() {
|
||||
let msg = if stderr_trimmed.len() > 500 {
|
||||
let mut end = 500;
|
||||
while !stderr_trimmed.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&stderr_trimmed[..end]
|
||||
} else {
|
||||
stderr_trimmed
|
||||
};
|
||||
return Err(format!("Daemon process exited during startup:\n{}", msg));
|
||||
}
|
||||
return Err(
|
||||
"Daemon process exited during startup with no error output. \
|
||||
Re-run with --debug for more details."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let endpoint_info = format!(
|
||||
"socket: {}",
|
||||
get_socket_dir().join(format!("{}.sock", session)).display()
|
||||
);
|
||||
#[cfg(windows)]
|
||||
let endpoint_info = format!("port: 127.0.0.1:{}", resolve_port(session));
|
||||
|
||||
Err(format!("Daemon failed to start ({})", endpoint_info))
|
||||
Err("Daemon failed to start".to_string())
|
||||
}
|
||||
|
||||
fn connect(session: &str) -> Result<Connection, String> {
|
||||
@@ -477,7 +252,7 @@ fn connect(session: &str) -> Result<Connection, String> {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let port = resolve_port(session);
|
||||
let port = get_port_for_session(session);
|
||||
TcpStream::connect(format!("127.0.0.1:{}", port))
|
||||
.map(Connection::Tcp)
|
||||
.map_err(|e| format!("Failed to connect: {}", e))
|
||||
@@ -485,67 +260,12 @@ fn connect(session: &str) -> Result<Connection, String> {
|
||||
}
|
||||
|
||||
pub fn send_command(cmd: Value, session: &str) -> Result<Response, String> {
|
||||
// Retry logic for transient errors (EAGAIN/EWOULDBLOCK/connection issues)
|
||||
const MAX_RETRIES: u32 = 5;
|
||||
const RETRY_DELAY_MS: u64 = 200;
|
||||
|
||||
let mut last_error = String::new();
|
||||
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
if attempt > 0 {
|
||||
thread::sleep(Duration::from_millis(RETRY_DELAY_MS * (attempt as u64)));
|
||||
}
|
||||
|
||||
match send_command_once(&cmd, session) {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(e) => {
|
||||
if is_transient_error(&e) {
|
||||
last_error = e;
|
||||
continue;
|
||||
}
|
||||
// Non-transient error, fail immediately
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"{} (after {} retries - daemon may be busy or unresponsive)",
|
||||
last_error, MAX_RETRIES
|
||||
))
|
||||
}
|
||||
|
||||
/// Check if an error is transient and worth retrying.
|
||||
/// Transient errors include:
|
||||
/// - EAGAIN/EWOULDBLOCK (os error 35 on macOS, 11 on Linux)
|
||||
/// - EOF errors (daemon closed connection before responding)
|
||||
/// - Connection reset/broken pipe (daemon crashed or restarting)
|
||||
/// - Connection refused/socket not found (daemon still starting)
|
||||
fn is_transient_error(error: &str) -> bool {
|
||||
error.contains("os error 35") // EAGAIN on macOS
|
||||
|| error.contains("os error 11") // EAGAIN on Linux
|
||||
|| error.contains("WouldBlock")
|
||||
|| error.contains("Resource temporarily unavailable")
|
||||
|| error.contains("EOF")
|
||||
|| error.contains("line 1 column 0") // Empty JSON response
|
||||
|| error.contains("Connection reset")
|
||||
|| error.contains("Broken pipe")
|
||||
|| error.contains("os error 54") // Connection reset by peer (macOS)
|
||||
|| error.contains("os error 104") // Connection reset by peer (Linux)
|
||||
|| error.contains("os error 2") // No such file or directory (socket gone)
|
||||
|| error.contains("os error 61") // Connection refused (macOS)
|
||||
|| error.contains("os error 111") // Connection refused (Linux)
|
||||
|| error.contains("os error 10061") // Connection refused (Windows)
|
||||
|| error.contains("os error 10054") // Connection reset by peer (Windows)
|
||||
}
|
||||
|
||||
fn send_command_once(cmd: &Value, session: &str) -> Result<Response, String> {
|
||||
let mut stream = connect(session)?;
|
||||
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30))).ok();
|
||||
stream.set_write_timeout(Some(Duration::from_secs(5))).ok();
|
||||
|
||||
let mut json_str = serde_json::to_string(cmd).map_err(|e| e.to_string())?;
|
||||
let mut json_str = serde_json::to_string(&cmd).map_err(|e| e.to_string())?;
|
||||
json_str.push('\n');
|
||||
|
||||
stream
|
||||
@@ -560,187 +280,3 @@ fn send_command_once(cmd: &Value, session: &str) -> Result<Response, String> {
|
||||
|
||||
serde_json::from_str(&response_line).map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
#[test]
|
||||
fn test_get_socket_dir_explicit_override() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "/custom/socket/path");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
assert_eq!(get_socket_dir(), PathBuf::from("/custom/socket/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_socket_dir_ignores_empty_socket_dir() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
assert!(get_socket_dir()
|
||||
.to_string_lossy()
|
||||
.ends_with(".agent-browser"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_socket_dir_xdg_runtime() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
_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")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_socket_dir_ignores_empty_xdg_runtime() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
_guard.set("AGENT_BROWSER_SOCKET_DIR", "");
|
||||
_guard.set("XDG_RUNTIME_DIR", "");
|
||||
|
||||
assert!(get_socket_dir()
|
||||
.to_string_lossy()
|
||||
.ends_with(".agent-browser"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_socket_dir_home_fallback() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "XDG_RUNTIME_DIR"]);
|
||||
|
||||
_guard.remove("AGENT_BROWSER_SOCKET_DIR");
|
||||
_guard.remove("XDG_RUNTIME_DIR");
|
||||
|
||||
let result = get_socket_dir();
|
||||
assert!(result.to_string_lossy().ends_with(".agent-browser"));
|
||||
assert!(
|
||||
result.to_string_lossy().contains("home") || result.to_string_lossy().contains("Users")
|
||||
);
|
||||
}
|
||||
|
||||
// === Transient Error Detection Tests ===
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_eagain_macos() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to read: Resource temporarily unavailable (os error 35)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_eagain_linux() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to read: Resource temporarily unavailable (os error 11)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_would_block() {
|
||||
assert!(is_transient_error("operation WouldBlock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_resource_unavailable() {
|
||||
assert!(is_transient_error("Resource temporarily unavailable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_eof() {
|
||||
assert!(is_transient_error(
|
||||
"Invalid response: EOF while parsing a value at line 1 column 0"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_empty_json() {
|
||||
assert!(is_transient_error(
|
||||
"Invalid response: expected value at line 1 column 0"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_reset() {
|
||||
assert!(is_transient_error("Connection reset by peer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_broken_pipe() {
|
||||
assert!(is_transient_error("Broken pipe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_reset_macos() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to send: Connection reset by peer (os error 54)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_reset_linux() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to send: Connection reset by peer (os error 104)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_socket_not_found() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to connect: No such file or directory (os error 2)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_refused_macos() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to connect: Connection refused (os error 61)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_refused_linux() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to connect: Connection refused (os error 111)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_refused_windows() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to connect: No connection could be made because the target machine actively refused it. (os error 10061)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_connection_reset_windows() {
|
||||
assert!(is_transient_error(
|
||||
"Failed to send: An existing connection was forcibly closed by the remote host. (os error 10054)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_transient_error_non_transient() {
|
||||
// These should NOT be considered transient
|
||||
assert!(!is_transient_error("Unknown command: foo"));
|
||||
assert!(!is_transient_error("Invalid JSON syntax"));
|
||||
assert!(!is_transient_error("Permission denied"));
|
||||
assert!(!is_transient_error("Daemon not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn test_get_port_for_session() {
|
||||
assert_eq!(get_port_for_session("default"), 50838);
|
||||
assert_eq!(get_port_for_session("my-session"), 63105);
|
||||
assert_eq!(get_port_for_session("work"), 51184);
|
||||
assert_eq!(get_port_for_session(""), 49152);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1380
File diff suppressed because it is too large
Load Diff
+144
-778
@@ -1,671 +1,169 @@
|
||||
use crate::color;
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{exit, Command, Stdio};
|
||||
|
||||
const LAST_KNOWN_GOOD_URL: &str =
|
||||
"https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
|
||||
|
||||
pub fn get_browsers_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".agent-browser")
|
||||
.join("browsers")
|
||||
}
|
||||
|
||||
pub fn find_installed_chrome() -> Option<PathBuf> {
|
||||
let browsers_dir = get_browsers_dir();
|
||||
let debug = std::env::var("AGENT_BROWSER_DEBUG").is_ok();
|
||||
|
||||
if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] home_dir={:?} browsers_dir={}",
|
||||
dirs::home_dir(),
|
||||
browsers_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
if !browsers_dir.exists() {
|
||||
if debug {
|
||||
let _ = writeln!(io::stderr(), "[chrome-search] browsers_dir does not exist");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(&browsers_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"Warning: cannot read Chrome cache directory {}: {}",
|
||||
browsers_dir.display(),
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let mut versions: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
let matches = e
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|n| n.starts_with("chrome-"));
|
||||
if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] entry {:?} matches={}",
|
||||
e.file_name(),
|
||||
matches
|
||||
);
|
||||
}
|
||||
matches
|
||||
})
|
||||
.collect();
|
||||
|
||||
versions.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
|
||||
|
||||
for entry in versions {
|
||||
let dir = entry.path();
|
||||
if let Some(bin) = chrome_binary_in_dir(&dir) {
|
||||
let exists = bin.exists();
|
||||
if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] candidate {} exists={}",
|
||||
bin.display(),
|
||||
exists
|
||||
);
|
||||
}
|
||||
if exists {
|
||||
return Some(bin);
|
||||
}
|
||||
} else if debug {
|
||||
let _ = writeln!(
|
||||
io::stderr(),
|
||||
"[chrome-search] no binary found in {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if debug {
|
||||
let _ = writeln!(io::stderr(), "[chrome-search] no installed Chrome found");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn chrome_binary_in_dir(dir: &Path) -> Option<PathBuf> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let app =
|
||||
dir.join("Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
|
||||
if app.exists() {
|
||||
return Some(app);
|
||||
}
|
||||
let inner = dir.join("chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
|
||||
if inner.exists() {
|
||||
return Some(inner);
|
||||
}
|
||||
let inner_x64 = dir.join(
|
||||
"chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
||||
);
|
||||
if inner_x64.exists() {
|
||||
return Some(inner_x64);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let bin = dir.join("chrome");
|
||||
if bin.exists() {
|
||||
return Some(bin);
|
||||
}
|
||||
let inner = dir.join("chrome-linux64/chrome");
|
||||
if inner.exists() {
|
||||
return Some(inner);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let bin = dir.join("chrome.exe");
|
||||
if bin.exists() {
|
||||
return Some(bin);
|
||||
}
|
||||
let inner = dir.join("chrome-win64/chrome.exe");
|
||||
if inner.exists() {
|
||||
return Some(inner);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_key() -> &'static str {
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
{
|
||||
"mac-arm64"
|
||||
}
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
{
|
||||
"mac-x64"
|
||||
}
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
{
|
||||
"linux64"
|
||||
}
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
{
|
||||
"win64"
|
||||
}
|
||||
#[cfg(not(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "macos", target_arch = "x86_64"),
|
||||
all(target_os = "linux", target_arch = "x86_64"),
|
||||
all(target_os = "windows", target_arch = "x86_64"),
|
||||
)))]
|
||||
{
|
||||
// Compiles on unsupported platforms (e.g. linux aarch64) so the binary
|
||||
// can still be used for other commands like `connect`. The install path
|
||||
// guards against this at runtime before calling platform_key().
|
||||
panic!("Unsupported platform for Chrome for Testing download")
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_download_url() -> Result<(String, String), String> {
|
||||
let resp = reqwest::get(LAST_KNOWN_GOOD_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))?;
|
||||
|
||||
let channel = body
|
||||
.get("channels")
|
||||
.and_then(|c| c.get("Stable"))
|
||||
.ok_or("No Stable channel found in version info")?;
|
||||
|
||||
let version = channel
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("No version string found")?
|
||||
.to_string();
|
||||
|
||||
let platform = platform_key();
|
||||
|
||||
let url = channel
|
||||
.get("downloads")
|
||||
.and_then(|d| d.get("chrome"))
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().find_map(|entry| {
|
||||
if entry.get("platform")?.as_str()? == platform {
|
||||
Some(entry.get("url")?.as_str()?.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| format!("No download URL found for platform: {}", platform))?;
|
||||
|
||||
Ok((version, url))
|
||||
}
|
||||
|
||||
async fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
let resp = reqwest::get(url)
|
||||
.await
|
||||
.map_err(|e| format!("Download failed: {}", e))?;
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn extract_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 raw_name = enclosed.to_string_lossy().to_string();
|
||||
// Strip the top-level "chrome-<platform>/" directory from zip entries.
|
||||
// On Windows, enclosed_name() normalizes paths to backslashes, so we
|
||||
// must split on either separator.
|
||||
let rel_path = raw_name
|
||||
.strip_prefix("chrome-")
|
||||
.and_then(|s| s.find(['/', '\\']).map(|i| &s[i + 1..]))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or(raw_name.clone());
|
||||
|
||||
if rel_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let out_path = dest.join(&rel_path);
|
||||
|
||||
// Defense-in-depth: ensure the resolved path is inside dest
|
||||
if !out_path.starts_with(dest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if file.is_dir() {
|
||||
fs::create_dir_all(&out_path)
|
||||
.map_err(|e| format!("Failed to create dir {}: {}", out_path.display(), e))?;
|
||||
} else {
|
||||
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))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Some(mode) = file.unix_mode() {
|
||||
let _ = fs::set_permissions(&out_path, fs::Permissions::from_mode(mode));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_install(with_deps: bool) {
|
||||
if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
|
||||
eprintln!(
|
||||
"{} Chrome for Testing does not provide Linux ARM64 builds.",
|
||||
color::error_indicator()
|
||||
);
|
||||
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");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
let is_linux = cfg!(target_os = "linux");
|
||||
|
||||
if is_linux {
|
||||
if with_deps {
|
||||
install_linux_deps();
|
||||
println!("\x1b[36mInstalling system dependencies...\x1b[0m");
|
||||
|
||||
let (pkg_mgr, deps) = if which_exists("apt-get") {
|
||||
(
|
||||
"apt-get",
|
||||
vec![
|
||||
"libxcb-shm0",
|
||||
"libx11-xcb1",
|
||||
"libx11-6",
|
||||
"libxcb1",
|
||||
"libxext6",
|
||||
"libxrandr2",
|
||||
"libxcomposite1",
|
||||
"libxcursor1",
|
||||
"libxdamage1",
|
||||
"libxfixes3",
|
||||
"libxi6",
|
||||
"libgtk-3-0",
|
||||
"libpangocairo-1.0-0",
|
||||
"libpango-1.0-0",
|
||||
"libatk1.0-0",
|
||||
"libcairo-gobject2",
|
||||
"libcairo2",
|
||||
"libgdk-pixbuf-2.0-0",
|
||||
"libxrender1",
|
||||
"libasound2",
|
||||
"libfreetype6",
|
||||
"libfontconfig1",
|
||||
"libdbus-1-3",
|
||||
"libnss3",
|
||||
"libnspr4",
|
||||
"libatk-bridge2.0-0",
|
||||
"libdrm2",
|
||||
"libxkbcommon0",
|
||||
"libatspi2.0-0",
|
||||
"libcups2",
|
||||
"libxshmfence1",
|
||||
"libgbm1",
|
||||
],
|
||||
)
|
||||
} else if which_exists("dnf") {
|
||||
(
|
||||
"dnf",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
"libxcb",
|
||||
"libX11-xcb",
|
||||
"libX11",
|
||||
"libXext",
|
||||
"libXcursor",
|
||||
"libXfixes",
|
||||
"libXi",
|
||||
"gtk3",
|
||||
"cairo-gobject",
|
||||
],
|
||||
)
|
||||
} else if which_exists("yum") {
|
||||
(
|
||||
"yum",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
],
|
||||
)
|
||||
} else {
|
||||
eprintln!("\x1b[31m✗\x1b[0m No supported package manager found (apt-get, dnf, or yum)");
|
||||
exit(1);
|
||||
};
|
||||
|
||||
let install_cmd = match pkg_mgr {
|
||||
"apt-get" => {
|
||||
format!(
|
||||
"sudo apt-get update && sudo apt-get install -y {}",
|
||||
deps.join(" ")
|
||||
)
|
||||
}
|
||||
_ => format!("sudo {} install -y {}", pkg_mgr, deps.join(" ")),
|
||||
};
|
||||
|
||||
println!("Running: {}", install_cmd);
|
||||
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!("\x1b[32m✓\x1b[0m System dependencies installed")
|
||||
}
|
||||
Ok(_) => eprintln!(
|
||||
"\x1b[33m⚠\x1b[0m Failed to install some dependencies. You may need to run manually with sudo."
|
||||
),
|
||||
Err(e) => eprintln!("\x1b[33m⚠\x1b[0m Could not run install command: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"{} Linux detected. If browser fails to launch, run:",
|
||||
color::warning_indicator()
|
||||
);
|
||||
println!("\x1b[33m⚠\x1b[0m Linux detected. If browser fails to launch, run:");
|
||||
println!(" agent-browser install --with-deps");
|
||||
println!(" or: npx playwright install-deps chromium");
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", color::cyan("Installing Chrome..."));
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
let (version, url) = match rt.block_on(fetch_download_url()) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let dest = get_browsers_dir().join(format!("chrome-{}", version));
|
||||
|
||||
if let Some(bin) = chrome_binary_in_dir(&dest) {
|
||||
if bin.exists() {
|
||||
println!(
|
||||
"{} Chrome {} is already installed",
|
||||
color::success_indicator(),
|
||||
version
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
println!(" Downloading Chrome {} for {}", version, platform_key());
|
||||
println!(" {}", url);
|
||||
|
||||
let bytes = match rt.block_on(download_bytes(&url)) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match extract_zip(bytes, &dest) {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"{} Chrome {} installed successfully",
|
||||
color::success_indicator(),
|
||||
version
|
||||
);
|
||||
println!(" Location: {}", dest.display());
|
||||
println!("\x1b[36mInstalling Chromium browser...\x1b[0m");
|
||||
|
||||
// On Windows, we need to use cmd.exe to run npx because npx is actually npx.cmd
|
||||
// and Command::new() doesn't resolve .cmd files the way the shell does.
|
||||
// Pass the entire command as a single string to /c to handle paths with spaces.
|
||||
#[cfg(windows)]
|
||||
let status = Command::new("cmd")
|
||||
.args(["/c", "npx playwright install chromium"])
|
||||
.status();
|
||||
|
||||
#[cfg(not(windows))]
|
||||
let status = Command::new("npx")
|
||||
.args(["playwright", "install", "chromium"])
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!("\x1b[32m✓\x1b[0m Chromium installed successfully");
|
||||
if is_linux && !with_deps {
|
||||
println!();
|
||||
println!(
|
||||
"{} If you see \"shared library\" errors when running, use:",
|
||||
color::yellow("Note:")
|
||||
);
|
||||
println!("\x1b[33mNote:\x1b[0m If you see \"shared library\" errors when running, use:");
|
||||
println!(" agent-browser install --with-deps");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
Ok(_) => {
|
||||
eprintln!("\x1b[31m✗\x1b[0m Failed to install browser");
|
||||
if is_linux {
|
||||
println!("\x1b[33mTip:\x1b[0m Try installing system dependencies first:");
|
||||
println!(" agent-browser install --with-deps");
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn report_install_status(status: io::Result<std::process::ExitStatus>) {
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!(
|
||||
"{} System dependencies installed",
|
||||
color::success_indicator()
|
||||
)
|
||||
Err(e) => {
|
||||
eprintln!("\x1b[31m✗\x1b[0m Failed to run npx: {}", e);
|
||||
eprintln!("Make sure Node.js is installed and npx is in your PATH");
|
||||
exit(1);
|
||||
}
|
||||
Ok(_) => eprintln!(
|
||||
"{} Failed to install some dependencies. You may need to run manually with sudo.",
|
||||
color::warning_indicator()
|
||||
),
|
||||
Err(e) => eprintln!(
|
||||
"{} Could not run install command: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn install_linux_deps() {
|
||||
println!("{}", color::cyan("Installing system dependencies..."));
|
||||
|
||||
let (pkg_mgr, deps) = if which_exists("apt-get") {
|
||||
// On Ubuntu 24.04+, many libraries were renamed with a t64 suffix as
|
||||
// part of the 64-bit time_t transition. Using the old names can cause
|
||||
// apt to propose removing hundreds of system packages to resolve
|
||||
// conflicts. We check for the t64 variant first to avoid this.
|
||||
let apt_deps: Vec<&str> = vec![
|
||||
("libxcb-shm0", None),
|
||||
("libx11-xcb1", None),
|
||||
("libx11-6", None),
|
||||
("libxcb1", None),
|
||||
("libxext6", None),
|
||||
("libxrandr2", None),
|
||||
("libxcomposite1", None),
|
||||
("libxcursor1", None),
|
||||
("libxdamage1", None),
|
||||
("libxfixes3", None),
|
||||
("libxi6", None),
|
||||
("libgtk-3-0", Some("libgtk-3-0t64")),
|
||||
("libpangocairo-1.0-0", Some("libpangocairo-1.0-0t64")),
|
||||
("libpango-1.0-0", Some("libpango-1.0-0t64")),
|
||||
("libatk1.0-0", Some("libatk1.0-0t64")),
|
||||
("libcairo-gobject2", Some("libcairo-gobject2t64")),
|
||||
("libcairo2", Some("libcairo2t64")),
|
||||
("libgdk-pixbuf-2.0-0", Some("libgdk-pixbuf-2.0-0t64")),
|
||||
("libxrender1", None),
|
||||
("libasound2", Some("libasound2t64")),
|
||||
("libfreetype6", None),
|
||||
("libfontconfig1", None),
|
||||
("libdbus-1-3", Some("libdbus-1-3t64")),
|
||||
("libnss3", None),
|
||||
("libnspr4", None),
|
||||
("libatk-bridge2.0-0", Some("libatk-bridge2.0-0t64")),
|
||||
("libdrm2", None),
|
||||
("libxkbcommon0", None),
|
||||
("libatspi2.0-0", Some("libatspi2.0-0t64")),
|
||||
("libcups2", Some("libcups2t64")),
|
||||
("libxshmfence1", None),
|
||||
("libgbm1", None),
|
||||
// Fonts: without actual font files, pages render with missing glyphs
|
||||
// (tofu). This is especially visible for CJK and emoji characters.
|
||||
("fonts-noto-color-emoji", None),
|
||||
("fonts-noto-cjk", None),
|
||||
("fonts-freefont-ttf", None),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(base, t64_variant)| {
|
||||
if let Some(t64) = t64_variant {
|
||||
if package_exists_apt(t64) {
|
||||
return t64;
|
||||
}
|
||||
}
|
||||
base
|
||||
})
|
||||
.collect();
|
||||
|
||||
("apt-get", apt_deps)
|
||||
} else if which_exists("dnf") {
|
||||
(
|
||||
"dnf",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
"libxcb",
|
||||
"libX11-xcb",
|
||||
"libX11",
|
||||
"libXext",
|
||||
"libXcursor",
|
||||
"libXfixes",
|
||||
"libXi",
|
||||
"gtk3",
|
||||
"cairo-gobject",
|
||||
// Fonts
|
||||
"google-noto-cjk-fonts",
|
||||
"google-noto-emoji-color-fonts",
|
||||
"liberation-fonts",
|
||||
],
|
||||
)
|
||||
} else if which_exists("yum") {
|
||||
(
|
||||
"yum",
|
||||
vec![
|
||||
"nss",
|
||||
"nspr",
|
||||
"atk",
|
||||
"at-spi2-atk",
|
||||
"cups-libs",
|
||||
"libdrm",
|
||||
"libXcomposite",
|
||||
"libXdamage",
|
||||
"libXrandr",
|
||||
"mesa-libgbm",
|
||||
"pango",
|
||||
"alsa-lib",
|
||||
"libxkbcommon",
|
||||
// Fonts
|
||||
"google-noto-cjk-fonts",
|
||||
"liberation-fonts",
|
||||
],
|
||||
)
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} No supported package manager found (apt-get, dnf, or yum)",
|
||||
color::error_indicator()
|
||||
);
|
||||
exit(1);
|
||||
};
|
||||
|
||||
if pkg_mgr == "apt-get" {
|
||||
// Run apt-get update first
|
||||
println!("Running: sudo apt-get update");
|
||||
let update_status = Command::new("sudo").args(["apt-get", "update"]).status();
|
||||
|
||||
match update_status {
|
||||
Ok(s) if !s.success() => {
|
||||
eprintln!(
|
||||
"{} apt-get update failed. Continuing with existing package lists.",
|
||||
color::warning_indicator()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Could not run apt-get update: {}",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Simulate the install first to detect if apt would remove any
|
||||
// packages. This prevents the catastrophic scenario where installing
|
||||
// these libraries triggers removal of hundreds of system packages
|
||||
// due to dependency conflicts (e.g. on Ubuntu 24.04 with the
|
||||
// t64 transition).
|
||||
println!("Checking for conflicts...");
|
||||
let sim_output = Command::new("sudo")
|
||||
.args(["apt-get", "install", "--simulate"])
|
||||
.args(&deps)
|
||||
.output();
|
||||
|
||||
match sim_output {
|
||||
Ok(output) => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let combined = format!("{}\n{}", stdout, stderr);
|
||||
|
||||
// Count packages that would be removed
|
||||
let removals: Vec<&str> = combined
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("Remv "))
|
||||
.collect();
|
||||
|
||||
if !removals.is_empty() {
|
||||
eprintln!(
|
||||
"{} Aborting: apt would remove {} package(s) to install these dependencies.",
|
||||
color::error_indicator(),
|
||||
removals.len()
|
||||
);
|
||||
eprintln!(
|
||||
" This usually means some package names have changed on your system"
|
||||
);
|
||||
eprintln!(" (e.g. Ubuntu 24.04 renamed libraries with a t64 suffix).");
|
||||
eprintln!();
|
||||
eprintln!(" Packages that would be removed:");
|
||||
for line in removals.iter().take(20) {
|
||||
eprintln!(" {}", line);
|
||||
}
|
||||
if removals.len() > 20 {
|
||||
eprintln!(" ... and {} more", removals.len() - 20);
|
||||
}
|
||||
eprintln!();
|
||||
eprintln!(" To install dependencies manually, run:");
|
||||
eprintln!(" sudo apt-get install {}", deps.join(" "));
|
||||
eprintln!();
|
||||
eprintln!(" Review the apt output carefully before confirming.");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} Could not simulate install ({}). Proceeding with caution.",
|
||||
color::warning_indicator(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Safe to proceed: no removals detected
|
||||
let install_cmd = format!("sudo apt-get install -y {}", deps.join(" "));
|
||||
println!("Running: {}", install_cmd);
|
||||
let status = Command::new("sudo")
|
||||
.args(["apt-get", "install", "-y"])
|
||||
.args(&deps)
|
||||
.status();
|
||||
|
||||
report_install_status(status);
|
||||
} else {
|
||||
// dnf / yum path — these package managers do not remove packages
|
||||
// during install, so the simulate-first guard is not needed.
|
||||
let install_cmd = format!("sudo {} install -y {}", pkg_mgr, deps.join(" "));
|
||||
println!("Running: {}", install_cmd);
|
||||
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
|
||||
|
||||
report_install_status(status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,135 +189,3 @@ fn which_exists(cmd: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn package_exists_apt(pkg: &str) -> bool {
|
||||
Command::new("apt-cache")
|
||||
.arg("show")
|
||||
.arg(pkg)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dashboard install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let url = dashboard_download_url();
|
||||
println!(" Downloading dashboard v{}", DASHBOARD_VERSION);
|
||||
println!(" {}", url);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+47
-1293
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,556 +0,0 @@
|
||||
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthProfile {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username_selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password_selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub submit_selector: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_login_at: Option<String>,
|
||||
}
|
||||
|
||||
// Keep legacy Credential alias for backward compatibility
|
||||
pub type Credential = AuthProfile;
|
||||
|
||||
fn validate_profile_name(name: &str) -> Result<(), String> {
|
||||
if name.is_empty()
|
||||
|| !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
return Err(format!(
|
||||
"Invalid profile name '{}'. Must match /^[a-zA-Z0-9_-]+$/",
|
||||
name
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_auth_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("auth")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("auth")
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profile_path(name: &str) -> PathBuf {
|
||||
get_auth_dir().join(format!("{}.json", name))
|
||||
}
|
||||
|
||||
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
|
||||
const KEY_FILE_NAME: &str = ".encryption-key";
|
||||
|
||||
fn get_agent_browser_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser")
|
||||
}
|
||||
}
|
||||
|
||||
fn get_key_file_path() -> PathBuf {
|
||||
get_agent_browser_dir().join(KEY_FILE_NAME)
|
||||
}
|
||||
|
||||
fn parse_key_hex(hex_str: &str) -> Option<Vec<u8>> {
|
||||
let hex_str = hex_str.trim();
|
||||
if hex_str.len() != 64 || !hex_str.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let bytes: Vec<u8> = (0..32)
|
||||
.map(|i| u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16).unwrap())
|
||||
.collect();
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Read the encryption key from AGENT_BROWSER_ENCRYPTION_KEY env var or
|
||||
/// ~/.agent-browser/.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(|| {
|
||||
format!(
|
||||
"{} should be a 64-character hex string (256 bits). Generate one with: openssl rand -hex 32",
|
||||
ENCRYPTION_KEY_ENV
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
let key_file = get_key_file_path();
|
||||
if key_file.exists() {
|
||||
let hex = fs::read_to_string(&key_file)
|
||||
.map_err(|e| format!("Failed to read encryption key file: {}", e))?;
|
||||
return parse_key_hex(&hex).ok_or_else(|| {
|
||||
format!(
|
||||
"Invalid encryption key in {}. Expected 64-character hex string.",
|
||||
key_file.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Encryption key required. Set {} or ensure {} exists.",
|
||||
ENCRYPTION_KEY_ENV,
|
||||
key_file.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Ensure an encryption key exists, auto-generating one if needed.
|
||||
fn ensure_encryption_key() -> Result<Vec<u8>, String> {
|
||||
if let Ok(key) = get_encryption_key() {
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
getrandom::getrandom(&mut key).map_err(|e| format!("Failed to generate key: {}", e))?;
|
||||
let key_hex = key.iter().map(|b| format!("{:02x}", b)).collect::<String>();
|
||||
|
||||
let dir = get_agent_browser_dir();
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
|
||||
let key_file = get_key_file_path();
|
||||
fs::write(&key_file, format!("{}\n", key_hex))
|
||||
.map_err(|e| format!("Failed to write encryption key: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&key_file, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"[agent-browser] Auto-generated encryption key at {} -- back up this file or set {}",
|
||||
key_file.display(),
|
||||
ENCRYPTION_KEY_ENV
|
||||
);
|
||||
|
||||
Ok(key.to_vec())
|
||||
}
|
||||
|
||||
/// Encrypt a profile to the JSON+base64 format compatible with Node.js.
|
||||
fn encrypt_profile(profile: &AuthProfile) -> Result<String, String> {
|
||||
let key = ensure_encryption_key()?;
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Encryption key error: {}", e))?;
|
||||
|
||||
let plaintext = serde_json::to_string(profile)
|
||||
.map_err(|e| format!("Failed to serialize profile: {}", e))?;
|
||||
|
||||
let mut iv = [0u8; 12];
|
||||
getrandom::getrandom(&mut iv).map_err(|e| format!("Failed to generate IV: {}", e))?;
|
||||
|
||||
// aes_gcm appends the 16-byte auth tag to the ciphertext
|
||||
let encrypted = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&iv), plaintext.as_bytes())
|
||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||
|
||||
let tag_offset = encrypted.len() - 16;
|
||||
let ciphertext = &encrypted[..tag_offset];
|
||||
let auth_tag = &encrypted[tag_offset..];
|
||||
|
||||
let payload = json!({
|
||||
"version": 1,
|
||||
"encrypted": true,
|
||||
"iv": STANDARD.encode(iv),
|
||||
"authTag": STANDARD.encode(auth_tag),
|
||||
"data": STANDARD.encode(ciphertext),
|
||||
});
|
||||
|
||||
serde_json::to_string_pretty(&payload)
|
||||
.map_err(|e| format!("Failed to serialize payload: {}", e))
|
||||
}
|
||||
|
||||
/// JSON envelope written by Node.js encryption (src/encryption.ts).
|
||||
#[derive(Deserialize)]
|
||||
struct EncryptedPayload {
|
||||
#[allow(dead_code)]
|
||||
version: u32,
|
||||
#[allow(dead_code)]
|
||||
encrypted: bool,
|
||||
iv: String,
|
||||
#[serde(rename = "authTag")]
|
||||
auth_tag: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
fn decrypt_profile(data: &[u8]) -> Result<AuthProfile, String> {
|
||||
let text = std::str::from_utf8(data).map_err(|_| {
|
||||
"Profile is not valid UTF-8 -- it may use an older incompatible binary format".to_string()
|
||||
})?;
|
||||
|
||||
if let Ok(payload) = serde_json::from_str::<EncryptedPayload>(text) {
|
||||
let key = get_encryption_key()?;
|
||||
|
||||
let iv = STANDARD
|
||||
.decode(&payload.iv)
|
||||
.map_err(|e| format!("Invalid base64 iv: {}", e))?;
|
||||
let auth_tag = STANDARD
|
||||
.decode(&payload.auth_tag)
|
||||
.map_err(|e| format!("Invalid base64 authTag: {}", e))?;
|
||||
let ciphertext = STANDARD
|
||||
.decode(&payload.data)
|
||||
.map_err(|e| format!("Invalid base64 data: {}", e))?;
|
||||
|
||||
// aes_gcm expects ciphertext || auth_tag as input to decrypt
|
||||
let mut combined = Vec::with_capacity(ciphertext.len() + auth_tag.len());
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
combined.extend_from_slice(&auth_tag);
|
||||
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Decryption key error: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(aes_gcm::Nonce::from_slice(&iv), combined.as_slice())
|
||||
.map_err(|e| format!("Decryption failed: {}", e))?;
|
||||
|
||||
let json_str = String::from_utf8(plaintext)
|
||||
.map_err(|e| format!("Decrypted data is not valid UTF-8: {}", e))?;
|
||||
return serde_json::from_str(&json_str).map_err(|e| format!("Invalid profile data: {}", e));
|
||||
}
|
||||
|
||||
// Fallback: try as plain unencrypted JSON profile
|
||||
serde_json::from_str::<AuthProfile>(text)
|
||||
.map_err(|_| "Profile is not a valid encrypted or unencrypted payload".to_string())
|
||||
}
|
||||
|
||||
fn save_profile(profile: &AuthProfile) -> Result<(), String> {
|
||||
let dir = get_auth_dir();
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create auth dir: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
|
||||
let encrypted_json = encrypt_profile(profile)?;
|
||||
let path = get_profile_path(&profile.name);
|
||||
fs::write(&path, &encrypted_json).map_err(|e| format!("Failed to write profile: {}", e))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_profile(name: &str) -> Result<AuthProfile, String> {
|
||||
let path = get_profile_path(name);
|
||||
if !path.exists() {
|
||||
return Err(format!("Auth profile '{}' not found", name));
|
||||
}
|
||||
let data = fs::read(&path).map_err(|e| format!("Failed to read profile: {}", e))?;
|
||||
decrypt_profile(&data)
|
||||
}
|
||||
|
||||
pub fn credentials_set(
|
||||
name: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
url: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let profile = AuthProfile {
|
||||
name: name.to_string(),
|
||||
url: url.unwrap_or("").to_string(),
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
save_profile(&profile)?;
|
||||
Ok(json!({ "saved": name }))
|
||||
}
|
||||
|
||||
pub fn auth_save(
|
||||
name: &str,
|
||||
url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
username_selector: Option<&str>,
|
||||
password_selector: Option<&str>,
|
||||
submit_selector: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let profile = AuthProfile {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
username_selector: username_selector.map(String::from),
|
||||
password_selector: password_selector.map(String::from),
|
||||
submit_selector: submit_selector.map(String::from),
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
save_profile(&profile)?;
|
||||
Ok(json!({ "saved": name }))
|
||||
}
|
||||
|
||||
pub fn credentials_get(name: &str) -> Result<Value, String> {
|
||||
let profile = load_profile(name)?;
|
||||
Ok(json!({
|
||||
"name": profile.name,
|
||||
"username": profile.username,
|
||||
"url": profile.url,
|
||||
"hasPassword": true,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn credentials_get_full(name: &str) -> Result<AuthProfile, String> {
|
||||
load_profile(name)
|
||||
}
|
||||
|
||||
pub fn credentials_delete(name: &str) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let path = get_profile_path(name);
|
||||
if !path.exists() {
|
||||
return Err(format!("Auth profile '{}' not found", name));
|
||||
}
|
||||
fs::remove_file(&path).map_err(|e| format!("Failed to delete profile: {}", e))?;
|
||||
Ok(json!({ "deleted": name }))
|
||||
}
|
||||
|
||||
pub fn credentials_list() -> Result<Value, String> {
|
||||
let dir = get_auth_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "profiles": [] }));
|
||||
}
|
||||
|
||||
let mut profiles = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let name = path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
match load_profile(&name) {
|
||||
Ok(profile) => {
|
||||
profiles.push(json!({
|
||||
"name": profile.name,
|
||||
"username": profile.username,
|
||||
"url": profile.url,
|
||||
}));
|
||||
}
|
||||
Err(_) => {
|
||||
profiles.push(json!({
|
||||
"name": name,
|
||||
"error": "Failed to decrypt",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({ "profiles": profiles }))
|
||||
}
|
||||
|
||||
pub fn auth_show(name: &str) -> Result<Value, String> {
|
||||
validate_profile_name(name)?;
|
||||
let profile = load_profile(name)?;
|
||||
Ok(json!({
|
||||
"profile": {
|
||||
"name": profile.name,
|
||||
"url": profile.url,
|
||||
"username": profile.username,
|
||||
"usernameSelector": profile.username_selector,
|
||||
"passwordSelector": profile.password_selector,
|
||||
"submitSelector": profile.submit_selector,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) static AUTH_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn with_test_key<F: FnOnce()>(f: F) {
|
||||
let _lock = AUTH_TEST_MUTEX.lock().unwrap();
|
||||
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
|
||||
let test_key = "a".repeat(64);
|
||||
// SAFETY: TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, &test_key) };
|
||||
f();
|
||||
// SAFETY: TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
match original {
|
||||
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
|
||||
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_profile_name() {
|
||||
assert!(validate_profile_name("github").is_ok());
|
||||
assert!(validate_profile_name("my-app").is_ok());
|
||||
assert!(validate_profile_name("test_123").is_ok());
|
||||
assert!(validate_profile_name("").is_err());
|
||||
assert!(validate_profile_name("has space").is_err());
|
||||
assert!(validate_profile_name("../evil").is_err());
|
||||
assert!(validate_profile_name("foo/bar").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_profile_serialization() {
|
||||
let profile = AuthProfile {
|
||||
name: "test".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "pass".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: Some("button[type=submit]".to_string()),
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
let json = serde_json::to_string(&profile).unwrap();
|
||||
let parsed: AuthProfile = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.name, "test");
|
||||
assert_eq!(
|
||||
parsed.submit_selector,
|
||||
Some("button[type=submit]".to_string())
|
||||
);
|
||||
assert!(parsed.username_selector.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
with_test_key(|| {
|
||||
let profile = AuthProfile {
|
||||
name: "roundtrip".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "s3cret!".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
let encrypted_json = encrypt_profile(&profile).unwrap();
|
||||
let decrypted = decrypt_profile(encrypted_json.as_bytes()).unwrap();
|
||||
assert_eq!(decrypted.name, "roundtrip");
|
||||
assert_eq!(decrypted.password, "s3cret!");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_encryption_key_from_env() {
|
||||
with_test_key(|| {
|
||||
let key = get_encryption_key().unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
assert!(key.iter().all(|&b| b == 0xaa));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_hex_valid() {
|
||||
let hex = "ab".repeat(32);
|
||||
let key = parse_key_hex(&hex).unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
assert!(key.iter().all(|&b| b == 0xab));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_hex_invalid() {
|
||||
assert!(parse_key_hex("too_short").is_none());
|
||||
assert!(parse_key_hex(&"g".repeat(64)).is_none());
|
||||
assert!(parse_key_hex("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_json_payload_format() {
|
||||
with_test_key(|| {
|
||||
let key = get_encryption_key().unwrap();
|
||||
let profile = AuthProfile {
|
||||
name: "json-test".to_string(),
|
||||
url: "https://example.com/login".to_string(),
|
||||
username: "admin".to_string(),
|
||||
password: "hunter2".to_string(),
|
||||
username_selector: Some("#email".to_string()),
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
|
||||
// Encrypt with aes_gcm, then manually build the JSON payload
|
||||
// to simulate what Node.js would produce
|
||||
let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
|
||||
let mut iv = [0u8; 12];
|
||||
getrandom::getrandom(&mut iv).unwrap();
|
||||
let plaintext = serde_json::to_string(&profile).unwrap();
|
||||
let encrypted = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&iv), plaintext.as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let tag_offset = encrypted.len() - 16;
|
||||
let ciphertext = &encrypted[..tag_offset];
|
||||
let auth_tag = &encrypted[tag_offset..];
|
||||
|
||||
let payload = format!(
|
||||
r#"{{"version":1,"encrypted":true,"iv":"{}","authTag":"{}","data":"{}"}}"#,
|
||||
STANDARD.encode(iv),
|
||||
STANDARD.encode(auth_tag),
|
||||
STANDARD.encode(ciphertext),
|
||||
);
|
||||
|
||||
let decrypted = decrypt_profile(payload.as_bytes()).unwrap();
|
||||
assert_eq!(decrypted.name, "json-test");
|
||||
assert_eq!(decrypted.password, "hunter2");
|
||||
assert_eq!(decrypted.username_selector, Some("#email".to_string()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypted_output_is_json_format() {
|
||||
with_test_key(|| {
|
||||
let profile = AuthProfile {
|
||||
name: "format-check".to_string(),
|
||||
url: "https://example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
password: "pass".to_string(),
|
||||
username_selector: None,
|
||||
password_selector: None,
|
||||
submit_selector: None,
|
||||
created_at: None,
|
||||
last_login_at: None,
|
||||
};
|
||||
let encrypted = encrypt_profile(&profile).unwrap();
|
||||
let parsed: Value = serde_json::from_str(&encrypted).unwrap();
|
||||
assert_eq!(parsed["version"], 1);
|
||||
assert_eq!(parsed["encrypted"], true);
|
||||
assert!(parsed["iv"].is_string());
|
||||
assert!(parsed["authTag"].is_string());
|
||||
assert!(parsed["data"].is_string());
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,361 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{broadcast, oneshot, Mutex};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::types::{CdpCommand, CdpEvent, CdpMessage};
|
||||
|
||||
type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<CdpMessage>>>>;
|
||||
|
||||
/// Interval between WebSocket ping frames sent to keep the connection alive
|
||||
/// through intermediate proxies (reverse proxies, load balancers, service meshes).
|
||||
const WS_KEEPALIVE_INTERVAL_SECS: u64 = 30;
|
||||
|
||||
/// Raw incoming CDP message (text) broadcast to all subscribers.
|
||||
/// Used by the inspect proxy to forward responses and events to DevTools.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RawCdpMessage {
|
||||
pub text: String,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct CdpClient {
|
||||
ws_tx: Arc<
|
||||
Mutex<
|
||||
futures_util::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
Message,
|
||||
>,
|
||||
>,
|
||||
>,
|
||||
next_id: AtomicU64,
|
||||
pending: PendingMap,
|
||||
event_tx: broadcast::Sender<CdpEvent>,
|
||||
raw_tx: broadcast::Sender<RawCdpMessage>,
|
||||
_reader_handle: tokio::task::JoinHandle<()>,
|
||||
_keepalive_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl CdpClient {
|
||||
pub async fn connect(url: &str) -> Result<Self, String> {
|
||||
Self::connect_with_headers(url, None).await
|
||||
}
|
||||
|
||||
pub async fn connect_with_headers(
|
||||
url: &str,
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
) -> Result<Self, String> {
|
||||
let mut request = url
|
||||
.into_client_request()
|
||||
.map_err(|e| format!("Invalid WebSocket URL: {}", e))?;
|
||||
|
||||
if let Some(hdrs) = headers {
|
||||
let req_headers = request.headers_mut();
|
||||
for (key, value) in hdrs {
|
||||
if let (Ok(name), Ok(val)) = (
|
||||
key.parse::<tokio_tungstenite::tungstenite::http::header::HeaderName>(),
|
||||
value.parse::<tokio_tungstenite::tungstenite::http::header::HeaderValue>(),
|
||||
) {
|
||||
req_headers.insert(name, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ws_config = WebSocketConfig {
|
||||
max_message_size: None,
|
||||
max_frame_size: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (ws_stream, _) =
|
||||
tokio_tungstenite::connect_async_with_config(request, Some(ws_config), false)
|
||||
.await
|
||||
.map_err(|e| format!("CDP WebSocket connect failed: {}", e))?;
|
||||
|
||||
enable_tcp_keepalive(ws_stream.get_ref());
|
||||
|
||||
let (ws_tx, mut ws_rx) = ws_stream.split();
|
||||
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 pending_clone = pending.clone();
|
||||
let event_tx_clone = event_tx.clone();
|
||||
let raw_tx_clone = raw_tx.clone();
|
||||
|
||||
// Notify used to stop the keepalive task when the reader loop exits.
|
||||
let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
|
||||
|
||||
let reader_handle = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_rx.next().await {
|
||||
// Accept both Text and Binary frames — remote CDP proxies
|
||||
// (e.g. Browserless) may send responses as Binary frames.
|
||||
let msg = match msg {
|
||||
Ok(Message::Text(text)) => text,
|
||||
Ok(Message::Binary(data)) => match String::from_utf8(data) {
|
||||
Ok(text) => text,
|
||||
Err(_) => continue,
|
||||
},
|
||||
Ok(Message::Close(frame)) => {
|
||||
if std::env::var("AGENT_BROWSER_DEBUG").is_ok() {
|
||||
let reason = frame
|
||||
.as_ref()
|
||||
.map(|f| format!("code={}, reason={}", f.code, f.reason))
|
||||
.unwrap_or_else(|| "no frame".to_string());
|
||||
let _ =
|
||||
writeln!(std::io::stderr(), "[cdp] WebSocket Close: {}", reason);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(Message::Pong(_)) => continue,
|
||||
Ok(_) => continue,
|
||||
Err(e) => {
|
||||
if std::env::var("AGENT_BROWSER_DEBUG").is_ok() {
|
||||
let _ = writeln!(std::io::stderr(), "[cdp] WebSocket Error: {}", e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Broadcast raw message for inspect proxy subscribers before typed parse,
|
||||
// so messages with negative IDs (used by the inspect proxy) are still delivered.
|
||||
if raw_tx_clone.receiver_count() > 0 {
|
||||
let session_id = serde_json::from_str::<serde_json::Value>(&msg)
|
||||
.ok()
|
||||
.and_then(|v| v.get("sessionId")?.as_str().map(String::from));
|
||||
let _ = raw_tx_clone.send(RawCdpMessage {
|
||||
text: msg.clone(),
|
||||
session_id,
|
||||
});
|
||||
}
|
||||
|
||||
let parsed: CdpMessage = match serde_json::from_str(&msg) {
|
||||
Ok(m) => m,
|
||||
// Expected for inspect proxy messages with negative IDs
|
||||
// (CdpMessage.id is u64); handled via raw broadcast above.
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(id) = parsed.id {
|
||||
// Response to a command
|
||||
let mut pending = pending_clone.lock().await;
|
||||
if let Some(tx) = pending.remove(&id) {
|
||||
let _ = tx.send(parsed);
|
||||
}
|
||||
} else if let Some(ref method) = parsed.method {
|
||||
// Event
|
||||
let event = CdpEvent {
|
||||
method: method.clone(),
|
||||
params: parsed.params.clone().unwrap_or(Value::Null),
|
||||
session_id: parsed.session_id.clone(),
|
||||
};
|
||||
let _ = event_tx_clone.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Reader loop exited (connection closed or error). Drop all pending
|
||||
// command senders so callers get an immediate channel-closed error
|
||||
// instead of waiting for the 30-second timeout.
|
||||
pending_clone.lock().await.clear();
|
||||
|
||||
// Stop the keepalive task — the connection is gone.
|
||||
let _ = cancel_tx.send(true);
|
||||
});
|
||||
|
||||
// Spawn a keepalive task that sends WebSocket Ping frames at a regular
|
||||
// interval. This prevents intermediate proxies (Envoy, nginx, OpenResty,
|
||||
// cloud load balancers) from closing idle WebSocket connections. If the
|
||||
// send fails, the connection is dead and we stop pinging.
|
||||
let keepalive_tx = ws_tx.clone();
|
||||
let keepalive_handle = tokio::spawn(async move {
|
||||
let interval = std::time::Duration::from_secs(WS_KEEPALIVE_INTERVAL_SECS);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(interval) => {}
|
||||
_ = cancel_rx.changed() => break,
|
||||
}
|
||||
let mut tx = keepalive_tx.lock().await;
|
||||
if tx.send(Message::Ping(Vec::new())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
ws_tx,
|
||||
next_id: AtomicU64::new(1),
|
||||
pending,
|
||||
event_tx,
|
||||
raw_tx,
|
||||
_reader_handle: reader_handle,
|
||||
_keepalive_handle: keepalive_handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn send_command(
|
||||
&self,
|
||||
method: &str,
|
||||
params: Option<Value>,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
let cmd = CdpCommand {
|
||||
id,
|
||||
method: method.to_string(),
|
||||
params,
|
||||
session_id: session_id.filter(|s| !s.is_empty()).map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&cmd)
|
||||
.map_err(|e| format!("Failed to serialize CDP command: {}", e))?;
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
{
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.insert(id, tx);
|
||||
}
|
||||
|
||||
{
|
||||
let mut ws_tx = self.ws_tx.lock().await;
|
||||
ws_tx
|
||||
.send(Message::Text(json))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send CDP command: {}", e))?;
|
||||
}
|
||||
|
||||
let response = match tokio::time::timeout(std::time::Duration::from_secs(30), rx).await {
|
||||
Ok(Ok(resp)) => resp,
|
||||
Ok(Err(_)) => return Err("CDP response channel closed".to_string()),
|
||||
Err(_) => {
|
||||
self.pending.lock().await.remove(&id);
|
||||
return Err(format!("CDP command timed out: {}", method));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(error) = response.error {
|
||||
return Err(format!("CDP error ({}): {}", method, error));
|
||||
}
|
||||
|
||||
Ok(response.result.unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<CdpEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Subscribe to all raw incoming CDP messages (responses + events).
|
||||
/// Used by the inspect proxy to forward traffic to the DevTools frontend.
|
||||
pub fn subscribe_raw(&self) -> broadcast::Receiver<RawCdpMessage> {
|
||||
self.raw_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Create a lightweight handle for the inspect WebSocket proxy.
|
||||
/// Contains only what's needed to forward messages bidirectionally.
|
||||
pub fn inspect_handle(&self) -> InspectProxyHandle {
|
||||
InspectProxyHandle {
|
||||
ws_tx: self.ws_tx.clone(),
|
||||
raw_tx: self.raw_tx.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_command_typed<P: serde::Serialize, R: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
params: &P,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<R, String> {
|
||||
let params_value = serde_json::to_value(params)
|
||||
.map_err(|e| format!("Failed to serialize params: {}", e))?;
|
||||
let result = self
|
||||
.send_command(method, Some(params_value), session_id)
|
||||
.await?;
|
||||
serde_json::from_value(result)
|
||||
.map_err(|e| format!("Failed to deserialize CDP response for {}: {}", method, e))
|
||||
}
|
||||
|
||||
pub async fn send_command_no_params(
|
||||
&self,
|
||||
method: &str,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
self.send_command(method, None, session_id).await
|
||||
}
|
||||
|
||||
/// Send raw JSON through the WebSocket without tracking a response.
|
||||
/// Used by the inspect proxy to forward DevTools frontend messages.
|
||||
pub async fn send_raw(&self, json: String) -> Result<(), String> {
|
||||
let mut ws_tx = self.ws_tx.lock().await;
|
||||
ws_tx
|
||||
.send(Message::Text(json))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send raw CDP message: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
type WsTx = Arc<
|
||||
Mutex<
|
||||
futures_util::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
Message,
|
||||
>,
|
||||
>,
|
||||
>;
|
||||
|
||||
/// Lightweight handle for the inspect WebSocket proxy, holding only
|
||||
/// the cloneable parts of CdpClient needed for bidirectional message forwarding.
|
||||
pub struct InspectProxyHandle {
|
||||
ws_tx: WsTx,
|
||||
raw_tx: broadcast::Sender<RawCdpMessage>,
|
||||
}
|
||||
|
||||
impl InspectProxyHandle {
|
||||
pub async fn send_raw(&self, json: String) -> Result<(), String> {
|
||||
let mut ws_tx = self.ws_tx.lock().await;
|
||||
ws_tx
|
||||
.send(Message::Text(json))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send raw CDP message: {}", e))
|
||||
}
|
||||
|
||||
pub fn subscribe_raw(&self) -> broadcast::Receiver<RawCdpMessage> {
|
||||
self.raw_tx.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable TCP SO_KEEPALIVE on the underlying socket of a WebSocket connection.
|
||||
/// This is best-effort: failures are silently ignored since the WebSocket-level
|
||||
/// Ping keepalive provides the primary connection liveness mechanism.
|
||||
fn enable_tcp_keepalive(stream: &tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>) {
|
||||
let tcp_stream = match stream {
|
||||
tokio_tungstenite::MaybeTlsStream::Plain(s) => s,
|
||||
tokio_tungstenite::MaybeTlsStream::Rustls(s) => s.get_ref().0,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// SockRef borrows the fd without taking ownership.
|
||||
let sock = socket2::SockRef::from(tcp_stream);
|
||||
let keepalive = socket2::TcpKeepalive::new().with_time(std::time::Duration::from_secs(30));
|
||||
|
||||
// with_interval sets TCP_KEEPINTVL — the time between probes after the
|
||||
// first keepalive probe goes unanswered. Available on most platforms
|
||||
// (Linux, macOS, Windows, FreeBSD, etc.) but not OpenBSD or Haiku.
|
||||
#[cfg(not(any(target_os = "openbsd", target_os = "haiku")))]
|
||||
let keepalive = keepalive.with_interval(std::time::Duration::from_secs(10));
|
||||
|
||||
let _ = sock.set_tcp_keepalive(&keepalive);
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::types::BrowserVersionInfo;
|
||||
|
||||
/// Default timeout for CDP discovery HTTP requests.
|
||||
const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Discover the CDP WebSocket URL for the given host and port.
|
||||
///
|
||||
/// Tries three methods in order: `/json/version`, `/json/list`, and a direct
|
||||
/// WebSocket connection to `/devtools/browser`. The returned URL has its
|
||||
/// host/port rewritten to match the requested target.
|
||||
///
|
||||
/// An optional `query` string (without the leading `?`) is appended to the
|
||||
/// final WebSocket URL so that user-supplied URL parameters (e.g.
|
||||
/// `?mode=Hello`) are forwarded to the remote endpoint.
|
||||
pub async fn discover_cdp_url(
|
||||
host: &str,
|
||||
port: u16,
|
||||
query: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
discover_cdp_url_with_timeout(host, port, query, DEFAULT_DISCOVERY_TIMEOUT).await
|
||||
}
|
||||
|
||||
/// Like [`discover_cdp_url`] but with a custom request timeout.
|
||||
pub async fn discover_cdp_url_with_timeout(
|
||||
host: &str,
|
||||
port: u16,
|
||||
query: Option<&str>,
|
||||
timeout: Duration,
|
||||
) -> Result<String, String> {
|
||||
// Primary: /json/version (standard path)
|
||||
let version_err = match fetch_cdp_info(host, port, timeout).await {
|
||||
Ok(info) => {
|
||||
if let Some(ws_url) = info.web_socket_debugger_url {
|
||||
return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query));
|
||||
}
|
||||
format!(
|
||||
"No webSocketDebuggerUrl in /json/version at {}:{}",
|
||||
host, port
|
||||
)
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
// Fallback: /json/list (returns target list; look for the browser target)
|
||||
let list_err = match fetch_cdp_list(host, port, timeout).await {
|
||||
Ok(ws_url) => return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query)),
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
// Final fallback: direct WebSocket at /devtools/browser.
|
||||
// Chrome 136+ with UI-based remote debugging (chrome://inspect) exposes
|
||||
// CDP over WebSocket but does not serve HTTP discovery endpoints.
|
||||
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
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bracket an IPv6 address for use in URLs. No-op for IPv4 or already-bracketed addresses.
|
||||
fn bracket_ipv6(host: &str) -> String {
|
||||
if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{}]", host)
|
||||
} else {
|
||||
host.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch `/json/version` from the given host:port and parse the response.
|
||||
async fn fetch_cdp_info(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
) -> Result<BrowserVersionInfo, String> {
|
||||
let url = format!("http://{}:{}/json/version", bracket_ipv6(host), port);
|
||||
|
||||
let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
|
||||
.await
|
||||
.map_err(|_| format!("Timeout connecting to CDP at {}:{}", host, port))?
|
||||
.map_err(|e| format!("Failed to connect to CDP at {}:{}: {}", host, port, e))?;
|
||||
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid /json/version response: {}", e))
|
||||
}
|
||||
|
||||
/// Rewrite the host and port in a WebSocket URL to match the target we
|
||||
/// actually connected to. Chrome's `/json/version` always returns
|
||||
/// `ws://127.0.0.1:<local-port>/...` which is unreachable when the
|
||||
/// browser is on a remote machine or behind a port-forward.
|
||||
fn rewrite_ws_host(ws_url: &str, host: &str, port: u16) -> String {
|
||||
if let Ok(mut parsed) = url::Url::parse(ws_url) {
|
||||
let _ = parsed.set_host(Some(&bracket_ipv6(host)));
|
||||
let _ = parsed.set_port(Some(port));
|
||||
parsed.to_string()
|
||||
} else {
|
||||
ws_url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a query string to a URL, preserving any existing query parameters.
|
||||
fn append_query(url: &str, query: Option<&str>) -> String {
|
||||
match query {
|
||||
Some(q) if !q.is_empty() => {
|
||||
if let Ok(mut parsed) = url::Url::parse(url) {
|
||||
{
|
||||
let mut pairs = parsed.query_pairs_mut();
|
||||
pairs.extend_pairs(url::form_urlencoded::parse(q.as_bytes()));
|
||||
}
|
||||
parsed.to_string()
|
||||
} else {
|
||||
// Fallback: raw string append
|
||||
if url.contains('?') {
|
||||
format!("{}&{}", url, q)
|
||||
} else {
|
||||
format!("{}?{}", url, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch `/json/list` and extract the `webSocketDebuggerUrl` from the first
|
||||
/// target with `type == "browser"`, or the first target if none has that type.
|
||||
async fn fetch_cdp_list(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
|
||||
let url = format!("http://{}:{}/json/list", bracket_ipv6(host), port);
|
||||
|
||||
let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
|
||||
.await
|
||||
.map_err(|_| format!("Timeout connecting to /json/list at {}:{}", host, port))?
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to connect to /json/list at {}:{}: {}",
|
||||
host, port, e
|
||||
)
|
||||
})?;
|
||||
|
||||
let targets: Vec<serde_json::Value> =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid /json/list response: {}", e))?;
|
||||
|
||||
// Prefer targets with type "browser", fall back to first target with a ws URL
|
||||
let browser_target = targets
|
||||
.iter()
|
||||
.find(|t| t.get("type").and_then(|v| v.as_str()) == Some("browser"));
|
||||
|
||||
let target = browser_target.or_else(|| targets.first());
|
||||
|
||||
target
|
||||
.and_then(|t| t.get("webSocketDebuggerUrl"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "No webSocketDebuggerUrl found in /json/list targets".to_string())
|
||||
}
|
||||
|
||||
/// Discover a CDP endpoint by connecting directly to `ws://host:port/devtools/browser`
|
||||
/// and verifying it responds to `Browser.getVersion`.
|
||||
/// Returns the WebSocket URL on success.
|
||||
async fn discover_cdp_ws(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
|
||||
let ws_url = format!("ws://{}:{}/devtools/browser", bracket_ipv6(host), port);
|
||||
|
||||
tokio::time::timeout(timeout, async {
|
||||
let (mut ws_stream, _) = tokio_tungstenite::connect_async(&ws_url)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket connect failed at {}: {}", ws_url, e))?;
|
||||
|
||||
let cmd = r#"{"id":1,"method":"Browser.getVersion"}"#;
|
||||
ws_stream
|
||||
.send(Message::Text(cmd.into()))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send command: {}", e))?;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CdpReply {
|
||||
id: u64,
|
||||
}
|
||||
|
||||
let mut result: Result<(), String> = Err("No valid CDP response received".to_string());
|
||||
while let Some(msg) = ws_stream.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
if serde_json::from_str::<CdpReply>(&text).is_ok_and(|r| r.id == 1) {
|
||||
result = Ok(());
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
let _ = ws_stream.close(None).await;
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|_| format!("Timeout connecting to WebSocket at {}", ws_url))?
|
||||
.map(|()| ws_url)
|
||||
}
|
||||
|
||||
async fn reqwest_get_string(url: &str) -> Result<String, String> {
|
||||
let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
|
||||
resp.text().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const HTTP_404: &str =
|
||||
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
|
||||
fn http_200(body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
|
||||
body.len(), body
|
||||
)
|
||||
}
|
||||
|
||||
async fn accept_http(listener: &TcpListener, response: &str) {
|
||||
let (mut s, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = s.read(&mut buf).await;
|
||||
s.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovers_ws_url_from_json_version() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(
|
||||
&listener,
|
||||
&http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_error_when_version_returns_invalid_json() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(&listener, &http_200("not-json")).await;
|
||||
// /json/list and ws fallback both fail (server closes)
|
||||
});
|
||||
|
||||
let err = discover_cdp_url("127.0.0.1", port, None).await.unwrap_err();
|
||||
assert!(err.contains("Invalid /json/version response"));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_back_to_json_list_on_version_404() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(&listener, HTTP_404).await;
|
||||
accept_http(
|
||||
&listener,
|
||||
&http_200(r#"[{"type":"browser","webSocketDebuggerUrl":"ws://127.0.0.1:1234/devtools/browser/abc"}]"#),
|
||||
).await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
|
||||
assert!(ws_url.contains("/devtools/browser/abc"));
|
||||
assert!(ws_url.contains(&port.to_string()));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_back_to_ws_when_http_returns_404() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
// /json/version -> 404, /json/list -> 404
|
||||
accept_http(&listener, HTTP_404).await;
|
||||
accept_http(&listener, HTTP_404).await;
|
||||
|
||||
// WebSocket handshake + respond to Browser.getVersion
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
|
||||
if let Some(Ok(Message::Text(text))) = ws.next().await {
|
||||
let req: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
let id = req.get("id").unwrap();
|
||||
let reply = format!(
|
||||
r#"{{"id":{},"result":{{"protocolVersion":"1.3","product":"Chrome/136"}}}}"#,
|
||||
id
|
||||
);
|
||||
ws.send(Message::Text(reply)).await.unwrap();
|
||||
}
|
||||
let _ = ws.close(None).await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/devtools/browser", port));
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_ws_host_replaces_host_and_port() {
|
||||
let original = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let rewritten = rewrite_ws_host(original, "10.211.55.12", 9223);
|
||||
assert_eq!(rewritten, "ws://10.211.55.12:9223/devtools/browser/abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_ws_host_handles_ipv6() {
|
||||
let original = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let rewritten = rewrite_ws_host(original, "::1", 9222);
|
||||
assert_eq!(rewritten, "ws://[::1]:9222/devtools/browser/abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_adds_params_to_url_without_query() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, Some("mode=Hello"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_merges_with_existing_query() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc?token=xyz";
|
||||
let result = append_query(url, Some("mode=Hello"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc?token=xyz&mode=Hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_noop_for_none() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, None);
|
||||
assert_eq!(result, url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_noop_for_empty() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, Some(""));
|
||||
assert_eq!(result, url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_query_handles_multiple_params() {
|
||||
let url = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||
let result = append_query(url, Some("mode=Hello&token=abc"));
|
||||
assert_eq!(
|
||||
result,
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello&token=abc"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_preserves_query_params() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
accept_http(
|
||||
&listener,
|
||||
&http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let ws_url = discover_cdp_url("127.0.0.1", port, Some("mode=Hello"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/?mode=Hello", port));
|
||||
server.await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::discovery::discover_cdp_url_with_timeout;
|
||||
|
||||
const LIGHTPANDA_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const LIGHTPANDA_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
const LIGHTPANDA_DISCOVERY_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
const LIGHTPANDA_SESSION_TIMEOUT_SECS: u64 = 604800; // 1 week, the documented maximum
|
||||
const MAX_LOG_LINES: usize = 40;
|
||||
|
||||
pub struct LightpandaProcess {
|
||||
child: Child,
|
||||
pub ws_url: String,
|
||||
_log_drainers: Vec<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl LightpandaProcess {
|
||||
pub fn kill(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LightpandaProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LightpandaLaunchOptions {
|
||||
pub executable_path: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
fn build_lightpanda_serve_args(port: u16, proxy: Option<&str>) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
port.to_string(),
|
||||
"--timeout".to_string(),
|
||||
LIGHTPANDA_SESSION_TIMEOUT_SECS.to_string(),
|
||||
];
|
||||
|
||||
if let Some(proxy) = proxy {
|
||||
args.push("--http_proxy".to_string());
|
||||
args.push(proxy.to_string());
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct LaunchLogBuffer {
|
||||
stdout: Arc<Mutex<VecDeque<String>>>,
|
||||
stderr: Arc<Mutex<VecDeque<String>>>,
|
||||
}
|
||||
|
||||
impl LaunchLogBuffer {
|
||||
fn push_stdout(&self, line: String) {
|
||||
push_bounded(&self.stdout, line);
|
||||
}
|
||||
|
||||
fn push_stderr(&self, line: String) {
|
||||
push_bounded(&self.stderr, line);
|
||||
}
|
||||
|
||||
fn snapshot_stdout(&self) -> Vec<String> {
|
||||
self.stdout
|
||||
.lock()
|
||||
.expect("stdout log buffer poisoned")
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn snapshot_stderr(&self) -> Vec<String> {
|
||||
self.stderr
|
||||
.lock()
|
||||
.expect("stderr log buffer poisoned")
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn push_bounded(buffer: &Mutex<VecDeque<String>>, line: String) {
|
||||
let mut guard = buffer.lock().expect("log buffer poisoned");
|
||||
if guard.len() >= MAX_LOG_LINES {
|
||||
guard.pop_front();
|
||||
}
|
||||
guard.push_back(line);
|
||||
}
|
||||
|
||||
pub fn find_lightpanda() -> Option<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Ok(output) = Command::new("which").arg("lightpanda").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(output) = Command::new("where").arg("lightpanda").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let candidates = [
|
||||
home.join(".lightpanda/lightpanda"),
|
||||
home.join(".local/bin/lightpanda"),
|
||||
];
|
||||
for c in &candidates {
|
||||
if c.exists() {
|
||||
return Some(c.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn launch_lightpanda(
|
||||
options: &LightpandaLaunchOptions,
|
||||
) -> Result<LightpandaProcess, String> {
|
||||
let binary_path = match &options.executable_path {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => find_lightpanda().ok_or(
|
||||
"Lightpanda not found. Install it from https://lightpanda.io/docs/open-source/installation or use --executable-path.",
|
||||
)?,
|
||||
};
|
||||
|
||||
let port = match options.port {
|
||||
Some(p) => p,
|
||||
None => TcpListener::bind("127.0.0.1:0")
|
||||
.and_then(|l| l.local_addr())
|
||||
.map(|a| a.port())
|
||||
.map_err(|e| format!("Failed to find an available port for Lightpanda: {}", e))?,
|
||||
};
|
||||
let args = build_lightpanda_serve_args(port, options.proxy.as_deref());
|
||||
|
||||
let mut child = Command::new(&binary_path)
|
||||
.args(&args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch Lightpanda at {:?}: {}", binary_path, e))?;
|
||||
|
||||
let (log_buffer, log_drainers) = start_log_drainers(&mut child)?;
|
||||
|
||||
let ws_url =
|
||||
match wait_for_lightpanda_ready(&mut child, port, &log_buffer, LIGHTPANDA_STARTUP_TIMEOUT)
|
||||
.await
|
||||
{
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(LightpandaProcess {
|
||||
child,
|
||||
ws_url,
|
||||
_log_drainers: log_drainers,
|
||||
})
|
||||
}
|
||||
|
||||
fn start_log_drainers(
|
||||
child: &mut Child,
|
||||
) -> Result<(LaunchLogBuffer, Vec<std::thread::JoinHandle<()>>), String> {
|
||||
let stdout = child.stdout.take().ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
"Failed to capture Lightpanda stdout".to_string()
|
||||
})?;
|
||||
let stderr = child.stderr.take().ok_or_else(|| {
|
||||
let _ = child.kill();
|
||||
"Failed to capture Lightpanda stderr".to_string()
|
||||
})?;
|
||||
|
||||
let logs = LaunchLogBuffer::default();
|
||||
let stdout_logs = logs.clone();
|
||||
let stderr_logs = logs.clone();
|
||||
|
||||
let stdout_handle =
|
||||
std::thread::spawn(move || drain_reader(stdout, move |line| stdout_logs.push_stdout(line)));
|
||||
let stderr_handle =
|
||||
std::thread::spawn(move || drain_reader(stderr, move |line| stderr_logs.push_stderr(line)));
|
||||
|
||||
Ok((logs, vec![stdout_handle, stderr_handle]))
|
||||
}
|
||||
|
||||
fn drain_reader<R, F>(reader: R, mut push: F)
|
||||
where
|
||||
R: std::io::Read,
|
||||
F: FnMut(String),
|
||||
{
|
||||
for line in BufReader::new(reader).lines() {
|
||||
match line {
|
||||
Ok(line) => push(line),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_lightpanda_ready(
|
||||
child: &mut Child,
|
||||
port: u16,
|
||||
logs: &LaunchLogBuffer,
|
||||
startup_timeout: Duration,
|
||||
) -> Result<String, String> {
|
||||
let deadline = std::time::Instant::now() + startup_timeout;
|
||||
let mut last_probe_error = None;
|
||||
|
||||
loop {
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
// Give the drainer threads a brief window to flush the last log lines
|
||||
// before we snapshot them. This is best-effort: lines written just
|
||||
// before exit may still be missing, but the most useful output (early
|
||||
// startup errors) will already be in the buffer.
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
return Err(lightpanda_launch_error(
|
||||
&format!(
|
||||
"Lightpanda exited before CDP became ready (status: {})",
|
||||
status
|
||||
),
|
||||
logs,
|
||||
last_probe_error.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
match discover_cdp_url_with_timeout("127.0.0.1", port, None, LIGHTPANDA_DISCOVERY_TIMEOUT)
|
||||
.await
|
||||
{
|
||||
Ok(ws_url) => return Ok(ws_url),
|
||||
Err(err) => last_probe_error = Some(err),
|
||||
}
|
||||
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(lightpanda_launch_error(
|
||||
&format!(
|
||||
"Timed out after {}ms waiting for Lightpanda CDP endpoint on port {}",
|
||||
startup_timeout.as_millis(),
|
||||
port
|
||||
),
|
||||
logs,
|
||||
last_probe_error.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
tokio::time::sleep(LIGHTPANDA_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn lightpanda_launch_error(
|
||||
message: &str,
|
||||
logs: &LaunchLogBuffer,
|
||||
last_probe_error: Option<&str>,
|
||||
) -> String {
|
||||
let stdout_lines = logs.snapshot_stdout();
|
||||
let stderr_lines = logs.snapshot_stderr();
|
||||
let mut details = Vec::new();
|
||||
|
||||
if let Some(err) = last_probe_error {
|
||||
details.push(format!("Last probe error: {}", err));
|
||||
}
|
||||
|
||||
if !stderr_lines.is_empty() {
|
||||
details.push(format!(
|
||||
"Lightpanda stderr (last {} lines):\n {}",
|
||||
stderr_lines.len(),
|
||||
stderr_lines.join("\n ")
|
||||
));
|
||||
}
|
||||
|
||||
if !stdout_lines.is_empty() {
|
||||
details.push(format!(
|
||||
"Lightpanda stdout (last {} lines):\n {}",
|
||||
stdout_lines.len(),
|
||||
stdout_lines.join("\n ")
|
||||
));
|
||||
}
|
||||
|
||||
if details.is_empty() {
|
||||
format!("{} (no stdout/stderr output from Lightpanda)", message)
|
||||
} else {
|
||||
format!("{}\n{}", message, details.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener as TokioTcpListener;
|
||||
|
||||
fn unused_port() -> u16 {
|
||||
std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port()
|
||||
}
|
||||
|
||||
async fn serve_json_version_once_after_delay(port: u16, delay_ms: u64, body: &'static str) {
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
let listener = TokioTcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = socket.read(&mut buf).await;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn waits_for_ready_without_logs() {
|
||||
let port = unused_port();
|
||||
tokio::spawn(serve_json_version_once_after_delay(
|
||||
port,
|
||||
150,
|
||||
r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/"}"#,
|
||||
));
|
||||
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "sleep 5"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
|
||||
let ws_url = wait_for_lightpanda_ready(&mut child, port, &logs, LIGHTPANDA_STARTUP_TIMEOUT)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn child_exit_surfaces_logs() {
|
||||
let port = unused_port();
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "echo boom >&2; sleep 0.1; exit 23"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
|
||||
let err = wait_for_lightpanda_ready(&mut child, port, &logs, LIGHTPANDA_STARTUP_TIMEOUT)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("Lightpanda exited before CDP became ready"));
|
||||
assert!(err.contains("boom"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn timeout_reports_last_probe_error() {
|
||||
let port = unused_port();
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "sleep 30"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let timeout = Duration::from_millis(300);
|
||||
let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
|
||||
let err = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
wait_for_lightpanda_ready(&mut child, port, &logs, timeout),
|
||||
)
|
||||
.await
|
||||
.expect("ready wait should return before outer timeout")
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("Timed out after 300ms waiting for Lightpanda CDP endpoint"));
|
||||
assert!(
|
||||
err.contains("Failed to connect to CDP") || err.contains("Timeout connecting to CDP")
|
||||
);
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_lightpanda_returns_none_when_missing() {
|
||||
let _ = find_lightpanda();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lightpanda_launch_error_no_logs() {
|
||||
let logs = LaunchLogBuffer::default();
|
||||
let msg = lightpanda_launch_error("Lightpanda exited", &logs, None);
|
||||
assert!(msg.contains("no stdout/stderr output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lightpanda_launch_error_with_lines() {
|
||||
let logs = LaunchLogBuffer::default();
|
||||
logs.push_stdout("stdout line".to_string());
|
||||
logs.push_stderr("stderr line".to_string());
|
||||
let msg = lightpanda_launch_error("Lightpanda exited", &logs, Some("connect failed"));
|
||||
assert!(msg.contains("stdout line"));
|
||||
assert!(msg.contains("stderr line"));
|
||||
assert!(msg.contains("Last probe error: connect failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_options() {
|
||||
let opts = LightpandaLaunchOptions::default();
|
||||
assert!(opts.executable_path.is_none());
|
||||
assert!(opts.proxy.is_none());
|
||||
assert!(opts.port.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lightpanda_serve_args_sets_explicit_session_timeout() {
|
||||
let args = build_lightpanda_serve_args(9222, None);
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
"9222".to_string(),
|
||||
"--timeout".to_string(),
|
||||
"604800".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_lightpanda_serve_args_with_proxy() {
|
||||
let args = build_lightpanda_serve_args(9333, Some("http://127.0.0.1:8080"));
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
"9333".to_string(),
|
||||
"--timeout".to_string(),
|
||||
"604800".to_string(),
|
||||
"--http_proxy".to_string(),
|
||||
"http://127.0.0.1:8080".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod chrome;
|
||||
pub mod client;
|
||||
pub mod discovery;
|
||||
pub mod lightpanda;
|
||||
pub mod types;
|
||||
@@ -1,586 +0,0 @@
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Deserialize a value that may be either a string or an integer into a String.
|
||||
/// Lightpanda sends numeric nodeIds/childIds in AX tree responses, while Chrome
|
||||
/// sends strings. This accepts both.
|
||||
fn string_or_int<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let v = Value::deserialize(deserializer)?;
|
||||
match v {
|
||||
Value::String(s) => Ok(s),
|
||||
Value::Number(n) => Ok(n.to_string()),
|
||||
other => Err(serde::de::Error::custom(format!(
|
||||
"expected string or integer, got {}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize an optional Vec where each element may be a string or integer.
|
||||
fn opt_vec_string_or_int<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let opt: Option<Vec<Value>> = Option::deserialize(deserializer)?;
|
||||
match opt {
|
||||
None => Ok(None),
|
||||
Some(vec) => {
|
||||
let mut result = Vec::with_capacity(vec.len());
|
||||
for v in vec {
|
||||
match v {
|
||||
Value::String(s) => result.push(s),
|
||||
Value::Number(n) => result.push(n.to_string()),
|
||||
other => {
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"expected string or integer in array, got {}",
|
||||
other
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CDP message envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CdpCommand {
|
||||
pub id: u64,
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CdpMessage {
|
||||
pub id: Option<u64>,
|
||||
pub result: Option<Value>,
|
||||
pub error: Option<CdpError>,
|
||||
pub method: Option<String>,
|
||||
pub params: Option<Value>,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CdpError {
|
||||
pub code: Option<i64>,
|
||||
pub message: String,
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CdpError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CDP events (broadcast to subscribers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CdpEvent {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Target domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetInfo {
|
||||
pub target_id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub target_type: String,
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub attached: Option<bool>,
|
||||
pub browser_context_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetTargetsResult {
|
||||
pub target_infos: Vec<TargetInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttachToTargetParams {
|
||||
pub target_id: String,
|
||||
pub flatten: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttachToTargetResult {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetDiscoverTargetsParams {
|
||||
pub discover: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateTargetParams {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateTargetResult {
|
||||
pub target_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CloseTargetParams {
|
||||
pub target_id: String,
|
||||
}
|
||||
|
||||
// Target events
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetCreatedEvent {
|
||||
pub target_info: TargetInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetDestroyedEvent {
|
||||
pub target_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TargetInfoChangedEvent {
|
||||
pub target_info: TargetInfo,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageNavigateParams {
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub referrer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageNavigateResult {
|
||||
pub frame_id: String,
|
||||
pub loader_id: Option<String>,
|
||||
pub error_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FrameNavigatedEvent {
|
||||
pub frame: FrameInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FrameInfo {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
// Page.javascriptDialogOpening
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JavascriptDialogOpeningEvent {
|
||||
pub url: String,
|
||||
pub message: String,
|
||||
#[serde(rename = "type")]
|
||||
pub dialog_type: String,
|
||||
pub default_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HandleJavaScriptDialogParams {
|
||||
pub accept: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_text: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EvaluateParams {
|
||||
pub expression: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub return_by_value: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub await_promise: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EvaluateResult {
|
||||
pub result: RemoteObject,
|
||||
pub exception_details: Option<ExceptionDetails>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteObject {
|
||||
#[serde(rename = "type")]
|
||||
pub object_type: String,
|
||||
pub subtype: Option<String>,
|
||||
pub value: Option<Value>,
|
||||
pub description: Option<String>,
|
||||
pub object_id: Option<String>,
|
||||
pub class_name: Option<String>,
|
||||
pub unserializable_value: Option<String>,
|
||||
pub preview: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExceptionDetails {
|
||||
pub text: String,
|
||||
pub exception: Option<RemoteObject>,
|
||||
pub line_number: Option<i64>,
|
||||
pub column_number: Option<i64>,
|
||||
}
|
||||
|
||||
// Runtime.consoleAPICalled
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsoleApiCalledEvent {
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub args: Vec<RemoteObject>,
|
||||
pub timestamp: Option<f64>,
|
||||
}
|
||||
|
||||
// Runtime.exceptionThrown
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExceptionThrownEvent {
|
||||
pub timestamp: f64,
|
||||
pub exception_details: ExceptionDetails,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessibility domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetFullAXTreeResult {
|
||||
pub nodes: Vec<AXNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AXNode {
|
||||
#[serde(deserialize_with = "string_or_int")]
|
||||
pub node_id: String,
|
||||
pub role: Option<AXValue>,
|
||||
pub name: Option<AXValue>,
|
||||
pub value: Option<AXValue>,
|
||||
pub description: Option<AXValue>,
|
||||
pub properties: Option<Vec<AXProperty>>,
|
||||
#[serde(default, deserialize_with = "opt_vec_string_or_int")]
|
||||
pub child_ids: Option<Vec<String>>,
|
||||
pub backend_d_o_m_node_id: Option<i64>,
|
||||
pub ignored: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AXValue {
|
||||
#[serde(rename = "type")]
|
||||
pub value_type: String,
|
||||
pub value: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AXProperty {
|
||||
pub name: String,
|
||||
pub value: AXValue,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Network domain (minimal for Phase 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RequestWillBeSentEvent {
|
||||
pub request_id: String,
|
||||
pub request: NetworkRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkRequest {
|
||||
pub url: String,
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoadingFinishedEvent {
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoadingFailedEvent {
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomResolveNodeParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backend_node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_group: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomResolveNodeResult {
|
||||
pub object: RemoteObject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetBoxModelParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backend_node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetBoxModelResult {
|
||||
pub model: BoxModel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BoxModel {
|
||||
pub content: Vec<f64>,
|
||||
pub padding: Vec<f64>,
|
||||
pub border: Vec<f64>,
|
||||
pub margin: Vec<f64>,
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomQuerySelectorParams {
|
||||
pub node_id: i64,
|
||||
pub selector: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomQuerySelectorResult {
|
||||
pub node_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetDocumentParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub depth: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomGetDocumentResult {
|
||||
pub root: DomNode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DomNode {
|
||||
pub node_id: i64,
|
||||
pub backend_node_id: Option<i64>,
|
||||
pub node_type: Option<i64>,
|
||||
pub node_name: Option<String>,
|
||||
pub children: Option<Vec<DomNode>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DispatchMouseEventParams {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub button: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub buttons: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub click_count: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delta_x: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delta_y: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modifiers: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DispatchKeyEventParams {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unmodified_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub windows_virtual_key_code: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub native_virtual_key_code: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modifiers: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InsertTextParams {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page.captureScreenshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CaptureScreenshotParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub format: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub clip: Option<Viewport>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub from_surface: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub capture_beyond_viewport: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Viewport {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CaptureScreenshotResult {
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime.callFunctionOn
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallFunctionOnParams {
|
||||
pub function_declaration: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<Vec<CallArgument>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub return_by_value: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub await_promise: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallArgument {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_id: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version info (from /json/version)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BrowserVersionInfo {
|
||||
#[serde(rename = "webSocketDebuggerUrl")]
|
||||
pub web_socket_debugger_url: Option<String>,
|
||||
#[serde(rename = "Browser")]
|
||||
pub browser: Option<String>,
|
||||
}
|
||||
|
||||
/// Auto-generated CDP types from protocol JSON files in `cdp-protocol/`.
|
||||
///
|
||||
/// To populate: download `browser_protocol.json` and `js_protocol.json` from
|
||||
/// <https://github.com/nicolo-ribaudo/nicolo-ribaudo.github.io/> (or any
|
||||
/// Chromium source) into `cli/cdp-protocol/` and rebuild.
|
||||
///
|
||||
/// Usage: `use super::cdp::types::generated::cdp_page::*;`
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs"));
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Cookie {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub domain: String,
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub expires: f64,
|
||||
#[serde(default)]
|
||||
pub size: i64,
|
||||
#[serde(default)]
|
||||
pub http_only: bool,
|
||||
#[serde(default)]
|
||||
pub secure: bool,
|
||||
#[serde(default)]
|
||||
pub session: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub same_site: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_all_cookies(client: &CdpClient, session_id: &str) -> Result<Vec<Cookie>, String> {
|
||||
let result = client
|
||||
.send_command_no_params("Network.getAllCookies", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let cookies: Vec<Cookie> = result
|
||||
.get("cookies")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
pub async fn get_cookies(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
urls: Option<Vec<String>>,
|
||||
) -> Result<Vec<Cookie>, String> {
|
||||
let params = match urls {
|
||||
Some(ref u) if !u.is_empty() => json!({ "urls": u }),
|
||||
_ => json!({}),
|
||||
};
|
||||
|
||||
let result = client
|
||||
.send_command("Network.getCookies", Some(params), Some(session_id))
|
||||
.await?;
|
||||
|
||||
let cookies: Vec<Cookie> = result
|
||||
.get("cookies")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
pub async fn set_cookies(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
cookies: Vec<Value>,
|
||||
current_url: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let cookies: Vec<Value> = cookies
|
||||
.into_iter()
|
||||
.map(|mut c| {
|
||||
// Auto-fill url if no domain/path/url provided
|
||||
if c.get("url").is_none() && c.get("domain").is_none() && current_url.is_some() {
|
||||
c.as_object_mut().map(|m| {
|
||||
m.insert(
|
||||
"url".to_string(),
|
||||
Value::String(current_url.unwrap().to_string()),
|
||||
)
|
||||
});
|
||||
}
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Network.setCookies",
|
||||
Some(json!({ "cookies": cookies })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_cookies(client: &CdpClient, session_id: &str) -> Result<(), String> {
|
||||
client
|
||||
.send_command_no_params("Network.clearBrowserCookies", Some(session_id))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,575 +0,0 @@
|
||||
use serde_json::Value;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::signal;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
|
||||
use super::actions::{execute_command, DaemonState};
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::state;
|
||||
use super::stream::StreamServer;
|
||||
|
||||
pub async fn run_daemon(session: &str) {
|
||||
let socket_dir = get_daemon_socket_dir();
|
||||
if !socket_dir.exists() {
|
||||
let _ = fs::create_dir_all(&socket_dir);
|
||||
}
|
||||
|
||||
// When debug mode is on, redirect stderr to a log file so daemon
|
||||
// output can be inspected (the daemon normally has stderr piped to its
|
||||
// parent which drops the read end after startup).
|
||||
#[cfg(unix)]
|
||||
if env::var("AGENT_BROWSER_DEBUG").is_ok() {
|
||||
let log_path = socket_dir.join(format!("{}.log", session));
|
||||
if let Ok(file) = fs::File::create(&log_path) {
|
||||
use std::os::unix::io::IntoRawFd;
|
||||
let fd = file.into_raw_fd();
|
||||
unsafe {
|
||||
libc::dup2(fd, 2);
|
||||
libc::close(fd);
|
||||
}
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"[daemon] Debug logging started for session: {}",
|
||||
session
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let pid_path = socket_dir.join(format!("{}.pid", session));
|
||||
let _ = fs::write(&pid_path, process::id().to_string());
|
||||
|
||||
// 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));
|
||||
|
||||
#[cfg(unix)]
|
||||
if socket_path.exists() {
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.port", session)));
|
||||
}
|
||||
|
||||
let stream_path = socket_dir.join(format!("{}.stream", session));
|
||||
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)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
|
||||
|
||||
if let Ok(days_str) = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS") {
|
||||
if let Ok(days) = days_str.parse::<u64>() {
|
||||
if days > 0 {
|
||||
let _ = state::state_clean(days);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>> = None;
|
||||
let mut stream_server_instance: Option<Arc<StreamServer>> = None;
|
||||
let preferred_port = env::var("AGENT_BROWSER_STREAM_PORT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(0);
|
||||
match StreamServer::start_without_client(preferred_port, session.to_string(), true).await {
|
||||
Ok((stream_server, client_slot)) => {
|
||||
stream_client = Some(client_slot.clone());
|
||||
if let Err(e) = fs::write(&stream_path, stream_server.port().to_string()) {
|
||||
let _ = writeln!(std::io::stderr(), "Failed to write .stream file: {}", e);
|
||||
}
|
||||
stream_server_instance = Some(Arc::new(stream_server));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "Stream server failed to start: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
let result = run_socket_server(
|
||||
&socket_path,
|
||||
session,
|
||||
stream_client,
|
||||
stream_server_instance,
|
||||
idle_timeout_ms,
|
||||
)
|
||||
.await;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.port", session)));
|
||||
}
|
||||
let _ = fs::remove_file(&pid_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)));
|
||||
let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session)));
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = writeln!(std::io::stderr(), "Daemon error: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn run_socket_server(
|
||||
socket_path: &PathBuf,
|
||||
session: &str,
|
||||
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
||||
stream_server: Option<Arc<StreamServer>>,
|
||||
idle_timeout_ms: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
let listener =
|
||||
UnixListener::bind(socket_path).map_err(|e| format!("Failed to bind socket: {}", e))?;
|
||||
|
||||
let stream_file: Option<PathBuf> = if stream_server.is_some() {
|
||||
let dir = socket_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
Some(dir.join(format!("{}.stream", session)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
|
||||
tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_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));
|
||||
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);
|
||||
|
||||
tokio::select! {
|
||||
accept_result = listener.accept() => {
|
||||
match accept_result {
|
||||
Ok((stream, _)) => {
|
||||
let state = state.clone();
|
||||
let reset_tx = reset_tx.clone();
|
||||
let sf = stream_file.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state, reset_tx, sf).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "Accept error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = drain_interval.tick() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
if mgr.has_process_exited() {
|
||||
let _ = mgr.close().await;
|
||||
s.browser = None;
|
||||
s.screencasting = false;
|
||||
s.update_stream_client().await;
|
||||
} else {
|
||||
s.drain_cdp_events_background().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
if let Some(ref mut s) = sleep_pin {
|
||||
s.as_mut().await
|
||||
} else {
|
||||
std::future::pending::<()>().await
|
||||
}
|
||||
}, if idle_timeout_ms.is_some() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
let _ = mgr.close().await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ = reset_rx.recv(), if idle_timeout_ms.is_some() => {
|
||||
continue;
|
||||
}
|
||||
_ = shutdown_signal() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
let _ = mgr.close().await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn run_socket_server(
|
||||
socket_path: &PathBuf,
|
||||
session: &str,
|
||||
stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
||||
stream_server: Option<Arc<StreamServer>>,
|
||||
idle_timeout_ms: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let preferred_port = get_port_for_session(session);
|
||||
// Try the hash-derived port first; if it is blocked (e.g. Windows Hyper-V
|
||||
// excluded port range), fall back to an OS-assigned ephemeral port.
|
||||
let listener = match TcpListener::bind(format!("127.0.0.1:{}", preferred_port)).await {
|
||||
Ok(l) => l,
|
||||
Err(_) => TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind TCP: {}", e))?,
|
||||
};
|
||||
let actual_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get local address: {}", e))?
|
||||
.port();
|
||||
|
||||
let socket_dir = socket_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
let port_path = socket_dir.join(format!("{}.port", session));
|
||||
let _ = fs::write(&port_path, actual_port.to_string());
|
||||
|
||||
let stream_file: Option<PathBuf> = if stream_server.is_some() {
|
||||
Some(socket_dir.join(format!("{}.stream", session)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = std::sync::Arc::new(
|
||||
tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_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);
|
||||
|
||||
tokio::select! {
|
||||
accept_result = listener.accept() => {
|
||||
match accept_result {
|
||||
Ok((stream, _)) => {
|
||||
let state = state.clone();
|
||||
let reset_tx = reset_tx.clone();
|
||||
let sf = stream_file.clone();
|
||||
tokio::spawn(async move {
|
||||
handle_connection(stream, state, reset_tx, sf).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "Accept error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
if let Some(ref mut s) = sleep_pin {
|
||||
s.as_mut().await
|
||||
} else {
|
||||
std::future::pending::<()>().await
|
||||
}
|
||||
}, if idle_timeout_ms.is_some() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
let _ = mgr.close().await;
|
||||
}
|
||||
let _ = fs::remove_file(&port_path);
|
||||
break;
|
||||
}
|
||||
_ = reset_rx.recv(), if idle_timeout_ms.is_some() => {
|
||||
continue;
|
||||
}
|
||||
_ = shutdown_signal() => {
|
||||
let mut s = state.lock().await;
|
||||
if let Some(ref mut mgr) = s.browser {
|
||||
let _ = mgr.close().await;
|
||||
}
|
||||
let _ = fs::remove_file(&port_path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_connection<S>(
|
||||
stream: S,
|
||||
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
|
||||
idle_reset_tx: Option<Arc<mpsc::Sender<()>>>,
|
||||
stream_file_cleanup: Option<PathBuf>,
|
||||
) where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let (reader, mut writer) = tokio::io::split(stream);
|
||||
let mut buf_reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
match buf_reader.read_line(&mut line).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if looks_like_http(trimmed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let cmd: Value = match serde_json::from_str(trimmed) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let err = serde_json::json!({
|
||||
"success": false,
|
||||
"error": format!("Invalid JSON: {}", e),
|
||||
});
|
||||
let mut resp = serde_json::to_string(&err).unwrap_or_default();
|
||||
resp.push('\n');
|
||||
let _ = writer.write_all(resp.as_bytes()).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref tx) = idle_reset_tx {
|
||||
let _ = tx.try_send(());
|
||||
}
|
||||
|
||||
let is_close = cmd.get("action").and_then(|v| v.as_str()) == Some("close");
|
||||
|
||||
let response = {
|
||||
let mut s = state.lock().await;
|
||||
execute_command(&cmd, &mut s).await
|
||||
};
|
||||
|
||||
let mut resp = serde_json::to_string(&response).unwrap_or_default();
|
||||
resp.push('\n');
|
||||
if writer.write_all(resp.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
if is_close {
|
||||
if let Some(ref path) = stream_file_cleanup {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
process::exit(0);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_http(line: &str) -> bool {
|
||||
let prefixes = [
|
||||
"GET ", "POST ", "PUT ", "DELETE ", "PATCH ", "HEAD ", "OPTIONS ", "CONNECT ", "TRACE ",
|
||||
];
|
||||
prefixes.iter().any(|p| line.starts_with(p))
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut sigint = match signal::unix::signal(signal::unix::SignalKind::interrupt()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "Failed to install SIGINT handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
let mut sigterm = match signal::unix::signal(signal::unix::SignalKind::terminate()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"Failed to install SIGTERM handler: {}",
|
||||
e
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
let mut sighup = match signal::unix::signal(signal::unix::SignalKind::hangup()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "Failed to install SIGHUP handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = sigint.recv() => {}
|
||||
_ = sigterm.recv() => {}
|
||||
_ = sighup.recv() => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Err(e) = signal::ctrl_c().await {
|
||||
let _ = writeln!(std::io::stderr(), "Failed to install Ctrl+C handler: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_daemon_socket_dir() -> PathBuf {
|
||||
if let Ok(dir) = env::var("AGENT_BROWSER_SOCKET_DIR") {
|
||||
if !dir.is_empty() {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(xdg) = env::var("XDG_RUNTIME_DIR") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("agent-browser");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(".agent-browser");
|
||||
}
|
||||
|
||||
std::env::temp_dir().join("agent-browser")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_port_for_session(session: &str) -> u16 {
|
||||
let mut hash: i32 = 0;
|
||||
for c in session.chars() {
|
||||
hash = ((hash << 5).wrapping_sub(hash)).wrapping_add(c as i32);
|
||||
}
|
||||
49152 + ((hash.unsigned_abs() as u32 % 16383) as u16)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn test_port_matches_client_algorithm() {
|
||||
assert_eq!(get_port_for_session("default"), 50838);
|
||||
assert_eq!(get_port_for_session("my-session"), 63105);
|
||||
assert_eq!(get_port_for_session("work"), 51184);
|
||||
assert_eq!(get_port_for_session(""), 49152);
|
||||
}
|
||||
|
||||
/// Guard against re-introducing `waitpid(-1)` in daemon code.
|
||||
///
|
||||
/// Issue #1035: a SIGCHLD handler that called `waitpid(-1, WNOHANG)` was
|
||||
/// added in v0.22.3 to reap zombie Chrome processes. This races with
|
||||
/// Rust's `Child::try_wait()` / `Child::wait()` because `waitpid(-1)`
|
||||
/// reaps *any* child, stealing the exit status before Rust can collect
|
||||
/// it. The result is ECHILD errors in `BrowserManager::has_process_exited()`
|
||||
/// and `ChromeProcess::kill()`, which can leave the daemon in a broken
|
||||
/// state or cause hangs on certain Linux configurations.
|
||||
///
|
||||
/// The fix uses the existing 500ms drain interval to call
|
||||
/// `has_process_exited()` (which delegates to `Child::try_wait()`)
|
||||
/// for targeted, race-free zombie detection.
|
||||
#[test]
|
||||
fn test_no_waitpid_minus_one_in_daemon() {
|
||||
let source = include_str!("daemon.rs");
|
||||
// Only check production code (everything before `#[cfg(test)]`)
|
||||
let production_code = source.split("#[cfg(test)]").next().unwrap_or(source);
|
||||
assert!(
|
||||
!production_code.contains("waitpid(-1"),
|
||||
"daemon.rs production code must not call waitpid(-1, ...). \
|
||||
Use Child::try_wait() via has_process_exited() instead. \
|
||||
See issue #1035."
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that `Child::try_wait()` correctly detects a crashed child
|
||||
/// without needing a global SIGCHLD handler or `waitpid(-1)`.
|
||||
/// This is what `has_process_exited()` uses in the fixed code.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_child_try_wait_detects_exit_without_sigchld_handler() {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "exit 42"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn child");
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
assert!(
|
||||
!status.success(),
|
||||
"child exited with code 42, should not be success"
|
||||
);
|
||||
}
|
||||
Ok(None) => panic!("try_wait() returned None but child should have exited"),
|
||||
Err(e) => panic!("try_wait() should succeed without waitpid(-1): {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// a SIGCHLD handler.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_has_exited_detects_killed_process() {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", "sleep 60"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn child");
|
||||
|
||||
// Process should be running
|
||||
match child.try_wait() {
|
||||
Ok(None) => {} // expected
|
||||
other => panic!("expected Ok(None) for running process, got {:?}", other),
|
||||
}
|
||||
|
||||
// Kill it (simulates Chrome crash)
|
||||
child.kill().expect("failed to kill child");
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
// try_wait should detect the exit
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {} // expected: detected the crash
|
||||
other => panic!(
|
||||
"expected Ok(Some(_)) after kill, got {:?}. \
|
||||
Crash detection via try_wait() must work for the drain \
|
||||
interval fix (issue #1035) to function correctly.",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use similar::{ChangeTag, TextDiff};
|
||||
|
||||
pub struct ScreenshotDiffResult {
|
||||
pub total_pixels: u64,
|
||||
pub different_pixels: u64,
|
||||
pub mismatch_percentage: f64,
|
||||
pub matched: bool,
|
||||
pub diff_image: Option<Vec<u8>>,
|
||||
pub dimension_mismatch: Option<Value>,
|
||||
}
|
||||
|
||||
pub struct SnapshotDiffResult {
|
||||
pub diff: String,
|
||||
pub additions: usize,
|
||||
pub removals: usize,
|
||||
pub unchanged: usize,
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
pub fn diff_screenshot(
|
||||
baseline: &[u8],
|
||||
current: &[u8],
|
||||
threshold: f64,
|
||||
) -> Result<ScreenshotDiffResult, String> {
|
||||
let img_a = image::load_from_memory(baseline)
|
||||
.map_err(|e| format!("Failed to decode baseline image: {}", e))?;
|
||||
let img_b = image::load_from_memory(current)
|
||||
.map_err(|e| format!("Failed to decode current image: {}", e))?;
|
||||
|
||||
let (wa, ha) = (img_a.width(), img_a.height());
|
||||
let (wb, hb) = (img_b.width(), img_b.height());
|
||||
|
||||
if wa != wb || ha != hb {
|
||||
return Ok(ScreenshotDiffResult {
|
||||
total_pixels: (wa as u64) * (ha as u64),
|
||||
different_pixels: (wa as u64) * (ha as u64),
|
||||
mismatch_percentage: 100.0,
|
||||
matched: false,
|
||||
diff_image: None,
|
||||
dimension_mismatch: Some(json!({
|
||||
"expected": { "width": wa, "height": ha },
|
||||
"actual": { "width": wb, "height": hb },
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
let rgba_a = img_a.to_rgba8();
|
||||
let rgba_b = img_b.to_rgba8();
|
||||
let total = (wa as u64) * (ha as u64);
|
||||
let max_color_distance = threshold * 255.0 * (3.0_f64).sqrt();
|
||||
let mut different = 0u64;
|
||||
|
||||
let mut diff_img = image::RgbaImage::new(wa, ha);
|
||||
|
||||
for y in 0..ha {
|
||||
for x in 0..wa {
|
||||
let pa = rgba_a.get_pixel(x, y);
|
||||
let pb = rgba_b.get_pixel(x, y);
|
||||
let dr = (pa[0] as f64) - (pb[0] as f64);
|
||||
let dg = (pa[1] as f64) - (pb[1] as f64);
|
||||
let db = (pa[2] as f64) - (pb[2] as f64);
|
||||
let dist = (dr * dr + dg * dg + db * db).sqrt();
|
||||
|
||||
if dist > max_color_distance {
|
||||
different += 1;
|
||||
diff_img.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
|
||||
} else {
|
||||
let gray = ((pa[0] as u16 + pa[1] as u16 + pa[2] as u16) / 3) as u8;
|
||||
let dimmed = (gray as f64 * 0.3) as u8;
|
||||
diff_img.put_pixel(x, y, image::Rgba([dimmed, dimmed, dimmed, 255]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mismatch = if total > 0 {
|
||||
(different as f64 / total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let diff_bytes = if different > 0 {
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
diff_img
|
||||
.write_to(&mut buf, image::ImageFormat::Png)
|
||||
.map_err(|e| format!("Failed to encode diff image: {}", e))?;
|
||||
Some(buf.into_inner())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ScreenshotDiffResult {
|
||||
total_pixels: total,
|
||||
different_pixels: different,
|
||||
mismatch_percentage: mismatch,
|
||||
matched: different == 0,
|
||||
diff_image: diff_bytes,
|
||||
dimension_mismatch: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute a snapshot diff using the Myers algorithm via the `similar` crate.
|
||||
pub fn diff_snapshots(before: &str, after: &str) -> SnapshotDiffResult {
|
||||
// Fast path: identical inputs.
|
||||
// This avoids constructing the `similar` TextDiff object and running the diff
|
||||
// iteration when agents compare a snapshot to itself (common in retry/loop
|
||||
// workloads).
|
||||
if before == after {
|
||||
let unchanged = before.lines().count();
|
||||
return SnapshotDiffResult {
|
||||
diff: String::new(),
|
||||
additions: 0,
|
||||
removals: 0,
|
||||
unchanged,
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
|
||||
let text_diff = TextDiff::from_lines(before, after);
|
||||
|
||||
let mut additions = 0usize;
|
||||
let mut removals = 0usize;
|
||||
let mut unchanged = 0usize;
|
||||
|
||||
for change in text_diff.iter_all_changes() {
|
||||
match change.tag() {
|
||||
ChangeTag::Insert => additions += 1,
|
||||
ChangeTag::Delete => removals += 1,
|
||||
ChangeTag::Equal => unchanged += 1,
|
||||
}
|
||||
}
|
||||
|
||||
let changed = additions > 0 || removals > 0;
|
||||
|
||||
let diff = text_diff
|
||||
.unified_diff()
|
||||
.context_radius(3)
|
||||
.header("before", "after")
|
||||
.to_string();
|
||||
|
||||
SnapshotDiffResult {
|
||||
diff,
|
||||
additions,
|
||||
removals,
|
||||
unchanged,
|
||||
changed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy JSON diff output for backwards compatibility.
|
||||
pub fn diff_text(a: &str, b: &str) -> Value {
|
||||
let result = diff_snapshots(a, b);
|
||||
json!({
|
||||
"identical": !result.changed,
|
||||
"additions": result.additions,
|
||||
"removals": result.removals,
|
||||
"deletions": result.removals,
|
||||
"unchanged": result.unchanged,
|
||||
"changed": result.changed,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn diff_unified(a: &str, b: &str) -> String {
|
||||
diff_snapshots(a, b).diff
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_diff_identical() {
|
||||
let result = diff_text("hello\nworld", "hello\nworld");
|
||||
assert_eq!(result.get("identical").unwrap(), true);
|
||||
assert_eq!(result.get("changed").unwrap(), false);
|
||||
assert_eq!(result.get("unchanged").unwrap(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_additions() {
|
||||
let result = diff_text("hello\n", "hello\nworld\n");
|
||||
assert_eq!(result.get("identical").unwrap(), false);
|
||||
assert_eq!(result.get("changed").unwrap(), true);
|
||||
assert!(result.get("additions").unwrap().as_i64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_deletions() {
|
||||
let result = diff_text("hello\nworld\n", "hello\n");
|
||||
assert_eq!(result.get("identical").unwrap(), false);
|
||||
assert!(result.get("removals").unwrap().as_i64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_unified_output() {
|
||||
let output = diff_unified("a\nb\nc\n", "a\nx\nc\n");
|
||||
assert!(output.contains("---"));
|
||||
assert!(output.contains("+++"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snapshot_diff_struct() {
|
||||
let result = diff_snapshots("line1\nline2\n", "line1\nline3\n");
|
||||
assert!(result.changed);
|
||||
assert_eq!(result.additions, 1);
|
||||
assert_eq!(result.removals, 1);
|
||||
assert_eq!(result.unchanged, 1);
|
||||
assert!(!result.diff.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_snapshots_identical_fast_path() {
|
||||
let input = "hello\nworld\n";
|
||||
let result = diff_snapshots(input, input);
|
||||
assert!(!result.changed);
|
||||
assert_eq!(result.additions, 0);
|
||||
assert_eq!(result.removals, 0);
|
||||
assert_eq!(result.unchanged, input.lines().count());
|
||||
assert!(result.diff.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_diff_snapshots_identical_and_changed() {
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
let identical_a = (0..200)
|
||||
.map(|i| format!("line {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let identical_b = identical_a.clone();
|
||||
|
||||
let changed_a = identical_a.clone();
|
||||
let changed_b = (0..200)
|
||||
.map(|i| {
|
||||
if i == 123 {
|
||||
format!("line {i} changed")
|
||||
} else {
|
||||
format!("line {i}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
// Keep the iteration count high enough to measure, but low enough
|
||||
// to avoid long CI times when someone runs `--ignored`.
|
||||
let iters = 50_000usize;
|
||||
|
||||
let start = Instant::now();
|
||||
let mut acc_changed = 0usize;
|
||||
for _ in 0..iters {
|
||||
let r = diff_snapshots(black_box(&identical_a), black_box(&identical_b));
|
||||
acc_changed ^= r.unchanged;
|
||||
}
|
||||
let identical_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let start = Instant::now();
|
||||
let mut acc_changed2 = 0usize;
|
||||
for _ in 0..iters {
|
||||
let r = diff_snapshots(black_box(&changed_a), black_box(&changed_b));
|
||||
acc_changed2 ^= r.additions;
|
||||
}
|
||||
let changed_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
// Prevent the compiler from optimizing everything away.
|
||||
black_box(acc_changed);
|
||||
black_box(acc_changed2);
|
||||
|
||||
println!(
|
||||
"bench_diff_snapshots_identical_and_changed: iters={iters} identical_ms={identical_ms:.2} changed_ms={changed_ms:.2}"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,362 +0,0 @@
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
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`.
|
||||
///
|
||||
/// Serves two purposes:
|
||||
/// - `GET /` redirects to Chrome's built-in DevTools frontend with `ws=` pointing to this server
|
||||
/// - WebSocket connections create a dedicated CDP session via `Target.attachToTarget` and proxy
|
||||
/// CDP messages through the daemon's existing browser-level connection, injecting/stripping
|
||||
/// `sessionId` so the DevTools frontend sees a page-level view
|
||||
pub struct InspectServer {
|
||||
port: u16,
|
||||
_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl InspectServer {
|
||||
/// Start the inspect proxy server.
|
||||
///
|
||||
/// - `proxy_handle`: lightweight handle for sending/receiving raw CDP messages
|
||||
/// - `target_id`: the CDP target ID of the page to inspect
|
||||
/// - `chrome_host_port`: the Chrome debug server address (e.g. "127.0.0.1:9222")
|
||||
pub async fn start(
|
||||
proxy_handle: InspectProxyHandle,
|
||||
target_id: String,
|
||||
chrome_host_port: String,
|
||||
) -> Result<Self, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to bind inspect server: {}", e))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get local addr: {}", e))?
|
||||
.port();
|
||||
|
||||
let proxy = Arc::new(proxy_handle);
|
||||
|
||||
let handle = tokio::spawn(accept_loop(
|
||||
listener,
|
||||
proxy,
|
||||
target_id,
|
||||
chrome_host_port,
|
||||
port,
|
||||
));
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
_handle: handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
pub fn shutdown(self) {
|
||||
self._handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn accept_loop(
|
||||
listener: TcpListener,
|
||||
proxy: Arc<InspectProxyHandle>,
|
||||
target_id: String,
|
||||
chrome_host_port: String,
|
||||
proxy_port: u16,
|
||||
) {
|
||||
loop {
|
||||
let (stream, _) = match listener.accept().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let proxy = proxy.clone();
|
||||
let tid = target_id.clone();
|
||||
let chp = chrome_host_port.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, proxy, tid, chp, proxy_port).await {
|
||||
let _ = writeln!(std::io::stderr(), "[inspect] connection error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: tokio::net::TcpStream,
|
||||
proxy: Arc<InspectProxyHandle>,
|
||||
target_id: String,
|
||||
chrome_host_port: String,
|
||||
proxy_port: u16,
|
||||
) -> Result<(), String> {
|
||||
// Peek at the request line to determine routing WITHOUT consuming bytes.
|
||||
// This is critical: tokio_tungstenite::accept_async needs to read the full
|
||||
// HTTP upgrade request itself, so we must not consume anything for WS paths.
|
||||
let mut peek_buf = [0u8; 32];
|
||||
let n = stream
|
||||
.peek(&mut peek_buf)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let peek = String::from_utf8_lossy(&peek_buf[..n]);
|
||||
|
||||
if peek.starts_with("GET /ws") {
|
||||
return handle_ws_proxy(stream, proxy, target_id).await;
|
||||
}
|
||||
|
||||
if peek.starts_with("GET / ") {
|
||||
let buf_reader = BufReader::new(stream);
|
||||
return handle_http_redirect(buf_reader, chrome_host_port, proxy_port).await;
|
||||
}
|
||||
|
||||
// Unknown request -- consume and respond 404
|
||||
let mut stream = stream;
|
||||
let mut discard = [0u8; 4096];
|
||||
let _ = stream.read(&mut discard).await;
|
||||
let resp = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
stream
|
||||
.write_all(resp.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MAX_HEADER_BYTES: usize = 8192;
|
||||
|
||||
async fn handle_http_redirect(
|
||||
buf_reader: BufReader<tokio::net::TcpStream>,
|
||||
chrome_host_port: String,
|
||||
proxy_port: u16,
|
||||
) -> Result<(), String> {
|
||||
let mut br = buf_reader;
|
||||
let mut total_bytes = 0usize;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let n = br.read_line(&mut line).await.map_err(|e| e.to_string())?;
|
||||
total_bytes += n;
|
||||
if line == "\r\n" || line == "\n" || line.is_empty() || total_bytes > MAX_HEADER_BYTES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let location = format!(
|
||||
"http://{}/devtools/devtools_app.html?ws=127.0.0.1:{}/ws",
|
||||
chrome_host_port, proxy_port
|
||||
);
|
||||
let body = format!(
|
||||
"<html><body>Redirecting to <a href=\"{url}\">{url}</a></body></html>",
|
||||
url = location
|
||||
);
|
||||
let resp = format!(
|
||||
"HTTP/1.1 302 Found\r\nLocation: {}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
location,
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let mut stream = br.into_inner();
|
||||
stream
|
||||
.write_all(resp.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_ws_proxy(
|
||||
stream: tokio::net::TcpStream,
|
||||
proxy: Arc<InspectProxyHandle>,
|
||||
target_id: String,
|
||||
) -> Result<(), String> {
|
||||
let ws_stream = tokio_tungstenite::accept_async(stream)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket handshake failed: {}", e))?;
|
||||
|
||||
// Create a dedicated CDP session for this DevTools connection.
|
||||
// Each connection gets its own session so domain enablements (DOM.enable, etc.)
|
||||
// always trigger fresh initial state dumps from Chrome.
|
||||
let attach_id = ATTACH_ID.fetch_sub(1, Ordering::SeqCst);
|
||||
let attach_cmd = format!(
|
||||
r#"{{"id":{},"method":"Target.attachToTarget","params":{{"targetId":"{}","flatten":true}}}}"#,
|
||||
attach_id, target_id
|
||||
);
|
||||
|
||||
// Subscribe BEFORE sending so we don't miss the response (tokio broadcast
|
||||
// receivers only deliver messages to receivers that already exist).
|
||||
let mut raw_rx = proxy.subscribe_raw();
|
||||
|
||||
proxy
|
||||
.send_raw(attach_cmd)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send attachToTarget: {}", e))?;
|
||||
|
||||
// Wait for the attachToTarget response to extract the session ID
|
||||
let session_id = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while let Ok(raw_msg) = raw_rx.recv().await {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&raw_msg.text) {
|
||||
if val.get("id").and_then(|v| v.as_i64()) == Some(attach_id) {
|
||||
if let Some(sid) = val
|
||||
.get("result")
|
||||
.and_then(|r| r.get("sessionId"))
|
||||
.and_then(|s| s.as_str())
|
||||
{
|
||||
return Ok(sid.to_string());
|
||||
}
|
||||
return Err("attachToTarget failed".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("raw message channel closed".to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Timed out waiting for attachToTarget response".to_string())?
|
||||
.map_err(|e| format!("Failed to create DevTools session: {}", e))?;
|
||||
|
||||
let (ws_tx, mut ws_rx) = ws_stream.split();
|
||||
let ws_tx = Arc::new(Mutex::new(ws_tx));
|
||||
|
||||
let mut raw_rx = proxy.subscribe_raw();
|
||||
let ws_tx_clone = ws_tx.clone();
|
||||
let session_id_clone = session_id.clone();
|
||||
|
||||
// Chrome -> DevTools: forward messages matching our session, strip sessionId
|
||||
let mut chrome_to_devtools = tokio::spawn(async move {
|
||||
loop {
|
||||
let raw_msg = match raw_rx.recv().await {
|
||||
Ok(msg) => msg,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"[inspect] warning: dropped {} CDP messages (channel lag)",
|
||||
n
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if raw_msg.session_id.as_deref() != Some(&session_id_clone) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let stripped = strip_session_id(&raw_msg.text);
|
||||
|
||||
let mut tx = ws_tx_clone.lock().await;
|
||||
if tx.send(Message::Text(stripped)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DevTools -> Chrome: inject sessionId and forward
|
||||
let proxy_for_send = proxy.clone();
|
||||
let session_id_for_send = session_id.clone();
|
||||
let mut devtools_to_chrome = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = ws_rx.next().await {
|
||||
let text = match msg {
|
||||
Message::Text(t) => t,
|
||||
Message::Close(_) => break,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let injected = inject_session_id(&text, &session_id_for_send);
|
||||
if proxy_for_send.send_raw(injected).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = &mut chrome_to_devtools => {
|
||||
devtools_to_chrome.abort();
|
||||
},
|
||||
_ = &mut devtools_to_chrome => {
|
||||
chrome_to_devtools.abort();
|
||||
},
|
||||
}
|
||||
|
||||
// Clean up the CDP session so Chrome doesn't leak attached targets
|
||||
let detach_cmd = format!(
|
||||
r#"{{"id":{},"method":"Target.detachFromTarget","params":{{"sessionId":"{}"}}}}"#,
|
||||
ATTACH_ID.fetch_sub(1, Ordering::SeqCst),
|
||||
session_id
|
||||
);
|
||||
let _ = proxy.send_raw(detach_cmd).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inject_session_id(json: &str, session_id: &str) -> String {
|
||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(json) {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.insert(
|
||||
"sessionId".to_string(),
|
||||
serde_json::Value::String(session_id.to_string()),
|
||||
);
|
||||
}
|
||||
serde_json::to_string(&val).unwrap_or_else(|_| json.to_string())
|
||||
} else {
|
||||
json.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_session_id(json: &str) -> String {
|
||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(json) {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.remove("sessionId");
|
||||
}
|
||||
serde_json::to_string(&val).unwrap_or_else(|_| json.to_string())
|
||||
} else {
|
||||
json.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_inject_session_id() {
|
||||
let input = r#"{"id":1,"method":"DOM.getDocument"}"#;
|
||||
let result = inject_session_id(input, "abc123");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
|
||||
assert_eq!(parsed["sessionId"], "abc123");
|
||||
assert_eq!(parsed["method"], "DOM.getDocument");
|
||||
assert_eq!(parsed["id"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_session_id_empty_object() {
|
||||
let result = inject_session_id("{}", "abc");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
|
||||
assert_eq!(parsed["sessionId"], "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_session_id() {
|
||||
let input = r#"{"id":1,"result":{},"sessionId":"abc123"}"#;
|
||||
let result = strip_session_id(input);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid JSON");
|
||||
assert!(parsed.get("sessionId").is_none());
|
||||
assert_eq!(parsed["id"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_then_strip_roundtrip() {
|
||||
let input = r#"{"id":42,"method":"Runtime.evaluate"}"#;
|
||||
let injected = inject_session_id(input, "sess1");
|
||||
let stripped = strip_session_id(&injected);
|
||||
let original: serde_json::Value = serde_json::from_str(input).unwrap();
|
||||
let result: serde_json::Value = serde_json::from_str(&stripped).unwrap();
|
||||
assert_eq!(original, result);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
||||
#[allow(dead_code)]
|
||||
pub mod actions;
|
||||
#[allow(dead_code)]
|
||||
pub mod auth;
|
||||
#[allow(dead_code)]
|
||||
pub mod browser;
|
||||
#[allow(dead_code)]
|
||||
pub mod cdp;
|
||||
#[allow(dead_code)]
|
||||
pub mod cookies;
|
||||
#[allow(dead_code)]
|
||||
pub mod daemon;
|
||||
#[allow(dead_code)]
|
||||
pub mod diff;
|
||||
#[allow(dead_code)]
|
||||
pub mod element;
|
||||
#[allow(dead_code)]
|
||||
pub mod inspect_server;
|
||||
#[allow(dead_code)]
|
||||
pub mod interaction;
|
||||
#[allow(dead_code)]
|
||||
pub mod network;
|
||||
#[allow(dead_code)]
|
||||
pub mod policy;
|
||||
#[allow(dead_code)]
|
||||
pub mod providers;
|
||||
#[allow(dead_code)]
|
||||
pub mod recording;
|
||||
#[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;
|
||||
#[allow(dead_code)]
|
||||
pub mod tracing;
|
||||
#[allow(dead_code)]
|
||||
pub mod webdriver;
|
||||
|
||||
#[cfg(test)]
|
||||
mod e2e_tests;
|
||||
#[cfg(test)]
|
||||
mod parity_tests;
|
||||
@@ -1,672 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
pub async fn set_extra_headers(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
headers: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let headers_value: Value = headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
|
||||
.collect::<serde_json::Map<String, Value>>()
|
||||
.into();
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Network.setExtraHTTPHeaders",
|
||||
Some(json!({ "headers": headers_value })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_offline(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
offline: bool,
|
||||
) -> Result<(), String> {
|
||||
client
|
||||
.send_command(
|
||||
"Network.emulateNetworkConditions",
|
||||
Some(json!({
|
||||
"offline": offline,
|
||||
"latency": 0,
|
||||
"downloadThroughput": -1,
|
||||
"uploadThroughput": -1,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_content(client: &CdpClient, session_id: &str, html: &str) -> Result<(), String> {
|
||||
// Get current frame ID
|
||||
let tree_result = client
|
||||
.send_command_no_params("Page.getFrameTree", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let frame_id = tree_result
|
||||
.get("frameTree")
|
||||
.and_then(|t| t.get("frame"))
|
||||
.and_then(|f| f.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.ok_or("Could not determine frame ID")?;
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Page.setDocumentContent",
|
||||
Some(json!({
|
||||
"frameId": frame_id,
|
||||
"html": html,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DomainFilter {
|
||||
pub allowed_domains: Vec<String>,
|
||||
}
|
||||
|
||||
impl DomainFilter {
|
||||
pub fn new(domains: &str) -> Self {
|
||||
let allowed = parse_domain_list(domains);
|
||||
Self {
|
||||
allowed_domains: allowed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_allowed(&self, hostname: &str) -> bool {
|
||||
if self.allowed_domains.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let hostname = hostname.to_lowercase();
|
||||
for pattern in &self.allowed_domains {
|
||||
if let Some(suffix) = pattern.strip_prefix("*.") {
|
||||
if hostname == suffix || hostname.ends_with(&format!(".{}", suffix)) {
|
||||
return true;
|
||||
}
|
||||
} else if hostname == *pattern {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn check_url(&self, url: &str) -> Result<(), String> {
|
||||
if self.allowed_domains.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let parsed = url::Url::parse(url).map_err(|_| format!("Invalid URL: {}", url))?;
|
||||
let hostname = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| format!("No hostname in URL: {}", url))?;
|
||||
if self.is_allowed(hostname) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Domain '{}' is not in the allowed domains list",
|
||||
hostname
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_domain_list(input: &str) -> Vec<String> {
|
||||
input
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn sanitize_existing_pages(
|
||||
client: &CdpClient,
|
||||
pages: &[super::browser::PageInfo],
|
||||
filter: &DomainFilter,
|
||||
) {
|
||||
for page in pages {
|
||||
if page.url.is_empty() || page.url == "about:blank" {
|
||||
continue;
|
||||
}
|
||||
if let Ok(parsed) = url::Url::parse(&page.url) {
|
||||
if let Some(hostname) = parsed.host_str() {
|
||||
if !filter.is_allowed(hostname) {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Page.navigate",
|
||||
Some(json!({ "url": "about:blank" })),
|
||||
Some(&page.session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn install_domain_filter_script(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
allowed_domains: &[String],
|
||||
) -> Result<(), String> {
|
||||
if allowed_domains.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let domains_json = serde_json::to_string(allowed_domains).unwrap_or("[]".to_string());
|
||||
let script = format!(
|
||||
r#"(() => {{
|
||||
const _allowed = {};
|
||||
function _isDomainAllowed(hostname) {{
|
||||
hostname = hostname.toLowerCase();
|
||||
for (const p of _allowed) {{
|
||||
if (p.startsWith('*.')) {{
|
||||
const suffix = p.slice(2);
|
||||
if (hostname === suffix || hostname.endsWith('.' + suffix)) return true;
|
||||
}} else if (hostname === p) return true;
|
||||
}}
|
||||
return false;
|
||||
}}
|
||||
const OrigWS = window.WebSocket;
|
||||
window.WebSocket = function(url, protocols) {{
|
||||
try {{
|
||||
const u = new URL(url, location.href);
|
||||
if (!_isDomainAllowed(u.hostname)) throw new DOMException('WebSocket blocked: ' + u.hostname, 'SecurityError');
|
||||
}} catch(e) {{ if (e instanceof DOMException) throw e; }}
|
||||
return new OrigWS(url, protocols);
|
||||
}};
|
||||
window.WebSocket.prototype = OrigWS.prototype;
|
||||
const OrigES = window.EventSource;
|
||||
if (OrigES) {{
|
||||
window.EventSource = function(url, opts) {{
|
||||
try {{
|
||||
const u = new URL(url, location.href);
|
||||
if (!_isDomainAllowed(u.hostname)) throw new DOMException('EventSource blocked: ' + u.hostname, 'SecurityError');
|
||||
}} catch(e) {{ if (e instanceof DOMException) throw e; }}
|
||||
return new OrigES(url, opts);
|
||||
}};
|
||||
window.EventSource.prototype = OrigES.prototype;
|
||||
}}
|
||||
const origBeacon = navigator.sendBeacon;
|
||||
if (origBeacon) {{
|
||||
navigator.sendBeacon = function(url, data) {{
|
||||
try {{
|
||||
const u = new URL(url, location.href);
|
||||
if (!_isDomainAllowed(u.hostname)) return false;
|
||||
}} catch(e) {{ return false; }}
|
||||
return origBeacon.call(navigator, url, data);
|
||||
}};
|
||||
}}
|
||||
}})()"#,
|
||||
domains_json,
|
||||
);
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
Some(json!({ "source": script })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable Fetch-based network interception for domain filtering.
|
||||
/// This intercepts all requests and checks them against the allowed domains list.
|
||||
/// The actual handling of `Fetch.requestPaused` events happens in
|
||||
/// `resolve_fetch_paused` in the actions module.
|
||||
pub async fn install_domain_filter_fetch(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
handle_auth_requests: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut params = json!({
|
||||
"patterns": [{ "urlPattern": "*" }]
|
||||
});
|
||||
if handle_auth_requests {
|
||||
params["handleAuthRequests"] = json!(true);
|
||||
}
|
||||
client
|
||||
.send_command("Fetch.enable", Some(params), Some(session_id))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install both layers of domain filtering on a session:
|
||||
/// 1. JS patching (WebSocket, EventSource, sendBeacon)
|
||||
/// 2. Fetch-based network interception
|
||||
pub async fn install_domain_filter(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
allowed_domains: &[String],
|
||||
handle_auth_requests: bool,
|
||||
) -> Result<(), String> {
|
||||
install_domain_filter_script(client, session_id, allowed_domains).await?;
|
||||
install_domain_filter_fetch(client, session_id, handle_auth_requests).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console arg formatting (CDP RemoteObject → human-readable string)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a single CDP RemoteObject arg into a human-readable string.
|
||||
/// Priority: value → preview → description.
|
||||
pub fn format_console_arg(arg: &Value) -> Option<String> {
|
||||
let obj_type = arg.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let subtype = arg.get("subtype").and_then(|v| v.as_str());
|
||||
|
||||
if obj_type == "undefined" {
|
||||
return Some("undefined".to_string());
|
||||
}
|
||||
|
||||
if subtype == Some("null") {
|
||||
return Some("null".to_string());
|
||||
}
|
||||
|
||||
// Primitive value
|
||||
if let Some(v) = arg.get("value") {
|
||||
return Some(match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => "null".to_string(),
|
||||
other => other.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Skip preview for Map/Set — their description ("Map(1)", "Set(3)") is more useful
|
||||
// than their preview properties (which only show "size")
|
||||
if let Some(preview) = arg.get("preview") {
|
||||
let preview_subtype = preview.get("subtype").and_then(|v| v.as_str());
|
||||
if matches!(preview_subtype, Some("map" | "set" | "weakmap" | "weakset")) {
|
||||
return arg
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
let is_array = subtype == Some("array") || preview_subtype == Some("array");
|
||||
if let Some(props) = preview.get("properties").and_then(|v| v.as_array()) {
|
||||
let overflow = preview
|
||||
.get("overflow")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let formatted_props: Vec<String> = props
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let value_str = p.get("value").and_then(|v| v.as_str())?;
|
||||
let prop_type = p.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let formatted_value = if prop_type == "string" {
|
||||
format!("\"{}\"", value_str)
|
||||
} else {
|
||||
value_str.to_string()
|
||||
};
|
||||
if is_array {
|
||||
Some(formatted_value)
|
||||
} else {
|
||||
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
Some(format!("{}: {}", name, formatted_value))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let inner = if overflow {
|
||||
format!("{}, ...", formatted_props.join(", "))
|
||||
} else {
|
||||
formatted_props.join(", ")
|
||||
};
|
||||
|
||||
return if is_array {
|
||||
Some(format!("[{}]", inner))
|
||||
} else {
|
||||
Some(format!("{{{}}}", inner))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to description
|
||||
arg.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Format an array of CDP RemoteObject args into a single space-separated string.
|
||||
pub fn format_console_args(args: &[Value]) -> String {
|
||||
args.iter()
|
||||
.filter_map(format_console_arg)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console and error tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConsoleEntry {
|
||||
pub level: String,
|
||||
pub text: String,
|
||||
pub args: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ErrorEntry {
|
||||
pub text: String,
|
||||
pub url: Option<String>,
|
||||
pub line: Option<i64>,
|
||||
pub column: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct EventTracker {
|
||||
pub console_entries: Vec<ConsoleEntry>,
|
||||
pub error_entries: Vec<ErrorEntry>,
|
||||
pub max_entries: usize,
|
||||
}
|
||||
|
||||
impl EventTracker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
console_entries: Vec::new(),
|
||||
error_entries: Vec::new(),
|
||||
max_entries: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_console(&mut self, level: &str, text: &str, args: Vec<Value>) {
|
||||
if self.console_entries.len() >= self.max_entries {
|
||||
self.console_entries.remove(0);
|
||||
}
|
||||
self.console_entries.push(ConsoleEntry {
|
||||
level: level.to_string(),
|
||||
text: text.to_string(),
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add_error(
|
||||
&mut self,
|
||||
text: &str,
|
||||
url: Option<&str>,
|
||||
line: Option<i64>,
|
||||
col: Option<i64>,
|
||||
) {
|
||||
if self.error_entries.len() >= self.max_entries {
|
||||
self.error_entries.remove(0);
|
||||
}
|
||||
self.error_entries.push(ErrorEntry {
|
||||
text: text.to_string(),
|
||||
url: url.map(String::from),
|
||||
line,
|
||||
column: col,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn clear_console(&mut self) {
|
||||
self.console_entries.clear();
|
||||
}
|
||||
|
||||
pub fn get_console_json(&self) -> Value {
|
||||
let messages: Vec<Value> = self
|
||||
.console_entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut msg = json!({ "type": e.level, "text": e.text });
|
||||
if !e.args.is_empty() {
|
||||
msg.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("args".to_string(), Value::Array(e.args.clone()));
|
||||
}
|
||||
msg
|
||||
})
|
||||
.collect();
|
||||
json!({ "messages": messages })
|
||||
}
|
||||
|
||||
pub fn get_errors_json(&self) -> Value {
|
||||
let entries: Vec<Value> = self
|
||||
.error_entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
json!({
|
||||
"text": e.text,
|
||||
"url": e.url,
|
||||
"line": e.line,
|
||||
"column": e.column,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "errors": entries })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_exact() {
|
||||
let filter = DomainFilter::new("example.com");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(!filter.is_allowed("other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_wildcard() {
|
||||
let filter = DomainFilter::new("*.example.com");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(filter.is_allowed("api.example.com"));
|
||||
assert!(filter.is_allowed("sub.api.example.com"));
|
||||
assert!(!filter.is_allowed("other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_empty() {
|
||||
let filter = DomainFilter::new("");
|
||||
assert!(filter.is_allowed("anything.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_domain_filter_multiple() {
|
||||
let filter = DomainFilter::new("example.com, *.api.io");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(filter.is_allowed("api.io"));
|
||||
assert!(filter.is_allowed("v1.api.io"));
|
||||
assert!(!filter.is_allowed("other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_domain_list() {
|
||||
let domains = parse_domain_list("A.com, B.com , *.C.com");
|
||||
assert_eq!(domains, vec!["a.com", "b.com", "*.c.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_tracker() {
|
||||
let mut tracker = EventTracker::new();
|
||||
tracker.add_console("log", "hello", vec![]);
|
||||
tracker.add_error("oops", Some("test.js"), Some(1), Some(5));
|
||||
|
||||
assert_eq!(tracker.console_entries.len(), 1);
|
||||
assert_eq!(tracker.error_entries.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_console_json_includes_args() {
|
||||
let mut tracker = EventTracker::new();
|
||||
let raw_args = vec![
|
||||
json!({"type": "string", "value": "hello"}),
|
||||
json!({"type": "number", "value": 42}),
|
||||
];
|
||||
tracker.add_console("log", "hello 42", raw_args);
|
||||
|
||||
let result = tracker.get_console_json();
|
||||
let messages = result.get("messages").unwrap().as_array().unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].get("text").unwrap(), "hello 42");
|
||||
let args = messages[0].get("args").unwrap().as_array().unwrap();
|
||||
assert_eq!(args.len(), 2);
|
||||
assert_eq!(args[0], json!({"type": "string", "value": "hello"}));
|
||||
assert_eq!(args[1], json!({"type": "number", "value": 42}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_console_json_empty_args_omits_field() {
|
||||
let mut tracker = EventTracker::new();
|
||||
tracker.add_console("log", "text only", vec![]);
|
||||
|
||||
let result = tracker.get_console_json();
|
||||
let messages = result.get("messages").unwrap().as_array().unwrap();
|
||||
assert!(messages[0].get("args").is_none());
|
||||
}
|
||||
|
||||
// -- format_console_arg: primitives --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_string() {
|
||||
let arg = json!({"type": "string", "value": "hello"});
|
||||
assert_eq!(format_console_arg(&arg), Some("hello".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_number() {
|
||||
let arg = json!({"type": "number", "value": 42});
|
||||
assert_eq!(format_console_arg(&arg), Some("42".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_null() {
|
||||
let arg = json!({"type": "object", "subtype": "null", "value": null});
|
||||
assert_eq!(format_console_arg(&arg), Some("null".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_undefined() {
|
||||
let arg = json!({"type": "undefined"});
|
||||
assert_eq!(format_console_arg(&arg), Some("undefined".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: objects with preview --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_object_preview() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"preview": {
|
||||
"properties": [
|
||||
{"name": "userId", "type": "string", "value": "abc123"},
|
||||
{"name": "count", "type": "number", "value": "42"}
|
||||
],
|
||||
"overflow": false
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
format_console_arg(&arg),
|
||||
Some("{userId: \"abc123\", count: 42}".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_object_preview_overflow() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"preview": {
|
||||
"properties": [
|
||||
{"name": "a", "type": "number", "value": "1"}
|
||||
],
|
||||
"overflow": true
|
||||
}
|
||||
});
|
||||
assert_eq!(format_console_arg(&arg), Some("{a: 1, ...}".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: arrays with preview --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_array_preview() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"subtype": "array",
|
||||
"preview": {
|
||||
"subtype": "array",
|
||||
"properties": [
|
||||
{"name": "0", "type": "number", "value": "1"},
|
||||
{"name": "1", "type": "number", "value": "2"},
|
||||
{"name": "2", "type": "number", "value": "3"}
|
||||
],
|
||||
"overflow": false
|
||||
}
|
||||
});
|
||||
assert_eq!(format_console_arg(&arg), Some("[1, 2, 3]".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: map/set use description --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_map_uses_description() {
|
||||
let arg = json!({
|
||||
"type": "object",
|
||||
"subtype": "map",
|
||||
"description": "Map(1)",
|
||||
"preview": {
|
||||
"subtype": "map",
|
||||
"properties": [{"name": "size", "type": "number", "value": "1"}]
|
||||
}
|
||||
});
|
||||
assert_eq!(format_console_arg(&arg), Some("Map(1)".to_string()));
|
||||
}
|
||||
|
||||
// -- format_console_arg: fallback --
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_description_fallback() {
|
||||
let arg = json!({"type": "object", "description": "RegExp"});
|
||||
assert_eq!(format_console_arg(&arg), Some("RegExp".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_arg_no_value_no_preview_no_description() {
|
||||
let arg = json!({"type": "object"});
|
||||
assert_eq!(format_console_arg(&arg), None);
|
||||
}
|
||||
|
||||
// -- format_console_args --
|
||||
|
||||
#[test]
|
||||
fn test_format_console_args_join() {
|
||||
let args = vec![
|
||||
json!({"type": "string", "value": "user"}),
|
||||
json!({
|
||||
"type": "object",
|
||||
"preview": {
|
||||
"properties": [{"name": "id", "type": "number", "value": "1"}],
|
||||
"overflow": false
|
||||
}
|
||||
}),
|
||||
];
|
||||
assert_eq!(format_console_args(&args), "user {id: 1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_console_args_filters_none() {
|
||||
// An arg that returns None should be skipped, not produce empty string
|
||||
let args = vec![
|
||||
json!({"type": "string", "value": "before"}),
|
||||
json!({"type": "object"}), // no value, preview, or description → None
|
||||
json!({"type": "string", "value": "after"}),
|
||||
];
|
||||
assert_eq!(format_console_args(&args), "before after");
|
||||
}
|
||||
}
|
||||
@@ -1,700 +0,0 @@
|
||||
//! Parity tests for the native daemon's command interface.
|
||||
//!
|
||||
//! These unit tests verify:
|
||||
//! - All documented actions are handled (not returning "Not yet implemented")
|
||||
//! - Response format consistency (success/error structure)
|
||||
//! - Credential and state actions work without a browser
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::actions::{execute_command, DaemonState};
|
||||
|
||||
const ENCRYPTION_KEY_ENV: &str = "AGENT_BROWSER_ENCRYPTION_KEY";
|
||||
|
||||
struct TestKeyGuard {
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
impl TestKeyGuard {
|
||||
fn new() -> Self {
|
||||
let lock = super::auth::AUTH_TEST_MUTEX
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let original = std::env::var(ENCRYPTION_KEY_ENV).ok();
|
||||
// SAFETY: AUTH_TEST_MUTEX serializes all test access so no concurrent mutation.
|
||||
unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, "a".repeat(64)) };
|
||||
Self {
|
||||
_lock: lock,
|
||||
original,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestKeyGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: AUTH_TEST_MUTEX is held via _lock.
|
||||
match &self.original {
|
||||
Some(val) => unsafe { std::env::set_var(ENCRYPTION_KEY_ENV, val) },
|
||||
None => unsafe { std::env::remove_var(ENCRYPTION_KEY_ENV) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All documented action names that should be implemented.
|
||||
const DOCUMENTED_ACTIONS: &[&str] = &[
|
||||
"launch",
|
||||
"navigate",
|
||||
"url",
|
||||
"title",
|
||||
"content",
|
||||
"evaluate",
|
||||
"close",
|
||||
"snapshot",
|
||||
"screenshot",
|
||||
"click",
|
||||
"dblclick",
|
||||
"fill",
|
||||
"type",
|
||||
"press",
|
||||
"hover",
|
||||
"scroll",
|
||||
"select",
|
||||
"check",
|
||||
"uncheck",
|
||||
"wait",
|
||||
"gettext",
|
||||
"getattribute",
|
||||
"isvisible",
|
||||
"isenabled",
|
||||
"ischecked",
|
||||
"back",
|
||||
"forward",
|
||||
"reload",
|
||||
"cookies_get",
|
||||
"cookies_set",
|
||||
"cookies_clear",
|
||||
"storage_get",
|
||||
"storage_set",
|
||||
"storage_clear",
|
||||
"setcontent",
|
||||
"headers",
|
||||
"offline",
|
||||
"console",
|
||||
"errors",
|
||||
"state_save",
|
||||
"state_load",
|
||||
"state_list",
|
||||
"state_show",
|
||||
"state_clear",
|
||||
"state_clean",
|
||||
"state_rename",
|
||||
"trace_start",
|
||||
"trace_stop",
|
||||
"profiler_start",
|
||||
"profiler_stop",
|
||||
"recording_start",
|
||||
"recording_stop",
|
||||
"recording_restart",
|
||||
"pdf",
|
||||
"tab_list",
|
||||
"tab_new",
|
||||
"tab_switch",
|
||||
"tab_close",
|
||||
"viewport",
|
||||
"user_agent",
|
||||
"set_media",
|
||||
"download",
|
||||
"diff_snapshot",
|
||||
"diff_url",
|
||||
"credentials_set",
|
||||
"credentials_get",
|
||||
"credentials_delete",
|
||||
"credentials_list",
|
||||
"mouse",
|
||||
"keyboard",
|
||||
"focus",
|
||||
"clear",
|
||||
"selectall",
|
||||
"scrollintoview",
|
||||
"dispatch",
|
||||
"highlight",
|
||||
"tap",
|
||||
"boundingbox",
|
||||
"innertext",
|
||||
"innerhtml",
|
||||
"inputvalue",
|
||||
"setvalue",
|
||||
"count",
|
||||
"styles",
|
||||
"bringtofront",
|
||||
"timezone",
|
||||
"locale",
|
||||
"geolocation",
|
||||
"permissions",
|
||||
"dialog",
|
||||
"upload",
|
||||
"addscript",
|
||||
"addinitscript",
|
||||
"addstyle",
|
||||
"clipboard",
|
||||
"wheel",
|
||||
"device",
|
||||
"screencast_start",
|
||||
"screencast_stop",
|
||||
"waitforurl",
|
||||
"waitforloadstate",
|
||||
"waitforfunction",
|
||||
"frame",
|
||||
"mainframe",
|
||||
"getbyrole",
|
||||
"getbytext",
|
||||
"getbylabel",
|
||||
"getbyplaceholder",
|
||||
"getbyalttext",
|
||||
"getbytitle",
|
||||
"getbytestid",
|
||||
"nth",
|
||||
"find",
|
||||
"evalhandle",
|
||||
"drag",
|
||||
"expose",
|
||||
"pause",
|
||||
"multiselect",
|
||||
"responsebody",
|
||||
"waitfordownload",
|
||||
"window_new",
|
||||
"diff_screenshot",
|
||||
"video_start",
|
||||
"video_stop",
|
||||
"har_start",
|
||||
"har_stop",
|
||||
"route",
|
||||
"unroute",
|
||||
"requests",
|
||||
"request_detail",
|
||||
"credentials",
|
||||
"auth_save",
|
||||
"auth_login",
|
||||
"auth_list",
|
||||
"auth_delete",
|
||||
"auth_show",
|
||||
"confirm",
|
||||
"deny",
|
||||
"swipe",
|
||||
"device_list",
|
||||
"input_mouse",
|
||||
"input_keyboard",
|
||||
"input_touch",
|
||||
"keydown",
|
||||
"keyup",
|
||||
"inserttext",
|
||||
"mousemove",
|
||||
"mousedown",
|
||||
"mouseup",
|
||||
];
|
||||
|
||||
fn minimal_command(action: &str, id: &str) -> Value {
|
||||
let mut cmd = json!({ "action": action, "id": id });
|
||||
let obj = cmd.as_object_mut().unwrap();
|
||||
|
||||
match action {
|
||||
"navigate" | "diff_url" | "waitforurl" => {
|
||||
obj.insert("url".to_string(), json!("https://example.com"));
|
||||
}
|
||||
"evaluate" | "expose" => {
|
||||
obj.insert("script".to_string(), json!("1"));
|
||||
}
|
||||
"click" | "dblclick" | "fill" | "type" | "press" | "hover" | "scroll" | "select"
|
||||
| "check" | "uncheck" | "gettext" | "getattribute" | "isvisible" | "isenabled"
|
||||
| "ischecked" | "focus" | "clear" | "selectall" | "scrollintoview" | "dispatch"
|
||||
| "highlight" | "tap" | "boundingbox" | "innertext" | "innerhtml" | "inputvalue"
|
||||
| "setvalue" | "count" | "find" | "nth" | "getbytext" | "getbylabel"
|
||||
| "getbyplaceholder" | "getbyalttext" | "getbytitle" | "getbytestid" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
}
|
||||
"getbyrole" => {
|
||||
obj.insert("role".to_string(), json!("button"));
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
}
|
||||
"setcontent" => {
|
||||
obj.insert("html".to_string(), json!("<html></html>"));
|
||||
}
|
||||
"cookies_set" => {
|
||||
obj.insert("name".to_string(), json!("test"));
|
||||
obj.insert("value".to_string(), json!("val"));
|
||||
}
|
||||
"storage_get" | "storage_set" | "storage_clear" => {
|
||||
obj.insert("origin".to_string(), json!("https://example.com"));
|
||||
}
|
||||
"state_save" | "state_load" | "state_show" | "state_clear" => {
|
||||
obj.insert("path".to_string(), json!("test-parity-state.json"));
|
||||
}
|
||||
"state_rename" => {
|
||||
obj.insert("path".to_string(), json!("test-parity-state.json"));
|
||||
obj.insert("name".to_string(), json!("renamed"));
|
||||
}
|
||||
"state_clean" => {
|
||||
obj.insert("days".to_string(), json!(7));
|
||||
}
|
||||
"credentials_set" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
obj.insert("username".to_string(), json!("u"));
|
||||
obj.insert("password".to_string(), json!("p"));
|
||||
}
|
||||
"auth_save" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
obj.insert("url".to_string(), json!("https://example.com"));
|
||||
obj.insert("username".to_string(), json!("u"));
|
||||
obj.insert("password".to_string(), json!("p"));
|
||||
}
|
||||
"credentials_get" | "credentials_delete" | "auth_show" | "auth_delete" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
}
|
||||
"tab_switch" | "tab_close" => {
|
||||
obj.insert("index".to_string(), json!(0));
|
||||
}
|
||||
"viewport" | "user_agent" | "set_media" | "timezone" | "locale" | "geolocation"
|
||||
| "permissions" | "device" => {
|
||||
obj.insert("value".to_string(), json!(null));
|
||||
}
|
||||
"headers" => {
|
||||
obj.insert("headers".to_string(), json!({}));
|
||||
}
|
||||
"offline" => {
|
||||
obj.insert("offline".to_string(), json!(false));
|
||||
}
|
||||
"wait" => {
|
||||
obj.insert("timeout".to_string(), json!(100));
|
||||
}
|
||||
"waitforloadstate" => {
|
||||
obj.insert("state".to_string(), json!("load"));
|
||||
}
|
||||
"waitforfunction" => {
|
||||
obj.insert("script".to_string(), json!("() => true"));
|
||||
}
|
||||
"frame" => {
|
||||
obj.insert("selector".to_string(), json!("iframe"));
|
||||
}
|
||||
"addscript" => {
|
||||
obj.insert("content".to_string(), json!("console.log('test')"));
|
||||
}
|
||||
"addinitscript" => {
|
||||
obj.insert("script".to_string(), json!("console.log('init')"));
|
||||
}
|
||||
"addstyle" => {
|
||||
obj.insert("content".to_string(), json!("body { color: red }"));
|
||||
}
|
||||
"wheel" => {
|
||||
obj.insert("deltaX".to_string(), json!(0));
|
||||
obj.insert("deltaY".to_string(), json!(0));
|
||||
}
|
||||
"upload" => {
|
||||
obj.insert("selector".to_string(), json!("input[type=file]"));
|
||||
obj.insert("files".to_string(), json!([]));
|
||||
}
|
||||
"dialog" => {
|
||||
obj.insert("accept".to_string(), json!(true));
|
||||
}
|
||||
"credentials" => {
|
||||
obj.insert("username".to_string(), json!("u"));
|
||||
obj.insert("password".to_string(), json!("p"));
|
||||
}
|
||||
"auth_login" => {
|
||||
obj.insert("name".to_string(), json!("parity-test-cred"));
|
||||
}
|
||||
"route" => {
|
||||
obj.insert("url".to_string(), json!("*"));
|
||||
obj.insert("handler".to_string(), json!("continue"));
|
||||
}
|
||||
"diff_snapshot" | "diff_screenshot" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
}
|
||||
"recording_start" | "recording_restart" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-recording.webm"));
|
||||
}
|
||||
"video_start" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-video.webm"));
|
||||
}
|
||||
"profiler_start" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-profile"));
|
||||
}
|
||||
"trace_stop" | "har_stop" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-trace"));
|
||||
}
|
||||
"download" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-download"));
|
||||
}
|
||||
"multiselect" => {
|
||||
obj.insert("selector".to_string(), json!("select"));
|
||||
obj.insert("values".to_string(), json!([]));
|
||||
}
|
||||
"responsebody" => {
|
||||
obj.insert("url".to_string(), json!("https://example.com"));
|
||||
}
|
||||
"waitfordownload" => {
|
||||
obj.insert("path".to_string(), json!("/tmp/parity-download"));
|
||||
}
|
||||
"styles" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
obj.insert("names".to_string(), json!([]));
|
||||
}
|
||||
"evalhandle" => {
|
||||
obj.insert("handle".to_string(), json!(""));
|
||||
obj.insert("script".to_string(), json!("h => h"));
|
||||
}
|
||||
"drag" => {
|
||||
obj.insert("source".to_string(), json!("body"));
|
||||
obj.insert("target".to_string(), json!("body"));
|
||||
}
|
||||
"swipe" => {
|
||||
obj.insert("selector".to_string(), json!("body"));
|
||||
obj.insert("direction".to_string(), json!("left"));
|
||||
}
|
||||
"input_mouse" | "mousemove" | "mousedown" | "mouseup" => {
|
||||
obj.insert("x".to_string(), json!(100));
|
||||
obj.insert("y".to_string(), json!(100));
|
||||
}
|
||||
"input_keyboard" | "keydown" | "keyup" => {
|
||||
obj.insert("key".to_string(), json!("a"));
|
||||
}
|
||||
"input_touch" => {
|
||||
obj.insert("type".to_string(), json!("touchStart"));
|
||||
obj.insert("touchPoints".to_string(), json!([]));
|
||||
}
|
||||
"inserttext" => {
|
||||
obj.insert("text".to_string(), json!("test"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Action dispatch coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_all_documented_actions_are_handled() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
for (i, action) in DOCUMENTED_ACTIONS.iter().enumerate() {
|
||||
let id = format!("parity-{}", i);
|
||||
let cmd = minimal_command(action, &id);
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert!(
|
||||
result.get("id").is_some(),
|
||||
"Action '{}': response missing 'id'",
|
||||
action
|
||||
);
|
||||
|
||||
let error = result.get("error").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
assert!(
|
||||
!error.contains("Not yet implemented"),
|
||||
"Action '{}' returned 'Not yet implemented')",
|
||||
action
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Response format consistency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_success_response_format() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "state_list", "id": "fmt-1" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], true);
|
||||
assert!(result.get("id").is_some());
|
||||
assert!(result.get("data").is_some());
|
||||
assert!(result.get("error").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_response_format() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "nonexistent_action_xyz", "id": "fmt-2" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], false);
|
||||
assert!(result.get("id").is_some());
|
||||
assert!(result.get("error").is_some());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Credential/state actions work without a browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_list_without_browser() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "state_list", "id": "nb-1" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], true);
|
||||
assert!(result["data"]["files"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_credentials_list_without_browser() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "credentials_list", "id": "nb-2" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
|
||||
assert_eq!(result["success"], true);
|
||||
assert!(result["data"]["credentials"].is_array() || result["data"]["profiles"].is_array());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. New feature parity tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_profile_name_validation() {
|
||||
use super::auth;
|
||||
let _key_guard = TestKeyGuard::new();
|
||||
let valid = auth::credentials_set("valid-name_123", "u", "p", None);
|
||||
assert!(valid.is_ok());
|
||||
let invalid = auth::credentials_set("invalid/name", "u", "p", None);
|
||||
assert!(invalid.is_err());
|
||||
let invalid2 = auth::credentials_set("", "u", "p", None);
|
||||
assert!(invalid2.is_err());
|
||||
let invalid3 = auth::credentials_set("has space", "u", "p", None);
|
||||
assert!(invalid3.is_err());
|
||||
// Cleanup
|
||||
let _ = auth::credentials_delete("valid-name_123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_save_and_show() {
|
||||
use super::auth;
|
||||
let _key_guard = TestKeyGuard::new();
|
||||
let result = auth::auth_save(
|
||||
"parity-roundtrip",
|
||||
"https://example.com",
|
||||
"user",
|
||||
"pass",
|
||||
Some("input#user"),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let show = auth::auth_show("parity-roundtrip");
|
||||
assert!(show.is_ok());
|
||||
let data = show.unwrap();
|
||||
assert_eq!(data["profile"]["username"], "user");
|
||||
assert_eq!(data["profile"]["usernameSelector"], "input#user");
|
||||
|
||||
let full = auth::credentials_get_full("parity-roundtrip");
|
||||
assert!(full.is_ok());
|
||||
assert_eq!(full.unwrap().password, "pass");
|
||||
|
||||
// Cleanup
|
||||
let _ = auth::credentials_delete("parity-roundtrip");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_har_start_stop_without_browser() {
|
||||
let mut state = DaemonState::new();
|
||||
// har_start requires a browser. Because execute_command auto-launches when
|
||||
// no browser is present, the result depends on Chrome availability: success
|
||||
// if Chrome is found (CI), failure if not. Both outcomes are valid.
|
||||
let cmd = json!({ "action": "har_start", "id": "har-1" });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
let success = result["success"].as_bool().unwrap_or(false);
|
||||
if success {
|
||||
assert!(state.har_recording);
|
||||
} else {
|
||||
assert!(result["error"].as_str().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_clean_action() {
|
||||
let mut state = DaemonState::new();
|
||||
let cmd = json!({ "action": "state_clean", "id": "clean-1", "days": 30 });
|
||||
let result = execute_command(&cmd, &mut state).await;
|
||||
assert_eq!(result["success"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_daemon_state_new_defaults() {
|
||||
let state = DaemonState::new();
|
||||
assert!(state.browser.is_none());
|
||||
assert!(!state.har_recording);
|
||||
assert!(state.har_entries.is_empty());
|
||||
assert!(state.pending_confirmation.is_none());
|
||||
assert!(!state.request_tracking);
|
||||
assert!(state.tracked_requests.is_empty());
|
||||
assert!(state.active_frame_id.is_none());
|
||||
assert!(state.webdriver_backend.is_none());
|
||||
assert!(state.stream_client.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tracked_request_struct() {
|
||||
use super::actions::TrackedRequest;
|
||||
let tr = TrackedRequest {
|
||||
url: "https://example.com/api".to_string(),
|
||||
method: "GET".to_string(),
|
||||
headers: json!({"Accept": "text/html"}),
|
||||
timestamp: 12345,
|
||||
resource_type: "Document".to_string(),
|
||||
request_id: "1.1".to_string(),
|
||||
post_data: None,
|
||||
status: Some(200),
|
||||
response_headers: None,
|
||||
mime_type: Some("text/html".to_string()),
|
||||
};
|
||||
let serialized = serde_json::to_value(&tr).unwrap();
|
||||
assert_eq!(serialized["url"], "https://example.com/api");
|
||||
assert_eq!(serialized["method"], "GET");
|
||||
assert_eq!(serialized["resourceType"], "Document");
|
||||
assert_eq!(serialized["timestamp"], 12345);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_tracking_state() {
|
||||
let mut state = DaemonState::new();
|
||||
assert!(!state.request_tracking);
|
||||
assert!(state.tracked_requests.is_empty());
|
||||
|
||||
state.tracked_requests.push(super::actions::TrackedRequest {
|
||||
url: "https://example.com".to_string(),
|
||||
method: "GET".to_string(),
|
||||
headers: json!({}),
|
||||
timestamp: 1,
|
||||
resource_type: "Document".to_string(),
|
||||
request_id: "1.1".to_string(),
|
||||
post_data: None,
|
||||
status: None,
|
||||
response_headers: None,
|
||||
mime_type: None,
|
||||
});
|
||||
state.tracked_requests.push(super::actions::TrackedRequest {
|
||||
url: "https://other.com".to_string(),
|
||||
method: "POST".to_string(),
|
||||
headers: json!({}),
|
||||
timestamp: 2,
|
||||
resource_type: "XHR".to_string(),
|
||||
request_id: "1.2".to_string(),
|
||||
post_data: None,
|
||||
status: None,
|
||||
response_headers: None,
|
||||
mime_type: None,
|
||||
});
|
||||
assert_eq!(state.tracked_requests.len(), 2);
|
||||
|
||||
// Filter
|
||||
let filtered: Vec<_> = state
|
||||
.tracked_requests
|
||||
.iter()
|
||||
.filter(|r| r.url.contains("example"))
|
||||
.collect();
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].url, "https://example.com");
|
||||
|
||||
// Clear
|
||||
state.tracked_requests.clear();
|
||||
assert!(state.tracked_requests.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matches_status_filter() {
|
||||
use super::actions::matches_status_filter;
|
||||
|
||||
// Exact match
|
||||
assert!(matches_status_filter(Some(200), "200"));
|
||||
assert!(!matches_status_filter(Some(201), "200"));
|
||||
|
||||
// Class match (Nxx)
|
||||
assert!(matches_status_filter(Some(200), "2xx"));
|
||||
assert!(matches_status_filter(Some(299), "2xx"));
|
||||
assert!(!matches_status_filter(Some(301), "2xx"));
|
||||
assert!(matches_status_filter(Some(404), "4xx"));
|
||||
|
||||
// Range match
|
||||
assert!(matches_status_filter(Some(400), "400-499"));
|
||||
assert!(matches_status_filter(Some(499), "400-499"));
|
||||
assert!(!matches_status_filter(Some(500), "400-499"));
|
||||
|
||||
// None status
|
||||
assert!(!matches_status_filter(None, "200"));
|
||||
assert!(!matches_status_filter(None, "2xx"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_addscript_and_addinitscript_separate_dispatch() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
// Both should be handled (not "Not yet implemented") even without a browser
|
||||
let cmd1 = json!({ "action": "addscript", "id": "as-1", "content": "console.log(1)" });
|
||||
let result1 = execute_command(&cmd1, &mut state).await;
|
||||
let err1 = result1["error"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
!err1.contains("Not yet implemented"),
|
||||
"addscript should be handled"
|
||||
);
|
||||
|
||||
let cmd2 = json!({ "action": "addinitscript", "id": "ais-1", "script": "console.log(2)" });
|
||||
let result2 = execute_command(&cmd2, &mut state).await;
|
||||
let err2 = result2["error"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
!err2.contains("Not yet implemented"),
|
||||
"addinitscript should be handled"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_frame_context_management() {
|
||||
let mut state = DaemonState::new();
|
||||
assert!(state.active_frame_id.is_none());
|
||||
|
||||
// Set a frame ID and verify it persists
|
||||
state.active_frame_id = Some("child-frame-123".to_string());
|
||||
assert_eq!(state.active_frame_id.as_deref(), Some("child-frame-123"));
|
||||
|
||||
// Clearing the frame ID (what mainframe does)
|
||||
state.active_frame_id = None;
|
||||
assert!(state.active_frame_id.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_addstyle_supports_content_and_url() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
// Both content-based and url-based addstyle should be recognized
|
||||
let cmd1 = json!({ "action": "addstyle", "id": "style-1", "content": "body { color: red }" });
|
||||
let result1 = execute_command(&cmd1, &mut state).await;
|
||||
let err1 = result1["error"].as_str().unwrap_or("");
|
||||
assert!(!err1.contains("Not yet implemented"));
|
||||
|
||||
let cmd2 =
|
||||
json!({ "action": "addstyle", "id": "style-2", "url": "https://example.com/style.css" });
|
||||
let result2 = execute_command(&cmd2, &mut state).await;
|
||||
let err2 = result2["error"].as_str().unwrap_or("");
|
||||
assert!(!err2.contains("Not yet implemented"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_domain_filter_sanitize() {
|
||||
use super::network::DomainFilter;
|
||||
let filter = DomainFilter::new("example.com");
|
||||
assert!(filter.is_allowed("example.com"));
|
||||
assert!(!filter.is_allowed("evil.com"));
|
||||
filter.check_url("https://example.com/path").unwrap();
|
||||
assert!(filter.check_url("https://evil.com").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_find_auto_returns_none_for_nonexistent() {
|
||||
use super::state;
|
||||
let result = state::find_auto_state_file("nonexistent-session-xyz");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Result of a policy check for an action.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PolicyResult {
|
||||
/// Action is allowed.
|
||||
Allow,
|
||||
/// Action is blocked with the given reason.
|
||||
Deny(String),
|
||||
/// Action requires confirmation before proceeding.
|
||||
RequiresConfirmation,
|
||||
}
|
||||
|
||||
/// Policy configuration loaded from a JSON file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionPolicy {
|
||||
#[serde(skip)]
|
||||
path: PathBuf,
|
||||
#[serde(default)]
|
||||
default: Option<String>,
|
||||
#[serde(default)]
|
||||
allow: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
deny: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
confirm: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Confirmation categories parsed from AGENT_BROWSER_CONFIRM_ACTIONS.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfirmActions {
|
||||
pub categories: HashSet<String>,
|
||||
}
|
||||
|
||||
impl ConfirmActions {
|
||||
pub fn from_env() -> Option<Self> {
|
||||
let val = env::var("AGENT_BROWSER_CONFIRM_ACTIONS").ok()?;
|
||||
if val.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let categories: HashSet<String> = val
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if categories.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Self { categories })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn requires_confirmation(&self, action: &str) -> bool {
|
||||
self.categories.contains(action)
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionPolicy {
|
||||
/// Load policy from a JSON file at the given path.
|
||||
pub fn load(path: &str) -> Result<Self, String> {
|
||||
let path_buf = PathBuf::from(path);
|
||||
let contents = fs::read_to_string(&path_buf)
|
||||
.map_err(|e| format!("Failed to read policy file: {}", e))?;
|
||||
let mut policy: ActionPolicy =
|
||||
serde_json::from_str(&contents).map_err(|e| format!("Invalid policy JSON: {}", e))?;
|
||||
policy.path = path_buf;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
/// Load policy if AGENT_BROWSER_ACTION_POLICY env var is set.
|
||||
/// Falls back to AGENT_BROWSER_POLICY for backwards compatibility.
|
||||
pub fn load_if_exists() -> Option<Self> {
|
||||
let path = env::var("AGENT_BROWSER_ACTION_POLICY")
|
||||
.or_else(|_| env::var("AGENT_BROWSER_POLICY"))
|
||||
.ok()?;
|
||||
Self::load(&path).ok()
|
||||
}
|
||||
|
||||
/// Check whether an action is allowed, denied, or requires confirmation.
|
||||
pub fn check(&self, action: &str) -> PolicyResult {
|
||||
if let Some(deny) = &self.deny {
|
||||
if deny.iter().any(|a| a == action) {
|
||||
return PolicyResult::Deny(format!("Action '{}' is denied by policy", action));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(confirm) = &self.confirm {
|
||||
if confirm.iter().any(|a| a == action) {
|
||||
return PolicyResult::RequiresConfirmation;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(allow) = &self.allow {
|
||||
if !allow.is_empty() && !allow.iter().any(|a| a == action) {
|
||||
let is_default_deny = self
|
||||
.default
|
||||
.as_deref()
|
||||
.map(|d| d.eq_ignore_ascii_case("deny"))
|
||||
.unwrap_or(true);
|
||||
if is_default_deny {
|
||||
return PolicyResult::Deny(format!(
|
||||
"Action '{}' is not in the allow list",
|
||||
action
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if let Some(ref default) = self.default {
|
||||
if default.eq_ignore_ascii_case("deny") {
|
||||
return PolicyResult::Deny(format!(
|
||||
"Action '{}' denied: default policy is deny",
|
||||
action
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
PolicyResult::Allow
|
||||
}
|
||||
|
||||
/// Reload policy from the file. Re-reads the JSON and updates the policy.
|
||||
pub fn reload(&mut self) -> Result<(), String> {
|
||||
let contents = fs::read_to_string(&self.path)
|
||||
.map_err(|e| format!("Failed to read policy file: {}", e))?;
|
||||
let mut policy: ActionPolicy =
|
||||
serde_json::from_str(&contents).map_err(|e| format!("Invalid policy JSON: {}", e))?;
|
||||
policy.path = self.path.clone();
|
||||
*self = policy;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
#[test]
|
||||
fn test_policy_allow_whitelist() {
|
||||
let json = r#"{"allow": ["click", "type"], "deny": [], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("click"), PolicyResult::Allow);
|
||||
assert_eq!(policy.check("type"), PolicyResult::Allow);
|
||||
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_deny() {
|
||||
let json = r#"{"allow": [], "deny": ["delete"], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(policy.check("delete"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_confirm() {
|
||||
let json = r#"{"allow": [], "deny": [], "confirm": ["submit"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("submit"), PolicyResult::RequiresConfirmation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_deny_takes_precedence() {
|
||||
let json = r#"{"allow": ["danger"], "deny": ["danger"], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(policy.check("danger"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_confirm_takes_precedence_over_allow() {
|
||||
let json = r#"{"allow": ["submit"], "deny": [], "confirm": ["submit"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("submit"), PolicyResult::RequiresConfirmation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_empty_allow_allows_all() {
|
||||
let json = r#"{"allow": [], "deny": [], "confirm": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("anything"), PolicyResult::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_missing_allow_allows_all() {
|
||||
let json = r#"{"deny": []}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("anything"), PolicyResult::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_default_allow() {
|
||||
let json = r#"{"default": "allow", "deny": ["navigate"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("click"), PolicyResult::Allow);
|
||||
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_policy_default_deny() {
|
||||
let json = r#"{"default": "deny", "allow": ["click"]}"#;
|
||||
let policy: ActionPolicy = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(policy.check("click"), PolicyResult::Allow);
|
||||
assert!(matches!(policy.check("navigate"), PolicyResult::Deny(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_confirm_actions_from_env() {
|
||||
let _guard = EnvGuard::new(&["AGENT_BROWSER_CONFIRM_ACTIONS"]);
|
||||
_guard.set("AGENT_BROWSER_CONFIRM_ACTIONS", "navigate,click,fill");
|
||||
let ca = ConfirmActions::from_env().unwrap();
|
||||
assert!(ca.requires_confirmation("navigate"));
|
||||
assert!(ca.requires_confirmation("click"));
|
||||
assert!(ca.requires_confirmation("fill"));
|
||||
assert!(!ca.requires_confirmation("screenshot"));
|
||||
}
|
||||
}
|
||||
@@ -1,816 +0,0 @@
|
||||
//! Browser provider connections for remote CDP sessions.
|
||||
//!
|
||||
//! Supports AgentCore, Browserbase, Browserless, Browser Use, and Kernel providers.
|
||||
//! Each provider returns a CDP WebSocket URL for connecting via BrowserManager.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
|
||||
/// Provider session info for cleanup on failure.
|
||||
#[derive(Debug)]
|
||||
pub struct ProviderSession {
|
||||
pub provider: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProviderConnection {
|
||||
pub ws_url: String,
|
||||
pub session: Option<ProviderSession>,
|
||||
/// If true, the WebSocket IS the page session (no Target.* commands).
|
||||
pub direct_page: bool,
|
||||
}
|
||||
|
||||
/// Connects to the specified browser provider and returns a CDP WebSocket URL
|
||||
/// along with session info for cleanup on failure.
|
||||
pub async fn connect_provider(provider_name: &str) -> Result<ProviderConnection, String> {
|
||||
match provider_name.to_lowercase().as_str() {
|
||||
"browserbase" => {
|
||||
let (url, session) = connect_browserbase().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"browserless" => {
|
||||
let (url, session) = connect_browserless().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"browser-use" | "browseruse" => {
|
||||
let (url, session) = connect_browser_use().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"kernel" => {
|
||||
let (url, session) = connect_kernel().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
"agentcore" => {
|
||||
let (url, session) = connect_agentcore().await?;
|
||||
Ok(ProviderConnection {
|
||||
ws_url: url,
|
||||
session,
|
||||
direct_page: false,
|
||||
})
|
||||
}
|
||||
_ => Err(format!(
|
||||
"Unknown provider '{}'. Supported: browserbase, browserless, browser-use, kernel, agentcore",
|
||||
provider_name
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Close a provider session (call on CDP connect failure).
|
||||
pub async fn close_provider_session(session: &ProviderSession) {
|
||||
let client = reqwest::Client::new();
|
||||
match session.provider.as_str() {
|
||||
"browserbase" => {
|
||||
if let Ok(api_key) = env::var("BROWSERBASE_API_KEY") {
|
||||
let _ = client
|
||||
.post(format!(
|
||||
"https://api.browserbase.com/v1/sessions/{}",
|
||||
session.session_id
|
||||
))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-BB-API-Key", &api_key)
|
||||
.json(&serde_json::json!({ "status": "REQUEST_RELEASE" }))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"browser-use" => {
|
||||
if let Ok(api_key) = env::var("BROWSER_USE_API_KEY") {
|
||||
let _ = client
|
||||
.patch(format!(
|
||||
"https://api.browser-use.com/api/v2/browsers/{}",
|
||||
session.session_id
|
||||
))
|
||||
.header("X-Browser-Use-API-Key", &api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({ "action": "stop" }))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"browserless" => {
|
||||
// session_id holds the stop URL for browserless
|
||||
let _ = client.delete(&session.session_id).send().await;
|
||||
}
|
||||
"kernel" => {
|
||||
if let Ok(api_key) = env::var("KERNEL_API_KEY") {
|
||||
let endpoint = env::var("KERNEL_ENDPOINT")
|
||||
.unwrap_or_else(|_| "https://api.onkernel.com".to_string());
|
||||
let _ = client
|
||||
.delete(format!(
|
||||
"{}/browsers/{}",
|
||||
endpoint.trim_end_matches('/'),
|
||||
session.session_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"agentcore" => {
|
||||
// AgentCore session cleanup is handled via signed DELETE request
|
||||
let _ = close_agentcore_session(&session.session_id).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_browserbase() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("BROWSERBASE_API_KEY")
|
||||
.map_err(|_| "BROWSERBASE_API_KEY environment variable is not set")?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post("https://api.browserbase.com/v1/sessions")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-bb-api-key", &api_key)
|
||||
.body("{}")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Browserbase request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Browserbase response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"Browserbase API error ({}): {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid Browserbase response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let ws_url = json
|
||||
.get("connectUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| "Browserbase response missing connectUrl".to_string())?;
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
Some(ProviderSession {
|
||||
provider: "browserbase".to_string(),
|
||||
session_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn connect_browserless() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("BROWSERLESS_API_KEY")
|
||||
.map_err(|_| "BROWSERLESS_API_KEY environment variable is not set")?;
|
||||
|
||||
let api_url = env::var("BROWSERLESS_API_URL")
|
||||
.unwrap_or_else(|_| "https://production-sfo.browserless.io".to_string());
|
||||
let browser_type =
|
||||
env::var("BROWSERLESS_BROWSER_TYPE").unwrap_or_else(|_| "chromium".to_string());
|
||||
|
||||
let supported = ["chromium", "chrome"];
|
||||
if !supported.contains(&browser_type.as_str()) {
|
||||
return Err(format!(
|
||||
"BROWSERLESS_BROWSER_TYPE \"{}\" is not supported. Only {} are allowed.",
|
||||
browser_type,
|
||||
supported.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
let ttl: u64 = env::var("BROWSERLESS_TTL")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(300000);
|
||||
let stealth = env::var("BROWSERLESS_STEALTH")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(true);
|
||||
|
||||
let url = format!("{}/session", api_url.trim_end_matches('/'));
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(&url)
|
||||
.query(&[("token", &api_key)])
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&json!({
|
||||
"ttl": ttl,
|
||||
"stealth": stealth,
|
||||
"browser": browser_type,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Browserless request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Browserless response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"Browserless API error ({}): {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid Browserless response: {}", e))?;
|
||||
|
||||
let connect_url = json
|
||||
.get("connect")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| "Browserless response missing 'connect' URL".to_string())?;
|
||||
|
||||
let stop_url = json
|
||||
.get("stop")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| "Browserless response missing 'stop' URL".to_string())?;
|
||||
|
||||
Ok((
|
||||
connect_url,
|
||||
Some(ProviderSession {
|
||||
provider: "browserless".to_string(),
|
||||
// Store the stop URL as the session_id for cleanup
|
||||
session_id: stop_url,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn connect_browser_use() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("BROWSER_USE_API_KEY")
|
||||
.map_err(|_| "BROWSER_USE_API_KEY environment variable is not set")?;
|
||||
|
||||
let ws_url = format!("wss://connect.browser-use.com?apiKey={}", api_key);
|
||||
|
||||
Ok((ws_url, None))
|
||||
}
|
||||
|
||||
async fn connect_kernel() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let api_key = env::var("KERNEL_API_KEY").ok();
|
||||
let endpoint =
|
||||
env::var("KERNEL_ENDPOINT").unwrap_or_else(|_| "https://api.onkernel.com".to_string());
|
||||
|
||||
let url = format!("{}/browsers", endpoint.trim_end_matches('/'));
|
||||
|
||||
let headless = env::var("KERNEL_HEADLESS")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(true);
|
||||
let stealth = env::var("KERNEL_STEALTH")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
let timeout_seconds = env::var("KERNEL_TIMEOUT_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(300);
|
||||
|
||||
let mut body = json!({
|
||||
"headless": headless,
|
||||
"stealth": stealth,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
});
|
||||
|
||||
if let Ok(profile) = env::var("KERNEL_PROFILE_NAME") {
|
||||
if !profile.is_empty() {
|
||||
body.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("profile".to_string(), json!(profile));
|
||||
}
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = client.post(&url).header("Content-Type", "application/json");
|
||||
if let Some(ref key) = api_key {
|
||||
request = request.header("Authorization", format!("Bearer {}", key));
|
||||
}
|
||||
let response = request
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Kernel request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let resp_body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read Kernel response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"Kernel API error ({}): {}",
|
||||
status.as_u16(),
|
||||
resp_body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&resp_body).map_err(|e| format!("Invalid Kernel response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("session_id")
|
||||
.or_else(|| json.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let ws_url = json
|
||||
.get("cdp_ws_url")
|
||||
.or_else(|| json.get("connectUrl"))
|
||||
.or_else(|| json.get("connect_url"))
|
||||
.or_else(|| json.get("cdpUrl"))
|
||||
.or_else(|| json.get("cdp_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| {
|
||||
"Kernel response missing cdp_ws_url, connectUrl, connect_url, cdpUrl, or cdp_url"
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
Some(ProviderSession {
|
||||
provider: "kernel".to_string(),
|
||||
session_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AgentCore Provider (AWS Bedrock AgentCore Browser)
|
||||
// ============================================================================
|
||||
|
||||
mod agentcore {
|
||||
use super::*;
|
||||
|
||||
/// AgentCore-specific session info for Live View URL
|
||||
pub struct AgentCoreSessionInfo {
|
||||
pub session_id: String,
|
||||
pub browser_identifier: String,
|
||||
pub region: String,
|
||||
pub live_view_url: String,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static AGENTCORE_INFO: std::cell::RefCell<Option<AgentCoreSessionInfo>> = const { std::cell::RefCell::new(None) };
|
||||
static AGENTCORE_WS_HEADERS: std::cell::RefCell<Option<Vec<(String, String)>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub fn set_agentcore_info(info: AgentCoreSessionInfo) {
|
||||
AGENTCORE_INFO.with(|cell| *cell.borrow_mut() = Some(info));
|
||||
}
|
||||
|
||||
pub fn get_agentcore_info() -> Option<AgentCoreSessionInfo> {
|
||||
AGENTCORE_INFO.with(|cell| {
|
||||
cell.borrow().as_ref().map(|i| AgentCoreSessionInfo {
|
||||
session_id: i.session_id.clone(),
|
||||
browser_identifier: i.browser_identifier.clone(),
|
||||
region: i.region.clone(),
|
||||
live_view_url: i.live_view_url.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_agentcore_ws_headers(headers: Vec<(String, String)>) {
|
||||
AGENTCORE_WS_HEADERS.with(|cell| *cell.borrow_mut() = Some(headers));
|
||||
}
|
||||
|
||||
pub fn take_agentcore_ws_headers() -> Option<Vec<(String, String)>> {
|
||||
AGENTCORE_WS_HEADERS.with(|cell| cell.borrow_mut().take())
|
||||
}
|
||||
|
||||
pub async fn connect() -> Result<(String, Option<ProviderSession>), String> {
|
||||
let region = env::var("AGENTCORE_REGION")
|
||||
.or_else(|_| env::var("AWS_REGION"))
|
||||
.or_else(|_| env::var("AWS_DEFAULT_REGION"))
|
||||
.unwrap_or_else(|_| "us-east-1".to_string());
|
||||
let browser_id =
|
||||
env::var("AGENTCORE_BROWSER_ID").unwrap_or_else(|_| "aws.browser.v1".to_string());
|
||||
let timeout_secs: u64 = env::var("AGENTCORE_SESSION_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3600);
|
||||
|
||||
let host = format!("bedrock-agentcore.{}.amazonaws.com", region);
|
||||
let path = format!(
|
||||
"/browsers/{}/sessions/start",
|
||||
urlencoding::encode(&browser_id)
|
||||
);
|
||||
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 mut body_json = json!({
|
||||
"name": session_name,
|
||||
"sessionTimeoutSeconds": timeout_secs
|
||||
});
|
||||
if let Ok(profile_id) = env::var("AGENTCORE_PROFILE_ID") {
|
||||
if !profile_id.is_empty() {
|
||||
body_json.as_object_mut().unwrap().insert(
|
||||
"profileConfiguration".to_string(),
|
||||
json!({ "profileIdentifier": profile_id }),
|
||||
);
|
||||
}
|
||||
}
|
||||
let body = serde_json::to_string(&body_json)
|
||||
.map_err(|e| format!("Failed to serialize request body: {}", e))?;
|
||||
|
||||
let signed_headers = sign_request("PUT", &url, ®ion, Some(&body)).await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut req = client.put(&url).body(body.clone());
|
||||
for (key, value) in &signed_headers {
|
||||
req = req.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
let response = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("AgentCore request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let resp_body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read AgentCore response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"AgentCore API error ({}): {}",
|
||||
status.as_u16(),
|
||||
resp_body
|
||||
));
|
||||
}
|
||||
|
||||
let json: Value = serde_json::from_str(&resp_body)
|
||||
.map_err(|e| format!("Invalid AgentCore response: {}", e))?;
|
||||
|
||||
let session_id = json
|
||||
.get("sessionId")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "AgentCore response missing sessionId".to_string())?
|
||||
.to_string();
|
||||
|
||||
let browser_identifier = json
|
||||
.get("browserIdentifier")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&browser_id)
|
||||
.to_string();
|
||||
|
||||
let live_view_url = format!(
|
||||
"https://{}.console.aws.amazon.com/bedrock-agentcore/browser/{}/session/{}#",
|
||||
region, browser_identifier, session_id
|
||||
);
|
||||
|
||||
set_agentcore_info(AgentCoreSessionInfo {
|
||||
session_id: session_id.clone(),
|
||||
browser_identifier: browser_identifier.clone(),
|
||||
region: region.clone(),
|
||||
live_view_url: live_view_url.clone(),
|
||||
});
|
||||
|
||||
eprintln!("Session: {}", session_id);
|
||||
eprintln!("Live View: {}", live_view_url);
|
||||
|
||||
let ws_path = format!(
|
||||
"/browser-streams/{}/sessions/{}/automation",
|
||||
browser_identifier, session_id
|
||||
);
|
||||
let ws_url = format!("wss://{}{}", host, ws_path);
|
||||
|
||||
let ws_headers = sign_request(
|
||||
"GET",
|
||||
&format!("https://{}{}", host, ws_path),
|
||||
®ion,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
set_agentcore_ws_headers(ws_headers);
|
||||
|
||||
Ok((
|
||||
ws_url,
|
||||
Some(ProviderSession {
|
||||
provider: "agentcore".to_string(),
|
||||
session_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Get AWS credentials from environment variables or AWS CLI
|
||||
fn get_aws_credentials() -> Result<(String, String, Option<String>), String> {
|
||||
// First try environment variables
|
||||
if let (Ok(access_key), Ok(secret_key)) = (
|
||||
env::var("AWS_ACCESS_KEY_ID"),
|
||||
env::var("AWS_SECRET_ACCESS_KEY"),
|
||||
) {
|
||||
return Ok((access_key, secret_key, env::var("AWS_SESSION_TOKEN").ok()));
|
||||
}
|
||||
|
||||
// Fall back to AWS CLI
|
||||
let mut cmd = std::process::Command::new("aws");
|
||||
cmd.args(["configure", "export-credentials", "--format", "env"]);
|
||||
|
||||
// Honor AWS_PROFILE
|
||||
if let Ok(profile) = env::var("AWS_PROFILE") {
|
||||
cmd.args(["--profile", &profile]);
|
||||
}
|
||||
|
||||
let output = cmd.output()
|
||||
.map_err(|e| format!("Failed to run aws CLI: {}. Install AWS CLI or set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"AWS CLI failed: {}. Run 'aws sso login' or set credentials",
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut access_key = None;
|
||||
let mut secret_key = None;
|
||||
let mut session_token = None;
|
||||
|
||||
for line in stdout.lines() {
|
||||
if let Some(val) = line.strip_prefix("export AWS_ACCESS_KEY_ID=") {
|
||||
access_key = Some(val.to_string());
|
||||
} else if let Some(val) = line.strip_prefix("export AWS_SECRET_ACCESS_KEY=") {
|
||||
secret_key = Some(val.to_string());
|
||||
} else if let Some(val) = line.strip_prefix("export AWS_SESSION_TOKEN=") {
|
||||
session_token = Some(val.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
match (access_key, secret_key) {
|
||||
(Some(ak), Some(sk)) => Ok((ak, sk, session_token)),
|
||||
_ => Err("Failed to parse credentials from AWS CLI output".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn sign_request(
|
||||
method: &str,
|
||||
url: &str,
|
||||
region: &str,
|
||||
body: Option<&str>,
|
||||
) -> Result<Vec<(String, String)>, String> {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// Get credentials from environment or AWS CLI
|
||||
let (access_key, secret_key, session_token) = get_aws_credentials()?;
|
||||
|
||||
let parsed_url = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
let host = parsed_url.host_str().unwrap_or("");
|
||||
|
||||
// Get current time
|
||||
let now = chrono::Utc::now();
|
||||
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date_stamp = now.format("%Y%m%d").to_string();
|
||||
|
||||
// Create canonical request
|
||||
let payload_hash = if let Some(b) = body {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
} else {
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string()
|
||||
// empty string hash
|
||||
};
|
||||
|
||||
let canonical_uri = parsed_url.path();
|
||||
let canonical_querystring = parsed_url.query().unwrap_or("");
|
||||
|
||||
let mut signed_headers = "content-type;host;x-amz-date".to_string();
|
||||
let mut canonical_headers = format!(
|
||||
"content-type:application/json\nhost:{}\nx-amz-date:{}\n",
|
||||
host, amz_date
|
||||
);
|
||||
|
||||
if let Some(ref token) = session_token {
|
||||
signed_headers = "content-type;host;x-amz-date;x-amz-security-token".to_string();
|
||||
canonical_headers = format!(
|
||||
"content-type:application/json\nhost:{}\nx-amz-date:{}\nx-amz-security-token:{}\n",
|
||||
host, amz_date, token
|
||||
);
|
||||
}
|
||||
|
||||
let canonical_request = format!(
|
||||
"{}\n{}\n{}\n{}\n{}\n{}",
|
||||
method,
|
||||
canonical_uri,
|
||||
canonical_querystring,
|
||||
canonical_headers,
|
||||
signed_headers,
|
||||
payload_hash
|
||||
);
|
||||
|
||||
// Create string to sign
|
||||
let algorithm = "AWS4-HMAC-SHA256";
|
||||
let credential_scope = format!("{}/{}/bedrock-agentcore/aws4_request", date_stamp, region);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(canonical_request.as_bytes());
|
||||
let canonical_request_hash = hex::encode(hasher.finalize());
|
||||
|
||||
let string_to_sign = format!(
|
||||
"{}\n{}\n{}\n{}",
|
||||
algorithm, amz_date, credential_scope, canonical_request_hash
|
||||
);
|
||||
|
||||
// Calculate signature
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let k_date = HmacSha256::new_from_slice(format!("AWS4{}", secret_key).as_bytes())
|
||||
.unwrap()
|
||||
.chain_update(date_stamp.as_bytes())
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let k_region = HmacSha256::new_from_slice(&k_date)
|
||||
.unwrap()
|
||||
.chain_update(region.as_bytes())
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let k_service = HmacSha256::new_from_slice(&k_region)
|
||||
.unwrap()
|
||||
.chain_update(b"bedrock-agentcore")
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let k_signing = HmacSha256::new_from_slice(&k_service)
|
||||
.unwrap()
|
||||
.chain_update(b"aws4_request")
|
||||
.finalize()
|
||||
.into_bytes();
|
||||
|
||||
let signature = hex::encode(
|
||||
HmacSha256::new_from_slice(&k_signing)
|
||||
.unwrap()
|
||||
.chain_update(string_to_sign.as_bytes())
|
||||
.finalize()
|
||||
.into_bytes(),
|
||||
);
|
||||
|
||||
// Build authorization header
|
||||
let authorization = format!(
|
||||
"{} Credential={}/{}, SignedHeaders={}, Signature={}",
|
||||
algorithm, access_key, credential_scope, signed_headers, signature
|
||||
);
|
||||
|
||||
let mut headers = vec![
|
||||
("host".to_string(), host.to_string()),
|
||||
("content-type".to_string(), "application/json".to_string()),
|
||||
("x-amz-date".to_string(), amz_date),
|
||||
("authorization".to_string(), authorization),
|
||||
];
|
||||
|
||||
if let Some(token) = session_token {
|
||||
headers.push(("x-amz-security-token".to_string(), token));
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
pub async fn close_session(session_id: &str) -> Result<(), String> {
|
||||
let info = get_agentcore_info();
|
||||
let (region, browser_id) = match &info {
|
||||
Some(i) => (i.region.clone(), i.browser_identifier.clone()),
|
||||
None => {
|
||||
let region = env::var("AGENTCORE_REGION")
|
||||
.or_else(|_| env::var("AWS_REGION"))
|
||||
.or_else(|_| env::var("AWS_DEFAULT_REGION"))
|
||||
.unwrap_or_else(|_| "us-east-1".to_string());
|
||||
let browser_id = env::var("AGENTCORE_BROWSER_ID")
|
||||
.unwrap_or_else(|_| "aws.browser.v1".to_string());
|
||||
(region, browser_id)
|
||||
}
|
||||
};
|
||||
|
||||
let host = format!("bedrock-agentcore.{}.amazonaws.com", region);
|
||||
let path = format!(
|
||||
"/browsers/{}/sessions/stop",
|
||||
urlencoding::encode(&browser_id)
|
||||
);
|
||||
let url = format!("https://{}{}", host, path);
|
||||
|
||||
let body = serde_json::to_string(&json!({ "sessionId": session_id }))
|
||||
.map_err(|e| format!("Failed to serialize close request: {}", e))?;
|
||||
|
||||
let signed_headers = sign_request("PUT", &url, ®ion, Some(&body)).await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut req = client.put(&url).body(body);
|
||||
for (key, value) in &signed_headers {
|
||||
req = req.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
let _ = req.send().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub use agentcore::{get_agentcore_info, take_agentcore_ws_headers};
|
||||
|
||||
async fn connect_agentcore() -> Result<(String, Option<ProviderSession>), String> {
|
||||
agentcore::connect().await
|
||||
}
|
||||
|
||||
async fn close_agentcore_session(session_id: &str) -> Result<(), String> {
|
||||
agentcore::close_session(session_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connect_provider_unknown() {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let result = rt.block_on(connect_provider("unknown-provider"));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unknown provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agentcore_env_defaults() {
|
||||
// Test that default values are used when env vars not set
|
||||
std::env::remove_var("AGENTCORE_REGION");
|
||||
std::env::remove_var("AGENTCORE_BROWSER_ID");
|
||||
std::env::remove_var("AGENTCORE_SESSION_TIMEOUT");
|
||||
|
||||
// These would be used in connect() - just verify they don't panic
|
||||
let region = std::env::var("AGENTCORE_REGION")
|
||||
.or_else(|_| std::env::var("AWS_REGION"))
|
||||
.unwrap_or_else(|_| "us-east-1".to_string());
|
||||
assert_eq!(region, "us-east-1");
|
||||
|
||||
let browser_id =
|
||||
std::env::var("AGENTCORE_BROWSER_ID").unwrap_or_else(|_| "aws.browser.v1".to_string());
|
||||
assert_eq!(browser_id, "aws.browser.v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agentcore_session_info_storage() {
|
||||
let info = agentcore::AgentCoreSessionInfo {
|
||||
session_id: "test-session".to_string(),
|
||||
browser_identifier: "aws.browser.v1".to_string(),
|
||||
region: "us-east-1".to_string(),
|
||||
live_view_url: "https://example.com".to_string(),
|
||||
};
|
||||
|
||||
agentcore::set_agentcore_info(info);
|
||||
let retrieved = get_agentcore_info();
|
||||
assert!(retrieved.is_some());
|
||||
let retrieved = retrieved.unwrap();
|
||||
assert_eq!(retrieved.session_id, "test-session");
|
||||
assert_eq!(retrieved.region, "us-east-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agentcore_ws_headers_storage() {
|
||||
let headers = vec![
|
||||
(
|
||||
"Authorization".to_string(),
|
||||
"AWS4-HMAC-SHA256...".to_string(),
|
||||
),
|
||||
("X-Amz-Date".to_string(), "20260304T180000Z".to_string()),
|
||||
];
|
||||
|
||||
agentcore::set_agentcore_ws_headers(headers);
|
||||
let taken = take_agentcore_ws_headers();
|
||||
assert!(taken.is_some());
|
||||
assert_eq!(taken.unwrap().len(), 2);
|
||||
|
||||
// Should be None after take
|
||||
let taken_again = take_agentcore_ws_headers();
|
||||
assert!(taken_again.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::{CaptureScreenshotParams, CaptureScreenshotResult};
|
||||
|
||||
const CAPTURE_INTERVAL_MS: u64 = 100;
|
||||
const CAPTURE_FPS: u32 = 10;
|
||||
|
||||
pub struct RecordingState {
|
||||
pub active: bool,
|
||||
pub output_path: String,
|
||||
pub frame_count: u64,
|
||||
pub capture_task: Option<tokio::task::JoinHandle<Result<(), String>>>,
|
||||
pub shared_frame_count: Option<Arc<AtomicU64>>,
|
||||
pub cancel_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl RecordingState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
output_path: String::new(),
|
||||
frame_count: 0,
|
||||
capture_task: None,
|
||||
shared_frame_count: None,
|
||||
cancel_tx: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recording_start(state: &mut RecordingState, path: &str) -> Result<Value, String> {
|
||||
if state.active {
|
||||
return Err("Recording already active".to_string());
|
||||
}
|
||||
|
||||
state.active = true;
|
||||
state.output_path = path.to_string();
|
||||
state.frame_count = 0;
|
||||
|
||||
Ok(json!({ "started": true, "path": path }))
|
||||
}
|
||||
|
||||
pub fn recording_stop(state: &mut RecordingState) -> Result<Value, String> {
|
||||
if !state.active {
|
||||
return Err("No recording in progress".to_string());
|
||||
}
|
||||
|
||||
state.active = false;
|
||||
|
||||
if state.frame_count == 0 {
|
||||
return Err("No frames captured".to_string());
|
||||
}
|
||||
|
||||
Ok(json!({ "path": &state.output_path, "frames": state.frame_count }))
|
||||
}
|
||||
|
||||
pub fn recording_restart(state: &mut RecordingState, path: &str) -> Result<Value, String> {
|
||||
let previous = if state.active {
|
||||
let stop_result = recording_stop(state);
|
||||
stop_result
|
||||
.ok()
|
||||
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
recording_start(state, path)?;
|
||||
|
||||
Ok(json!({
|
||||
"restarted": true,
|
||||
"previousPath": previous,
|
||||
"path": path,
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_ffmpeg_command(output_path: &str) -> tokio::process::Command {
|
||||
let mut cmd = tokio::process::Command::new("ffmpeg");
|
||||
|
||||
cmd.args(["-y"])
|
||||
.args(["-avioflags", "direct"])
|
||||
.args([
|
||||
"-fpsprobesize",
|
||||
"0",
|
||||
"-probesize",
|
||||
"32",
|
||||
"-analyzeduration",
|
||||
"0",
|
||||
])
|
||||
.args([
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-c:v",
|
||||
"mjpeg",
|
||||
"-framerate",
|
||||
&CAPTURE_FPS.to_string(),
|
||||
"-i",
|
||||
"pipe:0",
|
||||
])
|
||||
.args(["-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2"]);
|
||||
|
||||
if output_path.ends_with(".webm") {
|
||||
cmd.args(["-c:v", "libvpx", "-crf", "30", "-b:v", "1M"]);
|
||||
} else {
|
||||
cmd.args(["-c:v", "libx264", "-preset", "ultrafast"]);
|
||||
}
|
||||
|
||||
cmd.args(["-pix_fmt", "yuv420p", "-threads", "1"])
|
||||
.arg(output_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Spawn a background task that captures screenshots at a fixed interval
|
||||
/// and pipes them to ffmpeg in real-time.
|
||||
pub fn spawn_recording_task(
|
||||
client: Arc<CdpClient>,
|
||||
session_id: String,
|
||||
output_path: String,
|
||||
shared_count: Arc<AtomicU64>,
|
||||
cancel_rx: oneshot::Receiver<()>,
|
||||
) -> tokio::task::JoinHandle<Result<(), String>> {
|
||||
tokio::spawn(async move {
|
||||
let mut cancel_rx = std::pin::pin!(cancel_rx);
|
||||
|
||||
let mut ffmpeg = build_ffmpeg_command(&output_path).spawn().map_err(|e| {
|
||||
format!(
|
||||
"ffmpeg not found or failed to execute: {}. Install ffmpeg to enable recording.",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut stdin = ffmpeg
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Failed to open ffmpeg stdin".to_string())?;
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(CAPTURE_INTERVAL_MS));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
let params = CaptureScreenshotParams {
|
||||
format: Some("jpeg".to_string()),
|
||||
quality: Some(80),
|
||||
clip: None,
|
||||
from_surface: Some(true),
|
||||
capture_beyond_viewport: None,
|
||||
};
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut cancel_rx => break,
|
||||
_ = interval.tick() => {}
|
||||
}
|
||||
|
||||
let result: Result<CaptureScreenshotResult, _> = client
|
||||
.send_command_typed("Page.captureScreenshot", ¶ms, Some(&session_id))
|
||||
.await;
|
||||
|
||||
let screenshot = match result {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if e.contains("Target closed") || e.contains("not found") {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let bytes = match base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&screenshot.data,
|
||||
) {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if stdin.write_all(&bytes).await.is_err() {
|
||||
break;
|
||||
}
|
||||
shared_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
drop(stdin);
|
||||
|
||||
let output = ffmpeg
|
||||
.wait_with_output()
|
||||
.await
|
||||
.map_err(|e| format!("ffmpeg wait failed: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"ffmpeg failed: {}",
|
||||
stderr.chars().take(300).collect::<String>()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop_recording_task(state: &mut RecordingState) -> Result<(), String> {
|
||||
if let Some(tx) = state.cancel_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
|
||||
let counter = state.shared_frame_count.take();
|
||||
let handle = state.capture_task.take();
|
||||
|
||||
let result = if let Some(h) = handle {
|
||||
match h.await {
|
||||
Ok(Ok(())) => Ok(()),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(e) => Err(format!("Recording task panicked: {}", e)),
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if let Some(c) = counter {
|
||||
state.frame_count = c.load(Ordering::Relaxed);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_recording_state_new() {
|
||||
let state = RecordingState::new();
|
||||
assert!(!state.active);
|
||||
assert!(state.output_path.is_empty());
|
||||
assert_eq!(state.frame_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_start_sets_active() {
|
||||
let mut state = RecordingState::new();
|
||||
let result = recording_start(&mut state, "/tmp/test.mp4");
|
||||
assert!(result.is_ok());
|
||||
assert!(state.active);
|
||||
assert_eq!(state.output_path, "/tmp/test.mp4");
|
||||
assert_eq!(state.frame_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_start_while_active() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/test1.mp4").unwrap();
|
||||
let result = recording_start(&mut state, "/tmp/test2.mp4");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("already active"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_stop_not_active() {
|
||||
let mut state = RecordingState::new();
|
||||
let result = recording_stop(&mut state);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No recording"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_stop_no_frames() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/test.mp4").unwrap();
|
||||
let result = recording_stop(&mut state);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No frames"));
|
||||
assert!(!state.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_restart_while_inactive() {
|
||||
let mut state = RecordingState::new();
|
||||
let result = recording_restart(&mut state, "/tmp/new.webm");
|
||||
assert!(result.is_ok());
|
||||
assert!(state.active);
|
||||
assert_eq!(state.output_path, "/tmp/new.webm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recording_restart_while_active() {
|
||||
let mut state = RecordingState::new();
|
||||
recording_start(&mut state, "/tmp/old.webm").unwrap();
|
||||
state.frame_count = 10;
|
||||
let result = recording_restart(&mut state, "/tmp/new.webm").unwrap();
|
||||
assert!(state.active);
|
||||
assert_eq!(state.output_path, "/tmp/new.webm");
|
||||
assert_eq!(state.frame_count, 0);
|
||||
assert_eq!(result["previousPath"], "/tmp/old.webm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_ffmpeg_command_webm() {
|
||||
let cmd = build_ffmpeg_command("/tmp/out.webm");
|
||||
let args: Vec<&std::ffi::OsStr> = cmd.as_std().get_args().collect();
|
||||
let args_str: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
|
||||
assert!(args_str.contains(&"libvpx"));
|
||||
assert!(args_str.contains(&"/tmp/out.webm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_ffmpeg_command_mp4() {
|
||||
let cmd = build_ffmpeg_command("/tmp/out.mp4");
|
||||
let args: Vec<&std::ffi::OsStr> = cmd.as_std().get_args().collect();
|
||||
let args_str: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
|
||||
assert!(args_str.contains(&"libx264"));
|
||||
assert!(args_str.contains(&"/tmp/out.mp4"));
|
||||
}
|
||||
}
|
||||
@@ -1,691 +0,0 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::*;
|
||||
use super::element::RefMap;
|
||||
|
||||
const ANNOTATION_OVERLAY_ID: &str = "__agent_browser_annotations__";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Rect {
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RawAnnotation {
|
||||
ref_id: String,
|
||||
number: u64,
|
||||
role: String,
|
||||
name: Option<String>,
|
||||
rect: Rect,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AnnotationBox {
|
||||
pub x: i64,
|
||||
pub y: i64,
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScreenshotAnnotation {
|
||||
pub ref_id: String,
|
||||
pub number: u64,
|
||||
pub role: String,
|
||||
pub name: Option<String>,
|
||||
pub box_: AnnotationBox,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScreenshotResult {
|
||||
pub path: String,
|
||||
pub base64: String,
|
||||
pub annotations: Vec<ScreenshotAnnotation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScreenshotOptions {
|
||||
pub selector: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub full_page: bool,
|
||||
pub format: String,
|
||||
pub quality: Option<i32>,
|
||||
pub annotate: bool,
|
||||
pub output_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ScreenshotOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selector: None,
|
||||
path: None,
|
||||
full_page: false,
|
||||
format: "png".to_string(),
|
||||
quality: None,
|
||||
annotate: false,
|
||||
output_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ScreenshotAnnotation {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
use serde::ser::SerializeStruct;
|
||||
|
||||
let mut state = serializer.serialize_struct("ScreenshotAnnotation", 5)?;
|
||||
state.serialize_field("ref", &self.ref_id)?;
|
||||
state.serialize_field("number", &self.number)?;
|
||||
state.serialize_field("role", &self.role)?;
|
||||
if let Some(name) = &self.name {
|
||||
state.serialize_field("name", name)?;
|
||||
}
|
||||
state.serialize_field("box", &self.box_)?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures a screenshot via CDP and optionally overlays numbered annotations
|
||||
/// that mirror the Node.js screenshot `annotate` mode.
|
||||
pub async fn take_screenshot(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
options: &ScreenshotOptions,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<ScreenshotResult, String> {
|
||||
let target_rect = if options.annotate {
|
||||
match options.selector.as_deref() {
|
||||
Some(selector) => {
|
||||
get_rect_for_selector(client, session_id, ref_map, selector, iframe_sessions)
|
||||
.await?
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let raw_annotations = if options.annotate {
|
||||
collect_annotations(client, session_id, ref_map).await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let overlay_items = filter_annotations(raw_annotations, target_rect.as_ref());
|
||||
let overlay_injected = if options.annotate && !overlay_items.is_empty() {
|
||||
inject_annotation_overlay(client, session_id, &overlay_items).await?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let base64 =
|
||||
capture_screenshot_base64(client, session_id, ref_map, options, iframe_sessions).await;
|
||||
|
||||
if overlay_injected {
|
||||
let _ = remove_annotation_overlay(client, session_id).await;
|
||||
}
|
||||
|
||||
let base64 = base64?;
|
||||
let annotations = if options.annotate {
|
||||
let scroll = if options.full_page {
|
||||
Some(get_scroll_offsets(client, session_id).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
project_annotations(&overlay_items, target_rect.as_ref(), scroll)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let ext = if options.format == "jpeg" {
|
||||
"jpg"
|
||||
} else {
|
||||
"png"
|
||||
};
|
||||
let path = save_screenshot(
|
||||
&base64,
|
||||
options.path.as_deref(),
|
||||
ext,
|
||||
options.output_dir.as_deref(),
|
||||
)?;
|
||||
|
||||
Ok(ScreenshotResult {
|
||||
path,
|
||||
base64,
|
||||
annotations,
|
||||
})
|
||||
}
|
||||
|
||||
async fn capture_screenshot_base64(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
options: &ScreenshotOptions,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<String, String> {
|
||||
let mut params = CaptureScreenshotParams {
|
||||
format: Some(options.format.clone()),
|
||||
quality: if options.format == "jpeg" {
|
||||
options.quality.or(Some(80))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
clip: None,
|
||||
from_surface: Some(true),
|
||||
capture_beyond_viewport: if options.full_page { Some(true) } else { None },
|
||||
};
|
||||
|
||||
if options.full_page {
|
||||
let metrics: Value = client
|
||||
.send_command_no_params("Page.getLayoutMetrics", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let content_size = metrics
|
||||
.get("contentSize")
|
||||
.or_else(|| metrics.get("cssContentSize"));
|
||||
if let Some(size) = content_size {
|
||||
let width = size.get("width").and_then(|v| v.as_f64()).unwrap_or(1280.0);
|
||||
let height = size.get("height").and_then(|v| v.as_f64()).unwrap_or(720.0);
|
||||
|
||||
params.clip = Some(Viewport {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width,
|
||||
height,
|
||||
scale: 1.0,
|
||||
});
|
||||
}
|
||||
} else if let Some(ref selector) = options.selector {
|
||||
if let Some(rect) =
|
||||
get_rect_for_selector(client, session_id, ref_map, selector, iframe_sessions).await?
|
||||
{
|
||||
params.clip = Some(Viewport {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
scale: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let result: CaptureScreenshotResult = client
|
||||
.send_command_typed("Page.captureScreenshot", ¶ms, Some(session_id))
|
||||
.await?;
|
||||
|
||||
Ok(result.data)
|
||||
}
|
||||
|
||||
async fn collect_annotations(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
) -> Result<Vec<RawAnnotation>, String> {
|
||||
let entries = ref_map.entries_sorted();
|
||||
if entries.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Collect entries that have backend_node_ids for batch resolution.
|
||||
let with_backend_ids: Vec<(String, super::element::RefEntry, i64)> = entries
|
||||
.iter()
|
||||
.filter_map(|(ref_id, entry)| {
|
||||
entry
|
||||
.backend_node_id
|
||||
.map(|bid| (ref_id.clone(), entry.clone(), bid))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if with_backend_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Batch-resolve all backend_node_ids to object IDs using concurrent CDP calls.
|
||||
let resolve_futures: Vec<_> = with_backend_ids
|
||||
.iter()
|
||||
.map(|(_, _, backend_node_id)| {
|
||||
client.send_command(
|
||||
"DOM.resolveNode",
|
||||
Some(serde_json::json!({
|
||||
"backendNodeId": backend_node_id,
|
||||
"objectGroup": "agent-browser-annotate"
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let resolve_results = futures_util::future::join_all(resolve_futures).await;
|
||||
|
||||
// Collect resolved object IDs paired with their ref info.
|
||||
let mut resolved: Vec<(String, super::element::RefEntry, String)> = Vec::new();
|
||||
for (i, result) in resolve_results.into_iter().enumerate() {
|
||||
if let Ok(val) = result {
|
||||
if let Some(oid) = val
|
||||
.get("object")
|
||||
.and_then(|o| o.get("objectId"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let (ref_id, entry, _) = &with_backend_ids[i];
|
||||
resolved.push((ref_id.clone(), entry.clone(), oid.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resolved.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Batch-get bounding rects for all resolved elements using concurrent CDP calls.
|
||||
let rect_futures: Vec<_> = resolved
|
||||
.iter()
|
||||
.map(|(_, _, object_id)| get_rect_for_object(client, session_id, object_id))
|
||||
.collect();
|
||||
|
||||
let rect_results = futures_util::future::join_all(rect_futures).await;
|
||||
|
||||
let mut annotations = Vec::new();
|
||||
for (i, rect_result) in rect_results.into_iter().enumerate() {
|
||||
let rect = match rect_result {
|
||||
Ok(Some(r)) if r.width > 0.0 && r.height > 0.0 => r,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let (ref_id, entry, _) = &resolved[i];
|
||||
let number = ref_id
|
||||
.strip_prefix('e')
|
||||
.and_then(|n| n.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
annotations.push(RawAnnotation {
|
||||
ref_id: ref_id.clone(),
|
||||
number,
|
||||
role: entry.role.clone(),
|
||||
name: (!entry.name.is_empty()).then_some(entry.name.clone()),
|
||||
rect,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(annotations)
|
||||
}
|
||||
|
||||
async fn get_rect_for_selector(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
ref_map: &RefMap,
|
||||
selector: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<Option<Rect>, String> {
|
||||
let (object_id, effective_session_id) = super::element::resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
ref_map,
|
||||
selector,
|
||||
iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
get_rect_for_object(client, &effective_session_id, &object_id).await
|
||||
}
|
||||
|
||||
async fn get_rect_for_object(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
object_id: &str,
|
||||
) -> Result<Option<Rect>, String> {
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const rect = this.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id.to_string()),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result.result.value.as_ref().and_then(parse_rect))
|
||||
}
|
||||
|
||||
fn parse_rect(value: &Value) -> Option<Rect> {
|
||||
Some(Rect {
|
||||
x: value.get("x")?.as_f64()?,
|
||||
y: value.get("y")?.as_f64()?,
|
||||
width: value.get("width")?.as_f64()?,
|
||||
height: value.get("height")?.as_f64()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn filter_annotations(
|
||||
annotations: Vec<RawAnnotation>,
|
||||
target_rect: Option<&Rect>,
|
||||
) -> Vec<RawAnnotation> {
|
||||
let mut items = annotations
|
||||
.into_iter()
|
||||
.filter(|annotation| match target_rect {
|
||||
Some(target) => overlaps(&annotation.rect, target),
|
||||
None => true,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
items.sort_by_key(|annotation| annotation.number);
|
||||
items
|
||||
}
|
||||
|
||||
fn overlaps(left: &Rect, right: &Rect) -> bool {
|
||||
let left_x2 = left.x + left.width;
|
||||
let left_y2 = left.y + left.height;
|
||||
let right_x2 = right.x + right.width;
|
||||
let right_y2 = right.y + right.height;
|
||||
|
||||
left.x < right_x2 && left_x2 > right.x && left.y < right_y2 && left_y2 > right.y
|
||||
}
|
||||
|
||||
async fn inject_annotation_overlay(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
annotations: &[RawAnnotation],
|
||||
) -> Result<(), String> {
|
||||
let overlay_data = annotations
|
||||
.iter()
|
||||
.map(|annotation| {
|
||||
serde_json::json!({
|
||||
"number": annotation.number,
|
||||
"x": round(annotation.rect.x),
|
||||
"y": round(annotation.rect.y),
|
||||
"width": round(annotation.rect.width),
|
||||
"height": round(annotation.rect.height),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let expression = format!(
|
||||
r#"(() => {{
|
||||
var items = {items};
|
||||
var id = {overlay_id};
|
||||
var existing = document.getElementById(id);
|
||||
if (existing) existing.remove();
|
||||
var sx = window.scrollX || 0;
|
||||
var sy = window.scrollY || 0;
|
||||
var c = document.createElement('div');
|
||||
c.id = id;
|
||||
c.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;pointer-events:none;z-index:2147483647;';
|
||||
for (var i = 0; i < items.length; i++) {{
|
||||
var it = items[i];
|
||||
var dx = it.x + sx;
|
||||
var dy = it.y + sy;
|
||||
var b = document.createElement('div');
|
||||
b.style.cssText = 'position:absolute;left:' + dx + 'px;top:' + dy + 'px;width:' + it.width + 'px;height:' + it.height + 'px;border:2px solid rgba(255,0,0,0.8);box-sizing:border-box;pointer-events:none;';
|
||||
var l = document.createElement('div');
|
||||
l.textContent = String(it.number);
|
||||
var labelTop = dy < 14 ? '2px' : '-14px';
|
||||
l.style.cssText = 'position:absolute;top:' + labelTop + ';left:-2px;background:rgba(255,0,0,0.9);color:#fff;font:bold 11px/14px monospace;padding:0 4px;border-radius:2px;white-space:nowrap;';
|
||||
b.appendChild(l);
|
||||
c.appendChild(b);
|
||||
}}
|
||||
document.documentElement.appendChild(c);
|
||||
return true;
|
||||
}})()"#,
|
||||
items = serde_json::to_string(&overlay_data).unwrap_or_else(|_| "[]".to_string()),
|
||||
overlay_id =
|
||||
serde_json::to_string(ANNOTATION_OVERLAY_ID).unwrap_or_else(|_| "\"\"".to_string()),
|
||||
);
|
||||
|
||||
let _: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_annotation_overlay(client: &CdpClient, session_id: &str) -> Result<(), String> {
|
||||
let expression = format!(
|
||||
r#"(() => {{
|
||||
var el = document.getElementById({overlay_id});
|
||||
if (el) el.remove();
|
||||
return true;
|
||||
}})()"#,
|
||||
overlay_id =
|
||||
serde_json::to_string(ANNOTATION_OVERLAY_ID).unwrap_or_else(|_| "\"\"".to_string()),
|
||||
);
|
||||
|
||||
let _: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_scroll_offsets(client: &CdpClient, session_id: &str) -> Result<(f64, f64), String> {
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: "({x: window.scrollX || 0, y: window.scrollY || 0})".to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let value = result.result.value.unwrap_or(Value::Null);
|
||||
let x = value.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let y = value.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
Ok((x, y))
|
||||
}
|
||||
|
||||
fn project_annotations(
|
||||
annotations: &[RawAnnotation],
|
||||
target_rect: Option<&Rect>,
|
||||
scroll: Option<(f64, f64)>,
|
||||
) -> Vec<ScreenshotAnnotation> {
|
||||
annotations
|
||||
.iter()
|
||||
.map(|annotation| {
|
||||
let rect = if let Some(target) = target_rect {
|
||||
Rect {
|
||||
x: annotation.rect.x - target.x,
|
||||
y: annotation.rect.y - target.y,
|
||||
width: annotation.rect.width,
|
||||
height: annotation.rect.height,
|
||||
}
|
||||
} else if let Some((scroll_x, scroll_y)) = scroll {
|
||||
Rect {
|
||||
x: annotation.rect.x + scroll_x,
|
||||
y: annotation.rect.y + scroll_y,
|
||||
width: annotation.rect.width,
|
||||
height: annotation.rect.height,
|
||||
}
|
||||
} else {
|
||||
annotation.rect.clone()
|
||||
};
|
||||
|
||||
ScreenshotAnnotation {
|
||||
ref_id: annotation.ref_id.clone(),
|
||||
number: annotation.number,
|
||||
role: annotation.role.clone(),
|
||||
name: annotation.name.clone(),
|
||||
box_: AnnotationBox {
|
||||
x: round(rect.x),
|
||||
y: round(rect.y),
|
||||
width: round(rect.width),
|
||||
height: round(rect.height),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn save_screenshot(
|
||||
base64_data: &str,
|
||||
explicit_path: Option<&str>,
|
||||
ext: &str,
|
||||
output_dir: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let save_path = match explicit_path {
|
||||
Some(path) => path.to_string(),
|
||||
None => {
|
||||
let dir = match output_dir {
|
||||
Some(d) => PathBuf::from(d),
|
||||
None => get_screenshot_dir(),
|
||||
};
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let name = format!("screenshot-{}.{}", timestamp, ext);
|
||||
dir.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data)
|
||||
.map_err(|e| format!("Failed to decode screenshot: {}", e))?;
|
||||
|
||||
std::fs::write(&save_path, &bytes)
|
||||
.map_err(|e| format!("Failed to save screenshot to {}: {}", save_path, e))?;
|
||||
|
||||
Ok(save_path)
|
||||
}
|
||||
|
||||
fn round(value: f64) -> i64 {
|
||||
value.round() as i64
|
||||
}
|
||||
|
||||
fn get_screenshot_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("screenshots")
|
||||
} else {
|
||||
std::env::temp_dir()
|
||||
.join("agent-browser")
|
||||
.join("screenshots")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filters_annotations_to_target_overlap() {
|
||||
let annotations = vec![
|
||||
RawAnnotation {
|
||||
ref_id: "e1".to_string(),
|
||||
number: 1,
|
||||
role: "button".to_string(),
|
||||
name: Some("Inside".to_string()),
|
||||
rect: Rect {
|
||||
x: 10.0,
|
||||
y: 10.0,
|
||||
width: 50.0,
|
||||
height: 20.0,
|
||||
},
|
||||
},
|
||||
RawAnnotation {
|
||||
ref_id: "e2".to_string(),
|
||||
number: 2,
|
||||
role: "button".to_string(),
|
||||
name: Some("Outside".to_string()),
|
||||
rect: Rect {
|
||||
x: 200.0,
|
||||
y: 200.0,
|
||||
width: 40.0,
|
||||
height: 20.0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let target = Rect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 100.0,
|
||||
height: 100.0,
|
||||
};
|
||||
|
||||
let filtered = filter_annotations(annotations, Some(&target));
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].ref_id, "e1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_selector_annotations_relative_to_target() {
|
||||
let annotations = vec![RawAnnotation {
|
||||
ref_id: "e1".to_string(),
|
||||
number: 1,
|
||||
role: "button".to_string(),
|
||||
name: Some("Inside".to_string()),
|
||||
rect: Rect {
|
||||
x: 25.0,
|
||||
y: 35.0,
|
||||
width: 40.0,
|
||||
height: 20.0,
|
||||
},
|
||||
}];
|
||||
|
||||
let target = Rect {
|
||||
x: 10.0,
|
||||
y: 15.0,
|
||||
width: 100.0,
|
||||
height: 100.0,
|
||||
};
|
||||
|
||||
let projected = project_annotations(&annotations, Some(&target), None);
|
||||
assert_eq!(projected[0].box_.x, 15);
|
||||
assert_eq!(projected[0].box_.y, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_full_page_annotations_to_document_space() {
|
||||
let annotations = vec![RawAnnotation {
|
||||
ref_id: "e1".to_string(),
|
||||
number: 1,
|
||||
role: "button".to_string(),
|
||||
name: Some("Bottom".to_string()),
|
||||
rect: Rect {
|
||||
x: 5.0,
|
||||
y: 12.0,
|
||||
width: 40.0,
|
||||
height: 20.0,
|
||||
},
|
||||
}];
|
||||
|
||||
let projected = project_annotations(&annotations, None, Some((10.0, 1000.0)));
|
||||
assert_eq!(projected[0].box_.x, 15);
|
||||
assert_eq!(projected[0].box_.y, 1012);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,887 +0,0 @@
|
||||
use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::{
|
||||
AttachToTargetParams, AttachToTargetResult, CloseTargetParams, CreateTargetParams,
|
||||
CreateTargetResult, EvaluateParams,
|
||||
};
|
||||
use super::cookies::{self, Cookie};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorageState {
|
||||
pub cookies: Vec<Cookie>,
|
||||
pub origins: Vec<OriginStorage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OriginStorage {
|
||||
pub origin: String,
|
||||
pub local_storage: Vec<StorageEntry>,
|
||||
#[serde(default)]
|
||||
pub session_storage: Vec<StorageEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorageEntry {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
fn collect_frame_origins(tree: &Value, origins: &mut HashSet<String>) {
|
||||
if let Some(frame) = tree.get("frame") {
|
||||
if let Some(url_str) = frame.get("url").and_then(|v| v.as_str()) {
|
||||
if let Ok(parsed) = url::Url::parse(url_str) {
|
||||
let origin = parsed.origin().ascii_serialization();
|
||||
if origin != "null" && !origin.is_empty() {
|
||||
origins.insert(origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(children) = tree.get("childFrames").and_then(|v| v.as_array()) {
|
||||
for child in children {
|
||||
collect_frame_origins(child, origins);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the JS-evaluated origin storage data into an OriginStorage struct.
|
||||
fn parse_origin_storage(data: &Value) -> Option<OriginStorage> {
|
||||
if !data.is_object() {
|
||||
return None;
|
||||
}
|
||||
let origin = data
|
||||
.get("origin")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if origin.is_empty() || origin == "null" {
|
||||
return None;
|
||||
}
|
||||
let local_storage: Vec<StorageEntry> = data
|
||||
.get("localStorage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let session_storage: Vec<StorageEntry> = data
|
||||
.get("sessionStorage")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(OriginStorage {
|
||||
origin,
|
||||
local_storage,
|
||||
session_storage,
|
||||
})
|
||||
}
|
||||
|
||||
/// Evaluate the storage-collection JS snippet and parse the result.
|
||||
async fn eval_origin_storage(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
origin_js: &str,
|
||||
) -> Option<OriginStorage> {
|
||||
let result = client
|
||||
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: origin_js.to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let data = result.result.value.unwrap_or(Value::Null);
|
||||
parse_origin_storage(&data)
|
||||
}
|
||||
|
||||
/// Create a temporary CDP target, navigate it to each origin to collect localStorage,
|
||||
/// then close it. Uses Fetch interception to serve blank HTML instead of making real
|
||||
/// network requests.
|
||||
async fn collect_storage_via_temp_target(
|
||||
client: &CdpClient,
|
||||
origins: &[String],
|
||||
origin_js: &str,
|
||||
) -> Result<Vec<OriginStorage>, String> {
|
||||
let create_result: CreateTargetResult = client
|
||||
.send_command_typed(
|
||||
"Target.createTarget",
|
||||
&CreateTargetParams {
|
||||
url: "about:blank".to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let target_id = create_result.target_id;
|
||||
|
||||
// Ensure the target is closed even if attach or later steps fail
|
||||
let result = collect_storage_in_target(client, &target_id, origins, origin_js).await;
|
||||
|
||||
let _ = client
|
||||
.send_command_typed::<_, Value>(
|
||||
"Target.closeTarget",
|
||||
&CloseTargetParams { target_id },
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn collect_storage_in_target(
|
||||
client: &CdpClient,
|
||||
target_id: &str,
|
||||
origins: &[String],
|
||||
origin_js: &str,
|
||||
) -> Result<Vec<OriginStorage>, String> {
|
||||
let attach_result: AttachToTargetResult = client
|
||||
.send_command_typed(
|
||||
"Target.attachToTarget",
|
||||
&AttachToTargetParams {
|
||||
target_id: target_id.to_string(),
|
||||
flatten: true,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let temp_session = &attach_result.session_id;
|
||||
|
||||
client
|
||||
.send_command_no_params("Page.enable", Some(temp_session))
|
||||
.await?;
|
||||
client
|
||||
.send_command_no_params("Runtime.enable", Some(temp_session))
|
||||
.await?;
|
||||
|
||||
// Blank HTML response body, pre-encoded to avoid repeated base64 work per request
|
||||
let blank_html_b64 = base64::engine::general_purpose::STANDARD.encode("<html></html>");
|
||||
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Fetch.enable",
|
||||
Some(json!({ "patterns": [{ "urlPattern": "*" }] })),
|
||||
Some(temp_session),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut event_rx = client.subscribe();
|
||||
let mut results = Vec::new();
|
||||
|
||||
for target_origin in origins {
|
||||
let nav_url = format!("{}/", target_origin.trim_end_matches('/'));
|
||||
if client
|
||||
.send_command(
|
||||
"Page.navigate",
|
||||
Some(json!({ "url": nav_url })),
|
||||
Some(temp_session),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fulfill intercepted requests with blank HTML until the page loads
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(5);
|
||||
let mut page_loaded = false;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match tokio::time::timeout(tokio::time::Duration::from_secs(2), event_rx.recv()).await {
|
||||
Ok(Ok(evt)) if evt.session_id.as_deref() == Some(temp_session) => {
|
||||
if evt.method == "Fetch.requestPaused" {
|
||||
if let Some(request_id) =
|
||||
evt.params.get("requestId").and_then(|v| v.as_str())
|
||||
{
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Fetch.fulfillRequest",
|
||||
Some(json!({
|
||||
"requestId": request_id,
|
||||
"responseCode": 200,
|
||||
"responseHeaders": [
|
||||
{ "name": "Content-Type", "value": "text/html" }
|
||||
],
|
||||
"body": &blank_html_b64
|
||||
})),
|
||||
Some(temp_session),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
} else if evt.method == "Page.loadEventFired" {
|
||||
page_loaded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Ok(_)) => continue, // event for a different session
|
||||
Ok(Err(_)) => continue, // lagged or closed — retry within deadline
|
||||
Err(_) => break, // outer timeout elapsed
|
||||
}
|
||||
}
|
||||
|
||||
if !page_loaded {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(storage) = eval_origin_storage(client, temp_session, origin_js).await {
|
||||
if !storage.local_storage.is_empty() || !storage.session_storage.is_empty() {
|
||||
results.push(storage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub async fn save_state(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
path: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
session_id_str: &str,
|
||||
visited_origins: &HashSet<String>,
|
||||
) -> Result<String, String> {
|
||||
let cookies = cookies::get_all_cookies(client, session_id).await?;
|
||||
|
||||
let origin_js = r#"(() => {
|
||||
const result = { origin: location.origin, localStorage: [], sessionStorage: [] };
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
result.localStorage.push({ name: key, value: localStorage.getItem(key) });
|
||||
}
|
||||
} catch(e) {}
|
||||
try {
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
result.sessionStorage.push({ name: key, value: sessionStorage.getItem(key) });
|
||||
}
|
||||
} catch(e) {}
|
||||
return result;
|
||||
})()"#;
|
||||
|
||||
// Merge visited origins with current frame tree origins
|
||||
let mut all_origins = visited_origins.clone();
|
||||
if let Ok(tree_result) = client
|
||||
.send_command_no_params("Page.getFrameTree", Some(session_id))
|
||||
.await
|
||||
{
|
||||
if let Some(tree) = tree_result.get("frameTree") {
|
||||
collect_frame_origins(tree, &mut all_origins);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Collect localStorage from the current page
|
||||
let mut origins = Vec::new();
|
||||
let mut current_origin = String::new();
|
||||
|
||||
if let Some(storage) = eval_origin_storage(client, session_id, origin_js).await {
|
||||
current_origin = storage.origin.clone();
|
||||
if !storage.local_storage.is_empty() || !storage.session_storage.is_empty() {
|
||||
origins.push(storage);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Collect localStorage from remaining origins via a disposable temp target
|
||||
all_origins.remove(¤t_origin);
|
||||
if !all_origins.is_empty() {
|
||||
let remaining: Vec<String> = all_origins.into_iter().collect();
|
||||
if let Ok(temp_origins) =
|
||||
collect_storage_via_temp_target(client, &remaining, origin_js).await
|
||||
{
|
||||
origins.extend(temp_origins);
|
||||
}
|
||||
}
|
||||
|
||||
let state = StorageState { cookies, origins };
|
||||
let json_str = serde_json::to_string_pretty(&state)
|
||||
.map_err(|e| format!("Failed to serialize state: {}", e))?;
|
||||
|
||||
let mut save_path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let dir = get_sessions_dir();
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let name = session_name.unwrap_or("default");
|
||||
dir.join(format!("{}-{}.json", name, session_id_str))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(key) = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY") {
|
||||
let encrypted = encrypt_data(json_str.as_bytes(), &key)?;
|
||||
save_path.push_str(".enc");
|
||||
fs::write(&save_path, &encrypted)
|
||||
.map_err(|e| format!("Failed to write state to {}: {}", save_path, e))?;
|
||||
} else {
|
||||
fs::write(&save_path, &json_str)
|
||||
.map_err(|e| format!("Failed to write state to {}: {}", save_path, e))?;
|
||||
}
|
||||
|
||||
Ok(save_path)
|
||||
}
|
||||
|
||||
pub async fn load_state(client: &CdpClient, session_id: &str, path: &str) -> Result<(), String> {
|
||||
let json_str = if path.ends_with(".enc") {
|
||||
let key = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY").map_err(|_| {
|
||||
"Encrypted state file requires AGENT_BROWSER_ENCRYPTION_KEY".to_string()
|
||||
})?;
|
||||
let data =
|
||||
fs::read(path).map_err(|e| format!("Failed to read state from {}: {}", path, e))?;
|
||||
let decrypted = decrypt_data(&data, &key)?;
|
||||
String::from_utf8(decrypted)
|
||||
.map_err(|e| format!("Decrypted state is not valid UTF-8: {}", e))?
|
||||
} else {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if let Ok(key) = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY") {
|
||||
let enc_path = format!("{}.enc", path);
|
||||
if let Ok(data) = fs::read(&enc_path) {
|
||||
let decrypted = decrypt_data(&data, &key)?;
|
||||
String::from_utf8(decrypted)
|
||||
.map_err(|de| format!("Decrypted state is not valid UTF-8: {}", de))?
|
||||
} else {
|
||||
return Err(format!("Failed to read state from {}: {}", path, e));
|
||||
}
|
||||
} else {
|
||||
return Err(format!("Failed to read state from {}: {}", path, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let state: StorageState =
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Invalid state file: {}", e))?;
|
||||
|
||||
// Load cookies
|
||||
if !state.cookies.is_empty() {
|
||||
let cookie_values: Vec<Value> = state
|
||||
.cookies
|
||||
.iter()
|
||||
.map(|c| serde_json::to_value(c).unwrap_or(Value::Null))
|
||||
.collect();
|
||||
cookies::set_cookies(client, session_id, cookie_values, None).await?;
|
||||
}
|
||||
|
||||
// Load storage per origin
|
||||
for origin in &state.origins {
|
||||
if origin.local_storage.is_empty() && origin.session_storage.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Navigate to origin to set storage
|
||||
let navigate_url = format!("{}/", origin.origin.trim_end_matches('/'));
|
||||
client
|
||||
.send_command(
|
||||
"Page.navigate",
|
||||
Some(json!({ "url": navigate_url })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Brief wait for navigation
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
for entry in &origin.local_storage {
|
||||
let js = format!(
|
||||
"localStorage.setItem({}, {})",
|
||||
serde_json::to_string(&entry.name).unwrap_or_default(),
|
||||
serde_json::to_string(&entry.value).unwrap_or_default(),
|
||||
);
|
||||
let _ = client
|
||||
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
for entry in &origin.session_storage {
|
||||
let js = format!(
|
||||
"sessionStorage.setItem({}, {})",
|
||||
serde_json::to_string(&entry.name).unwrap_or_default(),
|
||||
serde_json::to_string(&entry.value).unwrap_or_default(),
|
||||
);
|
||||
let _ = client
|
||||
.send_command_typed::<_, super::cdp::types::EvaluateResult>(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js,
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_state_file(path: &std::path::Path) -> bool {
|
||||
let fname = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
fname.ends_with(".json") || fname.ends_with(".json.enc")
|
||||
}
|
||||
|
||||
fn is_encrypted_state(path: &std::path::Path) -> bool {
|
||||
path.to_string_lossy().ends_with(".json.enc")
|
||||
}
|
||||
|
||||
pub fn state_list() -> Result<Value, String> {
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "files": [], "directory": dir.to_string_lossy() }));
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read sessions dir: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if is_state_file(&path) {
|
||||
let metadata = fs::metadata(&path).ok();
|
||||
let filename = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
|
||||
let modified = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let encrypted = is_encrypted_state(&path);
|
||||
|
||||
files.push(json!({
|
||||
"filename": filename,
|
||||
"path": path.to_string_lossy(),
|
||||
"size": size,
|
||||
"modified": modified,
|
||||
"encrypted": encrypted,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({ "files": files, "directory": dir.to_string_lossy() }))
|
||||
}
|
||||
|
||||
pub fn state_show(path: &str) -> Result<Value, String> {
|
||||
let encrypted = path.ends_with(".enc");
|
||||
let json_str = if encrypted {
|
||||
let key = std::env::var("AGENT_BROWSER_ENCRYPTION_KEY").map_err(|_| {
|
||||
"Encrypted state file requires AGENT_BROWSER_ENCRYPTION_KEY".to_string()
|
||||
})?;
|
||||
let data = fs::read(path).map_err(|e| format!("Failed to read state file: {}", e))?;
|
||||
let decrypted = decrypt_data(&data, &key)?;
|
||||
String::from_utf8(decrypted)
|
||||
.map_err(|e| format!("Decrypted state is not valid UTF-8: {}", e))?
|
||||
} else {
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read state file: {}", e))?
|
||||
};
|
||||
|
||||
let state: StorageState =
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Invalid state file: {}", e))?;
|
||||
|
||||
let metadata = fs::metadata(path).ok();
|
||||
let filename = std::path::Path::new(path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
Ok(json!({
|
||||
"filename": filename,
|
||||
"path": path,
|
||||
"size": metadata.as_ref().map(|m| m.len()).unwrap_or(0),
|
||||
"modified": metadata.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
"encrypted": encrypted,
|
||||
"summary": format!("{} cookies, {} origins", state.cookies.len(), state.origins.len()),
|
||||
"state": state,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn state_clear(path: Option<&str>) -> Result<Value, String> {
|
||||
if let Some(p) = path {
|
||||
fs::remove_file(p).map_err(|e| format!("Failed to delete state: {}", e))?;
|
||||
return Ok(json!({ "deleted": p }));
|
||||
}
|
||||
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "deleted": 0 }));
|
||||
}
|
||||
|
||||
let mut count = 0;
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if is_state_file(&path) {
|
||||
let _ = fs::remove_file(&path);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({ "deleted": count }))
|
||||
}
|
||||
|
||||
pub fn state_clean(max_age_days: u64) -> Result<Value, String> {
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return Ok(json!({ "cleaned": 0, "keptCount": 0, "days": max_age_days }));
|
||||
}
|
||||
|
||||
let now = std::time::SystemTime::now();
|
||||
let max_age = std::time::Duration::from_secs(max_age_days * 86400);
|
||||
let mut deleted = 0;
|
||||
let mut kept = 0;
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !is_state_file(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(metadata) = fs::metadata(&path) {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if let Ok(age) = now.duration_since(modified) {
|
||||
if age > max_age {
|
||||
let _ = fs::remove_file(&path);
|
||||
deleted += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
kept += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({ "cleaned": deleted, "keptCount": kept, "days": max_age_days }))
|
||||
}
|
||||
|
||||
pub fn state_rename(old_path: &str, new_name: &str) -> Result<Value, String> {
|
||||
let old = PathBuf::from(old_path);
|
||||
if !old.exists() {
|
||||
return Err(format!("State file not found: {}", old_path));
|
||||
}
|
||||
|
||||
let fallback = PathBuf::from(".");
|
||||
let dir = old.parent().unwrap_or(&fallback);
|
||||
let new_path = dir.join(format!("{}.json", new_name));
|
||||
|
||||
fs::rename(&old, &new_path).map_err(|e| format!("Failed to rename state: {}", e))?;
|
||||
|
||||
Ok(json!({
|
||||
"renamed": true,
|
||||
"from": old_path,
|
||||
"to": new_path.to_string_lossy(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn encrypt_data(data: &[u8], key_str: &str) -> Result<Vec<u8>, String> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key_str.as_bytes());
|
||||
let key_bytes = hasher.finalize();
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| format!("Invalid key: {}", e))?;
|
||||
|
||||
let mut nonce = [0u8; 12];
|
||||
getrandom::getrandom(&mut nonce).map_err(|e| format!("Failed to generate nonce: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(aes_gcm::Nonce::from_slice(&nonce), data)
|
||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||
|
||||
let mut result = Vec::with_capacity(12 + ciphertext.len());
|
||||
result.extend_from_slice(&nonce);
|
||||
result.extend_from_slice(&ciphertext);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn decrypt_data(data: &[u8], key_str: &str) -> Result<Vec<u8>, String> {
|
||||
if data.len() < 13 {
|
||||
return Err("Ciphertext too short".to_string());
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = data.split_at(12);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key_str.as_bytes());
|
||||
let key_bytes = hasher.finalize();
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| format!("Invalid key: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(aes_gcm::Nonce::from_slice(nonce_bytes), ciphertext)
|
||||
.map_err(|e| format!("Decryption failed: {}", e))?;
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
pub fn find_auto_state_file(session_name: &str) -> Option<String> {
|
||||
let dir = get_sessions_dir();
|
||||
if !dir.exists() {
|
||||
return None;
|
||||
}
|
||||
let prefix = format!("{}-", session_name);
|
||||
let mut best_path: Option<(String, std::time::SystemTime)> = None;
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let fname = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let is_match = fname.starts_with(&prefix)
|
||||
&& (fname.ends_with(".json") || fname.ends_with(".json.enc"));
|
||||
if !is_match {
|
||||
continue;
|
||||
}
|
||||
let modified = fs::metadata(&path)
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
if best_path.as_ref().is_none_or(|(_, t)| modified > *t) {
|
||||
best_path = Some((path.to_string_lossy().to_string(), modified));
|
||||
}
|
||||
}
|
||||
}
|
||||
best_path.map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Dispatch a state management command from its JSON payload.
|
||||
/// Returns `Some(result)` for recognised state_* actions, `None` otherwise.
|
||||
pub fn dispatch_state_command(cmd: &Value) -> Option<Result<Value, String>> {
|
||||
let action = cmd.get("action").and_then(|v| v.as_str())?;
|
||||
match action {
|
||||
"state_list" => Some(state_list()),
|
||||
"state_show" => Some(
|
||||
cmd.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'path' parameter".to_string())
|
||||
.and_then(state_show),
|
||||
),
|
||||
"state_clear" => {
|
||||
let path = cmd.get("path").and_then(|v| v.as_str());
|
||||
Some(state_clear(path))
|
||||
}
|
||||
"state_clean" => {
|
||||
let days = cmd.get("days").and_then(|v| v.as_u64()).unwrap_or(30);
|
||||
Some(state_clean(days))
|
||||
}
|
||||
"state_rename" => Some(
|
||||
cmd.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'path' parameter".to_string())
|
||||
.and_then(|path| {
|
||||
cmd.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'name' parameter".to_string())
|
||||
.and_then(|name| state_rename(path, name))
|
||||
}),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_sessions_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("sessions")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("sessions")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_storage_state_serialization() {
|
||||
let state = StorageState {
|
||||
cookies: vec![Cookie {
|
||||
name: "session".to_string(),
|
||||
value: "abc123".to_string(),
|
||||
domain: ".example.com".to_string(),
|
||||
path: "/".to_string(),
|
||||
expires: 0.0,
|
||||
size: 0,
|
||||
http_only: true,
|
||||
secure: false,
|
||||
session: true,
|
||||
same_site: Some("Lax".to_string()),
|
||||
}],
|
||||
origins: vec![OriginStorage {
|
||||
origin: "https://example.com".to_string(),
|
||||
local_storage: vec![StorageEntry {
|
||||
name: "key".to_string(),
|
||||
value: "val".to_string(),
|
||||
}],
|
||||
session_storage: vec![],
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&state).unwrap();
|
||||
let parsed: StorageState = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.cookies.len(), 1);
|
||||
assert_eq!(parsed.cookies[0].name, "session");
|
||||
assert_eq!(parsed.origins.len(), 1);
|
||||
assert_eq!(parsed.origins[0].local_storage.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_state_empty() {
|
||||
let state = StorageState {
|
||||
cookies: vec![],
|
||||
origins: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&state).unwrap();
|
||||
let parsed: StorageState = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.cookies.is_empty());
|
||||
assert!(parsed.origins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_show_nonexistent_file() {
|
||||
let result = state_show("/tmp/nonexistent-agent-browser-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"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_rename_nonexistent() {
|
||||
let result = state_rename("/tmp/nonexistent-agent-browser-state-file.json", "new-name");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_list_returns_json() {
|
||||
let result = state_list().unwrap();
|
||||
assert!(result.get("files").is_some());
|
||||
assert!(result.get("directory").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sessions_dir_path() {
|
||||
let dir = get_sessions_dir();
|
||||
assert!(dir.to_string_lossy().contains("sessions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let plain = b"hello world";
|
||||
let key = "test-secret-key";
|
||||
let encrypted = encrypt_data(plain, key).unwrap();
|
||||
assert!(encrypted.len() > 12);
|
||||
assert_ne!(&encrypted[12..], plain);
|
||||
let decrypted = decrypt_data(&encrypted, key).unwrap();
|
||||
assert_eq!(decrypted, plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_wrong_key_fails() {
|
||||
let plain = b"secret data";
|
||||
let encrypted = encrypt_data(plain, "key1").unwrap();
|
||||
let result = decrypt_data(&encrypted, "key2");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cookie_serde_roundtrip() {
|
||||
let cookie = Cookie {
|
||||
name: "test".to_string(),
|
||||
value: "123".to_string(),
|
||||
domain: ".test.com".to_string(),
|
||||
path: "/api".to_string(),
|
||||
expires: 1700000000.0,
|
||||
size: 7,
|
||||
http_only: false,
|
||||
secure: true,
|
||||
session: false,
|
||||
same_site: Some("Strict".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&cookie).unwrap();
|
||||
assert_eq!(json["name"], "test");
|
||||
assert_eq!(json["httpOnly"], false);
|
||||
assert_eq!(json["secure"], true);
|
||||
assert_eq!(json["sameSite"], "Strict");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_command_routes_state_list() {
|
||||
let cmd = serde_json::json!({ "action": "state_list" });
|
||||
let result = dispatch_state_command(&cmd);
|
||||
assert!(result.is_some());
|
||||
assert!(result.unwrap().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_command_returns_none_for_unknown() {
|
||||
let cmd = serde_json::json!({ "action": "navigate" });
|
||||
assert!(dispatch_state_command(&cmd).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_command_returns_none_for_missing_action() {
|
||||
let cmd = serde_json::json!({});
|
||||
assert!(dispatch_state_command(&cmd).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_show_missing_path() {
|
||||
let cmd = serde_json::json!({ "action": "state_show" });
|
||||
let result = dispatch_state_command(&cmd).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "Missing 'path' parameter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dispatch_state_rename_missing_params() {
|
||||
let cmd = serde_json::json!({ "action": "state_rename" });
|
||||
let result = dispatch_state_command(&cmd).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "Missing 'path' parameter");
|
||||
|
||||
let cmd = serde_json::json!({ "action": "state_rename", "path": "/tmp/test.json" });
|
||||
let result = dispatch_state_command(&cmd).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "Missing 'name' parameter");
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
//! 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 }};"#,
|
||||
locale,
|
||||
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
|
||||
);
|
||||
|
||||
if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
|
||||
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };"#,
|
||||
) {
|
||||
format!("{}{}", config_line, rest)
|
||||
} else {
|
||||
format!("{}\n{}", config_line, STEALTH_SCRIPTS_RAW)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
fn platform_string() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Win32"
|
||||
} else {
|
||||
"Linux"
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,94 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
use super::cdp::types::EvaluateParams;
|
||||
|
||||
pub async fn storage_get(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
storage_type: &str,
|
||||
key: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let st = storage_js_name(storage_type);
|
||||
|
||||
if let Some(k) = key {
|
||||
let js = format!(
|
||||
"{}.getItem({})",
|
||||
st,
|
||||
serde_json::to_string(k).unwrap_or_default()
|
||||
);
|
||||
let result = eval_simple(client, session_id, &js).await?;
|
||||
Ok(json!({ "key": k, "value": result }))
|
||||
} else {
|
||||
let js = format!(
|
||||
r#"(() => {{
|
||||
const s = {};
|
||||
const data = {{}};
|
||||
for (let i = 0; i < s.length; i++) {{
|
||||
const key = s.key(i);
|
||||
data[key] = s.getItem(key);
|
||||
}}
|
||||
return data;
|
||||
}})()"#,
|
||||
st
|
||||
);
|
||||
let result = eval_simple(client, session_id, &js).await?;
|
||||
Ok(json!({ "data": result }))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn storage_set(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
storage_type: &str,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<(), String> {
|
||||
let st = storage_js_name(storage_type);
|
||||
let js = format!(
|
||||
"{}.setItem({}, {})",
|
||||
st,
|
||||
serde_json::to_string(key).unwrap_or_default(),
|
||||
serde_json::to_string(value).unwrap_or_default(),
|
||||
);
|
||||
eval_simple(client, session_id, &js).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn storage_clear(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
storage_type: &str,
|
||||
) -> Result<(), String> {
|
||||
let st = storage_js_name(storage_type);
|
||||
let js = format!("{}.clear()", st);
|
||||
eval_simple(client, session_id, &js).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn storage_js_name(storage_type: &str) -> &str {
|
||||
match storage_type {
|
||||
"session" => "sessionStorage",
|
||||
_ => "localStorage",
|
||||
}
|
||||
}
|
||||
|
||||
async fn eval_simple(client: &CdpClient, session_id: &str, js: &str) -> Result<Value, String> {
|
||||
let result: super::cdp::types::EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.evaluate",
|
||||
&EvaluateParams {
|
||||
expression: js.to_string(),
|
||||
return_by_value: Some(true),
|
||||
await_promise: Some(false),
|
||||
},
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(ref details) = result.exception_details {
|
||||
return Err(format!("Storage error: {}", details.text));
|
||||
}
|
||||
|
||||
Ok(result.result.value.unwrap_or(Value::Null))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,135 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Drag Probe</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.4 sans-serif;
|
||||
background: #f4f4f4;
|
||||
}
|
||||
|
||||
#pad {
|
||||
position: relative;
|
||||
width: 800px;
|
||||
height: 500px;
|
||||
margin: 24px;
|
||||
border: 1px solid #999;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#target {
|
||||
position: absolute;
|
||||
left: 320px;
|
||||
top: 40px;
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
background: #e34c26;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
#target.dragging {
|
||||
cursor: grabbing;
|
||||
background: #0d9488;
|
||||
}
|
||||
|
||||
#log {
|
||||
margin: 24px;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="pad">
|
||||
<div id="target">drag me</div>
|
||||
</div>
|
||||
<pre id="log"></pre>
|
||||
<script>
|
||||
const target = document.getElementById("target");
|
||||
const logEl = document.getElementById("log");
|
||||
|
||||
window.__dragProbe = {
|
||||
dragging: false,
|
||||
events: [],
|
||||
finalLeft: 320,
|
||||
finalTop: 40,
|
||||
};
|
||||
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
function pushEvent(event, extra = {}) {
|
||||
window.__dragProbe.events.push({
|
||||
type: event.type,
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
target: event.target.id || event.target.tagName,
|
||||
...extra,
|
||||
});
|
||||
logEl.textContent = JSON.stringify(window.__dragProbe, null, 2);
|
||||
}
|
||||
|
||||
function onPointerLikeStart(event) {
|
||||
if (event.type === "mousedown") {
|
||||
const rect = target.getBoundingClientRect();
|
||||
offsetX = event.clientX - rect.left;
|
||||
offsetY = event.clientY - rect.top;
|
||||
window.__dragProbe.dragging = true;
|
||||
target.classList.add("dragging");
|
||||
event.preventDefault();
|
||||
}
|
||||
pushEvent(event, { phase: "start" });
|
||||
}
|
||||
|
||||
target.addEventListener("mousedown", (event) => {
|
||||
const rect = target.getBoundingClientRect();
|
||||
offsetX = event.clientX - rect.left;
|
||||
offsetY = event.clientY - rect.top;
|
||||
window.__dragProbe.dragging = true;
|
||||
target.classList.add("dragging");
|
||||
event.preventDefault();
|
||||
pushEvent(event, { phase: "start" });
|
||||
});
|
||||
target.addEventListener("pointerdown", onPointerLikeStart);
|
||||
|
||||
document.addEventListener("mousemove", (event) => {
|
||||
if (window.__dragProbe.dragging) {
|
||||
const left = event.clientX - offsetX;
|
||||
const top = event.clientY - offsetY;
|
||||
target.style.left = `${left}px`;
|
||||
target.style.top = `${top}px`;
|
||||
window.__dragProbe.finalLeft = left;
|
||||
window.__dragProbe.finalTop = top;
|
||||
}
|
||||
pushEvent(event);
|
||||
});
|
||||
document.addEventListener("pointermove", (event) => {
|
||||
pushEvent(event);
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", (event) => {
|
||||
if (window.__dragProbe.dragging) {
|
||||
window.__dragProbe.dragging = false;
|
||||
target.classList.remove("dragging");
|
||||
}
|
||||
pushEvent(event, { phase: "end" });
|
||||
});
|
||||
document.addEventListener("pointerup", (event) => {
|
||||
pushEvent(event, { phase: "end" });
|
||||
});
|
||||
target.addEventListener("dragstart", (event) => {
|
||||
pushEvent(event, { phase: "dragstart" });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,91 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>HTML5 Drag Probe</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 24px;
|
||||
font: 14px/1.4 sans-serif;
|
||||
}
|
||||
|
||||
#source, #dest {
|
||||
width: 120px;
|
||||
height: 80px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #666;
|
||||
user-select: none;
|
||||
margin-right: 40px;
|
||||
}
|
||||
|
||||
#source {
|
||||
background: #f97316;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#dest {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 24px;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="source" draggable="true">drag source</div>
|
||||
<div id="dest">drop zone</div>
|
||||
<pre id="log"></pre>
|
||||
<script>
|
||||
const source = document.getElementById("source");
|
||||
const dest = document.getElementById("dest");
|
||||
const logEl = document.getElementById("log");
|
||||
|
||||
window.__html5DragProbe = { events: [] };
|
||||
|
||||
function pushEvent(event, extra = {}) {
|
||||
window.__html5DragProbe.events.push({
|
||||
type: event.type,
|
||||
target: event.target.id || event.target.tagName,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
...extra,
|
||||
});
|
||||
logEl.textContent = JSON.stringify(window.__html5DragProbe, null, 2);
|
||||
}
|
||||
|
||||
for (const type of ["pointerdown", "mousedown", "dragstart", "drag", "dragend"]) {
|
||||
source.addEventListener(type, (event) => {
|
||||
if (type === "dragstart") {
|
||||
event.dataTransfer.setData("text/plain", "probe");
|
||||
}
|
||||
pushEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
for (const type of ["pointermove", "mousemove", "dragenter", "dragover", "drop", "pointerup", "mouseup"]) {
|
||||
document.addEventListener(type, (event) => {
|
||||
if (type === "dragover") {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (type === "drop") {
|
||||
pushEvent(event, { dropped: event.dataTransfer.getData("text/plain") });
|
||||
return;
|
||||
}
|
||||
pushEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
dest.addEventListener("dragover", (event) => event.preventDefault());
|
||||
dest.addEventListener("drop", (event) => {
|
||||
pushEvent(event, { dropped: event.dataTransfer.getData("text/plain") });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,113 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Pointer Capture Probe</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 24px;
|
||||
font: 14px/1.4 sans-serif;
|
||||
}
|
||||
#crop {
|
||||
position: relative;
|
||||
width: 240px;
|
||||
height: 180px;
|
||||
border: 2px solid #fff;
|
||||
outline: 1px solid #555;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
#handle {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: -16px;
|
||||
left: -16px;
|
||||
padding-top: 13px;
|
||||
padding-left: 13px;
|
||||
box-sizing: content-box;
|
||||
background: rgba(255, 0, 0, 0.25);
|
||||
}
|
||||
#handle::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-top: 2px solid white;
|
||||
border-left: 2px solid white;
|
||||
}
|
||||
pre {
|
||||
margin-top: 24px;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="crop" aria-label="crop area">
|
||||
<div id="handle" aria-label="crop handle topLeft" data-anchor="topLeft"></div>
|
||||
</div>
|
||||
<pre id="log"></pre>
|
||||
<script>
|
||||
const crop = document.getElementById("crop");
|
||||
const handle = document.getElementById("handle");
|
||||
const logEl = document.getElementById("log");
|
||||
|
||||
const state = {
|
||||
targetAnchor: null,
|
||||
dragging: false,
|
||||
moved: false,
|
||||
events: [],
|
||||
};
|
||||
window.__pointerCaptureProbe = state;
|
||||
|
||||
function sync() {
|
||||
logEl.textContent = JSON.stringify(state, null, 2);
|
||||
}
|
||||
|
||||
function push(event, extra = {}) {
|
||||
state.events.push({
|
||||
type: event.type,
|
||||
target: event.target.id || event.target.tagName,
|
||||
currentTarget: event.currentTarget.id || event.currentTarget.tagName,
|
||||
pointerId: event.pointerId,
|
||||
button: event.button,
|
||||
buttons: event.buttons,
|
||||
hasCapture: event.currentTarget.hasPointerCapture?.(event.pointerId) ?? false,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
...extra,
|
||||
});
|
||||
sync();
|
||||
}
|
||||
|
||||
crop.addEventListener("pointerdown", (event) => {
|
||||
state.targetAnchor = event.target.getAttribute("data-anchor");
|
||||
crop.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
push(event, { phase: "down", targetAnchor: state.targetAnchor });
|
||||
});
|
||||
|
||||
crop.addEventListener("pointermove", (event) => {
|
||||
const hasCapture = crop.hasPointerCapture(event.pointerId);
|
||||
if (hasCapture && state.targetAnchor) {
|
||||
state.dragging = true;
|
||||
state.moved = true;
|
||||
}
|
||||
push(event, { phase: hasCapture ? "drag" : "hover", targetAnchor: state.targetAnchor });
|
||||
});
|
||||
|
||||
crop.addEventListener("pointerup", (event) => {
|
||||
const hadCapture = crop.hasPointerCapture(event.pointerId);
|
||||
state.dragging = false;
|
||||
push(event, { phase: "up", targetAnchor: state.targetAnchor, hadCapture });
|
||||
state.targetAnchor = null;
|
||||
});
|
||||
|
||||
handle.addEventListener("pointerdown", (event) => push(event, { listener: "handle" }));
|
||||
handle.addEventListener("pointermove", (event) => push(event, { listener: "handle" }));
|
||||
handle.addEventListener("pointerup", (event) => push(event, { listener: "handle" }));
|
||||
|
||||
sync();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,373 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::cdp::client::CdpClient;
|
||||
|
||||
const MAX_PROFILE_EVENTS: usize = 5_000_000;
|
||||
|
||||
const DEFAULT_PROFILER_CATEGORIES: &[&str] = &[
|
||||
"devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline",
|
||||
"disabled-by-default-devtools.timeline.frame",
|
||||
"disabled-by-default-devtools.timeline.stack",
|
||||
"v8.execute",
|
||||
"disabled-by-default-v8.cpu_profiler",
|
||||
"disabled-by-default-v8.cpu_profiler.hires",
|
||||
"v8",
|
||||
"disabled-by-default-v8.runtime_stats",
|
||||
"blink",
|
||||
"blink.user_timing",
|
||||
"latencyInfo",
|
||||
"renderer.scheduler",
|
||||
"sequence_manager",
|
||||
"toplevel",
|
||||
];
|
||||
|
||||
pub struct TracingState {
|
||||
pub active: bool,
|
||||
pub events: Vec<Value>,
|
||||
pub events_dropped: bool,
|
||||
}
|
||||
|
||||
impl TracingState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
events: Vec::new(),
|
||||
events_dropped: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn trace_start(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
) -> Result<Value, String> {
|
||||
if tracing_state.active {
|
||||
return Err("Tracing already active".to_string());
|
||||
}
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Tracing.start",
|
||||
Some(json!({
|
||||
"traceConfig": {
|
||||
"recordMode": "recordContinuously",
|
||||
},
|
||||
"transferMode": "ReturnAsStream",
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing_state.active = true;
|
||||
tracing_state.events.clear();
|
||||
tracing_state.events_dropped = false;
|
||||
|
||||
Ok(json!({ "started": true }))
|
||||
}
|
||||
|
||||
pub async fn trace_stop(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
path: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
if !tracing_state.active {
|
||||
return Err("No tracing in progress".to_string());
|
||||
}
|
||||
|
||||
// Subscribe to events before stopping
|
||||
let mut rx = client.subscribe();
|
||||
|
||||
client
|
||||
.send_command_no_params("Tracing.end", Some(session_id))
|
||||
.await?;
|
||||
|
||||
// Collect trace data with timeout
|
||||
let mut trace_events: Vec<Value> = Vec::new();
|
||||
let mut stream_handle: Option<String> = None;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
|
||||
|
||||
loop {
|
||||
let result = tokio::time::timeout_at(deadline, rx.recv()).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(event)) => {
|
||||
if event.session_id.as_deref() != Some(session_id) {
|
||||
continue;
|
||||
}
|
||||
match event.method.as_str() {
|
||||
"Tracing.dataCollected" => {
|
||||
if let Some(arr) = event.params.get("value").and_then(|v| v.as_array()) {
|
||||
trace_events.extend(arr.iter().cloned());
|
||||
}
|
||||
}
|
||||
"Tracing.tracingComplete" => {
|
||||
stream_handle = event
|
||||
.params
|
||||
.get("stream")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => {
|
||||
return Err("Tracing stop timed out after 30s".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If ReturnAsStream mode was used, read trace data from the IO stream
|
||||
if let Some(handle) = stream_handle {
|
||||
if trace_events.is_empty() {
|
||||
let stream_data = read_io_stream(client, session_id, &handle).await?;
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(&stream_data) {
|
||||
if let Some(events) = parsed.get("traceEvents").and_then(|v| v.as_array()) {
|
||||
trace_events.extend(events.iter().cloned());
|
||||
}
|
||||
} else {
|
||||
// Try parsing as newline-delimited JSON
|
||||
for line in stream_data.lines() {
|
||||
if let Ok(val) = serde_json::from_str::<Value>(line) {
|
||||
if let Some(events) = val.get("traceEvents").and_then(|v| v.as_array()) {
|
||||
trace_events.extend(events.iter().cloned());
|
||||
} else {
|
||||
trace_events.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Close the IO stream
|
||||
let _ = client
|
||||
.send_command(
|
||||
"IO.close",
|
||||
Some(json!({ "handle": handle })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing_state.active = false;
|
||||
|
||||
let save_path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let dir = get_traces_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
dir.join(format!("trace-{}.json", timestamp))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let trace_json = json!({ "traceEvents": trace_events });
|
||||
let json_str = serde_json::to_string(&trace_json)
|
||||
.map_err(|e| format!("Failed to serialize trace: {}", e))?;
|
||||
std::fs::write(&save_path, json_str)
|
||||
.map_err(|e| format!("Failed to write trace to {}: {}", save_path, e))?;
|
||||
|
||||
Ok(json!({ "path": save_path, "eventCount": trace_events.len() }))
|
||||
}
|
||||
|
||||
pub async fn profiler_start(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
categories: Option<Vec<String>>,
|
||||
) -> Result<Value, String> {
|
||||
if tracing_state.active {
|
||||
return Err("Profiling/tracing already active".to_string());
|
||||
}
|
||||
|
||||
let cats: Vec<String> = categories.unwrap_or_else(|| {
|
||||
DEFAULT_PROFILER_CATEGORIES
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
});
|
||||
|
||||
client
|
||||
.send_command(
|
||||
"Tracing.start",
|
||||
Some(json!({
|
||||
"traceConfig": {
|
||||
"includedCategories": cats,
|
||||
"enableSampling": true,
|
||||
},
|
||||
"transferMode": "ReportEvents",
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing_state.active = true;
|
||||
tracing_state.events.clear();
|
||||
tracing_state.events_dropped = false;
|
||||
|
||||
Ok(json!({ "started": true }))
|
||||
}
|
||||
|
||||
pub async fn profiler_stop(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
tracing_state: &mut TracingState,
|
||||
path: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
if !tracing_state.active {
|
||||
return Err("No profiling in progress".to_string());
|
||||
}
|
||||
|
||||
let mut rx = client.subscribe();
|
||||
|
||||
client
|
||||
.send_command_no_params("Tracing.end", Some(session_id))
|
||||
.await?;
|
||||
|
||||
let mut events: Vec<Value> = Vec::new();
|
||||
let mut dropped = false;
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
|
||||
|
||||
loop {
|
||||
let result = tokio::time::timeout_at(deadline, rx.recv()).await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(event)) => {
|
||||
if event.session_id.as_deref() != Some(session_id) {
|
||||
continue;
|
||||
}
|
||||
match event.method.as_str() {
|
||||
"Tracing.dataCollected" => {
|
||||
if let Some(arr) = event.params.get("value").and_then(|v| v.as_array()) {
|
||||
if events.len() + arr.len() > MAX_PROFILE_EVENTS {
|
||||
dropped = true;
|
||||
} else {
|
||||
events.extend(arr.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
"Tracing.tracingComplete" => {
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => {
|
||||
return Err("Profiler stop timed out after 30s".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing_state.active = false;
|
||||
|
||||
let save_path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let dir = get_profiles_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
dir.join(format!("profile-{}.json", timestamp))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let clock_domain = get_clock_domain();
|
||||
let mut profile = json!({ "traceEvents": events });
|
||||
if let Some(cd) = clock_domain {
|
||||
profile
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("metadata".to_string(), json!({ "clock-domain": cd }));
|
||||
}
|
||||
|
||||
let json_str = serde_json::to_string(&profile)
|
||||
.map_err(|e| format!("Failed to serialize profile: {}", e))?;
|
||||
std::fs::write(&save_path, json_str)
|
||||
.map_err(|e| format!("Failed to write profile to {}: {}", save_path, e))?;
|
||||
|
||||
let event_count = events.len();
|
||||
let mut result = json!({ "path": save_path, "eventCount": event_count });
|
||||
if dropped {
|
||||
result.as_object_mut().unwrap().insert(
|
||||
"warning".to_string(),
|
||||
Value::String(format!(
|
||||
"Events exceeded {} limit; some dropped",
|
||||
MAX_PROFILE_EVENTS
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Read all data from a CDP IO stream handle.
|
||||
async fn read_io_stream(
|
||||
client: &CdpClient,
|
||||
session_id: &str,
|
||||
handle: &str,
|
||||
) -> Result<String, String> {
|
||||
let mut data = String::new();
|
||||
loop {
|
||||
let result = client
|
||||
.send_command(
|
||||
"IO.read",
|
||||
Some(json!({
|
||||
"handle": handle,
|
||||
"size": 1024 * 1024,
|
||||
})),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(chunk) = result.get("data").and_then(|v| v.as_str()) {
|
||||
data.push_str(chunk);
|
||||
}
|
||||
|
||||
let eof = result.get("eof").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
if eof {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn get_clock_domain() -> Option<&'static str> {
|
||||
if cfg!(target_os = "linux") {
|
||||
Some("LINUX_CLOCK_MONOTONIC")
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Some("MAC_MACH_ABSOLUTE_TIME")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn get_traces_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("traces")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("traces")
|
||||
}
|
||||
}
|
||||
|
||||
fn get_profiles_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".agent-browser").join("tmp").join("profiles")
|
||||
} else {
|
||||
std::env::temp_dir().join("agent-browser").join("profiles")
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::client::WebDriverClient;
|
||||
|
||||
const APPIUM_DEFAULT_PORT: u16 = 4723;
|
||||
const APPIUM_STARTUP_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
pub struct AppiumManager {
|
||||
pub client: WebDriverClient,
|
||||
appium_process: Option<Child>,
|
||||
pub device_udid: Option<String>,
|
||||
}
|
||||
|
||||
impl AppiumManager {
|
||||
pub async fn connect_or_launch(device_udid: Option<&str>) -> Result<Self, String> {
|
||||
let port = APPIUM_DEFAULT_PORT;
|
||||
let client = WebDriverClient::new(port);
|
||||
|
||||
// Check if Appium is already running
|
||||
if is_appium_running(port).await {
|
||||
return Ok(Self {
|
||||
client,
|
||||
appium_process: None,
|
||||
device_udid: device_udid.map(String::from),
|
||||
});
|
||||
}
|
||||
|
||||
// Try to launch Appium
|
||||
let appium_process = launch_appium(port)?;
|
||||
|
||||
// Wait for Appium to be ready
|
||||
wait_for_appium(port, APPIUM_STARTUP_TIMEOUT_SECS).await?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
appium_process: Some(appium_process),
|
||||
device_udid: device_udid.map(String::from),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_ios_capabilities(
|
||||
device_udid: Option<&str>,
|
||||
device_name: Option<&str>,
|
||||
platform_version: Option<&str>,
|
||||
) -> Value {
|
||||
let mut caps = json!({
|
||||
"platformName": "iOS",
|
||||
"appium:automationName": "XCUITest",
|
||||
"browserName": "Safari",
|
||||
"appium:noReset": true,
|
||||
});
|
||||
|
||||
if let Some(name) = device_name {
|
||||
caps["appium:deviceName"] = json!(name);
|
||||
} else {
|
||||
caps["appium:deviceName"] = json!("iPhone");
|
||||
}
|
||||
|
||||
if let Some(ver) = platform_version {
|
||||
caps["appium:platformVersion"] = json!(ver);
|
||||
}
|
||||
|
||||
if let Some(udid) = device_udid {
|
||||
caps["appium:udid"] = json!(udid);
|
||||
}
|
||||
|
||||
caps
|
||||
}
|
||||
|
||||
pub async fn create_ios_session(
|
||||
&mut self,
|
||||
device_name: Option<&str>,
|
||||
platform_version: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let caps = Self::build_ios_capabilities(
|
||||
self.device_udid.as_deref(),
|
||||
device_name,
|
||||
platform_version,
|
||||
);
|
||||
self.client.create_session(caps).await
|
||||
}
|
||||
|
||||
pub async fn tap(&self, x: f64, y: f64) -> Result<(), String> {
|
||||
let sid = self
|
||||
.client
|
||||
.session_id_pub()
|
||||
.ok_or("No active session")?
|
||||
.to_string();
|
||||
let actions = json!({
|
||||
"actions": [{
|
||||
"type": "pointer",
|
||||
"id": "finger1",
|
||||
"parameters": { "pointerType": "touch" },
|
||||
"actions": [
|
||||
{ "type": "pointerMove", "duration": 0, "x": x as i64, "y": y as i64 },
|
||||
{ "type": "pointerDown", "button": 0 },
|
||||
{ "type": "pause", "duration": 100 },
|
||||
{ "type": "pointerUp", "button": 0 },
|
||||
]
|
||||
}]
|
||||
});
|
||||
self.client.execute_actions(&sid, &actions).await
|
||||
}
|
||||
|
||||
pub async fn swipe(
|
||||
&self,
|
||||
start_x: f64,
|
||||
start_y: f64,
|
||||
end_x: f64,
|
||||
end_y: f64,
|
||||
duration_ms: u64,
|
||||
) -> Result<(), String> {
|
||||
let sid = self
|
||||
.client
|
||||
.session_id_pub()
|
||||
.ok_or("No active session")?
|
||||
.to_string();
|
||||
let actions = json!({
|
||||
"actions": [{
|
||||
"type": "pointer",
|
||||
"id": "finger1",
|
||||
"parameters": { "pointerType": "touch" },
|
||||
"actions": [
|
||||
{ "type": "pointerMove", "duration": 0, "x": start_x as i64, "y": start_y as i64 },
|
||||
{ "type": "pointerDown", "button": 0 },
|
||||
{ "type": "pointerMove", "duration": duration_ms, "x": end_x as i64, "y": end_y as i64 },
|
||||
{ "type": "pointerUp", "button": 0 },
|
||||
]
|
||||
}]
|
||||
});
|
||||
self.client.execute_actions(&sid, &actions).await
|
||||
}
|
||||
|
||||
pub async fn close(&mut self) -> Result<(), String> {
|
||||
let _ = self.client.delete_session().await;
|
||||
if let Some(ref mut child) = self.appium_process {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AppiumManager {
|
||||
fn drop(&mut self) {
|
||||
if let Some(ref mut child) = self.appium_process {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_appium_running(port: u16) -> bool {
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
tokio::net::TcpStream::connect(&addr),
|
||||
)
|
||||
.await
|
||||
.map(|r| r.is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn launch_appium(port: u16) -> Result<Child, String> {
|
||||
// Try npx appium first, then direct appium
|
||||
let result = Command::new("npx")
|
||||
.args(["appium", "--relaxed-security", "--port", &port.to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
|
||||
match result {
|
||||
Ok(child) => Ok(child),
|
||||
Err(_) => Command::new("appium")
|
||||
.args(["--relaxed-security", "--port", &port.to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to launch Appium. Install it with: npm install -g appium. Error: {}",
|
||||
e
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_appium(port: u16, timeout_secs: u64) -> Result<(), String> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
if tokio::time::Instant::now() > deadline {
|
||||
return Err("Timeout waiting for Appium to start".to_string());
|
||||
}
|
||||
if is_appium_running(port).await {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_appium_constants() {
|
||||
assert_eq!(APPIUM_DEFAULT_PORT, 4723);
|
||||
assert_eq!(APPIUM_STARTUP_TIMEOUT_SECS, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ios_capabilities_use_vendor_prefix() {
|
||||
let caps = AppiumManager::build_ios_capabilities(
|
||||
Some("TEST-UDID-123"),
|
||||
Some("iPhone 16 Pro"),
|
||||
Some("18.5"),
|
||||
);
|
||||
|
||||
// W3C standard capabilities must NOT have vendor prefix
|
||||
assert!(caps.get("platformName").is_some());
|
||||
assert!(caps.get("browserName").is_some());
|
||||
|
||||
// Non-standard capabilities MUST have appium: vendor prefix
|
||||
assert!(caps.get("appium:automationName").is_some());
|
||||
assert!(caps.get("appium:noReset").is_some());
|
||||
assert!(caps.get("appium:deviceName").is_some());
|
||||
assert!(caps.get("appium:platformVersion").is_some());
|
||||
assert!(caps.get("appium:udid").is_some());
|
||||
|
||||
// Must NOT have unprefixed non-standard capabilities
|
||||
assert!(caps.get("automationName").is_none());
|
||||
assert!(caps.get("noReset").is_none());
|
||||
assert!(caps.get("deviceName").is_none());
|
||||
assert!(caps.get("udid").is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Abstract backend for browser automation. CDP (Chromium) and WebDriver
|
||||
/// (Safari/iOS) share this interface so actions.rs can remain backend-agnostic
|
||||
/// in the future.
|
||||
#[async_trait]
|
||||
pub trait BrowserBackend: Send + Sync {
|
||||
async fn navigate(&self, url: &str) -> Result<(), String>;
|
||||
async fn get_url(&self) -> Result<String, String>;
|
||||
async fn get_title(&self) -> Result<String, String>;
|
||||
async fn get_content(&self) -> Result<String, String>;
|
||||
async fn evaluate(&self, script: &str) -> Result<Value, String>;
|
||||
async fn screenshot(&self) -> Result<String, String>;
|
||||
async fn click(&self, selector: &str) -> Result<(), String>;
|
||||
async fn fill(&self, selector: &str, value: &str) -> Result<(), String>;
|
||||
async fn close(&mut self) -> Result<(), String>;
|
||||
async fn back(&self) -> Result<(), String>;
|
||||
async fn forward(&self) -> Result<(), String>;
|
||||
async fn reload(&self) -> Result<(), String>;
|
||||
async fn get_cookies(&self) -> Result<Value, String>;
|
||||
fn backend_type(&self) -> &str;
|
||||
|
||||
fn supports(&self, feature: &str) -> bool {
|
||||
match feature {
|
||||
"navigate" | "evaluate" | "screenshot" | "click" | "fill" => true,
|
||||
"screencast" | "tracing" | "network_intercept" | "cdp" => self.backend_type() == "cdp",
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_error(&self, action: &str) -> String {
|
||||
format!(
|
||||
"Action '{}' is not supported on the {} backend",
|
||||
action,
|
||||
self.backend_type()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// WebDriver implementation of BrowserBackend
|
||||
pub struct WebDriverBackend {
|
||||
client: super::client::WebDriverClient,
|
||||
}
|
||||
|
||||
impl WebDriverBackend {
|
||||
pub fn new(client: super::client::WebDriverClient) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BrowserBackend for WebDriverBackend {
|
||||
async fn navigate(&self, url: &str) -> Result<(), String> {
|
||||
self.client.navigate(url).await
|
||||
}
|
||||
|
||||
async fn get_url(&self) -> Result<String, String> {
|
||||
self.client.get_url().await
|
||||
}
|
||||
|
||||
async fn get_title(&self) -> Result<String, String> {
|
||||
self.client.get_title().await
|
||||
}
|
||||
|
||||
async fn get_content(&self) -> Result<String, String> {
|
||||
self.client.get_page_source().await
|
||||
}
|
||||
|
||||
async fn evaluate(&self, script: &str) -> Result<Value, String> {
|
||||
self.client.execute_script(script, vec![]).await
|
||||
}
|
||||
|
||||
async fn screenshot(&self) -> Result<String, String> {
|
||||
self.client.screenshot().await
|
||||
}
|
||||
|
||||
async fn click(&self, selector: &str) -> Result<(), String> {
|
||||
let element_id = self.client.find_element("css selector", selector).await?;
|
||||
self.client.click_element(&element_id).await
|
||||
}
|
||||
|
||||
async fn fill(&self, selector: &str, value: &str) -> Result<(), String> {
|
||||
let element_id = self.client.find_element("css selector", selector).await?;
|
||||
self.client.clear_element(&element_id).await?;
|
||||
self.client.send_keys(&element_id, value).await
|
||||
}
|
||||
|
||||
async fn close(&mut self) -> Result<(), String> {
|
||||
self.client.delete_session().await
|
||||
}
|
||||
|
||||
async fn back(&self) -> Result<(), String> {
|
||||
self.client.back().await
|
||||
}
|
||||
|
||||
async fn forward(&self) -> Result<(), String> {
|
||||
self.client.forward().await
|
||||
}
|
||||
|
||||
async fn reload(&self) -> Result<(), String> {
|
||||
self.client.refresh().await
|
||||
}
|
||||
|
||||
async fn get_cookies(&self) -> Result<Value, String> {
|
||||
self.client.get_cookies().await
|
||||
}
|
||||
|
||||
fn backend_type(&self) -> &str {
|
||||
"webdriver"
|
||||
}
|
||||
}
|
||||
|
||||
/// CDP-backed backend constants for unsupported actions on WebDriver
|
||||
pub const WEBDRIVER_UNSUPPORTED_ACTIONS: &[&str] = &[
|
||||
"screencast_start",
|
||||
"screencast_stop",
|
||||
"trace_start",
|
||||
"trace_stop",
|
||||
"profiler_start",
|
||||
"profiler_stop",
|
||||
"route",
|
||||
"unroute",
|
||||
"expose",
|
||||
"addscript",
|
||||
"addinitscript",
|
||||
"network",
|
||||
"har_start",
|
||||
"har_stop",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_unsupported_actions() {
|
||||
assert!(WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"screencast_start"));
|
||||
assert!(WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"trace_start"));
|
||||
assert!(!WEBDRIVER_UNSUPPORTED_ACTIONS.contains(&"navigate"));
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct WebDriverClient {
|
||||
base_url: String,
|
||||
session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl WebDriverClient {
|
||||
pub fn new(port: u16) -> Self {
|
||||
Self {
|
||||
base_url: format!("http://127.0.0.1:{}", port),
|
||||
session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_session(&mut self, capabilities: Value) -> Result<Value, String> {
|
||||
let body = json!({
|
||||
"capabilities": {
|
||||
"alwaysMatch": capabilities,
|
||||
}
|
||||
});
|
||||
|
||||
let response = self.post("/session", &body).await?;
|
||||
|
||||
let session_id = response
|
||||
.get("value")
|
||||
.and_then(|v| v.get("sessionId"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("No sessionId in response")?
|
||||
.to_string();
|
||||
|
||||
self.session_id = Some(session_id);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn delete_session(&mut self) -> Result<(), String> {
|
||||
if let Some(ref sid) = self.session_id.clone() {
|
||||
let _ = self.delete(&format!("/session/{}", sid)).await;
|
||||
self.session_id = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn navigate(&self, url: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/url", sid), &json!({ "url": url }))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_url(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/url", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_title(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/title", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn find_element(&self, using: &str, value: &str) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self
|
||||
.post(
|
||||
&format!("/session/{}/element", sid),
|
||||
&json!({ "using": using, "value": value }),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let element_value = response.get("value").ok_or("No element in response")?;
|
||||
|
||||
element_value
|
||||
.get("element-6066-11e4-a52e-4f735466cecf")
|
||||
.or_else(|| element_value.get("ELEMENT"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or("No element ID in response".to_string())
|
||||
}
|
||||
|
||||
pub async fn click_element(&self, element_id: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(
|
||||
&format!("/session/{}/element/{}/click", sid, element_id),
|
||||
&json!({}),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_keys(&self, element_id: &str, text: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(
|
||||
&format!("/session/{}/element/{}/value", sid, element_id),
|
||||
&json!({ "text": text }),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_element(&self, element_id: &str) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(
|
||||
&format!("/session/{}/element/{}/clear", sid, element_id),
|
||||
&json!({}),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn execute_script(&self, script: &str, args: Vec<Value>) -> Result<Value, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self
|
||||
.post(
|
||||
&format!("/session/{}/execute/sync", sid),
|
||||
&json!({ "script": script, "args": args }),
|
||||
)
|
||||
.await?;
|
||||
Ok(response.get("value").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub async fn screenshot(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/screenshot", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn get_cookies(&self) -> Result<Value, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/cookie", sid)).await?;
|
||||
Ok(response.get("value").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub async fn get_page_source(&self) -> Result<String, String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
let response = self.get(&format!("/session/{}/source", sid)).await?;
|
||||
Ok(response
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn back(&self) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/back", sid), &json!({}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn forward(&self) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/forward", sid), &json!({}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn refresh(&self) -> Result<(), String> {
|
||||
let sid = self.session_id()?.to_string();
|
||||
self.post(&format!("/session/{}/refresh", sid), &json!({}))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn session_id_pub(&self) -> Option<&str> {
|
||||
self.session_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn new_with_session(port: u16, session_id: String) -> Self {
|
||||
Self {
|
||||
base_url: format!("http://127.0.0.1:{}", port),
|
||||
session_id: Some(session_id),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_actions(&self, session_id: &str, actions: &Value) -> Result<(), String> {
|
||||
self.post(&format!("/session/{}/actions", session_id), actions)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn session_id(&self) -> Result<&str, String> {
|
||||
self.session_id
|
||||
.as_deref()
|
||||
.ok_or("No active WebDriver session".to_string())
|
||||
}
|
||||
|
||||
async fn get(&self, path: &str) -> Result<Value, String> {
|
||||
http_request("GET", &format!("{}{}", self.base_url, path), None).await
|
||||
}
|
||||
|
||||
async fn post(&self, path: &str, body: &Value) -> Result<Value, String> {
|
||||
http_request("POST", &format!("{}{}", self.base_url, path), Some(body)).await
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<Value, String> {
|
||||
http_request("DELETE", &format!("{}{}", self.base_url, path), None).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn http_request(method: &str, url: &str, body: Option<&Value>) -> Result<Value, String> {
|
||||
let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||
let port = parsed.port().unwrap_or(80);
|
||||
let path = parsed.path();
|
||||
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let stream = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
tokio::net::TcpStream::connect(&addr),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("Connection timeout: {}", addr))?
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let body_str = body
|
||||
.map(|b| serde_json::to_string(b).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
let request = if body.is_some() {
|
||||
format!(
|
||||
"{} {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
method, path, addr, body_str.len(), body_str
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
|
||||
method, path, addr
|
||||
)
|
||||
};
|
||||
|
||||
let mut stream = stream;
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Write failed: {}", e))?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream
|
||||
.read_to_end(&mut response)
|
||||
.await
|
||||
.map_err(|e| format!("Read failed: {}", e))?;
|
||||
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
let body_part = response_str.split("\r\n\r\n").nth(1).unwrap_or("").trim();
|
||||
|
||||
// Handle chunked encoding
|
||||
let json_body = if body_part.contains('\n')
|
||||
&& body_part
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_hexdigit())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
// Chunked: skip chunk size lines
|
||||
body_part
|
||||
.lines()
|
||||
.filter(|l| !l.chars().all(|c| c.is_ascii_hexdigit() || c == '\r'))
|
||||
.collect::<Vec<&str>>()
|
||||
.join("")
|
||||
} else {
|
||||
body_part.to_string()
|
||||
};
|
||||
|
||||
if json_body.is_empty() {
|
||||
return Ok(json!({}));
|
||||
}
|
||||
|
||||
serde_json::from_str(&json_body).map_err(|e| {
|
||||
format!(
|
||||
"Invalid JSON response: {} (body: {})",
|
||||
e,
|
||||
json_body.chars().take(100).collect::<String>()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_new() {
|
||||
let client = WebDriverClient::new(4444);
|
||||
assert_eq!(client.base_url, "http://127.0.0.1:4444");
|
||||
assert!(client.session_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_id_none() {
|
||||
let client = WebDriverClient::new(4444);
|
||||
let result = client.session_id();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No active WebDriver session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_custom_port() {
|
||||
let client = WebDriverClient::new(9515);
|
||||
assert_eq!(client.base_url, "http://127.0.0.1:9515");
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IosDevice {
|
||||
pub name: String,
|
||||
pub udid: String,
|
||||
pub state: String,
|
||||
pub runtime: String,
|
||||
pub is_real: bool,
|
||||
}
|
||||
|
||||
pub fn list_simulators() -> Result<Vec<IosDevice>, String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["simctl", "list", "devices", "--json"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run xcrun simctl: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("xcrun simctl failed. Xcode may not be installed.".to_string());
|
||||
}
|
||||
|
||||
let json_str = String::from_utf8_lossy(&output.stdout);
|
||||
let parsed: Value =
|
||||
serde_json::from_str(&json_str).map_err(|e| format!("Failed to parse simctl: {}", e))?;
|
||||
|
||||
let mut devices = Vec::new();
|
||||
if let Some(device_map) = parsed.get("devices").and_then(|v| v.as_object()) {
|
||||
for (runtime, device_list) in device_map {
|
||||
if let Some(arr) = device_list.as_array() {
|
||||
for device in arr {
|
||||
let name = device
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let udid = device
|
||||
.get("udid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let state = device
|
||||
.get("state")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
devices.push(IosDevice {
|
||||
name,
|
||||
udid,
|
||||
state,
|
||||
runtime: runtime.clone(),
|
||||
is_real: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
pub fn list_real_devices() -> Result<Vec<IosDevice>, String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["xctrace", "list", "devices"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run xcrun xctrace: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut devices = Vec::new();
|
||||
let mut in_devices = false;
|
||||
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("== Devices ==") {
|
||||
in_devices = true;
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with("== Simulators ==") {
|
||||
break;
|
||||
}
|
||||
if !in_devices || trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Format: "Device Name (OS Version) (UDID)"
|
||||
if let Some(udid_start) = trimmed.rfind('(') {
|
||||
let udid_end = trimmed.len() - 1;
|
||||
let udid = &trimmed[udid_start + 1..udid_end];
|
||||
// Validate it looks like a UDID (contains hyphens)
|
||||
if udid.contains('-') && udid.len() > 20 {
|
||||
let name_part = trimmed[..udid_start].trim();
|
||||
let name = if let Some(paren_pos) = name_part.rfind('(') {
|
||||
name_part[..paren_pos].trim().to_string()
|
||||
} else {
|
||||
name_part.to_string()
|
||||
};
|
||||
devices.push(IosDevice {
|
||||
name,
|
||||
udid: udid.to_string(),
|
||||
state: "Connected".to_string(),
|
||||
runtime: String::new(),
|
||||
is_real: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
pub fn list_all_devices() -> Result<Vec<IosDevice>, String> {
|
||||
let mut all = list_simulators().unwrap_or_default();
|
||||
all.extend(list_real_devices().unwrap_or_default());
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
pub fn boot_simulator(udid: &str) -> Result<(), String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["simctl", "boot", udid])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to boot simulator: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("current state: Booted") {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Failed to boot simulator {}: {}", udid, stderr));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn shutdown_simulator(udid: &str) -> Result<(), String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["simctl", "shutdown", udid])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to shutdown simulator: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("current state: Shutdown") {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Failed to shutdown simulator {}: {}", udid, stderr));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn select_device(device_name: Option<&str>, udid: Option<&str>) -> Result<IosDevice, String> {
|
||||
if let Some(u) = udid {
|
||||
let devices = list_all_devices()?;
|
||||
return devices
|
||||
.into_iter()
|
||||
.find(|d| d.udid == u)
|
||||
.ok_or_else(|| format!("Device with UDID '{}' not found", u));
|
||||
}
|
||||
|
||||
if let Some(name) = device_name {
|
||||
let devices = list_all_devices()?;
|
||||
return devices
|
||||
.into_iter()
|
||||
.find(|d| d.name.to_lowercase().contains(&name.to_lowercase()))
|
||||
.ok_or_else(|| format!("Device '{}' not found", name));
|
||||
}
|
||||
|
||||
// Default: prefer most recent iPhone, prefer Pro
|
||||
let devices = list_simulators()?;
|
||||
let iphone_devices: Vec<&IosDevice> = devices
|
||||
.iter()
|
||||
.filter(|d| d.name.starts_with("iPhone"))
|
||||
.collect();
|
||||
|
||||
if iphone_devices.is_empty() {
|
||||
return devices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or("No iOS simulators found".to_string());
|
||||
}
|
||||
|
||||
// Prefer Pro models
|
||||
if let Some(pro) = iphone_devices.iter().find(|d| d.name.contains("Pro")) {
|
||||
return Ok((*pro).clone());
|
||||
}
|
||||
|
||||
Ok((*iphone_devices.last().unwrap()).clone())
|
||||
}
|
||||
|
||||
pub fn to_device_json(devices: &[IosDevice]) -> Value {
|
||||
let list: Vec<Value> = devices
|
||||
.iter()
|
||||
.map(|d| {
|
||||
json!({
|
||||
"name": d.name,
|
||||
"udid": d.udid,
|
||||
"state": d.state,
|
||||
"runtime": d.runtime,
|
||||
"isReal": d.is_real,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "devices": list })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ios_device_struct() {
|
||||
let device = IosDevice {
|
||||
name: "iPhone 15 Pro".to_string(),
|
||||
udid: "ABC-123".to_string(),
|
||||
state: "Booted".to_string(),
|
||||
runtime: "iOS-17-0".to_string(),
|
||||
is_real: false,
|
||||
};
|
||||
assert_eq!(device.name, "iPhone 15 Pro");
|
||||
assert!(!device.is_real);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_device_json() {
|
||||
let devices = vec![IosDevice {
|
||||
name: "Test".to_string(),
|
||||
udid: "123".to_string(),
|
||||
state: "Shutdown".to_string(),
|
||||
runtime: "iOS-17".to_string(),
|
||||
is_real: false,
|
||||
}];
|
||||
let json = to_device_json(&devices);
|
||||
assert!(json.get("devices").unwrap().as_array().unwrap().len() == 1);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
pub mod appium;
|
||||
pub mod backend;
|
||||
pub mod client;
|
||||
pub mod ios;
|
||||
pub mod safari;
|
||||
pub mod types;
|
||||
@@ -1,80 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct SafariDriverProcess {
|
||||
child: Child,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl SafariDriverProcess {
|
||||
pub fn kill(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SafariDriverProcess {
|
||||
fn drop(&mut self) {
|
||||
self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_safaridriver() -> Option<PathBuf> {
|
||||
let candidates = ["/usr/bin/safaridriver"];
|
||||
|
||||
for c in &candidates {
|
||||
let p = PathBuf::from(c);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Try PATH
|
||||
if let Ok(output) = Command::new("which").arg("safaridriver").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn launch_safaridriver(port: u16) -> Result<SafariDriverProcess, String> {
|
||||
let driver_path = find_safaridriver()
|
||||
.ok_or("safaridriver not found. Safari WebDriver requires macOS with Safari.")?;
|
||||
|
||||
let child = Command::new(&driver_path)
|
||||
.arg("--port")
|
||||
.arg(port.to_string())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch safaridriver: {}", e))?;
|
||||
|
||||
// Wait for driver to be ready
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
Ok(SafariDriverProcess { child, port })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_find_safaridriver() {
|
||||
// Only check on macOS
|
||||
if cfg!(target_os = "macos") {
|
||||
let result = find_safaridriver();
|
||||
// Don't assert Some since it may not be enabled
|
||||
if let Some(path) = result {
|
||||
assert!(path.exists());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewSessionRequest {
|
||||
pub capabilities: Capabilities,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Capabilities {
|
||||
pub always_match: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionResponse {
|
||||
pub value: SessionValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionValue {
|
||||
pub session_id: String,
|
||||
pub capabilities: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WebDriverResponse {
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WebDriverError {
|
||||
pub error: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ElementResponse {
|
||||
pub value: ElementValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ElementValue {
|
||||
#[serde(rename = "element-6066-11e4-a52e-4f735466cecf")]
|
||||
pub element_id: Option<String>,
|
||||
#[serde(rename = "ELEMENT")]
|
||||
pub element_legacy: Option<String>,
|
||||
}
|
||||
|
||||
impl ElementValue {
|
||||
pub fn id(&self) -> Option<&str> {
|
||||
self.element_id
|
||||
.as_deref()
|
||||
.or(self.element_legacy.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FindElementRequest {
|
||||
pub using: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExecuteScriptRequest {
|
||||
pub script: String,
|
||||
pub args: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CookieRequest {
|
||||
pub cookie: CookieData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CookieData {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub secure: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub http_only: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expiry: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub same_site: Option<String>,
|
||||
}
|
||||
+152
-2032
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// Global mutex shared across all test modules to prevent parallel tests from
|
||||
/// interfering with each other when mutating environment variables.
|
||||
pub static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// RAII guard that locks [`ENV_MUTEX`] and restores environment variables on drop.
|
||||
pub struct EnvGuard<'a> {
|
||||
_lock: MutexGuard<'a, ()>,
|
||||
vars: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
impl<'a> EnvGuard<'a> {
|
||||
pub fn new(var_names: &[&str]) -> Self {
|
||||
let lock = ENV_MUTEX.lock().unwrap();
|
||||
let vars = var_names
|
||||
.iter()
|
||||
.map(|&name| (name.to_string(), std::env::var(name).ok()))
|
||||
.collect();
|
||||
Self { _lock: lock, vars }
|
||||
}
|
||||
|
||||
pub fn set(&self, name: &str, value: &str) {
|
||||
debug_assert!(
|
||||
self.vars.iter().any(|(n, _)| n == name),
|
||||
"EnvGuard::set called with unregistered var: {name}"
|
||||
);
|
||||
std::env::set_var(name, value);
|
||||
}
|
||||
|
||||
pub fn remove(&self, name: &str) {
|
||||
debug_assert!(
|
||||
self.vars.iter().any(|(n, _)| n == name),
|
||||
"EnvGuard::remove called with unregistered var: {name}"
|
||||
);
|
||||
std::env::remove_var(name);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
for (name, value) in &self.vars {
|
||||
match value {
|
||||
Some(v) => std::env::set_var(name, v),
|
||||
None => std::env::remove_var(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
use crate::color;
|
||||
use std::path::Path;
|
||||
use std::process::{exit, Command, Stdio};
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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 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
|
||||
}
|
||||
|
||||
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 command_output_contains(cmd: &str, args: &[&str], needle: &str) -> bool {
|
||||
Command::new(cmd)
|
||||
.args(args)
|
||||
.stderr(Stdio::null())
|
||||
.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);
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let method = detect_install_method();
|
||||
|
||||
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) {
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
} else {
|
||||
eprintln!("{} Upgrade failed.", color::error_indicator());
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/// Check if a session name is valid (alphanumeric, hyphens, and underscores only)
|
||||
pub fn is_valid_session_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
/// Generate error message for invalid session name
|
||||
pub fn session_name_error(name: &str) -> String {
|
||||
format!(
|
||||
"Invalid session name '{}'. Only alphanumeric characters, hyphens, and underscores are allowed.",
|
||||
name
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# Multi-platform Rust cross-compilation image
|
||||
FROM rust:1.94-bookworm
|
||||
FROM rust:1.85-bookworm
|
||||
|
||||
# Install cross-compilation toolchains
|
||||
RUN apt-get update && apt-get install -y \
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
(() => {
|
||||
const REQUEST_TYPE = 'AB_TAB_GROUP_REQUEST';
|
||||
const RESPONSE_TYPE = 'AB_TAB_GROUP_RESPONSE';
|
||||
|
||||
const CONTENT_EVENT_TYPE = 'AB_CONTENT_EVENT';
|
||||
const CONTENT_EXECUTE_ACTION = 'AB_CONTENT_EXECUTE_ACTION';
|
||||
const CONTENT_GET_DOM_STATE = 'AB_CONTENT_GET_DOM_STATE';
|
||||
const CONTENT_PING = 'AB_CONTENT_PING';
|
||||
const PAGE_BRIDGE_EVENT = 'AB_PAGE_BRIDGE_EVENT';
|
||||
const STORAGE_OPTIONS_KEY = 'abExtensionOptionsV1';
|
||||
|
||||
const mutationState = {
|
||||
total: 0,
|
||||
recent: [],
|
||||
observerReady: false,
|
||||
};
|
||||
|
||||
function pushMutationSummary(entry) {
|
||||
mutationState.total += 1;
|
||||
mutationState.recent.push({
|
||||
...entry,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
if (mutationState.recent.length > 40) {
|
||||
mutationState.recent.splice(0, mutationState.recent.length - 40);
|
||||
}
|
||||
}
|
||||
|
||||
function serializeValue(value, depth = 0) {
|
||||
if (value === null || typeof value === 'undefined') return value;
|
||||
if (typeof value === 'string') return value.slice(0, 300);
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
||||
if (value instanceof Error) return `${value.name}: ${value.message}`;
|
||||
if (depth > 2) return '[depth-limit]';
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 10).map((item) => serializeValue(item, depth + 1));
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const [key, entry] of Object.entries(value).slice(0, 15)) {
|
||||
out[key] = serializeValue(entry, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return String(value).slice(0, 300);
|
||||
}
|
||||
|
||||
function sendRuntimeEvent(kind, payload) {
|
||||
try {
|
||||
chrome.runtime.sendMessage({
|
||||
type: CONTENT_EVENT_TYPE,
|
||||
kind,
|
||||
payload: serializeValue(payload),
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// Ignore runtime channel errors.
|
||||
}
|
||||
}
|
||||
|
||||
function getPageBridgeEnabled() {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
chrome.storage.local.get([STORAGE_OPTIONS_KEY], (result) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const rawOptions = result?.[STORAGE_OPTIONS_KEY];
|
||||
resolve(Boolean(rawOptions && typeof rawOptions === 'object' && rawOptions.pageBridgeEnabled === true));
|
||||
});
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function installPageBridge() {
|
||||
// Receives events emitted by the injected page-world hook script.
|
||||
const bridgeListener = (event) => {
|
||||
if (event.source !== window) return;
|
||||
const data = event.data;
|
||||
if (!data || data.type !== PAGE_BRIDGE_EVENT) return;
|
||||
sendRuntimeEvent(data.kind || 'page-event', data.payload || {});
|
||||
};
|
||||
|
||||
window.addEventListener('message', bridgeListener);
|
||||
|
||||
const parent = document.documentElement || document.head || document.body;
|
||||
if (!parent) return;
|
||||
|
||||
if (!(await getPageBridgeEnabled())) {
|
||||
sendRuntimeEvent('lifecycle', {
|
||||
event: 'bridge-disabled-default',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use external extension script instead of inline text to reduce CSP conflicts.
|
||||
const script = document.createElement('script');
|
||||
script.src = chrome.runtime.getURL('page-bridge.js');
|
||||
script.async = false;
|
||||
script.dataset.abBridgeEvent = PAGE_BRIDGE_EVENT;
|
||||
script.onload = () => script.remove();
|
||||
script.onerror = () => {
|
||||
sendRuntimeEvent('lifecycle', {
|
||||
event: 'bridge-load-failed',
|
||||
host: window.location.hostname,
|
||||
});
|
||||
script.remove();
|
||||
};
|
||||
parent.appendChild(script);
|
||||
}
|
||||
|
||||
function ensureMutationObserver() {
|
||||
if (mutationState.observerReady) return;
|
||||
if (!document.documentElement) return;
|
||||
|
||||
const observer = new MutationObserver((records) => {
|
||||
const summary = {
|
||||
records: records.length,
|
||||
addedNodes: 0,
|
||||
removedNodes: 0,
|
||||
};
|
||||
|
||||
for (const record of records.slice(0, 40)) {
|
||||
summary.addedNodes += record.addedNodes?.length || 0;
|
||||
summary.removedNodes += record.removedNodes?.length || 0;
|
||||
}
|
||||
|
||||
pushMutationSummary(summary);
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'hidden', 'disabled', 'aria-hidden'],
|
||||
});
|
||||
|
||||
mutationState.observerReady = true;
|
||||
}
|
||||
|
||||
function toSimpleNode(element) {
|
||||
if (!element || typeof element !== 'object') return null;
|
||||
const node = {
|
||||
tag: element.tagName?.toLowerCase() || 'unknown',
|
||||
id: element.id || undefined,
|
||||
className: typeof element.className === 'string' ? element.className.slice(0, 120) : '',
|
||||
role: element.getAttribute?.('role') || undefined,
|
||||
name:
|
||||
element.getAttribute?.('aria-label') ||
|
||||
element.getAttribute?.('name') ||
|
||||
element.getAttribute?.('placeholder') ||
|
||||
'',
|
||||
text: (element.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 160),
|
||||
disabled: element.disabled === true,
|
||||
hidden: element.hidden === true,
|
||||
};
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
function collectInteractiveElements(root, limit = 80) {
|
||||
const selector = [
|
||||
'a[href]',
|
||||
'button',
|
||||
'input',
|
||||
'select',
|
||||
'textarea',
|
||||
'summary',
|
||||
'[role="button"]',
|
||||
'[role="link"]',
|
||||
'[tabindex]'
|
||||
].join(',');
|
||||
|
||||
const out = [];
|
||||
const nodes = root.querySelectorAll(selector);
|
||||
for (const element of nodes) {
|
||||
if (out.length >= limit) break;
|
||||
out.push(toSimpleNode(element));
|
||||
}
|
||||
return out.filter(Boolean);
|
||||
}
|
||||
|
||||
function collectDomState(options = {}) {
|
||||
const selector = typeof options.selector === 'string' ? options.selector.trim() : '';
|
||||
const root = selector ? document.querySelector(selector) : document.body || document.documentElement;
|
||||
|
||||
if (!root) {
|
||||
return {
|
||||
ok: false,
|
||||
error: selector ? `selector-not-found: ${selector}` : 'root-not-found',
|
||||
};
|
||||
}
|
||||
|
||||
const textPreview = (root.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 1000);
|
||||
const interactiveOnly = options.interactiveOnly === true;
|
||||
const interactiveElements = collectInteractiveElements(root, options.maxNodes || 80);
|
||||
|
||||
const dom = {
|
||||
href: window.location.href,
|
||||
title: document.title,
|
||||
readyState: document.readyState,
|
||||
selector: selector || null,
|
||||
rootTag: root.tagName?.toLowerCase() || 'unknown',
|
||||
textPreview,
|
||||
interactiveCount: interactiveElements.length,
|
||||
interactiveElements,
|
||||
mutation: {
|
||||
total: mutationState.total,
|
||||
recent: mutationState.recent.slice(-10),
|
||||
},
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
|
||||
if (interactiveOnly) {
|
||||
dom.textPreview = '';
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
state: dom,
|
||||
};
|
||||
}
|
||||
|
||||
function queryElement(selector) {
|
||||
if (typeof selector !== 'string' || selector.trim().length === 0) {
|
||||
throw new Error('selector is required');
|
||||
}
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) {
|
||||
throw new Error(`Element not found: ${selector}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function focusElement(element) {
|
||||
if (typeof element.focus === 'function') {
|
||||
element.focus({ preventScroll: false });
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchInputEvents(element) {
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
async function executeAction(command, args = {}) {
|
||||
switch (command) {
|
||||
case 'click': {
|
||||
const element = queryElement(args.selector);
|
||||
focusElement(element);
|
||||
element.click();
|
||||
return { ok: true, action: command, selector: args.selector };
|
||||
}
|
||||
case 'fill': {
|
||||
const element = queryElement(args.selector);
|
||||
if (!('value' in element)) {
|
||||
throw new Error(`Element is not fillable: ${args.selector}`);
|
||||
}
|
||||
focusElement(element);
|
||||
element.value = typeof args.value === 'string' ? args.value : String(args.value || '');
|
||||
dispatchInputEvents(element);
|
||||
return { ok: true, action: command, selector: args.selector, valueLength: element.value.length };
|
||||
}
|
||||
case 'press': {
|
||||
const key = typeof args.key === 'string' && args.key.trim().length > 0 ? args.key.trim() : 'Enter';
|
||||
let target;
|
||||
if (typeof args.selector === 'string' && args.selector.trim().length > 0) {
|
||||
target = queryElement(args.selector);
|
||||
focusElement(target);
|
||||
} else {
|
||||
target = document.activeElement || document.body;
|
||||
}
|
||||
|
||||
const down = new KeyboardEvent('keydown', { key, bubbles: true });
|
||||
const up = new KeyboardEvent('keyup', { key, bubbles: true });
|
||||
target.dispatchEvent(down);
|
||||
target.dispatchEvent(up);
|
||||
return { ok: true, action: command, key };
|
||||
}
|
||||
case 'eval': {
|
||||
if (typeof args.expression !== 'string' || args.expression.trim().length === 0) {
|
||||
throw new Error('expression is required');
|
||||
}
|
||||
const fn = new Function(`return (${args.expression});`);
|
||||
const result = fn();
|
||||
return { ok: true, action: command, result: serializeValue(result) };
|
||||
}
|
||||
case 'snapshot': {
|
||||
return {
|
||||
ok: true,
|
||||
action: command,
|
||||
...collectDomState({
|
||||
selector: args.selector,
|
||||
interactiveOnly: args.interactiveOnly === true,
|
||||
maxNodes: args.maxNodes,
|
||||
}),
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown content action: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
ensureMutationObserver();
|
||||
installPageBridge();
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.source !== window) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data;
|
||||
if (!data || data.type !== REQUEST_TYPE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = {
|
||||
type: REQUEST_TYPE,
|
||||
nonce: data.nonce,
|
||||
session: data.session,
|
||||
groupTitle: data.groupTitle,
|
||||
pluginId: data.pluginId,
|
||||
allowedDomains: Array.isArray(data.allowedDomains) ? data.allowedDomains : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
chrome.runtime.sendMessage(request, (response) => {
|
||||
const lastError = chrome.runtime.lastError;
|
||||
if (lastError) {
|
||||
window.postMessage(
|
||||
{
|
||||
type: RESPONSE_TYPE,
|
||||
nonce: request.nonce,
|
||||
ok: false,
|
||||
error: lastError.message,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = response && typeof response === 'object' ? response : { ok: false };
|
||||
|
||||
window.postMessage(
|
||||
{
|
||||
type: RESPONSE_TYPE,
|
||||
nonce: request.nonce,
|
||||
ok: payload.ok === true,
|
||||
extensionId:
|
||||
typeof payload.extensionId === 'string' && payload.extensionId.length > 0
|
||||
? payload.extensionId
|
||||
: chrome.runtime.id,
|
||||
groupId: typeof payload.groupId === 'number' ? payload.groupId : undefined,
|
||||
windowId: typeof payload.windowId === 'number' ? payload.windowId : undefined,
|
||||
color: typeof payload.color === 'string' ? payload.color : undefined,
|
||||
collapsed: payload.collapsed === true,
|
||||
policy:
|
||||
payload.policy && typeof payload.policy === 'object'
|
||||
? {
|
||||
enforced: payload.policy.enforced === true,
|
||||
blocked: payload.policy.blocked === true,
|
||||
reason:
|
||||
typeof payload.policy.reason === 'string' ? payload.policy.reason : undefined,
|
||||
}
|
||||
: undefined,
|
||||
riskHints: Array.isArray(payload.riskHints) ? payload.riskHints : undefined,
|
||||
error: typeof payload.error === 'string' ? payload.error : undefined,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
window.postMessage(
|
||||
{
|
||||
type: RESPONSE_TYPE,
|
||||
nonce: request.nonce,
|
||||
ok: false,
|
||||
error: errorMessage,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (!message || typeof message !== 'object') return;
|
||||
|
||||
if (message.type === CONTENT_PING) {
|
||||
sendResponse({
|
||||
ok: true,
|
||||
href: window.location.href,
|
||||
title: document.title,
|
||||
readyState: document.readyState,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === CONTENT_GET_DOM_STATE) {
|
||||
sendResponse(collectDomState(message.options || {}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === CONTENT_EXECUTE_ACTION) {
|
||||
executeAction(message.command, message.args || {})
|
||||
.then((result) => sendResponse(result))
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
ok: false,
|
||||
action: message.command,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -1,7 +0,0 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="128" height="128" rx="32" fill="#1A73E8"/>
|
||||
<rect x="30" y="34" width="68" height="10" rx="2" fill="white"/>
|
||||
<rect x="30" y="54" width="48" height="10" rx="2" fill="white" fill-opacity="0.8"/>
|
||||
<rect x="30" y="74" width="28" height="10" rx="2" fill="white" fill-opacity="0.6"/>
|
||||
<circle cx="94" cy="90" r="10" fill="#34A853" stroke="#1A73E8" stroke-width="4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 488 B |
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.2.0",
|
||||
"description": "Session-aware tab grouping and coordination for CDP-driven agent-browser workflows.",
|
||||
"icons": {
|
||||
"128": "icons/icon.svg"
|
||||
},
|
||||
"permissions": ["tabs", "tabGroups", "downloads", "storage", "sidePanel", "alarms"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "service-worker.js"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "agent-browser-stealth"
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidepanel.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content-script.js"],
|
||||
"run_at": "document_start",
|
||||
"match_about_blank": true
|
||||
}
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["page-bridge.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
(() => {
|
||||
if (window.__AB_STEALTH_BRIDGE_INSTALLED__) return;
|
||||
window.__AB_STEALTH_BRIDGE_INSTALLED__ = true;
|
||||
|
||||
const currentScript = document.currentScript;
|
||||
const TYPE = currentScript?.dataset?.abBridgeEvent || 'AB_PAGE_BRIDGE_EVENT';
|
||||
|
||||
const post = (kind, payload) => {
|
||||
try {
|
||||
window.postMessage({ type: TYPE, kind, payload, timestamp: Date.now() }, '*');
|
||||
} catch {
|
||||
// Ignore post failures.
|
||||
}
|
||||
};
|
||||
|
||||
const serializeArg = (value, depth = 0) => {
|
||||
if (value === null || typeof value === 'undefined') return value;
|
||||
if (typeof value === 'string') return value.slice(0, 250);
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
||||
if (value instanceof Error) return `${value.name}: ${value.message}`;
|
||||
if (depth > 2) return '[depth-limit]';
|
||||
if (Array.isArray(value)) return value.slice(0, 10).map((item) => serializeArg(item, depth + 1));
|
||||
if (typeof value === 'object') {
|
||||
const out = {};
|
||||
const entries = Object.entries(value).slice(0, 12);
|
||||
for (const [k, v] of entries) {
|
||||
out[k] = serializeArg(v, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return String(value).slice(0, 250);
|
||||
};
|
||||
|
||||
const patchConsoleMethod = (name) => {
|
||||
const original = console[name];
|
||||
if (typeof original !== 'function') return;
|
||||
console[name] = function patchedConsole(...args) {
|
||||
post('console', {
|
||||
level: name,
|
||||
args: args.map((arg) => serializeArg(arg)),
|
||||
});
|
||||
return original.apply(this, args);
|
||||
};
|
||||
};
|
||||
|
||||
patchConsoleMethod('error');
|
||||
patchConsoleMethod('warn');
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
post('console', {
|
||||
level: 'error',
|
||||
message: event.message,
|
||||
source: event.filename,
|
||||
line: event.lineno,
|
||||
column: event.colno,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
post('console', {
|
||||
level: 'error',
|
||||
message: 'Unhandled rejection',
|
||||
reason: serializeArg(event.reason),
|
||||
});
|
||||
});
|
||||
|
||||
if (typeof window.fetch === 'function') {
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async (...args) => {
|
||||
const startedAt = Date.now();
|
||||
const requestInfo = args[0];
|
||||
const requestInit = args[1] || {};
|
||||
const method = requestInit.method || 'GET';
|
||||
const url = typeof requestInfo === 'string' ? requestInfo : requestInfo?.url || '';
|
||||
|
||||
try {
|
||||
const response = await originalFetch(...args);
|
||||
post('network', {
|
||||
transport: 'fetch',
|
||||
method,
|
||||
url,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
post('network', {
|
||||
transport: 'fetch',
|
||||
method,
|
||||
url,
|
||||
error: serializeArg(error),
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window.XMLHttpRequest === 'function') {
|
||||
const originalOpen = XMLHttpRequest.prototype.open;
|
||||
const originalSend = XMLHttpRequest.prototype.send;
|
||||
|
||||
XMLHttpRequest.prototype.open = function patchedOpen(method, url, ...rest) {
|
||||
this.__abRequestMeta = {
|
||||
method: typeof method === 'string' ? method : 'GET',
|
||||
url: typeof url === 'string' ? url : String(url || ''),
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
return originalOpen.call(this, method, url, ...rest);
|
||||
};
|
||||
|
||||
XMLHttpRequest.prototype.send = function patchedSend(...args) {
|
||||
this.addEventListener('loadend', () => {
|
||||
const meta = this.__abRequestMeta || {};
|
||||
post('network', {
|
||||
transport: 'xhr',
|
||||
method: meta.method || 'GET',
|
||||
url: meta.url || '',
|
||||
status: this.status,
|
||||
ok: this.status >= 200 && this.status < 400,
|
||||
durationMs: Date.now() - (meta.startedAt || Date.now()),
|
||||
});
|
||||
});
|
||||
return originalSend.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
post('lifecycle', {
|
||||
event: 'bridge-installed',
|
||||
href: location.href,
|
||||
});
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,258 +0,0 @@
|
||||
:root {
|
||||
--bg: #f3f5f7;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f6f8fb;
|
||||
--primary: #1769e0;
|
||||
--primary-hover: #0f58c0;
|
||||
--border: #d8dde4;
|
||||
--text-main: #18212f;
|
||||
--text-secondary: #4a5568;
|
||||
--text-muted: #667287;
|
||||
--success: #117a3d;
|
||||
--warning: #b86d00;
|
||||
--danger: #bd1e24;
|
||||
--radius: 10px;
|
||||
--mono: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
background: var(--bg);
|
||||
color: var(--text-main);
|
||||
font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
all: unset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
background: var(--surface);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: var(--primary);
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-line {
|
||||
min-height: 18px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-line.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-line.warn {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-line.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row.wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row + .row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-main);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
input.mono,
|
||||
textarea.mono,
|
||||
code,
|
||||
pre {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
background: #0f172a;
|
||||
color: #dce6fb;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.item-url {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.caption {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.event-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 1px 7px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
background: #f0f4fa;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 14px 0;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>agent-browser-stealth panel</title>
|
||||
<link rel="stylesheet" href="sidepanel.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>agent-browser-stealth</h1>
|
||||
<div class="actions">
|
||||
<button id="refresh-btn" type="button">Refresh</button>
|
||||
<button id="cleanup-btn" type="button">Clean Empty Groups</button>
|
||||
</div>
|
||||
<div id="status-line" class="status-line"></div>
|
||||
</header>
|
||||
|
||||
<section id="control" class="card"></section>
|
||||
<section id="summary" class="card"></section>
|
||||
<section id="automation" class="card"></section>
|
||||
<section id="developer" class="card"></section>
|
||||
<section id="sessions" class="stack"></section>
|
||||
<section id="downloads" class="card"></section>
|
||||
|
||||
<script src="sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
+36
-18
@@ -1,53 +1,71 @@
|
||||
{
|
||||
"name": "agent-browser-stealth",
|
||||
"version": "0.24.0-fork.2",
|
||||
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
||||
"name": "agent-browser",
|
||||
"version": "0.4.3",
|
||||
"description": "Headless browser automation CLI for AI agents",
|
||||
"type": "module",
|
||||
"main": "dist/daemon.js",
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"scripts",
|
||||
"skills",
|
||||
"extensions"
|
||||
"skills"
|
||||
],
|
||||
"bin": {
|
||||
"agent-browser-stealth": "./bin/agent-browser.js",
|
||||
"agent-browser": "./bin/agent-browser.js",
|
||||
"abs": "./bin/agent-browser.js"
|
||||
"agent-browser": "./bin/agent-browser"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
"version:sync": "node scripts/sync-version.js",
|
||||
"version": "npm run version:sync && git add cli/Cargo.toml",
|
||||
"build": "tsc",
|
||||
"build:native": "npm run version:sync && cargo build --release --manifest-path cli/Cargo.toml && node scripts/copy-native.js",
|
||||
"build:linux": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-linux",
|
||||
"build:macos": "npm run version:sync && (cargo build --release --manifest-path cli/Cargo.toml --target aarch64-apple-darwin & cargo build --release --manifest-path cli/Cargo.toml --target x86_64-apple-darwin & wait) && cp cli/target/aarch64-apple-darwin/release/agent-browser bin/agent-browser-darwin-arm64 && cp cli/target/x86_64-apple-darwin/release/agent-browser bin/agent-browser-darwin-x64",
|
||||
"build:windows": "npm run version:sync && docker compose -f docker/docker-compose.yml run --rm build-windows",
|
||||
"build:all-platforms": "npm run version:sync && (npm run build:linux & npm run build:windows & wait) && npm run build:macos",
|
||||
"build:docker": "docker build -t agent-browser-builder -f docker/Dockerfile.build .",
|
||||
"release": "npm run version:sync && npm run build:all-platforms && npm publish --tag fork",
|
||||
"release": "npm run version:sync && npm run build && npm run build:all-platforms && npm publish",
|
||||
"start": "node dist/daemon.js",
|
||||
"dev": "tsx src/daemon.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write 'src/**/*.ts'",
|
||||
"format:check": "prettier --check 'src/**/*.ts'",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"postinstall": "node scripts/postinstall.js"
|
||||
},
|
||||
"keywords": [
|
||||
"browser",
|
||||
"automation",
|
||||
"headless",
|
||||
"chrome",
|
||||
"cdp",
|
||||
"playwright",
|
||||
"cli",
|
||||
"agent",
|
||||
"stealth",
|
||||
"anti-detection"
|
||||
"agent"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/leeguooooo/agent-browser-stealth.git"
|
||||
"url": "git+https://github.com/vercel-labs/agent-browser.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/leeguooooo/agent-browser-stealth/issues"
|
||||
"url": "https://github.com/vercel-labs/agent-browser/issues"
|
||||
},
|
||||
"homepage": "https://github.com/vercel-labs/agent-browser#readme",
|
||||
"dependencies": {
|
||||
"playwright-core": "^1.57.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"homepage": "https://github.com/leeguooooo/agent-browser-stealth",
|
||||
"devDependencies": {
|
||||
"husky": "^9.0.11"
|
||||
"@types/node": "^20.10.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.2.11",
|
||||
"playwright": "^1.57.0",
|
||||
"prettier": "^3.7.4",
|
||||
"tsx": "^4.6.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.ts": "prettier --write"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+763
-10428
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
packages:
|
||||
- '.'
|
||||
@@ -61,12 +61,6 @@ build_target "x86_64-apple-darwin" "agent-browser-darwin-x64"
|
||||
# macOS ARM64 (via zig for cross-compilation)
|
||||
build_target "aarch64-apple-darwin" "agent-browser-darwin-arm64"
|
||||
|
||||
# Linux musl x64 (Alpine)
|
||||
build_target "x86_64-unknown-linux-musl" "agent-browser-linux-musl-x64"
|
||||
|
||||
# Linux musl ARM64 (Alpine)
|
||||
build_target "aarch64-unknown-linux-musl" "agent-browser-linux-musl-arm64"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Build complete!${NC}"
|
||||
echo ""
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Verifies that package.json and cli/Cargo.toml have the same version.
|
||||
* Used in CI to catch version drift.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..');
|
||||
|
||||
// Read package.json version
|
||||
const packageJson = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf-8'));
|
||||
const packageVersion = packageJson.version;
|
||||
|
||||
// Read Cargo.toml version
|
||||
const cargoToml = readFileSync(join(rootDir, 'cli/Cargo.toml'), 'utf-8');
|
||||
const cargoVersionMatch = cargoToml.match(/^version\s*=\s*"([^"]*)"/m);
|
||||
|
||||
if (!cargoVersionMatch) {
|
||||
console.error('Could not find version in cli/Cargo.toml');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cargoVersion = cargoVersionMatch[1];
|
||||
|
||||
// Read dashboard package.json version
|
||||
const dashboardPkg = JSON.parse(readFileSync(join(rootDir, 'packages/dashboard/package.json'), 'utf-8'));
|
||||
const dashboardVersion = dashboardPkg.version;
|
||||
|
||||
const mismatches = [];
|
||||
if (packageVersion !== cargoVersion) {
|
||||
mismatches.push(` cli/Cargo.toml: ${cargoVersion}`);
|
||||
}
|
||||
if (packageVersion !== dashboardVersion) {
|
||||
mismatches.push(` packages/dashboard: ${dashboardVersion}`);
|
||||
}
|
||||
|
||||
if (mismatches.length > 0) {
|
||||
console.error('Version mismatch detected!');
|
||||
console.error(` package.json: ${packageVersion}`);
|
||||
for (const m of mismatches) console.error(m);
|
||||
console.error('');
|
||||
console.error("Run 'pnpm run version:sync' to fix this.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Versions are in sync: ${packageVersion}`);
|
||||
@@ -12,8 +12,7 @@ import { platform, arch } from 'os';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = join(__dirname, '..');
|
||||
|
||||
const sourceExt = platform() === 'win32' ? '.exe' : '';
|
||||
const sourcePath = join(projectRoot, `cli/target/release/agent-browser${sourceExt}`);
|
||||
const sourcePath = join(projectRoot, 'cli/target/release/agent-browser');
|
||||
const binDir = join(projectRoot, 'bin');
|
||||
|
||||
// Determine platform suffix
|
||||
|
||||
+19
-218
@@ -4,12 +4,9 @@
|
||||
* Postinstall script for agent-browser
|
||||
*
|
||||
* Downloads the platform-specific native binary if not present.
|
||||
* On global installs, patches npm's bin entry to use the native binary directly:
|
||||
* - Windows: Overwrites .cmd/.ps1 shims
|
||||
* - Mac/Linux: Replaces symlink to point to native binary
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, chmodSync, createWriteStream, unlinkSync, writeFileSync, symlinkSync, lstatSync } from 'fs';
|
||||
import { existsSync, mkdirSync, chmodSync, createWriteStream, unlinkSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { platform, arch } from 'os';
|
||||
@@ -20,20 +17,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = join(__dirname, '..');
|
||||
const binDir = join(projectRoot, 'bin');
|
||||
|
||||
// Detect if the system uses musl libc (e.g. Alpine Linux)
|
||||
function isMusl() {
|
||||
if (platform() !== 'linux') return false;
|
||||
try {
|
||||
const result = execSync('ldd --version 2>&1 || true', { encoding: 'utf8' });
|
||||
return result.toLowerCase().includes('musl');
|
||||
} catch {
|
||||
return existsSync('/lib/ld-musl-x86_64.so.1') || existsSync('/lib/ld-musl-aarch64.so.1');
|
||||
}
|
||||
}
|
||||
|
||||
// Platform detection
|
||||
const osKey = platform() === 'linux' && isMusl() ? 'linux-musl' : platform();
|
||||
const platformKey = `${osKey}-${arch()}`;
|
||||
const platformKey = `${platform()}-${arch()}`;
|
||||
const ext = platform() === 'win32' ? '.exe' : '';
|
||||
const binaryName = `agent-browser-${platformKey}${ext}`;
|
||||
const binaryPath = join(binDir, binaryName);
|
||||
@@ -45,7 +30,7 @@ const packageJson = JSON.parse(
|
||||
const version = packageJson.version;
|
||||
|
||||
// GitHub release URL
|
||||
const GITHUB_REPO = 'vercel-labs/agent-browser';
|
||||
const GITHUB_REPO = 'anthropics/agent-browser'; // Update this to your actual repo
|
||||
const DOWNLOAD_URL = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${binaryName}`;
|
||||
|
||||
async function downloadFile(url, dest) {
|
||||
@@ -80,46 +65,10 @@ async function downloadFile(url, dest) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which package manager ran this postinstall and write a marker file
|
||||
* next to the binary so `agent-browser upgrade` can use the correct one
|
||||
* without fragile path heuristics or slow subprocess probing.
|
||||
*
|
||||
* npm_config_user_agent is set by npm/pnpm/yarn/bun during lifecycle scripts,
|
||||
* e.g. "pnpm/8.10.0 node/v20.10.0 linux x64"
|
||||
*/
|
||||
function writeInstallMethod() {
|
||||
const ua = process.env.npm_config_user_agent || '';
|
||||
let method = '';
|
||||
if (ua.startsWith('pnpm/')) method = 'pnpm';
|
||||
else if (ua.startsWith('yarn/')) method = 'yarn';
|
||||
else if (ua.startsWith('bun/')) method = 'bun';
|
||||
else if (ua.startsWith('npm/')) method = 'npm';
|
||||
|
||||
if (method) {
|
||||
try {
|
||||
writeFileSync(join(binDir, '.install-method'), method);
|
||||
} catch {
|
||||
// Non-critical — upgrade will fall back to heuristics
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Check if binary already exists
|
||||
if (existsSync(binaryPath)) {
|
||||
// Ensure binary is executable (npm doesn't preserve execute bit)
|
||||
if (platform() !== 'win32') {
|
||||
chmodSync(binaryPath, 0o755);
|
||||
}
|
||||
console.log(`✓ Native binary ready: ${binaryName}`);
|
||||
|
||||
writeInstallMethod();
|
||||
|
||||
// On global installs, fix npm's bin entry to use native binary directly
|
||||
await fixGlobalInstallBin();
|
||||
|
||||
showInstallReminder();
|
||||
console.log(`✓ Native binary already exists: ${binaryName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,182 +82,34 @@ async function main() {
|
||||
|
||||
try {
|
||||
await downloadFile(DOWNLOAD_URL, binaryPath);
|
||||
|
||||
|
||||
// Make executable on Unix
|
||||
if (platform() !== 'win32') {
|
||||
chmodSync(binaryPath, 0o755);
|
||||
}
|
||||
|
||||
|
||||
console.log(`✓ Downloaded native binary: ${binaryName}`);
|
||||
} catch (err) {
|
||||
console.log(`Could not download native binary: ${err.message}`);
|
||||
console.log(`⚠ Could not download native binary: ${err.message}`);
|
||||
console.log(` The CLI will use Node.js fallback (slightly slower startup)`);
|
||||
console.log('');
|
||||
console.log('To build the native binary locally:');
|
||||
console.log(' 1. Install Rust: https://rustup.rs');
|
||||
console.log(' 2. Run: npm run build:native');
|
||||
}
|
||||
|
||||
writeInstallMethod();
|
||||
|
||||
// On global installs, fix npm's bin entry to use native binary directly
|
||||
// This avoids the /bin/sh error on Windows and provides zero-overhead execution
|
||||
await fixGlobalInstallBin();
|
||||
|
||||
showInstallReminder();
|
||||
}
|
||||
|
||||
function findSystemChrome() {
|
||||
const os = platform();
|
||||
if (os === 'darwin') {
|
||||
const candidates = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
];
|
||||
return candidates.find(p => existsSync(p)) || null;
|
||||
}
|
||||
if (os === 'linux') {
|
||||
const names = ['google-chrome', 'google-chrome-stable', 'chromium-browser', 'chromium'];
|
||||
for (const name of names) {
|
||||
try {
|
||||
const result = execSync(`which ${name} 2>/dev/null`, { encoding: 'utf8' }).trim();
|
||||
if (result) return result;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (os === 'win32') {
|
||||
const candidates = [
|
||||
`${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
];
|
||||
return candidates.find(p => p && existsSync(p)) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function showInstallReminder() {
|
||||
const systemChrome = findSystemChrome();
|
||||
if (systemChrome) {
|
||||
console.log('');
|
||||
console.log(` ✓ System Chrome found: ${systemChrome}`);
|
||||
console.log(' agent-browser will use it automatically.');
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reminder about Playwright browsers
|
||||
console.log('');
|
||||
console.log(' ⚠ No Chrome installation detected.');
|
||||
console.log(' If you plan to use a local browser, run:');
|
||||
console.log('');
|
||||
console.log(' agent-browser install');
|
||||
if (platform() === 'linux') {
|
||||
console.log('');
|
||||
console.log(' On Linux, include system dependencies with:');
|
||||
console.log('');
|
||||
console.log(' agent-browser install --with-deps');
|
||||
}
|
||||
console.log('');
|
||||
console.log(' You can skip this if you use --cdp, --provider, --engine, or --executable-path.');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix npm's bin entry on global installs to use the native binary directly.
|
||||
* This provides zero-overhead CLI execution for global installs.
|
||||
*/
|
||||
async function fixGlobalInstallBin() {
|
||||
if (platform() === 'win32') {
|
||||
await fixWindowsShims();
|
||||
} else {
|
||||
await fixUnixSymlink();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix npm symlink on Mac/Linux global installs.
|
||||
* Replace the symlink to the JS wrapper with a symlink to the native binary.
|
||||
*/
|
||||
async function fixUnixSymlink() {
|
||||
// Get npm's global bin directory (npm prefix -g + /bin)
|
||||
let npmBinDir;
|
||||
try {
|
||||
const prefix = execSync('npm prefix -g', { encoding: 'utf8' }).trim();
|
||||
npmBinDir = join(prefix, 'bin');
|
||||
} catch {
|
||||
return; // npm not available
|
||||
}
|
||||
|
||||
const symlinkPath = join(npmBinDir, 'agent-browser');
|
||||
|
||||
// Check if symlink exists (indicates global install)
|
||||
try {
|
||||
const stat = lstatSync(symlinkPath);
|
||||
if (!stat.isSymbolicLink()) {
|
||||
return; // Not a symlink, don't touch it
|
||||
}
|
||||
} catch {
|
||||
return; // Symlink doesn't exist, not a global install
|
||||
}
|
||||
|
||||
// Replace symlink to point directly to native binary
|
||||
try {
|
||||
unlinkSync(symlinkPath);
|
||||
symlinkSync(binaryPath, symlinkPath);
|
||||
console.log('✓ Optimized: symlink points to native binary (zero overhead)');
|
||||
} catch (err) {
|
||||
// Permission error or other issue - not critical, JS wrapper still works
|
||||
console.log(`⚠ Could not optimize symlink: ${err.message}`);
|
||||
console.log(' CLI will work via Node.js wrapper (slightly slower startup)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix npm-generated shims on Windows global installs.
|
||||
* npm generates shims that try to run /bin/sh, which doesn't exist on Windows.
|
||||
* We overwrite them to invoke the native .exe directly.
|
||||
*/
|
||||
async function fixWindowsShims() {
|
||||
let npmBinDir;
|
||||
try {
|
||||
npmBinDir = execSync('npm prefix -g', { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const cmdShim = join(npmBinDir, 'agent-browser.cmd');
|
||||
const ps1Shim = join(npmBinDir, 'agent-browser.ps1');
|
||||
|
||||
// Shims may not exist yet during postinstall (npm creates them after
|
||||
// lifecycle scripts). If missing, fall back: the JS wrapper at
|
||||
// bin/agent-browser.js handles Windows correctly via child_process.spawn.
|
||||
if (!existsSync(cmdShim)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect architecture so ARM64 Windows is handled correctly
|
||||
const cpuArch = arch() === 'arm64' ? 'arm64' : 'x64';
|
||||
const relativeBinaryPath = `node_modules\\agent-browser\\bin\\agent-browser-win32-${cpuArch}.exe`;
|
||||
const absoluteBinaryPath = join(npmBinDir, relativeBinaryPath);
|
||||
|
||||
// Only rewrite shims if the native binary actually exists
|
||||
if (!existsSync(absoluteBinaryPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cmdContent = `@ECHO off\r\n"%~dp0${relativeBinaryPath}" %*\r\n`;
|
||||
writeFileSync(cmdShim, cmdContent);
|
||||
|
||||
const ps1Content = `#!/usr/bin/env pwsh\r\n$basedir = Split-Path $MyInvocation.MyCommand.Definition -Parent\r\n& "$basedir\\${relativeBinaryPath}" $args\r\nexit $LASTEXITCODE\r\n`;
|
||||
writeFileSync(ps1Shim, ps1Content);
|
||||
|
||||
console.log('✓ Optimized: shims point to native binary (zero overhead)');
|
||||
} catch (err) {
|
||||
console.log(`⚠ Could not optimize shims: ${err.message}`);
|
||||
console.log(' CLI will work via Node.js wrapper (slightly slower startup)');
|
||||
}
|
||||
console.log('╔═══════════════════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ To download browser binaries, run: ║');
|
||||
console.log('║ ║');
|
||||
console.log('║ npx playwright install chromium ║');
|
||||
console.log('║ ║');
|
||||
console.log('║ On Linux, include system dependencies with: ║');
|
||||
console.log('║ ║');
|
||||
console.log('║ npx playwright install --with-deps chromium ║');
|
||||
console.log('║ ║');
|
||||
console.log('╚═══════════════════════════════════════════════════════════════════════════╝');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
|
||||
+2
-46
@@ -5,14 +5,12 @@
|
||||
* Run this script before building or releasing.
|
||||
*/
|
||||
|
||||
import { execSync } from "child_process";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
const cliDir = join(rootDir, "cli");
|
||||
|
||||
// Read version from package.json (single source of truth)
|
||||
const packageJson = JSON.parse(
|
||||
@@ -20,40 +18,20 @@ const packageJson = JSON.parse(
|
||||
);
|
||||
const version = packageJson.version;
|
||||
|
||||
function parseForkVersion(raw) {
|
||||
const match = raw.match(/^([0-9]+\.[0-9]+\.[0-9]+)-fork\.([A-Za-z0-9.-]+)$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
upstream: match[1],
|
||||
fork: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
const forkVersion = parseForkVersion(version);
|
||||
if (forkVersion) {
|
||||
console.log(
|
||||
`Syncing version ${version} (upstream=${forkVersion.upstream}, fork=${forkVersion.fork}) to all config files...`
|
||||
);
|
||||
} else {
|
||||
console.log(`Syncing version ${version} to all config files...`);
|
||||
}
|
||||
console.log(`Syncing version ${version} to all config files...`);
|
||||
|
||||
// Update Cargo.toml
|
||||
const cargoTomlPath = join(cliDir, "Cargo.toml");
|
||||
const cargoTomlPath = join(rootDir, "cli/Cargo.toml");
|
||||
let cargoToml = readFileSync(cargoTomlPath, "utf-8");
|
||||
const cargoVersionRegex = /^version\s*=\s*"[^"]*"/m;
|
||||
const newCargoVersion = `version = "${version}"`;
|
||||
const cargoNameMatch = cargoToml.match(/^name\s*=\s*"([^"]+)"/m);
|
||||
const cargoPackageName = cargoNameMatch?.[1] ?? "agent-browser-stealth";
|
||||
|
||||
let cargoTomlUpdated = false;
|
||||
if (cargoVersionRegex.test(cargoToml)) {
|
||||
const oldMatch = cargoToml.match(cargoVersionRegex)?.[0];
|
||||
if (oldMatch !== newCargoVersion) {
|
||||
cargoToml = cargoToml.replace(cargoVersionRegex, newCargoVersion);
|
||||
writeFileSync(cargoTomlPath, cargoToml);
|
||||
console.log(` Updated cli/Cargo.toml: ${oldMatch} -> ${newCargoVersion}`);
|
||||
cargoTomlUpdated = true;
|
||||
} else {
|
||||
console.log(` cli/Cargo.toml already up to date`);
|
||||
}
|
||||
@@ -62,26 +40,4 @@ if (cargoVersionRegex.test(cargoToml)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Update Cargo.lock to match Cargo.toml
|
||||
if (cargoTomlUpdated) {
|
||||
try {
|
||||
execSync(`cargo update -p ${cargoPackageName} --offline`, {
|
||||
cwd: cliDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
console.log(` Updated cli/Cargo.lock`);
|
||||
} catch {
|
||||
// --offline may fail if package not in cache, try without it
|
||||
try {
|
||||
execSync(`cargo update -p ${cargoPackageName}`, {
|
||||
cwd: cliDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
console.log(` Updated cli/Cargo.lock`);
|
||||
} catch (e) {
|
||||
console.error(` Warning: Could not update Cargo.lock: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Version sync complete.");
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INSTANCE_FILE="$SCRIPT_DIR/.instance"
|
||||
NAME_PREFIX="agent-browser-debug"
|
||||
INSTANCE_TYPE="${INSTANCE_TYPE:-t3.xlarge}"
|
||||
|
||||
if [[ -f "$INSTANCE_FILE" ]]; then
|
||||
echo "Error: Instance already provisioned. See $INSTANCE_FILE"
|
||||
echo "Run ./scripts/windows-debug/start.sh to start it, or delete .instance to re-provision."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REGION=$(aws configure get region 2>/dev/null || echo "")
|
||||
if [[ -z "$REGION" ]]; then
|
||||
echo "Error: No AWS region configured. Run: aws configure set region us-east-1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Provisioning Windows debug instance in $REGION..."
|
||||
|
||||
# --- IAM Role for SSM ---
|
||||
ROLE_NAME="${IAM_ROLE_NAME:-$NAME_PREFIX-ssm-role}"
|
||||
PROFILE_NAME="${INSTANCE_PROFILE_NAME:-$NAME_PREFIX-instance-profile}"
|
||||
|
||||
if aws iam get-instance-profile --instance-profile-name "$PROFILE_NAME" &>/dev/null; then
|
||||
echo "Instance profile $PROFILE_NAME already exists, reusing."
|
||||
else
|
||||
echo "Instance profile $PROFILE_NAME not found. Creating IAM resources..."
|
||||
|
||||
if ! aws iam get-role --role-name "$ROLE_NAME" &>/dev/null; then
|
||||
echo "Creating IAM role: $ROLE_NAME"
|
||||
if ! aws iam create-role \
|
||||
--role-name "$ROLE_NAME" \
|
||||
--assume-role-policy-document '{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "ec2.amazonaws.com"},
|
||||
"Action": "sts:AssumeRole"
|
||||
}]
|
||||
}' \
|
||||
--no-cli-pager; then
|
||||
|
||||
echo ""
|
||||
echo "Error: Failed to create IAM role (see error above)."
|
||||
echo ""
|
||||
echo "Ask an IAM admin to create the following, then re-run with:"
|
||||
echo " INSTANCE_PROFILE_NAME=<name> ./scripts/windows-debug/provision.sh"
|
||||
echo ""
|
||||
echo "What the admin needs to create:"
|
||||
echo " 1. IAM Role: $ROLE_NAME"
|
||||
echo " - Trusted entity: EC2 (ec2.amazonaws.com)"
|
||||
echo " - Attached policy: AmazonSSMManagedInstanceCore"
|
||||
echo " 2. Instance Profile: $PROFILE_NAME"
|
||||
echo " - With the above role added to it"
|
||||
echo ""
|
||||
echo "Or run these commands with an account that has iam:CreateRole permission:"
|
||||
echo ""
|
||||
echo " aws iam create-role --role-name $ROLE_NAME \\"
|
||||
echo " --assume-role-policy-document '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}'"
|
||||
echo ""
|
||||
echo " aws iam attach-role-policy --role-name $ROLE_NAME \\"
|
||||
echo " --policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
|
||||
echo ""
|
||||
echo " aws iam create-instance-profile --instance-profile-name $PROFILE_NAME"
|
||||
echo ""
|
||||
echo " aws iam add-role-to-instance-profile \\"
|
||||
echo " --instance-profile-name $PROFILE_NAME --role-name $ROLE_NAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
aws iam attach-role-policy \
|
||||
--role-name "$ROLE_NAME" \
|
||||
--policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
|
||||
else
|
||||
echo "IAM role $ROLE_NAME already exists."
|
||||
fi
|
||||
|
||||
echo "Creating instance profile: $PROFILE_NAME"
|
||||
aws iam create-instance-profile --instance-profile-name "$PROFILE_NAME" --no-cli-pager
|
||||
aws iam add-role-to-instance-profile \
|
||||
--instance-profile-name "$PROFILE_NAME" \
|
||||
--role-name "$ROLE_NAME"
|
||||
echo "Waiting for instance profile propagation..."
|
||||
sleep 10
|
||||
fi
|
||||
|
||||
# --- Security Group (no inbound rules) ---
|
||||
VPC_ID=$(aws ec2 describe-vpcs --filters "Name=isDefault,Values=true" --query "Vpcs[0].VpcId" --output text)
|
||||
if [[ "$VPC_ID" == "None" || -z "$VPC_ID" ]]; then
|
||||
echo "Error: No default VPC found. Create one with: aws ec2 create-default-vpc"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SG_NAME="$NAME_PREFIX-sg"
|
||||
SG_ID=$(aws ec2 describe-security-groups \
|
||||
--filters "Name=group-name,Values=$SG_NAME" "Name=vpc-id,Values=$VPC_ID" \
|
||||
--query "SecurityGroups[0].GroupId" --output text 2>/dev/null || echo "None")
|
||||
|
||||
if [[ "$SG_ID" == "None" || -z "$SG_ID" ]]; then
|
||||
echo "Creating security group: $SG_NAME"
|
||||
SG_ID=$(aws ec2 create-security-group \
|
||||
--group-name "$SG_NAME" \
|
||||
--description "agent-browser Windows debug instance (SSM only, no inbound)" \
|
||||
--vpc-id "$VPC_ID" \
|
||||
--query "GroupId" --output text)
|
||||
|
||||
# Revoke default egress isn't needed; SSM requires outbound HTTPS.
|
||||
# No inbound rules -- SSM uses outbound connections only.
|
||||
else
|
||||
echo "Security group $SG_NAME ($SG_ID) already exists, reusing."
|
||||
fi
|
||||
|
||||
# --- AMI (latest Windows Server 2022) ---
|
||||
AMI_ID=$(aws ssm get-parameter \
|
||||
--name "/aws/service/ami-windows-latest/Windows_Server-2022-English-Full-Base" \
|
||||
--query "Parameter.Value" --output text)
|
||||
echo "Using AMI: $AMI_ID (Windows Server 2022)"
|
||||
|
||||
# --- UserData bootstrap script ---
|
||||
USERDATA_FILE=$(mktemp)
|
||||
trap "rm -f $USERDATA_FILE" EXIT
|
||||
|
||||
cat > "$USERDATA_FILE" <<'PWSH'
|
||||
<powershell>
|
||||
$ErrorActionPreference = "Continue"
|
||||
$logFile = "C:\bootstrap.log"
|
||||
|
||||
function Log($msg) {
|
||||
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
"$ts $msg" | Tee-Object -FilePath $logFile -Append
|
||||
}
|
||||
|
||||
Log "--- Bootstrap starting ---"
|
||||
|
||||
# Install Git
|
||||
Log "Installing Git..."
|
||||
$gitInstaller = "$env:TEMP\git-installer.exe"
|
||||
Invoke-WebRequest -Uri "https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.2/Git-2.47.1.2-64-bit.exe" -OutFile $gitInstaller
|
||||
Start-Process -FilePath $gitInstaller -ArgumentList "/VERYSILENT /NORESTART /NOCANCEL /SP- /CLOSEAPPLICATIONS /RESTARTAPPLICATIONS /COMPONENTS=`"icons,ext\reg\shellhere,assoc,assoc_sh`"" -Wait
|
||||
$env:PATH = "C:\Program Files\Git\cmd;$env:PATH"
|
||||
[Environment]::SetEnvironmentVariable("PATH", "C:\Program Files\Git\cmd;$([Environment]::GetEnvironmentVariable('PATH', 'Machine'))", "Machine")
|
||||
Log "Git installed: $(git --version)"
|
||||
|
||||
# Install Rust
|
||||
Log "Installing Rust..."
|
||||
$rustupInit = "$env:TEMP\rustup-init.exe"
|
||||
Invoke-WebRequest -Uri "https://win.rustup.rs/x86_64" -OutFile $rustupInit
|
||||
Start-Process -FilePath $rustupInit -ArgumentList "-y --default-toolchain stable" -Wait
|
||||
$env:PATH = "$env:USERPROFILE\.cargo\bin;$env:PATH"
|
||||
[Environment]::SetEnvironmentVariable("PATH", "$env:USERPROFILE\.cargo\bin;$([Environment]::GetEnvironmentVariable('PATH', 'Machine'))", "Machine")
|
||||
Log "Rust installed: $(rustc --version)"
|
||||
|
||||
# Install MSVC build tools (required for Rust on Windows)
|
||||
Log "Installing Visual Studio Build Tools..."
|
||||
$vsInstaller = "$env:TEMP\vs_buildtools.exe"
|
||||
Invoke-WebRequest -Uri "https://aka.ms/vs/17/release/vs_buildtools.exe" -OutFile $vsInstaller
|
||||
Start-Process -FilePath $vsInstaller -ArgumentList "--quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended" -Wait
|
||||
Log "Build tools installed."
|
||||
|
||||
# Clone repo
|
||||
Log "Cloning agent-browser..."
|
||||
git clone https://github.com/vercel-labs/agent-browser.git C:\agent-browser
|
||||
Set-Location C:\agent-browser
|
||||
Log "Repo cloned."
|
||||
|
||||
# Build CLI
|
||||
Log "Building agent-browser CLI..."
|
||||
cargo build --release --manifest-path cli\Cargo.toml
|
||||
Log "Build complete."
|
||||
|
||||
# Install Chrome
|
||||
Log "Installing Chrome via agent-browser..."
|
||||
.\cli\target\release\agent-browser.exe install
|
||||
Log "Chrome installed."
|
||||
|
||||
Log "--- Bootstrap complete ---"
|
||||
</powershell>
|
||||
PWSH
|
||||
|
||||
# --- Launch instance ---
|
||||
echo "Launching $INSTANCE_TYPE instance..."
|
||||
INSTANCE_ID=$(aws ec2 run-instances \
|
||||
--image-id "$AMI_ID" \
|
||||
--instance-type "$INSTANCE_TYPE" \
|
||||
--iam-instance-profile "Name=$PROFILE_NAME" \
|
||||
--security-group-ids "$SG_ID" \
|
||||
--user-data "file://$USERDATA_FILE" \
|
||||
--block-device-mappings '[{"DeviceName":"/dev/sda1","Ebs":{"VolumeSize":80,"VolumeType":"gp3"}}]' \
|
||||
--tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=$NAME_PREFIX}]" \
|
||||
--metadata-options "HttpTokens=required" \
|
||||
--query "Instances[0].InstanceId" --output text)
|
||||
|
||||
echo "Instance launched: $INSTANCE_ID"
|
||||
|
||||
# Save instance config
|
||||
cat > "$INSTANCE_FILE" <<EOF
|
||||
INSTANCE_ID=$INSTANCE_ID
|
||||
REGION=$REGION
|
||||
EOF
|
||||
|
||||
echo "Waiting for instance to enter running state..."
|
||||
aws ec2 wait instance-running --instance-ids "$INSTANCE_ID"
|
||||
echo "Instance is running."
|
||||
|
||||
echo ""
|
||||
echo "Instance $INSTANCE_ID is booting and bootstrapping (Rust, Git, Chrome)."
|
||||
echo "Bootstrap takes ~15-20 minutes on first boot."
|
||||
echo ""
|
||||
echo "Check bootstrap progress:"
|
||||
echo " ./scripts/windows-debug/run.sh \"Get-Content C:\\bootstrap.log\""
|
||||
echo ""
|
||||
echo "Once ready, sync your branch and start debugging:"
|
||||
echo " ./scripts/windows-debug/sync.sh"
|
||||
echo " ./scripts/windows-debug/run.sh \"cd C:\\agent-browser && cargo test\""
|
||||
echo ""
|
||||
echo "Stop when done to save costs:"
|
||||
echo " ./scripts/windows-debug/stop.sh"
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INSTANCE_FILE="$SCRIPT_DIR/.instance"
|
||||
|
||||
if [[ ! -f "$INSTANCE_FILE" ]]; then
|
||||
echo "Error: No instance provisioned. Run ./scripts/windows-debug/provision.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "Usage: ./scripts/windows-debug/run.sh \"<powershell-command>\""
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " ./scripts/windows-debug/run.sh \"cd C:\\agent-browser && cargo test\""
|
||||
echo " ./scripts/windows-debug/run.sh \"Get-Content C:\\bootstrap.log\""
|
||||
echo " ./scripts/windows-debug/run.sh \"cd C:\\agent-browser && cargo test e2e -- --ignored --test-threads=1\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$INSTANCE_FILE"
|
||||
export AWS_DEFAULT_REGION="$REGION"
|
||||
|
||||
COMMAND="$*"
|
||||
|
||||
PARAMS_FILE=$(mktemp)
|
||||
trap "rm -f $PARAMS_FILE" EXIT
|
||||
|
||||
python3 -c '
|
||||
import json, sys
|
||||
path_setup = "$env:PATH = \"$env:USERPROFILE\\.cargo\\bin;C:\\Program Files\\Git\\cmd;$env:PATH\""
|
||||
cmd = path_setup + "\n" + sys.argv[1]
|
||||
json.dump({"commands": [cmd]}, open(sys.argv[2], "w"))
|
||||
' "$COMMAND" "$PARAMS_FILE"
|
||||
|
||||
COMMAND_ID=$(aws ssm send-command \
|
||||
--instance-ids "$INSTANCE_ID" \
|
||||
--document-name "AWS-RunPowerShellScript" \
|
||||
--parameters "file://$PARAMS_FILE" \
|
||||
--timeout-seconds 3600 \
|
||||
--query "Command.CommandId" --output text)
|
||||
|
||||
echo "Command sent (ID: $COMMAND_ID). Waiting..." >&2
|
||||
|
||||
while true; do
|
||||
RESULT=$(aws ssm get-command-invocation \
|
||||
--command-id "$COMMAND_ID" \
|
||||
--instance-id "$INSTANCE_ID" \
|
||||
--output json 2>&1) || true
|
||||
|
||||
STATUS=$(echo "$RESULT" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
print(json.loads(sys.stdin.read()).get('Status', 'Unknown'))
|
||||
except:
|
||||
print('Pending')
|
||||
" 2>/dev/null)
|
||||
|
||||
case "$STATUS" in
|
||||
Success)
|
||||
echo "$RESULT" | python3 -c "
|
||||
import sys, json
|
||||
r = json.loads(sys.stdin.read())
|
||||
out = r.get('StandardOutputContent', '').rstrip()
|
||||
err = r.get('StandardErrorContent', '').rstrip()
|
||||
if out:
|
||||
print(out)
|
||||
if err:
|
||||
print(err, file=sys.stderr)
|
||||
"
|
||||
exit 0
|
||||
;;
|
||||
Failed|TimedOut|Cancelled)
|
||||
echo "$RESULT" | python3 -c "
|
||||
import sys, json
|
||||
r = json.loads(sys.stdin.read())
|
||||
out = r.get('StandardOutputContent', '').rstrip()
|
||||
err = r.get('StandardErrorContent', '').rstrip()
|
||||
if out:
|
||||
print(out)
|
||||
if err:
|
||||
print(err, file=sys.stderr)
|
||||
"
|
||||
echo "Command $STATUS." >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
sleep 3
|
||||
;;
|
||||
esac
|
||||
done
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INSTANCE_FILE="$SCRIPT_DIR/.instance"
|
||||
|
||||
if [[ ! -f "$INSTANCE_FILE" ]]; then
|
||||
echo "Error: No instance provisioned. Run ./scripts/windows-debug/provision.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$INSTANCE_FILE"
|
||||
export AWS_DEFAULT_REGION="$REGION"
|
||||
|
||||
STATE=$(aws ec2 describe-instances \
|
||||
--instance-ids "$INSTANCE_ID" \
|
||||
--query "Reservations[0].Instances[0].State.Name" --output text)
|
||||
|
||||
if [[ "$STATE" == "running" ]]; then
|
||||
echo "Instance $INSTANCE_ID is already running."
|
||||
else
|
||||
echo "Starting instance $INSTANCE_ID..."
|
||||
aws ec2 start-instances --instance-ids "$INSTANCE_ID" --no-cli-pager
|
||||
echo "Waiting for running state..."
|
||||
aws ec2 wait instance-running --instance-ids "$INSTANCE_ID"
|
||||
echo "Instance is running."
|
||||
fi
|
||||
|
||||
echo "Waiting for SSM agent connectivity..."
|
||||
for i in $(seq 1 30); do
|
||||
SSM_STATUS=$(aws ssm describe-instance-information \
|
||||
--filters "Key=InstanceIds,Values=$INSTANCE_ID" \
|
||||
--query "InstanceInformationList[0].PingStatus" --output text 2>/dev/null || echo "None")
|
||||
if [[ "$SSM_STATUS" == "Online" ]]; then
|
||||
echo "SSM agent is online. Ready for commands."
|
||||
echo " ./scripts/windows-debug/run.sh \"your-command-here\""
|
||||
exit 0
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "Warning: SSM agent not online after 5 minutes. The instance may still be booting."
|
||||
echo "Try again in a minute: ./scripts/windows-debug/run.sh \"hostname\""
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INSTANCE_FILE="$SCRIPT_DIR/.instance"
|
||||
|
||||
if [[ ! -f "$INSTANCE_FILE" ]]; then
|
||||
echo "Error: No instance provisioned. Nothing to stop."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$INSTANCE_FILE"
|
||||
export AWS_DEFAULT_REGION="$REGION"
|
||||
|
||||
STATE=$(aws ec2 describe-instances \
|
||||
--instance-ids "$INSTANCE_ID" \
|
||||
--query "Reservations[0].Instances[0].State.Name" --output text)
|
||||
|
||||
if [[ "$STATE" == "stopped" ]]; then
|
||||
echo "Instance $INSTANCE_ID is already stopped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Stopping instance $INSTANCE_ID..."
|
||||
aws ec2 stop-instances --instance-ids "$INSTANCE_ID" --no-cli-pager
|
||||
echo "Waiting for stopped state..."
|
||||
aws ec2 wait instance-stopped --instance-ids "$INSTANCE_ID"
|
||||
echo "Instance stopped. No compute charges while stopped (storage only: ~$0.64/mo)."
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
RUN="$SCRIPT_DIR/run.sh"
|
||||
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "main")
|
||||
REMOTE_URL=$(git remote get-url origin 2>/dev/null || echo "https://github.com/vercel-labs/agent-browser.git")
|
||||
|
||||
echo "Syncing branch '$BRANCH' on Windows instance..."
|
||||
|
||||
"$RUN" "
|
||||
cd C:\agent-browser
|
||||
git remote set-url origin '$REMOTE_URL'
|
||||
git fetch origin
|
||||
git checkout -B '$BRANCH' 'origin/$BRANCH'
|
||||
git log -1 --oneline
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "Branch synced. Rebuilding..."
|
||||
|
||||
"$RUN" "
|
||||
cd C:\agent-browser
|
||||
cargo build --release --manifest-path cli\Cargo.toml
|
||||
Write-Host 'Build complete.'
|
||||
"
|
||||
+97
-723
@@ -1,26 +1,98 @@
|
||||
---
|
||||
name: agent-browser
|
||||
description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction.
|
||||
allowed-tools: Bash(npx agent-browser:*), Bash(agent-browser:*)
|
||||
description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages.
|
||||
---
|
||||
|
||||
# Browser Automation with agent-browser
|
||||
|
||||
The CLI uses Chrome/Chromium via CDP directly. Install via `npm i -g agent-browser`, `brew install agent-browser`, or `cargo install agent-browser`. Run `agent-browser install` to download Chrome. Existing Chrome, Brave, Playwright, and Puppeteer installations are detected automatically. Run `agent-browser upgrade` to update to the latest version.
|
||||
## Quick start
|
||||
|
||||
## Core Workflow
|
||||
```bash
|
||||
agent-browser open <url> # Navigate to page
|
||||
agent-browser snapshot -i # Get interactive elements with refs
|
||||
agent-browser click @e1 # Click element by ref
|
||||
agent-browser fill @e2 "text" # Fill input by ref
|
||||
agent-browser close # Close browser
|
||||
```
|
||||
|
||||
Every browser automation follows this pattern:
|
||||
## Core workflow
|
||||
|
||||
1. **Navigate**: `agent-browser open <url>`
|
||||
2. **Snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)
|
||||
3. **Interact**: Use refs to click, fill, select
|
||||
4. **Re-snapshot**: After navigation or DOM changes, get fresh refs
|
||||
1. Navigate: `agent-browser open <url>`
|
||||
2. Snapshot: `agent-browser snapshot -i` (returns elements with refs like `@e1`, `@e2`)
|
||||
3. Interact using refs from the snapshot
|
||||
4. Re-snapshot after navigation or significant DOM changes
|
||||
|
||||
## Commands
|
||||
|
||||
### Navigation
|
||||
```bash
|
||||
agent-browser open <url> # Navigate to URL
|
||||
agent-browser back # Go back
|
||||
agent-browser forward # Go forward
|
||||
agent-browser reload # Reload page
|
||||
agent-browser close # Close browser
|
||||
```
|
||||
|
||||
### Snapshot (page analysis)
|
||||
```bash
|
||||
agent-browser snapshot # Full accessibility tree
|
||||
agent-browser snapshot -i # Interactive elements only (recommended)
|
||||
agent-browser snapshot -c # Compact output
|
||||
agent-browser snapshot -d 3 # Limit depth to 3
|
||||
```
|
||||
|
||||
### Interactions (use @refs from snapshot)
|
||||
```bash
|
||||
agent-browser click @e1 # Click
|
||||
agent-browser dblclick @e1 # Double-click
|
||||
agent-browser fill @e2 "text" # Clear and type
|
||||
agent-browser type @e2 "text" # Type without clearing
|
||||
agent-browser press Enter # Press key
|
||||
agent-browser press Control+a # Key combination
|
||||
agent-browser hover @e1 # Hover
|
||||
agent-browser check @e1 # Check checkbox
|
||||
agent-browser uncheck @e1 # Uncheck checkbox
|
||||
agent-browser select @e1 "value" # Select dropdown
|
||||
agent-browser scroll down 500 # Scroll page
|
||||
agent-browser scrollintoview @e1 # Scroll element into view
|
||||
```
|
||||
|
||||
### Get information
|
||||
```bash
|
||||
agent-browser get text @e1 # Get element text
|
||||
agent-browser get value @e1 # Get input value
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get url # Get current URL
|
||||
```
|
||||
|
||||
### Screenshots
|
||||
```bash
|
||||
agent-browser screenshot # Screenshot to stdout
|
||||
agent-browser screenshot path.png # Save to file
|
||||
agent-browser screenshot --full # Full page
|
||||
```
|
||||
|
||||
### Wait
|
||||
```bash
|
||||
agent-browser wait @e1 # Wait for element
|
||||
agent-browser wait 2000 # Wait milliseconds
|
||||
agent-browser wait --text "Success" # Wait for text
|
||||
agent-browser wait --load networkidle # Wait for network idle
|
||||
```
|
||||
|
||||
### Semantic locators (alternative to refs)
|
||||
```bash
|
||||
agent-browser find role button click --name "Submit"
|
||||
agent-browser find text "Sign In" click
|
||||
agent-browser find label "Email" fill "user@test.com"
|
||||
```
|
||||
|
||||
## Example: Form submission
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com/form
|
||||
agent-browser snapshot -i
|
||||
# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Submit"
|
||||
# Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3]
|
||||
|
||||
agent-browser fill @e1 "user@example.com"
|
||||
agent-browser fill @e2 "password123"
|
||||
@@ -29,741 +101,43 @@ agent-browser wait --load networkidle
|
||||
agent-browser snapshot -i # Check result
|
||||
```
|
||||
|
||||
## Command Chaining
|
||||
|
||||
Commands can be chained with `&&` in a single shell invocation. The browser persists between commands via a background daemon, so chaining is safe and more efficient than separate calls.
|
||||
## Example: Authentication with saved state
|
||||
|
||||
```bash
|
||||
# Chain open + wait + snapshot in one call
|
||||
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
|
||||
|
||||
# Chain multiple interactions
|
||||
agent-browser fill @e1 "user@example.com" && agent-browser fill @e2 "password123" && agent-browser click @e3
|
||||
|
||||
# Navigate and capture
|
||||
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png
|
||||
```
|
||||
|
||||
**When to chain:** Use `&&` when you don't need to read the output of an intermediate command before proceeding (e.g., open + wait + screenshot). Run commands separately when you need to parse the output first (e.g., snapshot to discover refs, then interact using those refs).
|
||||
|
||||
## Handling Authentication
|
||||
|
||||
When automating a site that requires login, choose the approach that fits:
|
||||
|
||||
**Option 1: Import auth from the user's browser (fastest for one-off tasks)**
|
||||
|
||||
```bash
|
||||
# Connect to the user's running Chrome (they're already logged in)
|
||||
agent-browser --auto-connect state save ./auth.json
|
||||
# Use that auth state
|
||||
agent-browser --state ./auth.json open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
State files contain session tokens in plaintext -- add to `.gitignore` and delete when no longer needed. Set `AGENT_BROWSER_ENCRYPTION_KEY` for encryption at rest.
|
||||
|
||||
**Option 2: Persistent profile (simplest for recurring tasks)**
|
||||
|
||||
```bash
|
||||
# First run: login manually or via automation
|
||||
agent-browser --profile ~/.myapp open https://app.example.com/login
|
||||
# ... fill credentials, submit ...
|
||||
|
||||
# All future runs: already authenticated
|
||||
agent-browser --profile ~/.myapp open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
**Option 3: Session name (auto-save/restore cookies + localStorage)**
|
||||
|
||||
```bash
|
||||
agent-browser --session-name myapp open https://app.example.com/login
|
||||
# ... login flow ...
|
||||
agent-browser close # State auto-saved
|
||||
|
||||
# Next time: state auto-restored
|
||||
agent-browser --session-name myapp open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
**Option 4: Auth vault (credentials stored encrypted, login by name)**
|
||||
|
||||
```bash
|
||||
echo "$PASSWORD" | agent-browser auth save myapp --url https://app.example.com/login --username user --password-stdin
|
||||
agent-browser auth login myapp
|
||||
```
|
||||
|
||||
`auth login` navigates with `load` and then waits for login form selectors to appear before filling/clicking, which is more reliable on delayed SPA login screens.
|
||||
|
||||
**Option 5: State file (manual save/load)**
|
||||
|
||||
```bash
|
||||
# After logging in:
|
||||
agent-browser state save ./auth.json
|
||||
# In a future session:
|
||||
agent-browser state load ./auth.json
|
||||
agent-browser open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
See [references/authentication.md](references/authentication.md) for OAuth, 2FA, cookie-based auth, and token refresh patterns.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
# Navigation
|
||||
agent-browser open <url> # Navigate (aliases: goto, navigate)
|
||||
agent-browser close # Close browser
|
||||
agent-browser close --all # Close all active sessions
|
||||
|
||||
# Snapshot
|
||||
agent-browser snapshot -i # Interactive elements with refs (recommended)
|
||||
agent-browser snapshot -s "#selector" # Scope to CSS selector
|
||||
|
||||
# Interaction (use @refs from snapshot)
|
||||
agent-browser click @e1 # Click element
|
||||
agent-browser click @e1 --new-tab # Click and open in new tab
|
||||
agent-browser fill @e2 "text" # Clear and type text
|
||||
agent-browser type @e2 "text" # Type without clearing
|
||||
agent-browser select @e1 "option" # Select dropdown option
|
||||
agent-browser check @e1 # Check checkbox
|
||||
agent-browser press Enter # Press key
|
||||
agent-browser keyboard type "text" # Type at current focus (no selector)
|
||||
agent-browser keyboard inserttext "text" # Insert without key events
|
||||
agent-browser scroll down 500 # Scroll page
|
||||
agent-browser scroll down 500 --selector "div.content" # Scroll within a specific container
|
||||
|
||||
# Get information
|
||||
agent-browser get text @e1 # Get element text
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get cdp-url # Get CDP WebSocket URL
|
||||
|
||||
# Wait
|
||||
agent-browser wait @e1 # Wait for element
|
||||
agent-browser wait --load networkidle # Wait for network idle
|
||||
agent-browser wait --url "**/page" # Wait for URL pattern
|
||||
agent-browser wait 2000 # Wait milliseconds
|
||||
agent-browser wait --text "Welcome" # Wait for text to appear (substring match)
|
||||
agent-browser wait --fn "!document.body.innerText.includes('Loading...')" # Wait for text to disappear
|
||||
agent-browser wait "#spinner" --state hidden # Wait for element to disappear
|
||||
|
||||
# Downloads
|
||||
agent-browser download @e1 ./file.pdf # Click element to trigger download
|
||||
agent-browser wait --download ./output.zip # Wait for any download to complete
|
||||
agent-browser --download-path ./downloads open <url> # Set default download directory
|
||||
|
||||
# Network
|
||||
agent-browser network requests # Inspect tracked requests
|
||||
agent-browser network requests --type xhr,fetch # Filter by resource type
|
||||
agent-browser network requests --method POST # Filter by HTTP method
|
||||
agent-browser network requests --status 2xx # Filter by status (200, 2xx, 400-499)
|
||||
agent-browser network request <requestId> # View full request/response detail
|
||||
agent-browser network route "**/api/*" --abort # Block matching requests
|
||||
agent-browser network har start # Start HAR recording
|
||||
agent-browser network har stop ./capture.har # Stop and save HAR file
|
||||
|
||||
# Viewport & Device Emulation
|
||||
agent-browser set viewport 1920 1080 # Set viewport size (default: 1280x720)
|
||||
agent-browser set viewport 1920 1080 2 # 2x retina (same CSS size, higher res screenshots)
|
||||
agent-browser set device "iPhone 14" # Emulate device (viewport + user agent)
|
||||
|
||||
# Capture
|
||||
agent-browser screenshot # Screenshot to temp dir
|
||||
agent-browser screenshot --full # Full page screenshot
|
||||
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
|
||||
agent-browser screenshot --screenshot-dir ./shots # Save to custom directory
|
||||
agent-browser screenshot --screenshot-format jpeg --screenshot-quality 80
|
||||
agent-browser pdf output.pdf # Save as PDF
|
||||
|
||||
# Live preview / streaming
|
||||
agent-browser stream enable # Start runtime WebSocket streaming on an auto-selected port
|
||||
agent-browser stream enable --port 9223 # Bind a specific localhost port
|
||||
agent-browser stream status # Inspect enabled state, port, connection, and screencasting
|
||||
agent-browser stream disable # Stop runtime streaming and remove the .stream metadata file
|
||||
|
||||
# Clipboard
|
||||
agent-browser clipboard read # Read text from clipboard
|
||||
agent-browser clipboard write "Hello, World!" # Write text to clipboard
|
||||
agent-browser clipboard copy # Copy current selection
|
||||
agent-browser clipboard paste # Paste from clipboard
|
||||
|
||||
# Dialogs (alert, confirm, prompt, beforeunload)
|
||||
# By default, alert and beforeunload dialogs are auto-accepted so they never block the agent.
|
||||
# confirm and prompt dialogs still require explicit handling.
|
||||
# Use --no-auto-dialog (or AGENT_BROWSER_NO_AUTO_DIALOG=1) to disable automatic handling.
|
||||
agent-browser dialog accept # Accept dialog
|
||||
agent-browser dialog accept "my input" # Accept prompt dialog with text
|
||||
agent-browser dialog dismiss # Dismiss/cancel dialog
|
||||
agent-browser dialog status # Check if a dialog is currently open
|
||||
|
||||
# Diff (compare page states)
|
||||
agent-browser diff snapshot # Compare current vs last snapshot
|
||||
agent-browser diff snapshot --baseline before.txt # Compare current vs saved file
|
||||
agent-browser diff screenshot --baseline before.png # Visual pixel diff
|
||||
agent-browser diff url <url1> <url2> # Compare two pages
|
||||
agent-browser diff url <url1> <url2> --wait-until networkidle # Custom wait strategy
|
||||
agent-browser diff url <url1> <url2> --selector "#main" # Scope to element
|
||||
```
|
||||
|
||||
## Streaming
|
||||
|
||||
Every session automatically starts a WebSocket stream server on an OS-assigned port. Use `agent-browser stream status` to see the bound port and connection state. Use `stream disable` to tear it down, and `stream enable --port <port>` to re-enable on a specific port.
|
||||
|
||||
## Batch Execution
|
||||
|
||||
Execute multiple commands in a single invocation by piping a JSON array of string arrays to `batch`. This avoids per-command process startup overhead when running multi-step workflows.
|
||||
|
||||
```bash
|
||||
echo '[
|
||||
["open", "https://example.com"],
|
||||
["snapshot", "-i"],
|
||||
["click", "@e1"],
|
||||
["screenshot", "result.png"]
|
||||
]' | agent-browser batch --json
|
||||
|
||||
# Stop on first error
|
||||
agent-browser batch --bail < commands.json
|
||||
```
|
||||
|
||||
Use `batch` when you have a known sequence of commands that don't depend on intermediate output. Use separate commands or `&&` chaining when you need to parse output between steps (e.g., snapshot to discover refs, then interact).
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Form Submission
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com/signup
|
||||
agent-browser snapshot -i
|
||||
agent-browser fill @e1 "Jane Doe"
|
||||
agent-browser fill @e2 "jane@example.com"
|
||||
agent-browser select @e3 "California"
|
||||
agent-browser check @e4
|
||||
agent-browser click @e5
|
||||
agent-browser wait --load networkidle
|
||||
```
|
||||
|
||||
### Authentication with Auth Vault (Recommended)
|
||||
|
||||
```bash
|
||||
# Save credentials once (encrypted with AGENT_BROWSER_ENCRYPTION_KEY)
|
||||
# Recommended: pipe password via stdin to avoid shell history exposure
|
||||
echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin
|
||||
|
||||
# Login using saved profile (LLM never sees password)
|
||||
agent-browser auth login github
|
||||
|
||||
# List/show/delete profiles
|
||||
agent-browser auth list
|
||||
agent-browser auth show github
|
||||
agent-browser auth delete github
|
||||
```
|
||||
|
||||
`auth login` waits for username/password/submit selectors before interacting, with a timeout tied to the default action timeout.
|
||||
|
||||
### Authentication with State Persistence
|
||||
|
||||
```bash
|
||||
# Login once and save state
|
||||
# Login once
|
||||
agent-browser open https://app.example.com/login
|
||||
agent-browser snapshot -i
|
||||
agent-browser fill @e1 "$USERNAME"
|
||||
agent-browser fill @e2 "$PASSWORD"
|
||||
agent-browser fill @e1 "username"
|
||||
agent-browser fill @e2 "password"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --url "**/dashboard"
|
||||
agent-browser state save auth.json
|
||||
|
||||
# Reuse in future sessions
|
||||
# Later sessions: load saved state
|
||||
agent-browser state load auth.json
|
||||
agent-browser open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
### Session Persistence
|
||||
## Sessions (parallel browsers)
|
||||
|
||||
```bash
|
||||
# Auto-save/restore cookies and localStorage across browser restarts
|
||||
agent-browser --session-name myapp open https://app.example.com/login
|
||||
# ... login flow ...
|
||||
agent-browser close # State auto-saved to ~/.agent-browser/sessions/
|
||||
|
||||
# Next time, state is auto-loaded
|
||||
agent-browser --session-name myapp open https://app.example.com/dashboard
|
||||
|
||||
# Encrypt state at rest
|
||||
export AGENT_BROWSER_ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
agent-browser --session-name secure open https://app.example.com
|
||||
|
||||
# Manage saved states
|
||||
agent-browser state list
|
||||
agent-browser state show myapp-default.json
|
||||
agent-browser state clear myapp
|
||||
agent-browser state clean --older-than 7
|
||||
agent-browser --session test1 open site-a.com
|
||||
agent-browser --session test2 open site-b.com
|
||||
agent-browser session list
|
||||
```
|
||||
|
||||
### Working with Iframes
|
||||
|
||||
Iframe content is automatically inlined in snapshots. Refs inside iframes carry frame context, so you can interact with them directly.
|
||||
## JSON output (for parsing)
|
||||
|
||||
Add `--json` for machine-readable output:
|
||||
```bash
|
||||
agent-browser open https://example.com/checkout
|
||||
agent-browser snapshot -i
|
||||
# @e1 [heading] "Checkout"
|
||||
# @e2 [Iframe] "payment-frame"
|
||||
# @e3 [input] "Card number"
|
||||
# @e4 [input] "Expiry"
|
||||
# @e5 [button] "Pay"
|
||||
|
||||
# Interact directly — no frame switch needed
|
||||
agent-browser fill @e3 "4111111111111111"
|
||||
agent-browser fill @e4 "12/28"
|
||||
agent-browser click @e5
|
||||
|
||||
# To scope a snapshot to one iframe:
|
||||
agent-browser frame @e2
|
||||
agent-browser snapshot -i # Only iframe content
|
||||
agent-browser frame main # Return to main frame
|
||||
```
|
||||
|
||||
### Data Extraction
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com/products
|
||||
agent-browser snapshot -i
|
||||
agent-browser get text @e5 # Get specific element text
|
||||
agent-browser get text body > page.txt # Get all page text
|
||||
|
||||
# JSON output for parsing
|
||||
agent-browser snapshot -i --json
|
||||
agent-browser get text @e1 --json
|
||||
```
|
||||
|
||||
### Parallel Sessions
|
||||
## Debugging
|
||||
|
||||
```bash
|
||||
agent-browser --session site1 open https://site-a.com
|
||||
agent-browser --session site2 open https://site-b.com
|
||||
|
||||
agent-browser --session site1 snapshot -i
|
||||
agent-browser --session site2 snapshot -i
|
||||
|
||||
agent-browser session list
|
||||
```
|
||||
|
||||
### Connect to Existing Chrome
|
||||
|
||||
```bash
|
||||
# Auto-discover running Chrome with remote debugging enabled
|
||||
agent-browser --auto-connect open https://example.com
|
||||
agent-browser --auto-connect snapshot
|
||||
|
||||
# Or with explicit CDP port
|
||||
agent-browser --cdp 9222 snapshot
|
||||
```
|
||||
|
||||
Auto-connect discovers Chrome via `DevToolsActivePort`, common debugging ports (9222, 9229), and falls back to a direct WebSocket connection if HTTP-based CDP discovery fails.
|
||||
|
||||
### Color Scheme (Dark Mode)
|
||||
|
||||
```bash
|
||||
# Persistent dark mode via flag (applies to all pages and new tabs)
|
||||
agent-browser --color-scheme dark open https://example.com
|
||||
|
||||
# Or via environment variable
|
||||
AGENT_BROWSER_COLOR_SCHEME=dark agent-browser open https://example.com
|
||||
|
||||
# Or set during session (persists for subsequent commands)
|
||||
agent-browser set media dark
|
||||
```
|
||||
|
||||
### Viewport & Responsive Testing
|
||||
|
||||
```bash
|
||||
# Set a custom viewport size (default is 1280x720)
|
||||
agent-browser set viewport 1920 1080
|
||||
agent-browser screenshot desktop.png
|
||||
|
||||
# Test mobile-width layout
|
||||
agent-browser set viewport 375 812
|
||||
agent-browser screenshot mobile.png
|
||||
|
||||
# Retina/HiDPI: same CSS layout at 2x pixel density
|
||||
# Screenshots stay at logical viewport size, but content renders at higher DPI
|
||||
agent-browser set viewport 1920 1080 2
|
||||
agent-browser screenshot retina.png
|
||||
|
||||
# Device emulation (sets viewport + user agent in one step)
|
||||
agent-browser set device "iPhone 14"
|
||||
agent-browser screenshot device.png
|
||||
```
|
||||
|
||||
The `scale` parameter (3rd argument) sets `window.devicePixelRatio` without changing CSS layout. Use it when testing retina rendering or capturing higher-resolution screenshots.
|
||||
|
||||
### Visual Browser (Debugging)
|
||||
|
||||
```bash
|
||||
agent-browser --headed open https://example.com
|
||||
agent-browser highlight @e1 # Highlight element
|
||||
agent-browser inspect # Open Chrome DevTools for the active page
|
||||
agent-browser record start demo.webm # Record session
|
||||
agent-browser profiler start # Start Chrome DevTools profiling
|
||||
agent-browser profiler stop trace.json # Stop and save profile (path optional)
|
||||
```
|
||||
|
||||
Use `AGENT_BROWSER_HEADED=1` to enable headed mode via environment variable. Browser extensions work in both headed and headless mode.
|
||||
|
||||
### Local Files (PDFs, HTML)
|
||||
|
||||
```bash
|
||||
# Open local files with file:// URLs
|
||||
agent-browser --allow-file-access open file:///path/to/document.pdf
|
||||
agent-browser --allow-file-access open file:///path/to/page.html
|
||||
agent-browser screenshot output.png
|
||||
```
|
||||
|
||||
### iOS Simulator (Mobile Safari)
|
||||
|
||||
```bash
|
||||
# List available iOS simulators
|
||||
agent-browser device list
|
||||
|
||||
# Launch Safari on a specific device
|
||||
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
|
||||
|
||||
# Same workflow as desktop - snapshot, interact, re-snapshot
|
||||
agent-browser -p ios snapshot -i
|
||||
agent-browser -p ios tap @e1 # Tap (alias for click)
|
||||
agent-browser -p ios fill @e2 "text"
|
||||
agent-browser -p ios swipe up # Mobile-specific gesture
|
||||
|
||||
# Take screenshot
|
||||
agent-browser -p ios screenshot mobile.png
|
||||
|
||||
# Close session (shuts down simulator)
|
||||
agent-browser -p ios close
|
||||
```
|
||||
|
||||
**Requirements:** macOS with Xcode, Appium (`npm install -g appium && appium driver install xcuitest`)
|
||||
|
||||
**Real devices:** Works with physical iOS devices if pre-configured. Use `--device "<UDID>"` where UDID is from `xcrun xctrace list devices`.
|
||||
|
||||
## Security
|
||||
|
||||
All security features are opt-in. By default, agent-browser imposes no restrictions on navigation, actions, or output.
|
||||
|
||||
### Content Boundaries (Recommended for AI Agents)
|
||||
|
||||
Enable `--content-boundaries` to wrap page-sourced output in markers that help LLMs distinguish tool output from untrusted page content:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_CONTENT_BOUNDARIES=1
|
||||
agent-browser snapshot
|
||||
# Output:
|
||||
# --- AGENT_BROWSER_PAGE_CONTENT nonce=<hex> origin=https://example.com ---
|
||||
# [accessibility tree]
|
||||
# --- END_AGENT_BROWSER_PAGE_CONTENT nonce=<hex> ---
|
||||
```
|
||||
|
||||
### Domain Allowlist
|
||||
|
||||
Restrict navigation to trusted domains. Wildcards like `*.example.com` also match the bare domain `example.com`. Sub-resource requests, WebSocket, and EventSource connections to non-allowed domains are also blocked. Include CDN domains your target pages depend on:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com"
|
||||
agent-browser open https://example.com # OK
|
||||
agent-browser open https://malicious.com # Blocked
|
||||
```
|
||||
|
||||
### Action Policy
|
||||
|
||||
Use a policy file to gate destructive actions:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_ACTION_POLICY=./policy.json
|
||||
```
|
||||
|
||||
Example `policy.json`:
|
||||
|
||||
```json
|
||||
{ "default": "deny", "allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"] }
|
||||
```
|
||||
|
||||
Auth vault operations (`auth login`, etc.) bypass action policy but domain allowlist still applies.
|
||||
|
||||
### Output Limits
|
||||
|
||||
Prevent context flooding from large pages:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_MAX_OUTPUT=50000
|
||||
```
|
||||
|
||||
## Diffing (Verifying Changes)
|
||||
|
||||
Use `diff snapshot` after performing an action to verify it had the intended effect. This compares the current accessibility tree against the last snapshot taken in the session.
|
||||
|
||||
```bash
|
||||
# Typical workflow: snapshot -> action -> diff
|
||||
agent-browser snapshot -i # Take baseline snapshot
|
||||
agent-browser click @e2 # Perform action
|
||||
agent-browser diff snapshot # See what changed (auto-compares to last snapshot)
|
||||
```
|
||||
|
||||
For visual regression testing or monitoring:
|
||||
|
||||
```bash
|
||||
# Save a baseline screenshot, then compare later
|
||||
agent-browser screenshot baseline.png
|
||||
# ... time passes or changes are made ...
|
||||
agent-browser diff screenshot --baseline baseline.png
|
||||
|
||||
# Compare staging vs production
|
||||
agent-browser diff url https://staging.example.com https://prod.example.com --screenshot
|
||||
```
|
||||
|
||||
`diff snapshot` output uses `+` for additions and `-` for removals, similar to git diff. `diff screenshot` produces a diff image with changed pixels highlighted in red, plus a mismatch percentage.
|
||||
|
||||
## Timeouts and Slow Pages
|
||||
|
||||
The default timeout is 25 seconds. This can be overridden with the `AGENT_BROWSER_DEFAULT_TIMEOUT` environment variable (value in milliseconds). For slow websites or large pages, use explicit waits instead of relying on the default timeout:
|
||||
|
||||
```bash
|
||||
# Wait for network activity to settle (best for slow pages)
|
||||
agent-browser wait --load networkidle
|
||||
|
||||
# Wait for a specific element to appear
|
||||
agent-browser wait "#content"
|
||||
agent-browser wait @e1
|
||||
|
||||
# Wait for a specific URL pattern (useful after redirects)
|
||||
agent-browser wait --url "**/dashboard"
|
||||
|
||||
# Wait for a JavaScript condition
|
||||
agent-browser wait --fn "document.readyState === 'complete'"
|
||||
|
||||
# Wait a fixed duration (milliseconds) as a last resort
|
||||
agent-browser wait 5000
|
||||
```
|
||||
|
||||
When dealing with consistently slow websites, use `wait --load networkidle` after `open` to ensure the page is fully loaded before taking a snapshot. If a specific element is slow to render, wait for it directly with `wait <selector>` or `wait @ref`.
|
||||
|
||||
## JavaScript Dialogs (alert / confirm / prompt)
|
||||
|
||||
When a page opens a JavaScript dialog (`alert()`, `confirm()`, or `prompt()`), it blocks all other browser commands (snapshot, screenshot, click, etc.) until the dialog is dismissed. If commands start timing out unexpectedly, check for a pending dialog:
|
||||
|
||||
```bash
|
||||
# Check if a dialog is blocking
|
||||
agent-browser dialog status
|
||||
|
||||
# Accept the dialog (dismiss the alert / click OK)
|
||||
agent-browser dialog accept
|
||||
|
||||
# Accept a prompt dialog with input text
|
||||
agent-browser dialog accept "my input"
|
||||
|
||||
# Dismiss the dialog (click Cancel)
|
||||
agent-browser dialog dismiss
|
||||
```
|
||||
|
||||
When a dialog is pending, all command responses include a `warning` field indicating the dialog type and message. In `--json` mode this appears as a `"warning"` key in the response object.
|
||||
|
||||
## Session Management and Cleanup
|
||||
|
||||
When running multiple agents or automations concurrently, always use named sessions to avoid conflicts:
|
||||
|
||||
```bash
|
||||
# Each agent gets its own isolated session
|
||||
agent-browser --session agent1 open site-a.com
|
||||
agent-browser --session agent2 open site-b.com
|
||||
|
||||
# Check active sessions
|
||||
agent-browser session list
|
||||
```
|
||||
|
||||
Always close your browser session when done to avoid leaked processes:
|
||||
|
||||
```bash
|
||||
agent-browser close # Close default session
|
||||
agent-browser --session agent1 close # Close specific session
|
||||
agent-browser close --all # Close all active sessions
|
||||
```
|
||||
|
||||
If a previous session was not closed properly, the daemon may still be running. Use `agent-browser close` to clean it up, or `agent-browser close --all` to shut down every session at once.
|
||||
|
||||
To auto-shutdown the daemon after a period of inactivity (useful for ephemeral/CI environments):
|
||||
|
||||
```bash
|
||||
AGENT_BROWSER_IDLE_TIMEOUT_MS=60000 agent-browser open example.com
|
||||
```
|
||||
|
||||
## Ref Lifecycle (Important)
|
||||
|
||||
Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after:
|
||||
|
||||
- Clicking links or buttons that navigate
|
||||
- Form submissions
|
||||
- Dynamic content loading (dropdowns, modals)
|
||||
|
||||
```bash
|
||||
agent-browser click @e5 # Navigates to new page
|
||||
agent-browser snapshot -i # MUST re-snapshot
|
||||
agent-browser click @e1 # Use new refs
|
||||
```
|
||||
|
||||
## Annotated Screenshots (Vision Mode)
|
||||
|
||||
Use `--annotate` to take a screenshot with numbered labels overlaid on interactive elements. Each label `[N]` maps to ref `@eN`. This also caches refs, so you can interact with elements immediately without a separate snapshot.
|
||||
|
||||
```bash
|
||||
agent-browser screenshot --annotate
|
||||
# Output includes the image path and a legend:
|
||||
# [1] @e1 button "Submit"
|
||||
# [2] @e2 link "Home"
|
||||
# [3] @e3 textbox "Email"
|
||||
agent-browser click @e2 # Click using ref from annotated screenshot
|
||||
```
|
||||
|
||||
Use annotated screenshots when:
|
||||
|
||||
- The page has unlabeled icon buttons or visual-only elements
|
||||
- You need to verify visual layout or styling
|
||||
- Canvas or chart elements are present (invisible to text snapshots)
|
||||
- You need spatial reasoning about element positions
|
||||
|
||||
## Semantic Locators (Alternative to Refs)
|
||||
|
||||
When refs are unavailable or unreliable, use semantic locators:
|
||||
|
||||
```bash
|
||||
agent-browser find text "Sign In" click
|
||||
agent-browser find label "Email" fill "user@test.com"
|
||||
agent-browser find role button click --name "Submit"
|
||||
agent-browser find placeholder "Search" type "query"
|
||||
agent-browser find testid "submit-btn" click
|
||||
```
|
||||
|
||||
## JavaScript Evaluation (eval)
|
||||
|
||||
Use `eval` to run JavaScript in the browser context. **Shell quoting can corrupt complex expressions** -- use `--stdin` or `-b` to avoid issues.
|
||||
|
||||
```bash
|
||||
# Simple expressions work with regular quoting
|
||||
agent-browser eval 'document.title'
|
||||
agent-browser eval 'document.querySelectorAll("img").length'
|
||||
|
||||
# Complex JS: use --stdin with heredoc (RECOMMENDED)
|
||||
agent-browser eval --stdin <<'EVALEOF'
|
||||
JSON.stringify(
|
||||
Array.from(document.querySelectorAll("img"))
|
||||
.filter(i => !i.alt)
|
||||
.map(i => ({ src: i.src.split("/").pop(), width: i.width }))
|
||||
)
|
||||
EVALEOF
|
||||
|
||||
# Alternative: base64 encoding (avoids all shell escaping issues)
|
||||
agent-browser eval -b "$(echo -n 'Array.from(document.querySelectorAll("a")).map(a => a.href)' | base64)"
|
||||
```
|
||||
|
||||
**Why this matters:** When the shell processes your command, inner double quotes, `!` characters (history expansion), backticks, and `$()` can all corrupt the JavaScript before it reaches agent-browser. The `--stdin` and `-b` flags bypass shell interpretation entirely.
|
||||
|
||||
**Rules of thumb:**
|
||||
|
||||
- Single-line, no nested quotes -> regular `eval 'expression'` with single quotes is fine
|
||||
- Nested quotes, arrow functions, template literals, or multiline -> use `eval --stdin <<'EVALEOF'`
|
||||
- Programmatic/generated scripts -> use `eval -b` with base64
|
||||
|
||||
## Configuration File
|
||||
|
||||
Create `agent-browser.json` in the project root for persistent settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"headed": true,
|
||||
"proxy": "http://localhost:8080",
|
||||
"profile": "./browser-data"
|
||||
}
|
||||
```
|
||||
|
||||
Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser.json` < env vars < CLI flags. Use `--config <path>` or `AGENT_BROWSER_CONFIG` env var for a custom config file (exits with error if missing/invalid). All CLI options map to camelCase keys (e.g., `--executable-path` -> `"executablePath"`). Boolean flags accept `true`/`false` values (e.g., `--headed false` overrides config). Extensions from user and project configs are merged, not replaced.
|
||||
|
||||
## Deep-Dive Documentation
|
||||
|
||||
| Reference | When to Use |
|
||||
| -------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| [references/commands.md](references/commands.md) | Full command reference with all options |
|
||||
| [references/snapshot-refs.md](references/snapshot-refs.md) | Ref lifecycle, invalidation rules, troubleshooting |
|
||||
| [references/session-management.md](references/session-management.md) | Parallel sessions, state persistence, concurrent scraping |
|
||||
| [references/authentication.md](references/authentication.md) | Login flows, OAuth, 2FA handling, state reuse |
|
||||
| [references/video-recording.md](references/video-recording.md) | Recording workflows for debugging and documentation |
|
||||
| [references/profiling.md](references/profiling.md) | Chrome DevTools profiling for performance analysis |
|
||||
| [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies |
|
||||
|
||||
## Cloud Providers
|
||||
|
||||
Use `-p <provider>` (or `AGENT_BROWSER_PROVIDER`) to run against a cloud browser instead of launching a local Chrome instance. Supported providers: `agentcore`, `browserbase`, `browserless`, `browseruse`, `kernel`.
|
||||
|
||||
### AgentCore (AWS Bedrock)
|
||||
|
||||
```bash
|
||||
# Credentials auto-resolved from env vars or AWS CLI (SSO, IAM roles, etc.)
|
||||
agent-browser -p agentcore open https://example.com
|
||||
|
||||
# With persistent browser profile
|
||||
AGENTCORE_PROFILE_ID=my-profile agent-browser -p agentcore open https://example.com
|
||||
|
||||
# With explicit region
|
||||
AGENTCORE_REGION=eu-west-1 agent-browser -p agentcore open https://example.com
|
||||
```
|
||||
|
||||
Set `AWS_PROFILE` to select a named AWS profile.
|
||||
|
||||
## Browser Engine Selection
|
||||
|
||||
Use `--engine` to choose a local browser engine. The default is `chrome`.
|
||||
|
||||
```bash
|
||||
# Use Lightpanda (fast headless browser, requires separate install)
|
||||
agent-browser --engine lightpanda open example.com
|
||||
|
||||
# Via environment variable
|
||||
export AGENT_BROWSER_ENGINE=lightpanda
|
||||
agent-browser open example.com
|
||||
|
||||
# With custom binary path
|
||||
agent-browser --engine lightpanda --executable-path /path/to/lightpanda open example.com
|
||||
```
|
||||
|
||||
Supported engines:
|
||||
- `chrome` (default) -- Chrome/Chromium via CDP
|
||||
- `lightpanda` -- Lightpanda headless browser via CDP (10x faster, 10x less memory than Chrome)
|
||||
|
||||
Lightpanda does not support `--extension`, `--profile`, `--state`, or `--allow-file-access`. Install Lightpanda from https://lightpanda.io/docs/open-source/installation.
|
||||
|
||||
## Observability Dashboard
|
||||
|
||||
The dashboard is a standalone background server that shows live browser viewports, command activity, and console output for all sessions.
|
||||
|
||||
```bash
|
||||
# Install the dashboard once
|
||||
agent-browser dashboard install
|
||||
|
||||
# Start the dashboard server (background, port 4848)
|
||||
agent-browser dashboard start
|
||||
|
||||
# All sessions are automatically visible in the dashboard
|
||||
agent-browser open example.com
|
||||
|
||||
# Stop the dashboard
|
||||
agent-browser dashboard stop
|
||||
```
|
||||
|
||||
The dashboard runs independently of browser sessions on port 4848 (configurable with `--port`). All sessions automatically stream to the dashboard. Sessions can also be created from the dashboard UI with local engines or cloud providers.
|
||||
|
||||
## Ready-to-Use Templates
|
||||
|
||||
| Template | Description |
|
||||
| ------------------------------------------------------------------------ | ----------------------------------- |
|
||||
| [templates/form-automation.sh](templates/form-automation.sh) | Form filling with validation |
|
||||
| [templates/authenticated-session.sh](templates/authenticated-session.sh) | Login once, reuse state |
|
||||
| [templates/capture-workflow.sh](templates/capture-workflow.sh) | Content extraction with screenshots |
|
||||
|
||||
```bash
|
||||
./templates/form-automation.sh https://example.com/form
|
||||
./templates/authenticated-session.sh https://app.example.com/login
|
||||
./templates/capture-workflow.sh https://example.com ./output
|
||||
agent-browser open example.com --headed # Show browser window
|
||||
agent-browser console # View console messages
|
||||
agent-browser errors # View page errors
|
||||
```
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
# Authentication Patterns
|
||||
|
||||
Login flows, session persistence, OAuth, 2FA, and authenticated browsing.
|
||||
|
||||
**Related**: [session-management.md](session-management.md) for state persistence details, [SKILL.md](../SKILL.md) for quick start.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Import Auth from Your Browser](#import-auth-from-your-browser)
|
||||
- [Persistent Profiles](#persistent-profiles)
|
||||
- [Session Persistence](#session-persistence)
|
||||
- [Basic Login Flow](#basic-login-flow)
|
||||
- [Saving Authentication State](#saving-authentication-state)
|
||||
- [Restoring Authentication](#restoring-authentication)
|
||||
- [OAuth / SSO Flows](#oauth--sso-flows)
|
||||
- [Two-Factor Authentication](#two-factor-authentication)
|
||||
- [HTTP Basic Auth](#http-basic-auth)
|
||||
- [Cookie-Based Auth](#cookie-based-auth)
|
||||
- [Token Refresh Handling](#token-refresh-handling)
|
||||
- [Security Best Practices](#security-best-practices)
|
||||
|
||||
## Import Auth from Your Browser
|
||||
|
||||
The fastest way to authenticate is to reuse cookies from a Chrome session you are already logged into.
|
||||
|
||||
**Step 1: Start Chrome with remote debugging**
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222
|
||||
|
||||
# Linux
|
||||
google-chrome --remote-debugging-port=9222
|
||||
|
||||
# Windows
|
||||
"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
Log in to your target site(s) in this Chrome window as you normally would.
|
||||
|
||||
> **Security note:** `--remote-debugging-port` exposes full browser control on localhost. Any local process can connect and read cookies, execute JS, etc. Only use on trusted machines and close Chrome when done.
|
||||
|
||||
**Step 2: Grab the auth state**
|
||||
|
||||
```bash
|
||||
# Auto-discover the running Chrome and save its cookies + localStorage
|
||||
agent-browser --auto-connect state save ./my-auth.json
|
||||
```
|
||||
|
||||
**Step 3: Reuse in automation**
|
||||
|
||||
```bash
|
||||
# Load auth at launch
|
||||
agent-browser --state ./my-auth.json open https://app.example.com/dashboard
|
||||
|
||||
# Or load into an existing session
|
||||
agent-browser state load ./my-auth.json
|
||||
agent-browser open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
This works for any site, including those with complex OAuth flows, SSO, or 2FA -- as long as Chrome already has valid session cookies.
|
||||
|
||||
> **Security note:** State files contain session tokens in plaintext. Add them to `.gitignore`, delete when no longer needed, and set `AGENT_BROWSER_ENCRYPTION_KEY` for encryption at rest. See [Security Best Practices](#security-best-practices).
|
||||
|
||||
**Tip:** Combine with `--session-name` so the imported auth auto-persists across restarts:
|
||||
|
||||
```bash
|
||||
agent-browser --session-name myapp state load ./my-auth.json
|
||||
# From now on, state is auto-saved/restored for "myapp"
|
||||
```
|
||||
|
||||
## Persistent Profiles
|
||||
|
||||
Use `--profile` to point agent-browser at a Chrome user data directory. This persists everything (cookies, IndexedDB, service workers, cache) across browser restarts without explicit save/load:
|
||||
|
||||
```bash
|
||||
# First run: login once
|
||||
agent-browser --profile ~/.myapp-profile open https://app.example.com/login
|
||||
# ... complete login flow ...
|
||||
|
||||
# All subsequent runs: already authenticated
|
||||
agent-browser --profile ~/.myapp-profile open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
Use different paths for different projects or test users:
|
||||
|
||||
```bash
|
||||
agent-browser --profile ~/.profiles/admin open https://app.example.com
|
||||
agent-browser --profile ~/.profiles/viewer open https://app.example.com
|
||||
```
|
||||
|
||||
Or set via environment variable:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_PROFILE=~/.myapp-profile
|
||||
agent-browser open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
## Session Persistence
|
||||
|
||||
Use `--session-name` to auto-save and restore cookies + localStorage by name, without managing files:
|
||||
|
||||
```bash
|
||||
# Auto-saves state on close, auto-restores on next launch
|
||||
agent-browser --session-name twitter open https://twitter.com
|
||||
# ... login flow ...
|
||||
agent-browser close # state saved to ~/.agent-browser/sessions/
|
||||
|
||||
# Next time: state is automatically restored
|
||||
agent-browser --session-name twitter open https://twitter.com
|
||||
```
|
||||
|
||||
Encrypt state at rest:
|
||||
|
||||
```bash
|
||||
export AGENT_BROWSER_ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
agent-browser --session-name secure open https://app.example.com
|
||||
```
|
||||
|
||||
## Basic Login Flow
|
||||
|
||||
```bash
|
||||
# Navigate to login page
|
||||
agent-browser open https://app.example.com/login
|
||||
agent-browser wait --load networkidle
|
||||
|
||||
# Get form elements
|
||||
agent-browser snapshot -i
|
||||
# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Sign In"
|
||||
|
||||
# Fill credentials
|
||||
agent-browser fill @e1 "user@example.com"
|
||||
agent-browser fill @e2 "password123"
|
||||
|
||||
# Submit
|
||||
agent-browser click @e3
|
||||
agent-browser wait --load networkidle
|
||||
|
||||
# Verify login succeeded
|
||||
agent-browser get url # Should be dashboard, not login
|
||||
```
|
||||
|
||||
## Saving Authentication State
|
||||
|
||||
After logging in, save state for reuse:
|
||||
|
||||
```bash
|
||||
# Login first (see above)
|
||||
agent-browser open https://app.example.com/login
|
||||
agent-browser snapshot -i
|
||||
agent-browser fill @e1 "user@example.com"
|
||||
agent-browser fill @e2 "password123"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --url "**/dashboard"
|
||||
|
||||
# Save authenticated state
|
||||
agent-browser state save ./auth-state.json
|
||||
```
|
||||
|
||||
## Restoring Authentication
|
||||
|
||||
Skip login by loading saved state:
|
||||
|
||||
```bash
|
||||
# Load saved auth state
|
||||
agent-browser state load ./auth-state.json
|
||||
|
||||
# Navigate directly to protected page
|
||||
agent-browser open https://app.example.com/dashboard
|
||||
|
||||
# Verify authenticated
|
||||
agent-browser snapshot -i
|
||||
```
|
||||
|
||||
## OAuth / SSO Flows
|
||||
|
||||
For OAuth redirects:
|
||||
|
||||
```bash
|
||||
# Start OAuth flow
|
||||
agent-browser open https://app.example.com/auth/google
|
||||
|
||||
# Handle redirects automatically
|
||||
agent-browser wait --url "**/accounts.google.com**"
|
||||
agent-browser snapshot -i
|
||||
|
||||
# Fill Google credentials
|
||||
agent-browser fill @e1 "user@gmail.com"
|
||||
agent-browser click @e2 # Next button
|
||||
agent-browser wait 2000
|
||||
agent-browser snapshot -i
|
||||
agent-browser fill @e3 "password"
|
||||
agent-browser click @e4 # Sign in
|
||||
|
||||
# Wait for redirect back
|
||||
agent-browser wait --url "**/app.example.com**"
|
||||
agent-browser state save ./oauth-state.json
|
||||
```
|
||||
|
||||
## Two-Factor Authentication
|
||||
|
||||
Handle 2FA with manual intervention:
|
||||
|
||||
```bash
|
||||
# Login with credentials
|
||||
agent-browser open https://app.example.com/login --headed # Show browser
|
||||
agent-browser snapshot -i
|
||||
agent-browser fill @e1 "user@example.com"
|
||||
agent-browser fill @e2 "password123"
|
||||
agent-browser click @e3
|
||||
|
||||
# Wait for user to complete 2FA manually
|
||||
echo "Complete 2FA in the browser window..."
|
||||
agent-browser wait --url "**/dashboard" --timeout 120000
|
||||
|
||||
# Save state after 2FA
|
||||
agent-browser state save ./2fa-state.json
|
||||
```
|
||||
|
||||
## HTTP Basic Auth
|
||||
|
||||
For sites using HTTP Basic Authentication:
|
||||
|
||||
```bash
|
||||
# Set credentials before navigation
|
||||
agent-browser set credentials username password
|
||||
|
||||
# Navigate to protected resource
|
||||
agent-browser open https://protected.example.com/api
|
||||
```
|
||||
|
||||
## Cookie-Based Auth
|
||||
|
||||
Manually set authentication cookies:
|
||||
|
||||
```bash
|
||||
# Set auth cookie
|
||||
agent-browser cookies set session_token "abc123xyz"
|
||||
|
||||
# Navigate to protected page
|
||||
agent-browser open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
## Token Refresh Handling
|
||||
|
||||
For sessions with expiring tokens:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Wrapper that handles token refresh
|
||||
|
||||
STATE_FILE="./auth-state.json"
|
||||
|
||||
# Try loading existing state
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
agent-browser state load "$STATE_FILE"
|
||||
agent-browser open https://app.example.com/dashboard
|
||||
|
||||
# Check if session is still valid
|
||||
URL=$(agent-browser get url)
|
||||
if [[ "$URL" == *"/login"* ]]; then
|
||||
echo "Session expired, re-authenticating..."
|
||||
# Perform fresh login
|
||||
agent-browser snapshot -i
|
||||
agent-browser fill @e1 "$USERNAME"
|
||||
agent-browser fill @e2 "$PASSWORD"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --url "**/dashboard"
|
||||
agent-browser state save "$STATE_FILE"
|
||||
fi
|
||||
else
|
||||
# First-time login
|
||||
agent-browser open https://app.example.com/login
|
||||
# ... login flow ...
|
||||
fi
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never commit state files** - They contain session tokens
|
||||
```bash
|
||||
echo "*.auth-state.json" >> .gitignore
|
||||
```
|
||||
|
||||
2. **Use environment variables for credentials**
|
||||
```bash
|
||||
agent-browser fill @e1 "$APP_USERNAME"
|
||||
agent-browser fill @e2 "$APP_PASSWORD"
|
||||
```
|
||||
|
||||
3. **Clean up after automation**
|
||||
```bash
|
||||
agent-browser cookies clear
|
||||
rm -f ./auth-state.json
|
||||
```
|
||||
|
||||
4. **Use short-lived sessions for CI/CD**
|
||||
```bash
|
||||
# Don't persist state in CI
|
||||
agent-browser open https://app.example.com/login
|
||||
# ... login and perform actions ...
|
||||
agent-browser close # Session ends, nothing persisted
|
||||
```
|
||||
@@ -1,295 +0,0 @@
|
||||
# Command Reference
|
||||
|
||||
Complete reference for all agent-browser commands. For quick start and common patterns, see SKILL.md.
|
||||
|
||||
## Navigation
|
||||
|
||||
```bash
|
||||
agent-browser open <url> # Navigate to URL (aliases: goto, navigate)
|
||||
# Supports: https://, http://, file://, about:, data://
|
||||
# Auto-prepends https:// if no protocol given
|
||||
agent-browser back # Go back
|
||||
agent-browser forward # Go forward
|
||||
agent-browser reload # Reload page
|
||||
agent-browser close # Close browser (aliases: quit, exit)
|
||||
agent-browser connect 9222 # Connect to browser via CDP port
|
||||
```
|
||||
|
||||
## Snapshot (page analysis)
|
||||
|
||||
```bash
|
||||
agent-browser snapshot # Full accessibility tree
|
||||
agent-browser snapshot -i # Interactive elements only (recommended)
|
||||
agent-browser snapshot -c # Compact output
|
||||
agent-browser snapshot -d 3 # Limit depth to 3
|
||||
agent-browser snapshot -s "#main" # Scope to CSS selector
|
||||
```
|
||||
|
||||
## Interactions (use @refs from snapshot)
|
||||
|
||||
```bash
|
||||
agent-browser click @e1 # Click
|
||||
agent-browser click @e1 --new-tab # Click and open in new tab
|
||||
agent-browser dblclick @e1 # Double-click
|
||||
agent-browser focus @e1 # Focus element
|
||||
agent-browser fill @e2 "text" # Clear and type
|
||||
agent-browser type @e2 "text" # Type without clearing
|
||||
agent-browser press Enter # Press key (alias: key)
|
||||
agent-browser press Control+a # Key combination
|
||||
agent-browser keydown Shift # Hold key down
|
||||
agent-browser keyup Shift # Release key
|
||||
agent-browser hover @e1 # Hover
|
||||
agent-browser check @e1 # Check checkbox
|
||||
agent-browser uncheck @e1 # Uncheck checkbox
|
||||
agent-browser select @e1 "value" # Select dropdown option
|
||||
agent-browser select @e1 "a" "b" # Select multiple options
|
||||
agent-browser scroll down 500 # Scroll page (default: down 300px)
|
||||
agent-browser scrollintoview @e1 # Scroll element into view (alias: scrollinto)
|
||||
agent-browser drag @e1 @e2 # Drag and drop
|
||||
agent-browser upload @e1 file.pdf # Upload files
|
||||
```
|
||||
|
||||
## Get Information
|
||||
|
||||
```bash
|
||||
agent-browser get text @e1 # Get element text
|
||||
agent-browser get html @e1 # Get innerHTML
|
||||
agent-browser get value @e1 # Get input value
|
||||
agent-browser get attr @e1 href # Get attribute
|
||||
agent-browser get title # Get page title
|
||||
agent-browser get url # Get current URL
|
||||
agent-browser get cdp-url # Get CDP WebSocket URL
|
||||
agent-browser get count ".item" # Count matching elements
|
||||
agent-browser get box @e1 # Get bounding box
|
||||
agent-browser get styles @e1 # Get computed styles (font, color, bg, etc.)
|
||||
```
|
||||
|
||||
## Check State
|
||||
|
||||
```bash
|
||||
agent-browser is visible @e1 # Check if visible
|
||||
agent-browser is enabled @e1 # Check if enabled
|
||||
agent-browser is checked @e1 # Check if checked
|
||||
```
|
||||
|
||||
## Screenshots and PDF
|
||||
|
||||
```bash
|
||||
agent-browser screenshot # Save to temporary directory
|
||||
agent-browser screenshot path.png # Save to specific path
|
||||
agent-browser screenshot --full # Full page
|
||||
agent-browser pdf output.pdf # Save as PDF
|
||||
```
|
||||
|
||||
## Video Recording
|
||||
|
||||
```bash
|
||||
agent-browser record start ./demo.webm # Start recording
|
||||
agent-browser click @e1 # Perform actions
|
||||
agent-browser record stop # Stop and save video
|
||||
agent-browser record restart ./take2.webm # Stop current + start new
|
||||
```
|
||||
|
||||
## Wait
|
||||
|
||||
```bash
|
||||
agent-browser wait @e1 # Wait for element
|
||||
agent-browser wait 2000 # Wait milliseconds
|
||||
agent-browser wait --text "Success" # Wait for text (or -t)
|
||||
agent-browser wait --url "**/dashboard" # Wait for URL pattern (or -u)
|
||||
agent-browser wait --load networkidle # Wait for network idle (or -l)
|
||||
agent-browser wait --fn "window.ready" # Wait for JS condition (or -f)
|
||||
```
|
||||
|
||||
## Mouse Control
|
||||
|
||||
```bash
|
||||
agent-browser mouse move 100 200 # Move mouse
|
||||
agent-browser mouse down left # Press button
|
||||
agent-browser mouse up left # Release button
|
||||
agent-browser mouse wheel 100 # Scroll wheel
|
||||
```
|
||||
|
||||
## Semantic Locators (alternative to refs)
|
||||
|
||||
```bash
|
||||
agent-browser find role button click --name "Submit"
|
||||
agent-browser find text "Sign In" click
|
||||
agent-browser find text "Sign In" click --exact # Exact match only
|
||||
agent-browser find label "Email" fill "user@test.com"
|
||||
agent-browser find placeholder "Search" type "query"
|
||||
agent-browser find alt "Logo" click
|
||||
agent-browser find title "Close" click
|
||||
agent-browser find testid "submit-btn" click
|
||||
agent-browser find first ".item" click
|
||||
agent-browser find last ".item" click
|
||||
agent-browser find nth 2 "a" hover
|
||||
```
|
||||
|
||||
## Browser Settings
|
||||
|
||||
```bash
|
||||
agent-browser set viewport 1920 1080 # Set viewport size
|
||||
agent-browser set viewport 1920 1080 2 # 2x retina (same CSS size, higher res screenshots)
|
||||
agent-browser set device "iPhone 14" # Emulate device
|
||||
agent-browser set geo 37.7749 -122.4194 # Set geolocation (alias: geolocation)
|
||||
agent-browser set offline on # Toggle offline mode
|
||||
agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers
|
||||
agent-browser set credentials user pass # HTTP basic auth (alias: auth)
|
||||
agent-browser set media dark # Emulate color scheme
|
||||
agent-browser set media light reduced-motion # Light mode + reduced motion
|
||||
```
|
||||
|
||||
## Cookies and Storage
|
||||
|
||||
```bash
|
||||
agent-browser cookies # Get all cookies
|
||||
agent-browser cookies set name value # Set cookie
|
||||
agent-browser cookies clear # Clear cookies
|
||||
agent-browser storage local # Get all localStorage
|
||||
agent-browser storage local key # Get specific key
|
||||
agent-browser storage local set k v # Set value
|
||||
agent-browser storage local clear # Clear all
|
||||
```
|
||||
|
||||
## Network
|
||||
|
||||
```bash
|
||||
agent-browser network route <url> # Intercept requests
|
||||
agent-browser network route <url> --abort # Block requests
|
||||
agent-browser network route <url> --body '{}' # Mock response
|
||||
agent-browser network unroute [url] # Remove routes
|
||||
agent-browser network requests # View tracked requests
|
||||
agent-browser network requests --filter api # Filter requests
|
||||
```
|
||||
|
||||
## Tabs and Windows
|
||||
|
||||
```bash
|
||||
agent-browser tab # List tabs
|
||||
agent-browser tab new [url] # New tab
|
||||
agent-browser tab 2 # Switch to tab by index
|
||||
agent-browser tab close # Close current tab
|
||||
agent-browser tab close 2 # Close tab by index
|
||||
agent-browser window new # New window
|
||||
```
|
||||
|
||||
## Frames
|
||||
|
||||
```bash
|
||||
agent-browser frame "#iframe" # Switch to iframe by CSS selector
|
||||
agent-browser frame @e3 # Switch to iframe by element ref
|
||||
agent-browser frame main # Back to main frame
|
||||
```
|
||||
|
||||
### Iframe support
|
||||
|
||||
Iframes are detected automatically during snapshots. When the main-frame snapshot runs, `Iframe` nodes are resolved and their content is inlined beneath the iframe element in the output (one level of nesting; iframes within iframes are not expanded).
|
||||
|
||||
```bash
|
||||
agent-browser snapshot -i
|
||||
# @e3 [Iframe] "payment-frame"
|
||||
# @e4 [input] "Card number"
|
||||
# @e5 [button] "Pay"
|
||||
|
||||
# Interact directly — refs inside iframes already work
|
||||
agent-browser fill @e4 "4111111111111111"
|
||||
agent-browser click @e5
|
||||
|
||||
# Or switch frame context for scoped snapshots
|
||||
agent-browser frame @e3 # Switch using element ref
|
||||
agent-browser snapshot -i # Snapshot scoped to that iframe
|
||||
agent-browser frame main # Return to main frame
|
||||
```
|
||||
|
||||
The `frame` command accepts:
|
||||
- **Element refs** — `frame @e3` resolves the ref to an iframe element
|
||||
- **CSS selectors** — `frame "#payment-iframe"` finds the iframe by selector
|
||||
- **Frame name/URL** — matches against the browser's frame tree
|
||||
|
||||
## Dialogs
|
||||
|
||||
By default, `alert` and `beforeunload` dialogs are automatically accepted so they never block the agent. `confirm` and `prompt` dialogs still require explicit handling. Use `--no-auto-dialog` to disable this behavior.
|
||||
|
||||
```bash
|
||||
agent-browser dialog accept [text] # Accept dialog
|
||||
agent-browser dialog dismiss # Dismiss dialog
|
||||
agent-browser dialog status # Check if a dialog is currently open
|
||||
```
|
||||
|
||||
## JavaScript
|
||||
|
||||
```bash
|
||||
agent-browser eval "document.title" # Simple expressions only
|
||||
agent-browser eval -b "<base64>" # Any JavaScript (base64 encoded)
|
||||
agent-browser eval --stdin # Read script from stdin
|
||||
```
|
||||
|
||||
Use `-b`/`--base64` or `--stdin` for reliable execution. Shell escaping with nested quotes and special characters is error-prone.
|
||||
|
||||
```bash
|
||||
# Base64 encode your script, then:
|
||||
agent-browser eval -b "ZG9jdW1lbnQucXVlcnlTZWxlY3RvcignW3NyYyo9Il9uZXh0Il0nKQ=="
|
||||
|
||||
# Or use stdin with heredoc for multiline scripts:
|
||||
cat <<'EOF' | agent-browser eval --stdin
|
||||
const links = document.querySelectorAll('a');
|
||||
Array.from(links).map(a => a.href);
|
||||
EOF
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
```bash
|
||||
agent-browser state save auth.json # Save cookies, storage, auth state
|
||||
agent-browser state load auth.json # Restore saved state
|
||||
```
|
||||
|
||||
## Global Options
|
||||
|
||||
```bash
|
||||
agent-browser --session <name> ... # Isolated browser session
|
||||
agent-browser --json ... # JSON output for parsing
|
||||
agent-browser --headed ... # Show browser window (not headless)
|
||||
agent-browser --full ... # Full page screenshot (-f)
|
||||
agent-browser --cdp <port> ... # Connect via Chrome DevTools Protocol
|
||||
agent-browser -p <provider> ... # Cloud browser provider (--provider)
|
||||
agent-browser --proxy <url> ... # Use proxy server
|
||||
agent-browser --proxy-bypass <hosts> # Hosts to bypass proxy
|
||||
agent-browser --headers <json> ... # HTTP headers scoped to URL's origin
|
||||
agent-browser --executable-path <p> # Custom browser executable
|
||||
agent-browser --extension <path> ... # Load browser extension (repeatable)
|
||||
agent-browser --ignore-https-errors # Ignore SSL certificate errors
|
||||
agent-browser --help # Show help (-h)
|
||||
agent-browser --version # Show version (-V)
|
||||
agent-browser <command> --help # Show detailed help for a command
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```bash
|
||||
agent-browser --headed open example.com # Show browser window
|
||||
agent-browser --cdp 9222 snapshot # Connect via CDP port
|
||||
agent-browser connect 9222 # Alternative: connect command
|
||||
agent-browser console # View console messages
|
||||
agent-browser console --clear # Clear console
|
||||
agent-browser errors # View page errors
|
||||
agent-browser errors --clear # Clear errors
|
||||
agent-browser highlight @e1 # Highlight element
|
||||
agent-browser inspect # Open Chrome DevTools for this session
|
||||
agent-browser trace start # Start recording trace
|
||||
agent-browser trace stop trace.zip # Stop and save trace
|
||||
agent-browser profiler start # Start Chrome DevTools profiling
|
||||
agent-browser profiler stop trace.json # Stop and save profile
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
AGENT_BROWSER_SESSION="mysession" # Default session name
|
||||
AGENT_BROWSER_EXECUTABLE_PATH="/path/chrome" # Custom browser path
|
||||
AGENT_BROWSER_EXTENSIONS="/ext1,/ext2" # Comma-separated extension paths
|
||||
AGENT_BROWSER_PROVIDER="browserbase" # Cloud browser provider
|
||||
AGENT_BROWSER_STREAM_PORT="9223" # Override WebSocket streaming port (default: OS-assigned)
|
||||
AGENT_BROWSER_HOME="/path/to/agent-browser" # Custom install location
|
||||
```
|
||||
@@ -1,120 +0,0 @@
|
||||
# Profiling
|
||||
|
||||
Capture Chrome DevTools performance profiles during browser automation for performance analysis.
|
||||
|
||||
**Related**: [commands.md](commands.md) for full command reference, [SKILL.md](../SKILL.md) for quick start.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Basic Profiling](#basic-profiling)
|
||||
- [Profiler Commands](#profiler-commands)
|
||||
- [Categories](#categories)
|
||||
- [Use Cases](#use-cases)
|
||||
- [Output Format](#output-format)
|
||||
- [Viewing Profiles](#viewing-profiles)
|
||||
- [Limitations](#limitations)
|
||||
|
||||
## Basic Profiling
|
||||
|
||||
```bash
|
||||
# Start profiling
|
||||
agent-browser profiler start
|
||||
|
||||
# Perform actions
|
||||
agent-browser navigate https://example.com
|
||||
agent-browser click "#button"
|
||||
agent-browser wait 1000
|
||||
|
||||
# Stop and save
|
||||
agent-browser profiler stop ./trace.json
|
||||
```
|
||||
|
||||
## Profiler Commands
|
||||
|
||||
```bash
|
||||
# Start profiling with default categories
|
||||
agent-browser profiler start
|
||||
|
||||
# Start with custom trace categories
|
||||
agent-browser profiler start --categories "devtools.timeline,v8.execute,blink.user_timing"
|
||||
|
||||
# Stop profiling and save to file
|
||||
agent-browser profiler stop ./trace.json
|
||||
```
|
||||
|
||||
## Categories
|
||||
|
||||
The `--categories` flag accepts a comma-separated list of Chrome trace categories. Default categories include:
|
||||
|
||||
- `devtools.timeline` -- standard DevTools performance traces
|
||||
- `v8.execute` -- time spent running JavaScript
|
||||
- `blink` -- renderer events
|
||||
- `blink.user_timing` -- `performance.mark()` / `performance.measure()` calls
|
||||
- `latencyInfo` -- input-to-latency tracking
|
||||
- `renderer.scheduler` -- task scheduling and execution
|
||||
- `toplevel` -- broad-spectrum basic events
|
||||
|
||||
Several `disabled-by-default-*` categories are also included for detailed timeline, call stack, and V8 CPU profiling data.
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Diagnosing Slow Page Loads
|
||||
|
||||
```bash
|
||||
agent-browser profiler start
|
||||
agent-browser navigate https://app.example.com
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser profiler stop ./page-load-profile.json
|
||||
```
|
||||
|
||||
### Profiling User Interactions
|
||||
|
||||
```bash
|
||||
agent-browser navigate https://app.example.com
|
||||
agent-browser profiler start
|
||||
agent-browser click "#submit"
|
||||
agent-browser wait 2000
|
||||
agent-browser profiler stop ./interaction-profile.json
|
||||
```
|
||||
|
||||
### CI Performance Regression Checks
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
agent-browser profiler start
|
||||
agent-browser navigate https://app.example.com
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser profiler stop "./profiles/build-${BUILD_ID}.json"
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
The output is a JSON file in Chrome Trace Event format:
|
||||
|
||||
```json
|
||||
{
|
||||
"traceEvents": [
|
||||
{ "cat": "devtools.timeline", "name": "RunTask", "ph": "X", "ts": 12345, "dur": 100, ... },
|
||||
...
|
||||
],
|
||||
"metadata": {
|
||||
"clock-domain": "LINUX_CLOCK_MONOTONIC"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `metadata.clock-domain` field is set based on the host platform (Linux or macOS). On Windows it is omitted.
|
||||
|
||||
## Viewing Profiles
|
||||
|
||||
Load the output JSON file in any of these tools:
|
||||
|
||||
- **Chrome DevTools**: Performance panel > Load profile (Ctrl+Shift+I > Performance)
|
||||
- **Perfetto UI**: https://ui.perfetto.dev/ -- drag and drop the JSON file
|
||||
- **Trace Viewer**: `chrome://tracing` in any Chromium browser
|
||||
|
||||
## Limitations
|
||||
|
||||
- Only works with Chromium-based browsers (Chrome, Edge). Not supported on Firefox or WebKit.
|
||||
- Trace data accumulates in memory while profiling is active (capped at 5 million events). Stop profiling promptly after the area of interest.
|
||||
- Data collection on stop has a 30-second timeout. If the browser is unresponsive, the stop command may fail.
|
||||
@@ -1,194 +0,0 @@
|
||||
# Proxy Support
|
||||
|
||||
Proxy configuration for geo-testing, rate limiting avoidance, and corporate environments.
|
||||
|
||||
**Related**: [commands.md](commands.md) for global options, [SKILL.md](../SKILL.md) for quick start.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Basic Proxy Configuration](#basic-proxy-configuration)
|
||||
- [Authenticated Proxy](#authenticated-proxy)
|
||||
- [SOCKS Proxy](#socks-proxy)
|
||||
- [Proxy Bypass](#proxy-bypass)
|
||||
- [Common Use Cases](#common-use-cases)
|
||||
- [Verifying Proxy Connection](#verifying-proxy-connection)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
## Basic Proxy Configuration
|
||||
|
||||
Use the `--proxy` flag or set proxy via environment variable:
|
||||
|
||||
```bash
|
||||
# Via CLI flag
|
||||
agent-browser --proxy "http://proxy.example.com:8080" open https://example.com
|
||||
|
||||
# Via environment variable
|
||||
export HTTP_PROXY="http://proxy.example.com:8080"
|
||||
agent-browser open https://example.com
|
||||
|
||||
# HTTPS proxy
|
||||
export HTTPS_PROXY="https://proxy.example.com:8080"
|
||||
agent-browser open https://example.com
|
||||
|
||||
# Both
|
||||
export HTTP_PROXY="http://proxy.example.com:8080"
|
||||
export HTTPS_PROXY="http://proxy.example.com:8080"
|
||||
agent-browser open https://example.com
|
||||
```
|
||||
|
||||
## Authenticated Proxy
|
||||
|
||||
For proxies requiring authentication:
|
||||
|
||||
```bash
|
||||
# Include credentials in URL
|
||||
export HTTP_PROXY="http://username:password@proxy.example.com:8080"
|
||||
agent-browser open https://example.com
|
||||
```
|
||||
|
||||
## SOCKS Proxy
|
||||
|
||||
```bash
|
||||
# SOCKS5 proxy
|
||||
export ALL_PROXY="socks5://proxy.example.com:1080"
|
||||
agent-browser open https://example.com
|
||||
|
||||
# SOCKS5 with auth
|
||||
export ALL_PROXY="socks5://user:pass@proxy.example.com:1080"
|
||||
agent-browser open https://example.com
|
||||
```
|
||||
|
||||
## Proxy Bypass
|
||||
|
||||
Skip proxy for specific domains using `--proxy-bypass` or `NO_PROXY`:
|
||||
|
||||
```bash
|
||||
# Via CLI flag
|
||||
agent-browser --proxy "http://proxy.example.com:8080" --proxy-bypass "localhost,*.internal.com" open https://example.com
|
||||
|
||||
# Via environment variable
|
||||
export NO_PROXY="localhost,127.0.0.1,.internal.company.com"
|
||||
agent-browser open https://internal.company.com # Direct connection
|
||||
agent-browser open https://external.com # Via proxy
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### Geo-Location Testing
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Test site from different regions using geo-located proxies
|
||||
|
||||
PROXIES=(
|
||||
"http://us-proxy.example.com:8080"
|
||||
"http://eu-proxy.example.com:8080"
|
||||
"http://asia-proxy.example.com:8080"
|
||||
)
|
||||
|
||||
for proxy in "${PROXIES[@]}"; do
|
||||
export HTTP_PROXY="$proxy"
|
||||
export HTTPS_PROXY="$proxy"
|
||||
|
||||
region=$(echo "$proxy" | grep -oP '^\w+-\w+')
|
||||
echo "Testing from: $region"
|
||||
|
||||
agent-browser --session "$region" open https://example.com
|
||||
agent-browser --session "$region" screenshot "./screenshots/$region.png"
|
||||
agent-browser --session "$region" close
|
||||
done
|
||||
```
|
||||
|
||||
### Rotating Proxies for Scraping
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Rotate through proxy list to avoid rate limiting
|
||||
|
||||
PROXY_LIST=(
|
||||
"http://proxy1.example.com:8080"
|
||||
"http://proxy2.example.com:8080"
|
||||
"http://proxy3.example.com:8080"
|
||||
)
|
||||
|
||||
URLS=(
|
||||
"https://site.com/page1"
|
||||
"https://site.com/page2"
|
||||
"https://site.com/page3"
|
||||
)
|
||||
|
||||
for i in "${!URLS[@]}"; do
|
||||
proxy_index=$((i % ${#PROXY_LIST[@]}))
|
||||
export HTTP_PROXY="${PROXY_LIST[$proxy_index]}"
|
||||
export HTTPS_PROXY="${PROXY_LIST[$proxy_index]}"
|
||||
|
||||
agent-browser open "${URLS[$i]}"
|
||||
agent-browser get text body > "output-$i.txt"
|
||||
agent-browser close
|
||||
|
||||
sleep 1 # Polite delay
|
||||
done
|
||||
```
|
||||
|
||||
### Corporate Network Access
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Access internal sites via corporate proxy
|
||||
|
||||
export HTTP_PROXY="http://corpproxy.company.com:8080"
|
||||
export HTTPS_PROXY="http://corpproxy.company.com:8080"
|
||||
export NO_PROXY="localhost,127.0.0.1,.company.com"
|
||||
|
||||
# External sites go through proxy
|
||||
agent-browser open https://external-vendor.com
|
||||
|
||||
# Internal sites bypass proxy
|
||||
agent-browser open https://intranet.company.com
|
||||
```
|
||||
|
||||
## Verifying Proxy Connection
|
||||
|
||||
```bash
|
||||
# Check your apparent IP
|
||||
agent-browser open https://httpbin.org/ip
|
||||
agent-browser get text body
|
||||
# Should show proxy's IP, not your real IP
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Proxy Connection Failed
|
||||
|
||||
```bash
|
||||
# Test proxy connectivity first
|
||||
curl -x http://proxy.example.com:8080 https://httpbin.org/ip
|
||||
|
||||
# Check if proxy requires auth
|
||||
export HTTP_PROXY="http://user:pass@proxy.example.com:8080"
|
||||
```
|
||||
|
||||
### SSL/TLS Errors Through Proxy
|
||||
|
||||
Some proxies perform SSL inspection. If you encounter certificate errors:
|
||||
|
||||
```bash
|
||||
# For testing only - not recommended for production
|
||||
agent-browser open https://example.com --ignore-https-errors
|
||||
```
|
||||
|
||||
### Slow Performance
|
||||
|
||||
```bash
|
||||
# Use proxy only when necessary
|
||||
export NO_PROXY="*.cdn.com,*.static.com" # Direct CDN access
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use environment variables** - Don't hardcode proxy credentials
|
||||
2. **Set NO_PROXY appropriately** - Avoid routing local traffic through proxy
|
||||
3. **Test proxy before automation** - Verify connectivity with simple requests
|
||||
4. **Handle proxy failures gracefully** - Implement retry logic for unstable proxies
|
||||
5. **Rotate proxies for large scraping jobs** - Distribute load and avoid bans
|
||||
@@ -1,193 +0,0 @@
|
||||
# Session Management
|
||||
|
||||
Multiple isolated browser sessions with state persistence and concurrent browsing.
|
||||
|
||||
**Related**: [authentication.md](authentication.md) for login patterns, [SKILL.md](../SKILL.md) for quick start.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Named Sessions](#named-sessions)
|
||||
- [Session Isolation Properties](#session-isolation-properties)
|
||||
- [Session State Persistence](#session-state-persistence)
|
||||
- [Common Patterns](#common-patterns)
|
||||
- [Default Session](#default-session)
|
||||
- [Session Cleanup](#session-cleanup)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
## Named Sessions
|
||||
|
||||
Use `--session` flag to isolate browser contexts:
|
||||
|
||||
```bash
|
||||
# Session 1: Authentication flow
|
||||
agent-browser --session auth open https://app.example.com/login
|
||||
|
||||
# Session 2: Public browsing (separate cookies, storage)
|
||||
agent-browser --session public open https://example.com
|
||||
|
||||
# Commands are isolated by session
|
||||
agent-browser --session auth fill @e1 "user@example.com"
|
||||
agent-browser --session public get text body
|
||||
```
|
||||
|
||||
## Session Isolation Properties
|
||||
|
||||
Each session has independent:
|
||||
- Cookies
|
||||
- LocalStorage / SessionStorage
|
||||
- IndexedDB
|
||||
- Cache
|
||||
- Browsing history
|
||||
- Open tabs
|
||||
|
||||
## Session State Persistence
|
||||
|
||||
### Save Session State
|
||||
|
||||
```bash
|
||||
# Save cookies, storage, and auth state
|
||||
agent-browser state save /path/to/auth-state.json
|
||||
```
|
||||
|
||||
### Load Session State
|
||||
|
||||
```bash
|
||||
# Restore saved state
|
||||
agent-browser state load /path/to/auth-state.json
|
||||
|
||||
# Continue with authenticated session
|
||||
agent-browser open https://app.example.com/dashboard
|
||||
```
|
||||
|
||||
### State File Contents
|
||||
|
||||
```json
|
||||
{
|
||||
"cookies": [...],
|
||||
"localStorage": {...},
|
||||
"sessionStorage": {...},
|
||||
"origins": [...]
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Authenticated Session Reuse
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Save login state once, reuse many times
|
||||
|
||||
STATE_FILE="/tmp/auth-state.json"
|
||||
|
||||
# Check if we have saved state
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
agent-browser state load "$STATE_FILE"
|
||||
agent-browser open https://app.example.com/dashboard
|
||||
else
|
||||
# Perform login
|
||||
agent-browser open https://app.example.com/login
|
||||
agent-browser snapshot -i
|
||||
agent-browser fill @e1 "$USERNAME"
|
||||
agent-browser fill @e2 "$PASSWORD"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --load networkidle
|
||||
|
||||
# Save for future use
|
||||
agent-browser state save "$STATE_FILE"
|
||||
fi
|
||||
```
|
||||
|
||||
### Concurrent Scraping
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Scrape multiple sites concurrently
|
||||
|
||||
# Start all sessions
|
||||
agent-browser --session site1 open https://site1.com &
|
||||
agent-browser --session site2 open https://site2.com &
|
||||
agent-browser --session site3 open https://site3.com &
|
||||
wait
|
||||
|
||||
# Extract from each
|
||||
agent-browser --session site1 get text body > site1.txt
|
||||
agent-browser --session site2 get text body > site2.txt
|
||||
agent-browser --session site3 get text body > site3.txt
|
||||
|
||||
# Cleanup
|
||||
agent-browser --session site1 close
|
||||
agent-browser --session site2 close
|
||||
agent-browser --session site3 close
|
||||
```
|
||||
|
||||
### A/B Testing Sessions
|
||||
|
||||
```bash
|
||||
# Test different user experiences
|
||||
agent-browser --session variant-a open "https://app.com?variant=a"
|
||||
agent-browser --session variant-b open "https://app.com?variant=b"
|
||||
|
||||
# Compare
|
||||
agent-browser --session variant-a screenshot /tmp/variant-a.png
|
||||
agent-browser --session variant-b screenshot /tmp/variant-b.png
|
||||
```
|
||||
|
||||
## Default Session
|
||||
|
||||
When `--session` is omitted, commands use the default session:
|
||||
|
||||
```bash
|
||||
# These use the same default session
|
||||
agent-browser open https://example.com
|
||||
agent-browser snapshot -i
|
||||
agent-browser close # Closes default session
|
||||
```
|
||||
|
||||
## Session Cleanup
|
||||
|
||||
```bash
|
||||
# Close specific session
|
||||
agent-browser --session auth close
|
||||
|
||||
# List active sessions
|
||||
agent-browser session list
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Name Sessions Semantically
|
||||
|
||||
```bash
|
||||
# GOOD: Clear purpose
|
||||
agent-browser --session github-auth open https://github.com
|
||||
agent-browser --session docs-scrape open https://docs.example.com
|
||||
|
||||
# AVOID: Generic names
|
||||
agent-browser --session s1 open https://github.com
|
||||
```
|
||||
|
||||
### 2. Always Clean Up
|
||||
|
||||
```bash
|
||||
# Close sessions when done
|
||||
agent-browser --session auth close
|
||||
agent-browser --session scrape close
|
||||
```
|
||||
|
||||
### 3. Handle State Files Securely
|
||||
|
||||
```bash
|
||||
# Don't commit state files (contain auth tokens!)
|
||||
echo "*.auth-state.json" >> .gitignore
|
||||
|
||||
# Delete after use
|
||||
rm /tmp/auth-state.json
|
||||
```
|
||||
|
||||
### 4. Timeout Long Sessions
|
||||
|
||||
```bash
|
||||
# Set timeout for automated scripts
|
||||
timeout 60 agent-browser --session long-task get text body
|
||||
```
|
||||
@@ -1,219 +0,0 @@
|
||||
# Snapshot and Refs
|
||||
|
||||
Compact element references that reduce context usage dramatically for AI agents.
|
||||
|
||||
**Related**: [commands.md](commands.md) for full command reference, [SKILL.md](../SKILL.md) for quick start.
|
||||
|
||||
## Contents
|
||||
|
||||
- [How Refs Work](#how-refs-work)
|
||||
- [Snapshot Command](#the-snapshot-command)
|
||||
- [Using Refs](#using-refs)
|
||||
- [Ref Lifecycle](#ref-lifecycle)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Ref Notation Details](#ref-notation-details)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## How Refs Work
|
||||
|
||||
Traditional approach:
|
||||
```
|
||||
Full DOM/HTML → AI parses → CSS selector → Action (~3000-5000 tokens)
|
||||
```
|
||||
|
||||
agent-browser approach:
|
||||
```
|
||||
Compact snapshot → @refs assigned → Direct interaction (~200-400 tokens)
|
||||
```
|
||||
|
||||
## The Snapshot Command
|
||||
|
||||
```bash
|
||||
# Basic snapshot (shows page structure)
|
||||
agent-browser snapshot
|
||||
|
||||
# Interactive snapshot (-i flag) - RECOMMENDED
|
||||
agent-browser snapshot -i
|
||||
```
|
||||
|
||||
### Snapshot Output Format
|
||||
|
||||
```
|
||||
Page: Example Site - Home
|
||||
URL: https://example.com
|
||||
|
||||
@e1 [header]
|
||||
@e2 [nav]
|
||||
@e3 [a] "Home"
|
||||
@e4 [a] "Products"
|
||||
@e5 [a] "About"
|
||||
@e6 [button] "Sign In"
|
||||
|
||||
@e7 [main]
|
||||
@e8 [h1] "Welcome"
|
||||
@e9 [form]
|
||||
@e10 [input type="email"] placeholder="Email"
|
||||
@e11 [input type="password"] placeholder="Password"
|
||||
@e12 [button type="submit"] "Log In"
|
||||
|
||||
@e13 [footer]
|
||||
@e14 [a] "Privacy Policy"
|
||||
```
|
||||
|
||||
## Using Refs
|
||||
|
||||
Once you have refs, interact directly:
|
||||
|
||||
```bash
|
||||
# Click the "Sign In" button
|
||||
agent-browser click @e6
|
||||
|
||||
# Fill email input
|
||||
agent-browser fill @e10 "user@example.com"
|
||||
|
||||
# Fill password
|
||||
agent-browser fill @e11 "password123"
|
||||
|
||||
# Submit the form
|
||||
agent-browser click @e12
|
||||
```
|
||||
|
||||
## Ref Lifecycle
|
||||
|
||||
**IMPORTANT**: Refs are invalidated when the page changes!
|
||||
|
||||
```bash
|
||||
# Get initial snapshot
|
||||
agent-browser snapshot -i
|
||||
# @e1 [button] "Next"
|
||||
|
||||
# Click triggers page change
|
||||
agent-browser click @e1
|
||||
|
||||
# MUST re-snapshot to get new refs!
|
||||
agent-browser snapshot -i
|
||||
# @e1 [h1] "Page 2" ← Different element now!
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Snapshot Before Interacting
|
||||
|
||||
```bash
|
||||
# CORRECT
|
||||
agent-browser open https://example.com
|
||||
agent-browser snapshot -i # Get refs first
|
||||
agent-browser click @e1 # Use ref
|
||||
|
||||
# WRONG
|
||||
agent-browser open https://example.com
|
||||
agent-browser click @e1 # Ref doesn't exist yet!
|
||||
```
|
||||
|
||||
### 2. Re-Snapshot After Navigation
|
||||
|
||||
```bash
|
||||
agent-browser click @e5 # Navigates to new page
|
||||
agent-browser snapshot -i # Get new refs
|
||||
agent-browser click @e1 # Use new refs
|
||||
```
|
||||
|
||||
### 3. Re-Snapshot After Dynamic Changes
|
||||
|
||||
```bash
|
||||
agent-browser click @e1 # Opens dropdown
|
||||
agent-browser snapshot -i # See dropdown items
|
||||
agent-browser click @e7 # Select item
|
||||
```
|
||||
|
||||
### 4. Snapshot Specific Regions
|
||||
|
||||
For complex pages, snapshot specific areas:
|
||||
|
||||
```bash
|
||||
# Snapshot just the form
|
||||
agent-browser snapshot @e9
|
||||
```
|
||||
|
||||
## Ref Notation Details
|
||||
|
||||
```
|
||||
@e1 [tag type="value"] "text content" placeholder="hint"
|
||||
│ │ │ │ │
|
||||
│ │ │ │ └─ Additional attributes
|
||||
│ │ │ └─ Visible text
|
||||
│ │ └─ Key attributes shown
|
||||
│ └─ HTML tag name
|
||||
└─ Unique ref ID
|
||||
```
|
||||
|
||||
### Common Patterns
|
||||
|
||||
```
|
||||
@e1 [button] "Submit" # Button with text
|
||||
@e2 [input type="email"] # Email input
|
||||
@e3 [input type="password"] # Password input
|
||||
@e4 [a href="/page"] "Link Text" # Anchor link
|
||||
@e5 [select] # Dropdown
|
||||
@e6 [textarea] placeholder="Message" # Text area
|
||||
@e7 [div class="modal"] # Container (when relevant)
|
||||
@e8 [img alt="Logo"] # Image
|
||||
@e9 [checkbox] checked # Checked checkbox
|
||||
@e10 [radio] selected # Selected radio
|
||||
```
|
||||
|
||||
## Iframes
|
||||
|
||||
Snapshots automatically detect and inline iframe content. When the main-frame snapshot runs, each `Iframe` node is resolved and its child accessibility tree is included directly beneath it in the output. Refs assigned to elements inside iframes carry frame context, so interactions like `click`, `fill`, and `type` work without manually switching frames.
|
||||
|
||||
```bash
|
||||
agent-browser snapshot -i
|
||||
# @e1 [heading] "Checkout"
|
||||
# @e2 [Iframe] "payment-frame"
|
||||
# @e3 [input] "Card number"
|
||||
# @e4 [input] "Expiry"
|
||||
# @e5 [button] "Pay"
|
||||
# @e6 [button] "Cancel"
|
||||
|
||||
# Interact with iframe elements directly using their refs
|
||||
agent-browser fill @e3 "4111111111111111"
|
||||
agent-browser fill @e4 "12/28"
|
||||
agent-browser click @e5
|
||||
```
|
||||
|
||||
**Key details:**
|
||||
- Only one level of iframe nesting is expanded (iframes within iframes are not recursed)
|
||||
- Cross-origin iframes that block accessibility tree access are silently skipped
|
||||
- Empty iframes or iframes with no interactive content are omitted from the output
|
||||
- To scope a snapshot to a single iframe, use `frame @ref` then `snapshot -i`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Ref not found" Error
|
||||
|
||||
```bash
|
||||
# Ref may have changed - re-snapshot
|
||||
agent-browser snapshot -i
|
||||
```
|
||||
|
||||
### Element Not Visible in Snapshot
|
||||
|
||||
```bash
|
||||
# Scroll down to reveal element
|
||||
agent-browser scroll down 1000
|
||||
agent-browser snapshot -i
|
||||
|
||||
# Or wait for dynamic content
|
||||
agent-browser wait 1000
|
||||
agent-browser snapshot -i
|
||||
```
|
||||
|
||||
### Too Many Elements
|
||||
|
||||
```bash
|
||||
# Snapshot specific container
|
||||
agent-browser snapshot @e5
|
||||
|
||||
# Or use get text for content-only extraction
|
||||
agent-browser get text @e5
|
||||
```
|
||||
@@ -1,173 +0,0 @@
|
||||
# Video Recording
|
||||
|
||||
Capture browser automation as video for debugging, documentation, or verification.
|
||||
|
||||
**Related**: [commands.md](commands.md) for full command reference, [SKILL.md](../SKILL.md) for quick start.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Basic Recording](#basic-recording)
|
||||
- [Recording Commands](#recording-commands)
|
||||
- [Use Cases](#use-cases)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Output Format](#output-format)
|
||||
- [Limitations](#limitations)
|
||||
|
||||
## Basic Recording
|
||||
|
||||
```bash
|
||||
# Start recording
|
||||
agent-browser record start ./demo.webm
|
||||
|
||||
# Perform actions
|
||||
agent-browser open https://example.com
|
||||
agent-browser snapshot -i
|
||||
agent-browser click @e1
|
||||
agent-browser fill @e2 "test input"
|
||||
|
||||
# Stop and save
|
||||
agent-browser record stop
|
||||
```
|
||||
|
||||
## Recording Commands
|
||||
|
||||
```bash
|
||||
# Start recording to file
|
||||
agent-browser record start ./output.webm
|
||||
|
||||
# Stop current recording
|
||||
agent-browser record stop
|
||||
|
||||
# Restart with new file (stops current + starts new)
|
||||
agent-browser record restart ./take2.webm
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Debugging Failed Automation
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Record automation for debugging
|
||||
|
||||
agent-browser record start ./debug-$(date +%Y%m%d-%H%M%S).webm
|
||||
|
||||
# Run your automation
|
||||
agent-browser open https://app.example.com
|
||||
agent-browser snapshot -i
|
||||
agent-browser click @e1 || {
|
||||
echo "Click failed - check recording"
|
||||
agent-browser record stop
|
||||
exit 1
|
||||
}
|
||||
|
||||
agent-browser record stop
|
||||
```
|
||||
|
||||
### Documentation Generation
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Record workflow for documentation
|
||||
|
||||
agent-browser record start ./docs/how-to-login.webm
|
||||
|
||||
agent-browser open https://app.example.com/login
|
||||
agent-browser wait 1000 # Pause for visibility
|
||||
|
||||
agent-browser snapshot -i
|
||||
agent-browser fill @e1 "demo@example.com"
|
||||
agent-browser wait 500
|
||||
|
||||
agent-browser fill @e2 "password"
|
||||
agent-browser wait 500
|
||||
|
||||
agent-browser click @e3
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser wait 1000 # Show result
|
||||
|
||||
agent-browser record stop
|
||||
```
|
||||
|
||||
### CI/CD Test Evidence
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Record E2E test runs for CI artifacts
|
||||
|
||||
TEST_NAME="${1:-e2e-test}"
|
||||
RECORDING_DIR="./test-recordings"
|
||||
mkdir -p "$RECORDING_DIR"
|
||||
|
||||
agent-browser record start "$RECORDING_DIR/$TEST_NAME-$(date +%s).webm"
|
||||
|
||||
# Run test
|
||||
if run_e2e_test; then
|
||||
echo "Test passed"
|
||||
else
|
||||
echo "Test failed - recording saved"
|
||||
fi
|
||||
|
||||
agent-browser record stop
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Add Pauses for Clarity
|
||||
|
||||
```bash
|
||||
# Slow down for human viewing
|
||||
agent-browser click @e1
|
||||
agent-browser wait 500 # Let viewer see result
|
||||
```
|
||||
|
||||
### 2. Use Descriptive Filenames
|
||||
|
||||
```bash
|
||||
# Include context in filename
|
||||
agent-browser record start ./recordings/login-flow-2024-01-15.webm
|
||||
agent-browser record start ./recordings/checkout-test-run-42.webm
|
||||
```
|
||||
|
||||
### 3. Handle Recording in Error Cases
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cleanup() {
|
||||
agent-browser record stop 2>/dev/null || true
|
||||
agent-browser close 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
agent-browser record start ./automation.webm
|
||||
# ... automation steps ...
|
||||
```
|
||||
|
||||
### 4. Combine with Screenshots
|
||||
|
||||
```bash
|
||||
# Record video AND capture key frames
|
||||
agent-browser record start ./flow.webm
|
||||
|
||||
agent-browser open https://example.com
|
||||
agent-browser screenshot ./screenshots/step1-homepage.png
|
||||
|
||||
agent-browser click @e1
|
||||
agent-browser screenshot ./screenshots/step2-after-click.png
|
||||
|
||||
agent-browser record stop
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
- Default format: WebM (VP8/VP9 codec)
|
||||
- Compatible with all modern browsers and video players
|
||||
- Compressed but high quality
|
||||
|
||||
## Limitations
|
||||
|
||||
- Recording adds slight overhead to automation
|
||||
- Large recordings can consume significant disk space
|
||||
- Some headless environments may have codec limitations
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Template: Authenticated Session Workflow
|
||||
# Purpose: Login once, save state, reuse for subsequent runs
|
||||
# Usage: ./authenticated-session.sh <login-url> [state-file]
|
||||
#
|
||||
# RECOMMENDED: Use the auth vault instead of this template:
|
||||
# echo "<pass>" | agent-browser auth save myapp --url <login-url> --username <user> --password-stdin
|
||||
# agent-browser auth login myapp
|
||||
# The auth vault stores credentials securely and the LLM never sees passwords.
|
||||
#
|
||||
# Environment variables:
|
||||
# APP_USERNAME - Login username/email
|
||||
# APP_PASSWORD - Login password
|
||||
#
|
||||
# Two modes:
|
||||
# 1. Discovery mode (default): Shows form structure so you can identify refs
|
||||
# 2. Login mode: Performs actual login after you update the refs
|
||||
#
|
||||
# Setup steps:
|
||||
# 1. Run once to see form structure (discovery mode)
|
||||
# 2. Update refs in LOGIN FLOW section below
|
||||
# 3. Set APP_USERNAME and APP_PASSWORD
|
||||
# 4. Delete the DISCOVERY section
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LOGIN_URL="${1:?Usage: $0 <login-url> [state-file]}"
|
||||
STATE_FILE="${2:-./auth-state.json}"
|
||||
|
||||
echo "Authentication workflow: $LOGIN_URL"
|
||||
|
||||
# ================================================================
|
||||
# SAVED STATE: Skip login if valid saved state exists
|
||||
# ================================================================
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
echo "Loading saved state from $STATE_FILE..."
|
||||
if agent-browser --state "$STATE_FILE" open "$LOGIN_URL" 2>/dev/null; then
|
||||
agent-browser wait --load networkidle
|
||||
|
||||
CURRENT_URL=$(agent-browser get url)
|
||||
if [[ "$CURRENT_URL" != *"login"* ]] && [[ "$CURRENT_URL" != *"signin"* ]]; then
|
||||
echo "Session restored successfully"
|
||||
agent-browser snapshot -i
|
||||
exit 0
|
||||
fi
|
||||
echo "Session expired, performing fresh login..."
|
||||
agent-browser close 2>/dev/null || true
|
||||
else
|
||||
echo "Failed to load state, re-authenticating..."
|
||||
fi
|
||||
rm -f "$STATE_FILE"
|
||||
fi
|
||||
|
||||
# ================================================================
|
||||
# DISCOVERY MODE: Shows form structure (delete after setup)
|
||||
# ================================================================
|
||||
echo "Opening login page..."
|
||||
agent-browser open "$LOGIN_URL"
|
||||
agent-browser wait --load networkidle
|
||||
|
||||
echo ""
|
||||
echo "Login form structure:"
|
||||
echo "---"
|
||||
agent-browser snapshot -i
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Note the refs: username=@e?, password=@e?, submit=@e?"
|
||||
echo " 2. Update the LOGIN FLOW section below with your refs"
|
||||
echo " 3. Set: export APP_USERNAME='...' APP_PASSWORD='...'"
|
||||
echo " 4. Delete this DISCOVERY MODE section"
|
||||
echo ""
|
||||
agent-browser close
|
||||
exit 0
|
||||
|
||||
# ================================================================
|
||||
# LOGIN FLOW: Uncomment and customize after discovery
|
||||
# ================================================================
|
||||
# : "${APP_USERNAME:?Set APP_USERNAME environment variable}"
|
||||
# : "${APP_PASSWORD:?Set APP_PASSWORD environment variable}"
|
||||
#
|
||||
# agent-browser open "$LOGIN_URL"
|
||||
# agent-browser wait --load networkidle
|
||||
# agent-browser snapshot -i
|
||||
#
|
||||
# # Fill credentials (update refs to match your form)
|
||||
# agent-browser fill @e1 "$APP_USERNAME"
|
||||
# agent-browser fill @e2 "$APP_PASSWORD"
|
||||
# agent-browser click @e3
|
||||
# agent-browser wait --load networkidle
|
||||
#
|
||||
# # Verify login succeeded
|
||||
# FINAL_URL=$(agent-browser get url)
|
||||
# if [[ "$FINAL_URL" == *"login"* ]] || [[ "$FINAL_URL" == *"signin"* ]]; then
|
||||
# echo "Login failed - still on login page"
|
||||
# agent-browser screenshot /tmp/login-failed.png
|
||||
# agent-browser close
|
||||
# exit 1
|
||||
# fi
|
||||
#
|
||||
# # Save state for future runs
|
||||
# echo "Saving state to $STATE_FILE"
|
||||
# agent-browser state save "$STATE_FILE"
|
||||
# echo "Login successful"
|
||||
# agent-browser snapshot -i
|
||||
@@ -1,69 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Template: Content Capture Workflow
|
||||
# Purpose: Extract content from web pages (text, screenshots, PDF)
|
||||
# Usage: ./capture-workflow.sh <url> [output-dir]
|
||||
#
|
||||
# Outputs:
|
||||
# - page-full.png: Full page screenshot
|
||||
# - page-structure.txt: Page element structure with refs
|
||||
# - page-text.txt: All text content
|
||||
# - page.pdf: PDF version
|
||||
#
|
||||
# Optional: Load auth state for protected pages
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_URL="${1:?Usage: $0 <url> [output-dir]}"
|
||||
OUTPUT_DIR="${2:-.}"
|
||||
|
||||
echo "Capturing: $TARGET_URL"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# Optional: Load authentication state
|
||||
# if [[ -f "./auth-state.json" ]]; then
|
||||
# echo "Loading authentication state..."
|
||||
# agent-browser state load "./auth-state.json"
|
||||
# fi
|
||||
|
||||
# Navigate to target
|
||||
agent-browser open "$TARGET_URL"
|
||||
agent-browser wait --load networkidle
|
||||
|
||||
# Get metadata
|
||||
TITLE=$(agent-browser get title)
|
||||
URL=$(agent-browser get url)
|
||||
echo "Title: $TITLE"
|
||||
echo "URL: $URL"
|
||||
|
||||
# Capture full page screenshot
|
||||
agent-browser screenshot --full "$OUTPUT_DIR/page-full.png"
|
||||
echo "Saved: $OUTPUT_DIR/page-full.png"
|
||||
|
||||
# Get page structure with refs
|
||||
agent-browser snapshot -i > "$OUTPUT_DIR/page-structure.txt"
|
||||
echo "Saved: $OUTPUT_DIR/page-structure.txt"
|
||||
|
||||
# Extract all text content
|
||||
agent-browser get text body > "$OUTPUT_DIR/page-text.txt"
|
||||
echo "Saved: $OUTPUT_DIR/page-text.txt"
|
||||
|
||||
# Save as PDF
|
||||
agent-browser pdf "$OUTPUT_DIR/page.pdf"
|
||||
echo "Saved: $OUTPUT_DIR/page.pdf"
|
||||
|
||||
# Optional: Extract specific elements using refs from structure
|
||||
# agent-browser get text @e5 > "$OUTPUT_DIR/main-content.txt"
|
||||
|
||||
# Optional: Handle infinite scroll pages
|
||||
# for i in {1..5}; do
|
||||
# agent-browser scroll down 1000
|
||||
# agent-browser wait 1000
|
||||
# done
|
||||
# agent-browser screenshot --full "$OUTPUT_DIR/page-scrolled.png"
|
||||
|
||||
# Cleanup
|
||||
agent-browser close
|
||||
|
||||
echo ""
|
||||
echo "Capture complete:"
|
||||
ls -la "$OUTPUT_DIR"
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Template: Form Automation Workflow
|
||||
# Purpose: Fill and submit web forms with validation
|
||||
# Usage: ./form-automation.sh <form-url>
|
||||
#
|
||||
# This template demonstrates the snapshot-interact-verify pattern:
|
||||
# 1. Navigate to form
|
||||
# 2. Snapshot to get element refs
|
||||
# 3. Fill fields using refs
|
||||
# 4. Submit and verify result
|
||||
#
|
||||
# Customize: Update the refs (@e1, @e2, etc.) based on your form's snapshot output
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
FORM_URL="${1:?Usage: $0 <form-url>}"
|
||||
|
||||
echo "Form automation: $FORM_URL"
|
||||
|
||||
# Step 1: Navigate to form
|
||||
agent-browser open "$FORM_URL"
|
||||
agent-browser wait --load networkidle
|
||||
|
||||
# Step 2: Snapshot to discover form elements
|
||||
echo ""
|
||||
echo "Form structure:"
|
||||
agent-browser snapshot -i
|
||||
|
||||
# Step 3: Fill form fields (customize these refs based on snapshot output)
|
||||
#
|
||||
# Common field types:
|
||||
# agent-browser fill @e1 "John Doe" # Text input
|
||||
# agent-browser fill @e2 "user@example.com" # Email input
|
||||
# agent-browser fill @e3 "SecureP@ss123" # Password input
|
||||
# agent-browser select @e4 "Option Value" # Dropdown
|
||||
# agent-browser check @e5 # Checkbox
|
||||
# agent-browser click @e6 # Radio button
|
||||
# agent-browser fill @e7 "Multi-line text" # Textarea
|
||||
# agent-browser upload @e8 /path/to/file.pdf # File upload
|
||||
#
|
||||
# Uncomment and modify:
|
||||
# agent-browser fill @e1 "Test User"
|
||||
# agent-browser fill @e2 "test@example.com"
|
||||
# agent-browser click @e3 # Submit button
|
||||
|
||||
# Step 4: Wait for submission
|
||||
# agent-browser wait --load networkidle
|
||||
# agent-browser wait --url "**/success" # Or wait for redirect
|
||||
|
||||
# Step 5: Verify result
|
||||
echo ""
|
||||
echo "Result:"
|
||||
agent-browser get url
|
||||
agent-browser snapshot -i
|
||||
|
||||
# Optional: Capture evidence
|
||||
agent-browser screenshot /tmp/form-result.png
|
||||
echo "Screenshot saved: /tmp/form-result.png"
|
||||
|
||||
# Cleanup
|
||||
agent-browser close
|
||||
echo "Done"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user