# 存储权限分级与导入落点(登记册 §5.1 / §5.2 实现规格) 本文是 §5.1(存储权限分级与文件管理)与 §5.2(导入落点语义与投递目标)的实现规格: 先钉住**事实**与**决定**,再据此写代码。事实都带文件与行号,决定都带理由与代价。 与 §四(投递区)的关系:§四 定义「文件怎么进出」,本文定义「不同权限档下**有哪些通道**、 以及进来之后**落到哪**」。 ## 一、现状(按代码核实) | 事实 | 位置 | 含义 | |---|---|---| | 访客可见目录只有一条整体绑定 | `android/app/src/main/java/io/deepseekharness/mobile/runtime/RuntimeLaunchResolver.kt:161`(`sdcardMount()`,定义在 `:173`) | `/sdcard` 整体绑进访客,条件是宿主 `/sdcard` 可读可执行且访客侧同名目录存在。**这条绕过了「用户选了什么」**:只要 App 自己有权限,整个共享存储就进了访客 | | 三个存储相关原生方法已存在 | `MobileRuntimePlugin.kt:980` `getStorageAccessState`、`:1034` `requestMediaPermission`、`:1113` `openAllFilesAccessSettings` | 前端平台层也已接(`src/platform/{types,validation,native,browser}.ts`),「文件管理」一级页在用其中两个(该页由设置首页进入,投递区与共享目录共用同一个浏览器) | | 媒体权限按版本分流 | `MobileRuntimePlugin.kt:1040`(Android 13+ 用 `READ_MEDIA_IMAGES`/`READ_MEDIA_VIDEO`) | T1 的能力边界就是「媒体只读」,**不等于**文件管理 | | 投递区绑定是可选的 | `RuntimeLaunchResolver.kt:166`(`mailbox().bindMounts()`)、`RuntimeMailbox.kt:319` | 宿主目录不可访问时**一个都不追加**:绑定不存在的宿主路径会让 PRoot 直接起不来 | | 目录校验的现成做法 | `RuntimeMailbox.kt:483-493`(`Documents` 先 `canonicalFile` 再拼,三级要求 NoFollow 真实目录)、`RuntimeMailbox.kt:539-544`(访客挂载点逐级 NoFollow 创建) | 白名单必须走同一套校验,不要另发明一套 | | 应用级偏好的现成模式 | `AppLanguage.kt`、`overlay/OverlayBallPreferences.kt`(带可注入存储抽象,JVM 单测用内存替身) | 白名单持久化照抄这个模式,否则单测只能靠 `android.system.Os` 桩 | | 访客会话目录 | `RuntimePreservePolicy.kt:31-32`(`sessions`,即 `/root/.dsh/sessions`) | 宿主侧对应 `/root/.dsh/sessions`,**目录可以直接列**;但会话的标题与时间在日志里、日志默认 zstd 压缩 ⇒ 「读元数据」这一步**需要访客侧能力**(Node 24 自带 zstd)。详见 §4.2,那里有 pin 住版本的三处证据 | | 工作区文件管理已有桥 | `src/platform/types.ts:458-461`(`listRuntimeWorkspaceFiles` / `shareRuntimeWorkspaceFile` / `openRuntimeWorkspaceFile` / `deleteRuntimeWorkspaceFile`) | 「文件管理」的一半已经存在,缺的是**范围**(现在只有工作区)与**破坏性操作确认** | ## 二、分档定义(T0–T3) | 档 | 名称 | 判定来源 | 提供什么 | **不**提供什么 | |---|---|---|---|---| | T0 | 私有导入 | 默认档,无需任何权限 | 控制台上传、投递区(若宿主目录可访问)、工作区文件管理 | 共享存储里的任何文件 | | T1 | 媒体只读 | `READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO`(Android 13+)或 `READ_EXTERNAL_STORAGE`(12 及以下) | **只读**读取媒体文件(图片/视频),供「选一张图给 Agent」这类入口 | 浏览任意目录、写共享存储、删除媒体 | | T2 | 所有文件访问 | `MANAGE_EXTERNAL_STORAGE`(`Environment.isExternalStorageManager()`) | 共享存储的**读写**与文件管理;**用户白名单目录**绑进访客 | 应用私有目录之外的系统分区 | | T3 | Shizuku 文件管理 | Shizuku 已授权且服务已连接 | 以 **shell uid**(`adb shell` 等价,**不是 root**)执行文件操作 | root 能力;也不替代 T2 的绑定 | **档位不下沉**:T3 存在不等于 T2 的绑定可用(shell uid 能给的是「命令执行」,不是「把目录挂进 PRoot」); T2 存在也不等于媒体权限已授(媒体读取走 T1 的独立入口)。界面必须按**能力**展示, 不要按「有没有 Shizuku」推断文件管理可用。 ## 三、≤8 目录白名单(5.1 的核心) ### 3.1 决定 **用用户选择的目录白名单取代 `/sdcard` 整体绑定**(`RuntimeLaunchResolver.sdcardMount()` 不再调用)。 理由:整体绑定让「App 有什么权限」直接等价于「访客能看到什么」,用户没有任何表达机会; 白名单把可见范围收敛为**用户显式点过的目录**,这与投递区(两个固定目录)是同一个方向的第二步。 ### 3.2 选取与映射 1. 用户通过 `ACTION_OPEN_DOCUMENT_TREE` 选择目录(SAF)。 2. SAF 给的是**目录树 URI**,不是文件系统路径。映射规则:从 `DocumentsContract.getTreeDocumentId(uri)` 取形如 `primary:Documents/Foo` 的 document id,`primary:` 前缀映射到 `Environment.getExternalStorageDirectory()`,其余按 `/` 拼接;**非 `primary:` 卷一律拒绝** (SD 卡/OTG 的路径不可靠,拒绝比猜错好)。 3. 映射出的路径必须**规范化后重新校验**:`canonicalFile` 解析符号链接 → 必须仍是真实目录(NoFollow)→ 必须在 `/storage/emulated/0` 之下 → 不能是共享存储根、`Android/` 及其子目录、应用私有目录。 4. 校验失败的目录**不入白名单**,并向用户给出受控错误码(不静默忽略)。 5. 上限 8 个;重复选择同一目录不叠加;对已存在项再次选择视为移除(或明确给出移除入口)。 ### 3.3 绑定 - 访客挂载点固定为 `/mnt/user/<序号>`(序号即白名单顺序,稳定),宿主侧 `/mnt/user/<序号>` 逐级 NoFollow 创建。 - 与投递区一样属于**可选绑定**:任一条目校验或创建失败时**只跳过该条目**(并计入诊断), 不影响其余条目,也绝不让 PRoot 起不来。 - 白名单内容进 `profileKey`,变化即让缓存的启动档失效(与投递区同一机制)。 ### 3.4 破坏性操作在 App 侧确认 删除、覆盖、移动一律由 App 侧弹确认框执行;**不把确认逻辑放在访客里,也不依赖 dsh 的审批门**—— 单 uid 下访客代码与 App 同权限,审批门不是安全边界(登记册 §5.1 原文口径)。 ### 3.5 浏览共享目录(2026-10-01 新增) 投递区与共享目录在**界面上合并成同一个浏览器**(「文件管理」一级页,`src/App.tsx` 的 `FilesScreen`), 但底层仍是两条语义:投递区是「校验式批量搬运」,共享目录是「实时挂载」。新增的两个桥方法只做浏览与 建目录,不搬运: | 桥方法 | 原生实现 | 返回 | |---|---|---| | `getStorageDirectory(guestPath, subdirectory?)` | `MobileRuntimePlugin.kt:1373` → `RuntimeStorageDirs.guestDirectoryIndex`(`:432`)/ `directory`(`:592`) | `StorageDirectoryState`:`guestPath` + `path`(规范化后的相对子目录,根为 `null`)+ `entries`(`{name, kind, bytes}`,`kind ∈ file/directory`)+ `truncated` | | `createStorageFolder(guestPath, subdirectory)` | `MobileRuntimePlugin.kt:1391` → `RuntimeStorageDirs.createFolder`(`:614`) | 同上形状的新快照 | 口径与投递区**刻意保持一致**,避免两套实现漂移: - `guestPath` 只能是 `/mnt/user/<序号>`(序号 = 持久化顺序,1 起)。`/mnt/user`、`/mnt/user/01`、 `/mnt/user/1/`、`/mnt/user/1/extra` 等 16 种别名一律 `STORAGE_DIR_PATH_INVALID`;失效条目留下的 空洞**不补位**。 - 条目上限复用 `RuntimeMailboxLimits.MAX_DIRECTORY_ENTRIES`(256),目录在前、组内按名排序,超限只 置 `truncated: true`;**符号链接与硬链接不列出**;共享目录不做投递区那种 `.dsh-mailbox-probe-*` / `.dsh-export-*` 隐藏。 - 子目录校验复用 `RuntimeMailboxPolicy.normalizeSubdirectory`(`:338`,与投递区同一份实现,不写第二份); 新建走 `CREATE_NEW` 语义——同名已存在时**只报 `STORAGE_DIR_FOLDER_EXISTS`,绝不覆盖**。 - 受控码不带路径:`STORAGE_DIR_UNSUPPORTED` / `STORAGE_DIR_NEEDS_PERMISSION` / `STORAGE_DIR_PATH_INVALID` / `STORAGE_DIR_NOT_FOUND`,条目自身原因另带 `STORAGE_DIR_NOT_A_DIRECTORY` / `STORAGE_DIR_PRIVATE_REJECTED` / `STORAGE_DIR_UNREADABLE`, 加上两个新码 `STORAGE_DIR_DIRECTORY_FAILED`(判定通过但真读 IO 失败,可重试)与 `STORAGE_DIR_FOLDER_EXISTS`。 - 共用实现集中在 `runtime/RuntimeDirectoryBrowser.kt`(两套码与文案、快照、建目录)与 `runtime/OptionalBindProvider.kt`(可选绑定的分组遍历 + `ensureGuestMountPoint`), `RuntimeLaunchResolver` 里两段重复的「任一条目失效就整体撤掉」由此收敛。 **本机测不了的部分**:`RuntimeStorageDirsPolicy.requireCanonicalPath`(`RuntimeStorageDirs.kt:340`) 只接受纯 ASCII 字母数字与 `._-` 的宿主路径,Windows 临时目录过不了,因此共享目录的**成功路径在 JVM 单测里跑不到**(失败路径与校验有覆盖)。真机判据见 `docs/mobile-acceptance-checklist.md` 第 10 节 R10。 ## 四、导入落点语义(5.2) ### 4.1 两个落点,性质不同 | 落点 | 宿主路径 | 是否跨运行时升级保留 | 适合什么 | |---|---|---|---| | 会话附件 | `~/.dsh/attachments`(`RuntimePreservePolicy.PRESERVED_NAMES` 内) | **保留** | 给某个会话当输入/产物的文件 | | 工作区 | `root/1`(`PRESERVED_OUTSIDE_HOME` 的 `workspace`) | **保留**(`0acf3a4` 起) | Agent 的日常工作目录 | | 投递区导入落点 | `root/1/mailbox-import`(§4.7,整体替换) | 随工作区保留 | 批量搬运的落地 | **注意**:登记册 §5.2 原文把工作区记为「不保留」,那是 `0acf3a4` 之前的结论, **现在工作区已经在保留名单里**(`RuntimePreservePolicy.PRESERVED_OUTSIDE_HOME` 的 `"workspace" to "root/1"`)。 本文按当前代码口径写,并保留这条更正记录,避免后来者按旧结论设计。 ### 4.2 外部入口(分享 / 打开方式)不带会话 id 怎么办 外部入口(`ACTION_SEND` / `ACTION_VIEW`)只能拿到文件,**拿不到「投给哪个会话」**。 决定: 1. **主路径**:App 侧给一个选择器——「新建会话 / 投给已有会话 / 只放进工作区」。 **会话目录的形状:已定(三处独立证据一致,不再是猜测)**。上一轮本文写的是「前提尚未证实、 实现前必须先定」,原因是当时在 1197 个包文件的全盘扫描里超时中断、没拿到结论。 结论其实就在**仓库自己 pin 住的依赖副本**里(`scripts/runtime-profile/node_modules/.pnpm/`, `@deepseek-ai/dsh-session-persistence-jsonl@0.2.0-rc.2`,即运行时里真正跑的那份): | 事实 | 来源(可复核) | |---|---| | 根目录 = `/sessions`,手机上是 `/root/.dsh/sessions` | `dsh-base/cordis.patch.yml` 的 `session-persistence-jsonl` 配置 `root: !!js dshHomePath('sessions')`(手机 profile 的 bundles 就是 `dsh-base` + `dsh-web-app`);仓库侧另有 `scripts/mobile-session-publish.py` 的 `SESSION_ROOT` | | **两级目录**:`//` | 同包 `lib/index.js` 的 `projectDir()` / `sessionDir()`;`cwd` 为空时第一层是固定名 `_no-cwd` | | 第一层是 **cwd 的有损摘要**:`/ \ :` 折叠成 `-`,其他不安全字符转 `~XXXX`,截断 251 字节,两端包 `--` | 同文件 `projectKey()` | | 第二层是 **会话 id 且可逆**(`[A-Za-z0-9._-]` 原样保留,其余转 `~XXXX`,`.`→`~002E`、`..`→`~002E~002E`) | 同文件 `encodeSegment()` | | 文件名**固定**:`session.jsonl`(旧格式 v0)或 `session.v.jsonl`,当前格式版本是 **v3**;zstd 编码时再加 `.zstd` | 同文件 `generationLogFilename()` / `sessionFormatLogFilename()` 与 `dsh-session` 的 `SESSION_FORMAT_VERSION = 3`;仓库侧另有 `scripts/mobile-auth-preload.cjs` 的 `SESSION_TARGET_PATTERN`(要求恰好两层 + 该文件名正则) | | 一个会话**可能有多个文件**(迁移期 `session.jsonl` 与 `session.v3.jsonl` 并存),要取「当前压缩方式下最高的 generation」 | 同文件 `resolveGenerationInDirectory()` | 由此定下三件事: - **会话 id 从第二层目录名解码即可**(无损),**第一层不能反推 cwd**(有损)——cwd 只能从日志里读。 - **列目录拿不到标题,也拿不到 `createdAt`**:标题与时间都在会话日志里(`dsh-session-title` 是 "log-backed session title service"),日志第一行是 header(`type/version/id/createdAt/isSeeded/ delegationDepth`,可选 `cwd/parentSession/origin/agentPreset`)。 - **日志默认是 zstd**:`dsh-base` 只配了 `root`、**没有配 `compression`**,落到后端默认值 `DEFAULT_COMPRESSION = "zstd"`,`dsh-web-app` 也没有覆盖它。App 是 Kotlin,**没有内置 zstd**, 所以宿主侧不能想当然地按行读日志。 **两条可行路线(按代价排序,本轮只记录不实现)**: - **(a) 访客内 Node 读(推荐)**:Node 24 的 `node:zlib` 自带 zstd,后端自己就是这么读的, 语义最一致。做法是加一个 `assets/support/` 下的小脚本(与既有 `runtime-self-check.cjs` 同一条 投递与执行路径),输出「id / createdAt / cwd / 标题 / 文件名」的 JSON 给 App。 - **(b) 给 `dsh-base` 打补丁把 `compression` 设成 `none`**:仓库已有 pnpm 补丁通道,之后宿主侧可直接 按行解析。代价是访客磁盘占用上升,而且改的是运行时行为,必须走一次发布 + 真机验证。 **不要走的路**:`dsh-session-query-sqlite`(会话搜索索引)看着最省事,但 `dsh-web-app/cordis.patch.yml` 把它配成 `path: ':memory:'` + `openAt: never` ⇒ **磁盘上没有库文件可读**;插件里那个 `launcherSessionQueryPath` 启动槽确实是「由启动器指定一个持久路径」的杠杆,但今天没启用, 要用它得先改 profile 配置,不在本轮范围内。 **仍要在真机上确认的(是「确认配置生效」,不是「探测结构」)**:`ls -R /root/.dsh/sessions | head -50`。 判据已经写好:第二层下应当只有 `session.jsonl` / `session.vN.jsonl`(必要时带 `.zstd`)这一类文件名, 第一层应当是 `--…--` 形态的项目摘要目录。**不匹配就说明真机上的 profile 配置与这里读到的不同**, 那时再回来改这份规格——而不是先按猜的结构写枚举逻辑。 2. **兜底**(用户没选就离开、或列表读取失败):**静默落到 attachments + 发通知**, 而不是把文件丢掉或强行塞进某个会话。通知要说清「文件已收下、在哪个会话里可以用」。 3. **只在 T0 起可用**:外部入口本身不依赖任何存储权限(文件由系统以 `content://` 交给我们), 因此这条路径在 T0 就能工作——它与 T1/T2/T3 是**正交**的,不要写进权限门的判据里。 4. `content://` **必须由 App 拷贝**成真实文件(`ContentResolver.openInputStream`), 不能把 URI 交给访客:访客里没有 `ContentResolver`,也没有该 URI 的授权。 ## 四点五、实现时确定的口径(含两处**能力回退**,别当成故障) 白名单实现(`RuntimeStorageDirs` / `RuntimeStorageDirPreferences` / 桥方法三件)落地时, 有四处必须先定口径。全部按 **fail-closed** 处理,并把代价写在明面上: 1. **SAF 卷只认 `primary:`**:其它卷(SD 卡、OTG、以及下载抽屉常给出的 `raw:/storage/emulated/0/...` 与 `msf:` 文档提供方)一律拒绝,受控码 `STORAGE_DIR_VOLUME_UNSUPPORTED`。**界面必须给出可操作的提示**: 「请从『内部共享存储』进入后再选目录」——否则用户会觉得「选了个目录却总是失败」。 是否额外接受 `raw:` **待真机确认**(不能凭猜测放宽:不同 ROM 的文档提供方形态差异大)。 2. **多用户 / 工作资料按字面 fail-closed**:准入要求路径在 `/storage/emulated/0` 之下, 因此副用户(`/storage/emulated/`)会被判 `STORAGE_DIR_OUTSIDE_PUBLIC`。 这是保守选择:白名单挂进访客意味着**跨过应用沙箱边界**,宁可在不支持的用户态下不可用, 也不要凭猜测放行。若日后要放宽,改动点唯一:`requireCanonicalPath` 的 `publicRoot` 参数。 3. **⚠️ API 26–29(Android 8–10)的能力回退**:白名单按 T2 定义,而 `MANAGE_EXTERNAL_STORAGE` 是 API 30 起才有 ⇒ 这些版本上 `supported=false`; 同时**本轮删除了 `/sdcard` 整体绑定**。两者相加的结果是: **这些设备上的访客不再能看到共享存储**(此前靠 `READ_EXTERNAL_STORAGE` 可见 `/sdcard`)。 这是「用用户显式选择取代整体绑定」的**必然代价**,不是 bug,但**必须如实告诉用户**: 界面在这些版本上要说明「本机不支持目录白名单,访客内不提供共享存储」, 不得让用户以为功能坏了。若要覆盖旧版本,需要另开一条只读通道 (API<30 用 `READ_EXTERNAL_STORAGE` 只读绑定)——**本轮刻意没做**: 那等于把刚移除的整体绑定换个名字放回来,与本节的决定直接冲突,应当单独评估。 4. **中文 / 含空格的目录名会被拒绝**(受控码 `STORAGE_DIR_UNBINDABLE`)。根因不是白名单本身, 而是 `RuntimeCommand` 对 PRoot 绑定源的字符白名单(`^/[A-Za-z0-9._/-]+$`): 放行会让 `prootArgv` 抛 `RUNNER_ARGUMENT_INVALID`,**直接导致 PRoot 起不来**—— 那是比「这个目录用不了」严重得多的故障。因此选择**在选择当下如实拒绝**, 并有一条单测钉住「白名单放行的路径一定能通过绑定校验」(两侧规则必须一致)。 放宽这个字符集属**安全敏感改动**(它同时是 PRoot 参数的注入面),需单独评估。 ## 五、验收(真机项,本机无法验证的部分必须明说) - T0:未授予任何存储权限时,控制台上传可用、投递区如实显示不可用;外部分享入口仍可用(落 attachments)。 - T1:只授媒体权限时,媒体选择器可用,且**不能**浏览任意目录(越权尝试必须失败并如实报错)。 - T2:授「所有文件访问」后,白名单目录在访客内出现于 `/mnt/user/<序号>`, `/sdcard` **不再**整体可见;白名单变更后重启运行时,挂载点随之变化。 - T3:Shizuku 已连接时,文件管理以 shell uid 执行;**不得**把它描述成 root。 - 破坏性操作:删除/覆盖必须出现 App 侧确认;取消则不产生任何改动。 - 边界:白名单里的目录被用户删除/改名后,下一次启动只跳过该条目(记诊断),其余条目照常。 ## 六、刻意不做的事 - 不把白名单目录**默认**绑到 `/sdcard` 之类的「看起来像原来」的路径上:那等于把整体绑定换个名字。 - 不实现「App 自动发现所有可读目录并全部挂上」。 - 不在访客内做权限判定与确认(单 uid 下无意义)。 - 不为 T3 引入 root 能力(项目既有约束:Shizuku 是 shell uid,不是 root,也不是保活工具)。