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.
@@ -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.1.0'
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
- return requestHeaders(token, {
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
- child.stdout?.on('data', (chunk) => stdout.push(chunk))
688
- child.stderr?.on('data', (chunk) => stderr.push(chunk))
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 job = executeCommand({ command: request.command, cwd: request.cwd ?? null, timeoutMs: request.timeoutMs })
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), { headers: sseHeaders(config.token), signal })
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 body = { key, agentVersion: AGENT_VERSION, machine: machineInfo(requestedName) }
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> dsh-web 的地址,例如 http://127.0.0.1:3000
1878
- --key <key> 服务器生成的加入密钥(dshc_…),只能使用一次
1879
- --name <name> 显示在服务器电脑列表里的名称,默认使用主机名
1880
- --replace 让服务器用这台电脑的新凭据替换同名记录`
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 {