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
@@ -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()
}