cc-haha/src/server/index.ts
程序员阿江(Relakkes) 53d8e7ad77 feat(desktop): stabilize H5 access tokens, ports, and background sessions (#767, #764)
H5 远程访问的三处不稳定来源修复,让手机出门在外也能稳定连接、长任务不丢。

#767 令牌与端口固定:
- 令牌明文持久化到 cc-haha/settings.json,重启后二维码/令牌随时可查、可复制;
  enable 复用现有令牌、disable 保留令牌,仅 regenerate 才轮换。手改 token 字段
  即自定义固定令牌。完整令牌只经 local-trusted 面返回,远端 403。
- 新增可选固定端口 fixedPort,并在未配置时复用上次端口(desktop-server-state.json
  sticky,Electron/Tauri 双壳共享),反向代理/手机书签跨重启不失效;占用时回退随机。

#764 断连不杀正在运行的 CLI:
- 客户端断开时若该会话仍在跑一轮任务,不再 30s 后强杀子进程,而是等任务跑完;
  手机锁屏/切后台时长任务在后台跑完,重连即见结果。
- 空闲清理超时改为可配 disconnectGraceSeconds(H5 访问设置页,默认 30s),
  经 disconnectGraceConfig 同步缓存供 close 处理读取。

server / Electron / Tauri / React 四层 + 五语言 i18n + 配套单测全部覆盖。
2026-06-12 16:37:00 +08:00

527 lines
17 KiB
TypeScript

/**
* Claude Code Desktop App — HTTP + WebSocket Server
*
* 为桌面端 UI 提供 REST API 和 WebSocket 实时通信。
* 读写与 CLI 完全相同的文件系统,确保 CLI/UI 数据互通。
*/
import { handleApiRequest } from './router.js'
import { handleWebSocket, type WebSocketData } from './ws/handler.js'
import { resolveCors, type CorsResolution } from './middleware/cors.js'
import { requireAuth, requireH5Token } from './middleware/auth.js'
import { teamWatcher } from './services/teamWatcher.js'
import { cronScheduler } from './services/cronScheduler.js'
import { handleProxyRequest } from './proxy/handler.js'
import { ProviderService } from './services/providerService.js'
import { handleHahaOAuthCallback } from './api/haha-oauth.js'
import { handleHahaOpenAIOAuthCallback } from './api/haha-openai-oauth.js'
import { handlePreviewFs } from './api/previewFs.js'
import { handleLocalFile } from './api/localFile.js'
import { sessionService } from './services/sessionService.js'
import { conversationService } from './services/conversationService.js'
import { OPENAI_CODEX_REDIRECT_PATH } from '../services/openaiAuth/client.js'
import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js'
import { enableConfigs } from '../utils/config.js'
import { diagnosticsService } from './services/diagnosticsService.js'
import { ensurePersistentStorageUpgraded } from './services/persistentStorageMigrations.js'
import { handleStaticH5Request } from './staticH5.js'
import { classifyH5Request, shouldBlockDisabledH5Access, shouldRequireH5Token } from './h5AccessPolicy.js'
import { H5AccessService } from './services/h5AccessService.js'
import { refreshDisconnectGraceMs } from './ws/disconnectGraceConfig.js'
function readArgValue(flag: string): string | undefined {
const args = process.argv.slice(2)
const index = args.indexOf(flag)
if (index === -1) return undefined
return args[index + 1]
}
function hasArgFlag(flag: string): boolean {
return process.argv.slice(2).includes(flag)
}
function resolveServerOptions() {
const portArg = readArgValue('--port')
const port = Number.parseInt(portArg || process.env.SERVER_PORT || '3456', 10)
const host = readArgValue('--host') || process.env.SERVER_HOST || '127.0.0.1'
const cliPath = readArgValue('--cli-path')
const authRequired = hasArgFlag('--auth-required')
if (cliPath) {
process.env.CLAUDE_CLI_PATH = cliPath
}
return { port, host, authRequired }
}
const SERVER_OPTIONS = resolveServerOptions()
const PORT = SERVER_OPTIONS.port
const HOST = SERVER_OPTIONS.host
function withCors(response: Response, cors: CorsResolution): Response {
const headers = new Headers(response.headers)
for (const [key, value] of Object.entries(cors.headers)) {
headers.set(key, value)
}
return new Response(response.body, {
status: response.status,
headers,
})
}
function corsRejectedResponse(cors: CorsResolution): Response {
return Response.json(
{ error: 'CORS origin not allowed' },
{ status: 403, headers: cors.headers },
)
}
function h5AccessControlRejectedResponse(): Response {
return Response.json(
{
error: 'Forbidden',
message: 'H5 access settings can only be changed from the local desktop app.',
},
{ status: 403 },
)
}
function h5AccessDisabledResponse(): Response {
return Response.json(
{
error: 'Forbidden',
message: 'H5 access is disabled. Enable H5 access from the local desktop app first.',
},
{ status: 403 },
)
}
function isH5AccessControlRequest(
req: Request,
url: URL,
context: { clientAddress: string | null },
): boolean {
if (!url.pathname.startsWith('/api/h5-access')) {
return false
}
if (url.pathname === '/api/h5-access/verify') {
return false
}
return classifyH5Request(req, url, context) !== 'local-trusted'
}
function originFromUrl(value: string | null): string | null {
if (!value) {
return null
}
try {
return new URL(value).origin
} catch {
return null
}
}
export function startServer(port = PORT, host = HOST) {
enableConfigs()
// Warm the synchronous disconnect-grace cache from managed settings so the
// first client disconnect honors the configured value (issue #764).
void refreshDisconnectGraceMs()
// Don't hijack the global console / process handlers under `bun test`:
// a test that boots the server would otherwise route every test-side
// console.error/warn into the user's real diagnostics file.
if (process.env.NODE_ENV !== 'test') {
diagnosticsService.installConsoleCapture()
diagnosticsService.installProcessCapture()
}
let serverPort = port
const localConnectHost =
host === '0.0.0.0' || host === '127.0.0.1' || host === 'localhost'
? '127.0.0.1'
: host
/**
* Explicit deployment auth remains a stronger override than H5-scoped
* request gating.
*/
const forceAuth =
SERVER_OPTIONS.authRequired ||
process.env.SERVER_AUTH_REQUIRED === '1'
const h5AccessService = new H5AccessService()
let server: ReturnType<typeof Bun.serve<WebSocketData>>
try {
server = Bun.serve<WebSocketData>({
port,
hostname: host,
idleTimeout: 60,
async fetch(req, server) {
await ensurePersistentStorageUpgraded()
const url = new URL(req.url)
const origin = req.headers.get('Origin')
const clientAddress = server.requestIP(req)?.address ?? null
const h5RequestContext = { clientAddress }
const h5Settings = await h5AccessService.getSettings()
const h5PublicOrigin = originFromUrl(h5Settings.publicBaseUrl)
const cors = await resolveCors(origin, url.origin, {
h5Enabled: h5Settings.enabled,
isOriginAllowed: async (candidateOrigin) =>
candidateOrigin === h5PublicOrigin ||
await h5AccessService.isOriginAllowed(candidateOrigin),
})
const authRequired = shouldRequireH5Token({
request: req,
url,
h5Enabled: h5Settings.enabled,
context: h5RequestContext,
})
const h5AccessDisabledBlocked = shouldBlockDisabledH5Access({
request: req,
url,
h5Enabled: h5Settings.enabled,
explicitAuthRequired: forceAuth,
context: h5RequestContext,
})
const h5AccessControlBlocked = isH5AccessControlRequest(req, url, h5RequestContext)
if (h5AccessControlBlocked) {
return h5AccessControlRejectedResponse()
}
if (h5AccessDisabledBlocked) {
return h5AccessDisabledResponse()
}
// Handle CORS preflight
if (req.method === 'OPTIONS') {
if (cors.rejected) {
return corsRejectedResponse(cors)
}
return new Response(null, { status: 204, headers: cors.headers })
}
// WebSocket upgrade
if (url.pathname.startsWith('/ws/')) {
if (cors.rejected) {
return corsRejectedResponse(cors)
}
// Enforce authentication when required
if (authRequired) {
const authError = await requireH5Token(req, url.searchParams.get('token'))
if (authError) {
return withCors(authError, cors)
}
} else if (forceAuth) {
const authError = await requireAuth(req, url.searchParams.get('token'))
if (authError) {
return withCors(authError, cors)
}
}
// Validate session ID format
const sessionId = url.pathname.split('/').pop() || ''
if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) {
return new Response('Invalid session ID', { status: 400 })
}
const upgraded = server.upgrade(req, {
data: {
sessionId,
connectedAt: Date.now(),
channel: 'client',
sdkToken: null,
serverPort,
serverHost: localConnectHost,
},
})
if (upgraded) return undefined
return new Response('WebSocket upgrade failed', { status: 400 })
}
// Internal SDK WebSocket used by the spawned Claude CLI.
if (url.pathname.startsWith('/sdk/')) {
if (classifyH5Request(req, url, h5RequestContext) !== 'internal-sdk') {
return h5AccessControlRejectedResponse()
}
if (cors.rejected) {
return corsRejectedResponse(cors)
}
if (forceAuth) {
const authError = await requireAuth(req, url.searchParams.get('token'))
if (authError) {
return withCors(authError, cors)
}
}
const sessionId = url.pathname.split('/').pop() || ''
if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) {
return new Response('Invalid session ID', { status: 400 })
}
const upgraded = server.upgrade(req, {
data: {
sessionId,
connectedAt: Date.now(),
channel: 'sdk',
sdkToken: url.searchParams.get('token'),
serverPort,
serverHost: localConnectHost,
},
})
if (upgraded) return undefined
return new Response('WebSocket upgrade failed', { status: 400 })
}
if (url.pathname === '/callback') {
return handleHahaOAuthCallback(url)
}
if (
url.pathname === OPENAI_CODEX_REDIRECT_PATH ||
url.pathname === '/callback/openai'
) {
return handleHahaOpenAIOAuthCallback(url)
}
// Preview filesystem — serve sandboxed workspace files for a session.
if (url.pathname.startsWith('/preview-fs/')) {
if (cors.rejected) {
return corsRejectedResponse(cors)
}
if (authRequired) {
const authError = await requireH5Token(req)
if (authError) {
return withCors(authError, cors)
}
} else if (forceAuth) {
const authError = await requireAuth(req)
if (authError) {
return withCors(authError, cors)
}
}
const response = await handlePreviewFs(
url,
async (sessionId) =>
conversationService.getSessionWorkDir(sessionId) ||
(await sessionService.getSessionWorkDir(sessionId)) ||
null,
req.headers,
)
return withCors(response, cors)
}
// Local filesystem — serve an ABSOLUTE local file ($HOME/tmp/registered
// roots sandbox) so `file://` links / AI-emitted absolute paths open in
// the in-app browser. Gated identically to /preview-fs above.
if (url.pathname.startsWith('/local-file/')) {
if (cors.rejected) {
return corsRejectedResponse(cors)
}
if (authRequired) {
const authError = await requireH5Token(req)
if (authError) {
return withCors(authError, cors)
}
} else if (forceAuth) {
const authError = await requireAuth(req)
if (authError) {
return withCors(authError, cors)
}
}
const response = await handleLocalFile(url, req.headers)
return withCors(response, cors)
}
// REST API
if (url.pathname.startsWith('/api/')) {
if (cors.rejected) {
return corsRejectedResponse(cors)
}
// Enforce authentication when required
if (authRequired) {
const authError = await requireH5Token(req)
if (authError) {
return withCors(authError, cors)
}
} else if (forceAuth) {
const authError = await requireAuth(req)
if (authError) {
return withCors(authError, cors)
}
}
try {
const response = await handleApiRequest(req, url)
return withCors(response, cors)
} catch (error) {
void diagnosticsService.recordEvent({
type: 'api_request_failed',
severity: 'error',
summary: error instanceof Error ? error.message : String(error),
details: { path: url.pathname, method: req.method, error },
})
console.error('[Server] API error:', error)
return withCors(Response.json(
{ error: 'Internal server error' },
{ status: 500 },
), cors)
}
}
// Proxy — protocol-translating reverse proxy for OpenAI-compatible APIs
if (url.pathname.startsWith('/proxy/')) {
if (cors.rejected) {
return corsRejectedResponse(cors)
}
if (authRequired) {
const authError = await requireH5Token(req)
if (authError) {
return withCors(authError, cors)
}
} else if (forceAuth) {
const authError = await requireAuth(req)
if (authError) {
return withCors(authError, cors)
}
}
try {
const response = await handleProxyRequest(req, url)
return withCors(response, cors)
} catch (error) {
void diagnosticsService.recordEvent({
type: 'proxy_request_failed',
severity: 'error',
summary: error instanceof Error ? error.message : String(error),
details: { path: url.pathname, method: req.method, error },
})
console.error('[Server] Proxy error:', error)
return withCors(Response.json(
{ type: 'error', error: { type: 'api_error', message: 'Internal proxy error' } },
{ status: 500 },
), cors)
}
}
// Health check
if (url.pathname === '/health') {
if (cors.rejected) {
return corsRejectedResponse(cors)
}
return Response.json(
{ status: 'ok', timestamp: new Date().toISOString() },
{ headers: cors.headers },
)
}
// Static H5 shell/assets are non-secret bootstrap content and must load
// before the browser can read the QR token; API/proxy/ws stay protected above.
const staticResponse = await handleStaticH5Request(req, url)
if (staticResponse) {
return staticResponse
}
return new Response('Not Found', { status: 404 })
},
websocket: handleWebSocket,
})
serverPort = server.port
ProviderService.setServerPort(serverPort)
} catch (error) {
const message = error instanceof Error && error.message
? error.message
: `Failed to start server. Is port ${port} in use?`
throw new Error(message, { cause: error })
}
// Start watching ~/.claude/teams/ for real-time WebSocket push
teamWatcher.start()
// Start the cron scheduler to execute scheduled tasks
cronScheduler.start()
void ensureDesktopCliLauncherInstalled().catch((error) => {
console.error(
'[desktop-cli-launcher] failed to install bundled launcher:',
error instanceof Error ? error.message : error,
)
})
console.log(`[Server] Claude Code API server running at http://${host}:${serverPort}`)
return server
}
// ─── Graceful shutdown: kill all CLI subprocesses on exit ────────────────────
let shutdownInProgress: Promise<void> | null = null
export async function stopServerRuntimeForShutdown(
options: { waitForCli?: boolean } = {},
): Promise<void> {
teamWatcher.stop()
cronScheduler.stop()
const active = conversationService.getActiveSessions()
if (active.length > 0) {
console.log(`[Server] Shutting down — killing ${active.length} CLI subprocess(es)`)
if (options.waitForCli === false) {
conversationService.stopAllSessions()
} else {
await conversationService.stopAllSessionsAndWait()
}
}
}
function cleanupAllSessions() {
void stopServerRuntimeForShutdown({ waitForCli: false })
}
async function cleanupAllSessionsAndWait() {
await stopServerRuntimeForShutdown({ waitForCli: true })
}
function shutdownAndExit(signal: 'SIGTERM' | 'SIGINT', exitCode: number) {
if (shutdownInProgress) return
shutdownInProgress = (async () => {
console.log(`[Server] Received ${signal}`)
await cleanupAllSessionsAndWait()
process.exit(exitCode)
})().catch((error) => {
console.error(
`[Server] ${signal} shutdown cleanup failed:`,
error instanceof Error ? error.message : error,
)
process.exit(1)
})
}
process.on('SIGTERM', () => {
shutdownAndExit('SIGTERM', 0)
})
process.on('SIGINT', () => {
shutdownAndExit('SIGINT', 0)
})
process.on('exit', () => {
cleanupAllSessions()
})
// Direct execution
if (import.meta.main) {
startServer()
}