dsh-plugin-remote-connect-beta 0.1.0-beta.1
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 +115 -0
- package/CONTRIBUTING.md +50 -0
- package/LICENSE +21 -0
- package/README.md +279 -0
- package/README.zh.md +298 -0
- package/SECURITY.md +41 -0
- package/bin/dsh-remote.js +768 -0
- package/docs/client-preview.png +0 -0
- package/docs/market-submission.md +82 -0
- package/docs/multi-tenant.md +136 -0
- package/docs/multi-tenant.zh.md +127 -0
- package/docs/self-host.md +398 -0
- package/docs/self-host.zh.md +369 -0
- package/docs/tenants-preview.png +0 -0
- package/lib/client.js +1120 -0
- package/lib/core/assets/server-setup.sh.tpl +344 -0
- package/lib/core/credential.js +123 -0
- package/lib/core/instance.js +360 -0
- package/lib/core/messages.js +549 -0
- package/lib/core/paths.js +59 -0
- package/lib/core/preflight.js +410 -0
- package/lib/core/proxy.js +924 -0
- package/lib/core/serversetup.js +133 -0
- package/lib/core/snippets.js +193 -0
- package/lib/core/tailscale.js +158 -0
- package/lib/core/tenancy.js +267 -0
- package/lib/core/tenant.js +272 -0
- package/lib/core/tunnel.js +320 -0
- package/lib/index.js +1097 -0
- package/package.json +76 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 租户实例看护:为每个租户拉起一个**独立** Harness 进程(独立 DSH_HOME、独立端口、独立令牌)。
|
|
3
|
+
*
|
|
4
|
+
* 隔离靠进程边界 + home 边界,不靠约定:
|
|
5
|
+
* - `DSH_HOME=<租户目录>`:会话、凭据、设置、storages 全在租户自己的目录里;
|
|
6
|
+
* - `--host 127.0.0.1 --port <p>`:只监听回环,公网/局域网只能经网关进来;
|
|
7
|
+
* - 启动令牌由子进程 **stdout** 打印(`dsh web: http://127.0.0.1:<port>/?token=…`),
|
|
8
|
+
* 每个实例只认自己的令牌 —— 实测拿 A 的令牌访问 B 的端口是 401。
|
|
9
|
+
*
|
|
10
|
+
* 启动方式与 DSH Desktop 自己拉起 Harness 的方式保持一致(见桌面应用 main 进程):
|
|
11
|
+
*
|
|
12
|
+
* <runtime> --expose-internals <dsh 入口> --profile <id> [--from-default-profile web] \
|
|
13
|
+
* --no-open --host 127.0.0.1 --port <p>
|
|
14
|
+
*
|
|
15
|
+
* `--from-default-profile web` 只在租户 home 里还没有该 profile 时加(首次启动时初始化)。
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-plugin-remote-connect-beta/core/instance
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import os from 'node:os'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
import { spawn } from 'node:child_process'
|
|
23
|
+
import { translator } from './messages.js'
|
|
24
|
+
|
|
25
|
+
const tEn = translator('en')
|
|
26
|
+
|
|
27
|
+
/** 子进程 stdout 里的就绪行:`dsh web: http://127.0.0.1:8791/?token=…` */
|
|
28
|
+
const READY_PATTERN = /dsh web:\s*(http:\/\/[^\s]*[?&]token=([A-Za-z0-9_-]+))/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 找 DSH 的 CLI 入口(`@deepseek-ai/dsh/lib/bin.js`)。
|
|
32
|
+
*
|
|
33
|
+
* 顺序:显式配置 → 环境变量 → 桌面版自带 → 常见全局安装位置。
|
|
34
|
+
* 找不到就返回 null 并给出候选,让面板/CLI 能说人话,而不是抛一个 ENOENT。
|
|
35
|
+
*
|
|
36
|
+
* @param {object} [options]
|
|
37
|
+
* @param {string} [options.configured] 配置里显式给的路径
|
|
38
|
+
* @param {string} [options.env=process.env.DSH_HARNESS_BIN]
|
|
39
|
+
* @param {string} [options.appPath] DSH Desktop 安装路径(可覆盖,便于测试)
|
|
40
|
+
* @returns {{ bin: string, source: string } | null}
|
|
41
|
+
*/
|
|
42
|
+
export function discoverHarnessBin(options = {}) {
|
|
43
|
+
const candidates = []
|
|
44
|
+
const configured = typeof options.configured === 'string' ? options.configured.trim() : ''
|
|
45
|
+
if (configured !== '') candidates.push({ bin: configured, source: 'config' })
|
|
46
|
+
const fromEnv = typeof options.env === 'string' ? options.env.trim() : ''
|
|
47
|
+
if (fromEnv !== '') candidates.push({ bin: fromEnv, source: 'env' })
|
|
48
|
+
const appPath = options.appPath ?? '/Applications/DSH Desktop.app/Contents/Resources/app'
|
|
49
|
+
candidates.push({
|
|
50
|
+
bin: path.join(appPath, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'),
|
|
51
|
+
source: 'desktop-app',
|
|
52
|
+
})
|
|
53
|
+
candidates.push({ bin: '/usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js', source: 'npm-global' })
|
|
54
|
+
candidates.push({ bin: '/opt/homebrew/lib/node_modules/@deepseek-ai/dsh/lib/bin.js', source: 'npm-global' })
|
|
55
|
+
|
|
56
|
+
for (const candidate of candidates) {
|
|
57
|
+
try {
|
|
58
|
+
if (fs.existsSync(candidate.bin)) return candidate
|
|
59
|
+
} catch {
|
|
60
|
+
/* 继续找下一个 */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 找真正能跑 Harness 的 runtime。
|
|
68
|
+
*
|
|
69
|
+
* ⚠️ 关键坑:DSH Desktop 会把自己的 `node` shim(`ELECTRON_RUN_AS_NODE=1` + Electron Helper)
|
|
70
|
+
* 放进 PATH。它是 Electron,会**拒绝** `NODE_OPTIONS` 环境变量;用它跑 Harness 必须把
|
|
71
|
+
* `--expose-internals` 当**命令行参数**传(桌面应用自己就是这么做的)。所以这里优先找真 node,
|
|
72
|
+
* shim 只作为最后的兜底。
|
|
73
|
+
*
|
|
74
|
+
* @param {object} [options]
|
|
75
|
+
* @param {string} [options.configured]
|
|
76
|
+
* @param {NodeJS.ProcessEnv} [options.env=process.env]
|
|
77
|
+
* @returns {{ node: string, source: string, electron: boolean }}
|
|
78
|
+
*/
|
|
79
|
+
export function discoverRuntime(options = {}) {
|
|
80
|
+
const env = options.env ?? process.env
|
|
81
|
+
const configured = typeof options.configured === 'string' ? options.configured.trim() : ''
|
|
82
|
+
const pick = (value, source) => ({ node: value, source, electron: /Helper|Electron/i.test(value) })
|
|
83
|
+
if (configured !== '') return pick(configured, 'config')
|
|
84
|
+
if (typeof env.DSH_TENANT_NODE === 'string' && env.DSH_TENANT_NODE.trim() !== '') {
|
|
85
|
+
return pick(env.DSH_TENANT_NODE.trim(), 'env')
|
|
86
|
+
}
|
|
87
|
+
for (const candidate of ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node']) {
|
|
88
|
+
try {
|
|
89
|
+
if (fs.existsSync(candidate)) return pick(candidate, 'path')
|
|
90
|
+
} catch {
|
|
91
|
+
/* 继续 */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// 兜底:本进程自己(若宿主是 Electron,则要用命令行参数传 --expose-internals)
|
|
95
|
+
const self = process.execPath
|
|
96
|
+
const shim = path.join(env.HOME ?? os.homedir(), 'Library', 'Application Support', 'dsh-desktop', 'harness', '.desktop-bin', 'node')
|
|
97
|
+
if (/Helper|Electron/i.test(self)) return pick(self, 'self')
|
|
98
|
+
try {
|
|
99
|
+
if (fs.existsSync(shim)) return pick(shim, 'desktop-shim')
|
|
100
|
+
} catch {
|
|
101
|
+
/* 忽略 */
|
|
102
|
+
}
|
|
103
|
+
return pick(self, 'self')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 组装启动参数(纯函数,便于测试)。
|
|
108
|
+
* @param {object} options
|
|
109
|
+
* @param {string} options.bin dsh 入口
|
|
110
|
+
* @param {string} options.profile profile 名(= 租户 id)
|
|
111
|
+
* @param {number} options.port 0 = 让系统分配
|
|
112
|
+
* @param {boolean} options.initialize 是否加 `--from-default-profile web`
|
|
113
|
+
* @param {boolean} [options.electron] runtime 是不是 Electron Helper
|
|
114
|
+
* @param {string[]} [options.extraArgs]
|
|
115
|
+
*/
|
|
116
|
+
export function buildInstanceArgv(options) {
|
|
117
|
+
const argv = []
|
|
118
|
+
// Electron Helper 必须拿到这个参数(NODE_OPTIONS 会被它拒绝);真 node 给上也无害
|
|
119
|
+
if (options.electron !== false) argv.push('--expose-internals')
|
|
120
|
+
argv.push(options.bin)
|
|
121
|
+
argv.push('--profile', options.profile)
|
|
122
|
+
if (options.initialize) argv.push('--from-default-profile', 'web')
|
|
123
|
+
argv.push('--no-open', '--host', '127.0.0.1', '--port', String(options.port))
|
|
124
|
+
for (const extra of options.extraArgs ?? []) argv.push(extra)
|
|
125
|
+
return argv
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 从一行 stdout 里取就绪地址与令牌。 */
|
|
129
|
+
export function parseReadyLine(text) {
|
|
130
|
+
const match = READY_PATTERN.exec(String(text ?? ''))
|
|
131
|
+
if (match === null) return null
|
|
132
|
+
let port = 0
|
|
133
|
+
try {
|
|
134
|
+
port = Number(new URL(match[1]).port)
|
|
135
|
+
} catch {
|
|
136
|
+
port = 0
|
|
137
|
+
}
|
|
138
|
+
return { url: match[1], token: match[2], port: Number.isSafeInteger(port) ? port : 0 }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 一个租户实例。
|
|
143
|
+
*
|
|
144
|
+
* @param {object} options
|
|
145
|
+
* @param {object} options.tenant 归一化后的租户定义
|
|
146
|
+
* @param {{ bin: string, node: string, electron?: boolean, extraArgs?: string[] }} options.harness
|
|
147
|
+
* @param {number} [options.restartBaseMs=1000] 退避基数
|
|
148
|
+
* @param {number} [options.maxRestarts=5] 连续崩溃上限,超过则停手(等人处理)
|
|
149
|
+
* @param {(state: object) => void} [options.onState]
|
|
150
|
+
* @param {(line: string) => void} [options.log]
|
|
151
|
+
* @param {string} [options.logFile] 实例输出日志(默认 `<home>/instance.log`)。
|
|
152
|
+
* 写它的原因:租户起不来时运维需要一个能看的地方,e2e 也要能从里面取启动令牌。
|
|
153
|
+
* @param {typeof spawn} [options.spawnImpl] 测试用
|
|
154
|
+
*/
|
|
155
|
+
export function createInstance(options) {
|
|
156
|
+
const tenant = options.tenant
|
|
157
|
+
const harness = options.harness
|
|
158
|
+
const spawnImpl = options.spawnImpl ?? spawn
|
|
159
|
+
const log = options.log ?? (() => {})
|
|
160
|
+
const onState = options.onState ?? (() => {})
|
|
161
|
+
const restartBaseMs = options.restartBaseMs ?? 1000
|
|
162
|
+
const maxRestarts = options.maxRestarts ?? 5
|
|
163
|
+
const logFile = options.logFile ?? path.join(tenant.home, 'instance.log')
|
|
164
|
+
const logLimitBytes = options.logLimitBytes ?? 5 * 1024 * 1024
|
|
165
|
+
|
|
166
|
+
let child = null
|
|
167
|
+
let token = ''
|
|
168
|
+
let port = tenant.port
|
|
169
|
+
let stopped = false
|
|
170
|
+
let restarts = 0
|
|
171
|
+
let restartTimer = null
|
|
172
|
+
let state = { id: tenant.id, phase: 'idle', code: 'tenant.idle', params: {}, detail: tEn('tenant.idle'), port, hasToken: false, restarts: 0, pid: null }
|
|
173
|
+
|
|
174
|
+
function setState(patch) {
|
|
175
|
+
const next = { ...state, ...patch }
|
|
176
|
+
if (patch.code !== undefined && patch.detail === undefined) next.detail = tEn(patch.code, patch.params)
|
|
177
|
+
state = next
|
|
178
|
+
onState(state)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function profileExists() {
|
|
182
|
+
try {
|
|
183
|
+
return fs.existsSync(path.join(tenant.home, 'profiles', tenant.profile))
|
|
184
|
+
} catch {
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 追加一行到实例日志;超过上限就滚动一次,避免无限长大。 */
|
|
190
|
+
function appendLog(text) {
|
|
191
|
+
try {
|
|
192
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true, mode: 0o700 })
|
|
193
|
+
try {
|
|
194
|
+
if (fs.statSync(logFile).size > logLimitBytes) fs.renameSync(logFile, logFile + '.1')
|
|
195
|
+
} catch {
|
|
196
|
+
/* 不存在就无所谓 */
|
|
197
|
+
}
|
|
198
|
+
fs.appendFileSync(logFile, text, { mode: 0o600 })
|
|
199
|
+
} catch {
|
|
200
|
+
/* 日志写不进去不影响实例本身 */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function launch() {
|
|
205
|
+
if (stopped) return
|
|
206
|
+
fs.mkdirSync(tenant.home, { recursive: true, mode: 0o700 })
|
|
207
|
+
const initialize = !profileExists()
|
|
208
|
+
const argv = buildInstanceArgv({
|
|
209
|
+
bin: harness.bin,
|
|
210
|
+
profile: tenant.profile,
|
|
211
|
+
port: tenant.port,
|
|
212
|
+
initialize,
|
|
213
|
+
electron: harness.electron !== false,
|
|
214
|
+
extraArgs: harness.extraArgs,
|
|
215
|
+
})
|
|
216
|
+
setState({
|
|
217
|
+
phase: 'starting',
|
|
218
|
+
code: 'tenant.starting',
|
|
219
|
+
params: { id: tenant.id, port: String(tenant.port) },
|
|
220
|
+
pid: null,
|
|
221
|
+
hasToken: false,
|
|
222
|
+
})
|
|
223
|
+
appendLog('# launch ' + new Date().toISOString() + ' ' + JSON.stringify(argv) + '\n')
|
|
224
|
+
let spawned
|
|
225
|
+
try {
|
|
226
|
+
spawned = spawnImpl(harness.node, argv, {
|
|
227
|
+
cwd: tenant.home,
|
|
228
|
+
env: { ...process.env, DSH_HOME: tenant.home, NO_COLOR: '1' },
|
|
229
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
230
|
+
})
|
|
231
|
+
} catch (error) {
|
|
232
|
+
scheduleRestart(tEn('tenant.spawnFailed', { message: String(error?.message ?? error) }))
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
child = spawned
|
|
236
|
+
setState({ pid: spawned.pid ?? null })
|
|
237
|
+
let stderr = ''
|
|
238
|
+
const onText = (text, fromStderr) => {
|
|
239
|
+
const ready = parseReadyLine(text)
|
|
240
|
+
if (ready === null) return
|
|
241
|
+
token = ready.token
|
|
242
|
+
if (ready.port > 0) port = ready.port
|
|
243
|
+
setState({
|
|
244
|
+
phase: 'up',
|
|
245
|
+
code: 'tenant.up',
|
|
246
|
+
params: { id: tenant.id, port: String(port) },
|
|
247
|
+
port,
|
|
248
|
+
hasToken: true,
|
|
249
|
+
pid: spawned.pid ?? null,
|
|
250
|
+
})
|
|
251
|
+
log(tEn('tenant.ready', { id: tenant.id, url: 'http://127.0.0.1:' + String(port) + '/' }))
|
|
252
|
+
void fromStderr
|
|
253
|
+
}
|
|
254
|
+
spawned.stdout.on('data', (chunk) => {
|
|
255
|
+
appendLog(chunk.toString('utf8'))
|
|
256
|
+
onText(chunk.toString('utf8'), false)
|
|
257
|
+
})
|
|
258
|
+
spawned.stderr.on('data', (chunk) => {
|
|
259
|
+
const text = chunk.toString('utf8')
|
|
260
|
+
appendLog(text)
|
|
261
|
+
stderr = (stderr + text).slice(-4000)
|
|
262
|
+
onText(text, true)
|
|
263
|
+
})
|
|
264
|
+
spawned.on('error', (error) => {
|
|
265
|
+
scheduleRestart(tEn('tenant.spawnFailed', { message: String(error?.message ?? error) }))
|
|
266
|
+
})
|
|
267
|
+
spawned.on('exit', (code, signal) => {
|
|
268
|
+
if (child !== spawned) return
|
|
269
|
+
child = null
|
|
270
|
+
token = ''
|
|
271
|
+
if (stopped) {
|
|
272
|
+
setState({ phase: 'stopped', code: 'tenant.stopped', params: { id: tenant.id }, hasToken: false, pid: null })
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
const tail = stderr.trim().split('\n').slice(-2).join(' | ')
|
|
276
|
+
scheduleRestart(
|
|
277
|
+
'exit ' + (signal ?? String(code)) + (tail ? ': ' + tail : ''),
|
|
278
|
+
)
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function scheduleRestart(reason) {
|
|
283
|
+
if (stopped) return
|
|
284
|
+
restarts += 1
|
|
285
|
+
if (restarts > maxRestarts) {
|
|
286
|
+
setState({
|
|
287
|
+
phase: 'error',
|
|
288
|
+
code: 'tenant.crashed',
|
|
289
|
+
params: { id: tenant.id, reason },
|
|
290
|
+
hasToken: false,
|
|
291
|
+
restarts,
|
|
292
|
+
})
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
const delay = Math.min(restartBaseMs * 2 ** (restarts - 1), 30000)
|
|
296
|
+
setState({
|
|
297
|
+
phase: 'restarting',
|
|
298
|
+
code: 'tenant.restarting',
|
|
299
|
+
params: { id: tenant.id, reason, seconds: String(Math.round(delay / 1000)) },
|
|
300
|
+
hasToken: false,
|
|
301
|
+
restarts,
|
|
302
|
+
})
|
|
303
|
+
restartTimer = setTimeout(() => {
|
|
304
|
+
restartTimer = null
|
|
305
|
+
launch()
|
|
306
|
+
}, delay)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
tenantId: tenant.id,
|
|
311
|
+
/** 启动(首次会初始化该租户的 profile)。 */
|
|
312
|
+
start() {
|
|
313
|
+
stopped = false
|
|
314
|
+
restarts = 0
|
|
315
|
+
launch()
|
|
316
|
+
return state
|
|
317
|
+
},
|
|
318
|
+
/** 停止并等子进程退出。 */
|
|
319
|
+
async stop() {
|
|
320
|
+
stopped = true
|
|
321
|
+
if (restartTimer !== null) {
|
|
322
|
+
clearTimeout(restartTimer)
|
|
323
|
+
restartTimer = null
|
|
324
|
+
}
|
|
325
|
+
const current = child
|
|
326
|
+
child = null
|
|
327
|
+
token = ''
|
|
328
|
+
if (current !== null) {
|
|
329
|
+
current.kill('SIGTERM')
|
|
330
|
+
await new Promise((resolve) => {
|
|
331
|
+
const timer = setTimeout(() => {
|
|
332
|
+
try {
|
|
333
|
+
current.kill('SIGKILL')
|
|
334
|
+
} catch {
|
|
335
|
+
/* 已退出 */
|
|
336
|
+
}
|
|
337
|
+
resolve()
|
|
338
|
+
}, 4000)
|
|
339
|
+
current.once('exit', () => {
|
|
340
|
+
clearTimeout(timer)
|
|
341
|
+
resolve()
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
setState({ phase: 'stopped', code: 'tenant.stopped', params: { id: tenant.id }, hasToken: false, pid: null })
|
|
346
|
+
return state
|
|
347
|
+
},
|
|
348
|
+
/** 该租户当前的启动令牌(没起来时为空串)。 */
|
|
349
|
+
token() {
|
|
350
|
+
return token
|
|
351
|
+
},
|
|
352
|
+
/** 上游端口(`port: 0` 时以子进程实际打印的为准)。 */
|
|
353
|
+
upstreamPort() {
|
|
354
|
+
return port
|
|
355
|
+
},
|
|
356
|
+
state() {
|
|
357
|
+
return state
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
}
|