diff --git a/apps/desktop/src/app/settings/vault-settings.owner.test.tsx b/apps/desktop/src/app/settings/vault-settings.owner.test.tsx index 61dc700b33..962f8bd195 100644 --- a/apps/desktop/src/app/settings/vault-settings.owner.test.tsx +++ b/apps/desktop/src/app/settings/vault-settings.owner.test.tsx @@ -7,7 +7,9 @@ import { stubResizeObserver } from '@/test/jsdom' // Every vault RPC is routed to the OWNER profile's socket; the mock records which profile each // call targeted so the tests can prove a draft never crosses owners. -const { calls } = vi.hoisted(() => ({ calls: [] as { method: string; params: Record; profile: string }[] })) +const { calls } = vi.hoisted(() => ({ + calls: [] as { method: string; params: Record; profile: string }[] +})) let respond: (profile: string, method: string) => Promise = async () => ({}) vi.mock('@/store/gateway', async importActual => ({ @@ -59,7 +61,8 @@ beforeEach(() => { queryClient.clear() $activeGatewayProfile.set('default') $gatewayState.set('open') - respond = async (_profile, method) => (method === 'vault.sources' ? { sources } : method === 'vault.list' ? { items: [] } : { ok: true }) + respond = async (_profile, method) => + method === 'vault.sources' ? { sources } : method === 'vault.list' ? { items: [] } : { ok: true } }) afterEach(() => { @@ -80,7 +83,7 @@ it('a master-password draft is wiped on a profile switch and never submitted to await waitFor(() => expect(calls.some(c => c.profile === 'other-profile' && c.method === 'vault.list')).toBe(true)) }) -it("a late list response from profile A never paints under profile B", async () => { +it('a late list response from profile A never paints under profile B', async () => { let resolveA!: (value: unknown) => void const held = new Promise(r => (resolveA = r)) @@ -103,25 +106,51 @@ it("a late list response from profile A never paints under profile B", async () await waitFor(() => expect(calls.some(c => c.profile === 'other-profile' && c.method === 'vault.list')).toBe(true)) await act(async () => { - resolveA({ items: [{ id: 'a', kind: 'login', label: 'A-only private account', origin: 'https://a.example', identifier: 'a@example.com', created_at: '' }] }) + resolveA({ + items: [ + { + id: 'a', + kind: 'login', + label: 'A-only private account', + origin: 'https://a.example', + identifier: 'a@example.com', + created_at: '' + } + ] + }) await held }) expect(screen.queryByText('A-only private account')).toBeNull() }) it('vault.add secrets never enter the mutation cache', async () => { - respond = async (_profile, method) => (method === 'vault.sources' ? { sources } : method === 'vault.list' ? { items: [] } : { id: 'created' }) + 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' })) - for (const [label, value] of [['Label', 'fixture'], ['Site origin', 'https://example.com'], ['Identifier', 'fixture@example.com'], ['Password', 'fixture-retained-password']] as const) { + 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' })) await waitFor(() => expect(calls.some(c => c.method === 'vault.add')).toBe(true)) - expect((calls.find(c => c.method === 'vault.add')!.params.secret as Record).password).toBe('fixture-retained-password') + expect((calls.find(c => c.method === 'vault.add')!.params.secret as Record).password).toBe( + 'fixture-retained-password' + ) await waitFor(() => expect(screen.queryByLabelText('Password')).toBeNull()) view.unmount() - expect(JSON.stringify(queryClient.getMutationCache().getAll().map(m => m.state.variables))).not.toContain('fixture-retained-password') + expect( + JSON.stringify( + queryClient + .getMutationCache() + .getAll() + .map(m => m.state.variables) + ) + ).not.toContain('fixture-retained-password') }) diff --git a/apps/desktop/src/app/settings/vault-settings.test.tsx b/apps/desktop/src/app/settings/vault-settings.test.tsx index e6e8f894ed..b5965180b8 100644 --- a/apps/desktop/src/app/settings/vault-settings.test.tsx +++ b/apps/desktop/src/app/settings/vault-settings.test.tsx @@ -143,8 +143,22 @@ describe('VaultSettings', () => { it('unlocks a password manager from Settings; the master password leaves only via vault.unlock', async () => { const sources = [ - { name: 'onepassword', display_name: '1Password', enabled: true, needs_unlock: true, unlocked: false, installed: true }, - { name: 'bitwarden', display_name: 'Bitwarden', enabled: false, needs_unlock: true, unlocked: false, installed: false } + { + name: 'onepassword', + display_name: '1Password', + enabled: true, + needs_unlock: true, + unlocked: false, + installed: true + }, + { + name: 'bitwarden', + display_name: 'Bitwarden', + enabled: false, + needs_unlock: true, + unlocked: false, + installed: false + } ] requestGateway.mockImplementation(async (method: string) => { @@ -176,7 +190,9 @@ describe('VaultSettings', () => { await waitFor(() => expect(screen.getByText('Unlock 1Password')).toBeTruthy()) fireEvent.change(screen.getByPlaceholderText('Master password'), { target: { value: 'correct horse' } }) - fireEvent.click(screen.getByRole('button', { name: 'Unlock' }).closest('form')!.querySelector('button[type=submit]')!) + fireEvent.click( + screen.getByRole('button', { name: 'Unlock' }).closest('form')!.querySelector('button[type=submit]')! + ) await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('vault.unlock', { name: 'onepassword', password: 'correct horse' }) diff --git a/apps/desktop/src/app/settings/vault-settings.tsx b/apps/desktop/src/app/settings/vault-settings.tsx index c326c947bb..0e36e1af60 100644 --- a/apps/desktop/src/app/settings/vault-settings.tsx +++ b/apps/desktop/src/app/settings/vault-settings.tsx @@ -182,7 +182,6 @@ export function VaultSettings() { const pendingMasterPassword = useRef('') const pendingSecret = useRef>(null) - const { data: sourcesData } = useQuery({ enabled: gatewayState === 'open', queryKey: VAULT_SOURCES_QUERY_KEY, @@ -298,10 +297,7 @@ export function VaultSettings() { setSearchParams(next, { replace: true }) }, [openAdd, searchParams, setSearchParams]) - const invalidate = useCallback( - () => queryClient.invalidateQueries({ queryKey: VAULT_QUERY_KEY }), - [queryClient] - ) + const invalidate = useCallback(() => queryClient.invalidateQueries({ queryKey: VAULT_QUERY_KEY }), [queryClient]) const addMutation = useMutation({ mutationFn: async (payload: { kind: VaultKind; label: string; origin?: string }) => { @@ -419,11 +415,17 @@ export function VaultSettings() { {item.identifier && {v.identifierShown(item.identifier)}} {item.origin && item.origin.replace(/^https?:\/\//, '') !== item.label && ( <> - {item.identifier && ·} + {item.identifier && ( + + · + + )} {item.origin} )} - · + + · + {v.createdOn(formatCreated(item.created_at))} } @@ -462,7 +464,13 @@ export function VaultSettings() { {v.sources.lock} ) : ( - @@ -565,10 +573,7 @@ export function VaultSettings() { >
- setForm(f => ({ ...f, kind: value as VaultKind }))} value={form.kind}> diff --git a/apps/desktop/src/components/prompt-overlays.vault-save-login.test.tsx b/apps/desktop/src/components/prompt-overlays.vault-save-login.test.tsx index 9640fe1c4a..7a70508be5 100644 --- a/apps/desktop/src/components/prompt-overlays.vault-save-login.test.tsx +++ b/apps/desktop/src/components/prompt-overlays.vault-save-login.test.tsx @@ -38,7 +38,12 @@ it('sends identifier + password as one vault.save_login.respond to the owning pr 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' }) + setVaultSaveLoginRequest({ + origin: 'https://github.com', + requestId: 'req-s', + sessionId: 'session-a', + site: 'github.com' + }) render() expect(document.body.textContent).toContain('Save your github.com login?') @@ -55,7 +60,10 @@ it('sends identifier + password as one vault.save_login.respond to the owning pr 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(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()) }) @@ -64,13 +72,21 @@ 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' }) + setVaultSaveLoginRequest({ + origin: 'https://github.com', + requestId: 'req-d', + sessionId: 'session-a', + site: 'github.com' + }) render() const decline = Array.from(document.querySelectorAll('button')).find(b => b.textContent === "Don't save")! fireEvent.click(decline) await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1)) - expect((gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[])[3]).toEqual({ login: '', request_id: 'req-d' }) + expect((gatewayMocks.requestGatewayForAgent.mock.calls[0] as unknown[])[3]).toEqual({ + login: '', + request_id: 'req-d' + }) await waitFor(() => expect(sessionVaultSaveLoginRequest('session-a').get()).toBeNull()) }) diff --git a/apps/desktop/src/components/prompt-overlays.vault-unlock.test.tsx b/apps/desktop/src/components/prompt-overlays.vault-unlock.test.tsx index d85c98dd55..901380e3f3 100644 --- a/apps/desktop/src/components/prompt-overlays.vault-unlock.test.tsx +++ b/apps/desktop/src/components/prompt-overlays.vault-unlock.test.tsx @@ -47,7 +47,11 @@ it('routes the master password to the owning profile socket, never the ambient g fireEvent.submit(input.closest('form')!) await waitFor(() => expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(1)) - expect(gatewayMocks.requestGatewayForAgent.mock.calls[0].slice(0, 3)).toEqual(['conn-1', 'owner', 'vault.unlock.respond']) + expect(gatewayMocks.requestGatewayForAgent.mock.calls[0].slice(0, 3)).toEqual([ + 'conn-1', + 'owner', + 'vault.unlock.respond' + ]) expect(ambient).not.toHaveBeenCalled() await waitFor(() => expect(sessionVaultUnlockRequest('session-a').get()).toBeNull()) }) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 979db67325..fb432bf97e 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -518,7 +518,7 @@ export const en: Translations = { loadFailed: 'Could not load vault items', empty: 'Nothing saved yet', emptyDesc: - '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.', + "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.', @@ -562,7 +562,8 @@ export const en: Translations = { blurb: '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 => `Not detected. Install the ${name} command-line tool and sign in to it; Hermes picks it up automatically.`, + 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.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 3e934d4550..53a4234d4d 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -383,9 +383,11 @@ export const ja = defineLocale({ blurb: 'インストール済みのパスワードマネージャーは自動的に検出されます。エージェントがそこからログイン情報を初めて必要とするときにロック解除を求めます(セッションごとに一度)。メモリに残るのはセッショントークンのみで、エージェントはマスターパスワードやログイン情報を一切見ません。', toggleFailed: 'パスワードマネージャーの設定を更新できませんでした', - notInstalled: name => `未検出です。${name} のコマンドラインツールをインストールしてサインインすると、Hermes が自動的に検出します。`, + notInstalled: name => + `未検出です。${name} のコマンドラインツールをインストールしてサインインすると、Hermes が自動的に検出します。`, disabledDesc: '検出済みですが、Hermes では無効になっています。', - lockedDesc: '検出済み。エージェントがログイン情報を必要とするときにロック解除を求めます。今すぐ解除することもできます。', + lockedDesc: + '検出済み。エージェントがログイン情報を必要とするときにロック解除を求めます。今すぐ解除することもできます。', unlockedDesc: 'このセッションでロック解除済み。30分間操作がないか Hermes を閉じると自動的にロックされます。', statusLocked: 'ロック中', statusNotDetected: '未検出', diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index a005904bc0..9fe1bbf548 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -324,7 +324,8 @@ export const zhHant = defineLocale({ }, vault: { title: '密碼與登入', - blurb: '說一句「登入 GitHub」,代理就會代你登入。第一次遇到登入頁時它會當場向你索取登入資訊,之後就自動完成。密碼在本機加密儲存並直接填入頁面——模型永遠看不到。', + blurb: + '說一句「登入 GitHub」,代理就會代你登入。第一次遇到登入頁時它會當場向你索取登入資訊,之後就自動完成。密碼在本機加密儲存並直接填入頁面——模型永遠看不到。', count: n => `已儲存 ${n} 項`, loadFailed: '無法載入保險庫項目', empty: '尚未儲存任何內容', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 577c9e20db..171c0e1131 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -430,7 +430,8 @@ export const zh: Translations = { }, vault: { title: '密码与登录', - blurb: '说一句“登录 GitHub”,智能体就会代你登录。第一次遇到登录页时它会当场向你要登录信息,之后就自动完成。密码在本机加密保存并直接填入页面——模型永远看不到。', + blurb: + '说一句“登录 GitHub”,智能体就会代你登录。第一次遇到登录页时它会当场向你要登录信息,之后就自动完成。密码在本机加密保存并直接填入页面——模型永远看不到。', count: n => `已保存 ${n} 项`, loadFailed: '无法加载保险库条目', empty: '尚未保存任何内容', diff --git a/apps/desktop/src/store/prompts.ts b/apps/desktop/src/store/prompts.ts index f82103b558..e4d1258a57 100644 --- a/apps/desktop/src/store/prompts.ts +++ b/apps/desktop/src/store/prompts.ts @@ -270,10 +270,10 @@ export const hasBlockingPromptRequest = (sessionId: string | null | undefined): return Boolean( approval.$all.get()[key] || - sudo.$all.get()[key] || - secret.$all.get()[key] || - vaultUnlock.$all.get()[key] || - vaultSave.$all.get()[key] + sudo.$all.get()[key] || + secret.$all.get()[key] || + vaultUnlock.$all.get()[key] || + vaultSave.$all.get()[key] ) } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index b5b9e4de4f..b588af103b 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -646,7 +646,12 @@ export function useMainApp(gw: GatewayClient) { // Format: ` · · ` — name/cwd omitted when absent. const model = ui.info?.model?.replace(/^.*\//, '') ?? '' - const marker = overlay.approval || overlay.sudo || overlay.secret || overlay.vaultUnlock || overlay.clarify ? '⚠' : ui.busy ? '⏳' : '✓' + const marker = + overlay.approval || overlay.sudo || overlay.secret || overlay.vaultUnlock || overlay.clarify + ? '⚠' + : ui.busy + ? '⏳' + : '✓' const tabCwd = ui.info?.cwd diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 7f6ed894a4..84a6f7a515 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -767,8 +767,16 @@ export type GatewayEvent = } | { payload: { request_id: string }; session_id?: string; type: 'sudo.request' } | { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' } - | { payload: { request_id: string }; session_id?: string; type: 'secret.expire' | 'sudo.expire' | 'vault.unlock.expire' } - | { payload: { backend: string; display_name: string; request_id: string }; session_id?: string; type: 'vault.unlock.request' } + | { + payload: { request_id: string } + session_id?: string + type: 'secret.expire' | 'sudo.expire' | 'vault.unlock.expire' + } + | { + payload: { backend: string; display_name: string; request_id: string } + session_id?: string + type: 'vault.unlock.request' + } | { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' } | { payload: { question?: string; task_id: string; text: string }; session_id?: string; type: 'btw.complete' } | { payload?: { text?: string }; session_id?: string; type: 'review.summary' }