fix: address Codex review findings — auth, cron CLI, workdir, model IDs

- Enforce auth on API/WS when non-localhost or SERVER_AUTH_REQUIRED=1
- Fix CronScheduler: add --verbose flag and correct stream-json message envelope
- Resolve session workdir from JSONL file instead of hardcoding process.cwd()
- Give Opus 4.6 1M a unique composite ID (claude-opus-4-6-20250610:1m)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-05 15:21:55 +08:00
parent ce0908a4e0
commit 52f00a95d6
5 changed files with 92 additions and 12 deletions

View File

@ -21,7 +21,7 @@ const AVAILABLE_MODELS = [
context: '200k', context: '200k',
}, },
{ {
id: 'claude-opus-4-6-20250610', id: 'claude-opus-4-6-20250610:1m',
name: 'Opus 4.6 1M', name: 'Opus 4.6 1M',
description: 'Most capable for ambitious work', description: 'Most capable for ambitious work',
context: '1m', context: '1m',
@ -86,13 +86,19 @@ export async function handleModelsApi(
async function handleCurrentModel(req: Request): Promise<Response> { async function handleCurrentModel(req: Request): Promise<Response> {
if (req.method === 'GET') { if (req.method === 'GET') {
const settings = await settingsService.getUserSettings() const settings = await settingsService.getUserSettings()
const modelId = (settings.model as string) || DEFAULT_MODEL const baseModelId = (settings.model as string) || DEFAULT_MODEL
const model = AVAILABLE_MODELS.find((m) => m.id === modelId) || { const contextTier = (settings.modelContext as string) || undefined
id: modelId,
name: modelId, // Reconstruct composite ID for lookup (e.g. 'claude-opus-4-6-20250610:1m')
description: 'Custom model', const lookupId = contextTier ? `${baseModelId}:${contextTier}` : baseModelId
context: 'unknown', const model = AVAILABLE_MODELS.find((m) => m.id === lookupId)
} || AVAILABLE_MODELS.find((m) => m.id === baseModelId)
|| {
id: baseModelId,
name: baseModelId,
description: 'Custom model',
context: 'unknown',
}
return Response.json({ model }) return Response.json({ model })
} }
@ -102,7 +108,21 @@ async function handleCurrentModel(req: Request): Promise<Response> {
if (typeof modelId !== 'string' || !modelId) { if (typeof modelId !== 'string' || !modelId) {
throw ApiError.badRequest('Missing or invalid "modelId" in request body') throw ApiError.badRequest('Missing or invalid "modelId" in request body')
} }
await settingsService.updateUserSettings({ model: modelId })
// Parse composite IDs like 'claude-opus-4-6-20250610:1m'
// Persist the base model ID for CLI compatibility and context tier separately
const colonIdx = modelId.indexOf(':')
const baseId = colonIdx !== -1 ? modelId.slice(0, colonIdx) : modelId
const contextTier = colonIdx !== -1 ? modelId.slice(colonIdx + 1) : undefined
const updates: Record<string, unknown> = { model: baseId }
if (contextTier) {
updates.modelContext = contextTier
} else {
// Clear context tier when switching to a non-composite model
updates.modelContext = undefined
}
await settingsService.updateUserSettings(updates)
return Response.json({ ok: true, model: modelId }) return Response.json({ ok: true, model: modelId })
} }

View File

@ -16,6 +16,14 @@ const PORT = parseInt(process.env.SERVER_PORT || '3456', 10)
const HOST = process.env.SERVER_HOST || '127.0.0.1' const HOST = process.env.SERVER_HOST || '127.0.0.1'
export function startServer(port = PORT, host = HOST) { export function startServer(port = PORT, host = HOST) {
/**
* Auth is required when explicitly opted in or when bound to a non-localhost address.
* - Default localhost dev: no auth needed (tests pass as-is).
* - Production / non-localhost (e.g. 0.0.0.0): auth enforced automatically.
* - Explicit opt-in: SERVER_AUTH_REQUIRED=1 forces auth even on localhost.
*/
const authRequired = process.env.SERVER_AUTH_REQUIRED === '1' || host !== '127.0.0.1'
const server = Bun.serve<WebSocketData>({ const server = Bun.serve<WebSocketData>({
port, port,
hostname: host, hostname: host,
@ -32,6 +40,18 @@ export function startServer(port = PORT, host = HOST) {
// WebSocket upgrade // WebSocket upgrade
if (url.pathname.startsWith('/ws/')) { if (url.pathname.startsWith('/ws/')) {
// Enforce authentication when required
if (authRequired) {
const authError = requireAuth(req)
if (authError) {
const headers = new Headers(authError.headers)
for (const [key, value] of Object.entries(corsHeaders(origin))) {
headers.set(key, value)
}
return new Response(authError.body, { status: authError.status, headers })
}
}
// Validate session ID format // Validate session ID format
const sessionId = url.pathname.split('/').pop() || '' const sessionId = url.pathname.split('/').pop() || ''
if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) { if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) {
@ -49,6 +69,18 @@ export function startServer(port = PORT, host = HOST) {
// REST API // REST API
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
// Enforce authentication when required
if (authRequired) {
const authError = requireAuth(req)
if (authError) {
const headers = new Headers(authError.headers)
for (const [key, value] of Object.entries(corsHeaders(origin))) {
headers.set(key, value)
}
return new Response(authError.body, { status: authError.status, headers })
}
}
try { try {
const response = await handleApiRequest(req, url) const response = await handleApiRequest(req, url)
// Add CORS headers to all responses // Add CORS headers to all responses

View File

@ -274,7 +274,12 @@ export class CronScheduler {
const inputPayload = JSON.stringify({ const inputPayload = JSON.stringify({
type: 'user', type: 'user',
content: task.prompt, message: {
role: 'user',
content: [{ type: 'text', text: task.prompt }],
},
parent_tool_use_id: null,
session_id: '',
}) + '\n' }) + '\n'
const proc = Bun.spawn( const proc = Bun.spawn(
@ -282,6 +287,7 @@ export class CronScheduler {
'bun', 'bun',
cliPath, cliPath,
'--print', '--print',
'--verbose',
'--input-format', '--input-format',
'stream-json', 'stream-json',
'--output-format', '--output-format',

View File

@ -262,11 +262,23 @@ export class SessionService {
return results return results
} }
/**
* Convert a sanitized directory name back to the original absolute path.
* Reverses sanitizePath(): `-Users-nanmi-workspace` `/Users/nanmi/workspace`.
*/
desanitizePath(sanitized: string): string {
// The sanitized form replaces all '/' (and '\') with '-'.
// On POSIX the original path starts with '/', so the sanitized form starts with '-'.
// We restore by replacing every '-' with '/' (the platform separator).
// On Windows the leading character would be a drive letter, but we handle POSIX here.
return sanitized.replace(/-/g, path.sep)
}
/** /**
* Find the .jsonl file for a given session ID. * Find the .jsonl file for a given session ID.
* Searches across all project directories since sessions may belong to any project. * Searches across all project directories since sessions may belong to any project.
*/ */
private async findSessionFile( async findSessionFile(
sessionId: string sessionId: string
): Promise<{ filePath: string; projectDir: string } | null> { ): Promise<{ filePath: string; projectDir: string } | null> {
// Validate sessionId format to prevent path traversal // Validate sessionId format to prevent path traversal

View File

@ -9,6 +9,7 @@
import type { ServerWebSocket } from 'bun' import type { ServerWebSocket } from 'bun'
import type { ClientMessage, ServerMessage, WebSocketSession } from './events.js' import type { ClientMessage, ServerMessage, WebSocketSession } from './events.js'
import { conversationService } from '../services/conversationService.js' import { conversationService } from '../services/conversationService.js'
import { sessionService } from '../services/sessionService.js'
export type WebSocketData = { export type WebSocketData = {
sessionId: string sessionId: string
@ -94,7 +95,16 @@ async function handleUserMessage(
// 启动 CLI 子进程(如果还没有) // 启动 CLI 子进程(如果还没有)
if (!conversationService.hasSession(sessionId)) { if (!conversationService.hasSession(sessionId)) {
try { try {
const workDir = process.cwd() // Resolve the session's actual project directory from persisted data
let workDir = process.cwd() // fallback
try {
const sessionInfo = await sessionService.findSessionFile(sessionId)
if (sessionInfo) {
workDir = sessionService.desanitizePath(sessionInfo.projectDir)
}
} catch {
// fallback to cwd if session file not found
}
await conversationService.startSession(sessionId, workDir) await conversationService.startSession(sessionId, workDir)
// 注册 CLI stdout → WebSocket 转发 // 注册 CLI stdout → WebSocket 转发