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')}
-
-
- )}
-
-