diff --git a/apps/server/build.gradle b/apps/server/build.gradle index f074bb31..6cbaa24f 100644 --- a/apps/server/build.gradle +++ b/apps/server/build.gradle @@ -9,6 +9,7 @@ application { } dependencies { + implementation project(':core:config') implementation project(':core:events') implementation project(':core:approvals') implementation project(':core:sessions') diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index d746377e..fb2e937f 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -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, +): 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, ), ) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index eb089017..5ea826a6 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -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, ) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt b/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt index 65cf6bfc..62f9c108 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt @@ -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") } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ProtocolSerializer.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ProtocolSerializer.kt index c033d12b..ec641d42 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ProtocolSerializer.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ProtocolSerializer.kt @@ -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) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/ApprovalRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/ApprovalRoutes.kt index c52a65d3..f3dd35a6 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/ApprovalRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/ApprovalRoutes.kt @@ -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 diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/ProviderRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/ProviderRoutes.kt index 21c1b841..da3bc50e 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/ProviderRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/ProviderRoutes.kt @@ -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 diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt index 45816bd9..a29f30dc 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt @@ -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 -> diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/WorkflowRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/WorkflowRoutes.kt index 37e9ca1c..f89e96bd 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/WorkflowRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/WorkflowRoutes.kt @@ -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 diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index 5f5f338f..f005a2f6 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -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( diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt index b165fd15..7ddfd44a 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt @@ -60,7 +60,7 @@ fun main(args: Array) { state = next effects.forEach { effect -> effectScope.launch { - EffectDispatcher(ws) { runner.quit() }.dispatch(effect) + EffectDispatcher(ws, effectScope) { runner.quit() }.dispatch(effect) } } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt index 9e2f015d..36915eb3 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt @@ -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), diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/Effect.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/Effect.kt index e46daaec..6733fd5e 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/Effect.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/Effect.kt @@ -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 } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/EffectDispatcher.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/EffectDispatcher.kt index 5d132850..fa787820 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/EffectDispatcher.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/EffectDispatcher.kt @@ -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() } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt index eb2dee45..e2d14137 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt @@ -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") diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt index 7ecd999a..b526e7f3 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt @@ -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> = 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> = 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, diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt index 64f35954..20c1f4a7 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt @@ -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(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(frame.readText()) + }.onSuccess { msg -> + _messages.tryEmit(msg) + } + } + } + } + }.also { + sessionWsSession = null + } + } + } + + fun disconnectSession() { + sessionStreamJob?.cancel() + sessionStreamJob = null + sessionWsSession = null + } + fun close() { client.close() } diff --git a/build.gradle b/build.gradle index 6f5d0a50..14e79cc7 100644 --- a/build.gradle +++ b/build.gradle @@ -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*") diff --git a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt index 025e7a4d..5b19e879 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt @@ -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, ) } } diff --git a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt index 6107ce6c..bccdef2f 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt @@ -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 = emptyList(), + val defaultSystemPromptPath: String = "~/.config/correx/prompts/default_system.md", +) diff --git a/core/events/src/main/kotlin/com/correx/core/events/types/EventTypes.kt b/core/events/src/main/kotlin/com/correx/core/events/types/EventTypes.kt deleted file mode 100644 index 4aebddde..00000000 --- a/core/events/src/main/kotlin/com/correx/core/events/types/EventTypes.kt +++ /dev/null @@ -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" -} \ No newline at end of file diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt index d41fda54..f52fa36a 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt @@ -96,7 +96,7 @@ class ReplayOrchestrator( currentStageId = advanceStage(ctx.sessionId, ctx.currentStageId, decision), ), ) - is ReplayStepResult.Terminal -> return result.result + is ReplayStepResult.Terminal -> result.result } } diff --git a/detekt.yml b/detekt.yml index b527ee01..4b712761 100644 --- a/detekt.yml +++ b/detekt.yml @@ -1,3 +1,26 @@ +build: + maxIssues: 999999 + style: - NewLineAtEndOfFile: - active: false \ No newline at end of file + 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 diff --git a/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/artifact/LiveArtifactRepository.kt b/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/artifact/LiveArtifactRepository.kt index 7288a529..6e44fdfa 100644 --- a/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/artifact/LiveArtifactRepository.kt +++ b/infrastructure/persistence/src/main/kotlin/com/correx/infrastructure/persistence/artifact/LiveArtifactRepository.kt @@ -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 diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileEditTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileEditTool.kt index 92f5bc58..5c3dea24 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileEditTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileEditTool.kt @@ -24,9 +24,19 @@ import java.nio.file.StandardOpenOption @Suppress("TooManyFunctions") class FileEditTool( allowedPaths: Set = emptySet(), + private val workingDir: Path? = null, ) : Tool, FileAffectingTool, ToolExecutor { private val normalizedAllowedPaths: Set = 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 { 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) diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt index 4d3d0894..e6fc4538 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileReadTool.kt @@ -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 = emptySet(), ) : Tool, ToolExecutor { - private val normalizedAllowedPaths: Set = 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}") diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt index 6ff23b91..7428907a 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt @@ -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 = 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 = setOf(ToolCapability.FILE_WRITE) override fun affectedPaths(request: ToolRequest): Set { 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) } diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt index 5d89f221..58e24451 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt @@ -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 diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/ToolConfig.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/ToolConfig.kt index d056f960..9e79d14f 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/ToolConfig.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/ToolConfig.kt @@ -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 = emptySet()) -data class ShellConfig(val enabled: Boolean = false, val allowedExecutables: Set = emptySet()) +data class FileEditConfig( + val enabled: Boolean = false, + val allowedPaths: Set = emptySet(), + val workingDir: Path? = null, +) +data class ShellConfig(val enabled: Boolean = false, val allowedExecutables: Set = 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 = 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 = 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, )) } } diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt index 66f322c3..c08ca07d 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/shell/ShellTool.kt @@ -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 = 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.") - 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() val process = ProcessBuilder(argv).apply { - environment().clear() + workingDir?.let { directory(it.toFile()) } }.start() runCatching { runCmd(request, process)