dsh-remote-plugin 0.6.24 → 0.6.26

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.
Binary file
package/gateway-stats.cjs CHANGED
@@ -22,6 +22,17 @@ const path = require('node:path')
22
22
  const os = require('node:os')
23
23
  const { spawn } = require('node:child_process')
24
24
  const readline = require('node:readline')
25
+ const zlib = require('node:zlib')
26
+
27
+ function logGeneration(file) {
28
+ const match = /^session(?:\.v([1-9]\d*))?\.jsonl(?:\.zstd)?$/.exec(path.basename(file))
29
+ return match && Number.isSafeInteger(Number(match[1] || 0)) ? Number(match[1] || 0) : null
30
+ }
31
+
32
+ // V3 preserves message IDs, times and usage while renumbering durable seqs.
33
+ function usageIdentity(event) {
34
+ return JSON.stringify([event.data?.message?.id || '', event.time, normalizeUsage(event)])
35
+ }
25
36
 
26
37
  // ---------- 固定价格表(v1 硬编码; v2 将改为配置文件/环境变量) ----------
27
38
  // 时段判定: 工作日北京时间 9:00-12:00 与 14:00-18:00 为高峰(含起点不含终点),周末全天谷时
@@ -238,9 +249,9 @@ class StatsStore {
238
249
  return null
239
250
  }
240
251
 
