dsh-remote-agent 0.0.0-stage → 0.4.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/CHANGELOG.md +700 -0
- package/CONTRIBUTING.md +358 -0
- package/KNOWN-ISSUES.md +601 -0
- package/LICENSE +21 -0
- package/README.en.md +353 -0
- package/README.md +317 -2
- package/SECURITY.md +348 -0
- package/bin/dsh-remote-relay.js +257 -0
- package/bin/dsh-remote-setup.js +440 -0
- package/bin//345/217/214/345/207/273/350/277/220/350/241/214-/351/205/215/347/275/256/345/220/221/345/257/274.cmd +28 -0
- package/cordis.patch.yml +87 -0
- package/deploy/relay-vps/Caddyfile +24 -0
- package/deploy/relay-vps/README.md +132 -0
- package/deploy/relay-vps/dsh-remote-relay.service +47 -0
- package/deploy/relay-vps/nginx.conf +49 -0
- package/deploy/relay-vps/relay.env.example +20 -0
- package/docs/dispatch-automation-eval.md +140 -0
- package/docs/screenshots/panel-main.png +0 -0
- package/docs/screenshots/panel-task-detail.png +0 -0
- package/docs/verification-agent-e2e.md +425 -0
- package/docs/verification-repos-routing-real.md +521 -0
- package/docs/verification-resume-real.md +254 -0
- package/docs/worker-setup-pitfalls.md +266 -0
- package/lib/artifacts.js +451 -0
- package/lib/client.js +1752 -0
- package/lib/e2e.js +304 -0
- package/lib/glob.js +225 -0
- package/lib/hub/audit-log.js +161 -0
- package/lib/hub/hub-util.js +114 -0
- package/lib/hub/index.js +500 -0
- package/lib/hub/node-registry.js +943 -0
- package/lib/hub/pull-manager.js +781 -0
- package/lib/hub/registry-handshake.js +403 -0
- package/lib/hub/registry-ingest.js +346 -0
- package/lib/hub/relay-link.js +228 -0
- package/lib/hub/subagent-provider.js +424 -0
- package/lib/hub/templates.js +693 -0
- package/lib/hub/tools.js +1059 -0
- package/lib/hub/ui-routes.js +856 -0
- package/lib/hub/views.js +351 -0
- package/lib/identity.js +271 -0
- package/lib/index.js +272 -0
- package/lib/node/artifact-service.js +231 -0
- package/lib/node/diff.js +124 -0
- package/lib/node/exec.js +536 -0
- package/lib/node/hub-pin.js +71 -0
- package/lib/node/index.js +1022 -0
- package/lib/node/repos.js +134 -0
- package/lib/node/resource.js +63 -0
- package/lib/node/runner.js +792 -0
- package/lib/node/task-engine.js +712 -0
- package/lib/node/task-store.js +273 -0
- package/lib/node/transport.js +763 -0
- package/lib/pairing.js +186 -0
- package/lib/protocol.js +414 -0
- package/lib/relay-config.js +317 -0
- package/lib/relay.js +732 -0
- package/lib/setup-routes.js +313 -0
- package/lib/setup.js +522 -0
- package/lib/ws.js +466 -0
- package/package.json +103 -4
- package/profiles/README.md +573 -0
- package/profiles/hub.cordis.yml +84 -0
- package/profiles/worker.cordis.yml +60 -0
- package/scripts/check-doc-drift.mjs +785 -0
- package/scripts/check-packaging.mjs +130 -0
- package/scripts/diag-inject.mjs +243 -0
- package/scripts/e2e-live-check.mjs +181 -0
- package/scripts/relay-init.ps1 +286 -0
- package/scripts/relay-init.sh +199 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-remote-setup —— 小白向导。
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* node bin/dsh-remote-setup.js # 交互式:一步步生成两台的配置
|
|
7
|
+
* node bin/dsh-remote-setup.js --doctor # 体检当前配置,只报问题 + 怎么修
|
|
8
|
+
* node bin/dsh-remote-setup.js --role worker --hub-url ws://192.168.1.10:19387/api/remote-agent/node \
|
|
9
|
+
* --token nt_xxx --roots D:\work --node-id slave-pc # 非交互生成副机配置
|
|
10
|
+
* node bin/dsh-remote-setup.js --role hub --token nt_xxx --roots D:\work # 非交互生成主机配置
|
|
11
|
+
*
|
|
12
|
+
* 零外部依赖,只用 node: 内置模块。逻辑层在 lib/setup.js(可测试)。
|
|
13
|
+
*/
|
|
14
|
+
import readline from 'node:readline/promises'
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import os from 'node:os'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { parseArgs } from 'node:util'
|
|
19
|
+
import {
|
|
20
|
+
newNodeToken,
|
|
21
|
+
detectLanIps,
|
|
22
|
+
normalizeRoots,
|
|
23
|
+
buildHubYaml,
|
|
24
|
+
buildWorkerYaml,
|
|
25
|
+
buildHubUrl,
|
|
26
|
+
probeTcp,
|
|
27
|
+
doctorChecks,
|
|
28
|
+
renderDoctor,
|
|
29
|
+
patchFilePath,
|
|
30
|
+
listDshProfiles,
|
|
31
|
+
writePatchBlock,
|
|
32
|
+
} from '../lib/setup.js'
|
|
33
|
+
import { encodePairCode, decodePairCode } from '../lib/pairing.js'
|
|
34
|
+
import { IDENTITY_FILE_NAME, readHubIdentity } from '../lib/identity.js'
|
|
35
|
+
|
|
36
|
+
const DIM = '\x1b[2m'
|
|
37
|
+
const RESET = '\x1b[0m'
|
|
38
|
+
const BOLD = '\x1b[1m'
|
|
39
|
+
|
|
40
|
+
function say(msg = '') {
|
|
41
|
+
console.log(msg)
|
|
42
|
+
}
|
|
43
|
+
function hint(msg) {
|
|
44
|
+
console.log(`${DIM}${msg}${RESET}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 读一个用户输入(readline),支持默认值与校验循环。 */
|
|
48
|
+
async function ask(rl, question, { def = '', validate, retryMsg = '输入不合要求,请重试' } = {}) {
|
|
49
|
+
for (;;) {
|
|
50
|
+
const suffix = def !== '' ? `(回车 = ${def})` : ''
|
|
51
|
+
const raw = (await rl.question(`${question}${suffix} > `)).trim() || def
|
|
52
|
+
if (!validate) return raw
|
|
53
|
+
const err = validate(raw)
|
|
54
|
+
if (err === true || err === undefined) return raw
|
|
55
|
+
say(` ✗ ${typeof err === 'string' ? err : retryMsg}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseCliArgs() {
|
|
60
|
+
try {
|
|
61
|
+
const { values, positionals } = parseArgs({
|
|
62
|
+
allowPositionals: true, // --doctor <配置文件路径>
|
|
63
|
+
options: {
|
|
64
|
+
doctor: { type: 'boolean', default: false },
|
|
65
|
+
role: { type: 'string' }, // hub | worker
|
|
66
|
+
'hub-url': { type: 'string' },
|
|
67
|
+
'hub-ip': { type: 'string' },
|
|
68
|
+
port: { type: 'string' },
|
|
69
|
+
token: { type: 'string' },
|
|
70
|
+
'node-id': { type: 'string' },
|
|
71
|
+
pair: { type: 'string' }, // worker 非交互:直接用配对码
|
|
72
|
+
roots: { type: 'string' }, // 逗号分隔
|
|
73
|
+
out: { type: 'string' }, // 写文件而不是打印
|
|
74
|
+
write: { type: 'boolean', default: false }, // 非交互:直接写进 DSH 配置(自动备份)
|
|
75
|
+
'no-write': { type: 'boolean', default: false }, // 交互:禁止写盘询问(只输出)
|
|
76
|
+
force: { type: 'boolean', default: false }, // 有手写 remote-agent 条目时强制写入
|
|
77
|
+
profile: { type: 'string' }, // dsh profile 名(默认 desktop)
|
|
78
|
+
help: { type: 'boolean', default: false },
|
|
79
|
+
},
|
|
80
|
+
strict: true,
|
|
81
|
+
})
|
|
82
|
+
return { values, positionals }
|
|
83
|
+
} catch (err) {
|
|
84
|
+
say(`参数解析失败:${err.message}`)
|
|
85
|
+
say('用 --help 查看用法。')
|
|
86
|
+
process.exit(2)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function splitRoots(raw) {
|
|
91
|
+
return String(raw ?? '')
|
|
92
|
+
.split(/[,;]+/)
|
|
93
|
+
.map((s) => s.trim())
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function printYaml(name, yaml, outFile) {
|
|
98
|
+
if (outFile) {
|
|
99
|
+
fs.writeFileSync(path.resolve(outFile), yaml + '\n', 'utf8')
|
|
100
|
+
say(`✓ 已写入 ${path.resolve(outFile)}`)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
say('')
|
|
104
|
+
say(`${BOLD}=== ${name} ===${RESET}`)
|
|
105
|
+
say(yaml)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 询问并把 YAML 写进本机 DSH 配置(备份 → 托管块合并 → 校验,失败自动回滚)。
|
|
110
|
+
* 写盘是零终端小白的"最后一公里":不用自己找配置文件、开编辑器、粘 YAML。
|
|
111
|
+
*/
|
|
112
|
+
async function offerWrite(rl, values, yaml, roleLabel) {
|
|
113
|
+
if (values['no-write']) return false
|
|
114
|
+
const profile = String(values.profile ?? '').trim() || 'desktop'
|
|
115
|
+
const file = patchFilePath(profile)
|
|
116
|
+
const profiles = listDshProfiles()
|
|
117
|
+
if (profiles.length === 0 && !fs.existsSync(file)) {
|
|
118
|
+
hint(`未找到 DSH 配置(${file})—— 确认这台机器装了 DSH?下面保持只输出,请你手动粘贴。`)
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
const yes = await ask(rl, `要把这份${roleLabel}配置直接写进 DSH 吗?(自动备份原文件;profile=${profile})`, {
|
|
122
|
+
def: 'Y',
|
|
123
|
+
validate: (v) => (['y', 'yes', 'n', 'no', '1', '0'].includes(v.toLowerCase()) || '输 Y 或 N'),
|
|
124
|
+
})
|
|
125
|
+
if (!['y', 'yes', '1'].includes(yes.toLowerCase())) return false
|
|
126
|
+
return applyWrite({ file, blockYaml: yaml, force: values.force === true })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 非交互写盘执行 + 结果播报。返回 true = 成功。 */
|
|
130
|
+
function applyWrite({ file, blockYaml, force }) {
|
|
131
|
+
const r = writePatchBlock({ file, blockYaml, force })
|
|
132
|
+
if (r.ok) {
|
|
133
|
+
say(` ✓ 已${r.mode === 'replaced' ? '更新(原托管块替换)' : '写入(追加托管块)'} ${r.file}`)
|
|
134
|
+
say(` (原文件已备份:${r.backup})`)
|
|
135
|
+
hint(' 重启 DSH 后生效。')
|
|
136
|
+
} else {
|
|
137
|
+
say(` ✗ ${r.error}`)
|
|
138
|
+
}
|
|
139
|
+
return r.ok
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ══════════════════ doctor ══════════════════ */
|
|
143
|
+
|
|
144
|
+
async function runDoctor(positionals) {
|
|
145
|
+
const cfgPath = positionals[0]
|
|
146
|
+
if (!cfgPath || !fs.existsSync(cfgPath)) {
|
|
147
|
+
say('用法:--doctor <cordis配置文件.yml>')
|
|
148
|
+
say('它会读取配置文件里的 remote-agent 行做体检(角色、token、roots、目录存在性)。')
|
|
149
|
+
process.exit(2)
|
|
150
|
+
}
|
|
151
|
+
const raw = fs.readFileSync(cfgPath, 'utf8')
|
|
152
|
+
// 轻量解析 remote-agent 行:不引 YAML 依赖,只提取 role/各字段(生成器的输出形态)
|
|
153
|
+
const grab = (key) => {
|
|
154
|
+
const m = new RegExp(`^\\s*${key}:\\s*(.+?)\\s*$`, 'm').exec(raw)
|
|
155
|
+
return m ? m[1].replace(/^['"]|['"]$/g, '').trim() : ''
|
|
156
|
+
}
|
|
157
|
+
const rootsM = /roots:\s*\n((?:\s+-\s+.+\n?)+)/.exec(raw)
|
|
158
|
+
const roots = rootsM
|
|
159
|
+
? [...rootsM[1].matchAll(/-\s*(.+)/g)].map((m) => m[1].replace(/^['"]|['"]$/g, '').trim())
|
|
160
|
+
: splitRoots(grab('roots'))
|
|
161
|
+
const config = {
|
|
162
|
+
role: grab('role') || 'hub',
|
|
163
|
+
hubUrl: grab('hubUrl'),
|
|
164
|
+
token: grab('token'),
|
|
165
|
+
nodeToken: grab('nodeToken'),
|
|
166
|
+
nodeId: grab('nodeId'),
|
|
167
|
+
pair: grab('pair'),
|
|
168
|
+
roots,
|
|
169
|
+
uiPullRoot: grab('uiPullRoot'),
|
|
170
|
+
}
|
|
171
|
+
const role = config.role === 'worker' ? 'worker' : 'hub'
|
|
172
|
+
|
|
173
|
+
const checks = doctorChecks({ role, config, lanIps: detectLanIps() })
|
|
174
|
+
|
|
175
|
+
// 网络探活:worker 且 hubUrl 合法时试一把 TCP
|
|
176
|
+
if (role === 'worker' && config.hubUrl) {
|
|
177
|
+
try {
|
|
178
|
+
const u = new URL(/^wss?:\/\//i.test(config.hubUrl) ? config.hubUrl : `ws://${config.hubUrl}`)
|
|
179
|
+
const r = await probeTcp(u.hostname, u.port || 80)
|
|
180
|
+
checks.push({
|
|
181
|
+
ok: r.ok,
|
|
182
|
+
label: `主机端口可达 ws://${u.hostname}:${u.port || 80}${r.ok ? `(${r.ms}ms)` : ''}`,
|
|
183
|
+
fix: r.error ?? '',
|
|
184
|
+
})
|
|
185
|
+
} catch { /* URL 不合法已在静态检查报过 */ }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { text, failed } = renderDoctor(checks, { title: `dsh-remote-agent 体检(role=${role})` })
|
|
189
|
+
say(text)
|
|
190
|
+
process.exit(failed === 0 ? 0 : 1)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* ══════════════════ 交互式向导 ══════════════════ */
|
|
194
|
+
|
|
195
|
+
async function wizard() {
|
|
196
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
say('')
|
|
200
|
+
say(`${BOLD}dsh-remote-agent 配置向导${RESET}`)
|
|
201
|
+
hint('把另一台电脑变成你 DSH 的远程同事。全程回车可用默认值。')
|
|
202
|
+
say('')
|
|
203
|
+
|
|
204
|
+
const role = await ask(rl, '① 这台机器是?[1=主机(派活) 2=副机(干活)]', {
|
|
205
|
+
def: '1',
|
|
206
|
+
validate: (v) => (['1', '2', 'hub', 'worker'].includes(v.toLowerCase()) || '请输 1 或 2'),
|
|
207
|
+
})
|
|
208
|
+
const isHub = role === '1' || role.toLowerCase() === 'hub'
|
|
209
|
+
|
|
210
|
+
// ── roots:两台都要 ────────────────────────────────────────
|
|
211
|
+
const defaultRoot = path.join(fs.realpathSync(os.homedir()), 'dsh-remote-work')
|
|
212
|
+
let roots = []
|
|
213
|
+
await ask(rl, `② 允许任务操作的目录(多个用逗号分隔)`, {
|
|
214
|
+
def: defaultRoot,
|
|
215
|
+
validate: (v) => {
|
|
216
|
+
const { clean, missing } = normalizeRoots(splitRoots(v))
|
|
217
|
+
if (clean.length === 0) return '至少给一个目录'
|
|
218
|
+
if (missing.length > 0) {
|
|
219
|
+
for (const m of missing) {
|
|
220
|
+
try { fs.mkdirSync(m, { recursive: true }); hint(` 已自动创建缺失目录 ${m}`) } catch { return `无法创建目录 ${m}` }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
roots = clean
|
|
224
|
+
return true
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// ── token:hub 生成、worker 粘贴 ───────────────────────────
|
|
229
|
+
let hubToken = ''
|
|
230
|
+
let pairPrefill = null // 配对码解析结果(worker 路径用)
|
|
231
|
+
let pairRaw = '' // 用户粘贴的原始配对码串
|
|
232
|
+
if (isHub) {
|
|
233
|
+
hubToken = newNodeToken()
|
|
234
|
+
hint(`③ 已为你生成副机入网口令(nodeToken,粘到副机配置里):`)
|
|
235
|
+
say(` ${hubToken}`)
|
|
236
|
+
say('')
|
|
237
|
+
} else {
|
|
238
|
+
const tok = await ask(rl, '③ 主机的 nodeToken,或直接粘贴主机给的整行配对码(dshra1:// 开头)', {
|
|
239
|
+
def: '',
|
|
240
|
+
})
|
|
241
|
+
if (tok.toLowerCase().startsWith('dshra1://')) {
|
|
242
|
+
const dec = decodePairCode(tok)
|
|
243
|
+
if (!dec.ok) {
|
|
244
|
+
say(` ✗ ${dec.error}`)
|
|
245
|
+
hint(' 下面继续按手动方式配置。')
|
|
246
|
+
} else {
|
|
247
|
+
pairPrefill = dec.payload
|
|
248
|
+
pairRaw = tok
|
|
249
|
+
hubToken = dec.payload.token ?? ''
|
|
250
|
+
hint('✓ 配对码已识别 —— 地址与 token 自动填好,后面几步直接回车即可')
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
hubToken = tok
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let workerYaml = ''
|
|
258
|
+
if (isHub) {
|
|
259
|
+
// ── hub:IP 选择 ────────────────────────────────────────
|
|
260
|
+
const ips = detectLanIps()
|
|
261
|
+
say('')
|
|
262
|
+
hint('本机局域网地址(副机要连的就是它):')
|
|
263
|
+
ips.forEach((ip, i) => say(` [${i + 1}] ${ip.address} (${ip.name}${ip.private ? '' : ', 公网'})`))
|
|
264
|
+
const ipPick = await ask(rl, '④ 副机连哪个地址?', {
|
|
265
|
+
def: ips[0]?.address ?? '',
|
|
266
|
+
validate: (v) => (ips.some((i) => i.address === v) || /^[\w.-]+$/.test(v) || '请输入列表中的地址或合法主机名'),
|
|
267
|
+
})
|
|
268
|
+
const port = await ask(rl, '⑤ 端口', {
|
|
269
|
+
def: '19387',
|
|
270
|
+
validate: (v) => (/^\d+$/.test(v) && +v > 0 && +v < 65536 ? true : '1-65535 的数字'),
|
|
271
|
+
})
|
|
272
|
+
const nodeId = await ask(rl, '⑥ 这台主机的节点 id 留空即可;副机的节点 id(如 slave-pc)', { def: '' })
|
|
273
|
+
const hubUrl = buildHubUrl(ipPick, +port)
|
|
274
|
+
const workerNodeId = nodeId !== '' ? nodeId : 'slave-pc'
|
|
275
|
+
const hubFingerprint = hubFingerprintForPairCode()
|
|
276
|
+
const pairCode = encodePairCode({ hubUrl, token: hubToken, nodeId: workerNodeId, hubFingerprint })
|
|
277
|
+
|
|
278
|
+
const hubYaml = buildHubYaml({ token: hubToken, roots })
|
|
279
|
+
printYaml('主机(hub):粘到 DSH 的 cordis 配置里', hubYaml)
|
|
280
|
+
await offerWrite(rl, values, hubYaml, '主机(hub)')
|
|
281
|
+
// 配对码:最不容易抄错的一条路 —— 一行代替 hubUrl+token+transportMode。
|
|
282
|
+
say('')
|
|
283
|
+
say(`${BOLD}=== 副机(worker)配对码 —— 首选:整行复制发给副机 ===${RESET}`)
|
|
284
|
+
say(` ${pairCode}`)
|
|
285
|
+
hint(' (内含 token,等同凭证:当面或加密聊天传,别贴公开群里)')
|
|
286
|
+
if (hubFingerprint !== '') {
|
|
287
|
+
hint(` (已带 hub 指纹 ${hubFingerprint} —— 副机粘码即完成身份钉住,假 hub 冒充会被当场拒绝)`)
|
|
288
|
+
}
|
|
289
|
+
say('')
|
|
290
|
+
// 显式 YAML 作为备份/排查用(配对码展开后的形态)
|
|
291
|
+
workerYaml = buildWorkerYaml({
|
|
292
|
+
hubUrl,
|
|
293
|
+
token: hubToken,
|
|
294
|
+
nodeId: workerNodeId,
|
|
295
|
+
roots,
|
|
296
|
+
pinnedHubFingerprint: hubFingerprint,
|
|
297
|
+
})
|
|
298
|
+
printYaml('副机(worker)显式配置 —— 备用:不想用配对码就抄这一段(inject: [] 已替你填好)', workerYaml)
|
|
299
|
+
say('')
|
|
300
|
+
hint(`最后一步(Windows 主机):放行防火墙端口 ${port} ——`)
|
|
301
|
+
hint(` netsh advfirewall firewall add rule name="dsh-remote-agent" dir=in action=allow protocol=TCP localport=${port}`)
|
|
302
|
+
} else {
|
|
303
|
+
// ── worker:hubUrl ──────────────────────────────────────
|
|
304
|
+
const hubUrl = await ask(rl, '④ 主机地址(形如 ws://192.168.1.10:19387/api/remote-agent/node,或只给 IP:端口)', {
|
|
305
|
+
def: pairPrefill?.hubUrl ?? '',
|
|
306
|
+
validate: (v) => {
|
|
307
|
+
if (v === '') return '不能为空'
|
|
308
|
+
const norm = buildHubUrl(v, 19387)
|
|
309
|
+
return (norm !== '' ? true : '解析不了,示例:192.168.1.10:19387 或完整 ws:// 地址')
|
|
310
|
+
},
|
|
311
|
+
})
|
|
312
|
+
const nodeId = await ask(rl, '⑤ 本机节点 id(留空用主机名)', { def: pairPrefill?.nodeId ?? '' })
|
|
313
|
+
const finalUrl = buildHubUrl(hubUrl, 19387)
|
|
314
|
+
const u = new URL(finalUrl)
|
|
315
|
+
hint(`⑥ 探活 ${u.host} …`)
|
|
316
|
+
const r = await probeTcp(u.hostname, u.port || 80)
|
|
317
|
+
if (r.ok) say(` ✓ 端口可达(${r.ms}ms)`)
|
|
318
|
+
else say(` ✗ ${r.error}`)
|
|
319
|
+
say('')
|
|
320
|
+
|
|
321
|
+
// 配对码路径:字段没被手动改动时,直接输出 pair 一行版(最少可抄错的内容)
|
|
322
|
+
const usedPair = pairPrefill
|
|
323
|
+
&& finalUrl === buildHubUrl(pairPrefill.hubUrl, 19387)
|
|
324
|
+
&& nodeId === (pairPrefill.nodeId ?? '')
|
|
325
|
+
&& hubToken === (pairPrefill.token ?? '')
|
|
326
|
+
workerYaml = usedPair
|
|
327
|
+
? buildWorkerYaml({ pair: pairRaw, nodeId, roots })
|
|
328
|
+
: buildWorkerYaml({ hubUrl: finalUrl, token: hubToken, nodeId, roots, pinnedHubFingerprint: pairPrefill?.hubFingerprint ?? '' })
|
|
329
|
+
printYaml('副机(worker):粘到这台机器 DSH 的 cordis 配置里', workerYaml)
|
|
330
|
+
await offerWrite(rl, values, workerYaml, '副机(worker)')
|
|
331
|
+
if (usedPair) {
|
|
332
|
+
hint('(pair 一行在运行时自动展开成 hubUrl/token,显式配置优先 —— 改地址时写 hubUrl 即可覆盖)')
|
|
333
|
+
}
|
|
334
|
+
if (!r.ok) {
|
|
335
|
+
say('')
|
|
336
|
+
hint('主机端口当前不可达 —— 先在主机上跑好并放行防火墙,再重启副机 DSH。')
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
say('')
|
|
341
|
+
hint('配置写好后重启 DSH,对主机说「副机上有哪些节点在线?」验证连通。')
|
|
342
|
+
hint('出问题时用 --doctor 体检;详见 README「五分钟上手」。')
|
|
343
|
+
} finally {
|
|
344
|
+
rl.close()
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 读 hub 身份指纹用于配对码(P2-11)。
|
|
350
|
+
*
|
|
351
|
+
* 读不到(hub 还没在机上启动过 / 文件损坏)就返回 '' —— 配对码照常出,
|
|
352
|
+
* 只是少带指纹;绝不阻塞出码。注意:hub 配置了自定义 stateDir 时这里读的
|
|
353
|
+
* 是默认目录(~/.dsh-remote-agent),可能错过 —— 高级用户请显式写
|
|
354
|
+
* pinnedHubFingerprint,向导只覆盖常见路径。
|
|
355
|
+
*/
|
|
356
|
+
function hubFingerprintForPairCode() {
|
|
357
|
+
try {
|
|
358
|
+
const id = readHubIdentity(path.join(os.homedir(), '.dsh-remote-agent', IDENTITY_FILE_NAME))
|
|
359
|
+
return id?.fingerprint ?? ''
|
|
360
|
+
} catch {
|
|
361
|
+
return ''
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* ══════════════════ 非交互生成 ══════════════════ */
|
|
366
|
+
|
|
367
|
+
function generateNonInteractive(values) {
|
|
368
|
+
const role = String(values.role ?? '').toLowerCase()
|
|
369
|
+
if (role !== 'hub' && role !== 'worker') {
|
|
370
|
+
say('--role 必须是 hub 或 worker')
|
|
371
|
+
process.exit(2)
|
|
372
|
+
}
|
|
373
|
+
const roots = normalizeRoots(splitRoots(values.roots)).clean
|
|
374
|
+
const token = String(values.token ?? '')
|
|
375
|
+
const profile = String(values.profile ?? '').trim() || 'desktop'
|
|
376
|
+
const writeFile = patchFilePath(profile)
|
|
377
|
+
if (role === 'hub') {
|
|
378
|
+
const hubYaml = buildHubYaml({ token, roots })
|
|
379
|
+
printYaml('hub 配置', hubYaml, values.out)
|
|
380
|
+
// 给了 --hub-ip(可选 --port)就顺手生成配对码 —— 副机一行 pair 即可接入
|
|
381
|
+
if (values['hub-ip']) {
|
|
382
|
+
const port = /^\d+$/.test(String(values.port ?? '')) ? +values.port : 19387
|
|
383
|
+
const hubUrl = buildHubUrl(values['hub-ip'], port)
|
|
384
|
+
const pairCode = encodePairCode({ hubUrl, token, nodeId: String(values['node-id'] ?? ''), hubFingerprint: hubFingerprintForPairCode() })
|
|
385
|
+
if (pairCode !== '') {
|
|
386
|
+
say('')
|
|
387
|
+
say('副机配对码(整行粘到副机配置的 pair: 字段,或用 --role worker --pair 生成配置):')
|
|
388
|
+
say(` ${pairCode}`)
|
|
389
|
+
say(' ⚠ 内含 token,等同凭证,勿贴公开渠道')
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (values.write) {
|
|
393
|
+
if (!applyWrite({ file: writeFile, blockYaml: hubYaml, force: values.force === true })) process.exit(2)
|
|
394
|
+
}
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
const nodeId = String(values['node-id'] ?? '')
|
|
398
|
+
// --pair:配对码直接展开成 worker 配置(最省事的非交互路径)
|
|
399
|
+
if (values.pair) {
|
|
400
|
+
const dec = decodePairCode(values.pair)
|
|
401
|
+
if (!dec.ok) {
|
|
402
|
+
say(`配对码无效:${dec.error}`)
|
|
403
|
+
process.exit(2)
|
|
404
|
+
}
|
|
405
|
+
const workerYaml = buildWorkerYaml({ pair: values.pair, nodeId, roots })
|
|
406
|
+
printYaml('worker 配置(来自配对码)', workerYaml, values.out)
|
|
407
|
+
if (values.write) {
|
|
408
|
+
if (!applyWrite({ file: writeFile, blockYaml: workerYaml, force: values.force === true })) process.exit(2)
|
|
409
|
+
}
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
const hubUrl = buildHubUrl(values['hub-url'] ?? '', 19387)
|
|
413
|
+
if (hubUrl === '') {
|
|
414
|
+
say('--hub-url 必填,形如 192.168.1.10:19387 或完整 ws:// 地址(或改用 --pair 配对码)')
|
|
415
|
+
process.exit(2)
|
|
416
|
+
}
|
|
417
|
+
const workerYaml = buildWorkerYaml({ hubUrl, token, nodeId, roots })
|
|
418
|
+
printYaml('worker 配置', workerYaml, values.out)
|
|
419
|
+
if (values.write) {
|
|
420
|
+
if (!applyWrite({ file: writeFile, blockYaml: workerYaml, force: values.force === true })) process.exit(2)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* ══════════════════ 入口 ══════════════════ */
|
|
425
|
+
|
|
426
|
+
const { values, positionals } = parseCliArgs()
|
|
427
|
+
if (values.help) {
|
|
428
|
+
say('dsh-remote-setup —— dsh-remote-agent 配置向导(交互式 / 非交互 / 体检)')
|
|
429
|
+
say(' node bin/dsh-remote-setup.js 交互式生成两台的配置')
|
|
430
|
+
say(' node bin/dsh-remote-setup.js --doctor <配置.yml> 体检现有配置')
|
|
431
|
+
say(' --role hub --hub-ip 192.168.1.10 --token nt_xxx --roots a,b 非交互生成主机配置(附配对码)')
|
|
432
|
+
say(' --role worker --pair dshra1://... --roots a,b 非交互:用配对码生成副机配置')
|
|
433
|
+
say(' --role worker --hub-url --token --node-id --roots a,b 非交互:手动生成副机配置')
|
|
434
|
+
say(' 写盘(免手粘,自动备份原配置):交互式回车确认,或非交互加 --write')
|
|
435
|
+
say(' --profile desktop 指定 dsh profile(默认 desktop) --force 覆盖手写条目')
|
|
436
|
+
process.exit(0)
|
|
437
|
+
}
|
|
438
|
+
if (values.doctor) await runDoctor(positionals)
|
|
439
|
+
else if (values.role) generateNonInteractive(values)
|
|
440
|
+
else await wizard()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
rem ============================================================
|
|
3
|
+
rem dsh-remote-agent setup launcher (double-click entry)
|
|
4
|
+
rem Content is ASCII-only on purpose: cmd.exe parses batch files
|
|
5
|
+
rem in the OEM codepage, non-ASCII text here WILL be garbled.
|
|
6
|
+
rem The Chinese file name is fine (NTFS is Unicode); keep the
|
|
7
|
+
rem file CONTENT ASCII.
|
|
8
|
+
rem ============================================================
|
|
9
|
+
setlocal
|
|
10
|
+
cd /d "%~dp0.."
|
|
11
|
+
|
|
12
|
+
where node >nul 2>nul
|
|
13
|
+
if errorlevel 1 (
|
|
14
|
+
echo [ERROR] "node" not found in PATH.
|
|
15
|
+
echo Please install Node.js 22+ first: https://nodejs.org
|
|
16
|
+
echo.
|
|
17
|
+
pause
|
|
18
|
+
exit /b 1
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
echo Starting dsh-remote-agent setup wizard ...
|
|
22
|
+
echo (Answer the questions in this window. Enter = default.)
|
|
23
|
+
echo.
|
|
24
|
+
node bin\dsh-remote-setup.js %*
|
|
25
|
+
|
|
26
|
+
echo.
|
|
27
|
+
echo Done. You can close this window.
|
|
28
|
+
pause
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# dsh-remote-agent bundle patch(package.json 的 dsh.bundle.patch 指向本文件)。
|
|
2
|
+
#
|
|
3
|
+
# 作用:让 `dsh plugin add dsh-remote-agent` / plugin_manager 的 install_bundle
|
|
4
|
+
# 能把插件行插入 loader。没有这个文件,loader 读不到 patch,插件行永不插入,
|
|
5
|
+
# remote_* 工具永不注册 —— 这是 v0.1 的一个真实 blocker(由 t5 独立验证发现)。
|
|
6
|
+
#
|
|
7
|
+
# ── 关于 role 的默认值 ──────────────────────────────────────────────
|
|
8
|
+
# 一个包只有一个 dsh.bundle.patch,但 hub 与 worker 需要不同的 config。
|
|
9
|
+
# 所以这里插入一个**中性默认**(role: hub,不做任何连接),然后由用户在自己
|
|
10
|
+
# profile 的 cordis.patch.yml 里按 id 覆盖成需要的角色与连接参数:
|
|
11
|
+
#
|
|
12
|
+
# 主电脑(hub):
|
|
13
|
+
# - id: remote-agent
|
|
14
|
+
# config:
|
|
15
|
+
# role: hub
|
|
16
|
+
# relay:
|
|
17
|
+
# url: ws://RELAY_HOST:8890/relay
|
|
18
|
+
# token: <relay hub token>
|
|
19
|
+
#
|
|
20
|
+
# 副电脑(worker):
|
|
21
|
+
# - id: remote-agent
|
|
22
|
+
# config:
|
|
23
|
+
# role: worker
|
|
24
|
+
# transportMode: relay
|
|
25
|
+
# relayUrl: ws://RELAY_HOST:8890/relay
|
|
26
|
+
# token: <enroll token>
|
|
27
|
+
# roots: ['D:\work']
|
|
28
|
+
# nodeId: 'slave-pc'
|
|
29
|
+
#
|
|
30
|
+
# 完整的可复制片段见 profiles/hub.cordis.yml 与 profiles/worker.cordis.yml。
|
|
31
|
+
#
|
|
32
|
+
# ── 为什么默认 role: hub 而不是 worker ──────────────────────────────
|
|
33
|
+
# hub 角色在没有任何配置时是**惰性**的:它注册路由与工具,但不主动拨出任何连接,
|
|
34
|
+
# 也不写磁盘。worker 角色默认会尝试连 relay 并在连不上时退避重试(虽然不阻塞启动),
|
|
35
|
+
# 对一个"刚装好还没配置"的 profile 来说噪声更大。所以默认取 hub。
|
|
36
|
+
#
|
|
37
|
+
# 关闭本插件:给该行加 disabled: true,而不是删掉这个文件。
|
|
38
|
+
#
|
|
39
|
+
# ── 为什么这里要声明 inject(重要,别删)────────────────────────────
|
|
40
|
+
# cordis 的防护:**没有声明 inject 就不能访问服务** —— 直接读 `ctx.webServer`
|
|
41
|
+
# 会抛 `cannot get property "webServer" without inject`(实测)。
|
|
42
|
+
#
|
|
43
|
+
# 而"运行时用 ctx.inject(deps, cb) 拿服务"这条路走不通:inject 返回的是 thenable
|
|
44
|
+
# fiber,要 await 才算加载完成;而插件的 apply 是同步的,父 fiber 同步返回后框架
|
|
45
|
+
# 会清理尚未 settle 的子 fiber —— 实测注册成功 13ms 后出现
|
|
46
|
+
# `DISPOSED (fiber unloaded)`,路由被删且不再回来(面板 404/401,启动日志无异常)。
|
|
47
|
+
#
|
|
48
|
+
# 所以在**行上**声明 inject:框架会**推迟本行的激活**直到这些服务就绪,只 apply
|
|
49
|
+
# 一次、没有子 fiber 抖动。这是唯一被验证可用的形态。
|
|
50
|
+
#
|
|
51
|
+
# ── 为什么 inject 是 hub 的三个服务(2026-10-15 二次修订)──────────────
|
|
52
|
+
#
|
|
53
|
+
# `inject` 必须**跟着角色走**:
|
|
54
|
+
#
|
|
55
|
+
# role: ''(未配置)→ 需要 webServer(注册引导路由)
|
|
56
|
+
# role: hub → **需要 webServer + tools + systemPrompt**
|
|
57
|
+
# role: worker → 需要 tools + agentLoop + sessions
|
|
58
|
+
# (agent 的 ctx **继承本行的依赖声明**,少了 tools 就
|
|
59
|
+
# 挂不上 shell/文件工具 —— 实测踩过)
|
|
60
|
+
#
|
|
61
|
+
# ── 一次真实的翻车(我把它收窄成 ['webServer'] 导致的)──────────────────
|
|
62
|
+
#
|
|
63
|
+
# 曾把这里改成 `inject: ['webServer']`,理由"未配置时只需要 webServer"。
|
|
64
|
+
# 结果:**profile 里没写 inject 的机器(旧配置 / 手写配置)合并后就是
|
|
65
|
+
# `['webServer']`,hub 分支一访问 `ctx.tools` 就抛**
|
|
66
|
+
#
|
|
67
|
+
# cannot get property "tools" without inject
|
|
68
|
+
#
|
|
69
|
+
# 而 `applyHub` 里工具注册在路由注册**之后** —— 于是现象是:
|
|
70
|
+
# · /api/remote-agent/healthz 活着(200,还带着 nodes/online 计数)
|
|
71
|
+
# · /api/remote-agent/ui/* 活着
|
|
72
|
+
# · **8 个 remote_* 工具全部消失**
|
|
73
|
+
# · 插件管理页显示「异常」(fiber phase: failed)
|
|
74
|
+
#
|
|
75
|
+
# 一半活一半死,极难归因。
|
|
76
|
+
#
|
|
77
|
+
# ── 结论 ────────────────────────────────────────────────────────────────
|
|
78
|
+
#
|
|
79
|
+
# 包内默认取 **hub 的全集**:这是"没被 profile 覆盖时"最安全的默认 ——
|
|
80
|
+
# desktop profile 三个服务都在,未配置时多声明两个无害(只在 hub 分支被访问);
|
|
81
|
+
# 而选了 worker 的用户,引导流程会把 inject 一并改写成 worker 的那三个。
|
|
82
|
+
- insert:
|
|
83
|
+
- id: remote-agent
|
|
84
|
+
name: dsh-remote-agent
|
|
85
|
+
inject: ['webServer', 'tools', 'systemPrompt']
|
|
86
|
+
config:
|
|
87
|
+
role: ''
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# dsh-remote-relay 的 TLS 前置模板(Caddy v2)
|
|
2
|
+
#
|
|
3
|
+
# 安装:sudo cp Caddyfile /etc/caddy/Caddyfile && sudo systemctl reload caddy
|
|
4
|
+
# 前提:域名 DNS 已指向本机,80/443 未被占用(Caddy 用 ACME 自动签证书)。
|
|
5
|
+
#
|
|
6
|
+
# Caddy 自动处理 WebSocket 升级(Upgrade: websocket 检测是内置的),
|
|
7
|
+
# 所以 /relay 的 ws 升级与 /healthz、/api/relay/* 的普通 HTTP 用同一段
|
|
8
|
+
# reverse_proxy 即可,不需要为 WS 单写规则。
|
|
9
|
+
|
|
10
|
+
relay.example.com {
|
|
11
|
+
# 中继不返回 HTML,压缩只省流量;不需要可整行删掉
|
|
12
|
+
encode zstd gzip
|
|
13
|
+
|
|
14
|
+
reverse_proxy 127.0.0.1:8890
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# ── 配套要求 ──────────────────────────────────────────────────────────
|
|
18
|
+
# 1. 中继本体只听回环:/etc/dsh-remote-relay/env 里 DSH_RELAY_HOST=127.0.0.1
|
|
19
|
+
# (relay.env.example 模板默认即此值;公网不再直接暴露 8890)
|
|
20
|
+
# 2. 云安全组只放 80/443,不放 8890
|
|
21
|
+
# 3. 两端配置换成 wss:
|
|
22
|
+
# hub: relay.url = wss://relay.example.com/relay
|
|
23
|
+
# node: relayUrl = wss://relay.example.com/relay
|
|
24
|
+
# token 不变。端口 443 省略不写。
|