@lyhue1991/dsh-soup 0.4.7 → 0.4.9

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/lib/index.js CHANGED
@@ -1,678 +1,809 @@
1
- // src/host/speed.ts
2
- function setupSpeedTracking(deps) {
3
- const { ctx } = deps;
4
- const CJK = /[\u3000-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
5
- function estimateChars(text) {
6
- let cjk = 0;
7
- let rest = 0;
8
- for (const ch of text) {
9
- if (CJK.test(ch)) cjk++;
10
- else rest++;
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
+ }
11
94
  }
12
- return cjk + rest / 4;
13
95
  }
14
- const streams = /* @__PURE__ */ new Map();
15
- function extractKey(options) {
16
- try {
17
- if (!options) return null;
18
- if (typeof options.sessionId === "string") return options.sessionId;
19
- if (options.session && typeof options.session.id === "string") return options.session.id;
20
- if (options.agent) {
21
- const a = options.agent;
22
- if (typeof a.sessionId === "string") return a.sessionId;
23
- if (typeof a.id === "string") return a.id;
24
- if (a.session && typeof a.session.id === "string") return a.session.id;
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 { /* 会话目录不可达则跳过 */ }
25
120
  }
26
- if (options.meta && typeof options.meta.sessionId === "string") return options.meta.sessionId;
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)
27
147
  } catch (err) {
148
+ throw httpError(400, `${name}不可达: ${p}`)
28
149
  }
29
- return null;
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
30
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
+
31
186
  function extractChunk(chunk) {
32
- let text = "";
33
- let realTokens = null;
187
+ let text = ''
188
+ let realTokens = null
34
189
  try {
35
- if (!chunk) return { text, realTokens };
36
- if (typeof chunk.text === "string") text += chunk.text;
37
- if (typeof chunk.content === "string") text += chunk.content;
190
+ if (!chunk) return { text, realTokens }
191
+ if (typeof chunk.text === 'string') text += chunk.text
192
+ if (typeof chunk.content === 'string') text += chunk.content
38
193
  if (Array.isArray(chunk.content)) {
39
- for (const part of chunk.content) if (part && typeof part.text === "string") text += part.text;
194
+ for (const part of chunk.content) if (part && typeof part.text === 'string') text += part.text
40
195
  }
41
196
  if (chunk.delta) {
42
- const d = chunk.delta;
43
- if (typeof d.text === "string") text += d.text;
44
- if (typeof d.content === "string") text += d.content;
197
+ const d = chunk.delta
198
+ if (typeof d.text === 'string') text += d.text
199
+ if (typeof d.content === 'string') text += d.content
45
200
  if (d.usage) {
46
- const ot = d.usage.outputTokens != null ? d.usage.outputTokens : d.usage.output_tokens != null ? d.usage.output_tokens : d.usage.completion_tokens != null ? d.usage.completion_tokens : d.usage.completionTokens;
47
- if (typeof ot === "number") realTokens = ot;
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
48
205
  }
49
206
  }
50
207
  if (chunk.usage) {
51
- const u = chunk.usage;
52
- const ot = u.outputTokens != null ? u.outputTokens : u.output_tokens != null ? u.output_tokens : u.completion_tokens != null ? u.completion_tokens : u.completionTokens;
53
- if (typeof ot === "number") realTokens = ot;
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
54
213
  }
55
214
  if (Array.isArray(chunk.choices)) {
56
215
  for (const c of chunk.choices) {
57
- const d = c && c.delta;
58
- if (d && typeof d.content === "string") text += d.content;
216
+ const d = c && c.delta
217
+ if (d && typeof d.content === 'string') text += d.content
59
218
  if (d && d.usage) {
60
- const ot = d.usage.outputTokens != null ? d.usage.outputTokens : d.usage.output_tokens;
61
- if (typeof ot === "number") realTokens = ot;
219
+ const ot = d.usage.outputTokens != null ? d.usage.outputTokens : d.usage.output_tokens
220
+ if (typeof ot === 'number') realTokens = ot
62
221
  }
63
222
  }
64
223
  }
65
- } catch (err) {
66
- }
67
- return { text, realTokens };
224
+ } catch (err) { /* ignore */ }
225
+ return { text, realTokens }
68
226
  }
69
- ctx.on("llm/stream", (options, next) => {
70
- const key = extractKey(options) || "_";
71
- const now = Date.now();
72
- streams.set(key, { phase: "waiting", startedAt: now, firstChunkAt: null, charTokens: 0, realTokens: 0, hasReal: false, lastChunkAt: null, lastContentAt: null, samples: [], lastSeen: now });
73
- const innerP = Promise.resolve(next());
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())
74
233
  return (async function* () {
75
234
  try {
76
- const inner = await innerP;
235
+ const inner = await innerP
77
236
  for await (const chunk of inner) {
78
- const st = streams.get(key);
237
+ const st = streams.get(key)
79
238
  if (st) {
80
- const parsed = extractChunk(chunk);
81
- const t = Date.now();
82
- if (st.firstChunkAt === null) {
83
- st.firstChunkAt = t;
84
- st.phase = "streaming";
85
- }
86
- if (parsed.text) st.charTokens += estimateChars(parsed.text);
87
- if (parsed.realTokens != null) {
88
- st.realTokens = parsed.realTokens;
89
- st.hasReal = true;
90
- }
91
- st.lastChunkAt = t;
92
- if (parsed.text && parsed.text.length > 0 || parsed.realTokens != null) st.lastContentAt = t;
93
- st.samples.push([t, st.hasReal ? st.realTokens : st.charTokens]);
94
- if (st.samples.length > 128) st.samples.splice(0, st.samples.length - 128);
95
- st.lastSeen = t;
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
96
252
  }
97
- yield chunk;
253
+ yield chunk
98
254
  }
99
255
  } finally {
100
- const st = streams.get(key);
256
+ const st = streams.get(key)
101
257
  if (st) {
102
- st.phase = "done";
103
- st.lastSeen = Date.now();
258
+ st.phase = 'done'
259
+ st.lastSeen = Date.now()
104
260
  }
105
261
  }
106
- })();
107
- });
262
+ })()
263
+ })
264
+
108
265
  const stopReap = ctx.timer.interval(() => {
109
- const now = Date.now();
266
+ const now = Date.now()
110
267
  for (const [k, st] of streams) {
111
- if (st.phase === "done" && now - st.lastSeen > 4e3) streams.delete(k);
268
+ if (st.phase === 'done' && now - st.lastSeen > 4000) streams.delete(k)
112
269
  }
113
- }, 1e3);
114
- ctx.effect(stopReap, "dsh-soup: speed stream reap");
115
- const RATE_WINDOW_MS = 2e3;
116
- const RATE_MIN_SPAN_MS = 500;
117
- const STALL_MS = 2e3;
118
- const MIN_REAL_TPS = 3;
119
- const SPAN_GRACE_MS = 1500;
120
- function speedStatus(sessionId) {
121
- let st = sessionId ? streams.get(sessionId) : void 0;
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)
122
285
  if (!st) {
123
- let latest = null;
124
- for (const s of streams.values()) if (!latest || s.lastSeen > latest.lastSeen) latest = s;
125
- st = latest;
286
+ let latest = null
287
+ for (const s of streams.values()) if (!latest || s.lastSeen > latest.lastSeen) latest = s
288
+ st = latest
126
289
  }
