diff --git a/Partner-Core/src/main/java/work/slhaf/partner/core/action/runner/LocalRunnerClient.java b/Partner-Core/src/main/java/work/slhaf/partner/core/action/runner/LocalRunnerClient.java index f26094f4..874c0b80 100644 --- a/Partner-Core/src/main/java/work/slhaf/partner/core/action/runner/LocalRunnerClient.java +++ b/Partner-Core/src/main/java/work/slhaf/partner/core/action/runner/LocalRunnerClient.java @@ -11,6 +11,8 @@ import work.slhaf.partner.core.action.runner.execution.CommandExecutionService; import work.slhaf.partner.core.action.runner.execution.McpActionExecutor; import work.slhaf.partner.core.action.runner.execution.OriginExecutionService; import work.slhaf.partner.core.action.runner.mcp.*; +import work.slhaf.partner.core.action.runner.policy.BwrapPolicyProvider; +import work.slhaf.partner.core.action.runner.policy.ExecutionPolicyRegistry; import work.slhaf.partner.core.action.runner.support.ActionSerializer; import java.io.IOException; @@ -69,6 +71,8 @@ public class LocalRunnerClient extends RunnerClient implements AutoCloseable { McpConfigWatcher configWatcher = null; try { + ExecutionPolicyRegistry.INSTANCE.registerPolicyProvider(BwrapPolicyProvider.INSTANCE); + metaRegistry = new McpMetaRegistry(existedMetaActions); registerMcpClient(clientRegistry, transportFactory, MCP_NAME_DESC, metaRegistry.clientConfig(MCP_NAME_DESC, 10)); log.info("DescMcp 注册完毕"); diff --git a/Partner-Core/src/main/java/work/slhaf/partner/core/action/runner/policy/BwrapPolicyProvider.kt b/Partner-Core/src/main/java/work/slhaf/partner/core/action/runner/policy/BwrapPolicyProvider.kt new file mode 100644 index 00000000..4ec2f727 --- /dev/null +++ b/Partner-Core/src/main/java/work/slhaf/partner/core/action/runner/policy/BwrapPolicyProvider.kt @@ -0,0 +1,71 @@ +package work.slhaf.partner.core.action.runner.policy + +import work.slhaf.partner.core.action.exception.ActionInitFailedException + +private const val BWRAP_COMMAND = "bwrap" + +object BwrapPolicyProvider : PolicyProvider( + policyName = "bwrap" +) { + + init { + requireBwrapAvailable() + } + + override fun prepare( + policy: ExecutionPolicy, + commands: List + ): WrappedLaunchSpec { + val (command, args) = splitCommands(commands) + val wrappedArgs = buildList { + add("--ro-bind") + add("/") + add("/") + add("--proc") + add("/proc") + add("--dev") + add("/dev") + if (policy.net == ExecutionPolicy.Network.DISABLE) { + add("--unshare-net") + } + if (!policy.workingDirectory.isNullOrBlank()) { + add("--chdir") + add(policy.workingDirectory) + } + policy.readOnlyPaths.forEach { path -> + add("--ro-bind") + add(path) + add(path) + } + policy.writablePaths.forEach { path -> + add("--bind") + add(path) + add(path) + } + add("--") + add(command) + addAll(args) + } + return WrappedLaunchSpec( + command = BWRAP_COMMAND, + args = wrappedArgs, + workingDirectory = policy.workingDirectory, + environment = resolveEnvironment(policy) + ) + } + + private fun requireBwrapAvailable() { + val available = try { + val process = ProcessBuilder(BWRAP_COMMAND, "--version") + .redirectErrorStream(true) + .start() + val exitCode = process.waitFor() + exitCode == 0 + } catch (e: Exception) { + throw ActionInitFailedException("bwrap provider 初始化失败: 无法检测 $BWRAP_COMMAND 可执行文件", e) + } + if (!available) { + throw ActionInitFailedException("bwrap provider 初始化失败: 未检测到可执行命令 '$BWRAP_COMMAND'") + } + } +} diff --git a/Partner-Core/src/test/java/work/slhaf/partner/core/action/runner/LocalRunnerClientTest.java b/Partner-Core/src/test/java/work/slhaf/partner/core/action/runner/LocalRunnerClientTest.java index 0382433e..f2e84a81 100644 --- a/Partner-Core/src/test/java/work/slhaf/partner/core/action/runner/LocalRunnerClientTest.java +++ b/Partner-Core/src/test/java/work/slhaf/partner/core/action/runner/LocalRunnerClientTest.java @@ -8,6 +8,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import work.slhaf.partner.core.action.entity.MetaAction; import work.slhaf.partner.core.action.entity.MetaActionInfo; +import work.slhaf.partner.core.action.runner.policy.ExecutionPolicy; +import work.slhaf.partner.core.action.runner.policy.ExecutionPolicyRegistry; +import work.slhaf.partner.core.action.runner.policy.WrappedLaunchSpec; import work.slhaf.partner.module.modules.action.builtin.BuiltinActionRegistry; import java.io.IOException; @@ -383,6 +386,38 @@ public class LocalRunnerClientTest { } } + @Test + void testLocalRunnerClientRegisterBwrapPolicyProvider(@TempDir Path tempDir) { + ConcurrentHashMap existedMetaActions = new ConcurrentHashMap<>(); + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + LocalRunnerClient ignored = new LocalRunnerClient(existedMetaActions, executor, tempDir.toString())) { + ExecutionPolicyRegistry.INSTANCE.updatePolicy(new ExecutionPolicy( + ExecutionPolicy.Mode.SANDBOX, + "bwrap", + ExecutionPolicy.Network.DISABLE, + false, + Map.of("A", "B"), + tempDir.toString(), + Set.of("/etc"), + Set.of(tempDir.toString()) + )); + WrappedLaunchSpec wrapped = ExecutionPolicyRegistry.INSTANCE.prepare(List.of("python", "demo.py")); + Assertions.assertEquals("bwrap", wrapped.getCommand()); + Assertions.assertTrue(wrapped.getArgs().contains("--unshare-net")); + } finally { + ExecutionPolicyRegistry.INSTANCE.updatePolicy(new ExecutionPolicy( + ExecutionPolicy.Mode.DIRECT, + "direct", + ExecutionPolicy.Network.ENABLE, + true, + Map.of(), + null, + Set.of(), + Set.of() + )); + } + } + @Nested class DescMcpTest { diff --git a/Partner-Core/src/test/java/work/slhaf/partner/core/action/runner/policy/BwrapPolicyProviderTest.kt b/Partner-Core/src/test/java/work/slhaf/partner/core/action/runner/policy/BwrapPolicyProviderTest.kt new file mode 100644 index 00000000..efcfa18d --- /dev/null +++ b/Partner-Core/src/test/java/work/slhaf/partner/core/action/runner/policy/BwrapPolicyProviderTest.kt @@ -0,0 +1,86 @@ +package work.slhaf.partner.core.action.runner.policy + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import work.slhaf.partner.core.action.exception.ActionInitFailedException + +class BwrapPolicyProviderTest { + + @Test + fun `prepare wraps commands and policy settings`() { + val policy = ExecutionPolicy( + mode = ExecutionPolicy.Mode.SANDBOX, + provider = "bwrap", + net = ExecutionPolicy.Network.DISABLE, + inheritEnv = false, + env = mapOf("KEY" to "VALUE"), + workingDirectory = "/work/demo", + readOnlyPaths = setOf("/etc"), + writablePaths = setOf("/tmp/demo") + ) + + val wrapped = BwrapPolicyProvider.prepare(policy, listOf("python", "script.py", "--flag")) + + assertEquals("bwrap", wrapped.command) + assertEquals("/work/demo", wrapped.workingDirectory) + assertEquals(mapOf("KEY" to "VALUE"), wrapped.environment) + assertEquals( + listOf( + "--ro-bind", "/", "/", + "--proc", "/proc", + "--dev", "/dev", + "--unshare-net", + "--chdir", "/work/demo", + "--ro-bind", "/etc", "/etc", + "--bind", "/tmp/demo", "/tmp/demo", + "--", "python", "script.py", "--flag" + ), + wrapped.args + ) + } + + @Test + fun `prepare inherits environment`() { + val policy = ExecutionPolicy( + mode = ExecutionPolicy.Mode.SANDBOX, + provider = "bwrap", + net = ExecutionPolicy.Network.ENABLE, + inheritEnv = true, + env = mapOf("BWRAP_TEST_KEY" to "VALUE"), + workingDirectory = null, + readOnlyPaths = emptySet(), + writablePaths = emptySet() + ) + + val wrapped = BwrapPolicyProvider.prepare(policy, listOf("echo", "ok")) + + assertEquals("VALUE", wrapped.environment["BWRAP_TEST_KEY"]) + assertFalse(wrapped.environment.isEmpty()) + assertFalse(wrapped.args.contains("--unshare-net")) + } + + @Test + fun `require command available throws ActionInitFailedException when command missing`() { + val exception = assertThrows(ActionInitFailedException::class.java) { + bwrapPolicyFileFacadeClass().requireCommandAvailable("definitely-not-found-bwrap-command") + } + + assertTrue(exception.message!!.contains("definitely-not-found-bwrap-command")) + } +} + +private fun bwrapPolicyFileFacadeClass(): Class<*> { + return Class.forName("work.slhaf.partner.core.action.runner.policy.BwrapPolicyProviderKt") +} + +private fun Class<*>.requireCommandAvailable(command: String) { + val method = getDeclaredMethod("requireCommandAvailable", String::class.java) + method.isAccessible = true + try { + method.invoke(null, command) + } catch (e: java.lang.reflect.InvocationTargetException) { + throw (e.targetException as? RuntimeException) + ?: (e.targetException as? Error) + ?: RuntimeException(e.targetException) + } +}