Fix browser session proxy dropdown behavior and complete create-session config support (#4855)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run

Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
Suchintan 2026-02-24 11:47:25 -05:00 committed by GitHub
parent a520559856
commit b2653ebd80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 192 additions and 18 deletions

View file

@ -178,13 +178,14 @@ Here's the TOTP endpoint contract you should use:
Request (POST): Request (POST):
| Parameter | Type | Required? | Sample Value | Description | | Parameter | Type | Required? | Sample Value | Description |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| task_id | String | yes | tsk_123 | The task ID that needs the verification to be done | | task_id | String | no | tsk_123 | The task ID that needs the verification to be done |
| workflow_run_id | String | no | wr_123456 | The workflow run ID that needs the verification to be done |
| workflow_permanent_id | String | no | wpid_123456 | The permanent workflow ID that needs the verification to be done |
Response: Response:
| Parameter | Type | Required? | Sample Value | Description | | Parameter | Type | Required? | Sample Value | Description |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| task_id | String | yes | tsk_123 | The task ID that needs the verification to be done | | verification_code | String | yes | 123456 | The verification code |
| verification_code | String | no | 123456 | The verification code |
### Validate The Sender of The Request ### Validate The Sender of The Request
Same as the webhook API, your server needs to make sure its Skyvern thats making the request. Same as the webhook API, your server needs to make sure its Skyvern thats making the request.

View file

@ -28,12 +28,16 @@ interface GeoTargetSelectorProps {
value: GeoTarget | null; value: GeoTarget | null;
onChange: (value: GeoTarget) => void; onChange: (value: GeoTarget) => void;
className?: string; className?: string;
allowGranularSearch?: boolean;
modalPopover?: boolean;
} }
export function GeoTargetSelector({ export function GeoTargetSelector({
value, value,
onChange, onChange,
className, className,
allowGranularSearch = true,
modalPopover = false,
}: GeoTargetSelectorProps) { }: GeoTargetSelectorProps) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState(""); const [query, setQuery] = React.useState("");
@ -47,7 +51,9 @@ export function GeoTargetSelector({
const handleSearch = useDebouncedCallback(async (searchQuery: string) => { const handleSearch = useDebouncedCallback(async (searchQuery: string) => {
setLoading(true); setLoading(true);
try { try {
const data = await searchGeoData(searchQuery); const data = await searchGeoData(searchQuery, {
includeGranularResults: allowGranularSearch,
});
setResults(data); setResults(data);
} catch (error) { } catch (error) {
console.error("Failed to search geo data", error); console.error("Failed to search geo data", error);
@ -84,7 +90,7 @@ export function GeoTargetSelector({
}; };
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen} modal={modalPopover}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
@ -98,10 +104,14 @@ export function GeoTargetSelector({
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start"> <PopoverContent className="z-[100] w-[400px] p-0" align="start">
<Command shouldFilter={false}> <Command shouldFilter={false}>
<CommandInput <CommandInput
placeholder="Search country, state, or city..." placeholder={
allowGranularSearch
? "Search country, state, or city..."
: "Search country..."
}
value={query} value={query}
onValueChange={onInput} onValueChange={onInput}
/> />
@ -143,7 +153,7 @@ export function GeoTargetSelector({
</CommandGroup> </CommandGroup>
)} )}
{results.subdivisions.length > 0 && ( {allowGranularSearch && results.subdivisions.length > 0 && (
<CommandGroup heading="States / Regions"> <CommandGroup heading="States / Regions">
{results.subdivisions.map((item) => ( {results.subdivisions.map((item) => (
<CommandItem <CommandItem
@ -171,7 +181,7 @@ export function GeoTargetSelector({
</CommandGroup> </CommandGroup>
)} )}
{results.cities.length > 0 && ( {allowGranularSearch && results.cities.length > 0 && (
<CommandGroup heading="Cities"> <CommandGroup heading="Cities">
{results.cities.map((item) => ( {results.cities.map((item) => (
<CommandItem <CommandItem

View file

@ -9,9 +9,17 @@ type Props = {
value: ProxyLocation; value: ProxyLocation;
onChange: (value: ProxyLocation) => void; onChange: (value: ProxyLocation) => void;
className?: string; className?: string;
allowGranularSearch?: boolean;
modalPopover?: boolean;
}; };
function ProxySelector({ value, onChange, className }: Props) { function ProxySelector({
value,
onChange,
className,
allowGranularSearch = true,
modalPopover = false,
}: Props) {
// Convert input (string enum or object) to GeoTarget for the selector // Convert input (string enum or object) to GeoTarget for the selector
const geoTargetValue = proxyLocationToGeoTarget(value); const geoTargetValue = proxyLocationToGeoTarget(value);
@ -19,6 +27,8 @@ function ProxySelector({ value, onChange, className }: Props) {
<GeoTargetSelector <GeoTargetSelector
className={className} className={className}
value={geoTargetValue} value={geoTargetValue}
allowGranularSearch={allowGranularSearch}
modalPopover={modalPopover}
onChange={(newTarget) => { onChange={(newTarget) => {
// Convert back to ProxyLocation enum if possible (for simple countries) // Convert back to ProxyLocation enum if possible (for simple countries)
// or keep as GeoTarget object // or keep as GeoTarget object

View file

@ -6,6 +6,7 @@ import { ProxyLocation } from "@/api/types";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Drawer, Drawer,
DrawerContent, DrawerContent,
@ -25,6 +26,13 @@ import {
PaginationPrevious, PaginationPrevious,
} from "@/components/ui/pagination"; } from "@/components/ui/pagination";
import { ProxySelector } from "@/components/ProxySelector"; import { ProxySelector } from "@/components/ProxySelector";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@ -35,7 +43,11 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useBrowserSessionsQuery } from "@/routes/browserSessions/hooks/useBrowserSessionsQuery"; import { useBrowserSessionsQuery } from "@/routes/browserSessions/hooks/useBrowserSessionsQuery";
import { useCreateBrowserSessionMutation } from "@/routes/browserSessions/hooks/useCreateBrowserSessionMutation"; import { useCreateBrowserSessionMutation } from "@/routes/browserSessions/hooks/useCreateBrowserSessionMutation";
import { type BrowserSession } from "@/routes/workflows/types/browserSessionTypes"; import {
type BrowserSession,
type BrowserSessionExtension,
type BrowserSessionType,
} from "@/routes/workflows/types/browserSessionTypes";
import { CopyText } from "@/routes/workflows/editor/Workspace"; import { CopyText } from "@/routes/workflows/editor/Workspace";
import { basicTimeFormat } from "@/util/timeFormat"; import { basicTimeFormat } from "@/util/timeFormat";
import { cn, formatMs, toDate } from "@/util/utils"; import { cn, formatMs, toDate } from "@/util/utils";
@ -58,6 +70,31 @@ const Yes = () => (
</Badge> </Badge>
); );
const BROWSER_TYPE_OPTIONS: Array<{
value: BrowserSessionType;
label: string;
}> = [
{ value: "msedge", label: "Microsoft Edge" },
{ value: "chrome", label: "Google Chrome" },
];
const EXTENSION_OPTIONS: Array<{
value: BrowserSessionExtension;
label: string;
description: string;
}> = [
{
value: "ad-blocker",
label: "Ad Blocker",
description: "Blocks ads and common trackers in session pages.",
},
{
value: "captcha-solver",
label: "Captcha Solver",
description: "Enables automated captcha solving when available.",
},
];
function BrowserSessions() { function BrowserSessions() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -65,9 +102,13 @@ function BrowserSessions() {
const [sessionOptions, setSessionOptions] = useState<{ const [sessionOptions, setSessionOptions] = useState<{
proxyLocation: ProxyLocation; proxyLocation: ProxyLocation;
timeoutMinutes: number | null; timeoutMinutes: number | null;
browserType: BrowserSessionType | null;
extensions: BrowserSessionExtension[];
}>({ }>({
proxyLocation: ProxyLocation.Residential, proxyLocation: ProxyLocation.Residential,
timeoutMinutes: 60, timeoutMinutes: 60,
browserType: null,
extensions: [],
}); });
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1; const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
@ -123,6 +164,18 @@ function BrowserSessions() {
} }
} }
function toggleExtension(extension: BrowserSessionExtension) {
setSessionOptions((prev) => {
const exists = prev.extensions.includes(extension);
return {
...prev,
extensions: exists
? prev.extensions.filter((item) => item !== extension)
: [...prev.extensions, extension],
};
});
}
return ( return (
<div className="px-8"> <div className="px-8">
{/* header */} {/* header */}
@ -336,11 +389,13 @@ function BrowserSessions() {
</div> </div>
<ProxySelector <ProxySelector
value={sessionOptions.proxyLocation} value={sessionOptions.proxyLocation}
allowGranularSearch={false}
modalPopover
onChange={(value) => { onChange={(value) => {
setSessionOptions({ setSessionOptions((prev) => ({
...sessionOptions, ...prev,
proxyLocation: value, proxyLocation: value,
}); }));
}} }}
/> />
</div> </div>
@ -367,6 +422,76 @@ function BrowserSessions() {
}} }}
/> />
</div> </div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label>Browser Type</Label>
<HelpTooltip content="Choose the browser engine for this session. Leave default to use server defaults." />
</div>
<Select
value={sessionOptions.browserType ?? "default"}
onValueChange={(value) => {
setSessionOptions((prev) => ({
...prev,
browserType:
value === "default"
? null
: (value as BrowserSessionType),
}));
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
Default (Microsoft Edge)
</SelectItem>
{BROWSER_TYPE_OPTIONS.map((browserType) => (
<SelectItem
key={browserType.value}
value={browserType.value}
>
{browserType.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label>Extensions</Label>
<HelpTooltip content="Optional browser extensions to install when the session starts." />
</div>
<div className="space-y-2 rounded-md border p-3">
{EXTENSION_OPTIONS.map((extension) => (
<div
key={extension.value}
className="flex items-start space-x-2"
>
<Checkbox
id={`extension-${extension.value}`}
checked={sessionOptions.extensions.includes(
extension.value,
)}
onCheckedChange={() => {
toggleExtension(extension.value);
}}
/>
<div className="grid gap-1">
<Label
htmlFor={`extension-${extension.value}`}
className="font-medium"
>
{extension.label}
</Label>
<p className="text-xs text-muted-foreground">
{extension.description}
</p>
</div>
</div>
))}
</div>
</div>
<Button <Button
disabled={ disabled={
createBrowserSessionMutation.isPending || createBrowserSessionMutation.isPending ||
@ -380,6 +505,8 @@ function BrowserSessions() {
createBrowserSessionMutation.mutate({ createBrowserSessionMutation.mutate({
proxyLocation: sessionOptions.proxyLocation, proxyLocation: sessionOptions.proxyLocation,
timeout: sessionOptions.timeoutMinutes, timeout: sessionOptions.timeoutMinutes,
browserType: sessionOptions.browserType,
extensions: sessionOptions.extensions,
}); });
}} }}
> >

View file

@ -5,7 +5,11 @@ import { useMutation } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient"; import { getClient } from "@/api/AxiosClient";
import { toast } from "@/components/ui/use-toast"; import { toast } from "@/components/ui/use-toast";
import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { BrowserSession } from "@/routes/workflows/types/browserSessionTypes"; import {
BrowserSession,
BrowserSessionExtension,
BrowserSessionType,
} from "@/routes/workflows/types/browserSessionTypes";
import { ProxyLocation } from "@/api/types"; import { ProxyLocation } from "@/api/types";
function useCreateBrowserSessionMutation() { function useCreateBrowserSessionMutation() {
@ -17,9 +21,13 @@ function useCreateBrowserSessionMutation() {
mutationFn: async ({ mutationFn: async ({
proxyLocation = null, proxyLocation = null,
timeout = null, timeout = null,
extensions = [],
browserType = null,
}: { }: {
proxyLocation: ProxyLocation | null; proxyLocation: ProxyLocation | null;
timeout: number | null; timeout: number | null;
extensions?: BrowserSessionExtension[];
browserType?: BrowserSessionType | null;
}) => { }) => {
const client = await getClient(credentialGetter, "sans-api-v1"); const client = await getClient(credentialGetter, "sans-api-v1");
return client.post<string, { data: BrowserSession }>( return client.post<string, { data: BrowserSession }>(
@ -27,6 +35,8 @@ function useCreateBrowserSessionMutation() {
{ {
proxy_location: proxyLocation, proxy_location: proxyLocation,
timeout, timeout,
extensions,
browser_type: browserType,
}, },
); );
}, },

View file

@ -1,3 +1,6 @@
type BrowserSessionExtension = "ad-blocker" | "captcha-solver";
type BrowserSessionType = "msedge" | "chrome";
interface BrowserSession { interface BrowserSession {
browser_address: string | null; browser_address: string | null;
browser_session_id: string; browser_session_id: string;
@ -8,6 +11,8 @@ interface BrowserSession {
started_at: string | null; started_at: string | null;
status: string; status: string;
timeout: number | null; timeout: number | null;
extensions?: BrowserSessionExtension[] | null;
browser_type?: BrowserSessionType | null;
vnc_streaming_supported: boolean; vnc_streaming_supported: boolean;
} }
@ -18,4 +23,9 @@ interface Recording {
modified_at: string; modified_at: string;
} }
export { type BrowserSession, type Recording }; export {
type BrowserSession,
type BrowserSessionExtension,
type BrowserSessionType,
type Recording,
};

View file

@ -20,6 +20,10 @@ export type GroupedSearchResults = {
cities: SearchResultItem[]; cities: SearchResultItem[];
}; };
type SearchGeoDataOptions = {
includeGranularResults?: boolean;
};
// Store the promise so concurrent calls share one import // Store the promise so concurrent calls share one import
let cscModulePromise: Promise<typeof import("country-state-city")> | null = let cscModulePromise: Promise<typeof import("country-state-city")> | null =
null; null;
@ -37,6 +41,7 @@ function loadCsc() {
export async function searchGeoData( export async function searchGeoData(
query: string, query: string,
{ includeGranularResults = true }: SearchGeoDataOptions = {},
): Promise<GroupedSearchResults> { ): Promise<GroupedSearchResults> {
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const queryMatchesISP = normalizedQuery.includes("isp"); const queryMatchesISP = normalizedQuery.includes("isp");
@ -82,8 +87,9 @@ export async function searchGeoData(
} }
} }
// If query is very short, just return countries to save perf // Browser Sessions create currently supports country-level proxy locations only.
if (normalizedQuery.length < 2) { // Allow callers to disable subdivisions/cities so the dropdown matches API support.
if (!includeGranularResults || normalizedQuery.length < 2) {
return results; return results;
} }