feat: 为本地 HTTP API 增加 Token 鉴权与安全加固

- 使用 Electron safeStorage 加密存储并自动初始化 API Token
- 为 health 以外的接口增加 Bearer Token 鉴权
- 限制 CORS 仅允许可信本地 Origin
- 增加鉴权、Token rotation、safeStorage 和手动验收测试
This commit is contained in:
Wxw-Gu
2026-08-07 17:48:05 +08:00
parent 0c21008ec3
commit a73af3b5ad
33 changed files with 1328 additions and 130 deletions
+134
View File
@@ -0,0 +1,134 @@
import crypto from 'crypto'
import { app, safeStorage } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import type {
ApiTokenActionResult,
ApiTokenRevealResult,
ApiTokenStatus
} from '../shared/local-api-auth'
const MASKED_TOKEN = '••••••••••••••••'
const TOKEN_BYTES = 32
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/
interface TokenReadResult {
success: boolean
token?: string
error?: string
}
export class ApiTokenStore {
private cachedToken: string | null = null
constructor(private readonly filePathOverride?: string) {}
private get filePath(): string {
return this.filePathOverride || path.join(app.getPath('userData'), 'local-api-token.bin')
}
getStatus(): ApiTokenStatus {
const available = safeStorage.isEncryptionAvailable()
if (!available) {
return {
available: false,
hasToken: false,
maskedToken: MASKED_TOKEN,
error: '系统安全存储不可用,本地 API 已安全停用。请检查系统钥匙串或凭据服务后重试。'
}
}
const result = this.read()
return {
available: true,
hasToken: result.success && Boolean(result.token),
maskedToken: MASKED_TOKEN,
...(result.success ? {} : { error: result.error })
}
}
ensureToken(): ApiTokenActionResult {
if (!safeStorage.isEncryptionAvailable()) {
return {
success: false,
available: false,
hasToken: false,
maskedToken: MASKED_TOKEN,
error: '系统安全存储不可用,本地 API 已安全停用。请检查系统钥匙串或凭据服务后重试。'
}
}
const current = this.read()
if (!current.success) return this.actionError(current.error)
if (current.token) return this.actionSuccess()
return this.persist(this.generateToken())
}
revealToken(): ApiTokenRevealResult {
const ensured = this.ensureToken()
if (!ensured.success) return ensured
return { ...ensured, token: this.cachedToken || undefined }
}
rotateToken(): ApiTokenActionResult {
if (!safeStorage.isEncryptionAvailable()) return this.ensureToken()
return this.persist(this.generateToken())
}
getTokenForAuthentication(): string | null {
if (this.cachedToken) return this.cachedToken
const result = this.read()
return result.success ? result.token || null : null
}
private generateToken(): string {
return crypto.randomBytes(TOKEN_BYTES).toString('base64url')
}
private read(): TokenReadResult {
if (this.cachedToken) return { success: true, token: this.cachedToken }
if (!safeStorage.isEncryptionAvailable()) {
return { success: false, error: '系统安全存储不可用' }
}
if (!fs.existsSync(this.filePath)) return { success: true }
try {
const token = safeStorage.decryptString(fs.readFileSync(this.filePath))
if (!TOKEN_PATTERN.test(token)) throw new Error('invalid token data')
this.cachedToken = token
return { success: true, token }
} catch {
return { success: false, error: '已保存的 API Token 无法从系统安全存储读取' }
}
}
private persist(token: string): ApiTokenActionResult {
try {
fs.ensureDirSync(path.dirname(this.filePath))
fs.writeFileSync(this.filePath, safeStorage.encryptString(token), { mode: 0o600 })
fs.chmodSync(this.filePath, 0o600)
this.cachedToken = token
return this.actionSuccess()
} catch {
return this.actionError('API Token 无法保存到系统安全存储')
}
}
private actionSuccess(): ApiTokenActionResult {
return {
success: true,
available: true,
hasToken: true,
maskedToken: MASKED_TOKEN
}
}
private actionError(error?: string): ApiTokenActionResult {
return {
success: false,
available: safeStorage.isEncryptionAvailable(),
hasToken: false,
maskedToken: MASKED_TOKEN,
error: error || 'API Token 安全存储不可用'
}
}
}
export const apiTokenStore = new ApiTokenStore()
+69 -8
View File
@@ -1,3 +1,4 @@
import crypto from 'crypto'
import http, { IncomingMessage, ServerResponse, Server } from 'http'
import {
isReady,
@@ -12,6 +13,7 @@ import { GroupReportExportRequest } from '../shared/group-report'
import { generateAgentGroupReport } from './services/agent-group-report-service'
import { agentHubService } from './services/agent-hub-service'
import { safeError, safeLog, safeWarn } from './safe-log'
import { apiTokenStore } from './api-token-store'
export const DEFAULT_HTTP_HOST = '127.0.0.1'
export const DEFAULT_HTTP_PORT = 6131
@@ -29,6 +31,10 @@ interface RouteContext {
body?: unknown
}
export interface HttpServerOptions {
tokenProvider?: () => string | null
}
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
@@ -36,12 +42,50 @@ function sendJson(res: ServerResponse, status: number, payload: unknown): void {
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store'
})
res.end(body)
}
function isAllowedCorsOrigin(origin: string): boolean {
if (!/^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(origin)) return false
try {
const parsed = new URL(origin)
if (parsed.protocol !== 'http:') return false
if (parsed.username || parsed.password) return false
return ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname.toLowerCase())
} catch {
return false
}
}
function applyCorsHeaders(req: IncomingMessage, res: ServerResponse): boolean {
const origin = req.headers.origin
if (!origin) return true
if (!isAllowedCorsOrigin(origin)) return false
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
return true
}
function isAuthorized(req: IncomingMessage, expectedToken: string | null): boolean {
const header = req.headers.authorization
const match = typeof header === 'string' ? /^Bearer ([A-Za-z0-9_-]+)$/.exec(header) : null
if (!match || !expectedToken) return false
const actualDigest = crypto.createHash('sha256').update(match[1], 'utf8').digest()
const expectedDigest = crypto.createHash('sha256').update(expectedToken, 'utf8').digest()
return crypto.timingSafeEqual(actualDigest, expectedDigest)
}
function sendUnauthorized(res: ServerResponse): void {
sendJson(res, 401, {
error: 'unauthorized',
message: 'Valid API token required'
})
}
function sendError(res: ServerResponse, status: number, message: string, extra?: unknown): void {
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
}
@@ -301,24 +345,28 @@ const routes: Record<string, RouteHandler> = {
export function startHttpServer(
host: string = DEFAULT_HTTP_HOST,
port: number = DEFAULT_HTTP_PORT
port: number = DEFAULT_HTTP_PORT,
options: HttpServerOptions = {}
): Promise<HttpServerHandle> {
const tokenProvider = options.tokenProvider || (() => apiTokenStore.getTokenForAuthentication())
return new Promise((resolve, reject) => {
const server: Server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url || '/', `http://${host}:${port}`)
if (!applyCorsHeaders(req, res)) {
return sendError(res, 403, 'Origin 不允许访问本地 API')
}
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': '*'
})
res.writeHead(204)
return res.end()
}
const handler = routes[url.pathname]
if (!handler) {
return sendError(res, 404, `端点不存在: ${url.pathname}`)
}
if (url.pathname !== '/api/v1/health' && !isAuthorized(req, tokenProvider())) {
return sendUnauthorized(res)
}
let body: string | undefined
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
body = await readBody(req)
@@ -391,11 +439,24 @@ export const apiServer = {
return this.getState()
}
const token = apiTokenStore.ensureToken()
if (!token.success) {
singletonState = {
running: false,
host,
port,
error: token.error || 'API Token 安全存储不可用'
}
return { ...singletonState }
}
const maxAttempts = 4
let lastError: (NodeJS.ErrnoException & { friendlyMessage?: string }) | null = null
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
singleton = await startHttpServer(host, port)
singleton = await startHttpServer(host, port, {
tokenProvider: () => apiTokenStore.getTokenForAuthentication()
})
singletonState = {
running: true,
host: singleton.host,
+40 -4
View File
@@ -43,6 +43,7 @@ import type {
LegacyAIConfig
} from '../shared/ai-provider'
import { DatabaseKeyStore } from './database-key-store'
import { apiTokenStore } from './api-token-store'
import { ImageKeyConfigService } from './services/image-key-config-service'
import { AIProviderService } from './services/ai-provider-service'
import { imageInsightService } from './services/image-insight-service'
@@ -58,7 +59,7 @@ import { KeyService as KeyServiceWin } from './key-service-win'
import * as chat from './services/chat-service'
import { apiServer } from './http-server'
import { skillResourceService } from './services/skill-resource-service'
import { testLocalApiRequest } from './services/local-api-test-service'
import { buildLocalApiCurlCommand, testLocalApiRequest } from './services/local-api-test-service'
import { isWechatRunning } from './services/wechat-process-status'
import {
inspectImageDecryptionStatus,
@@ -321,7 +322,10 @@ function getLocalMediaMimeType(filePath: string): string {
}
}
function buildImageResponse(image: DecodedImage, includeData = false): {
function buildImageResponse(
image: DecodedImage,
includeData = false
): {
success: true
data: string
isThumb: boolean
@@ -443,8 +447,8 @@ app.whenReady().then(async () => {
app.getPath('userData'),
join(__dirname, 'knowledgeWorker.js')
)
knowledgeSearchService.setVoiceTranscriptResolver((reference) =>
voiceRecognition?.getTranscriptSnapshot(reference) || { state: 'pending' }
knowledgeSearchService.setVoiceTranscriptResolver(
(reference) => voiceRecognition?.getTranscriptSnapshot(reference) || { state: 'pending' }
)
voiceRecognition.onTranscriptUpdate((update) =>
knowledgeSearchService?.indexVoiceTranscript(update)
@@ -1441,6 +1445,25 @@ app.whenReady().then(async () => {
ipcMain.handle('api:getStatus', () => apiServer.getState())
ipcMain.handle('api:tokenStatus', () => apiTokenStore.ensureToken())
ipcMain.handle('api:revealToken', () => apiTokenStore.revealToken())
ipcMain.handle('api:copyToken', () => {
const result = apiTokenStore.revealToken()
if (!result.token) return { ...result, success: false }
try {
clipboard.writeText(result.token)
return {
success: true,
available: result.available,
hasToken: result.hasToken,
maskedToken: result.maskedToken
}
} catch {
return { ...apiTokenStore.getStatus(), success: false, error: 'API Token 复制失败' }
}
})
ipcMain.handle('api:rotateToken', () => apiTokenStore.rotateToken())
ipcMain.handle('api:start', async (_, host?: string, port?: number) => {
const settings = loadSettings()
const target = {
@@ -1466,6 +1489,16 @@ app.whenReady().then(async () => {
ipcMain.handle('api:revealSkill', () => skillResourceService.reveal())
ipcMain.handle('api:openSkillGithub', () => skillResourceService.openGithub())
ipcMain.handle('api:testLocalRequest', (_, request) => testLocalApiRequest(request))
ipcMain.handle('api:copyCurl', (_, request) => {
const result = buildLocalApiCurlCommand(request)
if (!result.success || !result.command) return { success: false, error: result.error }
try {
clipboard.writeText(result.command)
return { success: true }
} catch {
return { success: false, error: 'curl 命令复制失败' }
}
})
ipcMain.handle('api:copyText', (_, text: unknown) => {
if (typeof text !== 'string' || text.length > 1024 * 1024) {
return { success: false, error: '复制内容无效或过大' }
@@ -1499,6 +1532,9 @@ app.whenReady().then(async () => {
// 启动本地 HTTP API(由 settings.apiEnabled 控制)
const settings = loadSettings()
// v2.1.8 and earlier did not have an API token. Generate it once during
// upgrade/startup without changing any existing API or database settings.
apiTokenStore.ensureToken()
if (settings.apiEnabled) {
await apiServer.start(settings.apiHost, settings.apiPort)
}
+59 -1
View File
@@ -1,5 +1,6 @@
import http from 'http'
import { apiServer } from '../http-server'
import { apiTokenStore } from '../api-token-store'
import {
LOCAL_API_ENDPOINTS,
type LocalApiEndpointId,
@@ -45,6 +46,47 @@ function parseBody(bodyText: string, contentType?: string): { json?: unknown; bo
return { bodyText }
}
export function buildLocalApiCurlCommand(payload: unknown): {
success: boolean
command?: string
error?: string
} {
if (!payload || typeof payload !== 'object') return { success: false, error: '请求格式无效' }
const { endpointId, query = {}, body = '' } = payload as Partial<LocalApiTestRequest>
if (!isEndpointId(endpointId)) return { success: false, error: '不允许访问该 API 端点' }
if (!query || typeof query !== 'object' || Array.isArray(query))
return { success: false, error: '查询参数格式无效' }
if (typeof body !== 'string' || Buffer.byteLength(body) > MAX_BODY_SIZE)
return { success: false, error: '请求体格式无效' }
const endpoint = LOCAL_API_ENDPOINTS[endpointId]
const entries = Object.entries(query)
if (
entries.some(
([key, value]) => !endpoint.queryKeys.includes(key as never) || typeof value !== 'string'
)
) {
return { success: false, error: '查询参数不属于当前端点' }
}
const service = apiServer.getState()
const targetHost = requestHost(service.host)
const hostPart = targetHost.includes(':') ? `[${targetHost}]` : targetHost
const url = new URL(endpoint.path, `http://${hostPart}:${service.port}`)
entries.forEach(([key, value]) => {
if (value.trim()) url.searchParams.set(key, value.trim())
})
const token = endpointId === 'health' ? null : apiTokenStore.getTokenForAuthentication()
if (endpointId !== 'health' && !token) {
return { success: false, error: 'API Token 安全存储不可用,请在 API Center 检查 Token 状态' }
}
const authHeader = token ? ` -H 'Authorization: Bearer ${token}'` : ''
const command =
endpoint.method === 'POST'
? `curl -X POST '${url.toString()}'${authHeader} -H 'Content-Type: application/json' -d '${body.replaceAll("'", "\\'")}'`
: `curl '${url.toString()}'${authHeader}`
return { success: true, command }
}
export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTestResponse> {
if (!payload || typeof payload !== 'object') return invalidResponse('请求格式无效')
const { endpointId, query = {}, body = '' } = payload as Partial<LocalApiTestRequest>
@@ -94,11 +136,27 @@ export async function testLocalApiRequest(payload: unknown): Promise<LocalApiTes
settled = true
resolve(result)
}
const token = endpointId === 'health' ? null : apiTokenStore.getTokenForAuthentication()
if (endpointId !== 'health' && !token) {
return finish({
ok: false,
method: endpoint.method,
path: endpoint.path,
url: url.toString(),
durationMs: Date.now() - startedAt,
responseSize: 0,
errorCode: 'TOKEN_UNAVAILABLE',
error: 'API Token 安全存储不可用,请在 API Center 检查 Token 状态'
})
}
const headers: Record<string, string> = {}
if (endpoint.method === 'POST') headers['Content-Type'] = 'application/json'
if (token) headers.Authorization = `Bearer ${token}`
const request = http.request(
url,
{
method: endpoint.method,
headers: endpoint.method === 'POST' ? { 'Content-Type': 'application/json' } : undefined
headers
},
(response) => {
const chunks: Buffer[] = []
+1 -1
View File
@@ -48,7 +48,7 @@ function getStatus(): SkillResourceStatus {
}
return {
available: true,
version: 'v1.0',
version: 'v1.1',
filePath,
directoryPath,
source,
+7
View File
@@ -445,6 +445,10 @@ declare global {
port: number
error?: string
}>
apiTokenStatus: () => Promise<import('../shared/local-api-auth').ApiTokenStatus>
revealApiToken: () => Promise<import('../shared/local-api-auth').ApiTokenRevealResult>
copyApiToken: () => Promise<import('../shared/local-api-auth').ApiTokenActionResult>
rotateApiToken: () => Promise<import('../shared/local-api-auth').ApiTokenActionResult>
apiStart: (
host?: string,
port?: number
@@ -469,6 +473,9 @@ declare global {
revealReaderSkill: () => Promise<{ success: boolean; error?: string }>
openReaderSkillGithub: () => Promise<{ success: boolean; error?: string }>
testLocalApiRequest: (request: LocalApiTestRequest) => Promise<LocalApiTestResponse>
copyLocalApiCurl: (
request: LocalApiTestRequest
) => Promise<import('../shared/local-api-test').LocalApiCurlCopyResult>
copyText: (text: string) => Promise<{ success: boolean; error?: string }>
// ============================================================
// AI 图片理解基础设施(ImageInsightService)
+5
View File
@@ -253,6 +253,10 @@ const api = {
disconnectDb: (options?: { closeNative?: boolean }) =>
ipcRenderer.invoke('db:disconnect', options),
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
apiTokenStatus: () => ipcRenderer.invoke('api:tokenStatus'),
revealApiToken: () => ipcRenderer.invoke('api:revealToken'),
copyApiToken: () => ipcRenderer.invoke('api:copyToken'),
rotateApiToken: () => ipcRenderer.invoke('api:rotateToken'),
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
apiStop: () => ipcRenderer.invoke('api:stop'),
apiToggle: (enabled: boolean) => ipcRenderer.invoke('api:toggle', enabled),
@@ -261,6 +265,7 @@ const api = {
revealReaderSkill: () => ipcRenderer.invoke('api:revealSkill'),
openReaderSkillGithub: () => ipcRenderer.invoke('api:openSkillGithub'),
testLocalApiRequest: (request) => ipcRenderer.invoke('api:testLocalRequest', request),
copyLocalApiCurl: (request) => ipcRenderer.invoke('api:copyCurl', request),
copyText: (text: string) => ipcRenderer.invoke('api:copyText', text),
// ============================================================
// AI 图片理解基础设施(ImageInsightService)
+32 -18
View File
@@ -73,9 +73,12 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
const [settings, setSettings] = useState<AppSettings | null>(null)
const [settingsPath, setSettingsPath] = useState('')
const [apiState, setApiState] = useState<ApiState | null>(null)
const [testStatus, setTestStatus] = useState<
{ kind: 'idle' | 'ok' | 'fail'; message: string; wxid?: string; accountRoot?: string }
>({ kind: 'idle', message: '' })
const [testStatus, setTestStatus] = useState<{
kind: 'idle' | 'ok' | 'fail'
message: string
wxid?: string
accountRoot?: string
}>({ kind: 'idle', message: '' })
const [reopenStatus, setReopenStatus] = useState<string>('')
const [imageKeyStatus, setImageKeyStatus] = useState<{
kind: 'idle' | 'ok' | 'fail'
@@ -135,7 +138,10 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
setTestStatus({ kind: 'fail', message: result.error || '连接失败' })
}
} catch (error) {
setTestStatus({ kind: 'fail', message: error instanceof Error ? error.message : String(error) })
setTestStatus({
kind: 'fail',
message: error instanceof Error ? error.message : String(error)
})
} finally {
setBusy(false)
}
@@ -226,7 +232,11 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
<div className="settings-self">
<div className="settings-self-avatar">
{selfInfo.avatar ? (
<img src={selfInfo.avatar} alt={selfInfo.nickname} referrerPolicy="no-referrer" />
<img
src={selfInfo.avatar}
alt={selfInfo.nickname}
referrerPolicy="no-referrer"
/>
) : (
(selfInfo.nickname || selfInfo.wxid || '?').charAt(0)
)}
@@ -334,7 +344,8 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
</div>
)}
<div className="settings-hint">
使Windows 2-3
使Windows
2-3
</div>
</section>
@@ -346,24 +357,23 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
type="text"
className="settings-input"
value={settings?.dbRoot ?? ''}
onChange={(e) => setSettings(settings ? { ...settings, dbRoot: e.target.value } : null)}
onChange={(e) =>
setSettings(settings ? { ...settings, dbRoot: e.target.value } : null)
}
onBlur={(e) => handleSave({ dbRoot: e.target.value })}
placeholder={dbRootPlaceholder}
spellCheck={false}
/>
</div>
<div className="settings-row">
<button
className="settings-btn"
onClick={handleReopen}
disabled={busy || !dbReady}
>
<button className="settings-btn" onClick={handleReopen} disabled={busy || !dbReady}>
</button>
{reopenStatus && <span className="settings-status">{reopenStatus}</span>}
</div>
<div className="settings-hint">
Windows Documents\WeChat FilesmacOS xwechat_files db_storage/session.db
Windows Documents\WeChat FilesmacOS
xwechat_files db_storage/session.db
</div>
</section>
@@ -412,8 +422,7 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
/>
</div>
<div className="settings-hint">
沿
localStorage
沿 localStorage
</div>
</section>
@@ -442,7 +451,9 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
type="text"
className="settings-input settings-input-half"
value={settings?.apiHost ?? ''}
onChange={(e) => setSettings(settings ? { ...settings, apiHost: e.target.value } : null)}
onChange={(e) =>
setSettings(settings ? { ...settings, apiHost: e.target.value } : null)
}
onBlur={(e) => handleSave({ apiHost: e.target.value })}
placeholder="host"
spellCheck={false}
@@ -452,7 +463,9 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
className="settings-input settings-input-quarter"
value={settings?.apiPort ?? 6131}
onChange={(e) =>
setSettings(settings ? { ...settings, apiPort: Number(e.target.value) || 6131 } : null)
setSettings(
settings ? { ...settings, apiPort: Number(e.target.value) || 6131 } : null
)
}
onBlur={(e) => handleSave({ apiPort: Number(e.target.value) || 6131 })}
placeholder="port"
@@ -462,7 +475,8 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({
</button>
</div>
<div className="settings-hint">
API 访, Claude / Codex
API Bearer Token Token API Center
Claude / Codex
<br />
:<code>docs/skill/wechatexplorer-reader/SKILL.md</code>
</div>
@@ -54,7 +54,7 @@ export function ApiWorkspace({ selectedContact, dbReady, onOpenSettings }: Props
onBody={controller.updateBody}
onSend={controller.runRequest}
onClear={clear}
onCopyCurl={(command) => controller.copyText(command, 'curl 命令已复制')}
onCopyCurl={controller.copyCurl}
/>
</div>
{state.rawMarkdown && (
@@ -67,12 +67,18 @@ export function ApiWorkspace({ selectedContact, dbReady, onOpenSettings }: Props
</main>
<ApiRuntimePanel
service={state.service}
tokenStatus={state.tokenStatus}
revealedToken={state.revealedToken}
dbReady={dbReady}
response={state.response}
history={state.history}
onControl={controller.controlService}
onOpenSettings={onOpenSettings}
onCopy={controller.copyText}
onRevealToken={controller.revealToken}
onHideToken={controller.hideToken}
onCopyToken={controller.copyToken}
onRotateToken={controller.rotateToken}
/>
{state.toast && <div className="app-toast">{state.toast}</div>}
</div>
@@ -14,7 +14,7 @@ interface Props {
onBody: (body: string) => void
onSend: () => void
onClear: () => void
onCopyCurl: (command: string) => Promise<void>
onCopyCurl: () => Promise<void>
}
export function ApiRequestTester({
@@ -32,13 +32,6 @@ export function ApiRequestTester({
onCopyCurl
}: Props): ReactElement {
const url = settings ? buildApiUrl(settings.apiHost, settings.apiPort, endpoint.path, params) : ''
const copyCurl = (): void => {
const command =
endpoint.method === 'POST'
? `curl -X POST '${url}' -H 'Content-Type: application/json' -d '${body.replaceAll("'", "\\'")}'`
: `curl '${url}'`
void onCopyCurl(command)
}
const update = (key: string, value: string): void => onParams({ ...params, [key]: value })
const selectTestImage = async (): Promise<void> => {
const result = await window.api.selectAgentHubTestImage()
@@ -100,7 +93,7 @@ export function ApiRequestTester({
<button type="button" onClick={onClear}>
</button>
<button type="button" onClick={copyCurl}>
<button type="button" onClick={() => void onCopyCurl()}>
curl
</button>
<button
@@ -1,26 +1,43 @@
import type { ApiResponse, ApiServiceState, RequestHistoryItem } from '../model/types'
import type {
ApiResponse,
ApiServiceState,
ApiTokenStatus,
RequestHistoryItem
} from '../model/types'
import { type ReactElement } from 'react'
import { isLoopbackHost } from '../utils/buildApiUrl'
import { formatJson, formatResponseSize, inferResponseCount } from '../utils/formatResponse'
interface Props {
service: ApiServiceState | null
tokenStatus: ApiTokenStatus | null
revealedToken: string
dbReady: boolean
response: ApiResponse | null
history: RequestHistoryItem[]
onControl: (action: 'start' | 'stop' | 'restart') => void
onOpenSettings: () => void
onCopy: (text: string, message: string) => Promise<void>
onRevealToken: () => Promise<void>
onHideToken: () => void
onCopyToken: () => Promise<void>
onRotateToken: () => Promise<void>
}
export function ApiRuntimePanel({
service,
tokenStatus,
revealedToken,
dbReady,
response,
history,
onControl,
onOpenSettings,
onCopy
onCopy,
onRevealToken,
onHideToken,
onCopyToken,
onRotateToken
}: Props): ReactElement {
const host = service?.host || '127.0.0.1'
const port = service?.port || 6131
@@ -33,7 +50,7 @@ export function ApiRuntimePanel({
<div className="api-runtime-title">
<h2></h2>
<span className={service?.running ? 'ready' : 'stopped'}>
{service?.running ? '运行中' : '已停止'}
{service?.running ? 'API 已启用' : '已停止'}
</span>
</div>
<dl>
@@ -49,7 +66,7 @@ export function ApiRuntimePanel({
</div>
<div>
<dt></dt>
<dd></dd>
<dd>{tokenStatus?.hasToken ? 'Bearer Token' : '不可用'}</dd>
</div>
<div>
<dt>访</dt>
@@ -85,8 +102,45 @@ export function ApiRuntimePanel({
</div>
</section>
{!localOnly && (
<p className="api-security-warning">访 API </p>
<p className="api-security-warning">
访Bearer Token
</p>
)}
<section className="api-token-section">
<div className="api-runtime-title">
<h3>API Token</h3>
<span className={tokenStatus?.hasToken ? 'ready' : 'stopped'}>
{tokenStatus?.hasToken ? 'Token 已生成' : 'Token 不可用'}
</span>
</div>
<code className="api-token-value">
{revealedToken || tokenStatus?.maskedToken || '••••••••••••••••'}
</code>
{tokenStatus?.error && <p className="api-inline-error">{tokenStatus.error}</p>}
<div className="api-runtime-actions">
<button
type="button"
disabled={!tokenStatus?.hasToken}
onClick={() => void (revealedToken ? onHideToken() : onRevealToken())}
>
{revealedToken ? '隐藏 Token' : '显示 Token'}
</button>
<button
type="button"
disabled={!tokenStatus?.hasToken}
onClick={() => void onCopyToken()}
>
Token
</button>
<button
type="button"
disabled={!tokenStatus?.available}
onClick={() => void onRotateToken()}
>
Token
</button>
</div>
</section>
<section>
<h3></h3>
{response ? (
@@ -142,7 +196,7 @@ export function ApiRuntimePanel({
<p>
{localOnly
? '本地 API 默认监听 127.0.0.1。WechatExplorer 不会通过该接口自动把聊天内容发送到云端。外部 Agent 是否调用第三方模型,取决于其自身配置。'
: '当前服务并非仅本机访问。请确认局域网环境可信,且注意 API 暂无鉴权。'}
: '当前服务并非仅本机访问。请确认局域网环境可信;API Token 不等同于公网安全防护。'}
</p>
</section>
</div>
@@ -11,6 +11,7 @@ import type {
ApiResponse,
ApiServiceState,
ApiSettings,
ApiTokenStatus,
RequestHistoryItem,
SkillStatus
} from '../model/types'
@@ -19,12 +20,15 @@ import {
buildSkillInstallInstruction,
buildSkillVerificationPrompt
} from '../utils/buildSkillInstallInstruction'
import { confirmApiTokenRotation } from '../utils/confirmApiTokenRotation'
type RequestState = 'idle' | 'loading' | 'success' | 'error'
interface State {
settings: ApiSettings | null
service: ApiServiceState | null
tokenStatus: ApiTokenStatus | null
revealedToken: string
skill: SkillStatus | null
endpointId: string
params: Record<string, string>
@@ -39,7 +43,13 @@ interface State {
}
type Action =
| { type: 'loaded'; settings: ApiSettings; service: ApiServiceState; skill: SkillStatus }
| {
type: 'loaded'
settings: ApiSettings
service: ApiServiceState
skill: SkillStatus
tokenStatus: ApiTokenStatus
}
| { type: 'endpoint'; endpointId: string; talker?: string }
| { type: 'params'; params: Record<string, string> }
| { type: 'body'; body: string }
@@ -49,10 +59,15 @@ type Action =
| { type: 'markdown'; content: string | null }
| { type: 'toast'; message: string }
| { type: 'installTarget'; target: AgentInstallTarget }
| { type: 'tokenRevealed'; token: string }
| { type: 'tokenHidden' }
| { type: 'tokenStatus'; status: ApiTokenStatus }
const initialState: State = {
settings: null,
service: null,
tokenStatus: null,
revealedToken: '',
skill: null,
endpointId: 'health',
params: {},
@@ -69,7 +84,13 @@ const initialState: State = {
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'loaded':
return { ...state, settings: action.settings, service: action.service, skill: action.skill }
return {
...state,
settings: action.settings,
service: action.service,
skill: action.skill,
tokenStatus: action.tokenStatus
}
case 'endpoint': {
const preset =
action.endpointId === 'report'
@@ -108,6 +129,12 @@ function reducer(state: State, action: Action): State {
return { ...state, toast: action.message }
case 'installTarget':
return { ...state, installTarget: action.target }
case 'tokenRevealed':
return { ...state, revealedToken: action.token }
case 'tokenHidden':
return { ...state, revealedToken: '' }
case 'tokenStatus':
return { ...state, tokenStatus: action.status, revealedToken: '' }
}
}
@@ -124,6 +151,11 @@ export function useApiCenterController(selectedContact: Contact | null): {
reportError: (error: string) => void
showToast: (message: string) => void
copyText: (text: string, successMessage: string) => Promise<void>
copyCurl: () => Promise<void>
revealToken: () => Promise<void>
hideToken: () => void
copyToken: () => Promise<void>
rotateToken: () => Promise<void>
setInstallTarget: (target: AgentInstallTarget) => void
copyInstallInstruction: () => Promise<void>
copyVerificationPrompt: () => Promise<void>
@@ -135,12 +167,13 @@ export function useApiCenterController(selectedContact: Contact | null): {
const refresh = useCallback(async (): Promise<void> => {
try {
const [{ settings }, service, skill] = await Promise.all([
const [{ settings }, service, skill, tokenStatus] = await Promise.all([
window.api.getSettings(),
window.api.apiStatus(),
window.api.getReaderSkillStatus()
window.api.getReaderSkillStatus(),
window.api.apiTokenStatus()
])
dispatch({ type: 'loaded', settings, service, skill })
dispatch({ type: 'loaded', settings, service, skill, tokenStatus })
} catch (error) {
dispatch({
type: 'error',
@@ -265,6 +298,31 @@ export function useApiCenterController(selectedContact: Contact | null): {
},
[showToast]
)
const revealToken = useCallback(async (): Promise<void> => {
const result = await window.api.revealApiToken()
if (result.token) dispatch({ type: 'tokenRevealed', token: result.token })
else dispatch({ type: 'error', error: result.error || '无法读取 API Token' })
}, [])
const hideToken = useCallback((): void => dispatch({ type: 'tokenHidden' }), [])
const copyToken = useCallback(async (): Promise<void> => {
const result = await window.api.copyApiToken()
showToast(result.success ? 'Token 已复制' : result.error || 'Token 复制失败')
}, [showToast])
const rotateToken = useCallback(async (): Promise<void> => {
if (!confirmApiTokenRotation()) return
const result = await window.api.rotateApiToken()
dispatch({ type: 'tokenStatus', status: result })
showToast(result.success ? 'Token 已重新生成' : result.error || 'Token 重新生成失败')
}, [showToast])
const copyCurl = useCallback(async (): Promise<void> => {
const endpoint = findEndpoint(state.endpointId)
const result = await window.api.copyLocalApiCurl({
endpointId: endpoint.id,
query: state.params,
body: state.body
})
showToast(result.success ? 'curl 命令已复制' : result.error || 'curl 命令复制失败')
}, [showToast, state.body, state.endpointId, state.params])
const setInstallTarget = useCallback(
(target: AgentInstallTarget): void => dispatch({ type: 'installTarget', target }),
[]
@@ -318,6 +376,11 @@ export function useApiCenterController(selectedContact: Contact | null): {
reportError,
showToast,
copyText,
copyCurl,
revealToken,
hideToken,
copyToken,
rotateToken,
setInstallTarget,
copyInstallInstruction,
copyVerificationPrompt,
@@ -1,4 +1,5 @@
export type ApiMethod = 'GET' | 'POST'
export type { ApiTokenStatus } from '../../../../../shared/local-api-auth'
export interface ApiParameter {
key: string
@@ -33,7 +33,7 @@ export function buildSkillInstallInstruction({
source.type === 'local'
? `${opening(target)}\n\n${source.directoryPath}\n\n请先阅读该目录中的 SKILL.md,然后调用:`
: `请从以下地址安装 WechatExplorer Reader Skill\n\n${source.installUrl}\n\n阅读 SKILL.md 后,调用:`
return `${sourceText}\n\n${healthUrl}\n\n验证 WechatExplorer 本地 API 是否已就绪。安装完成后告诉我验证结果。`
return `${sourceText}\n\n${healthUrl}\n\n先调用公开的 health 接口验证服务。然后请用户在 WechatExplorer → API Center → API Token 中点击“复制 Token”,并把 Token 配置为 Agent 本机环境变量 WECHATEXPLORER_API_TOKEN。读取联系人、会话或聊天记录时,必须发送 Authorization: Bearer $WECHATEXPLORER_API_TOKEN。此服务是 Local HTTP API,不是 MCP Server。安装完成后告诉我验证结果。`
}
export function buildSkillVerificationPrompt(): string {
@@ -0,0 +1,8 @@
export const API_TOKEN_ROTATION_CONFIRMATION =
'重新生成后,旧 Token 将立即失效。\n已配置此 API 的 Agent / Reader Skill 需要更新 Token。\n是否继续?'
export function confirmApiTokenRotation(
confirm: (message: string) => boolean = window.confirm
): boolean {
return confirm(API_TOKEN_ROTATION_CONFIRMATION)
}
+17
View File
@@ -631,6 +631,23 @@
color: #915d1e;
font: 12px/18px var(--wxex-font);
}
.api-token-value {
display: block;
overflow-wrap: anywhere;
margin: 10px 0;
padding: 9px 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-subtle);
color: var(--wxex-text-primary);
font:
12px/18px ui-monospace,
monospace;
user-select: text;
}
.api-token-section .api-runtime-actions button:last-child {
grid-column: 1 / -1;
}
.api-response-summary {
display: flex;
align-items: center;
+14
View File
@@ -0,0 +1,14 @@
export interface ApiTokenStatus {
available: boolean
hasToken: boolean
maskedToken: string
error?: string
}
export interface ApiTokenRevealResult extends ApiTokenStatus {
token?: string
}
export interface ApiTokenActionResult extends ApiTokenStatus {
success: boolean
}
+12 -1
View File
@@ -38,6 +38,17 @@ export interface LocalApiTestResponse {
contentType?: string
json?: unknown
bodyText?: string
errorCode?: 'API_NOT_RUNNING' | 'CONNECTION_REFUSED' | 'TIMEOUT' | 'INVALID_REQUEST' | 'UNKNOWN'
errorCode?:
| 'API_NOT_RUNNING'
| 'TOKEN_UNAVAILABLE'
| 'CONNECTION_REFUSED'
| 'TIMEOUT'
| 'INVALID_REQUEST'
| 'UNKNOWN'
error?: string
}
export interface LocalApiCurlCopyResult {
success: boolean
error?: string
}