前一篇写的是鸿蒙版:系统 ArkWeb 渲染 + 进程内 Node,靠「零 ACL 的 --jitless 形态」换来免受限权限安装。
这一篇换一块地:Android。
同一个架构骨架搬过去,你会以为大部分结论可以直接平移。实际不是。Android 把鸿蒙上最痛的三件事------JIT 权限、符号链接、子进程------直接还给了你;但同时又塞回来另外五件鸿蒙上不存在的新账:assets 不是路径、主线程不能碰 node::Start、没有 rg、没有 git、content:// 不是路径。
所以这篇不写「移植成功」,写两本账:Android 让哪些设计消失了,又让哪些设计冒出来。
一、它是什么
先交代背景:DeepSeek Harness(dsh)是开源的 AI agent 运行框架,采用「一切皆插件」的架构------模型适配器、工具注册表、会话日志、乃至 agent 主循环本身,都是可替换的插件。它自带一套相当完整的 Web UI。
DSH Desktop 要做的,是把这个 Web UI 变成一等公民的应用形态。到现在为止有四个 edition,共用同一套外壳思路、同一条数据面:
| Edition | 渲染 | 运行时 | 平台 |
|---|---|---|---|
dsh-desktop |
Electron 内置 Chromium | Electron 官方运行时 | Windows / Linux / macOS |
dsh-desktop-hos |
Electron 37 | Electron 内置 Node 22.17 | 鸿蒙 2in1 / 平板 |
dsh-desktop-ark |
系统 ArkWeb | 进程内 libnode v24.2.0 | 鸿蒙 2in1 / 平板 |
dsh-desktop-android(本篇) |
系统 WebView | 进程内 libnode v24.22.1 | Android(arm64-v8a) |
四个 edition 的差别,始终只有一句话:由谁提供窗口,由谁提供 Node。 界面、数据面、扩展方式完全相同。
本篇这个版本的现状,先摆出来:
- 版本
0.1.0(versionCode1),上游基线钉在dsh-v0.2.1-rc.2,引擎libnode v24.22.1(--with-intl=small-icu)。 - 上游零源码补丁 :
androidprofile +@dsh-android/bundle+ 5 条带标记垫片 + 十余个 seam 插件。 - 端到端取证在 Android 模拟器(API 37 / x86_64 + ARM 转译)上完成,真机全量复测仍挂在待办上------这一点后面会单独说清。
为什么执着于「零改动」?因为 dsh 还在 developer preview,上游动得很勤。一个外壳钉一个上游 tag,升级就是重跑产物管线,而不是重做补丁。 一旦把界面重写一遍,就等于把上游最活跃的那部分永久 fork 进自己的仓库。
二、运行模型:三层,没有第四层
┌─ APK(arm64-v8a,单应用模块)
│ Kotlin 外壳(View 体系)
│ MainActivity(singleTask)→ WebShellView(WebView + 加载 / 错误 / 诊断覆盖层)
│ DshRuntime(Application 级单例,启动状态机)
│ PayloadInstaller ← assets 里的载荷 tar → filesDir(流式解包 + 幂等 marker)
│ HostCapabilities ← 宿主能力注册(JSON 进出、结构化失败、永不抛)
│ │ loadUrl(http://127.0.0.1:<随机端口>?token=...) ← 同源加载
│ │ JNI 槽位表(同步、10s 硬超时、8 槽、返回 ≤ 64 KiB)
│ ┌──────▼────────────────────────────────────────────────
│ │ Node(进程内,libnode.so / node::Start,detached 32MB 栈线程)
│ │ bootstrap.js:异常兜底 → 垫片 → env 断言 → host-boot.js
│ │ dsh Host:webserver(127.0.0.1:0) + /api + 事件流
│ │ seam 插件:subprocess / shell / picker / git / market / fs-search ...
│ └───────────────────────────────────────────────────────
│ 资源:assets 载荷(dsh.tar + dsh.version)· jniLibs 引擎与桥
└─
三条机制撑起全部:
1. 同源数据面。 Node 在进程内启动 dsh Host,webserver 绑 127.0.0.1:0(端口随机),拿到带一次性 token 的入口 URL;WebView 直接 loadUrl() 同源加载,?token= 换一枚签名 cookie,之后页面走 /api + WebSocket。零 CORS、零 Host 头改写、零自定义协议、零 cookie 注入。 壳完全不参与鉴权------不解析 token、不写 cookie。明文只对 loopback 放行,networkSecurityConfig 里不允许出现通配域名。
2. 适配层放在上游扩展点里。 新增一个 android profile + 自有 bundle(YAML patch 层),把 dsh 的能力 seam(命令执行、进程、沙箱、目录选择、文件、工具)换成 Android 实现。上游零源码补丁是设计目标,平台性缺陷只允许走「带标记的垫片」。
3. 宿主能力走 JNI 桥,不注入 preload。 Node ⇄ Kotlin 用桥的槽位表:host.invoke(name, json) 同步等,8 个槽、单次 10 秒硬超时、入参 ≤ 8 KiB、返回 ≤ 64 KiB。桌面能力全部由 Host 侧的 seam 插件经桥提供,网页里不注入任何东西 ------因为 dsh 的页内桥(__DSH_LOCALE__ / __DSH_HOST_PATHS__ / __DSH_DIRECTORY_PICKER__)本来就是可选的,缺失时前端会回落 Host RPC。
这三条里最值钱的仍然是第一条:dsh 的 webserver 本来就同源服务页面和 API,所以数据面不需要 CORS、不需要自定义协议、也不需要新的 IPC 载体。这是「上游代码保持原样」能成立的根。
三、Android 让哪些难题消失了
鸿蒙上那些拧巴的绕行,在 Android 上一大半自动解除了。这是移植过程中最舒服的部分:
| 问题 | 鸿蒙上的解法 | Android 上的现实 | 设计后果 |
|---|---|---|---|
| JIT / ACL | 默认 --jitless,声明面收敛到零 ACL |
JIT 直接可用,自分发没有 ACL 概念 | 不用申请受限权限;--expose-internals 实测生效 |
| WebAssembly 缺失 | 自研纯 JS fetch 兼容层(SSE/FormData/重定向/AbortSignal),并封印 node:http 惰性 getter |
WASM 可用,undici 原样能跑 | 兼容层与 undici 替身全部删掉 ;web_search / web_fetch 实测跑通 |
| 符号链接 / 硬链接 | fs.link / fs.symlink 两条垫片(FUSE 上 EACCES 回退 copy) |
应用私有目录语义正常 | 少两条垫片 |
| 子进程 | 应用域 fork 会被 seccomp 直接杀掉 | /system/bin/sh、toybox 可 exec |
ctx.shell / ctx.subprocess 走原生语义,不用哨兵假冒 |
| 后台 | 后台封死 | 前台服务可行 | 长 turn 不再被 LMK 随手杀掉 |
| 宿主桥 | 文件信箱(往返约 2ms) | JNI 槽位表 | 无轮询成本 |
| 安装权限 | 三根目录授权门 | MANAGE_EXTERNAL_STORAGE 一次性授权 |
路径模型与桌面版一致 |
垫片数量本身就是一张成绩单:鸿蒙 ArkWeb 版 6 条 → Android 版 5 条 → Electron on HarmonyOS 版 13 个源码补丁。 而且剩下这 5 条没有一条是「业务逻辑补丁」,全是「平台能力缺失的等价替代」:
| 标记 | 抹平的缺陷 |
|---|---|
S1 flock |
node-addon-system 的 flock 原生 addon 无 Android 产物;单进程下等价恒真 |
S2 sharp |
无 libvips;metadata() 解析容器头,缩放走桥到 Kotlin ImageDecoder |
S3 require-builtin |
node-addon-require-builtin 无目标产物;纯 JS 实现取 internal/*,白名单外明确抛错 |
S4 附件 fsync |
特定路径上的 fsync 语义差异 |
S5 fs-link / fs-symlink |
防御性回退(EACCES/EPERM/ENOSYS → copy) |
每条垫片都有唯一标记字符串、启动期自检断言、登记理由与撤销条件,启动时打一行 [shim] ... installed,诊断端点里也能列出来。「零补丁」不是口号,是能被审计的清单。
四、Android 又塞回来哪些新账
真正的工程量在这里。五件事,每一件都改写一条设计:
1. assets/ 不是文件系统路径。 鸿蒙那边载荷是 resfile 真路径,Node 自己解包(谁写谁读)。Android 的 APK 是个 zip,assets 没有路径可言。于是解包责任前移到 Kotlin:PayloadInstaller 流式解 tar 到 filesDir/payload/<sha12>/,先解到 .tmp-<pid> 再原子 rename,完成写幂等 marker .ready-<sha12>,并拒绝 ..、绝对路径与符号链接条目(zip-slip)。顺带把「载荷契约」变成三方对账:打包器、解包器、Node 只读使用,共用同一份 dsh.version 的 sha256。
2. node::Start 每进程只能一次。 这是最硬的一条。崩溃即终态,UI 只能「重启应用」,不能「重启服务」;process.exit(0) 在 Android 上不杀 App,node::Start 返回后运行时永久死亡、状态却还显示 running------这是最难查的一类故障。所以启动被建成一台显式状态机:
IDLE → INSTALLING → STARTING → WAITING → PROBING → READY → RUNNING
└──────────┴──────────┴─────────┴──────→ FAILED(终态)
就绪判据只有一条:ready.json 可读且 HTTP 探活返回 200/303/401 。日志、stage、目录存在都只算辅助证据。任一阶段失败 → 错误页显示阶段、错误、日志尾、last-crash.json,外加一个「重启应用」。
3. 主线程不能作恶。 dlopen、node::Start、大文件解包全部在后台线程(ANR 门槛 5s/10s/20s)。但桥的 handler 是在主线程 同步执行的------所以「弹系统选择器」这种必须等人操作的能力,不能阻塞等,只能两段式:
Node: 提交请求 → 拿到 {pending:true, id} → 轮询 host.result → 取结果
Kotlin: 立刻返回 pending,拉起系统 UI;完成后把结果放进内存槽位
槽位 5 分钟过期,Activity 重建不丢(状态放在 DshRuntime 单例里)。这一条协议同时支撑了目录选择、文件导入、文件导出、权限申请四类交互。
4. Activity 会被重建,WebView 渲染进程会被杀。 旋转、分屏、主题变化都会重建 Activity,但 DshRuntime 是 Application 级单例,复用已就绪的 URL 直接 loadUrl(),绝不重启 Node ;onRenderProcessGone 只重建 WebView 并重放 URL,不判定 Host 失败。把「渲染进程死了」和「Host 死了」混成一件事,用户看到的就是随机白屏。
5. 没有 rg,没有 git,私有目录里的 ELF 不可执行。 这三条直接决定了三个子系统的实现形态,下面单独说。
五、三条一等公民:命令、git、插件市场
命令执行。 两条路并存:@dsh-android/subprocess(node:child_process.spawn,白名单 /system/bin/* 与工作区内脚本,cwd 强制绝对,env 白名单,取消映射 SIGTERM→SIGKILL)和 @dsh-android/shell(常驻 /system/bin/sh,随机哨兵行 协议:__DSH_BEGIN_<nonce>__ / __DSH_END_<nonce>__ <rc>,默认 120 秒超时)。
哨兵行是为了解决一个具体问题:非 PTY 的常驻 shell 里,你怎么知道一条命令结束了 ?靠固定字符串会被用户输出伪造,所以每次调用现生成随机 nonce;同时保证 cd 不跨调用残留、超时可中断、不留僵尸。模拟器上 echo 端到端 exit 0 已过。
git,21 个工具,纯 JS。 设备上没有 git 二进制,用 isomorphic-git 的嵌套闭包随包投放,仓库根 chroot,只支持 http(s)。实测链路是:clone → History complete → git_log 取回 3 条 → push → 服务端一致。这条链能通,说明「21 个工具」不是注册成功就算完,而是真的在一个小型远端上跑完了一个来回。
插件市场,pnpm 跑在 worker 线程里。 Android 10+ 起,应用数据目录里的 ELF 不可执行(W^X)。所以「装插件」不能 spawn 一个自带 node 去跑 pnpm。解法是:pnpm 引擎(纯 JS,10.34.6)在构建期物化进载荷,运行期在同进程的 worker_threads 里 import() 它 ;布局锁 hoisted + copy 绕开链接语义,ignoreScripts 关闭插件作者代码执行(既是平台约束,也是安全属性);完成判据是 worker 的 exitCode,不是 import() 的 resolve。
市场前端随载荷内置,UI 与对话框能完整渲染。但安装链的 lookup 还没闭环 :上游插件管理器解析包信息时走的是裸 spawn 一个 pnpm,不读宿主提供的 desktopPnpm 契约------而本平台没有可 spawn 的 node/pnpm。这一项已经登记为「待上游 feature-detect 反哺」,而不是留在代码里假装能用。
六、搜索与图片:把小刀磨对
内容搜索。 上游 tool-fs-search 的实现是打包一个 ripgrep 然后裸 spawn------arm64 设备上没有这个二进制。原计划是「上游有纯 JS 兜底路径」,实测被证伪。于是换后端:进程内纯 JS 遍历,gitignore-aware、有界并发、二进制跳过、结果上限,工具名与参数与上游完全一致。中等仓库可用,大仓库会慢一个量级------这是明码标价的降级,不是隐藏的 bug。
图片。 sharp 没有 Android 产物,于是 S2 垫片只做它该做的部分:metadata() 真解析 PNG/JPEG/GIF/WebP 容器头;resize/toFormat/toBuffer 走桥到 Kotlin 的 ImageDecoder + Bitmap.compress。跑通的证据很具体:一张 320×200、深蓝底、黄字「DSH-7391」的图,从 read_image 到模型视觉回答,文字、颜色、尺寸全对,13 秒 / 183 tok/s。
还有一件顺手补上的事:ctx.fs 缺的四个原语。 上游 ctx.fs 没有 remove / move / copy / chmod,鸿蒙版的选择是「做不了就登记」。这里用物化期补丁给 dsh-fs-local 的原型补上四个方法,并让上游的 fs-sandbox 围栏(checkedTarget)把新原语一起包住------加能力可以,绕围栏不行。这条改动的纪律和垫片一致:唯一锚点、带标记、幂等。
七、界面:不改,但可以被认出来
「上游零补丁」不等于「长得像别人的应用」。品牌化分成两层做:
- 应用级:应用名、启动图标、圆形图标、通知小图标,从工作区同一份官方 logo 生成五密度 mipmap(与鸿蒙两版同源)。
- Web 级 :一个
@dsh-android/branding插件,通过上游官方的 slot 机制注入页面标题、侧栏品牌位、hero 品牌位三个槽,外加一个标题守卫。
注意这里没有改任何上游文件:三槽位是上游本来就提供的扩展面。也就是说,从「能改」到「改了还不用负责维护 fork」,中间差的只是「知道上游给了哪些缝」。
八、现在能用的,和明确不能用的
先说能用的(模拟器取证,逐条有证据锚点):
| 能力 | 状态 |
|---|---|
| dsh Web UI 在 WebView 内运行(100% 复用上游前端) | ✅ |
| 会话持久化 + SQLite 全文检索(FTS5 可用) | ✅ |
| 文本对话、看图分析、图片附件管线 | ✅ |
命令执行(bash 端到端 exit 0) |
✅ |
| git 21 工具(clone / log / push 闭环,仅 http(s)) | ✅ |
| 内容搜索 grep / glob(进程内纯 JS 后端) | ✅ |
| 三主目录工作区选择(沙箱 / 文档 / 下载)+ 权限门 | ✅ |
| 文档 / 下载目录写入(读回一致) | ✅ |
| 系统通知(回合完成、审批) | ✅ |
子 agent、后台任务 job_* |
✅ |
ctx.fs 删除 / 移动 / 复制 / 改权限(围栏内) |
✅ |
| agent 运行期保活(前台服务,引用计数,结束即停) | ✅ |
| 插件市场 UI + pnpm 引擎物化 | ⚠️ 安装链 lookup 待上游 |
再说做不到、或按产品算账决定不做的:
| 能力 | 状态 | 原因 |
|---|---|---|
| 交互式终端(PTY 面板) | ❌ | 上游终端后端硬依赖 node-pty,无 android-arm64 产物;本工程不建 NDK 链。节点存在 ≠ 能力可用 |
| 进程沙箱(landlock 类) | ❌ | Android 无可用隔离面;只做策略层,并如实上报 sandbox:'none' |
workflow / ralph |
❌ | 上游引擎要 spawn 辅助 node 进程,本平台不允许 |
| 托盘、开机自启、静默更新 | ❌ | 平台无此概念或无公开 API;用通知 + 前台服务替代 |
| 服务端缩略图 | ⚠️ | 上游无产物,靠客户端 CSS 缩放 |
原生插件(*.node) |
❌ | 安装前探测并明确拒绝,不假装成功 |
这套表的写法是刻意的:「不能用的」必须写清根因,否则会变成错误的期待。 一条 {ok:true} 但实际没做事的返回,比一个明确的 not-implemented + hint 危险得多。
九、账本:门禁、体积、启动
工程一开始就把 13 条 P0 门禁写成「不通过就改设计,不许先写着看」。最后的成绩单:
| 门禁 | 结论 |
|---|---|
| WebView 加载 loopback | ✅ 实测内核 145(下限要求 110) |
node:sqlite + FTS5 |
✅ 引擎内建可用,全文搜索不需要另编原生插件 |
| dsh Host 在 libnode 上启动 | ✅ READY 拿到入口 URL |
| 引擎 ICU | ✅(首版 --with-intl=none 失败 → 重编 small-icu) |
| Kotlin 解包 tar | ✅ sha12 幂等命中即跳过 |
| All-files 读写 | ✅ 文档目录列举 + 写入闭环 |
/system/bin/sh 哨兵协议 |
✅ 单命令端到端 |
| 引擎与桥装载 | ✅ dlsym 命中 node::Start,16 KB 页对齐 |
其中一条值得单独记:ICU 那条差点成为隐藏炸弹。 首版引擎是 --with-intl=none,症状很怪------不是「某个 locale 函数不准」,而是 Unicode 属性正则(\p{XID_Start} 之类)在 ESM 源码编译期 就失败,agent 主循环直接 require 不动,界面能开、一说话就崩。这类问题垫片救不了(编译期),唯一路径是重编 small-icu。先跑门禁再定版,比事后猜便宜得多。
体积与启动(模拟器实测):
| 项 | 数值 |
|---|---|
| APK | 约 135 MB(引擎约 81 MB 不压缩入包 + 载荷 tar 约 48 MB) |
| 引擎 | Node v24.22.1 / arm64-v8a / 16 KB 页对齐 |
| 首启到 ready | 34--71 秒(含首次解包) |
| 二次启动 | 命中幂等 marker,跳过解包 |
135 MB 对一个「自带 Node 的桌面级客户端」来说不算离谱,但也确实是这条路线的固定成本:运行时不是免费的,你把 Electron 省掉,引擎还在。
十、装到自己的机器上
环境前置几句话:SDK 侧用 Android Studio / 命令行工具,JDK 17;引擎与桥来自 Maven 依赖(runtimeOnly,版本号即 Node 版本,二进制不入库);只有 arm64-v8a,x86_64 模拟器需要带 ARM 转译的镜像;设备时区必须是 IANA 名(GMT 偏移名会被会话创建拒绝);Android 13+ 需授予通知权限。
bash
# 1. 载荷装配:物化上游 dsh 运行树 + 本工程插件 + bundle patch + 垫片
node scripts/assemble-payload.mjs --deploy --apply
# 2. 载荷打包 → assets/dsh-payload/{dsh.tar, dsh.version}
node scripts/package-payload.mjs --src build/payload-stage/root
# 3. 引擎投放(锁 sha256 + 八项 ELF 门禁)
node scripts/provision-engine.mjs
# 4. 构建
./gradlew assembleFullDebug # 模拟器 / 侧载调试
./gradlew assembleFullRelease # 发布候选(需签名配置)
# 5. 部署
adb install -r app/build/outputs/apk/full/debug/app-full-debug.apk
两条发行线:full(含 All-files 授权,路径模型与桌面一致)与 store(SAF-only,为商店政策准备)。前者是首期交付目标,后者是保留的可能性。
十一、诚实的边界
这个版本还有没做完的部分,写在这里而不是埋在仓库深处:
- 真机全量复测暂缓。 端到端取证目前在模拟器(x86_64 + ARM 转译)上完成;真机要一次性回归时区合规、授权、无转译性能、通知亮屏这些项。
- 市场安装链的 lookup 在上游。 需要上游对
desktopPnpm做 feature-detect,本工程侧不做假的成功。 - PTY 面板阻塞。 需要 aosp 侧编 node-pty/openpty 投放,或上游提供 JS 后端 seam。
- 无内核级沙箱。 工具以应用权限运行,UI 与文档都标注「无隔离」,不粉饰。
这些不是「没做」,而是做不了、或按产品算账决定不做。把边界写清楚,是让后面接手的人(包括三年后的自己)不踩空。
写在最后
DSH Desktop Android Edition 的核心,也只有一句话:抓住「dsh 的 webserver 本来就同源服务页面和 API」这个事实,然后把 Android 的平台差异老老实实收敛成 profile + bundle + 一层可审计的垫片。
它换来的东西很具体:官方 Web UI 一个字节没改、上游零源码补丁、JIT 直接可用(不需要任何受限权限)、包体约 135 MB、升上游 tag 只需重跑管线。代价也很清楚:引擎体积跑不掉、PTY 与进程沙箱仍是天花板、真机取证还欠一轮。
同一套思路,三种成本结构:鸿蒙 ArkWeb 版用「免权限 + 小包体」换灵活度,Electron on HarmonyOS 版用「13 个补丁 + 大包体」换保真度,Android 版用「大引擎 + All-files 授权」换最少的平台折衷。
代码和文档都在这里:
- Android Edition(本篇):https://github.com/fellow99/dsh-desktop-android
- 鸿蒙 ArkWeb Edition:https://github.com/fellow99/dsh-desktop-ark
- Electron on HarmonyOS 版:https://github.com/fellow99/dsh-desktop-hos
- 桌面工程(Windows / Linux / macOS):https://github.com/fellow99/dsh-desktop
- 上游 DeepSeek Harness:https://github.com/deepseek-ai/deepseek-harness
如果你也在折腾「在移动端跑一个真正的 AI agent 宿主」,希望这篇能帮你少走一点弯路。后面我会继续把真机复测、权限门、W^X 与 pnpm 这些脏活写细。
感谢各位关注,欢迎访问我的GitHub主页:https://fellow99.github.io/