241
- _setCursor(sessionId, lastSeq) {
252
+ _setCursor(sessionId, lastSeq, generation) {
242
253
  const c = this._loadCursors()
243
- c[sessionId] = { lastSeq, updatedAt: Date.now() }
254
+ c[sessionId] = { ...c[sessionId], lastSeq, updatedAt: Date.now(), ...(generation == null ? {} : { generation }) }
244
255
  this._saveCursors()
245
256
  }
246
257
 
@@ -296,102 +307,111 @@ class StatsStore {
296
307
  return this._enqueue(() => this._scanFile(file, onProgress))
297
308
  }
298
309
 
299
- _scanFile(file, onProgress) {
300
- return new Promise((resolvePromise) => {
301
- const sessionId = path.basename(path.dirname(file))
302
- const cur = this._cursor(sessionId)
303
- let lastSeq = cur ? cur.lastSeq : -1
304
- let processed = 0
305
- let currentModel = ''
306
- let headerParsed = false
307
- let lineCount = 0
308
- let yielding = false
309
- const dirtyDays = new Map()
310
- let scanError = ''
311
- const flush = () => {
312
- for (const day of dirtyDays.values()) this._saveDay(day)
313
- dirtyDays.clear()
314
- if (lastSeq >= 0) this._setCursor(sessionId, lastSeq)
315
- }
316
-
317
- const zstd = this.spawn('zstd', ['-dc', file], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] })
318
- const rl = readline.createInterface({ input: zstd.stdout })
319
-
320
- zstd.on('error', (err) => {
321
- if (err.code === 'ENOENT') {
322
- console.warn(`[stats] 未找到 zstd 命令, 跳过历史回填: ${file}`)
323
- } else {
324
- console.warn(`[stats] zstd 解压失败 ${file}: ${err.message}`)
325
- }
326
- scanError = err.code || err.message
327
- rl.close()
310
+ async _readLog(file, visit) {
311
+ let input, source, child, childResult
312
+ if (!file.endsWith('.zstd')) input = fs.createReadStream(file)
313
+ else if (this.spawn === spawn && typeof zlib.createZstdDecompress === 'function') {
314
+ source = fs.createReadStream(file)
315
+ input = zlib.createZstdDecompress()
316
+ source.on('error', error => input.destroy(error))
317
+ source.pipe(input)
318
+ } else {
319
+ child = this.spawn('zstd', ['-dc', file], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] })
320
+ input = child.stdout
321
+ childResult = new Promise(resolve => {
322
+ child.once('close', code => resolve({ code }))
323
+ child.once('error', error => resolve({ error }))
328
324
  })
325
+ child.on('error', error => input.destroy(error))
326
+ }
327
+ const rl = readline.createInterface({ input })
328
+ let lines = 0
329
+ try {
330
+ for await (const line of rl) {
331
+ if (!line.trim()) continue
332
+ const event = JSON.parse(line)
333
+ if (Number.isSafeInteger(event.seq)) visit(event)
334
+ if (++lines % 500 === 0) await new Promise(resolve => setImmediate(resolve))
335
+ }
336
+ if (childResult) {
337
+ const result = await childResult
338
+ if (result.error) throw result.error
339
+ if (result.code !== 0) throw new Error('zstd-decompression-failed')
340
+ }
341
+ } finally {
342
+ rl.close()
343
+ input.destroy()
344
+ source?.destroy()
345
+ child?.kill?.()
346
+ }
347
+ }
329
348
 
330
- rl.on('line', (line) => {
331
- lineCount++
332
- let event
333
- try {
334
- event = JSON.parse(line)
335
- } catch {
336
- return
337
- }
338
- // 首行是 session header, 没有 seq
339
- if (!headerParsed) {
340
- headerParsed = true
341
- return
342
- }
343
- if (typeof event.seq !== 'number') return
344
- if (event.seq <= lastSeq) return
345
- if (event.seq > lastSeq + 1) {
346
- // 日志理论上是连续 seq; 出现空洞时以文件为准继续顺序推进(seq 游标按文件顺序)
347
- }
348
- // 跟踪当前模型配置: request/header request/context 都可能带模型
349
- try {
350
- if (event.type === 'request/header' && event.data?.config?.model) currentModel = event.data.config.model
351
- if (event.type === 'request/context' && event.data?.model) currentModel = event.data.model
352
- } catch {}
353
- if (event.type === 'assistant/message') {
354
- const usage = normalizeUsage(event)
355
- if (usage) {
356
- const model = eventModel(event) || currentModel || 'unknown'
357
- const { date, hour, period } = eventKey(event.time)
358
- if (date >= PRICING_START_DATE) {
359
- const day = dirtyDays.get(date) || this._loadDay(date)
360
- dirtyDays.set(date, day)
361
- const hourBucket = day.hours[hour] || (day.hours[hour] = {})
362
- const modelBucket = hourBucket[model] || (hourBucket[model] = emptyBucket())
363
- addUsage(modelBucket, model, period, usage)
364
- processed++
365
- }
349
+ async _scanFile(file, onProgress) {
350
+ const sessionId = path.basename(path.dirname(file))
351
+ const cur = this._cursor(sessionId)
352
+ const generation = logGeneration(file) ?? 0
353
+ const migrated = cur && generation !== (cur.generation ?? 0)
354
+ let lastSeq = migrated ? -1 : (cur?.lastSeq ?? -1)
355
+ let processed = 0
356
+ let currentModel = ''
357
+ const dirtyDays = new Map()
358
+ const counted = new Map()
359
+ try {
360
+ if (migrated) {
361
+ // Read the preserved predecessor before advancing to a renumbered log.
362
+ // Missing predecessor is an explicit error, never a blind double count.
363
+ const names = await fs.promises.readdir(path.dirname(file))
364
+ const previous = names.find(name => logGeneration(name) === (cur.generation ?? 0))
365
+ if (!previous) throw new Error('stats-migration-source-missing')
366
+ await this._readLog(path.join(path.dirname(file), previous), event => {
367
+ if (event.seq <= cur.lastSeq && event.type === 'assistant/message' && normalizeUsage(event)) {
368
+ const key = usageIdentity(event)
369
+ counted.set(key, (counted.get(key) || 0) + 1)
366
370
  }
367
- }
371
+ })
372
+ }
373
+ await this._readLog(file, event => {
374
+ if (event.type === 'request/header' && event.data?.config?.model) currentModel = event.data.config.model
375
+ if (event.type === 'request/context' && event.data?.model) currentModel = event.data.model
376
+ if (event.seq <= lastSeq) return
368
377
  lastSeq = event.seq
369
- // 大历史文件按批次让出事件循环,避免回填长期占住实时 HTTP/WS 处理。
370
- if (lineCount % 500 === 0 && !yielding) {
371
- yielding = true
372
- rl.pause()
373
- setImmediate(() => { yielding = false; rl.resume() })
374
- }
378
+ if (event.type !== 'assistant/message') return
379
+ const usage = normalizeUsage(event)
380
+ if (!usage) return
381
+ const key = usageIdentity(event)
382
+ if (counted.get(key)) { counted.set(key, counted.get(key) - 1); return }
383
+ const model = eventModel(event) || currentModel || 'unknown'
384
+ const { date, hour, period } = eventKey(event.time)
385
+ if (date < PRICING_START_DATE) return
386
+ // Stage detached buckets: a failed read must not mutate cached totals.
387
+ const day = dirtyDays.get(date) || JSON.parse(JSON.stringify(this._loadDay(date)))
388
+ dirtyDays.set(date, day)
389
+ const hourBucket = day.hours[hour] || (day.hours[hour] = {})
390
+ addUsage(hourBucket[model] || (hourBucket[model] = emptyBucket()), model, period, usage)
391
+ processed++
375
392
  })
376
-
377
- rl.on('close', () => {
378
- flush()
379
- if (onProgress) onProgress({ sessionId, processed })
380
- resolvePromise({ sessionId, processed, ...(scanError ? { error: scanError } : {}) })
381
- })
382
- })
393
+ if ([...counted.values()].some(count => count > 0)) throw new Error('stats-migration-usage-mismatch')
394
+ for (const day of dirtyDays.values()) this._saveDay(day)
395
+ if (lastSeq >= 0) this._setCursor(sessionId, lastSeq, generation)
396
+ if (onProgress) onProgress({ sessionId, processed })
397
+ return { sessionId, processed }
398
+ } catch (error) {
399
+ return { sessionId, processed: 0, error: error.code || error.message }
400
+ }
383
401
  }
384
402
 
385
403
  /** 扫描 ~/.dsh/sessions 下全部 session.jsonl.zstd。 */
386
404
  async scanAll(sessionsRoot, onProgress) {
387
- const root = sessionsRoot || path.join(os.homedir(), '.dsh', 'sessions')
405
+ const root = sessionsRoot || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'sessions')
388
406
  let files = []
389
407
  try {
390
408
  const walk = async (dir) => {
391
409
  const entries = await fs.promises.readdir(dir, { withFileTypes: true })
410
+ const logs = entries.filter(ent => ent.isFile() && logGeneration(ent.name) !== null)
411
+ .sort((a, b) => logGeneration(b.name) - logGeneration(a.name) || a.name.localeCompare(b.name))
412
+ if (logs.length) files.push(path.join(dir, logs[0].name))
392
413
  for (const ent of entries) {
393
414
  if (ent.isDirectory()) await walk(path.join(dir, ent.name))
394
- else if (ent.name === 'session.jsonl.zstd') files.push(path.join(dir, ent.name))
395
415
  }
396
416
  }
397
417
  await walk(root)
@@ -400,12 +420,14 @@ class StatsStore {
400
420
  return { files: 0, processed: 0 }
401
421
  }
402
422
  let processed = 0
423
+ const errors = []
403
424
  // 串行扫描, 避免大量并发 zstd 子进程
404
425
  for (const file of files) {
405
426
  const out = await this.scanFile(file, onProgress)
406
427
  processed += out.processed || 0
428
+ if (out.error) errors.push({ sessionId: out.sessionId, error: out.error })
407
429
  }
408
- return { files: files.length, processed }
430
+ return { files: files.length, processed, ...(errors.length ? { errors } : {}) }
409
431
  }
410
432
 
411
433
  summary(days) {
package/gateway.cjs CHANGED
@@ -72,6 +72,8 @@ const WS_IDLE_MS = durationEnv('GATEWAY_WS_IDLE_MS', 180000, 0, 24 * 60 * 60 * 1
72
72
  const WS_UPGRADE_TIMEOUT_MS = durationEnv('GATEWAY_WS_UPGRADE_TIMEOUT_MS', 15000, 1000, 5 * 60 * 1000)
73
73
  const UPSTREAM_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_UPSTREAM_TIMEOUT_MS', 30000, 1000, 10 * 60 * 1000)
74
74
  const UPSTREAM = new URL(process.env.DSH_UPSTREAM || 'http://127.0.0.1:3080')
75
+ // URL 中 IPv6 带方括号,http.request 的 hostname 则要求裸地址。
76
+ const UPSTREAM_HOSTNAME = UPSTREAM.hostname.replace(/^\[|\]$/g, '')
75
77
  const UPSTREAM_TRANSPORT = UPSTREAM.protocol === 'https:' ? https : http
76
78
  const UPSTREAM_PORT = Number(UPSTREAM.port) || (UPSTREAM.protocol === 'https:' ? 443 : 80)
77
79
  const UPSTREAM_AUTHORITY = `${UPSTREAM.hostname}${UPSTREAM.port ? ':' + UPSTREAM.port : ''}`
@@ -208,7 +210,7 @@ const MIME = {
208
210
 
209
211
  // ---------- /fs 文件传输 ----------
210
212
  // 允许访问的根目录: DSH_REMOTE_FS_ROOT 使用系统路径分隔符分隔多个根,
211
- // POSIX 为 ':'、Windows 为 ';';默认仅 ~。
213
+ // POSIX 为 ':'、Windows 为 ';';Windows 默认用户目录及 C 盘以外的可用盘符。
212
214
  // 所有 /fs/* 路径 resolve 后都必须位于某个根内, 已存在的路径还会用 realpath
213
215
  // 复核一次, 防止 ../ 穿越与符号链接逃逸。
214
216
  const FS_DEFAULT_ROOT = path.resolve(os.homedir())
@@ -218,10 +220,20 @@ function fsConfiguredRoot(value) {
218
220
  if (/^~[\\/]/.test(raw)) return path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
219
221
  return path.resolve(raw)
220
222
  }
221
- const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
222
- .split(path.delimiter)
223
- .map(fsConfiguredRoot)
224
- .filter(Boolean)
223
+ function fsDefaultRoots(platform, home, isDirectory) {
224
+ const roots = [home]
225
+ if (platform === 'win32') {
226
+ for (const letter of 'ABDEFGHIJKLMNOPQRSTUVWXYZ') {
227
+ const root = `${letter}:\\`
228
+ try { if (isDirectory(root)) roots.push(root) } catch {}
229
+ }
230
+ }
231
+ return roots
232
+ }
233
+ const FS_ROOTS = process.env.DSH_REMOTE_FS_ROOT
234
+ ? process.env.DSH_REMOTE_FS_ROOT.split(path.delimiter).map(fsConfiguredRoot).filter(Boolean)
235
+ : fsDefaultRoots(process.platform, FS_DEFAULT_ROOT, root => fs.statSync(root).isDirectory())
236
+ const FS_WINDOWS_DEFAULT = process.platform === 'win32' && !process.env.DSH_REMOTE_FS_ROOT
225
237
  const FS_WORKSPACE_CACHE_MS = durationEnv('DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 15_000, 1000, 10 * 60_000)
226
238
  const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
227
239
  const FS_UPLOAD_TTL_MS = durationEnv('DSH_REMOTE_FS_UPLOAD_TTL_MS', 24 * 60 * 60 * 1000, 60_000, 7 * 24 * 60 * 60 * 1000)
@@ -235,6 +247,8 @@ function fsRootReals() {
235
247
  return FS_ROOT_REALS
236
248
  }
237
249
  function fsInsideReal(real) {
250
+ // 默认 Windows 授权不能被已登记工作区或盘符的 junction / SUBST 目标扩大。
251
+ if (FS_WINDOWS_DEFAULT) return FS_ROOTS.some(root => fsInsideRoot(real, root))
238
252
  for (const root of [...fsRootReals(), ...fsWorkspaceRootsCache.reals]) {
239
253
  if (fsInsideRoot(real, root)) return true
240
254
  }
@@ -1401,6 +1415,7 @@ const modernState = {
1401
1415
  sessionCursors: new Map(),
1402
1416
  workspaces: { items: [], archivedSessionIds: [] },
1403
1417
  pendingEvents: new Map(),
1418
+ assistantStreams: new Map(),
1404
1419
  }
1405
1420
 
1406
1421
  async function callUpstreamRemote(endpoint, args, rpcId = crypto.randomUUID()) {
@@ -1536,7 +1551,7 @@ async function translateModernRpc(method, payload, rpcId) {
1536
1551
  ...(payload.beforeSeq === undefined ? {} : { beforeSeq: payload.beforeSeq }),
1537
1552
  ...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
1538
1553
  } }
1539
- transform = value => legacyHistoryValue(value, summary)
1554
+ transform = value => ({ ...legacyHistoryValue(value, summary), ...modernReasoningValue(payload.sessionId) })
1540
1555
  } else if (method === 'session.models') {
1541
1556
  endpoint = 'session/modelCatalog'
1542
1557
  args = {}
@@ -1670,6 +1685,7 @@ function rememberCollectorReplay(kind, full, raw) {
1670
1685
  const replay = collectorReplay[kind]
1671
1686
  let key = ''
1672
1687
  if (payload.type === 'session/subscribed' && payload.sessionId) key = `session:${payload.sessionId}`
1688
+ else if (payload.type === 'session/reasoning' && payload.sessionId) key = `reasoning:${payload.sessionId}`
1673
1689
  else if (payload.type === 'approval/requested' && payload.approvalId) key = `approval:${payload.approvalId}`
1674
1690
  else if (payload.type === 'question/requested' && full.rpcId) key = `question:${full.rpcId}`
1675
1691
  else if (payload.type === 'approval/resolved' && payload.approvalId) replay.delete(`approval:${payload.approvalId}`)
@@ -1940,7 +1956,7 @@ function openModernSessionStream(ws, sessionId) {
1940
1956
  const streamId = 'session:' + sessionId
1941
1957
  ws.send(JSON.stringify({
1942
1958
  type: 'open', streamId, endpoint: 'session/follow',
1943
- payload: { args: { request: { address: { kind: 'session', sessionId } } } },
1959
+ payload: { args: { request: { address: { kind: 'session', sessionId }, assistantStream: true } } },
1944
1960
  }))
1945
1961
  }
1946
1962
 
@@ -1997,8 +2013,59 @@ function applyModernWorkspaceFrame(value) {
1997
2013
  }
1998
2014
  }
1999
2015
 
2016
+ // 0.1.5 streams are process-local and have no durable seq. Keep a bounded,
2017
+ // replaceable reasoning baseline for HTTP history, polling and late WS clients.
2018
+ function modernReasoningValue(sessionId) {
2019
+ const stream = modernState.assistantStreams.get(sessionId)
2020
+ return stream ? { partialReasoning: [...stream.blocks.values()] } : {}
2021
+ }
2022
+
2023
+ function applyModernAssistantStream(sessionId, value, baseline = false) {
2024
+ let stream = modernState.assistantStreams.get(sessionId)
2025
+ const publish = () => legacyPush('mux', { type: 'session/reasoning', sessionId, ...modernReasoningValue(sessionId) })
2026
+ const fold = chunk => {
2027
+ if (!chunk || !Number.isSafeInteger(chunk.index) || chunk.index < 0 || chunk.index >= 64) return false
2028
+ const item = stream.blocks.get(chunk.index) || { turn: stream.turn, step: stream.step, index: chunk.index, text: '' }
2029
+ if (chunk.type === 'reasoning-delta') item.text = (item.text + String(chunk.text || '')).slice(0, 12000)
2030
+ else if (chunk.type === 'block-start' && chunk.blockType === 'reasoning') item.text = ''
2031
+ else if (chunk.type === 'block-end' && chunk.block?.type === 'reasoning') item.text = String(chunk.block.text || '').slice(0, 12000)
2032
+ else return false
2033
+ stream.blocks.set(chunk.index, item)
2034
+ return true
2035
+ }
2036
+ if (baseline || value?.type === 'start') {
2037
+ const attempt = baseline ? value?.activeAttempt : value
2038
+ stream = { revision: value?.revision || 0, attemptId: attempt?.attemptId, turn: attempt?.turn, step: attempt?.step, nextIndex: attempt?.nextIndex || 0, blocks: new Map() }
2039
+ modernState.assistantStreams.set(sessionId, stream)
2040
+ for (const record of attempt?.stream || []) {
2041
+ if (record.type === 'chunk') fold(record.chunk)
2042
+ else if (record.type === 'reasoning-chunks') fold({ type: 'reasoning-delta', index: record.index, text: (record.texts || []).join('') })
2043
+ }
2044
+ publish()
2045
+ return
2046
+ }
2047
+ if (!stream || value?.revision <= stream.revision) return
2048
+ if (value?.revision !== stream.revision + 1 || value.attemptId !== stream.attemptId || value.index !== stream.nextIndex) {
2049
+ stream.blocks.clear()
2050
+ stream.attemptId = undefined
2051
+ stream.revision = value?.revision || stream.revision
2052
+ publish()
2053
+ return
2054
+ }
2055
+ stream.revision = value.revision
2056
+ if (value.type === 'end') {
2057
+ stream.blocks.clear()
2058
+ stream.attemptId = undefined
2059
+ publish()
2060
+ } else if (value.type === 'chunk') {
2061
+ stream.nextIndex++
2062
+ if (fold(value.chunk)) publish()
2063
+ }
2064
+ }
2065
+
2000
2066
  function applyModernSessionFrame(sessionId, value) {
2001
2067
  if (value?.type === 'snapshot') {
2068
+ if (value.assistantStream) applyModernAssistantStream(sessionId, value.assistantStream, true)
2002
2069
  modernState.sessionCursors.set(sessionId, value.cursor)
2003
2070
  legacyPush('mux', { type: 'session/subscribed', sessionId, lastSeq: value.cursor })
2004
2071
  for (const record of value.records || []) {
@@ -2012,6 +2079,8 @@ function applyModernSessionFrame(sessionId, value) {
2012
2079
  if (value?.type === 'event' && value.event) {
2013
2080
  modernState.sessionCursors.set(sessionId, value.event.seq)
2014
2081
  legacyPush('mux', { type: 'session/event', sessionId, event: value.event })
2082
+ } else if (value?.type === 'assistant-stream') {
2083
+ applyModernAssistantStream(sessionId, value.frame)
2015
2084
  }
2016
2085
  }
2017
2086
 
@@ -2074,6 +2143,8 @@ function applyModernRemoteEvent(ws, value) {
2074
2143
  } else if (value.event === 'api-session/removed') {
2075
2144
  modernState.sessions.delete(args[0])
2076
2145
  modernState.sessionCursors.delete(args[0])
2146
+ modernState.assistantStreams.delete(args[0])
2147
+ collectorReplay.mux.delete(`reasoning:${args[0]}`)
2077
2148
  try { ws.send(JSON.stringify({ type: 'cancel', streamId: 'session:' + args[0] })) } catch {}
2078
2149
  legacyPush('host', { type: 'host/session-removed', sessionId: args[0] })
2079
2150
  } else if (value.event === 'api-session/status') {
@@ -2229,6 +2300,7 @@ async function scanStatsOnce(delay) {
2229
2300
  try {
2230
2301
  const out = await statsStore.scanAll()
2231
2302
  if (out.files) console.log(`[stats] 历史回填扫描完成: ${out.processed} 个新事件 (${out.files} 个会话文件)`)
2303
+ if (out.errors?.length) console.warn(`[stats] ${out.errors.length} 个会话回填失败,保留原统计游标: ${out.errors.map(item => item.error).join(', ')}`)
2232
2304
  } catch (err) {
2233
2305
  console.warn('[stats] 历史回填扫描失败: ' + (err?.message || err))
2234
2306
  } finally {
@@ -2696,7 +2768,7 @@ function serveStatic(req, res, url) {
2696
2768
  // ---------- 管理 API ----------
2697
2769
  function upstreamReachable(cb) {
2698
2770
  const req = UPSTREAM_TRANSPORT.request({
2699
- hostname: UPSTREAM.hostname,
2771
+ hostname: UPSTREAM_HOSTNAME,
2700
2772
  port: UPSTREAM_PORT,
2701
2773
  method: 'GET',
2702
2774
  path: '/health',
@@ -3035,6 +3107,7 @@ async function fsResolve(input) {
3035
3107
  else if (path.isAbsolute(raw)) abs = path.resolve(raw)
3036
3108
  else abs = path.resolve(FS_ROOTS[0], raw) // 相对路径按默认根解析
3037
3109
  if (FS_ROOTS.some(root => fsInsideRoot(abs, root))) return { abs }
3110
+ if (FS_WINDOWS_DEFAULT) return { error: 'forbidden' }
3038
3111
  let workspaces = await loadFsWorkspaceRoots(false)
3039
3112
  if (workspaces.roots.some(root => fsInsideRoot(abs, root))) return { abs }
3040
3113
  // 新建/刚加入的工作区可能还没进入 15s 缓存,未命中时强制刷新一次。
@@ -3276,7 +3349,8 @@ function sha256FileHex(file, cb) {
3276
3349
  const activeUploads = new Map()
3277
3350
  const uploadPartDirs = new Set()
3278
3351
  function fsActiveKey(dirReal, name, session) {
3279
- return dirReal + '\n' + name + '\n' + (session || '')
3352
+ const key = fsPartPath(dirReal, name, session)
3353
+ return process.platform === 'win32' ? key.toLowerCase() : key
3280
3354
  }
3281
3355
 
3282
3356
  function rememberUploadDir(dirReal) {
@@ -3284,8 +3358,8 @@ function rememberUploadDir(dirReal) {
3284
3358
  }
3285
3359
 
3286
3360
  function uploadDirHasActive(dirReal) {
3287
- const prefix = dirReal + '\n'
3288
- for (const key of activeUploads.keys()) if (key.startsWith(prefix)) return true
3361
+ const dir = process.platform === 'win32' ? dirReal.toLowerCase() : dirReal
3362
+ for (const key of activeUploads.keys()) if (path.dirname(key) === dir) return true
3289
3363
  return false
3290
3364
  }
3291
3365
 
@@ -3360,24 +3434,49 @@ function fsUploadPipe(res, url, dirLex, dirReal, name) {
3360
3434
  return up ? fsUploadPipeFromTarget(res, up) : null
3361
3435
  }
3362
3436
 
3437
+ /** 同目录提交:覆盖用原子 rename;禁止覆盖用排他 link,绝不先删旧文件。 */
3438
+ function fsCommitUpload(tmp, target, overwrite) {
3439
+ if (overwrite) fs.renameSync(tmp, target)
3440
+ else {
3441
+ fs.linkSync(tmp, target)
3442
+ // 目标已提交,临时名字清理失败不应把成功误报为失败。
3443
+ try { fs.unlinkSync(tmp) } catch {}
3444
+ }
3445
+ }
3446
+
3363
3447
  function fsUploadPipeFromTarget(res, up) {
3364
3448
  let finished = false
3449
+ let ending = false
3450
+ let written = false
3365
3451
  const cleanup = () => {
3366
3452
  if (finished) return
3367
3453
  finished = true
3368
3454
  try { up.stream.destroy() } catch {}
3369
- try { fs.unlinkSync(up.tmp) } catch {}
3455
+ up.stream.once('close', () => { try { fs.unlinkSync(up.tmp) } catch {} })
3370
3456
  }
3371
3457
  up.stream.on('error', () => {
3372
3458
  if (finished) return
3373
3459
  finished = true
3374
- try { fs.unlinkSync(up.tmp) } catch {}
3460
+ up.stream.once('close', () => { try { fs.unlinkSync(up.tmp) } catch {} })
3375
3461
  if (!res.headersSent) fsJson(res, 500, { error: 'write-failed' })
3376
3462
  else try { res.destroy() } catch {}
3377
3463
  })
3464
+ up.stream.once('finish', () => { written = true })
3465
+ up.stream.once('close', () => {
3466
+ if (finished || !written) return
3467
+ finished = true
3468
+ try {
3469
+ fsCommitUpload(up.tmp, up.target, up.overwrite)
3470
+ } catch (err) {
3471
+ try { fs.unlinkSync(up.tmp) } catch {}
3472
+ if (!res.headersSent) fsJson(res, err.code === 'EEXIST' ? 409 : 403, { error: err.code === 'EEXIST' ? 'conflict' : 'write-failed', detail: err.message })
3473
+ return
3474
+ }
3475
+ fsJson(res, 201, { ok: true, path: up.displayPath, name: up.name, size: up.bytes })
3476
+ })
3378
3477
  return {
3379
3478
  write(chunk) {
3380
- if (finished) return
3479
+ if (finished || ending) return
3381
3480
  up.bytes += chunk.length
3382
3481
  if (up.bytes > FS_MAX_UPLOAD) {
3383
3482
  cleanup()
@@ -3388,19 +3487,9 @@ function fsUploadPipeFromTarget(res, up) {
3388
3487
  up.stream.write(chunk)
3389
3488
  },
3390
3489
  end() {
3391
- if (finished) return
3392
- finished = true
3393
- up.stream.end(() => {
3394
- try {
3395
- if (up.overwrite) fs.rmSync(up.target, { force: true })
3396
- fs.renameSync(up.tmp, up.target)
3397
- } catch (err) {
3398
- try { fs.unlinkSync(up.tmp) } catch {}
3399
- if (!res.headersSent) return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
3400
- return
3401
- }
3402
- fsJson(res, 201, { ok: true, path: up.displayPath, name: up.name, size: up.bytes })
3403
- })
3490
+ if (finished || ending) return
3491
+ ending = true
3492
+ up.stream.end()
3404
3493
  },
3405
3494
  abort(status, msg) {
3406
3495
  cleanup()
@@ -3588,6 +3677,8 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
3588
3677
  if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
3589
3678
  const session = url.searchParams.get('session') || ''
3590
3679
  if (!session) return fsJson(res, 400, { error: 'missing-session', detail: '断点续传需要 session 参数' })
3680
+ const activeKey = fsActiveKey(dirReal, name, session)
3681
+ if (activeUploads.has(activeKey)) return fsJson(res, 409, { error: 'upload-busy' })
3591
3682
  const queryOffsetRaw = url.searchParams.get('offset')
3592
3683
  const headerOffsetRaw = req.headers['upload-offset']
3593
3684
  const offsetRaw = queryOffsetRaw ?? headerOffsetRaw
@@ -3648,21 +3739,37 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
3648
3739
  } catch (err) {
3649
3740
  return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
3650
3741
  }
3651
- const activeKey = fsActiveKey(dirReal, name, session)
3652
3742
  activeUploads.set(activeKey, stream)
3653
3743
 
3654
3744
  let bytes = 0
3655
3745
  let finished = false
3746
+ let written = false
3747
+ let cancelled = false
3748
+ const release = () => {
3749
+ if (activeUploads.get(activeKey) === stream) activeUploads.delete(activeKey)
3750
+ }
3751
+ res.once('finish', () => { finished = true; if (!cancelled) release() })
3656
3752
  const abort = (status, msg, extra = {}) => {
3657
3753
  if (finished) return
3658
3754
  finished = true
3659
- activeUploads.delete(activeKey)
3755
+ cancelled = true
3756
+ const cleanup = () => {
3757
+ if (status === 413 || status === 500 || msg === 'cancelled') { try { fs.unlinkSync(part) } catch {} }
3758
+ release()
3759
+ }
3760
+ if (stream.closed) cleanup()
3761
+ else stream.once('close', cleanup)
3660
3762
  try { stream.destroy() } catch {}
3661
3763
  // 网络中断时保留分片, 客户端 probe 后续传; 只有超限/写失败才删
3662
- if (status === 413 || status === 500) { try { fs.unlinkSync(part) } catch {} }
3663
3764
  if (!res.headersSent) fsJson(res, status, { error: msg, ...extra })
3664
3765
  else try { res.destroy() } catch {}
3665
3766
  }
3767
+ stream.cancelUpload = () => {
3768
+ const closed = stream.closed ? Promise.resolve() : new Promise(resolve => stream.once('close', resolve))
3769
+ abort(409, 'cancelled')
3770
+ return closed
3771
+ }
3772
+ res.once('close', () => { if (!finished) abort(400, 'client-aborted') })
3666
3773
 
3667
3774
  stream.on('error', (err) => {
3668
3775
  abort(500, err.code === 'ENOENT' ? 'part-missing' : 'write-failed', { detail: err.message })
@@ -3680,9 +3787,11 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
3680
3787
  })
3681
3788
  req.on('end', () => {
3682
3789
  if (finished) return
3683
- finished = true
3684
- stream.end(() => {
3685
- activeUploads.delete(activeKey)
3790
+ stream.end()
3791
+ })
3792
+ stream.once('finish', () => { written = true })
3793
+ stream.once('close', () => {
3794
+ if (finished || !written) return
3686
3795
  const total = offset + bytes
3687
3796
  try {
3688
3797
  const st = fs.statSync(part)
@@ -3700,21 +3809,22 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
3700
3809
  return
3701
3810
  }
3702
3811
  const commit = (actualSha256) => {
3812
+ if (cancelled) return
3703
3813
  try {
3704
3814
  const ts = fsTargetState(target)
3705
3815
  if (ts.status) return fsJson(res, ts.status, { error: ts.error, detail: ts.detail })
3706
3816
  if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
3707
- if (ts.exists) fs.rmSync(target, { force: true })
3708
- fs.renameSync(part, target)
3817
+ fsCommitUpload(part, target, overwrite)
3709
3818
  fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session, uploadLength, ...(actualSha256 ? { sha256: actualSha256 } : {}) }, fsUploadHeaders(total, uploadLength))
3710
3819
  } catch (err) {
3711
- if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
3820
+ if (!res.headersSent) fsJson(res, err.code === 'EEXIST' ? 409 : 403, { error: err.code === 'EEXIST' ? 'conflict' : 'write-failed', detail: err.message })
3712
3821
  else try { res.destroy() } catch {}
3713
3822
  }
3714
3823
  }
3715
3824
  if (sha256Expected) {
3716
3825
  // 落盘前校验: 不匹配保留分片并返回 422, 客户端可重传或取消
3717
3826
  sha256FileHex(part, (err, actual) => {
3827
+ if (cancelled) return
3718
3828
  if (err) return fsJson(res, 403, { error: 'checksum-failed', detail: err.message })
3719
3829
  if (actual !== sha256Expected) {
3720
3830
  return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
@@ -3728,7 +3838,6 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
3728
3838
  if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
3729
3839
  else try { res.destroy() } catch {}
3730
3840
  }
3731
- })
3732
3841
  })
3733
3842
  }
3734
3843
 
@@ -3757,17 +3866,15 @@ async function fsUploadControl(req, res, url) {
3757
3866
  const part = fsPartPath(checked.abs, name, session)
3758
3867
  const active = activeUploads.get(fsActiveKey(checked.abs, name, session))
3759
3868
  if (active) {
3760
- try { active.destroy() } catch {}
3761
- activeUploads.delete(fsActiveKey(checked.abs, name, session))
3869
+ await active.cancelUpload()
3870
+ return fsJson(res, 200, { ok: true, cancelled: true, session })
3762
3871
  }
3763
- // 等写流关闭后再删, 防止 write 把分片重新创建出来
3764
- setTimeout(() => {
3872
+ // 无活动写流时同步删除,不留下可被新上传插入的延时窗口。
3765
3873
  let removed = false
3766
3874
  try { fs.unlinkSync(part); removed = true } catch (err) {
3767
3875
  if (err.code !== 'ENOENT') return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
3768
3876
  }
3769
3877
  fsJson(res, 200, { ok: true, cancelled: true, removed, session })
3770
- }, 80)
3771
3878
  }
3772
3879
 
3773
3880
  async function serveFs(req, res, url) {
@@ -4093,7 +4200,7 @@ async function proxyLegacyApi(req, res, url) {
4093
4200
 
4094
4201
  let responseDone = false
4095
4202
  const upstreamReq = UPSTREAM_TRANSPORT.request({
4096
- hostname: UPSTREAM.hostname,
4203
+ hostname: UPSTREAM_HOSTNAME,
4097
4204
  port: UPSTREAM_PORT,
4098
4205
  method: req.method,
4099
4206
  path: url.pathname + url.search,
@@ -4481,7 +4588,7 @@ server.on('upgrade', (req, socket, head) => {
4481
4588
  handshakeTimer = null
4482
4589
  }
4483
4590
  const upstreamReq = UPSTREAM_TRANSPORT.request({
4484
- hostname: UPSTREAM.hostname,
4591
+ hostname: UPSTREAM_HOSTNAME,
4485
4592
  port: UPSTREAM_PORT,
4486
4593
  method: req.method,
4487
4594
  path: url.pathname + url.search,