[Self-host] Add CDP streaming defaults and diagnostics (#5875)

Co-authored-by: Marc Kelechava <marc.kelechava@gmail.com>
Co-authored-by: OmX <omx@oh-my-codex.dev>
Co-authored-by: Marc Kelechava <marc@skyvern.com>
This commit is contained in:
Suchintan 2026-05-08 02:40:27 -04:00 committed by GitHub
parent e666d5b0c5
commit 2dc25e6146
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1951 additions and 115 deletions

View file

@ -1,8 +1,9 @@
# Environment that the agent will run in.
ENV=local
# Browser streaming mode: "cdp" for CDP screencast, "vnc" (default) for VNC streaming.
BROWSER_STREAMING_MODE=vnc
# Browser streaming mode: "cdp" for local CDP screencast, "vnc" for VNC streaming.
# Quickstart writes "cdp" for new local installs; app code falls back to "vnc" if unset.
BROWSER_STREAMING_MODE=cdp
# LLM Provider Configurations:
# ENABLE_OPENAI: Set to true to enable OpenAI as a language model provider.
@ -72,6 +73,11 @@ SECONDARY_LLM_KEY=""
# Web browser configuration for scraping:
# BROWSER_TYPE: Can be either "chromium-headless" or "chromium-headful".
BROWSER_TYPE="chromium-headful"
# BROWSER_REMOTE_DEBUGGING_HOST_HEADER: Optional Host header for cdp-connect.
# Windows chrome://inspect Docker bridges may need this set to 127.0.0.1:<chrome-port>.
BROWSER_REMOTE_DEBUGGING_HOST_HEADER=
# BROWSER_CDP_CONNECT_TIMEOUT_MS: Timeout for cdp-connect startup/approval in milliseconds.
BROWSER_CDP_CONNECT_TIMEOUT_MS=120000
# MAX_SCRAPING_RETRIES: Number of times to retry scraping a page before giving up, currently set to 0.
MAX_SCRAPING_RETRIES=0
# VIDEO_PATH: Path to the directory where videos will be saved.

View file

@ -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"]

View file

@ -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

View file

@ -64,6 +64,7 @@ Useful for:
```bash .env
BROWSER_TYPE=cdp-connect
BROWSER_REMOTE_DEBUGGING_URL=http://host.docker.internal:9222/
BROWSER_CDP_CONNECT_TIMEOUT_MS=120000
```
Connect to an existing Chrome instance running with remote debugging enabled. Useful for:
@ -71,6 +72,12 @@ Connect to an existing Chrome instance running with remote debugging enabled. Us
- Debugging with Chrome DevTools
- Running automations on a browser with specific extensions
<Note>
Docker Compose self-host installs default `BROWSER_STREAMING_MODE` to `cdp`.
Existing installs that already set this variable in `.env` keep their value.
To opt out of CDP livestreaming, set `BROWSER_STREAMING_MODE=vnc` in `.env`.
</Note>
---
## Setting up Use Your Own Chrome
@ -134,12 +141,16 @@ If port 9222 is already in use (e.g., from a previous session), Skyvern will con
CDP (Chrome DevTools Protocol) lets Skyvern control an external Chrome browser instead of launching its own.
### Step 1: Start Chrome with remote debugging
### Step 1: Enable Chrome remote debugging
When Skyvern runs in Docker, Chrome must listen on an address the container can
reach. Start Chrome manually with `--remote-debugging-address=0.0.0.0`:
<CodeGroup>
```bash macOS
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9222 \
--remote-debugging-address=0.0.0.0 \
--user-data-dir="$HOME/chrome-cdp-profile" \
--no-first-run \
--no-default-browser-check
@ -148,6 +159,7 @@ CDP (Chrome DevTools Protocol) lets Skyvern control an external Chrome browser i
```powershell Windows
"C:\Program Files\Google\Chrome\Application\chrome.exe" `
--remote-debugging-port=9222 `
--remote-debugging-address=0.0.0.0 `
--user-data-dir="C:\chrome-cdp-profile" `
--no-first-run `
--no-default-browser-check
@ -156,6 +168,7 @@ CDP (Chrome DevTools Protocol) lets Skyvern control an external Chrome browser i
```bash Linux
google-chrome \
--remote-debugging-port=9222 \
--remote-debugging-address=0.0.0.0 \
--user-data-dir="$HOME/chrome-cdp-profile" \
--no-first-run \
--no-default-browser-check
@ -164,11 +177,44 @@ google-chrome \
The `--user-data-dir` flag creates a separate profile for Skyvern, preserving your main Chrome profile.
#### Windows Docker Desktop with `chrome://inspect`
Chrome's `chrome://inspect/#remote-debugging` toggle can bind its debugging
server to host loopback only. Docker Desktop cannot connect to that listener
directly through `host.docker.internal`. Skyvern includes a Windows helper that
bridges Docker to Chrome and writes the exact WebSocket URL from Chrome's
`DevToolsActivePort` file.
Open Chrome, go to `chrome://inspect/#remote-debugging`, enable remote
debugging, then run this from an Administrator PowerShell in the Skyvern repo:
```powershell Windows
.\scripts\windows_chrome_inspect_cdp.ps1 -UpdateEnv
docker compose up -d --force-recreate skyvern
```
The helper creates a Windows port proxy from `0.0.0.0:9223` to Chrome's
loopback debugging port, adds a matching firewall rule, reads the full browser
WebSocket path, and writes:
```bash .env
BROWSER_TYPE=cdp-connect
BROWSER_REMOTE_DEBUGGING_URL=ws://host.docker.internal:9223/devtools/browser/<id>
BROWSER_REMOTE_DEBUGGING_HOST_HEADER=127.0.0.1:<chrome-port>
BROWSER_STREAMING_MODE=cdp
BROWSER_CDP_CONNECT_TIMEOUT_MS=120000
```
Chrome may prompt you to allow the remote debugging connection the first time
Skyvern connects. Click **Allow** and retry the browser session if the first
attempt times out.
### Step 2: Configure Skyvern
```bash .env
BROWSER_TYPE=cdp-connect
BROWSER_REMOTE_DEBUGGING_URL=http://host.docker.internal:9222/
BROWSER_CDP_CONNECT_TIMEOUT_MS=120000
```
When running Skyvern in Docker:
@ -178,18 +224,46 @@ When running Skyvern in Docker:
| macOS/Windows | `http://host.docker.internal:9222/` |
| Linux | `http://172.17.0.1:9222/` |
If Docker Desktop reaches Chrome but Chrome rejects the `host.docker.internal`
host name, Skyvern retries with the Docker host gateway IPv4 address. You can
also set `BROWSER_REMOTE_DEBUGGING_URL` directly to that IPv4 address.
If Windows Defender Firewall blocks the connection from Docker, allow inbound
TCP traffic on port 9222:
```powershell Windows
New-NetFirewallRule -DisplayName "Chrome Remote Debug 9222" -Direction Inbound -LocalPort 9222 -Protocol TCP -Action Allow
```
### Step 3: Verify connection
Test that Skyvern can reach Chrome:
For a classic HTTP CDP endpoint, test that Chrome is listening on port 9222:
```bash
curl http://localhost:9222/json/version
```
You should see Chrome's version information.
```powershell Windows
Invoke-RestMethod http://127.0.0.1:9222/json/version
```
You should see Chrome's version information and a `webSocketDebuggerUrl`.
If you used the Windows `chrome://inspect` helper, the configured URL is a
direct `ws://.../devtools/browser/<id>` endpoint. In that mode, verify by
starting a Skyvern browser session after restarting the backend.
<Note>
If `/json/version` does not return Chrome version information from inside the
Skyvern container, verify Chrome is listening on `0.0.0.0:9222` or `::9222` and
that Windows Firewall allows inbound traffic on port 9222.
</Note>
<Warning>
CDP mode exposes Chrome to network access. Only use this in trusted environments and bind to localhost when possible.
CDP mode exposes Chrome to network access. The examples use
`--remote-debugging-address=0.0.0.0` so Docker can reach Chrome on the host.
Only use this in trusted environments. If Skyvern runs on the same host outside
Docker, bind to `127.0.0.1` instead.
</Warning>
---

View file

@ -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

View file

@ -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)

