diff --git a/.env.example b/.env.example index 071549d9e..59aae9646 100644 --- a/.env.example +++ b/.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:. +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. diff --git a/Dockerfile.ui b/Dockerfile.ui index f4d014cf1..8005922dd 100644 --- a/Dockerfile.ui +++ b/Dockerfile.ui @@ -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"] - diff --git a/docker-compose.yml b/docker-compose.yml index f16df0201..459558ad4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/developers/self-hosted/browser.mdx b/docs/developers/self-hosted/browser.mdx index 515581ed2..40e7a6b68 100644 --- a/docs/developers/self-hosted/browser.mdx +++ b/docs/developers/self-hosted/browser.mdx @@ -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 + +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`. + + --- ## 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`: ```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/ +BROWSER_REMOTE_DEBUGGING_HOST_HEADER=127.0.0.1: +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/` endpoint. In that mode, verify by +starting a Skyvern browser session after restarting the backend. + + +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. + -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. --- diff --git a/docs/developers/self-hosted/docker.mdx b/docs/developers/self-hosted/docker.mdx index 3321fd40c..040a136d6 100644 --- a/docs/developers/self-hosted/docker.mdx +++ b/docs/developers/self-hosted/docker.mdx @@ -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 diff --git a/entrypoint-skyvernui.sh b/entrypoint-skyvernui.sh index 6deefae86..eea748ac6 100644 --- a/entrypoint-skyvernui.sh +++ b/entrypoint-skyvernui.sh @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 9d8406b54..405915128 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/scripts/windows_chrome_inspect_cdp.ps1 b/scripts/windows_chrome_inspect_cdp.ps1 new file mode 100644 index 000000000..2ec7db588 --- /dev/null +++ b/scripts/windows_chrome_inspect_cdp.ps1 @@ -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." diff --git a/skyvern-frontend/.env.example b/skyvern-frontend/.env.example index 3086cf6a4..b52b77093 100644 --- a/skyvern-frontend/.env.example +++ b/skyvern-frontend/.env.example @@ -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 diff --git a/skyvern-frontend/src/api/AxiosClient.ts b/skyvern-frontend/src/api/AxiosClient.ts index be05d207e..40fad40c0 100644 --- a/skyvern-frontend/src/api/AxiosClient.ts +++ b/skyvern-frontend/src/api/AxiosClient.ts @@ -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; diff --git a/skyvern-frontend/src/components/SelfHealApiKeyBanner.tsx b/skyvern-frontend/src/components/SelfHealApiKeyBanner.tsx index f05da6c86..f59bb4461 100644 --- a/skyvern-frontend/src/components/SelfHealApiKeyBanner.tsx +++ b/skyvern-frontend/src/components/SelfHealApiKeyBanner.tsx @@ -8,8 +8,12 @@ import { AuthStatusValue, useAuthDiagnostics, } from "@/hooks/useAuthDiagnostics"; +import { useAuthIssueStore } from "@/store/AuthIssueStore"; -type BannerStatus = Exclude | "error"; +type BannerStatus = + | Exclude + | "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(null); @@ -67,7 +79,9 @@ function SelfHealApiKeyBanner() { ? "error" : rawStatus && rawStatus !== "ok" ? rawStatus - : null; + : authIssue + ? "request_auth_error" + : null; if (!bannerStatus && !errorMessage) { if (isLoading) { @@ -103,6 +117,7 @@ function SelfHealApiKeyBanner() { } setApiKeyHeader(apiKey); + clearAuthIssue(); const fingerprintSuffix = fingerprint ? ` (fingerprint ${fingerprint})` @@ -172,6 +187,20 @@ function SelfHealApiKeyBanner() { {data?.next_step ? (

