SKY-9933 Avoid repeated credential clarification loop (#6027)
Some checks failed
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
zizmor / Audit GitHub Actions (push) Has been cancelled

This commit is contained in:
Andrew Neilson 2026-05-17 12:52:04 -07:00 committed by GitHub
parent 1b2e2da34c
commit eec704c4fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 129 additions and 29 deletions

View file

@ -173,7 +173,7 @@ This turns email-based 2FA into something nearly as automated as an authenticato
Instead of pushing codes to Skyvern, you can have Skyvern pull them from your server. Pass `totp_url` when running a [task](/cloud/getting-started/run-from-code) or [workflow](/cloud/building-workflows/build-a-workflow), and Skyvern will call your endpoint when it needs a verification code.
Your endpoint must accept POST requests from Skyvern. For workflow runs, use the `workflow_run_id` in the signed request body to find the matching code, ignore any additional metadata fields, then return:
Your endpoint must accept a POST request with `task_id`, `workflow_run_id`, and `workflow_permanent_id` in the body, and return:
```json
{

View file

@ -239,30 +239,28 @@ Use a virtual phone number service like [Twilio](https://www.twilio.com/en-us/do
</Accordion>
</AccordionGroup>
### Step 2: Start the workflow run with a TOTP identifier
### Step 2: Start the run with a TOTP identifier
When running the workflow that performs the login, include a `totp_identifier` that matches what you'll use when pushing codes:
When running your login, include a `totp_identifier` that matches what you'll use when pushing codes:
<CodeGroup>
```python Python
result = await client.run_workflow(
workflow_id="wpid_abc123",
parameters={
"login_credential": "cred_xyz789",
},
result = await client.login(
credential_type="skyvern",
url="https://portal.example.com/login",
credential_id="cred_xyz789",
totp_identifier="user@example.com", # Must match when pushing code
)
```
```bash cURL
curl -X POST "https://api.skyvern.com/v1/run/workflows" \
curl -X POST "https://api.skyvern.com/v1/run/tasks/login" \
-H "x-api-key: $SKYVERN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"workflow_id": "wpid_abc123",
"parameters": {
"login_credential": "cred_xyz789"
},
"credential_type": "skyvern",
"url": "https://portal.example.com/login",
"credential_id": "cred_xyz789",
"totp_identifier": "user@example.com"
}'
```
@ -316,7 +314,7 @@ Instead of pushing codes to Skyvern, you implement an endpoint that Skyvern poll
### Step 1: Implement the endpoint
Your endpoint must accept POST requests and return the verification code. For workflow runs, use the `workflow_run_id` in the request body to find the matching code in your system, and ignore any additional metadata fields Skyvern includes:
Your endpoint must accept POST requests and return the verification code:
**Request from Skyvern:**
```http
@ -325,7 +323,7 @@ x-skyvern-signature: <hmac-sha256-signature>
Content-Type: application/json
{
"workflow_run_id": "wr_123456"
"task_id": "tsk_123456"
}
```
@ -357,30 +355,28 @@ def verify_skyvern_request(request, api_key: str) -> bool:
return hmac.compare_digest(signature, expected)
```
### Step 3: Configure the workflow run
### Step 3: Configure the login
Pass `totp_url` when running the workflow that performs the login:
Pass `totp_url` when running your login:
<CodeGroup>
```python Python
result = await client.run_workflow(
workflow_id="wpid_abc123",
parameters={
"login_credential": "cred_xyz789",
},
result = await client.login(
credential_type="skyvern",
url="https://portal.example.com/login",
credential_id="cred_xyz789",
totp_url="https://your-server.com/totp-webhook",
)
```
```bash cURL
curl -X POST "https://api.skyvern.com/v1/run/workflows" \
curl -X POST "https://api.skyvern.com/v1/run/tasks/login" \
-H "x-api-key: $SKYVERN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"workflow_id": "wpid_abc123",
"parameters": {
"login_credential": "cred_xyz789"
},
"credential_type": "skyvern",
"url": "https://portal.example.com/login",
"credential_id": "cred_xyz789",
"totp_url": "https://your-server.com/totp-webhook"
}'
```

View file

@ -144,10 +144,11 @@ curl -X GET "https://api.skyvern.com/v1/credentials/totp?totp_identifier=user@ex
-H "x-api-key: $SKYVERN_API_KEY"
```
4. **For pull-based 2FA**: Check your endpoint uses the incoming `workflow_run_id` to find the code and returns the correct format:
4. **For pull-based 2FA**: Check your endpoint returns the correct format:
```json
{
"task_id": "tsk_123456",
"verification_code": "123456"
}
```

View file

@ -498,6 +498,15 @@ def _previous_credential_clarification_was_asked(global_llm_context: str) -> boo
)
def _last_assistant_message_was_saved_credential_question(
chat_history: list[WorkflowCopilotChatHistoryMessage],
) -> bool:
for message in reversed(chat_history):
if message.sender == WorkflowCopilotChatSender.AI:
return _SAVED_CREDENTIAL_NAME_QUESTION in message.content
return False
def _can_defer_unresolved_credential_name_for_draft(
policy: RequestPolicy,
*,
@ -512,6 +521,19 @@ def _can_defer_unresolved_credential_name_for_draft(
return False
def _should_defer_repeated_unresolved_credential_question(
policy: RequestPolicy,
*,
chat_history: list[WorkflowCopilotChatHistoryMessage],
) -> bool:
return (
policy.credential_input_kind in ("none", "credential_name")
and policy.clarification_reason == "credential_name_unresolved"
and not _has_resolvable_credential_scope(policy)
and _last_assistant_message_was_saved_credential_question(chat_history)
)
async def _resolve_credentials(policy: RequestPolicy, organization_id: str) -> None:
if policy.credential_input_kind == "credential_id":
ids = _clean_list([ref for ref in policy.credential_refs if ref.startswith("cred_")])
@ -534,7 +556,7 @@ async def _resolve_credentials(policy: RequestPolicy, organization_id: str) -> N
return
if policy.credential_input_kind == "credential_name" and not policy.credential_refs:
if policy.testing_intent == "skip_test" and policy.allow_missing_credentials_in_draft:
if policy.allow_missing_credentials_in_draft:
policy.allow_run_blocks = False
return
_block(
@ -734,6 +756,14 @@ async def build_request_policy(
policy.requires_user_clarification = False
policy.allow_missing_credentials_in_draft = True
if _should_defer_repeated_unresolved_credential_question(
policy,
chat_history=chat_history,
):
policy.requires_user_clarification = False
policy.allow_run_blocks = False
policy.allow_missing_credentials_in_draft = True
if policy.raw_secret_detected:
_block(
policy,

View file

@ -1384,6 +1384,10 @@ class TestRequestPolicyCredentialResolution:
prior_clarification_context = (
'{"decisions_made":["request-policy clarification required: credential_name/credential_name_unresolved"]}'
)
saved_credential_question = (
"Which saved credential should I use? "
"Please provide the exact credential name or a credential ID beginning with cred_."
)
history_refs_from_context = await build_request_policy(
user_message="Just draft a workflow without testing it.",
workflow_yaml="",
@ -1427,6 +1431,75 @@ class TestRequestPolicyCredentialResolution:
assert not follow_up_missing_name_policy.allow_update_workflow
assert not follow_up_missing_name_policy.allow_run_blocks
handler.response = {
"testing_intent": "require_test",
"credential_input_kind": "none",
"requires_user_clarification": True,
"clarification_reason": "credential_name_unresolved",
}
prior_clarification_follow_up_policy = await build_request_policy(
user_message="let me help logging in",
workflow_yaml="",
chat_history=_history(
("user", "log in via eduID"),
("ai", saved_credential_question),
),
global_llm_context=prior_clarification_context,
organization_id="org-1",
handler=handler,
)
assert prior_clarification_follow_up_policy.user_response_policy == "proceed"
assert prior_clarification_follow_up_policy.allow_update_workflow
assert not prior_clarification_follow_up_policy.allow_run_blocks
assert prior_clarification_follow_up_policy.allow_missing_credentials_in_draft
assert prior_clarification_follow_up_policy.clarification_question is None
handler.response = {
"testing_intent": "require_test",
"credential_input_kind": "credential_name",
"requires_user_clarification": True,
"clarification_reason": "credential_name_unresolved",
}
prior_clarification_name_policy = await build_request_policy(
user_message="let me help logging in",
workflow_yaml="",
chat_history=_history(
("user", "log in via eduID"),
("ai", saved_credential_question),
),
global_llm_context=prior_clarification_context,
organization_id="org-1",
handler=handler,
)
assert prior_clarification_name_policy.user_response_policy == "proceed"
assert prior_clarification_name_policy.allow_update_workflow
assert not prior_clarification_name_policy.allow_run_blocks
assert prior_clarification_name_policy.allow_missing_credentials_in_draft
assert prior_clarification_name_policy.clarification_question is None
handler.response = {
"testing_intent": "require_test",
"credential_input_kind": "none",
"requires_user_clarification": True,
"clarification_reason": "credential_name_unresolved",
}
stale_clarification_policy = await build_request_policy(
user_message="log into this other site",
workflow_yaml="",
chat_history=_history(
("user", "log in via eduID"),
("ai", saved_credential_question),
("user", "never mind"),
("ai", "Which page or URL should the workflow go to?"),
),
global_llm_context=prior_clarification_context,
organization_id="org-1",
handler=handler,
)
assert stale_clarification_policy.user_response_policy == "ask_clarification"
assert not stale_clarification_policy.allow_update_workflow
assert not stale_clarification_policy.allow_run_blocks
handler.response = {
"testing_intent": "skip_test",
"credential_input_kind": "credential_name",