From f0435092b40126d494383ba77ac89073bd75dd5b Mon Sep 17 00:00:00 2001 From: xixia666 <75162629+xixia666@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:16:55 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(agent):=20=E6=96=B0=E5=A2=9E=20Root=20?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=90=8E=E7=AB=AF=E4=B8=8E=20root=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在既有「本地容器 / 远程 SSH / Shizuku」之外,新增以超级用户(uid 0)身份 执行命令的 Root 后端。相比 Shizuku 的 adb shell(uid 2000),root 可访问 /data/data、/data/adb 等受限目录,执行需要 uid 0 的系统操作。 实现方式与 Shizuku 保持一致:作为独立工具接入,不进入 ExecutionMode 体系, 不改变主执行链路(Bash / terminal / 文件访问仍走原有模式)。 - RootManager:按候选路径探测 su(Magisk / KernelSU / APatch 等), 以 `su -c` 执行命令;输出读取与 waitFor 并行避免管道写满死锁, 并用 BoundedOutput 限幅。状态分 UNAVAILABLE / DENIED / READY。 不在构造时自动探测,避免 App 启动即弹 root 授权框。 - RootTool:工具名 Root,参数 command / timeout,走 ASK 授权与 命令前缀记忆,与 Bash / Shizuku 同策略。 - 设置页新增「运行环境 → Root」状态页(RootSection / RootViewModel), 支持查看状态与手动触发授权;文案进中英双语 strings.xml。 - ToolPermissionPolicyEngine 的 SHELL_TOOLS 纳入 Root。 - 同步提示词(60-tools-and-paths.md、plan-mode.md)与文档 (guide/root.md、侧栏与 overview 索引)。 权限说明:root 无编程式授权 API,授权由 root 管理器弹窗完成。 --- .../main/assets/prompts/60-tools-and-paths.md | 1 + .../main/assets/prompts/agent/plan-mode.md | 2 +- .../main/java/com/aicode/di/AgentModule.kt | 3 + .../permission/ToolPermissionPolicyEngine.kt | 2 +- .../feature/agent/domain/root/RootManager.kt | 195 ++++++++++++++++++ .../agent/domain/tool/root/RootTool.kt | 117 +++++++++++ .../settings/presentation/RootViewModel.kt | 25 +++ .../presentation/component/RootSection.kt | 84 ++++++++ .../presentation/component/SettingsScreen.kt | 17 ++ app/src/main/res/values-en/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + docs-site/.vitepress/config.ts | 3 +- docs-site/docs/guide/overview.md | 1 + docs-site/docs/guide/root.md | 58 ++++++ 14 files changed, 521 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/aicode/feature/agent/domain/root/RootManager.kt create mode 100644 app/src/main/java/com/aicode/feature/agent/domain/tool/root/RootTool.kt create mode 100644 app/src/main/java/com/aicode/feature/settings/presentation/RootViewModel.kt create mode 100644 app/src/main/java/com/aicode/feature/settings/presentation/component/RootSection.kt create mode 100644 docs-site/docs/guide/root.md diff --git a/app/src/main/assets/prompts/60-tools-and-paths.md b/app/src/main/assets/prompts/60-tools-and-paths.md index e6c61b44..a9c7048b 100644 --- a/app/src/main/assets/prompts/60-tools-and-paths.md +++ b/app/src/main/assets/prompts/60-tools-and-paths.md @@ -18,6 +18,7 @@ ## 命令与终端工具 - `Bash`:执行一次性 shell 命令(列目录、搜索、构建、lint、格式化、git、装依赖等),同步等待命令结束并返回输出。默认超时 120 秒,上限 1800 秒;耗时命令(如安装依赖)可用 timeout 参数调大。 - `Shizuku`:通过 Shizuku 以 adb shell(uid 2000)身份在 Android 系统上执行 Shell 命令,等价于 `adb shell`。适用于需要 shell 权限的系统操作:`pm`/`am`/`cmd` 等系统命令、读写 `/sdcard`、查询系统状态等。与 `Bash`(在本地容器或远程 SSH 中执行)不同,它直接作用于宿主 Android 系统本身。使用前用户需已安装 Shizuku 并在本应用中授权(设置 → 运行环境 → Shizuku),未就绪时会返回错误提示。参数:`command`(必填)、`timeout`(秒,可选,默认 120、上限 1800)。 +- `Root`:以 root(uid 0)身份在 Android 系统上执行 Shell 命令。相比 `Shizuku`(adb shell,uid 2000),root 可访问系统受限目录并执行需要超级用户权限的操作:读写 `/data/data`、`/data/adb`,修改系统属性,管理其他应用等。使用前设备需已 root,首次调用会弹出 root 管理器授权框(设置 → 运行环境 → Root 可查看状态并触发授权),未就绪时会返回错误提示。参数:`command`(必填)、`timeout`(秒,可选,默认 120、上限 1800)。 - 环境已内置常用开发工具:`git`、`rg`(ripgrep)、`py`/`python`、`node`。需要时优先直接通过 `Bash` 调用,不要先询问是否安装。 - `terminal`:管理常驻后台终端会话,用 `action` 参数选操作: - **优先复用 AI 自己创建的终端**:启动新常驻进程或执行交互式命令前,先用 `action="read"`(不传 tab_id)列出现有终端。若有 AI 之前创建的活跃标签,直接用 `action="send"` 复用,切忌反复 `start` 开一堆新窗口。 diff --git a/app/src/main/assets/prompts/agent/plan-mode.md b/app/src/main/assets/prompts/agent/plan-mode.md index 58d69b85..a0cfaee2 100644 --- a/app/src/main/assets/prompts/agent/plan-mode.md +++ b/app/src/main/assets/prompts/agent/plan-mode.md @@ -5,7 +5,7 @@ ## 绝对约束(覆盖其它所有指令) -- 禁止任何写操作:`writeFile`、`editFile`、`Bash`、`Shizuku`、`terminal` 的 start/send/key/close 等写工具调用会被拦截并返回错误,不要尝试调用。 +- 禁止任何写操作:`writeFile`、`editFile`、`Bash`、`Shizuku`、`Root`、`terminal` 的 start/send/key/close 等写工具调用会被拦截并返回错误,不要尝试调用。 - 除只读探索与输出方案外,不对系统做任何更改(不提交、不装包、不改配置、不动文件)。 - 用户尚未批准执行——以上约束优先于任何其它指令,包括用户直接要求编辑的请求。你只能观察、分析、规划。 diff --git a/app/src/main/java/com/aicode/di/AgentModule.kt b/app/src/main/java/com/aicode/di/AgentModule.kt index b96ab6a7..8ac576ba 100644 --- a/app/src/main/java/com/aicode/di/AgentModule.kt +++ b/app/src/main/java/com/aicode/di/AgentModule.kt @@ -38,6 +38,7 @@ import com.aicode.feature.agent.domain.tool.container.TerminalSessionTool import com.aicode.feature.agent.domain.tool.explorer.ListFilesTool import com.aicode.feature.agent.domain.tool.explorer.SearchCodeTool import com.aicode.feature.agent.domain.tool.shizuku.ShizukuTool +import com.aicode.feature.agent.domain.tool.root.RootTool import com.aicode.feature.agent.domain.tool.skill.LoadSkillTool import com.aicode.feature.agent.domain.tool.question.AskUserQuestionTool import com.aicode.feature.agent.domain.tool.todo.TodoTool @@ -273,6 +274,7 @@ object AgentModule { generateImageTool: GenerateImageTool, executeCommandTool: ExecuteCommandTool, shizukuTool: ShizukuTool, + rootTool: RootTool, terminalSessionTool: TerminalSessionTool, listFilesTool: ListFilesTool, searchCodeTool: SearchCodeTool, @@ -296,6 +298,7 @@ object AgentModule { register("generateImage", generateImageTool) register("Bash", executeCommandTool) register("Shizuku", shizukuTool) + register("Root", rootTool) register("terminal", terminalSessionTool) register("list", listFilesTool) register("search", searchCodeTool) diff --git a/app/src/main/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngine.kt b/app/src/main/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngine.kt index 923dffc9..f1b7a57c 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngine.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngine.kt @@ -28,7 +28,7 @@ class ToolPermissionPolicyEngine @Inject constructor( ) { private companion object { /** 以 `command` 参数承载 shell 命令、按命令前缀做指令级匹配的工具。 */ - val SHELL_TOOLS = setOf("Bash", "Shizuku") + val SHELL_TOOLS = setOf("Bash", "Shizuku", "Root") /** * 合并后的终端会话工具:其 `start` 动作承载 shell 命令,需走指令级前缀匹配; diff --git a/app/src/main/java/com/aicode/feature/agent/domain/root/RootManager.kt b/app/src/main/java/com/aicode/feature/agent/domain/root/RootManager.kt new file mode 100644 index 00000000..ae75dce7 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/root/RootManager.kt @@ -0,0 +1,195 @@ +package com.aicode.feature.agent.domain.root + +import android.content.Context +import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.domain.container.BoundedOutput +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** Root 可用状态,供设置页展示与工具执行前判定。 */ +enum class RootState { + /** 未检测到 `su`,设备无 root(或 root 方案未提供 su)。 */ + UNAVAILABLE, + + /** 检测到 `su`,但本应用未获授权(用户拒绝或 root 管理器未放行)。 */ + DENIED, + + /** 就绪,可以 root 身份执行命令。 */ + READY +} + +/** 一次 root 命令执行结果。[exitCode] 为负值表示超时或启动异常。 */ +data class RootCommandResult(val output: String, val exitCode: Int) + +/** + * Root 后端:以超级用户(uid 0)身份执行命令。 + * + * 与 [com.aicode.feature.agent.domain.shizuku.ShizukuManager](adb shell,uid 2000)不同, + * root 身份可访问系统受限目录(如 `/data/data`、`/data/adb`),能执行 shell 身份做不到的操作。 + * + * 实现方式:直接通过 `su -c ` 起子进程。`su` 由 root 管理器 + * (Magisk / KernelSU / APatch 等)在 `PATH` 或固定路径提供,本类按候选路径探测。 + * + * 与 Shizuku 的差异:root 没有可编程的授权 API,授权由 root 管理器自己的弹窗完成, + * 因此 [refreshState] 探测或执行命令时会触发管理器的授权框,需要用户在设备上点「允许」。 + * 也正因如此,**不在构造时自动探测**(避免 App 一启动就弹 root 框),改由设置页或工具调用触发。 + */ +@Singleton +class RootManager @Inject constructor( + @ApplicationContext private val context: Context +) { + private companion object { + const val TAG = "RootManager" + + /** + * `su` 常见路径。不同 root 方案位置不一: + * Magisk 通常在 `/system/bin/su`(早期 `/sbin/su`);KernelSU / APatch 亦为 `/system/bin/su`; + * 部分方案(如旧 Magisk、Sui)在 `/su/bin/su` 或 `/debug_ramdisk/su`。 + */ + val SU_CANDIDATES = listOf( + "/system/bin/su", + "/system/xbin/su", + "/sbin/su", + "/su/bin/su", + "/debug_ramdisk/su", + "/system/sbin/su", + "/vendor/bin/su" + ) + + /** + * 探测超时(毫秒)。比命令执行宽松:探测会弹 root 授权框,需给用户留出点击时间。 + */ + const val PROBE_TIMEOUT_MS = 30_000L + + /** 命令超时上限(毫秒),与 [com.aicode.feature.agent.domain.container.CommandEngine.MAX_TIMEOUT_MS] 对齐。 */ + const val MAX_TIMEOUT_MS = 1_800_000L + + /** 命令超时(进程被强杀)时的退出码。 */ + const val EXIT_TIMEOUT = -1000 + + /** 启动/读取异常时的退出码。 */ + const val EXIT_FAILURE = -1 + + /** 输出读取线程的 join 上限(毫秒)。 */ + const val READER_JOIN_TIMEOUT_MS = 2_000L + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val _state = MutableStateFlow(RootState.UNAVAILABLE) + val state: StateFlow = _state.asStateFlow() + + @Volatile + private var suPath: String? = null + + /** + * 重新探测并在后台发布当前状态。 + * + * 非阻塞:探测要起进程(可能弹 root 授权框),故放到 IO 线程,调用方可直接在 + * 主线程的 UI 回调里调用。 + */ + fun refreshState() { + scope.launch { + runCatching { computeState() } + .onSuccess { _state.value = it } + .onFailure { FileLogger.w(TAG, "探测 root 状态失败: ${it.message}") } + } + } + + private suspend fun computeState(): RootState = withContext(Dispatchers.IO) { + val su = resolveSu() ?: return@withContext RootState.UNAVAILABLE + // 实际跑一次 `id` 验证真能拿到 root:仅有 su 文件不代表授权通过。 + val probe = execWithSu(su, "id", PROBE_TIMEOUT_MS) + if (probe.exitCode == 0 && probe.output.contains("uid=0")) { + RootState.READY + } else { + RootState.DENIED + } + } + + /** 定位 `su`:先查候选路径,再兜底 `which su`。找到即缓存。 */ + private fun resolveSu(): String? { + suPath?.let { return it } + for (path in SU_CANDIDATES) { + if (File(path).exists()) { + suPath = path + return path + } + } + val which = runCatching { + val process = ProcessBuilder("sh", "-c", "which su") + .redirectErrorStream(true) + .start() + val out = process.inputStream.bufferedReader().use { it.readText() } + process.waitFor() + out.lineSequence().firstOrNull { it.isNotBlank() }?.trim() + }.getOrNull() + if (which != null && File(which).exists()) { + suPath = which + return which + } + return null + } + + /** + * 执行 root 命令。未检测到 `su` 时抛异常,由调用方转成工具错误。 + * + * 不预先依赖 [state]:授权状态可能尚未探测(避免启动即弹框),此处直接调用, + * 由 root 管理器在首次调用时弹框授权。 + */ + suspend fun runCommand(command: String, timeoutMs: Long): RootCommandResult { + val su = resolveSu() ?: throw IllegalStateException("未检测到 su,设备可能未 root") + val timeout = timeoutMs.coerceIn(1_000L, MAX_TIMEOUT_MS) + return withContext(Dispatchers.IO) { execWithSu(su, command, timeout) } + } + + /** + * 以 `su -c ` 执行并收集输出。 + * + * 输出读取与等待结束必须并行:管道写满会阻塞子进程,先 waitFor 再读会死锁。 + * 同时用 [BoundedOutput] 限幅,避免超大输出撑爆内存。 + * + * 注意:`destroyForcibly()` 只能杀掉 `su` 进程本身,其派生的孙进程可能残留—— + * 与 Shizuku 后端同样的取舍,超时场景调用方需知晓。 + */ + private fun execWithSu(su: String, command: String, timeoutMs: Long): RootCommandResult { + var process: Process? = null + return try { + process = ProcessBuilder(su, "-c", command) + .redirectErrorStream(true) + .start() + val output = BoundedOutput() + val reader = Thread { + runCatching { + process.inputStream.bufferedReader().useLines { lines -> + lines.forEach { line -> + output.append(line) + output.append("\n") + } + } + } + } + reader.start() + val finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS) + if (!finished) process.destroyForcibly() + reader.join(READER_JOIN_TIMEOUT_MS) + val exitCode = if (finished) process.exitValue() else EXIT_TIMEOUT + RootCommandResult(output.build(), exitCode) + } catch (e: Exception) { + RootCommandResult(e.message ?: "执行失败", EXIT_FAILURE) + } finally { + process?.destroy() + } + } +} diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/root/RootTool.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/root/RootTool.kt new file mode 100644 index 00000000..df4fe7b4 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/root/RootTool.kt @@ -0,0 +1,117 @@ +package com.aicode.feature.agent.domain.tool.root + +import com.aicode.core.util.FileLogger +import com.aicode.feature.agent.domain.container.BoundedOutput +import com.aicode.feature.agent.domain.root.RootManager +import com.aicode.feature.agent.domain.root.RootState +import com.aicode.feature.agent.domain.tool.AgentTool +import com.aicode.feature.agent.domain.tool.ParameterType +import com.aicode.feature.agent.domain.tool.PendingToolPermission +import com.aicode.feature.agent.domain.tool.ToolCapability +import com.aicode.feature.agent.domain.tool.ToolParameter +import com.aicode.feature.agent.domain.tool.ToolPermissionPolicy +import com.aicode.feature.agent.domain.tool.ToolResult +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import javax.inject.Inject + +/** + * 以 root(uid 0)身份执行命令的工具。 + * + * 与 [com.aicode.feature.agent.domain.tool.shizuku.ShizukuTool](adb shell,uid 2000)并列: + * 二者都直接作用于宿主 Android 系统,但 root 具备 shell 身份没有的权限—— + * 可访问 `/data/data`、`/data/adb` 等受限目录,执行需要 uid 0 的系统操作。 + * + * 需设备已 root 且用户授权(授权由 root 管理器弹窗完成),否则返回错误提示。 + */ +class RootTool @Inject constructor( + private val rootManager: RootManager +) : AgentTool() { + private companion object { + const val TAG = "RootTool" + + const val DEFAULT_TIMEOUT_SECONDS = 120L + const val MAX_TIMEOUT_SECONDS = 1_800L + } + + override val name = "Root" + + override val description = + "以 root(uid 0)身份在 Android 系统上执行 Shell 命令。" + + "相比 `Shizuku`(adb shell,uid 2000),root 可访问系统受限目录并执行需要超级用户权限的操作:" + + "读写 `/data/data`、`/data/adb`,修改系统属性,管理其他应用等。" + + "与 `Bash`(在本地容器或远程 SSH 中执行)不同,它直接作用于宿主 Android 系统本身。" + + "使用前设备需已 root 并在弹出授权框时允许,未就绪时会返回错误提示。" + + override val permissionPolicy = ToolPermissionPolicy.ASK + override val capabilities = setOf(ToolCapability.EXECUTE_COMMANDS) + + override val parameters: Map = mapOf( + "command" to ToolParameter( + name = "command", + type = ParameterType.STRING, + description = "要以 root 身份执行的 Shell 命令", + required = true + ), + "timeout" to ToolParameter( + name = "timeout", + type = ParameterType.INTEGER, + description = "命令最长执行时间(秒),超时将被强制终止。默认 $DEFAULT_TIMEOUT_SECONDS 秒,上限 $MAX_TIMEOUT_SECONDS 秒。", + required = false + ) + ) + + private fun resolveTimeoutMs(args: Map): Long { + val seconds = args["timeout"]?.jsonPrimitive?.longOrNull ?: DEFAULT_TIMEOUT_SECONDS + return seconds.coerceIn(1L, MAX_TIMEOUT_SECONDS) * 1000L + } + + override fun buildPermissionRequest( + callId: String, + args: Map, + argsPreview: String + ): PendingToolPermission { + val command = args["command"]?.jsonPrimitive?.contentOrNull ?: "未知命令" + val timeoutSeconds = resolveTimeoutMs(args) / 1000L + return PendingToolPermission( + id = callId, + toolName = name, + title = "确认执行 Root 命令", + summary = command, + details = "将以 root(uid 0)身份在 Android 系统上执行,权限高于 adb shell。\n超时:${timeoutSeconds} 秒", + argsPreview = argsPreview + ) + } + + override suspend fun execute(args: Map): ToolResult { + val command = args["command"]?.jsonPrimitive?.contentOrNull + ?: return ToolResult.Error("缺少必需参数: command") + + // UNAVAILABLE 时提前失败,避免无谓地起子进程;DENIED 仍尝试执行, + // 因为状态可能是未探测时的初值,首次调用会触发 root 管理器弹框重新授权。 + if (rootManager.state.value == RootState.UNAVAILABLE) { + return ToolResult.Error( + "未检测到 root:设备可能未 root,或 root 方案未提供 su", + code = "ROOT_NOT_AVAILABLE" + ) + } + + return try { + val timeoutMs = resolveTimeoutMs(args) + FileLogger.d(TAG, "Root exec (timeout=${timeoutMs}ms): $command") + val result = rootManager.runCommand(command, timeoutMs) + val output = BoundedOutput().apply { append(result.output) }.build() + FileLogger.v(TAG, "Root exec 完成,输出 ${result.output.length} 字符,退出码 ${result.exitCode}") + ToolResult.Success(JsonPrimitive(output)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + FileLogger.e(TAG, "Root exec 失败: $command", e) + ToolResult.Error("执行 Root 命令失败: ${e.message}") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/RootViewModel.kt b/app/src/main/java/com/aicode/feature/settings/presentation/RootViewModel.kt new file mode 100644 index 00000000..a40cca47 --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/presentation/RootViewModel.kt @@ -0,0 +1,25 @@ +package com.aicode.feature.settings.presentation + +import androidx.lifecycle.ViewModel +import com.aicode.feature.agent.domain.root.RootManager +import com.aicode.feature.agent.domain.root.RootState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +/** Root 执行后端的设置页状态与操作入口。 */ +@HiltViewModel +class RootViewModel @Inject constructor( + private val rootManager: RootManager +) : ViewModel() { + + val state: StateFlow = rootManager.state + + /** + * 重新探测状态。 + * + * root 没有可编程的授权 API,探测本身就会触发 root 管理器的授权弹窗, + * 属于预期行为(用户可从设置页主动触发授权)。 + */ + fun refresh() = rootManager.refreshState() +} \ No newline at end of file diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/RootSection.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/RootSection.kt new file mode 100644 index 00000000..15554dbd --- /dev/null +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/RootSection.kt @@ -0,0 +1,84 @@ +package com.aicode.feature.settings.presentation.component + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.LifecycleResumeEffect +import com.aicode.R +import com.aicode.core.theme.Spacing +import com.aicode.core.theme.semanticColors +import com.aicode.feature.agent.domain.root.RootState +import compose.icons.FeatherIcons +import compose.icons.feathericons.Terminal + +/** + * 「Root」二级页:展示 root 可用状态并提供授权/重新探测入口。 + * + * 状态由 [com.aicode.feature.agent.domain.root.RootManager] 统一维护。 + * root 没有可编程的授权 API,因此「重新探测」本身就会触发 root 管理器的授权弹窗。 + */ +@Composable +internal fun RootSection( + state: RootState, + onRefresh: () -> Unit +) { + LifecycleResumeEffect(Unit) { + onRefresh() + onPauseOrDispose { } + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.lg) + .padding(bottom = Spacing.xl), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + SettingsGroupHeader(text = stringResource(R.string.settings_category_environment)) + SettingsGroup { + SettingsRow( + icon = FeatherIcons.Terminal, + title = stringResource(R.string.settings_root), + subtitle = stringResource(state.hintRes()), + onClick = onRefresh, + trailing = { + Text( + text = stringResource(state.statusRes()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + } + Text( + text = stringResource(R.string.settings_root_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.semanticColors.subtleText, + modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.sm) + ) + } +} + +@StringRes +private fun RootState.statusRes(): Int = when (this) { + RootState.UNAVAILABLE -> R.string.settings_root_status_unavailable + RootState.DENIED -> R.string.settings_root_status_denied + RootState.READY -> R.string.settings_root_status_ready +} + +@StringRes +private fun RootState.hintRes(): Int = when (this) { + RootState.UNAVAILABLE -> R.string.settings_root_hint_unavailable + RootState.DENIED -> R.string.settings_root_hint_denied + RootState.READY -> R.string.settings_root_hint_ready +} \ No newline at end of file diff --git a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt index f8a5be61..12476739 100644 --- a/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt +++ b/app/src/main/java/com/aicode/feature/settings/presentation/component/SettingsScreen.kt @@ -82,6 +82,7 @@ import com.aicode.feature.settings.domain.model.AIProviderConfig import com.aicode.feature.settings.domain.model.ModelMetadata import com.aicode.feature.settings.presentation.SettingsViewModel import com.aicode.feature.settings.presentation.ShizukuViewModel +import com.aicode.feature.settings.presentation.RootViewModel import com.aicode.feature.settings.presentation.SkillImportState import com.aicode.feature.settings.presentation.SkillUiEntry import com.aicode.feature.agent.domain.skill.SkillImportError @@ -151,6 +152,7 @@ internal enum class SettingsSection(@param:StringRes val titleRes: Int) { SubAgentEditor(R.string.settings_subagents), Container(R.string.settings_container), Shizuku(R.string.settings_shizuku), + Root(R.string.settings_root), ContainerDownloads(R.string.container_download_image), Proxy(R.string.proxy_title), Log(R.string.settings_log), @@ -881,6 +883,15 @@ fun SettingsScreen( onRefresh = { shizukuViewModel.refresh() } ) } + SettingsSection.Root -> { + val rootViewModel: RootViewModel = + androidx.hilt.navigation.compose.hiltViewModel() + val rootState by rootViewModel.state.collectAsStateWithLifecycle() + RootSection( + state = rootState, + onRefresh = { rootViewModel.refresh() } + ) + } SettingsSection.ContainerDownloads -> ContainerImageDownloadSection( catalog = imageCatalog, state = imageDownload, @@ -1299,6 +1310,12 @@ internal fun SettingsMenu( title = stringResource(SettingsSection.Shizuku.titleRes), onClick = { onOpen(SettingsSection.Shizuku) } ) + SettingsDivider() + SettingsRow( + icon = FeatherIcons.Terminal, + title = stringResource(SettingsSection.Root.titleRes), + onClick = { onOpen(SettingsSection.Root) } + ) } // ── 工具与权限 ── diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 8c5c2b90..7941b212 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -60,6 +60,14 @@ Tap to request Shizuku permission Authorized — AI can run commands via adb shell Shizuku lets this app run commands as adb shell (uid 2000): pm / am / cmd system commands, reading and writing /sdcard, etc. Install and start the Shizuku app first, then authorize here. Once enabled, the AI gains a tool named Shizuku. + Root + Unavailable + Not authorized + Ready + No root (su) detected — tap to probe again + Tap to request authorization (the root manager dialog will appear) + Root granted — AI can run commands as uid 0 via the Root tool + Root lets this app run commands as the superuser (uid 0): access restricted directories such as /data/data and /data/adb, and perform system operations adb shell cannot. The device must be rooted. Root has no programmatic permission API — authorization happens in the root manager dialog, shown when you open this page or the AI first calls the Root tool. Once enabled, the AI gains a tool named Root. Backup & Restore About Language diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 352da710..64a93c4f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -60,6 +60,14 @@ 点击申请 Shizuku 授权 已获授权,AI 可通过 adb shell 执行命令 Shizuku 让本应用以 adb shell(uid 2000)身份执行命令,可运行 pm / am / cmd 等系统命令、读写 /sdcard。需先安装并启动 Shizuku 应用,再在此授权。开启后 AI 将获得名为 Shizuku 的工具。 + Root + 不可用 + 未授权 + 已就绪 + 未检测到 root(su),点击重新探测 + 点击触发授权(将弹出 root 管理器窗口) + 已获 root,AI 可通过 Root 工具以 uid 0 执行命令 + Root 让本应用以超级用户(uid 0)身份执行命令,可访问 /data/data、/data/adb 等受限目录,执行 adb shell 做不到的系统操作。需设备已 root;root 没有可编程的授权接口,授权由 root 管理器的弹窗完成,点击本页或首次调用 Root 工具时会弹出授权框。开启后 AI 将获得名为 Root 的工具。 备份与还原 关于 语言 diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts index 352fa010..b1d9e94f 100644 --- a/docs-site/.vitepress/config.ts +++ b/docs-site/.vitepress/config.ts @@ -135,7 +135,8 @@ export default defineConfig({ { text: '远程 SSH 模式', link: '/guide/remote-ssh' }, { text: '工作区同步', link: '/guide/sync' }, { text: '网络代理', link: '/guide/proxy' }, - { text: 'Shizuku 执行后端', link: '/guide/shizuku' } + { text: 'Shizuku 执行后端', link: '/guide/shizuku' }, + { text: 'Root 执行后端', link: '/guide/root' } ] }, { diff --git a/docs-site/docs/guide/overview.md b/docs-site/docs/guide/overview.md index 189365da..5dd59457 100644 --- a/docs-site/docs/guide/overview.md +++ b/docs-site/docs/guide/overview.md @@ -40,6 +40,7 @@ | 网络代理 | 全局代理与提供商级代理(1.11.0 起)→ [文档](/guide/proxy) | | 连接与同步 | SFTP / FTP 通道、工作区同步、内置 FTP 服务端 → [文档](/guide/sync) | | Shizuku 执行后端 | 以 adb shell(uid 2000)身份执行系统命令、读写 /sdcard → [文档](/guide/shizuku) | +| Root 执行后端 | 以 root(uid 0)身份执行命令、访问 /data/data 等受限目录 → [文档](/guide/root) | ### 工具与权限 diff --git a/docs-site/docs/guide/root.md b/docs-site/docs/guide/root.md new file mode 100644 index 00000000..3b237879 --- /dev/null +++ b/docs-site/docs/guide/root.md @@ -0,0 +1,58 @@ +# Root 执行后端 + +Root 让 AiCode 以**超级用户(uid 0)**身份在 Android 系统上执行命令。相比 Shizuku 的 adb shell(uid 2000),root 可以访问系统受限目录、执行需要超级用户权限的操作,弥补 shell 身份做不到的部分。 + +它与「本地容器 / 远程 SSH」并列,也不需要切换模式:只要设备已 root 且授权通过,AI 就能调用名为 `Root` 的工具。 + +## 能做什么 + +| 能力 | 本地容器(PRoot) | Shizuku(shell) | Root | +| --- | :---: | :---: | :---: | +| `pm` / `am` / `cmd` 等系统命令 | ✗ | ✓ | ✓ | +| 读写 `/sdcard` | 有限 | ✓ | ✓ | +| 访问其他应用私有目录 `/data/data` | ✗ | ✗ | ✓ | +| 读写 `/data/adb`、改系统属性 | ✗ | ✗ | ✓ | +| 需要设备 root | 否 | 否 | **是** | + +Root 权限最高,请谨慎授权。 + +## 前置:设备已 root + +需要设备已通过 Magisk / KernelSU / APatch 等方案获取 root,且 root 管理器提供了 `su`。常见路径包括 `/system/bin/su`、`/system/xbin/su`、`/sbin/su`、`/debug_ramdisk/su` 等,AiCode 会自动探测。 + +## 在 AiCode 中授权 + +打开「设置 → 运行环境 → Root」,页面会显示当前状态: + +- **不可用**:未检测到 `su`,设备可能未 root,或 root 方案未提供 `su`。 +- **未授权**:检测到 `su`,但尚未获得授权。 +- **已就绪**:可以 root 身份执行命令。 + +Root 没有像 Shizuku 那样的可编程授权接口,授权由 **root 管理器自己的弹窗**完成。因此: + +- 点击页面上的 Root 条目会触发一次探测,此时 root 管理器会弹出授权框,选择「允许」即可; +- 首次让 AI 调用 `Root` 工具时,同样会弹出授权框; +- 部分 root 管理器支持「记住选择」,之后不再重复询问。 + +## 使用与安全 + +- AI 调用 `Root` 工具时会走**工具授权**弹窗(与 `Bash` / `Shizuku` 一致),可选择单次放行或「始终允许」记住命令前缀。 +- 命令按前缀做指令级匹配:例如记住 `pm` 后,后续 `pm ...` 命令自动放行,其他命令仍会询问。 +- Plan(计划)模式下,该工具与其他写操作一样被拦截。 +- Root 权限极高,误操作可能影响系统稳定性。建议只在确有需要时授权,并留意 AI 请求执行的命令内容。 + +## 常见问题 + +**状态一直是「不可用」** + +说明未检测到 `su`。确认设备确实已 root,且 root 管理器正常工作;部分方案(如仅 Magisk 隐藏、未真正提供 su)不会有 `su`。 + +**命令报错或没有输出** + +- 首次执行时请在设备上留意 root 管理器的授权弹窗,未允许则命令拿不到 root。 +- 部分命令即使 root 也可能受 SELinux 策略限制,可查看命令自身的报错信息。 +- 超时后 `su` 进程会被强杀,但其派生的子进程可能残留,属已知限制。 + +**与 Shizuku 该用哪个?** + +只做常规系统命令、读写 `/sdcard`,用 Shizuku(无需 root)即可;需要访问 `/data/data`、`/data/adb` 或需要 uid 0 的操作,才用 Root。 From b4fac0b269842b05aa00e49fc6dc270c7f28831d Mon Sep 17 00:00:00 2001 From: xixia666 <75162629+xixia666@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:55:05 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(agent):=20=E4=BF=AE=E6=AD=A3=20Root=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E9=97=A8=E7=A6=81=E4=B8=8E=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机测试发现两个问题,均源自首版实现: 1. Root 工具频繁误报「未授权 / 未检测到 root」。原因是拿 RootManager 的缓存状态当执行门禁:state 仅在 App 启动时初始化为 UNAVAILABLE, 必须手动进入设置页才会刷新;App 进程被系统回收重建后状态回退, 于是明明已授权却被直接拒绝,从未真正调用过 su。 改为不做状态门禁、无条件真实执行,由 root 管理器按需弹窗授权; 执行结果反过来校正 state(成功 → READY,空输出且非 0 → DENIED), 并在状态未知时后台补探测,避免缓存与事实脱节。 2. 提示词未说明 Root 与容器的路径关系,AI 把容器视角套到 Root 上, 误判访问 /Android/data/<其它应用>/ 需要「挂载」。实际上 uid 0 在 宿主上读写任意路径都不受限。 在 60-tools-and-paths.md 增加「容器 vs 宿主」路径对照表,并补充 「Root 直接操作宿主存储、不需要挂载」一节(含一条 cp 取文件到 工作区的示例);docs-site 的 root.md 同步补充。 另外:RootTool 在命令命中容器专属路径时追加纠正提示;授权被拒 (空输出且非 0 退出)时给出明确指引,不再只报「未检测到 root」。 --- .../main/assets/prompts/60-tools-and-paths.md | 44 ++++++++++++- .../feature/agent/domain/root/RootManager.kt | 45 ++++++++++++- .../agent/domain/tool/root/RootTool.kt | 64 ++++++++++++++----- docs-site/docs/guide/root.md | 40 ++++++++++++ 4 files changed, 175 insertions(+), 18 deletions(-) diff --git a/app/src/main/assets/prompts/60-tools-and-paths.md b/app/src/main/assets/prompts/60-tools-and-paths.md index a9c7048b..1e2166ac 100644 --- a/app/src/main/assets/prompts/60-tools-and-paths.md +++ b/app/src/main/assets/prompts/60-tools-and-paths.md @@ -18,7 +18,7 @@ ## 命令与终端工具 - `Bash`:执行一次性 shell 命令(列目录、搜索、构建、lint、格式化、git、装依赖等),同步等待命令结束并返回输出。默认超时 120 秒,上限 1800 秒;耗时命令(如安装依赖)可用 timeout 参数调大。 - `Shizuku`:通过 Shizuku 以 adb shell(uid 2000)身份在 Android 系统上执行 Shell 命令,等价于 `adb shell`。适用于需要 shell 权限的系统操作:`pm`/`am`/`cmd` 等系统命令、读写 `/sdcard`、查询系统状态等。与 `Bash`(在本地容器或远程 SSH 中执行)不同,它直接作用于宿主 Android 系统本身。使用前用户需已安装 Shizuku 并在本应用中授权(设置 → 运行环境 → Shizuku),未就绪时会返回错误提示。参数:`command`(必填)、`timeout`(秒,可选,默认 120、上限 1800)。 -- `Root`:以 root(uid 0)身份在 Android 系统上执行 Shell 命令。相比 `Shizuku`(adb shell,uid 2000),root 可访问系统受限目录并执行需要超级用户权限的操作:读写 `/data/data`、`/data/adb`,修改系统属性,管理其他应用等。使用前设备需已 root,首次调用会弹出 root 管理器授权框(设置 → 运行环境 → Root 可查看状态并触发授权),未就绪时会返回错误提示。参数:`command`(必填)、`timeout`(秒,可选,默认 120、上限 1800)。 +- `Root`:以 root(uid 0)身份在 Android **宿主真机**上执行 Shell 命令(**不是容器内**)。⚠️ **路径与 `Bash` 不同**:`Bash`/`readFile`/`writeFile`/`terminal` 运行在 Linux 容器内,它们看到的 `~/workspace`、`/etc`、`/root` 都是**容器内路径**;`Root` 看到的是宿主真实的 `/data`、`/system`、`/sdcard`。宿主的 App 私有目录为 `/data/user/0/<包名>/files/`(debug 测试包为 `com.aicode.debug`,正式包为 `com.aicode`),其中 `projects/<项目名>/` 是工作区、`aicode/` 是 AI 配置、`rootfs/` 是容器根文件系统。相比 `Shizuku`(adb shell,uid 2000),root 还可访问 `/data/data`、`/data/adb` 等受限目录。使用前设备需已 root,首次调用会弹出 root 管理器授权框(设置 → 运行环境 → Root 可查看状态并触发授权),未就绪时会返回错误提示。参数:`command`(必填)、`timeout`(秒,可选,默认 120、上限 1800)。 - 环境已内置常用开发工具:`git`、`rg`(ripgrep)、`py`/`python`、`node`。需要时优先直接通过 `Bash` 调用,不要先询问是否安装。 - `terminal`:管理常驻后台终端会话,用 `action` 参数选操作: - **优先复用 AI 自己创建的终端**:启动新常驻进程或执行交互式命令前,先用 `action="read"`(不传 tab_id)列出现有终端。若有 AI 之前创建的活跃标签,直接用 `action="send"` 复用,切忌反复 `start` 开一堆新窗口。 @@ -38,6 +38,48 @@ - `search`:rg 风格搜索。参数 `args`,如 `search(args="-n \"fun main\" ~/workspace/app")`。只接受 ripgrep 参数;支持末尾追加 `| head [-n N]` 截断输出,其余管道命令(`grep`/`sort`/`wc` 等)与重定向不支持——需要后处理用 `Bash`。 ## 路径约定 + +> ⚠️ **容器 vs 宿主是两套文件系统视图,同一路径含义不同。** + +`Bash` / `terminal` / `readFile` / `writeFile` / `editFile` / `list` / `search` **全部在容器内**,用容器路径。 +`Root` / `Shizuku` **直接作用于宿主真机**,用宿主路径。二者不可混用。 + +| 你要操作的东西 | 容器工具(Bash 等)用 | Root / Shizuku 用(宿主真机) | +| --- | --- | --- | +| 当前工作区文件 | `~/workspace/x` 或相对路径 `x` | `/data/user/0/<包名>/files/projects/<项目名>/x` | +| AI 配置 / 输出日志 | `~/.aicode/x` | `/data/user/0/<包名>/files/aicode/x` | +| 容器内系统文件(如 `/etc/apk`) | `/etc/apk/...` | `/data/user/0/<包名>/files/rootfs/etc/apk/...` | +| 宿主真机系统文件 | 看不到 | `/system/...`、`/data/...` | +| 手机存储 | 通过挂载点映射 | `/sdcard/...` | + +**`<包名>`**:debug 测试包为 `com.aicode.debug`,正式包为 `com.aicode`。 + +**选择原则**: + +- 改**项目代码 / 工作区文件** → 用 `Bash` / `readFile` / `writeFile`(容器路径); +- 改 **Android 系统本身**(`pm` / `am` / `cmd`、`/data/data`、系统属性、别的 App) → 用 `Root` / `Shizuku`(宿主路径); +- 若你发现自己在用 `Root` 操作 `~/workspace`,那一定搞错了:那不是真机路径,宿主上不存在(或只是 rootfs 里的空占位目录)。 + +### Root 直接操作宿主存储(不用挂载) + +⚠️ **`Root` 以 uid 0 运行在宿主上,任何宿主路径都能直接读写,不存在权限门槛,也不需要"挂载"。** +如果遇到"需要挂载/需要授权才能访问"的结论,那是把容器视角套到了 Root 上,是错的。 + +典型场景——**访问其他 App 的 `/Android/data/<包名>/` 目录**(Android 11+ 普通 App 与 adb shell 都被限制): + +``` +Root(command="ls /storage/emulated/0/Android/data/com.tencent.mobileqq/Tencent/QQfile_recv/") +``` + +**把文件从受限目录取进工作区**(工作区的宿主真机路径见上表): + +``` +# 直接用 Root 一条命令拷过去,无需任何中间步骤 +Root(command="cp '/storage/emulated/0/Android/data/com.tencent.mobileqq/Tencent/QQfile_recv/xxx.pdf' /data/user/0/<包名>/files/projects/<项目名>/") +``` + +拷贝完再回到容器工具(`list` / `readFile` / `Bash`)用 `~/workspace/xxx.pdf` 正常处理即可。 +**不要**改用 SQL/挂载/授权请求绕路——Root 一步到位。 - 项目根目录固定为容器内路径 `~/workspace`。你只看得到、也只需使用容器内路径。 - 项目文件用 `~/workspace/...`(如 `~/workspace/src/Main.kt`)或相对路径(如 `src/Main.kt`,相对 `~/workspace`)。 - `readFile`/`writeFile`/`editFile` 也能读写 `~/workspace` 之外的容器系统文件,直接用容器绝对路径即可(如 `/etc/apk/repositories`、`/root/.bashrc`、`/usr/local/bin/...`)。 diff --git a/app/src/main/java/com/aicode/feature/agent/domain/root/RootManager.kt b/app/src/main/java/com/aicode/feature/agent/domain/root/RootManager.kt index ae75dce7..36f99639 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/root/RootManager.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/root/RootManager.kt @@ -142,6 +142,17 @@ class RootManager @Inject constructor( return null } + /** + * 宿主视角的关键路径提示,供工具在检测到 AI 误用容器路径时给出纠正建议。 + * + * Root 作用于宿主 Android,App 私有目录为 ``;而 Bash 等容器工具看到的 + * `~/workspace`、`/etc` 等是容器内路径,两者不同。 + */ + fun hostPathHint(): String { + val files = context.filesDir.absolutePath + return "宿主对应位置:工作区 $files/projects/<项目名>/;AI 配置 $files/aicode/;容器根文件系统 $files/rootfs/" + } + /** * 执行 root 命令。未检测到 `su` 时抛异常,由调用方转成工具错误。 * @@ -149,9 +160,39 @@ class RootManager @Inject constructor( * 由 root 管理器在首次调用时弹框授权。 */ suspend fun runCommand(command: String, timeoutMs: Long): RootCommandResult { - val su = resolveSu() ?: throw IllegalStateException("未检测到 su,设备可能未 root") + val su = resolveSu() + ?: throw IllegalStateException("未检测到 su(设备可能未 root,或 root 方案未提供 su)") val timeout = timeoutMs.coerceIn(1_000L, MAX_TIMEOUT_MS) - return withContext(Dispatchers.IO) { execWithSu(su, command, timeout) } + val result = withContext(Dispatchers.IO) { execWithSu(su, command, timeout) } + // 用真实执行结果校正状态:root 授权由管理器弹窗掌管,状态缓存随时可能过期 + // (App 被系统回收后重建、用户在管理器里改了授权等),不能拿旧状态当门槛。 + updateStateFromResult(result) + return result + } + + /** + * 状态未知时在后台补一次探测(不阻塞调用方)。 + * + * 用于「首次调用/进程重建后 state 还是初值」的场景:此时不应拒绝执行, + * 而是并行探测、同时照常执行命令。 + */ + fun probeInBackgroundIfUnknown() { + if (_state.value != RootState.UNAVAILABLE) return + refreshState() + } + + /** 依据一次真实执行的结果刷新状态,避免状态与事实脱节。 */ + private fun updateStateFromResult(result: RootCommandResult) { + val next = when { + result.exitCode == 0 -> RootState.READY + // su 被拒绝/无法取得 root 时通常无输出且非 0 退出;有输出则视为命令自身失败,不改状态 + result.output.isBlank() -> RootState.DENIED + else -> return + } + if (next != _state.value) { + _state.value = next + FileLogger.d(TAG, "root 状态校正为 $next(exit=${result.exitCode})") + } } /** diff --git a/app/src/main/java/com/aicode/feature/agent/domain/tool/root/RootTool.kt b/app/src/main/java/com/aicode/feature/agent/domain/tool/root/RootTool.kt index df4fe7b4..3b061dd7 100644 --- a/app/src/main/java/com/aicode/feature/agent/domain/tool/root/RootTool.kt +++ b/app/src/main/java/com/aicode/feature/agent/domain/tool/root/RootTool.kt @@ -3,7 +3,6 @@ package com.aicode.feature.agent.domain.tool.root import com.aicode.core.util.FileLogger import com.aicode.feature.agent.domain.container.BoundedOutput import com.aicode.feature.agent.domain.root.RootManager -import com.aicode.feature.agent.domain.root.RootState import com.aicode.feature.agent.domain.tool.AgentTool import com.aicode.feature.agent.domain.tool.ParameterType import com.aicode.feature.agent.domain.tool.PendingToolPermission @@ -36,15 +35,26 @@ class RootTool @Inject constructor( const val DEFAULT_TIMEOUT_SECONDS = 120L const val MAX_TIMEOUT_SECONDS = 1_800L + + /** + * 容器专属路径特征。命中即在结果后追加提示:Root 作用于宿主,这些路径在宿主上 + * 不存在(或指向 rootfs 内的空占位目录),AI 多半是想操作容器工作区。 + */ + val CONTAINER_PATH_REGEX = Regex( + """(?:^|[\s"'=;&|(`])(~/.aicode|~/workspace|/root/workspace|/root/.aicode|/root/.config)""" + ) } override val name = "Root" override val description = - "以 root(uid 0)身份在 Android 系统上执行 Shell 命令。" + - "相比 `Shizuku`(adb shell,uid 2000),root 可访问系统受限目录并执行需要超级用户权限的操作:" + - "读写 `/data/data`、`/data/adb`,修改系统属性,管理其他应用等。" + - "与 `Bash`(在本地容器或远程 SSH 中执行)不同,它直接作用于宿主 Android 系统本身。" + + "以 root(uid 0)身份在 Android 宿主系统上执行 Shell 命令(真机视角,不是容器内)。" + + "⚠️ 路径与 `Bash` 不同:`Bash`/`readFile`/`writeFile`/`terminal` 运行在 Linux 容器内," + + "它们看到的 `~/workspace`、`/etc`、`/root` 都是容器内路径;" + + "`Root` 作用于宿主真机,看到的是真实的 `/data`、`/system`、`/sdcard`。" + + "宿主的 App 私有目录为 `/data/user/0/<包名>/files/`,其中 `projects/<项目名>/` 是工作区、" + + "`aicode/` 是 AI 配置、`rootfs/` 是容器根文件系统。" + + "相比 `Shizuku`(adb shell,uid 2000),root 还可访问 `/data/data`、`/data/adb` 等受限目录。" + "使用前设备需已 root 并在弹出授权框时允许,未就绪时会返回错误提示。" override val permissionPolicy = ToolPermissionPolicy.ASK @@ -87,18 +97,32 @@ class RootTool @Inject constructor( ) } + /** + * 若命令里出现容器专属路径,返回一段纠正提示(否则返回空串)。 + * + * 只提示、不改写命令:自动翻译路径一旦判断错会静默写错位置,比报错更糟。 + */ + private fun containerPathWarning(command: String): String { + val hit = CONTAINER_PATH_REGEX.find(command)?.groupValues?.get(1) ?: return "" + return buildString { + append("\n\n[Root 路径提示] 命令中出现容器路径「") + append(hit) + append("」。`Root` 直接作用于宿主 Android,该路径在宿主上不存在(或指向 rootfs 内的空占位目录)。") + append("\n") + append(rootManager.hostPathHint()) + append("\n若目标是容器工作区文件,请改用 `Bash` / `readFile` / `writeFile`(它们才在容器内)。") + } + } + override suspend fun execute(args: Map): ToolResult { val command = args["command"]?.jsonPrimitive?.contentOrNull ?: return ToolResult.Error("缺少必需参数: command") - // UNAVAILABLE 时提前失败,避免无谓地起子进程;DENIED 仍尝试执行, - // 因为状态可能是未探测时的初值,首次调用会触发 root 管理器弹框重新授权。 - if (rootManager.state.value == RootState.UNAVAILABLE) { - return ToolResult.Error( - "未检测到 root:设备可能未 root,或 root 方案未提供 su", - code = "ROOT_NOT_AVAILABLE" - ) - } + // 刻意不做 state 门禁:state 只是缓存(App 启动/进程重建后为初值),拿它拒绝执行会 + // 造成「明明已授权却报未 root」。这里一律真实执行一次,由 root 管理器在需要时弹窗授权, + // 执行结果再反过来校正 state(见 RootManager.updateStateFromResult)。 + // 状态未知时顺手在后台补探测,供设置页显示。 + rootManager.probeInBackgroundIfUnknown() return try { val timeoutMs = resolveTimeoutMs(args) @@ -106,12 +130,22 @@ class RootTool @Inject constructor( val result = rootManager.runCommand(command, timeoutMs) val output = BoundedOutput().apply { append(result.output) }.build() FileLogger.v(TAG, "Root exec 完成,输出 ${result.output.length} 字符,退出码 ${result.exitCode}") - ToolResult.Success(JsonPrimitive(output)) + val denialHint = if (result.exitCode != 0 && output.isBlank()) { + "\n\n[Root 提示] 命令没有输出且退出码非 0(${result.exitCode})," + + "通常是 root 授权被拒(请在 root 管理器弹窗中选择「允许」,或在其应用列表中放开本应用)。" + } else { + "" + } + ToolResult.Success(JsonPrimitive(output + containerPathWarning(command) + denialHint)) } catch (e: CancellationException) { throw e } catch (e: Exception) { FileLogger.e(TAG, "Root exec 失败: $command", e) - ToolResult.Error("执行 Root 命令失败: ${e.message}") + ToolResult.Error( + "执行 Root 命令失败: ${e.message}\n" + + "(若提示未检测到 su:设备可能未 root;若设备确实已 root,请确认 root 管理器未禁用/隐藏 su)", + code = "ROOT_EXEC_FAILED" + ) } } } \ No newline at end of file diff --git a/docs-site/docs/guide/root.md b/docs-site/docs/guide/root.md index 3b237879..6ce724b7 100644 --- a/docs-site/docs/guide/root.md +++ b/docs-site/docs/guide/root.md @@ -20,6 +20,46 @@ Root 权限最高,请谨慎授权。 需要设备已通过 Magisk / KernelSU / APatch 等方案获取 root,且 root 管理器提供了 `su`。常见路径包括 `/system/bin/su`、`/system/xbin/su`、`/sbin/su`、`/debug_ramdisk/su` 等,AiCode 会自动探测。 +## ⚠️ 先搞清楚:容器路径 ≠ 真机路径 + +AiCode 里有两套文件系统视图,**同一个路径在两边含义不同**: + +- `Bash`、`terminal`、文件树、`readFile`/`writeFile` 等都在 **Linux 容器内**; +- `Root`(以及 `Shizuku`)直接作用于 **宿主真机**。 + +| 你想操作 | 容器工具用(Bash 等) | Root 用(真机) | +| --- | --- | --- | +| 当前工作区文件 | `~/workspace/xxx` | `/data/user/0/<包名>/files/projects/<项目名>/xxx` | +| AI 配置 | `~/.aicode/xxx` | `/data/user/0/<包名>/files/aicode/xxx` | +| 容器内系统文件 | `/etc/xxx` | `/data/user/0/<包名>/files/rootfs/etc/xxx` | +| 真机系统文件 | 看不到 | `/system/xxx`、`/data/xxx` | +| 手机存储 | 经挂载点映射 | `/sdcard/xxx` | + +`<包名>`:正式包为 `com.aicode`,debug 测试包为 `com.aicode.debug`。 + +**所以**:改项目里的文件,用 `Bash` 或文件树;要动 Android 系统本身(`pm`/`am`、`/data/data`、系统属性),才用 `Root`。 +如果你让 AI 用 `Root` 去操作 `~/workspace`,那是无效的——那不是真机路径。 + +## 直接访问手机上的受限目录 + +Android 11 起,普通 App 和 adb shell 都被限制访问 `/Android/data/<其它应用>/` 这类目录, +于是常见做法是"申请权限"或"挂载"。**用 Root 不需要这些**——uid 0 在宿主上读写任意路径都不受限制。 + +例:读取 QQ 接收的文件目录 + +``` +ls /storage/emulated/0/Android/data/com.tencent.mobileqq/Tencent/QQfile_recv/ +``` + +把文件取进当前工作区时,直接一条命令拷过去即可(工作区在真机上的路径见上表),不需要挂载: + +``` +cp '/storage/emulated/0/Android/data/com.tencent.mobileqq/Tencent/QQfile_recv/xxx.pdf' \ + /data/user/0/<包名>/files/projects/<项目名>/ +``` + +拷完就能在文件树 / `Bash` 里用 `~/workspace/xxx.pdf` 正常处理。 + ## 在 AiCode 中授权 打开「设置 → 运行环境 → Root」,页面会显示当前状态: