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:
@@ -2893,6 +2893,7 @@ class AgentDB(BaseAlchemyDB):
|
||||
waiting_for_verification_code: bool | None = None,
|
||||
verification_code_identifier: str | None = None,
|
||||
verification_code_polling_started_at: datetime | None = None,
|
||||
browser_profile_id: str | None | object = _UNSET,
|
||||
) -> WorkflowRun:
|
||||
async with self.Session() as session:
|
||||
workflow_run = (
|
||||
@@ -2936,6 +2937,8 @@ class AgentDB(BaseAlchemyDB):
|
||||
# Clear related fields when waiting is set to False
|
||||
workflow_run.verification_code_identifier = None
|
||||
workflow_run.verification_code_polling_started_at = None
|
||||
if browser_profile_id is not _UNSET:
|
||||
workflow_run.browser_profile_id = browser_profile_id
|
||||
await session.commit()
|
||||
await save_workflow_run_logs(workflow_run_id)
|
||||
await session.refresh(workflow_run)
|
||||
@@ -5448,7 +5451,12 @@ class AgentDB(BaseAlchemyDB):
|
||||
return [Credential.model_validate(credential) for credential in credentials]
|
||||
|
||||
async def update_credential(
|
||||
self, credential_id: str, organization_id: str, name: str | None = None, website_url: str | None = None
|
||||
self,
|
||||
credential_id: str,
|
||||
organization_id: str,
|
||||
name: str | None = None,
|
||||
browser_profile_id: str | None | object = _UNSET,
|
||||
tested_url: str | None | object = _UNSET,
|
||||
) -> Credential:
|
||||
async with self.Session() as session:
|
||||
credential = (
|
||||
@@ -5456,14 +5464,17 @@ class AgentDB(BaseAlchemyDB):
|
||||
select(CredentialModel)
|
||||
.filter_by(credential_id=credential_id)
|
||||
.filter_by(organization_id=organization_id)
|
||||
.filter(CredentialModel.deleted_at.is_(None))
|
||||
)
|
||||
).first()
|
||||
if not credential:
|
||||
raise NotFoundError(f"Credential {credential_id} not found")
|
||||
if name:
|
||||
if name is not None:
|
||||
credential.name = name
|
||||
if website_url:
|
||||
credential.website_url = website_url
|
||||
if browser_profile_id is not _UNSET:
|
||||
credential.browser_profile_id = browser_profile_id
|
||||
if tested_url is not _UNSET:
|
||||
credential.tested_url = tested_url
|
||||
await session.commit()
|
||||
await session.refresh(credential)
|
||||
return Credential.model_validate(credential)
|
||||
|
||||
@@ -950,6 +950,8 @@ class CredentialModel(Base):
|
||||
card_last4 = Column(String, nullable=True)
|
||||
card_brand = Column(String, nullable=True)
|
||||
secret_label = Column(String, nullable=True)
|
||||
browser_profile_id = Column(String, nullable=True)
|
||||
tested_url = Column(String, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
|
||||
modified_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow, nullable=False)
|
||||
|
||||
@@ -25,7 +25,9 @@ exfiltrate stored passwords, card numbers, and secrets — which is the
|
||||
exact threat the vault architecture is designed to prevent.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import BackgroundTasks, Body, Depends, HTTPException, Path, Query
|
||||
@@ -33,6 +35,7 @@ from fastapi import BackgroundTasks, Body, Depends, HTTPException, Path, Query
|
||||
from skyvern.config import settings
|
||||
from skyvern.forge import app
|
||||
from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType
|
||||
from skyvern.forge.sdk.executor.factory import AsyncExecutorFactory
|
||||
from skyvern.forge.sdk.routes.code_samples import (
|
||||
CREATE_CREDENTIAL_CODE_SAMPLE_CREDIT_CARD_PYTHON,
|
||||
CREATE_CREDENTIAL_CODE_SAMPLE_CREDIT_CARD_TS,
|
||||
@@ -49,14 +52,23 @@ from skyvern.forge.sdk.routes.code_samples import (
|
||||
)
|
||||
from skyvern.forge.sdk.routes.routers import base_router, legacy_base_router
|
||||
from skyvern.forge.sdk.schemas.credentials import (
|
||||
CancelTestResponse,
|
||||
CreateCredentialRequest,
|
||||
Credential,
|
||||
CredentialResponse,
|
||||
CredentialType,
|
||||
CredentialVaultType,
|
||||
CreditCardCredentialResponse,
|
||||
NonEmptyPasswordCredential,
|
||||
PasswordCredentialResponse,
|
||||
SecretCredentialResponse,
|
||||
TestCredentialRequest,
|
||||
TestCredentialResponse,
|
||||
TestCredentialStatusResponse,
|
||||
TestLoginRequest,
|
||||
TestLoginResponse,
|
||||
TotpType,
|
||||
UpdateCredentialRequest,
|
||||
)
|
||||
from skyvern.forge.sdk.schemas.organizations import (
|
||||
AzureClientSecretCredentialResponse,
|
||||
@@ -71,10 +83,24 @@ from skyvern.forge.sdk.schemas.totp_codes import OTPType, TOTPCode, TOTPCodeCrea
|
||||
from skyvern.forge.sdk.services import org_auth_service
|
||||
from skyvern.forge.sdk.services.bitwarden import BitwardenService
|
||||
from skyvern.forge.sdk.services.credential.credential_vault_service import CredentialVaultService
|
||||
from skyvern.forge.sdk.workflow.models.parameter import WorkflowParameterType
|
||||
from skyvern.forge.sdk.workflow.models.workflow import WorkflowRequestBody, WorkflowRunStatus
|
||||
from skyvern.schemas.workflows import (
|
||||
LoginBlockYAML,
|
||||
WorkflowCreateYAMLRequest,
|
||||
WorkflowDefinitionYAML,
|
||||
WorkflowParameterYAML,
|
||||
WorkflowStatus,
|
||||
)
|
||||
from skyvern.services.otp_service import OTPValue, parse_otp_login
|
||||
from skyvern.services.run_service import cancel_workflow_run
|
||||
|
||||
LOG = structlog.get_logger()
|
||||
|
||||
# Strong references to background tasks to prevent GC before completion.
|
||||
# See: https://docs.python.org/3/library/asyncio-task.html#creating-tasks
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
async def fetch_credential_item_background(item_id: str) -> None:
|
||||
"""
|
||||
@@ -312,6 +338,846 @@ async def create_credential(
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported credential type: {data.credential_type}")
|
||||
|
||||
|
||||
DEFAULT_LOGIN_PROMPT = (
|
||||
"Navigate to the login page if needed and log in with the provided credentials. "
|
||||
"Fill in the username and password fields and submit the form. "
|
||||
"After submitting, verify whether the login was successful by checking the page content. "
|
||||
"IMPORTANT: If the page asks for a credential you were NOT provided (e.g., a phone number, "
|
||||
"security question, or any field you don't have a value for), TERMINATE IMMEDIATELY and "
|
||||
"report that the login requires additional information that was not provided. "
|
||||
"Do NOT guess, make up values, or re-use other credentials in the wrong field. "
|
||||
"CRITICAL RULE — YOU MUST FOLLOW THIS: You may only submit the login form ONCE. "
|
||||
"After submitting, if the website shows ANY error or rejection — such as 'wrong password', "
|
||||
"'invalid credentials', 'incorrect password', 'account locked', 'suspended', "
|
||||
"'too many attempts', or any other error message — you MUST TERMINATE IMMEDIATELY. "
|
||||
"Do NOT fill in the form again. Do NOT click submit again. Do NOT retry. "
|
||||
"A failed login cannot be fixed by retrying with the same credentials. "
|
||||
"Retrying will cause the account to be locked or suspended. "
|
||||
"Report the exact error message from the website and terminate."
|
||||
)
|
||||
|
||||
BROWSER_PROFILE_LOGIN_PROMPT = (
|
||||
"A browser profile with saved session data has been loaded. "
|
||||
"FIRST, check whether you are already logged in by examining the page content. "
|
||||
"Look for signs of an authenticated session such as a dashboard, welcome message, "
|
||||
"user menu, profile icon, or any content that indicates a logged-in state. "
|
||||
"If you are already logged in, report success immediately — do NOT interact with "
|
||||
"any form fields or attempt to log in again. "
|
||||
"Only if the page clearly shows a login form and you are NOT logged in, "
|
||||
"then log in with the provided credentials. Fill in the username and password fields "
|
||||
"and submit the form. After submitting, verify whether the login was successful. "
|
||||
"IMPORTANT: If the page asks for a credential you were NOT provided (e.g., a phone number, "
|
||||
"security question, or any field you don't have a value for), TERMINATE IMMEDIATELY and "
|
||||
"report that the login requires additional information that was not provided. "
|
||||
"Do NOT guess, make up values, or re-use other credentials in the wrong field. "
|
||||
"CRITICAL RULE — YOU MUST FOLLOW THIS: You may only submit the login form ONCE. "
|
||||
"After submitting, if the website shows ANY error or rejection — such as 'wrong password', "
|
||||
"'invalid credentials', 'incorrect password', 'account locked', 'suspended', "
|
||||
"'too many attempts', or any other error message — you MUST TERMINATE IMMEDIATELY. "
|
||||
"Do NOT fill in the form again. Do NOT click submit again. Do NOT retry. "
|
||||
"A failed login cannot be fixed by retrying with the same credentials. "
|
||||
"Retrying will cause the account to be locked or suspended. "
|
||||
"Report the exact error message from the website and terminate."
|
||||
)
|
||||
|
||||
LOGIN_TEST_TERMINATE_CRITERION = (
|
||||
"Terminate IMMEDIATELY if ANY of these conditions are true: "
|
||||
"(1) The website displays an error message after a login attempt (e.g., wrong password, "
|
||||
"invalid credentials, account locked, suspicious activity, too many attempts). "
|
||||
"(2) The page asks for information you were not provided (e.g., phone number, "
|
||||
"security question, verification code that isn't TOTP). "
|
||||
"(3) You have already submitted the login form once and it was not successful. "
|
||||
"Never attempt to log in more than once. Never re-enter credentials after a failed attempt."
|
||||
)
|
||||
|
||||
|
||||
@base_router.patch(
|
||||
"/credentials/{credential_id}",
|
||||
response_model=CredentialResponse,
|
||||
summary="Rename credential",
|
||||
description="Updates a credential's metadata (e.g. name) without changing the stored secret.",
|
||||
tags=["Credentials"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
@base_router.patch(
|
||||
"/credentials/{credential_id}/",
|
||||
response_model=CredentialResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def rename_credential(
|
||||
credential_id: str = Path(
|
||||
...,
|
||||
description="The unique identifier of the credential to update",
|
||||
examples=["cred_1234567890"],
|
||||
),
|
||||
data: UpdateCredentialRequest = Body(
|
||||
...,
|
||||
description="The credential fields to update",
|
||||
),
|
||||
current_org: Organization = Depends(org_auth_service.get_current_org),
|
||||
) -> CredentialResponse:
|
||||
credential = await app.DATABASE.get_credential(
|
||||
credential_id=credential_id, organization_id=current_org.organization_id
|
||||
)
|
||||
if not credential:
|
||||
raise HTTPException(status_code=404, detail=f"Credential not found, credential_id={credential_id}")
|
||||
|
||||
update_kwargs: dict = {
|
||||
"credential_id": credential_id,
|
||||
"organization_id": current_org.organization_id,
|
||||
"name": data.name,
|
||||
}
|
||||
if data.tested_url is not None:
|
||||
update_kwargs["tested_url"] = data.tested_url
|
||||
updated = await app.DATABASE.update_credential(**update_kwargs)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=500, detail="Failed to update credential")
|
||||
|
||||
return _convert_to_response(updated)
|
||||
|
||||
|
||||
@base_router.post(
|
||||
"/credentials/test-login",
|
||||
response_model=TestLoginResponse,
|
||||
summary="Test login with inline credentials",
|
||||
description=(
|
||||
"Test a login by providing credentials inline (no saved credential required). "
|
||||
"Creates a temporary credential, runs a login test, and returns a workflow run ID to poll."
|
||||
),
|
||||
tags=["Credentials"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
@base_router.post(
|
||||
"/credentials/test-login/",
|
||||
response_model=TestLoginResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def test_login(
|
||||
background_tasks: BackgroundTasks,
|
||||
data: TestLoginRequest = Body(
|
||||
...,
|
||||
description="The login credentials and URL to test",
|
||||
),
|
||||
current_org: Organization = Depends(org_auth_service.get_current_org),
|
||||
) -> TestLoginResponse:
|
||||
"""Test a login with inline credentials without requiring a saved credential."""
|
||||
organization_id = current_org.organization_id
|
||||
|
||||
# Create a temporary credential
|
||||
create_request = CreateCredentialRequest(
|
||||
name=f"_test_login_{data.username}",
|
||||
credential_type=CredentialType.PASSWORD,
|
||||
credential=NonEmptyPasswordCredential(
|
||||
username=data.username,
|
||||
password=data.password,
|
||||
totp=data.totp,
|
||||
totp_type=data.totp_type,
|
||||
totp_identifier=data.totp_identifier,
|
||||
),
|
||||
)
|
||||
|
||||
credential_service = await _get_credential_vault_service()
|
||||
credential = await credential_service.create_credential(
|
||||
organization_id=organization_id,
|
||||
data=create_request,
|
||||
)
|
||||
|
||||
if credential.vault_type == CredentialVaultType.BITWARDEN:
|
||||
background_tasks.add_task(fetch_credential_item_background, credential.item_id)
|
||||
|
||||
credential_id = credential.credential_id
|
||||
|
||||
LOG.info(
|
||||
"Testing login with inline credentials",
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
url=data.url,
|
||||
)
|
||||
|
||||
# Build a login workflow
|
||||
parameter_key = "credential"
|
||||
label = "login"
|
||||
|
||||
yaml_parameters = [
|
||||
WorkflowParameterYAML(
|
||||
key=parameter_key,
|
||||
workflow_parameter_type=WorkflowParameterType.CREDENTIAL_ID,
|
||||
description="The credential to test",
|
||||
default_value=credential_id,
|
||||
)
|
||||
]
|
||||
|
||||
# 2FA flows need more steps (enter code, submit) than plain password logins
|
||||
max_steps = 5 if data.totp_type != TotpType.NONE else 3
|
||||
|
||||
login_block_yaml = LoginBlockYAML(
|
||||
label=label,
|
||||
title=label,
|
||||
url=data.url,
|
||||
navigation_goal=DEFAULT_LOGIN_PROMPT,
|
||||
terminate_criterion=LOGIN_TEST_TERMINATE_CRITERION,
|
||||
max_steps_per_run=max_steps,
|
||||
parameter_keys=[parameter_key],
|
||||
totp_verification_url=None,
|
||||
totp_identifier=data.totp_identifier,
|
||||
)
|
||||
|
||||
workflow_definition_yaml = WorkflowDefinitionYAML(
|
||||
parameters=yaml_parameters,
|
||||
blocks=[login_block_yaml],
|
||||
)
|
||||
|
||||
workflow_create_request = WorkflowCreateYAMLRequest(
|
||||
title=f"Login Test - {data.username}",
|
||||
description="Auto-generated workflow to test login credentials",
|
||||
persist_browser_session=True,
|
||||
workflow_definition=workflow_definition_yaml,
|
||||
status=WorkflowStatus.auto_generated,
|
||||
)
|
||||
|
||||
try:
|
||||
workflow = await app.WORKFLOW_SERVICE.create_workflow_from_request(
|
||||
organization=current_org,
|
||||
request=workflow_create_request,
|
||||
)
|
||||
|
||||
run_request = WorkflowRequestBody()
|
||||
|
||||
workflow_run = await app.WORKFLOW_SERVICE.setup_workflow_run(
|
||||
request_id=None,
|
||||
workflow_request=run_request,
|
||||
workflow_permanent_id=workflow.workflow_permanent_id,
|
||||
organization=current_org,
|
||||
max_steps_override=None,
|
||||
)
|
||||
|
||||
await AsyncExecutorFactory.get_executor().execute_workflow(
|
||||
request=None,
|
||||
background_tasks=background_tasks,
|
||||
organization=current_org,
|
||||
workflow_id=workflow_run.workflow_id,
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
workflow_permanent_id=workflow_run.workflow_permanent_id,
|
||||
max_steps_override=None,
|
||||
api_key=None,
|
||||
browser_session_id=None,
|
||||
block_labels=None,
|
||||
block_outputs=None,
|
||||
)
|
||||
except Exception:
|
||||
# Clean up the orphaned temporary credential if workflow setup fails
|
||||
LOG.exception(
|
||||
"Workflow setup failed for test_login, cleaning up temporary credential",
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
try:
|
||||
await app.DATABASE.delete_credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
except Exception:
|
||||
LOG.warning(
|
||||
"Failed to clean up temporary credential after workflow setup error",
|
||||
credential_id=credential_id,
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
# Always schedule profile creation for test_login — the entire purpose of this
|
||||
# endpoint is to create a temporary credential with a browser profile. This differs
|
||||
# from test_credential, which conditionally checks data.save_browser_profile because
|
||||
# that endpoint tests an existing credential that may or may not need a profile.
|
||||
task = asyncio.create_task(
|
||||
_create_browser_profile_after_workflow(
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
workflow_id=workflow_run.workflow_id,
|
||||
workflow_permanent_id=workflow.workflow_permanent_id,
|
||||
organization_id=organization_id,
|
||||
credential_name=f"_test_login_{data.username}",
|
||||
test_url=data.url,
|
||||
)
|
||||
)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
LOG.info(
|
||||
"Login test started",
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
|
||||
return TestLoginResponse(
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
status="running",
|
||||
)
|
||||
|
||||
|
||||
@base_router.post(
|
||||
"/credentials/{credential_id}/test",
|
||||
response_model=TestCredentialResponse,
|
||||
summary="Test a credential",
|
||||
description=(
|
||||
"Test a credential by running a login task against the specified URL. "
|
||||
"Optionally saves the browser profile after a successful login for reuse in workflows."
|
||||
),
|
||||
tags=["Credentials"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
@base_router.post(
|
||||
"/credentials/{credential_id}/test/",
|
||||
response_model=TestCredentialResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def test_credential(
|
||||
background_tasks: BackgroundTasks,
|
||||
credential_id: str = Path(
|
||||
...,
|
||||
description="The credential ID to test",
|
||||
examples=["cred_1234567890"],
|
||||
),
|
||||
data: TestCredentialRequest = Body(
|
||||
...,
|
||||
description="Test configuration including the login URL",
|
||||
),
|
||||
current_org: Organization = Depends(org_auth_service.get_current_org),
|
||||
) -> TestCredentialResponse:
|
||||
organization_id = current_org.organization_id
|
||||
|
||||
# Validate credential exists and is a password type
|
||||
credential = await app.DATABASE.get_credential(credential_id=credential_id, organization_id=organization_id)
|
||||
if not credential:
|
||||
raise HTTPException(status_code=404, detail=f"Credential {credential_id} not found")
|
||||
if credential.credential_type != CredentialType.PASSWORD:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Only password credentials can be tested with login",
|
||||
)
|
||||
|
||||
# Check if the credential already has a browser profile
|
||||
existing_browser_profile_id = credential.browser_profile_id
|
||||
if existing_browser_profile_id:
|
||||
profile = await app.DATABASE.get_browser_profile(
|
||||
profile_id=existing_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=existing_browser_profile_id,
|
||||
)
|
||||
existing_browser_profile_id = None
|
||||
|
||||
LOG.info(
|
||||
"Testing credential",
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
url=data.url,
|
||||
save_browser_profile=data.save_browser_profile,
|
||||
existing_browser_profile_id=existing_browser_profile_id,
|
||||
)
|
||||
|
||||
navigation_goal = BROWSER_PROFILE_LOGIN_PROMPT if existing_browser_profile_id else DEFAULT_LOGIN_PROMPT
|
||||
|
||||
parameter_key = "credential"
|
||||
label = "login"
|
||||
|
||||
yaml_parameters = [
|
||||
WorkflowParameterYAML(
|
||||
key=parameter_key,
|
||||
workflow_parameter_type=WorkflowParameterType.CREDENTIAL_ID,
|
||||
description="The credential to test",
|
||||
default_value=credential_id,
|
||||
)
|
||||
]
|
||||
|
||||
# 2FA flows need more steps (enter code, submit) than plain password logins
|
||||
max_steps = 5 if credential.totp_type != TotpType.NONE else 3
|
||||
|
||||
login_block_yaml = LoginBlockYAML(
|
||||
label=label,
|
||||
title=label,
|
||||
url=data.url,
|
||||
navigation_goal=navigation_goal,
|
||||
terminate_criterion=LOGIN_TEST_TERMINATE_CRITERION,
|
||||
max_steps_per_run=max_steps,
|
||||
parameter_keys=[parameter_key],
|
||||
totp_verification_url=None,
|
||||
totp_identifier=credential.totp_identifier,
|
||||
)
|
||||
|
||||
workflow_definition_yaml = WorkflowDefinitionYAML(
|
||||
parameters=yaml_parameters,
|
||||
blocks=[login_block_yaml],
|
||||
)
|
||||
|
||||
workflow_create_request = WorkflowCreateYAMLRequest(
|
||||
title=f"Credential Test - {credential.name}",
|
||||
description=f"Auto-generated workflow to test credential {credential_id}",
|
||||
persist_browser_session=data.save_browser_profile,
|
||||
workflow_definition=workflow_definition_yaml,
|
||||
status=WorkflowStatus.auto_generated,
|
||||
)
|
||||
|
||||
try:
|
||||
workflow = await app.WORKFLOW_SERVICE.create_workflow_from_request(
|
||||
organization=current_org,
|
||||
request=workflow_create_request,
|
||||
)
|
||||
|
||||
run_request = WorkflowRequestBody(
|
||||
browser_profile_id=existing_browser_profile_id,
|
||||
)
|
||||
|
||||
workflow_run = await app.WORKFLOW_SERVICE.setup_workflow_run(
|
||||
request_id=None,
|
||||
workflow_request=run_request,
|
||||
workflow_permanent_id=workflow.workflow_permanent_id,
|
||||
organization=current_org,
|
||||
max_steps_override=None,
|
||||
)
|
||||
|
||||
await AsyncExecutorFactory.get_executor().execute_workflow(
|
||||
request=None,
|
||||
background_tasks=background_tasks,
|
||||
organization=current_org,
|
||||
workflow_id=workflow_run.workflow_id,
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
workflow_permanent_id=workflow_run.workflow_permanent_id,
|
||||
max_steps_override=None,
|
||||
api_key=None,
|
||||
browser_session_id=None,
|
||||
block_labels=None,
|
||||
block_outputs=None,
|
||||
)
|
||||
except Exception:
|
||||
LOG.exception(
|
||||
"Workflow setup failed for test_credential",
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
raise
|
||||
|
||||
if data.save_browser_profile:
|
||||
task = asyncio.create_task(
|
||||
_create_browser_profile_after_workflow(
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
workflow_id=workflow_run.workflow_id,
|
||||
workflow_permanent_id=workflow.workflow_permanent_id,
|
||||
organization_id=organization_id,
|
||||
credential_name=credential.name,
|
||||
test_url=data.url,
|
||||
)
|
||||
)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
LOG.info(
|
||||
"Credential test started",
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
|
||||
return TestCredentialResponse(
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
status="running",
|
||||
)
|
||||
|
||||
|
||||
def _humanize_test_failure(raw_reason: str | None) -> str:
|
||||
"""Convert raw workflow failure output into a user-friendly message.
|
||||
|
||||
The raw failure_reason from the workflow engine contains LLM output with
|
||||
element IDs, action types, and technical details that are meaningless to
|
||||
end users. This function extracts the key insight and returns a concise,
|
||||
actionable message.
|
||||
"""
|
||||
if not raw_reason:
|
||||
return "The login test failed. The credentials may be incorrect or the login page may have changed."
|
||||
|
||||
reason_lower = raw_reason.lower()
|
||||
|
||||
# Log the raw reason for debugging, return friendly message
|
||||
LOG.debug("Raw test failure reason", raw_reason=raw_reason)
|
||||
|
||||
if "reached the maximum steps" in reason_lower:
|
||||
if "password" in reason_lower:
|
||||
return (
|
||||
"Login could not be completed — the password may be incorrect "
|
||||
"or the login page requires additional steps that couldn't be automated."
|
||||
)
|
||||
if "2fa" in reason_lower or "totp" in reason_lower or "verification" in reason_lower:
|
||||
return (
|
||||
"Login could not be completed — the two-factor authentication step "
|
||||
"could not be automated. Please check your 2FA settings."
|
||||
)
|
||||
return (
|
||||
"Login could not be completed within the allowed steps. "
|
||||
"The login page may require additional steps or the credentials may be incorrect."
|
||||
)
|
||||
|
||||
if "timed out" in reason_lower or "timeout" in reason_lower:
|
||||
return "The login page took too long to respond. Please check the URL and try again."
|
||||
|
||||
if "navigation" in reason_lower and ("failed" in reason_lower or "error" in reason_lower):
|
||||
return "Could not navigate to the login page. Please check the URL and try again."
|
||||
|
||||
if "password" in reason_lower and (
|
||||
"incorrect" in reason_lower or "invalid" in reason_lower or "wrong" in reason_lower
|
||||
):
|
||||
return "The login failed — the password appears to be incorrect."
|
||||
|
||||
if "username" in reason_lower and ("not found" in reason_lower or "invalid" in reason_lower):
|
||||
return "The login failed — the username was not recognized."
|
||||
|
||||
# Generic fallback — strip technical details
|
||||
return "The login test was unsuccessful. Please verify your credentials and the login URL, then try again."
|
||||
|
||||
|
||||
@base_router.get(
|
||||
"/credentials/{credential_id}/test/{workflow_run_id}",
|
||||
response_model=TestCredentialStatusResponse,
|
||||
summary="Get credential test status",
|
||||
description=(
|
||||
"Poll the status of a credential test. When the test completes successfully "
|
||||
"and save_browser_profile was enabled, a browser profile will be automatically "
|
||||
"created and linked to the credential."
|
||||
),
|
||||
tags=["Credentials"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
@base_router.get(
|
||||
"/credentials/{credential_id}/test/{workflow_run_id}/",
|
||||
response_model=TestCredentialStatusResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def get_test_credential_status(
|
||||
credential_id: str = Path(
|
||||
...,
|
||||
description="The credential ID being tested",
|
||||
examples=["cred_1234567890"],
|
||||
),
|
||||
workflow_run_id: str = Path(
|
||||
...,
|
||||
description="The workflow run ID from the test initiation",
|
||||
examples=["wr_1234567890"],
|
||||
),
|
||||
current_org: Organization = Depends(org_auth_service.get_current_org),
|
||||
) -> TestCredentialStatusResponse:
|
||||
organization_id = current_org.organization_id
|
||||
|
||||
workflow_run = await app.DATABASE.get_workflow_run(workflow_run_id=workflow_run_id, organization_id=organization_id)
|
||||
if not workflow_run:
|
||||
raise HTTPException(status_code=404, detail=f"Workflow run {workflow_run_id} not found")
|
||||
|
||||
credential = await app.DATABASE.get_credential(credential_id=credential_id, organization_id=organization_id)
|
||||
|
||||
status = workflow_run.status
|
||||
status_str = str(status)
|
||||
browser_profile_id = credential.browser_profile_id if credential else None
|
||||
tested_url = credential.tested_url if credential else None
|
||||
browser_profile_failure_reason: str | None = None
|
||||
|
||||
_FAILURE_STATUSES = {
|
||||
WorkflowRunStatus.failed,
|
||||
WorkflowRunStatus.terminated,
|
||||
WorkflowRunStatus.timed_out,
|
||||
WorkflowRunStatus.canceled,
|
||||
}
|
||||
|
||||
# If the credential was deleted (temp credential cleaned up after failure),
|
||||
# derive the status from the workflow run alone.
|
||||
if not credential and status in _FAILURE_STATUSES:
|
||||
return TestCredentialStatusResponse(
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
status=status_str,
|
||||
failure_reason=_humanize_test_failure(workflow_run.failure_reason),
|
||||
browser_profile_id=None,
|
||||
tested_url=None,
|
||||
browser_profile_failure_reason=None,
|
||||
)
|
||||
elif not credential:
|
||||
raise HTTPException(status_code=404, detail=f"Credential {credential_id} not found")
|
||||
|
||||
failure_reason: str | None = None
|
||||
if status == WorkflowRunStatus.failed:
|
||||
failure_reason = _humanize_test_failure(workflow_run.failure_reason)
|
||||
elif status == WorkflowRunStatus.timed_out:
|
||||
failure_reason = "The login page took too long to respond. Please check the URL and try again."
|
||||
elif status == WorkflowRunStatus.terminated:
|
||||
failure_reason = "The login test was terminated before it could complete."
|
||||
elif status == WorkflowRunStatus.canceled:
|
||||
failure_reason = "The login test was canceled."
|
||||
|
||||
# Detect browser profile creation failure: workflow completed successfully
|
||||
# but no profile was linked after the background task had time to finish.
|
||||
# The background task retries session retrieval 5 times with 2s sleeps (~12s),
|
||||
# so 30s is a generous grace period.
|
||||
_PROFILE_GRACE_PERIOD_SECONDS = 30
|
||||
if (
|
||||
status == WorkflowRunStatus.completed
|
||||
and not browser_profile_id
|
||||
and workflow_run.finished_at
|
||||
and (datetime.utcnow() - workflow_run.finished_at).total_seconds() > _PROFILE_GRACE_PERIOD_SECONDS
|
||||
):
|
||||
browser_profile_failure_reason = (
|
||||
"Login succeeded but the browser profile could not be saved. Please try testing again."
|
||||
)
|
||||
|
||||
return TestCredentialStatusResponse(
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
status=status_str,
|
||||
failure_reason=failure_reason,
|
||||
browser_profile_id=browser_profile_id,
|
||||
tested_url=tested_url,
|
||||
browser_profile_failure_reason=browser_profile_failure_reason,
|
||||
)
|
||||
|
||||
|
||||
@base_router.post(
|
||||
"/credentials/{credential_id}/test/{workflow_run_id}/cancel",
|
||||
response_model=CancelTestResponse,
|
||||
summary="Cancel a credential test",
|
||||
description="Cancel a running credential test and clean up temporary resources.",
|
||||
tags=["Credentials"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
@base_router.post(
|
||||
"/credentials/{credential_id}/test/{workflow_run_id}/cancel/",
|
||||
response_model=CancelTestResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def cancel_credential_test(
|
||||
credential_id: str = Path(
|
||||
...,
|
||||
description="The credential ID being tested",
|
||||
examples=["cred_1234567890"],
|
||||
),
|
||||
workflow_run_id: str = Path(
|
||||
...,
|
||||
description="The workflow run ID to cancel",
|
||||
examples=["wr_1234567890"],
|
||||
),
|
||||
current_org: Organization = Depends(org_auth_service.get_current_org),
|
||||
) -> CancelTestResponse:
|
||||
organization_id = current_org.organization_id
|
||||
|
||||
LOG.info(
|
||||
"Canceling credential test",
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
|
||||
try:
|
||||
await cancel_workflow_run(workflow_run_id=workflow_run_id, organization_id=organization_id)
|
||||
except Exception:
|
||||
LOG.warning(
|
||||
"Failed to cancel workflow run for credential test",
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
# Don't clean up the credential or claim success — the workflow may still be running.
|
||||
# The background task will handle cleanup when the workflow eventually terminates.
|
||||
return CancelTestResponse(status="cancel_failed")
|
||||
|
||||
# Only clean up temporary credentials after successful cancellation.
|
||||
# The background task may also try to delete — that's fine, it handles NotFound gracefully.
|
||||
try:
|
||||
credential = await app.DATABASE.get_credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
if credential and credential.name.startswith("_test_login_"):
|
||||
await app.DATABASE.delete_credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
LOG.info(
|
||||
"Cleaned up temporary credential after test cancellation",
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
except Exception:
|
||||
LOG.warning(
|
||||
"Failed to clean up temporary credential after test cancellation",
|
||||
credential_id=credential_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return CancelTestResponse(status="canceled")
|
||||
|
||||
|
||||
async def _create_browser_profile_after_workflow(
|
||||
credential_id: str,
|
||||
workflow_run_id: str,
|
||||
workflow_id: str,
|
||||
workflow_permanent_id: str,
|
||||
organization_id: str,
|
||||
credential_name: str,
|
||||
test_url: str,
|
||||
) -> None:
|
||||
"""Background task that polls the workflow run status and creates a browser
|
||||
profile from the persisted session when the run completes successfully."""
|
||||
max_polls = 120 # ~10 minutes at 5s intervals
|
||||
poll_interval = 5
|
||||
|
||||
try:
|
||||
for _ in range(max_polls):
|
||||
workflow_run = await app.DATABASE.get_workflow_run(
|
||||
workflow_run_id=workflow_run_id, organization_id=organization_id
|
||||
)
|
||||
if not workflow_run:
|
||||
LOG.warning(
|
||||
"Workflow run not found during browser profile creation poll",
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
)
|
||||
return
|
||||
|
||||
status = workflow_run.status
|
||||
if not status.is_final():
|
||||
await asyncio.sleep(poll_interval)
|
||||
continue
|
||||
|
||||
if status != WorkflowRunStatus.completed:
|
||||
LOG.info(
|
||||
"Workflow run did not complete successfully, skipping browser profile creation",
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
status=status,
|
||||
)
|
||||
# Clean up temporary credentials created by test-login
|
||||
if credential_name.startswith("_test_login_"):
|
||||
try:
|
||||
await app.DATABASE.delete_credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
LOG.info(
|
||||
"Deleted temporary credential after failed test",
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
except Exception:
|
||||
LOG.warning(
|
||||
"Failed to delete temporary credential after failed test",
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Workflow completed — wait for session data to be persisted
|
||||
session_dir = None
|
||||
max_retries = 5
|
||||
for attempt in range(max_retries):
|
||||
session_dir = await app.STORAGE.retrieve_browser_session(
|
||||
organization_id=organization_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
)
|
||||
if session_dir:
|
||||
break
|
||||
if attempt < max_retries - 1:
|
||||
LOG.info(
|
||||
"Browser session not yet persisted, retrying",
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
attempt=attempt + 1,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
if not session_dir:
|
||||
LOG.warning(
|
||||
"No persisted session found after retries for credential test workflow",
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
workflow_permanent_id=workflow_permanent_id,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
return
|
||||
|
||||
# Create the browser profile in DB
|
||||
profile_name = f"Profile - {credential_name} ({credential_id})"
|
||||
profile = await app.DATABASE.create_browser_profile(
|
||||
organization_id=organization_id,
|
||||
name=profile_name,
|
||||
description=f"Browser profile from credential test for {credential_name}",
|
||||
)
|
||||
|
||||
# Copy session data to the browser profile storage location
|
||||
await app.STORAGE.store_browser_profile(
|
||||
organization_id=organization_id,
|
||||
profile_id=profile.browser_profile_id,
|
||||
directory=session_dir,
|
||||
)
|
||||
|
||||
# Link browser profile to credential
|
||||
await app.DATABASE.update_credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
browser_profile_id=profile.browser_profile_id,
|
||||
tested_url=test_url,
|
||||
)
|
||||
|
||||
LOG.info(
|
||||
"Browser profile created from credential test",
|
||||
credential_id=credential_id,
|
||||
browser_profile_id=profile.browser_profile_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
)
|
||||
return
|
||||
|
||||
LOG.warning(
|
||||
"Timed out waiting for workflow run to complete for browser profile creation",
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
)
|
||||
# Clean up temporary credentials on poll timeout
|
||||
if credential_name.startswith("_test_login_"):
|
||||
try:
|
||||
await app.DATABASE.delete_credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
except Exception:
|
||||
LOG.warning(
|
||||
"Failed to delete temporary credential after poll timeout",
|
||||
credential_id=credential_id,
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception:
|
||||
LOG.exception(
|
||||
"Failed to create browser profile from credential test",
|
||||
credential_id=credential_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
)
|
||||
# Clean up temporary credentials on unexpected error
|
||||
if credential_name.startswith("_test_login_"):
|
||||
try:
|
||||
await app.DATABASE.delete_credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
except Exception:
|
||||
LOG.warning(
|
||||
"Failed to delete temporary credential after error",
|
||||
credential_id=credential_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@legacy_base_router.put("/credentials/{credential_id}")
|
||||
@legacy_base_router.put("/credentials/{credential_id}/", include_in_schema=False)
|
||||
@base_router.post(
|
||||
@@ -886,6 +1752,8 @@ def _convert_to_response(credential: Credential) -> CredentialResponse:
|
||||
credential_id=credential.credential_id,
|
||||
credential_type=credential.credential_type,
|
||||
name=credential.name,
|
||||
browser_profile_id=credential.browser_profile_id,
|
||||
tested_url=credential.tested_url,
|
||||
)
|
||||
elif credential.credential_type == CredentialType.CREDIT_CARD:
|
||||
credential_response = CreditCardCredentialResponse(
|
||||
@@ -897,6 +1765,8 @@ def _convert_to_response(credential: Credential) -> CredentialResponse:
|
||||
credential_id=credential.credential_id,
|
||||
credential_type=credential.credential_type,
|
||||
name=credential.name,
|
||||
browser_profile_id=credential.browser_profile_id,
|
||||
tested_url=credential.tested_url,
|
||||
)
|
||||
elif credential.credential_type == CredentialType.SECRET:
|
||||
credential_response = SecretCredentialResponse(secret_label=credential.secret_label)
|
||||
@@ -905,6 +1775,8 @@ def _convert_to_response(credential: Credential) -> CredentialResponse:
|
||||
credential_id=credential.credential_id,
|
||||
credential_type=credential.credential_type,
|
||||
name=credential.name,
|
||||
browser_profile_id=credential.browser_profile_id,
|
||||
tested_url=credential.tested_url,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Credential type not supported")
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Self
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from fastapi import status
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from skyvern.exceptions import SkyvernHTTPException
|
||||
from skyvern.utils.url_validators import validate_url
|
||||
|
||||
|
||||
class CredentialVaultType(StrEnum):
|
||||
@@ -170,6 +175,8 @@ class CredentialResponse(BaseModel):
|
||||
)
|
||||
credential_type: CredentialType = Field(..., description="Type of the credential")
|
||||
name: str = Field(..., description="Name of the credential", examples=["Amazon Login"])
|
||||
browser_profile_id: str | None = Field(default=None, description="Browser profile ID linked to this credential")
|
||||
tested_url: str | None = Field(default=None, description="Login page URL used during the credential test")
|
||||
|
||||
|
||||
class Credential(BaseModel):
|
||||
@@ -199,7 +206,159 @@ class Credential(BaseModel):
|
||||
card_last4: str | None = Field(..., description="For credit_card credentials: the last four digits of the card")
|
||||
card_brand: str | None = Field(..., description="For credit_card credentials: the card brand")
|
||||
secret_label: str | None = Field(default=None, description="For secret credentials: optional label")
|
||||
browser_profile_id: str | None = Field(default=None, description="Browser profile ID linked to this credential")
|
||||
tested_url: str | None = Field(default=None, description="Login page URL used during the credential test")
|
||||
|
||||
created_at: datetime = Field(..., description="Timestamp when the credential was created")
|
||||
modified_at: datetime = Field(..., description="Timestamp when the credential was last modified")
|
||||
deleted_at: datetime | None = Field(None, description="Timestamp when the credential was deleted, if applicable")
|
||||
|
||||
|
||||
class UpdateCredentialRequest(BaseModel):
|
||||
"""Request model for updating credential metadata."""
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="New name for the credential",
|
||||
examples=["My Updated Credential"],
|
||||
)
|
||||
tested_url: str | None = Field(
|
||||
default=None,
|
||||
description="Optional login page URL associated with this credential",
|
||||
examples=["https://example.com/login"],
|
||||
)
|
||||
|
||||
|
||||
class TestCredentialRequest(BaseModel):
|
||||
"""Request model for testing a credential by logging into a website."""
|
||||
|
||||
url: str = Field(
|
||||
...,
|
||||
description="The login page URL to test the credential against",
|
||||
examples=["https://example.com/login"],
|
||||
)
|
||||
save_browser_profile: bool = Field(
|
||||
default=True,
|
||||
description="Whether to save the browser profile after a successful login test",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_url(self) -> Self:
|
||||
result = validate_url(self.url)
|
||||
if result is None:
|
||||
raise SkyvernHTTPException(message=f"Invalid URL: {self.url}", status_code=status.HTTP_400_BAD_REQUEST)
|
||||
self.url = result
|
||||
return self
|
||||
|
||||
|
||||
class TestLoginRequest(BaseModel):
|
||||
"""Request model for testing a login with inline credentials (no saved credential required)."""
|
||||
|
||||
url: str = Field(
|
||||
...,
|
||||
description="The login page URL to test against",
|
||||
examples=["https://example.com/login"],
|
||||
)
|
||||
username: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="The username to test",
|
||||
examples=["user@example.com"],
|
||||
)
|
||||
password: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="The password to test",
|
||||
examples=["securepassword123"],
|
||||
)
|
||||
totp: str | None = Field(
|
||||
default=None,
|
||||
description="Optional TOTP secret for 2FA",
|
||||
)
|
||||
totp_type: TotpType = Field(
|
||||
default=TotpType.NONE,
|
||||
description="Type of 2FA method",
|
||||
)
|
||||
totp_identifier: str | None = Field(
|
||||
default=None,
|
||||
description="Identifier (email or phone) for TOTP",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_url(self) -> Self:
|
||||
result = validate_url(self.url)
|
||||
if result is None:
|
||||
raise SkyvernHTTPException(message=f"Invalid URL: {self.url}", status_code=status.HTTP_400_BAD_REQUEST)
|
||||
self.url = result
|
||||
return self
|
||||
|
||||
|
||||
class TestCredentialResponse(BaseModel):
|
||||
"""Response model for a credential test initiation."""
|
||||
|
||||
credential_id: str = Field(..., description="The credential being tested")
|
||||
workflow_run_id: str = Field(
|
||||
...,
|
||||
description="The workflow run ID to poll for test status",
|
||||
examples=["wr_1234567890"],
|
||||
)
|
||||
status: str = Field(
|
||||
...,
|
||||
description="Current status of the test",
|
||||
examples=["running"],
|
||||
)
|
||||
|
||||
|
||||
class TestLoginResponse(BaseModel):
|
||||
"""Response model for an inline login test (no saved credential)."""
|
||||
|
||||
credential_id: str = Field(
|
||||
...,
|
||||
description="The temporary credential ID created for this test",
|
||||
)
|
||||
workflow_run_id: str = Field(
|
||||
...,
|
||||
description="The workflow run ID to poll for test status",
|
||||
examples=["wr_1234567890"],
|
||||
)
|
||||
status: str = Field(
|
||||
...,
|
||||
description="Current status of the test",
|
||||
examples=["running"],
|
||||
)
|
||||
|
||||
|
||||
class TestCredentialStatusResponse(BaseModel):
|
||||
"""Response model for credential test status polling."""
|
||||
|
||||
credential_id: str = Field(..., description="The credential being tested")
|
||||
workflow_run_id: str = Field(..., description="The workflow run ID")
|
||||
status: str = Field(
|
||||
...,
|
||||
description="Current status: created, running, completed, failed, timed_out",
|
||||
examples=["completed"],
|
||||
)
|
||||
failure_reason: str | None = Field(default=None, description="Reason for failure, if any")
|
||||
browser_profile_id: str | None = Field(
|
||||
default=None,
|
||||
description="Browser profile ID created from successful test.",
|
||||
)
|
||||
tested_url: str | None = Field(
|
||||
default=None,
|
||||
description="Login page URL used during the credential test.",
|
||||
)
|
||||
browser_profile_failure_reason: str | None = Field(
|
||||
default=None,
|
||||
description="Reason the browser profile failed to save, if applicable.",
|
||||
)
|
||||
|
||||
|
||||
class CancelTestResponse(BaseModel):
|
||||
"""Response model for canceling a credential test."""
|
||||
|
||||
status: str = Field(
|
||||
...,
|
||||
description="Result of the cancellation: 'canceled' or 'cancel_failed'",
|
||||
examples=["canceled"],
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -365,6 +365,7 @@ class BlockStatus(StrEnum):
|
||||
terminated = "terminated"
|
||||
canceled = "canceled"
|
||||
timed_out = "timed_out"
|
||||
skipped = "skipped"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
Reference in New Issue
Block a user