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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/package.json +43 -4
  4. package/src/cli.js +1246 -0
  5. package/src/core/acl.js +81 -0
  6. package/src/core/audit.js +75 -0
  7. package/src/core/autostart.js +119 -0
  8. package/src/core/cluster.js +341 -0
  9. package/src/core/config.js +622 -0
  10. package/src/core/desktop-proxy.js +85 -0
  11. package/src/core/ds-web.js +394 -0
  12. package/src/core/host-agent.js +263 -0
  13. package/src/core/jobs.js +259 -0
  14. package/src/core/pairing.js +141 -0
  15. package/src/core/peer-client.js +273 -0
  16. package/src/core/peer-doctor.js +294 -0
  17. package/src/core/peer-http.js +61 -0
  18. package/src/core/repair.js +93 -0
  19. package/src/core/restart-policy.js +21 -0
  20. package/src/core/result.js +29 -0
  21. package/src/core/sakura-api.js +103 -0
  22. package/src/core/session-profile.js +146 -0
  23. package/src/core/tls.js +160 -0
  24. package/src/core/tunnels.js +212 -0
  25. package/src/core/util.js +68 -0
  26. package/src/e2e-autostart.js +58 -0
  27. package/src/e2e-channel.js +384 -0
  28. package/src/e2e-cluster.js +417 -0
  29. package/src/e2e-host.js +411 -0
  30. package/src/e2e-onboard.js +232 -0
  31. package/src/e2e-pair.js +317 -0
  32. package/src/e2e-plugin.js +395 -0
  33. package/src/e2e-repair.js +297 -0
  34. package/src/index.js +20 -0
  35. package/src/peer-bridge.js +124 -0
  36. package/src/selftest.js +872 -0
  37. package/src/server.js +317 -0
  38. package/src/tools/alerts.js +80 -0
  39. package/src/tools/channel.js +253 -0
  40. package/src/tools/cluster.js +235 -0
  41. package/src/tools/config-patch.js +182 -0
  42. package/src/tools/control-apps.js +469 -0
  43. package/src/tools/control-path.js +160 -0
  44. package/src/tools/control.js +232 -0
  45. package/src/tools/core.js +79 -0
  46. package/src/tools/delegate.js +653 -0
  47. package/src/tools/fs.js +436 -0
  48. package/src/tools/git.js +266 -0
  49. package/src/tools/host.js +305 -0
  50. package/src/tools/index.js +122 -0
  51. package/src/tools/repair-fs.js +130 -0
  52. package/src/tools/repair.js +317 -0
  53. package/src/tools/session-history.js +60 -0
  54. package/src/tools/shell.js +66 -0
  55. package/src/tools/tasks.js +117 -0
