feat(desktop): real-profile browsing toggle in Capabilities → Tools → Browser

Users reported no GUI switch for browser.use_real_profile — the only
desktop home was the generic Settings → Config editor, which nobody
found. The Browser toolset detail pane now renders a 'Use My Real
Browser Profile' ToggleRow above the backend/provider matrix.

- new BrowserRealProfilePanel: reads the shared profile-scoped config
  record cache, optimistic write-through, rollback on failure
- saveHermesConfigRecord: capability-scoped PUT /api/config counterpart
  of getHermesConfigRecord, so the Capabilities scope selector writes
  the profile it points at (possibly another gateway)
- i18n: en/ja/zh/zh-hant keys (ar inherits en via defineLocale)
- docs: browser.md desktop pointer corrected to the real location

Live E2E on the built app over CDP: clicking the switch flipped
browser.use_real_profile true→false→true in the sandbox HERMES_HOME
config.yaml, GET reflected it, no layout glitches (screenshots in PR).
This commit is contained in:
Teknium
2026-08-29 18:52:29 -07:00
parent 0ebce1de81
commit 6cb6aeb168
10 changed files with 269 additions and 1 deletions
+12
View File
@@ -99,6 +99,18 @@ export function saveHermesConfig(config: HermesConfigRecord, profile?: null | st
})
}
/** Capability-scoped counterpart of saveHermesConfig — writes the config of
* the profile/connection the Capabilities scope selector points at (possibly
* on another registered gateway), mirroring getHermesConfigRecord. */
export function saveHermesConfigRecord(config: HermesConfigRecord, profile?: ProfileScope): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...capabilityScoped(profile),
path: '/api/config',
method: 'PUT',
body: { config }
})
}
export function getEnvVars(profile?: null | string): Promise<Record<string, EnvVarInfo>> {
return hermesApi<Record<string, EnvVarInfo>>({
...profileScoped(profile),
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BrowserRealProfilePanel } from './browser-real-profile-panel'
const mocks = vi.hoisted(() => ({
cache: vi.fn(),
loadedConfig: {} as Record<string, unknown>,
notify: vi.fn(),
notifyError: vi.fn(),
save: vi.fn()
}))
vi.mock('@/hermes', () => ({
saveHermesConfigRecord: (config: Record<string, unknown>, profile?: unknown) => mocks.save(config, profile)
}))
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
settings: {
toolsets: {
browserRealProfile: {
label: 'Use My Real Browser Profile',
description: 'Copies your default browser profile into a managed snapshot.',
enabledTitle: 'Real-profile browsing on',
enabledMessage: 'New sessions use the snapshot.',
disabledTitle: 'Real-profile browsing off',
disabledMessage: 'Snapshot will be deleted.',
failedSave: 'Could not save the real-profile setting'
}
}
}
}
})
}))
vi.mock('@/store/notifications', () => ({
notify: (...args: unknown[]) => mocks.notify(...args),
notifyError: (...args: unknown[]) => mocks.notifyError(...args)
}))
vi.mock('../hooks/use-config-record', () => ({
hermesConfigCacheWriter: () => (config: Record<string, unknown>) => mocks.cache(config),
useHermesConfigRecord: () => ({ data: mocks.loadedConfig })
}))
describe('BrowserRealProfilePanel', () => {
beforeEach(() => {
mocks.loadedConfig = { browser: { allow_private_urls: false }, model: { provider: 'nous' } }
mocks.save.mockResolvedValue({ ok: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('renders off for a config without the key and turns it on', async () => {
render(<BrowserRealProfilePanel />)
const toggle = screen.getByRole('switch', { name: 'Use My Real Browser Profile' })
expect(toggle).toHaveProperty('ariaChecked', 'false')
await act(async () => {
fireEvent.click(toggle)
})
// Saves the WHOLE merged record with only use_real_profile added — sibling
// browser keys survive.
expect(mocks.save).toHaveBeenCalledWith(
{
browser: { allow_private_urls: false, use_real_profile: true },
model: { provider: 'nous' }
},
undefined
)
expect(mocks.cache).toHaveBeenCalledWith(mocks.save.mock.calls[0][0])
expect(mocks.notify).toHaveBeenCalled()
})
it('turns an enabled toggle off', async () => {
mocks.loadedConfig = { browser: { use_real_profile: true } }
render(<BrowserRealProfilePanel />)
const toggle = screen.getByRole('switch', { name: 'Use My Real Browser Profile' })
expect(toggle).toHaveProperty('ariaChecked', 'true')
await act(async () => {
fireEvent.click(toggle)
})
expect(mocks.save).toHaveBeenCalledWith({ browser: { use_real_profile: false } }, undefined)
})
it('rolls the optimistic cache write back when the save fails', async () => {
mocks.save.mockRejectedValue(new Error('boom'))
render(<BrowserRealProfilePanel />)
await act(async () => {
fireEvent.click(screen.getByRole('switch', { name: 'Use My Real Browser Profile' }))
})
// Last cache write restores the original record.
expect(mocks.cache).toHaveBeenLastCalledWith(mocks.loadedConfig)
expect(mocks.notifyError).toHaveBeenCalled()
})
})
@@ -0,0 +1,91 @@
import { useCallback, useState } from 'react'
import { type ProfileScope, saveHermesConfigRecord } from '@/hermes'
import { useI18n } from '@/i18n'
import { notify, notifyError } from '@/store/notifications'
import { hermesConfigCacheWriter, useHermesConfigRecord } from '../hooks/use-config-record'
import { ToggleRow } from './primitives'
interface BrowserRealProfilePanelProps {
/** Capabilities profile-scope override — the toggle reads/writes THIS
* profile's config.yaml instead of the app-wide active one. */
profile?: ProfileScope
}
function readUseRealProfile(record: Record<string, unknown> | undefined): boolean {
const browser = record?.browser
if (browser && typeof browser === 'object' && !Array.isArray(browser)) {
return Boolean((browser as Record<string, unknown>).use_real_profile)
}
return false
}
/**
* The `browser.use_real_profile` consent toggle, rendered at the top of the
* Capabilities → Tools → Browser detail pane (above the backend/provider
* matrix). This is the GUI home of the real-profile browsing switch: without
* it the only desktop path was the generic Settings → Config editor, which
* users reasonably never found ("no toggle in the browser section").
*
* Semantics mirror the config comment: turning it ON consents to snapshotting
* the default browser's profile (cookies/logins) into a Hermes-owned copy;
* turning it OFF deletes the snapshot store on next use. The toggle writes
* config.yaml through the same deep-merging PUT /api/config every other
* settings surface uses — applies to new sessions.
*/
export function BrowserRealProfilePanel({ profile }: BrowserRealProfilePanelProps) {
const { t } = useI18n()
const copy = t.settings.toolsets.browserRealProfile
const { data: config } = useHermesConfigRecord(profile)
const setConfig = hermesConfigCacheWriter(profile)
const [busy, setBusy] = useState(false)
const enabled = readUseRealProfile(config)
const toggle = useCallback(
async (on: boolean) => {
if (!config) {
return
}
const browser =
config.browser && typeof config.browser === 'object' && !Array.isArray(config.browser)
? (config.browser as Record<string, unknown>)
: {}
const next = { ...config, browser: { ...browser, use_real_profile: on } }
setBusy(true)
setConfig(next)
try {
await saveHermesConfigRecord(next, profile)
notify({
kind: 'info',
title: on ? copy.enabledTitle : copy.disabledTitle,
message: on ? copy.enabledMessage : copy.disabledMessage
})
} catch (err) {
setConfig(config)
notifyError(err, copy.failedSave)
} finally {
setBusy(false)
}
},
[config, copy, profile, setConfig]
)
return (
<ToggleRow
checked={enabled}
description={copy.description}
disabled={busy || !config}
label={copy.label}
onChange={on => void toggle(on)}
/>
)
}
+5
View File
@@ -55,6 +55,7 @@ import {
import { PanelEmpty, PanelPill } from '../overlays/panel'
import { PageSearchShell } from '../page-search-shell'
import { SETTINGS_ROUTE } from '../routes'
import { BrowserRealProfilePanel } from '../settings/browser-real-profile-panel'
import { ComputerUsePanel } from '../settings/computer-use-panel'
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
import { TerminalBackendPanel } from '../settings/terminal-backend-panel'
@@ -1166,6 +1167,10 @@ function ToolsetDetail({
</div>
)}
{toolset.name === 'computer_use' && <ComputerUsePanel onConfiguredChange={onConfiguredChange} />}
{/* Real-profile consent toggle ABOVE the backend/provider matrix — the
config option users kept missing because its only GUI home was the
generic Settings → Config editor. */}
{toolset.name === 'browser' && <BrowserRealProfilePanel profile={profile} />}
{toolset.name === 'terminal' && <TerminalBackendPanel onConfiguredChange={onConfiguredChange} />}
<ToolsetConfigPanel
key={`${toolset.name}:${profileScopeKey(profile)}`}
+10
View File
@@ -1248,6 +1248,16 @@ export const en: Translations = {
selectedMessage: backend => `Terminal commands now run via ${backend}. Applies to new sessions.`,
failedSelect: backend => `Failed to select ${backend}`,
needsSetupHint: 'You can select this backend now — commands will fail until setup is complete.'
},
browserRealProfile: {
label: 'Use My Real Browser Profile',
description:
"Copies your default browser's logins and cookies into a managed snapshot the agent browses with. Your live profile is never opened directly. Applies to new sessions.",
enabledTitle: 'Real-profile browsing on',
enabledMessage: 'New sessions will browse with a snapshot of your default browser profile.',
disabledTitle: 'Real-profile browsing off',
disabledMessage: 'The profile snapshot will be deleted; new sessions use a clean browser.',
failedSave: 'Could not save the real-profile setting'
}
}
},
+10
View File
@@ -1140,6 +1140,16 @@ export const ja = defineLocale({
selectedMessage: backend => `ターミナルコマンドは ${backend} で実行されます。新しいセッションに適用されます。`,
failedSelect: backend => `${backend} の選択に失敗しました`,
needsSetupHint: 'このバックエンドは今すぐ選択できますが、セットアップが完了するまでコマンドは失敗します。'
},
browserRealProfile: {
label: '実際のブラウザプロファイルを使用',
description:
'既定ブラウザのログイン情報と Cookie を管理されたスナップショットにコピーし、エージェントはそれを使ってブラウジングします。実際のプロファイルが直接開かれることはありません。新しいセッションに適用されます。',
enabledTitle: '実プロファイルブラウジング:オン',
enabledMessage: '新しいセッションは既定ブラウザプロファイルのスナップショットでブラウジングします。',
disabledTitle: '実プロファイルブラウジング:オフ',
disabledMessage: 'プロファイルのスナップショットは削除され、新しいセッションはクリーンなブラウザを使用します。',
failedSave: '実プロファイル設定を保存できませんでした'
}
}
},
+9
View File
@@ -1092,6 +1092,15 @@ export interface Translations {
failedSelect: (backend: string) => string
needsSetupHint: string
}
browserRealProfile: {
label: string
description: string
enabledTitle: string
enabledMessage: string
disabledTitle: string
disabledMessage: string
failedSave: string
}
}
}
+10
View File
@@ -1099,6 +1099,16 @@ export const zhHant = defineLocale({
selectedMessage: backend => `終端命令現在透過 ${backend} 執行。將套用於新工作階段。`,
failedSelect: backend => `選擇 ${backend} 失敗`,
needsSetupHint: '現在即可選擇此後端——但在完成設定前命令將會失敗。'
},
browserRealProfile: {
label: '使用我的真實瀏覽器設定檔',
description:
'將預設瀏覽器的登入資訊與 Cookie 複製到受管理的快照中,代理使用該快照進行瀏覽。絕不會直接開啟你的真實設定檔。將套用於新工作階段。',
enabledTitle: '真實設定檔瀏覽:已開啟',
enabledMessage: '新工作階段將使用預設瀏覽器設定檔的快照進行瀏覽。',
disabledTitle: '真實設定檔瀏覽:已關閉',
disabledMessage: '設定檔快照將被刪除;新工作階段使用乾淨的瀏覽器。',
failedSave: '無法儲存真實設定檔設定'
}
}
},
+10
View File
@@ -1438,6 +1438,16 @@ export const zh: Translations = {
selectedMessage: backend => `终端命令现在通过 ${backend} 运行。将应用于新会话。`,
failedSelect: backend => `选择 ${backend} 失败`,
needsSetupHint: '现在即可选择此后端——但在完成设置前命令将会失败。'
},
browserRealProfile: {
label: '使用我的真实浏览器配置文件',
description:
'将默认浏览器的登录信息和 Cookie 复制到托管快照中,代理使用该快照进行浏览。绝不会直接打开你的真实配置文件。将应用于新会话。',
enabledTitle: '真实配置文件浏览:已开启',
enabledMessage: '新会话将使用默认浏览器配置文件的快照进行浏览。',
disabledTitle: '真实配置文件浏览:已关闭',
disabledMessage: '配置文件快照将被删除;新会话使用干净的浏览器。',
failedSave: '无法保存真实配置文件设置'
}
}
},
+3 -1
View File
@@ -236,7 +236,9 @@ to fully quit the browser — it won't loop or kill again on its own.
- **Security framing:** this is a consent-gated convenience, not an isolation
boundary. A page the agent visits runs with your real logins, so only enable
it when you want the agent acting as you. Off by default.
- **Desktop:** toggle it in **Settings → Browser → Use My Real Browser Profile**.
- **Desktop:** toggle it in **Capabilities → Tools → Browser → Use My Real
Browser Profile** (the switch sits above the backend options), or in
Settings → Config under the `browser` section.
### Camofox local mode