From 6cb6aeb16891eeb29437d898e42e4102f5d05b2c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:52:29 -0700 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20real-profile=20browsing=20togg?= =?UTF-8?q?le=20in=20Capabilities=20=E2=86=92=20Tools=20=E2=86=92=20Browse?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/desktop/src/api/config.ts | 12 ++ .../browser-real-profile-panel.test.tsx | 109 ++++++++++++++++++ .../settings/browser-real-profile-panel.tsx | 91 +++++++++++++++ apps/desktop/src/app/skills/index.tsx | 5 + apps/desktop/src/i18n/en.ts | 10 ++ apps/desktop/src/i18n/ja.ts | 10 ++ apps/desktop/src/i18n/types.ts | 9 ++ apps/desktop/src/i18n/zh-hant.ts | 10 ++ apps/desktop/src/i18n/zh.ts | 10 ++ website/docs/user-guide/features/browser.md | 4 +- 10 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/app/settings/browser-real-profile-panel.test.tsx create mode 100644 apps/desktop/src/app/settings/browser-real-profile-panel.tsx diff --git a/apps/desktop/src/api/config.ts b/apps/desktop/src/api/config.ts index 38fd4945d6..2906c34d87 100644 --- a/apps/desktop/src/api/config.ts +++ b/apps/desktop/src/api/config.ts @@ -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> { return hermesApi>({ ...profileScoped(profile), diff --git a/apps/desktop/src/app/settings/browser-real-profile-panel.test.tsx b/apps/desktop/src/app/settings/browser-real-profile-panel.test.tsx new file mode 100644 index 0000000000..703b96e501 --- /dev/null +++ b/apps/desktop/src/app/settings/browser-real-profile-panel.test.tsx @@ -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, + notify: vi.fn(), + notifyError: vi.fn(), + save: vi.fn() +})) + +vi.mock('@/hermes', () => ({ + saveHermesConfigRecord: (config: Record, 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) => 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() + 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() + 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() + + 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() + }) +}) diff --git a/apps/desktop/src/app/settings/browser-real-profile-panel.tsx b/apps/desktop/src/app/settings/browser-real-profile-panel.tsx new file mode 100644 index 0000000000..72ca7234dc --- /dev/null +++ b/apps/desktop/src/app/settings/browser-real-profile-panel.tsx @@ -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 | undefined): boolean { + const browser = record?.browser + + if (browser && typeof browser === 'object' && !Array.isArray(browser)) { + return Boolean((browser as Record).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) + : {} + + 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 ( + void toggle(on)} + /> + ) +} diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index 464d84e928..fc24be9157 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -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({ )} {toolset.name === 'computer_use' && } + {/* 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' && } {toolset.name === 'terminal' && } `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' } } }, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 59a8a2992b..376e80fcdc 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1140,6 +1140,16 @@ export const ja = defineLocale({ selectedMessage: backend => `ターミナルコマンドは ${backend} で実行されます。新しいセッションに適用されます。`, failedSelect: backend => `${backend} の選択に失敗しました`, needsSetupHint: 'このバックエンドは今すぐ選択できますが、セットアップが完了するまでコマンドは失敗します。' + }, + browserRealProfile: { + label: '実際のブラウザプロファイルを使用', + description: + '既定ブラウザのログイン情報と Cookie を管理されたスナップショットにコピーし、エージェントはそれを使ってブラウジングします。実際のプロファイルが直接開かれることはありません。新しいセッションに適用されます。', + enabledTitle: '実プロファイルブラウジング:オン', + enabledMessage: '新しいセッションは既定ブラウザプロファイルのスナップショットでブラウジングします。', + disabledTitle: '実プロファイルブラウジング:オフ', + disabledMessage: 'プロファイルのスナップショットは削除され、新しいセッションはクリーンなブラウザを使用します。', + failedSave: '実プロファイル設定を保存できませんでした' } } }, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 00ec2eabaa..015fa30aee 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 + } } } diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 48329d11e9..2558187111 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1099,6 +1099,16 @@ export const zhHant = defineLocale({ selectedMessage: backend => `終端命令現在透過 ${backend} 執行。將套用於新工作階段。`, failedSelect: backend => `選擇 ${backend} 失敗`, needsSetupHint: '現在即可選擇此後端——但在完成設定前命令將會失敗。' + }, + browserRealProfile: { + label: '使用我的真實瀏覽器設定檔', + description: + '將預設瀏覽器的登入資訊與 Cookie 複製到受管理的快照中,代理使用該快照進行瀏覽。絕不會直接開啟你的真實設定檔。將套用於新工作階段。', + enabledTitle: '真實設定檔瀏覽:已開啟', + enabledMessage: '新工作階段將使用預設瀏覽器設定檔的快照進行瀏覽。', + disabledTitle: '真實設定檔瀏覽:已關閉', + disabledMessage: '設定檔快照將被刪除;新工作階段使用乾淨的瀏覽器。', + failedSave: '無法儲存真實設定檔設定' } } }, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 14c1231726..c646707b6c 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1438,6 +1438,16 @@ export const zh: Translations = { selectedMessage: backend => `终端命令现在通过 ${backend} 运行。将应用于新会话。`, failedSelect: backend => `选择 ${backend} 失败`, needsSetupHint: '现在即可选择此后端——但在完成设置前命令将会失败。' + }, + browserRealProfile: { + label: '使用我的真实浏览器配置文件', + description: + '将默认浏览器的登录信息和 Cookie 复制到托管快照中,代理使用该快照进行浏览。绝不会直接打开你的真实配置文件。将应用于新会话。', + enabledTitle: '真实配置文件浏览:已开启', + enabledMessage: '新会话将使用默认浏览器配置文件的快照进行浏览。', + disabledTitle: '真实配置文件浏览:已关闭', + disabledMessage: '配置文件快照将被删除;新会话使用干净的浏览器。', + failedSave: '无法保存真实配置文件设置' } } }, diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index af7d5f5cb6..628521ed1f 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -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