feat(vault): two-factor codes — automatic from a saved authenticator key, otherwise asked for in the user's UI
Follow-up to #106480. Sites that ask for a code after the password stopped the agent cold: the login classifier excludes one-time-code fields on purpose (a password must never land in an OTP box) and there was no tool for the second step, so the only move was to ask in chat. browser_vault_enter_code Fills the one-time code the current page asks for. Two sources, same invariant as passwords (the code goes to the page over the supervisor socket and never enters model context): - a TOTP seed on the login: local vault `otp_secret` (RFC 6238, stdlib, verified against the RFC test vectors), 1Password `op item get --otp`, Bitwarden `bw get totp`. Nobody is asked. - no seed: the surface prompts "Verification code for {site}"; the user types what their phone/email/app shows. Enter on empty / Skip declines and the tool returns code_declined ("do not ask again this turn"). no_code_field tells the model the site wants a passkey / hardware key / app approval: hand it to the user's device and wait for navigation. Per-digit OTP boxes (maxlength=1 pattern) get one digit each in DOM order. Surfaces CLI: sudo-style panel, code shown as typed (not a secret worth masking, typos must be visible), Enter submits, ESC/empty skips. Desktop: "Verification code for {site}" card via vault.code.request / vault.code.respond (gateway), owner-routed like the other vault prompts. Settings → Passwords & Logins: optional "Authenticator key" field on the add form (base32 or otpauth:// link); items with one show a "2FA auto" badge. `hermes vault add` asks for the same optional key. browser_vault_fill's result now says what to do next ("if the site asks for a verification code, call browser_vault_enter_code with this handle"). Six locales. Verified live (real model, local 2FA site that checks the TOTP; CLI PTY): A. login saved with authenticator key → signed in through 2FA, zero prompts, code/password absent from the transcript B. login without key → code panel → user types code → signed in C. panel dismissed → agent stops and explains, never asks in chat Unit: RFC 6238 vectors, seed normalisation, mint-without-asking, per-digit spread, decline, no-code-field; Desktop card test (owner routing, trim, Skip).
This commit is contained in:
@@ -232,6 +232,8 @@ def browser_vault_list() -> str:
|
||||
for meta in metas:
|
||||
entry = {"handle": meta.id, "backend": backend.name, "label": meta.label, "kind": meta.kind,
|
||||
"origin": meta.origin, "available": meta.kind == "login" or bool(meta.origin)}
|
||||
if meta.has_otp or backend.needs_unlock:
|
||||
entry["two_factor"] = "automatic" if meta.has_otp else "automatic if the manager stores a TOTP seed, else the user is asked"
|
||||
if meta.identifier:
|
||||
entry["identifier"] = meta.identifier
|
||||
entry["identifier_type"] = meta.identifier_type
|
||||
@@ -316,6 +318,75 @@ def browser_vault_save_login(label: str = "", task_id: Optional[str] = None) ->
|
||||
ensure_ascii=False)
|
||||
|
||||
|
||||
_TAB_PROBES["otp"] = ("!!document.querySelector('input[autocomplete=one-time-code], input[name*=otp i], input[name*=code i], "
|
||||
"input[id*=otp i], input[id*=code i], input[name*=totp i], input[aria-label*=code i]')")
|
||||
|
||||
|
||||
def browser_vault_enter_code(handle: str = "", task_id: Optional[str] = None) -> str:
|
||||
"""Second factor: fill the one-time code the CURRENT page asks for. If the saved login (``handle``) has an
|
||||
authenticator seed, the code is minted server-side and nobody is asked; otherwise the user is prompted on
|
||||
their surface for the code their phone/email/app shows. The code goes into the page over the supervisor
|
||||
socket and never enters the conversation."""
|
||||
from agent.redact import register_vault_redaction_value
|
||||
from agent.vault_backends import backend_for_handle
|
||||
from agent.vault_backends.unlock import can_prompt_here, get_code_prompt_callback
|
||||
from agent.vault_login_classifier import LoginControl, build_fill_js, build_inspection_js, build_otp_fills, classify_otp_controls
|
||||
|
||||
effective_task_id = task_id or "default"
|
||||
_focus_bound_origin(effective_task_id, "", "otp")
|
||||
origin = _current_page_origin(effective_task_id)
|
||||
if not origin:
|
||||
return json.dumps({"success": False, "error": "No page with a code field is open."})
|
||||
site = origin.split("://", 1)[-1]
|
||||
|
||||
nonce = secrets.token_hex(8)
|
||||
inspect = _eval_js(effective_task_id, build_inspection_js(nonce))
|
||||
raw_controls = _parse_json_result(inspect.get("result")) if inspect.get("success") else None
|
||||
if isinstance(raw_controls, str):
|
||||
raw_controls = _parse_json_result(raw_controls)
|
||||
otp_controls = classify_otp_controls([LoginControl.from_dict(r) for r in (raw_controls or []) if isinstance(r, dict)])
|
||||
if not otp_controls:
|
||||
return json.dumps({"success": False, "error_type": "no_code_field",
|
||||
"error": ("No one-time-code field on the current page. If the site wants a passkey, hardware key or "
|
||||
"an approval tap in an app, tell the user to complete it on their device and wait for the page to move on.")})
|
||||
|
||||
code: Optional[str] = None
|
||||
source = "user"
|
||||
backend = backend_for_handle(handle) if handle else None
|
||||
if backend is not None:
|
||||
try:
|
||||
code = backend.resolve_otp(handle)
|
||||
except Exception:
|
||||
code = None
|
||||
if code:
|
||||
source = backend.name
|
||||
if not code:
|
||||
prompt = get_code_prompt_callback()
|
||||
if prompt is None or not can_prompt_here():
|
||||
return json.dumps({"success": False, "error_type": "prompt_unavailable",
|
||||
"error": (f"{site} asks for a one-time code and this session cannot ask the user (headless/cron/API). "
|
||||
"Save an authenticator key for this login so codes can be generated automatically.")})
|
||||
code = (prompt(site, "") or "").strip().replace(" ", "").replace("-", "")
|
||||
if not code:
|
||||
return json.dumps({"success": False, "error_type": "code_declined",
|
||||
"error": "The user did not enter a code. Do not ask again this turn."})
|
||||
|
||||
register_vault_redaction_value(code)
|
||||
fills = build_otp_fills(otp_controls, code)
|
||||
result = _eval_js_secret(effective_task_id, build_fill_js(fills, expected_origin=origin, nonce=nonce))
|
||||
del code
|
||||
if not result.get("success"):
|
||||
return json.dumps({"success": False, "error": str(result.get("error") or "fill failed")[:200]})
|
||||
parsed = _parse_json_result(result.get("result"))
|
||||
if isinstance(parsed, str):
|
||||
parsed = _parse_json_result(parsed)
|
||||
if isinstance(parsed, dict) and parsed.get("refused") == "origin_changed":
|
||||
return json.dumps({"success": False, "error_type": "origin_changed", "error": "The page navigated before the code could be entered. Nothing was written."})
|
||||
filled = int(parsed.get("filled", 0)) if isinstance(parsed, dict) else 0
|
||||
return json.dumps({"success": bool(filled), "filled_fields": filled, "origin": origin, "source": source,
|
||||
"next": "Submit the form (many sites auto-submit when the last digit lands)."})
|
||||
|
||||
|
||||
def browser_vault_fill(handle: str, task_id: Optional[str] = None) -> str:
|
||||
"""Fill the current page's password field from a vault handle.
|
||||
|
||||
@@ -471,6 +542,9 @@ def browser_vault_fill(handle: str, task_id: Optional[str] = None) -> str:
|
||||
|
||||
out = {"success": bool(filled), "filled_fields": int(filled), "backend": backend.name,
|
||||
"kind": meta.kind, "origin": meta.origin}
|
||||
if meta.kind == "login":
|
||||
out["next"] = ("Submit. If the site then asks for a verification code, call browser_vault_enter_code with this handle"
|
||||
+ (" (a code will be generated automatically)." if meta.has_otp else "."))
|
||||
if meta.kind != "login":
|
||||
out["fields"] = sorted(f["token"] for f in fills) # which controls were targeted, never the values
|
||||
return json.dumps(out)
|
||||
@@ -568,6 +642,28 @@ BROWSER_VAULT_SAVE_LOGIN_SCHEMA = {
|
||||
}
|
||||
|
||||
|
||||
BROWSER_VAULT_ENTER_CODE_SCHEMA = {
|
||||
"name": "browser_vault_enter_code",
|
||||
"description": (
|
||||
"The page asks for a one-time / verification / 2FA code after the password: call this. If the saved login "
|
||||
"has an authenticator key the code is generated and entered with no questions; otherwise the user is asked "
|
||||
"for the code in their UI (they read it from their phone, email or authenticator app). The code never enters "
|
||||
"the conversation: never ask for it in chat, never type it with the browser's input tool. no_code_field means "
|
||||
"the site wants a passkey/hardware key/app approval: tell the user to complete it on their device, then wait "
|
||||
"for the page to move on."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"handle": {"type": "string", "description": "The login handle you just filled (lets Hermes generate the code when an authenticator key is saved)."}},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _handle_vault_enter_code(args: Dict[str, Any], **kwargs) -> str:
|
||||
return browser_vault_enter_code(handle=str(args.get("handle") or ""), task_id=kwargs.get("task_id"))
|
||||
|
||||
|
||||
def _handle_vault_save_login(args: Dict[str, Any], **kwargs) -> str:
|
||||
return browser_vault_save_login(label=str(args.get("label") or ""), task_id=kwargs.get("task_id"))
|
||||
|
||||
@@ -617,6 +713,15 @@ registry.register(
|
||||
emoji="🔐",
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="browser_vault_enter_code",
|
||||
toolset="browser",
|
||||
schema=BROWSER_VAULT_ENTER_CODE_SCHEMA,
|
||||
handler=_handle_vault_enter_code,
|
||||
check_fn=_check_vault_available,
|
||||
emoji="🔐",
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="browser_vault_fill",
|
||||
toolset="browser",
|
||||
|
||||
Reference in New Issue
Block a user