epic-12: after epic audit and init commit
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
||||
package com.correx.infrastructure.tools
|
||||
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
|
||||
class DefaultToolRegistry private constructor(
|
||||
private val tools: Map<String, Tool>,
|
||||
) : ToolRegistry {
|
||||
override fun resolve(name: String): Tool? = tools[name]
|
||||
override fun all(): List<Tool> = tools.values.toList()
|
||||
|
||||
companion object {
|
||||
fun build(vararg tools: Tool): ToolRegistry =
|
||||
DefaultToolRegistry(tools.associateBy { it.name })
|
||||
|
||||
fun build(tools: List<Tool>): ToolRegistry =
|
||||
DefaultToolRegistry(tools.associateBy { it.name })
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.correx.infrastructure.tools
|
||||
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
|
||||
class DispatchingToolExecutor(
|
||||
private val registry: ToolRegistry
|
||||
) : ToolExecutor {
|
||||
override suspend fun execute(request: ToolRequest): ToolResult {
|
||||
val tool = registry.resolve(request.toolName)
|
||||
?: return ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = "tool not found: ${request.toolName}",
|
||||
recoverable = false
|
||||
)
|
||||
|
||||
return (tool as? ToolExecutor)?.execute(request)
|
||||
?: ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = "tool ${request.toolName} does not support execution",
|
||||
recoverable = false
|
||||
)
|
||||
}
|
||||
}
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
package com.correx.infrastructure.tools
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.approvals.domain.ApprovalEngine
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalGrant
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.approvals.model.DomainApprovalRequest
|
||||
import com.correx.core.events.EventDispatcher
|
||||
import com.correx.core.events.events.ApprovalGrantCreatedEvent
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||
import com.correx.core.events.events.ToolExecutionStartedEvent
|
||||
import com.correx.core.events.events.ToolReceipt
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.events.types.ValidationReportId
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.core.tools.contract.FileAffectingTool
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.Clock
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.*
|
||||
|
||||
@SuppressWarnings("TooManyFunctions", "LongParameterList")
|
||||
class SandboxedToolExecutor(
|
||||
private val delegate: ToolExecutor,
|
||||
private val registry: ToolRegistry,
|
||||
private val approvalEngine: ApprovalEngine,
|
||||
private val eventStore: EventStore,
|
||||
private val eventDispatcher: EventDispatcher,
|
||||
private val workDir: Path = Path.of("/tmp/correx-sandbox"),
|
||||
) : ToolExecutor {
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
|
||||
val sessionId = request.sessionId
|
||||
val invocationId = request.invocationId
|
||||
val toolName = request.toolName
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
// 1. resolve tool
|
||||
val tool = registry.resolve(toolName)
|
||||
?: return@withContext ToolResult.Failure(
|
||||
invocationId = invocationId,
|
||||
reason = "tool not found: $toolName",
|
||||
recoverable = false,
|
||||
)
|
||||
|
||||
// 2. approval check for T2-T4
|
||||
val requiresApproval = when (tool.tier) {
|
||||
Tier.T0, Tier.T1 -> false
|
||||
Tier.T2, Tier.T3, Tier.T4 -> true
|
||||
}
|
||||
|
||||
if (requiresApproval) {
|
||||
val sessionEvents = eventStore.read(sessionId)
|
||||
val decision = evaluateApproval(tool, sessionId, request.stageId, sessionEvents)
|
||||
if (!decision.isApproved) {
|
||||
emitRejected(sessionId, invocationId, toolName, tool.tier, decision.reason ?: "approval denied")
|
||||
return@withContext ToolResult.Failure(
|
||||
invocationId = invocationId,
|
||||
reason = decision.reason ?: "approval denied",
|
||||
recoverable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. emit started
|
||||
emitStarted(sessionId, invocationId, toolName)
|
||||
|
||||
// 4. create working dir
|
||||
val workingDir = workDir.resolve(sessionId.value).resolve(invocationId.value)
|
||||
Files.createDirectories(workingDir)
|
||||
|
||||
// 5. compute affected paths once (A3: avoid double-invocation divergence)
|
||||
val affectedPaths: Set<Path> = if (tool is FileAffectingTool) tool.affectedPaths(request) else emptySet()
|
||||
|
||||
// 6. backup affected files
|
||||
val backupMap: Map<Path, Path> = try {
|
||||
backupAffectedFiles(affectedPaths, workingDir)
|
||||
} catch (e: IOException) {
|
||||
return@withContext ToolResult.Failure(
|
||||
invocationId = invocationId,
|
||||
reason = "backup failed: ${e.message}",
|
||||
recoverable = false,
|
||||
)
|
||||
}
|
||||
|
||||
// 7. delegate
|
||||
val durationMs = { System.currentTimeMillis() - startTime }
|
||||
|
||||
when (val result = delegate.execute(request)) {
|
||||
is ToolResult.Success -> {
|
||||
restoreOrClean(backupMap, success = true)
|
||||
cleanWorkingDir(workingDir)
|
||||
emitCompleted(sessionId, invocationId, toolName, result, tool, affectedPaths, durationMs())
|
||||
result
|
||||
}
|
||||
|
||||
is ToolResult.Failure -> {
|
||||
restoreOrClean(backupMap, success = false)
|
||||
cleanWorkingDir(workingDir)
|
||||
emitFailed(sessionId, invocationId, toolName, result.reason)
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- approval ---
|
||||
|
||||
private fun evaluateApproval(
|
||||
tool: Tool,
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
sessionEvents: List<StoredEvent>,
|
||||
) = approvalEngine.evaluate(
|
||||
request = DomainApprovalRequest(
|
||||
id = ApprovalRequestId(UUID.randomUUID().toString()),
|
||||
tier = tool.tier,
|
||||
validationReportId = ValidationReportId(UUID.randomUUID().toString()),
|
||||
riskSummaryId = null,
|
||||
timestamp = Clock.System.now(),
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
context = ApprovalContext(
|
||||
identity = ApprovalScopeIdentity(sessionId, stageId, null),
|
||||
mode = ApprovalMode.PROMPT,
|
||||
),
|
||||
grants = extractGrants(sessionEvents),
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
|
||||
private fun extractGrants(events: List<StoredEvent>): List<ApprovalGrant> =
|
||||
events
|
||||
.mapNotNull { it.payload as? ApprovalGrantCreatedEvent }
|
||||
.map { e ->
|
||||
ApprovalGrant(
|
||||
id = e.grantId,
|
||||
scope = e.scope,
|
||||
permittedTiers = e.permittedTiers,
|
||||
reason = e.reason,
|
||||
timestamp = Clock.System.now(),
|
||||
expiresAt = e.expiresAt,
|
||||
)
|
||||
}
|
||||
|
||||
// --- backup/restore ---
|
||||
|
||||
/**
|
||||
* Returns a map of originalPath -> backupPath for all affected files.
|
||||
* Throws IOException if any backup fails — caller handles this.
|
||||
*/
|
||||
private fun backupAffectedFiles(
|
||||
affectedPaths: Collection<Path>,
|
||||
workingDir: Path,
|
||||
): Map<Path, Path> = buildMap {
|
||||
for (original in affectedPaths) {
|
||||
// A1: skip files that don't yet exist (new-file tools have nothing to back up)
|
||||
if (!Files.exists(original)) continue
|
||||
val backupPath = workingDir.resolve("${original.fileName}.bak")
|
||||
Files.copy(original, backupPath, StandardCopyOption.REPLACE_EXISTING)
|
||||
put(original, backupPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On success: delete backups.
|
||||
* On failure: restore originals from backups.
|
||||
*/
|
||||
private fun restoreOrClean(backupMap: Map<Path, Path>, success: Boolean) {
|
||||
for ((original, backup) in backupMap) {
|
||||
try {
|
||||
if (success) {
|
||||
Files.deleteIfExists(backup)
|
||||
} else {
|
||||
Files.move(backup, original, StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanWorkingDir(workingDir: Path) {
|
||||
try {
|
||||
Files.walk(workingDir)
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.forEach { Files.deleteIfExists(it) }
|
||||
} catch (_: IOException) {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
// --- event emission ---
|
||||
|
||||
private suspend fun emitRejected(
|
||||
sessionId: SessionId,
|
||||
invocationId: ToolInvocationId,
|
||||
toolName: String,
|
||||
tier: Tier,
|
||||
reason: String,
|
||||
) = emit(sessionId, ToolExecutionRejectedEvent(invocationId, sessionId, toolName, tier, reason))
|
||||
|
||||
private suspend fun emitStarted(
|
||||
sessionId: SessionId,
|
||||
invocationId: ToolInvocationId,
|
||||
toolName: String,
|
||||
) = emit(sessionId, ToolExecutionStartedEvent(invocationId, sessionId, toolName))
|
||||
|
||||
private suspend fun emitCompleted(
|
||||
sessionId: SessionId,
|
||||
invocationId: ToolInvocationId,
|
||||
toolName: String,
|
||||
result: ToolResult.Success,
|
||||
tool: Tool,
|
||||
affectedPaths: Set<Path>,
|
||||
durationMs: Long,
|
||||
) {
|
||||
val affectedEntities = affectedPaths.map { it.toString() }
|
||||
emit(
|
||||
sessionId,
|
||||
ToolExecutionCompletedEvent(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
toolName = toolName,
|
||||
receipt = ToolReceipt(
|
||||
invocationId = invocationId,
|
||||
toolName = toolName,
|
||||
exitCode = result.exitCode,
|
||||
outputSummary = result.output,
|
||||
structuredOutput = result.metadata,
|
||||
affectedEntities = affectedEntities,
|
||||
durationMs = durationMs,
|
||||
tier = tool.tier,
|
||||
timestamp = Clock.System.now(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun emitFailed(
|
||||
sessionId: SessionId,
|
||||
invocationId: ToolInvocationId,
|
||||
toolName: String,
|
||||
reason: String,
|
||||
) = emit(sessionId, ToolExecutionFailedEvent(invocationId, sessionId, toolName, reason))
|
||||
|
||||
private suspend fun emit(sessionId: SessionId, payload: EventPayload) {
|
||||
eventDispatcher.emit(payload, sessionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.correx.infrastructure.tools
|
||||
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.infrastructure.tools.filesystem.FileEditTool
|
||||
import com.correx.infrastructure.tools.filesystem.FileReadTool
|
||||
import com.correx.infrastructure.tools.filesystem.FileWriteTool
|
||||
import com.correx.infrastructure.tools.shell.ShellTool
|
||||
import java.nio.file.Path
|
||||
|
||||
data class ToolConfig(
|
||||
val shell: ShellConfig = ShellConfig(),
|
||||
val fileRead: FileReadConfig = FileReadConfig(),
|
||||
val fileWrite: FileWriteConfig = FileWriteConfig(),
|
||||
val fileEdit: FileEditConfig = FileEditConfig(),
|
||||
)
|
||||
|
||||
data class FileReadConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
|
||||
data class FileWriteConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
|
||||
data class FileEditConfig(val enabled: Boolean = false, val allowedPaths: Set<Path> = emptySet())
|
||||
data class ShellConfig(val enabled: Boolean = false, val allowedExecutables: Set<String> = emptySet())
|
||||
|
||||
/**
|
||||
* Extension function to convert [ToolConfig] into a list of [Tool] implementations.
|
||||
*/
|
||||
fun ToolConfig.buildTools(): List<Tool> = buildList {
|
||||
if (shell.enabled) {
|
||||
add(ShellTool(
|
||||
allowedExecutables = shell.allowedExecutables
|
||||
))
|
||||
}
|
||||
if (fileRead.enabled) {
|
||||
add(FileReadTool(
|
||||
allowedPaths = fileRead.allowedPaths
|
||||
))
|
||||
}
|
||||
if (fileWrite.enabled) {
|
||||
add(FileWriteTool(
|
||||
allowedPaths = fileWrite.allowedPaths
|
||||
))
|
||||
}
|
||||
if (fileEdit.enabled) {
|
||||
add(FileEditTool(
|
||||
allowedPaths = fileEdit.allowedPaths
|
||||
))
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.correx.infrastructure.tools.shell
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
class ShellTool(
|
||||
private val allowedExecutables: Set<String> = emptySet(),
|
||||
private val timeoutMs: Long = 30_000L,
|
||||
) : Tool, ToolExecutor {
|
||||
override val name: String = "shell"
|
||||
override val tier: Tier = Tier.T2
|
||||
override val requiredCapabilities: Set<ToolCapability> =
|
||||
setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN)
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance<String>()
|
||||
return when {
|
||||
argv.isNullOrEmpty() ->
|
||||
ValidationResult.Invalid("Missing or empty 'argv' parameter. Expected List<String>.")
|
||||
argv[0] !in allowedExecutables ->
|
||||
ValidationResult.Invalid("Executable '${argv[0]}' is not in the allowed list.")
|
||||
else -> ValidationResult.Valid
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
|
||||
validateRequest(request).run {
|
||||
takeIf { this is ValidationResult.Valid }?.let {
|
||||
val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance<String>()
|
||||
val process = ProcessBuilder(argv).apply {
|
||||
environment().clear()
|
||||
}.start()
|
||||
runCatching {
|
||||
runCmd(request, process)
|
||||
}.getOrElse {
|
||||
process.destroyForcibly()
|
||||
if (it is TimeoutCancellationException) {
|
||||
ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = "Process timed out after ${timeoutMs}ms",
|
||||
recoverable = false,
|
||||
)
|
||||
} else {
|
||||
ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = it.message ?: "Unknown error occurred during execution",
|
||||
recoverable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = (this as ValidationResult.Invalid).reason,
|
||||
recoverable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runCmd(request: ToolRequest, process: Process): ToolResult = withTimeout(timeoutMs) {
|
||||
val stdoutDeferred = async { process.inputStream.bufferedReader().use { it.readText() } }
|
||||
val stderrDeferred = async { process.errorStream.bufferedReader().use { it.readText() } }
|
||||
|
||||
val exitCode = process.waitFor()
|
||||
val stdout = stdoutDeferred.await()
|
||||
val stderr = stderrDeferred.await()
|
||||
|
||||
val metadata = if (stderr.isNotBlank()) mapOf("stderr" to stderr) else emptyMap()
|
||||
if (exitCode != 0) {
|
||||
ToolResult.Failure(
|
||||
invocationId = request.invocationId,
|
||||
reason = "Process exited with code $exitCode${if (stderr.isNotBlank()) ": $stderr" else ""}",
|
||||
recoverable = false,
|
||||
)
|
||||
} else {
|
||||
ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = stdout,
|
||||
exitCode = exitCode,
|
||||
metadata = metadata,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.correx.infrastructure.tools.shell
|
||||
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.UUID
|
||||
|
||||
class ShellToolTest {
|
||||
|
||||
private val sessionId = SessionId(UUID.randomUUID().toString())
|
||||
private val stageId = StageId(UUID.randomUUID().toString())
|
||||
private val invocationId = ToolInvocationId(UUID.randomUUID().toString())
|
||||
|
||||
private fun createRequest(argv: List<String>): ToolRequest {
|
||||
return ToolRequest(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
toolName = "shell",
|
||||
parameters = mapOf("argv" to argv),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Valid for allowed executable`() = runBlocking {
|
||||
val tool = ShellTool(allowedExecutables = setOf("echo"))
|
||||
val request = createRequest(listOf("echo", "hello"))
|
||||
assertEquals(ValidationResult.Valid, tool.validateRequest(request))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Invalid for disallowed executable`() = runBlocking {
|
||||
val tool = ShellTool(allowedExecutables = setOf("echo"))
|
||||
val request = createRequest(listOf("ls"))
|
||||
val result = tool.validateRequest(request)
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertEquals("Executable 'ls' is not in the allowed list.", (result as ValidationResult.Invalid).reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Invalid for empty argv`() = runBlocking {
|
||||
val tool = ShellTool(allowedExecutables = setOf("echo"))
|
||||
val request = createRequest(emptyList())
|
||||
val result = tool.validateRequest(request)
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertEquals(
|
||||
"Missing or empty 'argv' parameter. Expected List<String>.",
|
||||
(result as ValidationResult.Invalid).reason,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validateRequest returns Invalid for null argv`() = runBlocking {
|
||||
val tool = ShellTool(allowedExecutables = setOf("echo"))
|
||||
val request = ToolRequest(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
toolName = "shell",
|
||||
parameters = emptyMap(),
|
||||
)
|
||||
val result = tool.validateRequest(request)
|
||||
assertTrue(result is ValidationResult.Invalid)
|
||||
assertEquals(
|
||||
"Missing or empty 'argv' parameter. Expected List<String>.",
|
||||
(result as ValidationResult.Invalid).reason,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute returns Success for exit code 0`() = runBlocking {
|
||||
val tool = ShellTool(allowedExecutables = setOf("echo"))
|
||||
val request = createRequest(listOf("echo", "hello"))
|
||||
val result = tool.execute(request)
|
||||
|
||||
assertTrue(result is ToolResult.Success)
|
||||
val success = result as ToolResult.Success
|
||||
assertTrue(success.output.startsWith("hello"))
|
||||
assertEquals(0, success.exitCode)
|
||||
assertEquals(invocationId, success.invocationId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute returns Failure with non-zero exit code for failed process`() = runBlocking {
|
||||
val tool = ShellTool(allowedExecutables = setOf("false"))
|
||||
val request = createRequest(listOf("false"))
|
||||
val result = tool.execute(request)
|
||||
|
||||
assertTrue(result is ToolResult.Failure)
|
||||
val failure = result as ToolResult.Failure
|
||||
assertTrue(failure.reason.contains("exited with code 1"))
|
||||
assertEquals(invocationId, failure.invocationId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `execute returns Failure on timeout`() = runBlocking {
|
||||
// Using sleep command to simulate timeout
|
||||
val tool = ShellTool(allowedExecutables = setOf("sleep"), timeoutMs = 100L)
|
||||
val request = createRequest(listOf("sleep", "1"))
|
||||
val result = tool.execute(request)
|
||||
|
||||
assertTrue(result is ToolResult.Failure)
|
||||
val failure = result as ToolResult.Failure
|
||||
assertEquals("Process timed out after 100ms", failure.reason)
|
||||
assertFalse(failure.recoverable)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user