dsh-mesh-node 0.0.0-stage → 0.0.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/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 +340 -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/session-profile.js +146 -0
- package/src/core/tls.js +160 -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 +313 -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,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
|
+
}
|