feat(h5): persist pairing and add secure mobile settings

Add mobile provider and General settings while preserving desktop behavior.
Harden remote credential handling, ngrok session ownership and consent upgrades.
This commit is contained in:
程序员阿江(Relakkes)
2026-09-14 00:38:55 +08:00
parent 9fce822df3
commit 8d7b5ea56b
51 changed files with 1400 additions and 131 deletions
+2 -1
View File
@@ -89,7 +89,8 @@ jobs:
- name: Install desktop dependencies
working-directory: desktop
run: bun install
# The x64 Windows runner also packages ARM64; retain both native addons.
run: bun install --cpu="*"
- name: Install adapter dependencies
working-directory: adapters
+2 -1
View File
@@ -200,7 +200,8 @@ jobs:
- name: Install desktop dependencies
working-directory: desktop
run: bun install
# The x64 Windows runner also packages ARM64; retain both native addons.
run: bun install --cpu="*"
- name: Install adapter dependencies
working-directory: adapters
+173 -14
View File
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { PublicAccessManager, classifyPublicAccessError, migratePublicAccessSettings } from './publicAccess'
import { PublicAccessManager, PUBLIC_ACCESS_CONSENT_VERSION, classifyPublicAccessError, migratePublicAccessSettings, forwardPublicAccess } from './publicAccess'
const directories: string[] = []
const managers: PublicAccessManager[] = []
@@ -50,6 +50,22 @@ describe('ngrok host management', () => {
await expect(manager.start(0)).rejects.toThrow('consent')
expect(forward).not.toHaveBeenCalled()
})
it('does not restore v1 consent after remote settings capabilities expand', async () => {
const { directory } = fixture()
const file = path.join(directory, 'public-access-private.json')
writeFileSync(file, JSON.stringify({ version: 1, authtoken: 'fake-token', autoStart: true, consentVersion: 1, future: 'preserved' }))
const request = vi.fn(async (route: string) => route === '/enable' ? { port: 32123 } : {})
const forward = vi.fn(async () => ({ url: () => 'https://fixture.ngrok-free.app', close: vi.fn(async () => {}) }))
const manager = new PublicAccessManager({ directory, backend: { request: request as never }, forward })
managers.push(manager)
await manager.restore()
await expect(manager.start(1)).rejects.toThrow('consent')
expect(forward).not.toHaveBeenCalled()
expect(manager.getStatus().consentVersion).toBe(1)
await manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
expect(forward).toHaveBeenCalledTimes(1)
expect(JSON.parse(readFileSync(file, 'utf8'))).toMatchObject({ consentVersion: 2, future: 'preserved' })
})
it('migrates persisted settings with unknown fields and consent', async () => {
const { directory } = fixture()
writeFileSync(path.join(directory, 'public-access-private.json'), JSON.stringify({ authtoken: 'fake-token', extra: true }))
@@ -71,17 +87,17 @@ describe('ngrok host management', () => {
it('deduplicates starts and binds the public origin only after tunnel creation', async () => {
const { manager, request, forward } = fixture()
await manager.saveCredential('fake-token')
await Promise.all([manager.start(1), manager.start(1)])
await Promise.all([manager.start(PUBLIC_ACCESS_CONSENT_VERSION), manager.start(PUBLIC_ACCESS_CONSENT_VERSION)])
expect(forward).toHaveBeenCalledTimes(1)
expect(request).toHaveBeenLastCalledWith('/origin', 'PUT', { publicUrl: 'https://fixture.ngrok-free.app' })
expect(manager.getStatus()).toMatchObject({ state: 'online', consentVersion: 1 })
expect(manager.getStatus()).toMatchObject({ state: 'online', consentVersion: PUBLIC_ACCESS_CONSENT_VERSION })
})
it('closes late SDK results after stop without publishing their URL', async () => {
const { manager, forward, listener, request } = fixture()
await manager.saveCredential('fake-token')
let finish!: (value: typeof listener) => void
forward.mockImplementation(() => new Promise(resolve => { finish = resolve }))
const start = manager.start(1)
const start = manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
await Promise.resolve()
await manager.stop()
finish(listener)
@@ -95,19 +111,19 @@ describe('ngrok host management', () => {
await manager.saveCredential('fake-token')
let finish!: (value: typeof listener) => void
forward.mockImplementation(() => new Promise(resolve => { finish = resolve }))
const start = manager.start(1)
const start = manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
await Promise.resolve()
await manager.dispose()
finish(listener)
await start
await manager.start(1)
await manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
expect(forward).toHaveBeenCalledTimes(1)
expect(listener.close).toHaveBeenCalled()
})
it('rebinds on sidecar restart and drops the old tunnel', async () => {
const { manager, forward, listener } = fixture()
await manager.saveCredential('fake-token')
await manager.start(1)
await manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
await manager.serverUnavailable()
expect(manager.getStatus().state).toBe('reconnecting')
await manager.serverChanged()
@@ -118,7 +134,7 @@ describe('ngrok host management', () => {
it('reflects SDK reconnect notifications and ignores callbacks after disable', async () => {
const { manager, forward } = fixture()
await manager.saveCredential('fake-token')
await manager.start(1)
await manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
const config = forward.mock.calls[0]![0] as { onStatusChange: (state: string) => void }
config.onStatusChange('closed')
expect(manager.getStatus().state).toBe('reconnecting')
@@ -133,13 +149,13 @@ describe('ngrok host management', () => {
const { manager, forward } = fixture()
await manager.saveCredential('fake-token')
forward.mockRejectedValueOnce(new Error('network unavailable fake-token'))
await manager.start(1)
await manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
expect(manager.getStatus()).toMatchObject({ state: 'reconnecting', error: 'network' })
await advance(1000)
expect(manager.getStatus().state).toBe('online')
await manager.stop()
forward.mockRejectedValueOnce(new Error('authtoken invalid fake-token'))
await manager.start(1)
await manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
expect(manager.getStatus()).toMatchObject({ state: 'failed', error: 'auth' })
await advance(60_000)
expect(forward).toHaveBeenCalledTimes(3)
@@ -149,7 +165,7 @@ describe('ngrok host management', () => {
vi.useFakeTimers()
const { manager, forward, listener } = fixture()
await manager.saveCredential('fake-token')
await manager.start(1)
await manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
const config = forward.mock.calls[0]![0] as { onStatusChange: (state: string) => void }
forward.mockRejectedValueOnce(new Error('ERR_NGROK_105 authentication denied'))
config.onStatusChange('closed')
@@ -168,14 +184,14 @@ describe('ngrok host management', () => {
(value as { onStatusChange: (state: string) => void }).onStatusChange('closed')
return listener
})
await manager.start(1)
await manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
expect(manager.getStatus().state).toBe('reconnecting')
})
it('disables the local entry immediately and bounds a hanging SDK close', async () => {
vi.useFakeTimers()
const { manager, listener, request } = fixture()
await manager.saveCredential('fake-token')
await manager.start(1)
await manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
listener.close.mockImplementation(() => new Promise(() => {}))
const stopped = manager.stop()
expect(request).toHaveBeenLastCalledWith('/disable', 'POST')
@@ -184,11 +200,30 @@ describe('ngrok host management', () => {
await stopped
expect(manager.getStatus().state).toBe('disabled')
})
it('does not publish an old connection error after stop during backend cleanup', async () => {
const { manager, forward, request } = fixture()
await manager.saveCredential('fake-token')
forward.mockRejectedValueOnce(new Error('network unavailable'))
let finishDisable!: () => void
let disableCount = 0
request.mockImplementation(async route => {
if (route === '/enable') return { port: 32123 }
if (route === '/disable' && ++disableCount === 1) await new Promise<void>(resolve => { finishDisable = resolve })
return {}
})
const connecting = manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
for (let i = 0; i < 20; i++) await Promise.resolve()
expect(finishDisable).toBeDefined()
await manager.stop()
finishDisable()
await connecting
expect(manager.getStatus()).toMatchObject({ state: 'disabled', error: null })
})
it('keeps auto-start opt-in across normal stop', async () => {
const { manager, directory, request, forward } = fixture()
await manager.saveCredential('fake-token')
manager.setAutoStart(true)
await manager.start(1)
await manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
await manager.stop()
const restored = new PublicAccessManager({ directory, backend: { request: request as never }, forward })
managers.push(restored)
@@ -196,3 +231,127 @@ describe('ngrok host management', () => {
expect(restored.getStatus().state).toBe('online')
})
})
// Models v1.7.0: forward() retains one session and its original credentials and
// callbacks after listener.close(). Explicit builders own independent sessions.
function sdkFixture() {
type Session = { token: string, disconnected: () => boolean, heartbeat: (latency: number | null) => void, close: ReturnType<typeof vi.fn> }
const sessions: Session[] = []
const tokens: string[] = []
const listenerCloses: ReturnType<typeof vi.fn>[] = []
const listen = vi.fn(async (session: Session) => {
tokens.push(session.token)
const close = vi.fn(async () => {})
listenerCloses.push(close)
return { url: (): string => 'https://fixture.ngrok-free.app', close }
})
class SessionBuilder {
token = ''
disconnected = () => true
heartbeat = (_latency: number | null) => {}
authtoken(token: string) {
this.token = token
return this
}
handleDisconnection(handler: () => boolean) {
this.disconnected = handler
return this
}
handleHeartbeat(handler: (latency: number | null) => void) {
this.heartbeat = handler
return this
}
async connect() {
const session = { token: this.token, disconnected: this.disconnected, heartbeat: this.heartbeat, close: vi.fn(async () => {}) }
sessions.push(session)
return { ...session, httpEndpoint: () => ({ listenAndForward: () => listen(session) }) }
}
}
let singleton: Session | undefined
const forward = vi.fn(async (config: { authtoken: string, onStatusChange: (status: string) => void }) => {
if (!singleton) {
singleton = {
token: config.authtoken,
disconnected: () => {
config.onStatusChange('closed')
return true
},
heartbeat: () => config.onStatusChange('connected'),
close: vi.fn(async () => {}),
}
sessions.push(singleton)
}
return listen(singleton)
})
const sdk = { SessionBuilder, forward } as never
return { sdk, sessions, tokens, listenerCloses, listen }
}
describe('ngrok SDK session ownership', () => {
it('changes credentials and reconnect callbacks after reopening and closes owned sessions', async () => {
const sdk = sdkFixture()
const { directory, request } = fixture()
const manager = new PublicAccessManager({ directory, backend: { request: request as never }, forward: config => forwardPublicAccess(config, sdk.sdk) })
managers.push(manager)
await manager.saveCredential('first-account')
await manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
await manager.saveCredential('second-account')
await manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
expect(sdk.tokens).toEqual(['first-account', 'second-account'])
expect(sdk.sessions[0]!.close).toHaveBeenCalledTimes(1)
sdk.sessions[0]!.disconnected()
expect(manager.getStatus().state).toBe('online')
sdk.sessions[1]!.disconnected()
expect(manager.getStatus().state).toBe('reconnecting')
sdk.sessions[1]!.heartbeat(null)
expect(manager.getStatus().state).toBe('reconnecting')
sdk.sessions[1]!.heartbeat(3)
expect(manager.getStatus().state).toBe('online')
await manager.deleteCredential()
expect(sdk.sessions[1]!.close).toHaveBeenCalledTimes(1)
expect(sdk.listenerCloses.every(close => close.mock.calls.length === 1)).toBe(true)
})
it('replaces a stalled session and retains callbacks on the replacement', async () => {
vi.useFakeTimers()
const sdk = sdkFixture()
const { directory, request } = fixture()
const manager = new PublicAccessManager({ directory, backend: { request: request as never }, forward: config => forwardPublicAccess(config, sdk.sdk) })
managers.push(manager)
await manager.saveCredential('fake-token')
await manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
sdk.sessions[0]!.disconnected()
await advance(30_000)
for (let i = 0; i < 30; i++) await Promise.resolve()
expect(sdk.sessions).toHaveLength(2)
expect(sdk.sessions[0]!.close).toHaveBeenCalledTimes(1)
expect(manager.getStatus().state).toBe('online')
sdk.sessions[1]!.disconnected()
expect(manager.getStatus().state).toBe('reconnecting')
sdk.sessions[1]!.heartbeat(4)
expect(manager.getStatus().state).toBe('online')
})
it('closes only the old owned session when its listener arrives after a new start', async () => {
const sdk = sdkFixture()
const { directory, request } = fixture()
const manager = new PublicAccessManager({ directory, backend: { request: request as never }, forward: config => forwardPublicAccess(config, sdk.sdk) })
managers.push(manager)
await manager.saveCredential('fake-token')
let finish!: (listener: Awaited<ReturnType<typeof sdk.listen>>) => void
sdk.listen.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
const first = manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
for (let i = 0; i < 20; i++) await Promise.resolve()
await manager.stop()
await manager.start(PUBLIC_ACCESS_CONSENT_VERSION)
finish({ url: () => 'https://old.ngrok-free.app', close: vi.fn(async () => {}) })
await first
expect(sdk.sessions[0]!.close).toHaveBeenCalledTimes(1)
expect(sdk.sessions[1]!.close).not.toHaveBeenCalled()
expect(manager.getStatus()).toMatchObject({ state: 'online', publicUrl: 'https://fixture.ngrok-free.app' })
})
it('closes the session even when endpoint creation fails', async () => {
const sdk = sdkFixture()
sdk.listen.mockRejectedValueOnce(new Error('network unavailable'))
await expect(forwardPublicAccess({ addr: '127.0.0.1:1', authtoken: 'fake', onStatusChange: () => {} }, sdk.sdk)).rejects.toThrow('network unavailable')
expect(sdk.sessions[0]!.close).toHaveBeenCalledTimes(1)
})
})
+49 -10
View File
@@ -1,5 +1,7 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { PUBLIC_ACCESS_CONSENT_VERSION } from '../../src/lib/desktopHost/types'
export { PUBLIC_ACCESS_CONSENT_VERSION } from '../../src/lib/desktopHost/types'
export type PublicAccessStatus = {
state: 'unconfigured' | 'disabled' | 'connecting' | 'online' | 'reconnecting' | 'failed'
@@ -11,13 +13,56 @@ export type PublicAccessStatus = {
}
type Settings = Record<string, unknown> & { version: number, authtoken: string, autoStart: boolean, consentVersion: number }
type Listener = { url(): string | null, close(): Promise<void> }
type ForwardConfig = { addr: string, authtoken: string, onStatusChange: (status: string) => void }
type NgrokSdk = Pick<typeof import('@ngrok/ngrok'), 'SessionBuilder'>
export async function forwardPublicAccess(config: ForwardConfig, sdk: NgrokSdk): Promise<Listener> {
// forward() retains a process-global session, including its original token
// and callbacks. Own a session per attempt so stop and credential rotation
// also end authentication, without closing another generation's session.
let closed = false
const session = await new sdk.SessionBuilder()
.authtoken(config.authtoken)
.handleDisconnection(() => {
if (!closed) config.onStatusChange('closed')
return true
})
.handleHeartbeat(latency => {
// The native SDK can pass null when a heartbeat has no response.
if (!closed && typeof latency === 'number') config.onStatusChange('connected')
})
.connect()
try {
const listener = await session.httpEndpoint().listenAndForward(`http://${config.addr}`)
return {
url: () => listener.url(),
async close() {
closed = true
await Promise.all([closePublicAccessResource(listener), closePublicAccessResource(session)])
},
}
} catch (error) {
closed = true
await closePublicAccessResource(session)
throw error
}
}
async function closePublicAccessResource(resource: { close(): Promise<void> }) {
let timeout: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
resource.close(),
new Promise<void>(resolve => { timeout = setTimeout(resolve, 3_000) }),
])
} catch { /* SDK errors can contain credentials; do not log them. */ }
finally { if (timeout) clearTimeout(timeout) }
}
type Backend = { request<T>(route: string, method: string, body?: unknown): Promise<T> }
type Options = {
directory: string
backend: Backend
forward?: (config: { addr: string, authtoken: string, onStatusChange: (status: string) => void }) => Promise<Listener>
}
export const PUBLIC_ACCESS_CONSENT_VERSION = 1
/** Forward migration is additive and keeps unrecognized fields in this private file. */
export function migratePublicAccessSettings(raw: unknown): Settings {
@@ -140,7 +185,7 @@ export class PublicAccessManager {
const { port } = await this.options.backend.request<{ port: number }>('/enable', 'POST')
if (generation !== this.generation) return this.getStatus()
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid listener port')
const forward = this.options.forward ?? (async config => (await import('@ngrok/ngrok')).forward(config))
const forward = this.options.forward ?? (async config => forwardPublicAccess(config, await import('@ngrok/ngrok')))
let disconnected = false
const onStatusChange = (state: string) => {
if (generation !== this.generation) return
@@ -173,6 +218,7 @@ export class PublicAccessManager {
await this.closeListener(listener)
if (generation !== this.generation) return this.getStatus()
await this.options.backend.request('/disable', 'POST').catch(() => {})
if (generation !== this.generation) return this.getStatus()
this.status.error = classifyPublicAccessError(error)
this.status.state = 'failed'
if (this.status.error === 'network' && this.wanted) {
@@ -186,14 +232,7 @@ export class PublicAccessManager {
private async closeListener(listener: Listener | null) {
if (!listener) return
let timeout: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
listener.close(),
new Promise<void>(resolve => { timeout = setTimeout(resolve, 3_000) }),
])
} catch { /* SDK errors can contain credentials; do not log them. */ }
finally { if (timeout) clearTimeout(timeout) }
await closePublicAccessResource(listener)
}
private async reconnectDisconnected(generation: number) {
@@ -1,8 +1,12 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { createRequire } from 'node:module'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const manifest = require('../../package.json')
const repositoryRoot = path.resolve(path.dirname(require.resolve('../../package.json')), '..')
describe('ngrok native packaging', () => {
it('ships and unpacks SDK native dependencies instead of bundling the loader', () => {
expect(manifest.dependencies['@ngrok/ngrok']).toBeTruthy()
@@ -10,13 +14,30 @@ describe('ngrok native packaging', () => {
expect(manifest.build.files).toContain('node_modules/@ngrok/**')
expect(manifest.scripts['build:electron']).toContain('--external @ngrok/ngrok')
const sdkManifest = require('@ngrok/ngrok/package.json')
for (const target of ['darwin-arm64', 'darwin-x64', 'win32-x64-msvc']) {
for (const target of ['darwin-arm64', 'darwin-x64', 'win32-x64-msvc', 'win32-arm64-msvc']) {
expect(sdkManifest.optionalDependencies[`@ngrok/ngrok-${target}`]).toBeTruthy()
}
})
it.each(['release-desktop.yml', 'build-desktop-dev.yml'])('%s installs native addons for cross-architecture packages', workflow => {
const source = parse(readFileSync(path.join(repositoryRoot, '.github', 'workflows', workflow), 'utf8'))
const jobs = Object.values(source.jobs) as { steps?: { name?: string, run?: string, 'working-directory'?: string }[] }[]
const desktopInstalls = jobs.flatMap(job => job.steps ?? []).filter(step => step.name === 'Install desktop dependencies')
expect(desktopInstalls.length).toBeGreaterThan(0)
for (const step of desktopInstalls) {
expect(step['working-directory']).toBe('desktop')
// Windows ARM64 is built on the x64 runner. Host-only installation omits
// ngrok-win32-arm64-msvc, and npmRebuild:false cannot fetch it at packaging.
expect(step.run).toContain('--cpu="*"')
}
})
it('loads the current host native addon without opening an ngrok session', () => {
const sdk = require('@ngrok/ngrok')
expect(typeof sdk.forward).toBe('function')
expect(typeof sdk.disconnect).toBe('function')
const builder = new sdk.SessionBuilder()
expect(typeof builder.authtoken).toBe('function')
expect(typeof builder.handleDisconnection).toBe('function')
expect(typeof builder.handleHeartbeat).toBe('function')
expect(typeof builder.connect).toBe('function')
})
})
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { act, fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
import { DesktopSettings as Settings } from '../pages/Settings'
import { useAgentStore } from '../stores/agentStore'
import { useSkillStore } from '../stores/skillStore'
import { useSettingsStore } from '../stores/settingsStore'
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
import { DesktopSettings as Settings } from '../pages/Settings'
import { SAFE_DOCTOR_STORAGE_KEYS } from '../lib/doctorRepair'
import { useSessionStore } from '../stores/sessionStore'
import { useSettingsStore } from '../stores/settingsStore'
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
import { DesktopSettings as Settings } from '../pages/Settings'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
import { useUpdateStore } from '../stores/updateStore'
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
import { DesktopSettings as Settings } from '../pages/Settings'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
import { DesktopSettings as Settings } from '../pages/Settings'
import { usePluginStore } from '../stores/pluginStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useSessionStore } from '../stores/sessionStore'
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
import { DesktopSettings as Settings } from '../pages/Settings'
import { useSkillStore } from '../stores/skillStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useSessionStore } from '../stores/sessionStore'
+2 -1
View File
@@ -11,8 +11,9 @@ it('pairs at the current origin with cookies and no bearer or URL secret', async
}))
})
it('does not expose an upstream response body in errors', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, text: async () => 'sensitive upstream body' }))
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, text: async () => 'sensitive upstream body' }))
await expect(remoteAccessApi.session()).rejects.toThrow('Remote access request failed')
await expect(remoteAccessApi.claim('phone', 'expired')).rejects.toMatchObject({ status: 401 })
})
it('routes local device approval through authenticated local API', async () => {
const post = vi.spyOn(api, 'post').mockResolvedValue({})
+2 -2
View File
@@ -1,4 +1,4 @@
import { api } from './client'
import { api, ApiError } from './client'
export type PublicAccessDevice = { id: string, name: string, createdAt: number, expiresAt: number }
export type PublicAccessServerStatus = {
@@ -30,7 +30,7 @@ async function remoteRequest<T>(path: string, body?: unknown): Promise<T> {
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!response.ok) throw new Error('Remote access request failed')
if (!response.ok) throw new ApiError(response.status, 'Remote access request failed')
return await response.json() as T
} finally {
clearTimeout(timeout)
@@ -668,7 +668,7 @@ describe('AppShell boot flow', () => {
expect(screen.getByTestId('mobile-sidebar-toggle')).toHaveClass('h-11', 'w-11')
})
it('keeps browser H5 mobile on chat tabs when settings was restored as active', async () => {
it('keeps browser H5 settings active alongside existing chat tabs', async () => {
mocks.isMobile = true
mocks.tabState.activeTabId = '__settings__'
mocks.tabState.tabs = [
@@ -680,8 +680,18 @@ describe('AppShell boot flow', () => {
await screen.findByText('content loaded')
expect(screen.queryByText('tabs loaded')).not.toBeInTheDocument()
await waitFor(() => {
expect(mocks.setActiveTab).toHaveBeenCalledWith('session-1')
})
expect(mocks.setActiveTab).not.toHaveBeenCalled()
expect(mocks.tabState.activeTabId).toBe('__settings__')
expect(screen.getByTestId('mobile-session-header')).toHaveTextContent('sidebar.settings')
})
it('keeps mobile settings active when no chat session exists', async () => {
mocks.isMobile = true
mocks.tabState.activeTabId = '__settings__'
mocks.tabState.tabs = [{ sessionId: '__settings__', title: 'Settings', type: 'settings', status: 'idle' }]
render(<AppShell />)
await screen.findByText('content loaded')
expect(mocks.tabState.activeTabId).toBe('__settings__')
expect(mocks.setActiveTab).not.toHaveBeenCalled()
})
})
+4 -2
View File
@@ -250,7 +250,7 @@ export function AppShell() {
useEffect(() => {
if (!ready || !isMobileShell) return
if (isChatTab(activeTab) || (!activeTab && !activeTabId)) return
if (isChatTab(activeTab) || activeTab?.type === 'settings' || (!activeTab && !activeTabId)) return
const nextChatTab = tabs.find(isChatTab)
if (nextChatTab) {
setActiveTab(nextChatTab.sessionId)
@@ -371,7 +371,9 @@ export function AppShell() {
aria-controls="sidebar-shell"
aria-expanded={effectiveSidebarOpen}
/>
{isActiveChatTab ? (
{activeTab?.type === 'settings' ? (
<h1 className="min-w-0 flex-1 truncate text-[15px] font-bold leading-tight text-[var(--color-text-primary)]">{t('sidebar.settings')}</h1>
) : isActiveChatTab ? (
<div className="min-w-0 flex-1">
<h1 className="truncate text-[15px] font-bold leading-tight text-[var(--color-text-primary)]">
{mobileSessionTitle}
@@ -0,0 +1,28 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { H5ConnectionView } from './H5ConnectionView'
import { readStoredH5Connection, saveAndVerifyH5Connection } from '../../lib/desktopRuntime'
import { useSettingsStore } from '../../stores/settingsStore'
vi.mock('../../lib/desktopRuntime', () => ({ readStoredH5Connection: vi.fn(), saveAndVerifyH5Connection: vi.fn() }))
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
vi.mocked(readStoredH5Connection).mockReturnValue({ serverUrl: 'https://paired.example', token: 'saved-token' })
vi.mocked(saveAndVerifyH5Connection).mockResolvedValue('https://paired.example')
})
afterEach(() => { cleanup(); vi.resetAllMocks() })
it('retries a remembered pairing after a network failure without asking for its token', async () => {
const connected = vi.fn()
render(<H5ConnectionView initialServerUrl="https://paired.example" error="Network unavailable" onConnected={connected} />)
expect(screen.getByLabelText(/H5 Token/)).toHaveValue('')
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await vi.waitFor(() => expect(connected).toHaveBeenCalledOnce())
expect(saveAndVerifyH5Connection).toHaveBeenCalledWith('https://paired.example', 'saved-token')
})
it('does not offer the saved credential to another entered server', () => {
render(<H5ConnectionView initialServerUrl="https://other.example" onConnected={vi.fn()} />)
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
})
@@ -1,6 +1,6 @@
import type { FormEvent } from 'react'
import { useState } from 'react'
import { saveAndVerifyH5Connection } from '../../lib/desktopRuntime'
import { readStoredH5Connection, saveAndVerifyH5Connection } from '../../lib/desktopRuntime'
import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
import { Input } from '@/components/ui/Input'
@@ -23,6 +23,8 @@ export function H5ConnectionView({
const [token, setToken] = useState('')
const [error, setError] = useState(initialError ?? '')
const [submitting, setSubmitting] = useState(false)
const remembered = readStoredH5Connection()
const canRetry = remembered.token && remembered.serverUrl === serverUrl.trim().replace(/\/$/, '')
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
@@ -95,6 +97,16 @@ export function H5ConnectionView({
<Button type="submit" size="lg" className="w-full" loading={submitting}>
{t('h5Connect.submit')}
</Button>
{canRetry && <Button type="button" variant="secondary" className="w-full" disabled={submitting} onClick={async () => {
setSubmitting(true)
setError('')
try {
await saveAndVerifyH5Connection(serverUrl, remembered.token!)
onConnected()
} catch (retryError) {
setError(retryError instanceof Error ? retryError.message : t('h5Connect.failed'))
} finally { setSubmitting(false) }
}}>{t('common.retry')}</Button>}
</form>
</Card>
</div>
@@ -2074,7 +2074,11 @@ describe('Sidebar', () => {
expect(screen.queryByRole('button', { name: 'Scheduled' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Skills Market' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
expect(useTabStore.getState().activeTabId).toBe('__settings__')
expect(onRequestClose).toHaveBeenCalledTimes(1)
onRequestClose.mockClear()
fireEvent.click(screen.getByRole('button', { name: /Open Session/ }))
expect(onRequestClose).toHaveBeenCalledTimes(1)
+1 -1
View File
@@ -1472,7 +1472,7 @@ export function Sidebar({
<div className="flex-1" aria-hidden="true" />
)}
{!isMobile && (
{(
<div
data-testid="sidebar-settings-dock"
className={`sidebar-settings-dock absolute bottom-0 left-0 right-0 border-t border-[var(--color-border)] p-3 ${expanded ? '' : 'flex justify-center'}`}
+12 -1
View File
@@ -1,4 +1,15 @@
export const en = {
"h5Settings.credentialRequired": "When changing a request URL, enter the model or image API key again. Keys saved on the computer cannot be sent automatically to a new address.",
"h5Settings.scope": "Model and agent settings apply to the connected computer. Desktop administration and remote-access controls remain on the desktop.",
"h5Settings.providerPrivacy": "Saved API keys are never shown here. Leave the key blank when editing to keep it, or enter a replacement. Official account sign-in and connection testing are available on the desktop.",
"h5Settings.browserOnly": "Appearance and interface language apply only to this browser.",
"h5Settings.agentPreferences": "Agent preferences",
"h5Settings.agentPreferencesHint": "These defaults are shared with the desktop. Existing sessions may keep their current behavior until restarted.",
"h5Settings.effort": "Reasoning effort",
"h5Settings.sendBehavior": "Keyboard send behavior",
"h5Settings.enter": "Enter sends · Shift+Enter inserts a line",
"h5Settings.modifierEnter": "Ctrl/Cmd+Enter sends · Enter inserts a line",
"publicAccess.title": "Public access · ngrok",
"publicAccess.intro": "Connect your own ngrok account once, then scan to use this computer from your phone.",
"publicAccess.account": "Get ngrok Authtoken",
@@ -37,7 +48,7 @@ export const en = {
"publicAccess.phoneUnpaired": "Open a new pairing QR code on your desktop and scan it.",
"publicAccess.deviceName": "Device name",
"publicAccess.requestPair": "Request pairing",
"publicAccess.privacy": "Once enabled, paired phones can view conversations, send instructions and respond to approvals over the internet. Operations run on this computer.\nYour ngrok account provides the connection. Standard HTTPS tunnels decrypt traffic at ngrok, so ngrok can technically access conversations, instructions and files sent through the tunnel. This is not end-to-end encryption that hides content from the relay.\nThe Authtoken is stored in this computer’s application data directory. Someone with access to your local account or administrator privileges may read it. Protect your account and pairing information.\nThe computer must stay running, online and awake. Free-plan traffic limits, request quotas and browser notice pages may affect access. You can turn off public access or revoke a phone at any time.",
"publicAccess.privacy": "Once enabled, paired phones can view conversations, send instructions and respond to approvals over the internet. They can also add, edit, delete and switch model provider configurations and API keys, and change selected General settings. Operations run on this computer.\nYour ngrok account provides the connection. Standard HTTPS tunnels decrypt traffic at ngrok, so ngrok can technically access conversations, instructions, files, model configurations and newly submitted API keys sent through the tunnel. This is not end-to-end encryption that hides content from the relay.\nThe Authtoken is stored in this computer’s application data directory. Someone with access to your local account or administrator privileges may read it. Protect your account and pairing information.\nThe computer must stay running, online and awake. Free-plan traffic limits, request quotas and browser notice pages may affect access. You can turn off public access or revoke a phone at any time.",
'settings.providers.compatibilityJsonHint': 'requestCompatibility is saved only for this provider, separately from global settings.',
'settings.providers.compatibilityBudget': 'Reply output budget',
+12 -1
View File
@@ -2,6 +2,17 @@ import type {
TranslationKey } from './en'
export const jp: Record<TranslationKey, string> = {
"h5Settings.credentialRequired": "リクエスト先の URL を変更する場合は、モデルまたは画像モデルの API キーを再入力してください。パソコンに保存されたキーを新しい送信先へ自動送信することはできません。",
"h5Settings.scope": "モデルとエージェントの設定は接続先のパソコンに適用されます。デスクトップ管理とリモートアクセスの制御はデスクトップで行ってください。",
"h5Settings.providerPrivacy": "保存済み API キーは表示されません。編集時は空欄で既存のキーを保持するか、新しいキーを入力してください。公式アカウントのログインと接続テストはデスクトップで行ってください。",
"h5Settings.browserOnly": "外観と表示言語はこのブラウザーにのみ適用されます。",
"h5Settings.agentPreferences": "エージェントの設定",
"h5Settings.agentPreferencesHint": "これらの既定値はデスクトップと共有されます。既存のセッションへの適用には再起動が必要な場合があります。",
"h5Settings.effort": "推論の強度",
"h5Settings.sendBehavior": "キーボードの送信方法",
"h5Settings.enter": "Enter で送信 · Shift+Enter で改行",
"h5Settings.modifierEnter": "Ctrl/Cmd+Enter で送信 · Enter で改行",
"publicAccess.title": "公開アクセス · ngrok",
"publicAccess.intro": "自分の ngrok アカウントを一度設定すれば、スマートフォンから QR コードでこのパソコンに接続できます。",
"publicAccess.account": "ngrok Authtoken を取得",
@@ -40,7 +51,7 @@ export const jp: Record<TranslationKey, string> = {
"publicAccess.phoneUnpaired": "デスクトップで新しいペアリング QR コードを表示してスキャンしてください。",
"publicAccess.deviceName": "端末名",
"publicAccess.requestPair": "ペアリングを要求",
"publicAccess.privacy": "有効にすると、ペアリング済みのスマートフォンでインターネット経由の会話閲覧、指示の送信、承認への応答が可能になります。操作はこのパソコンで実行されます。\n接続には自分の ngrok アカウントを使用します。標準 HTTPS トンネルは ngrok 側で復号されるため、ngrok は技術的に会話、指示、ファイルの内容にアクセスできます。中継側に内容を隠すエンドツーエンド暗号化ではありません。\nAuthtoken はローカルのアプリデータフォルダーに保存されます。ローカルアカウントや管理者権限を持つ人が読み取れる可能性があります。アカウントとペアリング情報を保護してください。\nパソコンは起動、オンライン、スリープしていない状態が必要です。無料プランの転送量、リクエスト上限、ブラウザーの案内ページがアクセスに影響する場合があります。いつでも公開アクセスの停止や端末の解除ができます。",
"publicAccess.privacy": "有効にすると、ペアリング済みのスマートフォンでインターネット経由の会話閲覧、指示の送信、承認への応答が可能になります。モデルプロバイダー設定と API キーの追加・変更・削除・切り替え、および一部の一般設定の変更も可能です。操作はこのパソコンで実行されます。\n接続には自分の ngrok アカウントを使用します。標準 HTTPS トンネルは ngrok 側で復号されるため、ngrok は技術的に会話、指示、ファイルの内容、モデル設定、新しく送信した API キーにアクセスできます。中継側に内容を隠すエンドツーエンド暗号化ではありません。\nAuthtoken はローカルのアプリデータフォルダーに保存されます。ローカルアカウントや管理者権限を持つ人が読み取れる可能性があります。アカウントとペアリング情報を保護してください。\nパソコンは起動、オンライン、スリープしていない状態が必要です。無料プランの転送量、リクエスト上限、ブラウザーの案内ページがアクセスに影響する場合があります。いつでも公開アクセスの停止や端末の解除ができます。",
'settings.providers.compatibilityJsonHint': 'requestCompatibility はこのプロバイダーにのみ保存され、グローバル設定には書き込まれません。',
'settings.providers.compatibilityBudget': '応答の出力予算',
+12 -1
View File
@@ -2,6 +2,17 @@ import type {
TranslationKey } from './en'
export const kr: Record<TranslationKey, string> = {
"h5Settings.credentialRequired": "요청 주소를 변경할 때는 모델 또는 이미지 모델의 API 키를 다시 입력하세요. 컴퓨터에 저장된 키를 새 주소로 자동 전송할 수 없습니다.",
"h5Settings.scope": "모델 및 에이전트 설정은 연결된 컴퓨터에 적용됩니다. 데스크톱 관리 및 원격 접속 제어는 데스크톱에서 진행하세요.",
"h5Settings.providerPrivacy": "저장된 API 키는 표시되지 않습니다. 편집할 때 비워 두면 기존 키를 유지하고 새 키를 입력하면 교체됩니다. 공식 계정 로그인과 연결 테스트는 데스크톱에서 진행하세요.",
"h5Settings.browserOnly": "화면 모양과 인터페이스 언어는 이 브라우저에만 적용됩니다.",
"h5Settings.agentPreferences": "에이전트 기본 설정",
"h5Settings.agentPreferencesHint": "이 기본 설정은 데스크톱과 공유됩니다. 기존 세션에 적용하려면 다시 시작해야 할 수 있습니다.",
"h5Settings.effort": "추론 강도",
"h5Settings.sendBehavior": "키보드 전송 방식",
"h5Settings.enter": "Enter로 전송 · Shift+Enter로 줄 바꿈",
"h5Settings.modifierEnter": "Ctrl/Cmd+Enter로 전송 · Enter로 줄 바꿈",
"publicAccess.title": "공개 접속 · ngrok",
"publicAccess.intro": "본인의 ngrok 계정을 한 번 연결하면 휴대폰에서 QR 코드를 스캔해 이 컴퓨터를 원격으로 사용할 수 있습니다.",
"publicAccess.account": "ngrok Authtoken 받기",
@@ -40,7 +51,7 @@ export const kr: Record<TranslationKey, string> = {
"publicAccess.phoneUnpaired": "데스크톱에서 새 페어링 QR 코드를 생성한 후 스캔하세요.",
"publicAccess.deviceName": "기기 이름",
"publicAccess.requestPair": "페어링 요청",
"publicAccess.privacy": "활성화하면 페어링된 휴대폰에서 인터넷을 통해 대화를 보고 지시를 보내고 승인 요청에 응답할 수 있습니다. 작업은 이 컴퓨터에서 실행됩니다.\n연결은 본인의 ngrok 계정으로 제공됩니다. 표준 HTTPS 터널은 ngrok 서버에서 복호화되므로 ngrok은 기술적으로 전송되는 대화, 지시 및 파일 내용에 접근할 수 있습니다. 중계 서버가 내용을 읽을 수 없는 종단간 암호화가 아닙니다.\nAuthtoken은 로컬 앱 데이터 폴더에 저장됩니다. 로컬 계정이나 관리자 권한이 있는 사람이 읽을 수 있습니다. 계정과 페어링 정보를 안전하게 보관하세요.\n컴퓨터는 실행 중이고 인터넷에 연결되어 있으며 절전 상태가 아니어야 합니다. 무료 요금제의 트래픽, 요청 한도와 브라우저 안내 페이지가 접속에 영향을 줄 수 있습니다. 언제든 공개 접속을 끄거나 휴대폰을 해제할 수 있습니다.",
"publicAccess.privacy": "활성화하면 페어링된 휴대폰에서 인터넷을 통해 대화를 보고 지시를 보내고 승인 요청에 응답할 수 있습니다. 모델 공급자 설정과 API 키를 추가, 수정, 삭제하거나 전환하고 일부 일반 설정을 변경할 수도 있습니다. 작업은 이 컴퓨터에서 실행됩니다.\n연결은 본인의 ngrok 계정으로 제공됩니다. 표준 HTTPS 터널은 ngrok 서버에서 복호화되므로 ngrok은 기술적으로 전송되는 대화, 지시, 파일 내용, 모델 설정 및 새로 제출한 API 키에 접근할 수 있습니다. 중계 서버가 내용을 읽을 수 없는 종단간 암호화가 아닙니다.\nAuthtoken은 로컬 앱 데이터 폴더에 저장됩니다. 로컬 계정이나 관리자 권한이 있는 사람이 읽을 수 있습니다. 계정과 페어링 정보를 안전하게 보관하세요.\n컴퓨터는 실행 중이고 인터넷에 연결되어 있으며 절전 상태가 아니어야 합니다. 무료 요금제의 트래픽, 요청 한도와 브라우저 안내 페이지가 접속에 영향을 줄 수 있습니다. 언제든 공개 접속을 끄거나 휴대폰을 해제할 수 있습니다.",
'settings.providers.compatibilityJsonHint': 'requestCompatibility는 이 공급자에만 저장되며 전역 설정에는 기록되지 않습니다.',
'settings.providers.compatibilityBudget': '응답 출력 예산',
+12 -1
View File
@@ -2,6 +2,17 @@ import type {
TranslationKey } from './en'
export const zh: Record<TranslationKey, string> = {
"h5Settings.credentialRequired": "變更請求網址時,請重新填寫主模型或圖片模型的 API Key,不能將電腦已儲存的金鑰自動傳送至新網址。",
"h5Settings.scope": "模型及 Agent 設定會套用至連接的電腦。桌面管理及遠端存取控制仍需在桌面操作。",
"h5Settings.providerPrivacy": "不會顯示已儲存的 API Key。編輯時留空可保留原金鑰,或輸入新金鑰取代。官方帳戶登入及連線測試請在桌面完成。",
"h5Settings.browserOnly": "外觀和介面語言僅套用於目前瀏覽器。",
"h5Settings.agentPreferences": "Agent 偏好",
"h5Settings.agentPreferencesHint": "這些預設設定與桌面共用,現有對話可能需要重新啟動才會套用。",
"h5Settings.effort": "推理強度",
"h5Settings.sendBehavior": "鍵盤傳送方式",
"h5Settings.enter": "Enter 傳送 · Shift+Enter 換行",
"h5Settings.modifierEnter": "Ctrl/Cmd+Enter 傳送 · Enter 換行",
"publicAccess.title": "公網存取 · ngrok",
"publicAccess.intro": "連接一次你自己的 ngrok 帳戶,即可掃碼,在手機上遠端使用這台電腦。",
"publicAccess.account": "取得 ngrok Authtoken",
@@ -40,7 +51,7 @@ export const zh: Record<TranslationKey, string> = {
"publicAccess.phoneUnpaired": "請在桌面產生新的配對 QR 碼,並使用手機掃碼。",
"publicAccess.deviceName": "裝置名稱",
"publicAccess.requestPair": "請求配對",
"publicAccess.privacy": "開啟後,已配對的手機可透過網際網路查看對話、傳送指令及回應核准,相關操作會在這台電腦上執行。\n連線由你的 ngrok 帳戶提供。標準 HTTPS 通道會在 ngrok 伺服器解密,因此 ngrok 在技術上能接觸傳輸的對話、指令和檔案內容;這不是中繼無法讀取的端對端加密。\nAuthtoken 儲存在本機應用程式資料目錄,擁有本機帳戶或管理員權限的人可能讀取它。請妥善保管帳戶及配對資訊。\n電腦必須保持執行、連網且未休眠。免費方案的流量、請求額度和瀏覽器提示頁可能影響存取。你可以隨時關閉公網存取或撤銷手機。",
"publicAccess.privacy": "開啟後,已配對的手機可透過網際網路查看對話、傳送指令及回應核准,也能新增、修改、刪除與切換模型服務商設定及 API Key,並修改部分一般設定,相關操作會在這台電腦上執行。\n連線由你的 ngrok 帳戶提供。標準 HTTPS 通道會在 ngrok 伺服器解密,因此 ngrok 在技術上能接觸傳輸的對話、指令、檔案內容、模型設定及新提交的 API Key;這不是中繼無法讀取的端對端加密。\nAuthtoken 儲存在本機應用程式資料目錄,擁有本機帳戶或管理員權限的人可能讀取它。請妥善保管帳戶及配對資訊。\n電腦必須保持執行、連網且未休眠。免費方案的流量、請求額度和瀏覽器提示頁可能影響存取。你可以隨時關閉公網存取或撤銷手機。",
'settings.providers.compatibilityJsonHint': 'requestCompatibility 僅儲存到目前供應商,不寫入全域設定。',
'settings.providers.compatibilityBudget': '回覆輸出預算',
+12 -1
View File
@@ -2,6 +2,17 @@ import type {
TranslationKey } from './en'
export const zh: Record<TranslationKey, string> = {
"h5Settings.credentialRequired": "更改请求地址时,请重新填写主模型或图片模型的 API Key,不能把电脑已保存的密钥自动发送到新地址。",
"h5Settings.scope": "模型和 Agent 设置会应用到连接的电脑。桌面管理及远程访问控制仍需在桌面操作。",
"h5Settings.providerPrivacy": "不会显示已保存的 API Key。编辑时留空可保留原密钥,或输入新密钥替换。官方账户登录和连接测试请在桌面完成。",
"h5Settings.browserOnly": "外观和界面语言仅应用于当前浏览器。",
"h5Settings.agentPreferences": "Agent 偏好",
"h5Settings.agentPreferencesHint": "这些默认设置与桌面共享,现有会话可能需要重启后才会应用。",
"h5Settings.effort": "推理强度",
"h5Settings.sendBehavior": "键盘发送方式",
"h5Settings.enter": "Enter 发送 · Shift+Enter 换行",
"h5Settings.modifierEnter": "Ctrl/Cmd+Enter 发送 · Enter 换行",
"publicAccess.title": "公网访问 · ngrok",
"publicAccess.intro": "连接一次你自己的 ngrok 账户,即可扫码,在手机上远程使用这台电脑。",
"publicAccess.account": "获取 ngrok Authtoken",
@@ -40,7 +51,7 @@ export const zh: Record<TranslationKey, string> = {
"publicAccess.phoneUnpaired": "请在桌面生成新的配对二维码,并使用手机扫码。",
"publicAccess.deviceName": "设备名称",
"publicAccess.requestPair": "请求配对",
"publicAccess.privacy": "开启后,已配对的手机可通过互联网查看会话、发送指令和响应审批,相关操作会在这台电脑上执行。\n连接由你的 ngrok 账户提供。标准 HTTPS 隧道会在 ngrok 服务端解密,因此 ngrok 在技术上可以接触经隧道传输的会话、指令和文件内容;这不是中转不可读的端到端加密。\nAuthtoken 保存在本机应用数据目录,拥有本机账户或管理员权限的人可能读取它。请妥善保管账户和配对信息。\n电脑需要保持运行、联网且未休眠。免费套餐存在流量、请求额度和浏览器提示页,可能影响访问。你可以随时关闭公网访问或撤销手机。",
"publicAccess.privacy": "开启后,已配对的手机可通过互联网查看会话、发送指令和响应审批,也可以新增、修改、删除和切换模型服务商配置及 API Key,并修改部分通用设置,相关操作会在这台电脑上执行。\n连接由你的 ngrok 账户提供。标准 HTTPS 隧道会在 ngrok 服务端解密,因此 ngrok 在技术上可以接触经隧道传输的会话、指令、文件内容、模型配置和新提交的 API Key;这不是中转不可读的端到端加密。\nAuthtoken 保存在本机应用数据目录,拥有本机账户或管理员权限的人可能读取它。请妥善保管账户和配对信息。\n电脑需要保持运行、联网且未休眠。免费套餐存在流量、请求额度和浏览器提示页,可能影响访问。你可以随时关闭公网访问或撤销手机。",
'settings.providers.compatibilityJsonHint': 'requestCompatibility 仅保存到当前供应商,不写入全局设置。',
'settings.providers.compatibilityBudget': '回复输出预算',
+3
View File
@@ -4,6 +4,9 @@ import type {
} from '../../types/settings'
import type { Locale } from '../../i18n/locale'
// Version 2 adds remote provider management and selected General settings.
export const PUBLIC_ACCESS_CONSENT_VERSION = 2
export type DesktopHostKind = 'browser' | 'electron'
export type DesktopHostCapability =
+74 -1
View File
@@ -40,6 +40,7 @@ describe('desktopRuntime browser H5 bootstrap', () => {
beforeEach(() => {
vi.clearAllMocks()
clientMocks.postVerify.mockReset()
clientMocks.defaultBaseUrl = 'http://127.0.0.1:3456'
clientMocks.explicitDefaultBaseUrl = false
vi.useRealTimers()
@@ -62,7 +63,7 @@ describe('desktopRuntime browser H5 bootstrap', () => {
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'old-secret')
clientMocks.explicitDefaultBaseUrl = true
clientMocks.defaultBaseUrl = 'https://configured.example'
globalThis.fetch = vi.fn().mockResolvedValue(healthOkResponse()) as typeof fetch
globalThis.fetch = vi.fn().mockImplementation(async () => healthOkResponse()) as typeof fetch
await expect(initializeDesktopServerUrl()).resolves.toBe(window.location.origin)
expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(window.location.origin)
@@ -445,6 +446,78 @@ describe('desktopRuntime browser H5 bootstrap', () => {
expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe('http://192.168.0.102:28670')
})
it('keeps the saved pairing through transient verification failure and reconnects without scanning', async () => {
const server = 'https://paired.example/app'
localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, server)
localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'remembered-token')
history.replaceState(null, '', '/?serverUrl=' + encodeURIComponent(server))
globalThis.fetch = vi.fn().mockImplementation(async () => healthOkResponse()) as typeof fetch
clientMocks.postVerify.mockRejectedValueOnce(new TypeError('Network unavailable')).mockResolvedValueOnce({ ok: true })
await expect(initializeDesktopServerUrl()).rejects.toMatchObject({ reason: 'verify-failed' })
expect(localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBe('remembered-token')
await expect(initializeDesktopServerUrl()).resolves.toBe(server)
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith('remembered-token')
})
it('keeps an existing valid pairing when the camera opens an older QR link', async () => {
const server = 'https://paired.example/app'
localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, server)
localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'current-token')
history.replaceState(null, '', '/?serverUrl=' + encodeURIComponent(server) + '&h5Token=old-qr-token&keep=yes')
globalThis.fetch = vi.fn().mockImplementation(async () => healthOkResponse()) as typeof fetch
clientMocks.postVerify.mockResolvedValueOnce({ ok: true })
await expect(initializeDesktopServerUrl()).resolves.toBe(server)
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith('current-token')
expect(localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBe('current-token')
expect(location.search).not.toContain('h5Token')
expect(new URLSearchParams(location.search).get('keep')).toBe('yes')
})
it('can replace an expired stored token with a fresh camera QR token', async () => {
const server = 'https://paired.example/app'
localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, server)
localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'expired-token')
history.replaceState(null, '', '/?serverUrl=' + encodeURIComponent(server) + '&h5Token=fresh-token')
globalThis.fetch = vi.fn().mockImplementation(async () => healthOkResponse()) as typeof fetch
clientMocks.postVerify.mockRejectedValueOnce(Object.assign(new Error('Invalid token'), { status: 401 })).mockResolvedValueOnce({ ok: true })
await expect(initializeDesktopServerUrl()).resolves.toBe(server)
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith('fresh-token')
expect(localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBe('fresh-token')
})
it('does not erase another server pairing when an unpaired QR destination is opened', async () => {
localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, 'https://paired.example')
localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'paired-token')
history.replaceState(null, '', '/?serverUrl=https%3A%2F%2Funpaired.example')
globalThis.fetch = vi.fn().mockImplementation(async () => healthOkResponse()) as typeof fetch
await expect(initializeDesktopServerUrl()).rejects.toMatchObject({ reason: 'missing-token' })
expect(localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe('https://paired.example')
expect(localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBe('paired-token')
})
it('preserves the legacy stored pairing while the desktop is offline', async () => {
vi.useFakeTimers()
localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, 'https://paired.example')
localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'saved-token')
history.replaceState(null, '', '/?serverUrl=https%3A%2F%2Fpaired.example')
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Offline')) as typeof fetch
const result = expect(initializeDesktopServerUrl()).rejects.toMatchObject({ reason: 'unreachable' })
await vi.runAllTimersAsync()
await result
expect(localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBe('saved-token')
})
it('forgets only a saved token explicitly rejected by its own server', async () => {
localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, 'https://paired.example')
localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'revoked-token')
history.replaceState(null, '', '/?serverUrl=https%3A%2F%2Fpaired.example')
globalThis.fetch = vi.fn().mockImplementation(async () => healthOkResponse()) as typeof fetch
clientMocks.postVerify.mockRejectedValueOnce(Object.assign(new Error('Invalid token'), { status: 401 }))
await expect(initializeDesktopServerUrl()).rejects.toMatchObject({ reason: 'invalid-token' })
expect(localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBeNull()
expect(localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe('https://paired.example')
})
it('uses and persists an H5 token from the QR launch URL', async () => {
window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com%2Fapp&h5Token=qr-token')
globalThis.fetch = vi.fn().mockResolvedValue(
+48 -22
View File
@@ -130,23 +130,17 @@ export async function saveAndVerifyH5Connection(serverUrl: string, token: string
setBaseUrl(normalizedServerUrl)
setAuthToken(normalizedToken)
rememberStoredH5ServerUrl(normalizedServerUrl)
if (!readStoredH5Connection().token) rememberStoredH5ServerUrl(normalizedServerUrl)
try {
await waitForHealth(normalizedServerUrl)
await verifyH5Access()
} catch (error) {
clearStoredH5Token()
forgetRejectedH5Token(error, normalizedServerUrl, normalizedToken)
throw normalizeBrowserH5Error(error, normalizedServerUrl)
}
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, normalizedToken)
} catch {
// Ignore storage failures after a successful verification.
}
}
rememberVerifiedH5Connection(normalizedServerUrl, normalizedToken)
return normalizedServerUrl
}
@@ -217,7 +211,7 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
requestedUrl === sameOriginUrl
// A bearer token belongs to exactly one H5 server. A query-selected server
// must never inherit credentials paired with a different authority.
const token = queryToken ?? (stored.serverUrl === requestedUrl ? stored.token : null)
let token = (stored.serverUrl === requestedUrl ? stored.token : null) ?? queryToken
const browserH5Runtime = requiresH5AuthForServerUrl(requestedUrl)
setBaseUrl(requestedUrl)
@@ -240,7 +234,7 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
}
if (browserH5Runtime) {
clearStoredH5Token()
setAuthToken(null)
throw normalizeBrowserH5Error(error, requestedUrl)
}
throw error
@@ -256,7 +250,7 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
// Keep the existing recovery UX for a first-time connection, but never
// replace a paired server while withholding its token from a new one.
if (!stored.token) rememberStoredH5ServerUrl(requestedUrl)
clearStoredH5Token()
setAuthToken(null)
throw new H5ConnectionRequiredError(
'Enter your H5 token to continue.',
requestedUrl,
@@ -265,20 +259,28 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
}
try {
await verifyH5Access()
try {
await verifyH5Access()
} catch (error) {
// Prefer the browser's working pairing over a stale camera QR. A fresh
// QR can replace a revoked token, but network failures never erase it.
if (!queryToken || queryToken === token || normalizeBrowserH5Error(error, requestedUrl).reason !== 'invalid-token') throw error
forgetRejectedH5Token(error, requestedUrl, token)
token = queryToken
setAuthToken(token)
await verifyH5Access()
}
} catch (error) {
clearStoredH5Token()
forgetRejectedH5Token(error, requestedUrl, token)
throw normalizeBrowserH5Error(error, requestedUrl)
}
rememberStoredH5ServerUrl(requestedUrl)
if (queryToken && typeof window !== 'undefined') {
try {
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, queryToken)
} catch {
// Ignore storage failures after successful verification.
}
rememberVerifiedH5Connection(requestedUrl, token)
if (typeof window !== 'undefined') {
const url = new URL(window.location.href)
url.searchParams.delete('h5Token')
url.searchParams.delete('token')
window.history.replaceState(window.history.state, '', url.pathname + url.search + url.hash)
}
markDesktopServerReady()
@@ -488,3 +490,27 @@ function clearStoredH5Token() {
setAuthToken(null)
}
/** Only an explicit rejection of the saved credential may forget a pairing. */
function forgetRejectedH5Token(error: unknown, serverUrl: string, token: string) {
const stored = readStoredH5Connection()
if (normalizeBrowserH5Error(error, serverUrl).reason === 'invalid-token' &&
stored.serverUrl === serverUrl && stored.token === token) {
clearStoredH5Token()
} else {
setAuthToken(null)
}
}
function rememberVerifiedH5Connection(serverUrl: string, token: string) {
if (typeof window === 'undefined') return
try {
// Retain the old storage shape. If a write fails mid-switch, never leave
// another server's token paired with the newly written URL.
if (readStoredH5Connection().serverUrl !== serverUrl) window.localStorage.removeItem(H5_TOKEN_STORAGE_KEY)
window.localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, serverUrl)
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, token)
} catch {
// Storage restrictions do not prevent the current authenticated connection.
}
}
+39
View File
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { RemoteAccessGate } from './RemoteAccess'
import { remoteAccessApi } from '@/api/publicAccess'
import { useSettingsStore } from '@/stores/settingsStore'
import { ApiError } from '@/api/client'
vi.mock('@/api/publicAccess', () => ({ remoteAccessApi: { session: vi.fn(), pair: vi.fn(), claim: vi.fn() } }))
beforeEach(() => { useSettingsStore.setState({ locale: 'en' }); history.replaceState(null, '', '/remote#pair=once-only') })
afterEach(() => { cleanup(); vi.resetAllMocks() })
@@ -30,6 +31,19 @@ it('unmounts private content when focus detects revoked authentication', async (
await waitFor(() => expect(screen.queryByText('Private conversations')).not.toBeInTheDocument())
expect(screen.getByText(/Open a new pairing QR/)).toBeInTheDocument()
})
it('reuses a paired browser cookie when a camera opens an expired or fresh QR again', async () => {
vi.mocked(remoteAccessApi.session).mockResolvedValue({ authenticated: true })
for (const fragment of ['expired-code', 'new-unused-code']) {
history.replaceState(null, '', '/remote#pair=' + fragment)
const view = render(<RemoteAccessGate><div>Private conversations</div></RemoteAccessGate>)
await screen.findByText('Private conversations')
expect(location.hash).toBe('')
expect(screen.queryByLabelText('Device name')).not.toBeInTheDocument()
view.unmount()
}
expect(remoteAccessApi.pair).not.toHaveBeenCalled()
expect(remoteAccessApi.claim).not.toHaveBeenCalled()
})
it('handles a rejected pairing without mounting private content', async () => {
vi.mocked(remoteAccessApi.session).mockResolvedValue({ authenticated: false })
vi.mocked(remoteAccessApi.pair).mockResolvedValue({ id: 'phone', claimSecret: 'claim' })
@@ -65,3 +79,28 @@ it('retains the scrubbed one-use secret through StrictMode initial replay', asyn
expect(remoteAccessApi.pair).toHaveBeenCalledTimes(1)
expect(remoteAccessApi.pair).toHaveBeenCalledWith('once-only', 'Phone')
})
it.each(['pair', 'claim'] as const)('ends an expired %s with a fresh QR instruction instead of an endless retry', async (step) => {
vi.mocked(remoteAccessApi.session).mockResolvedValue({ authenticated: false })
vi.mocked(remoteAccessApi.pair).mockResolvedValue({ id: 'phone', claimSecret: 'claim' })
vi.mocked(remoteAccessApi[step]).mockRejectedValue(new ApiError(401, { error: 'Remote access request failed' }))
render(<RemoteAccessGate><div>Private conversations</div></RemoteAccessGate>)
fireEvent.change(await screen.findByLabelText('Device name'), { target: { value: 'Phone' } })
fireEvent.click(screen.getByRole('button', { name: 'Request pairing' }))
await screen.findByText(/Open a new pairing QR/)
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
expect(screen.queryByText('Private conversations')).not.toBeInTheDocument()
})
it('retries a transient claim failure using the retained claim when no cookie has arrived', async () => {
vi.mocked(remoteAccessApi.session).mockResolvedValue({ authenticated: false })
vi.mocked(remoteAccessApi.pair).mockResolvedValue({ id: 'phone', claimSecret: 'claim' })
vi.mocked(remoteAccessApi.claim).mockRejectedValueOnce(new TypeError('Network offline')).mockResolvedValue({ status: 'approved' })
render(<RemoteAccessGate><div>Private conversations</div></RemoteAccessGate>)
fireEvent.change(await screen.findByLabelText('Device name'), { target: { value: 'Phone' } })
fireEvent.click(screen.getByRole('button', { name: 'Request pairing' }))
fireEvent.click(await screen.findByRole('button', { name: 'Retry' }))
await screen.findByText('Private conversations')
expect(remoteAccessApi.claim).toHaveBeenCalledTimes(2)
expect(remoteAccessApi.pair).toHaveBeenCalledTimes(1)
})
+15 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { remoteAccessApi } from '@/api/publicAccess'
import { ApiError } from '@/api/client'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { useTranslation } from '@/i18n'
@@ -37,7 +38,13 @@ export function RemoteAccessGate({ children }: { children: ReactNode }) {
if (result.status === 'approved') { setClaim(null); setState('ready') }
else if (result.status === 'rejected') { setClaim(null); setState('unpaired') }
else timer = setTimeout(() => void poll(), 1500)
} catch { if (active) setState('error') }
} catch (error) {
if (!active) return
if (error instanceof ApiError && error.status === 401) {
setClaim(null)
setState('unpaired')
} else setState('error')
}
}
void poll()
return () => { active = false; clearTimeout(timer) }
@@ -85,7 +92,13 @@ export function RemoteAccessGate({ children }: { children: ReactNode }) {
secret.current = null
setClaim(result)
setState('pending')
} catch { setState('error') }
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
secret.current = null
setClaim(null)
setState('unpaired')
} else setState('error')
}
}
if (state === 'ready') return children
return <main className="min-h-screen bg-[var(--color-surface)] p-6 text-[var(--color-text-primary)]">
+6
View File
@@ -1,3 +1,5 @@
import { H5Settings } from './settings/H5Settings'
import { getDesktopHost } from '@/lib/desktopHost'
import { useEffect, useRef } from 'react'
import { useTranslation } from '../i18n'
import { SettingsPageHeader } from '@/components/settings/SettingsSection'
@@ -24,6 +26,10 @@ import { AboutSettings } from './settings/AboutSettings'
import { ProviderSettings } from './settings/ProviderSettings'
export function Settings() {
return getDesktopHost().isDesktop ? <DesktopSettings /> : <H5Settings />
}
export function DesktopSettings() {
const activeTab = useUIStore((s) => s.activeSettingsTab)
const setActiveTab = useUIStore((s) => s.setActiveSettingsTab)
const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab)
@@ -14,7 +14,7 @@ vi.mock('../api/traces', () => ({
},
}))
import { Settings } from './Settings'
import { DesktopSettings as Settings } from './Settings'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
@@ -0,0 +1,75 @@
import { useState } from 'react'
import { useTranslation, type Locale } from '@/i18n'
import { useSettingsStore } from '@/stores/settingsStore'
import { useUIStore } from '@/stores/uiStore'
import { settingsApi } from '@/api/settings'
import { modelsApi } from '@/api/models'
import { SettingsSection } from '@/components/settings/SettingsSection'
import { SelectField } from '@/components/ui/SelectField'
import { Switch } from '@/components/ui/Switch'
import type { ThemeMode, UserSettings, EffortLevel } from '@/types/settings'
export function H5GeneralSettings() {
const t = useTranslation()
const settings = useSettingsStore()
const theme = useUIStore((state) => state.theme)
const [busy, setBusy] = useState(false)
const [failed, setFailed] = useState(false)
const save = async (patch: Partial<UserSettings>) => {
setBusy(true)
setFailed(false)
try {
await settingsApi.updateUser(patch)
await settings.fetchAll()
} catch { setFailed(true) }
finally { setBusy(false) }
}
const effort = async (level: EffortLevel) => {
setBusy(true)
setFailed(false)
try { await modelsApi.setEffort(level); useSettingsStore.setState({ effortLevel: level }) }
catch { setFailed(true) }
finally { setBusy(false) }
}
const languages: { value: Locale, label: string }[] = [
{ value: 'en', label: 'English' }, { value: 'zh', label: '简体中文' }, { value: 'zh-TW', label: '繁體中文' }, { value: 'jp', label: '日本語' }, { value: 'kr', label: '한국어' },
]
const themes: { value: ThemeMode, label: string }[] = [
{ value: 'white', label: t('settings.general.appearance.white') }, { value: 'paper', label: t('settings.general.appearance.paper') },
{ value: 'warm-classic', label: t('settings.general.appearance.warmClassic') }, { value: 'celadon', label: t('settings.general.appearance.celadon') },
{ value: 'dark', label: t('settings.general.appearance.dark') }, { value: 'ink-blue', label: t('settings.general.appearance.inkBlue') },
]
const effortLabels = {
low: t('settings.general.effort.low'), medium: t('settings.general.effort.medium'), high: t('settings.general.effort.high'),
xhigh: t('settings.general.effort.xhigh'), max: t('settings.general.effort.max'),
}
const levels: EffortLevel[] = ['low', 'medium', 'high', 'xhigh', 'max']
const supported = settings.currentModel?.supportedReasoningEfforts
const effortOptions = !settings.currentModel ? [] : levels.filter((value) => supported ? supported.includes(value) : value !== 'xhigh')
const selectedEffort = effortOptions.includes(settings.effortLevel) ? settings.effortLevel : effortOptions[0] ?? settings.effortLevel
const responseLanguages = [{ value: '', label: t('settings.general.responseLangDefault') },
{ value: 'english', label: 'English' }, { value: 'chinese', label: '中文' }, { value: 'japanese', label: '日本語' }, { value: 'korean', label: '한국어' }]
if (settings.responseLanguage && !responseLanguages.some((item) => item.value === settings.responseLanguage)) responseLanguages.push({ value: settings.responseLanguage, label: settings.responseLanguage })
const styles = [{ value: 'default', label: t('settings.general.outputStyleBuiltin.default.label') },
{ value: 'Explanatory', label: t('settings.general.outputStyleBuiltin.explanatory.label') }, { value: 'Learning', label: t('settings.general.outputStyleBuiltin.learning.label') }]
if (settings.outputStyle && !styles.some((item) => item.value === settings.outputStyle)) styles.push({ value: settings.outputStyle, label: settings.outputStyle })
return <div className="max-w-xl space-y-5">
{failed && <p role="alert" className="text-sm text-[var(--color-error)]">{t('publicAccess.genericError')}</p>}
<SettingsSection title={t('settings.general.appearanceTitle')} description={t('h5Settings.browserOnly')}>
<div className="space-y-4">
<SelectField label={t('settings.general.appearanceTitle')} value={theme} options={themes} onChange={(value) => { useUIStore.getState().setFollowSystemTheme(false); void settings.setTheme(value) }} />
<SelectField label={t('settings.general.languageTitle')} value={settings.locale} options={languages} onChange={settings.setLocale} />
</div>
</SettingsSection>
<SettingsSection title={t('h5Settings.agentPreferences')} description={t('h5Settings.agentPreferencesHint')}>
<div className="space-y-5">
<SelectField label={t('settings.general.responseLangTitle')} disabled={busy} value={settings.responseLanguage} options={responseLanguages} onChange={(language) => void save({ language })} />
<SelectField label={t('settings.general.outputStyleTitle')} disabled={busy} value={settings.outputStyle || 'default'} options={styles} onChange={(outputStyle) => void save({ outputStyle })} />
<SelectField label={t('h5Settings.effort')} disabled={busy || !effortOptions.length} value={selectedEffort} options={effortOptions.map((value) => ({ value, label: effortLabels[value] }))} onChange={(value) => void effort(value)} />
<SelectField label={t('h5Settings.sendBehavior')} disabled={busy} value={settings.chatSendBehavior} options={[{ value: 'enter', label: t('h5Settings.enter') }, { value: 'modifierEnter', label: t('h5Settings.modifierEnter') }]} onChange={(chatSendBehavior) => void save({ chatSendBehavior })} />
<Switch disabled={busy} checked={settings.thinkingEnabled} label={t('settings.general.thinkingEnabled')} description={t('settings.general.thinkingDescription')} onChange={(alwaysThinkingEnabled) => void save({ alwaysThinkingEnabled })} />
<Switch disabled={busy} checked={settings.workflowKeywordTriggerEnabled} label={t('settings.general.workflowKeywordEnabled')} description={t('settings.general.workflowKeywordDescription')} onChange={(workflowKeywordTriggerEnabled) => void save({ workflowKeywordTriggerEnabled })} />
</div>
</SettingsSection>
</div>
}
@@ -0,0 +1,111 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { H5Settings } from './H5Settings'
import { Settings } from '../Settings'
import { ProviderSettings } from './ProviderSettings'
import { useUIStore } from '@/stores/uiStore'
import { useSettingsStore } from '@/stores/settingsStore'
import { useProviderStore } from '@/stores/providerStore'
import { providersApi } from '@/api/providers'
import { settingsApi } from '@/api/settings'
import { modelsApi } from '@/api/models'
import type { SavedProvider } from '@/types/provider'
const saved: SavedProvider = {
id: 'fixture-provider', name: 'Fixture provider', presetId: 'custom', baseUrl: 'https://fixture.example', apiKey: '', apiFormat: 'anthropic',
models: { main: 'fixture-model', haiku: 'fixture-model', sonnet: 'fixture-model', opus: 'fixture-model' },
}
beforeEach(() => {
useSettingsStore.setState({ locale: 'en', outputStyle: 'default', responseLanguage: '', effortLevel: 'high', currentModel: { id: 'fixture-model', name: 'Fixture model', context: '', description: '', supportedReasoningEfforts: ['low', 'high'] } })
useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: null })
useProviderStore.setState({ providers: [saved], activeId: null, hasLoadedProviders: true })
vi.spyOn(providersApi, 'list').mockResolvedValue({ providers: [saved], activeId: null })
vi.spyOn(useSettingsStore.getState(), 'fetchAll').mockResolvedValue()
vi.spyOn(providersApi, 'getSettings').mockResolvedValue({})
vi.spyOn(providersApi, 'updateSettings').mockResolvedValue({ ok: true })
})
afterEach(() => { cleanup(); vi.restoreAllMocks() })
it('limits browser navigation to providers and general, with local appearance and shared agent preferences', async () => {
useUIStore.setState({ activeSettingsTab: 'terminal' })
render(<H5Settings />)
const nav = within(screen.getByRole('navigation', { name: 'Settings' }))
expect(nav.getAllByRole('button')).toHaveLength(2)
expect(screen.queryByRole('button', { name: 'Terminal' })).not.toBeInTheDocument()
fireEvent.click(nav.getByRole('button', { name: 'General' }))
expect(screen.getByText('Appearance and interface language apply only to this browser.')).toBeInTheDocument()
const update = vi.spyOn(settingsApi, 'updateUser').mockResolvedValue({ ok: true })
fireEvent.change(screen.getByLabelText('Output Style'), { target: { value: 'Learning' } })
await waitFor(() => expect(update).toHaveBeenCalledWith({ outputStyle: 'Learning' }))
await waitFor(() => expect(screen.getByLabelText('Reasoning effort')).not.toBeDisabled())
const effort = vi.spyOn(modelsApi, 'setEffort').mockResolvedValue({ ok: true, level: 'low' })
fireEvent.change(screen.getByLabelText('Reasoning effort'), { target: { value: 'low' } })
await waitFor(() => expect(effort).toHaveBeenCalledWith('low'))
expect(screen.queryByLabelText(/ngrok Authtoken/)).not.toBeInTheDocument()
})
it('edits saved providers without reading or overwriting stored keys or global settings', async () => {
const update = vi.spyOn(providersApi, 'update').mockResolvedValue({ provider: saved })
render(<ProviderSettings browserMode />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }))
const dialog = within(screen.getByRole('dialog'))
expect(dialog.queryByRole('textbox', { name: 'Settings JSON' })).not.toBeInTheDocument()
expect(dialog.queryByRole('button', { name: 'Test Connection' })).not.toBeInTheDocument()
expect(dialog.queryByRole('button', { name: /Fetch Models/ })).not.toBeInTheDocument()
expect(dialog.getByLabelText('API Key')).toHaveValue('')
expect(providersApi.getSettings).not.toHaveBeenCalled()
fireEvent.click(dialog.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(update).toHaveBeenCalled())
expect(update.mock.calls[0]![0]).toBe('fixture-provider')
expect(update.mock.calls[0]![1]).not.toHaveProperty('apiKey')
expect(providersApi.updateSettings).not.toHaveBeenCalled()
})
it('creates a provider using the existing form and reports save errors without leaking details', async () => {
const create = vi.spyOn(providersApi, 'create').mockRejectedValue(new Error('secret upstream credential'))
render(<ProviderSettings browserMode />)
fireEvent.click(screen.getByRole('button', { name: /Add Model/ }))
const dialog = within(screen.getByRole('dialog'))
fireEvent.change(dialog.getByPlaceholderText('sk-...'), { target: { value: 'fake-test-key' } })
fireEvent.click(dialog.getByRole('button', { name: 'Add' }))
await waitFor(() => expect(create).toHaveBeenCalled())
expect(await dialog.findByRole('alert')).toHaveTextContent('The action failed. Please retry.')
expect(screen.queryByText('secret upstream credential')).not.toBeInTheDocument()
})
it('activates and deletes providers through the existing API without connection probes', async () => {
const activate = vi.spyOn(providersApi, 'activate').mockResolvedValue({ ok: true })
const remove = vi.spyOn(providersApi, 'delete').mockResolvedValue({ ok: true })
const probe = vi.spyOn(providersApi, 'test')
render(<ProviderSettings browserMode />)
const row = within(await screen.findByTestId('provider-fixture-provider'))
fireEvent.click(row.getByRole('button', { name: 'Set default' }))
await waitFor(() => expect(activate).toHaveBeenCalledWith('fixture-provider'))
fireEvent.click(row.getByRole('button', { name: 'Delete' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete' }))
await waitFor(() => expect(remove).toHaveBeenCalledWith('fixture-provider'))
expect(probe).not.toHaveBeenCalled()
})
it('changes browser appearance without writing connected computer settings', async () => {
useUIStore.setState({ activeSettingsTab: 'general', followSystemTheme: true })
const update = vi.spyOn(settingsApi, 'updateUser')
render(<H5Settings />)
fireEvent.change(screen.getByLabelText('Appearance'), { target: { value: 'ink-blue' } })
expect(useUIStore.getState().theme).toBe('ink-blue')
expect(useUIStore.getState().followSystemTheme).toBe(false)
expect(update).not.toHaveBeenCalled()
})
it('routes the actual Settings page to the browser-safe panels', async () => {
render(<Settings />)
const nav = within(screen.getByRole('navigation', { name: 'Settings' }))
expect(nav.getAllByRole('button')).toHaveLength(2)
expect(screen.queryByTestId('settings-navigation')).not.toBeInTheDocument()
expect(await screen.findByTestId('provider-fixture-provider')).toBeInTheDocument()
})
it.each([true, false])('wraps the long beta environment variable only in browser forms (browserMode=%s)', async (browserMode) => {
render(<ProviderSettings browserMode={browserMode} />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }))
const description = within(screen.getByRole('dialog')).getByText(/CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1/)
// At 320px the checkbox description has less than 180px available. This
// unbroken identifier previously expanded the modal's horizontal scroll area.
expect(description.classList.contains('[overflow-wrap:anywhere]')).toBe(browserMode)
})
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from 'react'
import { useTranslation } from '@/i18n'
import { useUIStore } from '@/stores/uiStore'
import { SettingsPill } from '@/components/settings/SettingsSection'
import { ProviderSettings } from './ProviderSettings'
import { H5GeneralSettings } from './H5GeneralSettings'
export function H5Settings() {
const t = useTranslation()
const active = useUIStore((state) => state.activeSettingsTab)
const pending = useUIStore((state) => state.pendingSettingsTab)
const selected = (pending ?? active) === 'general' ? 'general' : 'providers'
useEffect(() => {
useUIStore.getState().setActiveSettingsTab(selected)
if (pending) useUIStore.getState().setPendingSettingsTab(null)
}, [pending, selected])
return <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-[var(--color-surface)]">
<nav aria-label={t('sidebar.settings')} className="flex shrink-0 [&_button]:min-h-11 flex-wrap gap-2 border-b border-[var(--color-border)] p-3">
<SettingsPill selected={selected === 'providers'} onClick={() => useUIStore.getState().setActiveSettingsTab('providers')}>{t('settings.tab.providers')}</SettingsPill>
<SettingsPill selected={selected === 'general'} onClick={() => useUIStore.getState().setActiveSettingsTab('general')}>{t('settings.tab.general')}</SettingsPill>
</nav>
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-4 py-5 sm:px-6">
<p className="mb-5 text-xs leading-5 text-[var(--color-text-tertiary)]">{t('h5Settings.scope')}</p>
{selected === 'providers' ? <ProviderSettings browserMode /> : <H5GeneralSettings />}
</div>
</div>
}
@@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-li
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { providersApi } from '../../api/providers'
import { ApiError } from '../../api/client'
import { getDesktopHost } from '../../lib/desktopHost'
import { useProviderStore } from '../../stores/providerStore'
import { useSettingsStore } from '../../stores/settingsStore'
@@ -121,6 +122,25 @@ describe('retired sponsor providers', () => {
vi.restoreAllMocks()
})
it('explains why a remote endpoint change needs an explicit key and allows retry', async () => {
const provider = { ...savedProviders[0]!, apiKey: '' }
vi.mocked(providersApi.list).mockResolvedValue({ providers: [provider], activeId: null })
const update = vi.spyOn(providersApi, 'update')
.mockRejectedValueOnce(new ApiError(400, { code: 'REMOTE_PROVIDER_CREDENTIAL_REQUIRED' }))
.mockResolvedValue({ provider })
render(<ProviderSettings browserMode />)
fireEvent.click(within(await screen.findByTestId(`provider-${provider.id}`)).getByRole('button', { name: 'Edit' }))
const dialog = within(screen.getByRole('dialog'))
fireEvent.change(dialog.getByDisplayValue(provider.baseUrl), { target: { value: 'https://replacement.invalid' } })
fireEvent.click(dialog.getByRole('button', { name: 'Save' }))
expect(await dialog.findByRole('alert')).toHaveTextContent('enter the model or image API key again')
expect(screen.getByRole('dialog')).toBeInTheDocument()
fireEvent.change(dialog.getAllByPlaceholderText('sk-...')[0]!, { target: { value: 'fake-explicit-new-key' } })
fireEvent.click(dialog.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(update).toHaveBeenLastCalledWith(provider.id, expect.objectContaining({ apiKey: 'fake-explicit-new-key', baseUrl: 'https://replacement.invalid' })))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
it('loads saved providers while hiding their add-provider chips', async () => {
render(<ProviderSettings />)
for (const provider of savedProviders) {
+62 -36
View File
@@ -32,7 +32,7 @@ import { compatibilityForm, invalidCompatibilityNumber, parseCompatibilityForm,
import { ProviderImageGenerationFields, type ImageGenerationFormValue } from '../../components/settings/ProviderImageGenerationFields'
import { BUILT_IN_PROVIDER_IDS, CLAUDE_OFFICIAL_PROVIDER_ID, OPENAI_OFFICIAL_PROVIDER_ID } from '../../constants/openaiOfficialProvider'
import { GROK_OFFICIAL_PROVIDER_ID } from '../../constants/grokOfficialProvider'
import { getBaseUrl } from '../../api/client'
import { ApiError, getBaseUrl } from '../../api/client'
import { getDesktopHost } from '../../lib/desktopHost'
import { API_KEY_JSON_PLACEHOLDER, maskSettingsJsonSecrets, restoreSettingsJsonSecrets, stripProviderSettingsJsonEnv } from '../../lib/providerSettingsJson'
import { SETTINGS_CHECKBOX_INPUT_CLASS, SettingsCheckboxMark } from '../settings/shared'
@@ -123,7 +123,7 @@ function providerItemTestId(item: ProviderListItem): string {
}
}
export function ProviderSettings() {
export function ProviderSettings({ browserMode = false }: { browserMode?: boolean }) {
const {
providers,
providerOrder,
@@ -131,6 +131,7 @@ export function ProviderSettings() {
hasLoadedProviders,
presets,
isLoading,
error: providerLoadError,
fetchProviders,
deleteProvider,
reorderProviders,
@@ -145,6 +146,7 @@ export function ProviderSettings() {
const [showCcSwitchImport, setShowCcSwitchImport] = useState(false)
const [pendingDeleteProvider, setPendingDeleteProvider] = useState<SavedProvider | null>(null)
const [isDeletingProvider, setIsDeletingProvider] = useState(false)
const [actionFailed, setActionFailed] = useState(false)
const [testResults, setTestResults] = useState<Record<string, { loading: boolean; result?: ProviderTestResult }>>({})
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -172,11 +174,12 @@ export function ProviderSettings() {
const confirmDelete = async () => {
if (!pendingDeleteProvider) return
setIsDeletingProvider(true)
setActionFailed(false)
try {
await deleteProvider(pendingDeleteProvider.id)
setPendingDeleteProvider(null)
} catch (error) {
console.error(error)
} catch {
setActionFailed(true)
} finally {
setIsDeletingProvider(false)
}
@@ -193,13 +196,15 @@ export function ProviderSettings() {
}
const handleActivate = async (id: string) => {
await activateProvider(id)
await fetchSettings()
setActionFailed(false)
try { await activateProvider(id); await fetchSettings() }
catch { setActionFailed(true) }
}
const handleActivateOfficial = async () => {
await activateOfficial()
await fetchSettings()
setActionFailed(false)
try { await activateOfficial(); await fetchSettings() }
catch { setActionFailed(true) }
}
const providerItems = useMemo(
@@ -224,20 +229,21 @@ export function ProviderSettings() {
const isGrokOfficialActive = hasLoadedProviders && activeId === GROK_OFFICIAL_PROVIDER_ID
return (
<div className="max-w-2xl">
<div className="min-w-0 max-w-2xl">
<SettingsPageHeader
className={browserMode ? 'flex-col' : undefined}
title={t('settings.providers.title')}
description={t('settings.providers.description')}
action={(
<>
<Button
{!browserMode && <Button
variant="secondary"
size="base"
onClick={() => setShowCcSwitchImport(true)}
icon={<span className="material-symbols-outlined text-[16px]">download</span>}
>
{t('settings.providers.ccSwitch.importButton')}
</Button>
</Button>}
<Button
size="base"
onClick={() => setShowCreateModal(true)}
@@ -249,6 +255,7 @@ export function ProviderSettings() {
)}
/>
{(actionFailed || (browserMode && providerLoadError)) && <p role="alert" className="mb-3 text-sm text-[var(--color-error)]">{t('publicAccess.genericError')}</p>}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@@ -263,6 +270,7 @@ export function ProviderSettings() {
if (item.kind === 'claude-official') {
return (
<SortableProviderCard
browserMode={browserMode}
key={item.id}
item={item}
isActive={isClaudeOfficialActive}
@@ -273,7 +281,7 @@ export function ProviderSettings() {
badges={isClaudeOfficialActive ? (
<Badge tone="brand" bordered>{t('settings.providers.default')}</Badge>
) : null}
details={isClaudeOfficialActive ? (
details={!browserMode && isClaudeOfficialActive ? (
<div className="border-t border-[var(--color-border-separator)] px-4 pb-4 pt-3">
<ClaudeOfficialLogin />
</div>
@@ -285,6 +293,7 @@ export function ProviderSettings() {
if (item.kind === 'openai-official') {
return (
<SortableProviderCard
browserMode={browserMode}
key={item.id}
item={item}
isActive={isOpenAIOfficialActive}
@@ -295,7 +304,7 @@ export function ProviderSettings() {
badges={isOpenAIOfficialActive ? (
<Badge tone="brand" bordered>{t('settings.providers.default')}</Badge>
) : null}
details={isOpenAIOfficialActive ? (
details={!browserMode && isOpenAIOfficialActive ? (
<div className="border-t border-[var(--color-border-separator)] px-4 pb-4 pt-3">
<ChatGPTOfficialLogin />
</div>
@@ -307,6 +316,7 @@ export function ProviderSettings() {
if (item.kind === 'grok-official') {
return (
<SortableProviderCard
browserMode={browserMode}
key={item.id}
item={item}
isActive={isGrokOfficialActive}
@@ -317,7 +327,7 @@ export function ProviderSettings() {
badges={isGrokOfficialActive ? (
<Badge tone="brand" bordered>{t('settings.providers.default')}</Badge>
) : null}
details={isGrokOfficialActive ? (
details={!browserMode && isGrokOfficialActive ? (
<div className="border-t border-[var(--color-border-separator)] px-4 pb-4 pt-3">
<GrokOfficialLogin />
</div>
@@ -333,6 +343,7 @@ export function ProviderSettings() {
return (
<SortableProviderCard
browserMode={browserMode}
key={item.id}
item={item}
isActive={isActive}
@@ -376,7 +387,7 @@ export function ProviderSettings() {
{!isActive && (
<Button variant="ghost" size="sm" onClick={() => handleActivate(provider.id)}>{t('settings.providers.setDefault')}</Button>
)}
<Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>{t('settings.providers.test')}</Button>
{!browserMode && <Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>{t('settings.providers.test')}</Button>}
<Button variant="ghost" size="sm" onClick={() => setEditingProvider(provider)}>{t('settings.providers.edit')}</Button>
{!isActive && (
<Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">{t('common.delete')}</Button>
@@ -398,12 +409,12 @@ export function ProviderSettings() {
{/* Create Modal — conditionally rendered so state resets on close */}
{showCreateModal && (
<ProviderFormModal open={true} onClose={() => setShowCreateModal(false)} mode="create" presets={presets} />
<ProviderFormModal browserMode={browserMode} open={true} onClose={() => setShowCreateModal(false)} mode="create" presets={presets} />
)}
{/* Edit Modal */}
{editingProvider && (
<ProviderFormModal key={editingProvider.id} open={true} onClose={() => setEditingProvider(null)} mode="edit" provider={editingProvider} presets={presets} />
<ProviderFormModal browserMode={browserMode} key={editingProvider.id} open={true} onClose={() => setEditingProvider(null)} mode="edit" provider={editingProvider} presets={presets} />
)}
{/* cc-switch import — conditionally rendered so the scan reruns each time */}
@@ -430,6 +441,7 @@ export function ProviderSettings() {
}
type SortableProviderCardProps = {
browserMode?: boolean
item: ProviderListItem
isActive: boolean
dragLabel: string
@@ -443,6 +455,7 @@ type SortableProviderCardProps = {
}
function SortableProviderCard({
browserMode = false,
item,
isActive,
dragLabel,
@@ -479,7 +492,7 @@ function SortableProviderCard({
: 'border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] hover:border-[var(--color-outline)] hover:bg-[var(--color-surface-hover)]'
} ${isDragging ? 'shadow-[var(--shadow-overlay)] opacity-90' : ''}`}
>
<div className="flex items-center gap-2 px-3.5 py-3">
<div className={browserMode ? "flex flex-wrap items-center gap-2 px-3.5 py-3" : "flex items-center gap-2 px-3.5 py-3"}>
<button
type="button"
{...attributes}
@@ -510,7 +523,7 @@ function SortableProviderCard({
</span>
</button>
{actions && (
<div className="flex shrink-0 items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-focus-within:opacity-100 sm:group-hover:opacity-100">
<div className={browserMode ? "flex w-full flex-wrap items-center justify-end gap-1 [&_button]:min-h-11" : "flex shrink-0 items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-focus-within:opacity-100 sm:group-hover:opacity-100"}>
{actions}
</div>
)}
@@ -523,6 +536,7 @@ function SortableProviderCard({
// ─── Provider Form Modal ──────────────────────────────────────
type ProviderFormProps = {
browserMode?: boolean
open: boolean
onClose: () => void
mode: 'create' | 'edit'
@@ -974,7 +988,7 @@ function openExternalUrl(url: string) {
.catch(() => window.open(url, '_blank', 'noopener,noreferrer'))
}
function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderFormProps) {
function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode = false }: ProviderFormProps) {
const { createProvider, updateProvider, testConfig, fetchModels } = useProviderStore()
const fetchSettings = useSettingsStore((s) => s.fetchAll)
const addToast = useUIStore((s) => s.addToast)
@@ -1039,6 +1053,8 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
})
const [showContextSettings, setShowContextSettings] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [saveFailed, setSaveFailed] = useState(false)
const [credentialRequired, setCredentialRequired] = useState(false)
const [testResult, setTestResult] = useState<ProviderTestResult | null>(null)
const [isTesting, setIsTesting] = useState(false)
const [fetchedModels, setFetchedModels] = useState<ProviderModelInfo[] | null>(null)
@@ -1071,6 +1087,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
// Load current settings.json and merge provider env vars
useEffect(() => {
if (browserMode) return
// Skip if JSON was just populated by user paste
if (jsonPastedRef.current) {
jsonPastedRef.current = false
@@ -1136,7 +1153,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedPreset.id, providerProxyBaseUrl])
}, [selectedPreset.id, providerProxyBaseUrl, browserMode])
// A fetched list only describes the endpoint and key it came from. cc-switch
// shipped this without a guard and kept offering the previous provider's
@@ -1504,10 +1521,12 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
}
: undefined
setIsSubmitting(true)
setSaveFailed(false)
setCredentialRequired(false)
try {
// Write the edited cc-haha settings.json first so provider-specific model
// settings never conflict with the user's global ~/.claude/settings.json.
if (settingsJson.trim()) {
if (!browserMode && settingsJson.trim()) {
try {
const parsed = restoreSettingsJsonSecrets(JSON.parse(settingsJson), settingsJson, apiKey)
const { providersApi } = await import('../../api/providers')
@@ -1562,8 +1581,11 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
}
await fetchSettings()
onClose()
} catch (err) {
console.error('Failed to save provider:', err)
} catch (error) {
setCredentialRequired(browserMode && error instanceof ApiError &&
!!error.body && typeof error.body === 'object' && 'code' in error.body &&
error.body.code === 'REMOTE_PROVIDER_CREDENTIAL_REQUIRED')
setSaveFailed(true)
} finally {
setIsSubmitting(false)
}
@@ -1626,7 +1648,9 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
</>
}
>
<div className="flex flex-col gap-4">
<div className="flex min-w-0 flex-col gap-4">
{browserMode && <p className="text-xs leading-5 text-[var(--color-text-secondary)]">{t('h5Settings.providerPrivacy')}</p>}
{saveFailed && <p role="alert" className="text-sm text-[var(--color-error)]">{t(credentialRequired ? 'h5Settings.credentialRequired' : 'publicAccess.genericError')}</p>}
{/* Preset chips */}
{mode === 'create' && (
<div>
@@ -1778,7 +1802,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.providers.disableExperimentalBetas')}
</div>
<div className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">
<div className={`mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]${browserMode ? ' [overflow-wrap:anywhere]' : ''}`}>
{t('settings.providers.disableExperimentalBetasDesc')}
</div>
</div>
@@ -1812,6 +1836,8 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
<div className="relative">
<input
id="provider-api-key"
autoComplete="off"
spellCheck={false}
type={showApiKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
@@ -1869,7 +1895,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
<div>
<div className="mb-2 flex items-center justify-between gap-2">
<label className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.providers.modelMapping')}</label>
<Button
{!browserMode && <Button
variant="secondary"
size="base"
onClick={handleFetchModels}
@@ -1878,9 +1904,9 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
icon={<span className="material-symbols-outlined text-[15px]">cloud_download</span>}
>
{t('settings.providers.fetchModels')}
</Button>
</Button>}
</div>
{!hasModelsApiKey ? (
{browserMode ? null : !hasModelsApiKey ? (
<p className="mb-2 text-[11px] text-[var(--color-text-tertiary)]">{t('settings.providers.fetchModelsApiKeyHint')}</p>
) : !hasModelsBaseUrl ? (
<p className="mb-2 text-[11px] text-[var(--color-text-tertiary)]">{t('settings.providers.fetchModelsHint')}</p>
@@ -1902,7 +1928,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
) : (
<p className="mb-2 text-[11px] text-[var(--color-text-tertiary)]">{t('settings.providers.fetchModelsSupportHint')}</p>
)}
<div className="grid grid-cols-2 gap-2">
<div className={browserMode ? "grid grid-cols-1 sm:grid-cols-2 gap-2" : "grid grid-cols-2 gap-2"}>
{MODEL_SLOTS.map((slot) => {
const labelKey = slot === 'main'
? 'settings.providers.mainModel'
@@ -1980,7 +2006,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
<div className="border-t border-[var(--color-border)] px-3 pb-3 pt-3">
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelContextWindows')}</label>
<div className="grid grid-cols-2 gap-2">
<div className={browserMode ? "grid grid-cols-1 sm:grid-cols-2 gap-2" : "grid grid-cols-2 gap-2"}>
{MODEL_SLOTS.map((slot) => {
const errorKey = getModelContextWindowErrorKey(modelContextInputs[slot])
const labelKey = slot === 'main'
@@ -2038,7 +2064,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
</div>
{/* Test connection */}
<div className="flex items-center gap-3">
{!browserMode && <div className="flex items-center gap-3">
<Button variant="secondary" size="sm" onClick={handleTest} loading={isTesting} disabled={!baseUrl.trim() || !models.main.trim() || compatibilityInvalid}>
{t('settings.providers.testConnection')}
</Button>
@@ -2058,10 +2084,10 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
)}
</div>
)}
</div>
</div>}
{/* Settings JSON — editable, shown for all presets including official */}
<div>
{/* Settings JSON stays on the trusted desktop. */}
{!browserMode && <div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.settingsJson')}</label>
<textarea
aria-label={t('settings.providers.settingsJson')}
@@ -2169,7 +2195,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
)}
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.settingsJsonDesc')}</p>
{apiFormat !== 'anthropic' && <p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.compatibilityJsonHint')}</p>}
</div>
</div>}
</div>
</Modal>
<ConfirmDialog
@@ -1,6 +1,7 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { PUBLIC_ACCESS_CONSENT_VERSION } from '@/lib/desktopHost/types'
import { PublicAccessSettings } from './PublicAccessSettings'
import { useSettingsStore } from '@/stores/settingsStore'
const mocks = vi.hoisted(() => ({
@@ -28,7 +29,7 @@ it('requires privacy consent before saving a masked credential and starting', as
expect(dialog.getByText(/Standard HTTPS tunnels decrypt/)).toBeInTheDocument()
expect(mocks.saveCredential).not.toHaveBeenCalled()
fireEvent.click(dialog.getByRole('button', { name: 'Agree and enable public access' }))
await waitFor(() => expect(mocks.start).toHaveBeenCalledWith(1))
await waitFor(() => expect(mocks.start).toHaveBeenCalledWith(PUBLIC_ACCESS_CONSENT_VERSION))
expect(mocks.saveCredential).toHaveBeenCalledWith('private-token')
expect(input).toHaveValue('')
})
@@ -38,6 +39,18 @@ it('opens only the official account page', async () => {
fireEvent.click(screen.getByRole('button', { name: 'Get ngrok Authtoken' }))
await waitFor(() => expect(mocks.open).toHaveBeenCalledWith('https://dashboard.ngrok.com/get-started/your-authtoken'))
})
it('asks v1 users to confirm model configuration management before enabling again', async () => {
mocks.getStatus.mockResolvedValue({ ...mocks.status, state: 'disabled', hasCredential: true, autoStart: true, consentVersion: 1 })
render(<PublicAccessSettings />)
await screen.findByText('Off')
expect(screen.getByRole('checkbox')).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Enable public access' }))
const dialog = within(await screen.findByRole('dialog'))
expect(dialog.getByText(/add, edit, delete and switch model provider configurations and API keys/)).toBeInTheDocument()
expect(mocks.start).not.toHaveBeenCalled()
fireEvent.click(dialog.getByRole('button', { name: 'Agree and enable public access' }))
await waitFor(() => expect(mocks.start).toHaveBeenCalledWith(PUBLIC_ACCESS_CONSENT_VERSION))
})
it('renders no credential controls in a browser', () => {
host.kind = 'browser'
const { container } = render(<PublicAccessSettings />)
@@ -45,7 +58,7 @@ it('renders no credential controls in a browser', () => {
host.kind = 'electron'
})
it('uses an expiring fragment QR and revokes only the selected device', async () => {
mocks.getStatus.mockResolvedValue({ state: 'online', hasCredential: true, publicUrl: 'https://test.ngrok-free.app', error: null, autoStart: false, consentVersion: 1 })
mocks.getStatus.mockResolvedValue({ state: 'online', hasCredential: true, publicUrl: 'https://test.ngrok-free.app', error: null, autoStart: false, consentVersion: PUBLIC_ACCESS_CONSENT_VERSION })
mocks.get.mockResolvedValue({ enabled: true, port: 1234, publicUrl: 'https://test.ngrok-free.app', pending: [{ id: 'new-phone', name: 'New phone' }], devices: [{ id: 'old-phone', name: 'Old phone', createdAt: 1, expiresAt: Date.now() + 10000 }] })
mocks.pairing.mockResolvedValue({ secret: 'one-time', expiresAt: Date.now() + 300000 })
mocks.qr.mockResolvedValue('data:image/png;base64,fake')
@@ -71,7 +84,7 @@ it('shows a retryable error without leaking upstream details', async () => {
})
it('allows closing a tunnel while SDK start is pending', async () => {
const connecting = { state: 'connecting', hasCredential: true, publicUrl: null, error: null, autoStart: false, consentVersion: 1 }
const connecting = { state: 'connecting', hasCredential: true, publicUrl: null, error: null, autoStart: false, consentVersion: PUBLIC_ACCESS_CONSENT_VERSION }
const disabled = { ...connecting, state: 'disabled' }
mocks.getStatus.mockResolvedValue(disabled)
let finishStart!: () => void
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import QRCode from 'qrcode'
import { publicAccessApi, type PublicAccessServerStatus } from '@/api/publicAccess'
import { getDesktopHost } from '@/lib/desktopHost'
import { PUBLIC_ACCESS_CONSENT_VERSION } from '@/lib/desktopHost/types'
import { copyTextToClipboard } from '@/lib/clipboard'
import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
@@ -76,7 +77,7 @@ export function PublicAccessSettings() {
const start = () => run(async () => {
if (token.trim()) { await bridge.saveCredential(token.trim()); setToken('') }
setConsent(false)
await bridge.start(1)
await bridge.start(PUBLIC_ACCESS_CONSENT_VERSION)
})
const stateLabels = {
unconfigured: t('publicAccess.unconfigured'), disabled: t('publicAccess.disabled'), connecting: t('publicAccess.connecting'),
@@ -93,11 +94,11 @@ export function PublicAccessSettings() {
<Input aria-describedby="public-access-error" type="password" autoComplete="off" spellCheck={false} aria-label={t('publicAccess.token')} placeholder={status?.hasCredential ? t('publicAccess.tokenSaved') : t('publicAccess.token')} value={token} onChange={(event) => setToken(event.target.value)} />
<p role="status" className="text-sm">{status ? stateLabels[status.state] : t('common.loading')}</p>
<div className="flex flex-wrap gap-2">
<Button disabled={busy || (!token.trim() && !status?.hasCredential) || status?.state === 'online' || status?.state === 'connecting' || status?.state === 'reconnecting'} onClick={() => { if (status?.consentVersion === 1) void start(); else setConsent(true) }}>{t('publicAccess.enable')}</Button>
<Button disabled={busy || (!token.trim() && !status?.hasCredential) || status?.state === 'online' || status?.state === 'connecting' || status?.state === 'reconnecting'} onClick={() => { if (status?.consentVersion === PUBLIC_ACCESS_CONSENT_VERSION) void start(); else setConsent(true) }}>{t('publicAccess.enable')}</Button>
<Button variant="secondary" disabled={stopping || (busy && status?.state !== 'connecting' && status?.state !== 'reconnecting') || !status || status.state === 'unconfigured' || status.state === 'disabled'} onClick={() => void stop()}>{t('publicAccess.disable')}</Button>
{status?.hasCredential && <Button variant="danger" disabled={busy} onClick={() => void run(async () => { await bridge.deleteCredential(); setToken(''); setQr(null); setExpiresAt(null) })}>{t('publicAccess.deleteCredential')}</Button>}
</div>
<Checkbox label={t('publicAccess.autoStart')} checked={status?.autoStart ?? false} disabled={busy || !status?.hasCredential || status.consentVersion !== 1} onChange={(event) => void run(() => bridge.setAutoStart(event.target.checked))} />
<Checkbox label={t('publicAccess.autoStart')} checked={status?.autoStart ?? false} disabled={busy || !status?.hasCredential || status.consentVersion !== PUBLIC_ACCESS_CONSENT_VERSION} onChange={(event) => void run(() => bridge.setAutoStart(event.target.checked))} />
<p className="text-xs text-[var(--color-text-tertiary)]">{t('publicAccess.freeNotice')}</p>
{(error || status?.error) && <p id="public-access-error" role="alert" className="text-sm text-[var(--color-error)]">{status?.error ? errorLabels[status.error] : t('publicAccess.genericError')}</p>}
{status?.state === 'online' && status.publicUrl && <div className="space-y-3 border-t border-[var(--color-border)] pt-4">
+7 -3
View File
@@ -25,7 +25,7 @@ order: 9
3. 点「生成令牌」,页面上会出现二维码和 H5 链接。
4. 用手机扫码,或者点「复制扫码链接」发到自己的设备上。
扫出来的链接里带着服务器地址和令牌,手机浏览器打开后会把连接信息存下来,之后直接打开就能用。
扫出来的链接里带着服务器地址和令牌。用手机相机扫码后,在你常用的 Safari、Chrome 或系统浏览器中打开,首次验证成功会把连接信息保存在该浏览器的 localStorage 中,并移除地址栏中的令牌。之后再次扫码或打开收藏可直接连接;短暂断网不会删除配对,连接失败时可点击「重试」使用已保存的凭据。令牌被撤销或重新生成后,需要用新二维码更新。
![手机端的对话界面与文件变更卡](../images/app/zh-CN/h5-session.webp)
@@ -61,7 +61,9 @@ H5 默认关闭,它也不是公开服务。开启前先确认你在自己信
会话列表和项目切换、发消息、停止、看流式回复、图片和文件附件、权限按钮、AI 提问、`@` 引用文件、复制和 Fork——对话主流程都在。
桌面工作区、内嵌终端、原生「打开方式」、Computer Use 授权和桌面宠物不在 H5 里。
侧栏底部的「设置」提供「模型供应商」和「通用配置」。手机上可以添加、编辑、删除、排序和切换供应商;已有 API Key 不会回传,编辑时留空表示保持不变。更改主模型或图片模型的请求地址时,需要重新填写对应的 API Key,避免把电脑已保存的密钥自动发送到新地址。通用配置支持界面主题与语言,以及回复语言、输出风格、思考强度、发送方式和工作流关键词等偏好。主题和界面语言只影响当前浏览器;Agent 偏好与电脑共享。
供应商网页登录、配置文件导入和桌面专用管理仍在电脑上完成。桌面工作区、内嵌终端、原生「打开方式」、Computer Use 授权和桌面宠物不在 H5 里。终端暂未开放:它目前由 Electron 管理,远程终端需要单独支持连接恢复、设备授权和撤销,不能直接复用桌面 IPC。
## 公网访问:ngrok
@@ -74,7 +76,9 @@ H5 默认关闭,它也不是公开服务。开启前先确认你在自己信
二维码中的配对材料 5 分钟有效且只能使用一次;手机访问凭据默认有效 30 天。不要分享二维码。桌面可以单独撤销手机,或关闭公网访问立即断开远程连接;已经运行的任务会继续执行。
公网入口支持会话和审批流程,但供应商管理、任意本机目录浏览及路径式文件预览仍需在桌面操作;会话内的文件和审查接口继续可用。
公网入口也支持上述供应商与通用设置。任意本机目录浏览及路径式文件预览仍需在桌面操作;会话内的文件和审查接口继续可用。
配对成功后,公网授权保存在浏览器的安全 Cookie 中,有效期内再次扫码直接进入,不会消耗新二维码;不会把长期公网凭据写入 localStorage。相机扫码可以在系统浏览器中完成配对。不同浏览器、无痕窗口、不同域名之间不会共享授权:如果扫描器先打开内置浏览器,请先选择在常用浏览器中打开,再完成配对。清除浏览器数据、授权到期或被撤销后需重新配对。
**隐私边界:** 标准 ngrok HTTPS 隧道会在 ngrok 服务端解密,再经加密隧道传到电脑。因此 ngrok 在技术上可以接触传输中的会话、指令和文件内容,这不是中转不可读的端到端加密。Authtoken 保存在当前应用数据目录的独立私有文件中,不采用系统钥匙串加密;拥有本机账户或管理员权限的人可能读取它。删除应用内凭据不会注销 ngrok 账户,怀疑泄漏时还应到 ngrok 撤销该凭据。
+7 -3
View File
@@ -25,7 +25,7 @@ Both require your computer to be on with the app running. Tasks execute on your
3. Click **Generate token**. A QR code and an H5 link appear.
4. Scan it with your phone, or click **Copy launch URL** and send it to your own device.
The scanned link carries the server address and the token. Once your phone's browser connects, it remembers the connection and later visits go straight in.
The scanned link carries the server address and token. Scan with your phone camera and open it in your usual Safari, Chrome, or system browser. Successful verification stores the connection in that browser's localStorage and removes the token from the address bar. Scanning again or opening a bookmark reconnects automatically. Temporary network failures do not forget pairing; choose **Retry** to use the saved credential. A revoked or regenerated token requires a fresh QR code.
![The mobile conversation view with a file-changes card](../../images/app/en/h5-session.webp)
@@ -61,7 +61,9 @@ Only when a task is idle *and* nothing is connected does the CLI process stop, a
Session list and project switching, sending messages, stopping, streaming replies, image and file attachments, permission buttons, questions from Claude, `@` file references, copy and fork — the whole conversation flow.
The desktop workspace, embedded terminal, native "open with", Computer Use authorization, and the desktop pet are not part of H5.
The **Settings** entry at the bottom of the sidebar provides **Model providers** and **General**. Add, edit, delete, reorder, or switch providers on your phone. Existing API keys are never returned; leave a key blank while editing to keep it. Changing a model or image request URL requires entering the corresponding API key again, so saved credentials are not automatically sent to a new address. General settings include theme, interface language, response language, output style, reasoning effort, send behavior, thinking, and workflow keywords. Theme and interface language affect this browser; Agent preferences are shared with the computer.
Provider website login, configuration import, and desktop administration remain on the computer. The desktop workspace, embedded terminal, native "open with", Computer Use authorization, and the desktop pet are not part of H5. Remote terminal execution is deferred: the current terminal is owned by Electron and requires a separate device-authorized transport with reconnect and revocation support.
## Public access with ngrok
@@ -74,7 +76,9 @@ Open **Settings → H5 Access → Public access · ngrok** to connect your own n
Pairing codes expire after 5 minutes and can only be used once. Phone credentials last 30 days by default. Keep QR codes private. Revoke individual phones or turn off public access to disconnect remote clients immediately; running tasks continue.
The public entry supports conversations and approvals. Provider management, unrestricted local directory browsing, and path-based file previews remain desktop-only; session-scoped file and review APIs remain available.
The public entry also supports the provider and General settings above. Unrestricted local directory browsing and path-based file previews remain desktop-only; session-scoped file and review APIs remain available.
Once paired, public authorization persists in a secure browser cookie. Scanning again while it is valid opens the app without consuming another pairing code. Long-lived public credentials are not stored in localStorage. Camera scanning works in system browsers, but different browsers, private windows, and hostnames do not share authorization. If a scanner opens an embedded browser, switch to your preferred browser before pairing. Clearing browser data, expiration, or revocation requires pairing again.
**Privacy:** Standard ngrok HTTPS tunnels terminate TLS at ngrok, which then forwards traffic through an encrypted tunnel to your computer. ngrok can technically access the conversations, commands, and files being transferred. This is not end-to-end encryption that prevents the relay from reading content. The Authtoken is stored in a separate private file in the active application data directory, without system keychain encryption. Your local account or an administrator may read it. Deleting the saved credential does not close your ngrok account; revoke the credential at ngrok if it may have leaked.
@@ -0,0 +1,90 @@
import { mkdir, writeFile, readFile } from 'node:fs/promises'
import path from 'node:path'
import { startServer, stopServerRuntimeForShutdown } from '../index.js'
import { H5AccessService } from '../services/h5AccessService.js'
import { ProviderService } from '../services/providerService.js'
const origin = 'https://fixture.ngrok-free.app'
const nativeFetch = globalThis.fetch
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(input instanceof Request ? input.url : String(input))
if (url.hostname !== '127.0.0.1') throw new Error('Public network forbidden in settings smoke')
return nativeFetch(input, init)
}) as typeof fetch
await mkdir(process.env.CLAUDE_CONFIG_DIR!, { recursive: true })
const settingsPath = path.join(process.env.CLAUDE_CONFIG_DIR!, 'settings.json')
await writeFile(settingsPath, JSON.stringify({ env: { ANTHROPIC_API_KEY: 'fake-original-key' }, language: 'en', unknownFutureSetting: { keep: true } }))
const server = startServer(0, '127.0.0.1')
const local = `http://127.0.0.1:${server.port}`
const control = (route: string, body: unknown) => fetch(`${local}/api/public-access/${route}`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.CC_HAHA_LOCAL_ACCESS_TOKEN}` }, body: JSON.stringify(body) })
function check(value: unknown, message: string): asserts value { if (!value) throw new Error(message) }
try {
const enabled = await (await control('enable', { publicUrl: origin })).json()
const remote = `http://127.0.0.1:${enabled.port}`
const code = await (await control('pairing', {})).json()
const pair = await (await fetch(`${remote}/api/public-access/pair`, { method: 'POST', headers: { Origin: origin }, body: JSON.stringify({ secret: code.secret, name: 'Settings fixture' }) })).json()
await control('approve', { id: pair.id })
const claimed = await fetch(`${remote}/api/public-access/claim`, { method: 'POST', headers: { Origin: origin }, body: JSON.stringify(pair) })
const cookie = claimed.headers.get('set-cookie')!.split(';')[0]!
const h5 = new H5AccessService()
const { token } = await h5.enable()
await h5.updateSettings({ allowedOrigins: [origin] })
const providerService = new ProviderService()
for (const transport of ['public', 'lan'] as const) {
const base = transport === 'public' ? remote : local
const headers = { Origin: origin, ...(transport === 'public' ? { Cookie: cookie } : { Authorization: `Bearer ${token}` }) }
const request = (route: string, method = 'GET', body?: unknown) => fetch(base + route, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
const protectedResponse = await fetch(base + '/api/providers', { headers: { Origin: origin } })
check(protectedResponse.status === 401, `${transport}: unauthenticated provider list allowed`)
const input = { presetId: 'custom', name: `Fixture ${transport}`, apiKey: `fake-${transport}-secret`, baseUrl: 'https://example.invalid', apiFormat: 'anthropic', models: { main: 'fixture-model', haiku: 'fixture-model', sonnet: 'fixture-model', opus: 'fixture-model' }, imageGeneration: { model: 'fixture-image', apiKey: `fake-${transport}-image-secret` }, requestCompatibility: { maxOutputTokens: 2048, privateFutureKey: 'fake-hidden-compatibility-key' } }
const created = await request('/api/providers', 'POST', input)
const createdBody = await created.json()
check(created.status === 201 && createdBody.provider.hasApiKey && createdBody.provider.apiKey === '', `${transport}: provider creation/redaction failed`)
const id = createdBody.provider.id
// Seed desktop-owned extensions as an existing store fixture, not via the remote API.
const providersPath = path.join(process.env.CLAUDE_CONFIG_DIR!, 'cc-haha', 'providers.json')
const stored = JSON.parse(await readFile(providersPath, 'utf8'))
const oldProvider = stored.providers.find((provider: { id: string }) => provider.id === id)
check(!oldProvider.requestCompatibility.privateFutureKey, 'Remote creation injected a hidden compatibility field')
oldProvider.requestCompatibility.privateFutureKey = 'fake-hidden-compatibility-key'
oldProvider.futureCredential = 'fake-top-level-credential'
await writeFile(providersPath, JSON.stringify(stored))
check(createdBody.provider.imageGeneration.apiKey === '' && createdBody.provider.imageGeneration.hasApiKey, 'Image key leaked')
for (const route of ['/api/providers', `/api/providers/${id}`]) {
const text = await (await request(route)).text()
check(!text.includes(input.apiKey) && !text.includes(input.imageGeneration.apiKey) && !text.includes('fake-hidden-compatibility-key') && !text.includes('fake-top-level-credential'), `${transport}: saved credentials leaked`)
}
const changed = await request(`/api/providers/${id}`, 'PUT', { name: 'Edited phone provider', apiKey: '', requestCompatibility: { maxOutputTokens: 4096 }, imageGeneration: { model: 'new-image-model', apiKey: '' } })
check(changed.ok, `${transport}: provider edit failed`)
const retained = await providerService.getProvider(id)
check(retained.requestCompatibility?.privateFutureKey === 'fake-hidden-compatibility-key', 'Unknown compatibility field lost during edit')
check((retained as unknown as Record<string, unknown>).futureCredential === 'fake-top-level-credential', 'Unknown provider field lost during edit')
check(retained.apiKey === input.apiKey && retained.imageGeneration?.apiKey === input.imageGeneration.apiKey, `${transport}: omitted credentials not preserved`)
check((await request(`/api/providers/${id}`, 'PUT', { imageGeneration: { model: 'omitted-key-model' } })).ok, 'Omitted image key edit failed')
check((await providerService.getProvider(id)).imageGeneration?.apiKey === input.imageGeneration.apiKey, 'Omitted image key was lost')
check((await request(`/api/providers/${id}`, 'PUT', { apiKey: 'fake-replacement-key' })).ok, 'Replacement failed')
check((await providerService.getProvider(id)).apiKey === 'fake-replacement-key', 'Replacement not persisted')
check((await request(`/api/providers/${id}/activate`, 'POST', {})).ok, 'Activation failed')
check((await providerService.listProviders()).activeId === id, 'Activation missing')
check((await request('/api/settings/user', 'PUT', { language: 'zh-CN', chatSendBehavior: 'modifierEnter', alwaysThinkingEnabled: false, workflowKeywordTriggerEnabled: true })).ok, 'General edit failed')
const general = await (await request('/api/settings/user')).json()
check(general.language === 'zh-CN' && general.alwaysThinkingEnabled === false && !('env' in general), 'General projection failed')
check((await request('/api/settings/user', 'PUT', { language: '' })).ok, 'Reset response language failed')
const resetGeneral = await (await request('/api/settings/user')).json()
check(resetGeneral.language === '' && resetGeneral.chatSendBehavior === 'modifierEnter' && resetGeneral.alwaysThinkingEnabled === false && resetGeneral.workflowKeywordTriggerEnabled === true, 'Language reset changed unrelated settings')
check((await request('/api/settings/user', 'PUT', { env: { API_KEY: 'must-not-save' } })).status === 400, 'Remote env mutation accepted')
for (const route of ['/api/providers/settings', '/api/providers/cc-switch/scan', '/api/settings/project', '/api/settings/cli-launcher']) check((await request(route)).status === 403, `${transport}: admin route accessible ${route}`)
const original = JSON.parse(await readFile(settingsPath, 'utf8'))
check(original.env.ANTHROPIC_API_KEY === 'fake-original-key' && original.unknownFutureSetting.keep, 'Settings edit lost unknown or protected data')
check((await fetch(base + `/api/providers/${id}`, { method: 'DELETE', headers: { ...headers, Origin: 'https://attacker.invalid' } })).status === 403, 'Cross-origin mutation accepted')
check((await request('/api/providers/official', 'POST', {})).ok, 'Official activation failed')
const deleted = await request(`/api/providers/${id}`, 'DELETE')
check(deleted.ok, `Delete failed: ${deleted.status} ${await deleted.text()}`)
check(!(await providerService.listProviders()).providers.some(p => p.id === id), 'Provider survived delete')
}
console.log('REMOTE_SETTINGS_INTEGRATION_PASSED')
} finally {
await stopServerRuntimeForShutdown()
await server.stop(true)
}
process.exit(0)
+1 -1
View File
@@ -537,7 +537,7 @@ export function startServer(port = PORT, host = HOST) {
try {
const response = await settleResponseOnRequestAbort(
req,
handleApiRequest(req, url),
handleApiRequest(req, url, { remoteBrowser: classifyH5Request(req, url, h5RequestContext) === 'h5-browser' }),
)
return withCors(response, cors)
} catch (error) {
+2 -2
View File
@@ -91,7 +91,7 @@ describe('public transport boundary', () => {
const { cookie } = await f.pair()
expect((await f.remote('/api/sessions', {}, cookie, 'https://attacker.example')).status).toBe(403)
expect((await f.remote('/api/sessions', {}, cookie, '')).status).toBe(403)
for (const route of ['/api/providers/auth-status/extra', '/sdk/session', '/proxy/v1/messages', '/api/providers', '/api/settings', '/api//settings/session-cleanup/', '/api/diagnostics', '/api/h5-access']) {
for (const route of ['/api/providers/auth-status/extra', '/sdk/session', '/proxy/v1/messages', '/api/providers/settings', '/api/settings', '/api//settings/session-cleanup/', '/api/diagnostics', '/api/h5-access']) {
expect((await f.remote(route, undefined, cookie)).status).toBe(403)
}
expect((await f.remote('/')).headers.get('location')).toBe('/remote')
@@ -159,7 +159,7 @@ describe('public transport boundary', () => {
test('business allowlist normalizes routes like the router', () => {
expect(isPublicBusinessPathAllowed(new URL('https://example.test/api//settings/user/'), 'GET')).toBe(true)
expect(isPublicBusinessPathAllowed(new URL('https://example.test/api//settings/user/'), 'PUT')).toBe(false)
expect(isPublicBusinessPathAllowed(new URL('https://example.test/api//settings/user/'), 'PUT')).toBe(true)
expect(isPublicBusinessPathAllowed(new URL('https://example.test/api/permissions/mode'), 'PUT')).toBe(false)
expect(isPublicBusinessPathAllowed(new URL('https://example.test/api/models/current'), 'PUT')).toBe(true)
expect(isPublicBusinessPathAllowed(new URL('https://example.test/api/effort'), 'PUT')).toBe(true)
+6 -3
View File
@@ -4,6 +4,7 @@ import os from 'node:os'
import path from 'node:path'
import type { Server, ServerWebSocket, WebSocketHandler } from 'bun'
import { isLocalAccessAuthorized } from './localAccessAuth.js'
import { remoteProviderRouteAllowed, remoteSettingsRouteAllowed, type ApiRequestContext } from './remoteBrowserPolicy.js'
import type { WebSocketData } from './ws/handler.js'
const PREFIX = '/api/public-access'
@@ -35,6 +36,8 @@ export function isPublicBusinessPathAllowed(url: URL, method: string): boolean {
const parts = url.pathname.split('/').filter(Boolean)
if (parts[0] === 'ws') return parts.length === 2 && /^[\w-]{1,64}$/.test(parts[1]!)
if (parts[0] !== 'api') return false
if (parts[1] === 'providers') return remoteProviderRouteAllowed(parts, method)
if (parts[1] === 'settings') return remoteSettingsRouteAllowed(parts, method)
if (method === 'PUT' && ['/api/models/current', '/api/effort'].includes('/' + parts.join('/'))) return true
if (method === 'GET' && ['/api/settings/user', '/api/permissions/mode', '/api/providers/auth-status'].includes('/' + parts.join('/'))) return true
if (['sessions', 'conversations'].includes(parts[1] ?? '')) return true
@@ -53,7 +56,7 @@ function secure(response: Response): Response {
const json = (value: unknown, status = 200) => secure(Response.json(value, { status }))
type Dependencies = {
handleApiRequest: (request: Request, url: URL) => Promise<Response>
handleApiRequest: (request: Request, url: URL, context?: ApiRequestContext) => Promise<Response>
handleStatic: (request: Request, url: URL) => Promise<Response | null>
websocket: WebSocketHandler<WebSocketData>
serverPort: () => number
@@ -291,7 +294,7 @@ export class PublicAccessServer {
const upgraded = server.upgrade(request, { data: { sessionId: url.pathname.split('/').pop()!, connectedAt: this.now(), channel: 'client', clientKind: 'full', sdkToken: null, serverPort: this.deps.serverPort(), serverHost: '127.0.0.1', remoteDeviceId: device.id } })
return upgraded ? undefined : json({ error: 'Upgrade failed' }, 400)
}
const response = await this.deps.handleApiRequest(request, url)
const response = await this.deps.handleApiRequest(request, url, { remoteBrowser: true })
if (route === 'api/providers/auth-status' && response.ok) {
const status = await response.json() as Record<string, unknown>
const sources = ['cc-haha-provider', 'claude-oauth', 'openai-oauth', 'grok-oauth', 'original-settings', 'env', 'none']
@@ -301,7 +304,7 @@ export class PublicAccessServer {
...(typeof status.activeProvider === 'string' ? { activeProvider: status.activeProvider } : {}),
})
}
if (route === 'api/settings/user' && response.ok) {
if (route === 'api/settings/user' && request.method === 'GET' && response.ok) {
const settings = await response.json() as Record<string, unknown>
const allowed = ['alwaysThinkingEnabled', 'workflowKeywordTriggerEnabled', 'autoDreamEnabled', 'skipAutoPermissionPrompt', 'chatSendBehavior', 'outputStyle', 'skipWebFetchPreflight', 'language']
return json(Object.fromEntries(allowed.filter(key => ['string', 'boolean', 'number'].includes(typeof settings[key])).map(key => [key, settings[key]])))
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from 'bun:test'
import { mkdtempSync, rmSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { createSandboxedTestEnvironment } from '../../scripts/pr/test-environment.js'
import { remoteProviderRouteAllowed, remoteSettingsRouteAllowed, validateRemoteSettingsPatch } from './remoteBrowserPolicy.js'
test('remote settings boundary permits only intended provider routes and General fields', () => {
const parts = (pathname: string) => pathname.split('/').filter(Boolean)
expect(remoteProviderRouteAllowed(parts('/api//providers/settings/'), 'GET')).toBe(false)
expect(remoteProviderRouteAllowed(parts('/api/providers/id/extra'), 'DELETE')).toBe(false)
expect(remoteProviderRouteAllowed(parts('/api/providers/id/activate'), 'POST')).toBe(true)
expect(remoteSettingsRouteAllowed(parts('/api/settings/user/extra'), 'PUT')).toBe(false)
expect(validateRemoteSettingsPatch({ hooks: {} })).toBe(false)
expect(validateRemoteSettingsPatch({ alwaysThinkingEnabled: 'true' })).toBe(false)
expect(validateRemoteSettingsPatch({ chatSendBehavior: 'modifierEnter' })).toBe(true)
expect(validateRemoteSettingsPatch({ language: '' })).toBe(true)
})
test('LAN and public provider CRUD redact and preserve keys; General edits isolate desktop secrets', async () => {
const home = mkdtempSync(path.join(os.tmpdir(), 'remote-settings-integration-'))
try {
const child = Bun.spawn([process.execPath, '--no-env-file', path.join(import.meta.dir, '__fixtures__/remoteBrowserSettingsSmoke.ts')], {
env: createSandboxedTestEnvironment(home, { CC_HAHA_LOCAL_ACCESS_TOKEN: 'fixture-process-credential' }), stdout: 'pipe', stderr: 'pipe',
})
const timeout = setTimeout(() => child.kill(), 15_000)
try {
const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
expect({ exitCode, output: exitCode === 0 ? '' : `${stdout}\n${stderr}` }).toEqual({ exitCode: 0, output: '' })
expect(stdout).toContain('REMOTE_SETTINGS_INTEGRATION_PASSED')
} finally { clearTimeout(timeout); child.kill() }
} finally { rmSync(home, { recursive: true, force: true }) }
}, 20_000)
+68
View File
@@ -0,0 +1,68 @@
import type { RequestCompatibility, SavedProvider } from './types/provider.js'
export type ApiRequestContext = { remoteBrowser?: boolean }
const READ_SETTINGS = ['alwaysThinkingEnabled', 'workflowKeywordTriggerEnabled', 'autoDreamEnabled', 'skipAutoPermissionPrompt', 'chatSendBehavior', 'outputStyle', 'skipWebFetchPreflight', 'language']
const WRITE_SETTINGS = new Set(['language', 'chatSendBehavior', 'alwaysThinkingEnabled', 'workflowKeywordTriggerEnabled', 'outputStyle'])
const RESERVED_PROVIDER_PATHS = new Set(['settings', 'cc-switch', 'test', 'models', 'presets', 'auth-status', 'official', 'reorder'])
const REMOTE_COMPATIBILITY_KEYS = new Set(['maxOutputTokens', 'outputTokenLimit', 'outputTokenField', 'sampling', 'reasoning', 'parallelTools', 'structuredOutput'])
/** The browser replaces the fields it can edit, while hidden extensions stay on the desktop. */
export function replaceRemoteCompatibility(current: RequestCompatibility | undefined, input: Record<string, unknown> | null) {
const hidden = Object.fromEntries(Object.entries(current ?? {}).filter(([key]) => !REMOTE_COMPATIBILITY_KEYS.has(key)))
const editable = Object.fromEntries(Object.entries(input ?? {}).filter(([key]) => REMOTE_COMPATIBILITY_KEYS.has(key)))
const result = { ...hidden, ...editable }
return Object.keys(result).length ? result : null
}
export function remoteProviderRouteAllowed(parts: string[], method: string): boolean {
const id = parts[2]
if (!id) return method === 'GET' || method === 'POST'
if (parts.length === 3) {
if (['presets', 'auth-status'].includes(id)) return method === 'GET'
if (id === 'official') return method === 'POST'
if (id === 'reorder') return method === 'PUT'
return !RESERVED_PROVIDER_PATHS.has(id) && ['GET', 'PUT', 'DELETE'].includes(method)
}
return parts.length === 4 && !RESERVED_PROVIDER_PATHS.has(id) && parts[3] === 'activate' && method === 'POST'
}
export function remoteSettingsRouteAllowed(parts: string[], method: string): boolean {
return parts.length === 3 && parts[2] === 'user' && ['GET', 'PUT'].includes(method)
}
export function projectRemoteSettings(settings: Record<string, unknown>) {
return Object.fromEntries(READ_SETTINGS.filter(key => ['string', 'boolean', 'number'].includes(typeof settings[key])).map(key => [key, settings[key]]))
}
export function validateRemoteSettingsPatch(input: Record<string, unknown>): boolean {
return Object.entries(input).every(([key, value]) => {
if (!WRITE_SETTINGS.has(key)) return false
if (key === 'language') return typeof value === 'string' && value.length <= 80
if (key === 'outputStyle') return typeof value === 'string' && ['default', 'Explanatory', 'Learning'].includes(value)
if (key === 'chatSendBehavior') return value === 'enter' || value === 'modifierEnter'
return typeof value === 'boolean'
})
}
export function projectRemoteProvider(provider: SavedProvider) {
const publicKeys = [
'id', 'presetId', 'name', 'authStrategy', 'baseUrl', 'apiFormat', 'runtimeKind', 'models',
'model1mSupport', 'autoCompactWindow', 'modelContextWindows', 'toolSearchEnabled',
'disableExperimentalBetas', 'supportsNestedToolResultMedia', 'notes',
] as const
return {
...Object.fromEntries(publicKeys.filter(key => provider[key] !== undefined).map(key => [key, provider[key]])),
apiKey: '',
hasApiKey: !!provider.apiKey,
...(provider.requestCompatibility ? {
requestCompatibility: Object.fromEntries([...REMOTE_COMPATIBILITY_KEYS].filter(key => provider.requestCompatibility![key] !== undefined).map(key => [key, provider.requestCompatibility![key]])),
} : {}),
...(provider.imageGeneration ? {
imageGeneration: {
model: provider.imageGeneration.model,
...(provider.imageGeneration.baseUrl !== undefined ? { baseUrl: provider.imageGeneration.baseUrl } : {}),
apiKey: '', hasApiKey: !!provider.imageGeneration.apiKey,
},
} : {}),
}
}
@@ -0,0 +1,36 @@
import { describe, expect, test } from 'bun:test'
import { remoteProviderNeedsCredentials } from './remoteProviderCredentials.js'
import type { SavedProvider } from './types/provider.js'
const provider: SavedProvider = {
id: 'fixture', presetId: 'custom', name: 'Fixture', apiKey: 'fake-main-key',
baseUrl: 'https://trusted.invalid', apiFormat: 'anthropic', runtimeKind: 'anthropic_compatible',
models: { main: 'fake', haiku: 'fake', sonnet: 'fake', opus: 'fake' },
}
describe('remote provider credential destination binding', () => {
test('requires an explicit key when changing the main destination', () => {
for (const apiKey of [undefined, '', ' ']) {
expect(remoteProviderNeedsCredentials(provider, { baseUrl: 'https://other.invalid', apiKey })).toBe(true)
}
expect(remoteProviderNeedsCredentials(provider, { baseUrl: 'https://other.invalid', apiKey: 'fake-new-key' })).toBe(false)
expect(remoteProviderNeedsCredentials(provider, { name: 'Renamed', apiKey: '' })).toBe(false)
})
test('new image destinations cannot inherit the hidden main key', () => {
const imageGeneration = { model: 'image', baseUrl: 'https://image.invalid' }
expect(remoteProviderNeedsCredentials(provider, { imageGeneration })).toBe(true)
expect(remoteProviderNeedsCredentials(provider, { imageGeneration, apiKey: 'fake-new-main' })).toBe(false)
expect(remoteProviderNeedsCredentials(provider, { imageGeneration: { ...imageGeneration, apiKey: 'fake-new-image' } })).toBe(false)
})
test('image URL removal and main URL changes honor dedicated image key retention', () => {
const separate = { ...provider, imageGeneration: { model: 'image', baseUrl: 'https://image.invalid', apiKey: 'fake-image' } }
expect(remoteProviderNeedsCredentials(separate, { imageGeneration: { model: 'new' } })).toBe(true)
expect(remoteProviderNeedsCredentials(separate, { imageGeneration: { model: 'new', baseUrl: '' }, apiKey: 'fake-new-main' })).toBe(true)
expect(remoteProviderNeedsCredentials(separate, { imageGeneration: { model: 'new', apiKey: 'fake-new-image' } })).toBe(false)
const inherited = { ...separate, imageGeneration: { model: 'image', apiKey: 'fake-image' } }
expect(remoteProviderNeedsCredentials(inherited, { baseUrl: 'https://other.invalid', apiKey: 'fake-new-main' })).toBe(true)
expect(remoteProviderNeedsCredentials(inherited, { baseUrl: 'https://other.invalid', apiKey: 'fake-new-main', imageGeneration: null })).toBe(false)
})
})
+29
View File
@@ -0,0 +1,29 @@
import type { SavedProvider } from './types/provider.js'
const nonempty = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0
/** A redacted credential may be reused only at its existing destination. */
export function remoteProviderNeedsCredentials(current: SavedProvider, input: Record<string, unknown>): boolean {
const nextBaseUrl = typeof input.baseUrl === 'string' ? input.baseUrl : current.baseUrl
const replacesMainKey = nonempty(input.apiKey)
if (current.apiKey && nextBaseUrl !== current.baseUrl && !replacesMainKey) return true
if (input.imageGeneration === null) return false
const imagePatch = input.imageGeneration && typeof input.imageGeneration === 'object' && !Array.isArray(input.imageGeneration)
? input.imageGeneration as Record<string, unknown>
: undefined
if (!imagePatch && !current.imageGeneration) return false
const oldImageDestination = current.imageGeneration?.baseUrl?.trim() || current.baseUrl
// Image updates replace the image object; an omitted/empty URL falls back to the main URL.
const nextImageDestination = imagePatch
? (nonempty(imagePatch.baseUrl) ? imagePatch.baseUrl.trim() : nextBaseUrl)
: (current.imageGeneration?.baseUrl?.trim() || nextBaseUrl)
if (nextImageDestination === oldImageDestination) return false
const replacesImageKey = !!imagePatch && nonempty(imagePatch.apiKey)
const retainedImageKey = current.imageGeneration?.apiKey?.trim()
if (replacesImageKey) return false
if (retainedImageKey) return true
// Without a dedicated image key, image generation uses the main provider key.
return !!current.apiKey && !replacesMainKey
}
+135
View File
@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
import { handleApiRequest } from './router.js'
import { ProviderService } from './services/providerService.js'
import { SettingsService } from './services/settingsService.js'
import { conversationService } from './services/conversationService.js'
import type { SavedProvider } from './types/provider.js'
const provider: SavedProvider = {
id: 'fixture-provider', presetId: 'custom', name: 'Fixture', apiKey: 'fake-existing-key',
baseUrl: 'https://fixture.invalid', apiFormat: 'anthropic', runtimeKind: 'anthropic_compatible',
models: { main: 'fixture-model', haiku: 'fixture-model', sonnet: 'fixture-model', opus: 'fixture-model' },
imageGeneration: { model: 'fixture-image', apiKey: 'fake-image-key' },
requestCompatibility: { maxOutputTokens: 2048, privateFutureField: 'fake-hidden-value' },
...{ futureCredential: 'fake-top-level-secret', env: { PRIVATE_KEY: 'fake-env-secret' } },
}
function request(pathname: string, method = 'GET', body?: unknown, remoteBrowser = true) {
const url = new URL(pathname, 'https://fixture.invalid')
const req = new Request(url, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
return handleApiRequest(req, url, { remoteBrowser })
}
beforeEach(() => {
// All persistence and runtime boundaries are substituted before any request.
spyOn(ProviderService.prototype, 'getProvider').mockResolvedValue(structuredClone(provider))
spyOn(ProviderService.prototype, 'listProviders').mockResolvedValue({ providers: [structuredClone(provider)], activeId: provider.id, providerOrder: [provider.id] })
spyOn(ProviderService.prototype, 'addProvider').mockImplementation(async input => ({ ...structuredClone(provider), ...input }))
spyOn(ProviderService.prototype, 'updateProvider').mockImplementation(async (_id, input) => ({ ...structuredClone(provider), ...input }))
spyOn(ProviderService.prototype, 'deleteProvider').mockResolvedValue(undefined)
spyOn(ProviderService.prototype, 'activateProvider').mockResolvedValue(undefined)
spyOn(SettingsService.prototype, 'getUserSettings').mockResolvedValue({ language: 'en', alwaysThinkingEnabled: true, env: { API_KEY: 'fake-never-expose' }, hooks: { dangerous: true } })
spyOn(SettingsService.prototype, 'updateUserSettings').mockResolvedValue(undefined)
spyOn(conversationService, 'getActiveSessions').mockReturnValue([])
})
afterEach(() => mock.restore())
describe('remote browser API routing', () => {
test('projects provider list, get, and create responses while local desktop retains full records', async () => {
for (const pathname of ['/api/providers', `/api/providers/${provider.id}`]) {
const response = await request(pathname)
expect(response.status).toBe(200)
const data = await response.json()
const result = data.provider ?? data.providers[0]
expect(result.apiKey).toBe('')
expect(result.hasApiKey).toBe(true)
expect(result.imageGeneration).toEqual({ model: 'fixture-image', apiKey: '', hasApiKey: true })
expect(result.requestCompatibility).toEqual({ maxOutputTokens: 2048 })
expect(JSON.stringify(data)).not.toContain('fake-existing-key')
expect(JSON.stringify(data)).not.toContain('fake-top-level-secret')
expect(JSON.stringify(data)).not.toContain('fake-env-secret')
}
const created = await request('/api/providers', 'POST', { ...provider, apiKey: 'fake-new-key' })
expect(created.status).toBe(201)
expect((await created.json()).provider.apiKey).toBe('')
expect(ProviderService.prototype.addProvider).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'fake-new-key' }))
expect((await (await request(`/api/providers/${provider.id}`, 'GET', undefined, false)).json()).provider.apiKey).toBe(provider.apiKey)
})
test('blank primary/image keys preserve secrets and compatibility updates preserve hidden future fields', async () => {
const response = await request(`/api/providers/${provider.id}`, 'PUT', {
name: 'Renamed', apiKey: '', imageGeneration: { model: 'updated-image', apiKey: '' }, requestCompatibility: { maxOutputTokens: 4096 },
})
expect(response.status).toBe(200)
expect(ProviderService.prototype.updateProvider).toHaveBeenCalledWith(provider.id, {
name: 'Renamed', imageGeneration: { model: 'updated-image', apiKey: 'fake-image-key' },
requestCompatibility: { maxOutputTokens: 4096, privateFutureField: 'fake-hidden-value' },
})
const data = await response.json()
expect(data.provider.hasApiKey).toBe(true)
expect(data.provider.apiKey).toBe('')
expect(data.provider.requestCompatibility).toEqual({ maxOutputTokens: 4096 })
})
test('remote compatibility writes cannot replace hidden extensions or introduce new ones', async () => {
await request(`/api/providers/${provider.id}`, 'PUT', { requestCompatibility: { maxOutputTokens: 4096, privateFutureField: 'overwrite', env: { NODE_OPTIONS: 'forbidden' } } })
expect(ProviderService.prototype.updateProvider).toHaveBeenLastCalledWith(provider.id, {
requestCompatibility: { maxOutputTokens: 4096, privateFutureField: 'fake-hidden-value' },
})
await request('/api/providers', 'POST', { ...provider, requestCompatibility: { maxOutputTokens: 4096, env: { NODE_OPTIONS: 'forbidden' } } })
expect(ProviderService.prototype.addProvider).toHaveBeenLastCalledWith(expect.objectContaining({ requestCompatibility: { maxOutputTokens: 4096 } }))
})
test('omitted image key is preserved; explicit replacement reaches service but never response', async () => {
await request(`/api/providers/${provider.id}`, 'PUT', { imageGeneration: { model: 'updated-image' } })
expect(ProviderService.prototype.updateProvider).toHaveBeenLastCalledWith(provider.id, { imageGeneration: { model: 'updated-image', apiKey: 'fake-image-key' } })
const replaced = await request(`/api/providers/${provider.id}`, 'PUT', { apiKey: 'fake-replacement', imageGeneration: { model: 'new', apiKey: 'fake-new-image-key' } })
expect(ProviderService.prototype.updateProvider).toHaveBeenLastCalledWith(provider.id, { apiKey: 'fake-replacement', imageGeneration: { model: 'new', apiKey: 'fake-new-image-key' } })
expect(await replaced.text()).not.toContain('fake-replacement')
})
test('replaces visible compatibility fields while preserving hidden fields when clearing controls', async () => {
for (const patch of [{ sampling: 'unsupported' }, {}, null]) {
const response = await request(`/api/providers/${provider.id}`, 'PUT', { requestCompatibility: patch })
expect(response.status).toBe(200)
expect(ProviderService.prototype.updateProvider).toHaveBeenLastCalledWith(provider.id, {
requestCompatibility: { privateFutureField: 'fake-hidden-value', ...patch },
})
expect((await response.json()).provider.requestCompatibility).toEqual(patch ?? {})
}
// A desktop editor owns the complete object and retains its existing reset semantics.
await request(`/api/providers/${provider.id}`, 'PUT', { requestCompatibility: null }, false)
expect(ProviderService.prototype.updateProvider).toHaveBeenLastCalledWith(provider.id, { requestCompatibility: null })
})
test('general reads exclude secrets and valid edits including language reset keep exact requested fields', async () => {
expect(await (await request('/api/settings/user')).json()).toEqual({ language: 'en', alwaysThinkingEnabled: true })
const patch = { language: '', chatSendBehavior: 'modifierEnter', workflowKeywordTriggerEnabled: true, alwaysThinkingEnabled: false, outputStyle: 'Learning' }
const response = await request('/api/settings/user', 'PUT', patch)
expect(response.status).toBe(200)
expect(SettingsService.prototype.updateUserSettings).toHaveBeenCalledWith(patch)
})
test('rejects protected routes and malformed patches before touching persistence', async () => {
for (const pathname of ['/api/providers/settings', '/api/providers/cc-switch/scan', '/api/settings/project', '/api/settings/user/extra']) {
expect((await request(pathname)).status).toBe(403)
}
for (const patch of [null, [], 'invalid', { env: { API_KEY: 'forbidden' } }, { alwaysThinkingEnabled: 'true' }]) {
expect((await request('/api/settings/user', 'PUT', patch)).status).toBe(400)
}
expect(SettingsService.prototype.updateUserSettings).not.toHaveBeenCalled()
const url = new URL('https://fixture.invalid/api/settings/user')
expect((await handleApiRequest(new Request(url, { method: 'PUT', body: '{broken' }), url, { remoteBrowser: true })).status).toBe(400)
})
test('forwards safe mutation results and preserves downstream validation errors', async () => {
expect((await request(`/api/providers/${provider.id}/activate`, 'POST', {})).status).toBe(200)
expect(ProviderService.prototype.activateProvider).toHaveBeenCalledWith(provider.id)
expect((await request(`/api/providers/${provider.id}`, 'DELETE')).status).toBe(200)
expect(ProviderService.prototype.deleteProvider).toHaveBeenCalledWith(provider.id)
expect((await request('/api/providers', 'POST', { name: 'missing-required-fields' })).status).toBe(400)
expect(ProviderService.prototype.addProvider).not.toHaveBeenCalled()
// Non-settings APIs retain the existing router behavior under remote context.
expect((await request('/api/nonexistent')).status).toBe(404)
})
})
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, expect, mock, spyOn, test } from 'bun:test'
import { handleApiRequest } from './router.js'
import { ProviderService } from './services/providerService.js'
import type { SavedProvider } from './types/provider.js'
const provider: SavedProvider = {
id: 'fixture', presetId: 'custom', name: 'Fixture', apiKey: 'fake-secret',
baseUrl: 'https://trusted.invalid', apiFormat: 'anthropic', runtimeKind: 'anthropic_compatible',
models: { main: 'fake', haiku: 'fake', sonnet: 'fake', opus: 'fake' },
}
beforeEach(() => {
spyOn(ProviderService.prototype, 'getProvider').mockResolvedValue(structuredClone(provider))
spyOn(ProviderService.prototype, 'updateProvider').mockImplementation(async (_id, input) => ({ ...provider, ...input } as SavedProvider))
})
afterEach(() => mock.restore())
function update(input: unknown, remoteBrowser = true) {
const url = new URL('https://fixture.invalid/api/providers/fixture')
return handleApiRequest(new Request(url, { method: 'PUT', body: JSON.stringify(input) }), url, { remoteBrowser })
}
test('remote endpoint changes reject retained secrets before persistence', async () => {
for (const input of [
{ baseUrl: 'https://attacker.invalid', apiKey: '' },
{ baseUrl: 'https://attacker.invalid' },
{ imageGeneration: { model: 'image', baseUrl: 'https://attacker.invalid', apiKey: '' } },
]) {
const response = await update(input)
expect(response.status).toBe(400)
expect((await response.json()).code).toBe('REMOTE_PROVIDER_CREDENTIAL_REQUIRED')
}
expect(ProviderService.prototype.updateProvider).not.toHaveBeenCalled()
})
test('explicit replacement permits remote endpoint edit and remains redacted', async () => {
const response = await update({ baseUrl: 'https://new.invalid', apiKey: 'fake-new-key' })
expect(response.status).toBe(200)
expect(ProviderService.prototype.updateProvider).toHaveBeenCalledWith('fixture', { baseUrl: 'https://new.invalid', apiKey: 'fake-new-key' })
expect(await response.text()).not.toContain('fake-new-key')
})
test('desktop retains the existing endpoint editing contract', async () => {
expect((await update({ baseUrl: 'https://new.invalid' }, false)).status).toBe(200)
expect(ProviderService.prototype.updateProvider).toHaveBeenCalledWith('fixture', { baseUrl: 'https://new.invalid' })
})
+57 -1
View File
@@ -31,7 +31,63 @@ import { handleDesktopUiApi } from './api/desktop-ui.js'
import { handleTracesApi } from './api/traces.js'
import { handleWorkflowsApi } from './api/workflows.js'
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
import { remoteProviderRouteAllowed, remoteSettingsRouteAllowed, projectRemoteProvider, projectRemoteSettings, replaceRemoteCompatibility, validateRemoteSettingsPatch, type ApiRequestContext } from './remoteBrowserPolicy.js'
import { ProviderService } from './services/providerService.js'
import type { SavedProvider } from './types/provider.js'
import { remoteProviderNeedsCredentials } from './remoteProviderCredentials.js'
export async function handleApiRequest(req: Request, url: URL, context: ApiRequestContext = {}): Promise<Response> {
if (!context.remoteBrowser) return routeApiRequest(req, url)
const parts = url.pathname.split('/').filter(Boolean)
const isProvider = parts[1] === 'providers'
const isSettings = parts[1] === 'settings'
if (!isProvider && !isSettings) return routeApiRequest(req, url)
if ((isProvider && !remoteProviderRouteAllowed(parts, req.method)) || (isSettings && !remoteSettingsRouteAllowed(parts, req.method))) {
return Response.json({ error: 'Desktop-only capability' }, { status: 403 })
}
try {
const createsProvider = isProvider && parts.length === 2 && req.method === 'POST'
if (createsProvider || (req.method === 'PUT' && (isSettings || (isProvider && parts.length === 3 && parts[2] !== 'reorder')))) {
const body: unknown = await req.json()
if (!body || typeof body !== 'object' || Array.isArray(body)) return Response.json({ error: 'Object required' }, { status: 400 })
const input = { ...body } as Record<string, unknown>
if (isSettings && !validateRemoteSettingsPatch(input)) return Response.json({ error: 'Unsupported General setting' }, { status: 400 })
if (isProvider) {
const saved = createsProvider ? undefined : await new ProviderService().getProvider(parts[2]!)
if (saved && remoteProviderNeedsCredentials(saved, input)) {
return Response.json({ error: 'Changing a credential destination requires an explicit API key', code: 'REMOTE_PROVIDER_CREDENTIAL_REQUIRED' }, { status: 400 })
}
// Empty fields mean retain the saved secret, never replace with the redacted placeholder.
if (!createsProvider && input.apiKey === '') delete input.apiKey
if (input.requestCompatibility === null || (input.requestCompatibility && typeof input.requestCompatibility === 'object' && !Array.isArray(input.requestCompatibility))) {
input.requestCompatibility = replaceRemoteCompatibility(saved?.requestCompatibility, input.requestCompatibility as Record<string, unknown> | null)
if (createsProvider && input.requestCompatibility === null) delete input.requestCompatibility
}
if (input.imageGeneration && typeof input.imageGeneration === 'object' && !Array.isArray(input.imageGeneration)) {
const image = { ...input.imageGeneration } as Record<string, unknown>
if (!createsProvider && (image.apiKey === '' || image.apiKey === undefined)) {
image.apiKey = saved?.imageGeneration?.apiKey
}
input.imageGeneration = image
}
}
req = new Request(req.url, { method: req.method, headers: req.headers, body: JSON.stringify(input) })
}
const response = await routeApiRequest(req, url)
if (!response.ok) return response
const body = await response.json() as Record<string, unknown>
if (isSettings && req.method === 'GET') return Response.json(projectRemoteSettings(body))
if (isProvider) {
if (Array.isArray(body.providers)) body.providers = (body.providers as SavedProvider[]).map(projectRemoteProvider)
if (body.provider && typeof body.provider === 'object') body.provider = projectRemoteProvider(body.provider as SavedProvider)
}
return Response.json(body, { status: response.status })
} catch {
return Response.json({ error: 'Remote settings request failed' }, { status: 400 })
}
}
async function routeApiRequest(req: Request, url: URL): Promise<Response> {
const path = url.pathname
const segments = path.split('/').filter(Boolean) // ['api', 'sessions', ...]