dsh-computers 1.1.0 → 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/PROTOCOL.md +135 -0
- package/README.md +45 -16
- package/agent/README.md +112 -5
- package/agent/dsh-computers-agent.mjs +835 -20
- package/client.js +777 -10
- package/cordis.patch.yml +2 -0
- package/index.js +32 -2
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/hub.js +346 -140
- package/lib/routes.js +425 -38
- package/lib/state.js +11 -2
- package/lib/tools.js +684 -85
- package/lib/version.js +62 -0
- package/package.json +1 -1
|
@@ -32,7 +32,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
|
32
32
|
import { parseArgs } from 'node:util'
|
|
33
33
|
|
|
34
34
|
// Keep in sync with package.json. The server compares this for display only.
|
|
35
|
-
export const AGENT_VERSION = '1.
|
|
35
|
+
export const AGENT_VERSION = '1.3.0'
|
|
36
36
|
|
|
37
37
|
const DEFAULT_EXEC_TIMEOUT_MS = 120_000
|
|
38
38
|
const MAX_EXEC_TIMEOUT_MS = 24 * 60 * 60 * 1000
|
|
@@ -45,6 +45,25 @@ const HEARTBEAT_LOG_INTERVAL_MS = 60_000
|
|
|
45
45
|
const TRANSIENT_SERVER_WARN_INTERVAL_MS = 60_000
|
|
46
46
|
const DEFAULT_HEARTBEAT_MS = 20_000
|
|
47
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
|
+
|
|
48
67
|
// Service identifier. Overridable so tests (and power users with two installs)
|
|
49
68
|
// can use an isolated launchd/systemd job. Not part of the protocol.
|
|
50
69
|
const SERVICE_LABEL = (process.env.DSH_COMPUTERS_SERVICE_LABEL || 'com.dsh.computers.agent').trim()
|
|
@@ -127,6 +146,37 @@ function machineInfo(name) {
|
|
|
127
146
|
}
|
|
128
147
|
}
|
|
129
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
|
+
|
|
130
180
|
// ---------------------------------------------------------------------------
|
|
131
181
|
// Paths and config (${DSH_COMPUTERS_HOME:-$HOME/.dsh-computers})
|
|
132
182
|
// ---------------------------------------------------------------------------
|
|
@@ -262,6 +312,7 @@ async function loadConfig() {
|
|
|
262
312
|
token: parsed.token,
|
|
263
313
|
name: parsed.name,
|
|
264
314
|
enrolledAt: typeof parsed.enrolledAt === 'string' ? parsed.enrolledAt : '',
|
|
315
|
+
workspaceBase: normalizeWorkspaceBase(parsed.workspaceBase),
|
|
265
316
|
}
|
|
266
317
|
}
|
|
267
318
|
|
|
@@ -363,6 +414,14 @@ function heartbeatUrl(serverUrl) {
|
|
|
363
414
|
return `${apiBase(serverUrl)}/agent/heartbeat`
|
|
364
415
|
}
|
|
365
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
|
+
|
|
366
425
|
function normalizeServerUrl(value) {
|
|
367
426
|
if (typeof value !== 'string' || !value.trim()) {
|
|
368
427
|
throw new CliError('缺少 --server <url>(dsh-web 的地址,例如 http://127.0.0.1:3000)')
|
|
@@ -448,11 +507,15 @@ async function readErrorBody(response) {
|
|
|
448
507
|
}
|
|
449
508
|
}
|
|
450
509
|
|
|
451
|
-
function sseHeaders(token) {
|
|
452
|
-
|
|
510
|
+
function sseHeaders(token, metadata) {
|
|
511
|
+
const headers = requestHeaders(token, {
|
|
453
512
|
accept: 'text/event-stream',
|
|
454
513
|
'cache-control': 'no-cache',
|
|
455
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
|
|
456
519
|
}
|
|
457
520
|
|
|
458
521
|
/** Parse one SSE block (`event:` / `data:` lines) into { event, data }. */
|
|
@@ -509,7 +572,7 @@ async function probeEventStream(config, timeoutMs = 5000) {
|
|
|
509
572
|
timer.unref?.()
|
|
510
573
|
try {
|
|
511
574
|
const response = await fetch(eventsUrl(config.serverUrl), {
|
|
512
|
-
headers: sseHeaders(config.token),
|
|
575
|
+
headers: sseHeaders(config.token, handshakeMetadata(config.workspaceBase)),
|
|
513
576
|
signal: controller.signal,
|
|
514
577
|
})
|
|
515
578
|
if (response.status === 401) {
|
|
@@ -607,7 +670,7 @@ function normalizeTimeout(value) {
|
|
|
607
670
|
* SIGKILLed on timeout/cancel; otherwise `sh -lc 'cmd | pipeline'` would leave
|
|
608
671
|
* grandchildren running and the timeout would not actually stop the work.
|
|
609
672
|
*/
|
|
610
|
-
function executeCommand({ command, cwd, timeoutMs }) {
|
|
673
|
+
function executeCommand({ command, cwd, timeoutMs, onStdout, onStderr }) {
|
|
611
674
|
const startedAt = Date.now()
|
|
612
675
|
const limit = normalizeTimeout(timeoutMs)
|
|
613
676
|
const stdout = createCapture()
|
|
@@ -684,8 +747,28 @@ function executeCommand({ command, cwd, timeoutMs }) {
|
|
|
684
747
|
return { promise, cancel() {} }
|
|
685
748
|
}
|
|
686
749
|
|
|
687
|
-
|
|
688
|
-
|
|
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
|
+
})
|
|
689
772
|
child.on('error', (error) => settle(null, null, `命令进程错误:${describeError(error)}`))
|
|
690
773
|
child.on('close', (code, signalCode) => settle(code, signalCode, null))
|
|
691
774
|
timeoutTimer = setTimeout(() => {
|
|
@@ -750,6 +833,7 @@ async function sendHeartbeat(runtime) {
|
|
|
750
833
|
cpuModel: machineInfo('').cpuModel,
|
|
751
834
|
memoryBytes: os.totalmem(),
|
|
752
835
|
agentVersion: AGENT_VERSION,
|
|
836
|
+
capabilities: [...AGENT_CAPABILITIES],
|
|
753
837
|
},
|
|
754
838
|
},
|
|
755
839
|
timeoutMs: 15_000,
|
|
@@ -785,6 +869,629 @@ function logHeartbeatError(runtime, message) {
|
|
|
785
869
|
runtimeLog(runtime, 'warn', `心跳失败:${message}`)
|
|
786
870
|
}
|
|
787
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
|
+
|
|
788
1495
|
// ---------------------------------------------------------------------------
|
|
789
1496
|
// SSE event handlers
|
|
790
1497
|
// ---------------------------------------------------------------------------
|
|
@@ -803,9 +1510,20 @@ function handleExec(runtime, rawData) {
|
|
|
803
1510
|
runtimeLog(runtime, 'warn', `忽略重复的请求 ${request.requestId}`)
|
|
804
1511
|
return
|
|
805
1512
|
}
|
|
806
|
-
const
|
|
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
|
+
})
|
|
807
1525
|
runtime.jobs.set(request.requestId, job)
|
|
808
|
-
runtimeLog(runtime, 'info', `▶ ${request.requestId} ${abbreviate(request.command)}`)
|
|
1526
|
+
runtimeLog(runtime, 'info', `▶ ${request.requestId}${streaming ? ' [stream]' : ''} ${abbreviate(request.command)}`)
|
|
809
1527
|
void job.promise
|
|
810
1528
|
.then(async (result) => {
|
|
811
1529
|
runtimeLog(
|
|
@@ -815,15 +1533,22 @@ function handleExec(runtime, rawData) {
|
|
|
815
1533
|
`${result.timedOut ? ' timedOut' : ''}${result.truncated ? ' truncated' : ''}`,
|
|
816
1534
|
)
|
|
817
1535
|
if (runtime.stopping) {
|
|
1536
|
+
pump?.abort()
|
|
818
1537
|
runtimeLog(runtime, 'info', `运行时已停止,不再上报结果 ${request.requestId}`)
|
|
819
1538
|
return
|
|
820
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()
|
|
821
1543
|
await postResult(runtime, { requestId: request.requestId, ...result })
|
|
822
1544
|
})
|
|
823
1545
|
.catch((error) => {
|
|
1546
|
+
pump?.abort()
|
|
824
1547
|
runtimeLog(runtime, 'error', `✗ ${request.requestId} 执行失败:${describeError(error)}`)
|
|
825
1548
|
})
|
|
826
1549
|
.finally(() => {
|
|
1550
|
+
pump?.abort()
|
|
1551
|
+
if (pump) runtime.pumps.delete(pump)
|
|
827
1552
|
runtime.jobs.delete(request.requestId)
|
|
828
1553
|
})
|
|
829
1554
|
}
|
|
@@ -840,6 +1565,37 @@ function handleCancel(runtime, rawData) {
|
|
|
840
1565
|
job.cancel()
|
|
841
1566
|
}
|
|
842
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
|
+
|
|
843
1599
|
function applyConfigEvent(runtime, rawData) {
|
|
844
1600
|
const value = parseJsonSafe(rawData)
|
|
845
1601
|
if (!value) return
|
|
@@ -871,6 +1627,13 @@ function normalizeRuntimeHeartbeat(value) {
|
|
|
871
1627
|
return Math.min(Math.max(1000, Math.floor(number)), 300_000)
|
|
872
1628
|
}
|
|
873
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
|
+
|
|
874
1637
|
function createRuntime(options) {
|
|
875
1638
|
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
876
1639
|
throw new TypeError('startRuntime(options) requires an options object')
|
|
@@ -887,6 +1650,7 @@ function createRuntime(options) {
|
|
|
887
1650
|
token: requireOptionString(options.token, 'token'),
|
|
888
1651
|
computerId: requireOptionString(options.computerId, 'computerId'),
|
|
889
1652
|
name: typeof options.name === 'string' ? options.name.trim() : '',
|
|
1653
|
+
workspaceBase: normalizeWorkspaceBase(options.workspaceBase),
|
|
890
1654
|
}
|
|
891
1655
|
return {
|
|
892
1656
|
config,
|
|
@@ -899,11 +1663,15 @@ function createRuntime(options) {
|
|
|
899
1663
|
emittingState: false,
|
|
900
1664
|
lastStateKey: '',
|
|
901
1665
|
loopPromise: null,
|
|
1666
|
+
fs: { controllers: new Set(), promises: new Set() },
|
|
1667
|
+
pumps: new Set(),
|
|
902
1668
|
state: {
|
|
903
1669
|
running: true,
|
|
904
1670
|
connected: false,
|
|
905
1671
|
heartbeatMs: normalizeRuntimeHeartbeat(options.heartbeatMs),
|
|
906
1672
|
name: config.name,
|
|
1673
|
+
features: [],
|
|
1674
|
+
chunkHeartbeatMs: normalizeChunkHeartbeat(options.chunkHeartbeatMs),
|
|
907
1675
|
since: null,
|
|
908
1676
|
lastConnectedAt: null,
|
|
909
1677
|
lastError: null,
|
|
@@ -911,6 +1679,7 @@ function createRuntime(options) {
|
|
|
911
1679
|
exitCode: null,
|
|
912
1680
|
lastPingLogAt: 0,
|
|
913
1681
|
lastHeartbeatErrorAt: 0,
|
|
1682
|
+
lastChunkDropAt: 0,
|
|
914
1683
|
},
|
|
915
1684
|
}
|
|
916
1685
|
}
|
|
@@ -967,6 +1736,9 @@ function runtimeStatus(runtime) {
|
|
|
967
1736
|
serverUrl: runtime.config.serverUrl,
|
|
968
1737
|
computerId: runtime.config.computerId,
|
|
969
1738
|
name: state.name || runtime.config.name,
|
|
1739
|
+
workspaceBase: runtime.config.workspaceBase,
|
|
1740
|
+
capabilities: [...AGENT_CAPABILITIES],
|
|
1741
|
+
features: [...state.features],
|
|
970
1742
|
heartbeatMs: state.heartbeatMs,
|
|
971
1743
|
since: state.since,
|
|
972
1744
|
lastConnectedAt: state.lastConnectedAt,
|
|
@@ -980,7 +1752,7 @@ function runtimeStatus(runtime) {
|
|
|
980
1752
|
function emitRuntimeState(runtime) {
|
|
981
1753
|
if (!runtime.onState || runtime.emittingState) return
|
|
982
1754
|
const status = runtimeStatus(runtime)
|
|
983
|
-
const key = [status.running, status.connected, status.since, status.lastError, status.name, status.exitCode].join('|')
|
|
1755
|
+
const key = [status.running, status.connected, status.since, status.lastError, status.name, status.exitCode, status.features.join(',')].join('|')
|
|
984
1756
|
if (key === runtime.lastStateKey) return
|
|
985
1757
|
runtime.lastStateKey = key
|
|
986
1758
|
runtime.emittingState = true
|
|
@@ -1020,8 +1792,24 @@ async function stopRuntime(runtime) {
|
|
|
1020
1792
|
runtime.stopping = true
|
|
1021
1793
|
runtimeLog(runtime, 'info', '正在停止运行时…')
|
|
1022
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
|
+
}
|
|
1023
1803
|
}
|
|
1024
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])
|
|
1025
1813
|
if (runtime.loopPromise) await runtime.loopPromise.catch(() => {})
|
|
1026
1814
|
// Reap jobs that a last in-flight event may have started before the flag.
|
|
1027
1815
|
await cancelAndReapJobs(runtime)
|
|
@@ -1114,7 +1902,10 @@ async function runRuntimeLoop(runtime) {
|
|
|
1114
1902
|
*/
|
|
1115
1903
|
async function runOneSession(runtime, signal) {
|
|
1116
1904
|
const config = runtime.config
|
|
1117
|
-
const response = await fetch(eventsUrl(config.serverUrl), {
|
|
1905
|
+
const response = await fetch(eventsUrl(config.serverUrl), {
|
|
1906
|
+
headers: sseHeaders(config.token, handshakeMetadata(config.workspaceBase)),
|
|
1907
|
+
signal,
|
|
1908
|
+
})
|
|
1118
1909
|
if (response.status === 401) {
|
|
1119
1910
|
const body = await readErrorBody(response)
|
|
1120
1911
|
if (isInvalidTokenRejection(response.status, body)) return { kind: 'unauthorized' }
|
|
@@ -1159,6 +1950,10 @@ async function runOneSession(runtime, signal) {
|
|
|
1159
1950
|
gotHello = true
|
|
1160
1951
|
if (typeof hello.name === 'string' && hello.name) state.name = hello.name
|
|
1161
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
|
+
: []
|
|
1162
1957
|
state.connected = true
|
|
1163
1958
|
state.since = new Date().toISOString()
|
|
1164
1959
|
state.lastConnectedAt = state.since
|
|
@@ -1167,7 +1962,8 @@ async function runOneSession(runtime, signal) {
|
|
|
1167
1962
|
runtime,
|
|
1168
1963
|
'info',
|
|
1169
1964
|
`已连接 ${config.serverUrl} · computer=${config.computerId}` +
|
|
1170
|
-
`${state.name ? ` · name=${state.name}` : ''} · heartbeat=${state.heartbeatMs}ms
|
|
1965
|
+
`${state.name ? ` · name=${state.name}` : ''} · heartbeat=${state.heartbeatMs}ms` +
|
|
1966
|
+
` · features=${state.features.length > 0 ? state.features.join(',') : 'none'}`,
|
|
1171
1967
|
)
|
|
1172
1968
|
emitRuntimeState(runtime)
|
|
1173
1969
|
startHeartbeatTimer()
|
|
@@ -1187,6 +1983,9 @@ async function runOneSession(runtime, signal) {
|
|
|
1187
1983
|
}
|
|
1188
1984
|
break
|
|
1189
1985
|
}
|
|
1986
|
+
case 'fs':
|
|
1987
|
+
handleFs(runtime, event.data)
|
|
1988
|
+
break
|
|
1190
1989
|
case 'config':
|
|
1191
1990
|
applyConfigEvent(runtime, event.data)
|
|
1192
1991
|
break
|
|
@@ -1226,6 +2025,7 @@ async function cmdRun(argv) {
|
|
|
1226
2025
|
token: config.token,
|
|
1227
2026
|
computerId: config.computerId,
|
|
1228
2027
|
name: config.name,
|
|
2028
|
+
workspaceBase: config.workspaceBase,
|
|
1229
2029
|
heartbeatMs: DEFAULT_HEARTBEAT_MS,
|
|
1230
2030
|
log: sink,
|
|
1231
2031
|
onState: (state) => {
|
|
@@ -1277,6 +2077,7 @@ async function cmdEnroll(argv) {
|
|
|
1277
2077
|
server: { type: 'string' },
|
|
1278
2078
|
key: { type: 'string' },
|
|
1279
2079
|
name: { type: 'string' },
|
|
2080
|
+
'workspace-base': { type: 'string' },
|
|
1280
2081
|
replace: { type: 'boolean' },
|
|
1281
2082
|
help: { type: 'boolean', short: 'h' },
|
|
1282
2083
|
},
|
|
@@ -1300,7 +2101,15 @@ async function cmdEnroll(argv) {
|
|
|
1300
2101
|
console.error(`提示:${configFile()} 已存在,本次注册会覆盖旧的凭据。`)
|
|
1301
2102
|
}
|
|
1302
2103
|
|
|
1303
|
-
const
|
|
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
|
+
}
|
|
1304
2113
|
if (values.replace) body.replace = true
|
|
1305
2114
|
|
|
1306
2115
|
let response
|
|
@@ -1333,9 +2142,11 @@ async function cmdEnroll(argv) {
|
|
|
1333
2142
|
token: data.token,
|
|
1334
2143
|
name: data.name,
|
|
1335
2144
|
enrolledAt: new Date().toISOString(),
|
|
2145
|
+
workspaceBase,
|
|
1336
2146
|
}
|
|
1337
2147
|
await saveConfig(config)
|
|
1338
2148
|
console.log(`✓ 凭据已保存到 ${configFile()}(仅本机可读,权限 0600)`)
|
|
2149
|
+
console.log(`工作目录:${workspaceBase}(exec 默认 cwd,glob/grep 的相对基准)`)
|
|
1339
2150
|
|
|
1340
2151
|
// Prove the round trip once. Failure to receive hello is not fatal: the
|
|
1341
2152
|
// enrollment itself succeeded and `run` retries forever.
|
|
@@ -1862,6 +2673,8 @@ async function cmdStatus(argv) {
|
|
|
1862
2673
|
console.log(`电脑:${config.name}(${config.computerId})`)
|
|
1863
2674
|
console.log(`注册时间:${config.enrolledAt || '未知'}`)
|
|
1864
2675
|
console.log(`Token:${redactToken(config.token)}(已脱敏)`)
|
|
2676
|
+
console.log(`工作目录:${config.workspaceBase}`)
|
|
2677
|
+
console.log(`能力:${AGENT_CAPABILITIES.join(', ')}`)
|
|
1865
2678
|
console.log(`配置:${configFile()}`)
|
|
1866
2679
|
console.log(`日志:${logFile()}`)
|
|
1867
2680
|
console.log(`服务:${await serviceSummary()}`)
|
|
@@ -1872,12 +2685,13 @@ async function cmdStatus(argv) {
|
|
|
1872
2685
|
// CLI plumbing
|
|
1873
2686
|
// ---------------------------------------------------------------------------
|
|
1874
2687
|
|
|
1875
|
-
const ENROLL_USAGE = `用法:dsh-computers-agent enroll --server <url> --key <key> [--name <name>] [--replace]
|
|
2688
|
+
const ENROLL_USAGE = `用法:dsh-computers-agent enroll --server <url> --key <key> [--name <name>] [--workspace-base <path>] [--replace]
|
|
1876
2689
|
|
|
1877
|
-
--server <url>
|
|
1878
|
-
--key <key>
|
|
1879
|
-
--name <name>
|
|
1880
|
-
--
|
|
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 让服务器用这台电脑的新凭据替换同名记录`
|
|
1881
2695
|
|
|
1882
2696
|
const RUN_USAGE = `用法:dsh-computers-agent run
|
|
1883
2697
|
|
|
@@ -1892,7 +2706,7 @@ const HELP_TEXT = `dsh-computers-agent ${AGENT_VERSION}
|
|
|
1892
2706
|
把本机接入 dsh-web,让服务器上的 agent 通过 computer_exec 在这台电脑执行命令。
|
|
1893
2707
|
|
|
1894
2708
|
用法 / Usage:
|
|
1895
|
-
dsh-computers-agent enroll --server <url> --key <key> [--name <name>] [--replace]
|
|
2709
|
+
dsh-computers-agent enroll --server <url> --key <key> [--name <name>] [--workspace-base <path>] [--replace]
|
|
1896
2710
|
用服务器给出的加入密钥注册这台电脑,并把凭据保存到本机。
|
|
1897
2711
|
dsh-computers-agent run
|
|
1898
2712
|
前台运行:保持 SSE 连接并执行服务器发来的命令(Ctrl-C 退出)。
|
|
@@ -1911,7 +2725,8 @@ const HELP_TEXT = `dsh-computers-agent ${AGENT_VERSION}
|
|
|
1911
2725
|
· 配置文件 ${configFile()} 权限 0600,原子写入。
|
|
1912
2726
|
· token 只保存在本机,不会写入日志;status 只显示末 6 位。
|
|
1913
2727
|
· 本程序只发起对外连接(enroll / SSE / 结果上报 / 心跳),不监听任何端口。
|
|
1914
|
-
· 命令以当前用户身份在 /bin/sh -lc
|
|
2728
|
+
· 命令以当前用户身份在 /bin/sh -lc 中执行,因此拥有与终端相同的权限。
|
|
2729
|
+
· 能力:exec(远程命令)+ fs(远程文件读写/glob/grep),默认工作目录可在 enroll 时指定。`
|
|
1915
2730
|
|
|
1916
2731
|
function parseCliArgs(args, options) {
|
|
1917
2732
|
try {
|