From 98d11c95f461812450a8d1b965e708d10fbf93fc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:15:25 -0700 Subject: [PATCH] =?UTF-8?q?feat(vault):=20zero-setup=20UX=20=E2=80=94=20sa?= =?UTF-8?q?ve=20a=20login=20on=20the=20page=20that=20needs=20it,=20manager?= =?UTF-8?q?s=20auto-detected,=20one=20"Passwords=20&=20Logins"=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..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. --- agent/vault_backends/base.py | 17 +- agent/vault_backends/unlock.py | 12 + .../gateway-event/input-requests.ts | 40 +++ .../settings/vault-settings.owner.test.tsx | 4 +- .../src/app/settings/vault-settings.test.tsx | 21 +- .../src/app/settings/vault-settings.tsx | 34 ++- .../src/components/prompt-overlays.tsx | 123 ++++++++ .../prompt-overlays.vault-save-login.test.tsx | 76 +++++ apps/desktop/src/i18n/ar.ts | 44 ++- apps/desktop/src/i18n/en.ts | 46 +-- apps/desktop/src/i18n/ja.ts | 44 ++- apps/desktop/src/i18n/types.ts | 11 + apps/desktop/src/i18n/zh-hant.ts | 44 ++- apps/desktop/src/i18n/zh.ts | 44 ++- apps/desktop/src/lib/chat-messages/types.ts | 3 + apps/desktop/src/lib/gateway-events.ts | 2 + apps/desktop/src/store/prompts.ts | 46 ++- cli.py | 8 +- hermes_cli/cli_chat_turn_mixin.py | 4 +- hermes_cli/cli_commands_mixin.py | 4 +- hermes_cli/cli_modal_mixin.py | 27 ++ hermes_cli/cli_tui_mixin.py | 20 +- hermes_cli/vault.py | 33 +- tests/test_browser_vault.py | 75 ++++- tools/browser_vault_tool.py | 81 ++++- tools/thread_context.py | 3 +- tui_gateway/agent_callbacks.py | 16 +- tui_gateway/methods_prompt.py | 3 +- tui_gateway/methods_vault.py | 5 +- tui_gateway/server.py | 3 +- .../user-guide/features/credential-vault.md | 282 +++++------------- 31 files changed, 806 insertions(+), 369 deletions(-) create mode 100644 apps/desktop/src/components/prompt-overlays.vault-save-login.test.tsx diff --git a/agent/vault_backends/base.py b/agent/vault_backends/base.py index f7a23fe440..31b3ecc443 100644 --- a/agent/vault_backends/base.py +++ b/agent/vault_backends/base.py @@ -108,16 +108,25 @@ def is_installed(name: str) -> bool: 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..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 each enabled external manager, in config order.""" + """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(): - section = cfg.get(cls.name) or {} - if isinstance(section, dict) and section.get("enabled"): - out.append(cls(section)) + if is_enabled(cls.name): + section = cfg.get(cls.name) or {} + out.append(cls(section if isinstance(section, dict) else {})) return out diff --git a/agent/vault_backends/unlock.py b/agent/vault_backends/unlock.py index 52009ceed8..687b689945 100644 --- a/agent/vault_backends/unlock.py +++ b/agent/vault_backends/unlock.py @@ -26,6 +26,9 @@ _sessions: Dict[tuple[str, str], tuple[str, float]] = {} # (profile home, back _callback_tls = threading.local() UnlockPrompt = Callable[[str, str], str] # (backend_name, display_name) -> master password ("" = cancelled) +# (origin, site label) -> {"identifier": str, "password": str} or None when the user declines. The +# surface owns the masked fields; the tool stores the answer in the local vault and fills at once. +SaveLoginPrompt = Callable[[str, str], Optional[Dict[str, str]]] def set_unlock_prompt_callback(cb: Optional[UnlockPrompt]) -> None: @@ -37,6 +40,15 @@ def get_unlock_prompt_callback() -> Optional[UnlockPrompt]: return getattr(_callback_tls, "prompt", None) +def set_save_login_prompt_callback(cb: Optional[SaveLoginPrompt]) -> None: + """Register the surface's "save this login" prompt (identifier + masked password), per thread.""" + _callback_tls.save_login = cb + + +def get_save_login_prompt_callback() -> Optional[SaveLoginPrompt]: + return getattr(_callback_tls, "save_login", None) + + def _key(backend: str) -> tuple[str, str]: # Tokens are profile-scoped: a Desktop gateway hosts several profiles in one process and # profile B must never reuse (or lock) profile A's manager session. diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts index 38e3155a6d..bee3ca128a 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts @@ -13,11 +13,14 @@ import { $gateway } from '@/store/gateway' import { setMcpSetupRequest } from '@/store/mcp-setup' import { dispatchNativeNotification } from '@/store/native-notifications' import { + $vaultSaveLoginRequests, $vaultUnlockRequests, + clearVaultSaveLoginRequest, clearVaultUnlockRequest, receiveApprovalRequest, setSecretRequest, setSudoRequest, + setVaultSaveLoginRequest, setVaultUnlockRequest } from '@/store/prompts' import { requestScrollToBottom } from '@/store/thread-scroll' @@ -164,6 +167,17 @@ export function handleInputRequestEvent(ctx: GatewayEventContext): boolean { return true } + if (event.type === 'vault.save_login.expire') { + const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' + const request = sessionId ? $vaultSaveLoginRequests.get()[sessionId] : undefined + + if (requestId && request && request.requestId === requestId) { + clearVaultSaveLoginRequest(sessionId, requestId) + } + + return true + } + if (event.type === 'vault.unlock.expire') { const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' const request = sessionId ? $vaultUnlockRequests.get()[sessionId] : undefined @@ -339,6 +353,32 @@ export function handleInputRequestEvent(ctx: GatewayEventContext): boolean { return true } + if (event.type === 'vault.save_login.request') { + // The agent is on a sign-in page with no saved login: identifier + masked password card; the + // answer is stored in the encrypted vault by the backend and filled at once (never shown to the model). + const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' + + if (requestId) { + const origin = typeof payload?.origin === 'string' ? payload.origin : '' + const site = typeof payload?.site === 'string' ? payload.site : origin + + setVaultSaveLoginRequest({ origin, requestId, sessionId: sessionId ?? null, site }) + + if (sessionId) { + updateSessionState(sessionId, state => ({ ...state, needsInput: true })) + } + + dispatchNativeNotification({ + body: translateNow('prompts.vaultSaveTitle', site), + kind: 'input', + sessionId, + title: translateNow('notifications.native.inputTitle') + }) + } + + return true + } + if (event.type === 'vault.unlock.request') { // External password-manager unlock (agent/vault_backends). Blocked on // vault.unlock.respond {request_id, password}; "" keeps it locked. diff --git a/apps/desktop/src/app/settings/vault-settings.owner.test.tsx b/apps/desktop/src/app/settings/vault-settings.owner.test.tsx index 0623eff0d1..61dc700b33 100644 --- a/apps/desktop/src/app/settings/vault-settings.owner.test.tsx +++ b/apps/desktop/src/app/settings/vault-settings.owner.test.tsx @@ -112,13 +112,13 @@ it("a late list response from profile A never paints under profile B", async () it('vault.add secrets never enter the mutation cache', async () => { respond = async (_profile, method) => (method === 'vault.sources' ? { sources } : method === 'vault.list' ? { items: [] } : { id: 'created' }) const view = mount() - fireEvent.click(await screen.findByRole('button', { name: 'Add credential' })) + fireEvent.click(await screen.findByRole('button', { name: 'Add' })) for (const [label, value] of [['Label', 'fixture'], ['Site origin', 'https://example.com'], ['Identifier', 'fixture@example.com'], ['Password', 'fixture-retained-password']] as const) { fireEvent.change(screen.getByLabelText(label), { target: { value } }) } - fireEvent.click(screen.getByRole('button', { name: 'Save to vault' })) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => expect(calls.some(c => c.method === 'vault.add')).toBe(true)) expect((calls.find(c => c.method === 'vault.add')!.params.secret as Record).password).toBe('fixture-retained-password') await waitFor(() => expect(screen.queryByLabelText('Password')).toBeNull()) diff --git a/apps/desktop/src/app/settings/vault-settings.test.tsx b/apps/desktop/src/app/settings/vault-settings.test.tsx index a7b3aec1e4..e6e8f894ed 100644 --- a/apps/desktop/src/app/settings/vault-settings.test.tsx +++ b/apps/desktop/src/app/settings/vault-settings.test.tsx @@ -59,7 +59,7 @@ describe('VaultSettings', () => { requestGateway.mockResolvedValue({ items: [] }) renderVault() - await waitFor(() => expect(screen.getByText('No saved credentials yet')).toBeTruthy()) + await waitFor(() => expect(screen.getByText('Nothing saved yet')).toBeTruthy()) expect(requestGateway).toHaveBeenCalledWith('vault.list', {}) }) @@ -89,12 +89,12 @@ describe('VaultSettings', () => { requestGateway.mockResolvedValue({ items: [] }) renderVault() - fireEvent.click(await screen.findByRole('button', { name: 'Add credential' })) + fireEvent.click(await screen.findByRole('button', { name: 'Add' })) fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'x' } }) fireEvent.change(screen.getByLabelText('Site origin'), { target: { value: 'not-a-url' } }) fireEvent.change(screen.getByLabelText('Identifier'), { target: { value: 'me@example.com' } }) fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pw' } }) - fireEvent.click(screen.getByRole('button', { name: 'Save to vault' })) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => expect(screen.getByText('Enter a valid URL like https://example.com.')).toBeTruthy()) expect(requestGateway).not.toHaveBeenCalledWith('vault.add', expect.anything()) @@ -106,12 +106,12 @@ describe('VaultSettings', () => { ) renderVault() - fireEvent.click(await screen.findByRole('button', { name: 'Add credential' })) + fireEvent.click(await screen.findByRole('button', { name: 'Add' })) fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'GitHub work' } }) fireEvent.change(screen.getByLabelText('Site origin'), { target: { value: 'https://github.com' } }) fireEvent.change(screen.getByLabelText('Identifier'), { target: { value: 'me@example.com' } }) fireEvent.change(screen.getByLabelText('Password'), { target: { value: 's3cret' } }) - fireEvent.click(screen.getByRole('button', { name: 'Save to vault' })) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('vault.add', { @@ -134,8 +134,8 @@ describe('VaultSettings', () => { renderVault() await waitFor(() => expect(screen.getByText('GitHub work')).toBeTruthy()) - fireEvent.click(screen.getByRole('button', { name: 'Delete credential' })) - await waitFor(() => expect(screen.getByText('Delete credential?')).toBeTruthy()) + fireEvent.click(screen.getByRole('button', { name: 'Remove saved item' })) + await waitFor(() => expect(screen.getByText('Delete this item?')).toBeTruthy()) fireEvent.click(screen.getByRole('button', { name: 'Delete' })) await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('vault.remove', { id: 'vault_abc123' })) @@ -168,9 +168,10 @@ describe('VaultSettings', () => { renderVault() await waitFor(() => expect(screen.getByText('GitHub via 1Password')).toBeTruthy()) - expect(screen.queryByRole('button', { name: 'Delete credential' })).toBeNull() - // Not-installed manager can't be switched on; the installed one can be unlocked. - expect(screen.getByRole('switch', { name: 'Bitwarden' })).toHaveProperty('disabled', true) + expect(screen.queryByRole('button', { name: 'Remove saved item' })).toBeNull() + // A manager that isn't installed has nothing to switch (detection is automatic); the installed one can be unlocked. + expect(screen.queryByRole('switch', { name: 'Bitwarden' })).toBeNull() + expect(screen.getByRole('switch', { name: '1Password' })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Unlock' })) await waitFor(() => expect(screen.getByText('Unlock 1Password')).toBeTruthy()) diff --git a/apps/desktop/src/app/settings/vault-settings.tsx b/apps/desktop/src/app/settings/vault-settings.tsx index 8ecb28b7cf..f16a839f15 100644 --- a/apps/desktop/src/app/settings/vault-settings.tsx +++ b/apps/desktop/src/app/settings/vault-settings.tsx @@ -459,15 +459,17 @@ export function VaultSettings() { {v.sources.unlock} ))} - { - triggerHaptic('selection') - setSourceEnabled.mutate({ name: source.name, enabled }) - }} - /> + {source.installed && ( + { + triggerHaptic('selection') + setSourceEnabled.mutate({ name: source.name, enabled }) + }} + /> + )} } description={ @@ -483,11 +485,15 @@ export function VaultSettings() { title={ {source.display_name} - {source.enabled && ( - - {source.unlocked ? v.sources.statusUnlocked : v.sources.statusLocked} - - )} + + {!source.installed + ? v.sources.statusNotDetected + : !source.enabled + ? v.sources.statusOff + : source.unlocked + ? v.sources.statusUnlocked + : v.sources.statusLocked} + } /> diff --git a/apps/desktop/src/components/prompt-overlays.tsx b/apps/desktop/src/components/prompt-overlays.tsx index 875edfb9e3..46de916e78 100644 --- a/apps/desktop/src/components/prompt-overlays.tsx +++ b/apps/desktop/src/components/prompt-overlays.tsx @@ -13,6 +13,7 @@ import { DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Field } from '@/components/ui/field' import { Input } from '@/components/ui/input' import { useI18n } from '@/i18n' import { isMissingPendingPromptRequest } from '@/lib/gateway-rpc' @@ -23,9 +24,11 @@ import { notifyError } from '@/store/notifications' import { clearSecretRequest, clearSudoRequest, + clearVaultSaveLoginRequest, clearVaultUnlockRequest, sessionSecretRequest, sessionSudoRequest, + sessionVaultSaveLoginRequest, sessionVaultUnlockRequest } from '@/store/prompts' import { ambientRequestFor } from '@/store/session-gone-latch' @@ -348,6 +351,125 @@ function VaultUnlockDialog({ sessionId }: { sessionId: string | null }) { ) } +/** "Save this login" card: the agent is on a sign-in page for `site` with nothing in the vault. + * Identifier is plain, password masked; the pair goes to `vault.save_login.respond` as JSON and + * the backend stores it encrypted and fills the page. Closing answers "" (don't save). */ +function VaultSaveLoginDialog({ sessionId }: { sessionId: string | null }) { + const { t } = useI18n() + const copy = t.prompts + const $request = useMemo(() => sessionVaultSaveLoginRequest(sessionId), [sessionId]) + const request = useStore($request) + const gateway = useStore($gateway) + const [identifier, setIdentifier] = useState('') + const [password, setPassword] = useState('') + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + setIdentifier('') + setPassword('') + setSubmitting(false) + }, [request?.requestId]) + + const send = useCallback( + async (login: string) => { + if (!request) { + return + } + + if (!gateway) { + notifyError(new Error(copy.gatewayDisconnected), copy.vaultSaveSendFailed) + + return + } + + setSubmitting(true) + + try { + await requestForOwnedSession<{ status?: string }>( + request.sessionId, + ambientRequestFor(gateway), + 'vault.save_login.respond', + { login, request_id: request.requestId } + ) + triggerHaptic('submit') + clearVaultSaveLoginRequest(request.sessionId, request.requestId) + } catch (error) { + if (isMissingPendingPromptRequest(error, 'login')) { + clearVaultSaveLoginRequest(request.sessionId, request.requestId) + + return + } + + notifyError(error, copy.vaultSaveSendFailed) + setSubmitting(false) + } finally { + setIdentifier('') + setPassword('') + } + }, + [copy.gatewayDisconnected, copy.vaultSaveSendFailed, gateway, request] + ) + + if (!request) { + return null + } + + const canSave = Boolean(identifier.trim()) && Boolean(password) + + return ( + !open && !submitting && void send('')} open> + + + {copy.vaultSaveTitle(request.site)} + {copy.vaultSaveDesc(request.origin)} + + +
{ + event.preventDefault() + + if (canSave) { + void send(JSON.stringify({ identifier: identifier.trim(), password })) + } + }} + > + + setIdentifier(event.target.value)} + placeholder={copy.vaultSaveIdentifierPlaceholder} + value={identifier} + /> + + + setPassword(event.target.value)} + type="password" + value={password} + /> + +

