dsh-remote-agent 0.9.1 → 0.9.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.
@@ -0,0 +1,207 @@
1
+ /**
2
+ * 独立配置文件(2026-10-18,解决"配置被 DSH 重写冲掉"的根因)。
3
+ *
4
+ * ═══════════════════════════════════════════════════════════════════════════
5
+ * 为什么需要它
6
+ * ═══════════════════════════════════════════════════════════════════════════
7
+ *
8
+ * 实测事故(2026-10-18):
9
+ *
10
+ * 01:44 cordis.patch.yml = 3962B / 15 条(含我们的 role + webserver)
11
+ * 12:05 cordis.patch.yml = 696B / 4 条(**我们的段整段消失**)
12
+ *
13
+ * 12:05 前后用户在 DSH 里**卸载重装了插件**,同时 pnpm-lock.yaml 被更新。
14
+ * 结论:**DSH 的插件管理器在改动依赖时会重写 `cordis.patch.yml`,只保留它
15
+ * 自己管理的条目** —— 手工写的段会被冲掉。
16
+ *
17
+ * 这正是"每次设置完重启又回到引导页"的真正根因(不是版本旧、不是写错 profile):
18
+ *
19
+ * 用户选角色 → 写入 cordis.patch.yml → 某次插件操作 → 我们的段被冲掉
20
+ * → 重启 → role 消失 → 面板又显示引导
21
+ *
22
+ * ═══════════════════════════════════════════════════════════════════════════
23
+ * 方案:配置分两层(本文件 = 不会被冲掉的那层)
24
+ * ═══════════════════════════════════════════════════════════════════════════
25
+ *
26
+ * · **第 1 层**:`cordis.patch.yml` 里的 `- id: remote-agent` 行
27
+ * —— 只负责"让 loader 加载这个插件",内容可以极简(甚至只有 id + name)
28
+ * · **第 2 层**:`<stateDir>/instance.json`(本文件管的这个)
29
+ * —— 存 role / hubUrl / roots 等**真正的配置**
30
+ *
31
+ * loader 传进来的 config 与这里的值**合并**,后者在关键字段上**优先**
32
+ * (因为它是用户明确设置过、且不会被外力冲掉的)。
33
+ *
34
+ * ═══════════════════════════════════════════════════════════════════════════
35
+ * 安全与边界
36
+ * ═══════════════════════════════════════════════════════════════════════════
37
+ *
38
+ * · 文件权限 0600(可能含 token)
39
+ * · 原子写(tmp → rename),与 templates.js 同一套做法
40
+ * · **绝不抛**:读失败/损坏时返回 null,让调用方退回 loader config
41
+ * (配置读不出来不该让 DSH 起不来)
42
+ * · 只存**白名单字段**,不存任意用户输入(防配置注入)
43
+ */
44
+
45
+ import fs from 'node:fs'
46
+ import os from 'node:os'
47
+ import path from 'node:path'
48
+
49
+ /** 独立配置文件名(放在 stateDir 下)。 */
50
+ export const INSTANCE_FILE_NAME = 'instance.json'
51
+
52
+ /**
53
+ * 允许持久化的字段白名单。
54
+ *
55
+ * 为什么不直接存整个 config:config 里可能有 DSH 注入的东西(未知字段会
56
+ * 被 schemastery passthrough 保留),把它们写回磁盘等于把 loader 的中间态
57
+ * 固化下来。只存我们**真正需要跨重启保留**的。
58
+ */
59
+ const PERSISTED_KEYS = Object.freeze([
60
+ 'role',
61
+ 'nodeId',
62
+ 'transportMode',
63
+ 'hubUrl',
64
+ 'relayUrl',
65
+ 'roots',
66
+ 'nodeToken',
67
+ 'uiPullRoot',
68
+ 'agentMode',
69
+ 'pair',
70
+ ])
71
+
72
+ /**
73
+ * 解析 stateDir(与 lib/node/index.js 的 resolveStateDir 同口径)。
74
+ * @param {object} [config]
75
+ * @returns {string}
76
+ */
77
+ export function resolveStateDir(config) {
78
+ const c = config && typeof config === 'object' ? config : {}
79
+ const explicit = typeof c.stateDir === 'string' ? c.stateDir.trim() : ''
80
+ if (explicit !== '') {
81
+ try {
82
+ return path.resolve(explicit)
83
+ } catch {
84
+ /* 落到默认 */
85
+ }
86
+ }
87
+ try {
88
+ return path.join(os.homedir(), '.dsh-remote-agent')
89
+ } catch {
90
+ return path.join(os.tmpdir(), '.dsh-remote-agent')
91
+ }
92
+ }
93
+
94
+ /** 独立配置文件的绝对路径。 */
95
+ export function instanceFilePath(config) {
96
+ return path.join(resolveStateDir(config), INSTANCE_FILE_NAME)
97
+ }
98
+
99
+ /**
100
+ * 读独立配置。
101
+ *
102
+ * @param {object} [config] 用于解析 stateDir
103
+ * @returns {object|null} 读不到/损坏时返回 null(**不抛**)
104
+ */
105
+ export function readInstanceConfig(config) {
106
+ const file = instanceFilePath(config)
107
+ let text
108
+ try {
109
+ text = fs.readFileSync(file, 'utf8')
110
+ } catch {
111
+ // ENOENT = 还没写过,是最常见的正常情况
112
+ return null
113
+ }
114
+ let parsed
115
+ try {
116
+ parsed = JSON.parse(text)
117
+ } catch {
118
+ // 损坏:返回 null 让调用方退回 loader config(不让 DSH 起不来)
119
+ return null
120
+ }
121
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
122
+ // 字段级清洗(只取白名单,且去掉明显非法值)
123
+ const out = {}
124
+ for (const k of PERSISTED_KEYS) {
125
+ if (!(k in parsed)) continue
126
+ const v = parsed[k]
127
+ if (v === null || v === undefined) continue
128
+ // roots 是数组,单独清洗
129
+ if (k === 'roots') {
130
+ if (Array.isArray(v)) {
131
+ const arr = v.filter((x) => typeof x === 'string' && x.trim() !== '').map((x) => x.trim())
132
+ if (arr.length > 0) out.roots = arr
133
+ }
134
+ continue
135
+ }
136
+ if (typeof v === 'string' && v.trim() !== '') out[k] = v.trim()
137
+ }
138
+ return Object.keys(out).length > 0 ? out : null
139
+ }
140
+
141
+ /**
142
+ * 写独立配置(原子写 + 0600)。
143
+ *
144
+ * @param {object} values 要写的字段(按白名单过滤)
145
+ * @param {object} [config] 用于解析 stateDir
146
+ * @returns {{ok:boolean, file:string, error?:string}}
147
+ */
148
+ export function writeInstanceConfig(values, config) {
149
+ const dir = resolveStateDir(config)
150
+ const file = path.join(dir, INSTANCE_FILE_NAME)
151
+ const src = values && typeof values === 'object' ? values : {}
152
+ const out = {}
153
+ for (const k of PERSISTED_KEYS) {
154
+ if (!(k in src)) continue
155
+ const v = src[k]
156
+ if (v === null || v === undefined) continue
157
+ if (k === 'roots') {
158
+ if (Array.isArray(v)) {
159
+ const arr = v.filter((x) => typeof x === 'string' && x.trim() !== '').map((x) => x.trim())
160
+ if (arr.length > 0) out.roots = arr
161
+ }
162
+ continue
163
+ }
164
+ if (typeof v === 'string' && v.trim() !== '') out[k] = v.trim()
165
+ }
166
+
167
+ const tmp = `${file}.tmp-${process.pid}-${Date.now().toString(36)}`
168
+ try {
169
+ fs.mkdirSync(dir, { recursive: true })
170
+ fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
171
+ fs.renameSync(tmp, file)
172
+ return { ok: true, file }
173
+ } catch (err) {
174
+ try {
175
+ fs.rmSync(tmp, { force: true })
176
+ } catch {
177
+ /* 清理失败不掩盖原始错误 */
178
+ }
179
+ return { ok: false, file, error: String(err?.message ?? err) }
180
+ }
181
+ }
182
+
183
+ /**
184
+ * 把独立配置**合并进** loader 传进来的 config。
185
+ *
186
+ * ── 优先级(关键设计)──────────────────────────────────────────────────
187
+ *
188
+ * **独立配置优先** —— 因为:
189
+ * · 它是用户通过面板明确设置过的值
190
+ * · 它不会被 DSH 重写冲掉
191
+ * · loader config 在"被冲掉"之后只剩包内默认(role: auto),
192
+ * 那正是"重启回引导页"的成因
193
+ *
194
+ * 例外:如果独立配置里**没有**某个字段,就用 loader config 的值。
195
+ *
196
+ * @param {object} loaderConfig
197
+ * @param {object} [opts]
198
+ * @param {boolean} [opts.overlay] 读到的独立配置(避免重复读盘)
199
+ * @returns {{config:object, source:object|null}}
200
+ */
201
+ export function mergeInstanceConfig(loaderConfig, { overlay } = {}) {
202
+ const base = loaderConfig && typeof loaderConfig === 'object' ? loaderConfig : {}
203
+ let saved = overlay
204
+ if (saved === undefined) saved = readInstanceConfig(base)
205
+ if (!saved) return { config: base, source: null }
206
+ return { config: { ...base, ...saved }, source: saved }
207
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * 独立监听端口(2026-10-18,解决"副机永远连不上"的架构级问题)。
3
+ *
4
+ * ═══════════════════════════════════════════════════════════════════════════
5
+ * 为什么不再用 ctx.webServer
6
+ * ═══════════════════════════════════════════════════════════════════════════
7
+ *
8
+ * 原实现把副机拨入端点注册在 DSH 的 `ctx.webServer` 上:
9
+ *
10
+ * ctx.webServer.registerUpgrade({ path: '/api/remote-agent/node', handler })
11
+ *
12
+ * 于是副机必须能访问 **DSH 的 webserver 端口**。而那个端口的绑定地址来自
13
+ * DSH 自己的配置,带来两个无法绕开的死结:
14
+ *
15
+ * 1. **DSH 每次启动都重写 `<profile>/cordis.patch.yml`**(实测:写进去的
16
+ * `webserver` 段在下一次启动时被删掉,只留 3 个 `ui-*` 条目)→ 绑回
17
+ * `127.0.0.1` → 副机连不上。
18
+ * 2. `webServer` 服务**激活即 listen**,没有 rebind 方法 → 插件运行时改不了。
19
+ *
20
+ * 而且新版 `dsh-host-webserver` 硬拒绝 `0.0.0.0`(会直接让 DSH 启动崩溃)。
21
+ *
22
+ * ═══════════════════════════════════════════════════════════════════════════
23
+ * 现在的做法:插件自己开一个端口
24
+ * ═══════════════════════════════════════════════════════════════════════════
25
+ *
26
+ * 用 `node:http` 起一个独立的 server,自己处理 WebSocket 升级
27
+ * (复用 `lib/ws.js` 的 `acceptUpgrade` —— 和 DSH 内部用的是同一套底层实现)。
28
+ *
29
+ * 好处:
30
+ * · **完全不依赖 DSH 的 webserver 配置**,DSH 怎么重写 patch 都不影响
31
+ * · host/port 由插件自己的配置决定,可以持久化到 instance.json
32
+ * · 不碰 DSH 的任何文件
33
+ *
34
+ * 代价:
35
+ * · 多一个监听端口(默认 19387)
36
+ * · 需要用户/配置告诉它绑哪个具体 IP(新版规则不允许 0.0.0.0,这本身是好事:
37
+ * 只绑用户指定的那张网卡,暴露面最小)
38
+ *
39
+ * ═══════════════════════════════════════════════════════════════════════════
40
+ * 绑定地址怎么定(关键设计)
41
+ * ═══════════════════════════════════════════════════════════════════════════
42
+ *
43
+ * 无法绑 `0.0.0.0`(与新版 DSH 同一考量:通配地址把服务暴露到所有网卡)。
44
+ * 所以按以下优先级选一个**具体 IP**:
45
+ *
46
+ * 1. 配置里的 `listenHost`(用户显式指定,最高优先)
47
+ * 2. **Tailscale IP**(100.64.0.0/10 段)—— 本插件的目标场景就是跨机,
48
+ * Tailscale 地址固定不变、跨网可达,是最合理的选择
49
+ * 3. 第一个私网 IPv4(192.168/10./172.16-31)
50
+ * 4. `127.0.0.1`(兜底:至少本机能连,同机 both 模式可用)
51
+ *
52
+ * **自动选 IP 是刻意的**:不让用户为了一个"该绑哪张网卡"去查 ipconfig。
53
+ */
54
+
55
+ import http from 'node:http'
56
+ import os from 'node:os'
57
+
58
+ import { acceptUpgrade } from './ws.js'
59
+
60
+ /** 默认端口:与历史行为一致(文档、配对码、用户既有配置都指向它)。 */
61
+ export const DEFAULT_LISTEN_PORT = 19387
62
+
63
+ /** Tailscale 的 CGNAT 段(100.64.0.0/10)。 */
64
+ function isTailscaleIp(ip) {
65
+ if (typeof ip !== 'string') return false
66
+ const m = /^100\.(\d+)\./.exec(ip)
67
+ if (!m) return false
68
+ const second = Number(m[1])
69
+ // 100.64.0.0/10 → 第二段 64..127
70
+ return second >= 64 && second <= 127
71
+ }
72
+
73
+ /** 私网段。 */
74
+ function isPrivateIp(ip) {
75
+ if (typeof ip !== 'string') return false
76
+ return (
77
+ ip.startsWith('192.168.') ||
78
+ ip.startsWith('10.') ||
79
+ /^172\.(1[6-9]|2\d|3[01])\./.test(ip)
80
+ )
81
+ }
82
+
83
+ /**
84
+ * 列出本机所有非内网回环的 IPv4(按优先级排序)。
85
+ *
86
+ * @returns {Array<{address:string, family:string, iface:string, score:number}>}
87
+ */
88
+ export function listCandidateAddresses() {
89
+ const out = []
90
+ let ifaces
91
+ try {
92
+ ifaces = os.networkInterfaces()
93
+ } catch {
94
+ return out
95
+ }
96
+ for (const [iface, list] of Object.entries(ifaces)) {
97
+ if (!Array.isArray(list)) continue
98
+ for (const info of list) {
99
+ if (!info || info.internal === true) continue
100
+ const family = info.family === 'IPv4' || info.family === 4 ? 'IPv4' : String(info.family)
101
+ if (family !== 'IPv4') continue
102
+ const address = typeof info.address === 'string' ? info.address : ''
103
+ if (address === '' || address.startsWith('169.254.')) continue // APIPA
104
+ // 优先级:Tailscale(0) > 私网(1) > 其它(2)
105
+ const score = isTailscaleIp(address) ? 0 : isPrivateIp(address) ? 1 : 2
106
+ out.push({ address, family, iface, score })
107
+ }
108
+ }
109
+ out.sort((a, b) => a.score - b.score)
110
+ return out
111
+ }
112
+
113
+ /**
114
+ * 决定监听地址。
115
+ *
116
+ * @param {object} [o]
117
+ * @param {string} [o.listenHost] 用户显式配置的地址
118
+ * @param {string[]} [o.candidates] 覆盖候选列表(测试用)
119
+ * @returns {{host:string, source:string, candidates:Array}}
120
+ */
121
+ export function resolveListenHost({ listenHost, candidates } = {}) {
122
+ const list = Array.isArray(candidates) ? candidates : listCandidateAddresses()
123
+
124
+ // 1. 用户显式指定(且不是通配地址 —— 新版规则不允许)
125
+ if (typeof listenHost === 'string') {
126
+ const h = listenHost.trim()
127
+ if (h !== '' && h !== '0.0.0.0' && h !== '::' && h !== '[::]') {
128
+ return { host: h, source: 'config', candidates: list }
129
+ }
130
+ }
131
+
132
+ // 2. Tailscale IP
133
+ const ts = list.find((x) => isTailscaleIp(x.address))
134
+ if (ts) return { host: ts.address, source: 'tailscale', candidates: list }
135
+
136
+ // 3. 私网 IP
137
+ const priv = list.find((x) => isPrivateIp(x.address))
138
+ if (priv) return { host: priv.address, source: 'private', candidates: list }
139
+
140
+ // 4. 任意非回环
141
+ if (list.length > 0) return { host: list[0].address, source: 'any', candidates: list }
142
+
143
+ // 5. 兜底:本机回环(同机 both 模式仍可用)
144
+ return { host: '127.0.0.1', source: 'loopback', candidates: list }
145
+ }
146
+
147
+ /**
148
+ * 启动一个只服务本插件 WS 端点的小 HTTP server。
149
+ *
150
+ * 只做两件事:
151
+ * · `GET <healthPath>` → 200 JSON(运维自检用,不泄露敏感信息)
152
+ * · `Upgrade: websocket` → 交给 `onUpgrade`(副机拨入)
153
+ *
154
+ * 其它路径一律 404 —— 这不是通用 web server,只承载节点端点。
155
+ *
156
+ * @param {object} o
157
+ * @param {string} o.host 绑定的具体 IP
158
+ * @param {number} o.port 端口(0 = 随机)
159
+ * @param {string} o.path WS 升级路径(必须精确匹配)
160
+ * @param {(conn:import('./ws.js').WSConnection, req:import('node:http').IncomingMessage)=>void} o.onUpgrade
161
+ * @param {string} [o.healthPath] 健康检查路径
162
+ * @param {object} [o.healthBody] 健康检查返回体
163
+ * @param {(level:string,msg:string,extra?:object)=>void} [o.log]
164
+ * @returns {Promise<{server:import('node:http').Server, host:string, port:number, close:()=>Promise<void>}>}
165
+ */
166
+ export function startListenServer({
167
+ host,
168
+ port = DEFAULT_LISTEN_PORT,
169
+ path: upgradePath,
170
+ onUpgrade,
171
+ healthPath = '/healthz',
172
+ healthBody = { ok: true },
173
+ log = () => {},
174
+ } = {}) {
175
+ return new Promise((resolve, reject) => {
176
+ const sockets = new Set()
177
+
178
+ const server = http.createServer((req, res) => {
179
+ // 健康检查:给运维/体检一个"端口真的活着"的判据
180
+ try {
181
+ const url = new URL(req.url || '/', 'http://localhost')
182
+ if (req.method === 'GET' && url.pathname === healthPath) {
183
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
184
+ res.end(JSON.stringify(healthBody))
185
+ return
186
+ }
187
+ } catch {
188
+ /* URL 解析失败:落到 404 */
189
+ }
190
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
191
+ res.end('not found\n')
192
+ })
193
+
194
+ // 追踪连接,close 时能强制断干净(否则旧连接会拖住 server.close)
195
+ server.on('connection', (socket) => {
196
+ sockets.add(socket)
197
+ socket.on('close', () => sockets.delete(socket))
198
+ })
199
+
200
+ server.on('upgrade', (req, socket, head) => {
201
+ let pathname
202
+ try {
203
+ pathname = new URL(req.url || '/', 'http://localhost').pathname
204
+ } catch {
205
+ socket.destroy()
206
+ return
207
+ }
208
+ // ⚠ 路径必须**精确匹配**:这个 server 只承载节点端点,
209
+ // 不开任何"顺手兼容一下"的口子(越宽的攻击面越难守)。
210
+ if (pathname !== upgradePath) {
211
+ socket.destroy()
212
+ return
213
+ }
214
+ const conn = acceptUpgrade(req, socket, head)
215
+ if (!conn) return
216
+ try {
217
+ onUpgrade(conn, req)
218
+ } catch (err) {
219
+ log('warn', 'upgrade handler threw', { error: String(err?.message ?? err) })
220
+ try {
221
+ conn.close(1011, 'internal error')
222
+ } catch {
223
+ /* 已关 */
224
+ }
225
+ }
226
+ })
227
+
228
+ server.on('error', (err) => {
229
+ // listen 失败(端口占用 / 地址不可用)要**明确报出来** ——
230
+ // 静默失败会让"副机连不上"变成又一个无头案。
231
+ reject(err)
232
+ })
233
+
234
+ server.listen(port, host, () => {
235
+ const addr = server.address()
236
+ const actualPort = addr && typeof addr === 'object' ? addr.port : port
237
+ log('info', 'node endpoint listening', { host, port: actualPort, path: upgradePath })
238
+
239
+ const close = () => new Promise((done) => {
240
+ try {
241
+ server.close(() => done())
242
+ } catch {
243
+ done()
244
+ }
245
+ // 强制断开存量连接:server.close 只是"停止接受新连接",
246
+ // 已有 WS 连接会一直挂着,插件卸载时不能留悬挂句柄。
247
+ for (const s of sockets) {
248
+ try {
249
+ s.destroy()
250
+ } catch {
251
+ /* 已关 */
252
+ }
253
+ }
254
+ sockets.clear()
255
+ // 兜底超时:某些平台 close 回调可能不触发
256
+ setTimeout(done, 2000).unref?.()
257
+ })
258
+
259
+ resolve({ server, host, port: actualPort, close })
260
+ })
261
+ })
262
+ }
@@ -35,6 +35,9 @@ import { writePatchBlock } from './setup.js'
35
35
  // 角色解析必须**只有一份实现** —— 面板说"未配置"而插件实际在跑 hub,
36
36
  // 是最难排查的撕裂(v0.7.0 踩过)。所以这里直接复用 lib/index.js 的解析器。
37
37
  import { resolveRoleDetail } from './index.js'
38
+ // 配置分层(2026-10-18):DSH 插件管理器会重写 cordis.patch.yml 并冲掉手工段,
39
+ // 所以每次写配置时也在 <stateDir>/instance.json 存一份(插件加载时合并回来)。
40
+ import { writeInstanceConfig, readInstanceConfig } from './instance-config.js'
38
41
 
39
42
  /** 引导路由的前缀。与 hub 的 `/api/remote-agent/ui` 分开 —— 未配置时后者不存在。 */
40
43
  export const SETUP_API_PREFIX = '/api/remote-agent/setup'
@@ -216,12 +219,28 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
216
219
  // `configured` 的语义是"用户已经做过选择,不需要引导"。
217
220
  // auto 也算已选择:它是个明确的工作模式(按 hubUrl 推断),不是"还没配"。
218
221
  const configured = role === 'auto' || detail.unconfigured !== true
222
+
223
+ // ── 独立配置状态(2026-10-18)──────────────────────────────────────
224
+ //
225
+ // 报告 `<stateDir>/instance.json` 里存了什么。这不是给用户看的细节,
226
+ // 而是排查"配置被 DSH 重写冲掉"这个坑的关键线索:
227
+ // · instance 有值 = 配置抗住了重写(重启后 apply() 会合并回来)
228
+ // · instance 为 null 但 patch 有值 = 只靠 patch 那层,下次重写会丢
229
+ const saved = readInstanceConfig(config)
219
230
  writeJson(res, 200, {
220
231
  ok: true,
221
232
  configured,
222
233
  // 推断结果(面板据此显示"当前生效角色")—— auto 时它是有值的
223
234
  effectiveRole: detail.role,
224
235
  inferred: typeof detail.inferred === 'string' ? detail.inferred : '',
236
+ // 配置持久化的两层状态
237
+ persist: {
238
+ // DSH 会重写 cordis.patch.yml(实测会把我们的段整段冲掉)
239
+ patchFile: currentPatchFile(),
240
+ // 这一层 DSH 冲不掉,是"重启后配置还在"的真正保障
241
+ instanceFile: saved !== null,
242
+ instanceRole: saved && typeof saved.role === 'string' ? saved.role : null,
243
+ },
225
244
  // 回显当前配置里已有的值,方便用户"改一个字段"而不是从头填
226
245
  current: {
227
246
  role: typeof config.role === 'string' ? role : '',
@@ -342,12 +361,41 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
342
361
  })
343
362
  }
344
363
  log('info', 'setup applied', { role, file: result.file, mode: result.mode, warnings: applyWarnings })
364
+
365
+ // ── 同时写独立配置(2026-10-18,方案 B:防被 DSH 重写冲掉)──────────
366
+ //
367
+ // 事故:用户在 DSH 里卸载重装插件后,`cordis.patch.yml` 被重写成只剩
368
+ // 4 条 —— 我们的 role + webserver 整段消失,重启后又回引导页。
369
+ //
370
+ // 所以每次 setup 写入成功后,把**同样的值**再存一份到
371
+ // `<stateDir>/instance.json`。插件 `apply()` 时会把它合并回来
372
+ // (独立配置优先)。这样 DSH 怎么重写 patch 文件都不影响角色配置。
373
+ //
374
+ // 失败不阻塞:独立配置写不进去时,patch 文件那层仍然生效(只是不抗冲掉)。
375
+ const persisted = writeInstanceConfig({
376
+ role,
377
+ nodeId: str(body.nodeId),
378
+ transportMode: str(body.transportMode) || undefined,
379
+ hubUrl: str(body.hubUrl) || undefined,
380
+ relayUrl: str(body.relayUrl) || undefined,
381
+ roots,
382
+ })
383
+ if (persisted.ok !== true) {
384
+ applyWarnings.push(`独立配置写入失败(${persisted.error})—— 角色配置仍写进了 patch 文件,`
385
+ + '但下次 DSH 重写该文件时可能丢失,建议重启后到面板确认一次。')
386
+ log('warn', 'instance config write failed', { file: persisted.file, error: persisted.error })
387
+ } else {
388
+ log('info', 'instance config persisted', { file: persisted.file })
389
+ }
390
+
345
391
  writeJson(res, 200, {
346
392
  ok: true,
347
393
  role,
348
394
  file: result.file,
349
395
  backup: result.backup,
350
396
  mode: result.mode,
397
+ // 独立配置的落点(让用户/排查者知道配置存在哪两处)
398
+ instanceFile: persisted.ok === true ? persisted.file : null,
351
399
  // 非致命提示(自动建目录的结果等)—— 面板要显示,否则用户以为
352
400
  // "目录已就绪"而实际没有
353
401
  warnings: applyWarnings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-agent",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "type": "module",
5
5
  "description": "把另一台电脑变成你 DSH 的远程 Agent 同事:副机本地持久跑任务、独立 Agent 循环,只回传 handoff 摘要与产物清单,成品按需拉取。",
6
6
  "license": "MIT",