dsh-mobile-plugin 0.0.0-stage → 0.1.58

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.
Files changed (87) hide show
  1. package/README.md +159 -2
  2. package/config/agent-presets/standard/agent.cordis.yml +170 -0
  3. package/config/agent-presets/standard/preset.yml +3 -0
  4. package/cordis.mobile-host.yml +48 -0
  5. package/cordis.patch.yml +21 -0
  6. package/dsh-mobile-credentials/lib/credentials.js +135 -0
  7. package/dsh-mobile-credentials/package.json +13 -0
  8. package/host.js +128 -0
  9. package/lib/auto-archive.cjs +98 -0
  10. package/lib/client.js +690 -0
  11. package/lib/desktop-half-stats.cjs +101 -0
  12. package/lib/desktop-view.cjs +109 -0
  13. package/lib/host-guard.cjs +230 -0
  14. package/lib/host-patches.cjs +46 -0
  15. package/lib/http/package.json +3 -0
  16. package/lib/http/routing.js +84 -0
  17. package/lib/impl.js +406 -0
  18. package/lib/mirror/pusher.cjs +706 -0
  19. package/lib/mirror-snapshot.cjs +39 -0
  20. package/lib/mobile/agent-presets.js +182 -0
  21. package/lib/mobile/attachments.js +116 -0
  22. package/lib/mobile/auth.js +78 -0
  23. package/lib/mobile/completion.js +114 -0
  24. package/lib/mobile/credentials.js +150 -0
  25. package/lib/mobile/directory-browse.js +295 -0
  26. package/lib/mobile/envelope.js +51 -0
  27. package/lib/mobile/events.js +293 -0
  28. package/lib/mobile/history-derive.js +608 -0
  29. package/lib/mobile/host-bridge/endpoints.js +189 -0
  30. package/lib/mobile/host-bridge/event-synthesis.js +232 -0
  31. package/lib/mobile/host-bridge/shims.js +218 -0
  32. package/lib/mobile/host-bridge/transport.js +168 -0
  33. package/lib/mobile/host-bridge.js +71 -0
  34. package/lib/mobile/host-settings.js +282 -0
  35. package/lib/mobile/idempotency.js +78 -0
  36. package/lib/mobile/jobs.js +97 -0
  37. package/lib/mobile/local-images.js +153 -0
  38. package/lib/mobile/package.json +3 -0
  39. package/lib/mobile/plugins.js +149 -0
  40. package/lib/mobile/questions.js +71 -0
  41. package/lib/mobile/references.js +209 -0
  42. package/lib/mobile/rpc.js +76 -0
  43. package/lib/mobile/session-cache.js +263 -0
  44. package/lib/mobile/session-goal.js +44 -0
  45. package/lib/mobile/session-log.js +94 -0
  46. package/lib/mobile/session-mirror.js +177 -0
  47. package/lib/mobile/session-models.js +153 -0
  48. package/lib/mobile/session-presets.js +52 -0
  49. package/lib/mobile/session-search.js +65 -0
  50. package/lib/mobile/session-send.js +278 -0
  51. package/lib/mobile/session-snapshot.js +142 -0
  52. package/lib/mobile/sessions.js +462 -0
  53. package/lib/mobile/skills.js +80 -0
  54. package/lib/mobile/stream.js +268 -0
  55. package/lib/mobile/subagents.js +265 -0
  56. package/lib/mobile/workspaces.js +214 -0
  57. package/lib/panel/index.html +33 -0
  58. package/lib/read-receipt.cjs +124 -0
  59. package/lib/reconnect-scheduler.cjs +37 -0
  60. package/lib/relay/core.js +77 -0
  61. package/lib/relay/lan-network.cjs +85 -0
  62. package/lib/relay/lan-proxy.js +593 -0
  63. package/lib/relay/package.json +3 -0
  64. package/lib/relay/pairing-policy.js +45 -0
  65. package/lib/relay/port-allocator.cjs +60 -0
  66. package/lib/relay/preview.js +374 -0
  67. package/lib/relay/protocol.js +177 -0
  68. package/lib/relay/status.js +145 -0
  69. package/lib/relay/tunnel.js +721 -0
  70. package/lib/routes/control-plane.js +377 -0
  71. package/lib/routes/mobile-api.js +208 -0
  72. package/lib/routes/package.json +3 -0
  73. package/lib/session-cleanup.cjs +153 -0
  74. package/lib/shared/credentials-entry.js +48 -0
  75. package/lib/shared/dshtoken.js +62 -0
  76. package/lib/shared/fresh-request.js +35 -0
  77. package/lib/shared/http-util.js +71 -0
  78. package/lib/shared/list-revision.js +51 -0
  79. package/lib/shared/list-signal.js +87 -0
  80. package/lib/shared/package.json +3 -0
  81. package/lib/shared/paths.js +27 -0
  82. package/lib/shared/session-signal.js +110 -0
  83. package/lib/shared/workspace-guard.js +127 -0
  84. package/lib/sidecar-facade-worker.cjs +353 -0
  85. package/lib/sidecar-facade.cjs +141 -0
  86. package/lib/sidecar-lifecycle.cjs +246 -0
  87. package/package.json +74 -4
