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.
- package/LICENSE +21 -0
- package/PROTOCOL.md +694 -0
- package/README.md +176 -2
- package/agent/README.md +383 -0
- package/agent/dsh-computers-agent.mjs +2808 -0
- package/client.js +3986 -0
- package/cordis.patch.yml +25 -0
- package/docs/deploy.md +103 -0
- package/icon.svg +8 -0
- package/index.js +241 -0
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/http.js +761 -0
- package/lib/hub.js +587 -0
- package/lib/keys.js +134 -0
- package/lib/routes.js +1084 -0
- package/lib/runtime.js +469 -0
- package/lib/selection.js +205 -0
- package/lib/state.js +190 -0
- package/lib/tools.js +993 -0
- package/lib/version.js +62 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
|
@@ -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('&', '&')
|
|
2193
|
+
.replaceAll('<', '<')
|
|
2194
|
+
.replaceAll('>', '>')
|
|
2195
|
+
.replaceAll('"', '"')
|
|
2196
|
+
.replaceAll("'", ''')
|
|
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
|
+
}
|