目录

把 DeepSeek Harness 开放到局域网:手机访问的几个坑与解法清单

官方还没放开 --host 0.0.0.0,用反代自己挂是主流过渡方案;但手机一打开就白屏、授权按钮失效——根因都指向同一份规范:安全上下文(Secure Contexts)

DeepSeek Harness

一、目标:让手机也能用局域网里的 dsh

DeepSeek Harness(dsh)的 web UI 默认绑 127.0.0.1,官方态度很明确:「--host 0.0.0.0 在远程访问具备鉴权层之前不予支持」。想从手机、平板访问,主流过渡方案是:dsh 继续绑本地,前面架一个带鉴权的反向代理(我挂在了自用的 agent 工作台 cenacle 里),局域网设备经代理访问。

PC 上一切正常。换成手机打开 http://192.168.0.102:9101,接连踩了两个坑:

  1. 首页能出来,一点「打开工作区」就白屏报错:crypto.randomUUID is not a function
  2. 修完白屏后,我自己写的 dsh 插件(browser-fs,让 agent 直接读你当前设备上的文件)在手机上点「授权目录」又失效:showDirectoryPicker 根本不存在

两个坑症状不同,根因是同一个:安全上下文(Secure Contexts)规范

二、根因:安全上下文把 Web API 分成了两类

规范里「安全上下文」大致是:https:// 页面、localhost/127.0.0.1(被特殊豁免)、file://。而 http://192.168.0.102 这种局域网 IP + 明文 HTTP,不算。

PC 上为什么没踩到?因为我习惯用 http://localhost:9101 访问——localhost 被豁免了。手机没有 localhost 可言,只能用局域网 IP,立刻现形。

关键认知是:受安全上下文限制的 API 其实分两类,命运完全不同——

类别 例子 非安全上下文下的表现 能否补救
纯函数/数据类 crypto.randomUUIDcrypto.subtle 函数不存在 可以 polyfill(有 crypto.getRandomValues 这个豁免原语兜底)
权限/对话框门控类 showDirectoryPicker(File System Access)、getUserMedia、Clipboard 读写、Notification、Service Worker 整个 API 对象不存在 补不出来,只能降级或上 HTTPS

任何用了这些 API 的现代前端(Vite/React 生态里 UUID 库遍地都是),在「局域网 HTTP」场景下都是直接崩给你看,而不是温柔降级。

三、坑一的修复:在反代层注入 polyfill

三条路权衡:

1. 给服务上 HTTPS。 治本,但局域网场景代价不小:自签证书要在每台手机装根证书,或者搞内网域名 + DNS-01 签真证书。对「我自己在家用」属于杀鸡用牛刀(完整方案对比见第六节清单)。

2. 改上游代码,去掉 randomUUID 依赖。 不现实——这次是 dsh,下次是 code-server、别的什么工具,每个都打补丁没法维护,升级还丢。

3. 在反向代理层注入 polyfill。 代理本来就过手所有响应,给 HTML 统一注入一小段 crypto.randomUUID 的兼容实现即可。一次投入,所有挂载的工具受益,上游零改动。

我选了 3。代理本体是 net/http/httputil.ReverseProxy,关键在 ModifyResponse 钩子:只处理 text/html,把 polyfill 脚本插到 <head> 之后(要保证它在页面任何业务脚本之前执行):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
ModifyResponse: func(resp *http.Response) error {
    if !strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") {
        return nil
    }
    body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
    resp.Body.Close()
    if err != nil {
        return err
    }
    if loc := headRe.FindIndex(body); loc != nil {
        body = append(body[:loc[1]], append([]byte(polyfillSnippet), body[loc[1]:]...)...)
    } else {
        body = append([]byte(polyfillSnippet), body...)
    }
    resp.Body = io.NopCloser(bytes.NewReader(body))
    resp.ContentLength = int64(len(body))
    resp.Header.Set("Content-Length", strconv.Itoa(len(body)))
    resp.Header.Del("ETag") // 内容已变,旧摘要作废
    return nil
},

注意两个细节:改了 body 就要同步 Content-LengthETag 必须摘掉,否则缓存校验会拿旧摘要说事。

polyfill 本体用 crypto.getRandomValues 实现(它不受安全上下文限制,这才是整个方案成立的关键),再老的浏览器退 Math.random

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
if (window.crypto && !crypto.randomUUID) {
  var grv = crypto.getRandomValues ? crypto.getRandomValues.bind(crypto) : null;
  crypto.randomUUID = function () {
    var b = grv ? grv(new Uint8Array(16)) : null;
    if (!b) { b = []; for (var i = 0; i < 16; i++) b.push(Math.floor(Math.random() * 256)); }
    b[6] = (b[6] & 15) | 64;   // version 4
    b[8] = (b[8] & 63) | 128;  // variant
    var h = [];
    for (var i = 0; i < 16; i++) h.push((b[i] < 16 ? "0" : "") + b[i].toString(16));
    var s = h.join("");
    return s.slice(0,8)+"-"+s.slice(8,12)+"-"+s.slice(12,16)+"-"+s.slice(16,20)+"-"+s.slice(20);
  };
}

