# 0.2.4 测试版:副屏输入、截图新鲜度与沙箱可执行性 本版尚未发布,用于下一次测试版构建;基线为 `v0.2.3-mobile-323`。 ## 改动 - 副屏动作新增受控 `long_press`、`keyevent` 和可打印 ASCII `text`,两端都执行白名单、坐标、时长和长度校验;中文和 emoji 继续使用分享 Intent 或设备输入法。 - 副屏截图返回目标包名、`frameAtElapsedMs` 和 `frameReused`,帮助 AI 区分缓存帧与新帧;静止页面仍可能复用缓存,不承诺高帧率流。 - **修复沙箱内命令与沙箱内 PTY 全部失败(方案已按真机证据改写)**:PRoot 会把受限进程的 `execve` 改写成对 `PROOT_LOADER` 的 `execve`;沙箱用 `landlock-run` 把 Landlock 规则套在 `--ro /`(外加 `/tmp` 与工作区的读写授权)上,探测(`--probe`)不执行程序所以照旧通过。初版据此把 loader 以**实体文件**落进运行时根内的 `root/.dsh-mobile/dsh-runner/loader`,结果在真机上引发下面那条回归;现在改为**按实测选择落地形态**,并把「受限执行被内核拒绝」与「其它启动器级失败」分开报码(`EXEC_LAUNCHER_DENIED`)。 - **修复升级后连普通(未受限)PRoot 也起不来**:把 loader 换成运行时根里的实体文件后,真机出现「PRoot 无法加载 Ubuntu 程序。」(`PROOT_GUEST_EXEC_FAILED`)。第一层原因确实是 loader 缺属主执行位:写入用的 `File.setExecutable(true, false)` 没检查返回值,而就绪判定只看「常规文件 + 字节一致」,于是坏文件被永久复用。现在写入改用 `Os.chmod(0o700)`,判定改用新策略对象 `RuntimeLauncherPolicy`(同时看权限位与 `access(X_OK)`),每次启动记一条 `RUNTIME_PHASE|phase=launcher` 与 `loader state:`。 - **loader 落地形态改为「实测哪个能执行就用哪个」(本轮最终修法)**:修好执行位后真机仍报同一句错误。取证排除了权限位、SELinux(同一窗口 6 条带 `execute` 的审计记录全是 `granted`)与挂载 noexec(`/dev/block/dm-77 /data f2fs rw,seclabel,nosuid,nodev,noatime,…` 没有 `noexec`;带 noexec 的只有 tmpfs 与 `/dev/fuse`)⇒ 应用**执行不了自己数据目录里的常规文件**(与 Android 15 起「需 `security.android.exec` 属性才允许执行」的策略一致;本机 `Os.setxattr` 静默失败、观测 `stamped=false`)。PRoot 全程真正 `execve` 的只有 `PROOT_LOADER`(访客程序与动态链接器都由 loader 用 mmap 拉起),所以 loader 落在应用数据目录,**每次**访客执行都会 EACCES,而 PRoot 把这句错误记在用户要运行的程序名下。现在 loader 按 `loader_root_copy` → `loader_symlink`(指回 `nativeLibraryDir` 的 `apk_data_file`,系统允许执行)→ `loader_private` 的顺序**逐个实测**(`exec probe: name=loader,path=…,result=started,exit=N`,每次启动都写),第一个真能起来的胜出,胜出形态记进 `SharedPreferences(runtime_loader_form)` 下次优先;三种都不行才抛 `RUNNER_PREPARE_FAILED`(消息里带每次尝试的形态与原因)。`launchLoaderPath()` 的语义随之变成「刚刚实测能执行的那一个」。**执行位巡检不再阻断探测**:上一版 `exec probe` 被执行位标记的提前 return 挡掉了,这一层完全没有日志。 - **修复「链接降级复制丢了执行位」(保留为加固,但不再当作本次回归的根因)**:`SafeRootfsExtractor` 的三处降级复制改用 `Os.chmod` 按源文件真实权限位落位(失败写 `rootfs copy chmod failed:`);每次启动打 `guest file:` 形态,并对每个运行时根做一次**有界**执行位巡检(只给「缺属主执行位且文件头是 ELF 或 `#!`」的常规文件补位,扫完才落标记 `.exec-repair.done`),诊断记 `RUNTIME_PHASE|phase=exec_repair`(`code=EXEC_BIT_REPAIRED|EXEC_BIT_INTACT`)。真机取证表明它不是挡住启动的原因(9 条关键路径 `execAccess` 全为 `true`、解释器实体 0755,失败一字未变),但 `File.setExecutable` 会静默失败这一点已在同机由 shell uid 复现,所以这层加固仍然保留。 - 自检新增结论码 `EXEC_LAUNCHER_DENIED`:把「受限执行被内核拒绝」与「其它启动器级失败(`EXEC_LAUNCHER_FAILED`)」分开,下次不必再靠上机复现才知道是权限被判否。 - **修复沙箱内命令与沙箱内 PTY 全失败(根因已用 A/B 取证确证)**:上游 `landlock-run` 的规则集只授权访客根(`--ro /`),而 PRoot 会把受限进程的 `execve` 改写成对 **`PROOT_LOADER`** 的 `execve` —— 那是 APK `nativeLibraryDir` 里的 `libdsh_proot_loader.so`,真实路径在授权根之外,于是内核拒绝(退出码 125)。现在 App ①把 `nativeLibraryDir` 绑到访客 `/.dsh-native`(必需级绑定,兼容性回退不会丢掉它);②新增资产 `assets/support/sandbox-runner.sh`,每次启动写入访客 `/root/.dsh-mobile/`,把上游给的 bwrap 形态档参数翻译成 `landlock-run` 的 `--ro` / `--rw`,并追加 loader 目录与 `/dev`、`/proc`、`/dev/null`、`/dev/ptmx`、`/dev/pts` 的授权;③在 `launcher-providers.patch.json` 里无条件写入 `{"id":"sandbox","config":{"runnerCommand":["/bin/sh","/root/.dsh-mobile/sandbox-runner.sh"],"runnerFailureSignatures":["dsh-sandbox-runner: "]}}`(**不依赖用户是否配置模型供应商**;条目 id 用的是运行时配置里的真实 id `sandbox` —— `dsh-base@0.2.0-rc.2` 的 `cordis.patch.yml` 里是 `- id: sandbox` + `name: '@deepseek-ai/dsh-sandbox-local'`,覆盖只按 id 定位,对不上时上游只 warn 一句就跳过);④启动档缓存键增加常量片段 `PROFILE_KEY_SANDBOX_BIND`,否则已缓存旧档的设备会一直复用不含新绑定的档。运行器遇到不认识的参数会**失败关闭**(退出 125 并打印 `dsh-sandbox-runner: …`),上游改档参数时不会悄悄跑在没有授权的位置。 - **能力边界**:这条路径提供的是 **Landlock 的文件效果限制**(只授权列出的路径),**没有** PID 命名空间隔离,也**没有**挂载隔离 —— 与上游 landlock 档本身一致;Android 应用建不了用户命名空间,bwrap 档在移动端不可用。 - 文档加入 HONOR AAP-AN00 / Android 17 真机回执,明确记录硬链接、C 编译器、make、白帧和中文输入限制。 - **修复「插件因运行时升级丢失」(真机证实的根因)**:第三方插件的包本体装在保留区 `root/.dsh-mobile/plugin-manager/versions/<事务>/node_modules/`(跨版本保留),而让运行时解析到它的链接落在 `root/.dsh/profiles/node_modules/<包名>`(或 `profiles/web/node_modules`)——`profiles` **不在**保留名单里,升级后随新 rootfs 整体重建。于是清单经状态快照回填后仍把插件列为「已启用」,包本体也还在保留区,却已经没有模块根指向它:插件列表显示「未安装」,Harness 也加载不到该组合包。现在 `repair` 增加一步「接回插件链接」:对清单里登记过、当前解析不到的非运行时作用域插件,从保留区里最近一次安装的版本目录取副本,按安装时同一条落点规则重建 junction;已经能解析、目标位被别的有效实体占着时不动(只清掉本来就是坏的悬空链接),保留区里确实没有副本时只记 `missing`(这种情况只能重装)。JSON 摘要新增 `plugins` / `relinked` / `missing` 三个计数,并写进 `REPAIR` 诊断事件。 - 副屏空白帧检测与错误码语义:截图前先按采样行判定画面是否空白(横纵各最多 3 条采样线、每条最多 48 点、逐通道极差 ≤8 判空白),空白帧不再写进并复用缓存,`state` 与截图回报新增 `frameBlank`;`VIRTUAL_SCREEN_STOPPED`(会话已切换,可重新读取状态)与 `VIRTUAL_SCREEN_UNSUPPORTED` 补进文档,未知动作改为 `VIRTUAL_SCREEN_INVALID`。**这一项只有单测覆盖,真机白帧未复现、跨 Binder 的码传播未验证。** - 副屏预览节奏可选:默认「省电」沿用原来的 180 毫秒限帧采集,另加 15/30/60 fps(采集间隔 66/33/16 毫秒);状态新增实际生效的 `previewMode`(`limited-fps` 或 `realtime-<模式>`)、`frameIntervalMs` 与最近 24 帧实测的 `frameFps`,原生预览页底部也有一行按钮可切换。只提高采集与预览频率,不改「静止页面复用最近一帧」,也不代表目标应用自身以该帧率渲染。 - 副屏触摸直传:预览区域把手势逐事件发给副屏(进程内 `InputManager.injectInputEvent`),状态字段 `touchChannel=stream`;通道不可用时自动退回原来的离散 `tap`/`swipe`(`touchChannel=discrete`),行为与旧版一致。触摸走独立通道,不占用普通动作的串行位,拖动不会因为「上一步仍在处理」被当成 `VIRTUAL_SCREEN_BUSY` 拒绝。 - 副屏目标应用可切换(工具 `mobile_virtual_screen_target`,即 `action=target`):把指定应用启动或迁移到当前副屏,不重建显示器、会话不重启,状态继续回报 `packageName`;按副屏编号确认目标已在该屏恢复后才算成功,校验失败时不改变当前目标。 - **副屏节点树与中文输入(工具 `mobile_virtual_screen_tree` 即 `action=tree`,以及含非 ASCII 的 `action=text`)**:由应用自己的无障碍服务按显示编号取窗口(只读、最多 400 节点、最多 8 层、单字段 120 字符),敏感窗口整棵拒绝、未启用无障碍时 `available=false` 并给出原因,**不回退主屏**;中文与 emoji 走同一条无障碍定向注入,只作用于聚焦的可编辑、非密码输入框,未满足时报 `VIRTUAL_SCREEN_UNAVAILABLE`。纯可打印 ASCII 仍由 `/system/bin/input` 输入。触屏/键位与节点数据都只在本机处理,不经过 Shell 通道。**这一项目前只有单测覆盖,真机回执见下面「验证」段。** ## 未解决的设备限制 - 硬链接被拒绝属于该设备的 PRoot/内核策略限制;应用不会把它伪报为已修复。 - DSH 副屏预览页前台时可能出现白帧;真实第三方应用、小窗互切、长期后台和锁屏恢复仍需继续验证。 - 目标应用副屏仍不支持多指手势,预览也仍是采样轮询管线、不承诺视频级的连续帧;多副屏并行会话本轮未实现(状态里始终只有一块副屏、一个会话标识与一个显示编号)。节点树与中文输入需要用户在系统设置里启用 DSH 的无障碍服务,未启用时只回报可用性与原因;剪贴板是否可用仍取决于设备与目标应用。 ## 验证 - Kotlin JVM 单测 72 类 / 573 用例 / 0 失败(含新增的 `RuntimeLauncherPolicyTest`、`RuntimeLauncherFilesTest`、`RuntimeGuestExecutablesTest` 与 `RuntimeSandboxRunnerTest`:执行位判定、loader 形态顺序与探测码、标记保护的一次性巡检、`RuntimeFilesTest` 内容比对、`RuntimeSelfCheckPolicyTest` 新码、沙箱运行器的覆盖条目与「Kotlin 常量 ↔ 访客脚本 ↔ 自检脚本逐字一致」的漂移守卫、`~`/`=` 的放行与既有拒绝项;本轮又新增 `VirtualScreenFramePolicyTest` 6 例、`VirtualScreenErrorCodeTest` 4 例与 `VirtualScreenTreeTest` 9 例,`VirtualScreenPolicyTest` 扩到 11 例)、`lintRelease` 0 Error / 48 Warning。其中 21 例依赖 POSIX 权限位或符号链接,在 Windows 主机上按项目既有约定 `assumeNoException` 跳过,由 CI 的 Linux 跑同一批用例。 - 前端:`vitest run`、`npx tsc -b`、`eslint . --max-warnings 0` 通过;脚本层 `node --test scripts/*.test.mjs` 166 项 / 162 通过 / 0 失败 / 4 跳过(跳过项是 Windows 上缺原生写锁绑定与 POSIX 可执行位判定;其中 `scripts/plugin-manager.test.mjs` 60 / 60,含「升级后接回插件链接」与「无副本只记 missing」两例;`scripts/mobile-device-tools.test.mjs` 13 / 13,含本轮新增的副屏中文文本、帧率配置、目标切换与节点树深度四例)。 - 真机复现(HONOR AAP-AN00,Android 17,shell uid 下同一套设备二进制):loader 在授权根外时 `--ro /` / 自检原样 / 沙箱内 shell 三条全部退出码 125,把 loader 以实体文件放进根内后三条全部退出码 0;根内符号链接仍为 125。**更正**:这组数据是在 `shell uid + /data/local/tmp` 下取得的,而 `/data/local/tmp` 不受「应用数据目录不可执行」那条策略约束,因此不能推出「应用自己也能执行根内实体 loader」——loader 形态必须按应用 uid 上的实测结果选择。 - loader 与解释器执行位复现(同机 shell uid):同一份 loader 权限 `0700` 时 PRoot 正常启动并打印标记,改成 `0600` 时复现出与应用真机**逐字相同**的 `proot error: execve("/usr/bin/env"): Permission denied`(退出码 1;文件缺失时报的是 `No such file or directory`,两种故障可区分);`PROOT_LOADER= -r /data/local/tmp/rt/root /usr/bin/env` 在解释器 `0755` 时退出码 0、正常打印访客环境,把 `usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1` 改成 `0644`(被运行的 `usr/bin/env` 本身仍是 `0755`)后报出同一句话,改回 `0755` 立刻恢复。两条只证明**机制成立**:报错文本一样,被拒的对象可能是程序、它的解释器,或 loader 自己,所以不能只据报错文本归因。 - 应用 uid 真机回执(HONOR AAP-AN00,Android 17,`6a4f1c0` 覆盖安装后):`loader state: code=RUNNER_OK,reason=loader_in_root,detail=regular=true,size=1632,mode=0700,execAccess=true,stamped=false`,9 条 `guest file:` 全部 `execAccess=true`(解释器实体 `usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1` 为 `mode=0755`),随后仍是 `guest probe failed code=PROOT_GUEST_EXEC_FAILED … proot error: execve("/usr/bin/env"): Permission denied`;同一窗口 SELinux 的 6 条 `execute` 审计全是 `granted`(含 `name="loader"`),`/data` 挂载无 `noexec`(`/dev/block/dm-77 /data f2fs rw,lazytime,seclabel,nosuid,nodev,noatime,…`)⇒ 三条常见解释都被排除,指向「应用数据目录里的常规文件不可执行」。 - **本轮修法的应用 uid 复测要求(已于 2026-10-03 19:03 满足,见下一段)**:真机日志要出现 `loader state: …probe=started…`(形态预期是 `loader_symlink`)、`exec probe: name=loader,path=…,result=started,exit=N`,并且 `guest probe failed code=PROOT_GUEST_EXEC_FAILED` 不再出现。 - **(已完成的复测,2026-10-03 19:03,`67e02dc` 覆盖安装后)**:`loader state: code=RUNNER_EXEC_DENIED,reason=loader_root_copy,probe=failed,detail=…error=13, Permission denied…` → `loader state: code=RUNNER_OK,reason=loader_symlink,probe=started,exit=139`(**符号链接胜出**)→ `exec repair: scanned=46601,repaired=1062,failed=0,elapsedMs=9450,completed=true` → 之后 `exec repair: skipped,reason=marker` 与每次启动都写的 `exec probe: name=loader,…,result=started,exit=139`;同一窗口 SELinux 记录 `avc: denied { execute_no_trans } for path="…/dsh-runner/loader"`(应用数据目录里的常规文件确实不可执行,这条把上一版的强推断升级成直证),而 `current/usr/bin/env`、`opt/node/bin/node`、解释器副本上的 `granted { execute }` 与先前一致;计数核对:`PROOT_GUEST_EXEC_FAILED` **0 次**、`guest probe failed` **0 次**;随后 `ActivityTaskManager … io.deepseekharness.mobile/.HarnessActivity` 启动成功,界面进入真实 Harness 对话页并正常应答。⇒ 未受限启动这一层的修复在应用 uid 上成立。 - **沙箱层修法的真机验证(shell uid 复现树,脚本 `_probe/guest-tests/runner-device-test.sh`)**:T1 沙箱内 `/bin/true` 退出码 **0**(修前 125 + `landlock-run: exec failed: Permission denied`);T2 读 `/proc/cpuinfo`、`/dev/urandom`、写 `/dev/null` = 0;T3 `/dev/ptmx` 可写并可 `exec 3<>` 打开 = 0;T4 工作区写成功;T5 授权之外写**被拒**(隔离仍然生效,不是「全放开」);T6 去掉 loader 绑定 = **125** 且打印 `dsh-sandbox-runner: native library directory not found`(失败关闭);T7 授权参数预览 = `--ro / --ro /.dsh-native --ro /dev --ro /proc --rw /dev/null --rw /dev/ptmx --rw /dev/pts -- /bin/true`;T8 `--tmpfs` 指向不存在的目录 = 0(不致命)。A/B 对照:仅 `--ro / --rw /root/.dsh` ⇒ 125,额外绑定并授权 loader 目录 ⇒ 0,不经 landlock ⇒ 0。 - **沙箱层在应用 uid 上的回执(2026-10-03 20:12 与 20:55–20:58,装机 `e8f567b` / `a87846f`;本条取代以上「仍未复测」的表述)**: - 自检(界面「运行与后台 → 运行自检」)由**9 项正常**变成**11 项正常**:「沙箱内命令执行」「沙箱内 PTY」两条由失败转正常,失败项只剩「硬链接」(设备限制),C 编译器 / make 仍是注意项。 - **端到端**:把启动默认权限临时切到 `workspace-write` 后新建会话,让 Agent 用 bash 工具执行 `echo dsh-sandbox-ok` ⇒ 输出 `dsh-sandbox-ok`、退出码 0(回复原文“Exit code was 0, so the bash tool is working in the current workspace (`/root/1/inbox`)”)。随后让同一会话执行 `touch /dsh-test-root`,被沙箱拒绝并弹出 dsh 的一次性提权审批(原文「workspace-write 沙箱无法写入…需要一次性更宽权限才能执行这条完全相同的命令」),选「拒绝」后回复“Nothing was written to `/dsh-test-root`; the root filesystem was not modified.” ⇒ 隔离仍然生效,不是「全放开」。 - App 实际写出的覆盖文件在真机终端 `cat /root/.dsh-mobile/launcher-providers.patch.json` 得到 `[{"id":"sandbox","config":{"runnerCommand":["\/bin\/sh","\/root\/.dsh-mobile\/sandbox-runner.sh"],"runnerFailureSignatures":["dsh-sandbox-runner: "]}}]`;`ls -l /root/.dsh-mobile/` 显示 `sandbox-runner.sh` 为 `-rwxr-xr-x`(7274 B)。 - 复测结束后已把「启动默认权限」改回「兼容模式,无沙箱(danger-full-access)」并保存,重新进入该页复核过显示值(两次复测都做了这一步;第二次的复核截图为 `_probe/shots/rv-saved2.png`、`rv-final`)。 - 仍要如实说明:这条路径是 **Landlock 的文件效果限制**,没有 PID 与挂载隔离;harness 自身的 stdout/stderr **不进 logcat**(`landlock` / `dsh-sandbox-runner` / `runnerCommand` 在 logcat 里命中数均为 0),所以「覆盖是否生效」只能靠上面那条文件回执与执行结果判断。 - **插件丢失的真机回执(2026-10-03 21:20–21:25,装机 `a87846f`,设备端运行时 0.2.4-mobile-332/333)**:插件管理页显示 `@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-harness/dsh-mobile-shizuku` 三个官方插件「已启用」,而用户自己装的 `dshmarket`、`dsh-web`、`dsh-web-mobile` 三条都是「**未安装** + 已启用」;同一台设备的终端里 `ls -l /root/.dsh/profiles/web/` 只有 4 个构建期文件(`cordis.patch.yml` 212 B、`cordis.yml` 223 B、`package.json` 607 B、`pnpm-workspace.yaml` 61 B),**没有 `node_modules`**;包本体仍在 `ls -l /root/.dsh-mobile/plugin-manager/versions/` 的三个事务目录里(`41968c0f…`、`ac6e6877…`、`f1d15472…`)。⇒ 链接丢失、包还在,与本节修法的假设一致。 - **插件接回逻辑的真机回执(2026-10-04 13:04–13:12,装机 `0206374` 构建,`app-release.apk` 314,398,628 B,设备端运行时随包更新)**:这轮把上面那条「尚未复测」补上了。 - 更新前:`adb install -r` 成功(`lastUpdateTime=2026-10-04 13:04:36`,`versionCode=26`、`versionName=0.2.4`),App 提示「安装包内置了新版运行环境」,点「更新运行环境」完成替换。 - **复现**:更新后在设备终端执行 `ls -l /root/.dsh/profiles/node_modules/dsh*` ⇒ `ls: cannot access '/root/.dsh/profiles/node_modules/dsh*': No such file or directory` —— 三条插件链接确实被运行时升级抹掉(与 2026-10-03 的观察同因)。 - **修复生效**:点「重新连接」启动 Harness(应用在代次变化后、启动前自动跑 `repair`),再执行同一命令 ⇒ 三条 junction 都在(`Oct 4 05:08`,访客时钟):`dsh-web -> …/plugin-manager/versions/ac6e6877-9f7a-405b-a4f9-604f4aae5db/node_modules/dsh-web`、`dsh-web-mobile -> …/versions/41968c0f-cdc4-4b2b-8d11-fd0bb1100846/node_modules/dsh-web-mobile`、`dshmarket -> …/versions/266e0941-546a-4882-83d5-c0e2809786d7/node_modules/dshmarket`(`dshmarket` 取的是保留区里最新一次安装的事务目录,符合「按 mtime 取最新副本」的规则)。 - **可解析**:`ls -l /root/.dsh/profiles/node_modules/dshmarket/package.json` ⇒ `-rw-------. 1 10397 10397 3786 Oct 3 17:10 …/dshmarket/package.json`(链接指向的包真实可读)。 - **界面结果**:插件管理页第三方插件三条由「未安装」变为带版本 —— `dshmarket` **1.66.8**、`dsh-web` **0.4.4**(`packages/dsh-web-all/cordis.patch.yml`)、`dsh-web-mobile` **3.0.3**,状态仍是「已启用」;官方三条不受影响(`0.2.0-rc.2` / `0.2.0-rc.2` / `0.1.0`)。随后 Harness 正常进入会话。 - 仍未验证:没有逐个验证这些插件在 Harness 里的实际功能表现;「保留区里副本已被清掉、只能重装」的真机路径也未走。