Files
hermes-agent/tests/test_browser_vault.py
T
Teknium 98d11c95f4 feat(vault): zero-setup UX — save a login on the page that needs it, managers auto-detected, one "Passwords & Logins" surface
Nobody should have to learn `hermes vault add` or find a toggle before "log into GitHub" works.

- browser_vault_save_login: when the agent reaches a sign-in page with no saved login it asks the user
  on THEIR surface (CLI two-step panel on the sudo modal: identifier shown, password masked; Desktop
  card with labelled Email/username + Password fields). The answer goes to the encrypted vault bound to
  the page origin and is filled at once; the model gets back only the handle and identifier. Declining
  returns save_declined; headless sessions get prompt_unavailable. Never a password in chat.
- Vault tools ride with the browser toolset (check_browser_requirements) instead of appearing only once
  the vault has items — an empty vault is exactly when save_login is needed. browser_vault_list hints
  at it when empty.
- 1Password / Bitwarden are login sources as soon as their CLI is installed; `vault.<name>.enabled`
  is opt-OUT only. Settings shows Detected/Locked/Unlocked/Off/Not detected with a switch only for
  installed managers; `hermes vault sources` reports detection, `--disable`/`--enable` flip the opt-out.
- Desktop nav/page renamed "Passwords & Logins"; empty state tells the user they do not need to add
  anything; all five locales updated. Docs rewritten from "how it works" to "say log into X".
- New per-thread SaveLoginPrompt callback (agent/vault_backends/unlock.py) installed beside the unlock
  prompt on every CLI site and the gateway bridge (vault.save_login.request/respond/expire), propagated
  to worker threads via tools.thread_context.

Live: CLI PTY (real model, packaged Chromium, local login server) — panel shown, identifier + masked
password typed, server received the correct password, password absent from terminal transcript and
from every file under HERMES_HOME outside vault/. Native Electron (headless, isolated HOME/HERMES_HOME,
own Vite + CDP port) — card shown, "Save & sign in", server received the password, Settings lists the
saved item, password absent from the rendered UI.
2026-09-10 10:35:07 -07:00

665 lines
32 KiB
Python

