@dr-co/dsh-node-client 0.0.0-stage → 0.0.60
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/README.md +276 -2
- package/bin/dshnc.mjs +337 -0
- package/lib/api.mjs +177 -0
- package/lib/approver.mjs +138 -0
- package/lib/ask.mjs +17 -0
- package/lib/autostart.mjs +311 -0
- package/lib/bash.mjs +168 -0
- package/lib/config.mjs +103 -0
- package/lib/i18n.mjs +511 -0
- package/lib/jobs.mjs +203 -0
- package/lib/onboard.mjs +199 -0
- package/lib/ping-policy.mjs +49 -0
- package/lib/plugins/files.mjs +327 -0
- package/lib/plugins/index.mjs +116 -0
- package/lib/plugins/job.mjs +111 -0
- package/lib/plugins/session.mjs +192 -0
- package/lib/plugins/shell.mjs +71 -0
- package/lib/presence.mjs +171 -0
- package/lib/runner.mjs +600 -0
- package/lib/sandbox.mjs +152 -0
- package/lib/sessions.mjs +148 -0
- package/lib/shell.mjs +154 -0
- package/lib/store.mjs +436 -0
- package/package.json +45 -4
package/lib/bash.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Исполнитель одной команды: bash.
|
|
3
|
+
*
|
|
4
|
+
* Результат — блоки `content` в форме MCP (`text`), как их ждёт сервер
|
|
5
|
+
* (план §4.5). Крупный вывод не режем посередине строки без пометки: модель
|
|
6
|
+
* должна видеть, что ей показали не всё.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-node-client/bash
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
|
|
12
|
+
import { killTree, sandboxSupported, shellArgv } from './shell.mjs'
|
|
13
|
+
import { mkdirSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
import { t } from './i18n.mjs'
|
|
17
|
+
|
|
18
|
+
const MAX_OUTPUT = 48 * 1024
|
|
19
|
+
|
|
20
|
+
function clip (text) {
|
|
21
|
+
if (text.length <= MAX_OUTPUT) return text
|
|
22
|
+
return text.slice(0, MAX_OUTPUT) + t('bash.clipped', { shown: MAX_OUTPUT, total: text.length })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Выполнить команду.
|
|
27
|
+
* @param {object} options
|
|
28
|
+
* @param {string} options.command командная строка
|
|
29
|
+
* @param {string} [options.cwd]
|
|
30
|
+
* @param {number} [options.timeout] секунды (приходит от сервера)
|
|
31
|
+
* @param {string} [options.shell]
|
|
32
|
+
* @param {AbortSignal} [options.signal]
|
|
33
|
+
* @returns {Promise<{ok: boolean, results?: object[], code?: string, message?: string}>}
|
|
34
|
+
*/
|
|
35
|
+
export function runBash ({ command, cwd, timeout, shell = '/bin/sh', env, stdin, signal, sandbox = null }) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
if (typeof command !== 'string' || !command.trim()) {
|
|
38
|
+
resolve({ ok: false, code: 'bad_args', message: t('bash.needCommand') })
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
const seconds = Number(timeout) > 0 ? Number(timeout) : 60
|
|
42
|
+
// Песочница: argv оборачивается в bwrap, каталог передаётся через --chdir.
|
|
43
|
+
// Нет изоляции при запрошенном режиме — команда не запускается вообще.
|
|
44
|
+
let argv = [shell, '-c', command]
|
|
45
|
+
let enforcement = 'none'
|
|
46
|
+
if (sandbox && sandbox.mode !== 'full' && !sandboxSupported) {
|
|
47
|
+
// честно: эквивалента bwrap на этой платформе нет, и делать вид, что
|
|
48
|
+
// команда изолирована, опаснее, чем отказать
|
|
49
|
+
return Promise.resolve({
|
|
50
|
+
ok: false,
|
|
51
|
+
code: 'sandbox_unavailable',
|
|
52
|
+
message: `на платформе ${process.platform} песочницы нет: разрешён только режим full`,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
if (sandbox && sandbox.mode !== 'full') {
|
|
56
|
+
const probeResult = sandbox.probe?.() ?? { available: true }
|
|
57
|
+
if (!probeResult.available) {
|
|
58
|
+
return Promise.resolve({
|
|
59
|
+
ok: false,
|
|
60
|
+
code: 'sandbox_unavailable',
|
|
61
|
+
message: probeResult.reason ?? 'bwrap недоступен',
|
|
62
|
+
results: null,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
argv = ['bwrap', ...sandbox.args, '--', shell, '-c', command]
|
|
66
|
+
enforcement = 'bwrap'
|
|
67
|
+
}
|
|
68
|
+
// Windows: своя группа процессов и detached означают другое, поэтому
|
|
69
|
+
// платформенные различия собраны в lib/shell.mjs
|
|
70
|
+
const windows = process.platform === 'win32'
|
|
71
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
72
|
+
cwd: sandbox && sandbox.mode !== 'full' ? undefined : (cwd || process.cwd()),
|
|
73
|
+
// LC_ALL=C обязателен: отказ ядра приходит локализованным, и сигнатура
|
|
74
|
+
// «read-only file system» на русской локали не совпала бы
|
|
75
|
+
env: { ...process.env, LC_ALL: 'C', LANG: 'C', ...env },
|
|
76
|
+
stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
77
|
+
// своя группа процессов: иначе по таймауту убивается только `sh -c`,
|
|
78
|
+
// а внуки (`bash -c 'long &'`) продолжают работать на машине
|
|
79
|
+
detached: !windows,
|
|
80
|
+
})
|
|
81
|
+
let stdout = ''
|
|
82
|
+
let stderr = ''
|
|
83
|
+
let settled = false
|
|
84
|
+
const finish = (value) => {
|
|
85
|
+
if (settled) return
|
|
86
|
+
settled = true
|
|
87
|
+
clearTimeout(timer)
|
|
88
|
+
resolve(value)
|
|
89
|
+
}
|
|
90
|
+
const killGroup = () => {
|
|
91
|
+
// на POSIX — вся группа, на Windows — дерево через taskkill
|
|
92
|
+
killTree(child, 'SIGKILL')
|
|
93
|
+
}
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
killGroup()
|
|
96
|
+
finish({
|
|
97
|
+
ok: false,
|
|
98
|
+
code: 'timeout',
|
|
99
|
+
message: t('bash.timeout', { seconds }),
|
|
100
|
+
})
|
|
101
|
+
}, seconds * 1000)
|
|
102
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
103
|
+
|
|
104
|
+
if (stdin !== undefined) {
|
|
105
|
+
// stdin закрываем сразу: команда, которая ждёт ввода, не должна висеть
|
|
106
|
+
child.stdin.on('error', () => {})
|
|
107
|
+
child.stdin.end(typeof stdin === 'string' ? stdin : String(stdin))
|
|
108
|
+
}
|
|
109
|
+
child.stdout.on('data', (chunk) => { if (stdout.length < MAX_OUTPUT * 2) stdout += chunk })
|
|
110
|
+
child.stderr.on('data', (chunk) => { if (stderr.length < MAX_OUTPUT * 2) stderr += chunk })
|
|
111
|
+
child.on('error', (error) => finish({ ok: false, code: 'internal', message: error.message }))
|
|
112
|
+
child.on('close', (code, signalName) => {
|
|
113
|
+
const content = []
|
|
114
|
+
if (stdout) content.push({ type: 'text', text: clip(stdout) })
|
|
115
|
+
// stderr помечаем: иначе модель видит два одинаковых текстовых блока
|
|
116
|
+
if (stderr) content.push({ type: 'text', text: '[stderr] ' + clip(stderr) })
|
|
117
|
+
const report = { code, signal: signalName ?? null, cwd: cwd || process.cwd() }
|
|
118
|
+
if (sandbox) {
|
|
119
|
+
// что именно применили и был ли отказ ядра: без этого модель видит
|
|
120
|
+
// обычную ошибку команды и не понимает, что дело в песочнице
|
|
121
|
+
const verdict = sandbox.classify
|
|
122
|
+
? sandbox.classify({ code, stderr, mode: sandbox.mode, enforcement })
|
|
123
|
+
: { denied: false, signature: null, runnerFailure: false }
|
|
124
|
+
report.sandbox = {
|
|
125
|
+
mode: sandbox.mode,
|
|
126
|
+
enforcement,
|
|
127
|
+
denied: verdict.denied,
|
|
128
|
+
failed: verdict.failed ?? Number(code) !== 0,
|
|
129
|
+
signature: verdict.signature,
|
|
130
|
+
runnerFailure: verdict.runnerFailure,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
content.push({ type: 'text', text: JSON.stringify(report) })
|
|
134
|
+
finish({ ok: true, results: content })
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
signal?.addEventListener?.('abort', () => {
|
|
138
|
+
child.kill('SIGKILL')
|
|
139
|
+
finish({ ok: false, code: 'cancelled', message: t('bash.cancelled') })
|
|
140
|
+
}, { once: true })
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Каталог сессии: `$workdir/<session-id>`, создаётся при первой команде.
|
|
146
|
+
*
|
|
147
|
+
* Идентификатор сессии приходит от сервера и не обязан быть безопасным для
|
|
148
|
+
* имени каталога, поэтому всё, кроме букв, цифр, точки, дефиса и подчёркивания,
|
|
149
|
+
* заменяется на «_». Пустая сессия означает «работаем в самом workdir».
|
|
150
|
+
*
|
|
151
|
+
* @param {object} options
|
|
152
|
+
* @param {string} options.workdir корень рабочих каталогов
|
|
153
|
+
* @param {string} [options.session] идентификатор сессии
|
|
154
|
+
* @returns {string} путь каталога сессии
|
|
155
|
+
*/
|
|
156
|
+
export function sessionDir ({ workdir, session }) {
|
|
157
|
+
const root = String(workdir)
|
|
158
|
+
if (!session) return root
|
|
159
|
+
const safe = String(session).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)
|
|
160
|
+
// «.» и «..» проходят фильтр символов, но означают сам каталог и его родителя:
|
|
161
|
+
// с ними команда уезжает за пределы workdir
|
|
162
|
+
if (!safe || safe === '.' || safe === '..') {
|
|
163
|
+
throw new Error(`недопустимый идентификатор сессии: ${JSON.stringify(session)}`)
|
|
164
|
+
}
|
|
165
|
+
const dir = join(root, safe)
|
|
166
|
+
mkdirSync(dir, { recursive: true })
|
|
167
|
+
return dir
|
|
168
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Клиент dsh-nodes: состояние в ~/.dshnc, соединения по URL.
|
|
3
|
+
*
|
|
4
|
+
* Один клиент может работать с несколькими серверами, поэтому всё, что
|
|
5
|
+
* относится к соединению (токен, курсор, epoch, реконнект), хранится по URL.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-node-client/config
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { homedir, hostname } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { randomUUID } from 'node:crypto'
|
|
13
|
+
|
|
14
|
+
/** Каталог состояния. `DSHNC_HOME` переопределяет (удобно в тестах). */
|
|
15
|
+
export function home () {
|
|
16
|
+
return process.env.DSHNC_HOME ?? join(homedir(), '.dshnc')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ensureDir (dir) {
|
|
20
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Конфиг: id клиента и профили подключений. */
|
|
24
|
+
export function loadConfig () {
|
|
25
|
+
const dir = home()
|
|
26
|
+
ensureDir(dir)
|
|
27
|
+
const file = join(dir, 'config.json')
|
|
28
|
+
let config = { id: null, name: null, aliases: [], servers: {} }
|
|
29
|
+
if (existsSync(file)) {
|
|
30
|
+
config = { ...config, ...JSON.parse(readFileSync(file, 'utf8')) }
|
|
31
|
+
}
|
|
32
|
+
if (!config.id) {
|
|
33
|
+
config.id = randomUUID()
|
|
34
|
+
config.name = (process.env.DSHNC_NAME ?? hostname() ?? 'node').slice(0, 64)
|
|
35
|
+
saveConfig(config)
|
|
36
|
+
}
|
|
37
|
+
config.servers ??= {}
|
|
38
|
+
config.aliases ??= []
|
|
39
|
+
config.workdir ??= null
|
|
40
|
+
// где разрешено размещать каталоги сессий; по умолчанию — только сам workdir
|
|
41
|
+
config.roots ??= null
|
|
42
|
+
// потолок прав, который ставит владелец узла
|
|
43
|
+
config.access ??= { max: 'rw', ask: true }
|
|
44
|
+
// правила доступа на каждый сервер отдельно: policy[url] важнее общих полей
|
|
45
|
+
config.policy ??= {}
|
|
46
|
+
return config
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Записать конфиг, не затирая чужих изменений.
|
|
51
|
+
*
|
|
52
|
+
* Демон сохраняет курсор после каждой команды, а CLI тем временем может
|
|
53
|
+
* поменять рабочую директорию или алиасы. Если писать свой объект целиком,
|
|
54
|
+
* правка из `dshnc workdir` пропадает через секунду — поэтому читаем файл и
|
|
55
|
+
* накладываем свои ключи поверх, а серверы сливаем по URL.
|
|
56
|
+
*/
|
|
57
|
+
export function saveConfig (config) {
|
|
58
|
+
const dir = home()
|
|
59
|
+
ensureDir(dir)
|
|
60
|
+
const file = join(dir, 'config.json')
|
|
61
|
+
let onDisk = {}
|
|
62
|
+
try {
|
|
63
|
+
if (existsSync(file)) onDisk = JSON.parse(readFileSync(file, 'utf8')) ?? {}
|
|
64
|
+
} catch {
|
|
65
|
+
onDisk = {}
|
|
66
|
+
}
|
|
67
|
+
// null означает «не задано» — такое значение не должно затирать то, что уже
|
|
68
|
+
// лежит на диске: у демона в памяти workdir может быть ещё null
|
|
69
|
+
const own = {}
|
|
70
|
+
for (const [key, value] of Object.entries(config)) {
|
|
71
|
+
if (value === null || value === undefined) continue
|
|
72
|
+
own[key] = value
|
|
73
|
+
}
|
|
74
|
+
const merged = { ...onDisk, ...own }
|
|
75
|
+
merged.servers = { ...(onDisk.servers ?? {}) }
|
|
76
|
+
for (const [url, state] of Object.entries(config.servers ?? {})) {
|
|
77
|
+
const clean = {}
|
|
78
|
+
for (const [key, value] of Object.entries(state ?? {})) {
|
|
79
|
+
if (value === null || value === undefined) continue
|
|
80
|
+
clean[key] = value
|
|
81
|
+
}
|
|
82
|
+
merged.servers[url] = { ...(onDisk.servers?.[url] ?? {}), ...clean }
|
|
83
|
+
}
|
|
84
|
+
writeFileSync(file, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 })
|
|
85
|
+
return merged
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Запись одного сервера: токен, курсор, epoch поколения. */
|
|
89
|
+
export function serverState (config, url) {
|
|
90
|
+
config.servers ??= {}
|
|
91
|
+
config.servers[url] ??= { token: null, cursor: null, epoch: null, boot: null }
|
|
92
|
+
return config.servers[url]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Нормализовать URL: без хвостового слэша и с базовым путём dsh-nodes. */
|
|
96
|
+
export function normalizeUrl (input) {
|
|
97
|
+
const url = new URL(input)
|
|
98
|
+
url.hash = ''
|
|
99
|
+
url.search = ''
|
|
100
|
+
let path = url.pathname.replace(/\/+$/, '')
|
|
101
|
+
if (!path) path = '/dsh-nodes'
|
|
102
|
+
return `${url.origin}${path}`
|
|
103
|
+
}
|