Skip to content

Commit efa3711

Browse files
committed
🐾 让 DeepSeek Harness 的 Web UI 在局域网跑起来:4 个最小补丁 + 一键打补丁/还原脚本
0 parents  commit efa3711

8 files changed

Lines changed: 973 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
shell:
10+
name: Shell checks
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Bash syntax check
16+
run: bash -n apply.sh revert.sh
17+
18+
- name: ShellCheck
19+
uses: ludeeus/action-shellcheck@master
20+
with:
21+
scandir: "./"
22+
severity: warning

.gitignore

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# macOS
2+
.DS_Store
3+
4+
# Editors / IDEs
5+
.vscode/
6+
.idea/
7+
*.swp
8+
*.swo
9+
*~
10+
11+
# Logs
12+
*.log
13+
14+
# OS noise
15+
Thumbs.db

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 deepseek-harness-lan contributors
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
# 🐾 deepseek-harness-lan
2+
3+
**English** | [简体中文](README.zh-CN.md)
4+
5+
> Run [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web UI on your home LAN — bind a specific IP, trust it, and open it from any device.
6+
7+
![License](https://img.shields.io/badge/license-MIT-green)
8+
![dsh](https://img.shields.io/badge/dsh-0.1.0--rc.5-blue)
9+
![built-with](https://img.shields.io/badge/built%20with-DeepSeek%20V4%20Flash-4D6BFE)
10+
11+
> **🎯 Target version: dsh `0.1.0-rc.5` (commit `47f9438`)** — the patch is written and verified against this version; other versions may break (see [🧩 Compatibility](#-compatibility)).
12+
13+
---
14+
15+
## 📖 What is this
16+
17+
dsh's Web UI is **officially restricted to localhost access only**. `--host 0.0.0.0` fails immediately:
18+
19+
```
20+
error: --host 0.0.0.0 is intentionally not supported yet for safety:
21+
it would expose remote code execution to the network; use 127.0.0.1 instead
22+
```
23+
24+
This is a **deliberate security design** — the dsh Web UI sits on top of an agent that can execute arbitrary commands, and the official project does not want it exposed to the network.
25+
26+
But what if you want to open dsh from **Windows, phones, or tablets** on your home/office LAN? This repository provides **4 minimal patches + one-click scripts** that let you bind a **specific LAN IP** and run the dsh Web UI safely.
27+
28+
## ✨ Features
29+
30+
- 🔧 **4 minimal diffs** (81 lines) — only the necessary source changes, no extra dependencies
31+
- 📦 **One-click apply / revert** (`apply.sh` / `revert.sh`) — auto-detection, duplicate-application guard
32+
- 🧩 **Does not break the official security model**: the `0.0.0.0` wildcard is still rejected; the `--trusted-host` explicit trust mechanism is preserved
33+
- 🧪 **Verified on dsh 0.1.0-rc.5**: page load, API calls, and privileged endpoints all pass
34+
- 🪄 **Auto-installs the missing build dependency** (`unrun`) so `pnpm run build` doesn't fail mysteriously
35+
36+
## 🚀 Quick Start
37+
38+
### From zero to running (6 steps)
39+
40+
The core order in one sentence: **install the dsh source first, then run `apply.sh`**`apply.sh` is a patching tool, not a dsh installer.
41+
42+
#### ① Prepare the environment
43+
44+
- Node.js **≥ 24** (verify with `node -v`)
45+
- pnpm (verify with `pnpm -v`)
46+
- git
47+
- 🪟 Windows users: run the bash scripts in **Git Bash or WSL**; the dsh source itself is cross-platform
48+
49+
#### ② Get the dsh source
50+
51+
```bash
52+
git clone https://github.com/deepseek-ai/deepseek-harness.git
53+
cd deepseek-harness
54+
pnpm install
55+
```
56+
57+
> ⚠️ **Must be a `git clone` checkout** — the packaged `npm install` build won't work, because the patch modifies source files.
58+
> ⚠️ **Do not use a shallow clone (`--depth 1`)** — the version pinning below needs the full history.
59+
>
60+
> The current master branch is the target version `0.1.0-rc.5` (commit `47f9438`). If upstream has moved on (`apply.sh` will warn about a version mismatch), pin the version first:
61+
>
62+
> ```bash
63+
> git checkout 47f9438
64+
> pnpm install
65+
> ```
66+
67+
#### ③ Clone deepseek-harness-lan and apply the patch
68+
69+
```bash
70+
git clone https://github.com/oitsukiii/deepseek-harness-lan.git
71+
cd deepseek-harness-lan
72+
./apply.sh /path/to/deepseek-harness
73+
```
74+
75+
`apply.sh` automatically: version check → verify the patch applies cleanly → apply → install the missing build dependency `unrun`. You're done when you see **`✓ 补丁应用完成`** (patch applied).
76+
77+
> 💡 **Directory-independent**: `deepseek-harness-lan` and `deepseek-harness` can live in any directories (any combination) — the script locates its own patch and the dsh checkout by argument, with no hardcoded paths. Three ways to run: pass the dsh path as an argument, `cd` into the dsh directory and run, or put deepseek-harness-lan next to dsh.
78+
79+
#### ④ Rebuild the frontend
80+
81+
```bash
82+
cd /path/to/deepseek-harness
83+
pnpm run build:web
84+
```
85+
86+
> The randomUUID polyfill lives in the web bundle, **you must rebuild for it to take effect**.
87+
88+
#### ⑤ Start and bind a LAN IP
89+
90+
```bash
91+
pnpm dsh web --host 192.168.1.100 --trusted-host 192.168.1.100
92+
```
93+
94+
Replace `192.168.1.100` with your LAN IP (check `ip addr` or your router's admin page).
95+
96+
#### ⑥ Access and verify
97+
98+
On another device (Windows / phone / tablet), open in a browser:
99+
100+
```
101+
http://192.168.1.100:3080
102+
```
103+
104+
> ⚠️ If you opened it before, do a **Ctrl + F5 hard refresh** (the browser may cache the old JS).
105+
106+
**Verification checklist:**
107+
108+
- [ ] The page loads normally, title "DeepSeek Harness"
109+
- [ ] Configuring a model API does not report 403 / `crypto.randomUUID is not a function`
110+
- [ ] Other devices on the LAN can open it too
111+
112+
#### Revert (optional)
113+
114+
```bash
115+
./revert.sh /path/to/deepseek-harness
116+
```
117+
118+
Removes the patches with one command; afterwards you can `git pull` to update dsh normally.
119+
120+
---
121+
122+
## 🔀 Alternative: no source changes (SSH tunnel)
123+
124+
If you **don't want to modify the dsh source** (keeping it pristine for zero-maintenance official upgrades), an SSH tunnel is the only clean LAN access method — **zero patches, all three gates pass naturally**.
125+
126+
### Why it works without patching
127+
128+
dsh's trust check (browser-trust) **looks at the request's Host header, not the source IP** (DNS rebinding defense); and `crypto.randomUUID` is only available in a secure context (HTTPS or localhost). An SSH tunnel makes the browser always access dsh via `127.0.0.1`, so:
129+
130+
| Three gates | Under SSH tunnel | Why |
131+
|---|---|---|
132+
| Gate 1 (CLI/schema) | ✅ not triggered | dsh listens on `127.0.0.1` = official default, no changes needed |
133+
| Gate 2 (browser-trust) | ✅ includes privileged endpoints | Host header = `127.0.0.1` → loopback exemption (the original privileged endpoints only allow loopback anyway) |
134+
| Gate 3 (randomUUID) | ✅ available | `127.0.0.1` is a secure context; the browser provides it natively |
135+
136+
### Steps
137+
138+
```bash
139+
# 1. Start dsh as official (no patch, listening on 127.0.0.1:3080)
140+
cd /path/to/deepseek-harness
141+
pnpm dsh web
142+
```
143+
144+
On the computer you want to access from, open the tunnel (Windows 10/11 ships OpenSSH):
145+
146+
```powershell
147+
ssh -N -L 3080:127.0.0.1:3080 <user>@<NAS-IP>
148+
```
149+
150+
Open in the browser:
151+
152+
```
153+
http://127.0.0.1:3080
154+
```
155+
156+
API configuration, privileged endpoints, and all features work normally.
157+
158+
### Comparison
159+
160+
| Option | Source changes | Computer | Phone/tablet | Official upgrades |
161+
|---|---|---|---|---|
162+
| **SSH tunnel** (this option) | ❌ zero changes || ⚠️ configure a tunnel per device | direct `git pull`, seamless |
163+
| **deepseek-harness-lan patch** (main option) | ✅ 4 minimal diffs || ✅ open the URL directly | need to re-apply the patch |
164+
165+
### Why nginx reverse proxy doesn't work
166+
167+
After a reverse proxy (HTTP or HTTPS), the Host header becomes the LAN IP, while the **original privileged endpoints** (`settings.describe`, `llm.providers`, etc. — required for configuring model APIs) use `isTrustedApiRequest(request, [])` which only allows loopback, so the configuration API step necessarily returns 403. The SSH tunnel is the only "no source changes + full functionality" path; if you want every device to open a URL directly, use the main patch option.
168+
169+
---
170+
171+
## 🧠 How it works
172+
173+
dsh officially blocks LAN access via **three gates**. The project's 4 patches break through them one by one:
174+
175+
### Gate 1: CLI rejects `0.0.0.0`, schema only accepts two literals
176+
177+
**Symptom**: `--host 0.0.0.0` fails immediately; binding a specific IP (e.g. `192.168.1.100`) also fails to start:
178+
179+
```
180+
ValidationError: invalid config:
181+
- $.host expected "127.0.0.1" | "0.0.0.0" but got "192.168.1.100" (at host)
182+
```
183+
184+
**Cause** (two layers of restrictions):
185+
186+
| Layer | Location | Restriction |
187+
|---|---|---|
188+
| CLI layer | `packages/bundle/web-app/src/startup.ts` | `options.host === '0.0.0.0'` triggers `program.error()` and refuses to start |
189+
| Schema layer | `packages/host/webserver/src/index.ts` | zod validates `z.union([z.const('127.0.0.1'), z.const('0.0.0.0')])`, and the type definition only allows those two values |
190+
191+
**Patch**:
192+
193+
```diff
194+
// packages/host/webserver/src/index.ts
195+
- host: '127.0.0.1' | '0.0.0.0' // type
196+
+ host: string
197+
- host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required() // zod
198+
+ host: z.string().required()
199+
```
200+
201+
```diff
202+
// packages/bundle/web-app/src/startup.ts
203+
- if (options.host === '0.0.0.0') {
204+
- program.error('...intentionally not supported yet for safety...')
205+
- }
206+
+ // only reject the 0.0.0.0 wildcard; specific LAN IPs are allowed
207+
```
208+
209+
**Design trade-off**: the `0.0.0.0` wildcard binds to every interface (including public/WG), which is dangerous; binding a **specific IP** exposes only one interface and stays controllable. So only specific IPs are unlocked — the wildcard stays rejected.
210+
211+
### Gate 2: the /api browser-trust fence (403)
212+
213+
**Symptom**: the page opens, but every `/api/*` request returns `HTTP 403`, e.g.:
214+
215+
```
216+
加载提供方目录失败: transport failure for /api/llm.providers: HTTP 403
217+
```
218+
219+
**Cause**: there is a **browser-trust fence** in front of dsh's `/api` gateway (anti DNS-rebinding / cross-site attack) in `packages/client/connection/src/index.ts`:
220+
221+
```ts
222+
// trust list: only when binding 0.0.0.0 does it collect all LAN IPs
223+
// binding a specific IP → empty list → all non-localhost requests 403
224+
const lanAddresses = bindHost === ALL_INTERFACES_HOST ? collectLanIps() : []
225+
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
226+
```
227+
228+
Additionally, **privileged methods** (`settings.*`, `credentials.*`, `agentPreset.*`, etc.) are checked against an **empty trust list** — meaning even with `--trusted-host`, those endpoints still only allow localhost:
229+
230+
```ts
231+
if (PRIVILEGED_METHODS.has(method) && !isTrustedApiRequest(request, [])) {
232+
return new Response('forbidden', { status: 403 })
233+
}
234+
```
235+
236+
**Patch** (`packages/client/connection/src/index.ts`):
237+
238+
```diff
239+
- && !isTrustedApiRequest(request, [])) { // empty list: loopback only
240+
+ && !isTrustedApiRequest(request, trustedHosts)) { // explicit trust takes effect
241+
```
242+
243+
**Design trade-off**: `--trusted-host` (the admin-configured explicit trust entry) now also applies to privileged endpoints. **Without `--trusted-host`, behavior is unchanged** — still loopback-only, so the official security semantics are preserved.
244+
245+
### Gate 3: `crypto.randomUUID is not a function`
246+
247+
**Symptom**: error when configuring APIs:
248+
249+
```
250+
加载提供方目录失败: crypto.randomUUID is not a function
251+
```
252+
253+
**Cause**: the browser Web Crypto API's `crypto.randomUUID()` is **only available in a secure context (HTTPS or localhost)**. When accessing `http://192.168.1.100:3080` (plaintext HTTP on the LAN), the function doesn't exist.
254+
255+
**Patch** (`apps/web/src/main.ts`, at the very top of the web entry):
256+
257+
```ts
258+
// crypto.randomUUID is only available in secure contexts; add a UUIDv4
259+
// implementation for plain-HTTP LAN environments
260+
if (typeof globalThis.crypto === 'object' && typeof globalThis.crypto.randomUUID !== 'function') {
261+
try {
262+
globalThis.crypto.randomUUID = () =>
263+
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
264+
const r = (Math.random() * 16) | 0
265+
const v = c === 'x' ? r : (r & 0x3) | 0x8
266+
return v.toString(16)
267+
})
268+
} catch { /* keep as-is in read-only crypto environments */ }
269+
}
270+
```
271+
272+
> 💡 The UUID is only used for local identifiers (attachment drafts, session ids, etc.) and is not involved in security checks, so the polyfill has no security risk.
273+
274+
---
275+
276+
## 🔒 Security notes (please read)
277+
278+
- ⚠️ **This UI can execute arbitrary commands**. After patching, **any device on the LAN** can access and control your agent.
279+
- ✅ Only use it on a **trusted home/office network**.
280+
-**Do not** port-forward 3080 to the public internet (port forwarding / DMZ).
281+
-**Do not** use `--host 0.0.0.0` (still rejected after patching — a deliberately kept line of defense).
282+
- 💡 Advanced: put an **authenticating reverse proxy** (e.g. nginx Basic Auth) in front of dsh.
283+
- 🔐 `--trusted-host` is the official browser-trust mechanism's explicit trust entry — only fill in your own LAN IP.
284+
285+
## 🧩 Compatibility
286+
287+
**This patch is written and verified against dsh `0.1.0-rc.5` (commit `47f9438`).**
288+
289+
- Verification chain: patch applied → `pnpm run build:web` build → Web UI page load → `/api` calls → privileged endpoints (`settings.describe`, etc.) all pass.
290+
- dsh upstream iterates fast; **other versions will most likely break**:
291+
- When the source context changes, `apply.sh`'s `git apply --check` **fails and aborts safely** without dirtying your repo (this is a protection mechanism, not a bug).
292+
- **Adapting to new versions**:
293+
1. Run `apply.sh` on the new version and note the conflicting files when it fails;
294+
2. Manually adapt by following the "three gates" approach in [🧠 How it works](#-how-it-works);
295+
3. PRs with adapted patches are welcome.
296+
- **Revert**: `revert.sh` removes the patches in one command; afterwards you can `git pull` to update dsh normally.
297+
298+
## ❓ FAQ
299+
300+
**Q: Can I access it from my phone?**
301+
A: Yes. As long as the phone is on the same LAN, open `http://<NAS-IP>:3080` in the browser.
302+
303+
**Q: What happens when dsh updates officially after patching?**
304+
A: `apply.sh` runs `git apply --check` first; if the upstream code changed, it fails and aborts without dirtying your repo. Revert with `revert.sh`, then `git pull` normally.
305+
306+
**Q: Why not run it with Docker?**
307+
A: dsh is officially distributed as a Node.js / npm package with no official Docker image; this project only modifies source, so following the official install path is the most stable.
308+
309+
**Q: Will the official project accept this patch?**
310+
A: The official project **deliberately** blocks wildcard binding and is unlikely to relax it soon; but the "bind a specific IP + explicit trust" idea is worth an issue/discussion upstream.
311+
312+
## 🧩 Patch files
313+
314+
| File | Addresses |
315+
|---|---|
316+
| `patches/deepseek-harness-lan.patch` | unified diff of all 4 changes (use directly with `git apply`) |
317+
318+
## 🤝 Contributing
319+
320+
- Bugs / compatibility issues: open an issue with the dsh version + error message
321+
- New platform / new version adaptation: open a PR, update the patch and verify on the target version
322+
- Patch invalidated by upstream dsh updates: PRs with updated patches are welcome
323+
324+
## 🎉 Acknowledgements
325+
326+
- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — an excellent agent framework ("Everything is a Plugin")
327+
- This project is developed with **DeepSeek V4 Flash** (troubleshooting → locating → patching → scripting → documentation, the whole flow)
328+
329+
## 📜 License
330+
331+
MIT License — an unofficial community project unrelated to DeepSeek Harness, for learning and personal reference only.

0 commit comments

Comments
 (0)