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).
39 lines
1.3 KiB
Python
39 lines
1.3 KiB
Python
"""Local Fernet vault as a login backend (the always-on default)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Dict, List, Optional
|
|
|
|
from agent.vault_backends.base import LoginBackend
|
|
from agent.vault_store import VaultItemMeta
|
|
|
|
|
|
def _store():
|
|
# Late import: tests and callers patch ``agent.vault_store.get_vault_store``; binding it here
|
|
# at call time keeps that facade name the single seam.
|
|
from agent.vault_store import get_vault_store
|
|
return get_vault_store()
|
|
|
|
|
|
class LocalLoginBackend(LoginBackend):
|
|
name = "local"
|
|
display_name = "Hermes vault"
|
|
prefix = "vault_"
|
|
|
|
def list_items(self) -> List[VaultItemMeta]:
|
|
return _store().list_items()
|
|
|
|
def get_meta(self, handle: str) -> Optional[VaultItemMeta]:
|
|
return _store().get_meta(handle)
|
|
|
|
def resolve_password(self, handle: str) -> str:
|
|
return str(_store().resolve_secret(handle).get("password") or "")
|
|
|
|
def resolve_otp(self, handle: str) -> Optional[str]:
|
|
from agent.vault_store import totp_now
|
|
seed = str(_store().resolve_secret(handle).get("otp_secret") or "")
|
|
return totp_now(seed) if seed else None
|
|
|
|
def resolve_secret(self, handle: str) -> Dict[str, str]:
|
|
return {k: str(v) for k, v in _store().resolve_secret(handle).items()}
|