一个顺手的坑:Accept-Encoding 删不掉

要改 body 就不能让上游返回压缩内容,直觉写法是在转发前删掉请求头:

1
pr.Out.Header.Del("Accept-Encoding") // 不够!

实测上游照样收到 gzip。原因是 Go 的 http.Transport 看到出站请求没有 Accept-Encoding 时会自作主张补一个 gzip(它本想帮你自动解压)。正确写法是显式声明不接受压缩:

1
pr.Out.Header.Set("Accept-Encoding", "identity")

这个行为在文档里写着,但不踩一次很难记住。

单测覆盖了注入位置(必须在 <head> 之后)、Content-Length 同步、非 HTML 内容原样透传、以及上面那个 Accept-Encoding 断言。上线后手机再开工作区,正常加载——坑一解决。

四、坑二:授权按钮失效——不是所有 API 都能 polyfill

白屏修好后,轮到我写的 browser-fs 插件出问题:它依赖 window.showDirectoryPicker 让用户授权一个本地目录给 agent 读写。这个 API 属于上表右列——整个对象在非安全上下文里根本不存在,polyfill 无米下锅。

这类「权限门控」API 没有补丁可打,只有两条路:上 HTTPS,或者应用层降级。我给插件做了后者:

  • 启动时检测 typeof window.showDirectoryPicker === 'function',检测不到自动进入「兼容模式」(不要看 isSecureContext 标志位——我的反代 polyfill 恰好动过它,会误判)
  • 兼容模式用 <input type="file" webkitdirectory> 选目录——这个老 API 不受安全上下文限制,手机浏览器普遍支持;但 iOS 上目录选择形同虚设(返回 0 个文件),所以授权区是「选择目录 / 选多个文件」双入口,选不到会给明确错误提示而不是静默
  • 选中后插件把文件列表整理成目录树,agent 照常列目录、读文件;文件预览(文本截断/图片 blob)两个模式同路径可用;降级体现在:只读(拿不到可写句柄,写操作返回明确错误)、页面刷新后需重选(没有句柄就没有 IndexedDB 持久化;目录树上的「↻」按钮在兼容模式下等价于重新选择目录)
  • UI 上挂「兼容模式」徽标,并列出获得完整模式的三条途径(SSH 端口转发、Chrome flag、HTTPS)

效果是手机上从「完全不可用」变成「能让 agent 读我手机上的文件」——对「把资料丢给 agent 处理」这个主场景够用了。

五、不只我一个人踩到

这个坑有多普遍?dsh 开源不到 48 小时,社区里已经长出了一圈「曲线救国」的方案:

  • dsh-lan 插件:一条 overlay 把 dsh web 绑到局域网,同时注入 crypto.randomUUID polyfill——和本文第三节一模一样的思路,只是注入点从反代换成了 dsh 自己的 HTML 变换钩子;
  • dsh-webui-auth 插件:给核心包打补丁加会话闸门,还做了「升级后自动重打」的自救逻辑;
  • Caddy basic-auth 一键包:密码保护 + systemd 常驻的运维向方案;
  • 还有直接做多用户网关的(登录门户 + 每用户实例隔离)。

这些项目的 README 里几乎都能翻到同一句话的官方引用:「--host 0.0.0.0 在远程访问具备鉴权层之前不予支持」。大家都知道这些是过渡产物——但过渡期的需求是真实的,而过渡期里最先崩的那个报错,往往就是 crypto.randomUUID

六、解法清单:局域网/手机访问内网服务

方案 能解决什么 代价 适用场景
SSH 端口转发 ssh -L 9101:127.0.0.1:9101 user@host 全部(localhost 豁免生效) 每台设备一条隧道;手机要用 Termius/Termux 类 App,体验差 桌面调试
Chrome chrome://flags/#unsafely-treat-insecure-origin-as-secure 全部(把指定源标记为安全) 逐浏览器设置;微信内置浏览器没门 安卓 Chrome 自用
反代注入 polyfill 「可补」类 API(randomUUID 等) 一次性实现,注意 Accept-Encoding 内网统一挂载多个工具
应用层降级 「权限门控」类 API 功能阉割(如只读) 自己写的组件/插件
裸 VPN(WireGuard / ZeroTier) 不能解决——隧道加密≠安全上下文,浏览器只看 URL 的 scheme+host,http://10.x.x.x 走 VPN 依然白屏 —— 只解决「连得上」,不解决「跑得起」
Tailscale Serve / Cloudflare Tunnel 全部(自动签真证书的 HTTPS 域名) 依赖第三方服务,流量路径出局域网 没域名也想零配置真 HTTPS
HTTPS 自签 + 点过警告 全部(isSecureContext 只看 scheme 不看证书有效性,点过去就是安全上下文) 每个浏览器点一次警告;微信内置浏览器更烦 临时自用
HTTPS + mkcert 根证书 全部,且无警告 每台设备装一次根 CA(iOS 要装描述文件并手动开信任) 固定几台设备自用
HTTPS + 域名 + DNS-01 真证书 全部,治本,且手机零配置 要有个域名,DNS 服务商配 API 多人共用、长期运行

