@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.
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Встроенный плагин `session`: переключение рабочего каталога сессии.
3
+ *
4
+ * Это не внешний инструмент, а реконфиг сессии: модель говорит «дальше работаем
5
+ * в каталоге foobar», и все последующие команды этой сессии стартуют там. Нужно
6
+ * ровно для удобства — чтобы модель не повторяла `cd` в каждой команде.
7
+ *
8
+ * Каталог проверяется по списку `roots` из настроек узла: вне их переключение
9
+ * запрещено, иначе песочница потеряла бы смысл.
10
+ *
11
+ * @module dsh-node-client/plugins/session
12
+ */
13
+ import { CLIENT_VERSION } from '../api.mjs'
14
+ import { preciseNow } from '../shell.mjs'
15
+ import { t } from '../i18n.mjs'
16
+
17
+ export function api () {
18
+ return {
19
+ name: 'session',
20
+ // контракт изменился (chdir стал строгим, добавился mkdir) — версия обязана
21
+ // вырасти, иначе узлы с разными схемами слипнутся в одну запись
22
+ // контракт снова вырос: добавлен метод ping
23
+ version: '4',
24
+ description: t('session.api.description'),
25
+ methods: [
26
+ {
27
+ method: 'chdir',
28
+ description: t('session.chdir.description'),
29
+ args: {
30
+ type: 'object',
31
+ properties: {
32
+ path: { type: 'string', description: t('session.chdir.arg.path') },
33
+ },
34
+ required: ['path'],
35
+ additionalProperties: false,
36
+ },
37
+ },
38
+ {
39
+ method: 'mkdir',
40
+ description: t('session.mkdir.description'),
41
+ args: {
42
+ type: 'object',
43
+ properties: {
44
+ path: { type: 'string', description: t('session.mkdir.arg.path') },
45
+ mode: { type: 'string', description: t('session.mkdir.arg.mode') },
46
+ },
47
+ required: ['path'],
48
+ additionalProperties: false,
49
+ },
50
+ },
51
+ {
52
+ method: 'ping',
53
+ description: t('session.ping.description'),
54
+ args: { type: 'object', properties: {}, additionalProperties: false },
55
+ },
56
+ {
57
+ method: 'approve',
58
+ description: t('session.approve.description'),
59
+ args: {
60
+ type: 'object',
61
+ properties: {
62
+ id: { type: 'string', description: t('session.approve.arg.id') },
63
+ text: { type: 'string', description: t('session.approve.arg.text') },
64
+ mode: { type: 'string', description: t('session.approve.arg.mode') },
65
+ },
66
+ required: ['id', 'text'],
67
+ additionalProperties: false,
68
+ },
69
+ },
70
+ {
71
+ method: 'cwd',
72
+ description: t('session.cwd.description'),
73
+ args: {
74
+ type: 'object',
75
+ properties: {
76
+ session: { type: 'string', description: t('session.arg.session') },
77
+ },
78
+ additionalProperties: false,
79
+ },
80
+ },
81
+ ],
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Выполнить команду плагина.
87
+ * @param {{method: string, args: object}} command
88
+ * @param {{session: string, sessions?: object, roots?: string[], workdir?: string}} ctx
89
+ */
90
+ export async function handle (command, ctx = {}) {
91
+ // Проверка связи не зависит ни от сессии, ни от каталогов: отвечаем раньше
92
+ // любых проверок, иначе `ping` на узле без сессий звучал бы как поломка.
93
+ if (command.method === 'ping') {
94
+ // Возвращаем отметку сервера обратно и добавляем свою: по этой паре
95
+ // сервер считает и время отклика, и разницу часов между машинами.
96
+ return {
97
+ ok: true,
98
+ results: [{
99
+ type: 'text',
100
+ text: JSON.stringify({
101
+ pong: preciseNow(),
102
+ // Поле объявлено, но не заполняется: узел не знает, сколько занял
103
+ // раундтрип по часам сервера. Сервер подставит значение сам.
104
+ rttMs: null,
105
+ sentAt: command.issuedAt ?? null,
106
+ upSeconds: Math.round(process.uptime()),
107
+ platform: process.platform,
108
+ version: CLIENT_VERSION,
109
+ }),
110
+ }],
111
+ }
112
+ }
113
+
114
+ const session = command.args?.session ?? ctx.session
115
+ const sessions = ctx.sessions
116
+ if (!sessions) {
117
+ return { ok: false, code: 'session_unavailable', message: t('session.noStore'), results: null }
118
+ }
119
+ if (!session) {
120
+ return { ok: false, code: 'bad_session', message: t('session.noSession'), results: null }
121
+ }
122
+
123
+ if (command.method === 'cwd') {
124
+ try {
125
+ const dir = sessions.cwd(session, { workdir: ctx.workdir, roots: ctx.roots })
126
+ return { ok: true, code: 'cwd', message: dir, results: [{ type: 'text', text: dir }] }
127
+ } catch (error) {
128
+ return { ok: false, code: error.code ?? 'workdir_invalid', message: error.message, results: null }
129
+ }
130
+ }
131
+
132
+ // Апрув — внутренняя команда: она не ходит через `bash`, поэтому песочница её
133
+ // не касается и отказать ей некому. Спрашиваем человека инструментом из
134
+ // настроек узла; нет инструмента — честная ошибка, а не тишина.
135
+ if (command.method === 'approve') {
136
+ const tool = ctx.approver?.command
137
+ if (!tool) {
138
+ return { ok: false, code: 'no_local_approver', message: t('session.approve.noTool'), results: null }
139
+ }
140
+ const prompt = String(command.args.text ?? '')
141
+ // Идентификаторы для инструмента вопроса: по ним диалог может запомнить
142
+ // «отвечать да до конца вопроса» и «до конца сессии».
143
+ const payload = JSON.stringify({
144
+ id: command.args.id,
145
+ mode: command.args.mode ?? null,
146
+ text: prompt,
147
+ session: command.session ?? null,
148
+ question: command.question ?? null,
149
+ })
150
+ const started = Date.now()
151
+ try {
152
+ const verdict = await ctx.approver.run({ tool, prompt, payload, signal: ctx.signal, timeout: command.timeout })
153
+ return {
154
+ ok: true,
155
+ code: verdict ? 'approved' : 'cancelled',
156
+ message: verdict ? 'ok' : 'cancel',
157
+ results: [{ type: 'text', text: JSON.stringify({ approved: Boolean(verdict), seconds: Math.round((Date.now() - started) / 1000) }) }],
158
+ }
159
+ } catch (error) {
160
+ return { ok: false, code: error.code ?? 'approve_failed', message: error.message, results: null }
161
+ }
162
+ }
163
+
164
+ if (command.method === 'mkdir') {
165
+ try {
166
+ const mode = typeof command.args.mode === 'string' ? Number.parseInt(command.args.mode, 8) : undefined
167
+ const result = sessions.mkdir(session, command.args.path, {
168
+ roots: ctx.roots,
169
+ mode: Number.isFinite(mode) ? mode : undefined,
170
+ })
171
+ return {
172
+ ok: true,
173
+ code: 'mkdir',
174
+ message: result.dir,
175
+ results: [{ type: 'text', text: `${result.dir}${result.created ? t('session.created') : t('session.exists')}` }],
176
+ }
177
+ } catch (error) {
178
+ return { ok: false, code: error.code ?? 'workdir_denied', message: error.message, results: null }
179
+ }
180
+ }
181
+
182
+ if (command.method !== 'chdir') {
183
+ return { ok: false, code: 'unknown_method', message: `session: unknown method ${command.method}`, results: null }
184
+ }
185
+
186
+ try {
187
+ const result = sessions.chdir(session, command.args.path, { roots: ctx.roots })
188
+ return { ok: true, code: 'chdir', message: result.dir, results: [{ type: 'text', text: result.dir }] }
189
+ } catch (error) {
190
+ return { ok: false, code: error.code ?? 'workdir_denied', message: error.message, results: null }
191
+ }
192
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Плагин `shell`: выполнить команду на узле и вернуть результат.
3
+ *
4
+ * Раньше API назывался `bash`, что было правдой только на POSIX: на Windows та
5
+ * же команда уходит в PowerShell. Имя `shell` честнее, а старое имя осталось
6
+ * отдельным плагином-алиасом, чтобы не ломать уже настроенные серверы.
7
+ *
8
+ * @module dsh-node-client/plugins/shell
9
+ */
10
+ import { runBash } from '../bash.mjs'
11
+ import { t } from '../i18n.mjs'
12
+
13
+ /** Общее описание метода: один и тот же exec для shell и для старого bash. */
14
+ export function execMethod () {
15
+ return {
16
+ method: 'exec',
17
+ description: t('bash.exec.description'),
18
+ args: {
19
+ type: 'object',
20
+ properties: {
21
+ command: { type: 'string', description: t('bash.arg.command') },
22
+ cwd: { type: 'string', description: t('bash.arg.cwd') },
23
+ timeout: { type: 'number', description: t('bash.arg.timeout') },
24
+ stdin: { type: 'string', description: t('bash.arg.stdin') },
25
+ },
26
+ required: ['command'],
27
+ additionalProperties: false,
28
+ },
29
+ }
30
+ }
31
+
32
+ export function api () {
33
+ return {
34
+ name: 'shell',
35
+ version: '1',
36
+ description: t('bash.api.description'),
37
+ methods: [execMethod()],
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Исполнить команду.
43
+ * @param {{method: string, args: object, timeout?: number}} command
44
+ * @param {{shell?: string, signal?: AbortSignal}} ctx
45
+ */
46
+ export async function handle (command, ctx = {}) {
47
+ if (command.method !== 'exec') {
48
+ return { ok: false, code: 'unknown_method', message: `shell: unknown method ${command.method}` }
49
+ }
50
+ const args = command.args ?? {}
51
+ // Приоритет: явный cwd в аргументах → каталог сессии → ничего (наследуем).
52
+ let cwd = args.cwd
53
+ if (!cwd && ctx.sessions && ctx.session) {
54
+ try {
55
+ cwd = ctx.sessions.cwd(ctx.session, { workdir: ctx.workdir, roots: ctx.roots })
56
+ } catch (error) {
57
+ // каталог сессии не сделали — лучше сказать прямо, чем исполнить команду
58
+ // в чужом каталоге и сделать вид, что так и надо
59
+ return { ok: false, code: error.code ?? 'bad_session', message: error.message }
60
+ }
61
+ }
62
+ return runBash({
63
+ command: args.command,
64
+ cwd,
65
+ timeout: command.timeout ?? args.timeout,
66
+ stdin: args.stdin,
67
+ sandbox: ctx.sandbox ?? null,
68
+ shell: ctx.shell,
69
+ signal: ctx.signal,
70
+ })
71
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Присутствие клиента: WebSocket с `hello`, `alive` и кадром `state`.
3
+ *
4
+ * Токен едет подпротоколом — заголовком `Sec-WebSocket-Protocol`, а не в URL.
5
+ * Клиент не обязан держать соединение: без него он работает опросом, просто
6
+ * узнаёт о работе с задержкой до периода опроса.
7
+ *
8
+ * @module dsh-node-client/presence
9
+ */
10
+ import { PROTO } from './api.mjs'
11
+ import { pingDelay } from './ping-policy.mjs'
12
+ import { t } from './i18n.mjs'
13
+
14
+ /**
15
+ * Открыть присутствие.
16
+ * @param {object} options
17
+ * @param {string} options.base адрес сервера, например `https://dsh.example/dsh-nodes`
18
+ * @param {string} options.token токен узла
19
+ * @param {string|null} options.epoch поколение, которое клиент помнит
20
+ * @param {number|null} options.known курсор узла
21
+ * @param {(event: object) => void} options.onEvent `state` | `hello-ok` | `closed`
22
+ * @param {number} [options.aliveMs] период `alive`
23
+ * @param {(level: string, message: string) => void} [options.log]
24
+ * @returns {{sendAlive: (known: number|null) => void, close: () => void, epoch: () => number|null}}
25
+ */
26
+ export function openPresence ({ base, token, epoch = null, known = null, getKnown = null, onEvent, workdir = null,
27
+ access = null, allowEscalation = true, uptimeSeconds = null, approver = null, aliveMs = 20_000,
28
+ pingInterval = 0, maxPingIntervalFactor = 0, startMode = null, log = () => {} }) {
29
+ const wsUrl = base.replace(/^http/, 'ws') + '/v1/ws'
30
+ let serverEpoch = null
31
+ let closed = false
32
+ let socket
33
+ let aliveTimer
34
+ // Расписание прикладного пинга: см. lib/ping-policy.mjs. Здесь только
35
+ // исполнение — арифметика живёт в чистой функции и проверяется тестом.
36
+ let pingTimer = null
37
+ let pongWatchdog = null
38
+ let lastSeenAt = Date.now()
39
+ let ticksWithoutCommands = 0
40
+ let pingSeq = 0
41
+ let awaitingPong = null
42
+ let connected = false
43
+ /** Что клиент знает о последнем номере команды прямо сейчас. */
44
+ const currentKnown = () => (typeof getKnown === 'function' ? getKnown() : known) ?? null
45
+
46
+ const send = (payload) => {
47
+ if (closed || socket?.readyState !== 1) return
48
+ try {
49
+ socket.send(JSON.stringify(payload))
50
+ } catch (error) {
51
+ log('warn', t('presence.sendFailed', { message: error.message }))
52
+ }
53
+ }
54
+
55
+ try {
56
+ socket = new WebSocket(wsUrl, [token])
57
+ } catch (error) {
58
+ log('warn', t('presence.openFailed', { message: error.message }))
59
+ return { sendAlive: () => {}, close: () => {}, epoch: () => null }
60
+ }
61
+
62
+ /**
63
+ * Поставить пинг на паузу по правилам (арифметика — в ping-policy.mjs).
64
+ *
65
+ * Пауза отсчитывается от последнего успешного общения: если сервер только что
66
+ * ответил, пинг не нужен — таймер перезапустится.
67
+ */
68
+ function schedulePing () {
69
+ if (pingTimer) clearTimeout(pingTimer)
70
+ const delay = pingDelay({ pingInterval, factor: maxPingIntervalFactor, ticks: ticksWithoutCommands })
71
+ if (!delay) return
72
+ const elapsed = Date.now() - lastSeenAt
73
+ pingTimer = setTimeout(() => {
74
+ if (Date.now() - lastSeenAt < delay) return schedulePing()
75
+ const id = ++pingSeq
76
+ awaitingPong = id
77
+ send({ t: 'ping', id })
78
+ pongWatchdog = setTimeout(() => {
79
+ log('warn', t('presence.pongTimeout', { seconds: Math.round(delay / 1000) }))
80
+ try {
81
+ socket.close(4002, 'нет понга')
82
+ } catch {}
83
+ }, delay)
84
+ if (typeof pongWatchdog.unref === 'function') pongWatchdog.unref()
85
+ }, Math.max(0, delay - elapsed))
86
+ if (typeof pingTimer.unref === 'function') pingTimer.unref()
87
+ }
88
+
89
+ /** Любое успешное общение с сервером сдвигает отсчёт пинга. */
90
+ function noteSeen () {
91
+ lastSeenAt = Date.now()
92
+ if (pongWatchdog) {
93
+ clearTimeout(pongWatchdog)
94
+ pongWatchdog = null
95
+ }
96
+ schedulePing()
97
+ }
98
+
99
+ /** Сервер прислал команды: растягивать паузу больше не за что. */
100
+ function noteCommands () {
101
+ ticksWithoutCommands = 0
102
+ noteSeen()
103
+ }
104
+
105
+ socket.addEventListener('open', () => {
106
+ connected = true
107
+ // workdir едет в hello: сервер покажет его в админке и будет знать,
108
+ // где у узла корень рабочих каталогов сессий
109
+ send({ t: 'hello', epoch, known, proto: PROTO, workdir: workdir ?? null, access: access ?? null, allowEscalation: allowEscalation ?? true, uptimeSeconds: uptimeSeconds ?? null, approver: approver ?? null, base: startMode ?? null })
110
+ // номер берём в момент отправки: иначе клиент вечно рапортует то, что знал
111
+ // при подключении, и сервер каждый раз считает его отставшим
112
+ aliveTimer = setInterval(() => send({ t: 'check_state', known: currentKnown() }), aliveMs)
113
+ if (typeof aliveTimer.unref === 'function') aliveTimer.unref()
114
+ schedulePing()
115
+ })
116
+
117
+ socket.addEventListener('message', (event) => {
118
+ let frame
119
+ try {
120
+ frame = JSON.parse(String(event.data))
121
+ } catch {
122
+ return log('warn', t('presence.badFrame'))
123
+ }
124
+ if (frame.t === 'hello-ok') {
125
+ serverEpoch = frame.epoch ?? null
126
+ // сразу заявляем ожидание: иначе сервер, у которого уже есть работа, не
127
+ // имеет права присылать кадр (он шлёт только ждущим)
128
+ send({ t: 'check_state', known: currentKnown() })
129
+ onEvent?.({ type: 'hello-ok', ...frame })
130
+ return
131
+ }
132
+ if (frame.t === 'pong') {
133
+ if (awaitingPong !== null && frame.id === awaitingPong) awaitingPong = null
134
+ ticksWithoutCommands += 1
135
+ noteSeen()
136
+ return
137
+ }
138
+ if (frame.t === 'state') {
139
+ if (frame.epoch != null) serverEpoch = frame.epoch
140
+ noteSeen()
141
+ onEvent?.({ type: 'state', ...frame })
142
+ }
143
+ })
144
+
145
+ socket.addEventListener('close', (event) => {
146
+ closed = true
147
+ clearInterval(aliveTimer)
148
+ onEvent?.({ type: 'closed', code: event.code, reason: event.reason })
149
+ })
150
+
151
+ socket.addEventListener('error', () => {
152
+ // close всё равно придёт; здесь только чтобы не падало
153
+ })
154
+
155
+ return {
156
+ sendAlive: (value) => send({ t: 'check_state', known: value ?? currentKnown() }),
157
+ online: () => connected,
158
+ // демон сообщает presence о жизни: после каждого удачного HTTP-ответа и
159
+ // после каждой пачки команд
160
+ noteSeen,
161
+ noteCommands,
162
+ close: () => {
163
+ closed = true
164
+ clearInterval(aliveTimer)
165
+ try {
166
+ socket.close(1000, 'bye')
167
+ } catch {}
168
+ },
169
+ epoch: () => serverEpoch,
170
+ }
171
+ }