dsh-zen-remote 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +51 -0
- package/LICENSE +38 -0
- package/README.md +237 -0
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +17 -0
- package/cordis.patch.yml.example +16 -0
- package/docs/interface.md +200 -0
- package/docs/remote-access.en.md +335 -0
- package/docs/remote-access.md +422 -0
- package/dsh-push.mjs +213 -0
- package/lan-gate.mjs +78 -0
- package/lib/client.js +6615 -0
- package/lib/index.js +310 -0
- package/lib/index.js.map +1 -0
- package/lib/lan-gate-server.cjs +626 -0
- package/lib/types/client/MobileAttachButton.d.ts +25 -0
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -0
- package/lib/types/client/MobileAttachChips.d.ts +32 -0
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts +21 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -0
- package/lib/types/client/MobileHome.d.ts +24 -0
- package/lib/types/client/MobileHome.d.ts.map +1 -0
- package/lib/types/client/MobileHomeChips.d.ts +76 -0
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -0
- package/lib/types/client/MobileNavOverlay.d.ts +15 -0
- package/lib/types/client/MobileNavOverlay.d.ts.map +1 -0
- package/lib/types/client/MobileNavToggle.d.ts +18 -0
- package/lib/types/client/MobileNavToggle.d.ts.map +1 -0
- package/lib/types/client/MobileSessionHeader.d.ts +58 -0
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -0
- package/lib/types/client/MobileSessionInfo.d.ts +41 -0
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -0
- package/lib/types/client/attach-upload.d.ts +78 -0
- package/lib/types/client/attach-upload.d.ts.map +1 -0
- package/lib/types/client/chips-store.d.ts +13 -0
- package/lib/types/client/chips-store.d.ts.map +1 -0
- package/lib/types/client/debug.d.ts +9 -0
- package/lib/types/client/debug.d.ts.map +1 -0
- package/lib/types/client/effects/aionui-compat.d.ts +4 -0
- package/lib/types/client/effects/aionui-compat.d.ts.map +1 -0
- package/lib/types/client/effects/gestures.d.ts +7 -0
- package/lib/types/client/effects/gestures.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts +15 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -0
- package/lib/types/client/effects/phone-chrome.d.ts +106 -0
- package/lib/types/client/effects/phone-chrome.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +25 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +18 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +58 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/nav-store.d.ts +73 -0
- package/lib/types/client/nav-store.d.ts.map +1 -0
- package/lib/types/client/session-dot.d.ts +12 -0
- package/lib/types/client/session-dot.d.ts.map +1 -0
- package/lib/types/client/styles/base.css.d.ts +2 -0
- package/lib/types/client/styles/base.css.d.ts.map +1 -0
- package/lib/types/client/styles/chips.css.d.ts +2 -0
- package/lib/types/client/styles/chips.css.d.ts.map +1 -0
- package/lib/types/client/styles/compat.css.d.ts +2 -0
- package/lib/types/client/styles/compat.css.d.ts.map +1 -0
- package/lib/types/client/styles/composer.css.d.ts +2 -0
- package/lib/types/client/styles/composer.css.d.ts.map +1 -0
- package/lib/types/client/styles/header.css.d.ts +2 -0
- package/lib/types/client/styles/header.css.d.ts.map +1 -0
- package/lib/types/client/styles/home.css.d.ts +2 -0
- package/lib/types/client/styles/home.css.d.ts.map +1 -0
- package/lib/types/client/styles/index.d.ts +15 -0
- package/lib/types/client/styles/index.d.ts.map +1 -0
- package/lib/types/client/styles/info.css.d.ts +2 -0
- package/lib/types/client/styles/info.css.d.ts.map +1 -0
- package/lib/types/client/styles/layout.css.d.ts +2 -0
- package/lib/types/client/styles/layout.css.d.ts.map +1 -0
- package/lib/types/client/styles/misc.css.d.ts +2 -0
- package/lib/types/client/styles/misc.css.d.ts.map +1 -0
- package/lib/types/client/styles/turn-fold.css.d.ts +2 -0
- package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -0
- package/lib/types/index.d.ts +83 -0
- package/lib/types/index.d.ts.map +1 -0
- package/llms.txt +20 -0
- package/package.json +128 -0
- package/pwa/app.css +95 -0
- package/pwa/icons/icon-192.png +0 -0
- package/pwa/icons/icon-512.png +0 -0
- package/pwa/icons/icon-maskable-512.png +0 -0
- package/pwa/icons/icon-maskable.svg +1 -0
- package/pwa/icons/icon.svg +1 -0
- package/pwa/inject.js +190 -0
- package/pwa/manifest.json +29 -0
- package/pwa/offline.html +38 -0
- package/pwa/sw.js +176 -0
- package/pwa/touch-gestures.js +94 -0
- package/scripts/build-client.mjs +101 -0
- package/scripts/cdp-probe.mjs +64 -0
- package/scripts/check-attach-upload.mjs +99 -0
- package/scripts/check-sunk-viewport.mjs +33 -0
- package/scripts/check-upload-endpoint.mjs +168 -0
- package/src/client/MobileAttachButton.tsx +125 -0
- package/src/client/MobileAttachChips.tsx +85 -0
- package/src/client/MobileDrawerFooter.tsx +56 -0
- package/src/client/MobileHome.tsx +348 -0
- package/src/client/MobileHomeChips.tsx +426 -0
- package/src/client/MobileNavOverlay.tsx +279 -0
- package/src/client/MobileNavToggle.tsx +52 -0
- package/src/client/MobileSessionHeader.tsx +242 -0
- package/src/client/MobileSessionInfo.tsx +303 -0
- package/src/client/attach-upload.ts +140 -0
- package/src/client/chips-store.ts +41 -0
- package/src/client/debug.ts +253 -0
- package/src/client/effects/aionui-compat.ts +183 -0
- package/src/client/effects/gestures.ts +265 -0
- package/src/client/effects/header-status.ts +30 -0
- package/src/client/effects/phone-chrome.ts +265 -0
- package/src/client/effects/turn-fold.ts +258 -0
- package/src/client/index.tsx +227 -0
- package/src/client/locales.ts +109 -0
- package/src/client/nav-store.ts +78 -0
- package/src/client/session-dot.ts +17 -0
- package/src/client/styles/base.css.ts +167 -0
- package/src/client/styles/chips.css.ts +224 -0
- package/src/client/styles/compat.css.ts +681 -0
- package/src/client/styles/composer.css.ts +504 -0
- package/src/client/styles/header.css.ts +368 -0
- package/src/client/styles/home.css.ts +477 -0
- package/src/client/styles/index.ts +25 -0
- package/src/client/styles/info.css.ts +238 -0
- package/src/client/styles/layout.css.ts +501 -0
- package/src/client/styles/misc.css.ts +128 -0
- package/src/client/styles/turn-fold.css.ts +106 -0
- package/src/index.ts +343 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
<h1 align="center">Remote access (gateway half)</h1>
|
|
2
|
+
<p align="center">Turn DeepSeek Harness into a mobile PWA you can safely reach from the public internet: pairing-code auth + token identity + real Web Push, with your own reverse proxy terminating TLS.</p>
|
|
3
|
+
|
|
4
|
+
> Full documentation for the gateway half of `dsh-zen-remote`: reverse proxy, pairing, environment variables, admin API, push, security boundary.
|
|
5
|
+
> Installation and quick start live in the [root README](../README.md) (Chinese); interface-side detail in [interface.md](interface.md).
|
|
6
|
+
|
|
7
|
+
Built on the MIT [dsh-mobile-gate](https://github.com/Bernardxu123/dsh-mobile-gate) secure-gateway base, with PWA differentiation.
|
|
8
|
+
|
|
9
|
+
[](https://www.npmjs.com/package/the gateway half)
|
|
10
|
+
[](https://github.com/KyoMio/the gateway half/blob/main/LICENSE)
|
|
11
|
+
[](https://github.com/topics/dsh-plugin)
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
| Module | What |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| 🔑 **Public-internet identity** | The gateway listens on `127.0.0.1` only, sitting behind your own reverse proxy. New devices trade a pairing code for a long-lived device token (cookie `lg_device`) — identity follows the token, not the source IP |
|
|
20
|
+
| 📱 **Real PWA** | `manifest.json` + service worker: once the proxy provides HTTPS, "Add to Home Screen" actually works — standalone full-screen app with icon, splash, theme-color, maskable assets |
|
|
21
|
+
| 🌐 **Offline** | SW v3: only the true static shell (manifest/icons/offline page) is cache-first, everything else (DSH client bundle JS/CSS, API, page HTML) is network-first — a new deploy is picked up immediately instead of lingering behind stale cached CSS |
|
|
22
|
+
| 👆 **Touch gestures** | Pinch-to-resize font (resettable); edge-swipe-back has been handed off to the interface half (see "Division of labor" below), pull-to-refresh has been removed entirely (an accidental overscroll used to fire a full reload mid-conversation) |
|
|
23
|
+
| 🔔 **Agent-done push** | Real Web Push (VAPID-signed, aes128gcm-encrypted). Notified when the agent finishes, even from another app — the notification never carries conversation content |
|
|
24
|
+
| 🛎️ **`push_notify` tool** | A model-callable push tool (registered by `dsh-push.mjs`): the model can decide mid-task that the user needs a decision, that a key milestone was reached, or that an error needs a human — and push straight to the lock screen instead of waiting for the turn to end. Usage discipline (don't call this often) is spelled out in the tool description; the host also enforces it with rate limits (max 1 per 60s per session, 20/hour globally) — over the limit, the call is silently dropped, never an error. Same aes128gcm end-to-end encryption, same lock-screen-only exposure. Turn it off entirely with `pushTool: false` in `lan-gate.config.json` (or `DSH_PUSH_TOOL=0`); it's also skipped automatically on hosts without a tool registry (`ctx.tools`), with no effect on the rest of the plugin |
|
|
25
|
+
| 📐 **Touch layout** | This repo now only keeps shell-level rules (iOS input-zoom fix, safe-area scroll padding, horizontal-scrolling code) — layout rules (44px targets, dialogs, composer chrome) moved to the interface half, see "Division of labor" below — desktop never affected |
|
|
26
|
+
| 🔒 **Desktop unaffected** | Every rule is rooted at `html:not([data-lan-device="desktop"])` (or an `@media(max-width:820px)` with the same exclusion) — an explicit "desktop" kind opts out, everything else (including a real phone's default "auto" kind) opts in |
|
|
27
|
+
| 🛡️ **Admin surface is local-only** | Generating pairing codes, managing devices, triggering pushes — these endpoints only accept direct local connections; anything arriving through the proxy gets 403 |
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Architecture
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
Public device (phone/laptop) --HTTPS--> your own reverse proxy (nginx/Caddy, terminates TLS)
|
|
35
|
+
│ HTTP + X-Forwarded-For/Proto
|
|
36
|
+
▼
|
|
37
|
+
gateway (isolated Node child · listens on 127.0.0.1:3088 by default)
|
|
38
|
+
│
|
|
39
|
+
┌──────────────────────────────────┼───────────────────────────────────┐
|
|
40
|
+
│ │ │
|
|
41
|
+
unpaired device paired device (has lg_device token cookie) direct-local request (no X-Forwarded-*)
|
|
42
|
+
→ any path redirects to → reverse-proxied to DSH Web UI → admin page / admin API / push trigger
|
|
43
|
+
the pairing page, (127.0.0.1:3080); HTML injected: /lan-gate/admin /status
|
|
44
|
+
POST code -> token manifest + PWA bootstrap + /action /pair /pwa/push/send
|
|
45
|
+
touch CSS + randomUUID polyfill
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- The gateway is an isolated child process: if it crashes, DSH's main service is unaffected; it's torn down automatically when the plugin stops.
|
|
49
|
+
- DSH's own web server still binds `127.0.0.1` only. The gateway never touches DSH's config or its `/api` trust fence.
|
|
50
|
+
- The one IP-based trust left: a loopback socket carrying **no** `X-Forwarded-*` headers is treated as the local user sitting at this machine — the only path into the admin surface. Requests that came through the proxy always carry forwarded headers, so they can never look local.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Division of labor with the interface half
|
|
55
|
+
|
|
56
|
+
Inside this single plugin, the boundary between the two halves is: **this repo only owns the shell and the channel** (pairing auth, tokens, rate limiting, the PWA install manifest, the service worker, first-frame safe-area injection); **all layout — typography, dialogs, composer chrome, bubble styling — belongs to the interface half**.
|
|
57
|
+
|
|
58
|
+
`pwa/app.css` was trimmed from 163 lines down to 95, keeping only shell-level rules. A stale inline `DEVICE_CSS` copy left inside the gateway (`lib/lan-gate-server.cjs`) went further than that — one of its rules stretched *any* `role="dialog" aria-modal="true"` overlay to fill the viewport, including the interface half's own session-info card, which is why it used to overflow the screen only when accessed through the gateway. That dead copy has been removed entirely. Pull-to-refresh and edge-swipe-back have also been removed from this repo's `touch-gestures.js`: the former kept firing full-page reloads on an accidental overscroll, and the latter's `history.back()` was always a no-op against DSH's own client-side routing — the interface half's own left-edge swipe gesture now owns that 24px hot zone instead. Pinch-to-resize stays here.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Quick start
|
|
63
|
+
|
|
64
|
+
### 1. Install the plugin
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
dsh plugin --profile web add github:KyoMio/the gateway half
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The package declares a `dsh.bundle` manifest; restart `dsh web` after installing.
|
|
71
|
+
|
|
72
|
+
Local-directory install (for hacking on the code yourself):
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
git clone https://github.com/KyoMio/the gateway half.git
|
|
76
|
+
cd the gateway half
|
|
77
|
+
dsh plugin --profile web add ./the gateway half
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Static mount is also available (see [`cordis.patch.yml.example`](../cordis.patch.yml.example) — swap in the absolute checkout path) or dynamic-plugin mount (see the header comment in `lan-gate.mjs`), for setups that skip `dsh plugin add`.
|
|
81
|
+
|
|
82
|
+
### 2. Put your own reverse proxy in front
|
|
83
|
+
|
|
84
|
+
The gateway listens on `127.0.0.1:3088` only by default — it will not expose itself to the public internet on its own. To reach it from a phone or another computer, run a reverse proxy somewhere that can see the gateway, have it terminate HTTPS, and forward to the gateway. Both configs below are meant to be copy-pasted as-is.
|
|
85
|
+
|
|
86
|
+
#### nginx
|
|
87
|
+
|
|
88
|
+
```nginx
|
|
89
|
+
# Put this once inside the http {} block; every server{} below can reuse it
|
|
90
|
+
map $http_upgrade $connection_upgrade {
|
|
91
|
+
default upgrade;
|
|
92
|
+
'' close;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
server {
|
|
96
|
+
listen 80;
|
|
97
|
+
server_name dsh.example.com;
|
|
98
|
+
return 301 https://$host$request_uri;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
server {
|
|
102
|
+
listen 443 ssl http2;
|
|
103
|
+
server_name dsh.example.com;
|
|
104
|
+
|
|
105
|
+
ssl_certificate /etc/letsencrypt/live/dsh.example.com/fullchain.pem;
|
|
106
|
+
ssl_certificate_key /etc/letsencrypt/live/dsh.example.com/privkey.pem;
|
|
107
|
+
|
|
108
|
+
location / {
|
|
109
|
+
proxy_pass http://127.0.0.1:3088;
|
|
110
|
+
proxy_http_version 1.1;
|
|
111
|
+
|
|
112
|
+
# WebSocket upgrade — required by the DSH Web UI
|
|
113
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
114
|
+
proxy_set_header Connection $connection_upgrade;
|
|
115
|
+
|
|
116
|
+
# The gateway relies on these two headers to identify the real client
|
|
117
|
+
proxy_set_header Host $host;
|
|
118
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
119
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
120
|
+
|
|
121
|
+
# Recommended for long-lived/streaming responses so nothing gets buffered away
|
|
122
|
+
proxy_buffering off;
|
|
123
|
+
proxy_read_timeout 3600s;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
#### Caddy
|
|
129
|
+
|
|
130
|
+
Caddy handles HTTPS certificate issuance, WebSocket forwarding, and forwarded headers automatically — one `reverse_proxy` line is enough:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
dsh.example.com {
|
|
134
|
+
reverse_proxy 127.0.0.1:3088
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
> Proxy and gateway not on the same host (e.g. the proxy runs in another container/server)? The gateway only trusts `X-Forwarded-For` coming from a loopback socket by default — add the proxy's egress IP to `LAN_GATE_TRUSTED_PROXIES` (see the env var table below).
|
|
139
|
+
|
|
140
|
+
#### Lucky
|
|
141
|
+
|
|
142
|
+
[Lucky](https://github.com/gdy666/lucky) is a popular all-in-one public-access toolbox on Chinese routers/NAS boxes (DDNS + ACME certs + reverse proxy; admin UI defaults to `http://<device-ip>:16601`). Prerequisite: set up your domain's DDNS and certificate in Lucky's DDNS and security-certificate modules first (ACME auto-renews). Then:
|
|
143
|
+
|
|
144
|
+
1. **Web Service → add a web-service rule**: listen on `443`, enable TLS and attach your domain's certificate.
|
|
145
|
+
2. **Add a sub-rule** under it: service type "reverse proxy", frontend address = your domain (e.g. `dsh.example.com`), backend address depending on your layout:
|
|
146
|
+
- Lucky and DSH on the **same machine**: `127.0.0.1:3088`, zero gateway-side config.
|
|
147
|
+
- Lucky on a **router/NAS** (the common case): use `<DSH-machine-LAN-IP>:3088`, and set two env vars on the gateway — `LAN_GATE_HOST=0.0.0.0` (so Lucky can reach it; other LAN devices still only ever see the pairing page) and `LAN_GATE_TRUSTED_PROXIES=<Lucky-device-LAN-IP>` (so the gateway trusts its forwarded headers).
|
|
148
|
+
3. **Turn on the sub-rule's 万事大吉 ("all is well") switch** — it auto-adds the common request headers including `X-Forwarded-For`. **On a same-machine deployment this switch is part of the security boundary**: without it, requests arriving through Lucky come from loopback with no forwarded headers and get treated as the local user — exposing the admin surface to the internet. With it on, the problem doesn't exist.
|
|
149
|
+
4. WebSocket passes through automatically, no extra setting; if the conversation stream stalls, upgrade Lucky first.
|
|
150
|
+
|
|
151
|
+
> Exact toggle names may vary slightly across Lucky versions — the three things that matter: HTTPS cert, reverse proxy to 3088, forwarded headers (万事大吉).
|
|
152
|
+
|
|
153
|
+
#### Post-setup self-check (do this for any proxy)
|
|
154
|
+
|
|
155
|
+
From **cellular data** (not your home Wi-Fi), open `https://your-domain/lan-gate/admin` — the correct result is **403**. If you can see the admin page, your proxy is not sending `X-Forwarded-*` headers and the gateway mistook a public request for the local user — **go fix the header config immediately** (nginx: the two `proxy_set_header` lines; Lucky: the 万事大吉 switch). Only start pairing devices after this check passes.
|
|
156
|
+
|
|
157
|
+
### 3. Generate a pairing code and pair devices
|
|
158
|
+
|
|
159
|
+
1. With the proxy in place, open `http://127.0.0.1:3088/lan-gate/admin` in a browser **on the host itself**.
|
|
160
|
+
2. Click "generate pairing code" to get an 8-character code, valid for 10 minutes, single-use.
|
|
161
|
+
3. On the phone or another computer, open your proxy's HTTPS domain in a browser — you'll land on the pairing page. Enter the code (device name is optional).
|
|
162
|
+
4. On success you're dropped straight into the DSH Web UI (PWA-injected); identity is stored in a long-lived cookie, so switching Wi-Fi/IP never logs you out.
|
|
163
|
+
5. On the phone, use the browser menu's "Add to Home Screen" to get a standalone app.
|
|
164
|
+
6. The page will prompt you to enable "agent-done push" — grant notification permission and you'll get a system notification when the agent finishes, even from another app.
|
|
165
|
+
|
|
166
|
+
The admin page also lets you set a device's kind (phone / desktop / auto layout), rename it, and revoke a single device or all of them at once.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Environment variables
|
|
171
|
+
|
|
172
|
+
| Variable | Default | What |
|
|
173
|
+
| --- | --- | --- |
|
|
174
|
+
| `LAN_GATE_PORT` | `3088` | Gateway listen port; on `EADDRINUSE` it retries up the port range (up to +20) |
|
|
175
|
+
| `LAN_GATE_HOST` | `127.0.0.1` | Gateway listen address. Leaving the default in place plus a reverse proxy is the recommended setup — only change this if you know exactly what you're doing |
|
|
176
|
+
| `LAN_GATE_TARGET_PORT` | `3080` | Local DSH Web UI port the gateway reverse-proxies to |
|
|
177
|
+
| `LAN_GATE_RATE_LIMIT` | `120` | Per-real-client-IP per-minute cap **for unpaired/unauthenticated requests only** (protects the pairing surface). Local users and paired devices are exempt — their guardrail is the token + revocation |
|
|
178
|
+
| `LAN_GATE_TRUSTED_PROXIES` | empty | Comma-separated IP list. When the proxy and gateway aren't on the same host (i.e. not a loopback socket), list the proxy's egress IP here so the gateway trusts the `X-Forwarded-For`/`X-Forwarded-Proto` it sends |
|
|
179
|
+
| `LAN_GATE_VAPID_SUBJECT` | `mailto:admin@localhost` | VAPID contact for Web Push. **Set this to a real mailto: address or https:// URL**: Apple rejects placeholder subjects with `403 BadJwtToken`, silently killing push to every iOS device (Google/Mozilla do not check). The gateway warns at startup if it looks invalid |
|
|
180
|
+
|
|
181
|
+
Besides env vars, the **recommended way is the config file** `~/.dsh/lan-gate.config.json` (shared by the gateway and the push plugin; restart `dsh web` after editing; explicit env vars win over the file):
|
|
182
|
+
|
|
183
|
+
```json
|
|
184
|
+
{
|
|
185
|
+
"host": "0.0.0.0",
|
|
186
|
+
"trustedProxies": "192.168.1.2",
|
|
187
|
+
"rateLimit": 600,
|
|
188
|
+
"pushSummary": true
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Field names = env var names minus the prefix, camelCased: `port` / `host` / `targetPort` / `rateLimit` / `trustedProxies` / `vapidSubject`, plus the push half `pushEvents` / `pushDebounceMs` / `pushSummary` / `pushTool` (the `push_notify` tool switch, defaults to `true`). On DSH versions whose insert rows support Cordis config, the same camelCase fields under the row's `config:` work too.
|
|
193
|
+
|
|
194
|
+
The optional push host plugin mounts via the profile patch (`~/.dsh/profiles/web/cordis.patch.yml`):
|
|
195
|
+
|
|
196
|
+
```yaml
|
|
197
|
+
- insert:
|
|
198
|
+
- id: the gateway half-push
|
|
199
|
+
name: the gateway half/dsh-push.mjs
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Admin API
|
|
205
|
+
|
|
206
|
+
All of the following endpoints are **local-direct-connection only**: the request's socket must be a loopback address and carry no `X-Forwarded-*` headers at all. Anything that came through the proxy (which always carries forwarded headers) gets 403 — the public internet can never reach these.
|
|
207
|
+
|
|
208
|
+
| Endpoint | Method | What | Params |
|
|
209
|
+
| --- | --- | --- | --- |
|
|
210
|
+
| `/lan-gate/pair` | POST | Generate a new one-time pairing code (valid 10 minutes) | none |
|
|
211
|
+
| `/lan-gate/status` | GET | Read running state, the current pairing code, the list of paired devices | none |
|
|
212
|
+
| `/lan-gate/action` | POST | Manage a device | `action`: `set-kind` / `rename` / `revoke` / `revoke-all`; `id`: device id (not needed for `revoke-all`); `set-kind` also needs `kind` (`phone`/`desktop`/`auto`); `rename` also needs `name` |
|
|
213
|
+
| `/pwa/push/send` | POST | Send one push to every subscribed device | `title`, `body` (plain text, no conversation content) |
|
|
214
|
+
|
|
215
|
+
The exception is `/lan-gate/pair/claim` (POST) — the one endpoint reachable from anywhere, since it's how a device redeems the pairing code for a token in the first place. It's protected by the code itself (single-use, 10-minute TTL) and a failure lockout (5 wrong codes locks that IP for 15 minutes), not by local identity.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Push notes
|
|
220
|
+
|
|
221
|
+
- The VAPID key pair is generated automatically on first boot and persisted to `~/.dsh/lan-gate-state.json` (override the directory with `DSH_HOME`); the public key is delivered to the page via the injected bootstrap script.
|
|
222
|
+
- `/pwa/push/subscribe` requires a valid device token cookie (i.e. the device must already be paired); each device gets at most one subscription, capped at 20 total, to keep strangers from spamming your server with subscriptions or using it to fire requests elsewhere.
|
|
223
|
+
- Push payloads carry only a title and a short body line (e.g. "DSH task complete") — **never any conversation content**. Delivery is standard Web Push (VAPID-signed, aes128gcm-encrypted); only the push service and your browser ever see the plaintext.
|
|
224
|
+
- Revoking a device deletes its push subscription too; a 404/410 from the push endpoint (expired subscription) gets it auto-cleaned on the next send.
|
|
225
|
+
- Mobile browsers require HTTPS before they'll register a service worker at all, so both push and offline support depend on step 2's reverse proxy — neither works on a real device until HTTPS is in place.
|
|
226
|
+
- The "notify when the agent finishes" wiring lives in the optional host plugin `dsh-push.mjs`: it listens on the DSH event bus and calls the local `/pwa/push/send`. Event names come from `DSH_PUSH_EVENTS` (comma-separated); the default `agent/turn-stopping` is the official turn-close checkpoint (fires once per turn when the model owes no response and no tool calls are live). Override the env var if your DSH version names it differently. `DSH_PUSH_DEBOUNCE_MS` (default 15000) sets the minimum gap between notifications. Want the turn's outcome in the notification body? Set `DSH_PUSH_SUMMARY=1` and the body becomes the turn's final assistant message (truncated to 120 chars). The push payload is aes128gcm-encrypted end to end — Google/Apple push servers only ever see ciphertext; the remaining exposure is your own lock screen / notification center (both OSes can hide notification content on the lock screen if that matters to you). You can also skip the plugin entirely and trigger pushes yourself: `curl -X POST http://127.0.0.1:3088/pwa/push/send -H 'Content-Type: application/json' -d '{"title":"DSH task complete"}'`.
|
|
227
|
+
- "The model pushes on its own" is the same `dsh-push.mjs` additionally registering a model tool, `push_notify` (`title` required, `body` optional), over the same encrypted `/pwa/push/send` path. It only shows up when the host has a tool registry (`ctx.tools`) and hasn't disabled it; `pushTool: false` in `lan-gate.config.json` (or `DSH_PUSH_TOOL=0`) turns it off entirely. Rate limiting is independent from the turn-close notifier above: at most 1 push per session per 60 seconds, 20 total per hour across all sessions — over the limit, the call is silently skipped (not sent, not an error), so a chatty model can't turn your phone into a notification firehose.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Security boundary
|
|
232
|
+
|
|
233
|
+
**What's covered:**
|
|
234
|
+
- Pairing-code brute force — the code is single-use with a 10-minute TTL, and 5 wrong attempts locks that source IP for 15 minutes.
|
|
235
|
+
- Revocable tokens — lost phone, lent-out device, one click on the admin page and it stops working immediately.
|
|
236
|
+
- Request volume — rate-limited per resolved real client IP, 120/min by default, 429 past that.
|
|
237
|
+
- The admin surface is local-only — generating pairing codes, managing devices, triggering pushes: all local-direct-connection only, and anything through the proxy (always carries forwarded headers) gets 403.
|
|
238
|
+
|
|
239
|
+
**What's not covered — your responsibility:**
|
|
240
|
+
- A misconfigured reverse proxy — e.g. accidentally exposing `127.0.0.1:3088/lan-gate/admin` on the public domain too, or a wrong `X-Forwarded-Proto` making the gateway misjudge the client's protocol. These are configuration mistakes the gateway can't defend against.
|
|
241
|
+
- A stolen or shared token — this is a single-user tool; the token is equivalent to full access, with no finer-grained permission tiers. Whoever has the token can use it — if you suspect a leak, revoke it and re-pair from the admin page.
|
|
242
|
+
- DSH's own capability boundary — the gateway only forwards HTTPS traffic to DSH safely; it can't and doesn't add security measures DSH itself doesn't have (DSH's own `/api` trust fence is DSH's concern).
|
|
243
|
+
- The state file `~/.dsh/lan-gate-state.json` stores the VAPID private key and every device's token in plaintext — this file *is* full access to your gateway. Mind its file permissions on the host, and don't sync `~/.dsh` into a shared drive or an untrusted backup location.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Known issue: iOS 26.x viewport shrink
|
|
248
|
+
|
|
249
|
+
On iOS 26.x, once DSH is added to the home screen and opened as a standalone PWA, the layout viewport loses a chunk of its bottom edge (measured on one iPhone on 26.5: 852px screen vs. 793px viewport — exactly one status-bar's worth) from cold start onward, until the app is fully quit and reopened. The same URL in a plain Safari tab is unaffected.
|
|
250
|
+
|
|
251
|
+
This is not a bug in this plugin — it's a known iOS 26.x system defect (the layout viewport permanently shrinks the first time the on-screen keyboard is shown inside a standalone PWA; `innerHeight`, `visualViewport.height` and `100dvh` all shrink together). The missing strip sits outside the document, so no stylesheet can reach it — only the system paints it, using the manifest's `background_color`. This repo changed that value to a light `#f9fafb` (matching the interface half's light theme background) so the dead strip blends into the page instead of standing out as a dark bar.
|
|
252
|
+
|
|
253
|
+
That's a visual mitigation, not a fix: in dark theme the strip is actually more visible (the manifest color can't follow the page theme), and it's also the launch-splash color, so the splash went from dark to light. The underlying shrink can only be fixed by Apple. the interface half applies two further mitigation layers (detection + an active reflow "heal") on its own side — see that plugin's README for details.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## FAQ
|
|
258
|
+
|
|
259
|
+
**Upgrading from an older version — what do I need to do?**
|
|
260
|
+
The old model approved devices by source IP, which is meaningless under the new token model. The first time the gateway starts with the new version, it detects the old state file and renames it to `lan-gate-state.json.v1.bak` (no data migration). Every device needs to go through pairing again.
|
|
261
|
+
|
|
262
|
+
**The pairing code says expired or wrong — now what?**
|
|
263
|
+
Codes are valid for 10 minutes and single-use — once expired or already used, go back to the local admin page and generate a new one. Five wrong codes in a row locks that source IP for 15 minutes; wait it out or try from a different network.
|
|
264
|
+
|
|
265
|
+
**Not receiving push notifications?**
|
|
266
|
+
Check in order: is the phone accessing an HTTPS domain (over plain HTTP the browser never registers a service worker, so push has nothing to run on)? Has the browser or the OS denied notification permission for this PWA? Check the local admin surface (`/lan-gate/status`) to see whether that device shows the 🔔 marker, confirming the subscription actually succeeded.
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## Test locally
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
npm test # boots a mock upstream, runs the gateway/auth/push suites: proxy+injection, pairing flow, push delivery
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Layout
|
|
279
|
+
|
|
280
|
+
| Path | Role |
|
|
281
|
+
| --- | --- |
|
|
282
|
+
| `lan-gate.mjs` | Cordis entry: spawns the gateway child process and manages its lifecycle |
|
|
283
|
+
| `dsh-push.mjs` | Optional agent-done push host plugin, calls the gateway's local `/pwa/push/send`; also registers the `push_notify` model tool |
|
|
284
|
+
| `lib/lan-gate-server.cjs` | The gateway itself: single-file CommonJS (Node stdlib + one runtime dependency, `web-push`) — HTTP/WebSocket reverse proxy, pairing/tokens, rate limiting, PWA injection, Web Push |
|
|
285
|
+
| `pwa/manifest.json` | PWA install manifest |
|
|
286
|
+
| `pwa/sw.js` | Service worker (offline caching + push notifications) |
|
|
287
|
+
| `pwa/inject.js` | Injected page bootstrap: SW register, gesture loader, push subscribe |
|
|
288
|
+
| `pwa/touch-gestures.js` | Edge-swipe back / pinch-zoom |
|
|
289
|
+
| `pwa/app.css` | Mobile touch-first CSS (`data-lan-device`-prefixed, desktop unaffected) |
|
|
290
|
+
| `pwa/offline.html` | Offline fallback page |
|
|
291
|
+
| `pwa/icons/` | SVG source + rasterized PNGs (192/512 + maskable) |
|
|
292
|
+
| `cordis.patch.yml` / `.example` | Bundle patch layer / static-mount example |
|
|
293
|
+
| `test/gateway.test.cjs` | Smoke tests: gateway boot, `/pwa` asset serving, HTML injection |
|
|
294
|
+
| `test/auth.test.cjs` | Pairing flow, tokens, lockout, v1-state archival, survives restart |
|
|
295
|
+
| `test/push.test.cjs` | Push subscribe/send, VAPID encryption, expired-subscription cleanup |
|
|
296
|
+
| `test/util.cjs` | Shared test harness (boot/request/pair helpers) — not a test file itself |
|
|
297
|
+
|
|
298
|
+
See [`AGENTS.md`](../AGENTS.md) for development conventions.
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Changelog
|
|
303
|
+
|
|
304
|
+
### v0.3.0
|
|
305
|
+
|
|
306
|
+
**Added**
|
|
307
|
+
|
|
308
|
+
- Clear division of labor with the interface half: layout rules handed off entirely, this repo keeps only shell-level CSS (see "Division of labor" above);
|
|
309
|
+
- Service worker bumped to v3: cache strategy changed from "shell and client assets both stale-while-revalidate" to "only the static shell is cache-first, everything else is network-first" — a new deploy is picked up immediately instead of leaving stale CSS behind across devices;
|
|
310
|
+
- First-frame HTML now carries `viewport-fit=cover` directly, so a standalone PWA's safe area is correct from the very first frame instead of waiting for the client bundle to patch it in;
|
|
311
|
+
- `dsh-push.mjs` adds a model tool, `push_notify`: the agent can decide for itself that a push is warranted (needs a decision, hit a key milestone, needs a human after an error) and fire it mid-task instead of waiting for the whole turn to close. Same aes128gcm-encrypted channel; host-side rate limiting (1/60s per session, 20/hour globally) and the `pushTool` switch (`lan-gate.config.json`) are independent of the existing turn-close auto-push.
|
|
312
|
+
|
|
313
|
+
**Fixed**
|
|
314
|
+
|
|
315
|
+
- Manifest and icons are now credential-less (no longer stuck behind the pairing wall) — this used to hide the install prompt on Android/desktop Chrome, with iOS Safari the accidental exception since it sends cookies on that fetch anyway;
|
|
316
|
+
- The gateway now strips an upstream manifest `<link>` tag that used to shadow the gateway's own mobile-tailored manifest (browsers only honor the first manifest link);
|
|
317
|
+
- Service worker registration now declares `scope: '/'` plus a `Service-Worker-Allowed: /` response header — previously its default scope was only `/pwa/` and it never actually controlled the app;
|
|
318
|
+
- Removed the gateway's dead inline `DEVICE_CSS` copy, whose fullscreen-dialog rule used to stretch the interface half's session-info card off-screen — long misdiagnosed as an iOS/Chromium engine difference;
|
|
319
|
+
- CSS/gesture gating switched from the literal `"phone"` value to "not desktop" — a real paired device defaults to kind `"auto"`, so the old gate never actually fired on a real phone;
|
|
320
|
+
- Removed pull-to-refresh (an accidental overscroll used to fire a full reload mid-conversation); removed edge-swipe-back, handing that 24px zone to the interface half's own gesture (the old handler was a no-op against DSH's client-side routing anyway);
|
|
321
|
+
- `manifest.json`'s `background_color` switched to a light color as a visual mitigation for the iOS 26.x standalone-PWA viewport shrink dead strip (known OS defect, not a fix — see "Known issue" above).
|
|
322
|
+
|
|
323
|
+
**Internal**
|
|
324
|
+
|
|
325
|
+
- `pwa/app.css` trimmed from 163 to 95 lines; added `test/sw.test.cjs` covering the service worker's new cache strategy.
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## Security
|
|
330
|
+
|
|
331
|
+
Installing a plugin runs third-party code with your own permissions; being listed or published is not a security review. The gateway listens on `127.0.0.1` only by default and will not expose itself to the public internet on its own — every public-facing path must go through a reverse proxy you configure and that terminates TLS yourself. Run this only on your own server, keep the state file out of untrusted locations, and audit changes to `lib/lan-gate-server.cjs`.
|
|
332
|
+
|
|
333
|
+
## License
|
|
334
|
+
|
|
335
|
+
MIT. The gateway `lib/lan-gate-server.cjs` extends `dsh-mobile-gate`; original MIT copyright/license retained — see [LICENSE](../LICENSE).
|