@lyhue1991/dsh-soup 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +2117 -0
- package/lib/index.js +725 -0
- package/package.json +58 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-soup — 宿主半区:资源管理器的文件能力供给器。
|
|
3
|
+
*
|
|
4
|
+
* 通过 `webServer` 注册一个同源 HTTP 路由 `/api/dsh-soup`(POST JSON),
|
|
5
|
+
* 为浏览器半区提供对文件系统的只具名操作:列目录、系统打开、移到废纸篓、
|
|
6
|
+
* 移动/重命名、新建、上传。永久插件(profile bundle)不经过动态 runner,
|
|
7
|
+
* 因此不依赖 dynamic 半区的 `harness.handle`/`host.call`,而是走
|
|
8
|
+
* 宿主 HTTP 路由 + 浏览器 `fetch` 的规范桥梁。
|
|
9
|
+
*
|
|
10
|
+
* 跨平台:move/create/upload 直接用 `node:fs/promises`(mac/linux/win 通用),
|
|
11
|
+
* 仅 open/trash 这类"唤起系统"的动作按 `process.platform` 分支选命令。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { rename, mkdir, writeFile, appendFile, open as openFile, stat, realpath } from 'node:fs/promises'
|
|
15
|
+
import { createReadStream } from 'node:fs'
|
|
16
|
+
import { randomBytes } from 'node:crypto'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
|
|
19
|
+
/** 稳定插件名(与 cordis.patch.yml 的 insert id 一致)。 */
|
|
20
|
+
export const name = 'ui-dsh-soup'
|
|
21
|
+
|
|
22
|
+
/** 注入的宿主服务。 */
|
|
23
|
+
export const inject = ['webServer', 'fs', 'subprocess', 'sandboxPolicy', 'sessions', 'timer']
|
|
24
|
+
|
|
25
|
+
/** 当前运行平台:'mac' | 'linux' | 'win'(其余回退 mac 逻辑)。 */
|
|
26
|
+
const PLATFORM = process.platform === 'win32' ? 'win'
|
|
27
|
+
: process.platform === 'darwin' ? 'mac'
|
|
28
|
+
: process.platform === 'linux' ? 'linux' : 'mac'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 读取一次请求体为 JSON 对象(损坏或不存在的 body 返回空对象)。
|
|
32
|
+
* @param req - node:http IncomingMessage。
|
|
33
|
+
*/
|
|
34
|
+
function readBody(req) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const chunks = []
|
|
37
|
+
req.on('data', (chunk) => chunks.push(chunk))
|
|
38
|
+
req.on('end', () => {
|
|
39
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
40
|
+
if (!text) { resolve({}); return }
|
|
41
|
+
try { resolve(JSON.parse(text)) } catch { resolve({}) }
|
|
42
|
+
})
|
|
43
|
+
req.on('error', reject)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 写一个 JSON 响应。 */
|
|
48
|
+
function writeJson(res, code, data) {
|
|
49
|
+
const body = JSON.stringify(data)
|
|
50
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
|
|
51
|
+
res.end(body)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 应用宿主半区:注册文件操作路由。
|
|
56
|
+
* @param ctx - cordis 宿主上下文。
|
|
57
|
+
*/
|
|
58
|
+
export function apply(ctx) {
|
|
59
|
+
const fs = ctx.fs
|
|
60
|
+
const subprocess = ctx.subprocess
|
|
61
|
+
const sessions = ctx.sessions
|
|
62
|
+
const root = (ctx.sandboxPolicy && ctx.sandboxPolicy.workspaceRoot) || '/'
|
|
63
|
+
|
|
64
|
+
// ------------------------------------------------------------------
|
|
65
|
+
// 路径围栏:除 list 走沙箱 fs 外,其余具名文件操作(read/move/
|
|
66
|
+
// trash/open/create/upload)一律先过 confine(),只允许落在
|
|
67
|
+
// 「workspace 根 ∪ 所有已知会话 cwd」的子树内。解析走 realpath,
|
|
68
|
+
// 防符号链接逃逸;容忍目标末级尚不存在(新建/上传/移动目标)。
|
|
69
|
+
// 已知残留:confine 与实际操作之间存在 TOCTOU 窗口(符号链接竞态),
|
|
70
|
+
// Node 可移植 API 下无法根除,见 README 安全模型。
|
|
71
|
+
// ------------------------------------------------------------------
|
|
72
|
+
/** win 文件系统大小写不敏感,比较时折叠大小写。 */
|
|
73
|
+
const FOLD_CASE = PLATFORM === 'win'
|
|
74
|
+
|
|
75
|
+
/** 带 HTTP 状态码的错误对象(handler 据此写响应码)。 */
|
|
76
|
+
function httpError(statusCode, message) {
|
|
77
|
+
return Object.assign(new Error(message), { statusCode })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 容忍末级不存在的 realpath:向上找最近一个已存在的祖先再拼回剩余段。 */
|
|
81
|
+
async function realPathLenient(p) {
|
|
82
|
+
let cur = path.resolve(String(p))
|
|
83
|
+
const tail = []
|
|
84
|
+
for (;;) {
|
|
85
|
+
try {
|
|
86
|
+
const rp = await realpath(cur)
|
|
87
|
+
return tail.length ? path.join(rp, ...tail) : rp
|
|
88
|
+
} catch (err) {
|
|
89
|
+
const parent = path.dirname(cur)
|
|
90
|
+
if (!err || err.code !== 'ENOENT' || parent === cur) throw err
|
|
91
|
+
tail.unshift(path.basename(cur))
|
|
92
|
+
cur = parent
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let rootRealCache = null
|
|
98
|
+
async function rootReal() {
|
|
99
|
+
if (rootRealCache === null) {
|
|
100
|
+
try { rootRealCache = await realpath(root) } catch { rootRealCache = path.resolve(root) }
|
|
101
|
+
}
|
|
102
|
+
return rootRealCache
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** candidate 是否位于 base 子树内(含 base 本身)。 */
|
|
106
|
+
function within(base, candidate) {
|
|
107
|
+
const b = FOLD_CASE ? base.toLowerCase() : base
|
|
108
|
+
const c = FOLD_CASE ? candidate.toLowerCase() : candidate
|
|
109
|
+
if (c === b) return true
|
|
110
|
+
const rel = path.relative(b, c)
|
|
111
|
+
return rel !== '' && rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 允许的基目录集合:workspace 根 + 所有已知会话的 cwd(尽力而为)。 */
|
|
115
|
+
async function allowedBases(preferredSessionId) {
|
|
116
|
+
const bases = [await rootReal()]
|
|
117
|
+
const pushCwd = async (cwd) => {
|
|
118
|
+
if (typeof cwd === 'string' && cwd) {
|
|
119
|
+
try { bases.push(await realPathLenient(cwd)) } catch { /* 会话目录不可达则跳过 */ }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// 指定会话优先直查:新会话刚创建时 list 枚举可能尚未纳入其 cwd。
|
|
123
|
+
if (preferredSessionId && typeof sessions.get === 'function') {
|
|
124
|
+
try {
|
|
125
|
+
const pref = sessions.get(preferredSessionId)
|
|
126
|
+
await pushCwd(pref && pref.header && pref.header.cwd)
|
|
127
|
+
} catch { /* ignore */ }
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const list = typeof sessions.list === 'function' ? sessions.list() : []
|
|
131
|
+
for (const s of list) await pushCwd(s && s.header && s.header.cwd)
|
|
132
|
+
} catch { /* 宿主无 list API 时仅用 workspace 根 */ }
|
|
133
|
+
return bases
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 围栏校验:解析真实路径并确认落在允许基目录集合内。
|
|
138
|
+
* 通过则返回规范化真实路径;越界/不可达抛带 statusCode 的错误。
|
|
139
|
+
*/
|
|
140
|
+
async function confine(rawPath, label, preferredSessionId) {
|
|
141
|
+
const name = label || '路径'
|
|
142
|
+
const p = String(rawPath || '')
|
|
143
|
+
if (!p) throw httpError(400, `缺少${name}`)
|
|
144
|
+
let rp
|
|
145
|
+
try {
|
|
146
|
+
rp = await realPathLenient(p)
|
|
147
|
+
} catch (err) {
|
|
148
|
+
throw httpError(400, `${name}不可达: ${p}`)
|
|
149
|
+
}
|
|
150
|
+
for (const base of await allowedBases(preferredSessionId)) {
|
|
151
|
+
if (within(base, rp)) return rp
|
|
152
|
+
}
|
|
153
|
+
throw httpError(403, `${name}超出允许范围(仅限工作区与会话目录): ${p}`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ------------------------------------------------------------------
|
|
157
|
+
// 速度徽标:包 llm/stream 统计 token 吞吐(等待/流式/done 三态)
|
|
158
|
+
// ------------------------------------------------------------------
|
|
159
|
+
const CJK = /[\u3000-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/
|
|
160
|
+
function estimateChars(text) {
|
|
161
|
+
let cjk = 0
|
|
162
|
+
let rest = 0
|
|
163
|
+
for (const ch of text) { if (CJK.test(ch)) cjk++; else rest++ }
|
|
164
|
+
return cjk + rest / 4
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 会话级流状态:phase / token / tps / ttft。 */
|
|
168
|
+
const streams = new Map()
|
|
169
|
+
|
|
170
|
+
function extractKey(options) {
|
|
171
|
+
try {
|
|
172
|
+
if (!options) return null
|
|
173
|
+
if (typeof options.sessionId === 'string') return options.sessionId
|
|
174
|
+
if (options.session && typeof options.session.id === 'string') return options.session.id
|
|
175
|
+
if (options.agent) {
|
|
176
|
+
const a = options.agent
|
|
177
|
+
if (typeof a.sessionId === 'string') return a.sessionId
|
|
178
|
+
if (typeof a.id === 'string') return a.id
|
|
179
|
+
if (a.session && typeof a.session.id === 'string') return a.session.id
|
|
180
|
+
}
|
|
181
|
+
if (options.meta && typeof options.meta.sessionId === 'string') return options.meta.sessionId
|
|
182
|
+
} catch (err) { /* ignore */ }
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function extractChunk(chunk) {
|
|
187
|
+
let text = ''
|
|
188
|
+
let realTokens = null
|
|
189
|
+
try {
|
|
190
|
+
if (!chunk) return { text, realTokens }
|
|
191
|
+
if (typeof chunk.text === 'string') text += chunk.text
|
|
192
|
+
if (typeof chunk.content === 'string') text += chunk.content
|
|
193
|
+
if (Array.isArray(chunk.content)) {
|
|
194
|
+
for (const part of chunk.content) if (part && typeof part.text === 'string') text += part.text
|
|
195
|
+
}
|
|
196
|
+
if (chunk.delta) {
|
|
197
|
+
const d = chunk.delta
|
|
198
|
+
if (typeof d.text === 'string') text += d.text
|
|
199
|
+
if (typeof d.content === 'string') text += d.content
|
|
200
|
+
if (d.usage) {
|
|
201
|
+
const ot = d.usage.outputTokens != null ? d.usage.outputTokens
|
|
202
|
+
: (d.usage.output_tokens != null ? d.usage.output_tokens
|
|
203
|
+
: (d.usage.completion_tokens != null ? d.usage.completion_tokens : d.usage.completionTokens))
|
|
204
|
+
if (typeof ot === 'number') realTokens = ot
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (chunk.usage) {
|
|
208
|
+
const u = chunk.usage
|
|
209
|
+
const ot = u.outputTokens != null ? u.outputTokens
|
|
210
|
+
: (u.output_tokens != null ? u.output_tokens
|
|
211
|
+
: (u.completion_tokens != null ? u.completion_tokens : u.completionTokens))
|
|
212
|
+
if (typeof ot === 'number') realTokens = ot
|
|
213
|
+
}
|
|
214
|
+
if (Array.isArray(chunk.choices)) {
|
|
215
|
+
for (const c of chunk.choices) {
|
|
216
|
+
const d = c && c.delta
|
|
217
|
+
if (d && typeof d.content === 'string') text += d.content
|
|
218
|
+
if (d && d.usage) {
|
|
219
|
+
const ot = d.usage.outputTokens != null ? d.usage.outputTokens : d.usage.output_tokens
|
|
220
|
+
if (typeof ot === 'number') realTokens = ot
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} catch (err) { /* ignore */ }
|
|
225
|
+
return { text, realTokens }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
ctx.on('llm/stream', (options, next) => {
|
|
229
|
+
const key = extractKey(options) || '_'
|
|
230
|
+
const now = Date.now()
|
|
231
|
+
streams.set(key, { phase: 'waiting', startedAt: now, firstChunkAt: null, charTokens: 0, realTokens: 0, hasReal: false, lastChunkAt: null, lastContentAt: null, samples: [], lastSeen: now })
|
|
232
|
+
const innerP = Promise.resolve(next())
|
|
233
|
+
return (async function* () {
|
|
234
|
+
try {
|
|
235
|
+
const inner = await innerP
|
|
236
|
+
for await (const chunk of inner) {
|
|
237
|
+
const st = streams.get(key)
|
|
238
|
+
if (st) {
|
|
239
|
+
const parsed = extractChunk(chunk)
|
|
240
|
+
const t = Date.now()
|
|
241
|
+
if (st.firstChunkAt === null) { st.firstChunkAt = t; st.phase = 'streaming' }
|
|
242
|
+
if (parsed.text) st.charTokens += estimateChars(parsed.text)
|
|
243
|
+
if (parsed.realTokens != null) { st.realTokens = parsed.realTokens; st.hasReal = true }
|
|
244
|
+
st.lastChunkAt = t
|
|
245
|
+
// 只有携带真实内容的 chunk 才算「有进展」:keepalive / 空 delta
|
|
246
|
+
// 不刷新内容时钟,避免等待期被空包滴漏伪装成慢速流式。
|
|
247
|
+
if ((parsed.text && parsed.text.length > 0) || parsed.realTokens != null) st.lastContentAt = t
|
|
248
|
+
// 滑动窗口采样:[时刻, 累计 token],用于算瞬时速率而非全程平均
|
|
249
|
+
st.samples.push([t, st.hasReal ? st.realTokens : st.charTokens])
|
|
250
|
+
if (st.samples.length > 128) st.samples.splice(0, st.samples.length - 128)
|
|
251
|
+
st.lastSeen = t
|
|
252
|
+
}
|
|
253
|
+
yield chunk
|
|
254
|
+
}
|
|
255
|
+
} finally {
|
|
256
|
+
const st = streams.get(key)
|
|
257
|
+
if (st) {
|
|
258
|
+
st.phase = 'done'
|
|
259
|
+
st.lastSeen = Date.now()
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
})()
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
const stopReap = ctx.timer.interval(() => {
|
|
266
|
+
const now = Date.now()
|
|
267
|
+
for (const [k, st] of streams) {
|
|
268
|
+
if (st.phase === 'done' && now - st.lastSeen > 4000) streams.delete(k)
|
|
269
|
+
}
|
|
270
|
+
}, 1000)
|
|
271
|
+
ctx.effect(stopReap, 'dsh-soup: speed stream reap')
|
|
272
|
+
|
|
273
|
+
/** 瞬时速率窗口 / 最小统计跨度 / 停顿判定阈值。 */
|
|
274
|
+
const RATE_WINDOW_MS = 2000
|
|
275
|
+
const RATE_MIN_SPAN_MS = 500
|
|
276
|
+
const STALL_MS = 2000
|
|
277
|
+
/** 真实生成下限:低于此速率必然是等待被误标(DeepSeek 正常输出远高于此)。 */
|
|
278
|
+
const MIN_REAL_TPS = 3
|
|
279
|
+
/** 速率分母宽限:内容停止后分母最多延伸这么久,静默期不稀释速率。 */
|
|
280
|
+
const SPAN_GRACE_MS = 500
|
|
281
|
+
|
|
282
|
+
/** 返回当前会话的速度状态(无记录时回退到最近活跃流)。 */
|
|
283
|
+
function speedStatus(sid) {
|
|
284
|
+
let st = streams.get(sid)
|
|
285
|
+
if (!st) {
|
|
286
|
+
let latest = null
|
|
287
|
+
for (const s of streams.values()) if (!latest || s.lastSeen > latest.lastSeen) latest = s
|
|
288
|
+
st = latest
|
|
289
|
+
}
|
|
290
|
+
if (!st) return { phase: 'idle' }
|
|
291
|
+
const now = Date.now()
|
|
292
|
+
const tokens = st.hasReal ? st.realTokens : Math.round(st.charTokens)
|
|
293
|
+
const ttft = st.firstChunkAt ? st.firstChunkAt - st.startedAt : null
|
|
294
|
+
if (st.phase !== 'streaming' || !st.firstChunkAt) {
|
|
295
|
+
return { phase: st.phase, tokens, tps: 0, ttft }
|
|
296
|
+
}
|
|
297
|
+
// 停顿按「内容时钟」判定:keepalive / 空 delta 滴漏不算活动。
|
|
298
|
+
const contentAt = st.lastContentAt || st.firstChunkAt
|
|
299
|
+
const sinceContent = now - contentAt
|
|
300
|
+
if (sinceContent > STALL_MS) {
|
|
301
|
+
return { phase: 'waiting', tokens, tps: 0, ttft }
|
|
302
|
+
}
|
|
303
|
+
// 滑动窗口瞬时速率:基线优先取窗口前最后一个样本(burst 场景也能算出增量),
|
|
304
|
+
// 否则取窗口内第一个样本。
|
|
305
|
+
const cutoff = now - RATE_WINDOW_MS
|
|
306
|
+
let base = null
|
|
307
|
+
let first = null
|
|
308
|
+
for (const s of st.samples) {
|
|
309
|
+
if (first === null) first = s
|
|
310
|
+
if (s[0] < cutoff) base = s
|
|
311
|
+
}
|
|
312
|
+
if (base === null) base = first
|
|
313
|
+
let tps = 0
|
|
314
|
+
if (base && now - base[0] >= RATE_MIN_SPAN_MS) {
|
|
315
|
+
// 分母封顶到最后内容时刻 + 宽限:静默期不再把速率摊薄成 0.x t/s。
|
|
316
|
+
const spanEnd = Math.min(now, contentAt + SPAN_GRACE_MS)
|
|
317
|
+
if (spanEnd - base[0] >= RATE_MIN_SPAN_MS) {
|
|
318
|
+
tps = Math.max(0, (tokens - base[1]) / ((spanEnd - base[0]) / 1000))
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// 近零进展护栏:内容已停顿超过最小跨度时,任何低于真实生成下限的读数
|
|
322
|
+
// (含 0 与负增量截断)都判回等待——0.3 t/s 必然是等待,不是极慢生成。
|
|
323
|
+
if (sinceContent >= RATE_MIN_SPAN_MS && tps < MIN_REAL_TPS) {
|
|
324
|
+
return { phase: 'waiting', tokens, tps: 0, ttft }
|
|
325
|
+
}
|
|
326
|
+
return { phase: 'streaming', tokens, tps, ttft }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function runCmd(argv) {
|
|
330
|
+
try {
|
|
331
|
+
const proc = subprocess.spawn({
|
|
332
|
+
argv,
|
|
333
|
+
cwd: '/',
|
|
334
|
+
stdio: { stdin: 'ignore', stdout: 'ignore', stderr: { maxBytes: 4096 } },
|
|
335
|
+
graceMs: 6000,
|
|
336
|
+
})
|
|
337
|
+
const outcome = await proc.done
|
|
338
|
+
return { code: outcome.exitCode }
|
|
339
|
+
} catch (err) {
|
|
340
|
+
return { error: String((err && err.message) || err) }
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** 拼接目录与子项(统一正斜杠,兼容各平台)。 */
|
|
345
|
+
function joinPath(dir, name) {
|
|
346
|
+
return String(dir || '').replace(/[\\/]+$/, '') + '/' + name
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 系统打开一个路径(按平台选命令)。 */
|
|
350
|
+
async function openPath(path) { const p = String(path || '')
|
|
351
|
+
if (!p) return { code: -1, error: '缺少路径' }
|
|
352
|
+
if (PLATFORM === 'win') {
|
|
353
|
+
// explorer /select 打开所在位置;单文件用 start 调默认程序
|
|
354
|
+
return runCmd(['cmd', '/c', 'start', '', p])
|
|
355
|
+
}
|
|
356
|
+
if (PLATFORM === 'linux') {
|
|
357
|
+
return runCmd(['xdg-open', p])
|
|
358
|
+
}
|
|
359
|
+
return runCmd(['/usr/bin/open', p])
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** 移到废纸篓(按平台选实现)。 */
|
|
363
|
+
async function trashPath(path) {
|
|
364
|
+
const p = String(path || '')
|
|
365
|
+
if (!p) return { ok: false, error: '缺少路径' }
|
|
366
|
+
if (PLATFORM === 'win') {
|
|
367
|
+
// PowerShell Shell.Application 的 NameSpace(10) = 回收站
|
|
368
|
+
const esc = String(p).replace(/'/g, "''")
|
|
369
|
+
const script = `$sh = New-Object -ComObject Shell.Application; $sh.NameSpace(10).MoveHere('${esc}'); Start-Sleep -Milliseconds 300`
|
|
370
|
+
const res = await runCmd(['powershell', '-NoProfile', '-Command', script])
|
|
371
|
+
return res.code === 0
|
|
372
|
+
? { ok: true }
|
|
373
|
+
: { ok: false, error: res.error || '移动失败', hint: '请确认 PowerShell 可用(Windows 10+ 自带)' }
|
|
374
|
+
}
|
|
375
|
+
if (PLATFORM === 'linux') {
|
|
376
|
+
// 优先 gio trash(GNOME 自带),回退 trash-cli 的 trash 命令
|
|
377
|
+
const res = await runCmd(['gio', 'trash', p])
|
|
378
|
+
if (res.code === 0) return { ok: true }
|
|
379
|
+
const res2 = await runCmd(['trash', p])
|
|
380
|
+
if (res2.code === 0) return { ok: true }
|
|
381
|
+
return { ok: false, error: (res.error || res2.error) || '移动失败', hint: '请安装 gio(glib2)或 trash-cli(如: sudo apt install trash-cli)' }
|
|
382
|
+
}
|
|
383
|
+
// mac: Finder osascript
|
|
384
|
+
const esc = String(p).replace(/"/g, '\\"')
|
|
385
|
+
const script = `tell application "Finder" to delete POSIX file "${esc}"`
|
|
386
|
+
const res = await runCmd(['/usr/bin/osascript', '-e', script])
|
|
387
|
+
return res.code === 0
|
|
388
|
+
? { ok: true }
|
|
389
|
+
: { ok: false, error: res.error || '移动失败', hint: '如需移到废纸篓,请在 系统设置→隐私与安全性→自动化 中允许 DSH 控制 Finder' }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ------------------------------------------------------------------
|
|
393
|
+
// 文件只读预览:read(文本/图片/二进制三态);无 write——预览-only 不提供保存
|
|
394
|
+
// ------------------------------------------------------------------
|
|
395
|
+
/** 文本读取上限(超过则截断标记 truncated)。 */
|
|
396
|
+
const TEXT_MAX_BYTES = 2 * 1024 * 1024
|
|
397
|
+
/** 图片内联上限。 */
|
|
398
|
+
const IMAGE_MAX_BYTES = 8 * 1024 * 1024
|
|
399
|
+
/** PDF 内联上限。 */
|
|
400
|
+
const PDF_MAX_BYTES = 20 * 1024 * 1024
|
|
401
|
+
/** Notebook(.ipynb)文本上限——含图片输出的 base64,体积膨胀快,宽于普通文本。 */
|
|
402
|
+
const NB_MAX_BYTES = 20 * 1024 * 1024
|
|
403
|
+
/** 二进制嗅探窗口:头部出现 NUL 即判二进制。 */
|
|
404
|
+
const BINARY_SNIFF = 8192
|
|
405
|
+
/** 图片扩展名 → MIME。 */
|
|
406
|
+
const IMAGE_MIME = {
|
|
407
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
408
|
+
webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon',
|
|
409
|
+
}
|
|
410
|
+
function extOf(p) {
|
|
411
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(String(p || ''))
|
|
412
|
+
return m ? m[1].toLowerCase() : ''
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 读取文件供 tab 展示:图片返回 base64,文本返回内容(可截断),
|
|
417
|
+
* 二进制只返回元信息。统一走 node:fs/promises(跨平台、无 shell 注入面)。
|
|
418
|
+
*/
|
|
419
|
+
async function readFileForView(path) {
|
|
420
|
+
const p = String(path || '')
|
|
421
|
+
if (!p) return { ok: false, error: '缺少路径' }
|
|
422
|
+
let st
|
|
423
|
+
try {
|
|
424
|
+
st = await stat(p)
|
|
425
|
+
} catch (err) {
|
|
426
|
+
return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
|
|
427
|
+
}
|
|
428
|
+
if (st.isDirectory()) return { ok: false, error: '目标是目录' }
|
|
429
|
+
const ext = extOf(p)
|
|
430
|
+
if (IMAGE_MIME[ext]) {
|
|
431
|
+
if (st.size > IMAGE_MAX_BYTES) return { ok: true, kind: 'image-too-large', size: st.size, limit: IMAGE_MAX_BYTES }
|
|
432
|
+
try {
|
|
433
|
+
const fh = await openFile(p, 'r')
|
|
434
|
+
try {
|
|
435
|
+
const buf = Buffer.alloc(st.size)
|
|
436
|
+
await fh.read(buf, 0, st.size, 0)
|
|
437
|
+
return { ok: true, kind: 'image', mime: IMAGE_MIME[ext], data: buf.toString('base64'), size: st.size }
|
|
438
|
+
} finally {
|
|
439
|
+
await fh.close()
|
|
440
|
+
}
|
|
441
|
+
} catch (err) {
|
|
442
|
+
return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// PDF:整文件 base64 返回,交给浏览器原生查看器渲染(JupyterLab
|
|
446
|
+
// pdf-extension 同思路:b64 → Blob → object 嵌入)。超限返回元信息。
|
|
447
|
+
if (ext === 'pdf') {
|
|
448
|
+
if (st.size > PDF_MAX_BYTES) return { ok: true, kind: 'pdf-too-large', size: st.size, limit: PDF_MAX_BYTES }
|
|
449
|
+
try {
|
|
450
|
+
const fh = await openFile(p, 'r')
|
|
451
|
+
try {
|
|
452
|
+
const buf = Buffer.alloc(st.size)
|
|
453
|
+
await fh.read(buf, 0, st.size, 0)
|
|
454
|
+
return { ok: true, kind: 'pdf', data: buf.toString('base64'), size: st.size }
|
|
455
|
+
} finally {
|
|
456
|
+
await fh.close()
|
|
457
|
+
}
|
|
458
|
+
} catch (err) {
|
|
459
|
+
return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// .ipynb 含图片输出的 base64,体积膨胀快——单独放宽文本上限
|
|
463
|
+
const textCap = ext === 'ipynb' ? NB_MAX_BYTES : TEXT_MAX_BYTES
|
|
464
|
+
const want = Math.min(st.size, textCap + 1)
|
|
465
|
+
try {
|
|
466
|
+
const fh = await openFile(p, 'r')
|
|
467
|
+
let bytesRead
|
|
468
|
+
let buf
|
|
469
|
+
try {
|
|
470
|
+
buf = Buffer.alloc(want)
|
|
471
|
+
const r = await fh.read(buf, 0, want, 0)
|
|
472
|
+
bytesRead = r.bytesRead
|
|
473
|
+
} finally {
|
|
474
|
+
await fh.close()
|
|
475
|
+
}
|
|
476
|
+
const sniffEnd = Math.min(bytesRead, BINARY_SNIFF)
|
|
477
|
+
for (let i = 0; i < sniffEnd; i++) {
|
|
478
|
+
if (buf[i] === 0) return { ok: true, kind: 'binary', size: st.size }
|
|
479
|
+
}
|
|
480
|
+
const truncated = st.size > textCap
|
|
481
|
+
return { ok: true, kind: 'text', content: buf.toString('utf8', 0, Math.min(bytesRead, textCap)), size: st.size, truncated }
|
|
482
|
+
} catch (err) {
|
|
483
|
+
return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ------------------------------------------------------------------
|
|
488
|
+
// 下载票据:POST 换取一次性短时效 URL,/dl 流式路由凭票发送。
|
|
489
|
+
// 票据不可伪造 → GET 通道无需鉴权头也能保持与 POST 同级的门禁强度。
|
|
490
|
+
// ------------------------------------------------------------------
|
|
491
|
+
const downloadTickets = new Map()
|
|
492
|
+
ctx.effect(
|
|
493
|
+
() => ctx.timer.interval(() => {
|
|
494
|
+
const now = Date.now()
|
|
495
|
+
for (const [k, v] of downloadTickets) if (v.exp < now) downloadTickets.delete(k)
|
|
496
|
+
}, 30000),
|
|
497
|
+
'dsh-soup: download ticket sweep',
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
/** RFC 5987/6266 下载头:ASCII 兜底 + UTF-8 真名。 */
|
|
501
|
+
function contentDisposition(name) {
|
|
502
|
+
const fallback = name.replace(/[^\x20-\x7E]|["\\;\r\n]/g, '_') || 'download'
|
|
503
|
+
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(name)}`
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** 流式下载路由:凭票单次发送,支持客户端中断即停读。 */
|
|
507
|
+
async function dlHandler(req, res) {
|
|
508
|
+
try {
|
|
509
|
+
const token = new URL(req.url || '/?t=', 'http://local').searchParams.get('t') || ''
|
|
510
|
+
const entry = downloadTickets.get(token)
|
|
511
|
+
if (!entry) {
|
|
512
|
+
writeJson(res, 404, { ok: false, error: '下载票据无效或已使用' })
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
downloadTickets.delete(token)
|
|
516
|
+
const st = await stat(entry.path)
|
|
517
|
+
res.writeHead(200, {
|
|
518
|
+
'content-type': 'application/octet-stream',
|
|
519
|
+
'content-length': String(st.size),
|
|
520
|
+
'content-disposition': contentDisposition(entry.name),
|
|
521
|
+
'cache-control': 'no-store',
|
|
522
|
+
})
|
|
523
|
+
const stream = createReadStream(entry.path)
|
|
524
|
+
req.on('close', () => stream.destroy())
|
|
525
|
+
stream.pipe(res)
|
|
526
|
+
} catch (err) {
|
|
527
|
+
if (!res.headersSent) writeJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
528
|
+
else res.end()
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
ctx.effect(
|
|
532
|
+
() => ctx.webServer.register({ kind: 'exact', path: '/api/dsh-soup/dl', handler: dlHandler }),
|
|
533
|
+
'dsh-soup: streaming download route',
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
/** 统一路由 dispatch:每个 action 返回可 JSON 序列化的结果。 */
|
|
537
|
+
async function handleAction(body) {
|
|
538
|
+
const action = body && body.action
|
|
539
|
+
const args = (body && body.args) || {}
|
|
540
|
+
const sessionIdArg = args && args.sessionId ? String(args.sessionId) : undefined
|
|
541
|
+
switch (action) {
|
|
542
|
+
case 'root': {
|
|
543
|
+
return { ok: true, root }
|
|
544
|
+
}
|
|
545
|
+
case 'sessionCwd': {
|
|
546
|
+
const id = args.sessionId
|
|
547
|
+
const session = id ? sessions.get(id) : undefined
|
|
548
|
+
const cwd = session && session.header && session.header.cwd
|
|
549
|
+
return cwd ? { ok: true, cwd } : { ok: false }
|
|
550
|
+
}
|
|
551
|
+
case 'list': {
|
|
552
|
+
const rp = await confine(String(args.path || '') || root, '目录', sessionIdArg)
|
|
553
|
+
let target
|
|
554
|
+
let entries
|
|
555
|
+
try {
|
|
556
|
+
target = await fs.resolve(rp, { cwd: root })
|
|
557
|
+
entries = await fs.listDir(target)
|
|
558
|
+
} catch (err) {
|
|
559
|
+
return { ok: false, error: `无法读取目录: ${String((err && err.message) || err)}` }
|
|
560
|
+
}
|
|
561
|
+
const basePath = (target && target.displayPath) || rp
|
|
562
|
+
const out = entries.map((entry) => {
|
|
563
|
+
const item = {
|
|
564
|
+
name: entry.name,
|
|
565
|
+
type: entry.type,
|
|
566
|
+
path: entry.target && entry.target.displayPath ? entry.target.displayPath : `${basePath}/${entry.name}`,
|
|
567
|
+
}
|
|
568
|
+
if (typeof entry.size === 'number' && entry.size >= 0) item.size = entry.size
|
|
569
|
+
return item
|
|
570
|
+
})
|
|
571
|
+
return { ok: true, root, path: basePath, entries: out }
|
|
572
|
+
}
|
|
573
|
+
case 'open': {
|
|
574
|
+
const rp = await confine(args.path, '打开路径', sessionIdArg)
|
|
575
|
+
const res = await openPath(rp)
|
|
576
|
+
return res.code === 0 ? { ok: true } : { ok: false, error: res.error || '打开失败' }
|
|
577
|
+
}
|
|
578
|
+
case 'trash': {
|
|
579
|
+
const rp = await confine(args.path, '废纸篓路径', sessionIdArg)
|
|
580
|
+
return trashPath(rp)
|
|
581
|
+
}
|
|
582
|
+
case 'move': {
|
|
583
|
+
const from = String((args && args.from) || '')
|
|
584
|
+
const to = String((args && args.to) || '')
|
|
585
|
+
if (!from || !to) return { ok: false, error: '缺少路径' }
|
|
586
|
+
const fromRp = await confine(from, '源路径', sessionIdArg)
|
|
587
|
+
const toRp = await confine(to, '目标路径', sessionIdArg)
|
|
588
|
+
if (toRp !== fromRp && within(fromRp, toRp)) return { ok: false, error: '不能把文件夹移入其自身子目录' }
|
|
589
|
+
try {
|
|
590
|
+
await rename(fromRp, toRp)
|
|
591
|
+
return { ok: true }
|
|
592
|
+
} catch (err) {
|
|
593
|
+
return { ok: false, error: `移动/重命名失败: ${String((err && err.message) || err)}` }
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
case 'create': {
|
|
597
|
+
const dir = String((args && args.dir) || '')
|
|
598
|
+
const newName = String((args && args.name) || '')
|
|
599
|
+
const isDir = Boolean(args && args.isDir)
|
|
600
|
+
if (!dir || !newName || newName === '.' || newName === '..' || newName.includes('/') || newName.includes('\\') || newName.includes('\0')) {
|
|
601
|
+
return { ok: false, error: '无效文件名' }
|
|
602
|
+
}
|
|
603
|
+
const dirRp = await confine(dir, '目录', sessionIdArg)
|
|
604
|
+
// 二次围栏:name 若与既有符号链接同名,lenient realpath 会解析出
|
|
605
|
+
// 链接真实指向,越界即拒绝。
|
|
606
|
+
const target = await confine(joinPath(dirRp, newName), '创建路径', sessionIdArg)
|
|
607
|
+
try {
|
|
608
|
+
if (isDir) {
|
|
609
|
+
await mkdir(target, { recursive: true })
|
|
610
|
+
} else {
|
|
611
|
+
await writeFile(target, '')
|
|
612
|
+
}
|
|
613
|
+
return { ok: true, path: target }
|
|
614
|
+
} catch (err) {
|
|
615
|
+
return { ok: false, error: `${isDir ? '新建文件夹' : '新建文件'}失败: ${String((err && err.message) || err)}` }
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
case 'upload': {
|
|
619
|
+
const dir = String((args && args.dir) || '')
|
|
620
|
+
const fileName = String((args && args.name) || '')
|
|
621
|
+
const data = String((args && args.data) || '')
|
|
622
|
+
// 文件名与 create 同规:禁路径分隔符与相对段,杜绝 ../ 穿越。
|
|
623
|
+
if (!dir || !fileName || fileName === '.' || fileName === '..' || fileName.includes('/') || fileName.includes('\\') || fileName.includes('\0')) {
|
|
624
|
+
return { ok: false, error: '无效文件名' }
|
|
625
|
+
}
|
|
626
|
+
const dirRp = await confine(dir, '目录', sessionIdArg)
|
|
627
|
+
const target = await confine(joinPath(dirRp, fileName), '上传路径', sessionIdArg)
|
|
628
|
+
// 分块上传(JupyterLab 同款语义):chunk=1/缺省覆盖写,chunk>=2 追加。
|
|
629
|
+
// 无需服务端状态——按块序到达即顺序落盘。
|
|
630
|
+
const chunk = Number((args && args.chunk) || 0)
|
|
631
|
+
if (chunk < 0 || !Number.isInteger(chunk)) return { ok: false, error: '无效的 chunk 序号' }
|
|
632
|
+
try {
|
|
633
|
+
const bytes = Buffer.from(data.replace(/\s+/g, ''), 'base64')
|
|
634
|
+
if (chunk >= 2) await appendFile(target, bytes)
|
|
635
|
+
else await writeFile(target, bytes)
|
|
636
|
+
return { ok: true, path: target }
|
|
637
|
+
} catch (err) {
|
|
638
|
+
return { ok: false, error: `写入失败: ${String((err && err.message) || err)}` }
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
case 'read': {
|
|
642
|
+
const rp = await confine(args.path, '文件路径', sessionIdArg)
|
|
643
|
+
return readFileForView(rp)
|
|
644
|
+
}
|
|
645
|
+
case 'download': {
|
|
646
|
+
// 流式下载签发:围栏校验通过后发一张一次性、2 分钟时效的票据 URL,
|
|
647
|
+
// 真正的字节由 /api/dsh-soup/dl 流式发送——任意大小文件零内存放大。
|
|
648
|
+
const rp = await confine(args.path, '下载路径', sessionIdArg)
|
|
649
|
+
let dst
|
|
650
|
+
try {
|
|
651
|
+
dst = await stat(rp)
|
|
652
|
+
} catch (err) {
|
|
653
|
+
return { ok: false, error: '无法读取文件' }
|
|
654
|
+
}
|
|
655
|
+
if (dst.isDirectory()) return { ok: false, error: '目标是目录,不支持下载' }
|
|
656
|
+
const token = randomBytes(24).toString('hex')
|
|
657
|
+
downloadTickets.set(token, { path: rp, name: path.basename(rp), exp: Date.now() + 120000 })
|
|
658
|
+
return { ok: true, name: path.basename(rp), size: dst.size, url: `/api/dsh-soup/dl?t=${token}` }
|
|
659
|
+
}
|
|
660
|
+
case 'speed-status': {
|
|
661
|
+
const sid = args && args.sessionId ? String(args.sessionId) : ''
|
|
662
|
+
return { ok: true, ...speedStatus(sid) }
|
|
663
|
+
}
|
|
664
|
+
default:
|
|
665
|
+
return { ok: false, error: `unknown action: ${String(action)}` }
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* 请求门:同源校验 + 强制自定义头。
|
|
671
|
+
* - 仅接受 POST;OPTIONS 直接 403(让浏览器 preflight 失败),其余 405;
|
|
672
|
+
* - 带 Origin 时必须与 Host 同源——挡跨站 CSRF 与 DNS rebinding
|
|
673
|
+
* (rebinding 页面的 Origin 是攻击者域名,与 Host 必不相同);
|
|
674
|
+
* - 必须携带 x-dsh-soup: 1 且 content-type 为 application/json:
|
|
675
|
+
* 自定义头会强制浏览器先走 preflight,而本路由永不回 CORS 头,
|
|
676
|
+
* 因此恶意网页无法用 text/plain「简单请求」盲打写操作。
|
|
677
|
+
* 非浏览器本地工具(无 Origin 头)不受影响。
|
|
678
|
+
*/
|
|
679
|
+
function gate(req, res) {
|
|
680
|
+
const method = String(req.method || 'POST').toUpperCase()
|
|
681
|
+
if (method === 'OPTIONS') {
|
|
682
|
+
writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
683
|
+
return true
|
|
684
|
+
}
|
|
685
|
+
if (method !== 'POST') {
|
|
686
|
+
res.setHeader('Allow', 'POST')
|
|
687
|
+
writeJson(res, 405, { ok: false, error: '仅支持 POST' })
|
|
688
|
+
return true
|
|
689
|
+
}
|
|
690
|
+
const headers = req.headers || {}
|
|
691
|
+
const origin = headers.origin
|
|
692
|
+
if (typeof origin === 'string' && origin !== '') {
|
|
693
|
+
let originHost = ''
|
|
694
|
+
try { originHost = new URL(origin).host } catch { originHost = '' }
|
|
695
|
+
const host = typeof headers.host === 'string' ? headers.host : ''
|
|
696
|
+
if (!originHost || originHost !== host) {
|
|
697
|
+
writeJson(res, 403, { ok: false, error: '跨源请求已拒绝' })
|
|
698
|
+
return true
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
const contentType = String(headers['content-type'] || '').toLowerCase()
|
|
702
|
+
if (headers['x-dsh-soup'] !== '1' || !contentType.startsWith('application/json')) {
|
|
703
|
+
writeJson(res, 403, { ok: false, error: '缺少必要的请求头' })
|
|
704
|
+
return true
|
|
705
|
+
}
|
|
706
|
+
return false
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** HTTP 处理器:请求门 → 解析 body → dispatch → JSON 响应。 */
|
|
710
|
+
async function handler(req, res) {
|
|
711
|
+
try {
|
|
712
|
+
if (gate(req, res)) return
|
|
713
|
+
const body = await readBody(req)
|
|
714
|
+
const result = await handleAction(body)
|
|
715
|
+
writeJson(res, 200, result)
|
|
716
|
+
} catch (err) {
|
|
717
|
+
writeJson(res, (err && err.statusCode) || 500, { ok: false, error: String((err && err.message) || err) })
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
ctx.effect(
|
|
722
|
+
() => ctx.webServer.register({ kind: 'exact', path: '/api/dsh-soup', handler }),
|
|
723
|
+
'dsh-soup: file http route',
|
|
724
|
+
)
|
|
725
|
+
}
|