View file

@ -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"

View file

@ -0,0 +1,111 @@
param(
[int]$ProxyPort = 9223,
[string]$EnvPath = ".env",
[switch]$UpdateEnv,
[switch]$NoFirewall
)
$ErrorActionPreference = "Stop"
function Find-DevToolsActivePort {
$roots = @()
if ($env:LOCALAPPDATA) {
$roots += Join-Path $env:LOCALAPPDATA "Google\Chrome\User Data"
}
if ($env:TEMP) {
$roots += $env:TEMP
}
$files = foreach ($root in $roots) {
if (Test-Path $root) {
Get-ChildItem $root -Recurse -Filter DevToolsActivePort -ErrorAction SilentlyContinue
}
}
$files | Sort-Object LastWriteTime -Descending | Select-Object -First 1
}
function Set-EnvValue {
param(
[string]$Path,
[string]$Name,
[string]$Value
)
$line = "$Name=$Value"
if (Test-Path $Path) {
$contents = @(Get-Content $Path)
$updated = $false
$contents = $contents | ForEach-Object {
if ($_ -match "^\s*$([regex]::Escape($Name))\s*=") {
$updated = $true
$line
} else {
$_
}
}
if (-not $updated) {
$contents += $line
}
$contents | Set-Content -Encoding ascii $Path
} else {
$line | Set-Content -Encoding ascii $Path
}
}
$portFile = Find-DevToolsActivePort
if (-not $portFile) {
Write-Error "DevToolsActivePort was not found. Open Chrome, go to chrome://inspect/#remote-debugging, enable remote debugging, then rerun this script."
}
$lines = Get-Content $portFile.FullName
if ($lines.Count -lt 2) {
Write-Error "DevToolsActivePort at $($portFile.FullName) did not contain a port and browser path."
}
$chromePort = [int]$lines[0]
$browserPath = [string]$lines[1]
if (-not $browserPath.StartsWith("/devtools/browser/")) {
Write-Error "DevToolsActivePort path '$browserPath' is not a browser websocket endpoint."
}
Write-Host "Found Chrome inspect endpoint:" -ForegroundColor Cyan
Write-Host " File: $($portFile.FullName)"
Write-Host " Chrome port: $chromePort"
Write-Host " Browser path: $browserPath"
& netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=$ProxyPort *> $null
& netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=$ProxyPort connectaddress=127.0.0.1 connectport=$chromePort
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to create Windows portproxy. Rerun PowerShell as Administrator."
}
if (-not $NoFirewall) {
$ruleName = "Skyvern Chrome Inspect Proxy $ProxyPort"
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
if (-not $existingRule) {
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -LocalPort $ProxyPort -Protocol TCP -Action Allow | Out-Null
}
}
$remoteUrl = "ws://host.docker.internal:$ProxyPort$browserPath"
$hostHeader = "127.0.0.1:$chromePort"
if ($UpdateEnv) {
Set-EnvValue -Path $EnvPath -Name "BROWSER_TYPE" -Value "cdp-connect"
Set-EnvValue -Path $EnvPath -Name "BROWSER_REMOTE_DEBUGGING_URL" -Value $remoteUrl
Set-EnvValue -Path $EnvPath -Name "BROWSER_REMOTE_DEBUGGING_HOST_HEADER" -Value $hostHeader
Set-EnvValue -Path $EnvPath -Name "BROWSER_STREAMING_MODE" -Value "cdp"
Set-EnvValue -Path $EnvPath -Name "BROWSER_CDP_CONNECT_TIMEOUT_MS" -Value "120000"
Write-Host "Updated $EnvPath for Skyvern Docker Compose." -ForegroundColor Green
}
Write-Host ""
Write-Host "Use this Skyvern setting:" -ForegroundColor Green
Write-Host "BROWSER_REMOTE_DEBUGGING_URL=$remoteUrl"
Write-Host "BROWSER_REMOTE_DEBUGGING_HOST_HEADER=$hostHeader"
Write-Host ""
Write-Host "Restart the backend after changing .env:"
Write-Host "docker compose up -d --force-recreate skyvern"
Write-Host ""
Write-Host "Chrome may prompt to allow remote debugging on first connection. Click Allow, then retry if the first attempt times out."

