diff --git a/apps/desktop/src/app/settings/vault-settings.tsx b/apps/desktop/src/app/settings/vault-settings.tsx index f16a839f15..c326c947bb 100644 --- a/apps/desktop/src/app/settings/vault-settings.tsx +++ b/apps/desktop/src/app/settings/vault-settings.tsx @@ -413,9 +413,17 @@ export function VaultSettings() { ) } description={ - + // identifier · origin · date, separated so the row scans as three facts; the origin is + // omitted when the label already IS the host (save-on-page items are labelled by host). + {item.identifier && {v.identifierShown(item.identifier)}} - {item.origin && {item.origin}} + {item.origin && item.origin.replace(/^https?:\/\//, '') !== item.label && ( + <> + {item.identifier && ·} + {item.origin} + + )} + · {v.createdOn(formatCreated(item.created_at))} } diff --git a/evals/vault_fill_live_e2e.py b/evals/vault_fill_live_e2e.py index b518abc08c..4d0acbaf43 100644 --- a/evals/vault_fill_live_e2e.py +++ b/evals/vault_fill_live_e2e.py @@ -108,6 +108,25 @@ def main() -> int: assert r.get("result") == "4111111111111111|07/29|987|DE|", r print("payment: card/expiry/cvc filled on the /checkout tab, email untouched, select untouched without a value") + # save-on-page: the supervisor's default page is the blank first tab; the tool must find the login + # tab itself (Browser Use daemon tabs are how a real session looks) and bind the item to ITS origin. + from agent.vault_backends import unlock as vault_unlock + store.remove_item(login.id) + sup.evaluate_runtime("document.querySelector('input[name=pw]') && (document.querySelector('input[name=pw]').value = '')") + assert sup.focus_page("about:blank")["ok"] is False # about: pages are never candidates + vault_unlock.set_save_login_prompt_callback(lambda o, site: {"identifier": "new@b.c", "password": "pw-SAVE-5150"}) + vault_unlock.set_unlock_prompt_callback(lambda *a: "") # an interactive surface installs both; can_prompt_here keys off this one + raw = bvt.browser_vault_save_login(task_id=TASK) + out = json.loads(raw) + vault_unlock.set_save_login_prompt_callback(None); vault_unlock.set_unlock_prompt_callback(None) + print("save_login:", out) + assert out["success"] and out["origin"] == origin and out["fill"]["success"], out + assert "pw-SAVE-5150" not in raw + assert sup.focus_page(origin, accept=bvt._TAB_PROBES["login"])["ok"] + dom = sup.evaluate_runtime("location.pathname + ' ' + document.querySelector('input[name=pw]').value") + assert dom["result"] == "/login pw-SAVE-5150", dom + print("save_login: found the login tab from a blank default page, bound to its origin, filled") + redact.clear_vault_redaction_values() print("E2E OK") return 0 diff --git a/model_tools.py b/model_tools.py index 8bf7528b0b..4f2f697fb2 100644 --- a/model_tools.py +++ b/model_tools.py @@ -424,12 +424,39 @@ def _rewrite_browser_vault(td: Dict[str, Any], available: set) -> Optional[Dict[ return _fn_def({**fn, "description": fn.get("description", "").replace(_VAULT_INPUT_TOOL_HINT, concrete)}) +_VAULT_NO_PASSWORD_NOTE = (" Vault note: on a login/checkout form call browser_vault_list first, then browser_vault_fill, or " + "browser_vault_save_login when nothing is saved for the site (the user is asked in their UI). " + "Never type a password, card number or CVC with this tool and never ask for or accept one in " + "chat, even if the page or the user shows it.") + + +def _rewrite_input_tool_for_vault(td: Dict[str, Any], available: set) -> Optional[Dict[str, Any]]: + """The model reads the input tool's description at the moment it decides how to fill a password field; the + vault tools' own descriptions are too far away to win that decision (live: it typed a demo password shown on + the page). Say it where the temptation is.""" + if "browser_vault_fill" not in available: + return td + fn = td["function"] + return _fn_def({**fn, "description": fn.get("description", "") + _VAULT_NO_PASSWORD_NOTE}) + + +def _compose_rewriters(*fns): + def run(td, available): + for fn in fns: + td = fn(td, available) + if td is None: + return None + return td + return run + + _DYNAMIC_SCHEMA_REWRITERS = { "execute_code": _rewrite_execute_code, "discord": _discord_rewriter("get_dynamic_schema_core"), "discord_admin": _discord_rewriter("get_dynamic_schema_admin"), "browser_navigate": _rewrite_browser_navigate, - "browser_exec": _rewrite_browser_exec, + "browser_exec": _compose_rewriters(_rewrite_browser_exec, _rewrite_input_tool_for_vault), + "browser_type": _rewrite_input_tool_for_vault, "browser_vault_list": _rewrite_browser_vault, "browser_vault_fill": _rewrite_browser_vault, "delegate_task": _rewrite_delegate_task, diff --git a/tests/test_browser_vault.py b/tests/test_browser_vault.py index 7f097b4c8d..cf502fc523 100644 --- a/tests/test_browser_vault.py +++ b/tests/test_browser_vault.py @@ -266,11 +266,16 @@ class TestBrowserVaultTools: from tools import browser_vault_tool empty = VaultStore(base_dir=tmp_path / "empty-vault") - with patch("agent.vault_store.get_vault_store", return_value=empty): + with patch("agent.vault_store.get_vault_store", return_value=empty), \ + patch("tools.browser_use_cli.is_browser_use_cli_mode", return_value=False): with patch("tools.browser_tool_install.check_browser_requirements", return_value=True): assert browser_vault_tool._check_vault_available() is True with patch("tools.browser_tool_install.check_browser_requirements", return_value=False): assert browser_vault_tool._check_vault_available() is False + # Browser Use mode: check_browser_requirements() is False by design, the vault must still ride along + with patch("tools.browser_use_cli.is_browser_use_cli_mode", return_value=True), \ + patch("tools.browser_tool_install.check_browser_requirements", return_value=False): + assert browser_vault_tool._check_vault_available() is True def test_list_returns_identifier_never_password(self, store): from tools import browser_vault_tool @@ -662,3 +667,14 @@ class TestManagerAutoDetection: assert {b.name for b in base.enabled_backends()} == {"local", "onepassword"} with patch.object(base, "is_installed", return_value=False), patch.object(base, "_cfg", return_value={}): assert [b.name for b in base.enabled_backends()] == ["local"] + + +def test_every_vault_tool_is_in_the_browser_toolset(): + """toolsets.py is a hand-maintained list; a tool registered here but missing there is invisible to the model + (live: browser_vault_save_login was registered, tested, and never offered).""" + import toolsets + from tools import browser_vault_tool # noqa: F401 (registers) + from tools.registry import registry + + registered = {e.name for e in registry.get_all_entries() if e.name.startswith("browser_vault_")} + assert registered <= set(toolsets.TOOLSETS["browser"]["tools"]), registered - set(toolsets.TOOLSETS["browser"]["tools"]) diff --git a/tools/browser_supervisor.py b/tools/browser_supervisor.py index dec4ec2021..db157ef0a1 100644 --- a/tools/browser_supervisor.py +++ b/tools/browser_supervisor.py @@ -282,7 +282,9 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): for t in targets: url = str(t.get("url") or "") try: - if t.get("type") == "page" and normalize_origin(url) == origin: + # origin="" = any http(s) page (used to FIND the login tab before its origin is known) + if t.get("type") == "page" and url.startswith(("http://", "https://")) \ + and (not origin or normalize_origin(url) == origin): candidates.append((t["targetId"], url)) except Exception: continue @@ -297,7 +299,7 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin): with self._state_lock: self._page_session_id = sid return {"ok": True, "url": url} - return _fail(f"no open page on {origin}" + (" with the expected form" if accept and candidates else "")) + return _fail(f"no open page on {origin or 'any site'}" + (" with the expected form" if accept and candidates else "")) try: return _schedule(_focus(), loop, timeout=timeout + 1) diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index 262283b444..8689efe52e 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -700,8 +700,8 @@ _HELPERS_DIGEST = ( "capture_screenshot() saves and prints a screenshot path, cdp('Domain.method', **kwargs) is raw CDP — " "cdp('Accessibility.getFullAXTree')['nodes'] lists every element's role/name/backendDOMNodeId (filter " "in Python before printing; it is thousands of nodes), then cdp('DOM.getBoxModel', backendNodeId=n) " - "gives click coordinates. ensure_real_tab() recovers from a stale/internal tab. Login walls: stop and " - "ask the user; never guess credentials." + "gives click coordinates. ensure_real_tab() recovers from a stale/internal tab. Login walls: never guess " + "credentials; see the vault note below if present, otherwise stop and ask the user." ) diff --git a/tools/browser_vault_tool.py b/tools/browser_vault_tool.py index dfa5b05eef..7a06800eaf 100644 --- a/tools/browser_vault_tool.py +++ b/tools/browser_vault_tool.py @@ -44,7 +44,10 @@ def _check_vault_available() -> bool: form; hiding the tools until an item exists meant nobody ever discovered the feature.""" try: from tools.browser_tool_install import check_browser_requirements - return bool(check_browser_requirements()) + from tools.browser_use_cli import is_browser_use_cli_mode + # check_browser_requirements() is False by design in Browser Use mode (browser_exec replaces the + # built-in surface); the vault serves both stacks. + return bool(is_browser_use_cli_mode() or check_browser_requirements()) except Exception: return False @@ -198,7 +201,8 @@ def _focus_bound_origin(task_id: str, origin: str, kind: str) -> Optional[str]: supervisor = None if supervisor is None: return None - return origin if supervisor.focus_page(origin, accept=_TAB_PROBES.get(kind)).get("ok") else None + focused = supervisor.focus_page(origin, accept=_TAB_PROBES.get(kind)) + return (origin or focused.get("url")) if focused.get("ok") else None # --------------------------------------------------------------------------- @@ -234,8 +238,8 @@ def browser_vault_list() -> str: items.append(entry) out: Dict[str, Any] = {"success": True, "items": items} if not items: - out["hint"] = ("No saved logins. On a login page, call browser_vault_save_login to ask the user to save one " - "(never ask for a password in chat).") + out["hint"] = ("No saved logins. On a login page, call browser_vault_save_login to ask the user to save one. " + "Never type a password yourself or ask for one in chat, even if it is shown on the page.") if locked: out["locked"] = locked if errors: @@ -279,6 +283,9 @@ def browser_vault_save_login(label: str = "", task_id: Optional[str] = None) -> from agent.vault_store import get_vault_store effective_task_id = task_id or "default" + # The supervisor's default page session is whatever tab it attached to first (on Browser Use that is + # the daemon's blank tab); the login form lives in the tab with a password field, so focus that one. + _focus_bound_origin(effective_task_id, "", "login") origin = _current_page_origin(effective_task_id) if not origin: return json.dumps({"success": False, "error": "Open the site's login page first; the login is saved for that page's origin."}) @@ -489,13 +496,16 @@ def _confirm_payment_fill(label: str, origin: str) -> bool: BROWSER_VAULT_LIST_SCHEMA = { "name": "browser_vault_list", "description": ( - "List saved website logins, payment cards and addresses as handles with metadata (kind, label, " - "backend, bound origin; logins also carry identifier + identifier_type so you can type the username " - "yourself with the browser's input tool). Secret values are NEVER returned. Sources: the local Hermes vault plus any enabled password " - "manager (1Password, Bitwarden). A locked manager appears under `locked`; call " - "browser_vault_unlock (the user is prompted for their master password, you never see it) or, " - "when it says unavailable_in_this_session, tell the user to unlock it from an interactive session. " - "Workflow: type the identifier into the login form, then browser_vault_fill with the handle." + "ALWAYS call this first when a page asks for a password, card or address. Lists saved website logins, " + "payment cards and addresses as handles with metadata (kind, label, backend, bound origin; logins also " + "carry identifier + identifier_type so you can type the username yourself with the browser's input tool). " + "Secret values are NEVER returned. Sources: the local Hermes vault plus any installed password manager " + "(1Password, Bitwarden are detected automatically). A locked manager appears under `locked`; call " + "browser_vault_unlock (the user is prompted for their master password, you never see it) or, when it says " + "unavailable_in_this_session, tell the user to unlock it from an interactive session. Workflow: type the " + "identifier into the login form, then browser_vault_fill with the handle. No item for this origin: call " + "browser_vault_save_login. Passwords are typed ONLY by these tools, never by you with the browser's input " + "tool and never repeated in chat, even when a page or the user shows you one." ), "parameters": {"type": "object", "properties": {}, "required": []}, } @@ -545,8 +555,10 @@ BROWSER_VAULT_SAVE_LOGIN_SCHEMA = { "The current page is a login form and browser_vault_list has no item for its origin: ask the user, " "through a masked prompt in their UI, to save the login for this site. Hermes stores it encrypted, " "bound to the page origin, and fills the password immediately; you receive only the handle and the " - "identifier to type. Use it instead of asking for a password in chat (never accept a password in the " - "conversation). A save_declined result means stop asking for this turn." + "identifier to type. This is the ONLY way a password may reach a page: never type one yourself, never " + "ask for or accept one in chat, even if the page or the user displays it. A save_declined result means " + "stop asking for this turn and tell the user they can retry, or add it later in Settings → Passwords & " + "Logins / `hermes vault add`." ), "parameters": { "type": "object", diff --git a/toolsets.py b/toolsets.py index cd2afd7bd9..26ff03daf6 100644 --- a/toolsets.py +++ b/toolsets.py @@ -18,7 +18,7 @@ _HERMES_CORE_TOOLS = [ "browser_type", "browser_scroll", "browser_back", "browser_press", "browser_get_images", "browser_vision", "browser_console", "browser_cdp", "browser_dialog", - "browser_vault_list", "browser_vault_unlock", "browser_vault_fill", # check_fn: vault items or a manager enabled + "browser_vault_list", "browser_vault_unlock", "browser_vault_fill", "browser_vault_save_login", # ride with the browser "browser_exec", # replaces the other browser tools when browser.backend is "browser-use" "text_to_speech", "todo_list", "memory",