diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index cfb4100a..00025e98 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -291,14 +291,17 @@ describe('Settings > General tab', () => { }) }) - it('renders the H5 section in a disabled state by default', () => { + it('renders the H5 section without token controls while access is open', () => { render() fireEvent.click(screen.getByText('General')) const section = screen.getByRole('region', { name: 'H5 Access' }) - expect(within(section).getByLabelText('Enable H5 access')).not.toBeChecked() - expect(within(section).getByText('Disabled')).toBeInTheDocument() + expect(within(section).getByText('Token access is temporarily disabled; H5 requests are allowed without an Authorization header.')).toBeInTheDocument() + expect(within(section).queryByLabelText('Enable H5 access')).not.toBeInTheDocument() + expect(within(section).queryByText('Token preview')).not.toBeInTheDocument() + expect(within(section).queryByRole('button', { name: 'Regenerate token' })).not.toBeInTheDocument() + expect(within(section).queryByLabelText('Allowed origins')).not.toBeInTheDocument() }) it('places H5 access after the common General settings sections', () => { @@ -311,88 +314,10 @@ describe('Settings > General tab', () => { expect((webSearchTitle.compareDocumentPosition(h5Title) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) }) - it('enables H5 access from the General settings section', async () => { - render() - - fireEvent.click(screen.getByText('General')) - - const section = screen.getByRole('region', { name: 'H5 Access' }) - await act(async () => { - fireEvent.click(within(section).getByLabelText('Enable H5 access')) - }) - - expect(useSettingsStore.getState().enableH5Access).toHaveBeenCalledTimes(1) - }) - - it('regenerates the H5 token from General settings', async () => { - useSettingsStore.setState({ - h5Access: { - enabled: true, - tokenPreview: 'h5a1b2c3', - allowedOrigins: ['https://phone.example'], - publicBaseUrl: 'https://phone.example/app', - }, - regenerateH5AccessToken: vi.fn().mockImplementation(async () => { - useSettingsStore.setState({ - h5Access: { - enabled: true, - tokenPreview: 'h5d4e5f6', - allowedOrigins: ['https://phone.example'], - publicBaseUrl: 'https://phone.example/app', - }, - }) - return 'h5_regenerated_secret_token' - }), - }) - render() - - fireEvent.click(screen.getByText('General')) - - const section = screen.getByRole('region', { name: 'H5 Access' }) - await act(async () => { - fireEvent.click(within(section).getByRole('button', { name: 'Regenerate token' })) - }) - - expect(useSettingsStore.getState().regenerateH5AccessToken).toHaveBeenCalledTimes(1) - }) - - it('copies the generated H5 token and clears it after a successful copy', async () => { - useSettingsStore.setState({ - enableH5Access: vi.fn().mockImplementation(async () => { - useSettingsStore.setState({ - h5Access: { - enabled: true, - tokenPreview: 'h5z1y2x3', - allowedOrigins: [], - publicBaseUrl: null, - }, - }) - return 'h5_secret_token' - }), - }) - render() - - fireEvent.click(screen.getByText('General')) - const section = screen.getByRole('region', { name: 'H5 Access' }) - - await act(async () => { - fireEvent.click(within(section).getByLabelText('Enable H5 access')) - }) - - expect(await within(section).findByText('h5_secret_token')).toBeInTheDocument() - - await act(async () => { - fireEvent.click(within(section).getByRole('button', { name: 'Copy' })) - }) - - expect(clipboardMock.copyTextToClipboard).toHaveBeenCalledWith('h5_secret_token') - expect(within(section).queryByText('h5_secret_token')).not.toBeInTheDocument() - }) - it('copies the H5 URL when available', async () => { useSettingsStore.setState({ h5Access: { - enabled: true, + enabled: false, tokenPreview: 'h5url123', allowedOrigins: ['https://phone.example'], publicBaseUrl: 'https://phone.example/app', @@ -410,39 +335,6 @@ describe('Settings > General tab', () => { expect(clipboardMock.copyTextToClipboard).toHaveBeenCalledWith('https://phone.example/app') }) - it('clears the generated token after the visibility timeout', async () => { - vi.useFakeTimers() - useSettingsStore.setState({ - enableH5Access: vi.fn().mockImplementation(async () => { - useSettingsStore.setState({ - h5Access: { - enabled: true, - tokenPreview: 'h5timeout', - allowedOrigins: [], - publicBaseUrl: null, - }, - }) - return 'h5_timeout_token' - }), - }) - render() - - fireEvent.click(screen.getByText('General')) - const section = screen.getByRole('region', { name: 'H5 Access' }) - - await act(async () => { - fireEvent.click(within(section).getByLabelText('Enable H5 access')) - }) - - expect(within(section).getByText('h5_timeout_token')).toBeInTheDocument() - - await act(async () => { - vi.advanceTimersByTime(30_000) - }) - - expect(within(section).queryByText('h5_timeout_token')).not.toBeInTheDocument() - }) - it('shows the H5-specific store error when the H5 settings load failed', () => { useSettingsStore.setState({ h5AccessError: 'H5 unavailable' }) render() @@ -453,10 +345,10 @@ describe('Settings > General tab', () => { expect(within(section).getByText('H5 unavailable')).toBeInTheDocument() }) - it('updates H5 public URL and allowed origins from General settings', async () => { + it('updates H5 public URL from General settings', async () => { useSettingsStore.setState({ h5Access: { - enabled: true, + enabled: false, tokenPreview: 'h5a1b2c3', allowedOrigins: ['https://old.example'], publicBaseUrl: null, @@ -470,9 +362,6 @@ describe('Settings > General tab', () => { fireEvent.change(within(section).getByLabelText('Public URL'), { target: { value: 'https://phone.example/app' }, }) - fireEvent.change(within(section).getByLabelText('Allowed origins'), { - target: { value: 'https://phone.example, https://tablet.example' }, - }) await act(async () => { fireEvent.click(within(section).getByRole('button', { name: 'Save H5 settings' })) @@ -480,7 +369,6 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({ publicBaseUrl: 'https://phone.example/app', - allowedOrigins: ['https://phone.example', 'https://tablet.example'], }) }) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index d7fc56f0..9bbc982b 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -697,7 +697,7 @@ export const en = { 'settings.general.notificationsTestTitle': 'Claude Code Haha notifications are enabled', 'settings.general.notificationsTestBody': 'Permission prompts and completed agent replies will now use system notifications.', 'settings.general.h5AccessTitle': 'H5 Access', - 'settings.general.h5AccessDescription': 'Opt in to browser access from your phone or another device using a generated token and an allowlisted origin set.', + 'settings.general.h5AccessDescription': 'Browser access is open while the token gate is paused. Configure the public URL only when you expose the local service through a stable address.', 'settings.general.h5AccessEnabled': 'Enable H5 access', 'settings.general.h5AccessEnabledHint': 'Turn this on only for networks and browser origins you control.', 'settings.general.h5AccessTokenPreview': 'Token preview', @@ -713,9 +713,10 @@ export const en = { 'settings.general.h5AccessAllowedOrigins': 'Allowed origins', 'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example', 'settings.general.h5AccessOriginsHint': 'Enter one origin per line or separate multiple origins with commas.', + 'settings.general.h5AccessOpenHint': 'Token access is temporarily disabled; H5 requests are allowed without an Authorization header.', 'settings.general.h5AccessSave': 'Save H5 settings', 'settings.general.h5AccessUrl': 'H5 URL', - 'settings.general.h5AccessSafetyNote': 'Treat the full token like a password and only allow origins you fully control.', + 'settings.general.h5AccessSafetyNote': 'Only expose this URL on networks and devices you trust until token access is restored.', 'settings.general.h5AccessError': 'Failed to update H5 access settings.', 'settings.general.webFetchPreflightTitle': 'WebFetch Preflight', 'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 753af5dd..86ba03f1 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -699,7 +699,7 @@ export const zh: Record = { 'settings.general.notificationsTestTitle': 'Claude Code Haha 通知已启用', 'settings.general.notificationsTestBody': '后续授权确认和 Agent 回复完成都会通过系统通知提醒。', 'settings.general.h5AccessTitle': 'H5 访问', - 'settings.general.h5AccessDescription': '通过一次性生成的令牌和允许来源列表,为手机或其他设备上的浏览器访问开启可选 H5 模式。', + 'settings.general.h5AccessDescription': 'H5 token 鉴权已临时暂停,浏览器访问会直接放行。只有需要固定外部访问地址时才配置公开 URL。', 'settings.general.h5AccessEnabled': '启用 H5 访问', 'settings.general.h5AccessEnabledHint': '只应在你可控的网络和浏览器来源上开启。', 'settings.general.h5AccessTokenPreview': '令牌预览', @@ -715,9 +715,10 @@ export const zh: Record = { 'settings.general.h5AccessAllowedOrigins': '允许的来源', 'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example', 'settings.general.h5AccessOriginsHint': '每行一个来源,或使用逗号分隔多个来源。', + 'settings.general.h5AccessOpenHint': '当前不会要求 Authorization header,H5 请求会直接放行。', 'settings.general.h5AccessSave': '保存 H5 设置', 'settings.general.h5AccessUrl': 'H5 链接', - 'settings.general.h5AccessSafetyNote': '完整令牌等同于密码,只允许你完全信任和可控的来源。', + 'settings.general.h5AccessSafetyNote': '恢复 token 鉴权前,只在你信任的网络和设备上暴露这个地址。', 'settings.general.h5AccessError': '更新 H5 设置失败。', 'settings.general.webFetchPreflightTitle': 'WebFetch 预检', 'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。', diff --git a/desktop/src/lib/desktopRuntime.test.ts b/desktop/src/lib/desktopRuntime.test.ts index d97f09e6..b62e7390 100644 --- a/desktop/src/lib/desktopRuntime.test.ts +++ b/desktop/src/lib/desktopRuntime.test.ts @@ -52,16 +52,16 @@ describe('desktopRuntime browser H5 bootstrap', () => { expect(requiresH5AuthForServerUrl('http://[::1]:3456')).toBe(false) expect(requiresH5AuthForServerUrl('http://127.0.0.1:3456')).toBe(false) expect(requiresH5AuthForServerUrl('http://localhost:3456')).toBe(false) - expect(requiresH5AuthForServerUrl('https://public.example.com/app')).toBe(true) - expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'phone.example.test')).toBe(true) + expect(requiresH5AuthForServerUrl('https://public.example.com/app')).toBe(false) + expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'phone.example.test')).toBe(false) }) - it('only lets localhost browser WebUI bypass H5 auth for private LAN servers', () => { + it('does not require H5 auth for LAN or public browser URLs while token access is paused', () => { expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', '127.0.0.1')).toBe(false) expect(requiresH5AuthForServerUrl('http://10.0.0.5:28670', 'localhost')).toBe(false) expect(requiresH5AuthForServerUrl('http://172.20.1.8:28670', 'localhost')).toBe(false) - expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'localhost')).toBe(true) - expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', 'phone.example.test')).toBe(true) + expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'localhost')).toBe(false) + expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', 'phone.example.test')).toBe(false) }) it('clears an invalid token but preserves the remembered remote server URL', async () => { @@ -188,6 +188,23 @@ describe('desktopRuntime browser H5 bootstrap', () => { }) }) + it('lets browser H5 connect to a public server URL without H5 token', async () => { + window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com%2Fapp') + window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'remote-token') + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(null, { status: 200 }), + ) as typeof fetch + + await expect(initializeDesktopServerUrl()).resolves.toBe('https://public.example.com/app') + + expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('https://public.example.com/app') + expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null) + expect(clientMocks.postVerify).not.toHaveBeenCalled() + expect(globalThis.fetch).toHaveBeenCalledWith('https://public.example.com/app/api/status', { + cache: 'no-store', + }) + }) + it('shows the H5 token recovery view when a local browser connects to an auth-required LAN server', async () => { window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F192.168.0.102%3A28670') globalThis.fetch = vi.fn() diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts index 0eeacc6d..4d84c1ea 100644 --- a/desktop/src/lib/desktopRuntime.ts +++ b/desktop/src/lib/desktopRuntime.ts @@ -269,46 +269,9 @@ export function isLoopbackHostname(hostname: string) { } export function requiresH5AuthForServerUrl(serverUrl: string, browserHostname = getBrowserHostname()) { - try { - const serverHostname = new URL(serverUrl).hostname - if (isLoopbackHostname(serverHostname)) { - return false - } - if (browserHostname && isLoopbackHostname(browserHostname) && isPrivateNetworkHostname(serverHostname)) { - return false - } - return true - } catch { - return false - } -} - -function isPrivateNetworkHostname(hostname: string) { - const normalized = hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase() - - if (normalized === '0.0.0.0') { - return true - } - - const ipv4Parts = normalized.split('.') - if (ipv4Parts.length === 4 && ipv4Parts.every((part) => /^\d+$/.test(part))) { - const octets = ipv4Parts.map((part) => Number(part)) - if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) { - return false - } - const a = octets[0] ?? -1 - const b = octets[1] ?? -1 - return ( - a === 10 || - (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 168) || - (a === 169 && b === 254) - ) - } - - return normalized.startsWith('fc') || - normalized.startsWith('fd') || - normalized.startsWith('fe80:') + void serverUrl + void browserHostname + return false } function getBrowserHostname() { diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index abf14a13..c0ea85f4 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -5,7 +5,6 @@ import { useTranslation } from '../i18n' import { Modal } from '../components/shared/Modal' import { ConfirmDialog } from '../components/shared/ConfirmDialog' import { Input } from '../components/shared/Input' -import { Textarea } from '../components/shared/Textarea' import { Button } from '../components/shared/Button' import { Dropdown } from '../components/shared/Dropdown' import type { PermissionMode, EffortLevel, ThemeMode, WebSearchMode } from '../types/settings' @@ -48,8 +47,6 @@ import { } from '../lib/providerSettingsJson' import { copyTextToClipboard } from '../components/chat/clipboard' -const H5_GENERATED_TOKEN_TIMEOUT_MS = 30_000 - export function Settings() { const [activeTab, setActiveTab] = useState('providers') const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab) @@ -1364,9 +1361,6 @@ function GeneralSettings() { setWebSearch, h5Access, h5AccessError, - enableH5Access, - disableH5Access, - regenerateH5AccessToken, updateH5AccessSettings, responseLanguage, setResponseLanguage, @@ -1374,16 +1368,12 @@ function GeneralSettings() { const t = useTranslation() const [webSearchDraft, setWebSearchDraft] = useState(webSearch) const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(h5Access.publicBaseUrl ?? '') - const [h5AllowedOriginsDraft, setH5AllowedOriginsDraft] = useState(serializeAllowedOrigins(h5Access.allowedOrigins)) - const [generatedH5Token, setGeneratedH5Token] = useState(null) const [notificationPermission, setNotificationPermission] = useState('default') const [notificationActionRunning, setNotificationActionRunning] = useState(false) const [h5ActionRunning, setH5ActionRunning] = useState(false) const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch) - const h5AccessUrl = h5Access.enabled && h5Access.publicBaseUrl ? h5Access.publicBaseUrl : null - const h5AccessDirty = - h5PublicBaseUrlDraft.trim() !== (h5Access.publicBaseUrl ?? '') || - !arraysEqual(parseAllowedOriginsDraft(h5AllowedOriginsDraft), h5Access.allowedOrigins) + const h5AccessUrl = h5Access.publicBaseUrl + const h5AccessDirty = h5PublicBaseUrlDraft.trim() !== (h5Access.publicBaseUrl ?? '') useEffect(() => { setWebSearchDraft(webSearch) @@ -1391,21 +1381,8 @@ function GeneralSettings() { useEffect(() => { setH5PublicBaseUrlDraft(h5Access.publicBaseUrl ?? '') - setH5AllowedOriginsDraft(serializeAllowedOrigins(h5Access.allowedOrigins)) }, [h5Access]) - useEffect(() => { - if (!generatedH5Token) return - - const timeout = window.setTimeout(() => { - setGeneratedH5Token(null) - }, H5_GENERATED_TOKEN_TIMEOUT_MS) - - return () => { - window.clearTimeout(timeout) - } - }, [generatedH5Token]) - useEffect(() => { let cancelled = false getDesktopNotificationPermission().then((permission) => { @@ -1531,36 +1508,14 @@ function GeneralSettings() { } } - const handleH5AccessToggle = async (enabled: boolean) => { - await runH5Action(async () => { - if (enabled) { - const token = await enableH5Access() - setGeneratedH5Token(token) - return - } - - setGeneratedH5Token(null) - await disableH5Access() - }) - } - const handleH5SettingsSave = async () => { await runH5Action(async () => { await updateH5AccessSettings({ publicBaseUrl: h5PublicBaseUrlDraft.trim() || null, - allowedOrigins: parseAllowedOriginsDraft(h5AllowedOriginsDraft), }) }) } - const handleGeneratedH5TokenCopy = async () => { - if (!generatedH5Token) return - const copied = await copyTextToClipboard(generatedH5Token) - if (copied) { - setGeneratedH5Token(null) - } - } - const handleH5UrlCopy = async () => { if (!h5AccessUrl) return await copyTextToClipboard(h5AccessUrl) @@ -1845,83 +1800,7 @@ function GeneralSettings() { {t('settings.general.h5AccessDescription')}

- - -
-
-
-
- {t('settings.general.h5AccessTokenPreview')} -
-
- {h5Access.tokenPreview ?? t('settings.general.h5AccessDisabledValue')} -
-
- -
-
- - {generatedH5Token && ( -
-
-
- {t('settings.general.h5AccessGeneratedToken')} -
-
- - -
-
- - {generatedH5Token} - -

- {t('settings.general.h5AccessGeneratedTokenHint')} -

-
- )} - -
+
setH5PublicBaseUrlDraft(event.target.value)} /> -