@@ -0,0 +1,122 @@
1
+ import { buildCore } from './core.js'
2
+ import { buildFs } from './fs.js'
3
+ import { buildGit } from './git.js'
4
+ import { buildShell } from './shell.js'
5
+ import { buildTasks } from './tasks.js'
6
+ import { buildChannel } from './channel.js'
7
+ import { buildHost } from './host.js'
8
+ import { buildRepair } from './repair.js'
9
+ import { buildRepairFs } from './repair-fs.js'
10
+ import { buildCluster } from './cluster.js'
11
+ import { buildDelegate } from './delegate.js'
12
+ import { buildSessionHistory } from './session-history.js'
13
+ import { buildConfigPatch } from './config-patch.js'
14
+ import { buildAlerts } from './alerts.js'
15
+ import { buildControl } from './control.js'
16
+ import { buildControlApps } from './control-apps.js'
17
+ import { buildControlPath } from './control-path.js'
18
+ import { denied, internal, notFound } from '../core/result.js'
19
+ import { short } from '../core/util.js'
20
+
21
+ /** Every tool spec for this node, built against the current context. */
22
+ export function buildAll(ctx) {
23
+ return [
24
+ ...buildCore(ctx),
25
+ ...buildFs(ctx),
26
+ ...buildGit(ctx),
27
+ ...buildShell(ctx),
28
+ ...buildTasks(ctx),
29
+ ...buildChannel(ctx),
30
+ ...buildHost(ctx),
31
+ ...buildRepair(ctx),
32
+ ...buildRepairFs(ctx),
33
+ ...buildCluster(ctx),
34
+ ...buildDelegate(ctx),
35
+ ...buildSessionHistory(ctx),
36
+ ...buildConfigPatch(ctx),
37
+ ...buildAlerts(ctx),
38
+ ...buildControl(ctx),
39
+ ...buildControlApps(ctx),
40
+ ...buildControlPath()
41
+ ]
42
+ }
43
+
44
+ export function toolNames(ctx) {
45
+ return buildAll(ctx).map((spec) => spec.name)
46
+ }
47
+
48
+ /** Deny-by-default ACL: `deny` wins over `allow`. */
49
+ export function isAllowed(config, name) {
50
+ const acl = config?.acl || {}
51
+ if ((acl.deny || []).includes(name)) return false
52
+ return (acl.allow || []).includes(name)
53
+ }
54
+
55
+ /**
56
+ * Narrow the node-wide config by a per-token ACL (from secrets.json).
57
+ * `allow` = intersection with the node-wide list (a peer can never gain a tool);
58
+ * `deny` = union (a peer-specific denial always wins); `fsRoots` may only narrow.
59
+ */
60
+ export function applyPeerAcl(config, peerAcl) {
61
+ if (!peerAcl) return config
62
+ const base = config?.acl || {}
63
+ const baseAllow = base.allow || []
64
+ const peerAllow = Array.isArray(peerAcl.allow) ? peerAcl.allow : null
65
+ const allow = peerAllow ? baseAllow.filter((name) => peerAllow.includes(name)) : baseAllow
66
+ const deny = [...new Set([...(base.deny || []), ...((peerAcl.deny || []))])]
67
+ const fsRoots = Array.isArray(peerAcl.fsRoots) && peerAcl.fsRoots.length > 0 ? peerAcl.fsRoots : base.fsRoots
68
+ return { ...config, acl: { ...base, allow, deny, fsRoots } }
69
+ }
70
+
71
+ /**
72
+ * The single execution path for every tool call, whether it comes from a peer over MCP
73
+ * or from the local job queue. Applies ACL, audits the attempt, and converts thrown
74
+ * errors into structured E_INTERNAL results so a peer always gets an answer.
75
+ */
76
+ export async function dispatch(ctx, name, args = {}, { signal, jobId } = {}) {
77
+ const specs = buildAll(ctx)
78
+ const spec = specs.find((s) => s.name === name)
79
+ if (!spec) return notFound(`unknown tool: ${name}`)
80
+ const config = ctx.getConfig()
81
+ if (!isAllowed(config, name)) {
82
+ await ctx.audit({ peer: ctx.peerId, tool: name, ok: false, code: 'E_DENIED', jobId, args: Object.keys(args || {}) })
83
+ return denied(`${name} is not allowed by local ACL`, { allow: config.acl?.allow || [] })
84
+ }
85
+ const started = Date.now()
86
+ try {
87
+ const result = await spec.handler(args, { signal, jobId, peerId: ctx.peerId })
88
+ await ctx.audit({
89
+ peer: ctx.peerId,
90
+ tool: name,
91
+ ok: !result?.isError,
92
+ code: result?.isError ? 'E_TOOL' : undefined,
93
+ ms: Date.now() - started,
94
+ jobId,
95
+ args: Object.keys(args || {})
96
+ })
97
+ return result
98
+ } catch (error) {
99
+ await ctx.audit({
100
+ peer: ctx.peerId,
101
+ tool: name,
102
+ ok: false,
103
+ code: 'E_INTERNAL',
104
+ ms: Date.now() - started,
105
+ jobId,
106
+ message: short(error?.message || error)
107
+ })
108
+ return internal(`${name} failed: ${short(error?.message || error)}`)
109
+ }
110
+ }
111
+
112
+ /** Register every tool on an McpServer; each callback funnels through dispatch(). */
113
+ export function registerTools(server, ctx) {
114
+ for (const spec of buildAll(ctx)) {
115
+ server.registerTool(
116
+ spec.name,
117
+ { title: spec.title, description: spec.description, inputSchema: spec.inputSchema },
118
+ async (args, extra) => dispatch(ctx, spec.name, args, { signal: extra?.signal })
119
+ )
120
+ }
121
+ return server
122
+ }
@@ -0,0 +1,130 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { z } from 'zod'
4
+ import { assertInside } from '../core/repair.js'
5
+ import { fail, ok } from '../core/result.js'
6
+ import {
7
+ backupRoot,
8
+ repairGate,
9
+ requireProfile,
10
+ resolveProfileTarget,
11
+ sha256,
12
+ timestamp
13
+ } from './repair.js'
14
+
15
+ const confirmSchema = z
16
+ .boolean()
17
+ .optional()
18
+ .describe('Must be true. Repair operations rewrite a DSH profile on this machine.')
19
+
20
+ const instanceSchema = z
21
+ .string()
22
+ .optional()
23
+ .describe('Host instance id whose profile is targeted (default: first enabled instance).')
24
+
25
+ /**
26
+ * M7 step 3 (fs_write): write ONE file inside a DSH profile, with a backup, a
27
+ * sha256 verification of the written bytes, and a rollback on any failure.
28
+ * Lives in its own module so the repair toolkit stays readable (and under the
29
+ * 400-line-per-file rule); the shared helpers come from ./repair.js.
30
+ */
31
+ export function buildRepairFs(ctx) {
32
+ return [
33
+ {
34
+ name: 'fs_write',
35
+ title: 'Write one UTF-8 file inside a DSH profile (backup + verify + rollback)',
36
+ description:
37
+ 'Writes one file inside the target DSH profile. The old content is copied to the backup dir, the new content is re-read and verified by sha256, and any failure rolls the file back. Confined to `hosts[].profileDir` (relative paths, or absolute paths inside that profile) — use it to repair a config/patch/plugin file on an instance you cannot reach otherwise. Requires config.repair.enabled=true and confirm:true.',
38
+ inputSchema: z.object({
39
+ path: z
40
+ .string()
41
+ .describe('Target file: relative to the profile root (forward slashes) or absolute inside it. Parent dirs are created when missing.'),
42
+ content: z.string().describe('Full new UTF-8 file content.'),
43
+ instance: instanceSchema,
44
+ profileDir: z.string().optional().describe('Explicit profile dir; must equal one of hosts[].profileDir.'),
45
+ createDirs: z.boolean().optional().describe('Create missing parent directories (default true).'),
46
+ confirm: confirmSchema
47
+ }),
48
+ handler: async (args) => {
49
+ const config = ctx.getConfig()
50
+ const gate = repairGate(config)
51
+ if (gate) return gate
52
+ if (args?.confirm !== true) {
53
+ return fail('E_INVALID_ARG', 'pass confirm:true to write a file inside a DSH profile')
54
+ }
55
+ const repair = config.repair || {}
56
+ const maxFileBytes = repair.maxFileBytes ?? 262144
57
+ const raw = String(args?.path || '').trim()
58
+ if (!raw) return fail('E_INVALID_ARG', 'path is required')
59
+ const content = args?.content
60
+ if (typeof content !== 'string') return fail('E_INVALID_ARG', 'content must be a string')
61
+ const bytes = Buffer.byteLength(content, 'utf8')
62
+ if (bytes > maxFileBytes) {
63
+ return fail('E_INVALID_ARG', `content is ${bytes} bytes > limit ${maxFileBytes}`)
64
+ }
65
+
66
+ let profileDir
67
+ let instanceId = ''
68
+ try {
69
+ const target = resolveProfileTarget(config, args)
70
+ profileDir = target.profileDir
71
+ instanceId = target.instanceId
72
+ requireProfile(profileDir)
73
+ } catch (error) {
74
+ return fail(error.code || 'E_INVALID_ARG', error.message)
75
+ }
76
+
77
+ let absolute
78
+ try {
79
+ const normalized = raw.replace(/\\/g, '/')
80
+ absolute =
81
+ /^[a-z]:/i.test(normalized) || normalized.startsWith('/')
82
+ ? assertInside(profileDir, normalized)
83
+ : assertInside(profileDir, join(profileDir, ...normalized.split('/').filter((part) => part.length > 0)))
84
+ } catch (error) {
85
+ return fail(error.code || 'E_INVALID_ARG', error.message)
86
+ }
87
+
88
+ const existed = existsSync(absolute)
89
+ const backupDir = backupRoot(config, ctx.home)
90
+ let backup = null
91
+ let previous = null
92
+ if (existed) {
93
+ previous = readFileSync(absolute)
94
+ backup = join(backupDir, `fs-${timestamp()}-${absolute.split(/[\\/]/).pop().replace(/[^\w.-]/g, '_')}`)
95
+ mkdirSync(backupDir, { recursive: true })
96
+ writeFileSync(backup, previous)
97
+ }
98
+ try {
99
+ if (args?.createDirs !== false) mkdirSync(dirname(absolute), { recursive: true })
100
+ writeFileSync(absolute, content, 'utf8')
101
+ const written = readFileSync(absolute)
102
+ const expected = sha256(Buffer.from(content, 'utf8'))
103
+ if (sha256(written) !== expected) throw new Error('sha256 mismatch after write')
104
+ return ok({
105
+ path: absolute,
106
+ instance: instanceId || undefined,
107
+ bytes,
108
+ sha256: expected,
109
+ created: !existed,
110
+ verified: true,
111
+ backup
112
+ })
113
+ } catch (error) {
114
+ // rollback: restore the previous bytes (or remove the file we just created)
115
+ try {
116
+ if (existed && previous !== null) writeFileSync(absolute, previous)
117
+ else rmSync(absolute, { force: true })
118
+ } catch {
119
+ /* best effort */
120
+ }
121
+ return fail('E_WRITE_FAILED', `fs_write failed and was rolled back: ${error.message}`, {
122
+ retryable: false,
123
+ path: absolute,
124
+ backup
125
+ })
126
+ }
127
+ }
128
+ }
129
+ ]
130
+ }
@@ -0,0 +1,317 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
3
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { z } from 'zod'
6
+ import { hostInstances, resolveHostInstance } from '../core/host-agent.js'
7
+ import {
8
+ applyPatchInsert,
9
+ assertInside,
10
+ normalizePluginFiles,
11
+ PLUGIN_NAME_RE,
12
+ pluginRoot,
13
+ repairError
14
+ } from '../core/repair.js'
15
+ import { fail, ok } from '../core/result.js'
16
+ import { validateRestartCommand } from '../core/restart-policy.js'
17
+
18
+ const confirmSchema = z
19
+ .boolean()
20
+ .optional()
21
+ .describe('Must be true. Repair operations rewrite a DSH profile on this machine.')
22
+
23
+ const instanceSchema = z
24
+ .string()
25
+ .optional()
26
+ .describe('Host instance id whose profile is targeted (default: first enabled instance).')
27
+
28
+ /**
29
+ * M7 step 3: the "repair another DSH" toolkit.
30
+ *
31
+ * These tools run on the machine that owns the DSH instance — a peer calls them
32
+ * over MCP, so a live instance on the other machine can fix this one. Double
33
+ * gate: `config.repair.enabled === true` AND an explicit ACL allow entry (none
34
+ * of these tools is in DEFAULT_ALLOW). Every write leaves a backup and is
35
+ * audited by the dispatcher.
36
+ */
37
+
38
+ export function repairGate(config) {
39
+ if (config?.repair?.enabled === true) return null
40
+ return fail(
41
+ 'E_DENIED',
42
+ 'repair tools are disabled on this node (config.repair.enabled=true is required, and the tool must also be allowed in acl.allow)'
43
+ )
44
+ }
45
+
46
+ export function backupRoot(config, home) {
47
+ const configured = config?.repair?.backupDir
48
+ return configured ? resolve(configured) : join(home, 'backups')
49
+ }
50
+
51
+ export function timestamp() {
52
+ return new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)
53
+ }
54
+
55
+ /**
56
+ * Resolve the profile a repair tool may touch: an explicit `profileDir` must be
57
+ * one of the configured `hosts[].profileDir` values, otherwise the instance id
58
+ * picks the profile. This is the single place the "which profile" decision is
59
+ * made, shared by plugin_install and fs_write.
60
+ */
61
+ export function resolveProfileTarget(config, args) {
62
+ const allowedProfiles = hostInstances(config)
63
+ .map((entry) => entry.profileDir)
64
+ .filter(Boolean)
65
+ .map((dir) => resolve(dir))
66
+ if (args?.profileDir) {
67
+ const profileDir = resolve(String(args.profileDir))
68
+ if (!allowedProfiles.includes(profileDir)) {
69
+ throw repairError(
70
+ 'E_INVALID_ARG',
71
+ `profileDir is not one of the configured hosts[].profileDir values: configured=${allowedProfiles.join(', ') || '(none)'}`
72
+ )
73
+ }
74
+ return { profileDir, instanceId: '' }
75
+ }
76
+ const entry = resolveHostInstance(config, args?.instance)
77
+ if (!entry.profileDir) {
78
+ throw repairError('E_INVALID_ARG', `host instance "${entry.id}" has no profileDir configured`)
79
+ }
80
+ return { profileDir: resolve(entry.profileDir), instanceId: entry.id }
81
+ }
82
+
83
+ /** sha256 of a string or buffer, used to verify a write actually landed. */
84
+ export function sha256(value) {
85
+ return createHash('sha256').update(value).digest('hex')
86
+ }
87
+
88
+ export function requireProfile(profileDir) {
89
+ if (!existsSync(join(profileDir, 'package.json'))) {
90
+ throw repairError('E_PROFILE_NOT_FOUND', `not a DSH profile (no package.json): ${profileDir}`)
91
+ }
92
+ }
93
+
94
+ function tail(text, limit = 8000) {
95
+ const value = String(text || '')
96
+ return value.length > limit ? `…${value.slice(-limit)}` : value
97
+ }
98
+
99
+ function runCommand(command, { timeoutMs, cwd }) {
100
+ return new Promise((resolvePromise) => {
101
+ const started = Date.now()
102
+ execFile(
103
+ command,
104
+ { shell: true, timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024, cwd },
105
+ (error, stdout, stderr) => {
106
+ const exitCode = error ? (typeof error.code === 'number' ? error.code : null) : 0
107
+ resolvePromise({
108
+ code: exitCode,
109
+ signal: error?.signal || null,
110
+ stdout: tail(stdout),
111
+ stderr: tail(stderr),
112
+ error: error ? String(error.message || error) : null,
113
+ ms: Date.now() - started
114
+ })
115
+ }
116
+ )
117
+ })
118
+ }
119
+
120
+ export function buildRepair(ctx) {
121
+ return [
122
+ {
123
+ name: 'repair_status',
124
+ title: 'Show repair toolkit state on this node',
125
+ description:
126
+ 'Read-only: whether repair tools are enabled, the file/size limits, which restart scripts are configured, and which DSH profile each host instance maps to. Everything the remote repairer needs before touching anything.',
127
+ inputSchema: z.object({}),
128
+ handler: async () => {
129
+ const config = ctx.getConfig()
130
+ const repair = config.repair || {}
131
+ return ok({
132
+ enabled: repair.enabled === true,
133
+ maxFiles: repair.maxFiles,
134
+ maxFileBytes: repair.maxFileBytes,
135
+ timeoutMs: repair.timeoutMs,
136
+ backupDir: backupRoot(config, ctx.home),
137
+ restartScripts: Object.keys(repair.restartScripts || {}),
138
+ instances: hostInstances(config).map((entry) => ({
139
+ id: entry.id,
140
+ label: entry.label || entry.id,
141
+ profileDir: entry.profileDir || ''
142
+ }))
143
+ })
144
+ }
145
+ },
146
+ {
147
+ name: 'plugin_install',
148
+ title: 'Install a DSH plugin into a profile (also patches cordis.patch.yml)',
149
+ description:
150
+ 'Writes plugin files into `<profileDir>/node_modules/<name>/`, backs up any existing copy and the profile patch, then adds a `- insert:` block when the plugin is not wired yet. Use it to repair or extend a DSH instance that is otherwise unreachable — no npm, no git, no shell needed. Requires config.repair.enabled=true and confirm:true.',
151
+ inputSchema: z.object({
152
+ name: z.string().describe('Plugin package name, e.g. dsh-mesh-host'),
153
+ files: z
154
+ .array(
155
+ z.object({
156
+ path: z.string().describe('Relative path inside the package, forward slashes, e.g. lib/index.js'),
157
+ content: z.string().describe('Full file text (UTF-8)')
158
+ })
159
+ )
160
+ .describe('Every file of the plugin package'),
161
+ instance: instanceSchema,
162
+ profileDir: z
163
+ .string()
164
+ .optional()
165
+ .describe('Override profile dir; must equal one of the configured hosts[].profileDir values'),
166
+ patch: z.boolean().optional().describe('Set false to skip wiring cordis.patch.yml (default true)'),
167
+ confirm: confirmSchema
168
+ }),
169
+ handler: async (args) => {
170
+ const config = ctx.getConfig()
171
+ const gate = repairGate(config)
172
+ if (gate) return gate
173
+ if (args?.confirm !== true) {
174
+ return fail('E_INVALID_ARG', 'pass confirm:true to rewrite a DSH profile')
175
+ }
176
+ const name = String(args?.name || '')
177
+ if (!PLUGIN_NAME_RE.test(name)) {
178
+ return fail('E_INVALID_ARG', `invalid plugin name: ${name || '(empty)'}`)
179
+ }
180
+ const repair = config.repair || {}
181
+ let files
182
+ try {
183
+ files = normalizePluginFiles(args?.files, {
184
+ maxFiles: repair.maxFiles ?? 20,
185
+ maxFileBytes: repair.maxFileBytes ?? 262144
186
+ })
187
+ } catch (error) {
188
+ return fail(error.code || 'E_INVALID_ARG', error.message)
189
+ }
190
+
191
+ let profileDir
192
+ let instanceId = ''
193
+ try {
194
+ const target = resolveProfileTarget(config, args)
195
+ profileDir = target.profileDir
196
+ instanceId = target.instanceId
197
+ requireProfile(profileDir)
198
+ } catch (error) {
199
+ return fail(error.code || 'E_INVALID_ARG', error.message)
200
+ }
201
+
202
+ let packageDir
203
+ try {
204
+ packageDir = assertInside(profileDir, pluginRoot(profileDir, name))
205
+ } catch (error) {
206
+ return fail(error.code || 'E_INVALID_ARG', error.message)
207
+ }
208
+
209
+ const backups = []
210
+ const backupDir = backupRoot(config, ctx.home)
211
+ let packageBackup = null
212
+ if (existsSync(packageDir)) {
213
+ packageBackup = join(backupDir, `plugin-${name.replace(/[@/]/g, '_')}-${timestamp()}`)
214
+ cpSync(packageDir, packageBackup, { recursive: true })
215
+ backups.push(packageBackup)
216
+ }
217
+ let patchBackup = null
218
+ let patchPath = null
219
+ let patchCurrent = ''
220
+ try {
221
+ for (const file of files) {
222
+ const absolute = join(packageDir, file.abs)
223
+ mkdirSync(dirname(absolute), { recursive: true })
224
+ writeFileSync(absolute, file.content, 'utf8')
225
+ }
226
+
227
+ let patchInfo = null
228
+ if (args?.patch !== false) {
229
+ patchPath = join(profileDir, 'cordis.patch.yml')
230
+ patchCurrent = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
231
+ const applied = applyPatchInsert(patchCurrent, name)
232
+ if (applied.changed) {
233
+ if (applied.backup) {
234
+ patchBackup = `${patchPath}.bak-mesh-${timestamp()}`
235
+ writeFileSync(patchBackup, patchCurrent, 'utf8')
236
+ backups.push(patchBackup)
237
+ }
238
+ writeFileSync(patchPath, applied.text, 'utf8')
239
+ }
240
+ patchInfo = { path: patchPath, changed: applied.changed }
241
+ }
242
+
243
+ return ok({
244
+ name,
245
+ instance: instanceId || undefined,
246
+ profileDir,
247
+ packageDir,
248
+ files: files.map((file) => ({ path: file.rel, bytes: file.bytes })),
249
+ patch: patchInfo,
250
+ backups,
251
+ restartHint: 'restart that DSH instance to load the plugin'
252
+ })
253
+ } catch (error) {
254
+ // rollback: a half-written plugin is worse than no plugin
255
+ if (packageBackup) {
256
+ rmSync(packageDir, { recursive: true, force: true })
257
+ cpSync(packageBackup, packageDir, { recursive: true })
258
+ } else {
259
+ rmSync(packageDir, { recursive: true, force: true })
260
+ }
261
+ if (patchPath && patchBackup) writeFileSync(patchPath, patchCurrent, 'utf8')
262
+ return fail('E_WRITE_FAILED', `plugin_install failed and was rolled back: ${error.message}`, { backups })
263
+ }
264
+ }
265
+ },
266
+ {
267
+ name: 'service_restart',
268
+ title: 'Run a preset restart script for a DSH instance',
269
+ description:
270
+ 'Runs one command from config.repair.restartScripts by name (the map is the allowlist — arbitrary commands are refused). Use it only after a verified change, and only when a restart is really required. Returns exit code, stdout/stderr tails and duration. Requires config.repair.enabled=true and confirm:true.',
271
+ inputSchema: z.object({
272
+ script: z.string().describe('Name of the script from config.repair.restartScripts'),
273
+ confirm: confirmSchema
274
+ }),
275
+ handler: async (args) => {
276
+ const config = ctx.getConfig()
277
+ const gate = repairGate(config)
278
+ if (gate) return gate
279
+ if (args?.confirm !== true) {
280
+ return fail('E_INVALID_ARG', 'pass confirm:true to run a restart script')
281
+ }
282
+ const scripts = config.repair?.restartScripts || {}
283
+ const names = Object.keys(scripts)
284
+ const script = String(args?.script || '')
285
+ if (!script || !Object.prototype.hasOwnProperty.call(scripts, script)) {
286
+ return fail('E_NOT_FOUND', `unknown restart script "${script}"; configured: ${names.join(', ') || '(none)'}`, {
287
+ scripts: names
288
+ })
289
+ }
290
+ const command = String(scripts[script])
291
+ if (!command.trim()) return fail('E_INVALID_ARG', `restart script "${script}" is empty`)
292
+ // P2 运行期白名单:手改 config 也塞不进任意命令
293
+ const policy = validateRestartCommand(command)
294
+ if (!policy.ok) {
295
+ return fail('E_DENIED', `restart script "${script}" refused by runtime allowlist: ${policy.reason}`, { script, command: command.slice(0, 160) })
296
+ }
297
+ const run = await runCommand(command, {
298
+ timeoutMs: config.repair?.timeoutMs ?? 120000,
299
+ cwd: config.repair?.cwd || undefined
300
+ })
301
+ if (run.code !== 0) {
302
+ return fail('E_SCRIPT_FAILED', `restart script "${script}" exited with ${run.code ?? run.signal}`, {
303
+ retryable: false,
304
+ script,
305
+ command,
306
+ exitCode: run.code,
307
+ signal: run.signal,
308
+ stdout: run.stdout,
309
+ stderr: run.stderr,
310
+ ms: run.ms
311
+ })
312
+ }
313
+ return ok({ script, command, code: run.code, stdout: run.stdout, stderr: run.stderr, ms: run.ms })
314
+ }
315
+ },
316
+ ]
317
+ }
@@ -0,0 +1,60 @@
1
+ // @generated 2026-10-27 P1:session_history(远程只读导出某条 DSH 会话正文)
2
+ // [why] 以前只有两条路:① 委托对端 AI 导出(起会话、花 token、慢);② 人跑到对端机器上用
3
+ // my-skills 的 dsh-sessions.mjs。现在 mesh server 在目标机本地跑 scripts/session-history.mjs
4
+ // 解 zstd 会话日志 → 跨机一条命令看正文。
5
+ import { spawnSync } from 'node:child_process'
6
+ import { dirname, join, resolve } from 'node:path'
7
+ import { homedir } from 'node:os'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { z } from 'zod'
10
+ import { fail, ok } from '../core/result.js'
11
+
12
+ const SCRIPT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts', 'session-history.mjs')
13
+
14
+ function homeOf(instance) {
15
+ const id = String(instance || 'dsh0').trim()
16
+ const match = /^dsh(\d+)$/.exec(id)
17
+ return join(homedir(), `.dsh${match ? Number(match[1]) : 0}`)
18
+ }
19
+
20
+ export function buildSessionHistory() {
21
+ return [
22
+ {
23
+ name: 'session_history',
24
+ title: 'Read one DSH session transcript (read-only)',
25
+ description:
26
+ 'Read the last N messages of one session log on THIS machine (decompresses the append-only zstd session log, no model round-trip). Use peer-inspect/host_sessions to find the sessionId first. Read-only; returns role/text/time/seq + title. Requires the node ACL to allow `session_history`.',
27
+ inputSchema: z.object({
28
+ sessionId: z.string().describe('DSH session id, e.g. session-abf88d8c-8798-43c7-a7d7-f6266bac075a'),
29
+ instance: z.string().optional().describe('Local DSH instance whose session store to read (default dsh0 → ~/.dsh0).'),
30
+ limit: z.number().int().min(1).max(200).optional().describe('How many trailing messages (default 40).')
31
+ }),
32
+ handler: async ({ sessionId, instance, limit }) => {
33
+ const id = String(sessionId || '').trim()
34
+ if (!/^[A-Za-z0-9._-]{8,120}$/.test(id)) return fail('E_INVALID_ARG', 'sessionId looks invalid', { sessionId: id })
35
+ const home = homeOf(instance)
36
+ const lines = Math.min(Math.max(Number(limit) || 40, 1), 200)
37
+ const run = spawnSync(process.execPath, [SCRIPT, '--home', home, '--session', id, '--limit', String(lines)], {
38
+ encoding: 'utf8',
39
+ windowsHide: true,
40
+ timeout: 60000
41
+ })
42
+ const stdout = (run.stdout || '').trim()
43
+ if (!stdout) {
44
+ return fail('E_HISTORY', `session-history.mjs produced no output (exit ${run.status})`, {
45
+ stderr: (run.stderr || '').slice(-400),
46
+ sessionId: id,
47
+ home
48
+ })
49
+ }
50
+ try {
51
+ const parsed = JSON.parse(stdout)
52
+ if (parsed.ok !== true) return fail(parsed.code || 'E_HISTORY', parsed.message || 'session history failed', parsed)
53
+ return ok(parsed)
54
+ } catch (error) {
55
+ return fail('E_HISTORY', `cannot parse session-history output: ${error?.message || error}`, { sample: stdout.slice(0, 300) })
56
+ }
57
+ }
58
+ }
59
+ ]
60
+ }