127
- if (!st) return { phase: "idle" };
128
- const now = Date.now();
129
- const tokens = st.hasReal ? st.realTokens : Math.round(st.charTokens);
130
- const ttft = st.firstChunkAt ? st.firstChunkAt - st.startedAt : null;
131
- if (st.phase !== "streaming" || !st.firstChunkAt) {
132
- return { phase: st.phase, tokens, tps: 0, ttft };
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 }
133
296
  }
134
- const contentAt = st.lastContentAt || st.firstChunkAt;
135
- const sinceContent = now - contentAt;
297
+ // 停顿按「内容时钟」判定:keepalive / 空 delta 滴漏不算活动。
298
+ const contentAt = st.lastContentAt || st.firstChunkAt
299
+ const sinceContent = now - contentAt
136
300
  if (sinceContent > STALL_MS) {
137
- return { phase: "waiting", tokens, tps: 0, ttft };
301
+ return { phase: 'waiting', tokens, tps: 0, ttft }
138
302
  }
139
- const cutoff = now - RATE_WINDOW_MS;
140
- let base = null;
141
- let first = null;
303
+ // 滑动窗口瞬时速率:基线优先取窗口前最后一个样本(burst 场景也能算出增量),
304
+ // 否则取窗口内第一个样本。
305
+ const cutoff = now - RATE_WINDOW_MS
306
+ let base = null
307
+ let first = null
142
308
  for (const s of st.samples) {
143
- if (first === null) first = s;
144
- if (s[0] < cutoff) base = s;
309
+ if (first === null) first = s
310
+ if (s[0] < cutoff) base = s
145
311
  }
146
- if (base === null) base = first;
147
- let tps = 0;
312
+ if (base === null) base = first
313
+ let tps = 0
148
314
  if (base && now - base[0] >= RATE_MIN_SPAN_MS) {
149
- const spanEnd = Math.min(now, contentAt + SPAN_GRACE_MS);
315
+ // 分母封顶到最后内容时刻 + 宽限:静默期不再把速率摊薄成 0.x t/s。
316
+ const spanEnd = Math.min(now, contentAt + SPAN_GRACE_MS)
150
317
  if (spanEnd - base[0] >= RATE_MIN_SPAN_MS) {
151
- tps = Math.max(0, (tokens - base[1]) / ((spanEnd - base[0]) / 1e3));
318
+ tps = Math.max(0, (tokens - base[1]) / ((spanEnd - base[0]) / 1000))
152
319
  }
153
320
  }
321
+ // 近零进展护栏:内容已停顿超过最小跨度时,任何低于真实生成下限的读数
322
+ // (含 0 与负增量截断)都判回等待——0.3 t/s 必然是等待,不是极慢生成。
154
323
  if (sinceContent >= RATE_MIN_SPAN_MS && tps < MIN_REAL_TPS) {
155
- return { phase: "waiting", tokens, tps: 0, ttft };
324
+ return { phase: 'waiting', tokens, tps: 0, ttft }
156
325
  }
157
- return { phase: "streaming", tokens, tps, ttft };
326
+ return { phase: 'streaming', tokens, tps, ttft }
158
327
  }
159
- return { speedStatus };
160
- }
161
328
 
