dsh-mesh-node 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +8 -2
- package/package.json +43 -4
- package/src/cli.js +1246 -0
- package/src/core/acl.js +81 -0
- package/src/core/audit.js +75 -0
- package/src/core/autostart.js +119 -0
- package/src/core/cluster.js +341 -0
- package/src/core/config.js +622 -0
- package/src/core/desktop-proxy.js +85 -0
- package/src/core/ds-web.js +394 -0
- package/src/core/host-agent.js +263 -0
- package/src/core/jobs.js +259 -0
- package/src/core/pairing.js +141 -0
- package/src/core/peer-client.js +273 -0
- package/src/core/peer-doctor.js +294 -0
- package/src/core/peer-http.js +61 -0
- package/src/core/repair.js +93 -0
- package/src/core/restart-policy.js +21 -0
- package/src/core/result.js +29 -0
- package/src/core/sakura-api.js +103 -0
- package/src/core/session-profile.js +146 -0
- package/src/core/tls.js +160 -0
- package/src/core/tunnels.js +212 -0
- package/src/core/util.js +68 -0
- package/src/e2e-autostart.js +58 -0
- package/src/e2e-channel.js +384 -0
- package/src/e2e-cluster.js +417 -0
- package/src/e2e-host.js +411 -0
- package/src/e2e-onboard.js +232 -0
- package/src/e2e-pair.js +317 -0
- package/src/e2e-plugin.js +395 -0
- package/src/e2e-repair.js +297 -0
- package/src/index.js +20 -0
- package/src/peer-bridge.js +124 -0
- package/src/selftest.js +872 -0
- package/src/server.js +317 -0
- package/src/tools/alerts.js +80 -0
- package/src/tools/channel.js +253 -0
- package/src/tools/cluster.js +235 -0
- package/src/tools/config-patch.js +182 -0
- package/src/tools/control-apps.js +469 -0
- package/src/tools/control-path.js +160 -0
- package/src/tools/control.js +232 -0
- package/src/tools/core.js +79 -0
- package/src/tools/delegate.js +653 -0
- package/src/tools/fs.js +436 -0
- package/src/tools/git.js +266 -0
- package/src/tools/host.js +305 -0
- package/src/tools/index.js +122 -0
- package/src/tools/repair-fs.js +130 -0
- package/src/tools/repair.js +317 -0
- package/src/tools/session-history.js +60 -0
- package/src/tools/shell.js +66 -0
- package/src/tools/tasks.js +117 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join, resolve, sep, isAbsolute } from 'node:path'
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync, statSync, renameSync } from 'node:fs'
|
|
4
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
5
|
+
import { readJsonFile } from './util.js'
|
|
6
|
+
|
|
7
|
+
export const MESH_VERSION = '0.17.0'
|
|
8
|
+
export const CONFIG_VERSION = 7
|
|
9
|
+
|
|
10
|
+
/** Defaults for M1 — used only to detect and upgrade an untouched M1 config. */
|
|
11
|
+
const M1_DEFAULT_ALLOW = ['mesh_self', 'fs_read', 'git_status']
|
|
12
|
+
|
|
13
|
+
/** Defaults for M2 — used to upgrade an untouched M2 allow-list to the M3 defaults. */
|
|
14
|
+
const M2_DEFAULT_ALLOW = [
|
|
15
|
+
'mesh_self',
|
|
16
|
+
'fs_read',
|
|
17
|
+
'fs_list',
|
|
18
|
+
'fs_stat',
|
|
19
|
+
'fs_grep',
|
|
20
|
+
'git_status',
|
|
21
|
+
'git_log',
|
|
22
|
+
'git_diff',
|
|
23
|
+
'repo_scan',
|
|
24
|
+
'task_submit',
|
|
25
|
+
'task_status',
|
|
26
|
+
'task_list',
|
|
27
|
+
'task_cancel',
|
|
28
|
+
'mesh_audit_tail'
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
/** Defaults for M3.0 — used to upgrade an untouched M3 allow-list to the M4 defaults. */
|
|
32
|
+
const M3_DEFAULT_ALLOW = [
|
|
33
|
+
'mesh_self',
|
|
34
|
+
'fs_read',
|
|
35
|
+
'fs_list',
|
|
36
|
+
'fs_stat',
|
|
37
|
+
'fs_grep',
|
|
38
|
+
'git_status',
|
|
39
|
+
'git_log',
|
|
40
|
+
'git_diff',
|
|
41
|
+
'repo_scan',
|
|
42
|
+
'task_submit',
|
|
43
|
+
'task_status',
|
|
44
|
+
'task_list',
|
|
45
|
+
'task_cancel',
|
|
46
|
+
'mesh_audit_tail',
|
|
47
|
+
'channel_targets',
|
|
48
|
+
'channel_sessions',
|
|
49
|
+
'channel_page'
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read-only tools plus the task queue are allowed out of the box.
|
|
54
|
+
* Anything that can run code (`shell_run`), call arbitrary host tools
|
|
55
|
+
* (`host_call`), or inject a message into a live DSH session
|
|
56
|
+
* (`channel_send` / `channel_wake`) stays denied until a human opts in.
|
|
57
|
+
*/
|
|
58
|
+
export const DEFAULT_ALLOW = [
|
|
59
|
+
...M3_DEFAULT_ALLOW,
|
|
60
|
+
'fs_stats',
|
|
61
|
+
'host_instances',
|
|
62
|
+
'mesh_overview',
|
|
63
|
+
'host_self',
|
|
64
|
+
'host_tools',
|
|
65
|
+
'host_sessions',
|
|
66
|
+
'host_logs',
|
|
67
|
+
'session_history',
|
|
68
|
+
'mesh_alert',
|
|
69
|
+
'channel_agents',
|
|
70
|
+
'cluster_status',
|
|
71
|
+
'delegate',
|
|
72
|
+
'delegate_status',
|
|
73
|
+
'delegate_reply',
|
|
74
|
+
'delegate_list',
|
|
75
|
+
'delegate_candidates',
|
|
76
|
+
'delegate_gc'
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Host bridge instances this node knows about. `dsh0`/`dsh1` mirror the
|
|
81
|
+
* two-DSH-per-machine convention (profile `~/.dsh0` / `~/.dsh1` on ports
|
|
82
|
+
* 9100/9101). `tokenFile: ''` means derive it: `dsh0` -> `<home>/host-token`,
|
|
83
|
+
* any other id -> `<home>/host-token-<id>`; the plugin honours
|
|
84
|
+
* `DSH_MESH_HOST_TOKEN_FILE` so each DSH process points at its own file.
|
|
85
|
+
*/
|
|
86
|
+
/** Directory of the DSH profile that belongs to instance `id` (dsh0/dsh1 convention). */
|
|
87
|
+
export function defaultProfileDir(id) {
|
|
88
|
+
const match = /^dsh(\d+)$/.exec(String(id || ''))
|
|
89
|
+
return match ? join(homedir(), `.dsh${match[1]}`, 'profiles', 'web') : ''
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const DEFAULT_HOSTS = [
|
|
93
|
+
{
|
|
94
|
+
id: 'dsh0',
|
|
95
|
+
label: 'DSH 3080 (.dsh0)',
|
|
96
|
+
enabled: true,
|
|
97
|
+
url: 'http://127.0.0.1:9100',
|
|
98
|
+
tokenFile: '',
|
|
99
|
+
timeoutMs: 120000,
|
|
100
|
+
/** Profile this instance boots from; repair tools may only write here. */
|
|
101
|
+
profileDir: defaultProfileDir('dsh0')
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 'dsh1',
|
|
105
|
+
label: 'DSH 3081 (.dsh1)',
|
|
106
|
+
enabled: true,
|
|
107
|
+
url: 'http://127.0.0.1:9101',
|
|
108
|
+
tokenFile: '',
|
|
109
|
+
timeoutMs: 120000,
|
|
110
|
+
profileDir: defaultProfileDir('dsh1')
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
/** Defaults for one host entry, by position (first entry doubles as dsh0). */
|
|
115
|
+
export function defaultHostEntry(index = 0) {
|
|
116
|
+
const fallback = DEFAULT_HOSTS[index] || {
|
|
117
|
+
id: `dsh${index}`,
|
|
118
|
+
label: `DSH instance ${index}`,
|
|
119
|
+
enabled: true,
|
|
120
|
+
url: `http://127.0.0.1:${9100 + index}`,
|
|
121
|
+
tokenFile: '',
|
|
122
|
+
timeoutMs: 120000,
|
|
123
|
+
profileDir: defaultProfileDir(`dsh${index}`)
|
|
124
|
+
}
|
|
125
|
+
return { ...fallback }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Directory holding config + secrets + jobs + audit. Override with DSH_MESH_HOME. */
|
|
129
|
+
export function meshHome() {
|
|
130
|
+
return process.env.DSH_MESH_HOME ? resolve(process.env.DSH_MESH_HOME) : join(homedir(), '.dsh-mesh')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function defaultCodeRoot() {
|
|
134
|
+
return join(homedir(), 'Code')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Default configuration; written on first run so a human can edit it. */
|
|
138
|
+
export function defaultConfig() {
|
|
139
|
+
return {
|
|
140
|
+
version: CONFIG_VERSION,
|
|
141
|
+
node: {
|
|
142
|
+
name: 'local',
|
|
143
|
+
bind: '127.0.0.1',
|
|
144
|
+
port: 8124,
|
|
145
|
+
path: '/mcp',
|
|
146
|
+
/** TLS terminated inside the node (Sakura TCP tunnels are not encrypted). */
|
|
147
|
+
tls: { enabled: false, cert: '', key: '' }
|
|
148
|
+
},
|
|
149
|
+
acl: {
|
|
150
|
+
/** Tools a peer may call. Deny-by-default: anything not listed is refused. */
|
|
151
|
+
allow: [...DEFAULT_ALLOW],
|
|
152
|
+
/** fs_* / repo_scan / shell cwd may only touch paths under these roots. */
|
|
153
|
+
fsRoots: [defaultCodeRoot()],
|
|
154
|
+
/** Hard deny wins over allow. */
|
|
155
|
+
deny: []
|
|
156
|
+
},
|
|
157
|
+
shell: {
|
|
158
|
+
/** shell_run is refused while this is false, even if `acl.allow` lists it. */
|
|
159
|
+
enabled: false,
|
|
160
|
+
/** Empty = any command; otherwise argv[0] must be in this list. */
|
|
161
|
+
allowCommands: [],
|
|
162
|
+
/** Extra allowed cwds (fsRoots always apply). */
|
|
163
|
+
cwdRoots: [],
|
|
164
|
+
timeoutMs: 60000
|
|
165
|
+
},
|
|
166
|
+
limits: {
|
|
167
|
+
maxReadBytes: 262144,
|
|
168
|
+
maxLines: 4000,
|
|
169
|
+
gitTimeoutMs: 20000,
|
|
170
|
+
maxListEntries: 500,
|
|
171
|
+
maxGrepFiles: 400,
|
|
172
|
+
maxDiffBytes: 131072,
|
|
173
|
+
jobTtlSec: 604800,
|
|
174
|
+
maxJobs: 500,
|
|
175
|
+
jobTimeoutMs: 300000,
|
|
176
|
+
channelWaitMs: 180000,
|
|
177
|
+
channelPollMs: 800,
|
|
178
|
+
channelMaxMessages: 60
|
|
179
|
+
},
|
|
180
|
+
/** One-time pairing codes: a peer redeems a short code for its own bearer token. */
|
|
181
|
+
pairing: {
|
|
182
|
+
enabled: true,
|
|
183
|
+
codeTtlSec: 1800,
|
|
184
|
+
maxAttempts: 5
|
|
185
|
+
},
|
|
186
|
+
/** Local DSH instances this node may talk to (route A of the channel design). */
|
|
187
|
+
dsh: {
|
|
188
|
+
targets: []
|
|
189
|
+
},
|
|
190
|
+
/**
|
|
191
|
+
* Host bridges (route B): each local DSH instance can load the
|
|
192
|
+
* `dsh-mesh-host` plugin, which exposes its in-process agents/tools on a
|
|
193
|
+
* loopback endpoint. One entry per DSH instance (default: dsh0 + dsh1).
|
|
194
|
+
*/
|
|
195
|
+
hosts: DEFAULT_HOSTS.map((entry) => ({ ...entry })),
|
|
196
|
+
/**
|
|
197
|
+
* Repair tooling (M7 step 3): write plugin files into a DSH profile and run
|
|
198
|
+
* preset restart scripts. Off by default and doubly gated — the tools are
|
|
199
|
+
* also not in DEFAULT_ALLOW, so a peer needs both an ACL entry and this flag.
|
|
200
|
+
*/
|
|
201
|
+
repair: {
|
|
202
|
+
enabled: false,
|
|
203
|
+
maxFiles: 20,
|
|
204
|
+
maxFileBytes: 262144,
|
|
205
|
+
timeoutMs: 120000,
|
|
206
|
+
/** '' -> `<mesh home>/backups` */
|
|
207
|
+
backupDir: '',
|
|
208
|
+
/** name -> command line; service_restart only runs a name from this map. */
|
|
209
|
+
restartScripts: {}
|
|
210
|
+
},
|
|
211
|
+
peers: []
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function configPath(home = meshHome()) {
|
|
216
|
+
return join(home, 'config.json')
|
|
217
|
+
}
|
|
218
|
+
export function secretsPath(home = meshHome()) {
|
|
219
|
+
return join(home, 'secrets.json')
|
|
220
|
+
}
|
|
221
|
+
export function pairingPath(home = meshHome()) {
|
|
222
|
+
return join(home, 'pairing.json')
|
|
223
|
+
}
|
|
224
|
+
export function peersPath(home = meshHome()) {
|
|
225
|
+
return join(home, 'peers.json')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function ensureHome(home = meshHome()) {
|
|
229
|
+
mkdirSync(home, { recursive: true, mode: 0o700 })
|
|
230
|
+
return home
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Normalise the host-bridge list: a raw `hosts` array wins; a legacy single
|
|
235
|
+
* `host` object (config v5) is folded into the first entry so an upgrade never
|
|
236
|
+
* silently loses a custom url/token file.
|
|
237
|
+
*/
|
|
238
|
+
function mergeHosts(raw) {
|
|
239
|
+
const list = Array.isArray(raw?.hosts) ? raw.hosts : null
|
|
240
|
+
if (list && list.length > 0) {
|
|
241
|
+
return list.map((entry, index) => ({ ...defaultHostEntry(index), ...(entry || {}) }))
|
|
242
|
+
}
|
|
243
|
+
if (raw?.host && typeof raw.host === 'object') {
|
|
244
|
+
return [{ ...defaultHostEntry(0), ...raw.host, id: raw.host.id || 'dsh0' }]
|
|
245
|
+
}
|
|
246
|
+
return DEFAULT_HOSTS.map((entry) => ({ ...entry }))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function mergeConfig(raw) {
|
|
250
|
+
const base = defaultConfig()
|
|
251
|
+
const merged = {
|
|
252
|
+
...base,
|
|
253
|
+
...raw,
|
|
254
|
+
node: {
|
|
255
|
+
...base.node,
|
|
256
|
+
...(raw.node || {}),
|
|
257
|
+
tls: { ...base.node.tls, ...((raw.node || {}).tls || {}) }
|
|
258
|
+
},
|
|
259
|
+
acl: {
|
|
260
|
+
...base.acl,
|
|
261
|
+
...(raw.acl || {}),
|
|
262
|
+
allow: Array.isArray(raw.acl?.allow) ? raw.acl.allow : base.acl.allow,
|
|
263
|
+
deny: Array.isArray(raw.acl?.deny) ? raw.acl.deny : [],
|
|
264
|
+
fsRoots: Array.isArray(raw.acl?.fsRoots) ? raw.acl.fsRoots : base.acl.fsRoots
|
|
265
|
+
},
|
|
266
|
+
shell: { ...base.shell, ...(raw.shell || {}) },
|
|
267
|
+
repair: { ...base.repair, ...(raw.repair || {}) },
|
|
268
|
+
pairing: { ...base.pairing, ...(raw.pairing || {}) },
|
|
269
|
+
limits: { ...base.limits, ...(raw.limits || {}) },
|
|
270
|
+
dsh: {
|
|
271
|
+
...base.dsh,
|
|
272
|
+
...(raw.dsh || {}),
|
|
273
|
+
targets: Array.isArray(raw.dsh?.targets) ? raw.dsh.targets : []
|
|
274
|
+
},
|
|
275
|
+
hosts: mergeHosts(raw),
|
|
276
|
+
peers: Array.isArray(raw.peers) ? raw.peers : []
|
|
277
|
+
}
|
|
278
|
+
// `host` was the v5 single-bridge key; keep it out of the in-memory shape.
|
|
279
|
+
delete merged.host
|
|
280
|
+
return merged
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Upgrade an old config in memory. Only an *untouched* allow-list is rewritten:
|
|
285
|
+
* a list the user already customised is preserved verbatim.
|
|
286
|
+
*/
|
|
287
|
+
function migrateConfig(raw) {
|
|
288
|
+
const version = Number.isFinite(raw?.version) ? raw.version : 1
|
|
289
|
+
const notes = []
|
|
290
|
+
if (version < 2) {
|
|
291
|
+
const allow = Array.isArray(raw?.acl?.allow) ? raw.acl.allow : []
|
|
292
|
+
const sameAsM1 =
|
|
293
|
+
allow.length === M1_DEFAULT_ALLOW.length && M1_DEFAULT_ALLOW.every((n) => allow.includes(n))
|
|
294
|
+
if (sameAsM1) {
|
|
295
|
+
raw.acl = { ...(raw.acl || {}), allow: [...M2_DEFAULT_ALLOW] }
|
|
296
|
+
notes.push('acl.allow: M1 defaults -> M2 defaults (read-only tools + task queue)')
|
|
297
|
+
}
|
|
298
|
+
raw.version = 2
|
|
299
|
+
notes.push('config version 1 -> 2')
|
|
300
|
+
}
|
|
301
|
+
if (Number(raw.version) < 3) {
|
|
302
|
+
const allow = Array.isArray(raw?.acl?.allow) ? raw.acl.allow : []
|
|
303
|
+
const sameAsM2 =
|
|
304
|
+
allow.length === M2_DEFAULT_ALLOW.length && M2_DEFAULT_ALLOW.every((n) => allow.includes(n))
|
|
305
|
+
if (sameAsM2) {
|
|
306
|
+
raw.acl = { ...(raw.acl || {}), allow: [...DEFAULT_ALLOW] }
|
|
307
|
+
notes.push('acl.allow: M2 defaults -> M3 defaults (+ read-only channel tools)')
|
|
308
|
+
}
|
|
309
|
+
if (raw.dsh === undefined) raw.dsh = { targets: [] }
|
|
310
|
+
raw.version = 3
|
|
311
|
+
notes.push('config version 2 -> 3 (dsh.targets)')
|
|
312
|
+
}
|
|
313
|
+
if (Number(raw.version) < 4) {
|
|
314
|
+
if (raw.pairing === undefined) raw.pairing = { enabled: true, codeTtlSec: 1800, maxAttempts: 5 }
|
|
315
|
+
raw.version = 4
|
|
316
|
+
notes.push('config version 3 -> 4 (pairing)')
|
|
317
|
+
}
|
|
318
|
+
if (Number(raw.version) < 5) {
|
|
319
|
+
const allow = Array.isArray(raw?.acl?.allow) ? raw.acl.allow : []
|
|
320
|
+
const sameAsM3 =
|
|
321
|
+
allow.length === M3_DEFAULT_ALLOW.length && M3_DEFAULT_ALLOW.every((n) => allow.includes(n))
|
|
322
|
+
if (sameAsM3) {
|
|
323
|
+
raw.acl = { ...(raw.acl || {}), allow: [...DEFAULT_ALLOW] }
|
|
324
|
+
notes.push('acl.allow: M3 defaults -> M4 defaults (+ read-only host bridge tools)')
|
|
325
|
+
}
|
|
326
|
+
if (raw.host === undefined) {
|
|
327
|
+
raw.host = { enabled: true, url: 'http://127.0.0.1:9100', tokenFile: '', timeoutMs: 120000 }
|
|
328
|
+
}
|
|
329
|
+
raw.version = 5
|
|
330
|
+
notes.push('config version 4 -> 5 (host bridge)')
|
|
331
|
+
}
|
|
332
|
+
if (Number(raw.version) < 6) {
|
|
333
|
+
if (raw.hosts === undefined) {
|
|
334
|
+
if (raw.host !== undefined) {
|
|
335
|
+
raw.hosts = [{ ...(raw.host || {}), id: raw.host?.id || 'dsh0' }]
|
|
336
|
+
// The two-DSH insurance convention is the default from v6 on: keep the
|
|
337
|
+
// upgraded bridge as dsh0 and add the second instance entry as dsh1.
|
|
338
|
+
if (!raw.hosts.some((entry) => entry.id === 'dsh1')) {
|
|
339
|
+
raw.hosts.push({ ...DEFAULT_HOSTS[1] })
|
|
340
|
+
}
|
|
341
|
+
delete raw.host
|
|
342
|
+
notes.push('host -> hosts[] (kept the existing bridge as dsh0, added dsh1)')
|
|
343
|
+
} else {
|
|
344
|
+
raw.hosts = defaultConfig().hosts
|
|
345
|
+
notes.push('hosts[]: dsh0 + dsh1 (one entry per local DSH instance)')
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
raw.version = 6
|
|
349
|
+
notes.push('config version 5 -> 6 (host bridge per instance)')
|
|
350
|
+
}
|
|
351
|
+
if (Number(raw.version) < 7) {
|
|
352
|
+
if (raw.repair === undefined) {
|
|
353
|
+
raw.repair = { ...defaultConfig().repair }
|
|
354
|
+
notes.push('repair: disabled by default (plugin_install / service_restart, M7)')
|
|
355
|
+
}
|
|
356
|
+
const hosts = Array.isArray(raw.hosts) ? raw.hosts : []
|
|
357
|
+
let addedProfiles = 0
|
|
358
|
+
for (const entry of hosts) {
|
|
359
|
+
if (!entry || typeof entry !== 'object') continue
|
|
360
|
+
if (entry.profileDir === undefined) {
|
|
361
|
+
const derived = defaultProfileDir(entry.id)
|
|
362
|
+
if (derived) {
|
|
363
|
+
entry.profileDir = derived
|
|
364
|
+
addedProfiles += 1
|
|
365
|
+
} else {
|
|
366
|
+
entry.profileDir = ''
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (addedProfiles > 0) notes.push(`hosts[].profileDir: derived for ${addedProfiles} instance(s)`)
|
|
371
|
+
raw.version = 7
|
|
372
|
+
notes.push('config version 6 -> 7 (repair tooling)')
|
|
373
|
+
}
|
|
374
|
+
return notes
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** How far this machine's allow-list sits from the defaults shipped by this version. */
|
|
378
|
+
export function aclDrift(config) {
|
|
379
|
+
const allow = Array.isArray(config?.acl?.allow) ? config.acl.allow : []
|
|
380
|
+
return {
|
|
381
|
+
missing: DEFAULT_ALLOW.filter((name) => !allow.includes(name)),
|
|
382
|
+
extra: allow.filter((name) => !DEFAULT_ALLOW.includes(name))
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Atomic save so a crash mid-write cannot leave a truncated config. */
|
|
387
|
+
export function saveConfig(config, home = meshHome()) {
|
|
388
|
+
ensureHome(home)
|
|
389
|
+
const p = configPath(home)
|
|
390
|
+
const tmp = `${p}.tmp-${process.pid}`
|
|
391
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', 'utf8')
|
|
392
|
+
renameSync(tmp, p)
|
|
393
|
+
return p
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Load config, creating a default one on first run and migrating old versions. */
|
|
397
|
+
export function loadConfig(home = meshHome(), { log = console } = {}) {
|
|
398
|
+
ensureHome(home)
|
|
399
|
+
const p = configPath(home)
|
|
400
|
+
if (!existsSync(p)) {
|
|
401
|
+
writeFileSync(p, JSON.stringify(defaultConfig(), null, 2) + '\n', 'utf8')
|
|
402
|
+
}
|
|
403
|
+
const raw = readJsonFile(p)
|
|
404
|
+
const notes = migrateConfig(raw)
|
|
405
|
+
const merged = mergeConfig(raw)
|
|
406
|
+
if (notes.length > 0 && merged.version === CONFIG_VERSION) {
|
|
407
|
+
try {
|
|
408
|
+
saveConfig(merged, home)
|
|
409
|
+
log?.info?.(`[dsh-mesh] config migrated: ${notes.join('; ')}`)
|
|
410
|
+
} catch (error) {
|
|
411
|
+
log?.warn?.(`[dsh-mesh] could not persist migrated config: ${error?.message || error}`)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (notes.length > 0) {
|
|
415
|
+
const drift = aclDrift(merged)
|
|
416
|
+
if (drift.missing.length > 0 || drift.extra.length > 0) {
|
|
417
|
+
const parts = []
|
|
418
|
+
if (drift.missing.length > 0) parts.push(`missing [${drift.missing.join(', ')}]`)
|
|
419
|
+
if (drift.extra.length > 0) parts.push(`extra [${drift.extra.join(', ')}]`)
|
|
420
|
+
log?.warn?.(
|
|
421
|
+
`[dsh-mesh] acl.allow differs from this version's defaults (${parts.join('; ')}) — ` +
|
|
422
|
+
'upgrades never widen an edited ACL; run `node src/cli.js acl reset` (or `acl allow <tool>`) to align, see DESIGN §29'
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return merged
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Hot-reloading config accessor: re-reads `config.json` whenever its mtime changes,
|
|
431
|
+
* so editing ACL on disk takes effect without restarting the node.
|
|
432
|
+
*/
|
|
433
|
+
export function createConfigLoader({ home = meshHome(), log = console } = {}) {
|
|
434
|
+
let cached = null
|
|
435
|
+
let lastMtime = -1
|
|
436
|
+
return function getConfig() {
|
|
437
|
+
let mtime = -1
|
|
438
|
+
try {
|
|
439
|
+
mtime = statSync(configPath(home)).mtimeMs
|
|
440
|
+
} catch {
|
|
441
|
+
mtime = -1
|
|
442
|
+
}
|
|
443
|
+
if (cached && mtime === lastMtime) return cached
|
|
444
|
+
try {
|
|
445
|
+
cached = loadConfig(home, { log })
|
|
446
|
+
lastMtime = mtime
|
|
447
|
+
} catch (error) {
|
|
448
|
+
if (!cached) throw error
|
|
449
|
+
log?.warn?.(`[dsh-mesh] config reload failed, keeping previous config: ${error?.message || error}`)
|
|
450
|
+
}
|
|
451
|
+
return cached
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function newToken() {
|
|
456
|
+
return randomBytes(32).toString('hex')
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Inbound tokens live in secrets.json (never in config.json, never in git).
|
|
461
|
+
* shape: { version, inbound: [{ id, token, note }] }
|
|
462
|
+
*/
|
|
463
|
+
export function loadSecrets(home = meshHome()) {
|
|
464
|
+
ensureHome(home)
|
|
465
|
+
const p = secretsPath(home)
|
|
466
|
+
if (existsSync(p)) {
|
|
467
|
+
const raw = readJsonFile(p)
|
|
468
|
+
const inbound = Array.isArray(raw.inbound) ? raw.inbound : []
|
|
469
|
+
if (inbound.length > 0) return raw
|
|
470
|
+
raw.inbound = [{ id: 'default', token: newToken(), note: 'auto-generated' }]
|
|
471
|
+
writeFileSync(p, JSON.stringify(raw, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
472
|
+
return raw
|
|
473
|
+
}
|
|
474
|
+
const secrets = {
|
|
475
|
+
version: 1,
|
|
476
|
+
inbound: [{ id: 'default', token: newToken(), note: 'auto-generated on first run' }]
|
|
477
|
+
}
|
|
478
|
+
writeFileSync(p, JSON.stringify(secrets, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
479
|
+
return secrets
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function safeEqual(a, b) {
|
|
483
|
+
const ab = Buffer.from(String(a))
|
|
484
|
+
const bb = Buffer.from(String(b))
|
|
485
|
+
if (ab.length !== bb.length) return false
|
|
486
|
+
return timingSafeEqual(ab, bb)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Persist secrets.json (0600, UTF-8 no BOM). */
|
|
490
|
+
export function saveSecrets(secrets, home = meshHome()) {
|
|
491
|
+
ensureHome(home)
|
|
492
|
+
const p = secretsPath(home)
|
|
493
|
+
const tmp = `${p}.tmp-${process.pid}`
|
|
494
|
+
writeFileSync(tmp, JSON.stringify(secrets, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
495
|
+
renameSync(tmp, p)
|
|
496
|
+
return p
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Replace (or create) an inbound token. Returns the new token so the caller can
|
|
501
|
+
* print it once; the old token stops working as soon as the server reloads secrets.
|
|
502
|
+
*/
|
|
503
|
+
export function rotateToken({ home = meshHome(), id = 'default', note } = {}) {
|
|
504
|
+
const secrets = loadSecrets(home)
|
|
505
|
+
const token = newToken()
|
|
506
|
+
const existing = (secrets.inbound || []).find((entry) => entry.id === id)
|
|
507
|
+
if (existing) {
|
|
508
|
+
existing.token = token
|
|
509
|
+
if (note) existing.note = note
|
|
510
|
+
} else {
|
|
511
|
+
secrets.inbound = [...(secrets.inbound || []), { id, token, note: note || 'rotated' }]
|
|
512
|
+
}
|
|
513
|
+
saveSecrets(secrets, home)
|
|
514
|
+
return { id, token }
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Per-token ACL; `null` means "inherit the node-wide acl". */
|
|
518
|
+
export function peerAclForToken(secrets, tokenId) {
|
|
519
|
+
const entry = (secrets?.inbound || []).find((item) => item?.id === tokenId)
|
|
520
|
+
const acl = entry?.acl
|
|
521
|
+
if (!acl || typeof acl !== 'object') return null
|
|
522
|
+
return {
|
|
523
|
+
allow: Array.isArray(acl.allow) ? acl.allow : undefined,
|
|
524
|
+
deny: Array.isArray(acl.deny) ? acl.deny : [],
|
|
525
|
+
fsRoots: Array.isArray(acl.fsRoots) ? acl.fsRoots : undefined
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Add an inbound token (used by pairing and by `peer` bookkeeping).
|
|
531
|
+
* `acl` is optional: when given the token may only call that subset of tools.
|
|
532
|
+
*/
|
|
533
|
+
export function addInboundToken({ home = meshHome(), id, note, acl, token } = {}) {
|
|
534
|
+
if (!id) throw new Error('addInboundToken requires an id')
|
|
535
|
+
const secrets = loadSecrets(home)
|
|
536
|
+
const existing = (secrets.inbound || []).find((entry) => entry.id === id)
|
|
537
|
+
if (existing) throw new Error(`token id already exists: ${id}`)
|
|
538
|
+
const entry = { id, token: token || newToken(), note: note || 'paired' }
|
|
539
|
+
if (acl && typeof acl === 'object') entry.acl = acl
|
|
540
|
+
secrets.inbound = [...(secrets.inbound || []), entry]
|
|
541
|
+
saveSecrets(secrets, home)
|
|
542
|
+
return { id: entry.id, token: entry.token }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Delete an inbound token; the holder's next request fails with 401. */
|
|
546
|
+
export function removeInboundToken({ home = meshHome(), id } = {}) {
|
|
547
|
+
const secrets = loadSecrets(home)
|
|
548
|
+
const before = (secrets.inbound || []).length
|
|
549
|
+
secrets.inbound = (secrets.inbound || []).filter((entry) => entry.id !== id)
|
|
550
|
+
saveSecrets(secrets, home)
|
|
551
|
+
return { removed: before - secrets.inbound.length }
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Secrets loader that notices on-disk changes (so a rotation takes effect without a restart). */
|
|
555
|
+
export function createSecretsLoader({ home = meshHome(), log = console } = {}) {
|
|
556
|
+
const file = secretsPath(home)
|
|
557
|
+
let cached = null
|
|
558
|
+
let mtimeMs = -1
|
|
559
|
+
return function getSecrets() {
|
|
560
|
+
try {
|
|
561
|
+
const stat = statSyncSafe(file)
|
|
562
|
+
if (cached && stat && stat.mtimeMs === mtimeMs) return cached
|
|
563
|
+
const next = loadSecrets(home)
|
|
564
|
+
cached = next
|
|
565
|
+
mtimeMs = stat ? stat.mtimeMs : -1
|
|
566
|
+
log?.debug?.(`[dsh-mesh] secrets reloaded (${(next.inbound || []).length} inbound token(s))`)
|
|
567
|
+
return next
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (cached) {
|
|
570
|
+
log?.warn?.(`[dsh-mesh] secrets reload failed, keeping previous: ${error?.message || error}`)
|
|
571
|
+
return cached
|
|
572
|
+
}
|
|
573
|
+
throw error
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function statSyncSafe(file) {
|
|
579
|
+
try {
|
|
580
|
+
return statSync(file)
|
|
581
|
+
} catch {
|
|
582
|
+
return null
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Verify a Bearer token against secrets.inbound. Returns the matching token id or null. */
|
|
587
|
+
export function verifyBearer(req, secrets) {
|
|
588
|
+
const header = req?.headers?.authorization || ''
|
|
589
|
+
const m = /^Bearer\s+(.+)$/i.exec(String(header).trim())
|
|
590
|
+
if (!m) return null
|
|
591
|
+
const presented = m[1].trim()
|
|
592
|
+
for (const entry of secrets?.inbound || []) {
|
|
593
|
+
if (entry?.token && safeEqual(presented, entry.token)) return entry.id || 'default'
|
|
594
|
+
}
|
|
595
|
+
return null
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function canonical(p) {
|
|
599
|
+
const abs = resolve(p)
|
|
600
|
+
try {
|
|
601
|
+
return realpathSync(abs)
|
|
602
|
+
} catch {
|
|
603
|
+
return abs
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** True when `p` resolves inside one of `roots`. */
|
|
608
|
+
export function isInsideRoots(p, roots) {
|
|
609
|
+
const abs = canonical(p)
|
|
610
|
+
const cand = process.platform === 'win32' ? abs.toLowerCase() : abs
|
|
611
|
+
for (const root of roots || []) {
|
|
612
|
+
const r = canonical(root)
|
|
613
|
+
const rr = process.platform === 'win32' ? r.toLowerCase() : r
|
|
614
|
+
if (cand === rr) return true
|
|
615
|
+
if (cand.startsWith(rr.endsWith(sep) ? rr : rr + sep)) return true
|
|
616
|
+
}
|
|
617
|
+
return false
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export function isAbsolutePath(p) {
|
|
621
|
+
return isAbsolute(p)
|
|
622
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* desktop-proxy:mesh server 可能运行在 session 0(S4U/计划任务),看不到用户桌面。
|
|
5
|
+
* 这种情况下把 pc_* 调用转发给用户会话里常驻的 desktop helper(127.0.0.1:19556)。
|
|
6
|
+
*
|
|
7
|
+
* 设计:
|
|
8
|
+
* - helper 由「登录时触发」的计划任务在交互 session 启动(install-desktop-helper-task.mjs);
|
|
9
|
+
* - mesh 侧缓存自己的 session id;只有 session 0 才转发;
|
|
10
|
+
* - helper 进程带 DSH_PC_DESKTOP_DIRECT=1,直接执行不再转发(避免回环);
|
|
11
|
+
* - helper 不可达(ECONNREFUSED 等)时回退本地执行(保持原有行为,不会让工具直接失败)。
|
|
12
|
+
*
|
|
13
|
+
* 2026-11-09 修正(close 报 E_WINDOW_NOT_FOUND 但窗口其实已关):
|
|
14
|
+
* PowerShell 在负载高的机器上冷启动可能 10~20s,原来固定 6s 超时 → 超时后回退到 session 0 本地执行,
|
|
15
|
+
* 而 session 0 看不见用户桌面,必然算出 NOT_FOUND,形成假失败(helper 其实已经把窗口关了)。
|
|
16
|
+
* 现在:超时默认放宽到 30s;超时不回退本地,而是返回超时哨兵,让上层明确报「结果未知,请复核」。
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = Number(process.env.DSH_PC_PROXY_TIMEOUT_MS || 30000)
|
|
19
|
+
const PROXY_TIMEOUT_MARK = '__meshDesktopProxyTimeout'
|
|
20
|
+
|
|
21
|
+
let sessionPromise = null
|
|
22
|
+
|
|
23
|
+
export function meshSessionId() {
|
|
24
|
+
if (!sessionPromise) {
|
|
25
|
+
sessionPromise = new Promise((resolve) => {
|
|
26
|
+
try {
|
|
27
|
+
execFile(
|
|
28
|
+
'powershell.exe',
|
|
29
|
+
['-NoProfile', '-NonInteractive', '-Command', '[System.Diagnostics.Process]::GetCurrentProcess().SessionId'],
|
|
30
|
+
{ timeout: 10000, windowsHide: true },
|
|
31
|
+
(error, stdout) => {
|
|
32
|
+
if (error) return resolve(-1)
|
|
33
|
+
const n = Number(String(stdout || '').trim())
|
|
34
|
+
resolve(Number.isFinite(n) ? n : -1)
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
} catch {
|
|
38
|
+
resolve(-1)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
return sessionPromise
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** true = helper 收到了请求但超时未响应(操作可能已生效,不能当失败处理) */
|
|
46
|
+
export function isProxyTimeout(value) {
|
|
47
|
+
return Boolean(value && value[PROXY_TIMEOUT_MARK] === true)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 超时哨兵对应的用户可读错误:明确"结果未知",避免上层误判成 E_WINDOW_NOT_FOUND。 */
|
|
51
|
+
export function proxyTimeoutMessage(tool, value) {
|
|
52
|
+
const sec = Math.round(Number(value?.timeoutMs || DEFAULT_TIMEOUT_MS) / 1000)
|
|
53
|
+
return (
|
|
54
|
+
'E_DESKTOP_TIMEOUT: desktop helper 已接收 ' +
|
|
55
|
+
tool +
|
|
56
|
+
' 请求但 ' +
|
|
57
|
+
sec +
|
|
58
|
+
's 内未返回(机器负载高时 PowerShell 冷启动可能很慢)。操作**可能已生效**:请用 window list / app list 复核,不要直接重发。'
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function maybeProxy(tool, args, options = {}) {
|
|
63
|
+
if (process.env.DSH_PC_DESKTOP_DIRECT === '1') return null
|
|
64
|
+
if (process.platform !== 'win32') return null
|
|
65
|
+
const sid = await meshSessionId()
|
|
66
|
+
if (sid !== 0) return null
|
|
67
|
+
const timeoutMs = Number(options.timeoutMs || DEFAULT_TIMEOUT_MS)
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch('http://127.0.0.1:19556/call', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'content-type': 'application/json' },
|
|
72
|
+
body: JSON.stringify({ tool, args }),
|
|
73
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
74
|
+
})
|
|
75
|
+
if (!res.ok) return null
|
|
76
|
+
const json = await res.json()
|
|
77
|
+
return json && json.ok && json.result ? json.result : null
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const name = String(error?.name || '')
|
|
80
|
+
if (name === 'TimeoutError' || name === 'AbortError') {
|
|
81
|
+
return { [PROXY_TIMEOUT_MARK]: true, tool, timeoutMs }
|
|
82
|
+
}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
}
|