@coclaw/openclaw-coclaw 0.26.3 → 0.26.5
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/index.js +14 -6
- package/package.json +2 -2
- package/src/auto-upgrade/updater-check.js +1 -1
- package/src/chat-history-manager/manager.js +19 -3
- package/src/common/claw-binding.js +2 -0
- package/src/file-manager/handler.js +15 -7
- package/src/model-default/resolve.js +1 -0
- package/src/realtime-bridge.js +16 -2
- package/src/utils/agent-id.js +24 -0
- package/src/webrtc/ndc-preloader.js +7 -2
- package/src/webrtc/webrtc-peer.js +22 -6
package/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { createFileHandler } from './src/file-manager/handler.js';
|
|
|
16
16
|
import { abortAgentRun } from './src/agent-abort.js';
|
|
17
17
|
import { decideCancelResponse } from './src/agent-cancel-heuristic.js';
|
|
18
18
|
import { remoteLog } from './src/remote-log.js';
|
|
19
|
+
import { normalizeAgentId } from './src/utils/agent-id.js';
|
|
19
20
|
import { registerProviderAuthHandlers } from './src/provider-auth/index.js';
|
|
20
21
|
import { reconcilePortalModels } from './src/provider-auth/reconcile.js';
|
|
21
22
|
import { registerModelDefaultHandlers } from './src/model-default/index.js';
|
|
@@ -417,6 +418,9 @@ const plugin = {
|
|
|
417
418
|
// 取消前一个 enroll(与 doBind/doUnbind 共享 helper)
|
|
418
419
|
cancelActiveEnroll();
|
|
419
420
|
const abortController = new AbortController();
|
|
421
|
+
// 故意提前 set:enrollClaw 抛错(如 ALREADY_BOUND)时此 controller 会残留,不在 catch 重置——
|
|
422
|
+
// signal 无 live waiter,abort 死 controller 是 no-op,唯一后果是下次多一行 cancel 日志;
|
|
423
|
+
// defer/catch-reset 两轮补丁均不可靠已 revert,故保持原样。
|
|
420
424
|
activeEnrollAbort = abortController;
|
|
421
425
|
|
|
422
426
|
const serverUrl = params?.serverUrl ?? api.pluginConfig?.serverUrl;
|
|
@@ -461,10 +465,13 @@ const plugin = {
|
|
|
461
465
|
|
|
462
466
|
api.registerGatewayMethod('nativeui.sessions.listAll', async ({ params, respond }) => {
|
|
463
467
|
try {
|
|
464
|
-
const agentId = params
|
|
468
|
+
const agentId = normalizeAgentId(params);
|
|
465
469
|
// best-effort ensure:失败不阻断 listAll
|
|
466
470
|
try { await ensureAgentSession(agentId); }
|
|
467
471
|
catch {}
|
|
472
|
+
// listAll 刻意传原始 params:manager.listAll 内部用与 normalizeAgentId 同一套 trim 逻辑
|
|
473
|
+
// 自行解析 agentId(见 manager.js)——非字符串已被上面 normalizeAgentId 先抛 INVALID_INPUT
|
|
474
|
+
// 拦死,带空白的两侧都归一到同一 agent、返回同一列表,无分叉,故此处不另归一、无需守卫。
|
|
468
475
|
respond(true, await manager.listAll(params ?? {}));
|
|
469
476
|
}
|
|
470
477
|
catch (err) {
|
|
@@ -536,7 +543,7 @@ const plugin = {
|
|
|
536
543
|
|
|
537
544
|
api.registerGatewayMethod('coclaw.topics.create', async ({ params, respond }) => {
|
|
538
545
|
try {
|
|
539
|
-
const agentId = params
|
|
546
|
+
const agentId = normalizeAgentId(params);
|
|
540
547
|
// 确保该 agent 的 topics 已加载
|
|
541
548
|
if (!topicManager.__cache.has(agentId)) {
|
|
542
549
|
await topicManager.load(agentId);
|
|
@@ -551,7 +558,7 @@ const plugin = {
|
|
|
551
558
|
|
|
552
559
|
api.registerGatewayMethod('coclaw.topics.list', async ({ params, respond }) => {
|
|
553
560
|
try {
|
|
554
|
-
const agentId = params
|
|
561
|
+
const agentId = normalizeAgentId(params);
|
|
555
562
|
if (!topicManager.__cache.has(agentId)) {
|
|
556
563
|
await topicManager.load(agentId);
|
|
557
564
|
}
|
|
@@ -583,7 +590,7 @@ const plugin = {
|
|
|
583
590
|
respondInvalid(respond, 'topicId required');
|
|
584
591
|
return;
|
|
585
592
|
}
|
|
586
|
-
const agentId = params
|
|
593
|
+
const agentId = normalizeAgentId(params);
|
|
587
594
|
// 直接复用 session-manager 的 get(),topicId 即 sessionId
|
|
588
595
|
respond(true, await manager.get({ agentId, sessionId: topicId }));
|
|
589
596
|
}
|
|
@@ -661,7 +668,7 @@ const plugin = {
|
|
|
661
668
|
|
|
662
669
|
api.registerGatewayMethod('coclaw.chatHistory.list', async ({ params, respond }) => {
|
|
663
670
|
try {
|
|
664
|
-
const agentId = params
|
|
671
|
+
const agentId = normalizeAgentId(params);
|
|
665
672
|
const sessionKey = params?.sessionKey?.trim?.();
|
|
666
673
|
if (!sessionKey) {
|
|
667
674
|
respondInvalid(respond, 'sessionKey required');
|
|
@@ -686,7 +693,7 @@ const plugin = {
|
|
|
686
693
|
respondInvalid(respond, 'sessionId required');
|
|
687
694
|
return;
|
|
688
695
|
}
|
|
689
|
-
const agentId = params
|
|
696
|
+
const agentId = normalizeAgentId(params);
|
|
690
697
|
const limit = params?.limit;
|
|
691
698
|
respond(true, await manager.getById({ agentId, sessionId, limit }));
|
|
692
699
|
}
|
|
@@ -865,6 +872,7 @@ const plugin = {
|
|
|
865
872
|
// 并发控制:取消前一个 enroll(与 RPC 路径共享 helper)
|
|
866
873
|
cancelActiveEnroll();
|
|
867
874
|
const abortController = new AbortController();
|
|
875
|
+
// 同上:故意提前 set,残留仅日志噪音、abort 为 no-op(理由见 RPC handler 处)。
|
|
868
876
|
activeEnrollAbort = abortController;
|
|
869
877
|
|
|
870
878
|
const serverUrl = options.server ?? api.pluginConfig?.serverUrl;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coclaw/openclaw-coclaw",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "OpenClaw plugin for remote chat over WebRTC. Run `openclaw coclaw enroll` after install.",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"webchat"
|
|
21
21
|
],
|
|
22
22
|
"engines": {
|
|
23
|
-
"node": ">=
|
|
23
|
+
"node": ">=20.11.0"
|
|
24
24
|
},
|
|
25
25
|
"publishConfig": {
|
|
26
26
|
"access": "public",
|
|
@@ -107,7 +107,7 @@ export async function inspectPluginInstall(pluginId, opts) {
|
|
|
107
107
|
*/
|
|
108
108
|
export function isNewerVersion(a, b) {
|
|
109
109
|
// 先比较 major.minor.patch(去掉 pre-release 后缀)
|
|
110
|
-
const parse = (v) => v.replace(/-.*$/, '').split('.').map(Number);
|
|
110
|
+
const parse = (v) => v.split('+')[0].replace(/-.*$/, '').split('.').map(Number);
|
|
111
111
|
const pa = parse(a);
|
|
112
112
|
const pb = parse(b);
|
|
113
113
|
for (let i = 0; i < 3; i++) {
|
|
@@ -326,8 +326,10 @@ export class ChatHistoryManager {
|
|
|
326
326
|
* RPC 契约:`coclaw.chatHistory.list` 直接透传本返回值,不做服务端过滤;调用方
|
|
327
327
|
* (UI / 其它消费者)按 `archivedAt != null` 自行过滤未归档头与孤儿历史段。
|
|
328
328
|
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
329
|
+
* 输出按「活跃头(archivedAt 为空)保序居首 + 已归档段按 archivedAt 降序(新→旧)」重排。
|
|
330
|
+
* **返回的是新数组(filter 产生的副本),不是 cache 引用**,故重排不污染 cache;但数组内
|
|
331
|
+
* 的 item 仍是 cache 共享对象,**调用方禁止改 item 字段**。RPC handler 立刻 JSON 序列化
|
|
332
|
+
* 所以无副作用,进程内消费者若需要修改 item 请先 deep copy。
|
|
331
333
|
*
|
|
332
334
|
* @param {{ agentId: string, sessionKey: string }} params
|
|
333
335
|
* @returns {Promise<{ history: { sessionId: string, archivedAt?: number }[] }>}
|
|
@@ -336,7 +338,21 @@ export class ChatHistoryManager {
|
|
|
336
338
|
await this.__reloadFromDisk(agentId);
|
|
337
339
|
const store = this.__getStore(agentId);
|
|
338
340
|
const history = Array.isArray(store[sessionKey]) ? store[sessionKey] : [];
|
|
339
|
-
|
|
341
|
+
// 分区:宽松判空 == null 把活跃头(archivedAt 实测是 undefined)与已归档段分开。
|
|
342
|
+
// filter 保序且产生新数组——绝不原地 sort 污染 cache(list 是无锁读路径,被污染的
|
|
343
|
+
// cache 可能被后续写盘误持久化)。
|
|
344
|
+
// 先剔除 null/非对象脏元素(仅外部篡改磁盘 JSON 才会出现;正常写盘只 unshift 对象)。
|
|
345
|
+
// 与写盘侧 __sanitizeAllSessionKeys(!item || typeof item !== 'object' 跳过)姿态一致。
|
|
346
|
+
// 丢弃而非保留:保留 null 没有任何消费者受益,反而会被 UI loadNextHistorySession
|
|
347
|
+
// 的无守卫 candidate.sessionId 解引用抛未捕获异常、打断历史加载。
|
|
348
|
+
const valid = history.filter((it) => it && typeof it === 'object');
|
|
349
|
+
const heads = valid.filter((it) => it.archivedAt == null);
|
|
350
|
+
const archived = valid.filter((it) => it.archivedAt != null);
|
|
351
|
+
// 全序安全比较器:任意值规整成有限数,坏值(NaN/非数字串/对象/Infinity)一律沉到 0、
|
|
352
|
+
// 落最旧端。哨兵必须用 0 不能用 -Infinity(两个 -Infinity 相减 = NaN 会复活乱序 bug)。
|
|
353
|
+
const ms = (x) => { const n = Number(x); return Number.isFinite(n) ? n : 0; };
|
|
354
|
+
archived.sort((a, b) => ms(b.archivedAt) - ms(a.archivedAt));
|
|
355
|
+
return { history: [...heads, ...archived] };
|
|
340
356
|
}
|
|
341
357
|
|
|
342
358
|
/**
|
|
@@ -73,6 +73,8 @@ export async function bindClaw({ code, serverUrl }, deps = {}) {
|
|
|
73
73
|
}
|
|
74
74
|
catch (writeErr) {
|
|
75
75
|
// 本地 writeCfg 失败 → 回滚 server 端,避免产生孤儿 claw(与 unbind 强制不容错的红线对称)
|
|
76
|
+
// unbindServer 是 async,调用恒返回 promise(连同步异常也转 rejected),.catch 必挂上;
|
|
77
|
+
// 故意不防"同步 throw"——只有非 async mock 才造得出、生产不可达,真发生也被外层 catch 兜住。
|
|
76
78
|
await unbindServer({ baseUrl, token: data.token }).catch(() => {
|
|
77
79
|
// 回滚失败不掩盖原因;用户根据原始 writeErr.message 排查,
|
|
78
80
|
// server 端孤儿可通过下次 enroll/bind 时 401/404/410 再清理
|
|
@@ -3,6 +3,7 @@ import fsp from 'node:fs/promises';
|
|
|
3
3
|
import nodePath from 'node:path';
|
|
4
4
|
import { randomUUID, randomBytes } from 'node:crypto';
|
|
5
5
|
import { remoteLog } from '../remote-log.js';
|
|
6
|
+
import { normalizeAgentId } from '../utils/agent-id.js';
|
|
6
7
|
|
|
7
8
|
// --- 常量 ---
|
|
8
9
|
|
|
@@ -142,7 +143,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
async function listFiles(params) {
|
|
145
|
-
const agentId = params
|
|
146
|
+
const agentId = normalizeAgentId(params);
|
|
146
147
|
const userPath = params?.path ?? ''; /* c8 ignore next -- ?? fallback */
|
|
147
148
|
const workspaceDir = await resolveWorkspace(agentId);
|
|
148
149
|
const resolved = await validatePath(workspaceDir, userPath || '.', pathDeps);
|
|
@@ -194,7 +195,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
194
195
|
}
|
|
195
196
|
|
|
196
197
|
async function deleteFile(params) {
|
|
197
|
-
const agentId = params
|
|
198
|
+
const agentId = normalizeAgentId(params);
|
|
198
199
|
const userPath = params?.path;
|
|
199
200
|
if (!userPath) {
|
|
200
201
|
const err = new Error('path is required');
|
|
@@ -237,7 +238,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
async function mkdirOp(params) {
|
|
240
|
-
const agentId = params
|
|
241
|
+
const agentId = normalizeAgentId(params);
|
|
241
242
|
const userPath = params?.path;
|
|
242
243
|
if (!userPath) {
|
|
243
244
|
const err = new Error('path is required');
|
|
@@ -251,7 +252,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
251
252
|
}
|
|
252
253
|
|
|
253
254
|
async function createFile(params) {
|
|
254
|
-
const agentId = params
|
|
255
|
+
const agentId = normalizeAgentId(params);
|
|
255
256
|
const userPath = params?.path;
|
|
256
257
|
if (!userPath) {
|
|
257
258
|
const err = new Error('path is required');
|
|
@@ -276,6 +277,10 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
276
277
|
|
|
277
278
|
// 确保父目录存在
|
|
278
279
|
await _mkdir(nodePath.dirname(resolved), { recursive: true });
|
|
280
|
+
// 并发同名创建走 last-writer-wins(后写胜出):故意用普通 writeFile,不用排他创建 'wx'。
|
|
281
|
+
// 两个并发 create 都建空文件、都成功,结果一个空文件、无数据丢失(对"创建"语义更友好)。
|
|
282
|
+
// 已评估并弃用 'wx'+ALREADY_EXISTS(2026-06-22 用户裁定)。极窄残留:create 与同名上传
|
|
283
|
+
// 并发时 lstat→write 窗口内可能截断刚到的上传内容,同用户秒级交错才触发,低危、接受。
|
|
279
284
|
await (deps.writeFile ?? fsp.writeFile)(resolved, '');
|
|
280
285
|
return {};
|
|
281
286
|
}
|
|
@@ -389,7 +394,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
389
394
|
|
|
390
395
|
let workspaceDir, resolved;
|
|
391
396
|
try {
|
|
392
|
-
const agentId = req
|
|
397
|
+
const agentId = normalizeAgentId(req);
|
|
393
398
|
workspaceDir = await resolveWorkspace(agentId);
|
|
394
399
|
resolved = await validatePath(workspaceDir, req.path, pathDeps);
|
|
395
400
|
} catch (err) {
|
|
@@ -528,7 +533,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
528
533
|
async function handlePut(dc, req, connId) {
|
|
529
534
|
let workspaceDir, resolved;
|
|
530
535
|
try {
|
|
531
|
-
const agentId = req
|
|
536
|
+
const agentId = normalizeAgentId(req);
|
|
532
537
|
workspaceDir = await resolveWorkspace(agentId);
|
|
533
538
|
resolved = await validatePath(workspaceDir, req.path, pathDeps);
|
|
534
539
|
} catch (err) {
|
|
@@ -541,7 +546,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
541
546
|
async function handlePost(dc, req, connId) {
|
|
542
547
|
let workspaceDir, dirResolved;
|
|
543
548
|
try {
|
|
544
|
-
const agentId = req
|
|
549
|
+
const agentId = normalizeAgentId(req);
|
|
545
550
|
workspaceDir = await resolveWorkspace(agentId);
|
|
546
551
|
dirResolved = await validatePath(workspaceDir, req.path || '.', pathDeps);
|
|
547
552
|
} catch (err) {
|
|
@@ -774,6 +779,9 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
774
779
|
if (pendingQueue.length === 0 && !draining) finishUpload();
|
|
775
780
|
}
|
|
776
781
|
} else {
|
|
782
|
+
// done 已收到后到达的迟到 binary 分片必须拒收:否则越界字节会被累加进
|
|
783
|
+
// receivedBytes 并落盘,让本应 size-mismatch 的传输蒙混过 size 校验、生成错误文件。
|
|
784
|
+
if (doneReceived) return;
|
|
777
785
|
// binary 数据帧 — 入队,由 drainLoop 按节奏写入
|
|
778
786
|
const chunk = event.data;
|
|
779
787
|
const len = chunk.byteLength ?? chunk.length ?? 0;
|
|
@@ -116,6 +116,7 @@ function hasInlineKey(cfg, provider, deps) {
|
|
|
116
116
|
const providers = cfg?.models?.providers;
|
|
117
117
|
if (!providers || typeof providers !== 'object') return false;
|
|
118
118
|
const targetId = deps.resolveProviderIdForAuth(provider);
|
|
119
|
+
if (!targetId) return false;
|
|
119
120
|
for (const [nodeId, entry] of Object.entries(providers)) {
|
|
120
121
|
if (!entry || !deps.hasConfiguredSecretInput(entry.apiKey)) continue;
|
|
121
122
|
if (deps.resolveProviderIdForAuth(nodeId) === targetId) return true;
|
package/src/realtime-bridge.js
CHANGED
|
@@ -959,6 +959,10 @@ export class RealtimeBridge {
|
|
|
959
959
|
}
|
|
960
960
|
}
|
|
961
961
|
// 终态才清条目;accepted 类中间态保留等下一帧
|
|
962
|
+
// 故意不把 delete 移到 await sendTo 之后:上游对每个 reqId 只发一帧终态 res、不重发,
|
|
963
|
+
// await 期间不会有第二帧同 id;万一到来则 miss 路由走 (d) 兜底广播,而 claw 与 userId
|
|
964
|
+
// 一一绑定(server validateClawOwnership),只会达同一用户自有 tab、原发起 tab 已 resolve
|
|
965
|
+
// 会忽略——无跨用户泄漏、无副作用,故不防。
|
|
962
966
|
if (isFinalResMsg(payload)) {
|
|
963
967
|
this.__dcPendingRequests.delete(payload.id);
|
|
964
968
|
this.logger.debug?.(`[coclaw/rpc-res-route] remove reqId=${payload.id} reason=final-res`);
|
|
@@ -1161,6 +1165,9 @@ export class RealtimeBridge {
|
|
|
1161
1165
|
}
|
|
1162
1166
|
// 撞号检测:UUID 全唯一时极小概率,但旧 UI 跨 tab 或 UI bug 可能触发。
|
|
1163
1167
|
// 删旧条目让旧响应未来走广播兜底,不主动回错给旧发起方(可能已断)
|
|
1168
|
+
// 故意不再回 DUPLICATE_REQUEST_ID 通知旧发起方:reqId 带 crypto.randomUUID 每连接前缀,跨连接
|
|
1169
|
+
// 撞号密码学不可达;且 claw↔userId 一一绑定,所有 DC peer 同属一用户,撞号即便由未来 UI bug 引入
|
|
1170
|
+
// 也只在该用户自有 tab 间串响应(可重试恢复)、无跨用户泄漏,故不加守卫。
|
|
1164
1171
|
const id = payload.id;
|
|
1165
1172
|
if (typeof id === 'string' && this.__dcPendingRequests.has(id)) {
|
|
1166
1173
|
this.logger.warn?.(`[coclaw] duplicate dc reqId, dropping previous mapping: id=${id}`);
|
|
@@ -1429,12 +1436,16 @@ export class RealtimeBridge {
|
|
|
1429
1436
|
// 保留 webrtcPeer / fileHandler 实例供重连后复用,避免雪崩式 PC 重建。
|
|
1430
1437
|
const isAuthClose = event?.code === 4001 || event?.code === 4003;
|
|
1431
1438
|
if (isAuthClose) {
|
|
1439
|
+
// 先等在飞的 lazy init 落地:close 撞上 __initWebrtcPeer 在飞时 webrtcPeer 仍是 null,
|
|
1440
|
+
// 不等就会漏掉对那个即将诞生的 peer/fileHandler 的清理,留下无主 PC。
|
|
1441
|
+
await this.__webrtcPeerReady?.catch(() => {});
|
|
1442
|
+
// 无条件清空:覆盖 init 已完成但 peer 仍可能为 null 的所有分支(init 失败、无 impl 等)。
|
|
1443
|
+
this.__webrtcPeerReady = null;
|
|
1432
1444
|
if (this.webrtcPeer) {
|
|
1433
1445
|
try { await this.webrtcPeer.closeAll(); }
|
|
1434
1446
|
/* c8 ignore next 3 -- 防御性兜底,werift close 异常时不可崩溃 gateway */
|
|
1435
1447
|
catch (e) { this.logger.warn?.(`[coclaw/rtc] closeAll failed: ${e?.message}`); }
|
|
1436
1448
|
this.webrtcPeer = null;
|
|
1437
|
-
this.__webrtcPeerReady = null;
|
|
1438
1449
|
}
|
|
1439
1450
|
if (this.__fileHandler) {
|
|
1440
1451
|
this.__fileHandler.cancelCleanup();
|
|
@@ -1680,10 +1691,13 @@ export class RealtimeBridge {
|
|
|
1680
1691
|
// 但显式销毁时必须清干净,避免 refresh 后留下指向旧 connId 的孤儿条目。
|
|
1681
1692
|
this.__dcPendingRequests.clear();
|
|
1682
1693
|
this.__closeGatewayWs();
|
|
1694
|
+
// 镜像 auth-close 守卫:stop()(refresh/restart 会走)撞 __initWebrtcPeer 在飞时
|
|
1695
|
+
// webrtcPeer 仍 null,不等就漏清理那个即将诞生的 peer/fileHandler,refresh/restart 后留无主实例。
|
|
1696
|
+
await this.__webrtcPeerReady?.catch(() => {});
|
|
1697
|
+
this.__webrtcPeerReady = null;
|
|
1683
1698
|
if (this.webrtcPeer) {
|
|
1684
1699
|
await this.webrtcPeer.closeAll().catch(() => {});
|
|
1685
1700
|
this.webrtcPeer = null;
|
|
1686
|
-
this.__webrtcPeerReady = null;
|
|
1687
1701
|
}
|
|
1688
1702
|
// pion: 关闭 Go 进程(异步,快速)
|
|
1689
1703
|
// ndc: 不调用 cleanup()——同步 join native threads 耗时 10s+,进程退出时 OS 回收
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 归一化 RPC / DC 请求里的 agentId。
|
|
3
|
+
*
|
|
4
|
+
* 缺省(undefined / null)回落到默认 agent 'main';空串 / 纯空白同样回落到 'main'(保持
|
|
5
|
+
* 既有 `X?.agentId?.trim?.() || 'main'` 契约)。但**非字符串**(number / object / bool /
|
|
6
|
+
* array 等)视为非法输入并响亮抛错——静默落 'main' 会让读站点跨 workspace 泄露文件列表/内容、
|
|
7
|
+
* 写站点误改默认 agent 的数据。
|
|
8
|
+
*
|
|
9
|
+
* 抛出的 Error 带 `.code = 'INVALID_INPUT'`,由各 handler 的外层 catch(index.js 的
|
|
10
|
+
* respondError / file-manager 的 sendError)浮现为结构化错误码。
|
|
11
|
+
*
|
|
12
|
+
* @param {{ agentId?: unknown }} [params] - 请求参数容器(index.js 的 params / handler.js 的 req)
|
|
13
|
+
* @returns {string} 归一化后的 agentId
|
|
14
|
+
*/
|
|
15
|
+
export function normalizeAgentId(params) {
|
|
16
|
+
const raw = params?.agentId;
|
|
17
|
+
if (raw === undefined || raw === null) return 'main';
|
|
18
|
+
if (typeof raw !== 'string') {
|
|
19
|
+
const err = new Error('agentId must be a string');
|
|
20
|
+
err.code = 'INVALID_INPUT';
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
return raw.trim() || 'main'; // 空串 / 纯空白 → 'main'
|
|
24
|
+
}
|
|
@@ -213,8 +213,13 @@ export async function preloadNdc(deps = {}) {
|
|
|
213
213
|
log(`ndc.loaded platform=${platformKey}`);
|
|
214
214
|
return { PeerConnection: wrapNdcCredentials(RTCPeerConnection), cleanup, impl: 'ndc' };
|
|
215
215
|
} catch (err) {
|
|
216
|
-
//
|
|
217
|
-
|
|
216
|
+
// node-datachannel 依赖已摘除:require.resolve 抛 MODULE_NOT_FOUND 是过渡期
|
|
217
|
+
// 预期路径,记为 skip 而非误导性的 unexpected。其余才是真正未预期异常。
|
|
218
|
+
if (err?.code === 'MODULE_NOT_FOUND') {
|
|
219
|
+
log('ndc.skip reason=ndc-not-installed');
|
|
220
|
+
} else {
|
|
221
|
+
log(`ndc.fallback reason=unexpected error=${err.message}`);
|
|
222
|
+
}
|
|
218
223
|
return weriftFallback(dynamicImport, log, importTimeout);
|
|
219
224
|
}
|
|
220
225
|
}
|
|
@@ -208,13 +208,17 @@ export class WebRtcPeer {
|
|
|
208
208
|
if (!expectedSession) return;
|
|
209
209
|
if (this.__sessions.get(connId) !== expectedSession) return;
|
|
210
210
|
const session = expectedSession;
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
211
|
+
// 同步段顺序:先 delete map,再 detach handler + clear timer,最后进入 fire-and-forget
|
|
212
|
+
// 兼容的异步收尾(sender/queue/pc.close)。三步同步、彼此间无 await,外部异步观察者
|
|
213
|
+
// (并发 offer / pc.close 滞后回调)看到的是整段原子切换,故 delete 提前不改变原有两条
|
|
214
|
+
// 保证:(1) pc.close 期间的滞后回调不会误改新 session(detach 仍在 pc.close 之前);
|
|
215
|
+
// (2) sessions.get(connId) 删表后立即返回 undefined,并发 offer 走替换路径而非误以为
|
|
216
|
+
// "close 还在跑"。delete 必须排在任何可能抛错的同步步骤之前:closeAll drain 用
|
|
217
|
+
// per-session catch 吞掉单个 reject,若 detach/clear 在 delete 前抛错,session 会残留在
|
|
218
|
+
// 表里让 while-drain 死循环、拖挂 gateway——delete 先行使这条不变量无条件成立。
|
|
219
|
+
this.__sessions.delete(connId);
|
|
215
220
|
this.__detachPcHandlers(session);
|
|
216
221
|
this.__clearSessionSyncState(session);
|
|
217
|
-
this.__sessions.delete(connId);
|
|
218
222
|
await this.__finalizeSessionAsync(session);
|
|
219
223
|
this.__remoteLog(`rtc.closed conn=${connId}`);
|
|
220
224
|
this.logger.info?.(`${this.__rtcTag} [${connId}] closed`);
|
|
@@ -236,7 +240,15 @@ export class WebRtcPeer {
|
|
|
236
240
|
// 下次复用会建新实例从 false 起。
|
|
237
241
|
this.__stopping = true;
|
|
238
242
|
while (this.__sessions.size > 0) {
|
|
239
|
-
|
|
243
|
+
// per-session catch:单个 closeByConnId reject 不能中断其余 session 的清理,也不能
|
|
244
|
+
// 打断 while-drain 兜底循环。session 在 closeByConnId 首个 await 前已同步从 Map 删除,
|
|
245
|
+
// 故失败的 session 不会被下一轮重复处理、也不会无限循环。失败降级为 warn 诊断而非
|
|
246
|
+
// 静默全吞,保留定位线索。
|
|
247
|
+
const closing = [...this.__sessions.values()].map((s) =>
|
|
248
|
+
this.closeByConnId(s.connId, s).catch((err) => {
|
|
249
|
+
this.logger.warn?.(`${this.__rtcTag} [${s.connId}] closeByConnId failed during closeAll: ${err?.message ?? err}`);
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
240
252
|
await Promise.all(closing);
|
|
241
253
|
}
|
|
242
254
|
}
|
|
@@ -1028,6 +1040,10 @@ export class WebRtcPeer {
|
|
|
1028
1040
|
// FBQ.init 承担 fs 残留清理(含同 connId 唯一后缀文件,不会撞旧实例);MemoryQueue.init 是 no-op。
|
|
1029
1041
|
// await 期间可能发生 closeByConnId / 同 connId 二次 ondatachannel,因此后面必须身份重核才能赋字段。
|
|
1030
1042
|
await queue.init();
|
|
1043
|
+
// 故意不为同 connId 并发 setup 引入 rpcSetupInProgress 协调锁:双 loop / 泄漏被下方身份重核
|
|
1044
|
+
// + 段首 teardown 结构性挡死(rpcChannel 在 ondatachannel 同步路径单调前移:先到者被后到
|
|
1045
|
+
// teardown 收割,后到者 dc 已非 rpcChannel 则自毁返回);即便分析有误,孤儿也只是空转 idle
|
|
1046
|
+
// 任务 + 少量内存(无 producer → 无 spill → 无 FD),无崩溃 / 丢数据。
|
|
1031
1047
|
// 身份重核:init 期间 session 可能被 closeByConnId 从 Map 删除,或被同 connId 二次
|
|
1032
1048
|
// ondatachannel 把 rpcChannel 替换成新 dc。任一不再成立都视为 stale,destroy queue 后
|
|
1033
1049
|
// 直接退出,绝不污染 session 三件套字段。monitor 自然 GC,无需 summarize(无 drop)。
|