Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/notte-browser/src/notte_browser/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
from notte_core.data.space import DataSpace, ImageData, StructuredData, TBaseModel
from notte_core.errors.actions import InvalidActionError
from notte_core.errors.base import NotteBaseError
from notte_core.errors.processing import CredentialFieldValidationError
from notte_core.errors.provider import RateLimitError
from notte_core.profiling import profiler
from notte_core.space import ActionSpace
Expand Down Expand Up @@ -545,6 +546,10 @@ async def _action_with_vault(self, action: BaseAction) -> BaseAction:
outerHTML=await locator.evaluate("el => el.outerHTML"),
)
return await self.vault.replace_credentials(action, attrs, snapshot)
except CredentialFieldValidationError:
# Sentinel placeholder used on an invalid target element — surface loudly instead of
# silently typing the literal placeholder string into the field.
raise
except ValueError as e:
# Credential field not found in vault (e.g., vault has email but action needs username)
# Return original action - it will fail at execution with a clearer error
Expand Down
9 changes: 7 additions & 2 deletions packages/notte-core/src/notte_core/credentials/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from notte_core.common.types import TResponseFormat
from notte_core.credentials.types import ValueWithPlaceholder, get_str_value
from notte_core.errors.actions import NoCredentialsFoundError
from notte_core.errors.processing import InvalidPlaceholderError
from notte_core.errors.processing import CredentialFieldValidationError, InvalidPlaceholderError
from notte_core.profiling import profiler
from notte_core.utils.url import get_root_domain

Expand Down Expand Up @@ -692,7 +692,12 @@ async def replace_credentials(
if cred_class is MFAField and isinstance(action, FillAction):
action = MultiFactorFillAction(id=action.id, value=action.value)
else:
logger.trace(f"Could not validate element with attrs {attrs} for {cred_key}")
# The caller passed a known sentinel placeholder, so they clearly intended a
# credential substitution — but the targeted element doesn't satisfy the field's
# validation (e.g. a password placeholder pointed at a <label> instead of the
# <input type="password">). Raising here prevents the literal sentinel string
# from being silently typed into the field.
raise CredentialFieldValidationError(placeholder_value, cred_key, attrs)
else:
# dont validate because element chosen by regex
assert isinstance(action.value, dict)
Expand Down
25 changes: 25 additions & 0 deletions packages/notte-core/src/notte_core/errors/processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,31 @@ def __init__(self, placeholder: str) -> None:
)


class CredentialFieldValidationError(NotteBaseError):
"""Raised when a sentinel placeholder is used to fill an element that fails the credential
field's element-validation check (e.g. a password placeholder targeted at a non-password
element such as a <label>). Without this error the substitution would silently no-op and the
literal placeholder string would be typed into the field."""

def __init__(self, placeholder: str, cred_key: str, attrs: object) -> None:
dev_message = (
f"Sentinel placeholder {placeholder!r} for credential field {cred_key!r} was supplied, "
f"but the targeted element failed validate_element. Element attrs: {attrs!r}. "
f"Common cause: the action targeted a wrapper (e.g. <label>) instead of the actual "
f"<input>. Re-target the input element directly."
)
agent_message = (
f"Could not fill credential {cred_key!r}: targeted element is not a valid {cred_key!r} input. "
f"Re-locate the actual input field and retry."
)
user_message = "Credential placeholder used on an element that is not a valid target for that field."
super().__init__(
agent_message=agent_message,
user_message=user_message,
dev_message=dev_message,
)


