@dr-co/dsh-node-client 0.0.0-stage → 0.0.59

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.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Песочница клиента: три режима поверх `bwrap`.
3
+ *
4
+ * Идея не наша — она взята у DSH (`@deepseek-ai/dsh-sandbox-local`, MIT): там
5
+ * ровно тот же профиль `bwrap` и те же таблицы сигнатур отказа. Повторяем
6
+ * профиль сами, потому что их пакет тянет cordis, платформенные ветки и 6 МБ
7
+ * политик, а нам нужны 40 строк и предсказуемое поведение.
8
+ *
9
+ * Главное, что нужно понимать про детект отказа: **ничего не предсказывается**.
10
+ * Команда запускается под изоляцией, ядро само не пускает запись, а мы уже после
11
+ * факта смотрим код возврата и stderr и решаем, был ли это отказ песочницы.
12
+ *
13
+ * `LC_ALL=C` обязателен: сообщения ядра локализованы («Файловая система доступна
14
+ * только для чтения»), и английская сигнатура на русской локали не совпадёт.
15
+ * DSH делает то же самое в `dsh-subprocess-local`.
16
+ *
17
+ * @module dsh-node-client/sandbox
18
+ */
19
+ import { spawnSync } from 'node:child_process'
20
+
21
+ /** Режимы по возрастанию прав: слева уже, справа шире. */
22
+ export const MODES = ['ro', 'rw', 'full']
23
+
24
+ /** Сигнатуры отказа, которые печатает ядро под `bwrap` (как в DSH). */
25
+ export const DENIAL_SIGNATURES = ['read-only file system']
26
+
27
+ /** Признаки поломки самого раннера: это не отказ политики, а «не запустилось». */
28
+ export const RUNNER_SIGNATURES = ['bwrap: ']
29
+
30
+ /**
31
+ * Отображение наших режимов на режимы DSH — чтобы тексты и лестница эскалации
32
+ * совпадали с тем, что модель видит в основном DSH.
33
+ */
34
+ export const DSH_MODES = { ro: 'read-only', rw: 'workspace-write', full: 'danger-full-access' }
35
+
36
+ /** Лестница «строго шире»: во что можно повышать режим. */
37
+ export const WIDER_MODES = {
38
+ ro: ['rw', 'full'],
39
+ rw: ['full'],
40
+ full: [],
41
+ }
42
+
43
+ /** Сравнить режимы: вернуть тот, что уже (ближе к ro). */
44
+ export function narrower (a, b) {
45
+ return MODES.indexOf(a) <= MODES.indexOf(b) ? a : b
46
+ }
47
+
48
+ /**
49
+ * Профиль `bwrap` для режима.
50
+ *
51
+ * - `ro` — весь хост только на чтение, сеть отрезана;
52
+ * - `rw` — плюс запись в каталог workdir и в /tmp, сеть отрезана;
53
+ * - `full` — профиля нет, команда запускается как есть.
54
+ *
55
+ * @param {object} options
56
+ * @param {'ro'|'rw'|'full'} options.mode
57
+ * @param {string} [options.workdir] каталог, разрешённый на запись в режиме rw
58
+ * @param {string} [options.cwd] рабочий каталог команды
59
+ * @param {string[]} [options.mask] каталоги, которые надо спрятать (--tmpfs)
60
+ * @returns {string[]} аргументы bwrap до `--`
61
+ */
62
+ export function profileArgs ({ mode, workdir, writable = [], cwd, mask = [] }) {
63
+ if (mode === 'full') return []
64
+ const args = [
65
+ '--ro-bind', '/', '/',
66
+ '--dev', '/dev',
67
+ '--unshare-pid',
68
+ '--unshare-net',
69
+ '--proc', '/proc',
70
+ '--die-with-parent',
71
+ ]
72
+ for (const path of mask) args.push('--tmpfs', path)
73
+ if (mode === 'rw') {
74
+ args.push('--tmpfs', '/tmp')
75
+ // запись разрешена в каждый разрешённый владельцем корень: сессия может
76
+ // переехать в соседний каталог (`session.chdir`), и он должен остаться
77
+ // доступным на запись, иначе rw превращается в ro после первого переезда
78
+ const targets = [...new Set([...(writable ?? []), workdir].filter(Boolean))]
79
+ for (const path of targets) args.push('--bind', path, path)
80
+ }
81
+ if (cwd) args.push('--chdir', cwd)
82
+ return args
83
+ }
84
+
85
+ let probeCache = null
86
+
87
+ /**
88
+ * Работает ли `bwrap` на этой машине. Проверяется один раз за процесс: профиль
89
+ * `ro` против самого себя, команда `true`. Не работает — значит изоляции нет,
90
+ * и режимы `ro`/`rw` обязаны отказать, а не сделать вид, что всё хорошо.
91
+ *
92
+ * @returns {{available: boolean, version?: string, reason?: string}}
93
+ */
94
+ export function probe (run = spawnSync) {
95
+ if (probeCache) return probeCache
96
+ const args = [...profileArgs({ mode: 'ro' }), '--', 'true']
97
+ try {
98
+ const result = run('bwrap', args, { timeout: 5000, stdio: 'ignore' })
99
+ if (result.status === 0) {
100
+ const version = run('bwrap', ['--version'], { timeout: 2000, encoding: 'utf8' })
101
+ probeCache = { available: true, version: String(version.stdout ?? '').trim() }
102
+ } else {
103
+ probeCache = { available: false, reason: `bwrap вернул ${result.status}` }
104
+ }
105
+ } catch (error) {
106
+ probeCache = { available: false, reason: error?.message ?? 'bwrap недоступен' }
107
+ }
108
+ return probeCache
109
+ }
110
+
111
+ /** Сбросить кэш пробы (тесты). */
112
+ export function resetProbe () {
113
+ probeCache = null
114
+ }
115
+
116
+ /**
117
+ * Разобрать результат запуска: это отказ песочницы, поломка раннера или
118
+ * обычная ошибка команды.
119
+ *
120
+ * @param {object} options
121
+ * @param {number|null} options.code код возврата
122
+ * @param {string} [options.stderr] stderr процесса
123
+ * @param {'ro'|'rw'|'full'} options.mode
124
+ * @param {string} [options.enforcement]
125
+ * `denied` — ядро отказало в доступе (сигнатура в stderr), `failed` — команда
126
+ * вернула ненулевой код. Это разные факты: составная команда может вернуть 0,
127
+ * хотя одна из её частей уже отвергнута, и наоборот.
128
+ *
129
+ * @returns {{denied: boolean, failed: boolean, runnerFailure: boolean, signature: string|null}}
130
+ */
131
+ export function classify ({ code, stderr = '', mode, enforcement = 'bwrap' }) {
132
+ const text = String(stderr ?? '').toLowerCase()
133
+ const signature = DENIAL_SIGNATURES.find((needle) => text.includes(needle)) ?? null
134
+ const runnerFailure = RUNNER_SIGNATURES.some((needle) => text.includes(needle))
135
+ // отказ — только при ненулевом коде возврата: команда, которая просто
136
+ // напечатала «read-only file system», отказом не считается
137
+ // Код возврата не обязателен: `echo … ; echo …` в одной строке возвращает 0,
138
+ // хотя запись уже была отвергнута. Сигнатура в stderr — единственный
139
+ // надёжный признак, а ложное срабатывание стоит одного лишнего вопроса.
140
+ const denied = mode !== 'full' && !runnerFailure && signature !== null
141
+ return { denied, failed: Number(code) !== 0, runnerFailure, signature: denied ? signature : null }
142
+ }
143
+
144
+ /** Ошибка «изоляции нет»: команда не должна запускаться вообще. */
145
+ export class SandboxUnavailableError extends Error {
146
+ constructor (message, reason) {
147
+ super(message)
148
+ this.name = 'SandboxUnavailableError'
149
+ this.code = 'sandbox_unavailable'
150
+ this.reason = reason
151
+ }
152
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Каталоги сессий на клиенте.
3
+ *
4
+ * У каждой сессии свой каталог внутри workdir: `<workdir>/<session-id>`. Он же
5
+ * рабочий каталог команд этой сессии. Каталог можно переключить через встроенный
6
+ * плагин `session` (метод `chdir`), но только внутри `roots` из конфига — иначе
7
+ * песочница перестала бы что-либо значить.
8
+ *
9
+ * Выбор живёт в `state.sqlite`, поэтому переживает рестарт демона.
10
+ *
11
+ * @module dsh-node-client/sessions
12
+ */
13
+ import { chmodSync, existsSync, mkdirSync, realpathSync, statSync } from 'node:fs'
14
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
15
+
16
+ /** Недопустимый идентификатор сессии: `.` и `..` означают не имя, а переход. */
17
+ export function safeSessionName (session) {
18
+ const safe = String(session ?? '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)
19
+ if (!safe || safe === '.' || safe === '..') {
20
+ throw Object.assign(new Error(`недопустимый идентификатор сессии: ${JSON.stringify(session)}`), { code: 'bad_session' })
21
+ }
22
+ return safe
23
+ }
24
+
25
+ /**
26
+ * Канонический путь: для существующего — `realpath`, для несуществующего хвоста —
27
+ * канонизация ближайшего существующего родителя плюс остаток.
28
+ */
29
+ export function canonicalPath (input) {
30
+ const absolute = isAbsolute(input) ? input : resolve(input)
31
+ if (existsSync(absolute)) return realpathSync(absolute)
32
+ const parts = []
33
+ let current = absolute
34
+ while (!existsSync(current)) {
35
+ parts.unshift(current.slice(current.lastIndexOf(sep) + 1))
36
+ const parent = dirname(current)
37
+ if (parent === current) break
38
+ current = parent
39
+ }
40
+ return join(existsSync(current) ? realpathSync(current) : current, ...parts)
41
+ }
42
+
43
+ /** Лежит ли путь внутри корня (по границе каталога, а не по префиксу строки). */
44
+ export function inside (path, root) {
45
+ const canonicalRoot = canonicalPath(root)
46
+ if (path === canonicalRoot) return true
47
+ return path.startsWith(canonicalRoot.endsWith(sep) ? canonicalRoot : canonicalRoot + sep)
48
+ }
49
+
50
+ /** Какие каталоги разрешены к размещению сессий. `['/']` — весь хост. */
51
+ export function normalizeRoots (roots) {
52
+ const list = Array.isArray(roots) && roots.length ? roots : ['/']
53
+ return list.map((root) => canonicalPath(String(root)))
54
+ }
55
+
56
+ /**
57
+ * Проверить и подготовить каталог сессии.
58
+ *
59
+ * @param {object} options
60
+ * @param {string} options.workdir базовый каталог (из настроек узла)
61
+ * @param {string} options.session идентификатор сессии
62
+ * @param {string[]} [options.roots] где разрешено создавать каталоги
63
+ * @returns {{dir: string, created: boolean}}
64
+ */
65
+ export function prepareSessionDir ({ workdir, session, roots }) {
66
+ if (typeof workdir !== 'string' || !workdir.trim()) {
67
+ throw Object.assign(new Error('рабочая директория не задана'), { code: 'workdir_invalid' })
68
+ }
69
+ const allowed = normalizeRoots(roots)
70
+ const dir = join(canonicalPath(workdir), safeSessionName(session))
71
+ assertInside(dir, allowed)
72
+ const created = !existsSync(dir)
73
+ if (created) mkdirSync(dir, { recursive: true })
74
+ if (!statSync(dir).isDirectory()) {
75
+ throw Object.assign(new Error(`${dir} не каталог`), { code: 'workdir_invalid' })
76
+ }
77
+ return { dir, created }
78
+ }
79
+
80
+ /** Проверить, что путь внутри одного из разрешённых корней. */
81
+ export function assertInside (path, roots) {
82
+ const allowed = normalizeRoots(roots)
83
+ const target = canonicalPath(path)
84
+ if (!allowed.some((root) => inside(target, root))) {
85
+ throw Object.assign(new Error(`каталог ${target} вне разрешённых: ${allowed.join(', ')}`), { code: 'workdir_denied' })
86
+ }
87
+ return target
88
+ }
89
+
90
+ /** Хранилище выбранных каталогов сессий. */
91
+ export function createSessionStore (store) {
92
+ return {
93
+ /**
94
+ * Каталог сессии: выбранный ранее, иначе `<workdir>/<session>`.
95
+ * @returns {string}
96
+ */
97
+ dir (session, { workdir, roots } = {}) {
98
+ // выбранный ранее каталог уже проверен по `roots` в момент переключения
99
+ const saved = store.readSessionDir?.(session)
100
+ if (saved && existsSync(saved)) return saved
101
+ return prepareSessionDir({ workdir, session, roots }).dir
102
+ },
103
+
104
+ /**
105
+ * Переключить каталог сессии. Каталог должен существовать: создание — это
106
+ * отдельное действие (`mkdir`), чтобы `chdir` не делал неявных вещей.
107
+ */
108
+ chdir (session, path, { roots } = {}) {
109
+ const target = assertInside(path, roots)
110
+ if (!existsSync(target)) {
111
+ throw Object.assign(new Error(`каталога ${target} нет`), { code: 'workdir_not_found' })
112
+ }
113
+ if (!statSync(target).isDirectory()) {
114
+ throw Object.assign(new Error(`${target} не каталог`), { code: 'workdir_invalid' })
115
+ }
116
+ store.writeSessionDir?.(session, target)
117
+ return { dir: target }
118
+ },
119
+
120
+ /**
121
+ * Создать каталог внутри разрешённых корней.
122
+ * @param {string} session сессия
123
+ * @param {string} path путь
124
+ * @param {object} options
125
+ * @param {string[]} options.roots разрешённые корни
126
+ * @param {number} [options.mode] права (по умолчанию 0o755)
127
+ */
128
+ mkdir (session, path, { roots, mode = 0o755 } = {}) {
129
+ const target = assertInside(path, roots)
130
+ if (existsSync(target)) {
131
+ if (!statSync(target).isDirectory()) {
132
+ throw Object.assign(new Error(`${target} уже существует и это не каталог`), { code: 'workdir_invalid' })
133
+ }
134
+ return { dir: target, created: false }
135
+ }
136
+ mkdirSync(target, { recursive: true, mode })
137
+ try {
138
+ chmodSync(target, mode)
139
+ } catch {}
140
+ return { dir: target, created: true }
141
+ },
142
+
143
+ /** Текущий каталог сессии, без создания. */
144
+ cwd (session, { workdir, roots } = {}) {
145
+ return this.dir(session, { workdir, roots })
146
+ },
147
+ }
148
+ }
package/lib/shell.mjs ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Чем исполнять команду на этой платформе.
3
+ *
4
+ * Модель зовёт API `shell`, а не «bash»: на Windows та же команда уходит в
5
+ * PowerShell, и называть это bash было бы враньём. Здесь собрано всё, что
6
+ * зависит от платформы: как собрать argv, как убить дерево процессов и есть ли
7
+ * вообще песочница.
8
+ *
9
+ * @module dsh-node-client/shell
10
+ */
11
+ import { spawn } from 'node:child_process'
12
+
13
+ /**
14
+ * Время с точностью лучше миллисекунды.
15
+ *
16
+ * `Date.now()` даёт миллисекунды, а на пинге в пределах одной сети этого мало:
17
+ * отклик бывает меньше миллисекунды, и разница часов утонула бы в округлении.
18
+ * `performance` даёт доли, но его начало отсчёта не сравнимо между машинами,
19
+ * поэтому складываем со `timeOrigin` — это уже обычное настенное время.
20
+ *
21
+ * @returns {number} миллисекунды с долями
22
+ */
23
+ export function preciseNow () {
24
+ return Math.round((performance.timeOrigin + performance.now()) * 1000) / 1000
25
+ }
26
+
27
+ /** Платформа, для которой выбираем реализацию. */
28
+ export const platform = process.platform
29
+
30
+ /** Есть ли на платформе песочница, которой мы умеем пользоваться. */
31
+ export const sandboxSupported = platform !== 'win32'
32
+
33
+ /**
34
+ * Собрать argv для запуска командной строки.
35
+ *
36
+ * @param {string} command командная строка от модели
37
+ * @param {object} [options]
38
+ * @param {string} [options.shell] путь к оболочке (POSIX)
39
+ * @returns {{file: string, args: string[]}}
40
+ */
41
+ export function shellArgv (command, { shell } = {}) {
42
+ if (platform === 'win32') {
43
+ // -NoProfile: чужой профиль меняет поведение и печатает баннеры в вывод;
44
+ // -NonInteractive: иначе команда может встать на приглашении
45
+ return {
46
+ file: process.env.DSHNC_POWERSHELL ?? 'powershell.exe',
47
+ args: ['-NoProfile', '-NonInteractive', '-Command', command],
48
+ }
49
+ }
50
+ return { file: shell ?? '/bin/sh', args: ['-c', command] }
51
+ }
52
+
53
+ /**
54
+ * Собрать argv с песочницей — так же, как для обычной команды.
55
+ *
56
+ * Задания (`job`) идут тем же путём, что и `exec`: в DSH фоновый процесс — это
57
+ * тот же процесс, и песочница применяется на спавне. Другая политика для
58
+ * фоновой работы означала бы, что политика узла обходится одной строкой.
59
+ *
60
+ * @param {object} options
61
+ * @param {string} options.command командная строка
62
+ * @param {string} [options.shell]
63
+ * @param {object|null} [options.sandbox] `{mode, args, probe}`
64
+ * @returns {{argv: string[], enforcement: string, error?: object}}
65
+ */
66
+ export function commandArgv ({ command, shell, sandbox = null }) {
67
+ const base = shellArgv(command, { shell })
68
+ let argv = [base.file, ...base.args]
69
+ if (!sandbox || sandbox.mode === 'full') return { argv, enforcement: 'none' }
70
+ if (!sandboxSupported) {
71
+ return {
72
+ argv,
73
+ enforcement: 'none',
74
+ error: { ok: false, code: 'sandbox_unavailable', message: `на платформе ${process.platform} песочницы нет: разрешён только режим full` },
75
+ }
76
+ }
77
+ const probeResult = sandbox.probe?.() ?? { available: true }
78
+ if (!probeResult.available) {
79
+ return {
80
+ argv,
81
+ enforcement: 'none',
82
+ error: { ok: false, code: 'sandbox_unavailable', message: probeResult.reason ?? 'bwrap недоступен', results: null },
83
+ }
84
+ }
85
+ // каталог задаётся самим профилем песочницы (--chdir), поэтому `cwd` не нужен
86
+ argv = ['bwrap', ...(sandbox.args ?? []), '--', base.file, ...base.args]
87
+ return { argv, enforcement: 'bwrap' }
88
+ }
89
+
90
+ /**
91
+ * Запустить процесс так, чтобы его можно было убить вместе с потомками.
92
+ *
93
+ * POSIX: своя группа (`detached`), сигнал всей группе через `-pid`.
94
+ * Windows: отдельного механизма нет, поэтому убиваем дерево `taskkill /T /F`.
95
+ *
96
+ * @param {string} command
97
+ * @param {object} [options]
98
+ * @param {string} [options.cwd]
99
+ * @param {object} [options.env]
100
+ * @param {string} [options.shell]
101
+ * @returns {import('node:child_process').ChildProcess}
102
+ */
103
+ export function spawnCommand (command, { cwd, env, shell } = {}) {
104
+ const { file, args } = shellArgv(command, { shell })
105
+ return spawn(file, args, {
106
+ cwd: cwd || undefined,
107
+ env: env ?? process.env,
108
+ // своя группа только на POSIX: на Windows флаг означает другое окно
109
+ detached: platform !== 'win32',
110
+ stdio: ['pipe', 'pipe', 'pipe'],
111
+ })
112
+ }
113
+
114
+ /**
115
+ * Убить процесс вместе с потомками.
116
+ * @param {import('node:child_process').ChildProcess} child
117
+ * @param {NodeJS.Signals} [signal]
118
+ * @returns {boolean} удалось ли отправить сигнал
119
+ */
120
+ export function killTree (child, signal = 'SIGTERM') {
121
+ if (!child?.pid) return false
122
+ if (platform === 'win32') {
123
+ try {
124
+ spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }).unref()
125
+ return true
126
+ } catch {
127
+ return false
128
+ }
129
+ }
130
+ try {
131
+ // минус — это группа процессов: bwrap и всё, что он породил, умрёт вместе
132
+ process.kill(-child.pid, signal)
133
+ return true
134
+ } catch {
135
+ try {
136
+ child.kill(signal)
137
+ return true
138
+ } catch {
139
+ return false
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Код возврата из результата PowerShell.
146
+ *
147
+ * `$LASTEXITCODE` есть только у внешних программ; если команда — чистый
148
+ * cmdlet, PowerShell вернёт 0, и это правильный ответ.
149
+ *
150
+ * @returns {string} хвост команды, который печатает код
151
+ */
152
+ export function exitCodeTail () {
153
+ return platform === 'win32' ? '; exit $LASTEXITCODE' : ''
154
+ }