Three wrong-store paths in the TUI/Desktop backend under multiplexing:
- insights.get was scoped=True and then called _get_db(): before the launch
handle was pinned (#102526, #108074) a scoped first touch bound the
process-wide handle to the requested profile's state.db; even pinned, the
RPC answered for the launch profile whatever `profile` said. Route it
through _profile_db so it counts the requested profile's sessions.
- prompt.background side agents were handed the launch handle, so a
named-profile Bot Chat's bg_* rows appeared in the default profile's
session list and were missing from the profile's own history. Inherit the
parent agent's dedicated _session_db.
- session_lifecycle's gateway-owned-source guard and the notification
poller's compression-tip resolver looked the session up in the launch
store, where a named-profile row does not exist: the guard was dead and
a compression-rotated profile session lost every post-compression
delegation/background completion (the fail-closed owner gate never
matched the compressed parent's key). Both now use the session's own
store via _session_db(session).
Addresses #102157, #102526.
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).
Nobody should have to learn `hermes vault add` or find a toggle before "log into GitHub" works.
- browser_vault_save_login: when the agent reaches a sign-in page with no saved login it asks the user
on THEIR surface (CLI two-step panel on the sudo modal: identifier shown, password masked; Desktop
card with labelled Email/username + Password fields). The answer goes to the encrypted vault bound to
the page origin and is filled at once; the model gets back only the handle and identifier. Declining
returns save_declined; headless sessions get prompt_unavailable. Never a password in chat.
- Vault tools ride with the browser toolset (check_browser_requirements) instead of appearing only once
the vault has items — an empty vault is exactly when save_login is needed. browser_vault_list hints
at it when empty.
- 1Password / Bitwarden are login sources as soon as their CLI is installed; `vault.<name>.enabled`
is opt-OUT only. Settings shows Detected/Locked/Unlocked/Off/Not detected with a switch only for
installed managers; `hermes vault sources` reports detection, `--disable`/`--enable` flip the opt-out.
- Desktop nav/page renamed "Passwords & Logins"; empty state tells the user they do not need to add
anything; all five locales updated. Docs rewritten from "how it works" to "say log into X".
- New per-thread SaveLoginPrompt callback (agent/vault_backends/unlock.py) installed beside the unlock
prompt on every CLI site and the gateway bridge (vault.save_login.request/respond/expire), propagated
to worker threads via tools.thread_context.
Live: CLI PTY (real model, packaged Chromium, local login server) — panel shown, identifier + masked
password typed, server received the correct password, password absent from terminal transcript and
from every file under HERMES_HOME outside vault/. Native Electron (headless, isolated HOME/HERMES_HOME,
own Vite + CDP port) — card shown, "Save & sign in", server received the password, Settings lists the
saved item, password absent from the rendered UI.
Manager tokens: lock generation fence (a Lock acknowledged while `bw unlock`
/ `op signin` is still running discards the late token); tokens record the
unlocking gateway session and are released when THAT session ends, not when
any sibling session in the profile is torn down.
1Password: OP_CONNECT_HOST/TOKEN come from the profile's scoped secret store
like the service token (Connect outranks a service token inside op), never
from the launch environment.
Vault RPCs bind params.profile (home + secret scope) so a shared remote
backend serving several profiles locks/lists/unlocks the requested one;
unknown profile → RPC error, not a crash.
Fill target: inspection stamps are `<nonce>:<index>`; a fill resolves only
its own inspection's stamps, so an interleaved second inspection can no
longer redirect A's password into a newly mounted field (real Chrome: 0
filled, both fields empty).
Desktop Settings: every RPC goes through the owner profile's socket
(requestGatewayForProfile), query keys carry (connection, profile), an owner
change closes dialogs and wipes drafts (a master password typed for A is
never submitted to B; a late list from A never paints under B), and vault.add
secrets travel in a ref consumed by the mutationFn instead of mutation
variables. Three owner-routing invariant tests on the real component.
Docs/PR body: session-scoped release, lock-race semantics, bw --passwordenv.
The browser vault now draws from three login sources behind one handle
shape: the local encrypted vault (vault_…), 1Password Login items (op:…)
and Bitwarden Password Manager logins (bw:…). browser_vault_list aggregates
metadata across them; browser_vault_fill routes by prefix and resolves the
password at fill time only, through the manager CLI.
External managers are locked until the user unlocks them for the current
session. The new browser_vault_unlock tool (and the fill path, implicitly)
asks the surface to show a masked master-password prompt — CLI panel
(reuses the sudo panel state), TUI/Desktop via a vault.unlock.request
blocking card. The password goes to `op signin --raw` / `bw unlock --raw`
on stdin, never argv or env; only the session token is kept, in memory,
with a 30-minute idle TTL, cleared on session close or `vault.lock`.
Headless contexts (cron, webhook, api_server, -q) can never prompt: the
manager is reported as locked with unlock=unavailable_in_this_session and
fill refuses — the same posture approvals take where nobody can answer.
Config: vault.onepassword / vault.bitwarden {enabled, binary_path, …};
a 1Password service-account token skips the prompt for headless use.
RPC: vault.sources, vault.source.set, vault.unlock, vault.lock for Settings.
Tests (2, real subprocess against a fake bw; each proven red by sabotage):
headless never prompts or spawns; unlock feeds stdin only, token never
enters os.environ, fill routes by prefix and the password only reaches the
fill script.
_sync_bot_capabilities (Bot Chat capability refresh, run at every turn start) and
_reset_session_agent (/new, tools.set) swap a fresh AIAgent into a LIVE session but
called _make_agent with neither the session's state.db handle nor its HERMES_HOME.
_make_agent resolves prompt/skills/toolsets through get_hermes_home() and defaults
session_db to the process-wide LAUNCH _get_db() handle, so a named-profile session
silently migrated onto the launch profile: every later turn appended to
~/.hermes/state.db under the same session id while the desktop replayed
profiles/<name>/state.db and showed a stale transcript.
Route both rebuilds through _rebuild_session_agent, which binds the session's
profile scopes and inherits the outgoing agent's handle (same session, same file)
so no second refcount is taken and teardown still releases it exactly once.
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.
- mcp_oauth_sessions: lift the probe+rollback block out of _worker (_probe_with_rollback).
- project_tree: lane group built from a field table; stamp_profile/_lane_key/_disambiguate
flattening. methods_projects: policy loader + scan loop tightened. agent_callbacks:
preview-history filter via _PREVIEW_HISTORY_ROLES. Blank-line squeeze after in-body
lazy imports (AST-identical). All goldens identical; WIRE-PARITY-OK.