{copy.vaultSaveFootnote}

+ + + + +
+
+
+ ) +} + /** Mid-turn prompt surfaces for ONE session. Mounted by both the primary chat * and each tile with its own session id, so a background/tiled session's * blocking prompt renders instead of silently stalling. */ @@ -358,6 +480,7 @@ export function PromptOverlays({ sessionId }: { sessionId: string | null }) { + ) } diff --git a/apps/desktop/src/components/prompt-overlays.vault-save-login.test.tsx b/apps/desktop/src/components/prompt-overlays.vault-save-login.test.tsx new file mode 100644 index 0000000000..9640fe1c4a --- /dev/null +++ b/apps/desktop/src/components/prompt-overlays.vault-save-login.test.tsx @@ -0,0 +1,76 @@ +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' + +import { stubResizeObserver } from '@/test/jsdom' + +const gatewayMocks = vi.hoisted(() => ({ + requestGatewayForAgent: vi.fn(async () => ({ status: 'ok' })) +})) + +vi.mock('@/store/gateway', async importActual => ({ + ...(await importActual>()), + requestGatewayForAgent: gatewayMocks.requestGatewayForAgent +})) +vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() })) +vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() })) + +import { PromptOverlays } from '@/components/prompt-overlays' +import { $gateway } from '@/store/gateway' +import { $profiles } from '@/store/profile' +import { clearAllPrompts, sessionVaultSaveLoginRequest, setVaultSaveLoginRequest } from '@/store/prompts' +import { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session' + +stubResizeObserver() + +afterEach(() => { + cleanup() + clearAllPrompts() + _resetSessionOwnerHintsForTests() + $gateway.set(null) + vi.clearAllMocks() +}) + +// The "save this login" card is the zero-setup path: the pair goes to the OWNING profile's socket as +// one JSON answer, the password field is masked, and Save is disabled until both fields are filled. +it('sends identifier + password as one vault.save_login.respond to the owning profile socket', async () => { + $profiles.set([{ name: 'owner' }, { name: 'profile-b' }] as never) + setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' }) + const ambient = vi.fn().mockResolvedValue({ status: 'ok' }) + $activeSessionId.set('session-b') + $gateway.set({ request: ambient } as never) + setVaultSaveLoginRequest({ origin: 'https://github.com', requestId: 'req-s', sessionId: 'session-a', site: 'github.com' }) + + render() + expect(document.body.textContent).toContain('Save your github.com login?') + const identifier = document.querySelector('input[autocomplete=username]') as HTMLInputElement + const password = document.querySelector('input[type=password]') as HTMLInputElement + const submit = document.querySelector('button[type=submit]') as HTMLButtonElement + expect(submit.disabled).toBe(true) + fireEvent.change(identifier, { target: { value: 'tek@acme.test' } }) + expect(submit.disabled).toBe(true) + fireEvent.change(password, { target: { value: 'fixture-pw' } }) + expect(submit.disabled).toBe(false) + fireEvent.submit(password.closest('form')!) + + await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1)) + const [conn, profile, method, params] = gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[] + expect([conn, profile, method]).toEqual(['conn-1', 'owner', 'vault.save_login.respond']) + expect(JSON.parse((params as { login: string }).login)).toEqual({ identifier: 'tek@acme.test', password: 'fixture-pw' }) + expect(ambient).not.toHaveBeenCalled() + await waitFor(() => expect(sessionVaultSaveLoginRequest('session-a').get()).toBeNull()) +}) + +it("Don't save answers an empty login and clears the card", async () => { + $profiles.set([{ name: 'owner' }] as never) + setSessionOwnerHint('session-a', { connectionId: 'conn-1', profile: 'owner' }) + $gateway.set({ request: vi.fn() } as never) + setVaultSaveLoginRequest({ origin: 'https://github.com', requestId: 'req-d', sessionId: 'session-a', site: 'github.com' }) + + render() + const decline = Array.from(document.querySelectorAll('button')).find(b => b.textContent === "Don't save")! + fireEvent.click(decline) + + await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1)) + expect((gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[])[3]).toEqual({ login: '', request_id: 'req-d' }) + await waitFor(() => expect(sessionVaultSaveLoginRequest('session-a').get()).toBeNull()) +}) diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index f857581885..0806c453a0 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -383,23 +383,23 @@ export const ar = defineLocale({ about: 'حول', notifications: 'الإشعارات', keybinds: 'اختصارات لوحة المفاتيح', - vault: 'خزنة بيانات الاعتماد' + vault: 'كلمات المرور وتسجيلات الدخول' }, vault: { - title: 'خزنة بيانات الاعتماد', + title: 'كلمات المرور وتسجيلات الدخول', blurb: - 'بيانات اعتماد محلية مشفّرة يمكن للوكيل استخدامها لتسجيل الدخول إلى المواقع دون أن يرى كلمة المرور أبداً. تظهر التسميات والأصول ومعرّفات تسجيل الدخول؛ أما كلمات المرور فلا تظهر أبداً.', + 'قل «سجّل الدخول إلى GitHub» وسيقوم الوكيل بذلك نيابةً عنك. في أول مرة يصادف صفحة تسجيل دخول يطلب منك بيانات الدخول في مكانها، وبعدها يعمل تلقائيًا. تُشفَّر كلمات المرور على هذا الجهاز وتُملأ في الصفحة مباشرة — ولا يراها النموذج أبدًا.', count: n => `${n} محفوظة`, loadFailed: 'تعذّر تحميل عناصر الخزنة', - empty: 'لا توجد بيانات اعتماد محفوظة بعد', + empty: 'لا شيء محفوظ بعد', emptyDesc: - 'أضف تسجيل دخول ليتمكن الوكيل من الدخول إلى ذلك الموقع نيابةً عنك — يكتب اسم المستخدم بنفسه ويملأ كلمة المرور من الخزنة دون أن يراها أبداً.', - add: 'إضافة بيانات اعتماد', - addTitle: 'إضافة بيانات اعتماد', + 'لا حاجة لإضافة أي شيء هنا. اطلب من الوكيل تسجيل الدخول إلى موقع وسيطلب منك بيانات الدخول مرة واحدة في مكانها. استخدم «إضافة» إذا كنت تفضّل إدخالها مسبقًا.', + add: 'إضافة', + addTitle: 'إضافة بيانات دخول أو بطاقة أو عنوان', addDescription: 'تُخزَّن مشفّرة على هذا الجهاز. لا يرى الوكيل كلمة المرور أبداً.', - added: 'تم حفظ بيانات الاعتماد في الخزنة.', + added: 'تم الحفظ.', adding: 'جارٍ الحفظ…', - addConfirm: 'حفظ في الخزنة', + addConfirm: 'حفظ', kindField: 'النوع', kinds: { login: 'تسجيل دخول', payment: 'بطاقة دفع', address: 'عنوان' }, labelField: 'التسمية', @@ -428,20 +428,22 @@ export const ar = defineLocale({ countryField: 'الدولة', optional: '(اختياري)', createdOn: date => `أُضيفت ${date}`, - deleteAction: 'حذف بيانات الاعتماد', - deleteTitle: 'حذف بيانات الاعتماد؟', + deleteAction: 'إزالة العنصر المحفوظ', + deleteTitle: 'حذف هذا العنصر؟', deleteDescription: label => `سيُزال "${label}" من الخزنة المشفّرة. لا يمكن التراجع عن هذا.`, deleteConfirm: 'حذف', sources: { title: 'مديرو كلمات المرور', blurb: - 'اسمح للوكيل بتسجيل الدخول باستخدام بيانات الدخول المحفوظة في مدير كلمات المرور. تفتح القفل مرة واحدة في كل جلسة بكلمة المرور الرئيسية؛ يُحتفظ فقط برمز الجلسة في الذاكرة، ولا يرى الوكيل كلمة المرور الرئيسية أو أي كلمة مرور أبدًا.', + 'تُكتشف مديري كلمات المرور المثبّتة تلقائيًا. يطلب منك الوكيل فتح أحدها في أول مرة يحتاج فيها إلى بيانات دخول منه (مرة واحدة لكل جلسة)؛ يبقى في الذاكرة رمز الجلسة فقط، ولا يرى الوكيل كلمة المرور الرئيسية أو أي بيانات دخول.', toggleFailed: 'تعذر تحديث مدير كلمات المرور', - notInstalled: name => `لم يتم العثور على واجهة ${name} CLI على هذا الجهاز. ثبّتها ثم فعّل هذا الخيار.`, - disabledDesc: 'متوقف. فعّله ليستخدم الوكيل بيانات الدخول من هذا المدير.', - lockedDesc: 'مقفل. افتح القفل الآن، أو سيطلب الوكيل ذلك عند أول حاجة إلى بيانات دخول.', + notInstalled: name => `غير مكتشف. ثبّت أداة سطر الأوامر ${name} وسجّل الدخول إليها؛ سيكتشفها Hermes تلقائيًا.`, + disabledDesc: 'مكتشف لكنه معطّل لـ Hermes.', + lockedDesc: 'مكتشف. سيطلب منك الوكيل فتحه عند الحاجة إلى بيانات دخول، أو افتحه الآن.', unlockedDesc: 'مفتوح لهذه الجلسة. يُقفل تلقائيًا بعد 30 دقيقة من الخمول أو عند إغلاق Hermes.', statusLocked: 'مقفل', + statusNotDetected: 'غير مكتشف', + statusOff: 'متوقف', statusUnlocked: 'مفتوح', unlock: 'فتح القفل', unlocking: 'جارٍ فتح القفل…', @@ -3038,7 +3040,17 @@ export const ar = defineLocale({ `يريد الوكيل تسجيل الدخول إلى موقع ببيانات دخول محفوظة في ${name}. أدخل كلمة المرور الرئيسية لفتح القفل لهذه الجلسة — تُسلَّم مباشرة إلى ${name} على هذا الجهاز ولا تُخزَّن ولا تُعرض على الوكيل.`, vaultUnlockPlaceholder: 'كلمة المرور الرئيسية', vaultUnlockKeepLocked: 'إبقاؤه مقفلًا', - vaultUnlockConfirm: 'فتح القفل' + vaultUnlockConfirm: 'فتح القفل', + vaultSaveSendFailed: 'تعذر حفظ بيانات الدخول', + vaultSaveTitle: site => `حفظ بيانات الدخول إلى ${site}؟`, + vaultSaveDesc: origin => + `وصل Hermes إلى صفحة تسجيل الدخول في ${origin} ولا توجد بيانات دخول محفوظة لها. أدخلها هنا مرة واحدة؛ تُشفَّر على هذا الجهاز وتُملأ في الصفحة مباشرة، ولا يرى النموذج كلمة المرور أبدًا.`, + vaultSaveIdentifierLabel: 'البريد الإلكتروني أو اسم المستخدم', + vaultSaveIdentifierPlaceholder: 'you@example.com', + vaultSavePasswordPlaceholder: 'كلمة المرور', + vaultSaveFootnote: 'أدِر بيانات الدخول المحفوظة من الإعدادات ← كلمات المرور وتسجيلات الدخول.', + vaultSaveDecline: 'عدم الحفظ', + vaultSaveConfirm: 'حفظ وتسجيل الدخول' }, desktop: { audioReadFailed: 'فشلت قراءة الصوت', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 8ba1949217..979db67325 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -440,7 +440,7 @@ export const en: Translations = { about: 'About', billing: 'Billing', notifications: 'Notifications', - vault: 'Credential Vault' + vault: 'Passwords & Logins' }, plugins: { title: 'Desktop plugins', @@ -511,20 +511,20 @@ export const en: Translations = { } }, vault: { - title: 'Credential Vault', + title: 'Passwords & Logins', blurb: - 'Encrypted local credentials the agent can use to sign into sites without ever seeing the password. Labels, origins, and login identifiers are visible; passwords never are.', + 'Say "log into GitHub" and the agent signs in for you. The first time it meets a sign-in page it asks you for the login right there; after that it just works. Passwords are encrypted on this machine and filled straight into the page — the model never sees them.', count: n => `${n} saved`, loadFailed: 'Could not load vault items', - empty: 'No saved credentials yet', + empty: 'Nothing saved yet', emptyDesc: - 'Add a login and the agent can sign into that site for you — it types the username itself and fills the password from the vault without ever seeing it.', - add: 'Add credential', - addTitle: 'Add credential', + 'You don\'t have to add anything here. Ask the agent to sign into a site and it will ask you for the login once, on the spot. Use Add if you prefer to enter one ahead of time.', + add: 'Add', + addTitle: 'Add a login, card or address', addDescription: 'Stored encrypted on this machine. The agent never sees the password.', - added: 'Credential saved to the vault.', + added: 'Saved.', adding: 'Saving…', - addConfirm: 'Save to vault', + addConfirm: 'Save', kindField: 'Kind', kinds: { login: 'Login', payment: 'Payment card', address: 'Address' }, labelField: 'Label', @@ -553,20 +553,22 @@ export const en: Translations = { countryField: 'Country', optional: '(optional)', createdOn: date => `Added ${date}`, - deleteAction: 'Delete credential', - deleteTitle: 'Delete credential?', - deleteDescription: label => `"${label}" will be removed from the encrypted vault. This cannot be undone.`, + deleteAction: 'Remove saved item', + deleteTitle: 'Delete this item?', + deleteDescription: label => `"${label}" will be removed. This cannot be undone.`, deleteConfirm: 'Delete', sources: { title: 'Password managers', blurb: - 'Let the agent sign in with logins from your password manager. You unlock it once per session with your master password; only a session token is kept in memory, and the agent never sees it or any password.', + 'Installed password managers are picked up automatically. The agent asks you to unlock one the first time it needs a login from it (once per session); only a session token stays in memory, and the agent never sees your master password or any login.', toggleFailed: 'Could not update password manager', - notInstalled: name => `${name} CLI not found on this machine. Install it, then turn this on.`, - disabledDesc: 'Off. Turn on to let the agent use logins from this manager.', - lockedDesc: 'Locked. Unlock now, or the agent will ask you the first time it needs a login.', + notInstalled: name => `Not detected. Install the ${name} command-line tool and sign in to it; Hermes picks it up automatically.`, + disabledDesc: 'Detected but turned off for Hermes.', + lockedDesc: 'Detected. The agent will ask you to unlock it when it needs a login, or unlock now.', unlockedDesc: 'Unlocked for this session. Locks automatically after 30 minutes idle or when Hermes closes.', statusLocked: 'Locked', + statusNotDetected: 'Not detected', + statusOff: 'Off', statusUnlocked: 'Unlocked', unlock: 'Unlock', unlocking: 'Unlocking…', @@ -3894,7 +3896,17 @@ export const en: Translations = { `The agent wants to sign into a site with a login saved in ${name}. Enter your master password to unlock it for this session — it goes straight to ${name} on this machine and is never stored or shown to the agent.`, vaultUnlockPlaceholder: 'Master password', vaultUnlockKeepLocked: 'Keep locked', - vaultUnlockConfirm: 'Unlock' + vaultUnlockConfirm: 'Unlock', + vaultSaveSendFailed: 'Could not save the login', + vaultSaveTitle: site => `Save your ${site} login?`, + vaultSaveDesc: origin => + `Hermes reached a sign-in page at ${origin} and has no login for it. Enter it once here; it is encrypted on this machine and filled into the page without the model ever seeing the password.`, + vaultSaveIdentifierLabel: 'Email or username', + vaultSaveIdentifierPlaceholder: 'you@example.com', + vaultSavePasswordPlaceholder: 'Password', + vaultSaveFootnote: 'Manage saved logins in Settings → Passwords & Logins.', + vaultSaveDecline: "Don't save", + vaultSaveConfirm: 'Save & sign in' }, desktop: { diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index daf41679cf..3e934d4550 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -329,23 +329,23 @@ export const ja = defineLocale({ about: '情報', billing: '請求', notifications: '通知', - vault: '資格情報ボールト' + vault: 'パスワードとログイン' }, vault: { - title: '資格情報ボールト', + title: 'パスワードとログイン', blurb: - 'エージェントがパスワードを一切見ることなくサイトへサインインするために使える、暗号化されたローカル資格情報です。ラベル・オリジン・ログイン識別子は表示されますが、パスワードは決して表示されません。', + '「GitHub にログインして」と言えば、エージェントが代わりにサインインします。初めてサインインページに出会ったときにその場でログイン情報を尋ね、以降は自動で処理します。パスワードはこのマシン上で暗号化され、ページに直接入力されます。モデルは一切見ません。', count: n => `${n} 件保存済み`, loadFailed: 'ボールト項目を読み込めませんでした', - empty: '保存された資格情報はまだありません', + empty: 'まだ何も保存されていません', emptyDesc: - 'ログインを追加すると、エージェントがそのサイトに代わりにサインインできます。ユーザー名はエージェント自身が入力し、パスワードはボールトから直接入力されるため、エージェントがパスワードを見ることはありません。', - add: '資格情報を追加', - addTitle: '資格情報を追加', + 'ここで何かを追加する必要はありません。エージェントにサイトへのサインインを頼むと、その場で一度だけログイン情報を尋ねます。事前に登録したい場合は「追加」を使ってください。', + add: '追加', + addTitle: 'ログイン情報・カード・住所を追加', addDescription: 'このマシン上に暗号化して保存されます。エージェントがパスワードを見ることはありません。', - added: '資格情報をボールトに保存しました。', + added: '保存しました。', adding: '保存中…', - addConfirm: 'ボールトに保存', + addConfirm: '保存', kindField: '種類', kinds: { login: 'ログイン', payment: '支払いカード', address: '住所' }, labelField: 'ラベル', @@ -374,20 +374,22 @@ export const ja = defineLocale({ countryField: '国', optional: '(任意)', createdOn: date => `追加日 ${date}`, - deleteAction: '資格情報を削除', - deleteTitle: '資格情報を削除しますか?', + deleteAction: '保存済み項目を削除', + deleteTitle: 'この項目を削除しますか?', deleteDescription: label => `「${label}」は暗号化ボールトから削除されます。元に戻せません。`, deleteConfirm: '削除', sources: { title: 'パスワードマネージャー', blurb: - 'パスワードマネージャーに保存したログインでエージェントがサインインできるようにします。セッションごとに一度マスターパスワードでロック解除し、メモリにはセッショントークンだけが保持されます。エージェントがマスターパスワードやパスワードを見ることはありません。', + 'インストール済みのパスワードマネージャーは自動的に検出されます。エージェントがそこからログイン情報を初めて必要とするときにロック解除を求めます(セッションごとに一度)。メモリに残るのはセッショントークンのみで、エージェントはマスターパスワードやログイン情報を一切見ません。', toggleFailed: 'パスワードマネージャーの設定を更新できませんでした', - notInstalled: name => `${name} CLI がこのマシンに見つかりません。インストールしてから有効にしてください。`, - disabledDesc: 'オフ。有効にすると、このマネージャーのログインをエージェントが使えるようになります。', - lockedDesc: 'ロック中。今ロック解除するか、エージェントが最初にログインを必要としたときに尋ねられます。', + notInstalled: name => `未検出です。${name} のコマンドラインツールをインストールしてサインインすると、Hermes が自動的に検出します。`, + disabledDesc: '検出済みですが、Hermes では無効になっています。', + lockedDesc: '検出済み。エージェントがログイン情報を必要とするときにロック解除を求めます。今すぐ解除することもできます。', unlockedDesc: 'このセッションでロック解除済み。30分間操作がないか Hermes を閉じると自動的にロックされます。', statusLocked: 'ロック中', + statusNotDetected: '未検出', + statusOff: 'オフ', statusUnlocked: 'ロック解除済み', unlock: 'ロック解除', unlocking: 'ロック解除中…', @@ -3438,7 +3440,17 @@ export const ja = defineLocale({ `エージェントが ${name} に保存されたログインでサイトにサインインしようとしています。このセッションでロック解除するにはマスターパスワードを入力してください。パスワードはこのマシン上の ${name} に直接渡され、保存されることもエージェントに表示されることもありません。`, vaultUnlockPlaceholder: 'マスターパスワード', vaultUnlockKeepLocked: 'ロックしたまま', - vaultUnlockConfirm: 'ロック解除' + vaultUnlockConfirm: 'ロック解除', + vaultSaveSendFailed: 'ログイン情報を保存できませんでした', + vaultSaveTitle: site => `${site} のログイン情報を保存しますか?`, + vaultSaveDesc: origin => + `Hermes は ${origin} のサインインページに到達しましたが、保存されたログイン情報がありません。ここで一度入力すると、このマシン上で暗号化して保存され、ページに直接入力されます。モデルはパスワードを一切見ません。`, + vaultSaveIdentifierLabel: 'メールアドレスまたはユーザー名', + vaultSaveIdentifierPlaceholder: 'you@example.com', + vaultSavePasswordPlaceholder: 'パスワード', + vaultSaveFootnote: '保存したログイン情報は「設定 → パスワードとログイン」で管理できます。', + vaultSaveDecline: '保存しない', + vaultSaveConfirm: '保存してサインイン' }, desktop: { diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 4ce111c974..342f40e877 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -500,6 +500,8 @@ export interface Translations { lockedDesc: string unlockedDesc: string statusLocked: string + statusNotDetected: string + statusOff: string statusUnlocked: string unlock: string unlocking: string @@ -3379,6 +3381,15 @@ export interface Translations { vaultUnlockSendFailed: string vaultUnlockTitle: (name: string) => string vaultUnlockDesc: (name: string) => string + vaultSaveSendFailed: string + vaultSaveTitle: (site: string) => string + vaultSaveDesc: (origin: string) => string + vaultSaveIdentifierLabel: string + vaultSaveIdentifierPlaceholder: string + vaultSavePasswordPlaceholder: string + vaultSaveFootnote: string + vaultSaveDecline: string + vaultSaveConfirm: string vaultUnlockPlaceholder: string vaultUnlockKeepLocked: string vaultUnlockConfirm: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 839e33d35e..a005904bc0 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -320,21 +320,21 @@ export const zhHant = defineLocale({ about: '關於', billing: '帳單', notifications: '通知', - vault: '憑證保險庫' + vault: '密碼與登入' }, vault: { - title: '憑證保險庫', - blurb: '加密儲存在本機的憑證,代理可用它們登入網站,但永遠看不到密碼。標籤、網站來源與登入識別碼可見;密碼永不可見。', + title: '密碼與登入', + blurb: '說一句「登入 GitHub」,代理就會代你登入。第一次遇到登入頁時它會當場向你索取登入資訊,之後就自動完成。密碼在本機加密儲存並直接填入頁面——模型永遠看不到。', count: n => `已儲存 ${n} 項`, loadFailed: '無法載入保險庫項目', - empty: '尚未儲存任何憑證', - emptyDesc: '新增一組登入憑證後,代理即可代你登入該網站——它會自行輸入使用者名稱,並從保險庫直接填入密碼,全程看不到密碼。', - add: '新增憑證', - addTitle: '新增憑證', + empty: '尚未儲存任何內容', + emptyDesc: '這裡不必手動新增。讓代理登入某個網站時,它會當場向你詢問一次登入資訊。若想提前輸入,可按「新增」。', + add: '新增', + addTitle: '新增登入資訊、信用卡或地址', addDescription: '加密儲存在此裝置上。代理永遠不會看到密碼。', - added: '憑證已儲存到保險庫。', + added: '已儲存。', adding: '儲存中…', - addConfirm: '儲存到保險庫', + addConfirm: '儲存', kindField: '類型', kinds: { login: '登入', payment: '支付卡', address: '地址' }, labelField: '標籤', @@ -363,20 +363,22 @@ export const zhHant = defineLocale({ countryField: '國家/地區', optional: '(選填)', createdOn: date => `新增於 ${date}`, - deleteAction: '刪除憑證', - deleteTitle: '刪除憑證?', + deleteAction: '移除已儲存項目', + deleteTitle: '刪除此項目?', deleteDescription: label => `「${label}」將從加密保險庫中移除。此操作無法復原。`, deleteConfirm: '刪除', sources: { title: '密碼管理器', blurb: - '允許代理使用密碼管理器中儲存的登入資訊登入網站。每個工作階段只需用主密碼解鎖一次,記憶體中僅保留工作階段權杖,代理永遠看不到主密碼或任何密碼。', + '已安裝的密碼管理器會被自動偵測。代理第一次需要其中的登入資訊時會請你解鎖(每個工作階段一次);記憶體中只保留工作階段權杖,代理永遠看不到你的主密碼或任何登入資訊。', toggleFailed: '無法更新密碼管理器', - notInstalled: name => `此電腦上找不到 ${name} CLI。請先安裝,再開啟此功能。`, - disabledDesc: '已關閉。開啟後代理即可使用此管理器中的登入資訊。', - lockedDesc: '已鎖定。可立即解鎖,否則代理在首次需要登入資訊時會詢問你。', + notInstalled: name => `未偵測到。安裝 ${name} 命令列工具並登入後,Hermes 會自動偵測。`, + disabledDesc: '已偵測到,但已為 Hermes 關閉。', + lockedDesc: '已偵測到。代理需要登入資訊時會請你解鎖,也可立即解鎖。', unlockedDesc: '本工作階段已解鎖。閒置 30 分鐘或關閉 Hermes 後會自動鎖定。', statusLocked: '已鎖定', + statusNotDetected: '未偵測到', + statusOff: '已關閉', statusUnlocked: '已解鎖', unlock: '解鎖', unlocking: '解鎖中…', @@ -3295,7 +3297,17 @@ export const zhHant = defineLocale({ `代理想使用儲存在 ${name} 中的登入資訊登入網站。輸入主密碼以在本工作階段解鎖——它會直接交給本機的 ${name},不會被儲存或顯示給代理。`, vaultUnlockPlaceholder: '主密碼', vaultUnlockKeepLocked: '保持鎖定', - vaultUnlockConfirm: '解鎖' + vaultUnlockConfirm: '解鎖', + vaultSaveSendFailed: '無法儲存登入資訊', + vaultSaveTitle: site => `儲存 ${site} 的登入資訊?`, + vaultSaveDesc: origin => + `Hermes 到達了 ${origin} 的登入頁,但沒有為它儲存的登入資訊。在此輸入一次;它會在本機加密儲存並直接填入頁面,模型永遠看不到密碼。`, + vaultSaveIdentifierLabel: '電子郵件或使用者名稱', + vaultSaveIdentifierPlaceholder: 'you@example.com', + vaultSavePasswordPlaceholder: '密碼', + vaultSaveFootnote: '在「設定 → 密碼與登入」中管理已儲存的登入資訊。', + vaultSaveDecline: '不儲存', + vaultSaveConfirm: '儲存並登入' }, desktop: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 2364d7e4b2..577c9e20db 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -426,21 +426,21 @@ export const zh: Translations = { about: '关于', billing: '账单', notifications: '通知', - vault: '凭据保险库' + vault: '密码与登录' }, vault: { - title: '凭据保险库', - blurb: '加密存储在本地的凭据,代理可用它们登录网站,但永远看不到密码。标签、站点来源和登录标识符可见;密码永不可见。', + title: '密码与登录', + blurb: '说一句“登录 GitHub”,智能体就会代你登录。第一次遇到登录页时它会当场向你要登录信息,之后就自动完成。密码在本机加密保存并直接填入页面——模型永远看不到。', count: n => `已保存 ${n} 项`, loadFailed: '无法加载保险库条目', - empty: '尚未保存任何凭据', - emptyDesc: '添加一个登录凭据后,代理即可代你登录该网站——它会自行输入用户名,并从保险库中直接填入密码,全程看不到密码。', - add: '添加凭据', - addTitle: '添加凭据', + empty: '尚未保存任何内容', + emptyDesc: '这里不必手动添加。让智能体登录某个网站时,它会当场向你询问一次登录信息。若想提前录入,可点“添加”。', + add: '添加', + addTitle: '添加登录信息、银行卡或地址', addDescription: '加密保存在本机。代理永远不会看到密码。', - added: '凭据已保存到保险库。', + added: '已保存。', adding: '保存中…', - addConfirm: '保存到保险库', + addConfirm: '保存', kindField: '类型', kinds: { login: '登录', payment: '支付卡', address: '地址' }, labelField: '标签', @@ -469,20 +469,22 @@ export const zh: Translations = { countryField: '国家/地区', optional: '(可选)', createdOn: date => `添加于 ${date}`, - deleteAction: '删除凭据', - deleteTitle: '删除凭据?', + deleteAction: '移除已保存项', + deleteTitle: '删除此项?', deleteDescription: label => `“${label}”将从加密保险库中移除。此操作无法撤销。`, deleteConfirm: '删除', sources: { title: '密码管理器', blurb: - '允许智能体使用密码管理器中保存的登录信息登录网站。每个会话只需用主密码解锁一次,内存中仅保留会话令牌,智能体永远看不到主密码或任何密码。', + '已安装的密码管理器会被自动识别。智能体第一次需要其中的登录信息时会请你解锁(每个会话一次);内存中只保留会话令牌,智能体永远看不到你的主密码或任何登录信息。', toggleFailed: '无法更新密码管理器', - notInstalled: name => `此电脑上未找到 ${name} CLI。请先安装,然后再开启。`, - disabledDesc: '已关闭。开启后智能体可使用此管理器中的登录信息。', - lockedDesc: '已锁定。可立即解锁,否则智能体在首次需要登录信息时会询问你。', + notInstalled: name => `未检测到。安装 ${name} 命令行工具并登录后,Hermes 会自动识别。`, + disabledDesc: '已检测到,但已为 Hermes 关闭。', + lockedDesc: '已检测到。智能体需要登录信息时会请你解锁,也可立即解锁。', unlockedDesc: '本会话已解锁。闲置 30 分钟或关闭 Hermes 后会自动锁定。', statusLocked: '已锁定', + statusNotDetected: '未检测到', + statusOff: '已关闭', statusUnlocked: '已解锁', unlock: '解锁', unlocking: '解锁中…', @@ -4012,7 +4014,17 @@ export const zh: Translations = { `智能体想使用保存在 ${name} 中的登录信息登录网站。输入主密码以在本会话中解锁——它会直接交给本机的 ${name},不会被存储或展示给智能体。`, vaultUnlockPlaceholder: '主密码', vaultUnlockKeepLocked: '保持锁定', - vaultUnlockConfirm: '解锁' + vaultUnlockConfirm: '解锁', + vaultSaveSendFailed: '无法保存登录信息', + vaultSaveTitle: site => `保存 ${site} 的登录信息?`, + vaultSaveDesc: origin => + `Hermes 到达了 ${origin} 的登录页,但没有为它保存的登录信息。在此输入一次;它将在本机加密保存并直接填入页面,模型永远看不到密码。`, + vaultSaveIdentifierLabel: '邮箱或用户名', + vaultSaveIdentifierPlaceholder: 'you@example.com', + vaultSavePasswordPlaceholder: '密码', + vaultSaveFootnote: '在“设置 → 密码与登录”中管理已保存的登录信息。', + vaultSaveDecline: '不保存', + vaultSaveConfirm: '保存并登录' }, desktop: { diff --git a/apps/desktop/src/lib/chat-messages/types.ts b/apps/desktop/src/lib/chat-messages/types.ts index df5046467a..1399551471 100644 --- a/apps/desktop/src/lib/chat-messages/types.ts +++ b/apps/desktop/src/lib/chat-messages/types.ts @@ -120,6 +120,9 @@ export type GatewayEventPayload = { // vault.unlock.request (external password-manager unlock) backend?: string display_name?: string + /** vault.save_login.request */ + origin?: string + site?: string // terminal.read.request / preview.read.request (GUI agent reading the // in-app terminal pane or the browser/preview pane) start?: number diff --git a/apps/desktop/src/lib/gateway-events.ts b/apps/desktop/src/lib/gateway-events.ts index 2ec1a64259..af503ff6d9 100644 --- a/apps/desktop/src/lib/gateway-events.ts +++ b/apps/desktop/src/lib/gateway-events.ts @@ -42,6 +42,8 @@ export const UNSCOPED_STREAM_EVENT_TYPES = new Set([ 'tool.generating', 'tool.progress', 'tool.start', + 'vault.save_login.expire', + 'vault.save_login.request', 'vault.unlock.expire', 'vault.unlock.request' ]) diff --git a/apps/desktop/src/store/prompts.ts b/apps/desktop/src/store/prompts.ts index 0589245f7d..f82103b558 100644 --- a/apps/desktop/src/store/prompts.ts +++ b/apps/desktop/src/store/prompts.ts @@ -119,6 +119,16 @@ const sudo = keyedPromptStore() const secret = keyedPromptStore() const vaultUnlock = keyedPromptStore() +// "Save this login" for the page the agent is on (tools/browser_vault_tool). Resolved via +// vault.save_login.respond {request_id, login: JSON {identifier, password}}; "" declines. +export interface VaultSaveLoginRequest extends KeyedPrompt { + origin: string + site: string + requestId: string +} + +const vaultSave = keyedPromptStore() + // Inline approval anchors, keyed by session: a tile's inline bar mounting must // not suppress the PRIMARY session's floating fallback (and vice versa). const $approvalInlineAnchors = atom>({}) @@ -232,14 +242,21 @@ export const $vaultUnlockRequests = vaultUnlock.$all export const sessionVaultUnlockRequest = (sessionId: string | null) => computed(vaultUnlock.$all, all => all[keyFor(sessionId)] ?? null) +export const $vaultSaveLoginRequest = vaultSave.$active +export const setVaultSaveLoginRequest = vaultSave.set +export const clearVaultSaveLoginRequest = vaultSave.clear +export const $vaultSaveLoginRequests = vaultSave.$all +export const sessionVaultSaveLoginRequest = (sessionId: string | null) => + computed(vaultSave.$all, all => all[keyFor(sessionId)] ?? null) + // True when the active session is blocked on the user (clarify question or an // approval / sudo / secret prompt). Mirrors the pet's `awaitingInput` concept // (agent/pet/state.py): the turn is paused on you, not working — so callers can // suppress "thinking" indicators and the Esc-to-interrupt shortcut while you // decide, instead of treating the wait as an in-flight turn. export const $activeSessionAwaitingInput = computed( - [$clarifyRequest, $approvalRequest, $sudoRequest, $secretRequest, $vaultUnlockRequest], - (clarify, approval, sudo, secret, vault) => Boolean(clarify || approval || sudo || secret || vault) + [$clarifyRequest, $approvalRequest, $sudoRequest, $secretRequest, $vaultUnlockRequest, $vaultSaveLoginRequest], + (clarify, approval, sudo, secret, vault, save) => Boolean(clarify || approval || sudo || secret || vault || save) ) /** True when `sessionId` is parked on a blocking prompt that typing cannot @@ -252,7 +269,11 @@ export const hasBlockingPromptRequest = (sessionId: string | null | undefined): const key = keyFor(sessionId) return Boolean( - approval.$all.get()[key] || sudo.$all.get()[key] || secret.$all.get()[key] || vaultUnlock.$all.get()[key] + approval.$all.get()[key] || + sudo.$all.get()[key] || + secret.$all.get()[key] || + vaultUnlock.$all.get()[key] || + vaultSave.$all.get()[key] ) } @@ -260,22 +281,25 @@ export const hasBlockingPromptRequest = (sessionId: string | null | undefined): * affordance (the primary button must advertise queue, not steer, while the * turn is parked on a prompt Enter can't answer). */ export const sessionBlockingPrompt = (sessionId: string | null) => - computed([approval.$all, sudo.$all, secret.$all, vaultUnlock.$all], (approvals, sudos, secrets, vaults) => { - const key = keyFor(sessionId) + computed( + [approval.$all, sudo.$all, secret.$all, vaultUnlock.$all, vaultSave.$all], + (approvals, sudos, secrets, vaults, saves) => { + const key = keyFor(sessionId) - return Boolean(approvals[key] || sudos[key] || secrets[key] || vaults[key]) - }) + return Boolean(approvals[key] || sudos[key] || secrets[key] || vaults[key] || saves[key]) + } + ) /** Per-session `awaitingInput` — the tile composer's counterpart of * `$activeSessionAwaitingInput` (same sources, fixed session instead of the * active one). */ export function sessionAwaitingInput(sessionId: string | null) { return computed( - [$clarifyRequests, approval.$all, sudo.$all, secret.$all, vaultUnlock.$all], - (clarify, approvals, sudos, secrets, vaults) => { + [$clarifyRequests, approval.$all, sudo.$all, secret.$all, vaultUnlock.$all, vaultSave.$all], + (clarify, approvals, sudos, secrets, vaults, saves) => { const key = keyFor(sessionId) - return Boolean(clarify[key] || approvals[key] || sudos[key] || secrets[key] || vaults[key]) + return Boolean(clarify[key] || approvals[key] || sudos[key] || secrets[key] || vaults[key] || saves[key]) } ) } @@ -288,6 +312,7 @@ export function clearAllPrompts(sessionId?: string | null): void { sudo.reset() secret.reset() vaultUnlock.reset() + vaultSave.reset() $approvalInlineAnchors.set({}) return @@ -297,4 +322,5 @@ export function clearAllPrompts(sessionId?: string | null): void { sudo.clear(sessionId) secret.clear(sessionId) vaultUnlock.clear(sessionId) + vaultSave.clear(sessionId) } diff --git a/cli.py b/cli.py index 97ce5b9fbb..2ac19d44f8 100644 --- a/cli.py +++ b/cli.py @@ -3000,8 +3000,9 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix set_sudo_password_callback(self._sudo_password_callback) set_approval_callback(self._approval_callback) set_secret_capture_callback(self._secret_capture_callback) - from agent.vault_backends.unlock import set_unlock_prompt_callback + from agent.vault_backends.unlock import set_save_login_prompt_callback, set_unlock_prompt_callback set_unlock_prompt_callback(self._vault_unlock_callback) + set_save_login_prompt_callback(self._vault_save_login_callback) try: from tools.computer_use_tool import set_approval_callback as _set_cu_cb @@ -3942,9 +3943,10 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix with suppress(Exception): from tools.voice_mode import cleanup_temp_recordings cleanup_temp_recordings() - from agent.vault_backends.unlock import lock as _vault_lock, set_unlock_prompt_callback + from agent.vault_backends.unlock import (lock as _vault_lock, set_save_login_prompt_callback, + set_unlock_prompt_callback) for _unset in (set_sudo_password_callback, set_approval_callback, set_secret_capture_callback, - set_unlock_prompt_callback): + set_unlock_prompt_callback, set_save_login_prompt_callback): _unset(None) _vault_lock() # session tokens for external password managers die with the session # On SIGHUP/SIGTERM the agent thread may be reaped before its own persistence runs. diff --git a/hermes_cli/cli_chat_turn_mixin.py b/hermes_cli/cli_chat_turn_mixin.py index 43577ef955..52a9049038 100644 --- a/hermes_cli/cli_chat_turn_mixin.py +++ b/hermes_cli/cli_chat_turn_mixin.py @@ -278,12 +278,13 @@ class CLIChatTurnMixin: _prepend_note_to_message, set_approval_callback, set_secret_capture_callback, set_sudo_password_callback, ) - from agent.vault_backends.unlock import set_unlock_prompt_callback + from agent.vault_backends.unlock import set_save_login_prompt_callback, set_unlock_prompt_callback # terminal_tool callbacks are thread-local: run()'s registration is invisible here. set_sudo_password_callback(self._sudo_password_callback) set_approval_callback(self._approval_callback) set_secret_capture_callback(self._secret_capture_callback) set_unlock_prompt_callback(self._vault_unlock_callback) + set_save_login_prompt_callback(self._vault_save_login_callback) # Bind the approval session key so ``is_current_session_yolo_enabled()`` resolves # against the same key ``/yolo`` toggles under (``enable_session_yolo(self.session_id)``). try: @@ -344,6 +345,7 @@ class CLIChatTurnMixin: set_approval_callback(None) set_secret_capture_callback(None) set_unlock_prompt_callback(None) + set_save_login_prompt_callback(None) except Exception: pass # Unbind the per-turn key; ``_session_yolo`` state itself persists across turns. diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 8549b489e4..935a9c4777 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1921,10 +1921,11 @@ class CLICommandsMixin: runtime = turn_route["runtime"] def produce(): - from agent.vault_backends.unlock import set_unlock_prompt_callback + from agent.vault_backends.unlock import set_save_login_prompt_callback, set_unlock_prompt_callback set_sudo_password_callback(self._sudo_password_callback) set_approval_callback(self._approval_callback) set_unlock_prompt_callback(self._vault_unlock_callback) + set_save_login_prompt_callback(self._vault_save_login_callback) with suppress(Exception): set_secret_capture_callback(self._secret_capture_callback) try: @@ -1963,6 +1964,7 @@ class CLICommandsMixin: set_approval_callback(None) set_secret_capture_callback(None) set_unlock_prompt_callback(None) + set_save_login_prompt_callback(None) def done(): self._background_tasks.pop(task_id, None) diff --git a/hermes_cli/cli_modal_mixin.py b/hermes_cli/cli_modal_mixin.py index 53a5fbd3cd..27d2c97d64 100644 --- a/hermes_cli/cli_modal_mixin.py +++ b/hermes_cli/cli_modal_mixin.py @@ -875,6 +875,33 @@ class CLIModalMixin: _cprint(f"\n{_DIM} ✓ Unlocking {display_name} for this session{_RST}") return result + def _vault_save_login_callback(self, origin: str, site: str): + """Two-step "save this login" prompt (identifier shown, password masked) on the sudo panel; the + answer goes to the vault store, never to the model. None = declined.""" + from cli import _DIM, _RST, _cprint + + answer: dict = {} + for step in ("identifier", "password"): + response_queue = queue.Queue() + self._capture_modal_input_snapshot() + self._sudo_state = {"response_queue": response_queue, "vault_save": {"site": site, "origin": origin, + "step": step}} + self._sudo_deadline = _time.monotonic() + 180 + if step == "identifier": + self._ring_bell(prompt=True, context=f"save login for {site}") + self._paint_now() + result = self._poll_modal_queue(response_queue, "_sudo_deadline", refresh=0) + self._sudo_state = None + self._sudo_deadline = 0 + self._restore_modal_input_snapshot() + self._paint_now() + if result is _TIMED_OUT or not result: + _cprint(f"\n{_DIM} ⏭ Not saving a login for {site}{_RST}") + return None + answer[step] = result + _cprint(f"\n{_DIM} ✓ Login for {site} saved to your vault{_RST}") + return answer + def _secret_capture_callback(self, var_name: str, prompt: str, metadata=None) -> dict: self._capture_modal_input_snapshot() try: diff --git a/hermes_cli/cli_tui_mixin.py b/hermes_cli/cli_tui_mixin.py index 7e762c6d4d..e2aa84affa 100644 --- a/hermes_cli/cli_tui_mixin.py +++ b/hermes_cli/cli_tui_mixin.py @@ -688,6 +688,18 @@ class CLITuiMixin: def _get_sudo_display_fragments(self): if not self._sudo_state: return [] + if save := self._sudo_state.get("vault_save"): + if save["step"] == "identifier": + return self._render_sudo_style_panel( + f'🔐 Save login for {save["site"]}', + ['The agent reached a sign-in page with no saved login for this site.', + 'Type the email / username you sign in with (shown), then Enter.', + 'Enter on an empty line skips. Nothing here is shown to the model.']) + return self._render_sudo_style_panel( + f'🔐 Save login for {save["site"]}', + ['Now the password (hidden). It is encrypted on this machine, bound to', + f'{save["origin"]}, and filled into the page without the model ever seeing it.', + 'Enter on an empty line skips.']) if backend := self._sudo_state.get("vault_backend"): return self._render_sudo_style_panel( f'🔐 Unlock {backend}', @@ -719,6 +731,8 @@ class CLITuiMixin: def _tui_hint_text(self): for state_attr, deadline_attr, hint in self._TUI_MODAL_HINTS: if getattr(self, state_attr): + if state_attr == "_sudo_state" and (self._sudo_state.get("vault_save") or {}).get("step") == "identifier": + hint = ' shown as you type · Enter to continue' remaining = max(0, int(getattr(self, deadline_attr) - time.monotonic())) return [('class:hint', hint), ('class:clarify-countdown', f' ({remaining}s)')] if self._clarify_state: @@ -749,6 +763,8 @@ class CLITuiMixin: if self._voice_processing: return "transcribing..." if self._sudo_state: + if (self._sudo_state.get("vault_save") or {}).get("step") == "identifier": + return "type your email / username, Enter to continue · ESC to skip" return "type password (hidden), Enter to submit · ESC to skip" if self._secret_state: return "type secret (hidden), Enter to submit · ESC to skip" @@ -2154,7 +2170,9 @@ class CLITuiMixin: # Mask input with '*' while a sudo/secret prompt is active. input_area.control.input_processors.append(ConditionalProcessor( PasswordProcessor(), - filter=Condition(lambda: bool(cli_ref._sudo_state) or bool(cli_ref._secret_state)))) + filter=Condition(lambda: (bool(cli_ref._sudo_state) + and (cli_ref._sudo_state.get("vault_save") or {}).get("step") != "identifier") + or bool(cli_ref._secret_state)))) class _PlaceholderProcessor(Processor): """Render grayed-out placeholder text inside the input when empty.""" diff --git a/hermes_cli/vault.py b/hermes_cli/vault.py index 9998626711..1f95662f06 100644 --- a/hermes_cli/vault.py +++ b/hermes_cli/vault.py @@ -136,7 +136,7 @@ def _cmd_list(args) -> None: def _cmd_sources(args) -> None: - """Show/enable/disable the external password managers (`vault..enabled`).""" + """Show the detected password managers; `--disable`/`--enable` flip the opt-out (`vault..enabled`).""" from agent.vault_backends import enabled_backends from agent.vault_backends.base import external_backend_classes, is_installed from hermes_cli.config import load_config, save_config @@ -149,19 +149,24 @@ def _cmd_sources(args) -> None: c.print(f"[red]Unknown password manager {name!r}[/] (expected one of {', '.join(classes)})") return cfg = load_config() - cfg.setdefault("vault", {}).setdefault(name, {})["enabled"] = bool(args.enable) + section = cfg.setdefault("vault", {}).setdefault(name, {}) + if args.enable: + section.pop("enabled", None) # detected managers are on by default; drop the opt-out + else: + section["enabled"] = False save_config(cfg) - state = "enabled" if args.enable else "disabled" - c.print(f"[green]{classes[name].display_name} {state}[/] for browser logins.") - if args.enable and name == "bitwarden": - c.print("[dim]Run `bw login` once in a terminal first; Hermes only ever unlocks, never logs in.[/]") + c.print(f"[green]{classes[name].display_name} {'on' if args.enable else 'off'}[/] for browser logins.") return enabled = {b.name for b in enabled_backends()} for name, cls in classes.items(): - status = "[green]on[/]" if name in enabled else "[dim]off[/]" - cli = "" if is_installed(name) else " [yellow](CLI not found)[/]" - c.print(f" {cls.display_name:<10} {status}{cli}") - c.print("[dim]Toggle with `hermes vault sources --enable onepassword` / `--disable bitwarden`.[/]") + if name in enabled: + status = "[green]detected[/] · the agent asks you to unlock it when it needs a login" + elif is_installed(name): + status = "[dim]turned off[/] (`hermes vault sources --enable {name}` to use it)".format(name=name) + else: + status = "[dim]not installed[/]" + c.print(f" {cls.display_name:<10} {status}") + c.print("[dim]Managers are picked up automatically when their CLI is installed and signed in.[/]") def _cmd_rm(args) -> None: @@ -180,7 +185,7 @@ def register_cli(subparser) -> None: p_add = subs.add_parser( "add", - help="Add a credential to the vault (interactive; secrets never echoed)", + help="Save a login, card or address ahead of time (optional: the agent asks you on the page when it needs one)", ) p_add.add_argument( "--kind", choices=["login", "payment", "address"], default=None, @@ -195,10 +200,10 @@ def register_cli(subparser) -> None: p_rm.add_argument("handle", help="Item handle (see `hermes vault list`)") p_rm.set_defaults(_vault_handler=_cmd_rm) - p_src = subs.add_parser("sources", help="Show or toggle password managers (1Password, Bitwarden) as login sources") + p_src = subs.add_parser("sources", help="Show detected password managers (1Password, Bitwarden); they are on automatically") group = p_src.add_mutually_exclusive_group() - group.add_argument("--enable", metavar="NAME", help="Enable a manager: onepassword | bitwarden") - group.add_argument("--disable", metavar="NAME", help="Disable a manager") + group.add_argument("--disable", metavar="NAME", help="Stop using a detected manager: onepassword | bitwarden") + group.add_argument("--enable", metavar="NAME", help="Undo --disable") p_src.set_defaults(_vault_handler=_cmd_sources) diff --git a/tests/test_browser_vault.py b/tests/test_browser_vault.py index 3de9d12fa9..7f097b4c8d 100644 --- a/tests/test_browser_vault.py +++ b/tests/test_browser_vault.py @@ -260,19 +260,17 @@ class TestClassifier: # --------------------------------------------------------------------------- class TestBrowserVaultTools: - def test_check_fn_false_when_vault_empty(self, tmp_path): + def test_check_fn_follows_the_browser_not_the_item_count(self, tmp_path): + """The vault tools ride with the browser toolset: an empty vault must still expose + browser_vault_save_login (that is how the first login gets saved), and no browser means no tools.""" from tools import browser_vault_tool empty = VaultStore(base_dir=tmp_path / "empty-vault") with patch("agent.vault_store.get_vault_store", return_value=empty): - assert browser_vault_tool._check_vault_available() is False - - def test_check_fn_true_with_items(self, store): - from tools import browser_vault_tool - - _add_login(store) - with patch("agent.vault_store.get_vault_store", return_value=store): - assert browser_vault_tool._check_vault_available() is True + with patch("tools.browser_tool_install.check_browser_requirements", return_value=True): + assert browser_vault_tool._check_vault_available() is True + with patch("tools.browser_tool_install.check_browser_requirements", return_value=False): + assert browser_vault_tool._check_vault_available() is False def test_list_returns_identifier_never_password(self, store): from tools import browser_vault_tool @@ -605,3 +603,62 @@ def test_every_registered_tool_schema_declares_openai_style_parameters(): missing = [entry.name for entry in registry.get_all_entries() if "parameters" not in entry.schema or "input_schema" in entry.schema] assert not missing, missing + + +class TestSaveLoginPrompt: + """browser_vault_save_login: the surface prompt supplies the login, the tool stores it bound to the page + origin and fills. The password must never come back in the tool result.""" + + def test_saves_to_page_origin_and_never_echoes_the_password(self, store, monkeypatch): + from agent.vault_backends import unlock as unlock_mod + from tools import browser_vault_tool + + seen = {} + + def prompt(origin, site): + seen["origin"], seen["site"] = origin, site + return {"identifier": "tek@acme.test", "password": "hunter2-very-secret"} + + unlock_mod.set_save_login_prompt_callback(prompt) + monkeypatch.setattr(browser_vault_tool, "_current_page_origin", lambda task_id: "https://acme.test") + monkeypatch.setattr(browser_vault_tool, "browser_vault_fill", + lambda handle, task_id=None: json.dumps({"success": True, "filled_fields": 1})) + with patch("agent.vault_store.get_vault_store", return_value=store), \ + patch("agent.vault_backends.unlock.can_prompt_here", return_value=True): + out = json.loads(browser_vault_tool.browser_vault_save_login(task_id="t1")) + unlock_mod.set_save_login_prompt_callback(None) + + assert out["success"] is True and out["identifier"] == "tek@acme.test" + assert "hunter2" not in json.dumps(out) + assert seen == {"origin": "https://acme.test", "site": "acme.test"} + [meta] = store.list_items() + assert meta.origin == "https://acme.test" and meta.identifier == "tek@acme.test" + + def test_declined_or_headless_stores_nothing(self, store, monkeypatch): + from agent.vault_backends import unlock as unlock_mod + from tools import browser_vault_tool + + monkeypatch.setattr(browser_vault_tool, "_current_page_origin", lambda task_id: "https://acme.test") + with patch("agent.vault_store.get_vault_store", return_value=store): + unlock_mod.set_save_login_prompt_callback(lambda origin, site: None) + with patch("agent.vault_backends.unlock.can_prompt_here", return_value=True): + declined = json.loads(browser_vault_tool.browser_vault_save_login()) + with patch("agent.vault_backends.unlock.can_prompt_here", return_value=False): + headless = json.loads(browser_vault_tool.browser_vault_save_login()) + unlock_mod.set_save_login_prompt_callback(None) + assert declined["error_type"] == "save_declined" + assert headless["error_type"] == "prompt_unavailable" + assert store.list_items() == [] + + +class TestManagerAutoDetection: + def test_installed_manager_is_a_source_without_config_and_config_can_opt_out(self): + from agent.vault_backends import base + + with patch.object(base, "is_installed", return_value=True): + with patch.object(base, "_cfg", return_value={}): + assert {b.name for b in base.enabled_backends()} == {"local", "onepassword", "bitwarden"} + with patch.object(base, "_cfg", return_value={"bitwarden": {"enabled": False}}): + assert {b.name for b in base.enabled_backends()} == {"local", "onepassword"} + with patch.object(base, "is_installed", return_value=False), patch.object(base, "_cfg", return_value={}): + assert [b.name for b in base.enabled_backends()] == ["local"] diff --git a/tools/browser_vault_tool.py b/tools/browser_vault_tool.py index b897f0475c..dfa5b05eef 100644 --- a/tools/browser_vault_tool.py +++ b/tools/browser_vault_tool.py @@ -39,13 +39,12 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- def _check_vault_available() -> bool: - """Schema-gate: the tools appear only when the local vault has items or an external manager is enabled. - Registered uncached: the answer is per profile (vault dir + config) and the registry's TTL cache is keyed - per profile only under multiplex; the probe is a local file stat, cheap enough to run every pass.""" + """Schema-gate: the vault tools ride with the browser. An empty vault still needs + browser_vault_save_login so the agent can offer to remember a login the first time it meets a + form; hiding the tools until an item exists meant nobody ever discovered the feature.""" try: - from agent.vault_backends import enabled_backends - from agent.vault_store import get_vault_store - return get_vault_store().has_items() or any(b.needs_unlock for b in enabled_backends()) + from tools.browser_tool_install import check_browser_requirements + return bool(check_browser_requirements()) except Exception: return False @@ -234,6 +233,9 @@ def browser_vault_list() -> str: entry["identifier_type"] = meta.identifier_type items.append(entry) out: Dict[str, Any] = {"success": True, "items": items} + if not items: + out["hint"] = ("No saved logins. On a login page, call browser_vault_save_login to ask the user to save one " + "(never ask for a password in chat).") if locked: out["locked"] = locked if errors: @@ -270,6 +272,43 @@ def browser_vault_unlock(backend_name: str) -> str: return json.dumps({"success": True, "backend": backend.name}) +def browser_vault_save_login(label: str = "", task_id: Optional[str] = None) -> str: + """Ask the user (masked prompt on their surface) for the login of the CURRENT page, store it in the local + vault bound to that origin, and fill the password at once. The values never enter the conversation.""" + from agent.vault_backends.unlock import can_prompt_here, get_save_login_prompt_callback + from agent.vault_store import get_vault_store + + effective_task_id = task_id or "default" + origin = _current_page_origin(effective_task_id) + if not origin: + return json.dumps({"success": False, "error": "Open the site's login page first; the login is saved for that page's origin."}) + prompt = get_save_login_prompt_callback() + if prompt is None or not can_prompt_here(): + return json.dumps({"success": False, "error_type": "prompt_unavailable", + "error": (f"This session cannot ask the user for a login (headless/cron/API). Tell them to run " + f"`hermes vault add` or use Desktop → Settings → Passwords & Logins for {origin}.")}) + host = origin.split("://", 1)[-1] + site = label.strip() or host + answer = prompt(origin, host) # the prompt names the site by host: the user recognises URLs, not agent labels + if not answer or not answer.get("password") or not answer.get("identifier"): + return json.dumps({"success": False, "error_type": "save_declined", + "error": "The user chose not to save a login for this site. Do not ask again this turn."}) + identifier = str(answer["identifier"]).strip() + id_type = "email" if "@" in identifier else ("phone" if identifier.lstrip("+").isdigit() else "username") + try: + meta = get_vault_store().add_item("login", site, {"identifier_type": id_type, "identifier": identifier, + "password": str(answer["password"])}, origin=origin) + except Exception as exc: + return json.dumps({"success": False, "error_type": "save_failed", "error": str(exc)[:200]}) + finally: + answer.clear() + filled = json.loads(browser_vault_fill(meta.id, task_id=effective_task_id)) + return json.dumps({"success": True, "handle": meta.id, "origin": origin, "identifier": identifier, + "identifier_type": id_type, "fill": filled, + "next": "Type the identifier into the username field if the form has one, then submit."}, + ensure_ascii=False) + + def browser_vault_fill(handle: str, task_id: Optional[str] = None) -> str: """Fill the current page's password field from a vault handle. @@ -500,6 +539,27 @@ BROWSER_VAULT_FILL_SCHEMA = { } +BROWSER_VAULT_SAVE_LOGIN_SCHEMA = { + "name": "browser_vault_save_login", + "description": ( + "The current page is a login form and browser_vault_list has no item for its origin: ask the user, " + "through a masked prompt in their UI, to save the login for this site. Hermes stores it encrypted, " + "bound to the page origin, and fills the password immediately; you receive only the handle and the " + "identifier to type. Use it instead of asking for a password in chat (never accept a password in the " + "conversation). A save_declined result means stop asking for this turn." + ), + "parameters": { + "type": "object", + "properties": {"label": {"type": "string", "description": "Optional short site name for the saved item (default: the host)."}}, + "required": [], + }, +} + + +def _handle_vault_save_login(args: Dict[str, Any], **kwargs) -> str: + return browser_vault_save_login(label=str(args.get("label") or ""), task_id=kwargs.get("task_id")) + + def _handle_vault_list(args: Dict[str, Any], **kwargs) -> str: return browser_vault_list() @@ -536,6 +596,15 @@ registry.register( emoji="🔐", ) +registry.register( + name="browser_vault_save_login", + toolset="browser", + schema=BROWSER_VAULT_SAVE_LOGIN_SCHEMA, + handler=_handle_vault_save_login, + check_fn=_check_vault_available, + emoji="🔐", +) + registry.register( name="browser_vault_fill", toolset="browser", diff --git a/tools/thread_context.py b/tools/thread_context.py index 7dc95a1db7..06e51e61b5 100644 --- a/tools/thread_context.py +++ b/tools/thread_context.py @@ -28,7 +28,8 @@ def _callback_api(): return ((tt._get_approval_callback, tt.set_approval_callback), (tt._get_sudo_password_callback, tt.set_sudo_password_callback), - (vault_unlock.get_unlock_prompt_callback, vault_unlock.set_unlock_prompt_callback)) + (vault_unlock.get_unlock_prompt_callback, vault_unlock.set_unlock_prompt_callback), + (vault_unlock.get_save_login_prompt_callback, vault_unlock.set_save_login_prompt_callback)) def propagate_context_to_thread(target: Callable) -> Callable: diff --git a/tui_gateway/agent_callbacks.py b/tui_gateway/agent_callbacks.py index 66e8b50753..03f729889b 100644 --- a/tui_gateway/agent_callbacks.py +++ b/tui_gateway/agent_callbacks.py @@ -4,6 +4,8 @@ globals at install time (method_ctx.bind_module), so they reference server.py gl from __future__ import annotations +import json + import contextlib import threading @@ -171,11 +173,23 @@ 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_unlock_prompt_callback + from agent.vault_backends.unlock import (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)) + def save_login_cb(origin, site): + # The renderer shows identifier + masked password; the JSON answer goes straight to the vault store. + raw = _block("vault.save_login.request", sid, {"origin": origin, "site": site}, timeout=180) + try: + data = json.loads(raw) if raw else None + except ValueError: + return None + return data if isinstance(data, dict) and data.get("password") else None + + set_save_login_prompt_callback(save_login_cb) + def _available_personalities(cfg: dict | None = None) -> dict: """Built-ins + user overrides, via hermes_cli.personality (single owner).""" diff --git a/tui_gateway/methods_prompt.py b/tui_gateway/methods_prompt.py index d3e8455566..8ecea3b631 100644 --- a/tui_gateway/methods_prompt.py +++ b/tui_gateway/methods_prompt.py @@ -1097,7 +1097,8 @@ def _(rid, params: dict) -> dict: _LATE_RESPOND_KEYS = { "terminal.read.respond": "text", "preview.read.respond": "text", "preview.act.respond": "text", "window.read.respond": "text", "tour.respond": "text", "mcp.setup.respond": "result", - "sudo.respond": "password", "secret.respond": "value", "vault.unlock.respond": "password"} + "sudo.respond": "password", "secret.respond": "value", "vault.unlock.respond": "password", + "vault.save_login.respond": "login"} for _name, _key in _LATE_RESPOND_KEYS.items(): method(_name)(lambda rid, params, _k=_key: _respond(rid, params, _k, allow_expired=True)) del _name, _key diff --git a/tui_gateway/methods_vault.py b/tui_gateway/methods_vault.py index 67918b77bb..76fa7bd0d9 100644 --- a/tui_gateway/methods_vault.py +++ b/tui_gateway/methods_vault.py @@ -97,7 +97,10 @@ def _(rid, params: dict) -> dict: enabled = bool(params.get("enabled")) cfg = load_config() section = cfg.setdefault("vault", {}).setdefault(name, {}) - section["enabled"] = enabled + if enabled: + section.pop("enabled", None) # detected managers are on by default; this removes the opt-out + else: + section["enabled"] = False if not enabled: lock(name) save_config(cfg) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index c34dbf05f2..5a89d346cd 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1247,7 +1247,8 @@ def _enable_gateway_prompts() -> None: # Blocking bridges whose `*.respond` tolerates a late reply (allow_expired=True): on timeout the tool # returns empty, but a slow renderer could still answer and hit a raw 4009 — `.expire` tears the card down. _EXPIRING_REQUESTS = frozenset({ - "secret.request", "sudo.request", "vault.unlock.request", "clarify.request", "terminal.read.request", + "secret.request", "sudo.request", "vault.unlock.request", "vault.save_login.request", "clarify.request", + "terminal.read.request", "preview.read.request", "preview.act.request", "window.read.request", "mcp.setup.request", "tour.request", }) diff --git a/website/docs/user-guide/features/credential-vault.md b/website/docs/user-guide/features/credential-vault.md index 7a0bcc051c..68e1eb7b63 100644 --- a/website/docs/user-guide/features/credential-vault.md +++ b/website/docs/user-guide/features/credential-vault.md @@ -1,237 +1,103 @@ -# Credential Vault (Password-Blind Autofill) +--- +title: Passwords & Logins +description: The agent signs into sites, pays and fills addresses for you without ever seeing a password. +--- -Let the agent log into websites **without ever seeing the password**, using -logins from a locally encrypted vault or from your password manager -(1Password, Bitwarden). The login identifier -(email/username/phone) is ordinary metadata the agent can see and type -itself; only the password is vault-secret — it is resolved server-side and -injected directly into the page. +# Passwords & Logins -## How it works +Say **"log into GitHub"** and the agent signs in for you. The first time it +reaches a sign-in page it has no login for, it asks you, right there, in a +masked prompt. After that it just works. Passwords are encrypted on this +machine and injected straight into the page; the model never sees them. -1. You add a credential with `hermes vault add` (interactive; the - identifier is prompted normally, the password is read with a hidden - prompt and never echoed or passed on the command line). -2. The password is encrypted at rest under `~/.hermes/vault/` (Fernet key + - vault file, both `0600`) and the item is bound to an exact **origin** - (`scheme://host[:port]`). The identifier is stored as item metadata. -3. When the vault has at least one item, two browser tools appear in the - agent's toolset (they add zero schema cost otherwise): - - `browser_vault_list` — handles + metadata, including the login - identifier. Passwords are never returned. - - `browser_vault_fill(handle)` — fills **only the password field** of - the current page's login form. -4. The agent types the identifier itself with its normal input tools, then - calls `browser_vault_fill`. Hermes checks that the **current page origin - exactly matches** the credential's bound origin — once up front, and - again synchronously inside the injected fill script immediately before - the write (so a page that navigates mid-flight gets a refusal and zero - bytes written). It classifies visible login fields (ported from - OpenInstinct's login-control classifier — autocomplete tokens win, - `new-password` / `one-time-code` fields are hard-excluded), picks the - single best current-password field, injects the value over the - supervised browser session's direct CDP WebSocket, and returns only - `{filled_fields, kind, origin, success}`. +There is nothing to set up. -The password does not appear in the fill's tool result, logs, or the -session database, and its exact bytes are registered with the browser-result -redaction boundary so a later `browser_*` read that echoes the page's DOM is -scrubbed. See *What this does and does not guarantee* below for the limits. +## What it looks like -## CLI +**CLI / TUI** -```bash -# Add a login (interactive wizard; password is hidden) -hermes vault add - -# List items — identifiers and origins shown, passwords never -hermes vault list - -# Remove an item by handle -hermes vault rm vault_ab12cd34ef56 +``` +🔐 Save login for github.com + The agent reached a sign-in page with no saved login for this site. + Type the email / username you sign in with (shown), then Enter. + ... + Now the password (hidden). It is encrypted on this machine, bound to + https://github.com, and filled into the page without the model ever seeing it. ``` -Item kinds: `login` (the password is the secret; the identifier is visible -metadata the agent types itself), `payment` (card number, cardholder, expiry, -CVC, billing postal code) and `address`. Every kind is bound to the site -origin it may be filled on. +**Desktop** — a "Save your github.com login?" card with an identifier field and +a masked password field. *Save & sign in* stores it and continues; *Don't save* +tells the agent to stop asking for this turn. -### Paying and filling addresses +From then on the agent lists your saved logins, types the identifier itself and +fills the password through Hermes. The tool result it sees is +`{filled_fields: 1, origin: "https://github.com"}`; the password is also +registered with the redactor so a later page read cannot echo it back. -A `payment` or `address` item fills the matching checkout fields the same way -a login fills the password: the agent calls `browser_vault_fill` with the -handle, Hermes classifies the page's controls (`autocomplete` tokens first, -then label/name heuristics: "Card number", "Expiry (MM/YY)", "CVC", "ZIP", -country and state `