dsh-computers 0.0.0-stage → 1.3.0

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,2808 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-computers-agent — the machine-side half of dsh-computers.
4
+ *
5
+ * What it does (the whole trust story, so this file can be audited):
6
+ * 1. `enroll` sends the one-time join key to the dsh-web server and stores the
7
+ * long-lived token it gets back in a 0600 JSON file under ~/.dsh-computers.
8
+ * 2. `run` keeps one outbound SSE stream open (GET .../agent/events) carrying
9
+ * `exec` / `cancel` / `ping` / `config` events, executes the commands the
10
+ * server sends with the local shell, and POSTs the result back.
11
+ * 3. `service` installs a user-level launchd / systemd unit so `run` starts at
12
+ * login and is restarted if it ever exits.
13
+ *
14
+ * It only makes outbound connections. It never listens on a port, never opens a
15
+ * reverse proxy, and never prints the token. There are no dependencies, no
16
+ * dynamic imports and no child processes other than the commands the server
17
+ * asks for, the service manager, and (on Windows) taskkill.
18
+ *
19
+ * Protocol: see PROTOCOL.md in the dsh-computers repository (v1).
20
+ *
21
+ * Node.js >= 22, self-contained ESM. This exact file is served at
22
+ * /api/dsh-computers/agent.mjs and downloaded by the install one-liner, so it
23
+ * must work from any install directory and must not import siblings.
24
+ */
25
+
26
+ import { execFile, spawn } from 'node:child_process'
27
+ import fs from 'node:fs'
28
+ import fsp from 'node:fs/promises'
29
+ import os from 'node:os'
30
+ import path from 'node:path'
31
+ import { fileURLToPath, pathToFileURL } from 'node:url'
32
+ import { parseArgs } from 'node:util'
33
+
34
+ // Keep in sync with package.json. The server compares this for display only.
35
+ export const AGENT_VERSION = '1.3.0'
36
+
37
+ const DEFAULT_EXEC_TIMEOUT_MS = 120_000
38
+ const MAX_EXEC_TIMEOUT_MS = 24 * 60 * 60 * 1000
39
+ const MAX_OUTPUT_BYTES = 1024 * 1024
40
+ const TRUNCATION_MARKER = '\n[output truncated]'
41
+ const LOG_ROTATE_BYTES = 5 * 1024 * 1024
42
+ const MIN_BACKOFF_MS = 1000
43
+ const MAX_BACKOFF_MS = 30_000
44
+ const HEARTBEAT_LOG_INTERVAL_MS = 60_000
45
+ const TRANSIENT_SERVER_WARN_INTERVAL_MS = 60_000
46
+ const DEFAULT_HEARTBEAT_MS = 20_000
47
+
48
+ // PROTOCOL §11: capabilities, streaming frames and remote fs.
49
+ const AGENT_CAPABILITIES = ['exec', 'fs']
50
+ const HANDSHAKE_VERSION = 1
51
+ const CHUNK_FLUSH_INTERVAL_MS = 100
52
+ const CHUNK_MAX_BATCH_BYTES = 32 * 1024
53
+ const CHUNK_HEARTBEAT_MS = 10_000
54
+ const CHUNK_POST_TIMEOUT_MS = 15_000
55
+ const FS_REQUEST_TIMEOUT_MS = 15_000
56
+ const FS_READ_MAX_BYTES = 1024 * 1024
57
+ const FS_READ_MAX_LINES = 20_000
58
+ const FS_READ_DEFAULT_LIMIT = 2000
59
+ const FS_WRITE_MAX_BYTES = 4 * 1024 * 1024
60
+ const FS_LIST_MAX_ENTRIES = 5000
61
+ const FS_GLOB_MAX_PATHS = 2000
62
+ const FS_GREP_MAX_MATCHES = 500
63
+ const FS_GREP_MAX_FILE_BYTES = 8 * 1024 * 1024
64
+ const FS_BINARY_SNIFF_BYTES = 8192
65
+ const FS_OPS = ['read', 'write', 'list', 'stat', 'mkdir', 'rename', 'delete', 'glob', 'grep']
66
+
67
+ // Service identifier. Overridable so tests (and power users with two installs)
68
+ // can use an isolated launchd/systemd job. Not part of the protocol.
69
+ const SERVICE_LABEL = (process.env.DSH_COMPUTERS_SERVICE_LABEL || 'com.dsh.computers.agent').trim()
70
+ const SERVICE_ACTIONS = ['install', 'uninstall', 'start', 'stop', 'restart', 'status']
71
+
72
+ const SELF_PATH = path.resolve(fileURLToPath(import.meta.url))
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Small utilities
76
+ // ---------------------------------------------------------------------------
77
+
78
+ class CliError extends Error {
79
+ constructor(message, exitCode = 1) {
80
+ super(message)
81
+ this.name = 'CliError'
82
+ this.exitCode = exitCode
83
+ }
84
+ }
85
+
86
+ function describeError(error) {
87
+ if (!error) return 'unknown error'
88
+ if (typeof error === 'string') return error
89
+ if (error.name === 'AbortError' || error.name === 'TimeoutError') return '请求被中止或超时'
90
+ const cause = error.cause
91
+ const causeText = cause ? (cause.code && cause.message ? `${cause.code} ${cause.message}` : String(cause.message || cause)) : ''
92
+ const text = error.message ? String(error.message) : String(error)
93
+ return causeText && !text.includes(causeText) ? `${text} (${causeText})` : text
94
+ }
95
+
96
+ function parseJsonSafe(text) {
97
+ try {
98
+ return JSON.parse(text)
99
+ } catch {
100
+ return undefined
101
+ }
102
+ }
103
+
104
+ function abbreviate(text, max = 160) {
105
+ const oneLine = String(text ?? '').replace(/\s+/g, ' ')
106
+ return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine
107
+ }
108
+
109
+ function sleep(ms, signal) {
110
+ return new Promise((resolve) => {
111
+ if (signal?.aborted) return resolve()
112
+ const timer = setTimeout(done, ms)
113
+ function done() {
114
+ clearTimeout(timer)
115
+ signal?.removeEventListener('abort', done)
116
+ resolve()
117
+ }
118
+ signal?.addEventListener('abort', done, { once: true })
119
+ })
120
+ }
121
+
122
+ function platformLabel(platform = process.platform) {
123
+ if (platform === 'darwin') return 'macOS'
124
+ if (platform === 'linux') return 'Linux'
125
+ if (platform === 'win32') return 'Windows'
126
+ return 'Unknown'
127
+ }
128
+
129
+ function machineInfo(name) {
130
+ const hostname = os.hostname()
131
+ let cpu = 'unknown'
132
+ try {
133
+ cpu = os.cpus()?.[0]?.model?.trim() || 'unknown'
134
+ } catch {
135
+ // os.cpus() is allowed to fail on some sandboxes; a placeholder is fine.
136
+ }
137
+ return {
138
+ hostname,
139
+ name: name && name.trim() ? name.trim() : hostname,
140
+ os: process.platform,
141
+ arch: process.arch,
142
+ platform: platformLabel(),
143
+ cpuModel: cpu,
144
+ memoryBytes: os.totalmem(),
145
+ nodeVersion: process.version,
146
+ }
147
+ }
148
+
149
+ /**
150
+ * The agent's default working directory (PROTOCOL §11.6): config `workspaceBase`,
151
+ * default `os.homedir()`. Used as the default exec cwd and the base for
152
+ * relative glob/grep patterns.
153
+ */
154
+ function normalizeWorkspaceBase(value) {
155
+ if (typeof value !== 'string' || !value.trim()) return os.homedir()
156
+ let text = value.trim()
157
+ if (text === '~') text = os.homedir()
158
+ else if (text.startsWith('~/') || text.startsWith('~\\')) text = path.join(os.homedir(), text.slice(2))
159
+ return path.resolve(text)
160
+ }
161
+
162
+ /** PROTOCOL §11.6 handshake metadata, reported at enroll and on every connect. */
163
+ function handshakeMetadata(workspaceBase) {
164
+ const info = machineInfo('')
165
+ return {
166
+ version: HANDSHAKE_VERSION,
167
+ agentVersion: AGENT_VERSION,
168
+ hostname: info.hostname,
169
+ os: info.os,
170
+ arch: info.arch,
171
+ capabilities: [...AGENT_CAPABILITIES],
172
+ workspaceBase: normalizeWorkspaceBase(workspaceBase),
173
+ }
174
+ }
175
+
176
+ function encodeMetadataHeader(metadata) {
177
+ return Buffer.from(JSON.stringify(metadata), 'utf8').toString('base64url')
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Paths and config (${DSH_COMPUTERS_HOME:-$HOME/.dsh-computers})
182
+ // ---------------------------------------------------------------------------
183
+
184
+ function agentHome() {
185
+ const override = process.env.DSH_COMPUTERS_HOME
186
+ if (override && override.trim()) return path.resolve(override.trim())
187
+ return path.join(os.homedir(), '.dsh-computers')
188
+ }
189
+
190
+ function configFile() {
191
+ return path.join(agentHome(), 'config.json')
192
+ }
193
+
194
+ function logFile() {
195
+ return path.join(agentHome(), 'agent.log')
196
+ }
197
+
198
+ function launchdPlistPath() {
199
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`)
200
+ }
201
+
202
+ function systemdUnitPath() {
203
+ return path.join(os.homedir(), '.config', 'systemd', 'user', `${SERVICE_LABEL}.service`)
204
+ }
205
+
206
+ function nohupPidFile() {
207
+ return path.join(agentHome(), 'agent.pid')
208
+ }
209
+
210
+ function ensureAgentHome() {
211
+ const home = agentHome()
212
+ try {
213
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 })
214
+ } catch {
215
+ // reported later when a write actually fails
216
+ }
217
+ try {
218
+ fs.chmodSync(home, 0o700)
219
+ } catch {
220
+ // best effort: FAT/exFAT and Windows do not support POSIX modes
221
+ }
222
+ return home
223
+ }
224
+
225
+ async function ensureDir(dir) {
226
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 })
227
+ }
228
+
229
+ /**
230
+ * Write `data` to `file` through a temp file + rename, with an explicit mode.
231
+ * Rename is atomic on the same filesystem, so a crash never leaves a partial
232
+ * config.json that would lose the enrollment.
233
+ */
234
+ async function atomicWriteFile(file, data, mode = 0o600) {
235
+ await ensureDir(path.dirname(file))
236
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp-${process.pid}-${Date.now()}`)
237
+ let handle
238
+ try {
239
+ handle = await fsp.open(tmp, 'w', mode)
240
+ await handle.writeFile(data, 'utf8')
241
+ try {
242
+ await handle.sync()
243
+ } catch {
244
+ // fsync is not supported everywhere (and not required for atomicity)
245
+ }
246
+ await handle.close()
247
+ handle = undefined
248
+ await fsp.rename(tmp, file)
249
+ try {
250
+ await fsp.chmod(file, mode)
251
+ } catch {
252
+ // best effort only
253
+ }
254
+ } catch (error) {
255
+ if (handle) {
256
+ try {
257
+ await handle.close()
258
+ } catch {
259
+ // ignore
260
+ }
261
+ }
262
+ try {
263
+ await fsp.rm(tmp, { force: true })
264
+ } catch {
265
+ // ignore
266
+ }
267
+ throw error
268
+ }
269
+ }
270
+
271
+ async function saveConfig(config) {
272
+ await atomicWriteFile(configFile(), `${JSON.stringify(config, null, 2)}\n`, 0o600)
273
+ }
274
+
275
+ async function loadConfig() {
276
+ const file = configFile()
277
+ let raw
278
+ let info
279
+ try {
280
+ info = await fsp.stat(file)
281
+ raw = await fsp.readFile(file, 'utf8')
282
+ } catch (error) {
283
+ if (error?.code === 'ENOENT') {
284
+ throw new CliError(`尚未注册:找不到配置文件 ${file}\n请先运行 enroll --server <url> --key <key>(或使用服务器给出的安装命令)。`)
285
+ }
286
+ throw new CliError(`无法读取配置文件 ${file}:${describeError(error)}`)
287
+ }
288
+ if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) {
289
+ process.stderr.write(`警告:${file} 的权限过宽,已修正为 0600\n`)
290
+ try {
291
+ await fsp.chmod(file, 0o600)
292
+ } catch {
293
+ // keep going; the token is still only used locally
294
+ }
295
+ }
296
+ const parsed = parseJsonSafe(raw)
297
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
298
+ throw new CliError(`配置文件 ${file} 不是合法 JSON,请重新 enroll。`)
299
+ }
300
+ if (parsed.version !== 1) {
301
+ throw new CliError(`配置文件 ${file} 的版本不受支持(${String(parsed.version)}),请重新 enroll。`)
302
+ }
303
+ for (const field of ['serverUrl', 'computerId', 'token', 'name']) {
304
+ if (typeof parsed[field] !== 'string' || parsed[field].length === 0) {
305
+ throw new CliError(`配置文件 ${file} 缺少字段 ${field},请重新 enroll。`)
306
+ }
307
+ }
308
+ return {
309
+ version: 1,
310
+ serverUrl: normalizeServerUrl(parsed.serverUrl),
311
+ computerId: parsed.computerId,
312
+ token: parsed.token,
313
+ name: parsed.name,
314
+ enrolledAt: typeof parsed.enrolledAt === 'string' ? parsed.enrolledAt : '',
315
+ workspaceBase: normalizeWorkspaceBase(parsed.workspaceBase),
316
+ }
317
+ }
318
+
319
+ function redactToken(token) {
320
+ if (typeof token !== 'string' || token.length === 0) return '(无)'
321
+ return `dshc_…${token.slice(-6)}`
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Logging (agent.log, append, 5 MiB rotation keeping one .1)
326
+ // ---------------------------------------------------------------------------
327
+
328
+ function appendLog(message) {
329
+ const file = logFile()
330
+ try {
331
+ ensureAgentHome()
332
+ let size = 0
333
+ try {
334
+ size = fs.statSync(file).size
335
+ } catch {
336
+ size = 0
337
+ }
338
+ if (size > LOG_ROTATE_BYTES) {
339
+ try {
340
+ fs.rmSync(`${file}.1`, { force: true })
341
+ } catch {
342
+ // ignore
343
+ }
344
+ try {
345
+ fs.renameSync(file, `${file}.1`)
346
+ } catch {
347
+ // a concurrent writer may have moved it; not fatal
348
+ }
349
+ }
350
+ fs.appendFileSync(file, `${new Date().toISOString()} ${message}\n`, { mode: 0o600 })
351
+ } catch {
352
+ // Logging must never take the agent down.
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Default logger for embedders that did not pass `log` (e.g. a test or a host
358
+ * that forgot it): stay quiet on info, surface warnings/errors on the console.
359
+ */
360
+ function defaultRuntimeLog(level, message) {
361
+ if (level === 'warn') console.warn(`dsh-computers-agent: ${message}`)
362
+ else if (level === 'error') console.error(`dsh-computers-agent: ${message}`)
363
+ }
364
+
365
+ /**
366
+ * CLI `run` sink: always append to agent.log, and also echo to stdout unless
367
+ * launchd is already capturing stdout into the same log file.
368
+ */
369
+ function createCliLogSink() {
370
+ return (level, message) => {
371
+ appendLog(level === 'info' ? message : `[${level}] ${message}`)
372
+ if (process.env.DSH_COMPUTERS_LOG_ONLY !== '1') {
373
+ process.stdout.write(`[${new Date().toISOString()}] ${message}\n`)
374
+ }
375
+ }
376
+ }
377
+
378
+ const RUNTIME_LOG_LEVELS = new Set(['info', 'warn', 'error'])
379
+
380
+ /** Call the runtime's logger; a broken host logger must never break the loop. */
381
+ function runtimeLog(runtime, level, message) {
382
+ try {
383
+ runtime.log(RUNTIME_LOG_LEVELS.has(level) ? level : 'info', message)
384
+ } catch {
385
+ // ignore
386
+ }
387
+ }
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // HTTP / SSE client (global fetch, no dependencies)
391
+ // ---------------------------------------------------------------------------
392
+
393
+ function trimTrailingSlash(value) {
394
+ return String(value).replace(/\/+$/, '')
395
+ }
396
+
397
+ function apiBase(serverUrl) {
398
+ return `${trimTrailingSlash(serverUrl)}/api/dsh-computers`
399
+ }
400
+
401
+ function enrollUrl(serverUrl) {
402
+ return `${apiBase(serverUrl)}/agent/enroll`
403
+ }
404
+
405
+ function eventsUrl(serverUrl) {
406
+ return `${apiBase(serverUrl)}/agent/events`
407
+ }
408
+
409
+ function resultsUrl(serverUrl) {
410
+ return `${apiBase(serverUrl)}/agent/results`
411
+ }
412
+
413
+ function heartbeatUrl(serverUrl) {
414
+ return `${apiBase(serverUrl)}/agent/heartbeat`
415
+ }
416
+
417
+ function chunkUrl(serverUrl) {
418
+ return `${apiBase(serverUrl)}/agent/results/chunk`
419
+ }
420
+
421
+ function fsResultUrl(serverUrl) {
422
+ return `${apiBase(serverUrl)}/agent/fs/result`
423
+ }
424
+
425
+ function normalizeServerUrl(value) {
426
+ if (typeof value !== 'string' || !value.trim()) {
427
+ throw new CliError('缺少 --server <url>(dsh-web 的地址,例如 http://127.0.0.1:3000)')
428
+ }
429
+ let url
430
+ try {
431
+ url = new URL(value.trim())
432
+ } catch {
433
+ throw new CliError(`--server 不是合法 URL:${value}`)
434
+ }
435
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
436
+ throw new CliError('--server 必须是 http:// 或 https:// 地址')
437
+ }
438
+ url.search = ''
439
+ url.hash = ''
440
+ return trimTrailingSlash(url.toString())
441
+ }
442
+
443
+ function requestHeaders(token, extra = {}) {
444
+ const headers = {
445
+ 'user-agent': `dsh-computers-agent/${AGENT_VERSION}`,
446
+ ...extra,
447
+ }
448
+ if (token) headers.authorization = `Bearer ${token}`
449
+ return headers
450
+ }
451
+
452
+ /**
453
+ * One JSON request. Returns { status, ok, json, text }. Throws only on
454
+ * transport-level failures (DNS, refused connection, timeout, abort).
455
+ */
456
+ async function requestJson(url, { method = 'GET', token, body, timeoutMs = 30_000, signal } = {}) {
457
+ const controller = new AbortController()
458
+ const onOuterAbort = () => controller.abort(signal.reason)
459
+ if (signal) {
460
+ if (signal.aborted) controller.abort(signal.reason)
461
+ else signal.addEventListener('abort', onOuterAbort, { once: true })
462
+ }
463
+ const timer = setTimeout(() => controller.abort(new Error(`请求超时(${timeoutMs}ms)`)), timeoutMs)
464
+ timer.unref?.()
465
+ try {
466
+ const response = await fetch(url, {
467
+ method,
468
+ headers: requestHeaders(token, body === undefined ? {} : { 'content-type': 'application/json; charset=utf-8' }),
469
+ body: body === undefined ? undefined : JSON.stringify(body),
470
+ signal: controller.signal,
471
+ })
472
+ const text = await response.text()
473
+ return { status: response.status, ok: response.ok, json: parseJsonSafe(text), text }
474
+ } finally {
475
+ clearTimeout(timer)
476
+ signal?.removeEventListener('abort', onOuterAbort)
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Decide whether an HTTP 401 is OUR rejection of the stored token, or a
482
+ * transient server-side condition.
483
+ *
484
+ * The host plugin answers a bad bearer with JSON `{"error":"invalid-token",...}`
485
+ * (PROTOCOL §3). Anything else on 401 — bare text, a different JSON error, an
486
+ * empty body — is NOT proof that the token is dead: while the plugin is being
487
+ * disabled/enabled or upgraded, the SSE route momentarily does not exist and
488
+ * the request falls through to the generic `/api` gateway, which answers a bare
489
+ * 401/`unauthorized`. Treating that as fatal would break every connected
490
+ * machine until the user re-ran the installer, so it must be retried instead.
491
+ *
492
+ * The same rule applies to 403/404/5xx: any non-2xx during connect is a
493
+ * server-side condition, never a reason to drop the enrollment.
494
+ */
495
+ function isInvalidTokenRejection(status, bodyText) {
496
+ if (status !== 401) return false
497
+ const parsed = parseJsonSafe(bodyText)
498
+ return Boolean(parsed && typeof parsed === 'object' && parsed.error === 'invalid-token')
499
+ }
500
+
501
+ /** Read an error response body without ever throwing (bodies are tiny). */
502
+ async function readErrorBody(response) {
503
+ try {
504
+ return (await response.text()).trim()
505
+ } catch {
506
+ return ''
507
+ }
508
+ }
509
+
510
+ function sseHeaders(token, metadata) {
511
+ const headers = requestHeaders(token, {
512
+ accept: 'text/event-stream',
513
+ 'cache-control': 'no-cache',
514
+ })
515
+ // PROTOCOL §11.6: every reconnect (SSE GET has no body) carries the handshake
516
+ // metadata as a base64url JSON header, mirroring the reference product.
517
+ if (metadata) headers['x-dsh-computers-metadata'] = encodeMetadataHeader(metadata)
518
+ return headers
519
+ }
520
+
521
+ /** Parse one SSE block (`event:` / `data:` lines) into { event, data }. */
522
+ function parseSseBlock(block) {
523
+ let name = ''
524
+ const dataLines = []
525
+ for (const line of block.split('\n')) {
526
+ if (!line || line.startsWith(':')) continue
527
+ const colon = line.indexOf(':')
528
+ const field = colon === -1 ? line : line.slice(0, colon)
529
+ let value = colon === -1 ? '' : line.slice(colon + 1)
530
+ if (value.startsWith(' ')) value = value.slice(1)
531
+ if (field === 'event') name = value
532
+ else if (field === 'data') dataLines.push(value)
533
+ // `id:` and `retry:` are accepted and ignored; the agent owns its backoff.
534
+ }
535
+ if (name === '' && dataLines.length === 0) return null
536
+ return { event: name || 'message', data: dataLines.join('\n') }
537
+ }
538
+
539
+ /** Async generator over the events of a text/event-stream body. */
540
+ async function* sseEvents(stream, signal) {
541
+ const reader = stream.getReader()
542
+ const decoder = new TextDecoder()
543
+ let buffer = ''
544
+ try {
545
+ while (true) {
546
+ if (signal?.aborted) return
547
+ const { value, done } = await reader.read()
548
+ if (done) break
549
+ buffer += decoder.decode(value, { stream: true })
550
+ buffer = buffer.replace(/\r\n/g, '\n')
551
+ let boundary
552
+ while ((boundary = buffer.indexOf('\n\n')) !== -1) {
553
+ const block = buffer.slice(0, boundary)
554
+ buffer = buffer.slice(boundary + 2)
555
+ const event = parseSseBlock(block)
556
+ if (event) yield event
557
+ }
558
+ }
559
+ } finally {
560
+ try {
561
+ await reader.cancel()
562
+ } catch {
563
+ // already errored/closed
564
+ }
565
+ }
566
+ }
567
+
568
+ /** Open the event stream for ~timeoutMs and wait for `hello`. */
569
+ async function probeEventStream(config, timeoutMs = 5000) {
570
+ const controller = new AbortController()
571
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
572
+ timer.unref?.()
573
+ try {
574
+ const response = await fetch(eventsUrl(config.serverUrl), {
575
+ headers: sseHeaders(config.token, handshakeMetadata(config.workspaceBase)),
576
+ signal: controller.signal,
577
+ })
578
+ if (response.status === 401) {
579
+ const body = await readErrorBody(response)
580
+ if (isInvalidTokenRejection(response.status, body)) {
581
+ return { ok: false, unauthorized: true, message: 'HTTP 401 invalid-token' }
582
+ }
583
+ return { ok: false, message: `HTTP 401${body ? ` ${abbreviate(body, 80)}` : ''}(服务器可能尚未就绪)` }
584
+ }
585
+ if (!response.ok) {
586
+ const body = await readErrorBody(response)
587
+ return { ok: false, message: `HTTP ${response.status}${body ? ` ${abbreviate(body, 80)}` : ''}` }
588
+ }
589
+ if (!response.body) return { ok: false, message: '响应没有 body' }
590
+ for await (const event of sseEvents(response.body, controller.signal)) {
591
+ if (event.event === 'hello') {
592
+ return { ok: true, hello: parseJsonSafe(event.data) || {} }
593
+ }
594
+ }
595
+ return { ok: false, message: '服务器没有发送 hello 事件' }
596
+ } catch (error) {
597
+ if (controller.signal.aborted) return { ok: false, message: `等待 hello 超时(${timeoutMs}ms)` }
598
+ return { ok: false, message: describeError(error) }
599
+ } finally {
600
+ clearTimeout(timer)
601
+ controller.abort()
602
+ }
603
+ }
604
+
605
+ // ---------------------------------------------------------------------------
606
+ // Command execution
607
+ // ---------------------------------------------------------------------------
608
+
609
+ /**
610
+ * Build the host-native shell invocation.
611
+ * POSIX: /bin/sh -lc <command> (login shell so the user's PATH is available).
612
+ * Windows: let Node build `cmd.exe /d /s /c <command>` via the `shell` option;
613
+ * passing the command as an argv element would double-apply CreateProcess
614
+ * quoting and break metacharacters.
615
+ */
616
+ function shellSpawnSpec(command) {
617
+ if (process.platform === 'win32') {
618
+ return { command, args: [], detached: false, shell: process.env.ComSpec || 'cmd.exe' }
619
+ }
620
+ return { command: '/bin/sh', args: ['-lc', command], detached: true, shell: false }
621
+ }
622
+
623
+ function resolveCwd(cwd) {
624
+ const home = os.homedir()
625
+ if (typeof cwd !== 'string' || cwd.trim() === '') return home
626
+ let value = cwd.trim()
627
+ if (value === '~') value = home
628
+ else if (value.startsWith('~/') || value.startsWith('~\\')) value = path.join(home, value.slice(2))
629
+ return path.resolve(value)
630
+ }
631
+
632
+ /** Bounded byte capture for one output stream; never grows past `limit`. */
633
+ function createCapture(limit = MAX_OUTPUT_BYTES) {
634
+ const chunks = []
635
+ let size = 0
636
+ let overflow = false
637
+ return {
638
+ push(chunk) {
639
+ if (overflow) return
640
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
641
+ const room = limit - size
642
+ if (buffer.length > room) {
643
+ if (room > 0) {
644
+ chunks.push(buffer.subarray(0, room))
645
+ size = limit
646
+ }
647
+ overflow = true
648
+ return
649
+ }
650
+ chunks.push(buffer)
651
+ size += buffer.length
652
+ },
653
+ isTruncated: () => overflow,
654
+ text: () => Buffer.concat(chunks, size).toString('utf8'),
655
+ }
656
+ }
657
+
658
+ function normalizeTimeout(value) {
659
+ const number = Number(value)
660
+ if (!Number.isFinite(number) || number <= 0) return DEFAULT_EXEC_TIMEOUT_MS
661
+ return Math.min(Math.max(1, Math.floor(number)), MAX_EXEC_TIMEOUT_MS)
662
+ }
663
+
664
+ /**
665
+ * Run one command. Resolves with exactly the §3.3 result shape (minus
666
+ * requestId):
667
+ * { ok, exitCode, signal, stdout, stderr, durationMs, truncated, timedOut, error }
668
+ *
669
+ * The child is spawned detached on POSIX so the whole process group can be
670
+ * SIGKILLed on timeout/cancel; otherwise `sh -lc 'cmd | pipeline'` would leave
671
+ * grandchildren running and the timeout would not actually stop the work.
672
+ */
673
+ function executeCommand({ command, cwd, timeoutMs, onStdout, onStderr }) {
674
+ const startedAt = Date.now()
675
+ const limit = normalizeTimeout(timeoutMs)
676
+ const stdout = createCapture()
677
+ const stderr = createCapture()
678
+ let child
679
+ let finished = false
680
+ let timedOut = false
681
+ let cancelled = false
682
+ let timeoutTimer
683
+ let killTimer
684
+ let resolvePromise
685
+ const promise = new Promise((resolve) => {
686
+ resolvePromise = resolve
687
+ })
688
+
689
+ const settle = (exitCode, signalCode, errorMessage) => {
690
+ if (finished) return
691
+ finished = true
692
+ clearTimeout(timeoutTimer)
693
+ clearTimeout(killTimer)
694
+ const truncated = stdout.isTruncated() || stderr.isTruncated()
695
+ resolvePromise({
696
+ ok: exitCode === 0 && !timedOut && !cancelled,
697
+ exitCode: typeof exitCode === 'number' ? exitCode : null,
698
+ signal: signalCode ?? null,
699
+ stdout: stdout.text() + (stdout.isTruncated() ? TRUNCATION_MARKER : ''),
700
+ stderr: stderr.text() + (stderr.isTruncated() ? TRUNCATION_MARKER : ''),
701
+ durationMs: Date.now() - startedAt,
702
+ truncated,
703
+ timedOut,
704
+ error: errorMessage ?? (cancelled ? 'cancelled' : null),
705
+ })
706
+ }
707
+
708
+ const killGroup = () => {
709
+ if (finished) return
710
+ if (!child || child.pid === undefined) return
711
+ if (process.platform === 'win32') {
712
+ try {
713
+ spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true }).unref()
714
+ } catch {
715
+ // best effort
716
+ }
717
+ } else {
718
+ try {
719
+ // Negative pid = the child's process group (it is a group leader).
720
+ process.kill(-child.pid, 'SIGKILL')
721
+ } catch {
722
+ try {
723
+ child.kill('SIGKILL')
724
+ } catch {
725
+ // already gone
726
+ }
727
+ }
728
+ }
729
+ // SIGKILL cannot be ignored, but guard against a wedged close event so the
730
+ // result is always reported.
731
+ killTimer = setTimeout(() => settle(null, 'SIGKILL', cancelled ? 'cancelled' : '命令在终止后仍未退出'), 5000)
732
+ killTimer.unref?.()
733
+ }
734
+
735
+ try {
736
+ const spec = shellSpawnSpec(String(command ?? ''))
737
+ child = spawn(spec.command, spec.args, {
738
+ cwd: resolveCwd(cwd),
739
+ env: process.env,
740
+ detached: spec.detached,
741
+ stdio: ['ignore', 'pipe', 'pipe'],
742
+ shell: spec.shell,
743
+ windowsHide: true,
744
+ })
745
+ } catch (error) {
746
+ settle(null, null, `无法启动命令:${describeError(error)}`)
747
+ return { promise, cancel() {} }
748
+ }
749
+
750
+ // The capture is capped for the final result; streaming (when enabled by the
751
+ // host) sees every chunk so the job ring/progress view stays live.
752
+ child.stdout?.on('data', (chunk) => {
753
+ stdout.push(chunk)
754
+ if (onStdout) {
755
+ try {
756
+ onStdout(chunk)
757
+ } catch {
758
+ // a broken stream sink must never kill the command
759
+ }
760
+ }
761
+ })
762
+ child.stderr?.on('data', (chunk) => {
763
+ stderr.push(chunk)
764
+ if (onStderr) {
765
+ try {
766
+ onStderr(chunk)
767
+ } catch {
768
+ // ignore
769
+ }
770
+ }
771
+ })
772
+ child.on('error', (error) => settle(null, null, `命令进程错误:${describeError(error)}`))
773
+ child.on('close', (code, signalCode) => settle(code, signalCode, null))
774
+ timeoutTimer = setTimeout(() => {
775
+ timedOut = true
776
+ killGroup()
777
+ }, limit)
778
+
779
+ return {
780
+ promise,
781
+ cancel() {
782
+ if (finished) return
783
+ cancelled = true
784
+ killGroup()
785
+ },
786
+ }
787
+ }
788
+
789
+ // ---------------------------------------------------------------------------
790
+ // results / heartbeat client (one runtime instance per startRuntime() call)
791
+ // ---------------------------------------------------------------------------
792
+
793
+ async function postResult(runtime, payload) {
794
+ const config = runtime.config
795
+ const url = resultsUrl(config.serverUrl)
796
+ for (let attempt = 1; attempt <= 3; attempt++) {
797
+ try {
798
+ const response = await requestJson(url, { method: 'POST', token: config.token, body: payload, timeoutMs: 30_000 })
799
+ if (response.status === 200) return true
800
+ if (response.status === 404) {
801
+ runtimeLog(runtime, 'info', '结果被服务器忽略(该请求已结算或已超时)')
802
+ return false
803
+ }
804
+ if (response.status === 401) {
805
+ if (isInvalidTokenRejection(response.status, response.text)) {
806
+ runtimeLog(runtime, 'error', '结果上报被拒绝(invalid-token):token 已失效,需要重新 enroll')
807
+ return false
808
+ }
809
+ // Bare/foreign 401 (server reloading): retry like any transient failure.
810
+ throw new Error(`HTTP 401${response.text ? ` ${abbreviate(response.text, 80)}` : ''}`)
811
+ }
812
+ throw new Error(`HTTP ${response.status}${response.text ? ` ${abbreviate(response.text, 100)}` : ''}`)
813
+ } catch (error) {
814
+ if (attempt === 3) {
815
+ runtimeLog(runtime, 'error', `结果上报失败(${payload.requestId}):${describeError(error)}`)
816
+ return false
817
+ }
818
+ await sleep(1000 * attempt)
819
+ }
820
+ }
821
+ return false
822
+ }
823
+
824
+ async function sendHeartbeat(runtime) {
825
+ const config = runtime.config
826
+ const state = runtime.state
827
+ try {
828
+ const response = await requestJson(heartbeatUrl(config.serverUrl), {
829
+ method: 'POST',
830
+ token: config.token,
831
+ body: {
832
+ meta: {
833
+ cpuModel: machineInfo('').cpuModel,
834
+ memoryBytes: os.totalmem(),
835
+ agentVersion: AGENT_VERSION,
836
+ capabilities: [...AGENT_CAPABILITIES],
837
+ },
838
+ },
839
+ timeoutMs: 15_000,
840
+ })
841
+ if (response.status === 401) {
842
+ if (isInvalidTokenRejection(response.status, response.text)) {
843
+ runtimeLog(runtime, 'error', '心跳被拒绝(invalid-token):token 已失效,请重新 enroll')
844
+ } else {
845
+ logHeartbeatError(runtime, `HTTP 401${response.text ? ` ${abbreviate(response.text, 60)}` : ''}(服务器可能正在重启)`)
846
+ }
847
+ return
848
+ }
849
+ if (!response.ok) {
850
+ logHeartbeatError(runtime, `HTTP ${response.status}`)
851
+ return
852
+ }
853
+ const name = response.json && typeof response.json.name === 'string' ? response.json.name : ''
854
+ if (name && name !== state.name) {
855
+ state.name = name
856
+ config.name = name
857
+ emitRuntimeState(runtime)
858
+ runtimeLog(runtime, 'info', `服务器把电脑名称更新为 ${name}`)
859
+ }
860
+ } catch (error) {
861
+ logHeartbeatError(runtime, describeError(error))
862
+ }
863
+ }
864
+
865
+ function logHeartbeatError(runtime, message) {
866
+ const now = Date.now()
867
+ if (now - runtime.state.lastHeartbeatErrorAt < HEARTBEAT_LOG_INTERVAL_MS) return
868
+ runtime.state.lastHeartbeatErrorAt = now
869
+ runtimeLog(runtime, 'warn', `心跳失败:${message}`)
870
+ }
871
+
872
+ // ---------------------------------------------------------------------------
873
+ // Streaming result chunks (§11.2 / §11.6)
874
+ //
875
+ // Only used when an `exec` event carries `stream: true` AND the host's `hello`
876
+ // advertised the `stream` feature (§11.5). Frames are posted sequentially (so
877
+ // `seq` order matches arrival order):
878
+ // { requestId, seq, type: 'stdout'|'stderr'|'heartbeat', data: base64 }
879
+ // Batches flush at most every 100 ms or 32 KiB (whichever first); a heartbeat
880
+ // frame goes out after each configured silent period (default 10 s). Chunk
881
+ // failures retry once, then that batch is dropped — the final /agent/results
882
+ // POST remains authoritative and still carries the capped buffers.
883
+ // ---------------------------------------------------------------------------
884
+
885
+ function logChunkDropThrottled(runtime, reason) {
886
+ const now = Date.now()
887
+ if (now - runtime.state.lastChunkDropAt < HEARTBEAT_LOG_INTERVAL_MS) return
888
+ runtime.state.lastChunkDropAt = now
889
+ runtimeLog(runtime, 'warn', `流式分片上报失败(已丢弃该批):${reason}`)
890
+ }
891
+
892
+ function createChunkPump(runtime, requestId) {
893
+ const buffers = { stdout: [], stderr: [] }
894
+ const sizes = { stdout: 0, stderr: 0 }
895
+ const heartbeatMs = runtime.state.chunkHeartbeatMs || CHUNK_HEARTBEAT_MS
896
+ let seq = 0
897
+ let queue = Promise.resolve()
898
+ let flushTimer = null
899
+ let heartbeatTimer = null
900
+ let closed = false
901
+ let aborted = false
902
+ let lastSentAt = Date.now()
903
+
904
+ const clearTimers = () => {
905
+ if (flushTimer) {
906
+ clearTimeout(flushTimer)
907
+ flushTimer = null
908
+ }
909
+ if (heartbeatTimer) {
910
+ clearInterval(heartbeatTimer)
911
+ heartbeatTimer = null
912
+ }
913
+ }
914
+
915
+ const postFrame = async (frame) => {
916
+ for (let attempt = 0; attempt < 2; attempt++) {
917
+ if (aborted || runtime.stopping) return false
918
+ try {
919
+ const response = await requestJson(chunkUrl(runtime.config.serverUrl), {
920
+ method: 'POST',
921
+ token: runtime.config.token,
922
+ body: frame,
923
+ timeoutMs: CHUNK_POST_TIMEOUT_MS,
924
+ signal: runtime.controller.signal,
925
+ })
926
+ if (response.status === 200) {
927
+ lastSentAt = Date.now()
928
+ return true
929
+ }
930
+ if (response.status === 401 && isInvalidTokenRejection(response.status, response.text)) {
931
+ runtimeLog(runtime, 'error', '流式分片被拒绝(invalid-token):token 已失效,需要重新 enroll')
932
+ return false
933
+ }
934
+ if (attempt === 0) {
935
+ await sleep(50)
936
+ continue
937
+ }
938
+ logChunkDropThrottled(runtime, `HTTP ${response.status}`)
939
+ return false
940
+ } catch (error) {
941
+ if (attempt === 0 && !aborted && !runtime.controller.signal.aborted) {
942
+ await sleep(50)
943
+ continue
944
+ }
945
+ logChunkDropThrottled(runtime, describeError(error))
946
+ return false
947
+ }
948
+ }
949
+ return false
950
+ }
951
+
952
+ const enqueue = (frame) => {
953
+ queue = queue.then(() => postFrame(frame)).catch(() => {})
954
+ }
955
+
956
+ const takeFrames = () => {
957
+ const frames = []
958
+ for (const type of ['stdout', 'stderr']) {
959
+ if (sizes[type] === 0) continue
960
+ const data = Buffer.concat(buffers[type], sizes[type])
961
+ buffers[type] = []
962
+ sizes[type] = 0
963
+ frames.push({ requestId, seq: seq++, type, data: data.toString('base64') })
964
+ }
965
+ return frames
966
+ }
967
+
968
+ const flushNow = () => {
969
+ if (closed || aborted) return
970
+ if (flushTimer) {
971
+ clearTimeout(flushTimer)
972
+ flushTimer = null
973
+ }
974
+ for (const frame of takeFrames()) enqueue(frame)
975
+ }
976
+
977
+ const scheduleFlush = () => {
978
+ if (flushTimer || closed || aborted) return
979
+ flushTimer = setTimeout(() => {
980
+ flushTimer = null
981
+ flushNow()
982
+ }, CHUNK_FLUSH_INTERVAL_MS)
983
+ flushTimer.unref?.()
984
+ }
985
+
986
+ const maybeHeartbeat = () => {
987
+ if (closed || aborted || runtime.stopping) return
988
+ if (Date.now() - lastSentAt >= heartbeatMs) {
989
+ enqueue({ requestId, seq: seq++, type: 'heartbeat', data: '' })
990
+ }
991
+ }
992
+
993
+ heartbeatTimer = setInterval(maybeHeartbeat, heartbeatMs)
994
+ heartbeatTimer.unref?.()
995
+
996
+ const pump = {
997
+ push(type, chunk) {
998
+ if (closed || aborted) return
999
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
1000
+ buffers[type].push(buffer)
1001
+ sizes[type] += buffer.length
1002
+ if (sizes.stdout + sizes.stderr >= CHUNK_MAX_BATCH_BYTES) flushNow()
1003
+ else scheduleFlush()
1004
+ },
1005
+ /** Flush what is buffered, stop timers and wait for queued frames. */
1006
+ async close() {
1007
+ flushNow()
1008
+ closed = true
1009
+ clearTimers()
1010
+ await queue
1011
+ },
1012
+ /** Drop everything (shutdown): no flush, no timers, no new frames. */
1013
+ abort() {
1014
+ aborted = true
1015
+ closed = true
1016
+ clearTimers()
1017
+ buffers.stdout = []
1018
+ buffers.stderr = []
1019
+ sizes.stdout = 0
1020
+ sizes.stderr = 0
1021
+ },
1022
+ }
1023
+ runtime.pumps.add(pump)
1024
+ return pump
1025
+ }
1026
+
1027
+ // ---------------------------------------------------------------------------
1028
+ // Remote file access (§11.3)
1029
+ //
1030
+ // Implemented in-process (no shell). Rules:
1031
+ // · read/write/list/stat/mkdir/rename/delete require ABSOLUTE paths and answer
1032
+ // `ok:false, error:"path must be absolute"` otherwise (§11.3).
1033
+ // · glob/grep accept absolute or cwd-relative paths/patterns; relative ones
1034
+ // resolve against the agent's workspaceBase (§11.6).
1035
+ // · reads follow symlinks (normal OS read semantics, like `cat`); lstat-based
1036
+ // stat/list report a symlink itself. write follows an existing symlink.
1037
+ // · directory walks never follow symlinked directories (loop safety) and skip
1038
+ // `.git`; grep skips binary files and files larger than 8 MiB.
1039
+ // · limits: read 1 MiB / 20 000 lines, write 4 MiB, list 5 000 entries,
1040
+ // glob 2 000 paths, grep 500 matches, 15 s per request.
1041
+ // ---------------------------------------------------------------------------
1042
+
1043
+ function escapeRegExpText(text) {
1044
+ return String(text).replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
1045
+ }
1046
+
1047
+ /** Turn a glob (`*`, `?`, `**`, `{a,b}`) into an anchored RegExp on '/'-paths. */
1048
+ function globToRegExp(pattern) {
1049
+ const normalized = String(pattern).replaceAll('\\', '/')
1050
+ let out = '^'
1051
+ for (let index = 0; index < normalized.length; index++) {
1052
+ const char = normalized[index]
1053
+ if (char === '*') {
1054
+ if (normalized[index + 1] === '*') {
1055
+ index += 1
1056
+ if (normalized[index + 1] === '/') {
1057
+ index += 1
1058
+ out += '(?:[^/]+/)*'
1059
+ } else {
1060
+ out += '.*'
1061
+ }
1062
+ } else {
1063
+ out += '[^/]*'
1064
+ }
1065
+ } else if (char === '?') {
1066
+ out += '[^/]'
1067
+ } else if (char === '{') {
1068
+ const close = normalized.indexOf('}', index)
1069
+ if (close === -1) {
1070
+ out += '\\{'
1071
+ } else {
1072
+ const options = normalized.slice(index + 1, close).split(',').map(escapeRegExpText)
1073
+ out += `(?:${options.join('|')})`
1074
+ index = close
1075
+ }
1076
+ } else if (char === '[') {
1077
+ const close = normalized.indexOf(']', index)
1078
+ if (close === -1) {
1079
+ out += '\\['
1080
+ } else {
1081
+ out += normalized.slice(index, close + 1)
1082
+ index = close
1083
+ }
1084
+ } else {
1085
+ out += escapeRegExpText(char)
1086
+ }
1087
+ }
1088
+ return new RegExp(`${out}$`)
1089
+ }
1090
+
1091
+ function toPosixPath(value) {
1092
+ return String(value).replaceAll('\\', '/')
1093
+ }
1094
+
1095
+ /** Directory prefix of a glob pattern that contains no wildcards. */
1096
+ function globBaseDirectory(pattern) {
1097
+ const normalized = toPosixPath(pattern)
1098
+ const absolute = normalized.startsWith('/')
1099
+ const segments = normalized.split('/')
1100
+ const base = []
1101
+ for (const segment of segments) {
1102
+ if (segment === '') continue
1103
+ if (/[*?[\]{}]/.test(segment)) break
1104
+ base.push(segment)
1105
+ }
1106
+ const joined = base.join('/')
1107
+ if (absolute) return joined ? `/${joined}` : '/'
1108
+ return joined || '.'
1109
+ }
1110
+
1111
+ function requireAbsoluteFsPath(value) {
1112
+ if (typeof value !== 'string' || value.trim() === '' || !path.isAbsolute(value)) {
1113
+ throw new Error('path must be absolute')
1114
+ }
1115
+ return value
1116
+ }
1117
+
1118
+ function resolveWorkspacePath(value, workspaceBase) {
1119
+ if (typeof value !== 'string' || value.trim() === '') return workspaceBase
1120
+ return path.isAbsolute(value) ? value : path.resolve(workspaceBase, value)
1121
+ }
1122
+
1123
+ function describeFsType(stats) {
1124
+ if (!stats) return 'other'
1125
+ if (stats.isDirectory()) return 'dir'
1126
+ if (stats.isFile()) return 'file'
1127
+ if (stats.isSymbolicLink()) return 'symlink'
1128
+ return 'other'
1129
+ }
1130
+
1131
+ function looksBinary(buffer) {
1132
+ const window = buffer.subarray(0, Math.min(buffer.length, FS_BINARY_SNIFF_BYTES))
1133
+ return window.includes(0)
1134
+ }
1135
+
1136
+ function clampInteger(value, minimum, maximum, fallback) {
1137
+ const number = Number(value)
1138
+ if (!Number.isFinite(number)) return fallback
1139
+ return Math.min(maximum, Math.max(minimum, Math.floor(number)))
1140
+ }
1141
+
1142
+ async function readFileWindow(file, maximumBytes) {
1143
+ const handle = await fsp.open(file, 'r')
1144
+ try {
1145
+ const stats = await handle.stat()
1146
+ const length = Math.min(stats.size, maximumBytes)
1147
+ const buffer = Buffer.alloc(length)
1148
+ let offset = 0
1149
+ while (offset < length) {
1150
+ const { bytesRead } = await handle.read(buffer, offset, length - offset, offset)
1151
+ if (bytesRead <= 0) break
1152
+ offset += bytesRead
1153
+ }
1154
+ return { buffer: buffer.subarray(0, offset), stats }
1155
+ } finally {
1156
+ await handle.close().catch(() => {})
1157
+ }
1158
+ }
1159
+
1160
+ async function fsRead(request) {
1161
+ const target = requireAbsoluteFsPath(request.path)
1162
+ const stats = await fsp.stat(target)
1163
+ if (stats.isDirectory()) throw new Error('EISDIR: illegal operation on a directory, read')
1164
+ const { buffer } = await readFileWindow(target, FS_READ_MAX_BYTES + FS_BINARY_SNIFF_BYTES)
1165
+ const requestedBase64 = request.encoding === 'base64'
1166
+ const requestedUtf8 = request.encoding === 'utf8'
1167
+ const binary = requestedBase64 || (!requestedUtf8 && looksBinary(buffer))
1168
+ const windowTruncated = stats.size > buffer.length
1169
+ if (binary) {
1170
+ const content = buffer.subarray(0, FS_READ_MAX_BYTES)
1171
+ return {
1172
+ encoding: 'base64',
1173
+ content: content.toString('base64'),
1174
+ bytes: content.length,
1175
+ truncated: windowTruncated || buffer.length > FS_READ_MAX_BYTES,
1176
+ }
1177
+ }
1178
+ let text = buffer.subarray(0, FS_READ_MAX_BYTES).toString('utf8')
1179
+ let readTruncated = windowTruncated || buffer.length > FS_READ_MAX_BYTES
1180
+ if (readTruncated) {
1181
+ // Do not return a half line/character at the window edge.
1182
+ const lastNewline = text.lastIndexOf('\n')
1183
+ if (lastNewline > 0) text = text.slice(0, lastNewline)
1184
+ }
1185
+ const lines = text.split('\n')
1186
+ const offset = clampInteger(request.offset, 0, Number.MAX_SAFE_INTEGER, 0)
1187
+ const limit = clampInteger(request.limit, 1, FS_READ_MAX_LINES, FS_READ_DEFAULT_LIMIT)
1188
+ const selected = lines.slice(offset, offset + limit)
1189
+ const selectedText = selected.join('\n')
1190
+ return {
1191
+ encoding: 'utf8',
1192
+ text: selectedText,
1193
+ lines: selected.length,
1194
+ bytes: Buffer.byteLength(selectedText, 'utf8'),
1195
+ truncated: readTruncated || offset + selected.length < lines.length,
1196
+ }
1197
+ }
1198
+
1199
+ async function fsWrite(request) {
1200
+ const target = requireAbsoluteFsPath(request.path)
1201
+ const encoding = request.encoding === 'base64' ? 'base64' : 'utf8'
1202
+ const raw = typeof request.content === 'string' ? request.content : request.content == null ? '' : String(request.content)
1203
+ const content = encoding === 'base64' ? Buffer.from(raw, 'base64') : Buffer.from(raw, 'utf8')
1204
+ if (content.length > FS_WRITE_MAX_BYTES) {
1205
+ throw new Error(`content exceeds the ${FS_WRITE_MAX_BYTES} byte write limit`)
1206
+ }
1207
+ let existed = true
1208
+ try {
1209
+ await fsp.access(target)
1210
+ } catch {
1211
+ existed = false
1212
+ }
1213
+ if (request.createDirs === true) await fsp.mkdir(path.dirname(target), { recursive: true })
1214
+ await fsp.writeFile(target, content)
1215
+ return { encoding, bytes: content.length, created: !existed }
1216
+ }
1217
+
1218
+ async function fsList(request) {
1219
+ const target = requireAbsoluteFsPath(request.path)
1220
+ const dirents = await fsp.readdir(target, { withFileTypes: true })
1221
+ dirents.sort((left, right) => left.name.localeCompare(right.name))
1222
+ const truncated = dirents.length > FS_LIST_MAX_ENTRIES
1223
+ const entries = []
1224
+ for (const dirent of dirents.slice(0, FS_LIST_MAX_ENTRIES)) {
1225
+ const entryPath = path.join(target, dirent.name)
1226
+ let stats = null
1227
+ try {
1228
+ stats = await fsp.lstat(entryPath)
1229
+ } catch {
1230
+ stats = null
1231
+ }
1232
+ entries.push({
1233
+ name: dirent.name,
1234
+ path: entryPath,
1235
+ type: describeFsType(stats ?? dirent),
1236
+ size: stats ? stats.size : 0,
1237
+ mtimeMs: stats ? stats.mtimeMs : 0,
1238
+ })
1239
+ }
1240
+ return { entries, truncated }
1241
+ }
1242
+
1243
+ async function fsStat(request) {
1244
+ const target = requireAbsoluteFsPath(request.path)
1245
+ const stats = await fsp.lstat(target)
1246
+ return {
1247
+ stat: {
1248
+ type: describeFsType(stats),
1249
+ size: stats.size,
1250
+ mtimeMs: stats.mtimeMs,
1251
+ mode: stats.mode & 0o777,
1252
+ },
1253
+ }
1254
+ }
1255
+
1256
+ async function fsMkdir(request) {
1257
+ const target = requireAbsoluteFsPath(request.path)
1258
+ await fsp.mkdir(target, { recursive: request.recursive === true })
1259
+ return { created: true }
1260
+ }
1261
+
1262
+ async function fsRename(request) {
1263
+ const from = requireAbsoluteFsPath(request.path)
1264
+ const to = requireAbsoluteFsPath(request.to)
1265
+ await fsp.rename(from, to)
1266
+ return { path: to }
1267
+ }
1268
+
1269
+ async function fsDelete(request) {
1270
+ const target = requireAbsoluteFsPath(request.path)
1271
+ await fsp.rm(target, { recursive: request.recursive === true, force: false })
1272
+ return {}
1273
+ }
1274
+
1275
+ /** Depth-first walk; never follows symlinked directories, checks abort/deadline. */
1276
+ async function walkTree(root, context, onEntry) {
1277
+ const stack = [root]
1278
+ while (stack.length > 0) {
1279
+ if (context.signal?.aborted) throw new Error('request aborted')
1280
+ if (Date.now() > context.deadline) throw new Error('request timed out')
1281
+ const directory = stack.pop()
1282
+ let entries
1283
+ try {
1284
+ entries = await fsp.readdir(directory, { withFileTypes: true })
1285
+ } catch {
1286
+ continue
1287
+ }
1288
+ entries.sort((left, right) => left.name.localeCompare(right.name))
1289
+ for (const entry of entries) {
1290
+ if (entry.name === '.git') continue
1291
+ const full = path.join(directory, entry.name)
1292
+ if (entry.isDirectory()) {
1293
+ stack.push(full)
1294
+ await onEntry(full, 'dir')
1295
+ } else if (entry.isFile()) {
1296
+ await onEntry(full, 'file')
1297
+ } else if (entry.isSymbolicLink()) {
1298
+ await onEntry(full, 'symlink')
1299
+ }
1300
+ }
1301
+ }
1302
+ }
1303
+
1304
+ async function fsGlob(request, context) {
1305
+ const rawPattern = typeof request.pattern === 'string' ? request.pattern.trim() : ''
1306
+ if (!rawPattern) throw new Error('pattern is required')
1307
+ const pattern = path.isAbsolute(rawPattern) ? rawPattern : path.resolve(context.workspaceBase, rawPattern)
1308
+ const regex = globToRegExp(toPosixPath(pattern))
1309
+ const base = globBaseDirectory(pattern)
1310
+ const paths = []
1311
+ let truncated = false
1312
+
1313
+ const record = (candidate) => {
1314
+ if (!regex.test(toPosixPath(candidate))) return
1315
+ if (paths.length >= FS_GLOB_MAX_PATHS) {
1316
+ truncated = true
1317
+ return
1318
+ }
1319
+ paths.push(candidate)
1320
+ }
1321
+
1322
+ let baseStats = null
1323
+ try {
1324
+ baseStats = await fsp.stat(base)
1325
+ } catch {
1326
+ baseStats = null
1327
+ }
1328
+ if (baseStats && baseStats.isFile()) {
1329
+ // A fully literal pattern (no wildcards) that points at a file.
1330
+ record(base)
1331
+ } else {
1332
+ await walkTree(base, context, async (full, type) => {
1333
+ record(full)
1334
+ if (type === 'dir') return
1335
+ })
1336
+ }
1337
+ paths.sort()
1338
+ return { paths, truncated }
1339
+ }
1340
+
1341
+ async function fsGrep(request, context) {
1342
+ const root = resolveWorkspacePath(request.path, context.workspaceBase)
1343
+ const rawPattern = typeof request.pattern === 'string' ? request.pattern : ''
1344
+ let regex
1345
+ try {
1346
+ regex = new RegExp(rawPattern)
1347
+ } catch (error) {
1348
+ throw new Error(`invalid pattern: ${describeError(error)}`)
1349
+ }
1350
+ const includeRegex =
1351
+ typeof request.include === 'string' && request.include.trim() ? globToRegExp(request.include.trim()) : null
1352
+ const maxMatches = clampInteger(request.maxMatches, 1, FS_GREP_MAX_MATCHES, FS_GREP_MAX_MATCHES)
1353
+ const matches = []
1354
+ let truncated = false
1355
+
1356
+ const consider = async (file) => {
1357
+ if (Date.now() > context.deadline) throw new Error('request timed out')
1358
+ if (context.signal?.aborted) throw new Error('request aborted')
1359
+ const relative = toPosixPath(path.relative(context.workspaceBase, file))
1360
+ const basename = path.basename(file)
1361
+ if (includeRegex && !includeRegex.test(relative) && !includeRegex.test(basename)) return
1362
+ let stats
1363
+ try {
1364
+ stats = await fsp.stat(file)
1365
+ } catch {
1366
+ return
1367
+ }
1368
+ if (!stats.isFile() || stats.size > FS_GREP_MAX_FILE_BYTES) return
1369
+ let buffer
1370
+ try {
1371
+ buffer = await fsp.readFile(file)
1372
+ } catch {
1373
+ return
1374
+ }
1375
+ if (looksBinary(buffer)) return
1376
+ const lines = buffer.toString('utf8').split(/\r?\n/)
1377
+ for (let index = 0; index < lines.length; index++) {
1378
+ if (!regex.test(lines[index])) continue
1379
+ if (matches.length >= maxMatches) {
1380
+ truncated = true
1381
+ return
1382
+ }
1383
+ matches.push({ path: file, line: index + 1, text: lines[index].slice(0, 2000) })
1384
+ }
1385
+ }
1386
+
1387
+ const stats = await fsp.stat(root)
1388
+ if (stats.isFile()) await consider(root)
1389
+ else await walkTree(root, context, async (full, type) => {
1390
+ if (type === 'file') await consider(full)
1391
+ })
1392
+ return { matches, truncated }
1393
+ }
1394
+
1395
+ async function runFsRequest(runtime, request, context) {
1396
+ const base = {
1397
+ requestId: request.requestId,
1398
+ ok: true,
1399
+ op: request.op,
1400
+ path: typeof request.path === 'string' ? request.path : '',
1401
+ error: null,
1402
+ }
1403
+ switch (request.op) {
1404
+ case 'read':
1405
+ return { ...base, ...(await fsRead(request)) }
1406
+ case 'write':
1407
+ return { ...base, ...(await fsWrite(request)) }
1408
+ case 'list':
1409
+ return { ...base, ...(await fsList(request)) }
1410
+ case 'stat':
1411
+ return { ...base, ...(await fsStat(request)) }
1412
+ case 'mkdir':
1413
+ return { ...base, ...(await fsMkdir(request)) }
1414
+ case 'rename':
1415
+ return { ...base, ...(await fsRename(request)) }
1416
+ case 'delete':
1417
+ return { ...base, ...(await fsDelete(request)) }
1418
+ case 'glob':
1419
+ return { ...base, ...(await fsGlob(request, context)) }
1420
+ case 'grep':
1421
+ return { ...base, ...(await fsGrep(request, context)) }
1422
+ default:
1423
+ throw new Error(`unsupported fs op: ${request.op}`)
1424
+ }
1425
+ }
1426
+
1427
+ /** Run one fs request with the 15 s deadline/abort wrapper. Never throws. */
1428
+ async function executeFsRequest(runtime, request) {
1429
+ const controller = new AbortController()
1430
+ runtime.fs.controllers.add(controller)
1431
+ const deadline = Date.now() + FS_REQUEST_TIMEOUT_MS
1432
+ let timer
1433
+ const timeout = new Promise((_resolve, reject) => {
1434
+ timer = setTimeout(() => {
1435
+ controller.abort()
1436
+ reject(new Error('request timed out'))
1437
+ }, FS_REQUEST_TIMEOUT_MS)
1438
+ timer.unref?.()
1439
+ })
1440
+ try {
1441
+ return await Promise.race([
1442
+ runFsRequest(runtime, request, {
1443
+ workspaceBase: runtime.config.workspaceBase,
1444
+ signal: controller.signal,
1445
+ deadline,
1446
+ }),
1447
+ timeout,
1448
+ ])
1449
+ } catch (error) {
1450
+ return {
1451
+ requestId: request.requestId,
1452
+ ok: false,
1453
+ op: request.op,
1454
+ path: typeof request.path === 'string' ? request.path : '',
1455
+ error: describeError(error),
1456
+ }
1457
+ } finally {
1458
+ clearTimeout(timer)
1459
+ runtime.fs.controllers.delete(controller)
1460
+ }
1461
+ }
1462
+
1463
+ async function postFsResult(runtime, payload) {
1464
+ for (let attempt = 0; attempt < 2; attempt++) {
1465
+ if (runtime.stopping) return false
1466
+ try {
1467
+ const response = await requestJson(fsResultUrl(runtime.config.serverUrl), {
1468
+ method: 'POST',
1469
+ token: runtime.config.token,
1470
+ body: payload,
1471
+ timeoutMs: 15_000,
1472
+ signal: runtime.controller.signal,
1473
+ })
1474
+ if (response.status === 200) return true
1475
+ if (response.status === 404) {
1476
+ runtimeLog(runtime, 'info', `fs 结果被服务器忽略(未知请求 ${payload.requestId})`)
1477
+ return false
1478
+ }
1479
+ if (response.status === 401 && isInvalidTokenRejection(response.status, response.text)) {
1480
+ runtimeLog(runtime, 'error', 'fs 结果上报被拒绝(invalid-token):token 已失效,需要重新 enroll')
1481
+ return false
1482
+ }
1483
+ throw new Error(`HTTP ${response.status}`)
1484
+ } catch (error) {
1485
+ if (attempt === 1 || runtime.stopping) {
1486
+ runtimeLog(runtime, 'warn', `fs 结果上报失败(${payload.requestId}):${describeError(error)}`)
1487
+ return false
1488
+ }
1489
+ await sleep(300)
1490
+ }
1491
+ }
1492
+ return false
1493
+ }
1494
+
1495
+ // ---------------------------------------------------------------------------
1496
+ // SSE event handlers
1497
+ // ---------------------------------------------------------------------------
1498
+
1499
+ function handleExec(runtime, rawData) {
1500
+ const request = parseJsonSafe(rawData)
1501
+ if (!request || typeof request.requestId !== 'string' || typeof request.command !== 'string') {
1502
+ runtimeLog(runtime, 'warn', '忽略一条字段不完整的 exec 事件')
1503
+ return
1504
+ }
1505
+ if (runtime.stopping || !runtime.state.running) {
1506
+ runtimeLog(runtime, 'warn', `运行时已停止,忽略请求 ${request.requestId}`)
1507
+ return
1508
+ }
1509
+ if (runtime.jobs.has(request.requestId)) {
1510
+ runtimeLog(runtime, 'warn', `忽略重复的请求 ${request.requestId}`)
1511
+ return
1512
+ }
1513
+ const explicitCwd = typeof request.cwd === 'string' && request.cwd.trim() ? request.cwd : null
1514
+ // §11.2/§11.5: stream chunks only when the host asked for this exec AND
1515
+ // advertised the `stream` feature in `hello`.
1516
+ const streaming = request.stream === true && runtime.state.features.includes('stream')
1517
+ const pump = streaming ? createChunkPump(runtime, request.requestId) : null
1518
+ const job = executeCommand({
1519
+ command: request.command,
1520
+ cwd: explicitCwd ?? runtime.config.workspaceBase,
1521
+ timeoutMs: request.timeoutMs,
1522
+ onStdout: pump ? (chunk) => pump.push('stdout', chunk) : undefined,
1523
+ onStderr: pump ? (chunk) => pump.push('stderr', chunk) : undefined,
1524
+ })
1525
+ runtime.jobs.set(request.requestId, job)
1526
+ runtimeLog(runtime, 'info', `▶ ${request.requestId}${streaming ? ' [stream]' : ''} ${abbreviate(request.command)}`)
1527
+ void job.promise
1528
+ .then(async (result) => {
1529
+ runtimeLog(
1530
+ runtime,
1531
+ 'info',
1532
+ `◀ ${request.requestId} exit=${result.exitCode ?? 'null'} ${result.durationMs}ms` +
1533
+ `${result.timedOut ? ' timedOut' : ''}${result.truncated ? ' truncated' : ''}`,
1534
+ )
1535
+ if (runtime.stopping) {
1536
+ pump?.abort()
1537
+ runtimeLog(runtime, 'info', `运行时已停止,不再上报结果 ${request.requestId}`)
1538
+ return
1539
+ }
1540
+ // Flush the tail chunks before the authoritative final result so the host
1541
+ // assembles output in order, then close the pump (no timers left).
1542
+ if (pump) await pump.close()
1543
+ await postResult(runtime, { requestId: request.requestId, ...result })
1544
+ })
1545
+ .catch((error) => {
1546
+ pump?.abort()
1547
+ runtimeLog(runtime, 'error', `✗ ${request.requestId} 执行失败:${describeError(error)}`)
1548
+ })
1549
+ .finally(() => {
1550
+ pump?.abort()
1551
+ if (pump) runtime.pumps.delete(pump)
1552
+ runtime.jobs.delete(request.requestId)
1553
+ })
1554
+ }
1555
+
1556
+ function handleCancel(runtime, rawData) {
1557
+ const request = parseJsonSafe(rawData)
1558
+ if (!request || typeof request.requestId !== 'string') return
1559
+ const job = runtime.jobs.get(request.requestId)
1560
+ if (!job) {
1561
+ runtimeLog(runtime, 'info', `取消了一个未知请求:${request.requestId}`)
1562
+ return
1563
+ }
1564
+ runtimeLog(runtime, 'info', `取消 ${request.requestId}`)
1565
+ job.cancel()
1566
+ }
1567
+
1568
+ function handleFs(runtime, rawData) {
1569
+ const request = parseJsonSafe(rawData)
1570
+ if (!request || typeof request.requestId !== 'string' || typeof request.op !== 'string') {
1571
+ runtimeLog(runtime, 'warn', '忽略一条字段不完整的 fs 事件')
1572
+ return
1573
+ }
1574
+ // §11.5: an old host never sends fs events; only accept them when it
1575
+ // advertised the `fs` feature in `hello`.
1576
+ if (!runtime.state.features.includes('fs')) {
1577
+ runtimeLog(runtime, 'warn', `忽略 fs 事件(服务器未声明 fs 能力):${request.op} ${request.requestId}`)
1578
+ return
1579
+ }
1580
+ if (!FS_OPS.includes(request.op)) {
1581
+ runtimeLog(runtime, 'warn', `忽略不支持的 fs op:${request.op}`)
1582
+ return
1583
+ }
1584
+ if (runtime.stopping || !runtime.state.running) return
1585
+ const promise = (async () => {
1586
+ const result = await executeFsRequest(runtime, request)
1587
+ if (runtime.stopping) return
1588
+ await postFsResult(runtime, result)
1589
+ })()
1590
+ .catch((error) => {
1591
+ runtimeLog(runtime, 'error', `fs 请求失败:${describeError(error)}`)
1592
+ })
1593
+ .finally(() => {
1594
+ runtime.fs.promises.delete(promise)
1595
+ })
1596
+ runtime.fs.promises.add(promise)
1597
+ }
1598
+
1599
+ function applyConfigEvent(runtime, rawData) {
1600
+ const value = parseJsonSafe(rawData)
1601
+ if (!value) return
1602
+ if (typeof value.name === 'string' && value.name && value.name !== runtime.state.name) {
1603
+ runtime.state.name = value.name
1604
+ runtime.config.name = value.name
1605
+ emitRuntimeState(runtime)
1606
+ runtimeLog(runtime, 'info', `服务器把电脑名称更新为 ${value.name}`)
1607
+ }
1608
+ if (value.execEnabled === false) {
1609
+ runtimeLog(runtime, 'warn', '服务器已关闭远程执行(execEnabled=false),仍保持连接')
1610
+ }
1611
+ }
1612
+
1613
+ // ---------------------------------------------------------------------------
1614
+ // Embeddable runtime (PROTOCOL §10.4) — owns SSE, exec, results, heartbeats
1615
+ // ---------------------------------------------------------------------------
1616
+
1617
+ function requireOptionString(value, field) {
1618
+ if (typeof value !== 'string' || value.trim() === '') {
1619
+ throw new TypeError(`startRuntime: options.${field} must be a non-empty string`)
1620
+ }
1621
+ return value.trim()
1622
+ }
1623
+
1624
+ function normalizeRuntimeHeartbeat(value) {
1625
+ const number = Number(value)
1626
+ if (!Number.isFinite(number) || number <= 0) return DEFAULT_HEARTBEAT_MS
1627
+ return Math.min(Math.max(1000, Math.floor(number)), 300_000)
1628
+ }
1629
+
1630
+ /** Chunk heartbeat cadence (§11.6 default 10 s; overridable for diagnostics). */
1631
+ function normalizeChunkHeartbeat(value) {
1632
+ const number = Number(value)
1633
+ if (!Number.isFinite(number) || number <= 0) return CHUNK_HEARTBEAT_MS
1634
+ return Math.min(Math.max(100, Math.floor(number)), 300_000)
1635
+ }
1636
+
1637
+ function createRuntime(options) {
1638
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
1639
+ throw new TypeError('startRuntime(options) requires an options object')
1640
+ }
1641
+ let serverUrl
1642
+ try {
1643
+ serverUrl = normalizeServerUrl(requireOptionString(options.serverUrl, 'serverUrl'))
1644
+ } catch (error) {
1645
+ if (error instanceof TypeError) throw error
1646
+ throw new TypeError(`startRuntime: options.serverUrl is not a valid http(s) URL (${describeError(error)})`)
1647
+ }
1648
+ const config = {
1649
+ serverUrl,
1650
+ token: requireOptionString(options.token, 'token'),
1651
+ computerId: requireOptionString(options.computerId, 'computerId'),
1652
+ name: typeof options.name === 'string' ? options.name.trim() : '',
1653
+ workspaceBase: normalizeWorkspaceBase(options.workspaceBase),
1654
+ }
1655
+ return {
1656
+ config,
1657
+ log: typeof options.log === 'function' ? options.log : defaultRuntimeLog,
1658
+ onState: typeof options.onState === 'function' ? options.onState : null,
1659
+ jobs: new Map(),
1660
+ controller: new AbortController(),
1661
+ stopping: false,
1662
+ stoppedLogged: false,
1663
+ emittingState: false,
1664
+ lastStateKey: '',
1665
+ loopPromise: null,
1666
+ fs: { controllers: new Set(), promises: new Set() },
1667
+ pumps: new Set(),
1668
+ state: {
1669
+ running: true,
1670
+ connected: false,
1671
+ heartbeatMs: normalizeRuntimeHeartbeat(options.heartbeatMs),
1672
+ name: config.name,
1673
+ features: [],
1674
+ chunkHeartbeatMs: normalizeChunkHeartbeat(options.chunkHeartbeatMs),
1675
+ since: null,
1676
+ lastConnectedAt: null,
1677
+ lastError: null,
1678
+ attempts: 0,
1679
+ exitCode: null,
1680
+ lastPingLogAt: 0,
1681
+ lastHeartbeatErrorAt: 0,
1682
+ lastChunkDropAt: 0,
1683
+ },
1684
+ }
1685
+ }
1686
+
1687
+ /**
1688
+ * Start an embedded runtime (PROTOCOL §10.4).
1689
+ *
1690
+ * @param {{
1691
+ * serverUrl: string,
1692
+ * token: string,
1693
+ * computerId: string,
1694
+ * name?: string,
1695
+ * heartbeatMs?: number,
1696
+ * log?: (level: 'info'|'warn'|'error', message: string) => void,
1697
+ * onState?: (state: object) => void,
1698
+ * }} options
1699
+ * @returns {{ stop: () => Promise<void>, status: () => object }}
1700
+ *
1701
+ * `status()` is synchronous and returns
1702
+ * `{ running, connected, serverUrl, computerId, name, heartbeatMs, since,
1703
+ * lastConnectedAt, lastError, attempts, exitCode }` —
1704
+ * the host manager only needs `connected` / `since` / `lastError` from it.
1705
+ *
1706
+ * A fatal `{"error":"invalid-token"}` 401 does NOT throw: the loop ends, sets
1707
+ * `lastError: 'invalid-token'`, `exitCode: 1`, and reports through `onState()`.
1708
+ */
1709
+ export function startRuntime(options) {
1710
+ const runtime = createRuntime(options)
1711
+ runtime.loopPromise = runRuntimeLoop(runtime)
1712
+ .catch((error) => {
1713
+ runtime.state.lastError = describeError(error)
1714
+ runtimeLog(runtime, 'error', `运行时错误:${describeError(error)}`)
1715
+ return 1
1716
+ })
1717
+ .then((exitCode) => {
1718
+ runtime.state.exitCode = exitCode
1719
+ runtime.state.running = false
1720
+ runtime.state.connected = false
1721
+ runtime.state.since = null
1722
+ emitRuntimeState(runtime)
1723
+ return exitCode
1724
+ })
1725
+ return {
1726
+ stop: () => stopRuntime(runtime),
1727
+ status: () => runtimeStatus(runtime),
1728
+ }
1729
+ }
1730
+
1731
+ function runtimeStatus(runtime) {
1732
+ const state = runtime.state
1733
+ return {
1734
+ running: state.running,
1735
+ connected: state.connected,
1736
+ serverUrl: runtime.config.serverUrl,
1737
+ computerId: runtime.config.computerId,
1738
+ name: state.name || runtime.config.name,
1739
+ workspaceBase: runtime.config.workspaceBase,
1740
+ capabilities: [...AGENT_CAPABILITIES],
1741
+ features: [...state.features],
1742
+ heartbeatMs: state.heartbeatMs,
1743
+ since: state.since,
1744
+ lastConnectedAt: state.lastConnectedAt,
1745
+ lastError: state.lastError,
1746
+ attempts: state.attempts,
1747
+ exitCode: state.exitCode,
1748
+ }
1749
+ }
1750
+
1751
+ /** Report a state snapshot; identical snapshots are skipped (one per transition). */
1752
+ function emitRuntimeState(runtime) {
1753
+ if (!runtime.onState || runtime.emittingState) return
1754
+ const status = runtimeStatus(runtime)
1755
+ const key = [status.running, status.connected, status.since, status.lastError, status.name, status.exitCode, status.features.join(',')].join('|')
1756
+ if (key === runtime.lastStateKey) return
1757
+ runtime.lastStateKey = key
1758
+ runtime.emittingState = true
1759
+ try {
1760
+ runtime.onState(status)
1761
+ } catch {
1762
+ // A broken host callback must not break the loop.
1763
+ } finally {
1764
+ runtime.emittingState = false
1765
+ }
1766
+ }
1767
+
1768
+ /** SIGKILL + reap every command still running for this runtime instance. */
1769
+ async function cancelAndReapJobs(runtime) {
1770
+ const jobs = [...runtime.jobs.values()]
1771
+ for (const job of jobs) {
1772
+ try {
1773
+ job.cancel()
1774
+ } catch {
1775
+ // ignore
1776
+ }
1777
+ }
1778
+ if (jobs.length > 0) await Promise.allSettled(jobs.map((job) => job.promise))
1779
+ }
1780
+
1781
+ async function stopRuntime(runtime) {
1782
+ if (runtime.emittingState) {
1783
+ // A host may call stop() from inside onState(); awaiting the loop from
1784
+ // within that callback would deadlock, so defer by a tick.
1785
+ return new Promise((resolve) => {
1786
+ setTimeout(() => {
1787
+ void stopRuntime(runtime).then(resolve)
1788
+ }, 0)
1789
+ })
1790
+ }
1791
+ if (!runtime.stopping) {
1792
+ runtime.stopping = true
1793
+ runtimeLog(runtime, 'info', '正在停止运行时…')
1794
+ runtime.controller.abort()
1795
+ // Drop buffered stream frames without flushing and clear every timer.
1796
+ for (const pump of [...runtime.pumps]) {
1797
+ try {
1798
+ pump.abort()
1799
+ } catch {
1800
+ // ignore
1801
+ }
1802
+ }
1803
+ }
1804
+ await cancelAndReapJobs(runtime)
1805
+ for (const controller of [...runtime.fs.controllers]) {
1806
+ try {
1807
+ controller.abort()
1808
+ } catch {
1809
+ // ignore
1810
+ }
1811
+ }
1812
+ if (runtime.fs.promises.size > 0) await Promise.allSettled([...runtime.fs.promises])
1813
+ if (runtime.loopPromise) await runtime.loopPromise.catch(() => {})
1814
+ // Reap jobs that a last in-flight event may have started before the flag.
1815
+ await cancelAndReapJobs(runtime)
1816
+ runtime.state.running = false
1817
+ runtime.state.connected = false
1818
+ runtime.state.since = null
1819
+ if (runtime.state.exitCode === null) runtime.state.exitCode = 0
1820
+ emitRuntimeState(runtime)
1821
+ if (!runtime.stoppedLogged) {
1822
+ runtime.stoppedLogged = true
1823
+ runtimeLog(runtime, 'info', '运行时已停止')
1824
+ }
1825
+ }
1826
+
1827
+ function jitteredDelay(base) {
1828
+ const factor = 0.75 + Math.random() * 0.5
1829
+ return Math.max(MIN_BACKOFF_MS, Math.min(MAX_BACKOFF_MS, Math.round(base * factor)))
1830
+ }
1831
+
1832
+ /**
1833
+ * The reconnect loop. Never throws for a fatal token rejection: it returns the
1834
+ * exit code (1 for invalid-token), and the caller (`startRuntime`) reports it.
1835
+ */
1836
+ async function runRuntimeLoop(runtime) {
1837
+ const config = runtime.config
1838
+ runtimeLog(runtime, 'info', `dsh-computers-agent ${AGENT_VERSION} 启动 · ${config.serverUrl} · computer=${config.computerId}`)
1839
+
1840
+ let backoff = MIN_BACKOFF_MS
1841
+ let lastTransientWarnAt = 0
1842
+ while (!runtime.controller.signal.aborted) {
1843
+ const attemptStartedAt = Date.now()
1844
+ runtime.state.attempts += 1
1845
+ let outcome
1846
+ try {
1847
+ outcome = await runOneSession(runtime, runtime.controller.signal)
1848
+ } catch (error) {
1849
+ if (runtime.controller.signal.aborted) break
1850
+ outcome = { kind: 'closed', reason: describeError(error), gotHello: false }
1851
+ }
1852
+
1853
+ if (outcome.kind === 'unauthorized') {
1854
+ // The server explicitly answered `{"error":"invalid-token"}` for our
1855
+ // bearer: retrying cannot fix it. Do not throw (PROTOCOL §10.4 — the
1856
+ // embedded host decides what to do); return the exit code for the CLI.
1857
+ runtime.state.connected = false
1858
+ runtime.state.since = null
1859
+ runtime.state.lastError = 'invalid-token'
1860
+ emitRuntimeState(runtime)
1861
+ runtimeLog(runtime, 'error', '✗ 服务器拒绝了已保存的凭据(HTTP 401 invalid-token)。token 已失效,请重新运行 enroll 完成注册。')
1862
+ return 1
1863
+ }
1864
+ if (runtime.controller.signal.aborted) break
1865
+
1866
+ runtime.state.connected = false
1867
+ runtime.state.since = null
1868
+ runtime.state.lastError = outcome.reason || 'unknown'
1869
+ emitRuntimeState(runtime)
1870
+
1871
+ const healthy = outcome.gotHello && Date.now() - attemptStartedAt > 60_000
1872
+ if (healthy) backoff = MIN_BACKOFF_MS
1873
+ const delay = jitteredDelay(backoff)
1874
+ if (outcome.transientServer) {
1875
+ // Plugin reload / server restart / generic /api fence: the token is not
1876
+ // the problem. Warn at most once a minute (not once per attempt) and keep
1877
+ // retrying forever.
1878
+ const now = Date.now()
1879
+ if (now - lastTransientWarnAt >= TRANSIENT_SERVER_WARN_INTERVAL_MS) {
1880
+ lastTransientWarnAt = now
1881
+ runtimeLog(
1882
+ runtime,
1883
+ 'warn',
1884
+ `⚠ 服务器暂时不可用(${outcome.reason || '未知原因'});保存的 token 未被拒绝,将继续重试(约 ${Math.round(delay / 1000)} 秒后)`,
1885
+ )
1886
+ }
1887
+ } else {
1888
+ runtimeLog(runtime, 'warn', `连接断开(${outcome.reason || '未知原因'}),${Math.round(delay / 1000)} 秒后重连`)
1889
+ }
1890
+ await sleep(delay, runtime.controller.signal)
1891
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS)
1892
+ }
1893
+ runtimeLog(runtime, 'info', '已退出')
1894
+ return 0
1895
+ }
1896
+
1897
+ /**
1898
+ * One connection attempt. Returns:
1899
+ * { kind: 'unauthorized' } -> only for our JSON `invalid-token` 401
1900
+ * { kind: 'closed', reason, gotHello, transientServer? }
1901
+ * Throws only on transport errors (also retried).
1902
+ */
1903
+ async function runOneSession(runtime, signal) {
1904
+ const config = runtime.config
1905
+ const response = await fetch(eventsUrl(config.serverUrl), {
1906
+ headers: sseHeaders(config.token, handshakeMetadata(config.workspaceBase)),
1907
+ signal,
1908
+ })
1909
+ if (response.status === 401) {
1910
+ const body = await readErrorBody(response)
1911
+ if (isInvalidTokenRejection(response.status, body)) return { kind: 'unauthorized' }
1912
+ // A bare/foreign 401 is the plugin-reload / gateway-fence case: the token is
1913
+ // probably still valid, so keep retrying with the normal backoff.
1914
+ return {
1915
+ kind: 'closed',
1916
+ reason: `HTTP 401${body ? ` ${abbreviate(body, 80)}` : ''}(token 可能仍然有效)`,
1917
+ gotHello: false,
1918
+ transientServer: true,
1919
+ }
1920
+ }
1921
+ if (!response.ok) {
1922
+ const detail = await readErrorBody(response)
1923
+ return {
1924
+ kind: 'closed',
1925
+ reason: `HTTP ${response.status}${detail ? ` ${abbreviate(detail, 80)}` : ''}`,
1926
+ gotHello: false,
1927
+ transientServer: true,
1928
+ }
1929
+ }
1930
+ if (!response.body) return { kind: 'closed', reason: '响应没有 body', gotHello: false }
1931
+
1932
+ const state = runtime.state
1933
+ let gotHello = false
1934
+ let heartbeatTimer
1935
+
1936
+ const startHeartbeatTimer = () => {
1937
+ if (heartbeatTimer) return
1938
+ const intervalMs = Math.max(2000, Math.min(state.heartbeatMs || DEFAULT_HEARTBEAT_MS, 300_000))
1939
+ heartbeatTimer = setInterval(() => {
1940
+ void sendHeartbeat(runtime)
1941
+ }, intervalMs)
1942
+ heartbeatTimer.unref?.()
1943
+ }
1944
+
1945
+ try {
1946
+ for await (const event of sseEvents(response.body, signal)) {
1947
+ switch (event.event) {
1948
+ case 'hello': {
1949
+ const hello = parseJsonSafe(event.data) || {}
1950
+ gotHello = true
1951
+ if (typeof hello.name === 'string' && hello.name) state.name = hello.name
1952
+ if (typeof hello.heartbeatMs === 'number' && hello.heartbeatMs > 0) state.heartbeatMs = hello.heartbeatMs
1953
+ // PROTOCOL §11.5: an old host sends no `features`; behave exactly like v1.
1954
+ state.features = Array.isArray(hello.features)
1955
+ ? hello.features.filter((feature) => typeof feature === 'string')
1956
+ : []
1957
+ state.connected = true
1958
+ state.since = new Date().toISOString()
1959
+ state.lastConnectedAt = state.since
1960
+ state.lastError = null
1961
+ runtimeLog(
1962
+ runtime,
1963
+ 'info',
1964
+ `已连接 ${config.serverUrl} · computer=${config.computerId}` +
1965
+ `${state.name ? ` · name=${state.name}` : ''} · heartbeat=${state.heartbeatMs}ms` +
1966
+ ` · features=${state.features.length > 0 ? state.features.join(',') : 'none'}`,
1967
+ )
1968
+ emitRuntimeState(runtime)
1969
+ startHeartbeatTimer()
1970
+ break
1971
+ }
1972
+ case 'exec':
1973
+ handleExec(runtime, event.data)
1974
+ break
1975
+ case 'cancel':
1976
+ handleCancel(runtime, event.data)
1977
+ break
1978
+ case 'ping': {
1979
+ const now = Date.now()
1980
+ if (now - state.lastPingLogAt >= HEARTBEAT_LOG_INTERVAL_MS) {
1981
+ state.lastPingLogAt = now
1982
+ runtimeLog(runtime, 'info', `心跳正常 · computer=${config.computerId}`)
1983
+ }
1984
+ break
1985
+ }
1986
+ case 'fs':
1987
+ handleFs(runtime, event.data)
1988
+ break
1989
+ case 'config':
1990
+ applyConfigEvent(runtime, event.data)
1991
+ break
1992
+ default:
1993
+ // Unknown event types are ignored for forward compatibility.
1994
+ break
1995
+ }
1996
+ }
1997
+ return { kind: 'closed', reason: '服务器关闭了事件流', gotHello }
1998
+ } finally {
1999
+ if (heartbeatTimer) clearInterval(heartbeatTimer)
2000
+ }
2001
+ }
2002
+
2003
+ // ---------------------------------------------------------------------------
2004
+ // CLI `run` — a thin wrapper around startRuntime() for foreground / launchd use
2005
+ // ---------------------------------------------------------------------------
2006
+
2007
+ async function cmdRun(argv) {
2008
+ const { values } = parseCliArgs(argv, { help: { type: 'boolean', short: 'h' } })
2009
+ if (values.help) {
2010
+ console.log(RUN_USAGE)
2011
+ return 0
2012
+ }
2013
+ const config = await loadConfig()
2014
+ ensureAgentHome()
2015
+
2016
+ const sink = createCliLogSink()
2017
+ let savedName = config.name
2018
+ let resolveDone
2019
+ const done = new Promise((resolve) => {
2020
+ resolveDone = resolve
2021
+ })
2022
+
2023
+ const runtime = startRuntime({
2024
+ serverUrl: config.serverUrl,
2025
+ token: config.token,
2026
+ computerId: config.computerId,
2027
+ name: config.name,
2028
+ workspaceBase: config.workspaceBase,
2029
+ heartbeatMs: DEFAULT_HEARTBEAT_MS,
2030
+ log: sink,
2031
+ onState: (state) => {
2032
+ // Keep the CLI config file in sync with server-driven renames; the
2033
+ // embedded runtime never writes ~/.dsh-computers.
2034
+ if (state.name && state.name !== savedName) {
2035
+ savedName = state.name
2036
+ config.name = state.name
2037
+ void saveConfig(config).catch(() => {})
2038
+ }
2039
+ if (!state.running) resolveDone(state)
2040
+ },
2041
+ })
2042
+
2043
+ let stopping = false
2044
+ const shutdown = (name) => {
2045
+ if (stopping) return
2046
+ stopping = true
2047
+ sink('info', `收到 ${name},正在退出`)
2048
+ void runtime.stop()
2049
+ }
2050
+ const onSigint = () => shutdown('SIGINT')
2051
+ const onSigterm = () => shutdown('SIGTERM')
2052
+ process.on('SIGINT', onSigint)
2053
+ process.on('SIGTERM', onSigterm)
2054
+
2055
+ try {
2056
+ await done
2057
+ } finally {
2058
+ process.off('SIGINT', onSigint)
2059
+ process.off('SIGTERM', onSigterm)
2060
+ // Idempotent: the fatal path already finished the loop, a signal path is
2061
+ // already stopping; this guarantees children are reaped either way.
2062
+ await runtime.stop().catch(() => {})
2063
+ }
2064
+ return runtime.status().exitCode ?? 0
2065
+ }
2066
+
2067
+ // ---------------------------------------------------------------------------
2068
+ // enroll
2069
+ // ---------------------------------------------------------------------------
2070
+
2071
+ async function cmdEnroll(argv) {
2072
+ let values
2073
+ try {
2074
+ ;({ values } = parseArgs({
2075
+ args: argv,
2076
+ options: {
2077
+ server: { type: 'string' },
2078
+ key: { type: 'string' },
2079
+ name: { type: 'string' },
2080
+ 'workspace-base': { type: 'string' },
2081
+ replace: { type: 'boolean' },
2082
+ help: { type: 'boolean', short: 'h' },
2083
+ },
2084
+ strict: true,
2085
+ allowPositionals: false,
2086
+ }))
2087
+ } catch (error) {
2088
+ throw new CliError(`参数错误:${describeError(error)}\n\n${ENROLL_USAGE}`)
2089
+ }
2090
+ if (values.help) {
2091
+ console.log(ENROLL_USAGE)
2092
+ return 0
2093
+ }
2094
+
2095
+ const serverUrl = normalizeServerUrl(values.server)
2096
+ const key = typeof values.key === 'string' ? values.key.trim() : ''
2097
+ if (!key) throw new CliError(`缺少 --key <key>(服务器生成的加入密钥)\n\n${ENROLL_USAGE}`)
2098
+ const requestedName = typeof values.name === 'string' ? values.name.trim() : ''
2099
+
2100
+ if (fs.existsSync(configFile())) {
2101
+ console.error(`提示:${configFile()} 已存在,本次注册会覆盖旧的凭据。`)
2102
+ }
2103
+
2104
+ const workspaceBase = normalizeWorkspaceBase(values['workspace-base'])
2105
+ const body = {
2106
+ key,
2107
+ agentVersion: AGENT_VERSION,
2108
+ // PROTOCOL §11.1 (top-level capabilities) + §11.6 (handshake metadata).
2109
+ capabilities: [...AGENT_CAPABILITIES],
2110
+ machine: machineInfo(requestedName),
2111
+ metadata: handshakeMetadata(workspaceBase),
2112
+ }
2113
+ if (values.replace) body.replace = true
2114
+
2115
+ let response
2116
+ try {
2117
+ response = await requestJson(enrollUrl(serverUrl), { method: 'POST', body, timeoutMs: 30_000 })
2118
+ } catch (error) {
2119
+ throw new CliError(`无法连接 ${serverUrl}:${describeError(error)}`)
2120
+ }
2121
+
2122
+ if (response.status !== 200) {
2123
+ const code = (response.json && response.json.error) || `http-${response.status}`
2124
+ const message = (response.json && response.json.message) || (response.text || '').trim()
2125
+ throw new CliError(`注册失败(${code})${message ? `:${message}` : ''}`)
2126
+ }
2127
+
2128
+ const data = response.json
2129
+ if (
2130
+ !data ||
2131
+ typeof data.computerId !== 'string' ||
2132
+ typeof data.token !== 'string' ||
2133
+ typeof data.name !== 'string'
2134
+ ) {
2135
+ throw new CliError('服务器返回的注册响应缺少 computerId / token / name,请升级服务器端的 dsh-computers 插件。')
2136
+ }
2137
+
2138
+ const config = {
2139
+ version: 1,
2140
+ serverUrl: normalizeServerUrl(typeof data.serverUrl === 'string' && data.serverUrl ? data.serverUrl : serverUrl),
2141
+ computerId: data.computerId,
2142
+ token: data.token,
2143
+ name: data.name,
2144
+ enrolledAt: new Date().toISOString(),
2145
+ workspaceBase,
2146
+ }
2147
+ await saveConfig(config)
2148
+ console.log(`✓ 凭据已保存到 ${configFile()}(仅本机可读,权限 0600)`)
2149
+ console.log(`工作目录:${workspaceBase}(exec 默认 cwd,glob/grep 的相对基准)`)
2150
+
2151
+ // Prove the round trip once. Failure to receive hello is not fatal: the
2152
+ // enrollment itself succeeded and `run` retries forever.
2153
+ const probe = await probeEventStream(config, 5000)
2154
+ if (probe.unauthorized) {
2155
+ throw new CliError('服务器拒绝了这个 token(HTTP 401),请重新生成加入密钥。')
2156
+ }
2157
+ if (probe.ok) {
2158
+ const name = (probe.hello && probe.hello.name) || config.name
2159
+ console.log(`✓ 已连接 ${config.serverUrl},这台电脑现在是 ${name}`)
2160
+ } else {
2161
+ console.log(`… 凭据已保存,但实时连接探测未成功(${probe.message});run 会自动重试。`)
2162
+ }
2163
+
2164
+ console.log(`下一步:node "${SELF_PATH}" service install && node "${SELF_PATH}" service start`)
2165
+ console.log(` 或者前台运行:node "${SELF_PATH}" run`)
2166
+ return 0
2167
+ }
2168
+
2169
+ // ---------------------------------------------------------------------------
2170
+ // service (launchd on macOS, systemd --user on Linux, manual note on Windows)
2171
+ // ---------------------------------------------------------------------------
2172
+
2173
+ /** Promise wrapper around execFile that never rejects; returns { code, stdout, stderr }. */
2174
+ function runCommandFile(file, args, { timeout = 20_000 } = {}) {
2175
+ return new Promise((resolve) => {
2176
+ execFile(file, args, { timeout, encoding: 'utf8', windowsHide: true }, (error, stdout, stderr) => {
2177
+ if (error) {
2178
+ const code = typeof error.code === 'number' ? error.code : 1
2179
+ const message = String(stderr || error.message || '').trim()
2180
+ resolve({ code, stdout: String(stdout || ''), stderr: message })
2181
+ return
2182
+ }
2183
+ resolve({ code: 0, stdout: String(stdout || ''), stderr: String(stderr || '').trim() })
2184
+ })
2185
+ })
2186
+ }
2187
+
2188
+ function xmlEscape(value) {
2189
+ const text = String(value)
2190
+ if (text.includes('\0')) throw new CliError('服务配置项包含非法空字符')
2191
+ return text
2192
+ .replaceAll('&', '&amp;')
2193
+ .replaceAll('<', '&lt;')
2194
+ .replaceAll('>', '&gt;')
2195
+ .replaceAll('"', '&quot;')
2196
+ .replaceAll("'", '&apos;')
2197
+ }
2198
+
2199
+ function launchdContext() {
2200
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 0
2201
+ return {
2202
+ label: SERVICE_LABEL,
2203
+ domain: `gui/${uid}`,
2204
+ target: `gui/${uid}/${SERVICE_LABEL}`,
2205
+ plistPath: launchdPlistPath(),
2206
+ }
2207
+ }
2208
+
2209
+ function renderLaunchdPlist(context) {
2210
+ const environment = {
2211
+ PATH: process.env.PATH || '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin',
2212
+ // Tell `run` that launchd already redirects stdout/stderr into agent.log,
2213
+ // so it should only append its own structured lines (no duplicates).
2214
+ DSH_COMPUTERS_LOG_ONLY: '1',
2215
+ }
2216
+ if (process.env.DSH_COMPUTERS_HOME) environment.DSH_COMPUTERS_HOME = path.resolve(process.env.DSH_COMPUTERS_HOME)
2217
+ if (process.env.DSH_COMPUTERS_SERVICE_LABEL) environment.DSH_COMPUTERS_SERVICE_LABEL = SERVICE_LABEL
2218
+ const environmentXml = Object.entries(environment)
2219
+ .map(([name, value]) => ` <key>${xmlEscape(name)}</key>\n <string>${xmlEscape(value)}</string>`)
2220
+ .join('\n')
2221
+ return `<?xml version="1.0" encoding="UTF-8"?>
2222
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2223
+ <plist version="1.0">
2224
+ <dict>
2225
+ <key>Label</key>
2226
+ <string>${xmlEscape(context.label)}</string>
2227
+ <key>ProgramArguments</key>
2228
+ <array>
2229
+ <string>${xmlEscape(process.execPath)}</string>
2230
+ <string>${xmlEscape(SELF_PATH)}</string>
2231
+ <string>run</string>
2232
+ </array>
2233
+ <key>EnvironmentVariables</key>
2234
+ <dict>
2235
+ ${environmentXml}
2236
+ </dict>
2237
+ <key>WorkingDirectory</key>
2238
+ <string>${xmlEscape(agentHome())}</string>
2239
+ <key>RunAtLoad</key>
2240
+ <true/>
2241
+ <key>KeepAlive</key>
2242
+ <true/>
2243
+ <key>ThrottleInterval</key>
2244
+ <integer>10</integer>
2245
+ <key>StandardOutPath</key>
2246
+ <string>${xmlEscape(logFile())}</string>
2247
+ <key>StandardErrorPath</key>
2248
+ <string>${xmlEscape(logFile())}</string>
2249
+ </dict>
2250
+ </plist>
2251
+ `
2252
+ }
2253
+
2254
+ async function launchdIsLoaded(context) {
2255
+ const result = await runCommandFile('/bin/launchctl', ['print', context.target])
2256
+ return result.code === 0
2257
+ }
2258
+
2259
+ async function launchdStatus(context) {
2260
+ const installed = fs.existsSync(context.plistPath)
2261
+ if (!installed) return { installed: false, loaded: false, pid: null }
2262
+ const loaded = await launchdIsLoaded(context)
2263
+ let pid = null
2264
+ if (loaded) {
2265
+ const result = await runCommandFile('/bin/launchctl', ['print', context.target])
2266
+ const match = result.stdout.match(/\bpid\s*=\s*(\d+)/)
2267
+ if (match) pid = Number(match[1])
2268
+ }
2269
+ return { installed, loaded, pid }
2270
+ }
2271
+
2272
+ async function launchdWaitLoaded(context, expected) {
2273
+ for (let attempt = 0; attempt < 25; attempt++) {
2274
+ if ((await launchdIsLoaded(context)) === expected) return true
2275
+ await sleep(200)
2276
+ }
2277
+ return (await launchdIsLoaded(context)) === expected
2278
+ }
2279
+
2280
+ async function launchdInstall(context) {
2281
+ ensureAgentHome()
2282
+ await ensureDir(path.dirname(context.plistPath))
2283
+ await atomicWriteFile(context.plistPath, renderLaunchdPlist(context), 0o600)
2284
+ const lint = await runCommandFile('/usr/bin/plutil', ['-lint', context.plistPath])
2285
+ console.log(`✓ launchd 配置已写入 ${context.plistPath}`)
2286
+ if (lint.code !== 0) {
2287
+ console.error(`警告:plutil 校验失败:${lint.stderr || lint.stdout}`)
2288
+ }
2289
+ if (!fs.existsSync(configFile())) {
2290
+ console.error('警告:还没有完成 enroll,服务启动后会反复重试直到注册成功。')
2291
+ }
2292
+ console.log(`下一步:node "${SELF_PATH}" service start`)
2293
+ return 0
2294
+ }
2295
+
2296
+ async function launchdStart(context) {
2297
+ if (!fs.existsSync(context.plistPath)) {
2298
+ console.error(`✗ 服务尚未安装(找不到 ${context.plistPath}),请先运行 service install。`)
2299
+ return 1
2300
+ }
2301
+ if (await launchdIsLoaded(context)) {
2302
+ console.log(`✓ 服务已在运行(${context.label})`)
2303
+ return 0
2304
+ }
2305
+ const bootstrap = await runCommandFile('/bin/launchctl', ['bootstrap', context.domain, context.plistPath])
2306
+ if (bootstrap.code !== 0) {
2307
+ // Legacy loader for older macOS releases / restricted sessions.
2308
+ const legacy = await runCommandFile('/bin/launchctl', ['load', '-w', context.plistPath])
2309
+ if (legacy.code !== 0 && !(await launchdIsLoaded(context))) {
2310
+ console.error(`✗ launchctl bootstrap/load 失败:${bootstrap.stderr || legacy.stderr || '未知错误'}`)
2311
+ return 1
2312
+ }
2313
+ }
2314
+ if (await launchdWaitLoaded(context, true)) {
2315
+ console.log(`✓ 服务已启动(${context.label})`)
2316
+ return 0
2317
+ }
2318
+ console.error('✗ 服务已提交但未出现在 launchctl 中,请查看日志:' + logFile())
2319
+ return 1
2320
+ }
2321
+
2322
+ async function launchdStop(context) {
2323
+ if (!(await launchdIsLoaded(context))) {
2324
+ console.log(`✓ 服务未在运行(${context.label})`)
2325
+ return 0
2326
+ }
2327
+ // bootout is asynchronous: keep polling until launchctl agrees it is gone.
2328
+ const bootout = await runCommandFile('/bin/launchctl', ['bootout', context.target])
2329
+ if (bootout.code !== 0 && fs.existsSync(context.plistPath)) {
2330
+ await runCommandFile('/bin/launchctl', ['unload', '-w', context.plistPath])
2331
+ }
2332
+ if (await launchdWaitLoaded(context, false)) {
2333
+ console.log(`✓ 服务已停止(${context.label})`)
2334
+ return 0
2335
+ }
2336
+ console.error('✗ 服务仍在运行,请检查 launchctl print ' + context.target)
2337
+ return 1
2338
+ }
2339
+
2340
+ async function launchdUninstall(context) {
2341
+ await launchdStop(context) // tolerant: reports "未在运行" when nothing is loaded
2342
+ try {
2343
+ await fsp.rm(context.plistPath, { force: true })
2344
+ } catch (error) {
2345
+ console.error(`✗ 无法删除 ${context.plistPath}:${describeError(error)}`)
2346
+ return 1
2347
+ }
2348
+ console.log('✓ launchd 服务已卸载(凭据与配置仍保留,可重新 enroll)')
2349
+ return 0
2350
+ }
2351
+
2352
+ async function launchdCommand(action) {
2353
+ const context = launchdContext()
2354
+ switch (action) {
2355
+ case 'install':
2356
+ return launchdInstall(context)
2357
+ case 'uninstall':
2358
+ return launchdUninstall(context)
2359
+ case 'start':
2360
+ return launchdStart(context)
2361
+ case 'stop':
2362
+ return launchdStop(context)
2363
+ case 'restart':
2364
+ await launchdStop(context)
2365
+ return launchdStart(context)
2366
+ case 'status': {
2367
+ const status = await launchdStatus(context)
2368
+ const label = !status.installed ? '未安装' : status.loaded ? `运行中${status.pid ? ` (pid ${status.pid})` : ''}` : '已安装,未运行'
2369
+ console.log(`launchd: ${label}`)
2370
+ console.log(`plist: ${context.plistPath}`)
2371
+ return 0
2372
+ }
2373
+ default:
2374
+ console.error(`未知的 service 子命令:${action}`)
2375
+ return 1
2376
+ }
2377
+ }
2378
+
2379
+ // --- Linux: systemd --user, with a nohup fallback -------------------------
2380
+
2381
+ function systemdContext() {
2382
+ return {
2383
+ unitName: `${SERVICE_LABEL}.service`,
2384
+ unitPath: systemdUnitPath(),
2385
+ }
2386
+ }
2387
+
2388
+ function systemdQuote(value) {
2389
+ const text = String(value)
2390
+ return /[\s"\\]/.test(text) ? `"${text.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : text
2391
+ }
2392
+
2393
+ function renderSystemdUnit(context) {
2394
+ const environmentPath = process.env.PATH || '/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin'
2395
+ const environmentHome = process.env.DSH_COMPUTERS_HOME
2396
+ ? `Environment=${systemdQuote(`DSH_COMPUTERS_HOME=${path.resolve(process.env.DSH_COMPUTERS_HOME)}`)}\n`
2397
+ : ''
2398
+ const environmentLabel = process.env.DSH_COMPUTERS_SERVICE_LABEL
2399
+ ? `Environment=${systemdQuote(`DSH_COMPUTERS_SERVICE_LABEL=${SERVICE_LABEL}`)}\n`
2400
+ : ''
2401
+ return `[Unit]
2402
+ Description=dsh-computers agent (run shell commands for dsh-web)
2403
+ After=network-online.target
2404
+
2405
+ [Service]
2406
+ Type=simple
2407
+ ExecStart=${systemdQuote(process.execPath)} ${systemdQuote(SELF_PATH)} run
2408
+ WorkingDirectory=${systemdQuote(agentHome())}
2409
+ Environment=${systemdQuote(`PATH=${environmentPath}`)}
2410
+ ${environmentHome}${environmentLabel}Restart=always
2411
+ RestartSec=5
2412
+ StandardOutput=append:${systemdQuote(logFile())}
2413
+ StandardError=append:${systemdQuote(logFile())}
2414
+
2415
+ [Install]
2416
+ WantedBy=default.target
2417
+ `
2418
+ }
2419
+
2420
+ async function systemctlAvailable() {
2421
+ const result = await runCommandFile('systemctl', ['--user', '--version'], { timeout: 5000 })
2422
+ return result.code === 0
2423
+ }
2424
+
2425
+ async function systemdInstall(context) {
2426
+ ensureAgentHome()
2427
+ await ensureDir(path.dirname(context.unitPath))
2428
+ await atomicWriteFile(context.unitPath, renderSystemdUnit(context), 0o644)
2429
+ console.log(`✓ systemd 用户单元已写入 ${context.unitPath}`)
2430
+ if (await systemctlAvailable()) {
2431
+ const reload = await runCommandFile('systemctl', ['--user', 'daemon-reload'])
2432
+ if (reload.code !== 0) console.error(`警告:daemon-reload 失败:${reload.stderr || reload.stdout}`)
2433
+ } else {
2434
+ console.error('警告:找不到可用的 systemctl --user,service start 会退化为 nohup 后台启动。')
2435
+ }
2436
+ if (!fs.existsSync(configFile())) {
2437
+ console.error('警告:还没有完成 enroll,服务启动后会反复重试直到注册成功。')
2438
+ }
2439
+ console.log(`下一步:node "${SELF_PATH}" service start`)
2440
+ return 0
2441
+ }
2442
+
2443
+ async function systemdStart(context) {
2444
+ if (!fs.existsSync(context.unitPath)) {
2445
+ console.error(`✗ 服务尚未安装(找不到 ${context.unitPath}),请先运行 service install。`)
2446
+ return 1
2447
+ }
2448
+ if (await systemctlAvailable()) {
2449
+ const start = await runCommandFile('systemctl', ['--user', 'enable', '--now', context.unitName])
2450
+ if (start.code !== 0) {
2451
+ console.error(`✗ systemctl --user enable --now 失败:${start.stderr || start.stdout}`)
2452
+ return 1
2453
+ }
2454
+ console.log(`✓ 服务已启动(${context.unitName})`)
2455
+ return 0
2456
+ }
2457
+ return nohupStart()
2458
+ }
2459
+
2460
+ async function systemdStop(context) {
2461
+ if (await systemctlAvailable()) {
2462
+ // disable --now exits non-zero when the unit is not loaded; that is fine.
2463
+ await runCommandFile('systemctl', ['--user', 'disable', '--now', context.unitName])
2464
+ console.log(`✓ 服务已停止(${context.unitName})`)
2465
+ return 0
2466
+ }
2467
+ return nohupStop()
2468
+ }
2469
+
2470
+ async function systemdUninstall(context) {
2471
+ if (await systemctlAvailable()) {
2472
+ await runCommandFile('systemctl', ['--user', 'disable', '--now', context.unitName])
2473
+ }
2474
+ // Also clean up a previous nohup fallback, without duplicating output.
2475
+ await nohupStop({ quiet: true }).catch(() => {})
2476
+ try {
2477
+ await fsp.rm(context.unitPath, { force: true })
2478
+ } catch (error) {
2479
+ console.error(`✗ 无法删除 ${context.unitPath}:${describeError(error)}`)
2480
+ return 1
2481
+ }
2482
+ if (await systemctlAvailable()) await runCommandFile('systemctl', ['--user', 'daemon-reload'])
2483
+ console.log('✓ systemd 服务已卸载(凭据与配置仍保留,可重新 enroll)')
2484
+ return 0
2485
+ }
2486
+
2487
+ async function systemdStatus(context) {
2488
+ const installed = fs.existsSync(context.unitPath)
2489
+ let state = '未安装'
2490
+ if (installed) {
2491
+ if (await systemctlAvailable()) {
2492
+ const active = await runCommandFile('systemctl', ['--user', 'is-active', context.unitName])
2493
+ state = active.code === 0 ? '运行中' : '已安装,未运行'
2494
+ } else {
2495
+ state = '已安装(无 systemctl --user,使用 nohup 回退)'
2496
+ }
2497
+ }
2498
+ console.log(`systemd: ${state}`)
2499
+ console.log(`unit: ${context.unitPath}`)
2500
+ return 0
2501
+ }
2502
+
2503
+ // --- nohup fallback (Linux without systemd --user) ------------------------
2504
+
2505
+ function readPidFile() {
2506
+ try {
2507
+ const pid = Number(fs.readFileSync(nohupPidFile(), 'utf8').trim())
2508
+ return Number.isInteger(pid) && pid > 0 ? pid : null
2509
+ } catch {
2510
+ return null
2511
+ }
2512
+ }
2513
+
2514
+ function processAlive(pid) {
2515
+ try {
2516
+ process.kill(pid, 0)
2517
+ return true
2518
+ } catch {
2519
+ return false
2520
+ }
2521
+ }
2522
+
2523
+ async function nohupStart() {
2524
+ const existing = readPidFile()
2525
+ if (existing && processAlive(existing)) {
2526
+ console.log(`✓ 已在后台运行(pid ${existing})`)
2527
+ return 0
2528
+ }
2529
+ ensureAgentHome()
2530
+ const out = fs.openSync(logFile(), 'a', 0o600)
2531
+ try {
2532
+ const child = spawn(process.execPath, [SELF_PATH, 'run'], {
2533
+ detached: true,
2534
+ stdio: ['ignore', out, out],
2535
+ cwd: agentHome(),
2536
+ env: process.env,
2537
+ })
2538
+ child.unref()
2539
+ fs.writeFileSync(nohupPidFile(), `${child.pid}\n`, { mode: 0o600 })
2540
+ console.log(`✓ 已在后台启动(nohup 回退,pid ${child.pid})`)
2541
+ } finally {
2542
+ try {
2543
+ fs.closeSync(out)
2544
+ } catch {
2545
+ // ignore
2546
+ }
2547
+ }
2548
+ return 0
2549
+ }
2550
+
2551
+ async function nohupStop({ quiet = false } = {}) {
2552
+ const pid = readPidFile()
2553
+ if (!pid || !processAlive(pid)) {
2554
+ try {
2555
+ await fsp.rm(nohupPidFile(), { force: true })
2556
+ } catch {
2557
+ // ignore
2558
+ }
2559
+ if (!quiet) console.log('✓ 服务未在运行')
2560
+ return 0
2561
+ }
2562
+ try {
2563
+ process.kill(pid, 'SIGTERM')
2564
+ } catch {
2565
+ // already gone
2566
+ }
2567
+ for (let attempt = 0; attempt < 25 && processAlive(pid); attempt++) await sleep(200)
2568
+ if (processAlive(pid)) {
2569
+ try {
2570
+ process.kill(pid, 'SIGKILL')
2571
+ } catch {
2572
+ // ignore
2573
+ }
2574
+ }
2575
+ try {
2576
+ await fsp.rm(nohupPidFile(), { force: true })
2577
+ } catch {
2578
+ // ignore
2579
+ }
2580
+ if (!quiet) console.log('✓ 服务已停止')
2581
+ return 0
2582
+ }
2583
+
2584
+ async function systemdCommand(action) {
2585
+ const context = systemdContext()
2586
+ switch (action) {
2587
+ case 'install':
2588
+ return systemdInstall(context)
2589
+ case 'uninstall':
2590
+ return systemdUninstall(context)
2591
+ case 'start':
2592
+ return systemdStart(context)
2593
+ case 'stop':
2594
+ return systemdStop(context)
2595
+ case 'restart':
2596
+ await systemdStop(context)
2597
+ return systemdStart(context)
2598
+ case 'status':
2599
+ return systemdStatus(context)
2600
+ default:
2601
+ console.error(`未知的 service 子命令:${action}`)
2602
+ return 1
2603
+ }
2604
+ }
2605
+
2606
+ function windowsServiceNote() {
2607
+ console.log('Windows 自动安装服务尚未实现(v1),请手动运行:')
2608
+ console.log(` node "${SELF_PATH}" run`)
2609
+ console.log('开机自启:任务计划程序 → 创建任务 → 触发器“登录时” → 操作“启动程序”')
2610
+ console.log(` 程序:"${process.execPath}"`)
2611
+ console.log(` 参数:"${SELF_PATH}" run`)
2612
+ console.log(`配置与日志位于 ${agentHome()}`)
2613
+ return 0
2614
+ }
2615
+
2616
+ async function cmdService(argv) {
2617
+ const action = String(argv[0] ?? '').toLowerCase()
2618
+ if (!SERVICE_ACTIONS.includes(action)) {
2619
+ console.error(`用法:dsh-computers-agent service <${SERVICE_ACTIONS.join('|')}>`)
2620
+ return 1
2621
+ }
2622
+ if (process.platform === 'darwin') return launchdCommand(action)
2623
+ if (process.platform === 'linux') return systemdCommand(action)
2624
+ return windowsServiceNote()
2625
+ }
2626
+
2627
+ // ---------------------------------------------------------------------------
2628
+ // status
2629
+ // ---------------------------------------------------------------------------
2630
+
2631
+ async function serviceSummary() {
2632
+ try {
2633
+ if (process.platform === 'darwin') {
2634
+ const context = launchdContext()
2635
+ const status = await launchdStatus(context)
2636
+ if (!status.installed) return '未安装'
2637
+ if (!status.loaded) return '已安装,未运行'
2638
+ return `运行中${status.pid ? ` (pid ${status.pid})` : ''}`
2639
+ }
2640
+ if (process.platform === 'linux') {
2641
+ const context = systemdContext()
2642
+ if (!fs.existsSync(context.unitPath)) return '未安装'
2643
+ if (await systemctlAvailable()) {
2644
+ const active = await runCommandFile('systemctl', ['--user', 'is-active', context.unitName])
2645
+ return active.code === 0 ? '运行中' : '已安装,未运行'
2646
+ }
2647
+ return '已安装(无 systemctl --user)'
2648
+ }
2649
+ return 'Windows 请手动运行(v1)'
2650
+ } catch (error) {
2651
+ return `未知(${describeError(error)})`
2652
+ }
2653
+ }
2654
+
2655
+ async function cmdStatus(argv) {
2656
+ const { values } = parseCliArgs(argv, { help: { type: 'boolean', short: 'h' } })
2657
+ if (values.help) {
2658
+ console.log(STATUS_USAGE)
2659
+ return 0
2660
+ }
2661
+ console.log(`dsh-computers-agent ${AGENT_VERSION}`)
2662
+ let config
2663
+ try {
2664
+ config = await loadConfig()
2665
+ } catch (error) {
2666
+ console.log('注册状态:未注册')
2667
+ console.log(`配置:${configFile()}(不存在或不可用)`)
2668
+ console.log(`提示:${describeError(error)}`)
2669
+ return 1
2670
+ }
2671
+ console.log('注册状态:已注册')
2672
+ console.log(`服务器:${config.serverUrl}`)
2673
+ console.log(`电脑:${config.name}(${config.computerId})`)
2674
+ console.log(`注册时间:${config.enrolledAt || '未知'}`)
2675
+ console.log(`Token:${redactToken(config.token)}(已脱敏)`)
2676
+ console.log(`工作目录:${config.workspaceBase}`)
2677
+ console.log(`能力:${AGENT_CAPABILITIES.join(', ')}`)
2678
+ console.log(`配置:${configFile()}`)
2679
+ console.log(`日志:${logFile()}`)
2680
+ console.log(`服务:${await serviceSummary()}`)
2681
+ return 0
2682
+ }
2683
+
2684
+ // ---------------------------------------------------------------------------
2685
+ // CLI plumbing
2686
+ // ---------------------------------------------------------------------------
2687
+
2688
+ const ENROLL_USAGE = `用法:dsh-computers-agent enroll --server <url> --key <key> [--name <name>] [--workspace-base <path>] [--replace]
2689
+
2690
+ --server <url> dsh-web 的地址,例如 http://127.0.0.1:3000
2691
+ --key <key> 服务器生成的加入密钥(dshc_…),只能使用一次
2692
+ --name <name> 显示在服务器电脑列表里的名称,默认使用主机名
2693
+ --workspace-base <path> 默认工作目录(exec 默认 cwd,glob/grep 的相对基准),默认 $HOME
2694
+ --replace 让服务器用这台电脑的新凭据替换同名记录`
2695
+
2696
+ const RUN_USAGE = `用法:dsh-computers-agent run
2697
+
2698
+ 连接服务器并等待执行请求(前台运行,Ctrl-C 退出)。
2699
+ 需要先完成 enroll;凭据保存在 ${configFile()}。`
2700
+
2701
+ const STATUS_USAGE = `用法:dsh-computers-agent status
2702
+
2703
+ 显示注册信息、脱敏后的 token 和服务状态。退出码 0 表示已注册,1 表示未注册。`
2704
+
2705
+ const HELP_TEXT = `dsh-computers-agent ${AGENT_VERSION}
2706
+ 把本机接入 dsh-web,让服务器上的 agent 通过 computer_exec 在这台电脑执行命令。
2707
+
2708
+ 用法 / Usage:
2709
+ dsh-computers-agent enroll --server <url> --key <key> [--name <name>] [--workspace-base <path>] [--replace]
2710
+ 用服务器给出的加入密钥注册这台电脑,并把凭据保存到本机。
2711
+ dsh-computers-agent run
2712
+ 前台运行:保持 SSE 连接并执行服务器发来的命令(Ctrl-C 退出)。
2713
+ dsh-computers-agent service install|uninstall|start|stop|restart|status
2714
+ 安装 / 卸载 / 启动 / 停止登录自启服务(macOS launchd、Linux systemd --user)。
2715
+ dsh-computers-agent status
2716
+ 显示注册信息、脱敏后的 token 与服务状态。
2717
+ dsh-computers-agent --version
2718
+ dsh-computers-agent --help
2719
+
2720
+ 环境变量:
2721
+ DSH_COMPUTERS_HOME 配置与日志目录,默认 ~/.dsh-computers
2722
+ DSH_COMPUTERS_SERVICE_LABEL 覆盖 launchd/systemd 服务标识(默认 ${SERVICE_LABEL})
2723
+
2724
+ 凭据与安全:
2725
+ · 配置文件 ${configFile()} 权限 0600,原子写入。
2726
+ · token 只保存在本机,不会写入日志;status 只显示末 6 位。
2727
+ · 本程序只发起对外连接(enroll / SSE / 结果上报 / 心跳),不监听任何端口。
2728
+ · 命令以当前用户身份在 /bin/sh -lc 中执行,因此拥有与终端相同的权限。
2729
+ · 能力:exec(远程命令)+ fs(远程文件读写/glob/grep),默认工作目录可在 enroll 时指定。`
2730
+
2731
+ function parseCliArgs(args, options) {
2732
+ try {
2733
+ return parseArgs({ args, options, strict: true, allowPositionals: false })
2734
+ } catch (error) {
2735
+ throw new CliError(`参数错误:${describeError(error)}\n\n${HELP_TEXT}`)
2736
+ }
2737
+ }
2738
+
2739
+ async function main(argv) {
2740
+ if (process.versions?.node && Number(process.versions.node.split('.')[0]) < 22) {
2741
+ console.error(`✗ 需要 Node.js >= 22,当前是 ${process.version}。请升级 Node.js 后重试。`)
2742
+ return 1
2743
+ }
2744
+
2745
+ const [command, ...rest] = argv
2746
+ switch (command) {
2747
+ case undefined:
2748
+ case 'help':
2749
+ case '--help':
2750
+ case '-h':
2751
+ console.log(HELP_TEXT)
2752
+ return 0
2753
+ case 'version':
2754
+ case '--version':
2755
+ case '-v':
2756
+ console.log(AGENT_VERSION)
2757
+ return 0
2758
+ case 'enroll':
2759
+ return cmdEnroll(rest)
2760
+ case 'run':
2761
+ return cmdRun(rest)
2762
+ case 'service':
2763
+ return cmdService(rest)
2764
+ case 'status':
2765
+ return cmdStatus(rest)
2766
+ default:
2767
+ throw new CliError(`未知命令:${command}\n\n${HELP_TEXT}`)
2768
+ }
2769
+ }
2770
+
2771
+ /**
2772
+ * True only when this file is the process entry point (`node agent.mjs …`),
2773
+ * false when it is imported (e.g. `import { startRuntime } from …` by the host
2774
+ * plugin) or evaluated with `node -e`. Importing the module must never run CLI
2775
+ * code or touch the filesystem.
2776
+ *
2777
+ * `process.argv[1]` is resolved through pathToFileURL so relative paths work;
2778
+ * a realpath comparison keeps symlinked installs working, and comparing the
2779
+ * URL keeps `--preserve-symlinks-main` working.
2780
+ */
2781
+ function isProcessEntryPoint() {
2782
+ const entry = process.argv[1]
2783
+ if (!entry) return false // `node -e`, REPL, `import()`
2784
+ try {
2785
+ if (pathToFileURL(path.resolve(entry)).href === import.meta.url) return true
2786
+ return fs.realpathSync(path.resolve(entry)) === fs.realpathSync(fileURLToPath(import.meta.url))
2787
+ } catch {
2788
+ return false
2789
+ }
2790
+ }
2791
+
2792
+ if (isProcessEntryPoint()) {
2793
+ main(process.argv.slice(2))
2794
+ .then((code) => {
2795
+ process.exitCode = code
2796
+ // If an idle HttpClient keeps the event loop alive, do not hang forever.
2797
+ const forced = setTimeout(() => process.exit(code), 5000)
2798
+ forced.unref?.()
2799
+ })
2800
+ .catch((error) => {
2801
+ const message = error instanceof CliError ? error.message : `✗ ${describeError(error)}`
2802
+ process.stderr.write(`${message}\n`)
2803
+ if (!(error instanceof CliError)) appendLog(`fatal: ${describeError(error)}${error?.stack ? `\n${error.stack}` : ''}`)
2804
+ process.exitCode = error instanceof CliError ? error.exitCode : 1
2805
+ const forced = setTimeout(() => process.exit(process.exitCode ?? 1), 5000)
2806
+ forced.unref?.()
2807
+ })
2808
+ }