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).
140 lines
5.7 KiB
Python
140 lines
5.7 KiB
Python
"""Login-backend contract + registry for the browser credential vault.
|
|
|
|
A ``LoginBackend`` lists login metadata (never secrets) and resolves ONE
|
|
password at fill time. External managers (1Password, Bitwarden) additionally
|
|
need a per-session unlock; ``resolve_password`` raises ``UnlockRequired``
|
|
while locked so the tool can ask the surface to prompt. Handles are
|
|
namespaced by ``prefix`` so ``backend_for_handle`` needs no lookup table.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from abc import ABC, abstractmethod
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Sequence
|
|
|
|
from agent.vault_store import VaultItemMeta
|
|
|
|
|
|
class UnlockRequired(Exception):
|
|
"""The backend is locked for this session; the surface must prompt for the master password."""
|
|
|
|
def __init__(self, backend: "LoginBackend"):
|
|
super().__init__(f"{backend.display_name} is locked")
|
|
self.backend = backend
|
|
|
|
|
|
class LoginBackend(ABC):
|
|
name: str # config key: local | onepassword | bitwarden
|
|
display_name: str # user-facing
|
|
prefix: str # handle prefix ("vault_", "op:", "bw:")
|
|
needs_unlock: bool = False
|
|
|
|
def owns(self, handle: str) -> bool:
|
|
return handle.startswith(self.prefix)
|
|
|
|
def is_unlocked(self) -> bool:
|
|
return True
|
|
|
|
@abstractmethod
|
|
def list_items(self) -> List[VaultItemMeta]:
|
|
"""Metadata only. Locked external backends return [] (the agent sees a lock hint instead)."""
|
|
|
|
@abstractmethod
|
|
def get_meta(self, handle: str) -> Optional[VaultItemMeta]: ...
|
|
|
|
@abstractmethod
|
|
def resolve_password(self, handle: str) -> str:
|
|
"""Server-side only; raises ``UnlockRequired`` when locked."""
|
|
|
|
def resolve_otp(self, handle: str) -> Optional[str]:
|
|
"""Current one-time code for a login that stores a TOTP seed, else None (the user is asked).
|
|
Server-side only, like resolve_password."""
|
|
return None
|
|
|
|
def resolve_secret(self, handle: str) -> Dict[str, str]:
|
|
"""Full payload of a payment/address item (server-side only). External managers list only
|
|
logins, so the base returns the password-only shape."""
|
|
return {"password": self.resolve_password(handle)}
|
|
|
|
|
|
def run_with_stdin_secret(argv: Sequence[str], *, env: Dict[str, str], secret: str, timeout: float,
|
|
label: str) -> subprocess.CompletedProcess:
|
|
"""Run a manager CLI feeding *secret* on stdin (never argv, never env). Spawn/timeout → RuntimeError."""
|
|
try:
|
|
return subprocess.run( # noqa: S603 — argv list, no shell
|
|
list(argv), env=env, input=secret + "\n", capture_output=True, text=True,
|
|
encoding="utf-8", errors="replace", timeout=timeout)
|
|
except subprocess.TimeoutExpired as exc:
|
|
raise RuntimeError(f"{label} unlock timed out after {timeout:.0f}s") from exc
|
|
except OSError as exc:
|
|
raise RuntimeError(f"failed to invoke {label}: {exc}") from exc
|
|
|
|
|
|
def run_with_secret_env(argv: Sequence[str], *, env: Dict[str, str], secret_env: str, secret: str, timeout: float,
|
|
label: str) -> subprocess.CompletedProcess:
|
|
"""Run a manager CLI whose non-interactive contract reads the secret from a named env var.
|
|
The variable is set on the child's environment only (never argv, never our process)."""
|
|
child_env = dict(env)
|
|
child_env[secret_env] = secret
|
|
try:
|
|
return subprocess.run( # noqa: S603 — argv list, no shell
|
|
list(argv), env=child_env, stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
|
encoding="utf-8", errors="replace", timeout=timeout)
|
|
except subprocess.TimeoutExpired as exc:
|
|
raise RuntimeError(f"{label} unlock timed out after {timeout:.0f}s") from exc
|
|
except OSError as exc:
|
|
raise RuntimeError(f"failed to invoke {label}: {exc}") from exc
|
|
|
|
|
|
def _cfg() -> Dict:
|
|
from hermes_cli.config import load_config_readonly
|
|
cfg = load_config_readonly().get("vault") or {}
|
|
return cfg if isinstance(cfg, dict) else {}
|
|
|
|
|
|
def external_backend_classes():
|
|
from agent.vault_backends.bitwarden import BitwardenLoginBackend
|
|
from agent.vault_backends.onepassword import OnePasswordLoginBackend
|
|
return (OnePasswordLoginBackend, BitwardenLoginBackend)
|
|
|
|
|
|
def is_installed(name: str) -> bool:
|
|
"""Is the manager CLI reachable — honouring a configured ``binary_path`` over PATH."""
|
|
import shutil
|
|
section = _cfg().get(name) or {}
|
|
explicit = str(section.get("binary_path") or "") if isinstance(section, dict) else ""
|
|
if explicit:
|
|
return Path(explicit).is_file()
|
|
if name == "onepassword":
|
|
from agent.secret_sources.onepassword import find_op
|
|
return find_op() is not None
|
|
return shutil.which("bw") is not None
|
|
|
|
|
|
def is_enabled(name: str) -> bool:
|
|
"""An installed manager is a login source unless the user opted out (``vault.<name>.enabled: false``).
|
|
Zero-config on purpose: a user with ``bw``/``op`` on PATH should never have to discover a toggle."""
|
|
section = _cfg().get(name) or {}
|
|
if isinstance(section, dict) and section.get("enabled") is False:
|
|
return False
|
|
return is_installed(name)
|
|
|
|
|
|
def enabled_backends() -> List[LoginBackend]:
|
|
"""Local first (always on), then every detected external manager the user has not turned off."""
|
|
from agent.vault_backends.local import LocalLoginBackend
|
|
|
|
cfg = _cfg()
|
|
out: List[LoginBackend] = [LocalLoginBackend()]
|
|
for cls in external_backend_classes():
|
|
if is_enabled(cls.name):
|
|
section = cfg.get(cls.name) or {}
|
|
out.append(cls(section if isinstance(section, dict) else {}))
|
|
return out
|
|
|
|
|
|
def backend_for_handle(handle: str) -> Optional[LoginBackend]:
|
|
return next((b for b in enabled_backends() if b.owns(handle)), None)
|