@@ -0,0 +1,39 @@
1
+ 'use strict'
2
+
3
+ // 桌面推送的「移动端镜像快照」新鲜度判定。这一个小模块独立存在,是为了能被 test/ 直接驱动
4
+ // (与 reconnect-scheduler.cjs 同样的做法),而不是把判定散在 sidecar 的读取函数里。
5
+ //
6
+ // 背景(真机缺陷):sidecar 把 /sessions、/workspaces 两个高频读请求直接从这份镜像回,
7
+ // 不去打扰主进程。但镜像**只能靠隧道推来**:
8
+ // - host 侧 pushMirrorPayload 在 `!tunnelConnected()` 时直接短路(lib/index.js:132);
9
+ // - 它依赖的 sidecar `/tunnel/send` 自己也要求 `tunnel && tunnel.connected`,否则 503
10
+ // (sidecar-facade-worker.cjs:186-190)。
11
+ // 于是「中转断掉、局域网仍然健康」时快照被永久冻结在最后一个版本,而 snapshotRead 依旧把
12
+ // 它当权威读:新建的会话/工作区在 App 里**永远不出现**。实测证据(Mac 宿主,kill 掉中转后
13
+ // 走局域网新建并发送):
14
+ // POST /session/new → session-1c9b048a-…,POST /session/send → {"ok":true,"accepted":true}
15
+ // GET /sessions → count 9(与 kill 之前一模一样),新会话 absent,+70s 仍 absent
16
+ // GET /app → 设备侧同样看不到
17
+ // GET /session/messages?sessionId=<新的> → 能读到 user 'SNAPSHOT-STALE-1'
18
+ // 最后一条说明回源主进程的通道是好的(快照里没有这个 sessionId 就自然回源),坏的只是
19
+ // 「列表类读被冻结的镜像顶掉」。
20
+ //
21
+ // 修法:把镜像当**短时代读缓存**,不是权威存储——超过 TTL 就当作缺失,让调用方回源主进程。
22
+ // 隧道连着时快照每几百毫秒就被重推一次(processBgQueue 的 500ms tick),所以 15s 足够长,
23
+ // 快路径照常生效;一旦中转断开,最多 15s 后所有读都回源,数据重新变准。
24
+
25
+ const MIRROR_SNAPSHOT_TTL_MS = 15_000
26
+
27
+ // 只有带可信时间戳 `at` 且在 TTL 内的快照才可用。
28
+ // 缺 `at` 的载荷一律按陈旧处理:host 的三个推送点(lib/index.js:212 / :267 / :302)都会写
29
+ // `at: Date.now()`,没有 `at` 说明这不是预期的镜像载荷,宁可回源也不要拿它当权威。
30
+ // 时间戳来自同一台机器,不存在跨机时钟偏移,所以未来时间戳按不可信处理。
31
+ function isMirrorSnapshotFresh(snapshot, now = Date.now()) {
32
+ if (!snapshot || typeof snapshot !== 'object') return false
33
+ const at = snapshot.at
34
+ if (typeof at !== 'number' || !Number.isFinite(at) || at <= 0) return false
35
+ const age = now - at
36
+ return age >= 0 && age <= MIRROR_SNAPSHOT_TTL_MS
37
+ }
38
+
39
+ module.exports = { MIRROR_SNAPSHOT_TTL_MS, isMirrorSnapshotFresh }
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 移动端「Agent 预设管理」API:把桌面端设置页里那个预设管理分区补齐到手机上。
5
+ *
6
+ * 桌面端由 dsh-client-ui-agent-preset 渲染四个表层,本仓库此前只补了前三个:
7
+ * 1. 设置里的一行 / 新建会话芯片 → 手机:**已下线**。App 侧曾有一颗 composer 的
8
+ * AgentPresetChip(list + select),现已整颗删除 —— 手机端一律吃宿主默认档,
9
+ * `POST /session/select-agent-preset` 路由也一并删除(消费端没了就是死路由)。
10
+ * 2. 会话标题旁的只读标签 → 手机:随 1 一起下线
11
+ * 3. 名单本身 → 手机:GET /agent-presets
12
+ * 4. **管理分区**:名单卡片 + 只读查看器 + 复制对话框 + 删除确认
13
+ * —— 这才是现在手机上唯一与预设有关的界面(app 的 PresetManager)。
14
+ *
15
+ * 三跳接口(都只是转发宿主的 agentPreset 域,转发函数在 lib/mobile/rpc.js,
16
+ * 与会话域的 callSessions 住在一起):
17
+ * - POST /agent-presets/read { agentPreset } → agentPreset.read
18
+ * - POST /agent-presets/copy { from, agentPreset, name? } → agentPreset.copy
19
+ * - POST /agent-presets/remove { agentPreset } → agentPreset.remove
20
+ *
21
+ * 语义按宿主原文透传,不在这里自作主张:
22
+ * - copy 是「整份复制」,标识符会成为目录名且事后无法更改;宿主自己会重校验 id
23
+ * 规则与重名(PresetExistsError → agent-preset-invalid),这里只做便利检查。
24
+ * - remove 只对 user 预设有效:随附预设宿主回 agent-preset-read-only,删不掉。
25
+ * - openDocument 不做。它打开的是**宿主机器**上的目录(与 host.openPath 同性质),
26
+ * 手机点了也看不见;桌面端在没有打开器时(hasDocument:false)本来就是把目录
27
+ * 当文本显示,手机端照此办理即可,不需要这条 RPC。
28
+ *
29
+ * 宿主的失败对象是「code + details」:not-found 带 available[]、read-only 带 reason、
30
+ * invalid 带 reason(可能是 id 非法,也可能是重名),都不是给用户看的文案;
31
+ * 按 code 说中文,同时把原文留在 details.hostMessage 里备查。
32
+ */
33
+
34
+ const { sendJson, readJsonBody } = require('../shared/http-util');
35
+ // apiProxy 的回执信封拆解在 lib/mobile/envelope.js(唯一实现)。
36
+ const { unwrap } = require('./envelope.js');
37
+ const { callAgentPresets } = require('./rpc.js');
38
+
39
+ const PRESET_ID_MAX_CHARS = 64; // 宿主的目录名上限远大于此,这里只是防呆
40
+ const PRESET_NAME_MAX_CHARS = 200; // 显示名上限
41
+ const PRESET_BODY_MAX_CHARS = 256 * 1024; // 预设正文(YAML + 文档)字符数兜底
42
+ const PRESET_MAX_BODY = 64 * 1024; // 请求体字节上限:三个接口都只送几个小字段
43
+
44
+ /**
45
+ * 宿主 id 规则:`[a-z0-9][a-z0-9-]*`。
46
+ * 桌面端的复制对话框复刻了同一条(dsh-client-ui-agent-preset 的 id 校验),宿主会再校验一次。
47
+ */
48
+ const PRESET_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
49
+
50
+ /** 宿主失败码 → HTTP 状态;没见过的码一律 400,不假装成功。 */
51
+ function statusOf(code) {
52
+ if (code === 'agent-preset-not-found') return 404;
53
+ if (code === 'agent-preset-read-only') return 403;
54
+ if (code === 'agent-preset-locked') return 409;
55
+ return 400;
56
+ }
57
+
58
+ /** 宿主失败码 → 中文一句话。 */
59
+ function presetMessage(code, error) {
60
+ if (code === 'agent-preset-not-found') return '这台宿主没有这个 Agent 预设';
61
+ if (code === 'agent-preset-read-only') return '随附预设是安装目录的一部分,不能删除';
62
+ if (code === 'agent-preset-invalid') return '预设标识不合法,或者这个标识已经被占用';
63
+ if (code === 'agent-preset-locked') return '会话已经开跑,Agent 预设固定为开始时的那一档';
64
+ if (code === 'bad-request') return 'Agent 预设请求不合法';
65
+ const message = error && typeof error.message === 'string' ? error.message : '';
66
+ return message || 'Agent 预设请求失败';
67
+ }
68
+
69
+ /** 失败回执:中文 message 在外,宿主原文与 details 原样留在里面。 */
70
+ function failure(error) {
71
+ const code = error && typeof error.code === 'string' && error.code ? error.code : 'internal';
72
+ const details = error && error.details && typeof error.details === 'object' ? error.details : undefined;
73
+ const hostMessage = error && typeof error.message === 'string' && error.message ? error.message : undefined;
74
+ return {
75
+ code,
76
+ message: presetMessage(code, error),
77
+ ...(details || hostMessage ? { details: { ...(details || {}), ...(hostMessage ? { hostMessage } : {}) } } : {}),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * 收窄客户端给的预设标识。
83
+ * @param raw 请求体里的字段。
84
+ * @returns { value } 或 { problem }(problem 是给用户看的中文)。
85
+ */
86
+ function normalizeId(raw) {
87
+ if (typeof raw !== 'string') return { problem: '需要预设标识' };
88
+ const value = raw.trim();
89
+ if (!value) return { problem: '需要预设标识' };
90
+ if (value.length > PRESET_ID_MAX_CHARS) return { problem: `预设标识最长 ${PRESET_ID_MAX_CHARS} 个字符` };
91
+ if (!PRESET_ID_RE.test(value)) return { problem: '预设标识只能用小写字母、数字和连字符,且以字母或数字开头' };
92
+ return { value };
93
+ }
94
+
95
+ /** 显示名可有可无;给了就收窄,别把 undefined / 数字塞给宿主。 */
96
+ function normalizeName(raw) {
97
+ if (raw === undefined || raw === null) return { value: undefined };
98
+ if (typeof raw !== 'string') return { value: undefined };
99
+ const value = raw.trim();
100
+ if (!value) return { value: undefined };
101
+ if (value.length > PRESET_NAME_MAX_CHARS) return { problem: `名称最长 ${PRESET_NAME_MAX_CHARS} 个字符` };
102
+ return { value };
103
+ }
104
+
105
+ /** 读回执里的正文与元数据:缺字段就按缺处理,不编造。 */
106
+ function normalizeRead(value, fallbackId) {
107
+ const id = value && typeof value.agentPreset === 'string' && value.agentPreset ? value.agentPreset : fallbackId;
108
+ const content = value && typeof value.content === 'string' ? value.content : '';
109
+ return {
110
+ agentPreset: id,
111
+ trust: value && value.trust === 'user' ? 'user' : 'system',
112
+ content: content.length > PRESET_BODY_MAX_CHARS ? content.slice(0, PRESET_BODY_MAX_CHARS) : content,
113
+ ...(value && typeof value.name === 'string' && value.name ? { name: value.name } : {}),
114
+ ...(value && typeof value.description === 'string' && value.description ? { description: value.description } : {}),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * POST /api/mobile/v1/agent-presets/read(token){ agentPreset } → { ok, agentPreset, trust, content, name?, description? }
120
+ * 桌面端「查看」:随附预设的正文是副本据以出发的已知良好组装,能读到它正是意义所在。
121
+ */
122
+ async function handleAgentPresetRead(ctx, req, res) {
123
+ const body = await readJsonBody(req, PRESET_MAX_BODY);
124
+ const id = normalizeId(body && body.agentPreset);
125
+ if (!id.value) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: id.problem } });
126
+ const { value, error } = unwrap(await callAgentPresets(ctx, 'read', { agentPreset: id.value }));
127
+ if (error) return sendJson(res, statusOf(error.code), { ok: false, error: failure(error) });
128
+ sendJson(res, 200, { ok: true, ...normalizeRead(value, id.value) });
129
+ }
130
+
131
+ /**
132
+ * POST /api/mobile/v1/agent-presets/copy(token){ from, agentPreset, name? } → { ok, agentPreset }
133
+ * 桌面端「复制预设」:整份复制到本机,标识符即目录名、事后无法更改,所以当场取好。
134
+ */
135
+ async function handleAgentPresetCopy(ctx, req, res) {
136
+ const body = await readJsonBody(req, PRESET_MAX_BODY);
137
+ const from = normalizeId(body && body.from);
138
+ if (!from.value) {
139
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要被复制的预设标识 from' } });
140
+ }
141
+ const id = normalizeId(body && body.agentPreset);
142
+ if (!id.value) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: id.problem } });
143
+ const name = normalizeName(body && body.name);
144
+ if (name.problem) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: name.problem } });
145
+ const payload = { from: from.value, agentPreset: id.value, ...(name.value ? { name: name.value } : {}) };
146
+ const { value, error } = unwrap(await callAgentPresets(ctx, 'copy', payload));
147
+ if (error) return sendJson(res, statusOf(error.code), { ok: false, error: failure(error) });
148
+ const copied = value && typeof value.agentPreset === 'string' && value.agentPreset ? value.agentPreset : id.value;
149
+ sendJson(res, 200, { ok: true, agentPreset: copied });
150
+ }
151
+
152
+ /**
153
+ * POST /api/mobile/v1/agent-presets/remove(token){ agentPreset } → { ok, agentPreset }
154
+ * 只对 user 预设有效:随附预设是安装目录的一部分,宿主回 agent-preset-read-only。
155
+ * 已在其上运行的会话不受影响,只是新会话不能再选它(宿主不改写既有会话)。
156
+ */
157
+ async function handleAgentPresetRemove(ctx, req, res) {
158
+ const body = await readJsonBody(req, PRESET_MAX_BODY);
159
+ const id = normalizeId(body && body.agentPreset);
160
+ if (!id.value) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: id.problem } });
161
+ const { error } = unwrap(await callAgentPresets(ctx, 'remove', { agentPreset: id.value }));
162
+ if (error) return sendJson(res, statusOf(error.code), { ok: false, error: failure(error) });
163
+ sendJson(res, 200, { ok: true, agentPreset: id.value });
164
+ }
165
+
166
+ module.exports = {
167
+ handleAgentPresetRead,
168
+ handleAgentPresetCopy,
169
+ handleAgentPresetRemove,
170
+ // 下面这些是给守卫测试直接驱动的纯函数(与 references.js 同做法)
171
+ normalizeId,
172
+ normalizeName,
173
+ normalizeRead,
174
+ failure,
175
+ unwrap,
176
+ statusOf,
177
+ presetMessage,
178
+ PRESET_ID_RE,
179
+ PRESET_ID_MAX_CHARS,
180
+ PRESET_NAME_MAX_CHARS,
181
+ PRESET_BODY_MAX_CHARS,
182
+ };
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 图片归一:会话历史存的是 `{type:'image', attachment}` 而不是内联字节,
5
+ * 手机端拿到后要经 session attachment RPC 把图片取回来。
6
+ *
7
+ * 宿主事件里图片可能出现在四处:message/content、inserted 上下文项、
8
+ * assistant chunk 的 block,以及更深的嵌套 content。全部在这里归一,
9
+ * 这样历史与 SSE 暴露给手机端的内联图片形状是同一个。
10
+ *
11
+ * 另外一类图片在正文里:`![](/abs/path.png)` 这种指向**宿主本机文件**的写法
12
+ * (本机绝对路径、file://、~)在手机上取不到字节,也会被 App 当成非 http 图片降级成字面
13
+ * markdown。这里顺手按同一形状内联成 data URI(实现与预算见 local-images.js)。
14
+ */
15
+
16
+ const { unwrap } = require('./envelope.js');
17
+ const { callSessions } = require('./rpc.js');
18
+ const { inlineTextImages, createTextImageBudget } = require('./local-images.js');
19
+
20
+ /**
21
+ * Resolve durable image references before exposing messages to mobile clients.
22
+ * Session history stores `{type:'image', attachment}` rather than inline bytes;
23
+ * the mobile client resolves these through the session attachment RPC.
24
+ *
25
+ * Host events can carry content in several places: message/content, inserted
26
+ * context items, or an assistant/chunk block. Keep this normalization here so
27
+ * history and SSE expose the same inline-image shape to mobile clients.
28
+ *
29
+ * Text blocks additionally get local-file image syntax inlined as data URIs
30
+ * (see local-images.js); `opts.budget` is a shared per-response budget so one
31
+ * paged history response or mirror snapshot stays bounded.
32
+ */
33
+ async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
34
+ if (!event || !event.data || typeof event.data !== 'object') return event;
35
+ const data = event.data;
36
+ const budget = opts.budget || createTextImageBudget();
37
+ let nextData = data;
38
+ let eventChanged = false;
39
+
40
+ const resolveBlock = async (block) => {
41
+ if (!block || typeof block !== 'object') return block;
42
+ if (block.type === 'text' && typeof block.text === 'string') {
43
+ const text = await inlineTextImages(block.text, budget);
44
+ return text === block.text ? block : { ...block, text };
45
+ }
46
+ if (block.type === 'image' && !block.data && block.attachment) {
47
+ const attachmentId = block.attachment.attachmentId;
48
+ if (typeof attachmentId !== 'string' || !attachmentId) return block;
49
+ let result;
50
+ try {
51
+ result = unwrap(await callSessions(ctx, 'attachment', { sessionId, attachmentId }));
52
+ } catch {
53
+ return block;
54
+ }
55
+ if (result.error || !result.value || typeof result.value.data !== 'string') return block;
56
+ const attachment = result.value.attachment || block.attachment;
57
+ return {
58
+ type: 'image',
59
+ attachment,
60
+ mediaType: attachment.mediaType || block.attachment.mediaType,
61
+ data: result.value.data,
62
+ ...(typeof attachment.name === 'string' ? { name: attachment.name } : {}),
63
+ };
64
+ }
65
+ if (Array.isArray(block.content)) {
66
+ const nested = await Promise.all(block.content.map(resolveBlock));
67
+ return nested.some((part, index) => part !== block.content[index]) ? { ...block, content: nested } : block;
68
+ }
69
+ return block;
70
+ };
71
+
72
+ const resolveContent = async (content) => {
73
+ if (!Array.isArray(content)) return content;
74
+ const resolved = await Promise.all(content.map(resolveBlock));
75
+ return resolved.some((part, index) => part !== content[index]) ? resolved : content;
76
+ };
77
+
78
+ const replaceContent = async (owner, key = 'content') => {
79
+ if (!owner || typeof owner !== 'object' || !Array.isArray(owner[key])) return;
80
+ const content = await resolveContent(owner[key]);
81
+ if (content === owner[key]) return;
82
+ eventChanged = true;
83
+ return { ...owner, [key]: content };
84
+ };
85
+
86
+ if (data.message && typeof data.message === 'object') {
87
+ const message = await replaceContent(data.message);
88
+ if (message) nextData = { ...nextData, message };
89
+ }
90
+ const root = await replaceContent(nextData);
91
+ if (root) nextData = root;
92
+
93
+ if (Array.isArray(nextData.inserted)) {
94
+ const inserted = await Promise.all(nextData.inserted.map(async (entry) => {
95
+ if (!entry || typeof entry !== 'object') return entry;
96
+ return (await replaceContent(entry)) || entry;
97
+ }));
98
+ if (inserted.some((entry, index) => entry !== nextData.inserted[index])) {
99
+ eventChanged = true;
100
+ nextData = { ...nextData, inserted };
101
+ }
102
+ }
103
+
104
+ const chunk = nextData.chunk;
105
+ if (chunk && typeof chunk === 'object' && chunk.block && typeof chunk.block === 'object') {
106
+ const block = await resolveBlock(chunk.block);
107
+ if (block !== chunk.block) {
108
+ eventChanged = true;
109
+ nextData = { ...nextData, chunk: { ...chunk, block } };
110
+ }
111
+ }
112
+
113
+ return eventChanged ? { ...event, data: nextData } : event;
114
+ }
115
+
116
+ module.exports = { resolveEventAttachments };
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * dsh-mobile 鉴权:移动 token 校验(HMAC,与中转同构)。
5
+ *
6
+ * token 格式 <instanceId>.<exp>.<hmac(lanSecret)>,算法/常数时间比较收在
7
+ * ../shared/dshtoken.js;本模块保留原公开导出面、内部改调共享实现。
8
+ */
9
+
10
+ const fs = require('node:fs');
11
+ const os = require('node:os');
12
+ const path = require('node:path');
13
+
14
+ const sharedToken = require('../shared/dshtoken');
15
+ const credentials = require('../shared/credentials-entry.js').credentialsEntry();
16
+
17
+ // Resolved lazily: freezing the path at module load ignored a later DSH_HOME
18
+ // change and made the lookup order-dependent on import timing.
19
+ function relayStatePath() {
20
+ return require('../shared/paths').relayStateFile();
21
+ }
22
+
23
+ function loadRelayState() {
24
+ try {
25
+ return JSON.parse(fs.readFileSync(relayStatePath(), 'utf8'));
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /** 校验移动 token:格式 <instanceId>.<exp>.<hmac(lanSecret)>,且 instanceId 与当前实例一致。 */
32
+ function verifyMobileToken(token) {
33
+ if (!token) return false;
34
+ const state = loadRelayState();
35
+ if (!state || !state.lanSecret || !state.instanceId) return false;
36
+ return sharedToken.verifyLanToken(state.lanSecret, token, state.instanceId);
37
+ }
38
+
39
+ /**
40
+ * 请求出示的设备凭据(`dshc_…`),取不到返回 null。
41
+ *
42
+ * App 把设备凭据放在独立的 `X-Dsh-Device-Credential` 头里,`Authorization` 留给
43
+ * 宿主 token;`Authorization` 分支仅用于兼容早期客户端。
44
+ */
45
+ function presentedCredential(req) {
46
+ const raw = req.headers['x-dsh-device-credential'];
47
+ const header = Array.isArray(raw) ? raw[0] : raw;
48
+ if (typeof header === 'string' && header.startsWith('dshc_')) return header;
49
+ const m = /^Bearer\s+([^\s]+)$/.exec(req.headers.authorization || '');
50
+ return m && m[1].startsWith('dshc_') ? m[1] : null;
51
+ }
52
+
53
+ /**
54
+ * 请求是否持有有效凭据。两条路径:
55
+ * 1) `Authorization: Bearer <instanceId>.<exp>.<hmac>` —— 与中转同构的 HMAC token;
56
+ * 2) `X-Dsh-Device-Credential: dshc_…` —— 宿主本机凭据库中按 instanceId 核对。
57
+ *
58
+ * 顺序是这里唯一的要点。出示了**本机签发过**的设备凭据时必须以它为准:一经吊销
59
+ * 或过期立即拒绝,不再回退到无状态 token。HMAC token 有效期一年,若允许回退,
60
+ * 吊销就永远不生效,第 2 条也就形同虚设。
61
+ *
62
+ * 「本机不认识这张凭据」则必须回退:公网配对时凭据由中转签发、存在中转的凭据库
63
+ * 里(`dsh-mobile-server/lib/tunnel-handler.js`),宿主这里查不到;若一律拒绝,
64
+ * 「公网配对后回到局域网直连」会直接 401。
65
+ */
66
+ function authorized(req) {
67
+ const presented = presentedCredential(req);
68
+ if (presented) {
69
+ const state = loadRelayState();
70
+ if (!state || !state.instanceId) return false;
71
+ const known = credentials.lookup(presented, state.instanceId);
72
+ if (known) return credentials.isLive(known);
73
+ }
74
+ const m = /^Bearer\s+([^\s]+)$/.exec(req.headers.authorization || '');
75
+ return Boolean(m && verifyMobileToken(m[1]));
76
+ }
77
+
78
+ module.exports = { loadRelayState, presentedCredential, verifyMobileToken, authorized };
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 完成提醒状态机:从宿主会话列表快照与 history 事件日志里确认「跑完了」。
5
+ *
6
+ * 三条铁律写在代码注释里,这里也复述一遍,因为它们各自都防过一次误报:
7
+ * 1. **不**从 running→idle 的状态边沿推断完成。agent-loop 是先 append 最终
8
+ * assistant/message 再 append turn/end,所以必须由 history 折出同一轮的这两个事件
9
+ * 才算完成;
10
+ * 2. `wasRunning` 是进程内边沿标记,只有本次 Host 存活期间观察到的轮次才有资格报完成,
11
+ * 否则冷启动的一份旧列表会把上次开机前的轮次当成新完成弹一遍通知;
12
+ * 3. 一轮的结局由 `turn/end` 的 `reason.kind` 说了算:`error` 记失败(宿主会话列表里
13
+ * 没有任何失败字段,App 侧栏的红点全靠这里折出来),`completed` 才算完成,其余
14
+ * kind(max-tokens / aborted)既不算完成也不算失败。
15
+ */
16
+
17
+ const { completionStateOf } = require('./session-cache.js');
18
+
19
+ /** Update shared completion reminders from one Host session-list snapshot. */
20
+ function updateCompletionStates(ctx, items, deviceKey = 'legacy') {
21
+ const states = completionStateOf(ctx, deviceKey);
22
+ const seen = new Set();
23
+ for (const summary of items || []) {
24
+ if (!summary || typeof summary.sessionId !== 'string' || !summary.sessionId) continue;
25
+ seen.add(summary.sessionId);
26
+ const running = !!summary.running;
27
+ const previous = states.get(summary.sessionId);
28
+ // Do not infer completion from the status edge. The history fold below
29
+ // must prove the matching turn/end plus its final assistant message.
30
+ const completed = previous?.completed === true;
31
+ // `wasRunning` is an internal edge marker. A completed notification is
32
+ // only eligible for a turn observed while this Host was alive; a cold
33
+ // list snapshot must not replay an old turn as a new completion.
34
+ states.set(summary.sessionId, {
35
+ running,
36
+ completed: running ? false : completed,
37
+ turn: previous?.turn,
38
+ wasRunning: previous?.running === true,
39
+ // 「上一轮失败」是历史事实:宿主列表摘要里没有这个信息,唯一来源是下面的 history
40
+ // 折叠。它必须跨快照、跨下一轮延续 —— 否则「失败之后用户又发一句话」会让
41
+ // state.turn 前进,红点还没被看见就没了。
42
+ failure: previous?.failure,
43
+ });
44
+ }
45
+ // The Host list is canonical for this device scope; discard detached
46
+ // sessions so completion state cannot leak across reused session IDs.
47
+ for (const sessionId of states.keys()) {
48
+ if (!seen.has(sessionId)) states.delete(sessionId);
49
+ }
50
+ return states;
51
+ }
52
+
53
+ /**
54
+ * Confirm the running -> idle edge against the event log. Agent status is a
55
+ * coarse activity signal: the agent-loop appends the final assistant/message
56
+ * and only then appends turn/end. Keep the edge tied to that exact turn so a
57
+ * late/stale list response cannot complete a newer (or an older) turn.
58
+ */
59
+ function confirmCompletionFromHistory(state, events, running) {
60
+ if (!state || !Array.isArray(events)) return;
61
+ let latestTurn;
62
+ const ends = new Map();
63
+ const assistants = new Map();
64
+ for (const entry of events) {
65
+ const event = entry?.event || entry;
66
+ if (!event || !event.data || typeof event.data !== 'object') continue;
67
+ const turn = event.data.turn;
68
+ if (typeof turn !== 'number') continue;
69
+ if (event.type === 'turn/start') latestTurn = turn;
70
+ else if (event.type === 'assistant/message') {
71
+ assistants.set(turn, event);
72
+ if (latestTurn === undefined) latestTurn = turn;
73
+ } else if (event.type === 'turn/end') {
74
+ ends.set(turn, event);
75
+ if (latestTurn === undefined) latestTurn = turn;
76
+ }
77
+ }
78
+ if (latestTurn !== undefined) state.turn = latestTurn;
79
+ if (running || !state.wasRunning || state.turn === undefined) return;
80
+ const end = ends.get(state.turn);
81
+ const assistant = assistants.get(state.turn);
82
+ const reason = end && end.data ? end.data.reason : undefined;
83
+ const kind = reason && typeof reason.kind === 'string' ? reason.kind : undefined;
84
+ // 本轮以错误收尾:宿主不会再补一条最终消息,但它刚刚追加过 turn/end —— 下面那条
85
+ // 「assistant 早于 turn/end 就算完成」的规则会把这种轮次判成「已完成待读」,侧栏于是
86
+ // 把一次失败显示成绿点。失败是宿主列表摘要里没有的信息,只能在这里折出来交给 App。
87
+ if (end && kind === 'error') {
88
+ const error = reason && reason.error && typeof reason.error === 'object' ? reason.error : {};
89
+ state.completed = false;
90
+ state.failure = {
91
+ turn: state.turn,
92
+ code: typeof error.code === 'string' ? error.code : null,
93
+ message: typeof error.message === 'string' ? error.message : null,
94
+ at: typeof end.time === 'number' ? end.time : Date.now(),
95
+ };
96
+ return;
97
+ }
98
+ // max-tokens / aborted 等:这一轮没跑完,同样不能报完成;但它没有错误码可显示,
99
+ // 所以只清完成标记,不产生红点。
100
+ if (end && kind !== undefined && kind !== 'completed') {
101
+ state.completed = false;
102
+ state.failure = undefined;
103
+ return;
104
+ }
105
+ // The assistant message must be the settled message for this turn, not an
106
+ // interrupted partial and not merely a preceding turn's message.
107
+ if (end && assistant && assistant.seq <= end.seq && assistant.data.interrupted !== true) {
108
+ state.completed = true;
109
+ // 成功收尾的轮次覆盖旧失败:标记必须跟着最新一次运行走。
110
+ state.failure = undefined;
111
+ }
112
+ }
113
+
114
+ module.exports = { updateCompletionStates, confirmCompletionFromHistory };