一个常见误解要特别说明:VPN 本身不改变安全上下文。WireGuard/ZeroTier 把流量加密送到对端,但浏览器看到的 URL 还是 http://内网 IP——它不知道也不关心底层链路加密了没有。能解决问题的是 Tailscale Serve、Cloudflare Tunnel 这类「VPN/隧道工具附带的 HTTPS 能力」:它们给你的是带真证书的 https:// 域名,这才算数。

另一个常被问到的是手机改 hosts:Android 改 /etc/hosts 要 root,iOS 要越狱;无 root 的等价物是 Hosts Go、DNS66 这类「本地 VPN 型」App(代价是常驻一个 VPN 槽位),或者干脆不改手机——在路由器/PC 上跑 dnsmasq、AdGuard Home 当局域网 DNS。但 hosts/DNS 只解决「域名到 IP 的映射」,不解决安全上下文,它始终是真证书方案的配角。实际上多数时候连它都不需要:把域名的公网 A 记录直接指向内网 IP(如 192.168.0.102)即可——DNS-01 签证书只验证域名控制权、不要求公网可访问,手机拿到域名后走局域网直连,零配置无警告。唯一的坑是部分路由器默认开启「DNS 重绑定保护」会拦截这种解析,关掉或加白名单即可。

实战建议组合用:反代 polyfill 打底(覆盖工具类 API),自己写的组件做降级(兜住权限类 API),真要给多人长期用再上 HTTPS。

七、附:一个 Python 最小实现(单文件,带鉴权)

不想搭工作台的话,「HTTPS 反代 + 简单鉴权」一个 Python 脚本就够(完整版在 cenacle 仓库 tests/debug/https_proxy.py,约 240 行)。四个要点:

  • 用 aiohttp 而不是标准库——WebSocket 必须透传(dsh 的终端流走 WS,不透传就是哑终端),标准库手写帧转发不值得;
  • 普通请求流式透传、剥 hop-by-hop 头;唯一的改写是 HTML 一律注入 crypto.randomUUID polyfill(脚本自带存在性判断,HTTPS 安全上下文下是 no-op,所以两种模式同一套逻辑);
  • Basic Auth:401 + WWW-Authenticate 头,浏览器自带弹窗,演示/自用足够;
  • --mode https(默认):443 对外 HTTPS,80 只做 301 跳转;--mode http:纯 HTTP 反代 + polyfill,不用碰证书。<1024 端口要 root 或 cap_net_bind_service,调试期用 8443/8080 也一样。

核心代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
HOP = {"connection", "keep-alive", "te", "trailers",
       "transfer-encoding", "upgrade"}  # hop-by-hop 头必须剥掉

def check_auth(req, user, pwd):
    h = req.headers.get("Authorization", "")
    if h.startswith("Basic "):
        u, _, p = base64.b64decode(h[6:]).decode().partition(":")
        return u == user and p == pwd
    return False

async def proxy_http(req, upstream):
    sess = req.app["sess"]
    headers = {k: v for k, v in req.headers.items() if k.lower() not in HOP}
    async with sess.request(req.method, upstream + str(req.rel_url),
                            headers=headers, data=await req.read(),
                            allow_redirects=False) as up:
        # HTML 一律注入 polyfill;aiohttp 已自动解压,必须摘掉
        # Content-Encoding/Content-Length 再重写长度
        if "text/html" in up.headers.get("Content-Type", "").lower():
            page = await up.read()
            page = inject_head(page, POLYFILL)   # 插到 <head> 之后
            out_headers = {k: v for k, v in up.headers.items()
                           if k.lower() not in HOP | {"content-encoding", "content-length", "etag"}}
            return web.Response(status=up.status, body=page, headers=out_headers)
        out = web.StreamResponse(status=up.status)
        for k, v in up.headers.items():
            if k.lower() not in HOP:
                out.headers[k] = v
        await out.prepare(req)
        async for chunk in up.content.iter_any():
            await out.write(chunk)
        await out.write_eof()
        return out

