dsh-mobile-plugin 0.2.0 → 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/README.md +1 -1
- package/lib/impl.js +9 -4
- package/lib/index.js +2 -1
- package/lib/mobile/credential-upgrade.js +11 -3
- package/lib/mobile/history-derive.js +52 -13
- package/lib/mobile/host-bridge/event-synthesis.js +21 -9
- package/lib/mobile/relay-control.js +13 -0
- package/lib/mobile/session-cache.js +41 -1
- package/lib/mobile/session-file.js +57 -32
- package/lib/mobile/session-send.js +9 -24
- package/lib/mobile/session-snapshot.js +5 -6
- package/lib/mobile/stream.js +281 -15
- package/lib/mobile/subagents.js +11 -2
- package/lib/mobile/uploads.js +12 -2
- package/lib/panel/index.html +1 -1
- package/lib/relay/core.js +3 -0
- package/lib/relay/lan-network.cjs +47 -0
- package/lib/routes/control-plane.js +4 -0
- package/lib/routes/mobile-api.js +6 -3
- package/lib/shell/updater.js +8 -1
- package/lib/update-install.cjs +13 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -138,7 +138,7 @@ npm test
|
|
|
138
138
|
1. 跑 `node scripts/bump-version.mjs` 更新版本号(演示页的 `DEMO_VERSION` 会一起改,别手改)。
|
|
139
139
|
2. 改过 `React/` 就跑 `cd React && pnpm run build`。
|
|
140
140
|
3. 提交这次发版该带的文件,别 `git add -A`。
|
|
141
|
-
4. 在**主仓**的插件目录跑 `npm run release:check`。它按固定顺序查六件事:是不是在主仓里(跨仓对账要读 `app/`)、版本号与 tag(三段 `0..999`、这个 tag
|
|
141
|
+
4. 在**主仓**的插件目录跑 `npm run release:check`。它按固定顺序查六件事:是不是在主仓里(跨仓对账要读 `app/`)、版本号与 tag(三段 `0..999`、这个 tag 没打过、不比上一版低)、工作区干不干净、面板产物跟不跟得上源码(拿当前源码真重建一次,与盘上那份逐字节比)、整套测试、真打一次包看文件清单。任何一步红就停,并说明该修哪里。**它要求在提交之后跑**,因为查的是「发出去的那一份」。
|
|
142
142
|
5. 把提交同步到公开仓库 `WNLUO/dsh-mobile-plugin`。
|
|
143
143
|
6. 推一个与版本号同名的 tag(版本 0.1.99 就推 `v0.1.99`)。那个仓库的 `.github/workflows/publish.yml` 会跑测试并直接发布,不需要人在浏览器里点二次验证 —— 它走 npm 的 trusted publishing(OIDC),npm 认的是这条流水线而不是某个令牌。
|
|
144
144
|
7. 等流水线绿。它末尾自己会核 `npm view dsh-mobile-plugin version`,你也可以手动看一眼。
|
package/lib/impl.js
CHANGED
|
@@ -27,7 +27,7 @@ const { viaChannel } = require('./shared/via-header')
|
|
|
27
27
|
const { createSidecarLifecycle } = require('./sidecar-lifecycle.cjs')
|
|
28
28
|
const { createMirrorPusher } = require('./mirror/pusher.cjs')
|
|
29
29
|
const { randomBytes } = require('node:crypto')
|
|
30
|
-
const { lanUrls, advertisedCandidates,
|
|
30
|
+
const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates } = require('./relay/lan-network.cjs')
|
|
31
31
|
const { resolvePort } = require('./relay/port-allocator.cjs')
|
|
32
32
|
const { createPreviewService } = require('./relay/preview.js')
|
|
33
33
|
// 本机设备凭据库:/api/relay/revoke-all 必须同时吊销局域网直连的 dshc_ 凭据,
|
|
@@ -98,6 +98,8 @@ export const RemoteControlSettingsSchema = z.object({
|
|
|
98
98
|
// 手机优先用哪条地址:空 = 不指定(按宿主网卡优先级排)。选择只挪候选顺序、不删候选,
|
|
99
99
|
// 理由与边界见 relay/lan-network.cjs 的 orderCandidates 与 relay.json 那条地图条目。
|
|
100
100
|
preferredLanAddress: z.string().max(64).default(''),
|
|
101
|
+
// 用户手填的直连地址(`manualLanUrl`,内网穿透域名)也随二维码一起走,见 lan-network.cjs。
|
|
102
|
+
manualLanUrl: z.string().max(200).default(''),
|
|
101
103
|
})
|
|
102
104
|
|
|
103
105
|
|
|
@@ -115,6 +117,7 @@ function configOf(state) {
|
|
|
115
117
|
// 不会把它回显给手机、二维码或日志。
|
|
116
118
|
registerSecret: typeof state.registerSecret === 'string' ? state.registerSecret : '',
|
|
117
119
|
preferredLanAddress: typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : '',
|
|
120
|
+
manualLanUrl: typeof state.manualLanUrl === 'string' ? state.manualLanUrl : '',
|
|
118
121
|
}
|
|
119
122
|
}
|
|
120
123
|
|
|
@@ -173,7 +176,9 @@ function createConfigStore(state) {
|
|
|
173
176
|
function pairingPayload(state, lanPort) {
|
|
174
177
|
const port = Number.isInteger(lanPort) ? lanPort : state.lanPort
|
|
175
178
|
// 面板上选过的「手机优先用这条」在这里兑现:只挪到最前,其余继续当兜底(App 按顺序挨个试)。
|
|
176
|
-
const candidates = state.lanEnabled
|
|
179
|
+
const candidates = state.lanEnabled
|
|
180
|
+
? phoneCandidates(advertisedCandidates(port), { preferred: state.preferredLanAddress, manual: state.manualLanUrl })
|
|
181
|
+
: []
|
|
177
182
|
const urls = candidates.map(candidate => candidate.url).filter(Boolean)
|
|
178
183
|
const relayUrl = state.relayUrl || ''
|
|
179
184
|
const payload = {
|
|
@@ -468,8 +473,8 @@ function apply(ctx) {
|
|
|
468
473
|
// 一进来就 ReferenceError。同一个 apply() 里给 createStatusView 传的是
|
|
469
474
|
// `sidecarLifecycle.getSnapshot`,两处不一致才让这个笔误活了下来。
|
|
470
475
|
getSidecarSnapshot: sidecarLifecycle.getSnapshot,
|
|
471
|
-
//
|
|
472
|
-
safeLanCandidates: advertisedCandidates,
|
|
476
|
+
// 移动端那边的「局域网候选」:私网优先的清单 + 用户手填的直连地址(网卡扫不出的域名)。
|
|
477
|
+
safeLanCandidates: (port) => phoneCandidates(advertisedCandidates(port), { manual: state.manualLanUrl }),
|
|
473
478
|
// IPv6 直连候选单独一份:App 拿它单开一条通道、和公网中转比速度,不掺进上面那份的排序。
|
|
474
479
|
safeIpv6Candidates: ipv6Candidates,
|
|
475
480
|
lanPortInUse: advertisedLanPort, // LAN 候选用实绑端口
|
package/lib/index.js
CHANGED
|
@@ -158,7 +158,8 @@ export function apply(ctx) {
|
|
|
158
158
|
.then(() => {
|
|
159
159
|
log('info', 'dsh-mobile-plugin: 已装载 %s(来源 %s)', runtime.status().version, runtime.status().source)
|
|
160
160
|
// 每 10 分钟让已落盘的正式版接管 OTA,并补上「盘上比运行中新」的那一次换代;
|
|
161
|
-
//
|
|
161
|
+
// 自动安装仍由开关控制,开着就**不等正在跑的对话**(`auto` 那一档,见 update-install)。
|
|
162
|
+
// 补换代不受开关管:它不下载任何东西,只是让已经在盘上的代码生效。
|
|
162
163
|
const timer = setInterval(() => {
|
|
163
164
|
deviceOta.reconcileOfficial()
|
|
164
165
|
.then(() => updater.reconcileInstalledGeneration({ runtime, log }))
|
|
@@ -14,10 +14,16 @@
|
|
|
14
14
|
* 不这么做就只能让用户重新扫一次码。升级之后所有流量仍由这把钥匙端到端加密。
|
|
15
15
|
*
|
|
16
16
|
* 换成什么不在本次判断里:拿到完整一套(凭据 + 密钥 + 标识)才回补丁,缺一样就当没升级。
|
|
17
|
+
*
|
|
18
|
+
* **请求体必须现读**(2026-10-25 修):这条路服务的恰好是协议 v1 的老会话,而老会话发的
|
|
19
|
+
* 是明文 —— 守卫只为「带信封」的那条路预先解好 `req.body`,明文请求的字节还挂在 socket 上
|
|
20
|
+
* 没人读过。原来这里直接取 `req.body`,于是永远拿到空对象、宿主每次都回「没升级」,手机
|
|
21
|
+
* 那边一次都没成功过(真机表现是「App 与插件都更新了,设备行仍旧写链路未加密」)。
|
|
22
|
+
* 现在与其它移动端路由同一条读法(`readJsonBody`:`req.body` 有就用,没有就读流)。
|
|
17
23
|
*/
|
|
18
24
|
|
|
19
25
|
const crypto = require('node:crypto');
|
|
20
|
-
const { sendJson } = require('../shared/http-util.js');
|
|
26
|
+
const { sendJson, readJsonBody } = require('../shared/http-util.js');
|
|
21
27
|
const { DEVICE_KEY_RE, KEY_ID_RE } = require('../shared/e2ee-pair.js');
|
|
22
28
|
|
|
23
29
|
/** 请求出示的设备凭据:独立头优先,`Authorization` 只用于兼容早期客户端。 */
|
|
@@ -51,14 +57,16 @@ function deviceSecretOf(body) {
|
|
|
51
57
|
* @param {object} res
|
|
52
58
|
* @param {{ instanceId: string, store: object, log?: Function }} deps
|
|
53
59
|
*/
|
|
54
|
-
function handleCredentialUpgrade(req, res, deps) {
|
|
60
|
+
async function handleCredentialUpgrade(req, res, deps) {
|
|
55
61
|
const instanceId = String((deps && deps.instanceId) || '');
|
|
56
62
|
const store = deps && deps.store;
|
|
57
63
|
if (!store) return sendJson(res, 503, { ok: false, error: { code: 'unavailable', message: '凭据库不可用' } });
|
|
58
64
|
|
|
59
65
|
const presented = presentedCredential(req);
|
|
60
66
|
const record = presented ? store.lookup(presented, instanceId) : null;
|
|
61
|
-
|
|
67
|
+
// 见文件头:明文请求的体没人读过,必须在这里现读(读法与其它移动端路由一致)。
|
|
68
|
+
const parsed = await readJsonBody(req, 64 * 1024);
|
|
69
|
+
const body = parsed && typeof parsed === 'object' ? parsed : {};
|
|
62
70
|
const meta = body.device && typeof body.device === 'object' ? body.device : body;
|
|
63
71
|
const deviceId = String(meta.deviceId || meta.id || (record && record.deviceId) || '');
|
|
64
72
|
|
|
@@ -170,8 +170,14 @@ function buildToolResultMap(events) {
|
|
|
170
170
|
* `items` 里的 tool-call 块本来就是同一张表构出来的,输出/图片/diff 再传一遍等于同一份
|
|
171
171
|
* 工具输出在一条响应里走两趟。客户端打补丁时状态照写、正文只在给了的时候才覆盖,所以
|
|
172
172
|
* 那几格省掉是安全的;消息块不在这份窗口里时才带全。
|
|
173
|
+
*
|
|
174
|
+
* `bodies: false`(增量拉取)连「消息块不在这份窗口里」的那些也只回状态。理由是同一个:
|
|
175
|
+
* 增量拉取的调用方手上已经有一份完整的会话,缺的只是「这几次调用结束了没有」;把整窗的
|
|
176
|
+
* 工具输出再灌一遍,等于每 15 秒把最近两百条消息里的工具输出重发一次(读文件、跑命令的
|
|
177
|
+
* 输出几十到几百 KB 很常见)。要全文的那条路是入口整份拉取(无游标)与展开时重新拉。
|
|
173
178
|
*/
|
|
174
|
-
function toolResultPatchesFor(events, items) {
|
|
179
|
+
function toolResultPatchesFor(events, items, options = {}) {
|
|
180
|
+
const bodies = options.bodies !== false;
|
|
175
181
|
const delivered = new Set();
|
|
176
182
|
for (const item of items || []) {
|
|
177
183
|
if (!item || !Array.isArray(item.blocks)) continue;
|
|
@@ -183,7 +189,7 @@ function toolResultPatchesFor(events, items) {
|
|
|
183
189
|
}
|
|
184
190
|
return [...buildToolResultMap(events).entries()].map(([callId, result]) => {
|
|
185
191
|
const state = result.isError ? (result.code === 'interrupted' ? 'stopped' : 'error') : 'ok';
|
|
186
|
-
if (delivered.has(callId)) return { callId, state };
|
|
192
|
+
if (!bodies || delivered.has(callId)) return { callId, state };
|
|
187
193
|
return {
|
|
188
194
|
callId,
|
|
189
195
|
state,
|
|
@@ -373,6 +379,19 @@ function deriveMessages(events) {
|
|
|
373
379
|
if (bucket.length > 0) presentedByTurn.set(data.turn, bucket);
|
|
374
380
|
}
|
|
375
381
|
|
|
382
|
+
/**
|
|
383
|
+
* 这一轮**被 write / edit 动过**的文件,按轮攒。
|
|
384
|
+
*
|
|
385
|
+
* 为什么按轮而不是按单条消息:一份文件是一轮里某一步写的,而收尾答复那条消息自己一个
|
|
386
|
+
* 写操作都没有 —— 按消息算的话,正文里提到刚写好的文件时那份清单永远是空的(2026-10-25
|
|
387
|
+
* 现场:手机上那个文件名一直是灰的,数一遍 54 条消息只有 4 条带得动这份清单)。
|
|
388
|
+
* 桌面 `dsh-client-ui-deliverables` 的 `selectProducedFiles(owner)` 也是按**整轮**取。
|
|
389
|
+
*
|
|
390
|
+
* 攒完在循环之后回填:同一轮里先发的消息也要能看到这一轮后几步才写出来的文件。
|
|
391
|
+
*/
|
|
392
|
+
const producedByTurn = new Map();
|
|
393
|
+
const producedItems = [];
|
|
394
|
+
|
|
376
395
|
// 单步计时预扫描:为每条 assistant 消息派生「本轮」的 ttft / decode / 输出 token
|
|
377
396
|
// (对齐桌面 turn-metrics/assistant-timing:ttftMs = 首个非空 delta − step/start;
|
|
378
397
|
// decodeMs = 落定 − 首个 delta;outputTokens = assistant/message.usage.outputTokens)。
|
|
@@ -490,17 +509,27 @@ function deriveMessages(events) {
|
|
|
490
509
|
seq: e.seq,
|
|
491
510
|
time: e.time,
|
|
492
511
|
};
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
512
|
+
// 这一轮的轮号(老日志没有):产出清单与交付声明都按它归并。
|
|
513
|
+
const turn = e.data && typeof e.data.turn === 'number' ? e.data.turn : undefined;
|
|
514
|
+
// 本条消息自己写 / 改过的文件:并进**这一轮**的那一份,不单独挂在它自己头上。
|
|
515
|
+
const ownWrites = blocks
|
|
516
|
+
.filter((block) => block.type === 'tool-call' && (block.name === 'write' || block.name === 'edit') && block.state === 'ok')
|
|
517
|
+
.map((block) => {
|
|
518
|
+
try {
|
|
519
|
+
const args = JSON.parse(block.argsRaw || '{}');
|
|
520
|
+
return typeof args.path === 'string' ? args.path : typeof args.file_path === 'string' ? args.file_path : null;
|
|
521
|
+
} catch { return null; }
|
|
522
|
+
})
|
|
523
|
+
.filter((path) => typeof path === 'string');
|
|
524
|
+
if (turn !== undefined && ownWrites.length > 0) {
|
|
525
|
+
const bucket = producedByTurn.get(turn) || new Set();
|
|
526
|
+
for (const path of ownWrites) bucket.add(path);
|
|
527
|
+
producedByTurn.set(turn, bucket);
|
|
528
|
+
}
|
|
529
|
+
// 回填要等循环跑完(这一轮后面几步写的也要算进来),先把这条记下来。
|
|
530
|
+
if (turn !== undefined) producedItems.push({ item, turn });
|
|
531
|
+
// 这一轮模型显式声明的交付文件:轮尾那张交付卡的唯一依据(与产出的清单分开)。
|
|
532
|
+
if (turn !== undefined) {
|
|
504
533
|
const presented = presentedByTurn.get(turn);
|
|
505
534
|
if (presented && presented.length > 0) item.presentedFiles = presented.map((file) => ({ ...file }));
|
|
506
535
|
}
|
|
@@ -756,6 +785,16 @@ function deriveMessages(events) {
|
|
|
756
785
|
}
|
|
757
786
|
// tool/result 等其余类型照旧忽略(工具结果在预扫描里配对回填;compaction 行由 start/end 发出)。
|
|
758
787
|
}
|
|
788
|
+
// 产出清单按轮回填:这一轮所有 write / edit 的并集,挂到这一轮每条 assistant 消息上。
|
|
789
|
+
// 挂「整轮」而不是「从写那一刻起」,与桌面 `selectProducedFiles(owner)` 同口径;正文里
|
|
790
|
+
// 提到这一轮写的文件就能解析成可点(App 侧按文件名或整条路径唯一命中才画,见
|
|
791
|
+
// `viewmodels/file-mentions`)。
|
|
792
|
+
if (producedItems.length > 0) {
|
|
793
|
+
for (const { item, turn } of producedItems) {
|
|
794
|
+
const bucket = producedByTurn.get(turn);
|
|
795
|
+
if (bucket && bucket.size > 0) item.producedFiles = [...bucket];
|
|
796
|
+
}
|
|
797
|
+
}
|
|
759
798
|
// 严格按事件 seq 升序(与消息混排;compaction 行升级/替换后位置以最终事件为准)。
|
|
760
799
|
out.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
|
|
761
800
|
return out;
|
|
@@ -304,19 +304,31 @@ function projectionState() {
|
|
|
304
304
|
* `Array.isArray(items)` 判定失败、整帧被静默丢弃 —— 排队条一次都不显示。
|
|
305
305
|
*
|
|
306
306
|
* `next-turn`(等下一轮才开始的消息)→ `placement: 'queued'`,QueueDock 只认这一个值。
|
|
307
|
-
* `next-step
|
|
308
|
-
*
|
|
309
|
-
*
|
|
307
|
+
* `next-step`(已收下、等本步末尾注入的插话)→ `placement: 'steering'`:排队条与正文列表都
|
|
308
|
+
* 只认 `queued`,所以它不会多画一行;它的用处是「宿主已经收下了」这条回执 —— 插话从发出去
|
|
309
|
+
* 到真被插进去中间可能隔着好几个工具往返,此前手机上只挂着一颗「发送中…」。页脚那排插话
|
|
310
|
+
* 气泡会把本机自己发的那几条排掉(`steeringForFooter`),不会与待发气泡双画。
|
|
311
|
+
* `message.id` 用宿主行自己的 id,App 靠它与正文去重(chatRenderRows)。
|
|
310
312
|
*/
|
|
311
313
|
function queueItems(inbox, ctx) {
|
|
312
|
-
const rows = inbox && Array.isArray(inbox['next-turn']) ? inbox['next-turn'] : [];
|
|
313
314
|
const items = [];
|
|
314
315
|
let dropped = 0;
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
316
|
+
/**
|
|
317
|
+
* 摊平一组待发行。`placement` 只标语义:`queued` 进排队条,`steering` 只是回执。
|
|
318
|
+
* 没有 id 的行丢弃并计数(理由见下面的注释)。
|
|
319
|
+
*/
|
|
320
|
+
const absorb = (rows, placement) => {
|
|
321
|
+
if (!Array.isArray(rows)) return;
|
|
322
|
+
for (const message of rows) {
|
|
323
|
+
if (!message || typeof message !== 'object') continue;
|
|
324
|
+
const id = typeof message.id === 'string' && message.id ? message.id : '';
|
|
325
|
+
if (!id) { dropped += 1; continue; } // 没有 id 的行没法被 session.updateQueue 定位,别发出去
|
|
326
|
+
items.push({ id, placement, message: { id, content: message.content } });
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
if (inbox && typeof inbox === 'object') {
|
|
330
|
+
absorb(inbox['next-turn'], 'queued');
|
|
331
|
+
absorb(inbox['next-step'], 'steering');
|
|
320
332
|
}
|
|
321
333
|
// 丢弃必须留痕:旧实现是裸 continue,手机上少一条待发消息而宿主侧一行日志都没有,
|
|
322
334
|
// 只能靠人对着桌面端数气泡。凭空造 id 更糟——App 的 session.updateQueue 按 id 定位,
|
|
@@ -75,6 +75,7 @@ function configView(state) {
|
|
|
75
75
|
lanPort: Number.isInteger(state.lanPort) ? state.lanPort : 18787,
|
|
76
76
|
pairingPolicy: pairingApproval.normalize(state.pairingPolicy),
|
|
77
77
|
preferredLanAddress: typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : '',
|
|
78
|
+
manualLanUrl: typeof state.manualLanUrl === 'string' ? state.manualLanUrl : '',
|
|
78
79
|
/** 注册密钥设没设(值本身不回)。 */
|
|
79
80
|
registerSecretSet: typeof state.registerSecret === 'string' && state.registerSecret !== '',
|
|
80
81
|
};
|
|
@@ -129,6 +130,16 @@ function configPatch(body) {
|
|
|
129
130
|
}
|
|
130
131
|
patch.preferredLanAddress = body.preferredLanAddress.trim();
|
|
131
132
|
}
|
|
133
|
+
// 用户手填的直连地址:**空串是合法值**(= 清掉这条,回到只认网卡候选)。
|
|
134
|
+
// 与 `relayUrl` 一样只挡类型与长度,格式由 `relay/lan-network.cjs` 的 `manualCandidate`
|
|
135
|
+
// 在产候选那一步判 —— 这里拒掉一条「看着不像地址」的字符串,只会让手机上那句错误提示
|
|
136
|
+
// 指向一个它自己也说不清的规则。
|
|
137
|
+
if (body.manualLanUrl !== undefined) {
|
|
138
|
+
if (typeof body.manualLanUrl !== 'string' || body.manualLanUrl.length > 200) {
|
|
139
|
+
return { ok: false, message: '直连地址必须是 200 字以内的字符串(空串表示清掉)' };
|
|
140
|
+
}
|
|
141
|
+
patch.manualLanUrl = body.manualLanUrl.trim().replace(/\/+$/, '');
|
|
142
|
+
}
|
|
132
143
|
if (Object.keys(patch).length === 0) return { ok: false, message: '没有任何可改的字段' };
|
|
133
144
|
return { ok: true, patch };
|
|
134
145
|
}
|
|
@@ -194,6 +205,8 @@ function createRelayControlHandlers(deps) {
|
|
|
194
205
|
lan: lanCandidates(port),
|
|
195
206
|
public: publicCandidates(port),
|
|
196
207
|
ipv6: ipv6Candidates(port),
|
|
208
|
+
/** 用户手填的那条直连地址(空串 = 没填)。与上面三份并列给出,界面不用拼。 */
|
|
209
|
+
manual: typeof state.manualLanUrl === 'string' ? state.manualLanUrl : '',
|
|
197
210
|
};
|
|
198
211
|
}
|
|
199
212
|
|
|
@@ -39,7 +39,8 @@ const lastTurnFacts = new WeakMap();
|
|
|
39
39
|
// Prevent duplicate prompt calls when a mobile request is retried after a timeout.
|
|
40
40
|
const sendIdempotency = new WeakMap();
|
|
41
41
|
// 写操作(新建/改名/归档/分叉)的 requestId 重放表:同一次写在换通道重投后不再执行第二遍。
|
|
42
|
-
// 与 sendIdempotency 分开是因为键与生命周期不同(那边按 sessionId+clientMessageId,
|
|
42
|
+
// 与 sendIdempotency 分开是因为键与生命周期不同(那边按 sessionId+clientMessageId,
|
|
43
|
+
// 成功之后留到进程结束;这里是每次写各自的 requestId、只活在重投窗口里)。
|
|
43
44
|
const writeIdempotency = new WeakMap();
|
|
44
45
|
|
|
45
46
|
// 工作区列表的条件刷新短路:上次构建留下的 revision / 生成时刻 / 当时的信号代号。
|
|
@@ -265,6 +266,44 @@ function sendIdempotencyStateOf(ctx) {
|
|
|
265
266
|
return state;
|
|
266
267
|
}
|
|
267
268
|
|
|
269
|
+
/**
|
|
270
|
+
* 同一个投递 id 在一次会话里只真正跑一次:成功之后记录留到进程结束,失败立刻释放。
|
|
271
|
+
*
|
|
272
|
+
* 要防的是「请求超时、但那一发其实已经交给宿主了」这一档:App 会拿着同一个
|
|
273
|
+
* clientMessageId 再发一次(内核重试或用户手点重试),第二次必须拿到第一次的结果,
|
|
274
|
+
* 而不是让模型 / 子代理把同一句话再说一遍。
|
|
275
|
+
*
|
|
276
|
+
* **失败不占位**:`run` 回的信封不是成功(宿主明确拒绝,如 model-unavailable /
|
|
277
|
+
* agent-busy)就当场删掉记录 —— 一次瞬时错误不能被固化成「这个 id 永远只回这一句错」;
|
|
278
|
+
* 同理 `run` 抛错(传输层失败)也删。两条都必须是「真的再试一次」,否则活的路径只剩
|
|
279
|
+
* 用户重开 App。
|
|
280
|
+
*
|
|
281
|
+
* @param {object} ctx 插件上下文(状态按 ctx 隔离)
|
|
282
|
+
* @param {string} key 投递身份:主会话 `sessionId:clientMessageId`,子代理额外加前缀
|
|
283
|
+
* @param {() => Promise<object>} run 真正发一次,回宿主信封(`{ result: { ok, value | error } }`)
|
|
284
|
+
*/
|
|
285
|
+
function promptOnceFor(ctx, key, run) {
|
|
286
|
+
const state = sendIdempotencyStateOf(ctx);
|
|
287
|
+
const existing = state.get(key);
|
|
288
|
+
if (existing) return existing.promise;
|
|
289
|
+
|
|
290
|
+
const promise = Promise.resolve().then(run);
|
|
291
|
+
const record = { promise };
|
|
292
|
+
state.set(key, record);
|
|
293
|
+
const release = () => { if (state.get(key) === record) state.delete(key); };
|
|
294
|
+
promise.then(
|
|
295
|
+
(response) => {
|
|
296
|
+
// 留下的这条记录就是「这一发已经交给宿主了」的回执;宿主明确拒绝(信封 ok 不为真)
|
|
297
|
+
// 就当场释放 —— 一次业务错误不能被固化成「这个 id 永远只回这一句」。这里只看收没
|
|
298
|
+
// 收下,不拆错误信封(拆信封在 envelope.js,本文件是零依赖的叶子)。
|
|
299
|
+
const accepted = Boolean(response && response.result && response.result.ok === true);
|
|
300
|
+
if (!accepted) release();
|
|
301
|
+
},
|
|
302
|
+
release,
|
|
303
|
+
);
|
|
304
|
+
return promise;
|
|
305
|
+
}
|
|
306
|
+
|
|
268
307
|
/** 写操作幂等表(实现在 mobile/idempotency.js,这里只保证按 ctx 隔离)。 */
|
|
269
308
|
function writeIdempotencyStateOf(ctx) {
|
|
270
309
|
let state = writeIdempotency.get(ctx);
|
|
@@ -356,5 +395,6 @@ module.exports = {
|
|
|
356
395
|
workspaceShortcutStateOf,
|
|
357
396
|
bumpCompletionGeneration,
|
|
358
397
|
sendIdempotencyStateOf,
|
|
398
|
+
promptOnceFor,
|
|
359
399
|
writeIdempotencyStateOf,
|
|
360
400
|
};
|
|
@@ -8,17 +8,24 @@
|
|
|
8
8
|
*
|
|
9
9
|
* 三条边界同时成立:
|
|
10
10
|
*
|
|
11
|
-
* 1.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* 1. **放行面 = 本会话工作区里的文件 ∪ 本会话 present 声明过的路径**(2026-10-25 用户拍板
|
|
12
|
+
* 的第二条,见 docs/decisions.md)。前者的判法是「解析后的落点在 cwd 之下」:相对路径
|
|
13
|
+
* 按 cwd 拼、绝对路径原样,然后整条路径必须在 cwd 里 —— 所以 `../x` 这种穿越落在
|
|
14
|
+
* cwd 之外,退回旧的那条白名单,不在声明里就 403。后者保持原样(`deliverables/presented`
|
|
15
|
+
* 里显式声明过的、**字符串全等**),它的用处是工作区之外的文件也能交付(绝对路径、
|
|
16
|
+
* cwd 变了的老会话)。
|
|
17
|
+
*
|
|
18
|
+
* **为什么敢放开工作区**:正文里模型写的 `[报告](docs/报告.md)` 就是这条路上的用户预期
|
|
19
|
+
* —— 手机上点一下能看,比「点了没反应」有意义得多;而这条会话的模型本来就能读同一个
|
|
20
|
+
* 工作区,手机与电脑是同一个使用者,放宽的是「同一份工作区能不能在手机上看」,不是
|
|
21
|
+
* 「能不能读这台电脑上的任意文件」(那仍然是 403,与不暴露 shell / 任意写入同一条边界)。
|
|
22
|
+
* 2. **相对路径按会话工作区解析**:cwd 取自会话列表摘要(与 /session 同一个来源)。没有
|
|
23
|
+
* cwd 的会话只能走 present 那条白名单,相对路径解析不出来就回 409。
|
|
17
24
|
* 3. **流式回写、不在插件进程里缓冲**:几十 MB 的录屏不该整包躺在内存里,与会话日志
|
|
18
25
|
* 导出同一个理由。
|
|
19
26
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
27
|
+
* 取不到时两种失败要分得开:两条口子都不认这个路径就回 403(`outside-workspace`),
|
|
28
|
+
* 认了、盘上没了回 404(`missing`)—— 前者是「这个路径不该被读」,后者是「文件被挪走了」。
|
|
22
29
|
*/
|
|
23
30
|
|
|
24
31
|
const fs = require('node:fs');
|
|
@@ -26,6 +33,7 @@ const { stat } = require('node:fs/promises');
|
|
|
26
33
|
const path = require('node:path');
|
|
27
34
|
const { pipeline } = require('node:stream/promises');
|
|
28
35
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
36
|
+
const { signedPath } = require('../shared/e2ee-request');
|
|
29
37
|
const { unwrap } = require('./envelope.js');
|
|
30
38
|
const { callSessions } = require('./rpc.js');
|
|
31
39
|
const { presentedPathsOf } = require('./history-derive.js');
|
|
@@ -97,6 +105,19 @@ async function declaredPathsOf(ctx, sessionId) {
|
|
|
97
105
|
return presentedPathsOf((value && value.events) || []);
|
|
98
106
|
}
|
|
99
107
|
|
|
108
|
+
/**
|
|
109
|
+
* 落点在不在工作区里。
|
|
110
|
+
*
|
|
111
|
+
* `path.relative` 出来是空的(就是 cwd 本身)或者以 `..` 开头(爬出去了)都算不在,
|
|
112
|
+
* 这样 `../x`、`a/../../x` 这些写法一并挡住 —— 判的是**解析后的真实落点**,不是输入字符串,
|
|
113
|
+
* 所以「看起来没写过 ..」最终指到外面的路径同样过不去。
|
|
114
|
+
*/
|
|
115
|
+
function isInsideWorkspace(cwd, target) {
|
|
116
|
+
if (!cwd) return false;
|
|
117
|
+
const rel = path.relative(path.resolve(cwd), target);
|
|
118
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
119
|
+
}
|
|
120
|
+
|
|
100
121
|
/** 会话的工作区目录(相对路径的解析基准)。取不到就回空串,由调用方决定怎么办。 */
|
|
101
122
|
async function cwdOf(ctx, sessionId) {
|
|
102
123
|
try {
|
|
@@ -124,34 +145,35 @@ async function handleSessionFile(ctx, req, res) {
|
|
|
124
145
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, path }' } });
|
|
125
146
|
}
|
|
126
147
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return sendJson(res, 502, {
|
|
132
|
-
ok: false,
|
|
133
|
-
error: { code: 'history-failed', message: String((error && error.message) || error) },
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
if (!declaredPaths.has(declared)) {
|
|
137
|
-
return sendJson(res, 403, {
|
|
138
|
-
ok: false,
|
|
139
|
-
error: { code: 'not-presented', message: '这个文件不在本会话的交付声明里' },
|
|
140
|
-
});
|
|
141
|
-
}
|
|
148
|
+
// 先把落点算出来:工作区那一侧判的是**解析后的真实路径**,不是输入字符串。
|
|
149
|
+
const cwd = await cwdOf(ctx, sessionId);
|
|
150
|
+
let target = path.isAbsolute(declared) ? path.resolve(declared) : '';
|
|
151
|
+
if (!target && cwd) target = path.resolve(cwd, declared);
|
|
142
152
|
|
|
143
|
-
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
// 工作区里的文件直接放行,一次历史都不读 —— 这是正文里点的链接走的那条路。
|
|
154
|
+
if (!target || !isInsideWorkspace(cwd, target)) {
|
|
155
|
+
// 工作区之外只剩交付声明这一条口子(绝对路径、或 cwd 变了的老会话)。
|
|
156
|
+
let declaredPaths;
|
|
157
|
+
try {
|
|
158
|
+
declaredPaths = await declaredPathsOf(ctx, sessionId);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return sendJson(res, 502, {
|
|
161
|
+
ok: false,
|
|
162
|
+
error: { code: 'history-failed', message: String((error && error.message) || error) },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (!declaredPaths.has(declared)) {
|
|
166
|
+
return sendJson(res, 403, {
|
|
167
|
+
ok: false,
|
|
168
|
+
error: { code: 'outside-workspace', message: '这个路径不在本会话的工作区里,手机上打不开' },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (!target) {
|
|
149
172
|
return sendJson(res, 409, {
|
|
150
173
|
ok: false,
|
|
151
174
|
error: { code: 'no-cwd', message: '这条会话没有工作区目录,没法解析相对路径' },
|
|
152
175
|
});
|
|
153
176
|
}
|
|
154
|
-
target = path.join(cwd, declared);
|
|
155
177
|
}
|
|
156
178
|
|
|
157
179
|
let info;
|
|
@@ -221,14 +243,17 @@ async function handleSessionFile(ctx, req, res) {
|
|
|
221
243
|
*/
|
|
222
244
|
async function writeEncryptedFile(req, res, target, opened) {
|
|
223
245
|
const e2ee = require('../shared/e2ee');
|
|
224
|
-
const path0 = e2ee.signedPath(req);
|
|
225
246
|
const requestNonce = opened.req.nonce;
|
|
226
247
|
let seq = 0;
|
|
227
248
|
let pending = Buffer.alloc(0);
|
|
228
249
|
let closed = false;
|
|
229
250
|
res.on('close', () => { closed = true; });
|
|
230
|
-
|
|
251
|
+
// **整段都在 try 里**:调用点已经 `writeHead(200)` 过了,此后任何一步抛错都改不了状态码,
|
|
252
|
+
// 而 `sendJson` 再想写一个 500 只会撞上「头已发出」被静默吞掉 —— 响应就永远不结束,
|
|
253
|
+
// 手机上表现成「下载中」一直转、首字节等到超时。所以只能主动断开,让对端按截断处理。
|
|
231
254
|
try {
|
|
255
|
+
const path0 = signedPath(req);
|
|
256
|
+
const stream = fs.createReadStream(target, { highWaterMark: FILE_CHUNK_BYTES });
|
|
232
257
|
for await (const chunk of stream) {
|
|
233
258
|
if (closed) break;
|
|
234
259
|
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 发消息、slash 命令、会话的读/停/改名/分叉。
|
|
5
5
|
*
|
|
6
|
-
* promptOnce
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* promptOnce 的去重是这里唯一非平凡的逻辑:手机端发消息时网络重试与用户连点会产生
|
|
7
|
+
* 同一个 clientMessageId 的多次请求,同一会话内它们共享同一个 prompt,而不是让模型跑
|
|
8
|
+
* 两遍。成功之后记录一直留着(不按时间窗过期):请求超时的那一发很可能已经交给宿主了,
|
|
9
|
+
* 用户隔几分钟再点一次重试,过期的窗口正好把同一条消息又发一遍。
|
|
10
|
+
* **只缓存成功**:失败必须能被重试,不能把一次瞬时错误固化成一条永远发不出去的消息。
|
|
10
11
|
*
|
|
11
12
|
* 文本块恰好一个且以 / 开头时派发为 slash 命令(commands.execute,不发给模型);
|
|
12
13
|
* 未知命令或网关未组合时回退普通消息,与桌面 miss 落入默认 sink 的行为一致。
|
|
@@ -18,7 +19,7 @@ const { unwrap } = require('./envelope.js');
|
|
|
18
19
|
const { callSessions, mobilePromptRpcId } = require('./rpc.js');
|
|
19
20
|
const {
|
|
20
21
|
canonicalDeviceKey, invalidateSessionList, invalidateSessionModels,
|
|
21
|
-
markCompletionRead,
|
|
22
|
+
markCompletionRead, promptOnceFor,
|
|
22
23
|
} = require('./session-cache.js');
|
|
23
24
|
const { promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
|
|
24
25
|
const { withIdempotency, requestIdOf } = require('./idempotency.js');
|
|
@@ -86,25 +87,9 @@ function promptOnce(ctx, clientMessageId, payload) {
|
|
|
86
87
|
// 因为实时帧里的图还被漏读,张数一对不上就彻底认领不了)。
|
|
87
88
|
const request = clientMessageId ? { ...payload, requestId: rpcId } : payload;
|
|
88
89
|
if (!clientMessageId) return callSessions(ctx, 'prompt', request, rpcId);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const existing = state.get(key);
|
|
93
|
-
if (existing && now - existing.createdAt < 60_000) return existing.promise;
|
|
94
|
-
if (existing) state.delete(key);
|
|
95
|
-
|
|
96
|
-
const promise = Promise.resolve(callSessions(ctx, 'prompt', request, rpcId));
|
|
97
|
-
// Keep the short-lived record so an in-flight/concurrent send with the same
|
|
98
|
-
// clientMessageId shares one prompt. Only cache a SUCCESS: a rejection must
|
|
99
|
-
// not poison later retries (a transient error should be retryable), so the
|
|
100
|
-
// record is released on failure.
|
|
101
|
-
const record = { createdAt: now, promise };
|
|
102
|
-
state.set(key, record);
|
|
103
|
-
promise.then(
|
|
104
|
-
() => { /* resolved stays cached until the 60s window expires */ },
|
|
105
|
-
() => { if (state.get(key) === record) state.delete(key); },
|
|
106
|
-
);
|
|
107
|
-
return promise;
|
|
90
|
+
// 同一个 clientMessageId 在本会话里只承诺一次:内核重试、以及隔了几分钟用户手点的
|
|
91
|
+
// 那次,都拿同一份结果(口径见 session-cache 的 `promptOnceFor`)。
|
|
92
|
+
return promptOnceFor(ctx, `${payload.sessionId}:${clientMessageId}`, () => callSessions(ctx, 'prompt', request, rpcId));
|
|
108
93
|
}
|
|
109
94
|
|
|
110
95
|
async function handleSend(ctx, req, res) {
|
|
@@ -171,12 +171,11 @@ async function handleMessages(ctx, req, res) {
|
|
|
171
171
|
const messages = deriveMessages(resolvedEvents);
|
|
172
172
|
const hasMore = !!(value && value.hasMore);
|
|
173
173
|
const items = wantThin ? thinEntryMessages(messages, { hasMore }) : messages;
|
|
174
|
-
// 工具结果不产生消息行,但会把**缓存里已有的**工具卡从 running
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
const toolResults = wantThin ? [] : toolResultPatchesFor(events, items);
|
|
174
|
+
// 工具结果不产生消息行,但会把**缓存里已有的**工具卡从 running 补成最终态。补丁取自
|
|
175
|
+
// 整个窗口(调用方游标可能已越过结果 seq);已随消息块下发的只回状态(见 toolResultPatchesFor)。
|
|
176
|
+
// 增量那一发(带 afterSeq)一律只回状态:调用方手上已有一份会话,整窗重发等于每 15 秒
|
|
177
|
+
// 把最近两百条的工具输出再传一次;入口裁剪那一发不带补丁(过程块已摘掉、无处可打)。
|
|
178
|
+
const toolResults = wantThin ? [] : toolResultPatchesFor(events, items, { bodies: afterSeq === null });
|
|
180
179
|
const seqs = events.map(eventSeqOf).filter((seq) => Number.isSafeInteger(seq) && seq >= 0);
|
|
181
180
|
const oldestSeq = seqs.length ? Math.min(...seqs) : null;
|
|
182
181
|
// A truncated history window is not a complete incremental answer. Keep the
|