fix(desktop): support web_password auth and fix password settings

This commit is contained in:
zhayujie
2026-07-08 20:15:16 +08:00
parent 4d87703e31
commit b98fbae6f6
12 changed files with 290 additions and 21 deletions

View File

@@ -315,7 +315,10 @@ export class PythonBackend extends EventEmitter {
const startedAt = Date.now()
const check = () => {
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
// Probe the unauthenticated health endpoint, NOT /config: /config
// requires auth once a web_password is set, which would make this poll
// 401 forever and hang startup.
const req = http.get(`http://127.0.0.1:${this.port}/api/health`, (res) => {
if (res.statusCode === 200) {
this.status = 'ready'
this.emit('log', `Backend ready on port ${this.port}`)

View File

@@ -5,6 +5,7 @@ import NavRail from './layout/NavRail'
import SessionList from './layout/SessionList'
import WindowControls from './layout/WindowControls'
import StatusScreen from './components/StatusScreen'
import LoginGate from './components/LoginGate'
import { useBackend } from './hooks/useBackend'
import { usePlatform } from './hooks/usePlatform'
import { useUIStore } from './store/uiStore'
@@ -32,16 +33,45 @@ const App: React.FC = () => {
const onboardingOpen = useOnboardingStore((s) => s.open)
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
const [, forceUpdate] = useState(0)
// Auth gate for web_password-protected backends. 'checking' until we know
// whether login is needed; 'need_login' shows the password screen; 'ok' lets
// the main UI render.
const [authState, setAuthState] = useState<'checking' | 'need_login' | 'ok'>('checking')
useEffect(() => {
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
}, [backend.status, backend.baseUrl])
// Once the backend is ready, check whether a web_password is set. If so and
// this session isn't authenticated, show the login gate before the app.
useEffect(() => {
if (backend.status !== 'ready') {
setAuthState('checking')
return
}
let cancelled = false
apiClient
.authCheck()
.then((res) => {
if (cancelled) return
const needLogin = res.auth_required && !res.authenticated
setAuthState(needLogin ? 'need_login' : 'ok')
})
.catch(() => {
// If the check itself fails, don't hard-block the user — assume no auth
// is required (backends without web_password never return errors here).
if (!cancelled) setAuthState('ok')
})
return () => {
cancelled = true
}
}, [backend.status, backend.baseUrl])
// First-run check: once the backend is ready, decide whether to show the
// onboarding wizard. It's config-driven — shown whenever the chat model isn't
// configured (and not dismissed earlier this session); no persisted flag.
useEffect(() => {
if (backend.status !== 'ready') return
if (backend.status !== 'ready' || authState !== 'ok') return
let cancelled = false
apiClient
.getModels()
@@ -65,7 +95,7 @@ const App: React.FC = () => {
return () => {
cancelled = true
}
}, [backend.status, maybeOpenOnboarding])
}, [backend.status, authState, maybeOpenOnboarding])
// Subscribe to auto-update status from the main process (no-op in dev).
useEffect(() => initUpdateListener(), [])
@@ -91,6 +121,15 @@ const App: React.FC = () => {
return <StatusScreen status={backend.status} error={backend.error} onRetry={backend.restart} />
}
// Backend is up but we're still resolving auth — keep the loading screen.
if (authState === 'checking') {
return <StatusScreen status="connecting" onRetry={backend.restart} />
}
if (authState === 'need_login') {
return <LoginGate onAuthenticated={() => setAuthState('ok')} />
}
const isChat = location.pathname === '/'
const showSessions = isChat && !sessionsCollapsed

View File

@@ -24,8 +24,16 @@ interface ApiResult {
message?: string
}
const AUTH_TOKEN_KEY = 'cow_auth_token'
class ApiClient {
private baseUrl = 'http://127.0.0.1:9876'
// Bearer token for web_password-protected backends. The desktop renderer
// runs from a file:// origin, where cross-origin cookies to http://127.0.0.1
// aren't sent reliably, so we authenticate via an Authorization header
// instead. Persisted in localStorage so it survives reloads.
private authToken: string | null =
typeof localStorage !== 'undefined' ? localStorage.getItem(AUTH_TOKEN_KEY) : null
setBaseUrl(url: string) {
this.baseUrl = url
@@ -35,13 +43,25 @@ class ApiClient {
return this.baseUrl
}
setAuthToken(token: string | null) {
this.authToken = token
try {
if (token) localStorage.setItem(AUTH_TOKEN_KEY, token)
else localStorage.removeItem(AUTH_TOKEN_KEY)
} catch {
// localStorage may be unavailable; in-memory token still works this session
}
}
private async request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
...options,
// Send cookies for future web_password auth support
// Cookies still work for browser access; the desktop app relies on the
// Authorization header below.
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {}),
...options?.headers,
},
})
@@ -93,8 +113,16 @@ class ApiClient {
})
}
// EventSource can't set an Authorization header, so append the auth token as
// a query param for SSE endpoints (the backend accepts it there).
private withToken(url: string): string {
if (!this.authToken) return url
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}token=${encodeURIComponent(this.authToken)}`
}
createSSEStream(requestId: string): EventSource {
return new EventSource(`${this.baseUrl}/stream?request_id=${requestId}`)
return new EventSource(this.withToken(`${this.baseUrl}/stream?request_id=${requestId}`))
}
async deleteMessage(opts: {
@@ -138,11 +166,13 @@ class ApiClient {
getFileUrl(previewUrl: string): string {
if (/^https?:\/\//.test(previewUrl)) return previewUrl
return `${this.baseUrl}${previewUrl}`
// Served via <img src>, which can't set headers — carry the token in the
// query so protected file endpoints load under web_password.
return this.withToken(`${this.baseUrl}${previewUrl}`)
}
getServeFileUrl(absPath: string): string {
return `${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`
return this.withToken(`${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`)
}
// ---------------------------------------------------------
@@ -390,7 +420,7 @@ class ApiClient {
// ---------------------------------------------------------
createLogStream(): EventSource {
return new EventSource(`${this.baseUrl}/api/logs`)
return new EventSource(this.withToken(`${this.baseUrl}/api/logs`))
}
async getVersion(): Promise<string> {
@@ -406,14 +436,19 @@ class ApiClient {
return this.request('/auth/check')
}
async authLogin(password: string): Promise<ApiResult> {
return this.request('/auth/login', {
async authLogin(password: string): Promise<ApiResult & { token?: string }> {
const res = await this.request<ApiResult & { token?: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
})
if (res.status === 'success' && res.token) {
this.setAuthToken(res.token)
}
return res
}
async authLogout(): Promise<ApiResult> {
this.setAuthToken(null)
return this.request('/auth/logout', { method: 'POST' })
}
}

View File

@@ -0,0 +1,72 @@
import React, { useState } from 'react'
import apiClient from '../api/client'
import { t } from '../i18n'
interface LoginGateProps {
// Called once the password is accepted (auth cookie set), so the app can
// proceed to the main UI.
onAuthenticated: () => void
}
/**
* Shown when the backend has a web_password set and the current session isn't
* authenticated yet. Submitting the correct password sets an auth cookie
* (handled by the backend), after which the app reloads its data.
*/
const LoginGate: React.FC<LoginGateProps> = ({ onAuthenticated }) => {
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (!password || submitting) return
setSubmitting(true)
setError('')
try {
const res = await apiClient.authLogin(password)
if (res.status === 'success') {
onAuthenticated()
} else {
setError(t('login_error'))
}
} catch {
setError(t('login_error'))
} finally {
setSubmitting(false)
}
}
return (
<div className="h-screen w-screen flex items-center justify-center bg-gray-50 dark:bg-[#111111]">
<form onSubmit={submit} className="text-center space-y-6 max-w-md px-8 w-full">
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mx-auto shadow-lg shadow-primary-500/20" />
<div className="space-y-2">
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('login_title')}</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{t('login_desc')}</p>
</div>
<input
type="password"
autoFocus
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (error) setError('')
}}
placeholder={t('login_placeholder')}
className="w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-[#1a1a1a] text-slate-800 dark:text-slate-100 text-sm outline-none focus:border-primary-500 transition-colors"
/>
{error && <p className="text-sm text-red-500">{error}</p>}
<button
type="submit"
disabled={submitting || !password}
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors text-sm font-medium cursor-pointer"
>
{submitting ? t('login_checking') : t('login_submit')}
</button>
</form>
</div>
)
}
export default LoginGate

View File

@@ -22,7 +22,10 @@ export function useBackend() {
const probeBackend = useCallback(async (port: number): Promise<boolean> => {
try {
const res = await fetch(`http://127.0.0.1:${port}/config`, {
// Probe the unauthenticated health endpoint, NOT /config: once a
// web_password is set, /config returns 401 and we'd wrongly treat the
// (healthy) backend as unreachable, hanging on "connecting".
const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
signal: AbortSignal.timeout(3000),
})
return res.ok

View File

@@ -356,6 +356,13 @@ const translations: Record<string, Record<string, string>> = {
status_error: '初始化失败',
status_error_desc: '客户端初始化失败,请重试',
status_retry: '重试',
// login (web_password)
login_title: '请输入访问密码',
login_desc: '此客户端已设置访问密码,请输入以继续',
login_placeholder: '访问密码',
login_submit: '进入',
login_error: '密码错误,请重试',
login_checking: '验证中...',
// slash command descriptions
slash_menu_title: '命令',
slash_new: '新建对话',
@@ -731,6 +738,13 @@ const translations: Record<string, Record<string, string>> = {
status_error: 'Initialization Failed',
status_error_desc: 'Failed to initialize the client, please retry',
status_retry: 'Retry',
// login (web_password)
login_title: 'Enter access password',
login_desc: 'This client is password-protected. Enter the password to continue.',
login_placeholder: 'Access password',
login_submit: 'Enter',
login_error: 'Wrong password, please try again',
login_checking: 'Verifying...',
// slash command descriptions
slash_menu_title: 'Commands',
slash_new: 'New chat',

View File

@@ -55,7 +55,9 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
setMaxSteps(data.agent_max_steps ?? 20)
setThinking(!!data.enable_thinking)
setEvolution(!!data.self_evolution_enabled)
setPassword(data.web_password_masked || '')
// Prefer the real password (desktop only) so it can be edited in place;
// fall back to the masked value for browser access.
setPassword(data.web_password ?? data.web_password_masked ?? '')
setPwDirty(false)
const ids = data.providers ? Object.keys(data.providers) : []
@@ -130,8 +132,14 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
setTimeout(() => setAgentStatus(''), 2000)
}
// Desktop returns the real password, so the field holds plaintext and can be
// saved (including cleared) directly. Browser access only has the masked
// value, where a masked string must never be saved as the real password.
const hasRealPassword = config?.web_password !== undefined
const savePassword = async () => {
if (!pwDirty || MASK_RE.test(password)) return
if (!pwDirty) return
if (!hasRealPassword && MASK_RE.test(password)) return
try {
await apiClient.updateConfig({ web_password: password })
setPwStatus(password ? t('config_password_saved') : t('config_password_cleared'))
@@ -292,10 +300,13 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
value={password}
placeholder={t('config_password_placeholder')}
onFocus={() => {
if (!pwDirty && MASK_RE.test(password)) setPassword('')
// Browser access shows a mask; clear it on focus so the user
// types a fresh password. Desktop holds the real password and
// must stay editable in place (cursor at the end).
if (!hasRealPassword && !pwDirty && MASK_RE.test(password)) setPassword('')
}}
onBlur={() => {
if (!pwDirty) setPassword(config?.web_password_masked || '')
if (!hasRealPassword && !pwDirty) setPassword(config?.web_password_masked || '')
}}
onChange={(e) => {
setPassword(e.target.value)

View File

@@ -230,6 +230,9 @@ export interface ConfigData {
api_keys: Record<string, string>
providers: Record<string, ProviderMeta>
web_password_masked?: string
// Real password, only returned to the desktop app (trusted local machine) so
// it can be edited in place. Undefined for browser access.
web_password?: string
}
// ============================================================