View file

@ -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

View file

@ -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;

View file

@ -8,8 +8,12 @@ import {
AuthStatusValue,
useAuthDiagnostics,
} from "@/hooks/useAuthDiagnostics";
import { useAuthIssueStore } from "@/store/AuthIssueStore";
type BannerStatus = Exclude<AuthStatusValue, "ok"> | "error";
type BannerStatus =
| Exclude<AuthStatusValue, "ok">
| "request_auth_error"
| "error";
function getCopy(status: BannerStatus): { title: string; description: string } {
switch (status) {
@ -43,6 +47,12 @@ function getCopy(status: BannerStatus): { title: string; description: string } {
description:
"The backend could not find the Skyvern-local organization. Regenerate the key to recreate it.",
};
case "request_auth_error":
return {
title: "Skyvern API requests are unauthorized",
description:
"The backend rejected a UI request. This usually means the frontend API key, backend API key, and local Docker database are out of sync.",
};
case "error":
default:
return {
@ -55,6 +65,8 @@ function getCopy(status: BannerStatus): { title: string; description: string } {
function SelfHealApiKeyBanner() {
const diagnosticsQuery = useAuthDiagnostics();
const authIssue = useAuthIssueStore((state) => state.issue);
const clearAuthIssue = useAuthIssueStore((state) => state.clearAuthIssue);
const { toast } = useToast();
const [isRepairing, setIsRepairing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@ -67,6 +79,8 @@ function SelfHealApiKeyBanner() {
? "error"
: rawStatus && rawStatus !== "ok"
? rawStatus
: authIssue
? "request_auth_error"
: null;
if (!bannerStatus && !errorMessage) {
@ -103,6 +117,7 @@ function SelfHealApiKeyBanner() {
}
setApiKeyHeader(apiKey);
clearAuthIssue();
const fingerprintSuffix = fingerprint
? ` (fingerprint ${fingerprint})`
@ -172,6 +187,20 @@ function SelfHealApiKeyBanner() {
{data?.next_step ? (
<p className="text-xs text-slate-300">{data.next_step}</p>
) : null}
{authIssue ? (
<p className="text-xs text-slate-300">
Last failed request: HTTP {authIssue.statusCode}
{authIssue.path ? ` at ${authIssue.path}` : ""}
{authIssue.detail ? ` (${authIssue.detail})` : ""}
</p>
) : null}
{bannerStatus === "request_auth_error" ? (
<p className="text-yellow-300">
Run <code>skyvern doctor --fix</code> from the cloned Skyvern
directory, then restart Docker Compose if the command asks you
to.
</p>
) : null}
{isProductionBuild && (
<p className="text-yellow-300">
When running a production build, the regenerated API key is

View file

@ -7,7 +7,7 @@ import { toast } from "@/components/ui/use-toast";
import { ZoomableImage } from "@/components/ZoomableImage";
import { useCostCalculator } from "@/hooks/useCostCalculator";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { getCredentialParam } from "@/util/env";
import { browserStreamingMode, getCredentialParam } from "@/util/env";
import {
keepPreviousData,
useQuery,
@ -70,10 +70,12 @@ function TaskActions() {
const taskIsNotFinalized = task && statusIsNotFinalized(task);
const taskIsRunningOrQueued = task && statusIsRunningOrQueued(task);
const browserSessionId = task?.browser_session_id;
const shouldUseCdpStream = browserStreamingMode === "cdp";
useEffect(() => {
// Skip screenshot WebSocket if VNC streaming is available via browser_session_id
if (browserSessionId) {
// In VNC mode, BrowserStream handles live sessions. In CDP mode, this
// screenshot WebSocket is the live stream.
if (browserSessionId && !shouldUseCdpStream) {
return;
}
@ -149,6 +151,7 @@ function TaskActions() {
taskId,
taskIsRunningOrQueued,
queryClient,
shouldUseCdpStream,
]);
const { data: steps, isLoading: stepsIsLoading } = useQuery<
@ -204,7 +207,8 @@ function TaskActions() {
function getStream() {
// Use VNC streaming via BrowserStream when browser_session_id is available
if (browserSessionId) {
// and CDP local livestreaming is not enabled.
if (browserSessionId && !shouldUseCdpStream) {
return (
<AspectRatio ratio={16 / 9}>
<BrowserStream

View file

@ -23,6 +23,7 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import { browserStreamingMode } from "@/util/env";
export type ActionItem = {
block: WorkflowRunBlock;
@ -112,8 +113,11 @@ function WorkflowRunOverview() {
selection.block_type === "human_interaction"))
);
const shouldShowBrowserStream = wantsVncStream && !vncFailed;
const shouldShowScreencastFallback = wantsVncStream && vncFailed;
const shouldUseCdpStream = browserStreamingMode === "cdp";
const shouldShowBrowserStream =
wantsVncStream && !shouldUseCdpStream && !vncFailed;
const shouldShowScreencastFallback =
wantsVncStream && (shouldUseCdpStream || vncFailed);
const isStreamActive =
shouldShowBrowserStream ||

View file

@ -0,0 +1,27 @@
import { create } from "zustand";
type AuthIssue = {
statusCode: number;
detail?: string;
path?: string;
seenAt: number;
};
type AuthIssueStore = {
issue: AuthIssue | null;
reportAuthIssue: (issue: Omit<AuthIssue, "seenAt">) => void;
clearAuthIssue: () => void;
};
const useAuthIssueStore = create<AuthIssueStore>((set) => ({
issue: null,
reportAuthIssue: (issue) => {
set({ issue: { ...issue, seenAt: Date.now() } });
},
clearAuthIssue: () => {
set({ issue: null });
},
}));
export { useAuthIssueStore };
export type { AuthIssue };

View file

@ -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");
});
});

View file

@ -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"

View file

@ -1,7 +1,5 @@
import json
import os
import platform
import subprocess
import time
from typing import Optional
from urllib.parse import urlparse
@ -26,9 +24,9 @@ _CDP_SCAN_PORTS = [9222, 9223, 9224, 9225, 9226, 9229]
def _check_cdp_ws(port: int) -> Optional[dict]:
"""Try a WebSocket CDP handshake on the given port.
Chrome's chrome://inspect#remote-debugging exposes a WS-only CDP server
(no /json/version HTTP endpoint). This sends Browser.getVersion over WS
to detect it.
Some CDP-compatible servers expose a WebSocket endpoint without the
/json/version HTTP discovery endpoint. This sends Browser.getVersion over
WS to detect those endpoints.
Returns the version info dict if successful, else None.
"""
@ -115,37 +113,46 @@ def _print_cdp_info(url: str, cached_info: dict | None = None) -> None:
console.print(f" WebSocket URL: [dim]{info['webSocketDebuggerUrl']}[/dim]")
def _open_chrome_inspect() -> None:
"""Open chrome://inspect/#remote-debugging in the user's default browser."""
def _classic_cdp_launch_command() -> str:
system = platform.system()
url = "chrome://inspect/#remote-debugging"
try:
if system == "Darwin":
subprocess.Popen(
["open", "-a", "Google Chrome", url],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
return (
"/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome \\\n"
" --remote-debugging-port=9222 \\\n"
" --remote-debugging-address=0.0.0.0 \\\n"
' --user-data-dir="$HOME/skyvern-chrome-cdp" \\\n'
" --no-first-run \\\n"
" --no-default-browser-check"
)
elif system == "Windows":
subprocess.Popen(
["start", "chrome", url],
shell=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
if system == "Windows":
return (
"taskkill /F /IM chrome.exe\n\n"
'& "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" `\n'
" --remote-debugging-port=9222 `\n"
" --remote-debugging-address=0.0.0.0 `\n"
' --user-data-dir="$env:TEMP\\skyvern-chrome-cdp" `\n'
" --no-first-run `\n"
" --no-default-browser-check"
)
return (
"google-chrome \\\n"
" --remote-debugging-port=9222 \\\n"
" --remote-debugging-address=0.0.0.0 \\\n"
' --user-data-dir="$HOME/skyvern-chrome-cdp" \\\n'
" --no-first-run \\\n"
" --no-default-browser-check"
)
def _print_classic_cdp_instructions() -> None:
console.print(
Panel(
"[bold]Enable Chrome remote debugging[/bold]\n\n"
"Start Chrome manually with a CDP endpoint that Docker can reach:\n\n"
f"[cyan]{_classic_cdp_launch_command()}[/cyan]",
border_style="cyan",
)
else:
# Linux — try common Chrome names
for cmd in ["google-chrome", "chromium", "chromium-browser"]:
if os.path.exists(f"/usr/bin/{cmd}"):
subprocess.Popen(
[cmd, url],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return
console.print(f"[yellow]Could not open Chrome automatically. Please navigate to: {url}[/yellow]")
except Exception:
console.print(f"[yellow]Could not open Chrome automatically. Please navigate to: {url}[/yellow]")
def _setup_local_browser_clone() -> tuple[str, Optional[str], Optional[str]]:
@ -238,20 +245,8 @@ def _setup_local_browser_actual() -> tuple[str, Optional[str], Optional[str]]:
)
return "cdp-connect", None, existing
# Step 2: Guide the user to enable remote debugging
console.print(
Panel(
"[bold]Enable Remote Debugging in Chrome[/bold]\n\n"
"1. We'll open [cyan]chrome://inspect/#remote-debugging[/cyan] in your browser\n"
"2. Click [bold]Enable[/bold] to start the debugging server\n"
"3. You should see: [green]Server running at: 127.0.0.1:9222[/green]",
border_style="cyan",
)
)
open_page = Confirm.ask("Open chrome://inspect/#remote-debugging now?", default=True)
if open_page:
_open_chrome_inspect()
# Step 2: Guide the user to enable Chrome remote debugging.
_print_classic_cdp_instructions()
console.print("\n[bold yellow]Enable remote debugging in Chrome, then press Enter to continue...[/bold yellow]")
Prompt.ask("Press Enter when ready", default="")
@ -277,7 +272,7 @@ def _setup_local_browser_actual() -> tuple[str, Optional[str], Optional[str]]:
# Step 4: Fallback — ask for manual URL
console.print("[yellow]Could not auto-detect the debugging server.[/yellow]")
console.print("[dim]Make sure you clicked 'Enable' on the chrome://inspect page.[/dim]")
console.print("[dim]Make sure /json/version returns JSON from the URL you enter.[/dim]")
manual_url = Prompt.ask(
"Enter the debugging URL manually (e.g. http://127.0.0.1:9222)",
default="http://127.0.0.1:9222",

View file

@ -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)

View file

@ -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,

View file

@ -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",

View file

@ -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:

View file

@ -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"

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1,164 @@
from __future__ import annotations
import re
import socket
from collections.abc import Iterable
from dataclasses import dataclass
from urllib.parse import urlparse
import structlog
from playwright.async_api import Browser, Playwright
from skyvern.exceptions import CdpConnectionConfigurationError
LOG = structlog.get_logger()
DEFAULT_CDP_CONNECT_TIMEOUT_MS = 30_000
_CDP_DISCOVERY_ERROR_RE = re.compile(
r"Unexpected status (?P<status>\d+) when connecting to (?P<url>https?://\S+/json/version/?)"
)
@dataclass(frozen=True)
class CdpConnectionCandidate:
url: str
label: str
headers: dict[str, str] | None = None
def build_cdp_connect_headers(host_header: str | None) -> dict[str, str] | None:
normalized_host_header = host_header.strip() if host_header else ""
if not normalized_host_header:
return None
return {"Host": normalized_host_header}
def parse_cdp_discovery_error(error: Exception) -> tuple[int, str] | None:
"""Return the HTTP status and discovery URL from a Playwright CDP discovery error."""
match = _CDP_DISCOVERY_ERROR_RE.search(str(error))
if not match:
return None
return int(match.group("status")), match.group("url")
def resolve_host_docker_internal_url(remote_browser_url: str) -> str | None:
"""Resolve host.docker.internal to IPv4 to avoid Chrome DevTools Host-header issues."""
parsed = urlparse(remote_browser_url)
if parsed.scheme not in {"http", "https", "ws", "wss"} or parsed.hostname != "host.docker.internal":
return None
try:
address_info = socket.getaddrinfo(
parsed.hostname,
parsed.port or 9222,
family=socket.AF_INET,
)
except socket.gaierror:
return None
if not address_info:
return None
resolved_host = str(address_info[0][4][0])
if not resolved_host:
return None
netloc = resolved_host
if parsed.port:
netloc = f"{resolved_host}:{parsed.port}"
return parsed._replace(netloc=netloc).geturl()
def build_cdp_connection_candidates(
remote_browser_url: str,
headers: dict[str, str] | None = None,
) -> Iterable[CdpConnectionCandidate]:
"""Yield fallback CDP endpoints after the primary connect attempt fails."""
resolved_url = resolve_host_docker_internal_url(remote_browser_url)
if resolved_url:
yield CdpConnectionCandidate(
url=resolved_url,
label="resolved host.docker.internal IPv4",
headers=headers,
)
def build_cdp_configuration_error(
remote_browser_url: str,
error: Exception,
) -> CdpConnectionConfigurationError | None:
discovery_error = parse_cdp_discovery_error(error)
if discovery_error is None:
return None
status_code, discovery_url = discovery_error
parsed = urlparse(remote_browser_url)
if parsed.scheme not in {"http", "https"}:
return None
guidance = (
f"Skyvern reached the configured CDP address ({remote_browser_url}), but "
f"{discovery_url} returned HTTP {status_code}. Skyvern cdp-connect requires "
"Chrome's classic DevTools Protocol endpoint, where /json/version returns JSON "
"with webSocketDebuggerUrl. If you enabled chrome://inspect/#remote-debugging, "
"set BROWSER_REMOTE_DEBUGGING_URL to the direct full "
"ws://.../devtools/browser/... URL from Chrome's DevToolsActivePort file. "
"On Windows Docker Desktop, run scripts/windows_chrome_inspect_cdp.ps1 to "
"bridge Chrome's loopback-only listener before connecting."
)
if parsed.hostname == "host.docker.internal":
guidance += (
" In Docker Desktop, Chrome can also reject the host.docker.internal "
"Host header; use the Docker host gateway IPv4 address if the classic "
"CDP endpoint returns HTTP 500."
)
return CdpConnectionConfigurationError(guidance)
async def connect_over_cdp_with_diagnostics(
playwright: Playwright,
remote_browser_url: str,
headers: dict[str, str] | None = None,
timeout_ms: int = DEFAULT_CDP_CONNECT_TIMEOUT_MS,
) -> Browser:
try:
return await playwright.chromium.connect_over_cdp(
remote_browser_url,
timeout=timeout_ms,
headers=headers,
)
except Exception as first_error:
errors: list[tuple[str, Exception]] = [(remote_browser_url, first_error)]
for candidate in build_cdp_connection_candidates(remote_browser_url, headers):
message = (
"Retrying CDP connection with resolved host.docker.internal IPv4"
if candidate.label == "resolved host.docker.internal IPv4"
else "Retrying CDP connection"
)
LOG.warning(
message,
reason=candidate.label,
remote_browser_url=remote_browser_url,
fallback_url=candidate.url,
)
try:
return await playwright.chromium.connect_over_cdp(
candidate.url,
timeout=timeout_ms,
headers=candidate.headers,
)
except Exception as candidate_error:
errors.append((candidate.url, candidate_error))
for url, error in reversed(errors):
configuration_error = build_cdp_configuration_error(url, error)
if configuration_error:
raise configuration_error from error
last_url, last_error = errors[-1]
if last_url == remote_browser_url:
raise last_error
raise last_error from first_error

265
tests/test_cli_doctor.py Normal file
View file

@ -0,0 +1,265 @@
import os
import stat
import subprocess
from pathlib import Path
import pytest
import skyvern.cli.doctor as doctor_module
def _write_valid_frontend_env(path: Path) -> None:
path.write_text(
"\n".join(
[
"VITE_API_BASE_URL=http://localhost:8000/api/v1",
"VITE_WSS_BASE_URL=ws://localhost:8000/api/v1",
"VITE_ARTIFACT_API_BASE_URL=http://localhost:9090",
"VITE_BROWSER_STREAMING_MODE=cdp",
"",
]
)
)
def test_frontend_runtime_env_missing_reports_placeholder_fallback(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "skyvern-frontend").mkdir()
(tmp_path / "docker-compose.yml").write_text("services: {}\n")
result = doctor_module._check_frontend_runtime_env()
assert result.status == "error"
assert "skyvern-frontend/.env is missing" in result.detail
assert "Dockerfile placeholders" in result.detail
def test_frontend_runtime_env_detects_placeholder_urls(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(doctor_module.shutil, "which", lambda _name: None)
frontend_dir = tmp_path / "skyvern-frontend"
frontend_dir.mkdir()
(frontend_dir / ".env").write_text(
"\n".join(
[
"VITE_API_BASE_URL=__VITE_API_BASE_URL_PLACEHOLDER__",
"VITE_WSS_BASE_URL=ws://localhost:8000/api/v1",
"VITE_ARTIFACT_API_BASE_URL=http://localhost:9090",
"VITE_BROWSER_STREAMING_MODE=cdp",
"",
]
)
)
result = doctor_module._check_frontend_runtime_env()
assert result.status == "error"
assert "VITE_API_BASE_URL is still __VITE_API_BASE_URL_PLACEHOLDER__" in result.detail
def test_frontend_runtime_env_accepts_valid_host_env_without_docker(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(doctor_module.shutil, "which", lambda _name: None)
frontend_dir = tmp_path / "skyvern-frontend"
frontend_dir.mkdir()
_write_valid_frontend_env(frontend_dir / ".env")
result = doctor_module._check_frontend_runtime_env()
assert result.status == "ok"
assert "valid Vite runtime URLs" in result.detail
def test_fix_frontend_runtime_env_writes_defaults_and_unquoted_api_key(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(doctor_module, "_docker_compose_available", lambda: False)
frontend_dir = tmp_path / "skyvern-frontend"
frontend_dir.mkdir()
(tmp_path / ".env").write_text("SKYVERN_API_KEY=sk-test\n")
(frontend_dir / ".env").write_text("VITE_API_BASE_URL=__VITE_API_BASE_URL_PLACEHOLDER__\n")
assert doctor_module._fix_frontend_runtime_env() is True
contents = (frontend_dir / ".env").read_text()
assert "VITE_API_BASE_URL=http://localhost:8000/api/v1" in contents
assert "VITE_WSS_BASE_URL=ws://localhost:8000/api/v1" in contents
assert "VITE_ARTIFACT_API_BASE_URL=http://localhost:9090" in contents
assert "VITE_BROWSER_STREAMING_MODE=cdp" in contents
assert "VITE_SKYVERN_API_KEY=sk-test" in contents
assert "VITE_SKYVERN_API_KEY='sk-test'" not in contents
def test_local_streaming_mode_warns_when_frontend_is_vnc(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(doctor_module, "_docker_compose_available", lambda: False)
(tmp_path / ".env").write_text("BROWSER_STREAMING_MODE=cdp\n")
frontend_dir = tmp_path / "skyvern-frontend"
frontend_dir.mkdir()
(frontend_dir / ".env").write_text("VITE_BROWSER_STREAMING_MODE=vnc\n")
result = doctor_module._check_local_streaming_mode()
assert result.status == "warn"
assert "VITE_BROWSER_STREAMING_MODE should be cdp" in result.detail
def test_fix_local_streaming_mode_writes_backend_and_frontend(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(doctor_module, "_docker_compose_available", lambda: False)
frontend_dir = tmp_path / "skyvern-frontend"
frontend_dir.mkdir()
(tmp_path / ".env").write_text("BROWSER_STREAMING_MODE=vnc\n")
(frontend_dir / ".env").write_text("VITE_BROWSER_STREAMING_MODE=vnc\n")
assert doctor_module._fix_local_streaming_mode() is True
assert "BROWSER_STREAMING_MODE=cdp" in (tmp_path / ".env").read_text()
assert "VITE_BROWSER_STREAMING_MODE=cdp" in (frontend_dir / ".env").read_text()
def test_compose_database_connection_can_satisfy_database_check(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / ".env").write_text("DATABASE_STRING=postgresql+psycopg://skyvern:skyvern@localhost:5432/skyvern\n")
def fake_run(*_args, **_kwargs):
return subprocess.CompletedProcess(
args=[],
returncode=1,
stdout="",
stderr="ModuleNotFoundError: No module named 'sqlalchemy'",
)
monkeypatch.setattr(doctor_module.subprocess, "run", fake_run)
monkeypatch.setattr(
doctor_module,
"_check_compose_database_connection",
lambda: doctor_module.CheckResult(
name="Database",
status="ok",
detail="Docker Compose backend can connect to database",
),
)
result = doctor_module._check_database()
assert result.status == "ok"
assert "Docker Compose backend can connect" in result.detail
def test_docker_local_auth_diagnostic_uses_backend_status_endpoint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured_script = ""
def fake_exec(_service: str, script: str, timeout: int = 30):
nonlocal captured_script
captured_script = script
return subprocess.CompletedProcess(
args=[],
returncode=0,
stdout='{"status": "ok", "organization_id": "o_test"}\n',
stderr="",
)
monkeypatch.setattr(doctor_module, "_docker_compose_available", lambda: True)
monkeypatch.setattr(doctor_module, "_run_docker_compose_exec", fake_exec)
result = doctor_module._check_docker_local_auth()
assert result.status == "ok"
assert "/api/v1/internal/auth/status" in captured_script
assert "from jose" not in captured_script
assert "app.DATABASE" not in captured_script
def test_docker_local_auth_reports_backend_error_detail(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def fake_exec(_service: str, _script: str, timeout: int = 30):
return subprocess.CompletedProcess(
args=[],
returncode=0,
stdout='{"detail": "Unable to diagnose API key"}\n',
stderr="",
)
monkeypatch.setattr(doctor_module, "_docker_compose_available", lambda: True)
monkeypatch.setattr(doctor_module, "_run_docker_compose_exec", fake_exec)
result = doctor_module._check_docker_local_auth()
assert result.status == "error"
assert "HTTP error" in result.detail
assert "Unable to diagnose API key" in result.detail
def test_fingerprint_does_not_leak_value() -> None:
api_key = "skv_live_abcdef0123456789abcdef0123456789"
tag = doctor_module._fingerprint(api_key)
assert len(tag) == 12
assert api_key not in tag
assert api_key[:8] not in tag
assert api_key[-4:] not in tag
def test_fingerprint_is_deterministic_within_process() -> None:
api_key = "skv_live_abcdef0123456789abcdef0123456789"
assert doctor_module._fingerprint(api_key) == doctor_module._fingerprint(api_key)
def test_fingerprint_missing_sentinel_for_empty_string() -> None:
assert doctor_module._fingerprint("") == "missing"
def test_fix_docker_local_auth_hardens_generated_credentials_file(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(
doctor_module,
"_run_docker_compose_exec",
lambda *_args, **_kwargs: subprocess.CompletedProcess(
args=[],
returncode=0,
stdout='{"api_key": "sk-test", "organization_id": "o_test"}\n',
stderr="",
),
)
monkeypatch.setattr(doctor_module, "_recreate_docker_services", lambda _services: True)
frontend_dir = tmp_path / "skyvern-frontend"
frontend_dir.mkdir()
(frontend_dir / ".env").write_text("VITE_API_BASE_URL=http://localhost:8000/api/v1\n")
credentials_file = tmp_path / ".skyvern" / "credentials.toml"
monkeypatch.setattr(doctor_module, "GENERATED_CREDENTIALS_FILE", credentials_file)
assert doctor_module._fix_docker_local_auth() is True
assert 'cred = "sk-test"' in credentials_file.read_text()
if os.name == "posix":
assert stat.S_IMODE(credentials_file.stat().st_mode) == 0o600

View file

@ -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
)

View file

@ -0,0 +1,180 @@
from __future__ import annotations
import socket
from typing import Any, cast
import pytest
from playwright.async_api import Playwright
import skyvern.webeye.cdp_connection as cdp_connection
from skyvern.webeye.cdp_connection import (
build_cdp_connect_headers,
build_cdp_connection_candidates,
connect_over_cdp_with_diagnostics,
resolve_host_docker_internal_url,
)
def test_build_cdp_connect_headers_uses_host_header() -> None:
assert build_cdp_connect_headers(" 127.0.0.1:9222 ") == {"Host": "127.0.0.1:9222"}
def test_build_cdp_connect_headers_ignores_empty_host_header() -> None:
assert build_cdp_connect_headers(None) is None
assert build_cdp_connect_headers(" ") is None
def test_resolve_host_docker_internal_url_uses_resolved_ipv4(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_getaddrinfo(host: str, port: int, family: socket.AddressFamily) -> list[Any]:
assert host == "host.docker.internal"
assert port == 9222
assert family == socket.AF_INET
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.168.65.254", port))]
monkeypatch.setattr(cdp_connection.socket, "getaddrinfo", fake_getaddrinfo)
assert resolve_host_docker_internal_url("http://host.docker.internal:9222/") == "http://192.168.65.254:9222/"
def test_resolve_host_docker_internal_url_ignores_non_docker_hosts() -> None:
assert resolve_host_docker_internal_url("http://127.0.0.1:9222/") is None
def test_build_cdp_connection_candidates_includes_resolved_host_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_getaddrinfo(host: str, port: int, family: socket.AddressFamily) -> list[Any]:
assert host == "host.docker.internal"
assert port == 9222
assert family == socket.AF_INET
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.168.65.254", port))]
monkeypatch.setattr(cdp_connection.socket, "getaddrinfo", fake_getaddrinfo)
candidates = list(build_cdp_connection_candidates("http://host.docker.internal:9222/", {"x-api-key": "key"}))
assert [(candidate.label, candidate.url, candidate.headers) for candidate in candidates] == [
("resolved host.docker.internal IPv4", "http://192.168.65.254:9222/", {"x-api-key": "key"}),
]
@pytest.mark.asyncio
async def test_connect_over_cdp_retries_resolved_host_with_headers(monkeypatch: pytest.MonkeyPatch) -> None:
expected_browser = object()
class FakeChromium:
def __init__(self) -> None:
self.calls: list[tuple[str, int | None, dict[str, str] | None]] = []
async def connect_over_cdp(
self,
url: str,
*,
timeout: int | None = None,
headers: dict[str, str] | None = None,
) -> object:
self.calls.append((url, timeout, headers))
if len(self.calls) == 1:
raise Exception(
"BrowserType.connect_over_cdp: Unexpected status 500 when connecting to "
"http://host.docker.internal:9222/json/version/."
)
return expected_browser
class FakePlaywright:
def __init__(self) -> None:
self.chromium = FakeChromium()
def fake_getaddrinfo(host: str, port: int, family: socket.AddressFamily) -> list[Any]:
assert host == "host.docker.internal"
assert port == 9222
assert family == socket.AF_INET
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("192.168.65.254", port))]
monkeypatch.setattr(cdp_connection.socket, "getaddrinfo", fake_getaddrinfo)
fake_playwright = FakePlaywright()
headers = {"x-api-key": "test-key"}
browser = await connect_over_cdp_with_diagnostics(
cast(Playwright, fake_playwright),
"http://host.docker.internal:9222/",
headers=headers,
timeout_ms=120000,
)
assert browser is expected_browser
assert fake_playwright.chromium.calls == [
("http://host.docker.internal:9222/", 120000, headers),
("http://192.168.65.254:9222/", 120000, headers),
]
@pytest.mark.asyncio
async def test_connect_over_cdp_accepts_direct_websocket_with_default_timeout() -> None:
expected_browser = object()
class FakeChromium:
def __init__(self) -> None:
self.calls: list[tuple[str, int | None, dict[str, str] | None]] = []
async def connect_over_cdp(
self,
url: str,
*,
timeout: int | None = None,
headers: dict[str, str] | None = None,
) -> object:
self.calls.append((url, timeout, headers))
return expected_browser
class FakePlaywright:
def __init__(self) -> None:
self.chromium = FakeChromium()
fake_playwright = FakePlaywright()
browser = await connect_over_cdp_with_diagnostics(
cast(Playwright, fake_playwright),
"ws://host.docker.internal:9223/devtools/browser/abc",
)
assert browser is expected_browser
assert fake_playwright.chromium.calls == [
("ws://host.docker.internal:9223/devtools/browser/abc", 30_000, None),
]
@pytest.mark.asyncio
async def test_connect_over_cdp_accepts_direct_websocket_with_host_header() -> None:
expected_browser = object()
class FakeChromium:
def __init__(self) -> None:
self.calls: list[tuple[str, int | None, dict[str, str] | None]] = []
async def connect_over_cdp(
self,
url: str,
*,
timeout: int | None = None,
headers: dict[str, str] | None = None,
) -> object:
self.calls.append((url, timeout, headers))
return expected_browser
class FakePlaywright:
def __init__(self) -> None:
self.chromium = FakeChromium()
fake_playwright = FakePlaywright()
headers = {"Host": "127.0.0.1:9222"}
browser = await connect_over_cdp_with_diagnostics(
cast(Playwright, fake_playwright),
"ws://host.docker.internal:9223/devtools/browser/abc",
headers=headers,
timeout_ms=120000,
)
assert browser is expected_browser
assert fake_playwright.chromium.calls == [
("ws://host.docker.internal:9223/devtools/browser/abc", 120000, headers),
]

View file

@ -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:

View file

@ -3,6 +3,7 @@ from http import HTTPStatus
import pytest
from skyvern.exceptions import (
CdpConnectionConfigurationError,
SkyvernException,
SkyvernExtraNotInstalled,
SkyvernHTTPException,
@ -33,6 +34,20 @@ def test_unknown_error_while_creating_browser_context_strips_call_log() -> None:
assert "support@skyvern.com" in message
def test_unknown_error_preserves_cdp_configuration_guidance() -> None:
inner_exception = CdpConnectionConfigurationError(
"Skyvern reached the configured CDP address, but /json/version returned HTTP 404. "
"Start Chrome with --remote-debugging-port=9222."
)
error = UnknownErrorWhileCreatingBrowserContext("cdp-connect", inner_exception)
message = str(error)
assert "CdpConnectionConfigurationError" in message
assert "/json/version returned HTTP 404" in message
assert "--remote-debugging-port=9222" in message
def test_get_user_facing_exception_message_for_skyvern_exception() -> None:
message = get_user_facing_exception_message(SkyvernException("Human-friendly message"))
assert message == "Human-friendly message"

2
uv.lock generated
View file

@ -5499,7 +5499,7 @@ wheels = [
[[package]]
name = "skyvern"
version = "1.0.34"
version = "1.0.35"
source = { editable = "." }
dependencies = [
{ name = "cachetools" },