-
Notifications
You must be signed in to change notification settings - Fork 37
feat(agent): 新增 Root 执行后端与 root 命令工具 #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 命令(**不是容器内**)。⚠️ **路径与 `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` 开一堆新窗口。 | ||
|
|
@@ -37,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 运行在宿主上,任何宿主路径都能直接读写,不存在权限门槛,也不需要"挂载"。** | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Do not describe Root access as unconditional. uid 0 bypasses normal application and DAC restrictions, but SELinux policy and read-only mounts can still reject an operation. The guide already documents the SELinux case.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| 如果遇到"需要挂载/需要授权才能访问"的结论,那是把容器视角套到了 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/...`)。 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: sed -n '1,260p' app/src/main/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngine.kt
rg -n 'AUTO|catastrophic|PROTECTED_SYSTEM_DIRS|ROOT_LEVEL_GLOB|SHELL_TOOLS|rm -rf' app/src/main app/src/testRepository: jieapi/AiCode Length of output: 43734 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- policy engine catastrophic path ---'
sed -n '240,380p' app/src/main/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngine.kt
printf '%s\n' '--- permission tests relevant to Root and AUTO ---'
sed -n '70,145p' app/src/test/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngineTest.kt
sed -n '300,380p' app/src/test/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngineTest.kt
rg -n -i 'class .*Root|object .*Root|RootTool|toolName.*Root|\"Root\"|root command|root shell|su |shizuku|/data|/system|/vendor|/product|/sdcard|/storage' app/src/main app/src/test --glob '*.kt' --glob '*.md' --glob '*.xml'
printf '%s\n' '--- tracked candidate files ---'
git ls-files | rg -i 'root|shizuku|permission|safety|prompt|tool'Repository: jieapi/AiCode Length of output: 50369 🤖 get_repo_knowledge executed:
Length of output: 326 🏁 Script executed: set -eu
sed -n '240,380p' app/src/main/java/com/aicode/feature/agent/domain/permission/ToolPermissionPolicyEngine.kt
printf '%s\n' '--- Root references ---'
rg -n -i 'Root|Shizuku|/data|/system|/vendor|/product|/sdcard|/storage|rm -rf|catastrophic' app/src/main app/src/test --glob '*.kt' --glob '*.md' --glob '*.xml'
printf '%s\n' '--- files ---'
git ls-files | rg -i 'root|shizuku|permission|safety|prompt|tool'Repository: jieapi/AiCode Length of output: 50370 🏁 Script executed: set -eu
printf '%s\n' '--- RootTool ---'
sed -n '20,125p' app/src/main/java/com/aicode/feature/agent/domain/tool/root/RootTool.kt
printf '%s\n' '--- Root registration ---'
sed -n '288,306p' app/src/main/java/com/aicode/di/AgentModule.kt
printf '%s\n' '--- host-path documentation ---'
sed -n '18,55p' app/src/main/assets/prompts/60-tools-and-paths.md
printf '%s\n' '--- AUTO documentation ---'
sed -n '1,18p' app/src/main/assets/prompts/agent/auto-mode.mdRepository: jieapi/AiCode Length of output: 10390 Protect host paths for
Use host-specific protected paths for host tools and keep container paths for 🛡️ Sketch of the fixval HOST_PROTECTED_DIRS = setOf(
"/data", "/system", "/system_ext", "/vendor", "/product",
"/sdcard", "/storage", "/cache", "/metadata", "/odm", "/apex"
)Pass the tool name into 🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * 合并后的终端会话工具:其 `start` 动作承载 shell 命令,需走指令级前缀匹配; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,236 @@ | ||||||||||||||||||||||||||
| 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 <command>` 起子进程。`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<RootState> = _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 | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * 宿主视角的关键路径提示,供工具在检测到 AI 误用容器路径时给出纠正建议。 | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * Root 作用于宿主 Android,App 私有目录为 `<filesDir>`;而 Bash 等容器工具看到的 | ||||||||||||||||||||||||||
| * `~/workspace`、`/etc` 等是容器内路径,两者不同。 | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| fun hostPathHint(): String { | ||||||||||||||||||||||||||
| val files = context.filesDir.absolutePath | ||||||||||||||||||||||||||
| return "宿主对应位置:工作区 $files/projects/<项目名>/;AI 配置 $files/aicode/;容器根文件系统 $files/rootfs/" | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * 执行 root 命令。未检测到 `su` 时抛异常,由调用方转成工具错误。 | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * 不预先依赖 [state]:授权状态可能尚未探测(避免启动即弹框),此处直接调用, | ||||||||||||||||||||||||||
| * 由 root 管理器在首次调用时弹框授权。 | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| suspend fun runCommand(command: String, timeoutMs: Long): RootCommandResult { | ||||||||||||||||||||||||||
| val su = resolveSu() | ||||||||||||||||||||||||||
| ?: throw IllegalStateException("未检测到 su(设备可能未 root,或 root 方案未提供 su)") | ||||||||||||||||||||||||||
| val timeout = timeoutMs.coerceIn(1_000L, MAX_TIMEOUT_MS) | ||||||||||||||||||||||||||
| val result = withContext(Dispatchers.IO) { execWithSu(su, command, timeout) } | ||||||||||||||||||||||||||
|
Comment on lines
+162
to
+166
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: rg -n 'fun executeTool|\.execute\(args|execute\(.*JsonElement|withContext\(Dispatchers\.(IO|Default)|runCommand\(' app/src/main/java/com/aicode/feature/agent
sed -n '130,190p' app/src/main/java/com/aicode/feature/agent/domain/root/RootManager.ktRepository: jieapi/AiCode Length of output: 13821 🏁 Script executed: set -o pipefail
printf '%s\n' '--- symbol and registration search ---'
rg -n -C 3 'RootTool|RootManager|runCommand\(|executeTool|tool.*execute|execute\(tool|\.execute\(.*args|ToolRegistry|ToolExecutor' app/src/main/java/com/aicode/feature/agent
printf '%s\n' '--- RootTool ---'
cat -n app/src/main/java/com/aicode/feature/agent/domain/tool/root/RootTool.kt
printf '%s\n' '--- AgentTool and contextual base ---'
cat -n app/src/main/java/com/aicode/feature/agent/domain/tool/AgentTool.kt
cat -n app/src/main/java/com/aicode/feature/agent/domain/tool/AbstractContextualTool.kt
printf '%s\n' '--- RootManager relevant definitions ---'
cat -n app/src/main/java/com/aicode/feature/agent/domain/root/RootManager.kt | sed -n '1,190p'Repository: jieapi/AiCode Length of output: 39277 🏁 Script executed: set -o pipefail
printf '%s\n' '--- workflow execute path and dispatcher boundaries ---'
cat -n app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt | sed -n '740,850p'
cat -n app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt | sed -n '900,975p'
rg -n -C 4 'suspend fun (run|execute|start)|withContext\(Dispatchers|launch\{|collectLatest|collect \{' app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt
printf '%s\n' '--- RootTool registration and registry API ---'
rg -n -C 5 'register\(|RootTool|ToolRegistry' app/src/main/java app/src/main/java/com/aicode | head -n 300
cat -n app/src/main/java/com/aicode/feature/agent/domain/tool/ToolRegistry.ktRepository: jieapi/AiCode Length of output: 50369 🏁 Script executed: set -o pipefail
printf '%s\n' '--- workflow execute path and dispatcher boundaries ---'
cat -n app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt | sed -n '740,850p'
cat -n app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt | sed -n '900,975p'
rg -n -C 4 'suspend fun (run|execute|start)|withContext\(Dispatchers|launch\{|collectLatest|collect \{' app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt
printf '%s\n' '--- RootTool registration and registry API ---'
rg -n -C 5 'register\(|RootTool|ToolRegistry' app/src/main/java/com/aicode | head -n 300
cat -n app/src/main/java/com/aicode/feature/agent/domain/tool/ToolRegistry.ktRepository: jieapi/AiCode Length of output: 50369 🏁 Script executed: set -o pipefail
printf '%s\n' '--- executeEvents definition and tool batch calls ---'
rg -n 'executeEvents|runToolSync|runToolStream|batchToolCalls|flowOn|withContext' app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt
sed -n '630,745p' app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt
printf '%s\n' '--- ViewModel ordinary workflow caller ---'
sed -n '1260,1375p' app/src/main/java/com/aicode/feature/agent/presentation/AIAgentViewModel.kt
printf '%s\n' '--- all direct runCommand bindings ---'
rg -n -C 2 'runCommand\(' app/src/main/java/com/aicode/feature/agent/domainRepository: jieapi/AiCode Length of output: 17474 🏁 Script executed: set -o pipefail
printf '%s\n' '--- executeEvents declaration and body start ---'
sed -n '440,520p' app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt
printf '%s\n' '--- exact line-980 context ---'
sed -n '965,1005p' app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt
printf '%s\n' '--- workflow class and interface declarations ---'
sed -n '70,115p' app/src/main/java/com/aicode/feature/agent/domain/workflow/StatefulAgentWorkflow.kt
rg -n -C 3 'interface AgentWorkflow|abstract class AgentWorkflow|executeEvents\(' app/src/main/java/com/aicode/feature/agent/domain/workflowRepository: jieapi/AiCode Length of output: 10806 Move
♻️ Proposed fix- val su = resolveSu()
- ?: throw IllegalStateException("未检测到 su(设备可能未 root,或 root 方案未提供 su)")
val timeout = timeoutMs.coerceIn(1_000L, MAX_TIMEOUT_MS)
- val result = withContext(Dispatchers.IO) { execWithSu(su, command, timeout) }
+ val result = withContext(Dispatchers.IO) {
+ val su = resolveSu()
+ ?: throw IllegalStateException("未检测到 su(设备可能未 root,或 root 方案未提供 su)")
+ execWithSu(su, command, timeout)
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
| // 用真实执行结果校正状态: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})") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * 以 `su -c <command>` 执行并收集输出。 | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * 输出读取与等待结束必须并行:管道写满会阻塞子进程,先 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() | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Scope container-path guidance to the local-container backend.
Bashcan use remote SSH, where its filesystem is the remote host rather than the local container. The current statements can direct commands to incorrect paths.app/src/main/assets/prompts/60-tools-and-paths.md#L44-L44: State that the container-path rule applies only when the local container backend is active.docs-site/docs/guide/root.md#L27-L27: Add the remote-SSH exception before the Root-versus-container path mapping.📍 Affects 2 files
app/src/main/assets/prompts/60-tools-and-paths.md#L44-L44(this comment)docs-site/docs/guide/root.md#L27-L27🤖 Prompt for AI Agents