@sparkelf/dsh-plugin-mobile-gateway 0.8.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.
@@ -0,0 +1,294 @@
1
+ # Typert Remote Gateway 功能清单
2
+
3
+ 本文用于持续跟踪 `dsh-plugin-mobile-gateway` 对 DeepSeek Harness 官方 Typert Remote Gateway 能力的适配情况。
4
+
5
+ - `[x]`:移动网关已经实现,并有对应测试或现有协议文档覆盖。
6
+ - `[ ]`:尚未实现,或当前实现不足以兼容标注的 Host 版本。
7
+ - 对于部分支持的能力,拆成已完成项和待完成子项,不使用含糊的“部分完成”标记。
8
+ - 功能只有在代码、测试和必要协议文档均完成后才能勾选。
9
+ - 每次实现本清单中的功能时,应在同一次变更中更新本文件。
10
+
11
+ ## 版本基线与兼容性
12
+
13
+ - [x] 以 DSH `0.1.5-rc.2` / Session format 3 为唯一适配基线,不提供旧 Host 分支。
14
+ - [x] 使用独立 Host Adapter 隔离 DSH Remote namespace、method 和严格参数名。
15
+ - [x] 保留移动配对和 `dsh-mobile-v1`、`hello.protocol = 3`;新增独立实时流需移动端接入。
16
+ - [x] rc.2 输入契约、流状态机和真实 WebSocket mock Host 回归测试。
17
+ - [ ] rc.2 真实 Host 与移动 App 完整联调。
18
+
19
+ ## 连接、鉴权与通用传输
20
+
21
+ - [x] 提供 `/ws/mobile` WebSocket 入口。
22
+ - [x] 支持本机、独立局域网监听和 TLS 反向代理公网入口。
23
+ - [x] 支持一次性配对码、长期设备 Token 和设备吊销。
24
+ - [x] 局域网监听强制设备鉴权。
25
+ - [x] 支持 `ping` / `pong` 心跳。
26
+ - [x] 支持按 Session 订阅和取消订阅实时事件。
27
+ - [x] 支持统一移动端错误帧。
28
+ - [ ] 为普通 RPC 增加移动端请求 ID 与响应关联。
29
+ - [ ] 支持取消单个正在执行的普通 RPC。
30
+ - [ ] 在移动连接断开时取消该连接拥有的未完成查询、命令和流。
31
+ - [ ] 将 Typert 的结构化错误类别完整映射为稳定的移动端错误码与详情。
32
+
33
+ ## Session 生命周期与查询
34
+
35
+ - [x] 列出 Session。
36
+ - [x] 搜索 Session 内容。
37
+ - [x] 使用 Workspace 创建 Session。
38
+ - [x] 使用 `cwd` 创建 Session。
39
+ - [x] 创建 Session 后发送第一条消息。
40
+ - [ ] 创建 Session 时显式选择 `agentPreset`。
41
+ - [ ] 创建或采用客户端预分配的 `sessionId`。
42
+ - [x] 在移动端重命名 Session。
43
+ - [x] Fork Session。
44
+ - [x] 支持指定 `atSeq` Fork。
45
+ - [ ] 在 Host 桌面打开 Session Workspace 路径。
46
+ - [x] 查询 Host 是否具备打开 Workspace 路径的能力。
47
+
48
+ ## 消息、队列与运行控制
49
+
50
+ - [x] 发送文本 Prompt。
51
+ - [x] 发送纯图片 Prompt。
52
+ - [x] 发送图文 Prompt。
53
+ - [x] 支持 `queue` 投递模式。
54
+ - [x] 支持 `steer` 投递模式。
55
+ - [x] 为 Host Prompt 生成唯一 `requestId`。
56
+ - [x] 取消当前正在执行的 Agent 回合,同时保留待处理队列。
57
+ - [x] 取消后使用相同 `sessionId` 发送新消息继续已有会话。
58
+ - [ ] 向移动端返回并持久关联 Prompt `requestId`。
59
+ - [x] 显示 Session 当前排队消息。
60
+ - [x] 编辑一条尚未处理的排队消息。
61
+ - [x] 删除一条尚未处理的排队消息。
62
+ - [x] 将指定排队消息转换为 Steer。
63
+ - [ ] 显示 Session 后台 Job 列表与运行状态。
64
+
65
+ ## 实时会话流与状态同步
66
+
67
+ - [x] 通过 Host `session/event` 向未启用 follow 的连接转发持久 SessionEvent。
68
+ - [x] 转发 `todos` projection 的增量更新。
69
+ - [x] 转发 `goal` projection 的增量更新。
70
+ - [x] 将 WebUI 或其他客户端写入的 `session/title` 变化实时通知移动端。
71
+ - [x] 将 Workspace 归档集合变化实时通知移动端。
72
+ - [x] 在 `0.1.5-rc.2` 上使用 `session.follow({ assistantStream: true })` 接收实时 Assistant 流。
73
+ - [x] 将 `assistant-stream` 的 start/chunk/end 转发为独立移动帧,并从 start/基线补齐 turn/step。
74
+ - [x] 处理 Assistant Stream reconnect baseline、revision、index 和 settlement。
75
+ - [x] 使用 `session.follow` 持续接收 durable event,而不只读取 opening snapshot。
76
+ - [x] 上游断流或序号出现缺口时重建 Session 快照;移动连接重建后重新订阅恢复。
77
+ - [x] 安装并向控制连接重放 todos/goal projection baseline。
78
+ - [x] 处理 `session.control` baseline 中的 queues,并在重连时整体替换。
79
+ - [ ] 处理 `session.control` baseline 中的 jobs。
80
+ - [ ] 处理 `session.control` baseline 中除 `todos`、`goal` 外的 projections。
81
+ - [x] 转发 `session.control` 的 queue 更新。
82
+ - [ ] 转发 `session.control` 的 jobs 更新。
83
+ - [ ] 支持除 `todos`、`goal` 之外的通用 projection 更新。
84
+ - [ ] 实时同步 Session 新增事件。
85
+ - [ ] 实时同步 Session 删除事件。
86
+ - [ ] 实时同步 Session running 状态。
87
+ - [ ] 实时同步 Session activity/排序时间。
88
+ - [ ] 实时同步 Session 非持久化错误。
89
+
90
+ ## 历史、统计与附件读取
91
+
92
+ - [x] 读取 Session opening snapshot。
93
+ - [x] 分页读取更早的历史记录。
94
+ - [x] 读取 format 3 event 记录,保留真实 seq 与内嵌 stream,不展开为虚构序号。
95
+ - [x] 输出历史格式/cursor;拒绝缺失或不匹配版本的分页/fork 游标。
96
+ - [x] 支持移动端历史帧字节预算。
97
+ - [x] 支持 conversation 裁剪视图。
98
+ - [x] 读取历史图片附件字节。
99
+ - [x] 查询 Session 统计信息。
100
+ - [x] 查询 Token Usage 与 Context Pressure。
101
+ - [x] 原始历史和实时事件保留 Assistant attempt,conversation 历史移除内嵌 stream。
102
+ - [ ] 移动端完成独立 Assistant 流及 attempt 明细展示。
103
+
104
+ ## Host 命令与技能
105
+
106
+ - [x] 按 Session 列出 Host 命令。
107
+ - [x] 执行 `0.1.5-rc.2` Host 命令。
108
+ - [x] 转发 `command/run` 与 `command/done` 生命周期事件。
109
+ - [x] 提供服务端驱动的命令菜单 UI 描述。
110
+ - [x] 提供模型和权限的通用二级选择菜单。
111
+ - [x] 按 Session 列出用户可调用技能。
112
+ - [x] 区分 Model 可调用技能与仅用户技能。
113
+ - [x] 适配 rc.2 `commands.execute.submittedAttachments` 参数。
114
+ - [x] 识别 rc.2 `input.attachments` 命令描述。
115
+ - [ ] 支持向 Host 命令附加普通文件 receipt。
116
+ - [ ] 实时同步 `commands/change`,使移动端命令目录自动刷新。
117
+
118
+ ## Human-in-the-loop
119
+
120
+ - [x] 转发 `user-questions/request`。
121
+ - [x] 支持单选、多选和自由输入答案。
122
+ - [x] 支持取消整批问题。
123
+ - [x] 校验问题答案的数量、顺序、ID 和选项。
124
+ - [x] 多设备竞争时只接受第一个合法响应。
125
+ - [x] 重放尚未处理的问题。
126
+ - [x] 没有匹配移动连接时回退给 WebUI 或后续 answerer。
127
+ - [x] 转发 `approval/request`。
128
+ - [x] 支持单次允许和拒绝审批。
129
+ - [x] 重放尚未处理的审批。
130
+ - [x] 广播问题和审批的最终状态。
131
+
132
+ ## Workspace 与目录
133
+
134
+ - [x] 读取 Workspace baseline/list。
135
+ - [x] 创建 Workspace。
136
+ - [x] 使用插件本地文件系统实现浏览 Host 目录。
137
+ - [x] 使用插件本地文件系统创建目录。
138
+ - [ ] 重命名 Workspace。
139
+ - [ ] 删除 Workspace 注册记录。
140
+ - [ ] 调整 Workspace 顺序。
141
+ - [ ] 调整一个 Workspace 内的 Session 顺序。
142
+ - [x] 在移动端归档 Session。
143
+ - [x] 持续消费 `workspace.follow` 的 baseline/archived 更新,并缓存完整归档集合供新连接恢复。
144
+ - [ ] 转发 `workspace.follow` 的 upsert/remove/order 更新。
145
+ - [x] 在 Workspace 流重连时用新 baseline 原子替换缓存的归档集合。
146
+ - [ ] 调用 Host 原生 Directory Picker;此项属于低优先级桌面能力。
147
+
148
+ ## 文件上传、下载与引用
149
+
150
+ - [x] 上传 Prompt 图片。
151
+ - [x] 下载历史图片附件。
152
+ - [x] 列出 Session Workspace 内的文件和目录。
153
+ - [x] 分块下载 Session Workspace 内的普通文件。
154
+ - [x] 校验文件下载 offset 和最终 SHA-256。
155
+ - [x] 防止通过绝对路径、`..` 或符号链接逃逸 Workspace。
156
+ - [x] 支持取消文件下载并清理空闲传输。
157
+ - [ ] 通过官方 `fileUploads.upload` 上传普通文件。
158
+ - [ ] 支持普通文件的流式 HTTP 上传。
159
+ - [ ] 使用文件 upload receipt 发送 Prompt。
160
+ - [ ] 使用文件 upload receipt 执行 Host 命令。
161
+ - [ ] 支持 `@文件` / `@目录` 候选补全。
162
+ - [ ] 支持跨 Session 引用候选补全。
163
+ - [ ] 支持发送规范化 `dsh-session:` 引用并显示引用快照状态。
164
+
165
+ ## 模型与 Provider
166
+
167
+ - [x] 获取 Host 模型目录。
168
+ - [x] 获取 Session 当前模型选择。
169
+ - [x] 切换 Session 模型。
170
+ - [x] 切换 Session Reasoning Effort。
171
+ - [x] 读取新 Session 默认模型。
172
+ - [x] 保存新 Session 默认模型。
173
+ - [x] 列出可配置 Provider。
174
+ - [ ] 调用 `llm.listProviders` 获取完整 Provider 运行时信息。
175
+ - [ ] 调用 `llm.discoverModels` 从指定 Provider 配置动态发现模型。
176
+ - [ ] 实时同步 `llm/adapters-updated` 并刷新模型目录。
177
+
178
+ ## 权限与 Settings
179
+
180
+ - [x] 读取 Settings namespace 描述。
181
+ - [x] 使用 `settings.update` 修改 namespace。
182
+ - [x] 读取权限 Preset 列表。
183
+ - [x] 读取 Session 当前权限。
184
+ - [x] 通过官方 `/permission` 命令切换 Session 权限。
185
+ - [x] 读取和修改新 Session 默认权限。
186
+ - [ ] 使用 `settings.replace` 完整替换 namespace section。
187
+ - [ ] 使用 `settings.mutate` 执行路径级 set/unset 操作。
188
+ - [ ] 完整处理 Settings revision conflict 并返回 Host 最新值。
189
+ - [ ] 实时同步 `settings/document-updated`。
190
+ - [ ] 在 Host 打开 Settings 文档;此项属于低优先级桌面能力。
191
+ - [ ] 在 Host 打开 Agent Preset 目录;此项属于低优先级桌面能力。
192
+
193
+ ## Credentials
194
+
195
+ - [ ] 查询脱敏后的 Credential 引用状态。
196
+ - [ ] 设置 Credential 值。
197
+ - [ ] 清除 Credential 值。
198
+ - [ ] 实时同步 `credentials/reference-updated`。
199
+ - [ ] 确保任何 Credential Secret 都不会通过移动端查询接口回传。
200
+
201
+ ## Agent Preset
202
+
203
+ - [x] 列出 Agent Preset 名册。
204
+ - [x] 显示默认 Agent Preset。
205
+ - [x] 修改默认 Agent Preset。
206
+ - [ ] 读取 Preset 文档或插件组成。
207
+ - [ ] 复制可编辑 Preset。
208
+ - [ ] 删除可编辑 Preset。
209
+ - [ ] 为尚未开始的空白 Session 切换 Agent Preset。
210
+ - [ ] 实时同步 `agent-preset/selected`。
211
+
212
+ ## Goal 与任务列表
213
+
214
+ - [x] 读取 `todos` 任务列表。
215
+ - [x] 实时同步 `todos` 更新。
216
+ - [x] 读取当前 Goal 和 CAS revision。
217
+ - [x] 编辑 Goal objective 或 maxGoalRounds。
218
+ - [x] 暂停 Goal。
219
+ - [x] 恢复 Goal。
220
+ - [x] 清除 Goal。
221
+ - [x] 实时同步 Goal 更新。
222
+ - [ ] 直接创建 Goal。
223
+ - [ ] 将 Goal 标记为完成。
224
+
225
+ ## 子 Agent
226
+
227
+ - [ ] 列出直接子 Agent 和后代树。
228
+ - [ ] 显示子 Agent 的模式、运行状态和血缘关系。
229
+ - [ ] 向 continuable 子 Agent 发送后续 Prompt。
230
+ - [ ] 在子 Agent 后续 Prompt 中发送图片。
231
+ - [ ] 从父 Session 中断 continuable 子 Agent。
232
+ - [ ] 订阅和展示子 Agent Session 的实时会话流。
233
+
234
+ ## 消息反馈
235
+
236
+ - [ ] 列出一个 Session 的 Assistant 消息反馈。
237
+ - [ ] 为最终 Assistant 消息添加点赞或点踩。
238
+ - [ ] 为反馈添加备注。
239
+ - [ ] 修改带版本保护的反馈。
240
+ - [ ] 删除带版本保护的反馈。
241
+ - [ ] 处理反馈 version conflict 并向移动端返回当前权威值。
242
+
243
+ ## Host 插件与动态 Cordis
244
+
245
+ - [ ] 查询 Host 插件清单和运行阶段。
246
+ - [ ] 查询 Agent Preset 的插件组成。
247
+ - [ ] 接收动态 Cordis 包运行请求。
248
+ - [ ] 允许或拒绝包含浏览器半包的动态 Cordis 运行。
249
+ - [ ] 停止或撤销动态 Cordis 包。
250
+ - [ ] 获取动态 Cordis Client 代码。
251
+ - [ ] 同步动态包发布和撤销事件。
252
+ - [ ] 支持 Cordis runtime inspect 查询。
253
+ - [ ] 上报动态 UI 渲染或 Client Guard 失败。
254
+
255
+ > 动态 Cordis 和 runtime inspect 强依赖 Web 页面生命周期,移动端优先级较低;在明确产品需求前保留未勾选状态。
256
+
257
+ ## 官方 Remote Event 转发
258
+
259
+ - [x] `approval/request`。
260
+ - [x] `user-questions/request`。
261
+ - [ ] `agent-preset/selected`。
262
+ - [ ] `api-session/activity`。
263
+ - [ ] `api-session/added`。
264
+ - [ ] `api-session/error`。
265
+ - [ ] `api-session/removed`。
266
+ - [ ] `api-session/status`。
267
+ - [ ] `commands/change`。
268
+ - [ ] `credentials/reference-updated`。
269
+ - [ ] `llm/adapters-updated`。
270
+ - [ ] `settings/document-updated`。
271
+ - [ ] `cordis/request-run`。
272
+ - [ ] `cordis/request-run-resolved`。
273
+ - [ ] `cordis/dynamic-package`。
274
+ - [ ] `cordis/dynamic-retract`。
275
+ - [ ] `cordis/inspect-query`。
276
+ - [ ] `cordis/inspect-query-resolved`。
277
+
278
+ ## 暂不纳入正式覆盖率
279
+
280
+ - [ ] 实验性 Agent Team Remote:`view`、`createTask`、`updateTask`。
281
+
282
+ > Agent Team 当前没有进入官方标准 `api-remotes` Client Assembly。只有在官方正式挂载或移动端产生明确需求后,才将其移动到正式功能章节。
283
+
284
+ ## 完成定义
285
+
286
+ 勾选一个待实现功能前,至少需要满足以下条件:
287
+
288
+ - [ ] Host Adapter 已封装对应 Remote 调用或流,不在业务层散落 namespace/method。
289
+ - [ ] 移动端协议请求、响应和错误形态已经定义。
290
+ - [ ] 对应单元测试或网关端到端测试通过。
291
+ - [ ] DSH 0.1.5-rc.2 相关契约和行为测试通过。
292
+ - [ ] `PROTOCOL.md` 与 README 中面向用户的能力说明已经同步。
293
+
294
+ > 本节是每个新功能的验收模板,不表示当前所有待实现功能已经满足这些条件,因此保持未勾选。
@@ -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)