@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/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
+ }