fix(app): fix broken dev server after security hardening
- Fix dev.sh unbound variable crash with ${VITE_DEV_API_TOKEN:-}
- Kill stale proxy on startup instead of skipping (token mismatch)
- Fix RSS middleware blocking all GET requests (check path before auth)
- Read dev auth token lazily from process.env (not cached at import)
- Restore network binding (host: true) for Vite dev server
- Add macOS keychain lookup for Claude Code OAuth token in proxy
- Rewrite proxy streaming to pipe SSE directly instead of await json()
- Prevent double web search (client-side + proxy) in useAI
- Reduce SearXNG timeout 6s→3s and max tries 8→3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e97c8f36ac
commit
1eaf30ae12
@@ -10,7 +10,7 @@ APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$APP_DIR"
|
||||
|
||||
# Generate a dev API token if not already set
|
||||
if [ -z "$VITE_DEV_API_TOKEN" ]; then
|
||||
if [ -z "${VITE_DEV_API_TOKEN:-}" ]; then
|
||||
export VITE_DEV_API_TOKEN=$(openssl rand -hex 16)
|
||||
fi
|
||||
|
||||
@@ -23,13 +23,14 @@ trap cleanup EXIT INT TERM
|
||||
# Use local node_modules binaries
|
||||
BIN="$APP_DIR/node_modules/.bin"
|
||||
|
||||
# Start Claude proxy in background (skip gracefully if already running on :3141)
|
||||
# Kill stale proxy from previous session (it has a different auth token)
|
||||
if lsof -ti:3141 > /dev/null 2>&1; then
|
||||
echo " Claude proxy already running on :3141 — skipping"
|
||||
else
|
||||
"$BIN/tsx" server/claude-proxy.ts &
|
||||
echo " Killing stale Claude proxy on :3141"
|
||||
kill $(lsof -ti:3141) 2>/dev/null || true
|
||||
sleep 0.3
|
||||
fi
|
||||
"$BIN/tsx" server/claude-proxy.ts &
|
||||
sleep 0.3
|
||||
|
||||
# Start Vite dev server in background (host binding controlled by vite.config.ts / VITE_HOST)
|
||||
"$BIN/vite" &
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn } from 'child_process'
|
||||
import { spawn, execSync } from 'child_process'
|
||||
import { createServer } from 'http'
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
import { resolve, dirname } from 'path'
|
||||
@@ -54,6 +54,21 @@ function getAnthropicCredential(): string | undefined {
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// macOS keychain: Claude Code stores OAuth credentials here
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const raw = execSync(
|
||||
'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null',
|
||||
{ encoding: 'utf8', timeout: 3000 },
|
||||
).trim()
|
||||
const creds = JSON.parse(raw)
|
||||
const oauthToken = creds?.claudeAiOauth?.accessToken
|
||||
if (typeof oauthToken === 'string' && oauthToken.startsWith('sk-ant-')) {
|
||||
console.log('[proxy] Found Claude OAuth token in macOS keychain')
|
||||
return oauthToken
|
||||
}
|
||||
} catch { /* keychain not available or no entry */ }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -104,13 +119,16 @@ async function runSearchWeb(query: string): Promise<string> {
|
||||
async function streamViaAnthropicApi(
|
||||
model: string,
|
||||
system: string | undefined,
|
||||
messages: { role: string; content: string }[],
|
||||
messages: { role: string; content: unknown }[],
|
||||
res: import('http').ServerResponse,
|
||||
useTools: boolean,
|
||||
credential: string,
|
||||
maxTokens?: number,
|
||||
): Promise<void> {
|
||||
const apiModel = mapModelToApi(model)
|
||||
const apiMessages = messages.map((m) => ({
|
||||
role: m.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
||||
content: typeof m.content === 'string' ? m.content : m.content,
|
||||
}))
|
||||
|
||||
let clientDisconnected = false
|
||||
@@ -128,79 +146,143 @@ async function streamViaAnthropicApi(
|
||||
}
|
||||
}
|
||||
|
||||
let turnMessages = [...apiMessages]
|
||||
const maxToolRounds = 5
|
||||
let rounds = 0
|
||||
|
||||
while (rounds < maxToolRounds) {
|
||||
rounds++
|
||||
const body: Record<string, unknown> = {
|
||||
model: apiModel,
|
||||
max_tokens: 4096,
|
||||
system,
|
||||
messages: turnMessages,
|
||||
tools: [SEARCH_WEB_TOOL],
|
||||
}
|
||||
|
||||
const buildHeaders = (): Record<string, string> => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
}
|
||||
if (isOAuthToken(ANTHROPIC_CREDENTIAL!)) {
|
||||
headers['Authorization'] = `Bearer ${ANTHROPIC_CREDENTIAL}`
|
||||
if (isOAuthToken(credential)) {
|
||||
headers['Authorization'] = `Bearer ${credential}`
|
||||
headers['anthropic-beta'] = 'oauth-2025-04-20'
|
||||
} else {
|
||||
headers['x-api-key'] = ANTHROPIC_CREDENTIAL!
|
||||
headers['x-api-key'] = credential
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
const apiRes = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
})
|
||||
// Tool use loop (non-streaming — needs to collect tool calls)
|
||||
if (useTools) {
|
||||
let turnMessages = [...apiMessages]
|
||||
const maxToolRounds = 5
|
||||
let rounds = 0
|
||||
|
||||
if (!apiRes.ok) {
|
||||
const errBody = await apiRes.text()
|
||||
sendError(`Anthropic API ${apiRes.status}: ${errBody.slice(0, 200)}`)
|
||||
while (rounds < maxToolRounds) {
|
||||
rounds++
|
||||
const body: Record<string, unknown> = {
|
||||
model: apiModel,
|
||||
max_tokens: maxTokens ?? 4096,
|
||||
system,
|
||||
messages: turnMessages,
|
||||
tools: [SEARCH_WEB_TOOL],
|
||||
}
|
||||
|
||||
const apiRes = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
})
|
||||
|
||||
if (!apiRes.ok) {
|
||||
const errBody = await apiRes.text()
|
||||
sendError(`Anthropic API ${apiRes.status}: ${errBody.slice(0, 200)}`)
|
||||
break
|
||||
}
|
||||
|
||||
const data = (await apiRes.json()) as {
|
||||
content?: { type: string; text?: string; id?: string; name?: string; input?: { query?: string } }[]
|
||||
stop_reason?: string
|
||||
}
|
||||
|
||||
const content = data.content ?? []
|
||||
const toolUses = content.filter((b) => b.type === 'tool_use')
|
||||
const textBlocks = content.filter((b) => b.type === 'text')
|
||||
|
||||
if (data.stop_reason === 'tool_use' && toolUses.length > 0) {
|
||||
const toolResults: { type: string; tool_use_id: string; content: string }[] = []
|
||||
for (const tu of toolUses) {
|
||||
if (tu.name === 'search_web' && tu.id && tu.input?.query) {
|
||||
console.log('[proxy] tool search_web:', tu.input.query)
|
||||
const result = await runSearchWeb(tu.input.query)
|
||||
toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: result })
|
||||
}
|
||||
}
|
||||
turnMessages = [
|
||||
...turnMessages,
|
||||
{ role: 'assistant' as const, content },
|
||||
{ role: 'user' as const, content: toolResults },
|
||||
]
|
||||
continue
|
||||
}
|
||||
|
||||
for (const block of textBlocks) {
|
||||
if (block.text) sendDelta(block.text)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const data = (await apiRes.json()) as {
|
||||
content?: { type: string; text?: string; id?: string; name?: string; input?: { query?: string } }[]
|
||||
stop_reason?: string
|
||||
if (!clientDisconnected) {
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
}
|
||||
|
||||
const content = data.content ?? []
|
||||
const toolUses = content.filter((b) => b.type === 'tool_use')
|
||||
const textBlocks = content.filter((b) => b.type === 'text')
|
||||
|
||||
if (data.stop_reason === 'tool_use' && toolUses.length > 0) {
|
||||
const toolResults: { type: string; tool_use_id: string; content: string }[] = []
|
||||
for (const tu of toolUses) {
|
||||
if (tu.name === 'search_web' && tu.id && tu.input?.query) {
|
||||
console.log('[proxy] tool search_web:', tu.input.query)
|
||||
const result = await runSearchWeb(tu.input.query)
|
||||
toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: result })
|
||||
}
|
||||
}
|
||||
turnMessages = [
|
||||
...turnMessages,
|
||||
{ role: 'assistant' as const, content },
|
||||
{ role: 'user' as const, content: toolResults },
|
||||
]
|
||||
continue
|
||||
}
|
||||
|
||||
for (const block of textBlocks) {
|
||||
if (block.text) sendDelta(block.text)
|
||||
}
|
||||
break
|
||||
return
|
||||
}
|
||||
|
||||
if (!clientDisconnected) {
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
// Streaming path (no tools)
|
||||
const body: Record<string, unknown> = {
|
||||
model: apiModel,
|
||||
max_tokens: maxTokens ?? 4096,
|
||||
stream: true,
|
||||
messages: apiMessages,
|
||||
}
|
||||
if (system) body.system = system
|
||||
|
||||
const apiRes = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
})
|
||||
|
||||
if (!apiRes.ok) {
|
||||
const errBody = await apiRes.text()
|
||||
sendError(`Anthropic API ${apiRes.status}: ${errBody.slice(0, 200)}`)
|
||||
if (!clientDisconnected) {
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Pipe SSE stream from Anthropic to client
|
||||
const reader = apiRes.body?.getReader()
|
||||
if (!reader) {
|
||||
sendError('No response body from API')
|
||||
if (!clientDisconnected) {
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
try {
|
||||
while (true) {
|
||||
if (clientDisconnected) break
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
res.write(chunk)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!clientDisconnected) {
|
||||
sendError(`Stream error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
} finally {
|
||||
reader.cancel().catch(() => {})
|
||||
if (!clientDisconnected) {
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,26 +396,30 @@ const server = createServer((req, res) => {
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(body)
|
||||
const { model, messages, system, webSearch } = payload
|
||||
const { model, messages, system, webSearch, max_tokens } = payload
|
||||
|
||||
const useTools = webSearch === true && !!ANTHROPIC_CREDENTIAL
|
||||
// Use client-provided API key if present, otherwise fall back to server credential
|
||||
const clientKey = req.headers['x-api-key'] as string | undefined
|
||||
const credential = clientKey || ANTHROPIC_CREDENTIAL
|
||||
|
||||
if (credential) {
|
||||
// Direct API — fast streaming
|
||||
const useTools = webSearch === true
|
||||
const apiModel = mapModelToApi(model)
|
||||
console.log(`[proxy] → Anthropic API ${apiModel}${useTools ? ' [tools]' : ' [stream]'}`)
|
||||
|
||||
if (useTools) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': 'http://localhost:5173',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'X-Accel-Buffering': 'no',
|
||||
})
|
||||
streamViaAnthropicApi(model, system, messages ?? [], res)
|
||||
streamViaAnthropicApi(model, system, messages ?? [], res, useTools, credential, max_tokens)
|
||||
return
|
||||
}
|
||||
|
||||
if (webSearch === true && !ANTHROPIC_CREDENTIAL) {
|
||||
console.log('[proxy] webSearch: using CLI built-in WebSearch + pre-fetched context')
|
||||
}
|
||||
|
||||
// CLI fallback — no API credential available
|
||||
const modelFlag = model?.includes('opus') ? 'opus'
|
||||
: model?.includes('haiku') ? 'haiku'
|
||||
: 'sonnet'
|
||||
@@ -362,10 +448,9 @@ const server = createServer((req, res) => {
|
||||
}
|
||||
args.push('--', lastUserMsg)
|
||||
|
||||
console.log(`[proxy] → claude -p --model ${modelFlag}${webSearch ? ' [WebSearch]' : ''} "${lastUserMsg.slice(0, 60)}..."`)
|
||||
console.log(`[proxy] → claude CLI --model ${modelFlag}${webSearch ? ' [WebSearch]' : ''} "${lastUserMsg.slice(0, 60)}..."`)
|
||||
|
||||
const procEnv = { ...process.env, NO_COLOR: '1', TERM: 'dumb' }
|
||||
// Allow CLI to spawn even when dev server is started from inside Claude Code
|
||||
delete procEnv.CLAUDECODE
|
||||
delete procEnv.CLAUDE_CODE
|
||||
delete procEnv.ANTHROPIC_CLAUDE_CODE
|
||||
@@ -385,7 +470,7 @@ const server = createServer((req, res) => {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': 'http://localhost:5173',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'X-Accel-Buffering': 'no',
|
||||
})
|
||||
|
||||
@@ -395,7 +480,6 @@ const server = createServer((req, res) => {
|
||||
proc.stdout.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString()
|
||||
fullOutput += text
|
||||
console.log(`[proxy] stdout +${text.length}b total=${fullOutput.length}b`)
|
||||
|
||||
if (!clientDisconnected) {
|
||||
const sseData = {
|
||||
|
||||
@@ -5,13 +5,12 @@
|
||||
*/
|
||||
import type { IncomingMessage, ServerResponse } from 'http'
|
||||
|
||||
const DEV_TOKEN = process.env.VITE_DEV_API_TOKEN ?? ''
|
||||
|
||||
/** Validate Authorization header. Returns true if authorized, false if rejected (response already sent). */
|
||||
export function validateDevAuth(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
if (!DEV_TOKEN) return true // No token configured, skip auth
|
||||
const token = process.env.VITE_DEV_API_TOKEN ?? ''
|
||||
if (!token) return true // No token configured, skip auth
|
||||
const auth = req.headers.authorization
|
||||
if (auth === `Bearer ${DEV_TOKEN}`) return true
|
||||
if (auth === `Bearer ${token}`) return true
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }))
|
||||
return false
|
||||
|
||||
@@ -551,17 +551,22 @@ export function useAI() {
|
||||
chatStore.isStreaming = true
|
||||
|
||||
let systemPrompt = buildSystemPrompt(chatStore)
|
||||
let clientSearchSucceeded = false
|
||||
if (chatStore.webSearchEnabled && userText.trim()) {
|
||||
const results = await searchWeb(userText)
|
||||
if (results.length > 0) {
|
||||
systemPrompt += formatWebSearchContext(results)
|
||||
chatStore.setMessageWebResults(cid, assistantMsg.id, results)
|
||||
clientSearchSucceeded = true
|
||||
console.log('[AIUI] Injected', results.length, 'web search results into context')
|
||||
} else {
|
||||
console.warn('[AIUI] Web search enabled but 0 results — check browser console for [AIUI web-search] logs')
|
||||
console.warn('[AIUI] Web search enabled but 0 results — proxy will handle search')
|
||||
}
|
||||
}
|
||||
|
||||
// If client-side search succeeded, don't ask the proxy to search again
|
||||
const proxyWebSearch = chatStore.webSearchEnabled && !clientSearchSucceeded
|
||||
|
||||
const history: ChatMessage[] = chatStore.messages
|
||||
.filter((m) => m.id !== assistantMsg.id)
|
||||
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, images: m.images }))
|
||||
@@ -576,7 +581,7 @@ export function useAI() {
|
||||
|
||||
try {
|
||||
if (provider === 'claude') {
|
||||
await streamClaude(history, onToken, onError, systemPrompt, chatStore.webSearchEnabled, signal, genParams)
|
||||
await streamClaude(history, onToken, onError, systemPrompt, proxyWebSearch, signal, genParams)
|
||||
} else if (provider === 'openrouter') {
|
||||
await streamOpenRouter(history, onToken, onError, systemPrompt, signal)
|
||||
} else {
|
||||
|
||||
@@ -142,10 +142,10 @@ async function fetchRssFromUrls(urls: string[]): Promise<RssArticle[]> {
|
||||
function createRssMiddleware() {
|
||||
return async (req: Connect.IncomingMessage, res: any, next: () => void) => {
|
||||
if (req.method !== 'GET') return next()
|
||||
if (!validateDevAuth(req, res)) return
|
||||
if (!checkRateLimit(req, res)) return
|
||||
const requestUrl = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
|
||||
if (!requestUrl.pathname.startsWith('/api/rss-articles')) return next()
|
||||
if (!validateDevAuth(req, res)) return
|
||||
if (!checkRateLimit(req, res)) return
|
||||
|
||||
const urls = requestUrl.searchParams.getAll('url').map((u) => u.trim()).filter(Boolean)
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ async function fetchFromSearXNG(
|
||||
try {
|
||||
const searchRes = await fetch(searchUrl, {
|
||||
headers: { Accept: 'application/json', 'User-Agent': 'AIUI/1.0' },
|
||||
signal: AbortSignal.timeout(6000),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
if (!searchRes.ok) return null
|
||||
const text = await searchRes.text()
|
||||
@@ -121,7 +121,9 @@ function createWebSearchMiddleware(searxUrl: string | undefined, braveApiKey: st
|
||||
let data: { results?: { title?: string; url?: string; content?: string; img_src?: string; thumbnail?: string; engine?: string }[] } | null = null
|
||||
let lastError = ''
|
||||
|
||||
for (let i = 0; i < instances.length; i++) {
|
||||
// Try at most 3 instances to avoid long waits
|
||||
const maxTries = Math.min(3, instances.length)
|
||||
for (let i = 0; i < maxTries; i++) {
|
||||
const baseUrl = instances[(startIdx + i) % instances.length].replace(/\/$/, '')
|
||||
const searchUrl = `${baseUrl}/search?${new URLSearchParams({ q, format: 'json', pageno: '1' })}`
|
||||
console.log('[web-search]', q.slice(0, 40), '→', baseUrl)
|
||||
|
||||
@@ -131,7 +131,7 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: process.env.VITE_HOST || 'localhost',
|
||||
host: process.env.VITE_HOST === 'false' ? 'localhost' : true,
|
||||
open: false,
|
||||
proxy: {
|
||||
'/api/claude': {
|
||||
|
||||
Reference in New Issue
Block a user