dsh-plugin-mobile-gateway 0.6.1 → 0.6.3

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/PROTOCOL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh Mobile Gateway — WebSocket 协议参考
2
2
 
3
- 移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送文字和图片、处理 Human-in-the-loop 提问、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.6.0)。
3
+ 移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送文字和图片、处理 Human-in-the-loop 提问、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.6.3)。
4
4
 
5
5
  - **本机端点**:`ws://127.0.0.1:3080/ws/mobile`(与 dsh web GUI 同端口)
6
6
  - **局域网端点**:`ws://<电脑的私有局域网 IP>:3081/ws/mobile`(插件独立监听,只提供经过鉴权的 WebSocket)
@@ -405,6 +405,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
405
405
  | `workspaces` | — | 全部工作区(含每个的 `sessionIds`) |
406
406
  | `workspace-create` | `path` | 对**已存在目录**创建工作区(已归属→`created:false` 幂等) |
407
407
  | `directories` | `path?` | 浏览 server 目录(缺省 = home);`crumbs` 面包屑 + `entries`(含 `hidden` 标记) |
408
+ | `directory-create` | `path`, `name` | 在父目录下创建一个子文件夹 |
408
409
 
409
410
  ```json
410
411
  { "type": "workspace-create", "path": "/Users/lichaofan/DeepseekHarnessProject" }
@@ -412,6 +413,19 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
412
413
  "created": true }
413
414
  ```
414
415
 
416
+ ### 创建文件夹
417
+
418
+ `path` 是当前父目录的绝对路径,`name` 只传新文件夹名称,不传完整目标路径:
419
+
420
+ ```json
421
+ { "type": "directory-create", "path": "/Users/lichaofan/DeepseekHarnessProject", "name": "Sources" }
422
+ → { "kind": "directory-create", "path": "/Users/lichaofan/DeepseekHarnessProject/Sources" }
423
+ ```
424
+
425
+ 创建和目录浏览使用同一套宿主 Node 文件系统实现,不依赖 DSH 的 native Directory Picker,因此 macOS native 模式也可远程创建目录。父路径必须是绝对路径且必须指向真实存在的目录;名称去除首尾空白后不能为空、`.`、`..`,也不能包含 `/` 或 `\\`。
426
+
427
+ 创建成功后,iOS 应重新发送对应父目录的 `directories` 请求来刷新列表。请求格式、相对父路径或非法名称返回 `bad-request`;目标已存在返回 `directory-exists`;父目录不存在、不是目录或其他文件系统失败返回 `directory-create-failed`。此操作只创建文件夹,不会自动注册工作区;如果要把新目录作为工作区,再使用返回的 `path` 调用 `workspace-create`。
428
+
415
429
  ---
416
430
 
417
431
  ## 7. 模型与思考等级
@@ -547,6 +561,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
547
561
  - `/ws/mobile` 的移动网关默认关闭;本机 WebUI 手动开启后,若 5 分钟内没有设备成功连接会自动关闭
548
562
  - 网关开启后仍要求已配对设备凭证;不要把 `requireAuth` 设为 `false` 后暴露到网络
549
563
  - `/mgw/*` 是配对/吊销管理面,默认只允许本机访问;公网代理只应转发 `/ws/mobile`
564
+ - `/mgw/public-setup` 仅供本机 WebUI 调用,通过受限 Unix Socket 请求 root Helper;Helper 只接受状态查询以及“公网 IPv4 + 当前 DSH 端口”的固定 Nginx 配置操作,不接受命令或文件路径
550
565
  - DSH HTTP Server 本身没有 TLS、认证或 Origin policy;公网必须使用 TLS 反向代理和 `wss://`
551
566
  - 长期 token 只保存在 iOS Keychain;服务端磁盘仅保存摘要
552
567
  - `set-default` / `save-default-model` 是全局写操作,客户端 UI 应加确认
@@ -574,6 +589,8 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
574
589
  | v0.3.0 | 默认设备鉴权;一次性二维码配对;摘要化凭证存储;WebUI 设备面板;在线状态和即时吊销 |
575
590
  | v0.5.0 | Human-in-the-loop:转发 API Gateway question 请求、整批回答/取消、重连重放与多端状态收敛 |
576
591
  | v0.6.0 | DSH 0.1.1 图片:WebSocket Base64 上传、实时图片引用、历史附件按会话安全读取 |
592
+ | v0.6.3 | macOS native picker 兼容:目录创建改用与目录浏览一致的宿主文件系统实现,并补齐路径、名称和错误码校验 |
593
+ | v0.6.2 | 目录创建:通过 API Gateway `host.createDirectory` 在工作区目录下创建子文件夹 |
577
594
 
578
595
  ---
579
596
 
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  ## 配套 iOS 客户端
15
15
 