async def proxy_ws(req, upstream):
    ws_in = web.WebSocketResponse()
    await ws_in.prepare(req)
    async with req.app["sess"].ws_connect(upstream + str(req.rel_url)) as ws_out:
        async def forward(src, dst):
            async for msg in src:
                if msg.type == WSMsgType.TEXT:
                    await dst.send_str(msg.data)
                elif msg.type == WSMsgType.BINARY:
                    await dst.send_bytes(msg.data)
                else:
                    break
        await asyncio.wait([asyncio.create_task(forward(ws_in, ws_out)),
                            asyncio.create_task(forward(ws_out, ws_in))],
                           return_when=asyncio.FIRST_COMPLETED)
    return ws_in

起服务:

1
2
3
pip install aiohttp
sudo python3 https_proxy.py --https-port 443 --http-port 80 \
  --cert ~/.cenacle/tls/fullchain.pem --key ~/.cenacle/tls/privkey.pem

不需要权限门控类 API(比如不用 browser-fs 的授权读写)的话,纯 HTTP 模式一行就够,证书、防火墙、端口转发全省:

1
python3 https_proxy.py --mode http --http-port 8080

手机开 http://内网IP:8080 就能进 dsh——polyfill 修好白屏;但 showDirectoryPicker 这类 API 在非安全上下文里整个对象不存在,那时再回 HTTPS 模式。

如果你的开发机和我一样是 Windows 宿主上的 QEMU Debian 虚拟机(用户模式网络是 NAT,虚拟机拿到 10.0.2.x,局域网 IP 在 Windows 宿主上),虚拟机里监听好还不够,要让宿主的 443 能进虚拟机。QEMU 侧用 hostfwd 做端口转发——运行中的虚拟机不用重启,在 QEMU monitor 里热添加即可:

1
2
(qemu) hostfwd_add tcp::443-:443
(qemu) hostfwd_add tcp::80-:80

想永久生效再把同样的转发写进启动参数的 -netdev user,id=n0,hostfwd=tcp::443-:443,... 里。

再在 Windows 的管理员 PowerShell 里放行入站:

1
New-NetFirewallRule -DisplayName "QEMU-HTTPS-443" -Direction Inbound -Protocol TCP -LocalPort 443 -Action Allow

如果你的开发机是 WSL2,思路一样、工具不同——用 netsh portproxy 把 443 转发进 WSL:

1
2
3
4
# 先看 443 有没有被旧规则占用,有就删了再加
netsh interface portproxy show all
netsh interface portproxy add v4tov4 listenport=443 listenaddress=0.0.0.0 connectport=443 connectaddress=localhost
New-NetFirewallRule -DisplayName "WSL2-HTTPS-443" -Direction Inbound -Protocol TCP -LocalPort 443 -Action Allow

(WSL 的 connectaddress=localhost 依赖其默认开启的 localhost 转发;不灵就换成 ip addr 里 WSL 的 eth0 地址,但它会随 WSL 重启变化。)

curl 实测四连:无鉴权 401(带 WWW-Authenticate 触发浏览器弹窗)→ basic auth 后 200 拿到 dsh 页面 → http:// 请求 301 跳 https → WS 升级 101 正常透传。手机打开 https://cenacle.whitefirer.org(点过自签警告)即是完整安全上下文。

插曲:域名访问触发 dsh 的 DNS-rebinding 防御

页面能开不代表完事——一点「打开目录」就 403。对照实验(都是 POST /api/host.listDirectory):

Host Origin 结果
127.0.0.1:3080(直连) 200
IP 字面量 IP 字面量 200
IP 字面量 cenacle.whitefirer.org 403
cenacle.whitefirer.org 403

dsh 对 Host 和 Origin 各查一遍:非 localhost/IP 字面量一律 403——这是 DNS-rebinding 防御,防的是恶意网站把公用域名指到 127.0.0.1 来打你本机服务。它宁可错杀我这种「域名指内网 IP」的场景。

修法在反代侧:转发时把 Host 改写成上游地址、剥掉 Origin(缺失的 Origin 反而放行——这条只挡「带错」不挡「没带」)。cenacle 的 Go 代理就是这么做的,上面 Python 版的 skip 清单也加了 host/origin 两个键。301 跳转腿不用处理——浏览器拿到新地址会自己重发。

八、收尾

这个问题的有趣之处在于:它只在「跨设备 + 明文 HTTP + 现代前端」三个条件叠加时出现,PC 开发自测完全覆盖不到。如果你也有局域网手机访问的服务,值得主动做两件事:

  1. 拿真手机打开 Console 看一眼,别等用户报白屏;
  2. 把代码里用到的现代 API 按「可 polyfill / 权限门控」分成两类,前者反代统一补,后者设计好降级路径。

以及,过渡方案终究是过渡方案——等官方鉴权落地,这些脚手架都可以体面退役。

本文是「DeepSeek Harness」系列第 4 篇,共 4 篇。上一篇:给 DeepSeek Harness 写插件:让远程 Agent 读写你电脑上的文件