@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/sandbox.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/sessions.mjs
ADDED
|
@@ -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
|
+
}
|