16
- [DeepSeek Harness Mobile](https://github.com/Clarklevis1995/dsh-mobile) 是本仓库的兄弟项目。它是面向 iOS 17+ 的 SwiftUI 原生客户端,支持工作区与会话、历史和实时对话、图片、Agent 执行轨迹、Human-in-the-loop、模型与权限设置。
16
+ [DeepSeek Harness Mobile](https://github.com/Clarklevis1995/dsh-mobile) 是本仓库的兄弟项目。它是面向 iOS 17+ 的 SwiftUI 原生客户端,支持工作区与会话、工作区内创建文件夹、历史和实时对话、图片、Agent 执行轨迹、Human-in-the-loop、模型与权限设置。
17
17
 
18
18
  <table>
19
19
  <tr>
@@ -32,10 +32,18 @@
32
32
 
33
33
  前提:已经安装 `dsh` CLI,并能正常启动 `dsh web`。
34
34
 
35
+ 局域网使用只需安装插件:
36
+
35
37
  ```bash
36
38
  dsh plugin --profile web add dsh-plugin-mobile-gateway@latest
37
39
  ```
38
40
 
41
+ 需要公网接入时,推荐执行统一初始化命令。它会安装/更新插件,并请求一次 sudo 权限安装系统 Helper:
42
+
43
+ ```bash
44
+ npx --yes dsh-plugin-mobile-gateway@latest init
45
+ ```
46
+
39
47
  安装后停止并重新启动 WebUI:
40
48
 
41
49
  ```bash
@@ -72,15 +80,13 @@ dsh web
72
80
 
73
81
  ### 2. 配置公网入口
74
82
 
75
- 把示例 IP 替换为云厂商控制台中的实际公网 IPv4:
83
+ 执行一次初始化(已经执行过可跳过):
76
84
 
77
85
  ```bash
78
- sudo env "PATH=$PATH" npx --yes dsh-plugin-mobile-gateway@latest setup \
79
- --ip 203.0.113.10 \
80
- --port 3080
86
+ npx --yes dsh-plugin-mobile-gateway@latest init
81
87
  ```
82
88
 
83
- 安装器完成后,重新启动:
89
+ 然后启动或重新启动:
84
90
 
85
91
  ```bash
86
92
  dsh web
@@ -91,24 +97,23 @@ dsh web
91
97
  如果 WebUI 运行在远程服务器,在自己的电脑执行:
92
98
 
93
99
  ```bash
94
- ssh -N -L 3080:127.0.0.1:3080 <服务器用户名>@<服务器公网 IP>
100
+ ssh -N -L <本地端口>:127.0.0.1:<DSH 实际端口> <服务器用户名>@<服务器公网 IP>
95
101
  ```
96
102
 
97
103
  然后在本地浏览器打开:
98
104
 
99
105
  ```text
100
- http://127.0.0.1:3080
106
+ http://127.0.0.1:<本地端口>
101
107
  ```
102
108
 
103
109
  ### 4. 使用 WebUI 配对
104
110
 
105
- 1. 打开“移动设备”。
106
- 2. 开启“允许移动设备连接”。
107
- 3. 保持“设备鉴权”开启。
108
- 4. 确认 WebSocket 地址为 `wss://<公网 IP>/ws/mobile`。
109
- 5. 填写设备名称并点击“生成配对二维码”。
110
- 6. iPhone 打开“设备认证”,扫描二维码;也可以复制 Base64URL 配对字符串手动连接。
111
- 7. WebUI 的可信设备显示“在线”后即完成。
111
+ 1. 打开“移动设备”,在“公网接入”中填写云厂商控制台提供的公网 IPv4。
112
+ 2. 点击“配置公网接入”。Helper 会自动使用当前 `dsh web` 端口配置 Nginx 和证书。
113
+ 3. 开启“允许移动设备连接”,保持“设备鉴权”开启。
114
+ 4. 填写设备名称并点击“生成配对二维码”。
115
+ 5. iPhone 打开“设备认证”并扫描二维码。
116
+ 6. WebUI 的可信设备显示“在线”后即完成。
112
117
 
113
118
  二维码只能使用一次,并会在 5 分钟后过期;超时后在 WebUI 重新生成即可。
114
119
 
@@ -128,10 +133,10 @@ sudo env "PATH=$PATH" npx --yes dsh-plugin-mobile-gateway@latest remove
128
133
 
129
134
  ## 更新插件
130
135
 
131
- 重新安装 npm 最新版本:
136
+ 重新运行初始化命令会同时更新插件和系统 Helper:
132
137
 
133
138
  ```bash
134
- dsh plugin --profile web add dsh-plugin-mobile-gateway@latest
139
+ npx --yes dsh-plugin-mobile-gateway@latest init
135
140
  ```
136
141
 
137
142
  随后停止并重新启动 `dsh web`。
@@ -142,6 +147,12 @@ dsh plugin --profile web add dsh-plugin-mobile-gateway@latest
142
147
  dsh plugin --profile web remove dsh-plugin-mobile-gateway
143
148
  ```
144
149
 
150
+ 如需同时移除系统 Helper(不会删除现有 Nginx 公网配置):
151
+
152
+ ```bash
153
+ sudo env "PATH=$PATH" npx --yes dsh-plugin-mobile-gateway@latest remove-helper
154
+ ```
155
+
145
156
  ## 常见问题
146
157
 
147
158
  | 现象 | 处理方式 |
package/bin/setup-ip.mjs CHANGED
@@ -18,10 +18,17 @@ const CERTBOT_HOME = '/opt/dsh-mobile-gateway/certbot'
18
18
  const CERTBOT = path.join(CERTBOT_HOME, 'bin/certbot')
19
19
  const RENEW_SERVICE = '/etc/systemd/system/dsh-mobile-gateway-cert-renew.service'
20
20
  const RENEW_TIMER = '/etc/systemd/system/dsh-mobile-gateway-cert-renew.timer'
21
+ const HELPER_SOURCE = fileURLToPath(new URL('../helper/dsh_mobile_gateway_helper.py', import.meta.url))
22
+ const HELPER_INSTALL = '/usr/local/libexec/dsh-mobile-gateway-helper'
23
+ const HELPER_SERVICE = '/etc/systemd/system/dsh-mobile-gateway-helper.service'
24
+ const HELPER_SOCKET = '/run/dsh-mobile-gateway/helper.sock'
21
25
 
22
26
  function printHelp() {
23
27
  console.log(`Usage:
24
28
  dsh-plugin-mobile-gateway setup [--ip <public IPv4>] [--port 3080] [--email <address>] [--yes]
29
+ dsh-plugin-mobile-gateway init
30
+ dsh-plugin-mobile-gateway setup-helper
31
+ dsh-plugin-mobile-gateway remove-helper [--yes]
25
32
  dsh-plugin-mobile-gateway status
26
33
  dsh-plugin-mobile-gateway remove [--yes]
27
34
 
@@ -74,6 +81,111 @@ function writeManagedFile(file, content, mode = 0o644) {
74
81
  fs.chmodSync(file, mode)
75
82
  }
76
83
 
84
+ function writeInstalledHelper(source, destination) {
85
+ if (fs.existsSync(destination)) {
86
+ const existing = fs.readFileSync(destination, 'utf8')
87
+ if (!existing.slice(0, 256).includes(MARKER)) {
88
+ throw new Error(`refusing to overwrite unmanaged file: ${destination}`)
89
+ }
90
+ }
91
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o755 })
92
+ const temporary = `${destination}.tmp-${process.pid}`
93
+ fs.copyFileSync(source, temporary)
94
+ fs.chmodSync(temporary, 0o755)
95
+ fs.renameSync(temporary, destination)
96
+ }
97
+
98
+ function helperService(uid) {
99
+ return `${MARKER}
100
+ [Unit]
101
+ Description=DSH mobile gateway privileged configuration helper
102
+ After=network.target
103
+
104
+ [Service]
105
+ Type=simple
106
+ ExecStart=/usr/bin/python3 ${HELPER_INSTALL} --uid ${uid} --socket ${HELPER_SOCKET}
107
+ Restart=on-failure
108
+ RestartSec=2s
109
+ RuntimeDirectory=dsh-mobile-gateway
110
+ RuntimeDirectoryMode=0755
111
+ NoNewPrivileges=true
112
+ PrivateTmp=true
113
+ ProtectHome=true
114
+ ProtectSystem=full
115
+ ReadWritePaths=/etc/nginx /etc/dsh-mobile-gateway /etc/letsencrypt /var/lib/dsh-mobile-gateway /var/lib/letsencrypt /var/log/letsencrypt /run/dsh-mobile-gateway
116
+
117
+ [Install]
118
+ WantedBy=multi-user.target
119
+ `
120
+ }
121
+
122
+ function invokingUserId() {
123
+ const value = Number(process.env.SUDO_UID)
124
+ if (!Number.isInteger(value) || value < 1) {
125
+ throw new Error('setup-helper must be run with sudo from the user that runs dsh web')
126
+ }
127
+ return value
128
+ }
129
+
130
+ async function init() {
131
+ if (typeof process.getuid === 'function' && process.getuid() === 0) {
132
+ throw new Error('init must run as the normal DSH user, without sudo')
133
+ }
134
+ if (!commandExists('dsh') || !commandExists('sudo')) {
135
+ throw new Error('init requires dsh and sudo on PATH')
136
+ }
137
+ run('dsh', ['plugin', '--profile', 'web', 'add', 'dsh-plugin-mobile-gateway@latest'])
138
+ const script = fileURLToPath(import.meta.url)
139
+ run('sudo', ['env', `PATH=${process.env.PATH || ''}`, process.execPath, script, 'setup-helper'])
140
+ console.log('\nInitialization completed. Start or restart DSH with: dsh web')
141
+ }
142
+
143
+ function setupHelper() {
144
+ assertRoot()
145
+ const uid = invokingUserId()
146
+ if (!commandExists('apt-get') || !commandExists('systemctl')) {
147
+ throw new Error('helper installation currently supports systemd-based Ubuntu/Debian servers only')
148
+ }
149
+ run('apt-get', ['update'])
150
+ run('apt-get', ['install', '-y', 'nginx', 'python3', 'python3-venv'])
151
+ if (!fs.existsSync(CERTBOT)) {
152
+ run('python3', ['-m', 'venv', CERTBOT_HOME])
153
+ run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'pip'])
154
+ }
155
+ run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'certbot>=5.4,<6'])
156
+ for (const directory of [CONFIG_DIR, WEBROOT, '/etc/letsencrypt', '/var/lib/letsencrypt', '/var/log/letsencrypt']) {
157
+ fs.mkdirSync(directory, { recursive: true, mode: 0o755 })
158
+ }
159
+ writeInstalledHelper(HELPER_SOURCE, HELPER_INSTALL)
160
+ writeManagedFile(HELPER_SERVICE, helperService(uid))
161
+ writeManagedFile(RENEW_SERVICE, renewalService())
162
+ writeManagedFile(RENEW_TIMER, renewalTimer())
163
+ run('systemctl', ['daemon-reload'])
164
+ run('systemctl', ['enable', '--now', path.basename(HELPER_SERVICE)])
165
+ run('systemctl', ['restart', path.basename(HELPER_SERVICE)])
166
+ run('systemctl', ['enable', '--now', path.basename(RENEW_TIMER)])
167
+ console.log(`\nHelper installed for uid ${uid}. Public access can now be configured from the Mobile Devices panel.`)
168
+ }
169
+
170
+ async function removeHelper(options) {
171
+ assertRoot()
172
+ if (!await confirm('Remove the privileged mobile gateway helper? Existing Nginx configuration will be kept.', options.yes)) {
173
+ console.log('Cancelled.')
174
+ return
175
+ }
176
+ if (commandExists('systemctl')) {
177
+ try { run('systemctl', ['disable', '--now', path.basename(HELPER_SERVICE)]) } catch {}
178
+ }
179
+ for (const file of [HELPER_SERVICE, HELPER_INSTALL]) {
180
+ if (!fs.existsSync(file)) continue
181
+ const existing = fs.readFileSync(file, 'utf8')
182
+ if (!existing.slice(0, 256).includes(MARKER)) throw new Error(`refusing to remove unmanaged file: ${file}`)
183
+ fs.rmSync(file)
184
+ console.log(`Removed ${file}`)
185
+ }
186
+ if (commandExists('systemctl')) run('systemctl', ['daemon-reload'])
187
+ }
188
+
77
189
  function metadataPublicIp() {
78
190
  return new Promise((resolve) => {
79
191
  const request = http.get({
@@ -310,6 +422,7 @@ export {
310
422
  nginxHttpConfig,
311
423
  nginxTlsConfig,
312
424
  parseArgs,
425
+ helperService,
313
426
  renewalService,
314
427
  renewalTimer,
315
428
  }
@@ -329,6 +442,9 @@ if (isMainModule(process.argv[1])) {
329
442
  try {
330
443
  const options = parseArgs(process.argv.slice(2))
331
444
  if (options.command === 'help') printHelp()
445
+ else if (options.command === 'init') await init()
446
+ else if (options.command === 'setup-helper') setupHelper()
447
+ else if (options.command === 'remove-helper') await removeHelper(options)
332
448
  else if (options.command === 'setup') await setup(options)
333
449
  else if (options.command === 'status') status()
334
450
  else if (options.command === 'remove') await remove(options)
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env python3
2
+ # Managed by dsh-plugin-mobile-gateway
3
+ """Root-only, deliberately narrow system helper for the mobile gateway."""
4
+
5
+ import argparse
6
+ import ipaddress
7
+ import json
8
+ import os
9
+ import pathlib
10
+ import re
11
+ import shutil
12
+ import socketserver
13
+ import subprocess
14
+ import sys
15
+
16
+
17
+ VERSION = 1
18
+ MARKER = "# Managed by dsh-plugin-mobile-gateway"
19
+ SOCKET_PATH = "/run/dsh-mobile-gateway/helper.sock"
20
+ CONFIG_DIR = pathlib.Path("/etc/dsh-mobile-gateway")
21
+ PUBLIC_URL_FILE = CONFIG_DIR / "public-url"
22
+ NGINX_CONFIG = pathlib.Path("/etc/nginx/conf.d/dsh-mobile-gateway.conf")
23
+ WEBROOT = pathlib.Path("/var/lib/dsh-mobile-gateway/acme")
24
+ CERTBOT_HOME = pathlib.Path("/opt/dsh-mobile-gateway/certbot")
25
+ CERTBOT = CERTBOT_HOME / "bin/certbot"
26
+
27
+
28
+ def run(args):
29
+ subprocess.run(args, check=True)
30
+
31
+
32
+ def atomic_managed_write(target, content, mode=0o644):
33
+ target = pathlib.Path(target)
34
+ if target.exists() and not target.read_text(encoding="utf-8").startswith(MARKER):
35
+ raise RuntimeError(f"refusing to overwrite unmanaged file: {target}")
36
+ target.parent.mkdir(parents=True, exist_ok=True)
37
+ temporary = target.with_name(f"{target.name}.tmp-{os.getpid()}")
38
+ temporary.write_text(content, encoding="utf-8")
39
+ os.chmod(temporary, mode)
40
+ os.replace(temporary, target)
41
+
42
+
43
+ def validate_public_ip(value):
44
+ try:
45
+ address = ipaddress.ip_address(value)
46
+ except ValueError as error:
47
+ raise ValueError("publicIp must be a valid IPv4 address") from error
48
+ if address.version != 4 or not address.is_global:
49
+ raise ValueError("publicIp must be a public IPv4 address")
50
+ return str(address)
51
+
52
+
53
+ def validate_port(value):
54
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 65535:
55
+ raise ValueError("backendPort must be an integer between 1 and 65535")
56
+ return value
57
+
58
+
59
+ def certificate_name(public_ip):
60
+ return f"dsh-mobile-gateway-{public_ip.replace('.', '-')}"
61
+
62
+
63
+ def nginx_http_config(public_ip):
64
+ return f"""{MARKER}
65
+ server {{
66
+ listen 80;
67
+ server_name {public_ip};
68
+
69
+ location ^~ /.well-known/acme-challenge/ {{
70
+ root {WEBROOT};
71
+ default_type text/plain;
72
+ }}
73
+
74
+ location / {{ return 404; }}
75
+ }}
76
+ """
77
+
78
+
79
+ def nginx_tls_config(public_ip, backend_port):
80
+ live = f"/etc/letsencrypt/live/{certificate_name(public_ip)}"
81
+ return nginx_http_config(public_ip) + f"""
82
+ server {{
83
+ listen 443 ssl;
84
+ server_name {public_ip};
85
+ server_tokens off;
86
+ access_log off;
87
+
88
+ ssl_certificate {live}/fullchain.pem;
89
+ ssl_certificate_key {live}/privkey.pem;
90
+ ssl_protocols TLSv1.2 TLSv1.3;
91
+
92
+ location = /ws/mobile {{
93
+ if ($args != \"\") {{ return 404; }}
94
+ proxy_pass http://127.0.0.1:{backend_port};
95
+ proxy_http_version 1.1;
96
+ proxy_set_header Host 127.0.0.1:{backend_port};
97
+ proxy_set_header Upgrade $http_upgrade;
98
+ proxy_set_header Connection \"upgrade\";
99
+ proxy_set_header X-Forwarded-Proto https;
100
+ proxy_read_timeout 3600s;
101
+ proxy_send_timeout 3600s;
102
+ proxy_buffering off;
103
+ }}
104
+
105
+ location / {{ return 404; }}
106
+ }}
107
+ """
108
+
109
+
110
+ def current_status():
111
+ public_url = None
112
+ if PUBLIC_URL_FILE.exists():
113
+ public_url = next((line.strip() for line in PUBLIC_URL_FILE.read_text(encoding="utf-8").splitlines()
114
+ if line.strip().startswith("wss://")), None)
115
+ backend_port = None
116
+ if NGINX_CONFIG.exists():
117
+ match = re.search(r"proxy_pass http://127\.0\.0\.1:(\d+);", NGINX_CONFIG.read_text(encoding="utf-8"))
118
+ if match:
119
+ backend_port = int(match.group(1))
120
+ return {
121
+ "installed": True,
122
+ "version": VERSION,
123
+ "configured": bool(public_url and backend_port),
124
+ "publicUrl": public_url,
125
+ "backendPort": backend_port,
126
+ }
127
+
128
+
129
+ def ensure_dependencies():
130
+ if not shutil.which("nginx") or not CERTBOT.exists():
131
+ raise RuntimeError("helper dependencies are missing; rerun init to repair the installation")
132
+
133
+
134
+ def configure(public_ip, backend_port):
135
+ public_ip = validate_public_ip(public_ip)
136
+ backend_port = validate_port(backend_port)
137
+ previous = NGINX_CONFIG.read_bytes() if NGINX_CONFIG.exists() else None
138
+ if previous is not None and not previous.decode("utf-8").startswith(MARKER):
139
+ raise RuntimeError(f"refusing to overwrite unmanaged file: {NGINX_CONFIG}")
140
+
141
+ ensure_dependencies()
142
+ (WEBROOT / ".well-known/acme-challenge").mkdir(parents=True, exist_ok=True)
143
+ try:
144
+ atomic_managed_write(NGINX_CONFIG, nginx_http_config(public_ip))
145
+ run(["nginx", "-t"])
146
+ run(["systemctl", "enable", "--now", "nginx"])
147
+ run(["systemctl", "reload", "nginx"])
148
+
149
+ args = [
150
+ str(CERTBOT), "certonly", "--non-interactive", "--agree-tos",
151
+ "--preferred-profile", "shortlived", "--webroot", "--webroot-path", str(WEBROOT),
152
+ "--ip-address", public_ip, "--cert-name", certificate_name(public_ip),
153
+ "--keep-until-expiring", "--register-unsafely-without-email",
154
+ ]
155
+ run(args)
156
+ atomic_managed_write(NGINX_CONFIG, nginx_tls_config(public_ip, backend_port))
157
+ atomic_managed_write(PUBLIC_URL_FILE, f"{MARKER}\nwss://{public_ip}/ws/mobile\n")
158
+ run(["nginx", "-t"])
159
+ run(["systemctl", "reload", "nginx"])
160
+ except Exception:
161
+ if previous is None:
162
+ NGINX_CONFIG.unlink(missing_ok=True)
163
+ else:
164
+ NGINX_CONFIG.write_bytes(previous)
165
+ if shutil.which("nginx"):
166
+ subprocess.run(["nginx", "-t"], check=False)
167
+ subprocess.run(["systemctl", "reload", "nginx"], check=False)
168
+ raise
169
+ return current_status()
170
+
171
+
172
+ def dispatch(message):
173
+ action = message.get("action")
174
+ if action == "status":
175
+ return current_status()
176
+ if action == "configure":
177
+ return configure(message.get("publicIp"), message.get("backendPort"))
178
+ raise ValueError("unsupported helper action")
179
+
180
+
181
+ class Handler(socketserver.StreamRequestHandler):
182
+ def handle(self):
183
+ try:
184
+ raw = self.rfile.readline(65537)
185
+ if not raw or len(raw) > 65536:
186
+ raise ValueError("invalid helper request")
187
+ message = json.loads(raw.decode("utf-8"))
188
+ if not isinstance(message, dict):
189
+ raise ValueError("helper request must be an object")
190
+ response = {"ok": True, "result": dispatch(message)}
191
+ except Exception as error: # Return a typed error; never expose a traceback.
192
+ response = {"ok": False, "error": str(error)}
193
+ self.wfile.write((json.dumps(response, separators=(",", ":")) + "\n").encode("utf-8"))
194
+
195
+
196
+ class Server(socketserver.UnixStreamServer):
197
+ allow_reuse_address = True
198
+
199
+
200
+ def serve(uid, socket_path):
201
+ if os.geteuid() != 0:
202
+ raise RuntimeError("helper must run as root")
203
+ path = pathlib.Path(socket_path)
204
+ path.parent.mkdir(parents=True, exist_ok=True)
205
+ path.unlink(missing_ok=True)
206
+ with Server(str(path), Handler) as server:
207
+ os.chown(path, uid, -1)
208
+ os.chmod(path, 0o600)
209
+ server.serve_forever()
210
+
211
+
212
+ def main():
213
+ parser = argparse.ArgumentParser()
214
+ parser.add_argument("--uid", type=int, required=True)
215
+ parser.add_argument("--socket", default=SOCKET_PATH)
216
+ options = parser.parse_args()
217
+ if options.uid < 1:
218
+ raise ValueError("uid must identify a non-root user")
219
+ serve(options.uid, options.socket)
220
+
221
+
222
+ if __name__ == "__main__":
223
+ try:
224
+ main()
225
+ except Exception as error:
226
+ print(f"dsh-mobile-gateway-helper: {error}", file=sys.stderr)
227
+ raise SystemExit(1)
package/lib/client.js CHANGED
@@ -116,6 +116,9 @@ window.__ModuleLoader__.load({
116
116
  const [devices, setDevices] = React.useState(null)
117
117
  const [status, setStatus] = React.useState(null)
118
118
  const [publicUrl, setPublicUrl] = React.useState('')
119
+ const [publicIp, setPublicIp] = React.useState('')
120
+ const [publicSetup, setPublicSetup] = React.useState(null)
121
+ const [publicSetupBusy, setPublicSetupBusy] = React.useState(false)
119
122
  const [deviceName, setDeviceName] = React.useState('iPhone')
120
123
  const [qr, setQr] = React.useState(null)
121
124
  const [error, setError] = React.useState(null)
@@ -175,10 +178,40 @@ window.__ModuleLoader__.load({
175
178
  React.useEffect(() => {
176
179
  let active = true
177
180
  refresh()
181
+ request('/mgw/public-setup').then((data) => {
182
+ if (!active) return
183
+ setPublicSetup(data)
184
+ if (data.publicUrl) {
185
+ const match = /^wss:\/\/([^/]+)\/ws\/mobile$/.exec(data.publicUrl)
186
+ if (match) setPublicIp(match[1])
187
+ }
188
+ }).catch((cause) => active && setPublicSetup({ installed: false, configured: false, error: cause.message }))
178
189
  const timer = window.setInterval(refresh, 3000)
179
190
  return () => { active = false; window.clearInterval(timer) }
180
191
  }, [refresh])
181
192
 
193
+ const configurePublicAccess = async () => {
194
+ if (publicSetupBusy) return
195
+ setPublicSetupBusy(true)
196
+ setError(null)
197
+ try {
198
+ const data = await request('/mgw/public-setup', {
199
+ method: 'POST',
200
+ headers: { 'Content-Type': 'application/json' },
201
+ body: JSON.stringify({ publicIp }),
202
+ })
203
+ setPublicSetup(data)
204
+ if (data.publicUrl) {
205
+ automaticUrlRef.current = data.publicUrl
206
+ setPublicUrl(data.publicUrl)
207
+ }
208
+ } catch (cause) {
209
+ setError(cause.message)
210
+ } finally {
211
+ setPublicSetupBusy(false)
212
+ }
213
+ }
214
+
182
215
  const createPairing = async (endpoint) => request('/mgw/pair', {
183
216
  method: 'POST',
184
217
  headers: { 'Content-Type': 'application/json' },
@@ -331,6 +364,37 @@ window.__ModuleLoader__.load({
331
364
  status && !status.requireAuth
332
365
  ? React.createElement('p', { style: { ...styles.error, margin: '12px 0 0' } }, 'Debug 模式:本机 DSH 入口将跳过设备凭证校验;独立局域网入口仍强制鉴权。')
333
366
  : null),
367
+ React.createElement('section', { style: styles.card },
368
+ React.createElement('strong', null, '公网接入'),
369
+ publicSetup === null
370
+ ? React.createElement('p', { style: styles.muted }, '正在检查系统组件…')
371
+ : publicSetup.installed
372
+ ? React.createElement(React.Fragment, null,
373
+ React.createElement('div', { style: { ...styles.muted, margin: '5px 0 12px' } }, publicSetup.configured
374
+ ? publicSetup.backendPort && status && status.webPort && publicSetup.backendPort !== status.webPort
375
+ ? `端口需要更新:Nginx 当前为 ${publicSetup.backendPort},DSH 当前为 ${status.webPort}`
376
+ : `已配置${publicSetup.backendPort ? `,Nginx 转发至 DSH 端口 ${publicSetup.backendPort}` : ''}`
377
+ : `Helper 已就绪,将自动使用当前 DSH 端口${status && status.webPort ? ` ${status.webPort}` : ''}`),
378
+ React.createElement('label', { style: styles.label }, '服务器公网 IPv4'),
379
+ React.createElement('input', {
380
+ style: styles.input,
381
+ value: publicIp,
382
+ onChange: (event) => setPublicIp(event.target.value.trim()),
383
+ placeholder: '203.0.113.10',
384
+ inputMode: 'decimal',
385
+ spellCheck: false,
386
+ }),
387
+ React.createElement('button', {
388
+ style: { ...styles.button, ...styles.primary, opacity: publicSetupBusy || !publicIp ? .65 : 1 },
389
+ disabled: publicSetupBusy || !publicIp,
390
+ onClick: configurePublicAccess,
391
+ }, publicSetupBusy ? '正在配置 Nginx 与证书…' : publicSetup.configured ? '更新公网配置' : '配置公网接入'),
392
+ publicSetup.publicUrl
393
+ ? React.createElement('div', { style: { ...styles.muted, marginTop: 9, overflowWrap: 'anywhere' } }, publicSetup.publicUrl)
394
+ : null)
395
+ : React.createElement(React.Fragment, null,
396
+ React.createElement('p', { style: { ...styles.muted, marginBottom: 8 } }, '尚未安装系统 Helper。请在服务器执行一次初始化:'),
397
+ React.createElement('code', { style: { display: 'block', fontSize: 10, lineHeight: 1.5, overflowWrap: 'anywhere', userSelect: 'all' } }, 'npx --yes dsh-plugin-mobile-gateway@latest init'))),
334
398
  React.createElement('section', { style: styles.card },
335
399
  React.createElement('label', { style: styles.label }, 'WebSocket 地址'),
336
400
  React.createElement('input', { style: styles.input, value: publicUrl, onChange: (event) => { automaticUrlRef.current = ''; setPublicUrl(event.target.value) }, placeholder: 'ws://192.168.1.10:3081/ws/mobile', spellCheck: false }),
package/lib/index.mjs CHANGED
@@ -38,6 +38,7 @@
38
38
  // { "type": "default-model" } -> agentDefaultModel.currentSelection()
39
39
  // { "type": "save-default-model", "provider", "model", "reasoningEffort"? }
40
40
  // { "type": "directories", "path"? } -> server dir listing (fs-based)
41
+ // { "type": "directory-create", "path", "name" } -> create one child directory
41
42
  // { "type": "workspace-create", "path" } -> create workspace over a dir
42
43
  // { "type": "fork", "sessionId", "atSeq"? } -> branch a new session from a completed turn
43
44
  // { "type": "models", "sessionId"? } -> per-session catalog (with sessionId) or global (without)
@@ -68,6 +69,7 @@
68
69
  import fs from 'node:fs'
69
70
  import fsp from 'node:fs/promises'
70
71
  import http from 'node:http'
72
+ import net from 'node:net'
71
73
  import os from 'node:os'
72
74
  import path from 'node:path'
73
75
  import crypto from 'node:crypto'
@@ -87,6 +89,7 @@ const PLUGIN_VERSION = (() => {
87
89
  })()
88
90
  const MAX_PREVIEW = 400
89
91
  const LOG_FILE = '/tmp/mobile-gateway.log'
92
+ const HELPER_SOCKET = '/run/dsh-mobile-gateway/helper.sock'
90
93
  const DEFAULT_WS_PATH = '/ws/mobile'
91
94
  const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000
92
95
  const DEFAULT_GATEWAY_WAIT_TIMEOUT_MS = 5 * 60 * 1000
@@ -380,6 +383,56 @@ async function listServerDirectory(target) {
380
383
  }
381
384
  }
382
385
 
386
+ function directoryCreateError(code, message) {
387
+ return { kind: 'error', code, message, requestType: 'directory-create' }
388
+ }
389
+
390
+ // Create one child directory directly on the host filesystem. This deliberately
391
+ // shares the same backend as `directories`: macOS native Directory Picker only
392
+ // exposes its system dialog and does not implement remote createDirectory RPCs.
393
+ async function createServerDirectory(parentPath, directoryName) {
394
+ if (typeof parentPath !== 'string' || parentPath.trim() === '') {
395
+ return directoryCreateError('bad-request', 'directory-create requires an absolute parent path')
396
+ }
397
+ const parent = path.resolve(parentPath.trim())
398
+ if (!path.isAbsolute(parentPath.trim())) {
399
+ return directoryCreateError('bad-request', 'directory-create path must be absolute')
400
+ }
401
+
402
+ if (typeof directoryName !== 'string') {
403
+ return directoryCreateError('bad-request', 'directory-create requires a folder name')
404
+ }
405
+ const name = directoryName.trim()
406
+ if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
407
+ return directoryCreateError('bad-request', 'directory-create name must be a single non-empty folder name')
408
+ }
409
+
410
+ try {
411
+ const stat = await fsp.stat(parent)
412
+ if (!stat.isDirectory()) {
413
+ return directoryCreateError('directory-create-failed', 'parent path is not a directory')
414
+ }
415
+ } catch (error) {
416
+ const message = error && error.message ? error.message : String(error)
417
+ log(`query failed: directory-create -> ${message}`)
418
+ return directoryCreateError('directory-create-failed', message)
419
+ }
420
+
421
+ const target = path.join(parent, name)
422
+ try {
423
+ await fsp.mkdir(target)
424
+ log(`query ok: directory-create (${target})`)
425
+ return { kind: 'directory-create', path: target }
426
+ } catch (error) {
427
+ if (error && error.code === 'EEXIST') {
428
+ return directoryCreateError('directory-exists', 'directory already exists')
429
+ }
430
+ const message = error && error.message ? error.message : String(error)
431
+ log(`query failed: directory-create -> ${message}`)
432
+ return directoryCreateError('directory-create-failed', message)
433
+ }
434
+ }
435
+
383
436
  // Validate one sessionId field; returns { value } or { error }.
384
437
  function requireSessionId(msg) {
385
438
  const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
@@ -578,6 +631,9 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
578
631
  if (msg.type === 'directories') {
579
632
  return listServerDirectory(typeof msg.path === 'string' && msg.path.trim() !== '' ? msg.path.trim() : undefined)
580
633
  }
634
+ if (msg.type === 'directory-create') {
635
+ return createServerDirectory(msg.path, msg.name)
636
+ }
581
637
  if (msg.type === 'models') {
582
638
  const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
583
639
  if (sessionId) {
@@ -888,6 +944,54 @@ function sendJson(res, status, value) {
888
944
  res.end(body)
889
945
  }
890
946
 
947
+ function callSystemHelper(message, timeoutMs = 2_000) {
948
+ return new Promise((resolve, reject) => {
949
+ let settled = false
950
+ let body = ''
951
+ const finish = (error, value) => {
952
+ if (settled) return
953
+ settled = true
954
+ socket.destroy()
955
+ if (error) reject(error)
956
+ else resolve(value)
957
+ }
958
+ const socket = net.createConnection(HELPER_SOCKET)
959
+ socket.setEncoding('utf8')
960
+ socket.setTimeout(timeoutMs)
961
+ socket.on('connect', () => socket.write(`${JSON.stringify(message)}\n`))
962
+ socket.on('data', (chunk) => {
963
+ body += chunk
964
+ if (body.length > 1024 * 1024) return finish(new Error('system helper response is too large'))
965
+ const newline = body.indexOf('\n')
966
+ if (newline < 0) return
967
+ try {
968
+ const response = JSON.parse(body.slice(0, newline))
969
+ if (!response || response.ok !== true) throw new Error(response && response.error ? response.error : 'system helper request failed')
970
+ finish(null, response.result)
971
+ } catch (error) {
972
+ finish(error)
973
+ }
974
+ })
975
+ socket.on('timeout', () => finish(new Error('system helper request timed out')))
976
+ socket.on('error', (error) => finish(error))
977
+ socket.on('end', () => {
978
+ if (!settled) finish(new Error('system helper closed without a response'))
979
+ })
980
+ })
981
+ }
982
+
983
+ async function systemHelperStatus() {
984
+ try {
985
+ return await callSystemHelper({ action: 'status' })
986
+ } catch (error) {
987
+ return {
988
+ installed: false,
989
+ configured: false,
990
+ error: error && error.message ? error.message : String(error),
991
+ }
992
+ }
993
+ }
994
+
891
995
  const plugin = {
892
996
  name: 'mobile-gateway',
893
997
  Config,
@@ -1101,6 +1205,7 @@ const plugin = {
1101
1205
  gatewayEnabled,
1102
1206
  waitExpiresAt,
1103
1207
  connectedClients: clients.size,
1208
+ webPort: webServer.port,
1104
1209
  wsPath,
1105
1210
  publicUrl: configuredPublicUrl() || null,
1106
1211
  lan: {
@@ -1115,6 +1220,18 @@ const plugin = {
1115
1220
  pairingTtlMs: options.pairingTtlMs,
1116
1221
  queryTokenAllowed: !!options.allowQueryToken,
1117
1222
  })
1223
+ } else if (req.method === 'GET' && p === '/mgw/public-setup') {
1224
+ sendJson(res, 200, await systemHelperStatus())
1225
+ } else if (req.method === 'POST' && p === '/mgw/public-setup') {
1226
+ const body = await readBody(req)
1227
+ if (typeof body.publicIp !== 'string' || !body.publicIp.trim()) throw badRequest('publicIp is required')
1228
+ const result = await callSystemHelper({
1229
+ action: 'configure',
1230
+ publicIp: body.publicIp.trim(),
1231
+ backendPort: webServer.port,
1232
+ }, 15 * 60 * 1000)
1233
+ log(`public endpoint configured: ${result.publicUrl} -> 127.0.0.1:${result.backendPort}`)
1234
+ sendJson(res, 200, result)
1118
1235
  } else if (req.method === 'POST' && p === '/mgw/gateway') {
1119
1236
  const body = await readBody(req)
1120
1237
  if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
@@ -1269,7 +1386,7 @@ const plugin = {
1269
1386
  if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
1270
1387
  })
1271
1388
  } else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
1272
- msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' ||
1389
+ msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
1273
1390
  msg.type === 'workspace-create' || msg.type === 'models' || msg.type === 'select-model' ||
1274
1391
  msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
1275
1392
  msg.type === 'agent-presets' || msg.type === 'defaults' || msg.type === 'set-default' ||
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "update readme",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [
7
7
  "bin",
8
+ "helper",
8
9
  "lib",
9
10
  "docs",
10
11
  "cordis.patch.yml",