class ScrapeFailedError(NotteBaseError):
def __init__(self, error_message: str) -> None:
super().__init__(
Expand Down
78 changes: 77 additions & 1 deletion tests/test_session_vault_helpers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import asyncio

import pytest
from notte_browser.session import NotteSession
from notte_core.actions import FormFillAction
from notte_core.actions import FillAction, FormFillAction
from notte_core.credentials import EMAIL, PASSWORD, USERNAME
from notte_core.credentials.base import LocatorAttributes
from notte_core.credentials.types import ValueWithPlaceholder
from notte_core.errors.processing import CredentialFieldValidationError

from tests.mock.mock_vault import MockVault
from tests.mock.snapshot_factory import make_snapshot
Expand Down Expand Up @@ -112,3 +115,76 @@ def test_session_vault_action_without_placeholders_passes_through() -> None:
result = asyncio.run(session._action_with_vault(action))
# Should pass through unchanged since no placeholders
assert result is action


def test_password_placeholder_on_invalid_element_raises() -> None:
"""A password sentinel typed into a non-password element (e.g. a <label>) must raise instead
of silently no-op'ing. Otherwise the literal placeholder string is typed into the field."""
vault = MockVault({"https://example.com": {"username": "real_user", "password": "s3cr3t"}})
snapshot = make_snapshot("https://example.com")

# Mimics the locator attrs you get when targeting a <label> instead of <input type="password">:
# the type attribute is None because <label> has no type.
label_attrs = LocatorAttributes(type=None, autocomplete=None, outerHTML="<label>Password</label>")
action = FillAction(id="M2", value=PASSWORD)

with pytest.raises(CredentialFieldValidationError) as exc_info:
asyncio.run(vault.replace_credentials(action, label_attrs, snapshot))

# Error message should name the credential and hint at the wrong-element cause
assert "password" in exc_info.value.dev_message
assert PASSWORD in exc_info.value.dev_message

# And the action value must not have been mutated to a ValueWithPlaceholder — caller decides
# whether to recover, but the literal placeholder must not silently leak into typing.
assert action.value == PASSWORD


def test_password_placeholder_on_password_input_substitutes() -> None:
"""Happy path: when the targeted element really is type=password, substitution proceeds."""
vault = MockVault({"https://example.com": {"username": "real_user", "password": "s3cr3t"}})
snapshot = make_snapshot("https://example.com")

input_attrs = LocatorAttributes(
type="password", autocomplete="current-password", outerHTML='<input type="password">'
)
action = FillAction(id="I2", value=PASSWORD)
updated = asyncio.run(vault.replace_credentials(action, input_attrs, snapshot))

assert isinstance(updated.value, ValueWithPlaceholder)
assert updated.value.get_secret_value() == "s3cr3t"


def test_username_placeholder_unaffected_by_validation_change() -> None:
"""UserNameField.validate_element returns True unconditionally, so username substitution
still works even when targeting a non-input wrapper. This pins the asymmetric design that
only PasswordField (and other typed fields) gates on element type."""
vault = MockVault({"https://example.com": {"username": "real_user", "password": "s3cr3t"}})
snapshot = make_snapshot("https://example.com")

label_attrs = LocatorAttributes(type=None, autocomplete=None, outerHTML="<label>Username</label>")
action = FillAction(id="M1", value=USERNAME)
updated = asyncio.run(vault.replace_credentials(action, label_attrs, snapshot))

assert isinstance(updated.value, ValueWithPlaceholder)
assert updated.value.get_secret_value() == "real_user"


def test_session_action_with_vault_propagates_validation_error() -> None:
"""The session-level catch must not swallow CredentialFieldValidationError — otherwise the
placeholder leaks into the typed value despite the new raise."""
vault = MockVault({"https://example.com": {"username": "u", "password": "p"}})
session = NotteSession(vault=vault)
session.snapshot = make_snapshot("https://example.com")

# Force the validation-failing path by stubbing locate() to return None: replace_credentials
# then runs against the default-empty LocatorAttributes (type=None), which fails the
# PasswordField check.
async def _no_locator(_action):
return None

session.locate = _no_locator # type: ignore[method-assign]

action = FillAction(id="M2", value=PASSWORD)
with pytest.raises(CredentialFieldValidationError):
asyncio.run(session._action_with_vault(action))
Loading