@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/runner.mjs
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Соединение с одним сервером: заявка, объявление, цикл команд.
|
|
3
|
+
*
|
|
4
|
+
* Состояние держится per URL (план клиента): курсор, epoch, токен. Реконнект —
|
|
5
|
+
* экспоненциальная пауза, но без потери уже полученного токена.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-node-client/runner
|
|
8
|
+
*/
|
|
9
|
+
import * as api from './api.mjs'
|
|
10
|
+
import { runBash } from './bash.mjs'
|
|
11
|
+
import { realpathSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { normalizeUrl } from './config.mjs'
|
|
14
|
+
import { home, openStore } from './store.mjs'
|
|
15
|
+
import { openPresence } from './presence.mjs'
|
|
16
|
+
import { classify, DSH_MODES, MODES, narrower, profileArgs, probe } from './sandbox.mjs'
|
|
17
|
+
import { createSessionStore } from './sessions.mjs'
|
|
18
|
+
import { askLocally } from './approver.mjs'
|
|
19
|
+
import { apisOf, loadPlugins, resolvePlugin } from './plugins/index.mjs'
|
|
20
|
+
import { t } from './i18n.mjs'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Пауза с уважением к отмене.
|
|
24
|
+
*
|
|
25
|
+
* Слушатель снимается в finally: демон живёт сутками и делает паузу раз в
|
|
26
|
+
* несколько секунд. Иначе один AbortSignal копит тысячи слушателей — это и
|
|
27
|
+
* предупреждение MaxListenersExceeded, и растущая память.
|
|
28
|
+
*/
|
|
29
|
+
/** Код возврата из результата команды: клиент кладёт его последним блоком. */
|
|
30
|
+
export function codeOf (outcome) {
|
|
31
|
+
const blocks = Array.isArray(outcome?.results) ? outcome.results : []
|
|
32
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
33
|
+
const text = blocks[i]?.text
|
|
34
|
+
if (typeof text !== 'string' || !text.startsWith('{')) continue
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(text)
|
|
37
|
+
if (typeof parsed?.code === 'number') return parsed.code
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Исполнить пачку команд.
|
|
45
|
+
*
|
|
46
|
+
* `session.chdir` применяется первым и по порядку: он меняет каталог сессии, и
|
|
47
|
+
* все остальные команды пачки должны стартовать уже в новом. Остальное идёт
|
|
48
|
+
* параллельно, но с потолком: один ход модели может принести десяток вызовов, и
|
|
49
|
+
* поднимать десяток процессов разом — плохая идея.
|
|
50
|
+
*
|
|
51
|
+
* @param {object[]} commands команды из выдачи
|
|
52
|
+
* @param {object} options
|
|
53
|
+
* @param {(command: object) => Promise<object>} options.run исполнить одну
|
|
54
|
+
* @param {number} [options.concurrency]
|
|
55
|
+
*/
|
|
56
|
+
export async function runConcurrently (commands, run, { concurrency = 5 } = {}) {
|
|
57
|
+
const queue = [...commands]
|
|
58
|
+
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
|
59
|
+
for (;;) {
|
|
60
|
+
const command = queue.shift()
|
|
61
|
+
if (!command) return
|
|
62
|
+
try {
|
|
63
|
+
await run(command)
|
|
64
|
+
} catch {
|
|
65
|
+
// одна упавшая команда не должна утащить за собой всю пачку: иначе
|
|
66
|
+
// остальные останутся `processing` и сервер сочтёт их потерянными
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
await Promise.all(workers)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
76
|
+
resolve()
|
|
77
|
+
}, ms)
|
|
78
|
+
const onAbort = () => {
|
|
79
|
+
clearTimeout(timer)
|
|
80
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
81
|
+
reject(new Error('aborted'))
|
|
82
|
+
}
|
|
83
|
+
signal?.addEventListener?.('abort', onAbort, { once: true })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Подключение по ссылке с кодом: подтверждать нечего, код всё связывает сам.
|
|
88
|
+
* @returns {Promise<object>} запись сервера с токеном
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* Сохранить рантайм сервера: курсор и прочее — в state.sqlite, токен — в
|
|
92
|
+
* credentials.yaml. В config.json не пишем ничего: там только настройки.
|
|
93
|
+
*/
|
|
94
|
+
function persist (store, url, state) {
|
|
95
|
+
store.writeState(url, {
|
|
96
|
+
cursor: state.cursor ?? null,
|
|
97
|
+
epoch: state.epoch ?? null,
|
|
98
|
+
boot: state.boot ?? null,
|
|
99
|
+
node: state.node ?? null,
|
|
100
|
+
alias: state.alias ?? null,
|
|
101
|
+
apiIds: state.apiIds ?? {},
|
|
102
|
+
})
|
|
103
|
+
if ('token' in state) store.setToken(url, state.token ?? null)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Песочница для одной команды.
|
|
108
|
+
*
|
|
109
|
+
* Режим приходит от сервера (`sandbox.mode`), но владелец узла ограничивает его
|
|
110
|
+
* сверху через `config.max`: эффективный режим — более узкий из двух. Каталог
|
|
111
|
+
* сессии становится рабочим каталогом команды, а в режиме `rw` — единственным
|
|
112
|
+
* местом, куда вообще можно писать (плюс `/tmp`).
|
|
113
|
+
*
|
|
114
|
+
* @param {object} config настройки узла
|
|
115
|
+
* @param {object} command команда из очереди
|
|
116
|
+
* @param {object} sessions хранилище каталогов сессий
|
|
117
|
+
* @returns {object|null} описание песочницы или null, если команда вне песочницы
|
|
118
|
+
*/
|
|
119
|
+
/** Правила узла для конкретного сервера: policy[url] поверх общих настроек. */
|
|
120
|
+
export function policyFor (config, url) {
|
|
121
|
+
const common = {
|
|
122
|
+
max: config.access?.max ?? 'rw',
|
|
123
|
+
roots: config.roots ?? null,
|
|
124
|
+
workdir: config.workdir ?? null,
|
|
125
|
+
maxConcurrent: config.maxConcurrent ?? 5,
|
|
126
|
+
// режим, в котором команда стартует: `full` = не понижать (как было раньше)
|
|
127
|
+
base: config.access?.base ?? 'full',
|
|
128
|
+
approver: config.access?.approver ?? 'none',
|
|
129
|
+
approvalTtlSeconds: config.approvalTtlSeconds ?? 86400,
|
|
130
|
+
// `ask` — каналы (web/local). Это не то же, что «разрешено ли спрашивать»:
|
|
131
|
+
// булево для сервера считается из approver, см. askEnabled ниже.
|
|
132
|
+
ask: config.ask ?? null,
|
|
133
|
+
}
|
|
134
|
+
const own = url ? config.policy?.[url] ?? {} : {}
|
|
135
|
+
return { ...common, ...Object.fromEntries(Object.entries(own).filter(([, value]) => value !== undefined && value !== null)) }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Разрешено ли серверу спрашивать человека на этом узле.
|
|
140
|
+
*
|
|
141
|
+
* В протокол уходит именно булево (`allowEscalation`), а `approver` описывает
|
|
142
|
+
* канал: `local` — спрашиваем на машине, `server` — через DSH, `none` — никогда.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} policy правила узла
|
|
145
|
+
* @returns {boolean}
|
|
146
|
+
*/
|
|
147
|
+
export function askEnabled (policy) {
|
|
148
|
+
return policy?.approver === 'local' || policy?.approver === 'server'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Команда локального апрува из настроек узла.
|
|
153
|
+
*
|
|
154
|
+
* `ask.local.enabled: false` или пустой шаблон означают «спросить некого» — тогда
|
|
155
|
+
* плагин вернёт `no_local_approver`, и сервер не будет делать вид, что вопрос
|
|
156
|
+
* задан.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} config настройки клиента
|
|
159
|
+
* @returns {string|null}
|
|
160
|
+
*/
|
|
161
|
+
export function askLocalCommand (policy) {
|
|
162
|
+
const local = policy?.ask?.local
|
|
163
|
+
if (!local?.enabled) return null
|
|
164
|
+
const command = local.tool?.command
|
|
165
|
+
return typeof command === 'string' && command.trim() ? command.trim() : null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function sandboxFor (config, command, sessions, url) {
|
|
169
|
+
const policy = policyFor(config, url)
|
|
170
|
+
const requested = MODES.includes(command.sandbox?.mode) ? command.sandbox.mode : policy.max
|
|
171
|
+
// потолок узла режет запрошенное всегда
|
|
172
|
+
const want = narrower(requested, policy.max)
|
|
173
|
+
// `base` — это «в каком режиме стартуем», но подтверждённую человеком команду
|
|
174
|
+
// понижать нельзя: иначе апрув ничего не значит
|
|
175
|
+
const approved = Boolean(command.approved)
|
|
176
|
+
const mode = approved || policy.approver === 'none' ? want : narrower(want, policy.base)
|
|
177
|
+
let cwd = null
|
|
178
|
+
if (command.session && sessions) {
|
|
179
|
+
try {
|
|
180
|
+
cwd = sessions.cwd(command.session, { workdir: policy.workdir, roots: policy.roots })
|
|
181
|
+
} catch {
|
|
182
|
+
cwd = policy.workdir ?? null
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
cwd = policy.workdir ?? null
|
|
186
|
+
}
|
|
187
|
+
const workdir = policy.workdir ? canonicalWorkdir(policy.workdir) : null
|
|
188
|
+
const roots = policy.roots?.length ? policy.roots : (workdir ? [workdir] : ['/'])
|
|
189
|
+
return {
|
|
190
|
+
mode,
|
|
191
|
+
dshMode: DSH_MODES[mode],
|
|
192
|
+
order: config.access?.order ?? [],
|
|
193
|
+
args: profileArgs({ mode, workdir, writable: roots, cwd }),
|
|
194
|
+
cwd,
|
|
195
|
+
probe,
|
|
196
|
+
classify,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function canonicalWorkdir (workdir) {
|
|
201
|
+
try {
|
|
202
|
+
return realpathSync(workdir)
|
|
203
|
+
} catch {
|
|
204
|
+
return workdir
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function pairServer ({ url, config, print }) {
|
|
209
|
+
const { base, code } = api.parseConnectUrl(url)
|
|
210
|
+
if (!code) throw new Error(t('connect.noCode'))
|
|
211
|
+
const store = openStore()
|
|
212
|
+
const state = { ...store.readState(base), token: store.token(base) }
|
|
213
|
+
const response = await api.pair(base, { code, uuid: config.id, name: config.name, aliases: config.aliases })
|
|
214
|
+
state.token = response.body.token
|
|
215
|
+
state.cursor = 0
|
|
216
|
+
state.epoch = null
|
|
217
|
+
state.alias = response.body.alias ?? null
|
|
218
|
+
state.node = response.body.node ?? null
|
|
219
|
+
delete state.announced
|
|
220
|
+
persist(store, base, state)
|
|
221
|
+
print(t('connect.paired', { node: response.body.node, alias: response.body.alias }))
|
|
222
|
+
return state
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Первичная регистрация: заявка и ожидание одобрения человеком.
|
|
227
|
+
* @param {object} options
|
|
228
|
+
* @param {string} options.url
|
|
229
|
+
* @param {object} options.config
|
|
230
|
+
* @param {(line: string) => void} options.print
|
|
231
|
+
* @param {AbortSignal} [options.signal]
|
|
232
|
+
* @returns {Promise<object>} запись сервера с токеном
|
|
233
|
+
*/
|
|
234
|
+
export async function enrollServer ({ url, config, print, signal, forever = false }) {
|
|
235
|
+
const base = normalizeUrl(url)
|
|
236
|
+
const store = openStore()
|
|
237
|
+
const state = { ...store.readState(base), token: store.token(base) }
|
|
238
|
+
const uuid = config.id
|
|
239
|
+
let announced = false
|
|
240
|
+
let attempts = 0
|
|
241
|
+
let repost = 0
|
|
242
|
+
let retryMs = 5000
|
|
243
|
+
for (;;) {
|
|
244
|
+
if (signal?.aborted) throw new Error('aborted')
|
|
245
|
+
// заявку повторяем, если она старая: у неё свой TTL в админке
|
|
246
|
+
if (!announced || repost >= 30) {
|
|
247
|
+
try {
|
|
248
|
+
await api.enroll(base, { uuid, name: config.name, aliases: config.aliases, nonce: store.clientNonce() })
|
|
249
|
+
if (!announced) {
|
|
250
|
+
print(t('connect.requested', { name: config.name, short: uuid.slice(0, 8) }))
|
|
251
|
+
print(t('connect.waiting', { base }))
|
|
252
|
+
}
|
|
253
|
+
announced = true
|
|
254
|
+
repost = 0
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (error.code === 'unauthorized') throw error
|
|
257
|
+
announced = false
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
repost += 1
|
|
261
|
+
try {
|
|
262
|
+
const response = await api.enrollStatus(base, uuid, store.clientNonce())
|
|
263
|
+
if (response.status === 200 && response.body?.token) {
|
|
264
|
+
state.token = response.body.token
|
|
265
|
+
state.cursor = 0
|
|
266
|
+
state.epoch = null
|
|
267
|
+
state.alias = response.body.alias ?? null
|
|
268
|
+
state.node = response.body.node ?? null
|
|
269
|
+
delete state.announced
|
|
270
|
+
persist(store, base, state)
|
|
271
|
+
print(t('connect.approved', { node: response.body.node ?? '?', alias: response.body.alias ?? '—' }))
|
|
272
|
+
return state
|
|
273
|
+
}
|
|
274
|
+
if (response.body?.status === 'rejected') throw new Error(t('connect.rejected'))
|
|
275
|
+
if (response.body?.status === 'handed') throw new Error(t('connect.handed'))
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error.code === 'unauthorized') throw error
|
|
278
|
+
if (/отклон|rejected/.test(error.message)) throw error
|
|
279
|
+
// 409 «уже выдан» — сервер не знает, что нам нужен новый токен: ждём
|
|
280
|
+
// одобрения в админке и напоминаем о себе всё реже, а не каждые 5 секунд
|
|
281
|
+
print(t('run.retry', { base, message: error.message, seconds: Math.round(retryMs / 1000) }))
|
|
282
|
+
await sleep(retryMs, signal)
|
|
283
|
+
retryMs = Math.min(Math.round(retryMs * 2), 60000)
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
attempts += 1
|
|
287
|
+
if (!forever && attempts > 360) throw new Error(t('connect.timeout'))
|
|
288
|
+
await sleep(2000, signal)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Цикл одного сервера. Возвращается только по отмене или фатальной ошибке.
|
|
294
|
+
*/
|
|
295
|
+
export async function runServer ({ url, config, print, signal, shell, pollIdleMs = 5000, plugins = null }) {
|
|
296
|
+
const base = normalizeUrl(url)
|
|
297
|
+
const store = openStore()
|
|
298
|
+
const state = { ...store.readState(base), token: store.token(base) }
|
|
299
|
+
const sessions = createSessionStore(store)
|
|
300
|
+
const loaded = plugins ?? await loadPlugins({ dir: join(home(), 'plugins'), log: (level, message) => print(message) })
|
|
301
|
+
/**
|
|
302
|
+
* Активные диалоги апрува. Живут отдельно от пачки: их не ждут, но при смене
|
|
303
|
+
* эпохи (сервер перезапустился, очередь пуста) их надо закрыть — иначе человек
|
|
304
|
+
* увидит вопрос, на который уже некому ответить.
|
|
305
|
+
*/
|
|
306
|
+
const pendingApprovals = new Map()
|
|
307
|
+
let reloadRequested = false
|
|
308
|
+
// SIGHUP читает конфиг заново и будит цикл: без пробуждения reload оставался
|
|
309
|
+
// бы «принятым», но не применённым — цикл спит до кадра от сервера
|
|
310
|
+
const onHup = () => {
|
|
311
|
+
reloadRequested = true
|
|
312
|
+
wake?.()
|
|
313
|
+
}
|
|
314
|
+
process.on('SIGHUP', onHup)
|
|
315
|
+
let backoff = 1000
|
|
316
|
+
// SIGHUP меняет config на месте и ставит отметку: по ней понимаем, что пора
|
|
317
|
+
// переобъявить API и пересоздать сокет с новыми правилами
|
|
318
|
+
let reloadToken = config.reloadToken ?? null
|
|
319
|
+
// `announced` живёт в памяти процесса, а не в конфиге: после рестарта клиента
|
|
320
|
+
// объявляться надо снова — сервер мог перезапуститься, контракт мог измениться.
|
|
321
|
+
let announced = false
|
|
322
|
+
let presence = null
|
|
323
|
+
let wake = null
|
|
324
|
+
/** Разбудить цикл: пришёл кадр `state`, опрос ждать не надо. */
|
|
325
|
+
const nudge = () => {
|
|
326
|
+
const resolve = wake
|
|
327
|
+
wake = null
|
|
328
|
+
resolve?.()
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Пауза, которую может прервать уведомление с сервера.
|
|
332
|
+
*
|
|
333
|
+
* Таймер НЕ unref: idle-ожидание — единственное, что держит процесс живым,
|
|
334
|
+
* когда сокета нет (например, присутствие не поднялось). С unref демон
|
|
335
|
+
* молча выходил с кодом 0 после первой же паузы.
|
|
336
|
+
*/
|
|
337
|
+
/**
|
|
338
|
+
* Ждать кадр без таймера.
|
|
339
|
+
*
|
|
340
|
+
* Единственный способ проснуться — уведомление от сокета или остановка
|
|
341
|
+
* демона. Если сокет умрёт молча, клиент так и будет спать: это цена
|
|
342
|
+
* событийной схемы, и она приемлема, пока страховочный опрос выключен.
|
|
343
|
+
*/
|
|
344
|
+
const waitForWake = () => new Promise((resolve) => {
|
|
345
|
+
wake = resolve
|
|
346
|
+
signal?.addEventListener?.('abort', () => {
|
|
347
|
+
wake = null
|
|
348
|
+
resolve()
|
|
349
|
+
}, { once: true })
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
const pause = (ms) => new Promise((resolve) => {
|
|
353
|
+
wake = resolve
|
|
354
|
+
const timer = setTimeout(() => {
|
|
355
|
+
wake = null
|
|
356
|
+
resolve()
|
|
357
|
+
}, ms)
|
|
358
|
+
signal?.addEventListener?.('abort', () => {
|
|
359
|
+
clearTimeout(timer)
|
|
360
|
+
wake = null
|
|
361
|
+
resolve()
|
|
362
|
+
}, { once: true })
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
while (!signal?.aborted) {
|
|
366
|
+
try {
|
|
367
|
+
if (reloadRequested) {
|
|
368
|
+
reloadRequested = false
|
|
369
|
+
print(t('daemon.reload'))
|
|
370
|
+
const fresh = openStore().settings ?? {}
|
|
371
|
+
for (const key of Object.keys(config)) delete config[key]
|
|
372
|
+
Object.assign(config, fresh)
|
|
373
|
+
// новые правила должны доехать до сервера: переобъявляем и пересоздаём
|
|
374
|
+
// сокет, иначе изменившиеся base/approver/ping останутся на бумаге
|
|
375
|
+
announced = false
|
|
376
|
+
presence?.close()
|
|
377
|
+
presence = null
|
|
378
|
+
}
|
|
379
|
+
if ((config.reloadToken ?? null) !== reloadToken) {
|
|
380
|
+
reloadToken = config.reloadToken ?? null
|
|
381
|
+
// политика, права и каналы вопросов могли измениться: объявляем заново,
|
|
382
|
+
// сокет пересоздаём — иначе новые pingInterval/approver не подхватятся
|
|
383
|
+
announced = false
|
|
384
|
+
presence?.close()
|
|
385
|
+
presence = null
|
|
386
|
+
}
|
|
387
|
+
// демон без токена сам идёт в заявку и ждёт одобрения: тогда из админки
|
|
388
|
+
// связь появляется сама, без перезапуска клиента
|
|
389
|
+
if (!state.token) {
|
|
390
|
+
await enrollServer({ url: base, config, print, signal, forever: true })
|
|
391
|
+
// заявку одобрили в другом вызове: перечитываем состояние, иначе
|
|
392
|
+
// дальше пойдём со старым пустым токеном и получим 401
|
|
393
|
+
Object.assign(state, store.readState(base), { token: store.token(base) })
|
|
394
|
+
}
|
|
395
|
+
if (!announced) {
|
|
396
|
+
const response = await api.announce(base, state.token, apisOf(loaded), {
|
|
397
|
+
workdir: config.workdir ?? null,
|
|
398
|
+
access: policyFor(config, base).max,
|
|
399
|
+
allowEscalation: askEnabled(policyFor(config, base)),
|
|
400
|
+
uptimeSeconds: process.uptime(),
|
|
401
|
+
approver: policyFor(config, base).approver,
|
|
402
|
+
startMode: policyFor(config, base).base,
|
|
403
|
+
// прикладной пинг: 0 — выключен, factor — потолок растяжения
|
|
404
|
+
pingInterval: config.pingInterval ?? 0,
|
|
405
|
+
maxPingIntervalFactor: config.maxPingIntervalFactor ?? 0,
|
|
406
|
+
})
|
|
407
|
+
announced = true
|
|
408
|
+
state.epoch = response.body.epoch ?? null
|
|
409
|
+
state.apiIds = {}
|
|
410
|
+
for (const entry of response.body.apis ?? []) state.apiIds[entry.name] = entry.id
|
|
411
|
+
persist(store, base, state)
|
|
412
|
+
const names = (response.body.apis ?? []).map((entry) => `${entry.name}#${entry.id}`).join(', ')
|
|
413
|
+
print(t('run.announced', { base, apis: names, epoch: state.epoch }))
|
|
414
|
+
for (const warning of response.body.warnings ?? []) print(t('run.warning', { warning }))
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!presence) presence = openPresence({
|
|
418
|
+
workdir: policyFor(config, base).workdir ?? config.workdir ?? null,
|
|
419
|
+
access: policyFor(config, base).max,
|
|
420
|
+
allowEscalation: askEnabled(policyFor(config, base)),
|
|
421
|
+
// время старта процесса: сервер посчитает аптайм по своему clock
|
|
422
|
+
uptimeSeconds: process.uptime(),
|
|
423
|
+
approver: policyFor(config, base).approver,
|
|
424
|
+
// `base` — стартовый режим: сервер покажет его в панели рядом с потолком.
|
|
425
|
+
// В presence параметр назван startMode, чтобы не спорить с именем url-базы
|
|
426
|
+
startMode: policyFor(config, base).base,
|
|
427
|
+
base,
|
|
428
|
+
token: state.token,
|
|
429
|
+
epoch: state.epoch,
|
|
430
|
+
known: state.cursor ?? null,
|
|
431
|
+
log: (level, message) => print(`${base}: ${message}`),
|
|
432
|
+
onEvent: (event) => {
|
|
433
|
+
if (event.type === 'hello-ok') {
|
|
434
|
+
if (event.reconnect) {
|
|
435
|
+
print(t('run.reconnect', { base, from: state.epoch, to: event.epoch }))
|
|
436
|
+
state.epoch = event.epoch
|
|
437
|
+
state.cursor = 0
|
|
438
|
+
persist(store, base, state)
|
|
439
|
+
} else {
|
|
440
|
+
state.epoch = event.epoch ?? state.epoch
|
|
441
|
+
}
|
|
442
|
+
// сразу сообщаем курсор: сервер запомнит, что узел ждёт работы,
|
|
443
|
+
// и первая же команда придёт кадром, а не через период опроса
|
|
444
|
+
presence?.sendAlive(state.cursor ?? null)
|
|
445
|
+
nudge()
|
|
446
|
+
}
|
|
447
|
+
if (event.type === 'state') nudge()
|
|
448
|
+
if (event.type === 'closed') {
|
|
449
|
+
print(t('run.presenceClosed', { base, code: event.code }))
|
|
450
|
+
presence = null
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
// сверка поколений: несовпадение = реконнект, курсор в ноль (§4.7)
|
|
456
|
+
const current = await api.state(base, state.token)
|
|
457
|
+
if (state.epoch != null && current.body.epoch !== state.epoch) {
|
|
458
|
+
print(t('run.restarted', { base, from: state.epoch, to: current.body.epoch }))
|
|
459
|
+
state.epoch = current.body.epoch
|
|
460
|
+
state.cursor = null
|
|
461
|
+
// диалоги прошлой жизни: сервер их уже не помнит, ответить некому
|
|
462
|
+
for (const controller of pendingApprovals.values()) controller.abort()
|
|
463
|
+
pendingApprovals.clear()
|
|
464
|
+
persist(store, base, state)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Контекст для плагина: каталог сессии, корни из правил сервера и песочница.
|
|
469
|
+
* Считается на каждую команду — политика и каталог могут отличаться.
|
|
470
|
+
*/
|
|
471
|
+
const pluginContext = (command) => {
|
|
472
|
+
const policy = policyFor(config, base)
|
|
473
|
+
return {
|
|
474
|
+
shell,
|
|
475
|
+
// у диалога апрува свой сигнал: его отменяют отдельно от всего цикла
|
|
476
|
+
signal: command.signal ?? signal,
|
|
477
|
+
workdir: policy.workdir ?? config.workdir,
|
|
478
|
+
roots: policy.roots?.length ? policy.roots : (policy.workdir ? [policy.workdir] : ['/']),
|
|
479
|
+
// `files` не должен получать «/» как разрешение на всё: пустой список
|
|
480
|
+
// означает «каталоги не настроены», и это отказ, а не свобода
|
|
481
|
+
fileRoots: policy.roots?.length ? policy.roots : (policy.workdir ? [policy.workdir] : []),
|
|
482
|
+
session: command.session ?? null,
|
|
483
|
+
sessions,
|
|
484
|
+
approver: {
|
|
485
|
+
command: askLocalCommand(policyFor(config, base)),
|
|
486
|
+
run: (options) => askLocally({ ...options, timeout: options.timeout ?? config.approvalTtlSeconds ?? 86400 }),
|
|
487
|
+
},
|
|
488
|
+
sandbox: sandboxFor(config, command, sessions, base),
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Исполнить одну команду и отчитаться. Вынесено, чтобы пачка могла идти
|
|
494
|
+
* параллельно, а отчёт по каждой команде оставался прежним.
|
|
495
|
+
*/
|
|
496
|
+
const executeOne = async (command) => {
|
|
497
|
+
const ctx = pluginContext(command)
|
|
498
|
+
const plugin = resolvePlugin(loaded, command, state.apiIds ?? {})
|
|
499
|
+
const outcome = plugin
|
|
500
|
+
? await plugin.handle(command, ctx)
|
|
501
|
+
: { ok: false, code: 'unknown_method', message: t('run.noPlugin', { method: command.method }) }
|
|
502
|
+
try {
|
|
503
|
+
await api.result(base, state.token, command.seq, outcome)
|
|
504
|
+
} catch (error) {
|
|
505
|
+
print(t('run.resultFailed', { seq: command.seq, message: error.message }))
|
|
506
|
+
}
|
|
507
|
+
// режим full ничего не ограничивает, поэтому оставляем след
|
|
508
|
+
if (plugin && ctx.sandbox?.mode === 'full') {
|
|
509
|
+
try {
|
|
510
|
+
store.writeAudit({
|
|
511
|
+
session: command.session ?? null,
|
|
512
|
+
mode: 'full',
|
|
513
|
+
command: typeof command.args?.command === 'string' ? command.args.command : JSON.stringify(command.args ?? {}),
|
|
514
|
+
code: codeOf(outcome),
|
|
515
|
+
at: Date.now(),
|
|
516
|
+
})
|
|
517
|
+
} catch (error) {
|
|
518
|
+
print(t('run.auditFailed', { message: error.message }))
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const flag = outcome.ok ? t('run.ok') : t('run.error', { code: outcome.code ?? '' })
|
|
522
|
+
print(t('run.command', { base, seq: command.seq, method: command.method, flag }))
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Пачка: сначала `session.chdir` по порядку (он меняет каталог сессии, и
|
|
527
|
+
* остальные команды должны стартовать уже в новом), затем остальное
|
|
528
|
+
* параллельно с потолком.
|
|
529
|
+
*/
|
|
530
|
+
const runBatch = async (batch) => {
|
|
531
|
+
// `chdir` меняет каталог сессии, поэтому он первый и по порядку:
|
|
532
|
+
// остальные команды пачки должны стартовать уже в новом каталоге
|
|
533
|
+
for (const command of batch.filter((cmd) => cmd.method === 'chdir')) await executeOne(command)
|
|
534
|
+
|
|
535
|
+
// `approve` — это диалог с человеком, он может висеть сутки. Держать им
|
|
536
|
+
// слот пула нельзя: иначе пачка не закроется и опрос встанет
|
|
537
|
+
for (const command of batch.filter((cmd) => cmd.method === 'approve')) {
|
|
538
|
+
const controller = new AbortController()
|
|
539
|
+
pendingApprovals.set(command.seq, controller)
|
|
540
|
+
void executeOne({ ...command, signal: controller.signal })
|
|
541
|
+
.catch(() => {})
|
|
542
|
+
.finally(() => pendingApprovals.delete(command.seq))
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const rest = batch.filter((cmd) => cmd.method !== 'chdir' && cmd.method !== 'approve')
|
|
546
|
+
// потолок одновременных процессов: один ход модели может принести
|
|
547
|
+
// десяток вызовов, и заваливать узел нагрузкой нельзя
|
|
548
|
+
await runConcurrently(rest, executeOne, { concurrency: policyFor(config, base).maxConcurrent ?? 5 })
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const response = await api.commands(base, state.token, { limit: config.batch ?? 5 })
|
|
552
|
+
presence?.noteSeen?.()
|
|
553
|
+
const commands = response.body.commands ?? []
|
|
554
|
+
if (state.epoch !== response.body.epoch) {
|
|
555
|
+
state.epoch = response.body.epoch
|
|
556
|
+
state.cursor = null
|
|
557
|
+
}
|
|
558
|
+
// `known` — до какого номера я в курсе. Сервер отдаёт `state` (последний
|
|
559
|
+
// выданный номер), но опираться на него нельзя: между выдачами он растёт.
|
|
560
|
+
// Поэтому помним максимум из полученных команд — это ровно то, что
|
|
561
|
+
// спрашивается в `check_state`.
|
|
562
|
+
for (const command of commands) {
|
|
563
|
+
if (Number.isFinite(command.seq) && (state.cursor == null || command.seq > state.cursor)) state.cursor = command.seq
|
|
564
|
+
}
|
|
565
|
+
persist(store, base, state)
|
|
566
|
+
presence?.sendAlive(state.cursor ?? null)
|
|
567
|
+
|
|
568
|
+
if (!commands.length) {
|
|
569
|
+
// Периодического опроса нет вовсе: `pollIdleMs: 0` выключает его
|
|
570
|
+
// полностью, и тогда единственный повод проснуться — кадр от сервера
|
|
571
|
+
// или восстановление сокета. Ненулевое значение оставляет страховку.
|
|
572
|
+
const idlePoll = config.pollIdleMs ?? pollIdleMs
|
|
573
|
+
if (!idlePoll) {
|
|
574
|
+
await waitForWake()
|
|
575
|
+
continue
|
|
576
|
+
}
|
|
577
|
+
await pause(presence?.online?.() ? (config.pollFallbackMs ?? idlePoll) : idlePoll)
|
|
578
|
+
continue
|
|
579
|
+
}
|
|
580
|
+
backoff = 1000
|
|
581
|
+
presence?.noteCommands?.()
|
|
582
|
+
await runBatch(commands)
|
|
583
|
+
presence?.sendAlive(state.cursor ?? null)
|
|
584
|
+
} catch (error) {
|
|
585
|
+
presence?.close()
|
|
586
|
+
presence = null
|
|
587
|
+
if (signal?.aborted) return
|
|
588
|
+
if (error.code === 'unauthorized' || error.status === 401) {
|
|
589
|
+
print(t('run.tokenRejected', { base }))
|
|
590
|
+
state.token = null
|
|
591
|
+
announced = false
|
|
592
|
+
persist(store, base, state)
|
|
593
|
+
continue
|
|
594
|
+
}
|
|
595
|
+
print(t('run.retry', { base, message: error.message, seconds: Math.round(backoff / 1000) }))
|
|
596
|
+
await pause(backoff)
|
|
597
|
+
backoff = Math.min(backoff * 2, 30000)
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|