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:
@@ -173,8 +173,8 @@ def _wire_callbacks(sid: str):
|
||||
set_secret_capture_callback(secret_cb)
|
||||
# External password-manager unlock: the renderer shows a masked master-password card; the
|
||||
# answer is consumed by the manager CLI on stdin and only a session token stays in memory.
|
||||
from agent.vault_backends.unlock import (set_current_session_id, set_save_login_prompt_callback,
|
||||
set_unlock_prompt_callback)
|
||||
from agent.vault_backends.unlock import (set_code_prompt_callback, set_current_session_id,
|
||||
set_save_login_prompt_callback, set_unlock_prompt_callback)
|
||||
set_current_session_id(sid) # an unlock made on this turn belongs to this session (released with it)
|
||||
set_unlock_prompt_callback(lambda backend, display_name: _block(
|
||||
"vault.unlock.request", sid, {"backend": backend, "display_name": display_name}, timeout=120))
|
||||
@@ -189,6 +189,8 @@ def _wire_callbacks(sid: str):
|
||||
return data if isinstance(data, dict) and data.get("password") else None
|
||||
|
||||
set_save_login_prompt_callback(save_login_cb)
|
||||
set_code_prompt_callback(lambda site, hint: _block(
|
||||
"vault.code.request", sid, {"site": site, "hint": hint}, timeout=180))
|
||||
|
||||
|
||||
def _available_personalities(cfg: dict | None = None) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user