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:
Dorian
2026-03-06 03:09:06 +00:00
co-authored by Claude Opus 4.6
parent e97c8f36ac
commit 1eaf30ae12
7 changed files with 181 additions and 90 deletions
+158 -74
View File
@@ -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 = {
+3 -4
View File
@@ -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