162
- // src/host/files.ts
163
- import { rename, mkdir, writeFile, appendFile, open as openFile, stat } from "node:fs/promises";
164
- import { createReadStream } from "node:fs";
165
- import { randomBytes } from "node:crypto";
166
- import path2 from "node:path";
167
-
168
- // src/host/confine.ts
169
- import { realpath } from "node:fs/promises";
170
- import path from "node:path";
171
- function createConfine(deps) {
172
- const { root, sessions } = deps;
173
- const FOLD_CASE = deps.PLATFORM === "win";
174
- function httpError(statusCode, message) {
175
- return Object.assign(new Error(message), { statusCode });
176
- }
177
- async function realPathLenient(p) {
178
- let cur = path.resolve(String(p));
179
- const tail = [];
180
- for (; ; ) {
181
- try {
182
- const rp = await realpath(cur);
183
- return tail.length ? path.join(rp, ...tail) : rp;
184
- } catch (err) {
185
- const parent = path.dirname(cur);
186
- if (!err || err.code !== "ENOENT" || parent === cur) throw err;
187
- tail.unshift(path.basename(cur));
188
- cur = parent;
189
- }
190
- }
191
- }
192
- let rootRealCache = null;
193
- async function rootReal() {
194
- if (rootRealCache === null) {
195
- try {
196
- rootRealCache = await realpath(root);
197
- } catch {
198
- rootRealCache = path.resolve(root);
199
- }
200
- }
201
- return rootRealCache;
202
- }
203
- function within(base, candidate) {
204
- const b = FOLD_CASE ? base.toLowerCase() : base;
205
- const c = FOLD_CASE ? candidate.toLowerCase() : candidate;
206
- if (c === b) return true;
207
- const rel = path.relative(b, c);
208
- return rel !== "" && rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel);
209
- }
210
- async function allowedBases(preferredSessionId) {
211
- const bases = [await rootReal()];
212
- const pushCwd = async (cwd) => {
213
- if (typeof cwd === "string" && cwd) {
214
- try {
215
- bases.push(await realPathLenient(cwd));
216
- } catch {
217
- }
218
- }
219
- };
220
- if (preferredSessionId && typeof sessions.get === "function") {
221
- try {
222
- const pref = sessions.get(preferredSessionId);
223
- await pushCwd(pref && pref.header && pref.header.cwd);
224
- } catch {
225
- }
226
- }
227
- try {
228
- const list = typeof sessions.list === "function" ? sessions.list() : [];
229
- for (const s of list) await pushCwd(s && s.header && s.header.cwd);
230
- } catch {
231
- }
232
- return bases;
233
- }
234
- async function confine(rawPath, label, preferredSessionId) {
235
- const name2 = label || "\u8DEF\u5F84";
236
- const p = String(rawPath || "");
237
- if (!p) throw httpError(400, `\u7F3A\u5C11${name2}`);
238
- let rp;
239
- try {
240
- rp = await realPathLenient(p);
241
- } catch (err) {
242
- throw httpError(400, `${name2}\u4E0D\u53EF\u8FBE: ${p}`);
243
- }
244
- for (const base of await allowedBases(preferredSessionId)) {
245
- if (within(base, rp)) return rp;
246
- }
247
- throw httpError(403, `${name2}\u8D85\u51FA\u5141\u8BB8\u8303\u56F4\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u4E0E\u4F1A\u8BDD\u76EE\u5F55\uFF09: ${p}`);
248
- }
249
- return { confine, httpError, realPathLenient };
250
- }
251
-
252
- // src/host/files.ts
253
- function readBody(req) {
254
- return new Promise((resolve, reject) => {
255
- const chunks = [];
256
- req.on("data", (chunk) => chunks.push(chunk));
257
- req.on("end", () => {
258
- const text = Buffer.concat(chunks).toString("utf8");
259
- if (!text) {
260
- resolve({});
261
- return;
262
- }
263
- try {
264
- resolve(JSON.parse(text));
265
- } catch {
266
- resolve({});
267
- }
268
- });
269
- req.on("error", reject);
270
- });
271
- }
272
- function writeJson(res, code, data) {
273
- const body = JSON.stringify(data);
274
- res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
275
- res.end(body);
276
- }
277
- function setupFiles(deps) {
278
- const { ctx, root, PLATFORM: PLATFORM2 } = deps;
279
- const fs = ctx.fs;
280
- const subprocess = ctx.subprocess;
281
- const sessions = ctx.sessions;
282
- const { confine, httpError } = createConfine({ root, PLATFORM: PLATFORM2, sessions });
283
329
  async function runCmd(argv) {
284
330
  try {
285
331
  const proc = subprocess.spawn({
286
332
  argv,
287
- cwd: "/",
288
- stdio: { stdin: "ignore", stdout: "ignore", stderr: { maxBytes: 4096 } },
289
- graceMs: 6e3
290
- });
291
- const outcome = await proc.done;
292
- return { code: outcome.exitCode };
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 }
293
339
  } catch (err) {
294
- return { error: String(err && err.message || err) };
340
+ return { error: String((err && err.message) || err) }
295
341
  }
296
342
  }
297
- function joinPath(dir, name2) {
298
- return String(dir || "").replace(/[\\/]+$/, "") + "/" + name2;
343
+
344
+ /** 拼接目录与子项(统一正斜杠,兼容各平台)。 */
345
+ function joinPath(dir, name) {
346
+ return String(dir || '').replace(/[\\/]+$/, '') + '/' + name
299
347
  }
300
- async function openPath(p) {
301
- const pp = String(p || "");
302
- if (!pp) return { code: -1, error: "\u7F3A\u5C11\u8DEF\u5F84" };
303
- if (PLATFORM2 === "win") return runCmd(["cmd", "/c", "start", "", pp]);
304
- if (PLATFORM2 === "linux") return runCmd(["xdg-open", pp]);
305
- return runCmd(["/usr/bin/open", pp]);
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])
306
360
  }
307
- async function trashPath(p) {
308
- const pp = String(p || "");
309
- if (!pp) return { ok: false, error: "\u7F3A\u5C11\u8DEF\u5F84" };
310
- if (PLATFORM2 === "win") {
311
- const esc2 = String(pp).replace(/'/g, "''");
312
- const script2 = `$sh = New-Object -ComObject Shell.Application; $sh.NameSpace(10).MoveHere('${esc2}'); Start-Sleep -Milliseconds 300`;
313
- const res2 = await runCmd(["powershell", "-NoProfile", "-Command", script2]);
314
- return res2.code === 0 ? { ok: true } : { ok: false, error: res2.error || "\u79FB\u52A8\u5931\u8D25", hint: "\u8BF7\u786E\u8BA4 PowerShell \u53EF\u7528\uFF08Windows 10+ \u81EA\u5E26\uFF09" };
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+ 自带)' }
315
374
  }
316
- if (PLATFORM2 === "linux") {
317
- const res2 = await runCmd(["gio", "trash", pp]);
318
- if (res2.code === 0) return { ok: true };
319
- const res22 = await runCmd(["trash", pp]);
320
- if (res22.code === 0) return { ok: true };
321
- return { ok: false, error: res2.error || res22.error || "\u79FB\u52A8\u5931\u8D25", hint: "\u8BF7\u5B89\u88C5 gio\uFF08glib2\uFF09\u6216 trash-cli\uFF08\u5982: sudo apt install trash-cli\uFF09" };
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)' }
322
382
  }
323
- const esc = String(pp).replace(/"/g, '\\"');
324
- const script = `tell application "Finder" to delete POSIX file "${esc}"`;
325
- const res = await runCmd(["/usr/bin/osascript", "-e", script]);
326
- return res.code === 0 ? { ok: true } : { ok: false, error: res.error || "\u79FB\u52A8\u5931\u8D25", hint: "\u5982\u9700\u79FB\u5230\u5E9F\u7EB8\u7BD3\uFF0C\u8BF7\u5728 \u7CFB\u7EDF\u8BBE\u7F6E\u2192\u9690\u79C1\u4E0E\u5B89\u5168\u6027\u2192\u81EA\u52A8\u5316 \u4E2D\u5141\u8BB8 DSH \u63A7\u5236 Finder" };
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' }
327
390
  }
328
- const TEXT_MAX_BYTES = 8 * 1024 * 1024;
329
- const IMAGE_MAX_BYTES = 8 * 1024 * 1024;
330
- const PDF_MAX_BYTES = 20 * 1024 * 1024;
331
- const NB_MAX_BYTES = 20 * 1024 * 1024;
332
- const BINARY_SNIFF = 8192;
391
+
392
+ // ------------------------------------------------------------------
393
+ // 文件只读预览:read(文本/图片/二进制三态);无 write——预览-only 不提供保存
394
+ // ------------------------------------------------------------------
395
+ /** 文本读取上限(超过则截断标记 truncated)。8 MB——HTML 常含内联 base64 图片,2 MB 太紧。 */
396
+ const TEXT_MAX_BYTES = 8 * 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。 */
333
406
  const IMAGE_MIME = {
334
- png: "image/png",
335
- jpg: "image/jpeg",
336
- jpeg: "image/jpeg",
337
- gif: "image/gif",
338
- webp: "image/webp",
339
- svg: "image/svg+xml",
340
- bmp: "image/bmp",
341
- ico: "image/x-icon"
342
- };
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
+ }
343
410
  function extOf(p) {
344
- const m = /\.([A-Za-z0-9]+)$/.exec(String(p || ""));
345
- return m ? m[1].toLowerCase() : "";
411
+ const m = /\.([A-Za-z0-9]+)$/.exec(String(p || ''))
412
+ return m ? m[1].toLowerCase() : ''
346
413
  }
347
- async function readFileForView(p) {
348
- const pp = String(p || "");
349
- if (!pp) return { ok: false, error: "\u7F3A\u5C11\u8DEF\u5F84" };
350
- let st;
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
351
423
  try {
352
- st = await stat(pp);
424
+ st = await stat(p)
353
425
  } catch (err) {
354
- return { ok: false, error: `\u65E0\u6CD5\u8BFB\u53D6\u6587\u4EF6: ${String(err && err.message || err)}` };
426
+ return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
355
427
  }
356
- if (st.isDirectory()) return { ok: false, error: "\u76EE\u6807\u662F\u76EE\u5F55" };
357
- const ext = extOf(pp);
428
+ if (st.isDirectory()) return { ok: false, error: '目标是目录' }
429
+ const ext = extOf(p)
358
430
  if (IMAGE_MIME[ext]) {
359
- if (st.size > IMAGE_MAX_BYTES) return { ok: true, kind: "image-too-large", size: st.size, limit: IMAGE_MAX_BYTES };
431
+ if (st.size > IMAGE_MAX_BYTES) return { ok: true, kind: 'image-too-large', size: st.size, limit: IMAGE_MAX_BYTES }
360
432
  try {
361
- const fh = await openFile(pp, "r");
433
+ const fh = await openFile(p, 'r')
362
434
  try {
363
- const buf = Buffer.alloc(st.size);
364
- await fh.read(buf, 0, st.size, 0);
365
- return { ok: true, kind: "image", mime: IMAGE_MIME[ext], data: buf.toString("base64"), size: st.size };
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 }
366
438
  } finally {
367
- await fh.close();
439
+ await fh.close()
368
440
  }
369
441
  } catch (err) {
370
- return { ok: false, error: `\u65E0\u6CD5\u8BFB\u53D6\u6587\u4EF6: ${String(err && err.message || err)}` };
442
+ return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
371
443
  }
372
444
  }
373
- if (ext === "pdf") {
374
- if (st.size > PDF_MAX_BYTES) return { ok: true, kind: "pdf-too-large", size: st.size, limit: PDF_MAX_BYTES };
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 }
375
449
  try {
376
- const fh = await openFile(pp, "r");
450
+ const fh = await openFile(p, 'r')
377
451
  try {
378
- const buf = Buffer.alloc(st.size);
379
- await fh.read(buf, 0, st.size, 0);
380
- return { ok: true, kind: "pdf", data: buf.toString("base64"), size: st.size };
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 }
381
455
  } finally {
382
- await fh.close();
456
+ await fh.close()
383
457
  }
384
458
  } catch (err) {
385
- return { ok: false, error: `\u65E0\u6CD5\u8BFB\u53D6\u6587\u4EF6: ${String(err && err.message || err)}` };
459
+ return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
386
460
  }
387
461
  }
388
- const textCap = ext === "ipynb" ? NB_MAX_BYTES : TEXT_MAX_BYTES;
389
- const want = Math.min(st.size, textCap + 1);
462
+ // .ipynb 含图片输出的 base64,体积膨胀快——单独放宽文本上限
463
+ const textCap = ext === 'ipynb' ? NB_MAX_BYTES : TEXT_MAX_BYTES
464
+ const want = Math.min(st.size, textCap + 1)
390
465
  try {
391
- const fh = await openFile(pp, "r");
392
- let bytesRead;
393
- let buf;
466
+ const fh = await openFile(p, 'r')
467
+ let bytesRead
468
+ let buf
394
469
  try {
395
- buf = Buffer.alloc(want);
396
- const r = await fh.read(buf, 0, want, 0);
397
- bytesRead = r.bytesRead;
470
+ buf = Buffer.alloc(want)
471
+ const r = await fh.read(buf, 0, want, 0)
472
+ bytesRead = r.bytesRead
398
473
  } finally {
399
- await fh.close();
474
+ await fh.close()
400
475
  }
401
- const head = buf.subarray(0, Math.min(BINARY_SNIFF, bytesRead));
402
- let isBinary = false;
403
- for (let i = 0; i < head.length; i++) {
404
- if (head[i] === 0) {
405
- isBinary = true;
406
- break;
407
- }
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 }
408
479
  }
409
- if (isBinary) return { ok: true, kind: "binary", size: st.size };
410
- const truncated = st.size > textCap;
411
- return { ok: true, kind: "text", content: buf.subarray(0, Math.min(bytesRead, textCap)).toString("utf-8"), truncated, size: st.size };
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 }
412
482
  } catch (err) {
413
- return { ok: false, error: `\u65E0\u6CD5\u8BFB\u53D6\u6587\u4EF6: ${String(err && err.message || err)}` };
483
+ return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
414
484
  }
415
485
  }
416
- const downloadTickets = /* @__PURE__ */ new Map();
486
+
487
+ // ------------------------------------------------------------------
488
+ // 下载票据:POST 换取一次性短时效 URL,/dl 流式路由凭票发送。
489
+ // 票据不可伪造 → GET 通道无需鉴权头也能保持与 POST 同级的门禁强度。
490
+ // ------------------------------------------------------------------
491
+ const downloadTickets = new Map()
417
492
  ctx.effect(
418
493
  () => ctx.timer.interval(() => {
419
- const now = Date.now();
420
- for (const [k, v] of downloadTickets) if (v.exp < now) downloadTickets.delete(k);
421
- }, 3e4),
422
- "dsh-soup: download ticket sweep"
423
- );
424
- function contentDisposition(name2) {
425
- const fallback = name2.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "download";
426
- return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(name2)}`;
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)}`
427
504
  }
505
+
506
+ /** 流式下载路由:凭票单次发送,支持客户端中断即停读。 */
428
507
  async function dlHandler(req, res) {
429
508
  try {
430
- const token = new URL(req.url || "/?t=", "http://local").searchParams.get("t") || "";
431
- const entry = downloadTickets.get(token);
509
+ const token = new URL(req.url || '/?t=', 'http://local').searchParams.get('t') || ''
510
+ const entry = downloadTickets.get(token)
432
511
  if (!entry) {
433
- writeJson(res, 404, { ok: false, error: "\u4E0B\u8F7D\u7968\u636E\u65E0\u6548\u6216\u5DF2\u4F7F\u7528" });
434
- return;
512
+ writeJson(res, 404, { ok: false, error: '下载票据无效或已使用' })
513
+ return
435
514
  }
436
- downloadTickets.delete(token);
437
- const st = await stat(entry.path);
515
+ downloadTickets.delete(token)
516
+ const st = await stat(entry.path)
438
517
  res.writeHead(200, {
439
- "content-type": "application/octet-stream",
440
- "content-length": String(st.size),
441
- "content-disposition": contentDisposition(entry.name)
442
- });
443
- const stream = createReadStream(entry.path);
444
- stream.on("error", () => res.end());
445
- stream.pipe(res);
446
- req.on("close", () => stream.destroy());
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)
447
526
  } catch (err) {
448
- try {
449
- writeJson(res, 500, { ok: false, error: String(err && err.message || err) });
450
- } catch {
451
- }
527
+ if (!res.headersSent) writeJson(res, 500, { ok: false, error: String((err && err.message) || err) })
528
+ else res.end()
452
529
  }
453
530
  }
454
- async function handleImgRoute(req, res) {
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
+ /**
537
+ * 图片内联端点:GET /api/dsh-soup/img?p=<路径>。
538
+ * 浏览器端 Markdown 预览会把「相对路径图片」改写为这里的绝对 URL,
539
+ * 由 <img> 内联加载真实图片字节(JupyterLab md 图片同思路)。只读、无
540
+ * Content-Disposition(内联显示而非下载)。
541
+ * 安全模型:confine 围栏(只读工作区/会话目录)+ 仅图片类型 + 大小上限 +
542
+ * 不返回任何 CORS 头 + 拒绝跨站(Sec-Fetch-Site: cross-site 与不同源 Origin)。
543
+ * 注意 <img> 只能确认「是否 200/404」,无法读取像素,跨站利用面很小。
544
+ */
545
+ async function imageHandler(req, res) {
455
546
  try {
456
- const url = new URL(req.url || "/?p=", "http://local");
457
- const p = url.searchParams.get("p") || "";
458
- if (!p) {
459
- writeJson(res, 400, { ok: false, error: "\u7F3A\u5C11\u8DEF\u5F84" });
460
- return;
461
- }
462
- const rp = await confine(p, "\u56FE\u7247\u8DEF\u5F84");
463
- const st = await stat(rp);
464
- if (st.isDirectory()) {
465
- writeJson(res, 400, { ok: false, error: "\u76EE\u6807\u662F\u76EE\u5F55" });
466
- return;
467
- }
468
- const ext = extOf(rp);
469
- const mime = IMAGE_MIME[ext];
470
- if (!mime) {
471
- writeJson(res, 415, { ok: false, error: "\u4E0D\u652F\u6301\u7684\u56FE\u7247\u7C7B\u578B" });
472
- return;
473
- }
474
- if (st.size > IMAGE_MAX_BYTES) {
475
- writeJson(res, 413, { ok: false, error: "\u56FE\u7247\u8FC7\u5927" });
476
- return;
547
+ const site = String(req.headers['sec-fetch-site'] || '')
548
+ if (site === 'cross-site') {
549
+ writeJson(res, 403, { ok: false, error: '跨站图片请求已拒绝' })
550
+ return
551
+ }
552
+ const origin = req.headers.origin
553
+ if (typeof origin === 'string' && origin !== '') {
554
+ let originHost = ''
555
+ try { originHost = new URL(origin).host } catch { originHost = '' }
556
+ const host = typeof req.headers.host === 'string' ? req.headers.host : ''
557
+ if (!originHost || originHost !== host) {
558
+ writeJson(res, 403, { ok: false, error: '跨源图片请求已拒绝' })
559
+ return
560
+ }
477
561
  }
478
- const fh = await openFile(rp, "r");
562
+ let p = ''
563
+ try { p = new URL(req.url || '/img?', 'http://local').searchParams.get('p') || '' } catch { p = '' }
564
+ if (!p) { writeJson(res, 400, { ok: false, error: '缺少图片路径' }); return }
565
+ const rp = await confine(p, '图片路径')
566
+ const mime = IMAGE_MIME[extOf(rp)]
567
+ if (!mime) { writeJson(res, 415, { ok: false, error: '不是可预览的图片类型' }); return }
568
+ let st
569
+ try { st = await stat(rp) } catch { writeJson(res, 404, { ok: false, error: '图片不可读' }); return }
570
+ if (st.isDirectory() || st.size > IMAGE_MAX_BYTES) {
571
+ writeJson(res, 413, { ok: false, error: '图片过大或目标为目录' }); return
572
+ }
573
+ const fh = await openFile(rp, 'r')
574
+ let buf
479
575
  try {
480
- const buf = Buffer.alloc(st.size);
481
- await fh.read(buf, 0, st.size, 0);
482
- res.writeHead(200, { "content-type": mime, "cache-control": "private, max-age=3" });
483
- res.end(buf);
484
- } finally {
485
- await fh.close();
486
- }
576
+ buf = Buffer.alloc(st.size)
577
+ await fh.read(buf, 0, st.size, 0)
578
+ } finally { await fh.close() }
579
+ res.writeHead(200, {
580
+ 'content-type': mime,
581
+ 'content-length': String(st.size),
582
+ 'cache-control': 'no-store',
583
+ })
584
+ res.end(buf)
487
585
  } catch (err) {
488
- try {
489
- writeJson(res, err && err.statusCode || 500, { ok: false, error: String(err && err.message || err) });
490
- } catch {
491
- }
586
+ if (!res.headersSent) writeJson(res, (err && err.statusCode) || 500, { ok: false, error: String((err && err.message) || err) })
587
+ else res.end()
492
588
  }
493
589
  }
590
+ ctx.effect(
591
+ () => ctx.webServer.register({ kind: 'exact', path: '/api/dsh-soup/img', handler: imageHandler }),
592
+ 'dsh-soup: inline image route',
593
+ )
594
+
595
+ /** 统一路由 dispatch:每个 action 返回可 JSON 序列化的结果。 */
494
596
  async function handleAction(body) {
495
- const action = String(body.action || "");
496
- const args = body.args || {};
497
- const sessionIdArg = args.sessionId || void 0;
597
+ const action = body && body.action
598
+ const args = (body && body.args) || {}
599
+ const sessionIdArg = args && args.sessionId ? String(args.sessionId) : undefined
498
600
  switch (action) {
499
- case "root":
500
- return { ok: true, root };
501
- case "sessionCwd": {
502
- const session = typeof sessions.get === "function" ? sessions.get(args.sessionId) : void 0;
503
- const cwd = session && session.header && session.header.cwd;
504
- return cwd ? { ok: true, cwd } : { ok: false };
505
- }
506
- case "list": {
507
- const rp = await confine(String(args.path || "") || root, "\u76EE\u5F55", sessionIdArg);
508
- let target;
509
- let entries;
601
+ case 'root': {
602
+ return { ok: true, root }
603
+ }
604
+ case 'sessionCwd': {
605
+ const id = args.sessionId
606
+ const session = id ? sessions.get(id) : undefined
607
+ const cwd = session && session.header && session.header.cwd
608
+ return cwd ? { ok: true, cwd } : { ok: false }
609
+ }
610
+ case 'list': {
611
+ const rp = await confine(String(args.path || '') || root, '目录', sessionIdArg)
612
+ let target
613
+ let entries
510
614
  try {
511
- target = await fs.resolve(rp, { cwd: root });
512
- entries = await fs.listDir(target);
615
+ target = await fs.resolve(rp, { cwd: root })
616
+ entries = await fs.listDir(target)
513
617
  } catch (err) {
514
- return { ok: false, error: `\u65E0\u6CD5\u8BFB\u53D6\u76EE\u5F55: ${String(err && err.message || err)}` };
618
+ return { ok: false, error: `无法读取目录: ${String((err && err.message) || err)}` }
515
619
  }
516
- const basePath = target && target.displayPath || rp;
620
+ const basePath = (target && target.displayPath) || rp
517
621
  const out = entries.map((entry) => {
518
622
  const item = {
519
623
  name: entry.name,
520
624
  type: entry.type,
521
- path: entry.target && entry.target.displayPath ? entry.target.displayPath : `${basePath}/${entry.name}`
522
- };
523
- if (typeof entry.size === "number" && entry.size >= 0) item.size = entry.size;
524
- return item;
525
- });
625
+ path: entry.target && entry.target.displayPath ? entry.target.displayPath : `${basePath}/${entry.name}`,
626
+ }
627
+ if (typeof entry.size === 'number' && entry.size >= 0) item.size = entry.size
628
+ return item
629
+ })
630
+ // 目录在前、文件在后,各按名称排(与 VS Code / Finder 一致)。
526
631
  out.sort((a, b) => {
527
- const dirDiff = (a.type === "directory" ? 0 : 1) - (b.type === "directory" ? 0 : 1);
528
- if (dirDiff !== 0) return dirDiff;
529
- return a.name.localeCompare(b.name, void 0, { numeric: true, sensitivity: "base" });
530
- });
531
- return { ok: true, root, path: basePath, entries: out };
532
- }
533
- case "open": {
534
- const rp = await confine(args.path, "\u6253\u5F00\u8DEF\u5F84", sessionIdArg);
535
- return openPath(rp);
536
- }
537
- case "trash": {
538
- const rp = await confine(args.path, "\u5E9F\u7EB8\u7BD3\u8DEF\u5F84", sessionIdArg);
539
- return trashPath(rp);
540
- }
541
- case "move": {
542
- const fromRp = await confine(args.from, "\u6E90\u8DEF\u5F84", sessionIdArg);
543
- const toRp = await confine(args.to, "\u76EE\u6807\u8DEF\u5F84", sessionIdArg);
632
+ const dirDiff = (a.type === 'directory' ? 0 : 1) - (b.type === 'directory' ? 0 : 1)
633
+ if (dirDiff !== 0) return dirDiff
634
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
635
+ })
636
+ return { ok: true, root, path: basePath, entries: out }
637
+ }
638
+ case 'open': {
639
+ const rp = await confine(args.path, '打开路径', sessionIdArg)
640
+ const res = await openPath(rp)
641
+ return res.code === 0 ? { ok: true } : { ok: false, error: res.error || '打开失败' }
642
+ }
643
+ case 'trash': {
644
+ const rp = await confine(args.path, '废纸篓路径', sessionIdArg)
645
+ return trashPath(rp)
646
+ }
647
+ case 'move': {
648
+ const from = String((args && args.from) || '')
649
+ const to = String((args && args.to) || '')
650
+ if (!from || !to) return { ok: false, error: '缺少路径' }
651
+ const fromRp = await confine(from, '源路径', sessionIdArg)
652
+ const toRp = await confine(to, '目标路径', sessionIdArg)
653
+ if (toRp !== fromRp && within(fromRp, toRp)) return { ok: false, error: '不能把文件夹移入其自身子目录' }
544
654
  try {
545
- await mkdir(path2.dirname(toRp), { recursive: true });
546
- await rename(fromRp, toRp);
547
- return { ok: true, path: toRp };
655
+ await rename(fromRp, toRp)
656
+ return { ok: true }
548
657
  } catch (err) {
549
- return { ok: false, error: `\u79FB\u52A8\u5931\u8D25: ${String(err && err.message || err)}` };
658
+ return { ok: false, error: `移动/重命名失败: ${String((err && err.message) || err)}` }
550
659
  }
551
660
  }
552
- case "create": {
553
- const rp = await confine(args.path, "\u521B\u5EFA\u8DEF\u5F84", sessionIdArg);
661
+ case 'create': {
662
+ const dir = String((args && args.dir) || '')
663
+ const newName = String((args && args.name) || '')
664
+ const isDir = Boolean(args && args.isDir)
665
+ if (!dir || !newName || newName === '.' || newName === '..' || newName.includes('/') || newName.includes('\\') || newName.includes('\0')) {
666
+ return { ok: false, error: '无效文件名' }
667
+ }
668
+ const dirRp = await confine(dir, '目录', sessionIdArg)
669
+ // 二次围栏:name 若与既有符号链接同名,lenient realpath 会解析出
670
+ // 链接真实指向,越界即拒绝。
671
+ const target = await confine(joinPath(dirRp, newName), '创建路径', sessionIdArg)
554
672
  try {
555
- if (args.isDir) {
556
- await mkdir(rp, { recursive: true });
557
- return { ok: true, path: rp };
673
+ if (isDir) {
674
+ await mkdir(target, { recursive: true })
675
+ } else {
676
+ await writeFile(target, '')
558
677
  }
559
- await mkdir(path2.dirname(rp), { recursive: true });
560
- await writeFile(rp, "", { flag: "wx" });
561
- return { ok: true, path: rp };
678
+ return { ok: true, path: target }
562
679
  } catch (err) {
563
- if (err && err.code === "EEXIST") return { ok: false, error: "\u6587\u4EF6\u5DF2\u5B58\u5728" };
564
- return { ok: false, error: `\u521B\u5EFA\u5931\u8D25: ${String(err && err.message || err)}` };
680
+ return { ok: false, error: `${isDir ? '新建文件夹' : '新建文件'}失败: ${String((err && err.message) || err)}` }
565
681
  }
566
682
  }
567
- case "upload": {
568
- const rp = await confine(args.path, "\u4E0A\u4F20\u8DEF\u5F84", sessionIdArg);
569
- const chunk = typeof args.chunk === "number" ? args.chunk : 1;
570
- if (chunk < 0 || !Number.isInteger(chunk)) return { ok: false, error: "\u65E0\u6548\u7684 chunk \u5E8F\u53F7" };
683
+ case 'upload': {
684
+ const dir = String((args && args.dir) || '')
685
+ const fileName = String((args && args.name) || '')
686
+ const data = String((args && args.data) || '')
687
+ // 文件名与 create 同规:禁路径分隔符与相对段,杜绝 ../ 穿越。
688
+ if (!dir || !fileName || fileName === '.' || fileName === '..' || fileName.includes('/') || fileName.includes('\\') || fileName.includes('\0')) {
689
+ return { ok: false, error: '无效文件名' }
690
+ }
691
+ const dirRp = await confine(dir, '目录', sessionIdArg)
692
+ const target = await confine(joinPath(dirRp, fileName), '上传路径', sessionIdArg)
693
+ // 分块上传(JupyterLab 同款语义):chunk=1/缺省覆盖写,chunk>=2 追加。
694
+ // 无需服务端状态——按块序到达即顺序落盘。
695
+ const chunk = Number((args && args.chunk) || 0)
696
+ if (chunk < 0 || !Number.isInteger(chunk)) return { ok: false, error: '无效的 chunk 序号' }
571
697
  try {
572
- const bytes = Buffer.from(String(args.data || "").replace(/\s+/g, ""), "base64");
573
- if (chunk >= 2) await appendFile(rp, bytes);
574
- else await writeFile(rp, bytes);
575
- return { ok: true, path: rp };
698
+ const bytes = Buffer.from(data.replace(/\s+/g, ''), 'base64')
699
+ if (chunk >= 2) await appendFile(target, bytes)
700
+ else await writeFile(target, bytes)
701
+ return { ok: true, path: target }
576
702
  } catch (err) {
577
- return { ok: false, error: `\u5199\u5165\u5931\u8D25: ${String(err && err.message || err)}` };
703
+ return { ok: false, error: `写入失败: ${String((err && err.message) || err)}` }
578
704
  }
579
705
  }
580
- case "read": {
581
- const rp = await confine(args.path, "\u6587\u4EF6\u8DEF\u5F84", sessionIdArg);
582
- return readFileForView(rp);
706
+ case 'read': {
707
+ const rp = await confine(args.path, '文件路径', sessionIdArg)
708
+ return readFileForView(rp)
583
709
  }
584
- case "download": {
585
- const rp = await confine(args.path, "\u4E0B\u8F7D\u8DEF\u5F84", sessionIdArg);
586
- let dst;
710
+ case 'download': {
711
+ // 流式下载签发:围栏校验通过后发一张一次性、2 分钟时效的票据 URL,
712
+ // 真正的字节由 /api/dsh-soup/dl 流式发送——任意大小文件零内存放大。
713
+ const rp = await confine(args.path, '下载路径', sessionIdArg)
714
+ let dst
587
715
  try {
588
- dst = await stat(rp);
716
+ dst = await stat(rp)
589
717
  } catch (err) {
590
- return { ok: false, error: "\u65E0\u6CD5\u8BFB\u53D6\u6587\u4EF6" };
718
+ return { ok: false, error: '无法读取文件' }
591
719
  }
592
- if (dst.isDirectory()) return { ok: false, error: "\u76EE\u6807\u662F\u76EE\u5F55\uFF0C\u4E0D\u652F\u6301\u4E0B\u8F7D" };
593
- const token = randomBytes(24).toString("hex");
594
- downloadTickets.set(token, { path: rp, name: path2.basename(rp), exp: Date.now() + 12e4 });
595
- return { ok: true, name: path2.basename(rp), size: dst.size, url: `/api/dsh-soup/dl?t=${token}` };
596
- }
597
- case "mtime": {
598
- const paths = Array.isArray(args && args.paths) ? args.paths.slice(0, 64) : [];
599
- const mtimes = {};
720
+ if (dst.isDirectory()) return { ok: false, error: '目标是目录,不支持下载' }
721
+ const token = randomBytes(24).toString('hex')
722
+ downloadTickets.set(token, { path: rp, name: path.basename(rp), exp: Date.now() + 120000 })
723
+ return { ok: true, name: path.basename(rp), size: dst.size, url: `/api/dsh-soup/dl?t=${token}` }
724
+ }
725
+ case 'mtime': {
726
+ // 轻量变更探测:对一批路径返回 mtime(+size/目录标志),供浏览器端
727
+ // 低频轮询比较、变了才真正重拉——避免引入跨平台文件 watcher。
728
+ // 上限 64 个路径;越界/不可达的路径返回 null(客户端视为消失)。
729
+ const paths = Array.isArray(args && args.paths) ? args.paths.slice(0, 64) : []
730
+ const mtimes = {}
600
731
  for (const raw of paths) {
601
- const p = String(raw || "");
602
- if (!p) continue;
732
+ const p = String(raw || '')
733
+ if (!p) continue
603
734
  try {
604
- const rp = await confine(p, "\u63A2\u6D4B\u8DEF\u5F84", sessionIdArg);
605
- const st = await stat(rp);
606
- mtimes[p] = { m: st.mtimeMs, s: st.size, d: st.isDirectory() };
735
+ const rp = await confine(p, '探测路径', sessionIdArg)
736
+ const st = await stat(rp)
737
+ mtimes[p] = st.isDirectory() ? { m: st.mtimeMs, d: 1 } : { m: st.mtimeMs, s: st.size }
607
738
  } catch {
608
- mtimes[p] = null;
739
+ mtimes[p] = null
609
740
  }
610
741
  }
611
- return { ok: true, mtimes };
742
+ return { ok: true, mtimes }
612
743
  }
613
- case "speed-status": {
614
- return { ok: true, ...deps.speedStatus(args.sessionId) };
744
+ case 'speed-status': {
745
+ const sid = args && args.sessionId ? String(args.sessionId) : ''
746
+ return { ok: true, ...speedStatus(sid) }
615
747
  }
616
748
  default:
617
- return { ok: false, error: `\u672A\u77E5\u64CD\u4F5C: ${action}` };
749
+ return { ok: false, error: `unknown action: ${String(action)}` }
618
750
  }
619
751
  }
752
+
753
+ /**
754
+ * 请求门:同源校验 + 强制自定义头。
755
+ * - 仅接受 POST;OPTIONS 直接 403(让浏览器 preflight 失败),其余 405;
756
+ * - 带 Origin 时必须与 Host 同源——挡跨站 CSRF 与 DNS rebinding
757
+ * (rebinding 页面的 Origin 是攻击者域名,与 Host 必不相同);
758
+ * - 必须携带 x-dsh-soup: 1 且 content-type 为 application/json:
759
+ * 自定义头会强制浏览器先走 preflight,而本路由永不回 CORS 头,
760
+ * 因此恶意网页无法用 text/plain「简单请求」盲打写操作。
761
+ * 非浏览器本地工具(无 Origin 头)不受影响。
762
+ */
620
763
  function gate(req, res) {
621
- const headers = req.headers || {};
622
- const origin = String(headers.origin || "");
623
- const host = String(headers.host || "");
624
- if (origin) {
625
- try {
626
- const originHost = new URL(origin).host;
627
- if (!originHost || originHost !== host) {
628
- writeJson(res, 403, { ok: false, error: "\u8DE8\u6E90\u8BF7\u6C42\u5DF2\u62D2\u7EDD" });
629
- return true;
630
- }
631
- } catch {
764
+ const method = String(req.method || 'POST').toUpperCase()
765
+ if (method === 'OPTIONS') {
766
+ writeJson(res, 403, { ok: false, error: 'forbidden' })
767
+ return true
768
+ }
769
+ if (method !== 'POST') {
770
+ res.setHeader('Allow', 'POST')
771
+ writeJson(res, 405, { ok: false, error: '仅支持 POST' })
772
+ return true
773
+ }
774
+ const headers = req.headers || {}
775
+ const origin = headers.origin
776
+ if (typeof origin === 'string' && origin !== '') {
777
+ let originHost = ''
778
+ try { originHost = new URL(origin).host } catch { originHost = '' }
779
+ const host = typeof headers.host === 'string' ? headers.host : ''
780
+ if (!originHost || originHost !== host) {
781
+ writeJson(res, 403, { ok: false, error: '跨源请求已拒绝' })
782
+ return true
632
783
  }
633
784
  }
634
- const contentType = String(headers["content-type"] || "").toLowerCase();
635
- if (headers["x-dsh-soup"] !== "1" || !contentType.startsWith("application/json")) {
636
- writeJson(res, 403, { ok: false, error: "\u7F3A\u5C11\u5FC5\u8981\u7684\u8BF7\u6C42\u5934" });
637
- return true;
785
+ const contentType = String(headers['content-type'] || '').toLowerCase()
786
+ if (headers['x-dsh-soup'] !== '1' || !contentType.startsWith('application/json')) {
787
+ writeJson(res, 403, { ok: false, error: '缺少必要的请求头' })
788
+ return true
638
789
  }
639
- return false;
790
+ return false
640
791
  }
792
+
793
+ /** HTTP 处理器:请求门 → 解析 body → dispatch → JSON 响应。 */
641
794
  async function handler(req, res) {
642
795
  try {
643
- if (gate(req, res)) return;
644
- const body = await readBody(req);
645
- const result = await handleAction(body);
646
- writeJson(res, 200, result);
796
+ if (gate(req, res)) return
797
+ const body = await readBody(req)
798
+ const result = await handleAction(body)
799
+ writeJson(res, 200, result)
647
800
  } catch (err) {
648
- writeJson(res, err && err.statusCode || 500, { ok: false, error: String(err && err.message || err) });
801
+ writeJson(res, (err && err.statusCode) || 500, { ok: false, error: String((err && err.message) || err) })
649
802
  }
650
803
  }
651
- ctx.effect(
652
- () => ctx.webServer.register({ kind: "exact", path: "/api/dsh-soup", handler }),
653
- "dsh-soup: file http route"
654
- );
655
- ctx.effect(
656
- () => ctx.webServer.register({ kind: "prefix", path: "/api/dsh-soup/img", handler: handleImgRoute }),
657
- "dsh-soup: image inline route"
658
- );
659
- ctx.effect(
660
- () => ctx.webServer.register({ kind: "prefix", path: "/api/dsh-soup/dl", handler: dlHandler }),
661
- "dsh-soup: download route"
662
- );
663
- }
664
804
 
665
- // src/host/index.ts
666
- var name = "ui-dsh-soup";
667
- var inject = ["webServer", "fs", "subprocess", "sandboxPolicy", "sessions", "timer"];
668
- var PLATFORM = process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform === "linux" ? "linux" : "mac";
669
- function apply(ctx) {
670
- const root = ctx.sandboxPolicy && ctx.sandboxPolicy.workspaceRoot || "/";
671
- const { speedStatus } = setupSpeedTracking({ ctx });
672
- setupFiles({ ctx, root, PLATFORM, speedStatus });
805
+ ctx.effect(
806
+ () => ctx.webServer.register({ kind: 'exact', path: '/api/dsh-soup', handler }),
807
+ 'dsh-soup: file http route',
808
+ )
673
809
  }
674
- export {
675
- apply,
676
- inject,
677
- name
678
- };