@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.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/config.js +69 -0
- package/cordis.patch.yml +6 -0
- package/core/cloud.js +55 -0
- package/core/connector-core.js +910 -0
- package/core/dsh-web.js +556 -0
- package/core/keys.js +68 -0
- package/core/path-guard.js +60 -0
- package/core/pending-approvals.js +80 -0
- package/core/push-notifier.js +79 -0
- package/core/session-titles.js +85 -0
- package/core/tunnel.js +607 -0
- package/dist/client.js +556 -0
- package/dist/client.js.map +7 -0
- package/http-api.js +531 -0
- package/index.js +173 -0
- package/mobile-bridge/README.md +33 -0
- package/mobile-bridge/core/bridge-core.js +367 -0
- package/mobile-bridge/core/enumerate.js +44 -0
- package/mobile-bridge/core/files.js +431 -0
- package/mobile-bridge/core/roots.js +50 -0
- package/mobile-bridge/index.js +59 -0
- package/package.json +87 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// packages/remote-connector/core/path-guard.js
|
|
2
|
+
// 完整磁盘权限(fullDisk)门禁:手机被限制为"已登记工作区 ∪ 主机 cwd"范围内
|
|
3
|
+
// (与文件树锚定一致,2026-08 新增)。
|
|
4
|
+
// 浏览(mobile.files.browse)/ 新建目录(mobile.files.mkdir)/ 新建工作区
|
|
5
|
+
// (workspace.create 反代)三处共用本判定:fullDisk=true 直接放行;false 时
|
|
6
|
+
// path 的"最近存在祖先"必须位于允许根之内(mkdir 目标可能尚不存在)。
|
|
7
|
+
// 纯函数 + 真实 fs,可单测。
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
const fsp = fs.promises;
|
|
13
|
+
|
|
14
|
+
/** 向上找"最近存在的祖先"的 realpath(目标可能尚不存在,如 mkdir 新建目录)。 */
|
|
15
|
+
export async function nearestExistingReal(p) {
|
|
16
|
+
let cur = p;
|
|
17
|
+
for (;;) {
|
|
18
|
+
try {
|
|
19
|
+
return await fsp.realpath(cur);
|
|
20
|
+
} catch {
|
|
21
|
+
// 不存在,向上
|
|
22
|
+
}
|
|
23
|
+
const parent = path.dirname(cur);
|
|
24
|
+
if (parent === cur) return null; // 到根仍未找到(理论上不可达,防御)
|
|
25
|
+
cur = parent;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 判定 canonical 路径是否位于任一允许根之内(含根本身)。roots 已 canonical。 */
|
|
30
|
+
export function insideRoots(real, roots) {
|
|
31
|
+
if (typeof real !== 'string' || !real) return false;
|
|
32
|
+
for (const r of roots) {
|
|
33
|
+
if (typeof r !== 'string' || !r) continue;
|
|
34
|
+
if (real === r || real.startsWith(r + path.sep)) return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 权限门禁。返回 null=放行;否则返回错误码('files-root-forbidden')。
|
|
41
|
+
* @param {{path: string, fullDisk: boolean, roots: string[]}} opts
|
|
42
|
+
* roots 为"原始"允许根(工作区路径 ∪ 主机 cwd),内部 canonical 化后匹配。
|
|
43
|
+
*/
|
|
44
|
+
export async function guardPath({ path: p, fullDisk, roots }) {
|
|
45
|
+
if (fullDisk) return null;
|
|
46
|
+
if (typeof p !== 'string' || !p.trim()) return 'files-root-forbidden';
|
|
47
|
+
if (!path.isAbsolute(p.trim())) return 'files-root-forbidden';
|
|
48
|
+
const real = await nearestExistingReal(path.normalize(p.trim()));
|
|
49
|
+
if (!real) return 'files-root-forbidden';
|
|
50
|
+
const canon = [];
|
|
51
|
+
for (const r of roots) {
|
|
52
|
+
if (typeof r !== 'string' || !r) continue;
|
|
53
|
+
try {
|
|
54
|
+
canon.push(await fsp.realpath(r));
|
|
55
|
+
} catch {
|
|
56
|
+
// 允许根不可达(如已删除的工作区)忽略
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return insideRoots(real, canon) ? null : 'files-root-forbidden';
|
|
60
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// packages/remote-connector/core/pending-approvals.js
|
|
2
|
+
// PendingApprovalCache —— connector 侧 pending 审批/提问的镜像缓存(方案 A)。
|
|
3
|
+
//
|
|
4
|
+
// 背景:审批/提问是实时瞬态事件——手机离线期间 relay 直接丢弃 EVENT 帧(无缓冲无重发);
|
|
5
|
+
// DSH 的 pending 重放只在"新 mux 订阅建立"时触发(mux-open replay),而 connector 对
|
|
6
|
+
// dsh web 是单条长连接(手机重连不产生新订阅)→ 手机永远收不到离线期间产生的审批:
|
|
7
|
+
// PC 侧一直挂着(agent 阻塞等待),手机无弹窗、无法响应。
|
|
8
|
+
// 修复:connector 在 dsh 事件流上镜像 pending 审批/提问帧;手机连接恢复时经
|
|
9
|
+
// mobile.approvals.list 拉取,与实时帧同路径分发(全局审批窗照常弹出、响应可回)。
|
|
10
|
+
//
|
|
11
|
+
// 数据来源:dsh web events.mux 的 server-request 帧(method ∈ approval/question 域)。
|
|
12
|
+
// 剪枝:
|
|
13
|
+
// - approval/resolved:按 payload.approvalId 匹配 requested 帧删除;
|
|
14
|
+
// - question/resolved:按 payload.questionRpcId(= 原 requested 帧的 rpcId)删除;
|
|
15
|
+
// - TTL 兜底:resolved 帧只在存活 mux 订阅上广播(broadcast 语义),事件流断线窗口
|
|
16
|
+
// 可能丢失 → TTL 过期清除,防止陈旧条目永久残留(正常路径靠 resolved 帧剪枝);
|
|
17
|
+
// - mux 重开时 clear():DSH 会把全部 pending 重放进新队列(mux-open replay),
|
|
18
|
+
// 等重放重建缓存即可。
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h:resolved 帧丢失时的兜底过期;正常剪枝走 resolved 帧。
|
|
21
|
+
// 2026-08 从 1h 调大:手机端「方案 A」补拉(mobile.approvals.list)读本缓存;TTL 太短会让
|
|
22
|
+
// 超过 1h 仍未处理的审批/提问在手机重连补拉时丢失(角标/弹窗提前消失)。调 24h 后长时间
|
|
23
|
+
// pending 仍能补拉;mux 重开时 DSH 重放当前 pending 会重建缓存,正常解析仍走 resolved 帧剪枝。
|
|
24
|
+
|
|
25
|
+
// 快速预过滤:只命中 method 为审批/提问域的四类帧;其余高频帧(session/event 等)零开销跳过
|
|
26
|
+
const APPROVAL_FRAME_RE = /"method"\s*:\s*"(approval|question)\/(requested|resolved)"/;
|
|
27
|
+
|
|
28
|
+
export class PendingApprovalCache {
|
|
29
|
+
constructor({ ttlMs = DEFAULT_TTL_MS } = {}) {
|
|
30
|
+
this.ttlMs = ttlMs;
|
|
31
|
+
/** @type {Map<string, {frame: object, at: number}>} key: 'approval:<approvalId>' | 'question:<rpcId>' */
|
|
32
|
+
this.entries = new Map();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 事件流帧入口:文本 → 快速预过滤 → JSON 解析 → 更新缓存。 */
|
|
36
|
+
ingest(text) {
|
|
37
|
+
if (typeof text !== 'string') return;
|
|
38
|
+
if (!APPROVAL_FRAME_RE.test(text)) return;
|
|
39
|
+
let frame;
|
|
40
|
+
try {
|
|
41
|
+
frame = JSON.parse(text);
|
|
42
|
+
} catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const method = frame?.method;
|
|
46
|
+
const payload = frame?.payload || {};
|
|
47
|
+
if (method === 'approval/requested' || method === 'question/requested') {
|
|
48
|
+
const key = method === 'approval/requested' ? `approval:${payload.approvalId}` : `question:${frame.rpcId}`;
|
|
49
|
+
if (!key) return;
|
|
50
|
+
this.entries.set(key, { frame, at: Date.now() });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (method === 'approval/resolved') {
|
|
54
|
+
if (payload.approvalId) this.entries.delete(`approval:${payload.approvalId}`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (method === 'question/resolved') {
|
|
58
|
+
if (payload.questionRpcId) this.entries.delete(`question:${payload.questionRpcId}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 拉取入口:返回与实时 mux 帧同形的 server-request 帧数组(含 TTL 惰性清扫)。 */
|
|
63
|
+
list() {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
const frames = [];
|
|
66
|
+
for (const [key, { frame, at }] of this.entries) {
|
|
67
|
+
if (now - at > this.ttlMs) {
|
|
68
|
+
this.entries.delete(key);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
frames.push(frame);
|
|
72
|
+
}
|
|
73
|
+
return { frames };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** mux 重开时清空(DSH 重放全部 pending,缓存随之重建)。 */
|
|
77
|
+
clear() {
|
|
78
|
+
this.entries.clear();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// packages/remote-connector/core/push-notifier.js
|
|
2
|
+
// 在 dsh 事件流上检测"需要通知离线手机"的事件(与 pending-approvals 同风格、同位置被调)。
|
|
3
|
+
// 触发:
|
|
4
|
+
// ① approval/requested、question/requested → kind='approval'
|
|
5
|
+
// ② host/session-status 的 running true→false 边沿 → kind='completion'
|
|
6
|
+
// 只对 offline 集合内的手机触发;每手机×kind 去抖(默认 60s)。
|
|
7
|
+
|
|
8
|
+
const APPROVAL_RE = /"method"\s*:\s*"(approval|question)\/requested"/;
|
|
9
|
+
const STATUS_RE = /"method"\s*:\s*"host\/session-status"/;
|
|
10
|
+
const MAX_RUNNING = 5000; // running 边沿表容量上限(每会话一条,超限整体重置,防无界增长)
|
|
11
|
+
|
|
12
|
+
export function createPushNotifier({ onNotify, throttleMs = 60_000 } = {}) {
|
|
13
|
+
const running = new Map(); // sessionId -> boolean
|
|
14
|
+
const lastAt = new Map(); // `${phonePk}:${kind}` -> ts
|
|
15
|
+
|
|
16
|
+
function emit(offlineSet, kind, sessionId) {
|
|
17
|
+
if (typeof onNotify !== 'function') return;
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
const targets = [];
|
|
20
|
+
for (const phonePk of offlineSet) {
|
|
21
|
+
const key = `${phonePk}:${kind}`;
|
|
22
|
+
if (now - (lastAt.get(key) ?? 0) < throttleMs) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
targets.push(phonePk);
|
|
26
|
+
lastAt.set(key, now);
|
|
27
|
+
}
|
|
28
|
+
if (targets.length === 0) return;
|
|
29
|
+
for (const phonePk of targets) {
|
|
30
|
+
try {
|
|
31
|
+
onNotify(phonePk, kind, sessionId);
|
|
32
|
+
} catch {
|
|
33
|
+
// 推送回调异常静默(推送日志整体不显示,避免刷屏)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ingest(text, offlineSet) {
|
|
39
|
+
if (typeof text !== 'string' || !offlineSet || offlineSet.size === 0) return;
|
|
40
|
+
|
|
41
|
+
if (APPROVAL_RE.test(text)) {
|
|
42
|
+
let f;
|
|
43
|
+
try {
|
|
44
|
+
f = JSON.parse(text);
|
|
45
|
+
} catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (f?.method === 'approval/requested' || f?.method === 'question/requested') {
|
|
49
|
+
emit(offlineSet, 'approval', f?.payload?.sessionId ?? null);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (STATUS_RE.test(text)) {
|
|
55
|
+
let f;
|
|
56
|
+
try {
|
|
57
|
+
f = JSON.parse(text);
|
|
58
|
+
} catch {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const p = f?.payload;
|
|
62
|
+
const sid = typeof p?.sessionId === 'string' ? p.sessionId : null;
|
|
63
|
+
if (!sid || typeof p.running !== 'boolean') return;
|
|
64
|
+
if (running.size >= MAX_RUNNING && !running.has(sid)) running.clear();
|
|
65
|
+
const before = running.get(sid);
|
|
66
|
+
running.set(sid, p.running);
|
|
67
|
+
if (before === true && p.running === false) {
|
|
68
|
+
emit(offlineSet, 'completion', sid);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function clear() {
|
|
74
|
+
running.clear();
|
|
75
|
+
lastAt.clear();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { ingest, clear };
|
|
79
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// packages/remote-connector/core/session-titles.js
|
|
2
|
+
// sessionId → title 轻量缓存(有界 LRU + TTL)。喂两个源:
|
|
3
|
+
// ① session.list 响应(权威;tunnel 反代 session.list 时顺手落表)
|
|
4
|
+
// ② session/projection、session/event 帧(改名/标题更新的增量,防御式提取)
|
|
5
|
+
// 用途:connector 触发推送时给通知带上会话标题;查不到则 relay 用兜底文案。
|
|
6
|
+
|
|
7
|
+
// 快速预过滤:事件流高频帧(assistant/chunk 等)零开销跳过,只在 session/projection、
|
|
8
|
+
// session/event 帧上才 JSON.parse(对齐 pending-approvals / push-notifier 的预过滤模式)。
|
|
9
|
+
const TITLE_FRAME_RE = /"method"\s*:\s*"(session\/projection|session\/event)"/;
|
|
10
|
+
|
|
11
|
+
export function createSessionTitleCache({ maxEntries = 5000, ttlMs = 24 * 3600 * 1000 } = {}) {
|
|
12
|
+
const map = new Map(); // sessionId -> { title, at }
|
|
13
|
+
|
|
14
|
+
function set(sessionId, title) {
|
|
15
|
+
if (typeof sessionId !== 'string' || !sessionId) return;
|
|
16
|
+
if (typeof title !== 'string') return;
|
|
17
|
+
const clean = title.trim();
|
|
18
|
+
if (!clean) return;
|
|
19
|
+
map.delete(sessionId); // LRU:移到最后
|
|
20
|
+
map.set(sessionId, { title: clean, at: Date.now() });
|
|
21
|
+
while (map.size > maxEntries) {
|
|
22
|
+
const oldest = map.keys().next().value;
|
|
23
|
+
map.delete(oldest);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function titleOf(sessionId) {
|
|
28
|
+
if (typeof sessionId !== 'string' || !sessionId) return null;
|
|
29
|
+
const e = map.get(sessionId);
|
|
30
|
+
if (!e) return null;
|
|
31
|
+
if (Date.now() - e.at > ttlMs) {
|
|
32
|
+
map.delete(sessionId);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return e.title;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** session.list 响应 JSON(字符串):items[].sessionId + projections.values.title。 */
|
|
39
|
+
function ingestSessionList(jsonStr) {
|
|
40
|
+
if (typeof jsonStr !== 'string') return;
|
|
41
|
+
let full;
|
|
42
|
+
try {
|
|
43
|
+
full = JSON.parse(jsonStr);
|
|
44
|
+
} catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const items = full?.result?.value?.items;
|
|
48
|
+
if (!Array.isArray(items)) return;
|
|
49
|
+
for (const it of items) {
|
|
50
|
+
const sid = it?.sessionId;
|
|
51
|
+
if (typeof sid !== 'string') continue;
|
|
52
|
+
set(sid, it?.projections?.values?.title);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 事件流帧文本:session/projection、session/event(含 rename)里可能带 title,防御式提取。 */
|
|
57
|
+
function ingest(text) {
|
|
58
|
+
if (typeof text !== 'string' || !TITLE_FRAME_RE.test(text)) return;
|
|
59
|
+
let f;
|
|
60
|
+
try {
|
|
61
|
+
f = JSON.parse(text);
|
|
62
|
+
} catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const method = f?.method;
|
|
66
|
+
if (method !== 'session/projection' && method !== 'session/event') return;
|
|
67
|
+
const p = f?.payload;
|
|
68
|
+
if (!p || typeof p !== 'object') return;
|
|
69
|
+
const sid = typeof p.sessionId === 'string' ? p.sessionId : null;
|
|
70
|
+
if (!sid) return;
|
|
71
|
+
const title =
|
|
72
|
+
typeof p.title === 'string' ? p.title
|
|
73
|
+
: typeof p.values?.title === 'string' ? p.values.title
|
|
74
|
+
: typeof p.projections?.values?.title === 'string' ? p.projections.values.title
|
|
75
|
+
: (p.event && typeof p.event.title === 'string') ? p.event.title
|
|
76
|
+
: null;
|
|
77
|
+
if (title) set(sid, title);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function clear() {
|
|
81
|
+
map.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { titleOf, ingest, ingestSessionList, clear };
|
|
85
|
+
}
|