feat(vault): zero-setup UX — save a login on the page that needs it, managers auto-detected, one "Passwords & Logins" surface
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.
This commit is contained in:
@@ -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.<name>.enabled: false``).
|
||||
Zero-config on purpose: a user with ``bw``/``op`` on PATH should never have to discover a toggle."""
|
||||
section = _cfg().get(name) or {}
|
||||
if isinstance(section, dict) and section.get("enabled") is False:
|
||||
return False
|
||||
return is_installed(name)
|
||||
|
||||
|
||||
def enabled_backends() -> List[LoginBackend]:
|
||||
"""Local first (always on), then 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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, string>).password).toBe('fixture-retained-password')
|
||||
await waitFor(() => expect(screen.queryByLabelText('Password')).toBeNull())
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -459,15 +459,17 @@ export function VaultSettings() {
|
||||
{v.sources.unlock}
|
||||
</Button>
|
||||
))}
|
||||
<Switch
|
||||
aria-label={source.display_name}
|
||||
checked={source.enabled}
|
||||
disabled={setSourceEnabled.isPending || (!source.installed && !source.enabled)}
|
||||
onCheckedChange={enabled => {
|
||||
triggerHaptic('selection')
|
||||
setSourceEnabled.mutate({ name: source.name, enabled })
|
||||
}}
|
||||
/>
|
||||
{source.installed && (
|
||||
<Switch
|
||||
aria-label={source.display_name}
|
||||
checked={source.enabled}
|
||||
disabled={setSourceEnabled.isPending}
|
||||
onCheckedChange={enabled => {
|
||||
triggerHaptic('selection')
|
||||
setSourceEnabled.mutate({ name: source.name, enabled })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
@@ -483,11 +485,15 @@ export function VaultSettings() {
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{source.display_name}</span>
|
||||
{source.enabled && (
|
||||
<Pill tone={source.unlocked ? 'primary' : 'muted'}>
|
||||
{source.unlocked ? v.sources.statusUnlocked : v.sources.statusLocked}
|
||||
</Pill>
|
||||
)}
|
||||
<Pill tone={source.enabled && source.unlocked ? 'primary' : 'muted'}>
|
||||
{!source.installed
|
||||
? v.sources.statusNotDetected
|
||||
: !source.enabled
|
||||
? v.sources.statusOff
|
||||
: source.unlocked
|
||||
? v.sources.statusUnlocked
|
||||
: v.sources.statusLocked}
|
||||
</Pill>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<Dialog onOpenChange={open => !open && !submitting && void send('')} open>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle icon={ShieldLock}>{copy.vaultSaveTitle(request.site)}</DialogTitle>
|
||||
<DialogDescription>{copy.vaultSaveDesc(request.origin)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="grid gap-3"
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
|
||||
if (canSave) {
|
||||
void send(JSON.stringify({ identifier: identifier.trim(), password }))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field htmlFor="vault-save-identifier" label={copy.vaultSaveIdentifierLabel}>
|
||||
<Input
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
disabled={submitting}
|
||||
id="vault-save-identifier"
|
||||
onChange={event => setIdentifier(event.target.value)}
|
||||
placeholder={copy.vaultSaveIdentifierPlaceholder}
|
||||
value={identifier}
|
||||
/>
|
||||
</Field>
|
||||
<Field htmlFor="vault-save-password" label={copy.vaultSavePasswordPlaceholder}>
|
||||
<Input
|
||||
autoComplete="current-password"
|
||||
disabled={submitting}
|
||||
id="vault-save-password"
|
||||
onChange={event => setPassword(event.target.value)}
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</Field>
|
||||
<p className="text-xs text-muted-foreground">{copy.vaultSaveFootnote}</p>
|
||||
<DialogFooter>
|
||||
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
|
||||
{copy.vaultSaveDecline}
|
||||
</Button>
|
||||
<Button disabled={submitting || !canSave} type="submit">
|
||||
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : copy.vaultSaveConfirm}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 }) {
|
||||
<SudoDialog sessionId={sessionId} />
|
||||
<SecretDialog sessionId={sessionId} />
|
||||
<VaultUnlockDialog sessionId={sessionId} />
|
||||
<VaultSaveLoginDialog sessionId={sessionId} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>>()),
|
||||
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(<PromptOverlays sessionId="session-a" />)
|
||||
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(<PromptOverlays sessionId="session-a" />)
|
||||
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())
|
||||
})
|
||||
+28
-16
@@ -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: 'فشلت قراءة الصوت',
|
||||
|
||||
+29
-17
@@ -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: {
|
||||
|
||||
+28
-16
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+28
-16
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
@@ -119,6 +119,16 @@ const sudo = keyedPromptStore<SudoRequest>()
|
||||
const secret = keyedPromptStore<SecretRequest>()
|
||||
const vaultUnlock = keyedPromptStore<VaultUnlockRequest>()
|
||||
|
||||
// "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<VaultSaveLoginRequest>()
|
||||
|
||||
// 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<Record<string, number>>({})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
+19
-14
@@ -136,7 +136,7 @@ def _cmd_list(args) -> None:
|
||||
|
||||
|
||||
def _cmd_sources(args) -> None:
|
||||
"""Show/enable/disable the external password managers (`vault.<name>.enabled`)."""
|
||||
"""Show the detected password managers; `--disable`/`--enable` flip the opt-out (`vault.<name>.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)
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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 `<select>`s) and writes the values over the supervised CDP
|
||||
socket. The result names the targeted fields (`cc-number`, `cc-exp`, `cc-csc`,
|
||||
`address-line1`, …) but never a value; card values are registered with the
|
||||
redactor like passwords.
|
||||
## Already using 1Password or Bitwarden?
|
||||
|
||||
**Every payment fill asks you first.** Before a card is written you get the
|
||||
same approval prompt as a dangerous command (button in Desktop/TUI/chat
|
||||
platforms, panel in the CLI). Declining returns `payment_declined` to the
|
||||
agent and writes nothing; headless sessions (cron, webhook, API) cannot
|
||||
confirm and are refused. This is the guard against a prompt injection that
|
||||
reaches a checkout page: it can ask, it cannot spend. Address fills need no
|
||||
confirmation (an address is not a spending instrument).
|
||||
Nothing to enable. If the `op` or `bw` command-line tool is installed and signed
|
||||
in, Hermes picks it up automatically and its website logins become fillable
|
||||
alongside the local ones. The first time the agent needs one of those logins it
|
||||
asks you to unlock the manager with your master password (masked prompt; once
|
||||
per session, 30 minutes idle). Hermes hands the master password to the manager's
|
||||
CLI through its non-interactive channel (`op signin` on stdin, `bw unlock
|
||||
--passwordenv` in the child's environment) and keeps only the session token in
|
||||
memory. The agent never sees the master password, the token, or any login.
|
||||
|
||||
## Password managers (1Password, Bitwarden)
|
||||
Prefer not to use a detected manager? `hermes vault sources --disable bitwarden`,
|
||||
or the switch in **Settings → Passwords & Logins**.
|
||||
|
||||
You don't have to copy logins into the Hermes vault. Enable a password
|
||||
manager and its website logins become fillable handles alongside the local
|
||||
ones (`op:…` for 1Password, `bw:…` for Bitwarden). The password is fetched
|
||||
from the manager's CLI at fill time only and follows the same server-side
|
||||
injection, origin binding, and redaction as a local item.
|
||||
## Paying and filling addresses
|
||||
|
||||
```bash
|
||||
hermes vault sources # status of each manager
|
||||
hermes vault sources --enable onepassword # needs the `op` CLI on PATH
|
||||
hermes vault sources --enable bitwarden # needs the `bw` CLI; run `bw login` once first
|
||||
```
|
||||
Cards and addresses work the same way as logins: saved once (**Settings →
|
||||
Passwords & Logins → Add**, or `hermes vault add`), bound to the checkout site,
|
||||
and filled by the agent on that site only. **Every card fill asks you first**,
|
||||
with the same approval prompt as a dangerous command; declining writes nothing.
|
||||
Headless sessions (cron, webhooks, the API server) cannot confirm and are
|
||||
refused, so a prompt injection that reaches a checkout page can ask, but it
|
||||
cannot spend. Address fills need no confirmation.
|
||||
|
||||
or **Desktop → Settings → Credential Vault → Password managers** (toggle,
|
||||
Unlock, Lock).
|
||||
## Managing what's saved
|
||||
|
||||
### Unlocking is per session
|
||||
- **Desktop → Settings → Passwords & Logins**: everything saved, the detected
|
||||
password managers with Unlock/Lock, Add, Remove.
|
||||
- **CLI**: `hermes vault list`, `hermes vault add`, `hermes vault rm <handle>`,
|
||||
`hermes vault sources`.
|
||||
|
||||
A manager starts **locked**. The first time the agent needs one of its
|
||||
logins it asks you to unlock: a masked master-password prompt appears in
|
||||
the CLI, TUI, or Desktop chat (or you can unlock ahead of time from
|
||||
Settings). Hermes hands the master password to the manager CLI through its
|
||||
non-interactive channel (`op signin` reads stdin; `bw unlock --passwordenv`
|
||||
reads a variable set only in the child process) — never as a command-line
|
||||
argument, never in Hermes' own environment — and keeps only the resulting
|
||||
session token in memory, scoped to the current profile. The token expires
|
||||
after 30 minutes idle, when you press **Lock**, or when the chat session that
|
||||
unlocked it ends (other sessions in the same profile keep their own unlocks).
|
||||
A **Lock** pressed while an unlock is still in flight wins. The agent never
|
||||
sees the master password, the token, or any password.
|
||||
Items live encrypted under `~/.hermes/vault/` (Fernet key + vault file, both
|
||||
`0600`), scoped to the profile. Labels, site origins and login identifiers are
|
||||
visible metadata; passwords and card values never leave the vault except into
|
||||
the page.
|
||||
|
||||
`browser_vault_list` reports a locked manager under `locked`, and
|
||||
`browser_vault_unlock(backend)` triggers the prompt explicitly.
|
||||
## Headless sessions
|
||||
|
||||
### Headless sessions never prompt
|
||||
|
||||
Cron jobs, webhooks, the API server, and `hermes chat -q` have nobody to
|
||||
answer a prompt, so a locked manager is reported as
|
||||
`unavailable_in_this_session` and fills refuse — the same posture command
|
||||
approvals take there. Unlock from an interactive session or the Desktop app
|
||||
first (the token is per process, so a running gateway that you unlock from
|
||||
a chat keeps serving its own cron jobs), or give 1Password a service-account
|
||||
token (`OP_SERVICE_ACCOUNT_TOKEN`) to skip the prompt entirely. The local
|
||||
vault needs no unlock and keeps working everywhere.
|
||||
Cron jobs, webhooks, the API server and `hermes chat -q` have nobody to answer a
|
||||
prompt. Saved local logins keep working there; a locked password manager reports
|
||||
`unavailable_in_this_session` and a missing login reports `prompt_unavailable`.
|
||||
Unlock or save from an interactive session first, or give 1Password a service
|
||||
account token (`OP_SERVICE_ACCOUNT_TOKEN`).
|
||||
|
||||
```yaml
|
||||
vault:
|
||||
onepassword:
|
||||
enabled: true
|
||||
account: "" # `op --account` shorthand; empty = default
|
||||
enabled: false # opt OUT of a detected manager (default: on when installed)
|
||||
account: "" # `op --account` shorthand; empty = default
|
||||
service_account_token_env: OP_SERVICE_ACCOUNT_TOKEN
|
||||
bitwarden:
|
||||
enabled: true
|
||||
enabled: false
|
||||
```
|
||||
|
||||
Bitwarden here means the **Password Manager** (`bw`) — website logins — not
|
||||
the Secrets Manager (`bws`) that the [secrets](../secrets/bitwarden) feature
|
||||
uses for API keys.
|
||||
## What this does and does not guarantee
|
||||
|
||||
## Desktop app
|
||||
**Does:** the password never enters the model's context through Hermes: not in
|
||||
tool results, logs, the session database, or the CLI arguments of any process.
|
||||
Fills happen over the supervised browser session's direct CDP socket and are
|
||||
refused unless the page origin exactly matches the saved origin, checked again
|
||||
inside the page immediately before the write.
|
||||
|
||||
Desktop users can manage the vault without a terminal: open
|
||||
**Settings → Credential Vault** (right next to the Browser section). The
|
||||
panel lists saved items — label, kind, login identifier, origin, and
|
||||
creation date; passwords are never displayed — and lets you add or delete
|
||||
credentials. The
|
||||
Add dialog adapts to the selected kind (login / payment card / address),
|
||||
masks secret fields, and submits them straight into the encrypted store
|
||||
over the local gateway connection.
|
||||
|
||||
The panel is deep-linkable: opening
|
||||
|
||||
```text
|
||||
hermes://open/settings?tab=vault&kind=login&label=github&origin=https://github.com
|
||||
```
|
||||
|
||||
launches the app on the vault panel with the Add dialog pre-filled from
|
||||
the query parameters (metadata only — a secret can never travel in a
|
||||
link). When a fill request fails because no matching item exists, the
|
||||
agent's error message points at both `hermes vault add` and this panel.
|
||||
|
||||
## Example agent flow
|
||||
|
||||
```
|
||||
User: log into example.com and check my dashboard
|
||||
Agent: browser_navigate("https://example.com/login")
|
||||
Agent: browser_vault_list() → {items: [{handle: "op:…", backend: "onepassword", label: "Example", identifier: "me@example.com", origin: "https://example.com"}]}
|
||||
(or, if 1Password is still locked: {items: [], locked: [{backend: "onepassword", unlock: "browser_vault_unlock"}]} → the agent calls browser_vault_unlock and you get a masked prompt)
|
||||
Agent: <types "me@example.com" into the username field with the browser's input tool>
|
||||
Agent: browser_vault_fill("op:…") → {"success": true, "filled_fields": 1, "backend": "onepassword", "kind": "login", "origin": "https://example.com"}
|
||||
Agent: browser_click(<submit>)
|
||||
```
|
||||
|
||||
The same flow works on the default Browser Use backend (`browser_exec`): the
|
||||
supervisor attaches to the browser `browser_exec` drives, and the fill picks
|
||||
the open tab on the item's origin that actually holds the form, so the agent
|
||||
can keep several tabs open. On a checkout:
|
||||
|
||||
```
|
||||
Agent: browser_vault_list() → {items: [{handle: "vault_…", kind: "payment", label: "Visa", origin: "https://shop.example"}]}
|
||||
Agent: browser_vault_fill("vault_…") → you see "Fill payment card 'Visa' on https://shop.example — approve?"
|
||||
→ {"success": true, "filled_fields": 3, "kind": "payment", "fields": ["cc-csc", "cc-exp", "cc-number"]}
|
||||
```
|
||||
|
||||
## Security properties
|
||||
|
||||
- **Password-blind:** the agent never sees password values — only handles,
|
||||
labels, identifiers, and origins.
|
||||
- **Origin-bound at use time:** fills are refused unless the page origin
|
||||
exactly matches (scheme + host + port) the origin the credential was
|
||||
saved for — asserted both before the fill and atomically inside the fill
|
||||
script itself, so a mid-flight navigation (including cross-origin) writes
|
||||
nothing.
|
||||
- **No argv exposure:** the secret-bearing injection runs exclusively over
|
||||
the supervised browser session's CDP WebSocket. If that session is not
|
||||
available, the fill refuses rather than falling back to a subprocess
|
||||
path that would place the password in argv.
|
||||
- **Redaction-backed egress boundary:** filled password and card bytes are
|
||||
registered with the browser tool-result redactor (per profile, most recent
|
||||
64 values); every `browser_*` result (including raw `browser_cdp` output)
|
||||
is scrubbed against them.
|
||||
- **Payment needs a human:** a card is never written without an approval
|
||||
prompt answered in the session; headless sessions cannot fill cards.
|
||||
- **Multi-process safe:** the local vault file is written under a
|
||||
cross-process lock with `fsync`, so a Desktop gateway, a CLI `hermes vault
|
||||
add` and a TUI worker cannot drop each other's items.
|
||||
- **No signup/OTP capture:** fields marked `autocomplete="new-password"`
|
||||
or `one-time-code`, and fields labeled *new/confirm/create/repeat
|
||||
password*, are never filled.
|
||||
- **Encrypted at rest:** vault file and key are created `0600` in your
|
||||
Hermes home; nothing is sent to any server.
|
||||
- **Master password never stored:** for 1Password/Bitwarden the master
|
||||
password is consumed by the manager CLI and dropped; only the session
|
||||
token is held, in memory, per profile, with an idle timeout. Headless
|
||||
sessions can't prompt and see the manager as locked.
|
||||
|
||||
### What this does and does not guarantee
|
||||
|
||||
The vault keeps passwords out of the model's *normal* path: list/fill
|
||||
results carry metadata only, the fill runs over the supervised CDP socket,
|
||||
and every browser tool result is scrubbed for the exact filled bytes
|
||||
(including the CR/LF-normalized form a text input stores, and JSON object
|
||||
keys). This is accidental-disclosure protection, not an execution sandbox:
|
||||
a session that also has arbitrary page JavaScript (`browser_cdp
|
||||
Runtime.evaluate`) or host code execution could in principle transform a
|
||||
filled value (for example base64-encode it) into a string the redactor does
|
||||
not recognize. If that matters for a credential, restrict the session's
|
||||
toolset (drop `browser_cdp`/`terminal`/`execute_code`) or use a dedicated
|
||||
low-privilege account for agent logins. Treat the filled credential as
|
||||
exposed to the same trust boundary as the browser session itself.
|
||||
|
||||
## Notes
|
||||
|
||||
- No configuration is needed for the local vault; the tools activate
|
||||
automatically once it has an item or a password manager is enabled.
|
||||
- The fill targets the single best current-password field (autocomplete
|
||||
token beats type heuristics; ties break in DOM order).
|
||||
- Design ported from Merit-Systems/OpenInstinct's opaque-handle vault
|
||||
autofill (MIT).
|
||||
**Does not:** protect against the page itself. Once a password is typed into a
|
||||
site, that site (and any script it runs) has it, exactly as when you type it
|
||||
yourself. On a cloud browser backend the vendor's browser sees the page like any
|
||||
other. The origin binding is the guard against filling on the wrong site, not
|
||||
against a compromised right one.
|
||||
|
||||
Reference in New Issue
Block a user