feat: Browser profile testing & login-free workflow support — backend (#SKY-8012) (#4818)

Co-authored-by: Shuchang Zheng <wintonzheng0325@gmail.com>
This commit is contained in:
Celal Zamanoğlu
2026-02-20 02:23:32 +03:00
committed by GitHub
parent f8f9d2a17f
commit 4adaf8dd53
8 changed files with 1287 additions and 5 deletions

View File

@@ -816,6 +816,23 @@ class BaseTaskBlock(Block):
if working_page.url == "about:blank" and self.url:
await browser_state.navigate_to_url(page=working_page, url=self.url)
# When a browser profile is loaded, wait for the page to fully settle
# so that cookie-based authentication can redirect or restore the session
# BEFORE the agent starts interacting with the page.
if workflow_run.browser_profile_id:
LOG.info(
"Browser profile loaded — waiting for page to settle before agent acts",
browser_profile_id=workflow_run.browser_profile_id,
workflow_run_id=workflow_run.workflow_run_id,
)
try:
await working_page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
LOG.debug(
"networkidle timeout after browser profile load (non-fatal)",
workflow_run_id=workflow_run.workflow_run_id,
)
except Exception as e:
LOG.exception(
"Failed to get browser state for first task",

View File

@@ -65,6 +65,7 @@ from skyvern.forge.sdk.workflow.exceptions import (
WorkflowVersionConflict,
)
from skyvern.forge.sdk.workflow.models.block import (
Block,
BlockTypeVar,
ConditionalBlock,
ExtractionBlock,
@@ -1364,6 +1365,99 @@ class WorkflowService:
model=block.model,
)
# ── Skip LoginBlock when credential has a browser profile ────
if block.block_type == BlockType.LOGIN:
resolved_browser_profile_id = await self._resolve_login_block_browser_profile_id(
block=block,
workflow_run_id=workflow_run_id,
organization_id=organization_id,
)
# Save the original navigation goal before any mutation so
# retries don't stack the browser-session prefix repeatedly.
original_navigation_goal = block.navigation_goal
if resolved_browser_profile_id:
LOG.info(
"LoginBlock has credential with browser profile — skipping login agent",
workflow_run_id=workflow_run_id,
block_label=block.label,
browser_profile_id=resolved_browser_profile_id,
url=block.url,
)
# Persist the browser_profile_id on the workflow_run so
# subsequent blocks create / reuse a browser with the
# saved profile (cookies, localStorage, etc.).
await app.DATABASE.update_workflow_run(
workflow_run_id=workflow_run_id,
browser_profile_id=resolved_browser_profile_id,
)
workflow_run = (
await app.DATABASE.get_workflow_run(
workflow_run_id=workflow_run_id,
organization_id=organization_id,
)
or workflow_run
)
# Create the browser with the saved profile and navigate
# to the login block's URL. When a login-free credential
# is selected, the user is guided to enter the post-login
# target URL (e.g. homepage/dashboard) rather than the
# login page. The saved cookies will authenticate the
# session once the page loads.
profile_loaded = bool(block.url)
if block.url:
try:
browser_state = await app.BROWSER_MANAGER.get_or_create_for_workflow_run(
workflow_run=workflow_run,
url=block.url,
browser_profile_id=resolved_browser_profile_id,
)
working_page = await browser_state.get_working_page()
if working_page and working_page.url == "about:blank":
await browser_state.navigate_to_url(page=working_page, url=block.url)
# Wait for the page to settle so cookies/redirects complete
if working_page:
try:
await working_page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
LOG.debug(
"networkidle timeout after browser profile navigation (non-fatal)",
workflow_run_id=workflow_run_id,
)
except Exception:
LOG.warning(
"Saved browser profile failed to load, falling back to normal login",
workflow_run_id=workflow_run_id,
block_label=block.label,
browser_profile_id=resolved_browser_profile_id,
exc_info=True,
)
profile_loaded = False
# Clear the profile so the normal login path doesn't reuse it
await app.DATABASE.update_workflow_run(
workflow_run_id=workflow_run_id,
browser_profile_id=None,
)
if not profile_loaded:
# Fall through to normal block execution below
pass
else:
# Browser profile loaded — the session may still be
# valid or may have expired (common with bank sites).
# Instead of skipping the login block, modify the
# navigation goal so the AI checks whether the user is
# already logged in and only performs login if needed.
if original_navigation_goal:
block.navigation_goal = (
"A saved browser session has been loaded. "
"Check if the user is already logged in. "
"If already logged in, complete this task immediately without taking any action. "
"If not logged in (e.g. the session expired), "
"proceed to log in with the provided credentials.\n\n"
f"Original goal: {original_navigation_goal}"
)
valid_to_run_code = (
is_script_run and block.label and block.label in script_blocks_by_label and not block.disable_cache
)
@@ -1520,6 +1614,99 @@ class WorkflowService:
)
return workflow_run, blocks_to_update, workflow_run_block_result, True, branch_metadata
async def _resolve_login_block_browser_profile_id(
self,
block: Block,
workflow_run_id: str,
organization_id: str | None,
) -> str | None:
"""Inspect the block-level parameters and return the browser_profile_id
from the credential parameter bound to this specific block."""
params = block.parameters
# Pre-fetch run parameters once (used by WorkflowParameter/CREDENTIAL_ID style).
run_param_tuples: list[tuple[Any, Any]] | None = None
for param in params:
credential_id: str | None = None
# Style 1: CredentialParameter (has credential_id directly)
if isinstance(param, CredentialParameter):
credential_id = param.credential_id
# Style 2: WorkflowParameter with type CREDENTIAL_ID
elif (
isinstance(param, WorkflowParameter)
and getattr(param, "workflow_parameter_type", None) == WorkflowParameterType.CREDENTIAL_ID
):
# The credential_id is stored as the run-parameter value (or
# falls back to default_value on the workflow parameter).
if run_param_tuples is None:
try:
run_param_tuples = await app.DATABASE.get_workflow_run_parameters(
workflow_run_id=workflow_run_id,
)
except Exception:
LOG.warning(
"Failed to fetch workflow run parameters for credential resolution",
workflow_run_id=workflow_run_id,
exc_info=True,
)
run_param_tuples = []
for wf_param, run_param in run_param_tuples:
if wf_param.key == param.key:
if isinstance(run_param.value, str) and run_param.value:
credential_id = run_param.value
break
# Fallback to default_value
if not credential_id:
dv = getattr(param, "default_value", None)
if isinstance(dv, str) and dv:
credential_id = dv
if not credential_id:
continue
# Look up the credential and check for a browser_profile_id
if not organization_id:
continue
try:
db_cred = await app.DATABASE.get_credential(
credential_id=credential_id,
organization_id=organization_id,
)
if db_cred and db_cred.browser_profile_id:
# Verify the browser profile still exists before using it
profile = await app.DATABASE.get_browser_profile(
profile_id=db_cred.browser_profile_id,
organization_id=organization_id,
)
if not profile:
LOG.warning(
"Credential has browser_profile_id but profile not found, ignoring",
credential_id=credential_id,
browser_profile_id=db_cred.browser_profile_id,
workflow_run_id=workflow_run_id,
)
continue
LOG.info(
"Resolved browser_profile_id from LoginBlock credential",
credential_id=credential_id,
browser_profile_id=db_cred.browser_profile_id,
workflow_run_id=workflow_run_id,
)
return db_cred.browser_profile_id
except Exception:
LOG.warning(
"Failed to look up credential for browser profile",
credential_id=credential_id,
workflow_run_id=workflow_run_id,
exc_info=True,
)
return None
async def _handle_block_result_status(
self,
*,