e9f7d44f3f
Live Desktop repro: the fixture model called browser_vault_unlock and got unlock_unavailable although the renderer was interactive. tool_executor runs handlers on a propagated worker thread; thread_context only copied the approval and sudo thread-local callbacks, so the unlock prompt registered by _wire_callbacks was invisible there and can_prompt_here() said nobody could answer. The callback table in thread_context now lists every per-thread prompt (approval, sudo, vault unlock) so a new one cannot silently drop off worker threads again. After the fix the same turn shows the masked card and completes. vault.sources / `hermes vault sources` report a manager as installed when its configured binary_path exists, not only when it is on PATH.
105 lines
3.9 KiB
Python
105 lines
3.9 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 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 _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 enabled_backends() -> List[LoginBackend]:
|
|
"""Local first (always on), then each enabled external manager, in config order."""
|
|
from agent.vault_backends.local import LocalLoginBackend
|
|
|
|
cfg = _cfg()
|
|
out: List[LoginBackend] = [LocalLoginBackend()]
|
|
for cls in external_backend_classes():
|
|
section = cfg.get(cls.name) or {}
|
|
if isinstance(section, dict) and section.get("enabled"):
|
|
out.append(cls(section))
|
|
return out
|
|
|
|
|
|
def backend_for_handle(handle: str) -> Optional[LoginBackend]:
|
|
return next((b for b in enabled_backends() if b.owns(handle)), None)
|