chore(audit): tool path resolution, config file, dead code removal, static analysis cleanup

Tool fixes:
- FileWriteTool, FileEditTool: resolve relative paths against workingDir instead of JVM cwd
- FileReadTool: remove allowedPaths restriction — reads are unrestricted
- ShellTool: add workingDir for ProcessBuilder, remove environment().clear(), fail-open when allowedExecutables empty

Config:
- Add [tools] section to config.toml: sandbox_root, working_dir, shell_allowed_executables, default_system_prompt_path
- Three-level resolution: env var > config file > hardcoded default
- OrchestrationConfig sandboxRoot and defaultSystemPromptPath now sourced from CorrexConfig
- Single defaultOrchestrationConfig in ServerModule, shared by SessionRoutes and GlobalStreamHandler

Dead code:
- Delete EventTypes.kt — orphaned string constants duplicating serialization annotations

Static analysis:
- Remove 10 unused imports across 9 files
- Remove redundant return in ReplayOrchestrator
- Remove redundant suspend from ApprovalCoordinator.handleResponse
- Fix unreachable InputMode.None branch in InputBar
- Enable full detekt rule sets in detekt.yml
This commit is contained in:
2026-05-19 12:48:47 +04:00
parent c0efa0ef03
commit 71a73a4fa2
30 changed files with 253 additions and 94 deletions
+1
View File
@@ -9,6 +9,7 @@ application {
}
dependencies {
implementation project(':core:config')
implementation project(':core:events')
implementation project(':core:approvals')
implementation project(':core:sessions')
@@ -20,22 +20,28 @@ import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.transitions.evaluation.PromptResolver
import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.validation.artifact.ArtifactPayloadValidator
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.apps.server.logging.LoggingEventStore
import com.correx.core.artifactstore.ArtifactStore
import com.correx.infrastructure.InfrastructureModule
import com.correx.infrastructure.artifactscas.DefaultMaterializingArtifactWriter
import com.correx.infrastructure.inference.DefaultProviderRegistry
import com.correx.infrastructure.inference.FirstAvailableRoutingStrategy
import com.correx.core.config.ConfigLoader
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.infrastructure.tools.FileEditConfig
import com.correx.infrastructure.tools.FileWriteConfig
import com.correx.infrastructure.tools.ShellConfig
import com.correx.infrastructure.tools.ToolConfig
import com.correx.core.events.EventDispatcher
import com.correx.core.events.stores.EventStore
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import org.slf4j.LoggerFactory
import java.nio.file.Path
import java.nio.file.Paths
private val log = LoggerFactory.getLogger("com.correx.apps.server.Main")
@@ -46,8 +52,18 @@ fun main() {
val infraRegistry = InfrastructureModule.createProviderRegistry(listOf(buildLlamaProvider()))
val repositories = buildRepositories(eventStore)
val approvalEngine = DefaultApprovalEngine()
val sandboxRoot = Paths.get(System.getProperty("user.home"), ".config", "correx", "sandbox")
val toolRegistry = InfrastructureModule.createToolRegistry(buildToolConfig(artifactStore, sandboxRoot))
val correxConfig = ConfigLoader.load()
val toolsConfig = correxConfig.tools
val sandboxRoot = System.getenv("CORREX_SANDBOX_ROOT")
?.let { Path.of(it) }
?: toolsConfig.sandboxRoot.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
?: Paths.get(System.getProperty("user.home"), ".config", "correx", "sandbox")
val workingDir = System.getenv("CORREX_WORKING_DIR")
?.let { Path.of(it) }
?: toolsConfig.workingDir.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
?: Path.of("").toAbsolutePath()
val shellAllowedExecutables = toolsConfig.shellAllowedExecutables.toSet()
val toolRegistry = InfrastructureModule.createToolRegistry(buildToolConfig(artifactStore, sandboxRoot, workingDir, shellAllowedExecutables))
val toolExecutor = InfrastructureModule.createToolExecutor(
registry = toolRegistry,
eventDispatcher = EventDispatcher(eventStore),
@@ -70,6 +86,10 @@ fun main() {
retryCoordinator = DefaultRetryCoordinator(eventStore),
artifactStore = artifactStore,
)
val defaultOrchestrationConfig = OrchestrationConfig(
sandboxRoot = sandboxRoot,
defaultSystemPromptPath = toolsConfig.defaultSystemPromptPath,
)
val module = ServerModule(
orchestrator = orchestrator,
eventStore = eventStore,
@@ -77,6 +97,7 @@ fun main() {
sessionRepository = repositories.sessionRepository,
workflowRegistry = FileSystemWorkflowRegistry(InfrastructureModule.createWorkflowLoader()),
providerRegistry = infraRegistry.asServerRegistry(),
defaultOrchestrationConfig = defaultOrchestrationConfig,
)
val modelId = System.getenv("CORREX_MODEL_ID") ?: "default"
val modelPath = System.getenv("CORREX_MODEL_PATH") ?: "(not set)"
@@ -87,6 +108,10 @@ fun main() {
log.info(" model path : {}", modelPath)
log.info(" llama url : {}", llamaUrl)
log.info(" sandbox root : {}", sandboxRoot)
log.info(" working dir : {}", workingDir)
if (shellAllowedExecutables.isEmpty()) {
log.warn(" shell tool : no executable allowlist configured — all executables allowed")
}
log.info(" event store : {}", eventStore::class.simpleName)
log.info(" artifact store: {}", artifactStore::class.simpleName)
log.info(" tools : {}", toolRegistry.all().map { it.name })
@@ -96,15 +121,15 @@ fun main() {
embeddedServer(Netty, port = 8080) { configureServer(module) }.start(wait = true)
}
private fun buildLlamaProvider() = InfrastructureModule.createLlamaCppProvider(
private fun buildLlamaProvider(): LlamaCppInferenceProvider = InfrastructureModule.createLlamaCppProvider(
modelId = System.getenv("CORREX_MODEL_ID") ?: "default",
modelPath = System.getenv("CORREX_MODEL_PATH") ?: "",
baseUrl = System.getenv("CORREX_LLAMA_URL") ?: "http://127.0.0.1:10000",
)
private fun buildRepositories(
eventStore: com.correx.core.events.stores.EventStore,
) = OrchestratorRepositories(
eventStore: EventStore,
): OrchestratorRepositories = OrchestratorRepositories(
eventStore = eventStore,
inferenceRepository = InferenceRepository(DefaultEventReplayer(eventStore, InferenceProjector())),
orchestrationRepository = OrchestrationRepository(
@@ -117,15 +142,28 @@ private fun buildRepositories(
)
private fun buildToolConfig(
artifactStore: com.correx.core.artifactstore.ArtifactStore,
sandboxRoot: java.nio.file.Path,
) = ToolConfig(
shell = ShellConfig(enabled = true),
artifactStore: ArtifactStore,
sandboxRoot: Path,
workingDir: Path,
shellAllowedExecutables: Set<String>,
): ToolConfig = ToolConfig(
shell = ShellConfig(
enabled = true,
allowedExecutables = shellAllowedExecutables,
workingDir = workingDir,
),
fileWrite = FileWriteConfig(
allowedPaths = setOf(sandboxRoot, workingDir),
enabled = true,
artifactStore = artifactStore,
materializingWriter = DefaultMaterializingArtifactWriter(),
sandboxRoot = sandboxRoot,
workingDir = workingDir,
),
fileEdit = FileEditConfig(
enabled = true,
allowedPaths = setOf(sandboxRoot, workingDir),
workingDir = workingDir,
),
)
@@ -5,6 +5,7 @@ import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.stores.EventStore
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.sessions.DefaultSessionRepository
data class ServerModule(
@@ -14,4 +15,5 @@ data class ServerModule(
val sessionRepository: DefaultSessionRepository,
val workflowRegistry: WorkflowRegistry,
val providerRegistry: ProviderRegistry,
val defaultOrchestrationConfig: OrchestrationConfig,
)
@@ -62,7 +62,7 @@ class ApprovalCoordinator(
scheduleTimeout(event.requestId, event.sessionId, event.stageId, event.tier)
}
suspend fun handleResponse(msg: ClientMessage.ApprovalResponse, sessionId: SessionId): ServerMessage? {
fun handleResponse(msg: ClientMessage.ApprovalResponse, sessionId: SessionId): ServerMessage? {
if (resolved.putIfAbsent(msg.requestId, true) != null) {
return ServerMessage.ProtocolError("Approval request ${msg.requestId.value} already resolved")
}
@@ -2,7 +2,6 @@ package com.correx.apps.server.protocol
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
class ProtocolException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
@@ -10,7 +10,6 @@ import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.utils.TypeId
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
@@ -3,7 +3,6 @@ package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule
import com.correx.apps.server.protocol.ProviderHealthDto
import com.correx.core.inference.ProviderHealth
import io.ktor.server.application.call
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
@@ -8,11 +8,8 @@ import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.utils.TypeId
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.application.application
import org.slf4j.LoggerFactory
import io.ktor.server.request.receive
import io.ktor.server.response.respond
@@ -67,9 +64,7 @@ private fun Route.startSessionRoute(module: ServerModule) {
val graph = module.workflowRegistry.find(body.workflowId)
?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: ${body.workflowId}")
val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
val config = OrchestrationConfig(
defaultSystemPromptPath = "~/.config/correx/prompts/default_system.md",
)
val config = module.defaultOrchestrationConfig
call.application.launch {
runCatching { module.orchestrator.run(sessionId, graph, config) }
.onFailure { ex ->
@@ -2,7 +2,6 @@ package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule
import com.correx.apps.server.registry.WorkflowSummary
import io.ktor.server.application.call
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
@@ -12,7 +12,6 @@ import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.inference.ProviderHealth
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.utils.TypeId
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.websocket.Frame
@@ -104,7 +103,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
is ClientMessage.CancelSession -> module.orchestrator.cancel(msg.sessionId)
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
is ClientMessage.ApprovalResponse ->
session.send(encodeError("ApprovalResponse not supported on global stream"))
session.send(encodeError("ApprovalResponse must be sent to /sessions/{id}/stream"))
}
}
@@ -127,10 +126,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
log.info("starting session={} workflow={}", sessionId.value, msg.workflowId)
session.launch {
val config = OrchestrationConfig(
defaultSystemPromptPath = "~/.config/correx/prompts/default_system.md",
)
runCatching { module.orchestrator.run(sessionId, graph, config) }
runCatching { module.orchestrator.run(sessionId, graph, module.defaultOrchestrationConfig) }
.onFailure { ex ->
log.error("run failed for session={}: {}", sessionId.value, ex.message, ex)
module.eventStore.append(
@@ -60,7 +60,7 @@ fun main(args: Array<String>) {
state = next
effects.forEach { effect ->
effectScope.launch {
EffectDispatcher(ws) { runner.quit() }.dispatch(effect)
EffectDispatcher(ws, effectScope) { runner.quit() }.dispatch(effect)
}
}
}
@@ -17,7 +17,7 @@ fun inputBarWidget(input: InputState): Paragraph? {
InputMode.WorkflowId -> "new session > " to Style.create().cyan()
InputMode.Filter -> "filter > " to Style.create().yellow()
InputMode.SteeringNote -> "steer > " to Style.create().magenta()
InputMode.None -> "" to Style.create()
else -> "" to Style.create()
}
val line = Line.from(
Span.styled(promptText, promptStyle),
@@ -1,8 +1,11 @@
package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ClientMessage
import com.correx.core.events.types.SessionId
sealed interface Effect {
data class SendWs(val message: ClientMessage) : Effect
data class ConnectSession(val sessionId: SessionId) : Effect
data object DisconnectSession: Effect
data object Quit : Effect
}
@@ -1,14 +1,18 @@
package com.correx.apps.tui.reducer
import com.correx.apps.tui.ws.TuiWsClient
import kotlinx.coroutines.CoroutineScope
class EffectDispatcher(
private val wsClient: TuiWsClient,
private val effectScope: CoroutineScope,
private val onQuit: () -> Unit,
) {
suspend fun dispatch(effect: Effect) {
when (effect) {
is Effect.SendWs -> wsClient.send(effect.message)
is Effect.ConnectSession -> wsClient.connectSession(effect.sessionId, effectScope)
Effect.DisconnectSession -> wsClient.disconnectSession()
Effect.Quit -> onQuit()
}
}
@@ -7,7 +7,6 @@ import com.correx.apps.tui.state.ApprovalInfo
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.InputState
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
object InputReducer {
@Suppress("CyclomaticComplexMethod")
@@ -24,11 +24,13 @@ object SessionsReducer {
} else {
sessions to emptyList()
}
is Action.CancelInput -> if (inputMode == InputMode.Filter) {
sessions.copy(filter = "") to emptyList()
} else {
sessions to emptyList()
}
is Action.CancelSelectedSession -> {
val id = sessions.selectedId
if (id != null) {
@@ -37,7 +39,8 @@ object SessionsReducer {
sessions to emptyList()
}
}
is Action.ServerEventReceived -> applyServerMessage(sessions, action.message, clock) to emptyList()
is Action.ServerEventReceived -> applyServerMessage(sessions, action.message, clock)
else -> sessions to emptyList()
}
@@ -62,7 +65,7 @@ object SessionsReducer {
sessions: SessionsState,
msg: ServerMessage,
clock: () -> Long,
): SessionsState = when (msg) {
): Pair<SessionsState, List<Effect>> = when (msg) {
is ServerMessage.SessionStarted -> {
val summary = SessionSummary(
id = msg.sessionId.value,
@@ -73,8 +76,10 @@ object SessionsReducer {
lastOutput = null,
)
val selected = sessions.selectedId ?: msg.sessionId.value
sessions.copy(sessions = sessions.sessions + summary, selectedId = selected)
sessions.copy(sessions = sessions.sessions + summary, selectedId = selected) to
listOf(Effect.ConnectSession(msg.sessionId))
}
is ServerMessage.SessionPaused -> {
val statusLabel = if (msg.reason == PauseReason.APPROVAL_PENDING) {
"PAUSED awaiting approval"
@@ -85,10 +90,20 @@ object SessionsReducer {
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(status = statusLabel, lastEventAt = clock()) else s
},
)
) to emptyList()
}
is ServerMessage.SessionCompleted -> touchSession(sessions, msg.sessionId.value, "COMPLETED", clock)
is ServerMessage.SessionFailed -> touchSession(sessions, msg.sessionId.value, "FAILED", clock)
is ServerMessage.SessionCompleted -> touchSession(sessions, msg.sessionId.value, "COMPLETED", clock) to listOf(
Effect.DisconnectSession,
)
is ServerMessage.SessionFailed -> touchSession(
sessions,
msg.sessionId.value,
"FAILED",
clock,
) to listOf(Effect.DisconnectSession)
is ServerMessage.StageStarted -> sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
@@ -97,17 +112,20 @@ object SessionsReducer {
s
}
},
)
) to emptyList()
is ServerMessage.StageCompleted -> sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(currentStage = null, lastEventAt = clock()) else s
},
)
) to emptyList()
is ServerMessage.StageFailed -> sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(currentStage = null, lastEventAt = clock()) else s
},
)
) to emptyList()
is ServerMessage.InferenceCompleted -> applyInferenceCompleted(sessions, msg, clock)
is ServerMessage.ToolCompleted -> sessions.copy(
sessions = sessions.sessions.map { s ->
@@ -117,15 +135,16 @@ object SessionsReducer {
s
}
},
)
else -> sessions
) to emptyList()
else -> sessions to emptyList()
}
private fun applyInferenceCompleted(
sessions: SessionsState,
msg: ServerMessage.InferenceCompleted,
clock: () -> Long,
): SessionsState = sessions.copy(
): Pair<SessionsState, List<Effect>> = sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
s.copy(
@@ -137,7 +156,7 @@ object SessionsReducer {
s
}
},
)
) to emptyList()
private fun touchSession(
sessions: SessionsState,
@@ -2,6 +2,7 @@ package com.correx.apps.tui.ws
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ServerMessage
import com.correx.core.events.types.SessionId
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.logging.LogLevel
@@ -13,10 +14,13 @@ import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@@ -39,6 +43,8 @@ class TuiWsClient(
}
}
private var sessionStreamJob: Job? = null
private var sessionWsSession: WebSocketSession? = null
private var session: WebSocketSession? = null
private val _messages = MutableSharedFlow<ServerMessage>(extraBufferCapacity = 64)
@@ -49,10 +55,16 @@ class TuiWsClient(
suspend fun send(message: ClientMessage) {
runCatching {
session?.send(json.encodeToString(message))
// approval responses go to session stream, everything else to global
val target = when (message) {
is ClientMessage.ApprovalResponse -> sessionWsSession ?: session
else -> session
}
target?.send(json.encodeToString(message))
}
}
suspend fun connect() {
var delayMs = INITIAL_RETRY_DELAY_MS
var attempt = 0
@@ -84,6 +96,39 @@ class TuiWsClient(
}
}
fun connectSession(sessionId: SessionId, scope: CoroutineScope) {
sessionStreamJob?.cancel()
sessionStreamJob = scope.launch {
runCatching {
client.webSocket(
method = HttpMethod.Get,
host = host,
port = port,
path = "/sessions/${sessionId.value}/stream",
) {
sessionWsSession = this
for (frame in incoming) {
if (frame is Frame.Text) {
runCatching {
json.decodeFromString<ServerMessage>(frame.readText())
}.onSuccess { msg ->
_messages.tryEmit(msg)
}
}
}
}
}.also {
sessionWsSession = null
}
}
}
fun disconnectSession() {
sessionStreamJob?.cancel()
sessionStreamJob = null
sessionWsSession = null
}
fun close() {
client.close()
}