mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
[Self-host] Add CDP streaming defaults and diagnostics (#5875)
Co-authored-by: Marc Kelechava <marc.kelechava@gmail.com> Co-authored-by: OmX <omx@oh-my-codex.dev> Co-authored-by: Marc Kelechava <marc@skyvern.com>
This commit is contained in:
parent
e666d5b0c5
commit
2dc25e6146
32 changed files with 1951 additions and 115 deletions
10
.env.example
10
.env.example
|
|
@ -1,8 +1,9 @@
|
|||
# Environment that the agent will run in.
|
||||
ENV=local
|
||||
|
||||
# Browser streaming mode: "cdp" for CDP screencast, "vnc" (default) for VNC streaming.
|
||||
BROWSER_STREAMING_MODE=vnc
|
||||
# Browser streaming mode: "cdp" for local CDP screencast, "vnc" for VNC streaming.
|
||||
# Quickstart writes "cdp" for new local installs; app code falls back to "vnc" if unset.
|
||||
BROWSER_STREAMING_MODE=cdp
|
||||
|
||||
# LLM Provider Configurations:
|
||||
# ENABLE_OPENAI: Set to true to enable OpenAI as a language model provider.
|
||||
|
|
@ -72,6 +73,11 @@ SECONDARY_LLM_KEY=""
|
|||
# Web browser configuration for scraping:
|
||||
# BROWSER_TYPE: Can be either "chromium-headless" or "chromium-headful".
|
||||
BROWSER_TYPE="chromium-headful"
|
||||
# BROWSER_REMOTE_DEBUGGING_HOST_HEADER: Optional Host header for cdp-connect.
|
||||
# Windows chrome://inspect Docker bridges may need this set to 127.0.0.1:<chrome-port>.
|
||||
BROWSER_REMOTE_DEBUGGING_HOST_HEADER=
|
||||
# BROWSER_CDP_CONNECT_TIMEOUT_MS: Timeout for cdp-connect startup/approval in milliseconds.
|
||||
BROWSER_CDP_CONNECT_TIMEOUT_MS=120000
|
||||
# MAX_SCRAPING_RETRIES: Number of times to retry scraping a page before giving up, currently set to 0.
|
||||
MAX_SCRAPING_RETRIES=0
|
||||
# VIDEO_PATH: Path to the directory where videos will be saved.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ ENV VITE_API_BASE_URL=__VITE_API_BASE_URL_PLACEHOLDER__
|
|||
ENV VITE_WSS_BASE_URL=__VITE_WSS_BASE_URL_PLACEHOLDER__
|
||||
ENV VITE_ARTIFACT_API_BASE_URL=__VITE_ARTIFACT_API_BASE_URL_PLACEHOLDER__
|
||||
ENV VITE_SKYVERN_API_KEY=__SKYVERN_API_KEY_PLACEHOLDER__
|
||||
ENV VITE_BROWSER_STREAMING_MODE=__VITE_BROWSER_STREAMING_MODE_PLACEHOLDER__
|
||||
|
||||
# APP_VERSION is baked into the JS bundle at build time via Vite's define.
|
||||
# Pass --build-arg APP_VERSION=$(git rev-parse HEAD) when building the image.
|
||||
|
|
@ -25,4 +26,3 @@ RUN npm run build
|
|||
# Use tini as init for proper signal handling and zombie reaping
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["/bin/bash", "/app/entrypoint-skyvernui.sh"]
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ services:
|
|||
retries: 5
|
||||
skyvern:
|
||||
image: public.ecr.aws/skyvern/skyvern:latest
|
||||
# For local backend development, replace the image line above with:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
# Or keep the public image and uncomment the source mounts below.
|
||||
restart: on-failure
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
|
@ -28,7 +33,6 @@ services:
|
|||
ports:
|
||||
- 8000:8000
|
||||
- 6080:6080 # for VNC WebSocket streaming
|
||||
- 9222:9222 # for cdp browser forwarding
|
||||
volumes:
|
||||
- ./artifacts:/data/artifacts
|
||||
- ./videos:/data/videos
|
||||
|
|
@ -53,14 +57,20 @@ services:
|
|||
# .env owns secrets.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
- DATABASE_STRING=postgresql+psycopg://skyvern:skyvern@postgres:5432/skyvern
|
||||
- BROWSER_TYPE=chromium-headful
|
||||
- BROWSER_STREAMING_MODE=${BROWSER_STREAMING_MODE:-cdp}
|
||||
- BROWSER_TYPE=${BROWSER_TYPE:-chromium-headful}
|
||||
- BROWSER_REMOTE_DEBUGGING_URL=${BROWSER_REMOTE_DEBUGGING_URL:-http://127.0.0.1:9222}
|
||||
- BROWSER_REMOTE_DEBUGGING_HOST_HEADER=${BROWSER_REMOTE_DEBUGGING_HOST_HEADER:-}
|
||||
- BROWSER_CDP_CONNECT_TIMEOUT_MS=${BROWSER_CDP_CONNECT_TIMEOUT_MS:-120000}
|
||||
- ENABLE_CODE_BLOCK=true
|
||||
# --- Control your own browser (Chrome) ---
|
||||
# 1. Open Chrome and navigate to: chrome://inspect/#remote-debugging
|
||||
# 2. Click "Enable" to start the debugging server
|
||||
# 3. Uncomment the two lines below:
|
||||
# --- Control your own browser (Chrome/Chromium) ---
|
||||
# Start Chrome with --remote-debugging-address=0.0.0.0 so Docker can reach it.
|
||||
# On Windows, scripts/windows_chrome_inspect_cdp.ps1 can bridge a
|
||||
# chrome://inspect/#remote-debugging listener and write the full ws:// URL.
|
||||
# Then set:
|
||||
# - BROWSER_TYPE=cdp-connect
|
||||
# - BROWSER_REMOTE_DEBUGGING_URL=http://host.docker.internal:9222/
|
||||
# See docs/developers/self-hosted/browser.mdx for Windows/Docker Desktop notes.
|
||||
# =========================
|
||||
# LLM Settings - Use `skyvern init llm` for interactive setup
|
||||
# =========================
|
||||
|
|
@ -138,6 +148,10 @@ services:
|
|||
start_period: 180s
|
||||
skyvern-ui:
|
||||
image: public.ecr.aws/skyvern/skyvern-ui:latest
|
||||
# For local UI development/testing, replace the image line above with:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile.ui
|
||||
restart: on-failure
|
||||
ports:
|
||||
- 8080:8080
|
||||
|
|
@ -148,11 +162,13 @@ services:
|
|||
- ./har:/data/har
|
||||
# Generated credentials allow the UI to pick up the local API key on first startup.
|
||||
- ./.skyvern:/app/.skyvern
|
||||
# User secrets and frontend config live in skyvern-frontend/.env
|
||||
# (loaded via env_file). No inline environment for the UI service.
|
||||
# User secrets and most frontend config live in skyvern-frontend/.env
|
||||
# (loaded via env_file). CDP streaming is inline so local livestreaming is
|
||||
# enabled even if an older frontend .env is present.
|
||||
env_file:
|
||||
- skyvern-frontend/.env
|
||||
environment: {}
|
||||
environment:
|
||||
- VITE_BROWSER_STREAMING_MODE=${VITE_BROWSER_STREAMING_MODE:-cdp}
|
||||
# - VITE_ENABLE_CODE_BLOCK=true
|
||||
# if you want to run skyvern on a remote server,
|
||||
# you need to change the host in VITE_WSS_BASE_URL and VITE_API_BASE_URL to match your server ip
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ Useful for:
|
|||
```bash .env
|
||||
BROWSER_TYPE=cdp-connect
|
||||
BROWSER_REMOTE_DEBUGGING_URL=http://host.docker.internal:9222/
|
||||
BROWSER_CDP_CONNECT_TIMEOUT_MS=120000
|
||||
```
|
||||
|
||||
Connect to an existing Chrome instance running with remote debugging enabled. Useful for:
|
||||
|
|
@ -71,6 +72,12 @@ Connect to an existing Chrome instance running with remote debugging enabled. Us
|
|||
- Debugging with Chrome DevTools
|
||||
- Running automations on a browser with specific extensions
|
||||
|
||||
<Note>
|
||||
Docker Compose self-host installs default `BROWSER_STREAMING_MODE` to `cdp`.
|
||||
Existing installs that already set this variable in `.env` keep their value.
|
||||
To opt out of CDP livestreaming, set `BROWSER_STREAMING_MODE=vnc` in `.env`.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Setting up Use Your Own Chrome
|
||||
|
|
@ -134,12 +141,16 @@ If port 9222 is already in use (e.g., from a previous session), Skyvern will con
|
|||
|
||||
CDP (Chrome DevTools Protocol) lets Skyvern control an external Chrome browser instead of launching its own.
|
||||
|
||||
### Step 1: Start Chrome with remote debugging
|
||||
### Step 1: Enable Chrome remote debugging
|
||||
|
||||
When Skyvern runs in Docker, Chrome must listen on an address the container can
|
||||
reach. Start Chrome manually with `--remote-debugging-address=0.0.0.0`:
|
||||
|
||||
<CodeGroup>
|
||||
```bash macOS
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--remote-debugging-address=0.0.0.0 \
|
||||
--user-data-dir="$HOME/chrome-cdp-profile" \
|
||||
--no-first-run \
|
||||
--no-default-browser-check
|
||||
|
|
@ -148,6 +159,7 @@ CDP (Chrome DevTools Protocol) lets Skyvern control an external Chrome browser i
|
|||
```powershell Windows
|
||||
"C:\Program Files\Google\Chrome\Application\chrome.exe" `
|
||||
--remote-debugging-port=9222 `
|
||||
--remote-debugging-address=0.0.0.0 `
|
||||
--user-data-dir="C:\chrome-cdp-profile" `
|
||||
--no-first-run `
|
||||
--no-default-browser-check
|
||||
|
|
@ -156,6 +168,7 @@ CDP (Chrome DevTools Protocol) lets Skyvern control an external Chrome browser i
|
|||
```bash Linux
|
||||
google-chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--remote-debugging-address=0.0.0.0 \
|
||||
--user-data-dir="$HOME/chrome-cdp-profile" \
|
||||
--no-first-run \
|
||||
--no-default-browser-check
|
||||
|
|
@ -164,11 +177,44 @@ google-chrome \
|
|||
|
||||
The `--user-data-dir` flag creates a separate profile for Skyvern, preserving your main Chrome profile.
|
||||
|
||||
#### Windows Docker Desktop with `chrome://inspect`
|
||||
|
||||
Chrome's `chrome://inspect/#remote-debugging` toggle can bind its debugging
|
||||
server to host loopback only. Docker Desktop cannot connect to that listener
|
||||
directly through `host.docker.internal`. Skyvern includes a Windows helper that
|
||||
bridges Docker to Chrome and writes the exact WebSocket URL from Chrome's
|
||||
`DevToolsActivePort` file.
|
||||
|
||||
Open Chrome, go to `chrome://inspect/#remote-debugging`, enable remote
|
||||
debugging, then run this from an Administrator PowerShell in the Skyvern repo:
|
||||
|
||||
```powershell Windows
|
||||
.\scripts\windows_chrome_inspect_cdp.ps1 -UpdateEnv
|
||||
docker compose up -d --force-recreate skyvern
|
||||
```
|
||||
|
||||
The helper creates a Windows port proxy from `0.0.0.0:9223` to Chrome's
|
||||
loopback debugging port, adds a matching firewall rule, reads the full browser
|
||||
WebSocket path, and writes:
|
||||
|
||||
```bash .env
|
||||
BROWSER_TYPE=cdp-connect
|
||||
BROWSER_REMOTE_DEBUGGING_URL=ws://host.docker.internal:9223/devtools/browser/<id>
|
||||
BROWSER_REMOTE_DEBUGGING_HOST_HEADER=127.0.0.1:<chrome-port>
|
||||
BROWSER_STREAMING_MODE=cdp
|
||||
BROWSER_CDP_CONNECT_TIMEOUT_MS=120000
|
||||
```
|
||||
|
||||
Chrome may prompt you to allow the remote debugging connection the first time
|
||||
Skyvern connects. Click **Allow** and retry the browser session if the first
|
||||
attempt times out.
|
||||
|
||||
### Step 2: Configure Skyvern
|
||||
|
||||
```bash .env
|
||||
BROWSER_TYPE=cdp-connect
|
||||
BROWSER_REMOTE_DEBUGGING_URL=http://host.docker.internal:9222/
|
||||
BROWSER_CDP_CONNECT_TIMEOUT_MS=120000
|
||||
```
|
||||
|
||||
When running Skyvern in Docker:
|
||||
|
|
@ -178,18 +224,46 @@ When running Skyvern in Docker:
|
|||
| macOS/Windows | `http://host.docker.internal:9222/` |
|
||||
| Linux | `http://172.17.0.1:9222/` |
|
||||
|
||||
If Docker Desktop reaches Chrome but Chrome rejects the `host.docker.internal`
|
||||
host name, Skyvern retries with the Docker host gateway IPv4 address. You can
|
||||
also set `BROWSER_REMOTE_DEBUGGING_URL` directly to that IPv4 address.
|
||||
|
||||
If Windows Defender Firewall blocks the connection from Docker, allow inbound
|
||||
TCP traffic on port 9222:
|
||||
|
||||
```powershell Windows
|
||||
New-NetFirewallRule -DisplayName "Chrome Remote Debug 9222" -Direction Inbound -LocalPort 9222 -Protocol TCP -Action Allow
|
||||
```
|
||||
|
||||
### Step 3: Verify connection
|
||||
|
||||
Test that Skyvern can reach Chrome:
|
||||
For a classic HTTP CDP endpoint, test that Chrome is listening on port 9222:
|
||||
|
||||
```bash
|
||||
curl http://localhost:9222/json/version
|
||||
```
|
||||
|
||||
You should see Chrome's version information.
|
||||
```powershell Windows
|
||||
Invoke-RestMethod http://127.0.0.1:9222/json/version
|
||||
```
|
||||
|
||||
You should see Chrome's version information and a `webSocketDebuggerUrl`.
|
||||
|
||||
If you used the Windows `chrome://inspect` helper, the configured URL is a
|
||||
direct `ws://.../devtools/browser/<id>` endpoint. In that mode, verify by
|
||||
starting a Skyvern browser session after restarting the backend.
|
||||
|
||||
<Note>
|
||||
If `/json/version` does not return Chrome version information from inside the
|
||||
Skyvern container, verify Chrome is listening on `0.0.0.0:9222` or `::9222` and
|
||||
that Windows Firewall allows inbound traffic on port 9222.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
CDP mode exposes Chrome to network access. Only use this in trusted environments and bind to localhost when possible.
|
||||
CDP mode exposes Chrome to network access. The examples use
|
||||
`--remote-debugging-address=0.0.0.0` so Docker can reach Chrome on the host.
|
||||
Only use this in trusted environments. If Skyvern runs on the same host outside
|
||||
Docker, bind to `127.0.0.1` instead.
|
||||
</Warning>
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ The `.env` file controls the Skyvern server. Here are the most important variabl
|
|||
|
||||
`docker-compose.yml` uses TWO sources for service environment:
|
||||
|
||||
1. **Inline `environment:` block** — compose-network values that depend on the docker network topology. Examples: `DATABASE_STRING=postgresql+psycopg://skyvern:skyvern@postgres:5432/skyvern` (the `postgres` hostname only resolves inside the compose network), `BROWSER_TYPE`, `ENABLE_CODE_BLOCK`.
|
||||
1. **Inline `environment:` block** — compose-network values that depend on the docker network topology. Examples: `DATABASE_STRING=postgresql+psycopg://skyvern:skyvern@postgres:5432/skyvern` (the `postgres` hostname only resolves inside the compose network), `BROWSER_TYPE`, `BROWSER_STREAMING_MODE`, `ENABLE_CODE_BLOCK`.
|
||||
|
||||
2. **`env_file: .env`** — user secrets (LLM API keys, etc.).
|
||||
|
||||
|
|
@ -194,6 +194,9 @@ LLM_KEY=ANTHROPIC_CLAUDE4.7_OPUS
|
|||
# Browser mode: chromium-headful (visible) or chromium-headless (no display)
|
||||
BROWSER_TYPE=chromium-headful
|
||||
|
||||
# Browser livestreaming mode: cdp (default local screencast) or vnc
|
||||
BROWSER_STREAMING_MODE=cdp
|
||||
|
||||
# Timeout for individual browser actions (milliseconds)
|
||||
BROWSER_ACTION_TIMEOUT_MS=5000
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ set -e
|
|||
VITE_API_BASE_URL="${VITE_API_BASE_URL:-http://localhost:8000/api/v1}"
|
||||
VITE_WSS_BASE_URL="${VITE_WSS_BASE_URL:-ws://localhost:8000/api/v1}"
|
||||
VITE_ARTIFACT_API_BASE_URL="${VITE_ARTIFACT_API_BASE_URL:-http://localhost:9090}"
|
||||
VITE_BROWSER_STREAMING_MODE="${VITE_BROWSER_STREAMING_MODE:-vnc}"
|
||||
|
||||
# Priority for VITE_SKYVERN_API_KEY:
|
||||
# 1. Environment variable (from .env file or docker-compose environment),
|
||||
|
|
@ -31,6 +32,7 @@ find /app/dist -name "*.js" -exec sed -i \
|
|||
-e "s|__VITE_WSS_BASE_URL_PLACEHOLDER__|${VITE_WSS_BASE_URL}|g" \
|
||||
-e "s|__VITE_ARTIFACT_API_BASE_URL_PLACEHOLDER__|${VITE_ARTIFACT_API_BASE_URL}|g" \
|
||||
-e "s|__SKYVERN_API_KEY_PLACEHOLDER__|${VITE_SKYVERN_API_KEY}|g" \
|
||||
-e "s|__VITE_BROWSER_STREAMING_MODE_PLACEHOLDER__|${VITE_BROWSER_STREAMING_MODE}|g" \
|
||||
{} \;
|
||||
|
||||
# Start the servers (no rebuild needed)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "skyvern"
|
||||
version = "1.0.34"
|
||||
version = "1.0.35"
|
||||
description = ""
|
||||
authors = [{ name = "Skyvern AI", email = "info@skyvern.com" }]
|
||||
requires-python = ">=3.11,<3.14"
|
||||
|
|
|
|||
111
scripts/windows_chrome_inspect_cdp.ps1
Normal file
111
scripts/windows_chrome_inspect_cdp.ps1
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
param(
|
||||
[int]$ProxyPort = 9223,
|
||||
[string]$EnvPath = ".env",
|
||||
[switch]$UpdateEnv,
|
||||
[switch]$NoFirewall
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Find-DevToolsActivePort {
|
||||
$roots = @()
|
||||
if ($env:LOCALAPPDATA) {
|
||||
$roots += Join-Path $env:LOCALAPPDATA "Google\Chrome\User Data"
|
||||
}
|
||||
if ($env:TEMP) {
|
||||
$roots += $env:TEMP
|
||||
}
|
||||
|
||||
$files = foreach ($root in $roots) {
|
||||
if (Test-Path $root) {
|
||||
Get-ChildItem $root -Recurse -Filter DevToolsActivePort -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
$files | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
}
|
||||
|
||||
function Set-EnvValue {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$Name,
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
$line = "$Name=$Value"
|
||||
if (Test-Path $Path) {
|
||||
$contents = @(Get-Content $Path)
|
||||
$updated = $false
|
||||
$contents = $contents | ForEach-Object {
|
||||
if ($_ -match "^\s*$([regex]::Escape($Name))\s*=") {
|
||||
$updated = $true
|
||||
$line
|
||||
} else {
|
||||
$_
|
||||
}
|
||||
}
|
||||
if (-not $updated) {
|
||||
$contents += $line
|
||||
}
|
||||
$contents | Set-Content -Encoding ascii $Path
|
||||
} else {
|
||||
$line | Set-Content -Encoding ascii $Path
|
||||
}
|
||||
}
|
||||
|
||||
$portFile = Find-DevToolsActivePort
|
||||
if (-not $portFile) {
|
||||
Write-Error "DevToolsActivePort was not found. Open Chrome, go to chrome://inspect/#remote-debugging, enable remote debugging, then rerun this script."
|
||||
}
|
||||
|
||||
$lines = Get-Content $portFile.FullName
|
||||
if ($lines.Count -lt 2) {
|
||||
Write-Error "DevToolsActivePort at $($portFile.FullName) did not contain a port and browser path."
|
||||
}
|
||||
|
||||
$chromePort = [int]$lines[0]
|
||||
$browserPath = [string]$lines[1]
|
||||
if (-not $browserPath.StartsWith("/devtools/browser/")) {
|
||||
Write-Error "DevToolsActivePort path '$browserPath' is not a browser websocket endpoint."
|
||||
}
|
||||
|
||||
Write-Host "Found Chrome inspect endpoint:" -ForegroundColor Cyan
|
||||
Write-Host " File: $($portFile.FullName)"
|
||||
Write-Host " Chrome port: $chromePort"
|
||||
Write-Host " Browser path: $browserPath"
|
||||
|
||||
& netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=$ProxyPort *> $null
|
||||
& netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=$ProxyPort connectaddress=127.0.0.1 connectport=$chromePort
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to create Windows portproxy. Rerun PowerShell as Administrator."
|
||||
}
|
||||
|
||||
if (-not $NoFirewall) {
|
||||
$ruleName = "Skyvern Chrome Inspect Proxy $ProxyPort"
|
||||
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
|
||||
if (-not $existingRule) {
|
||||
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -LocalPort $ProxyPort -Protocol TCP -Action Allow | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
$remoteUrl = "ws://host.docker.internal:$ProxyPort$browserPath"
|
||||
$hostHeader = "127.0.0.1:$chromePort"
|
||||
|
||||
if ($UpdateEnv) {
|
||||
Set-EnvValue -Path $EnvPath -Name "BROWSER_TYPE" -Value "cdp-connect"
|
||||
Set-EnvValue -Path $EnvPath -Name "BROWSER_REMOTE_DEBUGGING_URL" -Value $remoteUrl
|
||||
Set-EnvValue -Path $EnvPath -Name "BROWSER_REMOTE_DEBUGGING_HOST_HEADER" -Value $hostHeader
|
||||
Set-EnvValue -Path $EnvPath -Name "BROWSER_STREAMING_MODE" -Value "cdp"
|
||||
Set-EnvValue -Path $EnvPath -Name "BROWSER_CDP_CONNECT_TIMEOUT_MS" -Value "120000"
|
||||
Write-Host "Updated $EnvPath for Skyvern Docker Compose." -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Use this Skyvern setting:" -ForegroundColor Green
|
||||
Write-Host "BROWSER_REMOTE_DEBUGGING_URL=$remoteUrl"
|
||||
Write-Host "BROWSER_REMOTE_DEBUGGING_HOST_HEADER=$hostHeader"
|
||||
Write-Host ""
|
||||
Write-Host "Restart the backend after changing .env:"
|
||||
Write-Host "docker compose up -d --force-recreate skyvern"
|
||||
Write-Host ""
|
||||
Write-Host "Chrome may prompt to allow remote debugging on first connection. Click Allow, then retry if the first attempt times out."
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
# Browser streaming mode:
|
||||
# - cdp: use CDP screencast (for local development without VNC)
|
||||
# - vnc (default): use VNC streaming
|
||||
VITE_BROWSER_STREAMING_MODE=vnc
|
||||
# - cdp: use CDP screencast for local livestreaming
|
||||
# - vnc: use VNC streaming
|
||||
# Quickstart writes cdp for new local installs; app code falls back to vnc if unset.
|
||||
VITE_BROWSER_STREAMING_MODE=cdp
|
||||
|
||||
VITE_API_BASE_URL=http://localhost:8000/api/v1
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
persistRuntimeApiKey,
|
||||
clearRuntimeApiKey,
|
||||
} from "@/util/env";
|
||||
import { useAuthIssueStore } from "@/store/AuthIssueStore";
|
||||
import axios from "axios";
|
||||
|
||||
type ApiVersion = "sans-api-v1" | "v1" | "v2";
|
||||
|
|
@ -51,6 +52,39 @@ const artifactApiClient = axios.create({
|
|||
|
||||
const clients = [client, v2Client, clientSansApiV1] as const;
|
||||
|
||||
function getResponseDetail(data: unknown): string | undefined {
|
||||
if (data && typeof data === "object" && "detail" in data) {
|
||||
const detail = (data as { detail?: unknown }).detail;
|
||||
return typeof detail === "string" ? detail : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
clients.forEach((instance) => {
|
||||
instance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const statusCode = error.response?.status;
|
||||
const detail = getResponseDetail(error.response?.data);
|
||||
const isAuthFailure =
|
||||
statusCode === 401 ||
|
||||
statusCode === 403 ||
|
||||
(statusCode === 404 && detail === "Organization not found");
|
||||
|
||||
if (statusCode && isAuthFailure) {
|
||||
useAuthIssueStore.getState().reportAuthIssue({
|
||||
statusCode,
|
||||
detail,
|
||||
path: error.config?.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function setHeaderForAllClients(header: string, value: string) {
|
||||
clients.forEach((instance) => {
|
||||
instance.defaults.headers.common[header] = value;
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@ import {
|
|||
AuthStatusValue,
|
||||
useAuthDiagnostics,
|
||||
} from "@/hooks/useAuthDiagnostics";
|
||||
import { useAuthIssueStore } from "@/store/AuthIssueStore";
|
||||
|
||||
type BannerStatus = Exclude<AuthStatusValue, "ok"> | "error";
|
||||
type BannerStatus =
|
||||
| Exclude<AuthStatusValue, "ok">
|
||||
| "request_auth_error"
|
||||
| "error";
|
||||
|
||||
function getCopy(status: BannerStatus): { title: string; description: string } {
|
||||
switch (status) {
|
||||
|
|
@ -43,6 +47,12 @@ function getCopy(status: BannerStatus): { title: string; description: string } {
|
|||
description:
|
||||
"The backend could not find the Skyvern-local organization. Regenerate the key to recreate it.",
|
||||
};
|
||||
case "request_auth_error":
|
||||
return {
|
||||
title: "Skyvern API requests are unauthorized",
|
||||
description:
|
||||
"The backend rejected a UI request. This usually means the frontend API key, backend API key, and local Docker database are out of sync.",
|
||||
};
|
||||
case "error":
|
||||
default:
|
||||
return {
|
||||
|
|
@ -55,6 +65,8 @@ function getCopy(status: BannerStatus): { title: string; description: string } {
|
|||
|
||||
function SelfHealApiKeyBanner() {
|
||||
const diagnosticsQuery = useAuthDiagnostics();
|
||||
const authIssue = useAuthIssueStore((state) => state.issue);
|
||||
const clearAuthIssue = useAuthIssueStore((state) => state.clearAuthIssue);
|
||||
const { toast } = useToast();
|
||||
const [isRepairing, setIsRepairing] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
|
@ -67,6 +79,8 @@ function SelfHealApiKeyBanner() {
|
|||
? "error"
|
||||
: rawStatus && rawStatus !== "ok"
|
||||
? rawStatus
|
||||
: authIssue
|
||||
? "request_auth_error"
|
||||
: null;
|
||||
|
||||
if (!bannerStatus && !errorMessage) {
|
||||
|
|
@ -103,6 +117,7 @@ function SelfHealApiKeyBanner() {
|
|||
}
|
||||
|
||||
setApiKeyHeader(apiKey);
|
||||
clearAuthIssue();
|
||||
|
||||
const fingerprintSuffix = fingerprint
|
||||
? ` (fingerprint ${fingerprint})`
|
||||
|
|
@ -172,6 +187,20 @@ function SelfHealApiKeyBanner() {
|
|||
{data?.next_step ? (
|
||||
<p className="text-xs text-slate-300">{data.next_step}</p>
|
||||
) : null}
|
||||
{authIssue ? (
|
||||
<p className="text-xs text-slate-300">
|
||||
Last failed request: HTTP {authIssue.statusCode}
|
||||
{authIssue.path ? ` at ${authIssue.path}` : ""}
|
||||
{authIssue.detail ? ` (${authIssue.detail})` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{bannerStatus === "request_auth_error" ? (
|
||||
<p className="text-yellow-300">
|
||||
Run <code>skyvern doctor --fix</code> from the cloned Skyvern
|
||||
directory, then restart Docker Compose if the command asks you
|
||||
to.
|
||||
</p>
|
||||
) : null}
|
||||
{isProductionBuild && (
|
||||
<p className="text-yellow-300">
|
||||
When running a production build, the regenerated API key is
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { toast } from "@/components/ui/use-toast";
|
|||
import { ZoomableImage } from "@/components/ZoomableImage";
|
||||
import { useCostCalculator } from "@/hooks/useCostCalculator";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { getCredentialParam } from "@/util/env";
|
||||
import { browserStreamingMode, getCredentialParam } from "@/util/env";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useQuery,
|
||||
|
|
@ -70,10 +70,12 @@ function TaskActions() {
|
|||
const taskIsNotFinalized = task && statusIsNotFinalized(task);
|
||||
const taskIsRunningOrQueued = task && statusIsRunningOrQueued(task);
|
||||
const browserSessionId = task?.browser_session_id;
|
||||
const shouldUseCdpStream = browserStreamingMode === "cdp";
|
||||
|
||||
useEffect(() => {
|
||||
// Skip screenshot WebSocket if VNC streaming is available via browser_session_id
|
||||
if (browserSessionId) {
|
||||
// In VNC mode, BrowserStream handles live sessions. In CDP mode, this
|
||||
// screenshot WebSocket is the live stream.
|
||||
if (browserSessionId && !shouldUseCdpStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -149,6 +151,7 @@ function TaskActions() {
|
|||
taskId,
|
||||
taskIsRunningOrQueued,
|
||||
queryClient,
|
||||
shouldUseCdpStream,
|
||||
]);
|
||||
|
||||
const { data: steps, isLoading: stepsIsLoading } = useQuery<
|
||||
|
|
@ -204,7 +207,8 @@ function TaskActions() {
|
|||
|
||||
function getStream() {
|
||||
// Use VNC streaming via BrowserStream when browser_session_id is available
|
||||
if (browserSessionId) {
|
||||
// and CDP local livestreaming is not enabled.
|
||||
if (browserSessionId && !shouldUseCdpStream) {
|
||||
return (
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<BrowserStream
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { browserStreamingMode } from "@/util/env";
|
||||
|
||||
export type ActionItem = {
|
||||
block: WorkflowRunBlock;
|
||||
|
|
@ -112,8 +113,11 @@ function WorkflowRunOverview() {
|
|||
selection.block_type === "human_interaction"))
|
||||
);
|
||||
|
||||
const shouldShowBrowserStream = wantsVncStream && !vncFailed;
|
||||
const shouldShowScreencastFallback = wantsVncStream && vncFailed;
|
||||
const shouldUseCdpStream = browserStreamingMode === "cdp";
|
||||
const shouldShowBrowserStream =
|
||||
wantsVncStream && !shouldUseCdpStream && !vncFailed;
|
||||
const shouldShowScreencastFallback =
|
||||
wantsVncStream && (shouldUseCdpStream || vncFailed);
|
||||
|
||||
const isStreamActive =
|
||||
shouldShowBrowserStream ||
|
||||
|
|
|
|||
27
skyvern-frontend/src/store/AuthIssueStore.ts
Normal file
27
skyvern-frontend/src/store/AuthIssueStore.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
type AuthIssue = {
|
||||
statusCode: number;
|
||||
detail?: string;
|
||||
path?: string;
|
||||
seenAt: number;
|
||||
};
|
||||
|
||||
type AuthIssueStore = {
|
||||
issue: AuthIssue | null;
|
||||
reportAuthIssue: (issue: Omit<AuthIssue, "seenAt">) => void;
|
||||
clearAuthIssue: () => void;
|
||||
};
|
||||
|
||||
const useAuthIssueStore = create<AuthIssueStore>((set) => ({
|
||||
issue: null,
|
||||
reportAuthIssue: (issue) => {
|
||||
set({ issue: { ...issue, seenAt: Date.now() } });
|
||||
},
|
||||
clearAuthIssue: () => {
|
||||
set({ issue: null });
|
||||
},
|
||||
}));
|
||||
|
||||
export { useAuthIssueStore };
|
||||
export type { AuthIssue };
|
||||
|
|
@ -42,3 +42,24 @@ describe("getCredentialParam", () => {
|
|||
expect(params.get("token")).toBe("Bearer clerk token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("browserStreamingMode", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("preserves VNC behavior when no streaming mode is configured", async () => {
|
||||
const { browserStreamingMode } = await loadEnv();
|
||||
|
||||
expect(browserStreamingMode).toBe("vnc");
|
||||
});
|
||||
|
||||
it("uses the configured streaming mode when present", async () => {
|
||||
vi.stubEnv("VITE_BROWSER_STREAMING_MODE", "CDP");
|
||||
|
||||
const { browserStreamingMode } = await loadEnv();
|
||||
|
||||
expect(browserStreamingMode).toBe("cdp");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ if (!environment) {
|
|||
console.warn("environment environment variable was not set");
|
||||
}
|
||||
|
||||
const browserStreamingMode =
|
||||
(import.meta.env.VITE_BROWSER_STREAMING_MODE as string) ?? "vnc";
|
||||
const browserStreamingMode = (
|
||||
(import.meta.env.VITE_BROWSER_STREAMING_MODE as string | undefined) || "vnc"
|
||||
).toLowerCase();
|
||||
|
||||
const buildTimeApiKey: string | null =
|
||||
typeof import.meta.env.VITE_SKYVERN_API_KEY === "string"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
|
@ -26,9 +24,9 @@ _CDP_SCAN_PORTS = [9222, 9223, 9224, 9225, 9226, 9229]
|
|||
def _check_cdp_ws(port: int) -> Optional[dict]:
|
||||
"""Try a WebSocket CDP handshake on the given port.
|
||||
|
||||
Chrome's chrome://inspect#remote-debugging exposes a WS-only CDP server
|
||||
(no /json/version HTTP endpoint). This sends Browser.getVersion over WS
|
||||
to detect it.
|
||||
Some CDP-compatible servers expose a WebSocket endpoint without the
|
||||
/json/version HTTP discovery endpoint. This sends Browser.getVersion over
|
||||
WS to detect those endpoints.
|
||||
|
||||
Returns the version info dict if successful, else None.
|
||||
"""
|
||||
|
|
@ -115,37 +113,46 @@ def _print_cdp_info(url: str, cached_info: dict | None = None) -> None:
|
|||
console.print(f" WebSocket URL: [dim]{info['webSocketDebuggerUrl']}[/dim]")
|
||||
|
||||
|
||||
def _open_chrome_inspect() -> None:
|
||||
"""Open chrome://inspect/#remote-debugging in the user's default browser."""
|
||||
def _classic_cdp_launch_command() -> str:
|
||||
system = platform.system()
|
||||
url = "chrome://inspect/#remote-debugging"
|
||||
try:
|
||||
if system == "Darwin":
|
||||
subprocess.Popen(
|
||||
["open", "-a", "Google Chrome", url],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
return (
|
||||
"/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome \\\n"
|
||||
" --remote-debugging-port=9222 \\\n"
|
||||
" --remote-debugging-address=0.0.0.0 \\\n"
|
||||
' --user-data-dir="$HOME/skyvern-chrome-cdp" \\\n'
|
||||
" --no-first-run \\\n"
|
||||
" --no-default-browser-check"
|
||||
)
|
||||
elif system == "Windows":
|
||||
subprocess.Popen(
|
||||
["start", "chrome", url],
|
||||
shell=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
if system == "Windows":
|
||||
return (
|
||||
"taskkill /F /IM chrome.exe\n\n"
|
||||
'& "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" `\n'
|
||||
" --remote-debugging-port=9222 `\n"
|
||||
" --remote-debugging-address=0.0.0.0 `\n"
|
||||
' --user-data-dir="$env:TEMP\\skyvern-chrome-cdp" `\n'
|
||||
" --no-first-run `\n"
|
||||
" --no-default-browser-check"
|
||||
)
|
||||
return (
|
||||
"google-chrome \\\n"
|
||||
" --remote-debugging-port=9222 \\\n"
|
||||
" --remote-debugging-address=0.0.0.0 \\\n"
|
||||
' --user-data-dir="$HOME/skyvern-chrome-cdp" \\\n'
|
||||
" --no-first-run \\\n"
|
||||
" --no-default-browser-check"
|
||||
)
|
||||
|
||||
|
||||
def _print_classic_cdp_instructions() -> None:
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold]Enable Chrome remote debugging[/bold]\n\n"
|
||||
"Start Chrome manually with a CDP endpoint that Docker can reach:\n\n"
|
||||
f"[cyan]{_classic_cdp_launch_command()}[/cyan]",
|
||||
border_style="cyan",
|
||||
)
|
||||
else:
|
||||
# Linux — try common Chrome names
|
||||
for cmd in ["google-chrome", "chromium", "chromium-browser"]:
|
||||
if os.path.exists(f"/usr/bin/{cmd}"):
|
||||
subprocess.Popen(
|
||||
[cmd, url],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return
|
||||
console.print(f"[yellow]Could not open Chrome automatically. Please navigate to: {url}[/yellow]")
|
||||
except Exception:
|
||||
console.print(f"[yellow]Could not open Chrome automatically. Please navigate to: {url}[/yellow]")
|
||||
|
||||
|
||||
def _setup_local_browser_clone() -> tuple[str, Optional[str], Optional[str]]:
|
||||
|
|
@ -238,20 +245,8 @@ def _setup_local_browser_actual() -> tuple[str, Optional[str], Optional[str]]:
|
|||
)
|
||||
return "cdp-connect", None, existing
|
||||
|
||||
# Step 2: Guide the user to enable remote debugging
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold]Enable Remote Debugging in Chrome[/bold]\n\n"
|
||||
"1. We'll open [cyan]chrome://inspect/#remote-debugging[/cyan] in your browser\n"
|
||||
"2. Click [bold]Enable[/bold] to start the debugging server\n"
|
||||
"3. You should see: [green]Server running at: 127.0.0.1:9222[/green]",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
|
||||
open_page = Confirm.ask("Open chrome://inspect/#remote-debugging now?", default=True)
|
||||
if open_page:
|
||||
_open_chrome_inspect()
|
||||
# Step 2: Guide the user to enable Chrome remote debugging.
|
||||
_print_classic_cdp_instructions()
|
||||
|
||||
console.print("\n[bold yellow]Enable remote debugging in Chrome, then press Enter to continue...[/bold yellow]")
|
||||
Prompt.ask("Press Enter when ready", default="")
|
||||
|
|
@ -277,7 +272,7 @@ def _setup_local_browser_actual() -> tuple[str, Optional[str], Optional[str]]:
|
|||
|
||||
# Step 4: Fallback — ask for manual URL
|
||||
console.print("[yellow]Could not auto-detect the debugging server.[/yellow]")
|
||||
console.print("[dim]Make sure you clicked 'Enable' on the chrome://inspect page.[/dim]")
|
||||
console.print("[dim]Make sure /json/version returns JSON from the URL you enter.[/dim]")
|
||||
manual_url = Prompt.ask(
|
||||
"Enter the debugging URL manually (e.g. http://127.0.0.1:9222)",
|
||||
default="http://127.0.0.1:9222",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import json
|
|||
import os
|
||||
import platform
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
|
|
@ -28,6 +29,7 @@ doctor_app = typer.Typer(help="Check Skyvern installation health.")
|
|||
|
||||
GENERATED_CREDENTIALS_FILE = Path(".skyvern/credentials.toml")
|
||||
LEGACY_STREAMLIT_CREDENTIALS_FILE = Path(".streamlit/secrets.toml")
|
||||
_FINGERPRINT_TAGS: dict[str, str] = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -38,6 +40,25 @@ class CheckResult:
|
|||
hint: str = field(default="")
|
||||
|
||||
|
||||
FRONTEND_RUNTIME_URL_VARS: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||
"VITE_API_BASE_URL": ("http://localhost:8000/api/v1", ("http://", "https://")),
|
||||
"VITE_WSS_BASE_URL": ("ws://localhost:8000/api/v1", ("ws://", "wss://")),
|
||||
"VITE_ARTIFACT_API_BASE_URL": ("http://localhost:9090", ("http://", "https://")),
|
||||
}
|
||||
LOCAL_STREAMING_MODE = "cdp"
|
||||
FRONTEND_STREAMING_MODE_VAR = "VITE_BROWSER_STREAMING_MODE"
|
||||
BACKEND_STREAMING_MODE_VAR = "BROWSER_STREAMING_MODE"
|
||||
ALLOWED_STREAMING_MODES = {"cdp", "vnc"}
|
||||
|
||||
FRONTEND_BUNDLE_PLACEHOLDERS = {
|
||||
"__VITE_API_BASE_URL_PLACEHOLDER__",
|
||||
"__VITE_WSS_BASE_URL_PLACEHOLDER__",
|
||||
"__VITE_ARTIFACT_API_BASE_URL_PLACEHOLDER__",
|
||||
"__SKYVERN_API_KEY_PLACEHOLDER__",
|
||||
"__VITE_BROWSER_STREAMING_MODE_PLACEHOLDER__",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individual checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -329,6 +350,11 @@ def _check_database() -> CheckResult:
|
|||
)
|
||||
if result.returncode == 0:
|
||||
return CheckResult(name="Database", status="ok", detail=f"Connected: {_redact_password(db_string)}")
|
||||
|
||||
compose_check = _check_compose_database_connection()
|
||||
if compose_check is not None:
|
||||
return compose_check
|
||||
|
||||
stderr = result.stderr.strip()
|
||||
if "does not exist" in stderr:
|
||||
return CheckResult(
|
||||
|
|
@ -365,6 +391,42 @@ def _check_database() -> CheckResult:
|
|||
)
|
||||
|
||||
|
||||
def _check_compose_database_connection() -> CheckResult | None:
|
||||
"""Return an ok result if the running Compose backend can reach its database."""
|
||||
if not _docker_compose_available():
|
||||
return None
|
||||
|
||||
script = r"""
|
||||
import json
|
||||
import sqlalchemy
|
||||
|
||||
from skyvern.config import settings
|
||||
|
||||
engine = sqlalchemy.create_engine(settings.DATABASE_STRING)
|
||||
with engine.connect():
|
||||
pass
|
||||
|
||||
print(json.dumps({"status": "ok"}))
|
||||
"""
|
||||
try:
|
||||
result = _run_docker_compose_exec("skyvern", script, timeout=30)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return None
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
return None
|
||||
|
||||
if payload.get("status") == "ok":
|
||||
return CheckResult(name="Database", status="ok", detail="Docker Compose backend can connect to database")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _check_docker() -> CheckResult:
|
||||
if not shutil.which("docker"):
|
||||
return CheckResult(
|
||||
|
|
@ -485,7 +547,6 @@ def _check_api_key_consistency() -> CheckResult:
|
|||
Generated Docker credentials are only a fallback for compose startup; backend .env
|
||||
remains the preferred source when present.
|
||||
"""
|
||||
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from skyvern.utils.env_paths import resolve_backend_env_path
|
||||
|
|
@ -577,6 +638,546 @@ def _check_legacy_streamlit_secrets() -> CheckResult:
|
|||
)
|
||||
|
||||
|
||||
def _fingerprint(value: str) -> str:
|
||||
"""Return an opaque process-local tag for comparing sensitive values in logs."""
|
||||
if not value:
|
||||
return "missing"
|
||||
if value not in _FINGERPRINT_TAGS:
|
||||
_FINGERPRINT_TAGS[value] = secrets.token_hex(6)
|
||||
return _FINGERPRINT_TAGS[value]
|
||||
|
||||
|
||||
def _write_generated_credentials_file(api_key: str) -> None:
|
||||
"""Write generated local credentials with private file permissions from creation."""
|
||||
GENERATED_CREDENTIALS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
||||
fd = os.open(GENERATED_CREDENTIALS_FILE, flags, 0o600)
|
||||
try:
|
||||
os.write(fd, f'[general]\ncred = "{api_key}"\n'.encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _run_docker_compose_exec(service: str, script: str, timeout: int = 30) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["docker", "compose", "exec", "-T", service, "python", "-c", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _docker_compose_available() -> bool:
|
||||
return shutil.which("docker") is not None and Path("docker-compose.yml").exists()
|
||||
|
||||
|
||||
def _is_placeholder_env_value(value: str) -> bool:
|
||||
normalized = value.strip()
|
||||
return (
|
||||
normalized in ("", "PLACEHOLDER", "YOUR_API_KEY")
|
||||
or normalized in FRONTEND_BUNDLE_PLACEHOLDERS
|
||||
or (normalized.startswith("__") and normalized.endswith("__") and "PLACEHOLDER" in normalized)
|
||||
)
|
||||
|
||||
|
||||
def _validate_frontend_runtime_url_values(values: dict[str, str], source: str) -> list[str]:
|
||||
problems: list[str] = []
|
||||
for name, (_default, allowed_prefixes) in FRONTEND_RUNTIME_URL_VARS.items():
|
||||
value = values.get(name, "").strip()
|
||||
if not value:
|
||||
problems.append(f"{source} {name} is missing")
|
||||
elif _is_placeholder_env_value(value):
|
||||
problems.append(f"{source} {name} is still {value}")
|
||||
elif not value.startswith(allowed_prefixes):
|
||||
allowed = " or ".join(allowed_prefixes)
|
||||
problems.append(f"{source} {name} must start with {allowed}; got {value}")
|
||||
return problems
|
||||
|
||||
|
||||
def _normalize_streaming_mode(value: str | None) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def _validate_streaming_mode_value(value: str | None, source: str) -> list[str]:
|
||||
mode = _normalize_streaming_mode(value)
|
||||
if not mode:
|
||||
return [f"{source} streaming mode is missing"]
|
||||
if _is_placeholder_env_value(value or ""):
|
||||
return [f"{source} streaming mode is still {value}"]
|
||||
if mode not in ALLOWED_STREAMING_MODES:
|
||||
allowed = ", ".join(sorted(ALLOWED_STREAMING_MODES))
|
||||
return [f"{source} streaming mode must be one of {allowed}; got {value}"]
|
||||
return []
|
||||
|
||||
|
||||
def _check_local_streaming_mode() -> CheckResult:
|
||||
"""Check that local self-hosted installs default to CDP livestreaming."""
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from skyvern.utils.env_paths import resolve_backend_env_path
|
||||
|
||||
backend_env = resolve_backend_env_path()
|
||||
frontend_env = Path("skyvern-frontend/.env")
|
||||
|
||||
backend_raw = dotenv_values(backend_env).get(BACKEND_STREAMING_MODE_VAR, "") if backend_env.exists() else ""
|
||||
frontend_raw = dotenv_values(frontend_env).get(FRONTEND_STREAMING_MODE_VAR, "") if frontend_env.exists() else ""
|
||||
|
||||
problems: list[str] = []
|
||||
if backend_env.exists():
|
||||
problems.extend(_validate_streaming_mode_value(str(backend_raw or ""), "backend .env"))
|
||||
if _normalize_streaming_mode(str(backend_raw or "")) != LOCAL_STREAMING_MODE:
|
||||
problems.append(f"backend .env {BACKEND_STREAMING_MODE_VAR} should be {LOCAL_STREAMING_MODE}")
|
||||
else:
|
||||
problems.append("backend .env is missing")
|
||||
|
||||
if frontend_env.exists():
|
||||
problems.extend(_validate_streaming_mode_value(str(frontend_raw or ""), "skyvern-frontend/.env"))
|
||||
if _normalize_streaming_mode(str(frontend_raw or "")) != LOCAL_STREAMING_MODE:
|
||||
problems.append(f"skyvern-frontend/.env {FRONTEND_STREAMING_MODE_VAR} should be {LOCAL_STREAMING_MODE}")
|
||||
else:
|
||||
problems.append("skyvern-frontend/.env is missing")
|
||||
|
||||
if problems:
|
||||
return CheckResult(
|
||||
name="Local Streaming Mode",
|
||||
status="warn",
|
||||
detail="; ".join(problems),
|
||||
hint="Run `skyvern doctor --fix` to enable CDP livestreaming for backend and UI",
|
||||
)
|
||||
|
||||
if not _docker_compose_available():
|
||||
return CheckResult(
|
||||
name="Local Streaming Mode",
|
||||
status="ok",
|
||||
detail="backend and frontend env files enable CDP livestreaming",
|
||||
)
|
||||
|
||||
container_problems: list[str] = []
|
||||
checked_containers = 0
|
||||
backend_result = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", "skyvern", "printenv", BACKEND_STREAMING_MODE_VAR],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if backend_result.returncode == 0:
|
||||
checked_containers += 1
|
||||
backend_container_mode = _normalize_streaming_mode(backend_result.stdout)
|
||||
if backend_container_mode != LOCAL_STREAMING_MODE:
|
||||
container_problems.append(
|
||||
f"running skyvern {BACKEND_STREAMING_MODE_VAR} is {backend_container_mode or 'missing'}"
|
||||
)
|
||||
|
||||
ui_result = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", "skyvern-ui", "printenv", FRONTEND_STREAMING_MODE_VAR],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if ui_result.returncode == 0:
|
||||
checked_containers += 1
|
||||
ui_container_mode = _normalize_streaming_mode(ui_result.stdout)
|
||||
if ui_container_mode != LOCAL_STREAMING_MODE:
|
||||
container_problems.append(
|
||||
f"running skyvern-ui {FRONTEND_STREAMING_MODE_VAR} is {ui_container_mode or 'missing'}"
|
||||
)
|
||||
|
||||
if container_problems:
|
||||
return CheckResult(
|
||||
name="Local Streaming Mode",
|
||||
status="warn",
|
||||
detail="; ".join(container_problems),
|
||||
hint="Run `docker compose up -d --force-recreate skyvern skyvern-ui`",
|
||||
)
|
||||
|
||||
if checked_containers == 0:
|
||||
return CheckResult(
|
||||
name="Local Streaming Mode",
|
||||
status="ok",
|
||||
detail="backend and frontend env files enable CDP livestreaming; running containers not checked",
|
||||
)
|
||||
|
||||
return CheckResult(
|
||||
name="Local Streaming Mode",
|
||||
status="ok",
|
||||
detail="CDP livestreaming is enabled for backend, UI, and running containers",
|
||||
)
|
||||
|
||||
|
||||
def _check_frontend_runtime_env() -> CheckResult:
|
||||
"""Check Vite runtime config before the UI can white-screen on placeholders."""
|
||||
frontend_dir = Path("skyvern-frontend")
|
||||
compose_file = Path("docker-compose.yml")
|
||||
if not frontend_dir.exists() and not compose_file.exists():
|
||||
return CheckResult(name="Frontend Runtime Env", status="ok", detail="not checked (no frontend or compose)")
|
||||
|
||||
from dotenv import dotenv_values
|
||||
|
||||
frontend_env = frontend_dir / ".env"
|
||||
frontend_example = frontend_dir / ".env.example"
|
||||
if not frontend_env.exists():
|
||||
hint = "Run `skyvern doctor --fix` to create skyvern-frontend/.env"
|
||||
if frontend_example.exists():
|
||||
hint += ", or copy skyvern-frontend/.env.example to skyvern-frontend/.env"
|
||||
return CheckResult(
|
||||
name="Frontend Runtime Env",
|
||||
status="error",
|
||||
detail="skyvern-frontend/.env is missing; skyvern-ui will fall back to Dockerfile placeholders",
|
||||
hint=hint,
|
||||
)
|
||||
|
||||
host_values_raw = dotenv_values(frontend_env)
|
||||
host_values = {name: str(host_values_raw.get(name) or "") for name in FRONTEND_RUNTIME_URL_VARS}
|
||||
host_problems = _validate_frontend_runtime_url_values(host_values, "skyvern-frontend/.env")
|
||||
host_problems.extend(
|
||||
_validate_streaming_mode_value(
|
||||
str(host_values_raw.get(FRONTEND_STREAMING_MODE_VAR) or ""),
|
||||
"skyvern-frontend/.env",
|
||||
)
|
||||
)
|
||||
if host_problems:
|
||||
return CheckResult(
|
||||
name="Frontend Runtime Env",
|
||||
status="error",
|
||||
detail="; ".join(host_problems),
|
||||
hint="Run `skyvern doctor --fix`, then `docker compose up -d --force-recreate skyvern-ui`",
|
||||
)
|
||||
|
||||
if not _docker_compose_available():
|
||||
return CheckResult(
|
||||
name="Frontend Runtime Env",
|
||||
status="ok",
|
||||
detail="skyvern-frontend/.env has valid Vite runtime URLs",
|
||||
)
|
||||
|
||||
script = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const names = [
|
||||
"VITE_API_BASE_URL",
|
||||
"VITE_WSS_BASE_URL",
|
||||
"VITE_ARTIFACT_API_BASE_URL",
|
||||
"VITE_BROWSER_STREAMING_MODE",
|
||||
];
|
||||
const placeholders = [
|
||||
"__VITE_API_BASE_URL_PLACEHOLDER__",
|
||||
"__VITE_WSS_BASE_URL_PLACEHOLDER__",
|
||||
"__VITE_ARTIFACT_API_BASE_URL_PLACEHOLDER__",
|
||||
"__SKYVERN_API_KEY_PLACEHOLDER__",
|
||||
"__VITE_BROWSER_STREAMING_MODE_PLACEHOLDER__",
|
||||
];
|
||||
|
||||
const values = {};
|
||||
for (const name of names) {
|
||||
values[name] = process.env[name] || "";
|
||||
}
|
||||
|
||||
const bundlePlaceholders = new Set();
|
||||
let bundleError = "";
|
||||
try {
|
||||
const assetsDir = "/app/dist/assets";
|
||||
for (const file of fs.readdirSync(assetsDir)) {
|
||||
if (!file.endsWith(".js")) {
|
||||
continue;
|
||||
}
|
||||
const contents = fs.readFileSync(path.join(assetsDir, file), "utf8");
|
||||
for (const placeholder of placeholders) {
|
||||
if (contents.includes(placeholder)) {
|
||||
bundlePlaceholders.add(placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
bundleError = String(error);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
values,
|
||||
bundle_placeholders: Array.from(bundlePlaceholders),
|
||||
bundle_error: bundleError,
|
||||
}));
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", "skyvern-ui", "node", "-e", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
||||
return CheckResult(
|
||||
name="Frontend Runtime Env",
|
||||
status="warn",
|
||||
detail=f"host frontend env is valid; running skyvern-ui not checked: {exc}",
|
||||
hint="Start skyvern-ui and rerun `skyvern doctor`",
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or result.stdout.strip() or "skyvern-ui compose service is not running"
|
||||
return CheckResult(
|
||||
name="Frontend Runtime Env",
|
||||
status="warn",
|
||||
detail=f"host frontend env is valid; running skyvern-ui not checked: {detail[:240]}",
|
||||
hint="Run `docker compose up -d skyvern-ui` and rerun `skyvern doctor`",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
return CheckResult(
|
||||
name="Frontend Runtime Env",
|
||||
status="warn",
|
||||
detail=f"could not parse skyvern-ui env diagnostic output: {result.stdout.strip()[:200]}",
|
||||
)
|
||||
|
||||
container_values = {name: str((payload.get("values") or {}).get(name) or "") for name in FRONTEND_RUNTIME_URL_VARS}
|
||||
container_problems = _validate_frontend_runtime_url_values(container_values, "running skyvern-ui")
|
||||
container_problems.extend(
|
||||
_validate_streaming_mode_value(
|
||||
str((payload.get("values") or {}).get(FRONTEND_STREAMING_MODE_VAR) or ""),
|
||||
"running skyvern-ui",
|
||||
)
|
||||
)
|
||||
if container_problems:
|
||||
return CheckResult(
|
||||
name="Frontend Runtime Env",
|
||||
status="error",
|
||||
detail="; ".join(container_problems),
|
||||
hint=(
|
||||
"Recreate the UI after fixing skyvern-frontend/.env: `docker compose up -d --force-recreate skyvern-ui`"
|
||||
),
|
||||
)
|
||||
|
||||
bundle_placeholders = [str(value) for value in payload.get("bundle_placeholders") or []]
|
||||
if bundle_placeholders:
|
||||
return CheckResult(
|
||||
name="Frontend Runtime Env",
|
||||
status="error",
|
||||
detail="running UI bundle still contains placeholders: " + ", ".join(bundle_placeholders),
|
||||
hint="Run `docker compose up -d --force-recreate skyvern-ui` after fixing skyvern-frontend/.env",
|
||||
)
|
||||
|
||||
bundle_error = str(payload.get("bundle_error") or "")
|
||||
if bundle_error:
|
||||
return CheckResult(
|
||||
name="Frontend Runtime Env",
|
||||
status="warn",
|
||||
detail=f"could not inspect running UI bundle: {bundle_error[:240]}",
|
||||
)
|
||||
|
||||
return CheckResult(
|
||||
name="Frontend Runtime Env",
|
||||
status="ok",
|
||||
detail="skyvern-frontend/.env, running skyvern-ui env, and UI bundle placeholders look valid",
|
||||
)
|
||||
|
||||
|
||||
def _check_docker_local_auth() -> CheckResult:
|
||||
"""Validate that the running Docker backend accepts its configured local API key."""
|
||||
if not _docker_compose_available():
|
||||
return CheckResult(name="Docker Local Auth", status="ok", detail="not checked (no docker-compose.yml)")
|
||||
|
||||
script = r"""
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
token = os.environ.get("SKYVERN_API_KEY", "").strip()
|
||||
if not token or token == "PLACEHOLDER":
|
||||
print(json.dumps({"status": "missing_container_api_key"}))
|
||||
raise SystemExit(0)
|
||||
|
||||
request = urllib.request.Request(
|
||||
"http://127.0.0.1:8000/api/v1/internal/auth/status",
|
||||
headers={"x-api-key": token},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
print(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8")
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
payload = {}
|
||||
|
||||
if isinstance(payload, dict) and payload.get("status"):
|
||||
print(json.dumps(payload))
|
||||
else:
|
||||
print(json.dumps({
|
||||
"status": "http_error",
|
||||
"status_code": exc.code,
|
||||
"detail": payload.get("detail") if isinstance(payload, dict) else body or str(exc),
|
||||
}))
|
||||
"""
|
||||
|
||||
try:
|
||||
result = _run_docker_compose_exec("skyvern", script, timeout=60)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return CheckResult(
|
||||
name="Docker Local Auth",
|
||||
status="warn",
|
||||
detail=f"not checked: Docker auth diagnostic timed out after {exc.timeout}s",
|
||||
hint="Start Docker Compose and rerun `skyvern doctor`",
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return CheckResult(
|
||||
name="Docker Local Auth",
|
||||
status="warn",
|
||||
detail=f"not checked: {exc}",
|
||||
hint="Start Docker Compose and rerun `skyvern doctor`",
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
detail = stderr or result.stdout.strip() or "skyvern compose service is not running"
|
||||
return CheckResult(
|
||||
name="Docker Local Auth",
|
||||
status="warn",
|
||||
detail=detail[:300],
|
||||
hint="Run `docker compose up -d` to enable Docker auth diagnostics",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
return CheckResult(
|
||||
name="Docker Local Auth",
|
||||
status="warn",
|
||||
detail=f"could not parse diagnostic output: {result.stdout.strip()[:200]}",
|
||||
)
|
||||
|
||||
status_value = payload.get("status") or ("http_error" if payload.get("detail") else None)
|
||||
org_id = payload.get("organization_id", "")
|
||||
if status_value == "ok":
|
||||
return CheckResult(
|
||||
name="Docker Local Auth", status="ok", detail=f"running backend accepts API key for {org_id}"
|
||||
)
|
||||
|
||||
detail_text = str(payload.get("detail") or "")
|
||||
details = {
|
||||
"missing_container_api_key": "running backend has no SKYVERN_API_KEY",
|
||||
"missing_api_key": "running backend has no SKYVERN_API_KEY",
|
||||
"invalid_format": "running backend cannot decode SKYVERN_API_KEY",
|
||||
"invalid": "running backend cannot validate SKYVERN_API_KEY",
|
||||
"expired": f"running backend API key is expired for {org_id}",
|
||||
"not_found": "API key decodes, but its organization is missing from Docker DB",
|
||||
"organization_not_found": f"API key decodes, but organization {org_id} is missing from Docker DB",
|
||||
"token_not_in_database": f"organization {org_id} exists, but this API key is not in Docker DB",
|
||||
"db_token_invalid": f"organization {org_id} exists, but this API key is marked invalid",
|
||||
"http_error": "running backend auth diagnostics endpoint returned an HTTP error",
|
||||
}
|
||||
detail = details.get(str(status_value), f"unknown Docker auth status: {status_value}")
|
||||
if detail_text:
|
||||
detail = f"{detail}: {detail_text}"
|
||||
return CheckResult(
|
||||
name="Docker Local Auth",
|
||||
status="error",
|
||||
detail=detail,
|
||||
hint="Run `skyvern doctor --fix` to create a Docker-local org/key and sync env files",
|
||||
)
|
||||
|
||||
|
||||
def _check_frontend_build_api_key() -> CheckResult:
|
||||
"""Check whether the running production UI bundle was built with the current Vite API key."""
|
||||
if not _docker_compose_available():
|
||||
return CheckResult(name="Frontend Build API Key", status="ok", detail="not checked (no docker-compose.yml)")
|
||||
|
||||
script = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function readGeneratedKey() {
|
||||
const credentialsFile = process.env.SKYVERN_CREDENTIALS_FILE || "/app/.skyvern/credentials.toml";
|
||||
try {
|
||||
const contents = fs.readFileSync(credentialsFile, "utf8");
|
||||
const match = contents.match(/(?:^|[^A-Za-z0-9_])cred\s*=\s*"([^"]*)"/);
|
||||
return match ? match[1].trim() : "";
|
||||
} catch (_error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const envKey = (process.env.VITE_SKYVERN_API_KEY || "").trim();
|
||||
const generatedKey = readGeneratedKey();
|
||||
const key = envKey && envKey !== "YOUR_API_KEY" ? envKey : generatedKey;
|
||||
if (!key) {
|
||||
console.log(JSON.stringify({ status: "missing_env" }));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const assetsDir = "/app/dist/assets";
|
||||
let matches = 0;
|
||||
try {
|
||||
for (const file of fs.readdirSync(assetsDir)) {
|
||||
if (!file.endsWith(".js")) {
|
||||
continue;
|
||||
}
|
||||
const contents = fs.readFileSync(path.join(assetsDir, file), "utf8");
|
||||
if (contents.includes(key)) {
|
||||
matches += 1;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify({ status: "missing_bundle", detail: String(error) }));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ status: matches > 0 ? "ok" : "stale_or_missing_bundle", matches }));
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", "skyvern-ui", "node", "-e", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
||||
return CheckResult(
|
||||
name="Frontend Build API Key",
|
||||
status="warn",
|
||||
detail=f"not checked: {exc}",
|
||||
hint="Start skyvern-ui and rerun `skyvern doctor`",
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or result.stdout.strip() or "skyvern-ui compose service is not running"
|
||||
return CheckResult(
|
||||
name="Frontend Build API Key",
|
||||
status="warn",
|
||||
detail=detail[:300],
|
||||
hint="Run `docker compose up -d skyvern-ui` to enable UI bundle diagnostics",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
return CheckResult(
|
||||
name="Frontend Build API Key",
|
||||
status="warn",
|
||||
detail=f"could not parse diagnostic output: {result.stdout.strip()[:200]}",
|
||||
)
|
||||
|
||||
if payload.get("status") == "ok":
|
||||
return CheckResult(name="Frontend Build API Key", status="ok", detail="running UI bundle contains Vite API key")
|
||||
if payload.get("status") == "missing_env":
|
||||
return CheckResult(
|
||||
name="Frontend Build API Key",
|
||||
status="error",
|
||||
detail="running skyvern-ui has no VITE_SKYVERN_API_KEY or generated credentials file",
|
||||
hint="Run `skyvern doctor --fix` to sync env files and recreate skyvern-ui",
|
||||
)
|
||||
return CheckResult(
|
||||
name="Frontend Build API Key",
|
||||
status="error",
|
||||
detail="running UI bundle does not contain the current VITE_SKYVERN_API_KEY",
|
||||
hint="Run `skyvern doctor --fix` or recreate skyvern-ui after editing skyvern-frontend/.env",
|
||||
)
|
||||
|
||||
|
||||
def _redact_password(db_string: str) -> str:
|
||||
"""Replace password in a database URL with ***."""
|
||||
import re
|
||||
|
|
@ -642,6 +1243,10 @@ _CHECKS = [
|
|||
_check_llm_config,
|
||||
_check_api_key_consistency,
|
||||
_check_legacy_streamlit_secrets,
|
||||
_check_local_streaming_mode,
|
||||
_check_frontend_runtime_env,
|
||||
_check_docker_local_auth,
|
||||
_check_frontend_build_api_key,
|
||||
_check_playwright_browser,
|
||||
_check_port_8000,
|
||||
_check_api_connectivity,
|
||||
|
|
@ -671,6 +1276,14 @@ def _try_fix(result: CheckResult) -> bool:
|
|||
return _fix_api_key_consistency()
|
||||
if result.name == "Legacy Streamlit Secrets" and result.status == "warn":
|
||||
return _fix_legacy_streamlit_secrets()
|
||||
if result.name == "Local Streaming Mode" and result.status in {"warn", "error"}:
|
||||
return _fix_local_streaming_mode()
|
||||
if result.name == "Frontend Runtime Env" and result.status == "error":
|
||||
return _fix_frontend_runtime_env()
|
||||
if result.name == "Docker Local Auth" and result.status == "error":
|
||||
return _fix_docker_local_auth()
|
||||
if result.name == "Frontend Build API Key" and result.status == "error":
|
||||
return _recreate_docker_services(["skyvern-ui"])
|
||||
if result.name == "Docker" and "not running" in result.detail:
|
||||
console.print(" [yellow]→ Please start Docker Desktop manually[/yellow]")
|
||||
return False
|
||||
|
|
@ -759,7 +1372,7 @@ def _fix_api_key_consistency() -> bool:
|
|||
if not backend_env.exists():
|
||||
backend_env.touch()
|
||||
|
||||
set_key(str(backend_env), "SKYVERN_API_KEY", canonical)
|
||||
set_key(str(backend_env), "SKYVERN_API_KEY", canonical, quote_mode="never")
|
||||
|
||||
if not frontend_env.exists() and frontend_example.exists():
|
||||
shutil.copy(frontend_example, frontend_env)
|
||||
|
|
@ -769,7 +1382,7 @@ def _fix_api_key_consistency() -> bool:
|
|||
console.print(" [yellow]→ skyvern-frontend/.env not found and no .env.example to copy[/yellow]")
|
||||
return False
|
||||
|
||||
set_key(str(frontend_env), "VITE_SKYVERN_API_KEY", canonical)
|
||||
set_key(str(frontend_env), "VITE_SKYVERN_API_KEY", canonical, quote_mode="never")
|
||||
source = (
|
||||
"backend .env"
|
||||
if backend_key
|
||||
|
|
@ -805,19 +1418,188 @@ def _fix_legacy_streamlit_secrets() -> bool:
|
|||
if not backend_key and legacy_key:
|
||||
if not backend_env.exists():
|
||||
backend_env.touch()
|
||||
set_key(str(backend_env), "SKYVERN_API_KEY", legacy_key)
|
||||
set_key(str(backend_env), "SKYVERN_API_KEY", legacy_key, quote_mode="never")
|
||||
if not frontend_env.exists() and frontend_example.exists():
|
||||
shutil.copy(frontend_example, frontend_env)
|
||||
if frontend_env.exists():
|
||||
set_key(str(frontend_env), "VITE_SKYVERN_API_KEY", legacy_key)
|
||||
set_key(str(frontend_env), "VITE_SKYVERN_API_KEY", legacy_key, quote_mode="never")
|
||||
console.print(" [green]✅ Migrated legacy .streamlit API key into .env files[/green]")
|
||||
|
||||
LEGACY_STREAMLIT_CREDENTIALS_FILE.unlink()
|
||||
console.print(" [green]✅ Removed deprecated .streamlit/secrets.toml[/green]")
|
||||
return True
|
||||
|
||||
|
||||
def _ensure_frontend_env_exists() -> Path:
|
||||
frontend_env = Path("skyvern-frontend/.env")
|
||||
frontend_example = Path("skyvern-frontend/.env.example")
|
||||
if not frontend_env.exists():
|
||||
frontend_env.parent.mkdir(parents=True, exist_ok=True)
|
||||
if frontend_example.exists():
|
||||
shutil.copy(frontend_example, frontend_env)
|
||||
console.print(" [cyan]Created skyvern-frontend/.env from .env.example[/cyan]")
|
||||
else:
|
||||
frontend_env.touch()
|
||||
console.print(" [cyan]Created empty skyvern-frontend/.env[/cyan]")
|
||||
return frontend_env
|
||||
|
||||
|
||||
def _fix_local_streaming_mode() -> bool:
|
||||
"""Enable local CDP livestreaming in backend and frontend env files."""
|
||||
from dotenv import set_key
|
||||
|
||||
from skyvern.utils.env_paths import resolve_backend_env_path
|
||||
|
||||
backend_env = resolve_backend_env_path()
|
||||
if not backend_env.exists():
|
||||
backend_env.touch()
|
||||
set_key(str(backend_env), BACKEND_STREAMING_MODE_VAR, LOCAL_STREAMING_MODE, quote_mode="never")
|
||||
console.print(f" [cyan]Set {BACKEND_STREAMING_MODE_VAR}={LOCAL_STREAMING_MODE} in backend .env[/cyan]")
|
||||
|
||||
frontend_env = _ensure_frontend_env_exists()
|
||||
set_key(str(frontend_env), FRONTEND_STREAMING_MODE_VAR, LOCAL_STREAMING_MODE, quote_mode="never")
|
||||
console.print(f" [cyan]Set {FRONTEND_STREAMING_MODE_VAR}={LOCAL_STREAMING_MODE} in skyvern-frontend/.env[/cyan]")
|
||||
|
||||
if _docker_compose_available():
|
||||
return _recreate_docker_services(["skyvern", "skyvern-ui"])
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _fix_frontend_runtime_env() -> bool:
|
||||
"""Create/fill frontend Vite env values and recreate the UI container."""
|
||||
from dotenv import dotenv_values, set_key
|
||||
|
||||
from skyvern.utils.env_paths import resolve_backend_env_path
|
||||
|
||||
frontend_env = _ensure_frontend_env_exists()
|
||||
|
||||
values = dotenv_values(frontend_env)
|
||||
changed = False
|
||||
for name, (default, allowed_prefixes) in FRONTEND_RUNTIME_URL_VARS.items():
|
||||
value = str(values.get(name) or "").strip()
|
||||
if not value or _is_placeholder_env_value(value) or not value.startswith(allowed_prefixes):
|
||||
set_key(str(frontend_env), name, default, quote_mode="never")
|
||||
console.print(f" [cyan]Set {name}={default} in skyvern-frontend/.env[/cyan]")
|
||||
changed = True
|
||||
|
||||
streaming_mode = str(values.get(FRONTEND_STREAMING_MODE_VAR) or "").strip()
|
||||
if _normalize_streaming_mode(streaming_mode) != LOCAL_STREAMING_MODE:
|
||||
set_key(str(frontend_env), FRONTEND_STREAMING_MODE_VAR, LOCAL_STREAMING_MODE, quote_mode="never")
|
||||
console.print(
|
||||
f" [cyan]Set {FRONTEND_STREAMING_MODE_VAR}={LOCAL_STREAMING_MODE} in skyvern-frontend/.env[/cyan]"
|
||||
)
|
||||
changed = True
|
||||
|
||||
backend_env = resolve_backend_env_path()
|
||||
backend_key = dotenv_values(backend_env).get("SKYVERN_API_KEY", "") if backend_env.exists() else ""
|
||||
generated_key = _read_generated_credential()
|
||||
legacy_key = _read_legacy_streamlit_credential()
|
||||
canonical_key = str(backend_key or generated_key or legacy_key or "").strip()
|
||||
frontend_api_key = str(values.get("VITE_SKYVERN_API_KEY") or "").strip()
|
||||
if canonical_key and _is_placeholder_env_value(frontend_api_key):
|
||||
set_key(str(frontend_env), "VITE_SKYVERN_API_KEY", canonical_key, quote_mode="never")
|
||||
console.print(" [cyan]Synced VITE_SKYVERN_API_KEY in skyvern-frontend/.env[/cyan]")
|
||||
changed = True
|
||||
elif not canonical_key:
|
||||
console.print(" [yellow]→ No backend API key found to sync into skyvern-frontend/.env[/yellow]")
|
||||
|
||||
if _docker_compose_available():
|
||||
return _recreate_docker_services(["skyvern-ui"]) or changed
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
def _fix_docker_local_auth() -> bool:
|
||||
"""Create a fresh local org/token inside Docker Postgres and sync host env files."""
|
||||
from dotenv import set_key
|
||||
|
||||
from skyvern.utils.env_paths import resolve_backend_env_path
|
||||
|
||||
script = r"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from skyvern.forge import app
|
||||
from skyvern.forge.sdk.services.org_auth_token_service import create_org_api_token
|
||||
|
||||
|
||||
async def main():
|
||||
org = await app.DATABASE.organizations.create_organization("Skyvern Local Demo")
|
||||
token = await create_org_api_token(org.organization_id)
|
||||
print(json.dumps({"api_key": token.token, "organization_id": org.organization_id}))
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
"""
|
||||
console.print(" [cyan]Creating a fresh local org/API key in Docker Postgres...[/cyan]")
|
||||
try:
|
||||
result = _run_docker_compose_exec("skyvern", script, timeout=30)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
||||
console.print(f" [red]Failed to run Docker auth repair: {exc}[/red]")
|
||||
return False
|
||||
|
||||
if result.returncode != 0:
|
||||
console.print(f" [red]Failed to create Docker-local API key: {(result.stderr or result.stdout)[:500]}[/red]")
|
||||
return False
|
||||
|
||||
try:
|
||||
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
console.print(f" [red]Could not parse Docker auth repair output: {result.stdout[:500]}[/red]")
|
||||
return False
|
||||
|
||||
api_key = str(payload.get("api_key") or "")
|
||||
organization_id = str(payload.get("organization_id") or "")
|
||||
if not api_key:
|
||||
console.print(" [red]Docker auth repair did not return an API key[/red]")
|
||||
return False
|
||||
|
||||
backend_env = resolve_backend_env_path()
|
||||
frontend_env = Path("skyvern-frontend/.env")
|
||||
frontend_example = Path("skyvern-frontend/.env.example")
|
||||
|
||||
if not backend_env.exists():
|
||||
backend_env.touch()
|
||||
if not frontend_env.exists() and frontend_example.exists():
|
||||
shutil.copy(frontend_example, frontend_env)
|
||||
console.print(" [cyan]Created skyvern-frontend/.env from .env.example[/cyan]")
|
||||
if not frontend_env.exists():
|
||||
console.print(" [red]skyvern-frontend/.env not found and no .env.example to copy[/red]")
|
||||
return False
|
||||
|
||||
set_key(str(backend_env), "SKYVERN_API_KEY", api_key, quote_mode="never")
|
||||
set_key(str(frontend_env), "VITE_SKYVERN_API_KEY", api_key, quote_mode="never")
|
||||
_write_generated_credentials_file(api_key)
|
||||
if LEGACY_STREAMLIT_CREDENTIALS_FILE.exists():
|
||||
LEGACY_STREAMLIT_CREDENTIALS_FILE.unlink()
|
||||
|
||||
console.print(
|
||||
" [green]✅ Synced fresh Docker-local API key "
|
||||
f"for {organization_id} (fingerprint {_fingerprint(api_key)})[/green]"
|
||||
)
|
||||
return _recreate_docker_services(["skyvern", "skyvern-ui"])
|
||||
|
||||
|
||||
def _recreate_docker_services(services: list[str]) -> bool:
|
||||
if not _docker_compose_available():
|
||||
return False
|
||||
|
||||
console.print(f" [cyan]Recreating Docker service(s): {', '.join(services)}...[/cyan]")
|
||||
result = subprocess.run(
|
||||
["docker", "compose", "up", "-d", "--force-recreate", *services],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
console.print(" [green]✅ Docker services recreated[/green]")
|
||||
return True
|
||||
|
||||
console.print(f" [red]Failed to recreate Docker services: {(result.stderr or result.stdout)[:500]}[/red]")
|
||||
return False
|
||||
|
||||
|
||||
def _fix_install_playwright() -> bool:
|
||||
console.print(" [cyan]Installing Chromium via Playwright...[/cyan]")
|
||||
result = subprocess.run(["playwright", "install", "chromium"], capture_output=True, text=True)
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ def init_env(
|
|||
update_or_add_env_var("CHROME_EXECUTABLE_PATH", browser_location)
|
||||
if remote_debugging_url:
|
||||
update_or_add_env_var("BROWSER_REMOTE_DEBUGGING_URL", remote_debugging_url)
|
||||
update_or_add_env_var("BROWSER_STREAMING_MODE", "cdp")
|
||||
console.print("✅ [green]Browser configuration complete.[/green]")
|
||||
|
||||
console.print("🌐 [bold blue]Setting Skyvern Base URL to: http://localhost:8000[/bold blue]")
|
||||
|
|
@ -247,6 +248,7 @@ def init_browser() -> None:
|
|||
update_or_add_env_var("CHROME_EXECUTABLE_PATH", browser_location)
|
||||
if remote_debugging_url:
|
||||
update_or_add_env_var("BROWSER_REMOTE_DEBUGGING_URL", remote_debugging_url)
|
||||
update_or_add_env_var("BROWSER_STREAMING_MODE", "cdp")
|
||||
capture_setup_event(
|
||||
"browser-config-complete",
|
||||
success=True,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ def update_or_add_env_var(key: str, value: str) -> None:
|
|||
"GEMINI_API_KEY": "",
|
||||
"LLM_KEY": "",
|
||||
"SECONDARY_LLM_KEY": "",
|
||||
"BROWSER_STREAMING_MODE": "cdp",
|
||||
"BROWSER_TYPE": "chromium-headful",
|
||||
"MAX_SCRAPING_RETRIES": "0",
|
||||
"VIDEO_PATH": "./videos",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from rich.prompt import Confirm
|
|||
from skyvern.analytics import capture_setup_error, capture_setup_event
|
||||
|
||||
# Import console after skyvern.cli to ensure proper initialization
|
||||
from skyvern.cli.browser import _open_chrome_inspect
|
||||
from skyvern.cli.browser import _print_classic_cdp_instructions
|
||||
from skyvern.cli.console import console
|
||||
from skyvern.cli.init_command import init_env # init is used directly
|
||||
from skyvern.cli.llm_setup import setup_llm_providers
|
||||
|
|
@ -70,6 +70,27 @@ def check_postgres_container_conflict() -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _configure_cdp_livestreaming_defaults() -> None:
|
||||
"""Enable local CDP livestreaming for self-hosted quickstart paths."""
|
||||
from dotenv import set_key
|
||||
|
||||
from skyvern.cli.llm_setup import update_or_add_env_var
|
||||
|
||||
update_or_add_env_var("BROWSER_STREAMING_MODE", "cdp")
|
||||
|
||||
frontend_env = Path("skyvern-frontend/.env")
|
||||
frontend_example = Path("skyvern-frontend/.env.example")
|
||||
if not frontend_env.exists() and frontend_example.exists():
|
||||
import shutil
|
||||
|
||||
frontend_env.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(frontend_example, frontend_env)
|
||||
console.print("✅ [green]Created skyvern-frontend/.env from .env.example[/green]")
|
||||
|
||||
if frontend_env.exists():
|
||||
set_key(str(frontend_env), "VITE_BROWSER_STREAMING_MODE", "cdp", quote_mode="never")
|
||||
|
||||
|
||||
def run_docker_compose_setup() -> None:
|
||||
"""Run the Docker Compose setup for Skyvern."""
|
||||
console.print("\n[bold blue]Setting up Skyvern with Docker Compose...[/bold blue]")
|
||||
|
|
@ -108,15 +129,7 @@ def run_docker_compose_setup() -> None:
|
|||
# Configure LLM provider
|
||||
console.print("\n[bold blue]Step 1: Configure LLM Provider[/bold blue]")
|
||||
setup_llm_providers()
|
||||
|
||||
# Ensure frontend .env exists (docker-compose.yml references it via env_file)
|
||||
frontend_env = Path("skyvern-frontend/.env")
|
||||
frontend_example = Path("skyvern-frontend/.env.example")
|
||||
if not frontend_env.exists() and frontend_example.exists():
|
||||
import shutil
|
||||
|
||||
shutil.copy(frontend_example, frontend_env)
|
||||
console.print("✅ [green]Created skyvern-frontend/.env from .env.example[/green]")
|
||||
_configure_cdp_livestreaming_defaults()
|
||||
|
||||
# Run docker compose up
|
||||
console.print("\n[bold blue]Step 2: Starting Docker Compose...[/bold blue]")
|
||||
|
|
@ -171,19 +184,11 @@ def run_docker_compose_setup() -> None:
|
|||
default=False,
|
||||
)
|
||||
if use_own_browser:
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold]Enable Remote Debugging in Chrome[/bold]\n\n"
|
||||
"1. We'll open [cyan]chrome://inspect/#remote-debugging[/cyan] in your browser\n"
|
||||
"2. Click [bold]Enable[/bold] to start the debugging server\n"
|
||||
"3. You should see: [green]Server running at: 127.0.0.1:9222[/green]",
|
||||
border_style="cyan",
|
||||
_print_classic_cdp_instructions()
|
||||
confirmed = Confirm.ask(
|
||||
"Have you enabled remote debugging in Chrome?",
|
||||
default=False,
|
||||
)
|
||||
)
|
||||
open_page = Confirm.ask("Open chrome://inspect/#remote-debugging now?", default=True)
|
||||
if open_page:
|
||||
_open_chrome_inspect()
|
||||
confirmed = Confirm.ask("Have you enabled remote debugging in Chrome?", default=False)
|
||||
if confirmed:
|
||||
from skyvern.cli.llm_setup import update_or_add_env_var
|
||||
|
||||
|
|
@ -193,7 +198,9 @@ def run_docker_compose_setup() -> None:
|
|||
console.print(" [cyan]docker compose up -d[/cyan]")
|
||||
else:
|
||||
console.print(
|
||||
"[yellow]No problem — you can enable it later by navigating to chrome://inspect/#remote-debugging in Chrome.[/yellow]"
|
||||
"[yellow]No problem - you can configure it later by setting "
|
||||
"BROWSER_TYPE=cdp-connect and starting Chrome with a Docker-reachable "
|
||||
"remote debugging address.[/yellow]"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -267,6 +274,8 @@ def quickstart(
|
|||
database_string=database_string,
|
||||
skip_browser_install=skip_browser_install,
|
||||
)
|
||||
if run_local:
|
||||
_configure_cdp_livestreaming_defaults()
|
||||
|
||||
# Start services
|
||||
if run_local:
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ class Settings(BaseSettings):
|
|||
|
||||
BROWSER_TYPE: str = "chromium-headful"
|
||||
BROWSER_REMOTE_DEBUGGING_URL: str = "http://127.0.0.1:9222"
|
||||
BROWSER_REMOTE_DEBUGGING_HOST_HEADER: str | None = None
|
||||
BROWSER_CDP_CONNECT_TIMEOUT_MS: int = 120000
|
||||
CHROME_EXECUTABLE_PATH: str | None = None
|
||||
MAX_SCRAPING_RETRIES: int = 0
|
||||
VIDEO_PATH: str | None = "./video"
|
||||
|
|
|
|||
|
|
@ -382,6 +382,10 @@ class UnknownBrowserType(SkyvernException):
|
|||
super().__init__(f"Unknown browser type {browser_type}")
|
||||
|
||||
|
||||
class CdpConnectionConfigurationError(SkyvernException):
|
||||
"""Raised when a configured CDP endpoint is reachable but not usable by Skyvern."""
|
||||
|
||||
|
||||
class UnknownErrorWhileCreatingBrowserContext(SkyvernException):
|
||||
SUPPORT_GUIDANCE = "Please try re-running. If this continues, contact support@skyvern.com."
|
||||
|
||||
|
|
@ -392,6 +396,9 @@ class UnknownErrorWhileCreatingBrowserContext(SkyvernException):
|
|||
|
||||
@staticmethod
|
||||
def _get_detail(exception: Exception) -> str:
|
||||
if isinstance(exception, CdpConnectionConfigurationError):
|
||||
return exception.message or str(exception)
|
||||
|
||||
raw_message = str(exception).strip()
|
||||
raw_lower = raw_message.lower()
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import structlog
|
|||
from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright
|
||||
|
||||
from skyvern.config import settings
|
||||
from skyvern.webeye.cdp_connection import connect_over_cdp_with_diagnostics
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from skyvern.forge.sdk.routes.streaming.channels.vnc import VncChannel
|
||||
|
|
@ -99,7 +100,7 @@ class CdpChannel:
|
|||
close_task = asyncio.create_task(self.close())
|
||||
close_task.add_done_callback(lambda _: asyncio.create_task(self.connect())) # TODO: avoid blind reconnect
|
||||
|
||||
self.browser = await pw.chromium.connect_over_cdp(url, headers=headers)
|
||||
self.browser = await connect_over_cdp_with_diagnostics(pw, url, headers=headers)
|
||||
self.browser.on("disconnected", on_close)
|
||||
|
||||
await self.apply_download_behavior(self.browser)
|
||||
|
|
|
|||
|
|
@ -26,12 +26,17 @@ from skyvern.constants import (
|
|||
BROWSER_DOWNLOAD_TIMEOUT,
|
||||
SKYVERN_DIR,
|
||||
)
|
||||
from skyvern.exceptions import UnknownBrowserType, UnknownErrorWhileCreatingBrowserContext
|
||||
from skyvern.exceptions import (
|
||||
UnknownBrowserType,
|
||||
UnknownErrorWhileCreatingBrowserContext,
|
||||
)
|
||||
from skyvern.forge import app
|
||||
from skyvern.forge.sdk.api.files import get_download_dir, make_temp_directory
|
||||
from skyvern.forge.sdk.core.skyvern_context import current, ensure_context
|
||||
from skyvern.schemas.runs import ProxyLocation, get_tzinfo_from_proxy
|
||||
from skyvern.webeye.browser_artifacts import BrowserArtifacts, VideoArtifact
|
||||
from skyvern.webeye.cdp_connection import build_cdp_connect_headers
|
||||
from skyvern.webeye.cdp_connection import connect_over_cdp_with_diagnostics as _connect_over_cdp_with_diagnostics
|
||||
from skyvern.webeye.cdp_download_interceptor import CDPDownloadInterceptor
|
||||
from skyvern.webeye.dialog_handler import set_dialog_handler
|
||||
|
||||
|
|
@ -795,7 +800,12 @@ async def _connect_to_cdp_browser(
|
|||
)
|
||||
|
||||
LOG.info("Connecting browser CDP connection", remote_browser_url=remote_browser_url)
|
||||
browser = await playwright.chromium.connect_over_cdp(remote_browser_url)
|
||||
browser = await _connect_over_cdp_with_diagnostics(
|
||||
playwright,
|
||||
remote_browser_url,
|
||||
headers=build_cdp_connect_headers(settings.BROWSER_REMOTE_DEBUGGING_HOST_HEADER),
|
||||
timeout_ms=settings.BROWSER_CDP_CONNECT_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
if apply_download_behaviour:
|
||||
await _apply_download_behaviour(browser)
|
||||
|
|
|
|||
164
skyvern/webeye/cdp_connection.py
Normal file
164
skyvern/webeye/cdp_connection.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import socket
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import structlog
|
||||
from playwright.async_api import Browser, Playwright
|
||||
|
||||
from skyvern.exceptions import CdpConnectionConfigurationError
|
||||
|
||||
LOG = structlog.get_logger()
|
||||
DEFAULT_CDP_CONNECT_TIMEOUT_MS = 30_000
|
||||
|
||||
_CDP_DISCOVERY_ERROR_RE = re.compile(
|
||||
r"Unexpected status (?P<status>\d+) when connecting to (?P<url>https?://\S+/json/version/?)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CdpConnectionCandidate:
|
||||
url: str
|
||||
label: str
|
||||
headers: dict[str, str] | None = None
|
||||
|
||||
|
||||
def build_cdp_connect_headers(host_header: str | None) -> dict[str, str] | None:
|
||||
normalized_host_header = host_header.strip() if host_header else ""
|
||||
if not normalized_host_header:
|
||||
return None
|
||||
return {"Host": normalized_host_header}
|
||||
|
||||
|
||||
def parse_cdp_discovery_error(error: Exception) -> tuple[int, str] | None:
|
||||
"""Return the HTTP status and discovery URL from a Playwright CDP discovery error."""
|
||||
match = _CDP_DISCOVERY_ERROR_RE.search(str(error))
|
||||
if not match:
|
||||
return None
|
||||
return int(match.group("status")), match.group("url")
|
||||
|
||||
|
||||
def resolve_host_docker_internal_url(remote_browser_url: str) -> str | None:
|
||||
"""Resolve host.docker.internal to IPv4 to avoid Chrome DevTools Host-header issues."""
|
||||
parsed = urlparse(remote_browser_url)
|
||||
if parsed.scheme not in {"http", "https", "ws", "wss"} or parsed.hostname != "host.docker.internal":
|
||||
return None
|
||||
|
||||
try:
|
||||
address_info = socket.getaddrinfo(
|
||||
parsed.hostname,
|
||||
parsed.port or 9222,
|
||||
family=socket.AF_INET,
|
||||
)
|
||||
except socket.gaierror:
|
||||
return None
|
||||
|
||||
if not address_info:
|
||||
return None
|
||||
|
||||
resolved_host = str(address_info[0][4][0])
|
||||
if not resolved_host:
|
||||
return None
|
||||
|
||||
netloc = resolved_host
|
||||
if parsed.port:
|
||||
netloc = f"{resolved_host}:{parsed.port}"
|
||||
|
||||
return parsed._replace(netloc=netloc).geturl()
|
||||
|
||||
|
||||
def build_cdp_connection_candidates(
|
||||
remote_browser_url: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> Iterable[CdpConnectionCandidate]:
|
||||
"""Yield fallback CDP endpoints after the primary connect attempt fails."""
|
||||
resolved_url = resolve_host_docker_internal_url(remote_browser_url)
|
||||
if resolved_url:
|
||||
yield CdpConnectionCandidate(
|
||||
url=resolved_url,
|
||||
label="resolved host.docker.internal IPv4",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def build_cdp_configuration_error(
|
||||
remote_browser_url: str,
|
||||
error: Exception,
|
||||
) -> CdpConnectionConfigurationError | None:
|
||||
discovery_error = parse_cdp_discovery_error(error)
|
||||
if discovery_error is None:
|
||||
return None
|
||||
|
||||
status_code, discovery_url = discovery_error
|
||||
parsed = urlparse(remote_browser_url)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
return None
|
||||
|
||||
guidance = (
|
||||
f"Skyvern reached the configured CDP address ({remote_browser_url}), but "
|
||||
f"{discovery_url} returned HTTP {status_code}. Skyvern cdp-connect requires "
|
||||
"Chrome's classic DevTools Protocol endpoint, where /json/version returns JSON "
|
||||
"with webSocketDebuggerUrl. If you enabled chrome://inspect/#remote-debugging, "
|
||||
"set BROWSER_REMOTE_DEBUGGING_URL to the direct full "
|
||||
"ws://.../devtools/browser/... URL from Chrome's DevToolsActivePort file. "
|
||||
"On Windows Docker Desktop, run scripts/windows_chrome_inspect_cdp.ps1 to "
|
||||
"bridge Chrome's loopback-only listener before connecting."
|
||||
)
|
||||
|
||||
if parsed.hostname == "host.docker.internal":
|
||||
guidance += (
|
||||
" In Docker Desktop, Chrome can also reject the host.docker.internal "
|
||||
"Host header; use the Docker host gateway IPv4 address if the classic "
|
||||
"CDP endpoint returns HTTP 500."
|
||||
)
|
||||
|
||||
return CdpConnectionConfigurationError(guidance)
|
||||
|
||||
|
||||
async def connect_over_cdp_with_diagnostics(
|
||||
playwright: Playwright,
|
||||
remote_browser_url: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout_ms: int = DEFAULT_CDP_CONNECT_TIMEOUT_MS,
|
||||
) -> Browser:
|
||||
try:
|
||||
return await playwright.chromium.connect_over_cdp(
|
||||
remote_browser_url,
|
||||
timeout=timeout_ms,
|
||||
headers=headers,
|
||||
)
|
||||
except Exception as first_error:
|
||||
errors: list[tuple[str, Exception]] = [(remote_browser_url, first_error)]
|
||||
for candidate in build_cdp_connection_candidates(remote_browser_url, headers):
|
||||
message = (
|
||||
"Retrying CDP connection with resolved host.docker.internal IPv4"
|
||||
if candidate.label == "resolved host.docker.internal IPv4"
|
||||
else "Retrying CDP connection"
|
||||
)
|
||||
LOG.warning(
|
||||
message,
|
||||
reason=candidate.label,
|
||||
remote_browser_url=remote_browser_url,
|
||||
fallback_url=candidate.url,
|
||||
)
|
||||
try:
|
||||
return await playwright.chromium.connect_over_cdp(
|
||||
candidate.url,
|
||||
timeout=timeout_ms,
|
||||
headers=candidate.headers,
|
||||
)
|
||||
except Exception as candidate_error:
|
||||
errors.append((candidate.url, candidate_error))
|
||||
|
||||
for url, error in reversed(errors):
|
||||
configuration_error = build_cdp_configuration_error(url, error)
|
||||
if configuration_error:
|
||||
raise configuration_error from error
|
||||
|
||||
last_url, last_error = errors[-1]
|
||||
if last_url == remote_browser_url:
|
||||
raise last_error
|
||||
raise last_error from first_error
|
||||
265
tests/test_cli_doctor.py
Normal file
265
tests/test_cli_doctor.py
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import skyvern.cli.doctor as doctor_module
|
||||
|
||||
|
||||
def _write_valid_frontend_env(path: Path) -> None:
|
||||
path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"VITE_API_BASE_URL=http://localhost:8000/api/v1",
|
||||
"VITE_WSS_BASE_URL=ws://localhost:8000/api/v1",
|
||||
"VITE_ARTIFACT_API_BASE_URL=http://localhost:9090",
|
||||
"VITE_BROWSER_STREAMING_MODE=cdp",
|
||||
"",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_frontend_runtime_env_missing_reports_placeholder_fallback(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "skyvern-frontend").mkdir()
|
||||
(tmp_path / "docker-compose.yml").write_text("services: {}\n")
|
||||
|
||||
result = doctor_module._check_frontend_runtime_env()
|
||||
|
||||
assert result.status == "error"
|
||||
assert "skyvern-frontend/.env is missing" in result.detail
|
||||
assert "Dockerfile placeholders" in result.detail
|
||||
|
||||
|
||||
def test_frontend_runtime_env_detects_placeholder_urls(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(doctor_module.shutil, "which", lambda _name: None)
|
||||
frontend_dir = tmp_path / "skyvern-frontend"
|
||||
frontend_dir.mkdir()
|
||||
(frontend_dir / ".env").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"VITE_API_BASE_URL=__VITE_API_BASE_URL_PLACEHOLDER__",
|
||||
"VITE_WSS_BASE_URL=ws://localhost:8000/api/v1",
|
||||
"VITE_ARTIFACT_API_BASE_URL=http://localhost:9090",
|
||||
"VITE_BROWSER_STREAMING_MODE=cdp",
|
||||
"",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
result = doctor_module._check_frontend_runtime_env()
|
||||
|
||||
assert result.status == "error"
|
||||
assert "VITE_API_BASE_URL is still __VITE_API_BASE_URL_PLACEHOLDER__" in result.detail
|
||||
|
||||
|
||||
def test_frontend_runtime_env_accepts_valid_host_env_without_docker(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(doctor_module.shutil, "which", lambda _name: None)
|
||||
frontend_dir = tmp_path / "skyvern-frontend"
|
||||
frontend_dir.mkdir()
|
||||
_write_valid_frontend_env(frontend_dir / ".env")
|
||||
|
||||
result = doctor_module._check_frontend_runtime_env()
|
||||
|
||||
assert result.status == "ok"
|
||||
assert "valid Vite runtime URLs" in result.detail
|
||||
|
||||
|
||||
def test_fix_frontend_runtime_env_writes_defaults_and_unquoted_api_key(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(doctor_module, "_docker_compose_available", lambda: False)
|
||||
frontend_dir = tmp_path / "skyvern-frontend"
|
||||
frontend_dir.mkdir()
|
||||
(tmp_path / ".env").write_text("SKYVERN_API_KEY=sk-test\n")
|
||||
(frontend_dir / ".env").write_text("VITE_API_BASE_URL=__VITE_API_BASE_URL_PLACEHOLDER__\n")
|
||||
|
||||
assert doctor_module._fix_frontend_runtime_env() is True
|
||||
|
||||
contents = (frontend_dir / ".env").read_text()
|
||||
assert "VITE_API_BASE_URL=http://localhost:8000/api/v1" in contents
|
||||
assert "VITE_WSS_BASE_URL=ws://localhost:8000/api/v1" in contents
|
||||
assert "VITE_ARTIFACT_API_BASE_URL=http://localhost:9090" in contents
|
||||
assert "VITE_BROWSER_STREAMING_MODE=cdp" in contents
|
||||
assert "VITE_SKYVERN_API_KEY=sk-test" in contents
|
||||
assert "VITE_SKYVERN_API_KEY='sk-test'" not in contents
|
||||
|
||||
|
||||
def test_local_streaming_mode_warns_when_frontend_is_vnc(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(doctor_module, "_docker_compose_available", lambda: False)
|
||||
(tmp_path / ".env").write_text("BROWSER_STREAMING_MODE=cdp\n")
|
||||
frontend_dir = tmp_path / "skyvern-frontend"
|
||||
frontend_dir.mkdir()
|
||||
(frontend_dir / ".env").write_text("VITE_BROWSER_STREAMING_MODE=vnc\n")
|
||||
|
||||
result = doctor_module._check_local_streaming_mode()
|
||||
|
||||
assert result.status == "warn"
|
||||
assert "VITE_BROWSER_STREAMING_MODE should be cdp" in result.detail
|
||||
|
||||
|
||||
def test_fix_local_streaming_mode_writes_backend_and_frontend(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(doctor_module, "_docker_compose_available", lambda: False)
|
||||
frontend_dir = tmp_path / "skyvern-frontend"
|
||||
frontend_dir.mkdir()
|
||||
(tmp_path / ".env").write_text("BROWSER_STREAMING_MODE=vnc\n")
|
||||
(frontend_dir / ".env").write_text("VITE_BROWSER_STREAMING_MODE=vnc\n")
|
||||
|
||||
assert doctor_module._fix_local_streaming_mode() is True
|
||||
|
||||
assert "BROWSER_STREAMING_MODE=cdp" in (tmp_path / ".env").read_text()
|
||||
assert "VITE_BROWSER_STREAMING_MODE=cdp" in (frontend_dir / ".env").read_text()
|
||||
|
||||
|
||||
def test_compose_database_connection_can_satisfy_database_check(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / ".env").write_text("DATABASE_STRING=postgresql+psycopg://skyvern:skyvern@localhost:5432/skyvern\n")
|
||||
|
||||
def fake_run(*_args, **_kwargs):
|
||||
return subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="ModuleNotFoundError: No module named 'sqlalchemy'",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(doctor_module.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(
|
||||
doctor_module,
|
||||
"_check_compose_database_connection",
|
||||
lambda: doctor_module.CheckResult(
|
||||
name="Database",
|
||||
status="ok",
|
||||
detail="Docker Compose backend can connect to database",
|
||||
),
|
||||
)
|
||||
|
||||
result = doctor_module._check_database()
|
||||
|
||||
assert result.status == "ok"
|
||||
assert "Docker Compose backend can connect" in result.detail
|
||||
|
||||
|
||||
def test_docker_local_auth_diagnostic_uses_backend_status_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured_script = ""
|
||||
|
||||
def fake_exec(_service: str, script: str, timeout: int = 30):
|
||||
nonlocal captured_script
|
||||
captured_script = script
|
||||
return subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout='{"status": "ok", "organization_id": "o_test"}\n',
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(doctor_module, "_docker_compose_available", lambda: True)
|
||||
monkeypatch.setattr(doctor_module, "_run_docker_compose_exec", fake_exec)
|
||||
|
||||
result = doctor_module._check_docker_local_auth()
|
||||
|
||||
assert result.status == "ok"
|
||||
assert "/api/v1/internal/auth/status" in captured_script
|
||||
assert "from jose" not in captured_script
|
||||
assert "app.DATABASE" not in captured_script
|
||||
|
||||
|
||||
def test_docker_local_auth_reports_backend_error_detail(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_exec(_service: str, _script: str, timeout: int = 30):
|
||||
return subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout='{"detail": "Unable to diagnose API key"}\n',
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(doctor_module, "_docker_compose_available", lambda: True)
|
||||
monkeypatch.setattr(doctor_module, "_run_docker_compose_exec", fake_exec)
|
||||
|
||||
result = doctor_module._check_docker_local_auth()
|
||||
|
||||
assert result.status == "error"
|
||||
assert "HTTP error" in result.detail
|
||||
assert "Unable to diagnose API key" in result.detail
|
||||
|
||||
|
||||
def test_fingerprint_does_not_leak_value() -> None:
|
||||
api_key = "skv_live_abcdef0123456789abcdef0123456789"
|
||||
|
||||
tag = doctor_module._fingerprint(api_key)
|
||||
|
||||
assert len(tag) == 12
|
||||
assert api_key not in tag
|
||||
assert api_key[:8] not in tag
|
||||
assert api_key[-4:] not in tag
|
||||
|
||||
|
||||
def test_fingerprint_is_deterministic_within_process() -> None:
|
||||
api_key = "skv_live_abcdef0123456789abcdef0123456789"
|
||||
|
||||
assert doctor_module._fingerprint(api_key) == doctor_module._fingerprint(api_key)
|
||||
|
||||
|
||||
def test_fingerprint_missing_sentinel_for_empty_string() -> None:
|
||||
assert doctor_module._fingerprint("") == "missing"
|
||||
|
||||
|
||||
def test_fix_docker_local_auth_hardens_generated_credentials_file(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
doctor_module,
|
||||
"_run_docker_compose_exec",
|
||||
lambda *_args, **_kwargs: subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout='{"api_key": "sk-test", "organization_id": "o_test"}\n',
|
||||
stderr="",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(doctor_module, "_recreate_docker_services", lambda _services: True)
|
||||
|
||||
frontend_dir = tmp_path / "skyvern-frontend"
|
||||
frontend_dir.mkdir()
|
||||
(frontend_dir / ".env").write_text("VITE_API_BASE_URL=http://localhost:8000/api/v1\n")
|
||||
credentials_file = tmp_path / ".skyvern" / "credentials.toml"
|
||||
monkeypatch.setattr(doctor_module, "GENERATED_CREDENTIALS_FILE", credentials_file)
|
||||
|
||||
assert doctor_module._fix_docker_local_auth() is True
|
||||
assert 'cred = "sk-test"' in credentials_file.read_text()
|
||||
|
||||
if os.name == "posix":
|
||||
assert stat.S_IMODE(credentials_file.stat().st_mode) == 0o600
|
||||
|
|
@ -4,6 +4,10 @@ from skyvern.webeye.browser_factory import (
|
|||
_is_browser_profile_corruption_error,
|
||||
_is_display_server_error,
|
||||
)
|
||||
from skyvern.webeye.cdp_connection import (
|
||||
build_cdp_configuration_error,
|
||||
parse_cdp_discovery_error,
|
||||
)
|
||||
|
||||
# -- _is_display_server_error ------------------------------------------------
|
||||
|
||||
|
|
@ -105,3 +109,69 @@ class TestIsBrowserProfileCorruptionError:
|
|||
def test_unrelated_error_is_not_corruption(self) -> None:
|
||||
error = Exception("network timeout after 30 seconds")
|
||||
assert _is_browser_profile_corruption_error(error) is False
|
||||
|
||||
|
||||
# -- CDP connection diagnostics ---------------------------------------------
|
||||
|
||||
|
||||
class TestCdpConnectionDiagnostics:
|
||||
"""Detect non-CDP listeners on a configured CDP HTTP endpoint."""
|
||||
|
||||
def test_parse_playwright_http_discovery_status_error(self) -> None:
|
||||
error = Exception(
|
||||
"BrowserType.connect_over_cdp: Unexpected status 404 when connecting to "
|
||||
"http://192.168.65.254:9222/json/version/. This does not look like a "
|
||||
"DevTools server, try connecting via ws://."
|
||||
)
|
||||
|
||||
assert parse_cdp_discovery_error(error) == (
|
||||
404,
|
||||
"http://192.168.65.254:9222/json/version/",
|
||||
)
|
||||
|
||||
def test_builds_mcp_style_remote_debugging_guidance(self) -> None:
|
||||
error = Exception(
|
||||
"BrowserType.connect_over_cdp: Unexpected status 404 when connecting to "
|
||||
"http://192.168.65.254:9222/json/version/. This does not look like a "
|
||||
"DevTools server, try connecting via ws://."
|
||||
)
|
||||
|
||||
configuration_error = build_cdp_configuration_error(
|
||||
"http://192.168.65.254:9222/",
|
||||
error,
|
||||
)
|
||||
|
||||
assert configuration_error is not None
|
||||
message = str(configuration_error)
|
||||
assert "/json/version returns JSON with webSocketDebuggerUrl" in message
|
||||
assert "chrome://inspect/#remote-debugging" in message
|
||||
assert "DevToolsActivePort" in message
|
||||
assert "windows_chrome_inspect_cdp.ps1" in message
|
||||
|
||||
def test_host_docker_internal_guidance_mentions_gateway_ip(self) -> None:
|
||||
error = Exception(
|
||||
"BrowserType.connect_over_cdp: Unexpected status 500 when connecting to "
|
||||
"http://host.docker.internal:9222/json/version/. This does not look like a "
|
||||
"DevTools server, try connecting via ws://."
|
||||
)
|
||||
|
||||
configuration_error = build_cdp_configuration_error(
|
||||
"http://host.docker.internal:9222/",
|
||||
error,
|
||||
)
|
||||
|
||||
assert configuration_error is not None
|
||||
message = str(configuration_error)
|
||||
assert "host.docker.internal" in message
|
||||
assert "Docker host gateway IPv4" in message
|
||||
|
||||
def test_direct_websocket_errors_are_not_rewritten(self) -> None:
|
||||
error = Exception("WebSocket was closed before the connection was established")
|
||||
|
||||
assert (
|
||||
build_cdp_configuration_error(
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc",
|
||||
error,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
|
|
|||
180
tests/unit/test_cdp_connection.py
Normal file
180
tests/unit/test_cdp_connection.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from playwright.async_api import Playwright
|
||||
|
||||
import skyvern.webeye.cdp_connection as cdp_connection
|
||||
from skyvern.webeye.cdp_connection import (
|
||||
build_cdp_connect_headers,
|
||||
build_cdp_connection_candidates,
|
||||
connect_over_cdp_with_diagnostics,
|
||||
resolve_host_docker_internal_url,
|
||||
)
|
||||
|
||||
|
||||
def test_build_cdp_connect_headers_uses_host_header() -> None:
|
||||
assert build_cdp_connect_headers(" 127.0.0.1:9222 ") == {"Host": "127.0.0.1:9222"}
|
||||
|
||||
|
||||
def test_build_cdp_connect_headers_ignores_empty_host_header() -> None:
|
||||
assert build_cdp_connect_headers(None) is None
|
||||
assert build_cdp_connect_headers(" ") is None
|
||||
|
||||
|
||||
def test_resolve_host_docker_internal_url_uses_resolved_ipv4(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_getaddrinfo(host: str, port: int, family: socket.AddressFamily) -> list[Any]:
|
||||
assert host == "host.docker.internal"
|
||||
assert port == 9222
|
||||
assert family == socket.AF_INET
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.168.65.254", port))]
|
||||
|
||||
monkeypatch.setattr(cdp_connection.socket, "getaddrinfo", fake_getaddrinfo)
|
||||
|
||||
assert resolve_host_docker_internal_url("http://host.docker.internal:9222/") == "http://192.168.65.254:9222/"
|
||||
|
||||
|
||||
def test_resolve_host_docker_internal_url_ignores_non_docker_hosts() -> None:
|
||||
assert resolve_host_docker_internal_url("http://127.0.0.1:9222/") is None
|
||||
|
||||
|
||||
def test_build_cdp_connection_candidates_includes_resolved_host_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def fake_getaddrinfo(host: str, port: int, family: socket.AddressFamily) -> list[Any]:
|
||||
assert host == "host.docker.internal"
|
||||
assert port == 9222
|
||||
assert family == socket.AF_INET
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.168.65.254", port))]
|
||||
|
||||
monkeypatch.setattr(cdp_connection.socket, "getaddrinfo", fake_getaddrinfo)
|
||||
|
||||
candidates = list(build_cdp_connection_candidates("http://host.docker.internal:9222/", {"x-api-key": "key"}))
|
||||
|
||||
assert [(candidate.label, candidate.url, candidate.headers) for candidate in candidates] == [
|
||||
("resolved host.docker.internal IPv4", "http://192.168.65.254:9222/", {"x-api-key": "key"}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_over_cdp_retries_resolved_host_with_headers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
expected_browser = object()
|
||||
|
||||
class FakeChromium:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, int | None, dict[str, str] | None]] = []
|
||||
|
||||
async def connect_over_cdp(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> object:
|
||||
self.calls.append((url, timeout, headers))
|
||||
if len(self.calls) == 1:
|
||||
raise Exception(
|
||||
"BrowserType.connect_over_cdp: Unexpected status 500 when connecting to "
|
||||
"http://host.docker.internal:9222/json/version/."
|
||||
)
|
||||
return expected_browser
|
||||
|
||||
class FakePlaywright:
|
||||
def __init__(self) -> None:
|
||||
self.chromium = FakeChromium()
|
||||
|
||||
def fake_getaddrinfo(host: str, port: int, family: socket.AddressFamily) -> list[Any]:
|
||||
assert host == "host.docker.internal"
|
||||
assert port == 9222
|
||||
assert family == socket.AF_INET
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.168.65.254", port))]
|
||||
|
||||
monkeypatch.setattr(cdp_connection.socket, "getaddrinfo", fake_getaddrinfo)
|
||||
fake_playwright = FakePlaywright()
|
||||
headers = {"x-api-key": "test-key"}
|
||||
|
||||
browser = await connect_over_cdp_with_diagnostics(
|
||||
cast(Playwright, fake_playwright),
|
||||
"http://host.docker.internal:9222/",
|
||||
headers=headers,
|
||||
timeout_ms=120000,
|
||||
)
|
||||
|
||||
assert browser is expected_browser
|
||||
assert fake_playwright.chromium.calls == [
|
||||
("http://host.docker.internal:9222/", 120000, headers),
|
||||
("http://192.168.65.254:9222/", 120000, headers),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_over_cdp_accepts_direct_websocket_with_default_timeout() -> None:
|
||||
expected_browser = object()
|
||||
|
||||
class FakeChromium:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, int | None, dict[str, str] | None]] = []
|
||||
|
||||
async def connect_over_cdp(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> object:
|
||||
self.calls.append((url, timeout, headers))
|
||||
return expected_browser
|
||||
|
||||
class FakePlaywright:
|
||||
def __init__(self) -> None:
|
||||
self.chromium = FakeChromium()
|
||||
|
||||
fake_playwright = FakePlaywright()
|
||||
|
||||
browser = await connect_over_cdp_with_diagnostics(
|
||||
cast(Playwright, fake_playwright),
|
||||
"ws://host.docker.internal:9223/devtools/browser/abc",
|
||||
)
|
||||
|
||||
assert browser is expected_browser
|
||||
assert fake_playwright.chromium.calls == [
|
||||
("ws://host.docker.internal:9223/devtools/browser/abc", 30_000, None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_over_cdp_accepts_direct_websocket_with_host_header() -> None:
|
||||
expected_browser = object()
|
||||
|
||||
class FakeChromium:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, int | None, dict[str, str] | None]] = []
|
||||
|
||||
async def connect_over_cdp(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> object:
|
||||
self.calls.append((url, timeout, headers))
|
||||
return expected_browser
|
||||
|
||||
class FakePlaywright:
|
||||
def __init__(self) -> None:
|
||||
self.chromium = FakeChromium()
|
||||
|
||||
fake_playwright = FakePlaywright()
|
||||
headers = {"Host": "127.0.0.1:9222"}
|
||||
|
||||
browser = await connect_over_cdp_with_diagnostics(
|
||||
cast(Playwright, fake_playwright),
|
||||
"ws://host.docker.internal:9223/devtools/browser/abc",
|
||||
headers=headers,
|
||||
timeout_ms=120000,
|
||||
)
|
||||
|
||||
assert browser is expected_browser
|
||||
assert fake_playwright.chromium.calls == [
|
||||
("ws://host.docker.internal:9223/devtools/browser/abc", 120000, headers),
|
||||
]
|
||||
|
|
@ -53,8 +53,8 @@ def test_legacy_streamlit_fix_migrates_only_parseable_key(tmp_path: Path, monkey
|
|||
assert "backend .env is missing" in result.detail
|
||||
assert doctor._fix_legacy_streamlit_secrets() is True
|
||||
assert not legacy.exists()
|
||||
assert "SKYVERN_API_KEY='legacy-key'" in (tmp_path / ".env").read_text()
|
||||
assert "VITE_SKYVERN_API_KEY='legacy-key'" in (tmp_path / "skyvern-frontend" / ".env").read_text()
|
||||
assert "SKYVERN_API_KEY=legacy-key" in (tmp_path / ".env").read_text()
|
||||
assert "VITE_SKYVERN_API_KEY=legacy-key" in (tmp_path / "skyvern-frontend" / ".env").read_text()
|
||||
|
||||
|
||||
def test_legacy_streamlit_fix_removes_matching_deprecated_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from http import HTTPStatus
|
|||
import pytest
|
||||
|
||||
from skyvern.exceptions import (
|
||||
CdpConnectionConfigurationError,
|
||||
SkyvernException,
|
||||
SkyvernExtraNotInstalled,
|
||||
SkyvernHTTPException,
|
||||
|
|
@ -33,6 +34,20 @@ def test_unknown_error_while_creating_browser_context_strips_call_log() -> None:
|
|||
assert "support@skyvern.com" in message
|
||||
|
||||
|
||||
def test_unknown_error_preserves_cdp_configuration_guidance() -> None:
|
||||
inner_exception = CdpConnectionConfigurationError(
|
||||
"Skyvern reached the configured CDP address, but /json/version returned HTTP 404. "
|
||||
"Start Chrome with --remote-debugging-port=9222."
|
||||
)
|
||||
|
||||
error = UnknownErrorWhileCreatingBrowserContext("cdp-connect", inner_exception)
|
||||
message = str(error)
|
||||
|
||||
assert "CdpConnectionConfigurationError" in message
|
||||
assert "/json/version returned HTTP 404" in message
|
||||
assert "--remote-debugging-port=9222" in message
|
||||
|
||||
|
||||
def test_get_user_facing_exception_message_for_skyvern_exception() -> None:
|
||||
message = get_user_facing_exception_message(SkyvernException("Human-friendly message"))
|
||||
assert message == "Human-friendly message"
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -5499,7 +5499,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "skyvern"
|
||||
version = "1.0.34"
|
||||
version = "1.0.35"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "cachetools" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue