@dshfly/remote-connector 0.2.1

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.
@@ -0,0 +1,431 @@
1
+ // core/files.js —— 文件服务(mobile.files.*)。
2
+ //
3
+ // 纯 Node、零依赖、可单测。职责:
4
+ // - files.list:列一层目录(目录优先 + localeCompare 稳定序,200 条/页,hidden 标记)
5
+ // - files.read:UTF-8 文本分页读取(64KB/页,页尾对齐字符边界,单文件预览上限 5MB)
6
+ // - files.mkdir:创建目录(递归,已存在幂等)——"添加工作区"用:手机输入 PC 上
7
+ // 不存在(或已存在)的目录绝对路径,先建目录再 workspace.create(2026-08 新增)
8
+ //
9
+ // 安全:
10
+ // - list/read 锚定(§5,服务端强制):root 必须命中注入的 resolveRoots() 白名单;
11
+ // path 经 realpath 规范化后必须位于 root 之内(符号链接逃逸被拒)
12
+ // - mkdir 是"新目录"写入,目标不在既有工作区白名单内是常态,因此不套用白名单——
13
+ // 信任级与"配对手机可 prompt agent 在 PC 上执行命令"一致(file-tree-plan §5 论证);
14
+ // 仅接受绝对路径,错误消息不携带绝对路径
15
+ // - 仅 UTF-8 文本(fatal 解码,二进制拒绝);错误消息不携带绝对路径
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+
20
+ const fsp = fs.promises;
21
+
22
+ export class FilesError extends Error {
23
+ constructor(code, message) {
24
+ super(message);
25
+ this.code = code;
26
+ }
27
+ }
28
+
29
+ export const FILES_DEFAULTS = {
30
+ pageSize: 200, // list 每页条目数
31
+ pageBytes: 64 * 1024, // read 每页字节数
32
+ maxPreviewBytes: 5 * 1024 * 1024, // 单文件预览上限(文本)
33
+ maxDownloadBytes: 200 * 1024 * 1024, // 单文件下载上限(方案 file-image-download-plan.md §5.4;超出 files-too-large)
34
+ defaultChunkBytes: 512 * 1024, // download 缺省分块大小(客户端可传 length 覆盖)
35
+ };
36
+
37
+ /** 隐藏文件 = POSIX 点前缀(跨平台统一约定;Windows 无此语义,v1 不做平台差异)。 */
38
+ function isHidden(name) {
39
+ return name.startsWith('.') && name !== '.' && name !== '..';
40
+ }
41
+
42
+ /** name 排序:目录优先,同级 localeCompare(稳定序,分页不重不漏)。 */
43
+ function compareEntries(a, b) {
44
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
45
+ return a.name.localeCompare(b.name);
46
+ }
47
+
48
+ /** 扩展名 → MIME(非图片回退分发用;图片由魔数权威判定,不在此表)。 */
49
+ const EXT_MIME = {
50
+ '.txt': 'text/plain', '.md': 'text/markdown', '.log': 'text/plain',
51
+ '.json': 'application/json', '.xml': 'application/xml', '.yaml': 'text/yaml', '.yml': 'text/yaml', '.toml': 'text/x-toml',
52
+ '.csv': 'text/csv', '.html': 'text/html', '.css': 'text/css',
53
+ '.js': 'text/javascript', '.jsx': 'text/javascript', '.ts': 'text/typescript', '.tsx': 'text/typescript',
54
+ '.py': 'text/x-python', '.sh': 'text/x-shellscript', '.bash': 'text/x-shellscript', '.go': 'text/x-go',
55
+ '.rs': 'text/x-rust', '.java': 'text/x-java', '.c': 'text/x-c', '.h': 'text/x-c',
56
+ '.cpp': 'text/x-c++', '.hpp': 'text/x-c++', '.rb': 'text/x-ruby', '.php': 'text/x-php',
57
+ '.swift': 'text/x-swift', '.sql': 'text/x-sql', '.gradle': 'text/x-groovy', '.env': 'text/plain',
58
+ '.pdf': 'application/pdf', '.zip': 'application/zip', '.gz': 'application/gzip',
59
+ '.tar': 'application/x-tar', '.tgz': 'application/gzip', '.rar': 'application/vnd.rar',
60
+ '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
61
+ '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
62
+ '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
63
+ };
64
+
65
+ export class FilesService {
66
+ /**
67
+ * @param {{resolveRoots?: () => Promise<string[]>, pageSize?: number, pageBytes?: number, maxPreviewBytes?: number}} opts
68
+ * resolveRoots:每次调用实时返回允许的根(canonical 绝对路径)集合;未注入时 files.* 停用。
69
+ */
70
+ constructor({ resolveRoots = null, pageSize = FILES_DEFAULTS.pageSize, pageBytes = FILES_DEFAULTS.pageBytes, maxPreviewBytes = FILES_DEFAULTS.maxPreviewBytes, maxDownloadBytes = FILES_DEFAULTS.maxDownloadBytes, defaultChunkBytes = FILES_DEFAULTS.defaultChunkBytes } = {}) {
71
+ this.resolveRoots = resolveRoots;
72
+ this.pageSize = pageSize;
73
+ this.pageBytes = pageBytes;
74
+ this.maxPreviewBytes = maxPreviewBytes;
75
+ this.maxDownloadBytes = maxDownloadBytes;
76
+ this.defaultChunkBytes = defaultChunkBytes;
77
+ this._decoder = new TextDecoder('utf-8', { fatal: true });
78
+ }
79
+
80
+ /** 校验 root 命中白名单,返回 {rootReal}(canonical)。 */
81
+ async _assertRoot(root) {
82
+ if (typeof root !== 'string' || !root) {
83
+ throw new FilesError('files-root-forbidden', 'root 必须是非空路径');
84
+ }
85
+ if (typeof this.resolveRoots !== 'function') {
86
+ throw new FilesError('files-root-forbidden', '文件服务未启用');
87
+ }
88
+ let roots;
89
+ try {
90
+ roots = await this.resolveRoots();
91
+ } catch {
92
+ roots = [];
93
+ }
94
+ if (!Array.isArray(roots)) roots = [];
95
+ const canon = new Set();
96
+ for (const r of roots) {
97
+ if (typeof r !== 'string' || !r) continue;
98
+ try {
99
+ canon.add(await fsp.realpath(r));
100
+ } catch {
101
+ // 白名单里不可达的根(如已删除的工作区)直接忽略
102
+ }
103
+ }
104
+ let rootReal;
105
+ try {
106
+ rootReal = await fsp.realpath(root);
107
+ } catch {
108
+ throw new FilesError('files-root-forbidden', 'root 不可达或未授权');
109
+ }
110
+ if (!canon.has(rootReal)) {
111
+ throw new FilesError('files-root-forbidden', 'root 未授权');
112
+ }
113
+ return { rootReal };
114
+ }
115
+
116
+ /** 把 path 规范化到 root 之内(realpath;越界/符号链接逃逸拒绝)。返回 canonical 绝对路径。 */
117
+ async _resolveInside(rootReal, p) {
118
+ if (typeof p !== 'string' || !p) {
119
+ throw new FilesError('files-not-found', '路径必填');
120
+ }
121
+ const abs = path.isAbsolute(p) ? p : path.join(rootReal, p);
122
+ let real;
123
+ try {
124
+ real = await fsp.realpath(abs);
125
+ } catch {
126
+ throw new FilesError('files-not-found', '路径不存在');
127
+ }
128
+ if (real !== rootReal && !real.startsWith(rootReal + path.sep)) {
129
+ throw new FilesError('files-outside-root', '路径越出工作区');
130
+ }
131
+ return real;
132
+ }
133
+
134
+ /**
135
+ * 一层目录列表。
136
+ * 响应 entries[] 每项携带绝对 path(对齐 host.listDirectory 契约:"the client
137
+ * never joins path segments itself");分页游标 offset 按排序后索引。
138
+ */
139
+ async list({ root, path: p, offset = 0 } = {}) {
140
+ const { rootReal } = await this._assertRoot(root);
141
+ // 缺省列 root;空字符串视为非法(files-not-found),防止客户端误传
142
+ const target = p === undefined || p === null ? rootReal : await this._resolveInside(rootReal, p);
143
+ // parent 语义:相对工作区根(root 本身无上级 → null)
144
+ return this._readDir(target, offset, (t) => t === rootReal);
145
+ }
146
+
147
+ /**
148
+ * 浏览任意目录(mobile.files.browse,2026-08:添加工作区选目录用)。
149
+ * 与 list 的区别:**不做 resolveRoots 白名单锚定**——"添加工作区"的语义就是浏览并
150
+ * 采纳 PC 上任意目录(含新建),锚定既有工作区会锁死该场景;信任级与"配对手机可
151
+ * prompt agent 在 PC 上执行 bash 命令"一致(file-tree-plan §5 论证),仅暴露目录
152
+ * 条目元信息(名称/类型/大小/时间),不读文件内容。
153
+ * 约束:path 必须是绝对路径(缺省 = connector 进程 cwd,通常即 dsh web 主目录);
154
+ * realpath 规范化;目标必须是目录;错误消息不携带绝对路径。
155
+ */
156
+ async browse({ path: p, offset = 0 } = {}) {
157
+ let target;
158
+ if (p === undefined || p === null || p === '') {
159
+ target = process.cwd();
160
+ } else if (typeof p === 'string' && path.isAbsolute(p)) {
161
+ target = path.normalize(p);
162
+ } else {
163
+ throw new FilesError('files-not-found', '路径必须是绝对路径');
164
+ }
165
+ let real;
166
+ try {
167
+ real = await fsp.realpath(target);
168
+ } catch {
169
+ throw new FilesError('files-not-found', '路径不存在');
170
+ }
171
+ let st;
172
+ try {
173
+ st = await fsp.stat(real);
174
+ } catch {
175
+ throw new FilesError('files-not-found', '路径不存在');
176
+ }
177
+ if (!st.isDirectory()) {
178
+ throw new FilesError('files-not-directory', '目标不是目录');
179
+ }
180
+ // parent 语义:相对文件系统根(文件系统根本身无上级 → null)
181
+ return this._readDir(real, offset, (t) => t === path.parse(t).root);
182
+ }
183
+
184
+ /** 列一层目录(list/browse 共用):stat → 目录校验 → readdir → 逐项 stat → 排序 → 分页。
185
+ * parentRoot(t):返回 true 表示 t 是"无上级"的根(list=工作区根,browse=文件系统根)。 */
186
+ async _readDir(target, offset, parentRoot) {
187
+ let st;
188
+ try {
189
+ st = await fsp.stat(target);
190
+ } catch {
191
+ throw new FilesError('files-not-found', '路径不存在');
192
+ }
193
+ if (!st.isDirectory()) {
194
+ throw new FilesError('files-not-directory', '目标不是目录');
195
+ }
196
+ let dirents;
197
+ try {
198
+ dirents = await fsp.readdir(target, { withFileTypes: true });
199
+ } catch {
200
+ throw new FilesError('files-io', '目录读取失败');
201
+ }
202
+
203
+ // 逐项 stat(跟随符号链接):目录/文件分类 + 大小 + 时间;坏链/特殊文件跳过
204
+ const rows = [];
205
+ for (const d of dirents) {
206
+ let cst;
207
+ try {
208
+ cst = await fsp.stat(path.join(target, d.name));
209
+ } catch {
210
+ continue; // 坏链或瞬时 IO:跳过(不破坏整层)
211
+ }
212
+ const type = cst.isDirectory() ? 'dir' : cst.isFile() ? 'file' : null;
213
+ if (!type) continue;
214
+ rows.push({
215
+ name: d.name,
216
+ // 绝对路径(client 端不自行拼接路径段——对齐 host.listDirectory 契约先例)
217
+ path: path.join(target, d.name),
218
+ type,
219
+ size: type === 'file' ? cst.size : 0,
220
+ mtime: cst.mtimeMs,
221
+ hidden: isHidden(d.name),
222
+ });
223
+ }
224
+ rows.sort(compareEntries);
225
+
226
+ const start = Number.isFinite(offset) && offset > 0 ? Math.floor(offset) : 0;
227
+ const page = rows.slice(start, start + this.pageSize);
228
+ const truncated = start + page.length < rows.length;
229
+ return {
230
+ root: target,
231
+ path: target,
232
+ parent: parentRoot(target) ? null : path.dirname(target),
233
+ entries: page,
234
+ truncated,
235
+ nextOffset: truncated ? start + page.length : null,
236
+ };
237
+ }
238
+
239
+ /** UTF-8 文本分页读取(64KB/页,页尾对齐字符边界,预览上限 maxPreviewBytes)。 */
240
+ async read({ root, path: p, offset = 0 } = {}) {
241
+ const { rootReal } = await this._assertRoot(root);
242
+ const target = await this._resolveInside(rootReal, p);
243
+
244
+ let st;
245
+ try {
246
+ st = await fsp.stat(target);
247
+ } catch {
248
+ throw new FilesError('files-not-found', '路径不存在');
249
+ }
250
+ if (!st.isFile()) {
251
+ throw new FilesError('files-not-regular', '目标不是普通文件');
252
+ }
253
+
254
+ const start = Number.isFinite(offset) && offset > 0 ? Math.floor(offset) : 0;
255
+ const end = Math.min(st.size, this.maxPreviewBytes);
256
+ if (start >= end) {
257
+ return {
258
+ root: rootReal, path: target, name: path.basename(target),
259
+ size: st.size, offset: start, content: '', totalRead: end,
260
+ nextOffset: null, truncated: st.size > this.maxPreviewBytes,
261
+ };
262
+ }
263
+
264
+ // 只读 want 字节;页尾若切在多字节字符中间,从尾部逐字节回退(最多 3 字节)
265
+ // 找到合法 UTF-8 边界。绝不把下一页的字节带进本页结果。
266
+ const want = Math.min(this.pageBytes, end - start);
267
+ const buf = Buffer.alloc(want);
268
+ let bytesRead = 0;
269
+ try {
270
+ const fh = await fsp.open(target, 'r');
271
+ try {
272
+ ({ bytesRead } = await fh.read(buf, 0, want, start));
273
+ } finally {
274
+ await fh.close();
275
+ }
276
+ } catch {
277
+ throw new FilesError('files-io', '文件读取失败');
278
+ }
279
+
280
+ let content = null;
281
+ let used = bytesRead;
282
+ for (let trim = 0; trim <= 3 && trim <= bytesRead; trim++) {
283
+ try {
284
+ content = this._decoder.decode(buf.subarray(0, bytesRead - trim));
285
+ used = bytesRead - trim;
286
+ break;
287
+ } catch {
288
+ // 尾部字符不完整,继续回退
289
+ }
290
+ }
291
+ if (content === null) {
292
+ throw new FilesError('files-not-text', '不是可预览的 UTF-8 文本');
293
+ }
294
+
295
+ const totalRead = start + used;
296
+ const truncated = st.size > this.maxPreviewBytes;
297
+ return {
298
+ root: rootReal, path: target, name: path.basename(target),
299
+ size: st.size, offset: start, content, totalRead,
300
+ nextOffset: totalRead < end ? totalRead : null,
301
+ truncated,
302
+ };
303
+ }
304
+
305
+ /** 校验 target 是普通文件,返回其 stat(files-not-found / files-not-regular)。 */
306
+ async _statRegular(target) {
307
+ let st;
308
+ try {
309
+ st = await fsp.stat(target);
310
+ } catch {
311
+ throw new FilesError('files-not-found', '路径不存在');
312
+ }
313
+ if (!st.isFile()) {
314
+ throw new FilesError('files-not-regular', '目标不是普通文件');
315
+ }
316
+ return st;
317
+ }
318
+
319
+ /**
320
+ * 文件元信息(方案 file-image-download-plan.md §4.1):下载/预览前获取
321
+ * 文件名(含扩展名)、总字节、MIME(魔数嗅探判定,非扩展名)、mtime。
322
+ * - mediaType 供客户端决定「文本预览 / 图片查看 / 下载并打开」三路分发(§5.1)。
323
+ * - 锚定同 read(root ∈ 白名单,path ∈ root)。仅 stat + 最多读文件头 12B,开销极小。
324
+ */
325
+ async info({ root, path: p } = {}) {
326
+ const { rootReal } = await this._assertRoot(root);
327
+ const target = await this._resolveInside(rootReal, p);
328
+ const st = await this._statRegular(target);
329
+ const mediaType = await this._sniffMediaType(target);
330
+ return {
331
+ name: path.basename(target),
332
+ size: st.size,
333
+ mediaType: mediaType || 'application/octet-stream',
334
+ mtime: st.mtimeMs,
335
+ };
336
+ }
337
+
338
+ /**
339
+ * 二进制分块读取(方案 file-image-download-plan.md §4.2):返回 [offset, offset+length)
340
+ * 的**原始字节**(Uint8Array)。客户端据 info.size 与已收字节判断是否下载完。
341
+ * - 无 base64、无分块内元数据(信文明文就是文件字节,避业务层 base64,见 §3)。
342
+ * - length 缺省 defaultChunkBytes(512KB);服务端钳到 size-offset。
343
+ * - 单文件超过 maxDownloadBytes → files-too-large(服务端护栏,客户端另行确认)。
344
+ * - 锚定同 read;目标必须是普通文件。
345
+ */
346
+ async download({ root, path: p, offset = 0, length } = {}) {
347
+ const { rootReal } = await this._assertRoot(root);
348
+ const target = await this._resolveInside(rootReal, p);
349
+ const st = await this._statRegular(target);
350
+ if (st.size > this.maxDownloadBytes) {
351
+ throw new FilesError('files-too-large', '文件超过单次下载上限');
352
+ }
353
+ const start = Number.isFinite(offset) && offset > 0 ? Math.floor(offset) : 0;
354
+ if (start >= st.size) return new Uint8Array(0); // 越界 → 空块(客户端据 size 已收满即停)
355
+ const want = Number.isFinite(length) && length > 0 ? Math.floor(length) : this.defaultChunkBytes;
356
+ const n = Math.min(want, st.size - start);
357
+ const buf = Buffer.alloc(n);
358
+ let bytesRead = 0;
359
+ try {
360
+ const fh = await fsp.open(target, 'r');
361
+ try {
362
+ ({ bytesRead } = await fh.read(buf, 0, n, start));
363
+ } finally {
364
+ await fh.close();
365
+ }
366
+ } catch {
367
+ throw new FilesError('files-io', '文件读取失败');
368
+ }
369
+ // Buffer 本身就是 Uint8Array 子类;切出实际读到的那段(避免把未写满的尾字节带出去)
370
+ return new Uint8Array(buf.subarray(0, bytesRead));
371
+ }
372
+
373
+ /** 魔数嗅探媒体类型:优先图片(权威,不信任扩展名);无魔数则按扩展名回退(文本/常见类型)。
374
+ * 仅读前 12 字节。图片类型判定用于客户端 → 图片查看器;文本/常见类型用于 → 文本预览/下载。 */
375
+ async _sniffMediaType(file) {
376
+ let head;
377
+ try {
378
+ const fh = await fsp.open(file, 'r');
379
+ try {
380
+ const b = Buffer.alloc(12);
381
+ await fh.read(b, 0, 12, 0);
382
+ head = b;
383
+ } finally {
384
+ await fh.close();
385
+ }
386
+ } catch {
387
+ return null;
388
+ }
389
+ const eq = (sig) => sig.every((v, i) => head[i] === v);
390
+ // 图片(魔数)
391
+ if (eq([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png';
392
+ if (eq([0xff, 0xd8, 0xff])) return 'image/jpeg';
393
+ if (eq([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) || eq([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) return 'image/gif';
394
+ if (eq([0x42, 0x4d])) return 'image/bmp';
395
+ if (String.fromCharCode(...head.subarray(0, 4)) === 'RIFF' && String.fromCharCode(...head.subarray(8, 12)) === 'WEBP') return 'image/webp';
396
+ if (String.fromCharCode(...head.subarray(4, 8)) === 'ftyp') {
397
+ const brand = String.fromCharCode(...head.subarray(8, 12));
398
+ if (['heic', 'heix', 'hevc', 'mif1', 'avif', 'heif'].includes(brand)) return 'image/heic';
399
+ }
400
+ // 非图片:按扩展名回退(用于文本/常见类型分发)
401
+ const ext = path.extname(file).toLowerCase();
402
+ return EXT_MIME[ext] || null;
403
+ }
404
+
405
+ /**
406
+ * 创建目录(递归,已存在幂等)。"添加工作区"流程:手机输入 PC 目录绝对路径 →
407
+ * mkdir(不存在则连同父目录一并创建)→ workspace.create 采纳。
408
+ * 不套用 resolveRoots 白名单(目标通常是"新"目录);仅绝对路径 + 错误不携带路径。
409
+ */
410
+ async mkdir({ path: p } = {}) {
411
+ if (typeof p !== 'string' || !p.trim()) {
412
+ throw new FilesError('files-mkdir-failed', '路径必填');
413
+ }
414
+ if (!path.isAbsolute(p.trim())) {
415
+ throw new FilesError('files-mkdir-failed', '路径必须是绝对路径');
416
+ }
417
+ const target = path.normalize(p.trim());
418
+ try {
419
+ await fsp.mkdir(target, { recursive: true });
420
+ } catch (e) {
421
+ throw new FilesError('files-mkdir-failed', e?.code === 'EACCES' ? '没有权限创建目录' : '创建目录失败');
422
+ }
423
+ let real;
424
+ try {
425
+ real = await fsp.realpath(target);
426
+ } catch {
427
+ real = target;
428
+ }
429
+ return { path: real };
430
+ }
431
+ }
@@ -0,0 +1,50 @@
1
+ // core/roots.js —— 文件树根白名单解析器(方案 docs/file-tree-plan.md §3/§5 + 审计 P1-1/D4)。
2
+ //
3
+ // 宿主(remote-connector 插件)注入给 MobileBridgeCore 的 resolveRoots 实现:
4
+ // 每次调用经本机 loopback /api(与手机同一 client-request 信封协议,对齐
5
+ // plugin-myassistant 的自环先例)实时取:
6
+ // - workspace.list → 已登记工作区 canonical path
7
+ // 工作区增删 / 换 PC 即时生效,不做缓存。
8
+ //
9
+ // ⚠️ D4(审计 P1-1):host.describe 的 cwd 不再并入允许根——dsh web 常从用户家目录启动
10
+ // (cwd=home 会让"受限"手机(fullDisk=false)经 files.list/read
11
+ // 读取 ~/.ssh、~/.aws 等整个家目录,使"不允许完整磁盘"开关名存实亡。受限手机可见范围
12
+ // = **仅已登记工作区**(cwd 若本身就是已登记工作区则天然在根内)。
13
+ // 任何失败(loopback 不可达、网关报错)→ 返回空数组 → 所有 files.* 请求
14
+ // 以 files-root-forbidden 拒绝(fail-closed)。
15
+
16
+ /** 构造 loopback 根解析器。fetchImpl 可注入(单测)。 */
17
+ export function createLoopbackRootsResolver({
18
+ baseUrl = 'http://127.0.0.1:3080',
19
+ fetchImpl = globalThis.fetch,
20
+ timeoutMs = 3000,
21
+ } = {}) {
22
+ const base = baseUrl.replace(/\/$/, '');
23
+ let counter = 0;
24
+
25
+ async function rpc(method) {
26
+ const rpcId = `fs-roots-${Date.now()}-${++counter}`;
27
+ const res = await fetchImpl(`${base}/api/${method}`, {
28
+ method: 'POST',
29
+ headers: { 'content-type': 'application/json' },
30
+ body: JSON.stringify({ type: 'client-request', rpcId, method, payload: {} }),
31
+ signal: AbortSignal.timeout(timeoutMs),
32
+ });
33
+ if (!res.ok) throw new Error(`loopback ${method} -> HTTP ${res.status}`);
34
+ const full = await res.json();
35
+ if (!full || full.rpcId !== rpcId || !full.result?.ok) {
36
+ throw new Error(`loopback ${method} -> ${full?.result?.error?.code ?? 'bad envelope'}`);
37
+ }
38
+ return full.result.value;
39
+ }
40
+
41
+ /** resolveRoots:Promise<string[]>(fail-closed,永不抛)。仅已登记工作区(D4)。 */
42
+ return async function resolveRoots() {
43
+ try {
44
+ const ws = await rpc('workspace.list');
45
+ return (ws?.items ?? []).map((i) => i?.path).filter((p) => typeof p === 'string' && p);
46
+ } catch {
47
+ return [];
48
+ }
49
+ };
50
+ }
@@ -0,0 +1,59 @@
1
+ // packages/remote-connector/mobile-bridge/index.js
2
+ // DSH 移动端插件规范实现(M4.x-c,2026-08-17 整合为纯库;现已并入 @dshfly/remote-connector)。
3
+ // 原 @dshfly/mobile-bridge 已不再作为独立 publish 包——由 remote-connector 承载。
4
+ import os from 'node:os';
5
+ //
6
+ // 整合决策(方案 1b):本包不再是独立 cordis 插件——`ctx.mobileBridge` 服务由
7
+ // `@dshfly/remote-connector` 承载(它拥有传输层,是服务的天然所有者)。本包只提供:
8
+ // - MobileBridgeCore(枚举/注册表/RPC/事件,纯 Node 可单测)
9
+ // - createMobileBridgeService(把 core 包装成服务对象,connector 负责 ctx.provide)
10
+ // - resolveProfileDir(profile 目录解析)
11
+ // 收益:bridge 无法被单独安装(非 bundle,"装了没用"的状态空间消失);装 connector
12
+ // 一个命令即带出;无插件加载时序问题。插件侧契约不变:ctx.mobileBridge.register。
13
+ // 规范实现的独立边界保留:未来 DSH 上游若吸收移动端规范,本库可直接上贡。
14
+
15
+ export { MobileBridgeCore, MobileBridgeError } from './core/bridge-core.js';
16
+ export { readProfileBundles, resolveBundleDir } from './core/enumerate.js';
17
+ export { FilesService, FilesError } from './core/files.js';
18
+ export { createLoopbackRootsResolver } from './core/roots.js';
19
+
20
+ /** 把 core 包装成服务对象(connector apply 里 ctx.provide('mobileBridge', ...) 用;也便于测试)。 */
21
+ export function createMobileBridgeService(core) {
22
+ return {
23
+ listPlugins: () => core.listPlugins(),
24
+ getHome: (pluginId, params) => core.getHome(pluginId, params),
25
+ invoke: (pluginId, actionId, params) => core.invoke(pluginId, actionId, params),
26
+ subscribe: (pluginId) => core.subscribe(pluginId),
27
+ openChat: (pluginId, sessionId) => core.openChat(pluginId, sessionId),
28
+ register: (opts) => core.register(opts || {}),
29
+ unregister: (pluginId) => core.unregister(pluginId),
30
+ emit: (pluginId, name, payload) => core.emit(pluginId, name, payload),
31
+ onEvent: (sink) => core.onEvent(sink),
32
+ handleRpc: (method, payload) => core.handleRpc(method, payload),
33
+ // 方案 A:pending 审批/提问缓存数据源注入(connector 隧道持有)
34
+ setPendingApprovalProvider: (fn) => core.setPendingApprovalProvider(fn),
35
+ // 2026-08:中继用量/配额数据源注入(connector 持 deviceToken 查 relay /api/v1/usage)
36
+ setRelayUsageProvider: (fn) => core.setRelayUsageProvider(fn),
37
+ health: () => core.health,
38
+ };
39
+ }
40
+
41
+ /** 解析 profile 目录(默认 <dshHome>/profiles/<profileName>,profileName 默认 'web')。
42
+ * 2026-08 修复:pnpm 符号链接布局下,源码包动态 import('@deepseek-ai/dsh-home-paths') 从
43
+ * 仓库真实路径解析不到该依赖(它只装在 profile 安装目录,未提升到仓库根)→ 原 catch 返回 null,
44
+ * 导致 enumerate 跳过、插件 staticSource 缺失(App 插件 ID 回退短 id)。DSH 默认 home = ~/.dsh
45
+ * (resolveDshHome 同值),追加 os.homedir() 兜底,免依赖该 import。 */
46
+ export function resolveProfileDir({ profileDir = '', profileName = 'web' } = {}) {
47
+ if (profileDir) return profileDir;
48
+ try {
49
+ const { resolveDshHome } = awaitImportDshHome();
50
+ return `${resolveDshHome()}/profiles/${profileName}`;
51
+ } catch {
52
+ return `${os.homedir()}/.dsh/profiles/${profileName}`;
53
+ }
54
+ }
55
+
56
+ // 延迟 import(单测等非宿主环境不强制解析 dsh-home-paths;connector 运行时正常解析)
57
+ async function awaitImportDshHome() {
58
+ return import('@deepseek-ai/dsh-home-paths');
59
+ }
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "@dshfly/remote-connector",
3
+ "version": "0.2.1",
4
+ "type": "module",
5
+ "description": "DSH Fly remote connector as a DeepSeek Harness (DSH) cordis plugin — outbound relay tunnel + E2EE endpoint + loopback proxy into dsh web",
6
+ "keywords": [
7
+ "dsh",
8
+ "deepseek-harness",
9
+ "dshfly",
10
+ "remote",
11
+ "e2ee",
12
+ "cordis"
13
+ ],
14
+ "exports": {
15
+ ".": "./index.js",
16
+ "./client": "./dist/client.js",
17
+ "./core": "./core/connector-core.js",
18
+ "./keys": "./core/keys.js",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "index.js",
23
+ "config.js",
24
+ "http-api.js",
25
+ "core",
26
+ "dist",
27
+ "mobile-bridge",
28
+ "!mobile-bridge/test",
29
+ "cordis.patch.yml"
30
+ ],
31
+ "dependencies": {
32
+ "@deepseek-ai/dsh-home-paths": "0.1.0-rc.6",
33
+ "@deepseek-ai/schemastery": "^3.18.1",
34
+ "qrcode": "^1.5.4",
35
+ "ws": "^8.21.3",
36
+ "@dshfly/tunnel-protocol": "0.1.0",
37
+ "@dshfly/mobile-plugin-schema": "0.1.0",
38
+ "@dshfly/crypto": "0.1.0"
39
+ },
40
+ "peerDependencies": {
41
+ "@deepseek-ai/cordis": ">=4.0.0",
42
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.6",
43
+ "react": "^18.2.0 || ^19.0.0",
44
+ "react-dom": "^18.2.0 || ^19.0.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@deepseek-ai/cordis": {
48
+ "optional": true
49
+ },
50
+ "@deepseek-ai/dsh-host-webserver": {
51
+ "optional": true
52
+ },
53
+ "react": {
54
+ "optional": true
55
+ },
56
+ "react-dom": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "devDependencies": {
61
+ "esbuild": "^0.25.0",
62
+ "@dshfly/plugin-notifications": "0.1.0",
63
+ "@dshfly/plugin-myassistant": "0.1.0"
64
+ },
65
+ "engines": {
66
+ "node": ">=22"
67
+ },
68
+ "dsh": {
69
+ "bundle": {
70
+ "patch": "./cordis.patch.yml"
71
+ },
72
+ "client": {
73
+ "inject": [
74
+ "@deepseek-ai/dsh-client-runtime",
75
+ "@deepseek-ai/dsh-client-locale",
76
+ "@deepseek-ai/dsh-client-ui-primitives",
77
+ "@deepseek-ai/dsh-client-ui-settings"
78
+ ],
79
+ "platform": "web"
80
+ }
81
+ },
82
+ "scripts": {
83
+ "build:client": "node scripts/build-client.mjs",
84
+ "test": "node --test test/*.test.js test/mobile-bridge/test/*.test.js",
85
+ "build": "node scripts/build-client.mjs"
86
+ }
87
+ }