{data.next_step}

) : null} + {authIssue ? ( +

+ Last failed request: HTTP {authIssue.statusCode} + {authIssue.path ? ` at ${authIssue.path}` : ""} + {authIssue.detail ? ` (${authIssue.detail})` : ""} +

+ ) : null} + {bannerStatus === "request_auth_error" ? ( +

+ Run skyvern doctor --fix from the cloned Skyvern + directory, then restart Docker Compose if the command asks you + to. +

+ ) : null} {isProductionBuild && (

When running a production build, the regenerated API key is diff --git a/skyvern-frontend/src/routes/tasks/detail/TaskActions.tsx b/skyvern-frontend/src/routes/tasks/detail/TaskActions.tsx index 3464e641c..179cb582b 100644 --- a/skyvern-frontend/src/routes/tasks/detail/TaskActions.tsx +++ b/skyvern-frontend/src/routes/tasks/detail/TaskActions.tsx @@ -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 ( ) => void; + clearAuthIssue: () => void; +}; + +const useAuthIssueStore = create((set) => ({ + issue: null, + reportAuthIssue: (issue) => { + set({ issue: { ...issue, seenAt: Date.now() } }); + }, + clearAuthIssue: () => { + set({ issue: null }); + }, +})); + +export { useAuthIssueStore }; +export type { AuthIssue }; diff --git a/skyvern-frontend/src/util/env.test.ts b/skyvern-frontend/src/util/env.test.ts index b8addc229..57895d243 100644 --- a/skyvern-frontend/src/util/env.test.ts +++ b/skyvern-frontend/src/util/env.test.ts @@ -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"); + }); +}); diff --git a/skyvern-frontend/src/util/env.ts b/skyvern-frontend/src/util/env.ts index 835b424b1..346612b01 100644 --- a/skyvern-frontend/src/util/env.ts +++ b/skyvern-frontend/src/util/env.ts @@ -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" diff --git a/skyvern/cli/browser.py b/skyvern/cli/browser.py index 31a42681f..074ba754e 100644 --- a/skyvern/cli/browser.py +++ b/skyvern/cli/browser.py @@ -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, - ) - elif system == "Windows": - subprocess.Popen( - ["start", "chrome", url], - shell=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - 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]") + if system == "Darwin": + 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" + ) + 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", + ) + ) 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", diff --git a/skyvern/cli/doctor.py b/skyvern/cli/doctor.py index f074968ac..b579de0f3 100644 --- a/skyvern/cli/doctor.py +++ b/skyvern/cli/doctor.py @@ -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) diff --git a/skyvern/cli/init_command.py b/skyvern/cli/init_command.py index 23d29444a..3fba64b47 100644 --- a/skyvern/cli/init_command.py +++ b/skyvern/cli/init_command.py @@ -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, diff --git a/skyvern/cli/llm_setup.py b/skyvern/cli/llm_setup.py index c75319930..7d122145b 100644 --- a/skyvern/cli/llm_setup.py +++ b/skyvern/cli/llm_setup.py @@ -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", diff --git a/skyvern/cli/quickstart.py b/skyvern/cli/quickstart.py index cce3b6be4..e9d38ee49 100644 --- a/skyvern/cli/quickstart.py +++ b/skyvern/cli/quickstart.py @@ -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: diff --git a/skyvern/config.py b/skyvern/config.py index abf3d6801..1024a7798 100644 --- a/skyvern/config.py +++ b/skyvern/config.py @@ -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" diff --git a/skyvern/exceptions.py b/skyvern/exceptions.py index 0b8956556..a5c444f05 100644 --- a/skyvern/exceptions.py +++ b/skyvern/exceptions.py @@ -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() diff --git a/skyvern/forge/sdk/routes/streaming/channels/cdp.py b/skyvern/forge/sdk/routes/streaming/channels/cdp.py index 76e9bae12..500b99c1b 100644 --- a/skyvern/forge/sdk/routes/streaming/channels/cdp.py +++ b/skyvern/forge/sdk/routes/streaming/channels/cdp.py @@ -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) diff --git a/skyvern/webeye/browser_factory.py b/skyvern/webeye/browser_factory.py index e5baf8ef7..a4361ea8a 100644 --- a/skyvern/webeye/browser_factory.py +++ b/skyvern/webeye/browser_factory.py @@ -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) diff --git a/skyvern/webeye/cdp_connection.py b/skyvern/webeye/cdp_connection.py new file mode 100644 index 000000000..77d14a1fe --- /dev/null +++ b/skyvern/webeye/cdp_connection.py @@ -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\d+) when connecting to (?Phttps?://\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 diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py new file mode 100644 index 000000000..69c15b5eb --- /dev/null +++ b/tests/test_cli_doctor.py @@ -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 diff --git a/tests/unit/test_browser_profile_corruption_detection.py b/tests/unit/test_browser_profile_corruption_detection.py index 678adab45..b725c8f33 100644 --- a/tests/unit/test_browser_profile_corruption_detection.py +++ b/tests/unit/test_browser_profile_corruption_detection.py @@ -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 + ) diff --git a/tests/unit/test_cdp_connection.py b/tests/unit/test_cdp_connection.py new file mode 100644 index 000000000..85fede7c7 --- /dev/null +++ b/tests/unit/test_cdp_connection.py @@ -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), + ] diff --git a/tests/unit/test_cli_doctor.py b/tests/unit/test_cli_doctor.py index 029e5c2fb..a3e3e7933 100644 --- a/tests/unit/test_cli_doctor.py +++ b/tests/unit/test_cli_doctor.py @@ -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: diff --git a/tests/unit/test_exception_messages.py b/tests/unit/test_exception_messages.py index 2d276d551..222108d83 100644 --- a/tests/unit/test_exception_messages.py +++ b/tests/unit/test_exception_messages.py @@ -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" diff --git a/uv.lock b/uv.lock index 90b514488..11892d361 100644 --- a/uv.lock +++ b/uv.lock @@ -5499,7 +5499,7 @@ wheels = [ [[package]] name = "skyvern" -version = "1.0.34" +version = "1.0.35" source = { editable = "." } dependencies = [ { name = "cachetools" },