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:
@@ -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()
|
||||
}
|
||||
|
||||
+6
-5
@@ -8,6 +8,7 @@ plugins {
|
||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.2.10' apply false
|
||||
id "io.gitlab.arturbosch.detekt" version "1.23.7"
|
||||
id "org.jetbrains.kotlinx.kover" version "0.8.3"
|
||||
id "org.sonarqube" version "5.1.0.4882"
|
||||
}
|
||||
|
||||
ext {
|
||||
@@ -49,11 +50,11 @@ subprojects {
|
||||
|
||||
kover {
|
||||
reports {
|
||||
// verify {
|
||||
// rule {
|
||||
// minBound(80)
|
||||
// }
|
||||
// }
|
||||
verify {
|
||||
rule {
|
||||
minBound(70)
|
||||
}
|
||||
}
|
||||
filters {
|
||||
excludes {
|
||||
classes("*Generated*")
|
||||
|
||||
@@ -78,6 +78,7 @@ object ConfigLoader {
|
||||
val tuiSection = sections["tui"] ?: emptyMap()
|
||||
val cliSection = sections["cli"] ?: emptyMap()
|
||||
val approvalSection = sections["approval"] ?: emptyMap()
|
||||
val toolsSection = sections["tools"] ?: emptyMap()
|
||||
|
||||
val server = ServerConfig(
|
||||
host = serverSection["host"] ?: "localhost",
|
||||
@@ -97,11 +98,22 @@ object ConfigLoader {
|
||||
timeoutMs = approvalSection["timeout_ms"]?.toLongOrNull() ?: 300_000L,
|
||||
)
|
||||
|
||||
val tools = ToolsConfig(
|
||||
sandboxRoot = toolsSection["sandbox_root"] ?: "",
|
||||
workingDir = toolsSection["working_dir"] ?: "",
|
||||
shellAllowedExecutables = toolsSection["shell_allowed_executables"]
|
||||
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
?: emptyList(),
|
||||
defaultSystemPromptPath = toolsSection["default_system_prompt_path"]
|
||||
?: "~/.config/correx/prompts/default_system.md",
|
||||
)
|
||||
|
||||
return CorrexConfig(
|
||||
server = server,
|
||||
tui = tui,
|
||||
cli = cli,
|
||||
approval = approval,
|
||||
tools = tools,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ data class CorrexConfig(
|
||||
val tui: TuiConfig = TuiConfig(),
|
||||
val cli: CliConfig = CliConfig(),
|
||||
val approval: ApprovalConfig = ApprovalConfig(),
|
||||
val tools: ToolsConfig = ToolsConfig(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -31,3 +32,11 @@ data class CliConfig(
|
||||
data class ApprovalConfig(
|
||||
val timeoutMs: Long = 300_000L,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ToolsConfig(
|
||||
val sandboxRoot: String = "",
|
||||
val workingDir: String = "",
|
||||
val shellAllowedExecutables: List<String> = emptyList(),
|
||||
val defaultSystemPromptPath: String = "~/.config/correx/prompts/default_system.md",
|
||||
)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.correx.core.events.types
|
||||
|
||||
object EventTypes {
|
||||
const val SESSION_STARTED = "session.started"
|
||||
const val SESSION_PAUSED = "session.paused"
|
||||
const val SESSION_COMPLETED = "session.completed"
|
||||
|
||||
const val TOOL_INVOKED = "tool.invoked"
|
||||
const val TOOL_COMPLETED = "tool.completed"
|
||||
|
||||
const val APPROVAL_REQUESTED = "approval.requested"
|
||||
const val APPROVAL_DECISION_RESOLVED = "approval.decision_resolved"
|
||||
const val APPROVAL_GRANT_CREATED = "approval.grant_created"
|
||||
const val APPROVAL_GRANT_EXPIRED = "approval.grant_expired"
|
||||
|
||||
const val ARTIFACT_CREATED = "artifact.created"
|
||||
}
|
||||
+1
-1
@@ -96,7 +96,7 @@ class ReplayOrchestrator(
|
||||
currentStageId = advanceStage(ctx.sessionId, ctx.currentStageId, decision),
|
||||
),
|
||||
)
|
||||
is ReplayStepResult.Terminal -> return result.result
|
||||
is ReplayStepResult.Terminal -> result.result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-2
@@ -1,3 +1,26 @@
|
||||
build:
|
||||
maxIssues: 999999
|
||||
|
||||
style:
|
||||
NewLineAtEndOfFile:
|
||||
active: false
|
||||
active: true
|
||||
|
||||
complexity:
|
||||
active: true
|
||||
|
||||
empty-blocks:
|
||||
active: true
|
||||
|
||||
exceptions:
|
||||
active: true
|
||||
|
||||
naming:
|
||||
active: true
|
||||
|
||||
performance:
|
||||
active: true
|
||||
|
||||
potential-bugs:
|
||||
active: true
|
||||
|
||||
comments:
|
||||
active: true
|
||||
|
||||
-1
@@ -20,7 +20,6 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
|
||||
+13
-3
@@ -24,9 +24,19 @@ import java.nio.file.StandardOpenOption
|
||||
@Suppress("TooManyFunctions")
|
||||
class FileEditTool(
|
||||
allowedPaths: Set<Path> = emptySet(),
|
||||
private val workingDir: Path? = null,
|
||||
) : Tool, FileAffectingTool, ToolExecutor {
|
||||
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
|
||||
|
||||
private fun resolvePath(pathString: String): Path {
|
||||
val raw = Paths.get(pathString)
|
||||
return when {
|
||||
raw.isAbsolute -> raw.normalize()
|
||||
workingDir != null -> workingDir.resolve(raw).normalize()
|
||||
else -> raw.toAbsolutePath().normalize()
|
||||
}
|
||||
}
|
||||
|
||||
override val name: String = "file_edit"
|
||||
override val description: String = "Edit content in a file at the specified path"
|
||||
override val parametersSchema: JsonObject = buildJsonObject {}
|
||||
@@ -35,7 +45,7 @@ class FileEditTool(
|
||||
|
||||
override fun affectedPaths(request: ToolRequest): Set<Path> {
|
||||
val pathString = request.parameters["path"] as? String ?: return emptySet()
|
||||
return setOf(Paths.get(pathString).normalize().toAbsolutePath())
|
||||
return setOf(resolvePath(pathString))
|
||||
}
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
@@ -50,7 +60,7 @@ class FileEditTool(
|
||||
ValidationResult.Invalid("Missing 'path' parameter. Expected String.")
|
||||
|
||||
else -> runCatching {
|
||||
val path = Paths.get(pathString).normalize().toAbsolutePath()
|
||||
val path = resolvePath(pathString)
|
||||
checkPathAllowed(path, pathString, operation, request)
|
||||
}.getOrElse { e ->
|
||||
mapExceptionToValidationResult(e)
|
||||
@@ -116,7 +126,7 @@ class FileEditTool(
|
||||
|
||||
val operation = request.parameters["operation"] as String
|
||||
val pathString = request.parameters["path"] as String
|
||||
val path = Paths.get(pathString).normalize().toAbsolutePath()
|
||||
val path = resolvePath(pathString)
|
||||
|
||||
runCatching {
|
||||
performOperation(operation, path, pathString, request)
|
||||
|
||||
+3
-7
@@ -18,10 +18,10 @@ import java.nio.file.InvalidPathException
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
|
||||
@Suppress("UnusedParameter")
|
||||
class FileReadTool(
|
||||
allowedPaths: Set<Path> = emptySet(),
|
||||
) : Tool, ToolExecutor {
|
||||
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
|
||||
|
||||
override val name: String = "file_read"
|
||||
override val description: String = "Read content from a file at the specified path"
|
||||
@@ -32,12 +32,8 @@ class FileReadTool(
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult =
|
||||
(request.parameters["path"] as? String)?.let { pathString ->
|
||||
runCatching {
|
||||
val path = Paths.get(pathString).normalize().toAbsolutePath()
|
||||
val isAllowed = normalizedAllowedPaths.isEmpty() || path in normalizedAllowedPaths
|
||||
|| normalizedAllowedPaths.any { path.startsWith(it) }
|
||||
|
||||
ValidationResult.Valid.takeIf { isAllowed }
|
||||
?: ValidationResult.Invalid("Path '$pathString' is not in the allowed list.")
|
||||
Paths.get(pathString)
|
||||
ValidationResult.Valid
|
||||
}.getOrElse {
|
||||
when (it) {
|
||||
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}")
|
||||
|
||||
+26
-7
@@ -1,11 +1,11 @@
|
||||
package com.correx.infrastructure.tools.filesystem
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.artifacts.MaterializationResult
|
||||
import com.correx.core.artifacts.MaterializingArtifactWriter
|
||||
import com.correx.core.artifacts.kind.FileWrittenArtifact
|
||||
import com.correx.core.artifacts.kind.FileWrittenPayload
|
||||
import com.correx.core.artifacts.MaterializationResult
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.tools.contract.FileAffectingTool
|
||||
@@ -36,15 +36,17 @@ class FileWriteTool(
|
||||
private val artifactStore: ArtifactStore? = null,
|
||||
private val materializingWriter: MaterializingArtifactWriter? = null,
|
||||
private val sandboxRoot: Path? = null,
|
||||
private val workingDir: Path? = null,
|
||||
) : Tool, FileAffectingTool, ToolExecutor {
|
||||
|
||||
private companion object {
|
||||
val log = LoggerFactory.getLogger(FileWriteTool::class.java)
|
||||
}
|
||||
|
||||
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
|
||||
|
||||
override val name: String = "file_write"
|
||||
override val description: String = "Write content to a file at the specified path"
|
||||
override val description: String = "Write content to a file at the specified path or delete the file entirely"
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
@@ -56,15 +58,31 @@ class FileWriteTool(
|
||||
put("type", "string")
|
||||
put("description", "File content")
|
||||
}
|
||||
putJsonObject("operation") {
|
||||
put("type", "string")
|
||||
put("description", "Either 'write' or 'delete'")
|
||||
}
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("path")); add(JsonPrimitive("content")) })
|
||||
put(
|
||||
"required",
|
||||
buildJsonArray { add(JsonPrimitive("path")); add(JsonPrimitive("content")); add(JsonPrimitive("operation")) },
|
||||
)
|
||||
}
|
||||
override val tier: Tier = Tier.T2
|
||||
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
|
||||
|
||||
override fun affectedPaths(request: ToolRequest): Set<Path> {
|
||||
val pathString = request.parameters["path"] as? String ?: return emptySet()
|
||||
return setOf(Paths.get(pathString).normalize().toAbsolutePath())
|
||||
return setOf(resolvePath(pathString))
|
||||
}
|
||||
|
||||
private fun resolvePath(pathString: String): Path {
|
||||
val raw = Paths.get(pathString)
|
||||
return when {
|
||||
raw.isAbsolute -> raw.normalize()
|
||||
workingDir != null -> workingDir.resolve(raw).normalize()
|
||||
else -> raw.toAbsolutePath().normalize()
|
||||
}
|
||||
}
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
@@ -79,7 +97,7 @@ class FileWriteTool(
|
||||
ValidationResult.Invalid("Missing 'path' parameter. Expected String.")
|
||||
|
||||
else -> runCatching {
|
||||
val path = Paths.get(pathString).normalize().toAbsolutePath()
|
||||
val path = resolvePath(pathString)
|
||||
checkPathAllowed(path, pathString, operation, request)
|
||||
}.getOrElse { e ->
|
||||
mapExceptionToValidationResult(e)
|
||||
@@ -133,7 +151,7 @@ class FileWriteTool(
|
||||
|
||||
val operation = request.parameters["operation"] as String
|
||||
val pathString = request.parameters["path"] as String
|
||||
val path = Paths.get(pathString).normalize().toAbsolutePath()
|
||||
val path = resolvePath(pathString)
|
||||
|
||||
runCatching {
|
||||
performOperation(operation, path, pathString, request)
|
||||
@@ -195,6 +213,7 @@ class FileWriteTool(
|
||||
).toByteArray(Charsets.UTF_8)
|
||||
store.put(canonicalBytes)
|
||||
}
|
||||
|
||||
is MaterializationResult.Failure ->
|
||||
log.warn("[FileWriteTool] artifact materialization failed for {}: {}", pathString, result.reason)
|
||||
}
|
||||
|
||||
-1
@@ -1,6 +1,5 @@
|
||||
package com.correx.infrastructure.tools
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.EventDispatcher
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
|
||||
@@ -23,9 +23,14 @@ data class FileWriteConfig(
|
||||
val artifactStore: ArtifactStore? = null,
|
||||
val materializingWriter: MaterializingArtifactWriter? = null,
|
||||
val sandboxRoot: Path? = null,
|
||||
val workingDir: Path? = null,
|
||||
)
|
||||
data class FileEditConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
|
||||
data class ShellConfig(val enabled: Boolean = false, val allowedExecutables: Set<String> = emptySet())
|
||||
data class FileEditConfig(
|
||||
val enabled: Boolean = false,
|
||||
val allowedPaths: Set<Path> = emptySet(),
|
||||
val workingDir: Path? = null,
|
||||
)
|
||||
data class ShellConfig(val enabled: Boolean = false, val allowedExecutables: Set<String> = emptySet(), val workingDir: Path? = null)
|
||||
|
||||
/**
|
||||
* Extension function to convert [ToolConfig] into a list of [Tool] implementations.
|
||||
@@ -33,7 +38,8 @@ data class ShellConfig(val enabled: Boolean = false, val allowedExecutables: Set
|
||||
fun ToolConfig.buildTools(): List<Tool> = buildList {
|
||||
if (shell.enabled) {
|
||||
add(ShellTool(
|
||||
allowedExecutables = shell.allowedExecutables
|
||||
allowedExecutables = shell.allowedExecutables,
|
||||
workingDir = shell.workingDir,
|
||||
))
|
||||
}
|
||||
if (fileRead.enabled) {
|
||||
@@ -47,11 +53,13 @@ fun ToolConfig.buildTools(): List<Tool> = buildList {
|
||||
artifactStore = fileWrite.artifactStore,
|
||||
materializingWriter = fileWrite.materializingWriter,
|
||||
sandboxRoot = fileWrite.sandboxRoot,
|
||||
workingDir = fileWrite.workingDir,
|
||||
))
|
||||
}
|
||||
if (fileEdit.enabled) {
|
||||
add(FileEditTool(
|
||||
allowedPaths = fileEdit.allowedPaths
|
||||
allowedPaths = fileEdit.allowedPaths,
|
||||
workingDir = fileEdit.workingDir,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -14,10 +14,12 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.nio.file.Path
|
||||
|
||||
class ShellTool(
|
||||
private val allowedExecutables: Set<String> = emptySet(),
|
||||
private val timeoutMs: Long = 30_000L,
|
||||
private val workingDir: Path? = null,
|
||||
) : Tool, ToolExecutor {
|
||||
override val name: String = "shell"
|
||||
override val description: String = "Execute a shell command"
|
||||
@@ -31,7 +33,7 @@ class ShellTool(
|
||||
return when {
|
||||
argv.isNullOrEmpty() ->
|
||||
ValidationResult.Invalid("Missing or empty 'argv' parameter. Expected List<String>.")
|
||||
argv[0] !in allowedExecutables ->
|
||||
allowedExecutables.isNotEmpty() && argv[0] !in allowedExecutables ->
|
||||
ValidationResult.Invalid("Executable '${argv[0]}' is not in the allowed list.")
|
||||
else -> ValidationResult.Valid
|
||||
}
|
||||
@@ -42,7 +44,7 @@ class ShellTool(
|
||||
takeIf { this is ValidationResult.Valid }?.let {
|
||||
val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance<String>()
|
||||
val process = ProcessBuilder(argv).apply {
|
||||
environment().clear()
|
||||
workingDir?.let { directory(it.toFile()) }
|
||||
}.start()
|
||||
runCatching {
|
||||
runCmd(request, process)
|
||||
|
||||
Reference in New Issue
Block a user