"""Tests for the vault-backed password-blind browser autofill feature.
Covers:
- VaultStore: encrypt/decrypt round-trip, file perms, identifier-as-metadata
(login secret payload is password-only)
- login-control classifier: scoring + new-password/one-time-code exclusion,
password-only fill selection
- origin-binding refusal (pre-check + in-script TOCTOU assert)
- fail-closed secret eval (no argv fallback)
- vault-value redaction registry (browser_cdp read-back regression)
- tool gating: check_fn False when the vault is empty
"""
from __future__ import annotations
import json
import os
import stat
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from agent.vault_login_classifier import ( # noqa: E402
ClassifiedLoginControl,
LoginControl,
build_fill_js,
classify_login_control,
select_password_fill,
)
from agent.vault_store import ( # noqa: E402
VaultError,
VaultStore,
normalize_origin,
scrub_secret_from_text,
)
@pytest.fixture()
def store(tmp_path):
return VaultStore(base_dir=tmp_path / "vault")
def _add_login(store, origin="https://example.com", password="s3cret-pw"):
return store.add_item(
kind="login",
label="Example login",
origin=origin,
secret={
"identifier_type": "email",
"identifier": "user@example.com",
"password": password,
"origin": origin,
},
)
# ---------------------------------------------------------------------------
# VaultStore
# ---------------------------------------------------------------------------
class TestVaultStore:
def test_roundtrip_encrypt_decrypt(self, store):
meta = _add_login(store)
secret = store.resolve_secret(meta.id)
# Design: login secret payload is password-only; identifier is metadata.
assert secret == {"password": "s3cret-pw"}
assert meta.identifier == "user@example.com"
assert meta.identifier_type == "email"
def test_vault_file_never_contains_password(self, store, tmp_path):
_add_login(store)
blob = (tmp_path / "vault" / "vault.json.enc").read_bytes()
assert b"s3cret-pw" not in blob
def test_file_permissions_0600(self, store, tmp_path):
_add_login(store)
for name in ("vault.json.enc", "vault.key"):
mode = stat.S_IMODE(os.stat(tmp_path / "vault" / name).st_mode)
assert mode == 0o600, f"{name} has mode {oct(mode)}"
def test_listing_is_password_free(self, store):
meta = _add_login(store)
items = store.list_items()
assert len(items) == 1
dumped = json.dumps(items[0].to_dict())
assert "s3cret-pw" not in dumped
assert "password" not in dumped
# Identifier IS visible metadata now.
assert items[0].identifier == "user@example.com"
assert items[0].identifier_type == "email"
assert items[0].id == meta.id
assert items[0].origin == "https://example.com"
def test_remove_item(self, store):
meta = _add_login(store)
assert store.remove_item(meta.id) is True
assert store.remove_item(meta.id) is False
assert store.list_items() == []
def test_login_requires_origin(self, store):
with pytest.raises(VaultError):
store.add_item(
kind="login",
label="x",
secret={
"identifier_type": "email",
"identifier": "a@b.c",
"password": "p",
},
)
def test_all_kinds_supported(self, store):
store.add_item(kind="payment", label="Card", origin="https://shop.test", secret=_CARD)
store.add_item(kind="address", label="Home", secret=_ADDRESS)
kinds = {m.kind for m in store.list_items()}
assert kinds == {"payment", "address"}
def test_checkout_kinds_keep_only_canonical_fields(self, store):
"""The fill maps canonical names → autocomplete tokens; a stray ad-hoc key would be stored (secret!)
yet unfillable, and a missing required field would make the item dead on every checkout."""
meta = store.add_item(kind="payment", label="Card", secret={**_CARD, "note": "personal"})
assert "note" not in store.resolve_secret(meta.id)
with pytest.raises(VaultError, match="cvc"):
store.add_item(kind="payment", label="Card", secret={k: v for k, v in _CARD.items() if k != "cvc"})
def test_unknown_kind_rejected(self, store):
with pytest.raises(VaultError):
store.add_item(kind="totp", label="x", secret={})
def test_has_items(self, store):
assert store.has_items() is False
_add_login(store)
assert store.has_items() is True
def test_normalize_origin(self):
assert normalize_origin("https://Example.com:443/login?x=1") == "https://example.com"
assert normalize_origin("http://localhost:8931/") == "http://localhost:8931"
assert normalize_origin("http://site.test:80") == "http://site.test"
with pytest.raises(VaultError):
normalize_origin("example.com")
def test_scrub_secret_from_text(self):
secret = {"password": "hunter22x", "identifier": "me@x.io"}
out = scrub_secret_from_text("boom hunter22x at me@x.io", secret)
assert "hunter22x" not in out
assert "me@x.io" not in out
# ---------------------------------------------------------------------------
# Classifier
# ---------------------------------------------------------------------------
_CARD = {"card_number": "4111111111111111", "cardholder_name": "A User", "exp_month": "7", "exp_year": "2029",
"cvc": "123", "billing_postal_code": "94110"}
_ADDRESS = {"address_line1": "1 Main St", "city": "Springfield", "postal_code": "12345", "country": "US"}
def _ctrl(**kw):
base = dict(autocomplete="", form_index=0, index=0, label="", name="", type="text")
base.update(kw)
return LoginControl(**base)
class TestClassifier:
def test_autocomplete_exact_match_scores_100(self):
for token in ("username", "email", "tel", "current-password"):
res = classify_login_control(_ctrl(autocomplete=token))
assert res is not None and res.score == 100 and res.token == token
def test_new_password_autocomplete_excluded(self):
assert classify_login_control(
_ctrl(autocomplete="new-password", type="password")
) is None
def test_one_time_code_excluded(self):
assert classify_login_control(_ctrl(autocomplete="one-time-code")) is None
def test_label_new_password_excluded(self):
for label in ("New password", "Confirm Password", "create-password", "Repeat password"):
assert classify_login_control(_ctrl(type="password", label=label)) is None, label
def test_password_type_scores_90(self):
res = classify_login_control(_ctrl(type="password"))
assert res.score == 90 and res.token == "current-password"
def test_email_tel_types_score_85(self):
assert classify_login_control(_ctrl(type="email")).score == 85
res = classify_login_control(_ctrl(type="tel"))
assert res.score == 85 and res.token == "tel"
def test_label_heuristics(self):
assert classify_login_control(_ctrl(label="E-mail address")).token == "email"
assert classify_login_control(_ctrl(name="mobile_number")).token == "tel"
res = classify_login_control(_ctrl(label="Username or account"))
assert res.token == "username" and res.score == 70
def test_unmatched_returns_none(self):
assert classify_login_control(_ctrl(label="Search the docs")) is None
def test_select_password_fill_picks_best_password(self):
user = ClassifiedLoginControl(_ctrl(index=0, form_index=0, autocomplete="username"), 100, "username")
pw_heur = ClassifiedLoginControl(_ctrl(index=1, form_index=0, type="password"), 90, "current-password")
pw_exact = ClassifiedLoginControl(_ctrl(index=3, form_index=0, autocomplete="current-password"), 100, "current-password")
fills = select_password_fill([user, pw_heur, pw_exact], "p")
# Password only — the identifier field is never filled by the vault.
assert [(f["index"], f["token"]) for f in fills] == [(3, "current-password")]
def test_select_password_fill_requires_password_field(self):
user = ClassifiedLoginControl(_ctrl(index=0, autocomplete="username"), 100, "username")
assert select_password_fill([user], "p") == []
def test_select_password_fill_single_field_only(self):
pw1 = ClassifiedLoginControl(_ctrl(index=1, type="password"), 90, "current-password")
pw2 = ClassifiedLoginControl(_ctrl(index=2, type="password"), 90, "current-password")
fills = select_password_fill([pw1, pw2], "p")
assert len(fills) == 1 and fills[0]["index"] == 1
def test_build_fill_js_contains_events(self):
js = build_fill_js(
[{"index": 0, "token": "current-password", "value": "x"}],
expected_origin="https://example.com",
)
assert "InputEvent" in js and '"change"' in js and "filled" in js
def test_build_fill_js_leaves_no_dom_marker_and_binds_target_to_inspection(self):
# P1-1: no persistent selector for filled controls. The fill targets the input by the
# <nonce>:<index> stamp ITS OWN inspection wrote (a bare index is re-resolved by position,
# and a second inspection in between would re-stamp — either way the password could land in
# another field); stamps carry no secret and the fill script strips every one before returning.
js = build_fill_js(
[{"index": 0, "token": "current-password", "value": "x"}],
expected_origin="https://example.com",
)
assert "vaultSecret" not in js
assert "data-vault-secret" not in js
assert "elements[f.index]" not in js
assert "[data-hermes-vault-slot=" in js and "nonce + ':' + f.index" in js
assert 'f.token === "current-password" && el.type !== "password"' in js # a password fill never lands in a text box
assert js.index('removeAttribute("data-hermes-vault-slot")') > js.index("setter.set.call")
def test_build_fill_js_asserts_origin_before_any_write(self):
# P1-2: the origin assert must run inside the SAME script, before
# any element write.
js = build_fill_js(
[{"index": 0, "token": "current-password", "value": "x"}],
expected_origin="https://example.com",
)
assert '"https://example.com"' in js
assert "window.location.origin" in js
assert "origin_changed" in js
assert js.index("origin_changed") < js.index("querySelectorAll")
# ---------------------------------------------------------------------------
# Browser tool: origin binding + gating
# ---------------------------------------------------------------------------
class TestBrowserVaultTools:
def test_check_fn_follows_the_browser_not_the_item_count(self, tmp_path):
"""The vault tools ride with the browser toolset: an empty vault must still expose
browser_vault_save_login (that is how the first login gets saved), and no browser means no tools."""
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("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
def test_list_returns_identifier_never_password(self, store):
from tools import browser_vault_tool
_add_login(store)
with patch("agent.vault_store.get_vault_store", return_value=store):
out = json.loads(browser_vault_tool.browser_vault_list())
assert out["success"] is True
assert out["items"][0]["handle"].startswith("vault_")
# Design change: identifier is agent-visible metadata.
assert out["items"][0]["identifier"] == "user@example.com"
assert out["items"][0]["identifier_type"] == "email"
assert "s3cret-pw" not in json.dumps(out)
def test_fill_refused_on_origin_mismatch(self, store):
from tools import browser_vault_tool
meta = _add_login(store, origin="https://example.com")
with patch("agent.vault_store.get_vault_store", return_value=store), \
patch.object(browser_vault_tool, "_current_page_origin", return_value="https://evil.com"):
out = json.loads(browser_vault_tool.browser_vault_fill(meta.id))
assert out["success"] is False
assert "Refused" in out["error"]
assert "s3cret-pw" not in json.dumps(out)
def test_fill_unknown_handle(self, store):
from tools import browser_vault_tool
with patch("agent.vault_store.get_vault_store", return_value=store):
out = json.loads(browser_vault_tool.browser_vault_fill("vault_nope"))
assert out["success"] is False
def test_fill_success_returns_counts_only(self, store):
from tools import browser_vault_tool
meta = _add_login(store, origin="https://example.com")
controls = [
{"autocomplete": "email", "formIndex": 0, "index": 0, "label": "", "name": "email", "type": "email"},
{"autocomplete": "current-password", "formIndex": 0, "index": 1, "label": "", "name": "pw", "type": "password"},
]
def fake_eval(task_id, expression):
if "location.href" in expression:
return {"success": True, "result": "https://example.com/login"}
return {"success": True, "result": json.dumps(controls)}
secret_exprs = []
def fake_eval_secret(task_id, expression):
secret_exprs.append(expression)
return {"success": True, "result": json.dumps({"filled": 1})}
with patch("agent.vault_store.get_vault_store", return_value=store), \
patch.object(browser_vault_tool, "_eval_js", side_effect=fake_eval), \
patch.object(browser_vault_tool, "_eval_js_secret", side_effect=fake_eval_secret):
raw = browser_vault_tool.browser_vault_fill(meta.id)
out = json.loads(raw)
# Password-only fill: exactly one field.
assert out == {
"success": True,
"filled_fields": 1,
"backend": "local",
"kind": "login",
"origin": "https://example.com",
}
assert "s3cret-pw" not in raw
# The secret expression only ever goes through the secret eval path,
# and it targets only the password field (index 1).
assert len(secret_exprs) == 1
assert "s3cret-pw" in secret_exprs[0]
assert '"index": 0' not in secret_exprs[0]
assert "user@example.com" not in secret_exprs[0]
def test_fill_toctou_navigation_writes_nothing(self, store):
"""P1-2 schedule regression: inspection passes on the allowed origin,
the page navigates before the fill script runs, the in-script origin
assert refuses, and zero credential bytes are written."""
from tools import browser_vault_tool
meta = _add_login(store, origin="https://example.com")
controls = [
{"autocomplete": "current-password", "formIndex": 0, "index": 0, "label": "", "name": "pw", "type": "password"},
]
def fake_eval(task_id, expression):
if "location.href" in expression:
# Pre-check sees the allowed origin.
return {"success": True, "result": "https://example.com/login"}
return {"success": True, "result": json.dumps(controls)}
def fake_eval_secret(task_id, expression):
# The evaluated script itself must carry the origin assert.
assert "window.location.origin" in expression
assert '"https://example.com"' in expression
# Simulate the page having navigated cross-origin by the time
# the fill script executes: the script's own assert fires.
return {
"success": True,
"result": json.dumps(
{"refused": "origin_changed", "found": "https://evil.com"}
),
}
with patch("agent.vault_store.get_vault_store", return_value=store), \
patch.object(browser_vault_tool, "_eval_js", side_effect=fake_eval), \
patch.object(browser_vault_tool, "_eval_js_secret", side_effect=fake_eval_secret):
raw = browser_vault_tool.browser_vault_fill(meta.id)
out = json.loads(raw)
assert out["success"] is False
assert out["error_type"] == "origin_changed"
assert out.get("filled_fields", 0) == 0
assert "s3cret-pw" not in raw
def test_secret_eval_fails_closed_without_supervisor(self, store):
"""P1-1: the secret-bearing eval NEVER falls back to the argv path."""
from tools import browser_vault_tool
meta = _add_login(store, origin="https://example.com")
controls = [
{"autocomplete": "current-password", "formIndex": 0, "index": 0, "label": "", "name": "pw", "type": "password"},
]
def fake_eval(task_id, expression):
if "location.href" in expression:
return {"success": True, "result": "https://example.com/login"}
return {"success": True, "result": json.dumps(controls)}
# No supervisor registered and the local daemon exposes no CDP endpoint → the fill refuses.
# The daemon may be asked for its endpoint (`get cdp-url`, no secret) but NEVER handed an
# `eval` carrying the password: that is the argv exposure this test pins.
with patch("agent.vault_store.get_vault_store", return_value=store), \
patch.object(browser_vault_tool, "_eval_js", side_effect=fake_eval), \
patch("tools.browser_supervisor.SUPERVISOR_REGISTRY") as reg, \
patch("tools.browser_tool_session._run_browser_command", return_value={"success": False}) as run_cmd:
reg.get.return_value = None
raw = browser_vault_tool.browser_vault_fill(meta.id)
out = json.loads(raw)
assert out["success"] is False
assert out["error_type"] == "supervisor_required"
assert "supervis" in out["error"].lower()
assert all(call.args[1] == "get" for call in run_cmd.call_args_list), run_cmd.call_args_list
assert "s3cret-pw" not in json.dumps([str(c) for c in run_cmd.call_args_list]) and "s3cret-pw" not in raw
def test_nonsecret_eval_fallback_still_works(self):
"""_eval_js (non-secret) may still fall back to the CLI eval path."""
from tools import browser_vault_tool
with patch("tools.browser_supervisor.SUPERVISOR_REGISTRY") as reg, \
patch("tools.browser_tool._last_session_key", return_value="k"), \
patch("tools.browser_tool_session._run_browser_command") as run_cmd:
reg.get.return_value = None
run_cmd.return_value = {"success": True, "data": {"result": "https://x.test"}}
res = browser_vault_tool._eval_js("t", "window.location.href")
assert res == {"success": True, "result": "https://x.test"}
run_cmd.assert_called_once()
def test_vault_canary_redacted_from_browser_cdp_results(self, store):
"""P1-1 regression: a filled, non-token-shaped canary password must be
unrecoverable through a model-facing browser_cdp-style result."""
from agent import redact
from agent.redact import redact_sensitive_text
from tools import browser_vault_tool
from tools.browser_cdp_tool import _redact_cdp_output
canary = "plain sentence nobody would flag 7"
meta = _add_login(store, origin="https://example.com", password=canary)
controls = [
{"autocomplete": "current-password", "formIndex": 0, "index": 0, "label": "", "name": "pw", "type": "password"},
]
def fake_eval(task_id, expression):
if "location.href" in expression:
return {"success": True, "result": "https://example.com/login"}
return {"success": True, "result": json.dumps(controls)}
def fake_eval_secret(task_id, expression):
return {"success": True, "result": json.dumps({"filled": 1})}
try:
with patch("agent.vault_store.get_vault_store", return_value=store), \
patch.object(browser_vault_tool, "_eval_js", side_effect=fake_eval), \
patch.object(browser_vault_tool, "_eval_js_secret", side_effect=fake_eval_secret):
out = json.loads(browser_vault_tool.browser_vault_fill(meta.id))
assert out["success"] is True
# Simulate a browser_cdp Runtime.evaluate sibling read echoing
# the canary back (e.g. reading the input's value from the DOM).
cdp_result = {
"result": {"type": "string", "value": canary},
"description": f"input value is {canary}",
}
scrubbed = _redact_cdp_output(cdp_result)
assert canary not in json.dumps(scrubbed)
assert "«redacted-vault-secret»" in json.dumps(scrubbed, ensure_ascii=False)
# And the generic browser-result scrub catches it too, even with
# user-level redaction preferences irrelevant (unconditional).
assert canary not in redact_sensitive_text(f"page text: {canary}")
finally:
redact.clear_vault_redaction_values()
def test_payment_fill_requires_confirmation_then_fills_card_fields(self, store):
"""A card is written only after the user confirms (a prompt injection reaching a checkout must not be
able to spend); the secret eval then targets the classified card controls and the result carries
the field tokens but never a value."""
from tools import browser_vault_tool
from agent import redact
meta = store.add_item(kind="payment", label="Visa", origin="https://shop.test", secret=_CARD)
controls = [
{"autocomplete": "cc-number", "index": 0, "type": "text"},
{"label": "Expiry (MM/YY)", "index": 1, "type": "text"},
{"label": "CVC", "index": 2, "type": "text"},
{"autocomplete": "email", "index": 3, "type": "email"},
]
def fake_eval(task_id, expression):
if "location.href" in expression:
return {"success": True, "result": "https://shop.test/checkout"}
return {"success": True, "result": json.dumps(controls)}
secret_exprs = []
def fake_eval_secret(task_id, expression):
secret_exprs.append(expression)
return {"success": True, "result": json.dumps({"filled": 3})}
try:
with patch("agent.vault_store.get_vault_store", return_value=store), \
patch.object(browser_vault_tool, "_eval_js", side_effect=fake_eval), \
patch.object(browser_vault_tool, "_eval_js_secret", side_effect=fake_eval_secret), \
patch("tools.approval_prompt.request_elicitation_consent", return_value="decline"):
declined = json.loads(browser_vault_tool.browser_vault_fill(meta.id))
assert declined["success"] is False and declined["error_type"] == "payment_declined"
assert secret_exprs == []
with patch("agent.vault_store.get_vault_store", return_value=store), \
patch.object(browser_vault_tool, "_eval_js", side_effect=fake_eval), \
patch.object(browser_vault_tool, "_eval_js_secret", side_effect=fake_eval_secret), \
patch("tools.approval_prompt.request_elicitation_consent", return_value="accept"):
raw = browser_vault_tool.browser_vault_fill(meta.id)
out = json.loads(raw)
assert out["success"] is True and out["fields"] == ["cc-csc", "cc-exp", "cc-number"]
assert _CARD["card_number"] not in raw and _CARD["cvc"] not in raw
assert len(secret_exprs) == 1 and _CARD["card_number"] in secret_exprs[0] and "07/29" in secret_exprs[0]
assert '"index": 3' not in secret_exprs[0] # the email box is never a card target
assert _CARD["card_number"] not in redact.redact_sensitive_text(f"dom says {_CARD['card_number']}")
finally:
redact.clear_vault_redaction_values()
class TestVaultHardening:
"""Read-deny, backup perms-tightening, canonical dir securing.
Mirrors the browser-profile snapshot hardening (f1d05c): the vault dir
holds key + ciphertext side by side, so it gets the same treatment.
"""
def test_read_block_vault_dir_and_contents(self, tmp_path, monkeypatch):
import agent.file_safety as fs
home = tmp_path / "hermes_home"
vault = home / "vault"
vault.mkdir(parents=True)
(vault / "vault.key").write_text("k", encoding="utf-8")
(vault / "vault.json.enc").write_text("blob", encoding="utf-8")
monkeypatch.setattr(fs, "_hermes_home_path", lambda: home)
for target in (vault, vault / "vault.key", vault / "vault.json.enc"):
err = fs.get_read_block_error(str(target))
assert err is not None, f"expected read deny for {target}"
assert "vault" in err.lower()
def test_read_block_leaves_sibling_dirs_alone(self, tmp_path, monkeypatch):
import agent.file_safety as fs
home = tmp_path / "hermes_home"
other = home / "vaults-notes"
other.mkdir(parents=True)
f = other / "notes.txt"
f.write_text("hi", encoding="utf-8")
monkeypatch.setattr(fs, "_hermes_home_path", lambda: home)
assert fs.get_read_block_error(str(f)) is None
def test_backup_secret_names_include_vault_files(self):
from hermes_cli.backup import _SECRET_FILE_NAMES
assert "vault.key" in _SECRET_FILE_NAMES
assert "vault.json.enc" in _SECRET_FILE_NAMES
def test_ensure_dir_uses_canonical_secure_dir(self, tmp_path, monkeypatch):
from unittest.mock import MagicMock
import hermes_cli.config as cfg
from agent.vault_store import VaultStore
called = MagicMock()
monkeypatch.setattr(cfg, "_secure_dir", called)
store = VaultStore(base_dir=tmp_path / "vault")
store._ensure_dir()
assert called.call_count == 1
class TestVaultSchemaCrossToolset:
def test_vault_schemas_name_the_input_tool_of_the_active_browser_stack(self):
"""The vault tools sit in `browser`; the tool that types the identifier lives in `browser-use`
(`fill_input` inside browser_exec) or is browser_type. A static name would be a ghost on one stack,
so model_tools resolves it per session from the tools actually present."""
import model_tools
from tools.browser_vault_tool import BROWSER_VAULT_FILL_SCHEMA
assert "fill_input" not in BROWSER_VAULT_FILL_SCHEMA["description"]
base = model_tools._fn_def(dict(BROWSER_VAULT_FILL_SCHEMA))
with_exec = model_tools._apply_dynamic_schemas([base, model_tools._fn_def({"name": "browser_exec", "description": "x"}),
model_tools._fn_def({"name": "terminal", "description": "x"})])
with_builtin = model_tools._apply_dynamic_schemas([base, model_tools._fn_def({"name": "browser_type", "description": "x"})])
desc_exec = with_exec[0]["function"]["description"]
desc_builtin = with_builtin[0]["function"]["description"]
assert "`fill_input` inside browser_exec" in desc_exec and "browser_type" not in desc_exec
assert "browser_type" in desc_builtin and "fill_input" not in desc_builtin
def test_every_registered_tool_schema_declares_openai_style_parameters():
"""The registry emits ``parameters`` (OpenAI function shape) and every provider adapter converts from
it; a schema that spells it ``input_schema`` (Anthropic shape) ships with NO parameters, so the model is
told the tool takes nothing and calls browser_vault_fill without a handle."""
import model_tools # noqa: F401 — triggers discovery
from tools.registry import registry
missing = [entry.name for entry in registry.get_all_entries()
if "parameters" not in entry.schema or "input_schema" in entry.schema]
assert not missing, missing
class TestSaveLoginPrompt:
"""browser_vault_save_login: the surface prompt supplies the login, the tool stores it bound to the page
origin and fills. The password must never come back in the tool result."""
def test_saves_to_page_origin_and_never_echoes_the_password(self, store, monkeypatch):
from agent.vault_backends import unlock as unlock_mod
from tools import browser_vault_tool
seen = {}
def prompt(origin, site):
seen["origin"], seen["site"] = origin, site
return {"identifier": "tek@acme.test", "password": "hunter2-very-secret"}
unlock_mod.set_save_login_prompt_callback(prompt)
monkeypatch.setattr(browser_vault_tool, "_current_page_origin", lambda task_id: "https://acme.test")
monkeypatch.setattr(browser_vault_tool, "browser_vault_fill",
lambda handle, task_id=None: json.dumps({"success": True, "filled_fields": 1}))
with patch("agent.vault_store.get_vault_store", return_value=store), \
patch("agent.vault_backends.unlock.can_prompt_here", return_value=True):
out = json.loads(browser_vault_tool.browser_vault_save_login(task_id="t1"))
unlock_mod.set_save_login_prompt_callback(None)
assert out["success"] is True and out["identifier"] == "tek@acme.test"
assert "hunter2" not in json.dumps(out)
assert seen == {"origin": "https://acme.test", "site": "acme.test"}
[meta] = store.list_items()
assert meta.origin == "https://acme.test" and meta.identifier == "tek@acme.test"
def test_declined_or_headless_stores_nothing(self, store, monkeypatch):
from agent.vault_backends import unlock as unlock_mod
from tools import browser_vault_tool
monkeypatch.setattr(browser_vault_tool, "_current_page_origin", lambda task_id: "https://acme.test")
with patch("agent.vault_store.get_vault_store", return_value=store):
unlock_mod.set_save_login_prompt_callback(lambda origin, site: None)
with patch("agent.vault_backends.unlock.can_prompt_here", return_value=True):
declined = json.loads(browser_vault_tool.browser_vault_save_login())
with patch("agent.vault_backends.unlock.can_prompt_here", return_value=False):
headless = json.loads(browser_vault_tool.browser_vault_save_login())
unlock_mod.set_save_login_prompt_callback(None)
assert declined["error_type"] == "save_declined"
assert headless["error_type"] == "prompt_unavailable"
assert store.list_items() == []
class TestManagerAutoDetection:
def test_installed_manager_is_a_source_without_config_and_config_can_opt_out(self):
from agent.vault_backends import base
with patch.object(base, "is_installed", return_value=True):
with patch.object(base, "_cfg", return_value={}):
assert {b.name for b in base.enabled_backends()} == {"local", "onepassword", "bitwarden"}
with patch.object(base, "_cfg", return_value={"bitwarden": {"enabled": False}}):
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"]