@coclaw/openclaw-coclaw 0.22.3 → 0.23.0

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 CHANGED
@@ -18,7 +18,9 @@ import { abortAgentRun } from './src/agent-abort.js';
18
18
  import { decideCancelResponse } from './src/agent-cancel-heuristic.js';
19
19
  import { remoteLog } from './src/remote-log.js';
20
20
  import { registerProviderAuthHandlers } from './src/provider-auth/index.js';
21
+ import { reconcilePortalModels } from './src/provider-auth/reconcile.js';
21
22
  import { registerModelDefaultHandlers } from './src/model-default/index.js';
23
+ import { getClawConfig } from './src/claw-config.js';
22
24
 
23
25
  import { getPluginVersion, __resetPluginVersion } from './src/plugin-version.js';
24
26
  export { getPluginVersion, __resetPluginVersion };
@@ -32,6 +34,15 @@ export function awaitPluginInit() {
32
34
  return __pluginInitDone;
33
35
  }
34
36
 
37
+ // abort-threw 远端日志节流:gateway 进程活动期间最多上报一次,避免上游 handle.abort
38
+ // 持续抛错时 500ms tick 重试把 remote-log 刷爆。logger.info 已对 abort-threw 静默
39
+ // (见 coclaw.agent.abort handler 注释),但完全失去诊断信号会让运维瞎子,故保留首条。
40
+ let __abortThrewReported = false;
41
+
42
+ export function __resetAbortThrewReported() {
43
+ __abortThrewReported = false;
44
+ }
45
+
35
46
  // 侧门注册表观测:patch OpenClaw embeddedRunState.activeRuns 的 set/delete,
36
47
  // 用于跟踪 sessionId 何时注册/注销(agent 取消流程实际读取的就是这张表)。
37
48
  // OpenClaw 侧门形状变化时(缺失 / 抛异常),通过 remoteLog 上报为升级契约变更的早期信号。
@@ -70,8 +81,9 @@ function patchMapLogging(map, label, logger) {
70
81
  }
71
82
  const origSet = map.set.bind(map);
72
83
  const origDel = map.delete.bind(map);
73
- // log 行包 try/catch 兜底:上游若把 Map 换成有 throwing getter(如 Proxy)的对象,
74
- // 不能让本插件的诊断 log 把 OpenClaw 内部 set/delete 流程带崩
84
+ // AGENTS.md §"日志器"一般规则是"调用点不要再包 try/catch",此处特例:safeLog/safeSize
85
+ // 是绑到 OpenClaw 内部 Map.set/delete 路径上的诊断仪器,上游若把 Map 换成 throwing-getter
86
+ // Proxy 或 logger 自身异常都不能把宿主的 set/delete 流程带崩,故保留 swallow 兜底
75
87
  const safeLog = (msg) => {
76
88
  try { logger?.info?.(msg); } catch { /* swallow — diag log 不得影响主流程 */ }
77
89
  };
@@ -192,7 +204,16 @@ const plugin = {
192
204
  .catch((err) => {
193
205
  logger.warn?.(`[coclaw] chat history manager load/reconcile failed: ${String(err?.message ?? err)}`);
194
206
  });
195
- __pluginInitDone = Promise.all([topicLoadP, chatHistoryLoadP]);
207
+ // 启动对账 minimax-portal 模型清单:已绑定且与内置表不一致才刷新(一致零写入,
208
+ // 防"写配置触发重启"时的反复重启)。升级补了新模型靠这条让老用户重启后自动同步。
209
+ // config-mutation 字面量 specifier 必须出现在本入口源码里(loader 只扫入口识别 jiti alias)。
210
+ const portalSyncP = import('openclaw/plugin-sdk/config-mutation')
211
+ .then(({ mutateConfigFile }) => reconcilePortalModels({ getConfig: getClawConfig, mutateConfigFile }))
212
+ .then((r) => { if (r.changed) logger.info?.('[coclaw] minimax-portal model list synced from plugin catalog'); })
213
+ .catch((err) => {
214
+ logger.warn?.(`[coclaw] minimax-portal model reconcile failed: ${String(err?.message ?? err)}`);
215
+ });
216
+ __pluginInitDone = Promise.all([topicLoadP, chatHistoryLoadP, portalSyncP]);
196
217
 
197
218
  // 追踪 chat 因 reset 产生的 session 流水。双源回调(hook + sessions.changed)共用 helper。
198
219
  // recordSessionTransition 内部已 __reloadFromDisk + mutex,外层无需再 cache.has + load。
@@ -692,8 +713,8 @@ const plugin = {
692
713
  const runDuration = typeof params?.runDuration === 'number' ? params.runDuration : undefined;
693
714
  const abortDuration = typeof params?.abortDuration === 'number' ? params.abortDuration : undefined;
694
715
  const result = decideCancelResponse(abortResult, { runDuration, abortDuration });
695
- // not-found UI 重试期常态(注册空窗),不打日志避免噪音;其余分支保留 info
696
- if (result.reason !== 'not-found') {
716
+ // not-found(注册空窗)与 abort-threw(上游 handle.abort 持续抛)都是 UI 500ms tick 重试期常态,跳过 info 日志避免洪水;其余分支保留 info
717
+ if (result.reason !== 'not-found' && result.reason !== 'abort-threw') {
697
718
  logger.info?.(`[coclaw.agent.abort] result sessionId=${sessionId} ok=${result.ok}${result.reason ? ` reason=${result.reason}` : ''}${result.error ? ` error=${result.error}` : ''}`);
698
719
  }
699
720
  if (result.ok) {
@@ -707,6 +728,12 @@ const plugin = {
707
728
  // 启发升格:双闸均达阈值,把 not-found 升格为 gone,让 UI 主动 settleByCancel
708
729
  remoteLog(`abort.gone sid=${sessionId} runDur=${runDuration} abortDur=${abortDuration}`);
709
730
  }
731
+ else if (result.reason === 'abort-threw' && !__abortThrewReported) {
732
+ // 一次性:上游 handle.abort 首次抛错时上报,让运维知道这个 gateway 实例发生过;
733
+ // 后续 tick 重试不再上报,避免被 UI 500ms 拍频刷爆远端日志
734
+ __abortThrewReported = true;
735
+ remoteLog(`abort.threw sid=${sessionId} error=${result.error || ''}`);
736
+ }
710
737
  respond(true, result);
711
738
  }
712
739
  catch (err) {
@@ -729,7 +756,7 @@ const plugin = {
729
756
 
730
757
  const fileHandler = createFileHandler({
731
758
  resolveWorkspace: (agentId) => {
732
- const cfg = api.runtime?.config?.loadConfig();
759
+ const cfg = getClawConfig();
733
760
  const dir = api.runtime?.agent?.resolveAgentWorkspaceDir(cfg, agentId);
734
761
  if (!dir) {
735
762
  const err = new Error('Cannot resolve workspace: runtime not available');
@@ -773,13 +800,15 @@ const plugin = {
773
800
  }
774
801
  });
775
802
 
776
- // provider 认证管理 RPC(API key 写入 / 列表 / 撤销)。SDK 走懒加载 dynamic import,
777
- // 不增加本插件 cold-load 开销,也让测试环境无需 openclaw 包就能加载 index.js。
778
- // loadSdk 字面量必须留在本入口源码里:OpenClaw plugin loader 只扫入口文件识别
803
+ // provider 认证管理 RPC(API key 写入 / 列表 / 撤销 + OAuth 登录/取消)。SDK 走懒加载
804
+ // dynamic import,不增加本插件 cold-load 开销,也让测试环境无需 openclaw 包就能加载 index.js。
805
+ // load* 字面量必须留在本入口源码里:OpenClaw plugin loader 只扫入口文件识别
779
806
  // `openclaw/plugin-sdk/*` 字符串字面量、命中后才把整张依赖图过 jiti 改写到自家 dist;
780
- // 字面量留在子模块里 loader 看不到 → 整张图走原生 Node 解析必败(plugin 部署目录不带 openclaw 包)
807
+ // 字面量留在子模块里 loader 看不到 → 整张图走原生 Node 解析必败(plugin 部署目录不带 openclaw 包)。
808
+ // config-mutation 供 OAuth 写 provider 节点 baseUrl(hot-reload,零打断)
781
809
  registerProviderAuthHandlers(api, {
782
810
  loadSdk: () => import('openclaw/plugin-sdk/provider-auth'),
811
+ loadConfigMutation: () => import('openclaw/plugin-sdk/config-mutation'),
783
812
  });
784
813
 
785
814
  // 模型默认配置 RPC(coclaw.model.set / list)。三个 SDK 子入口的字面量
@@ -811,8 +840,13 @@ const plugin = {
811
840
 
812
841
  try {
813
842
  if (action === 'bind') {
843
+ // 先校验非空 code,避免无效输入触发 doBind 内的 cancelActiveEnroll 副作用
844
+ const code = positionals[0];
845
+ if (typeof code !== 'string' || code.length === 0) {
846
+ return { text: 'Error: binding code is required' };
847
+ }
814
848
  const result = await doBind({
815
- code: positionals[0],
849
+ code,
816
850
  serverUrl: options.server,
817
851
  });
818
852
  return { text: bindOk(result) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.22.3",
3
+ "version": "0.23.0",
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.",
@@ -50,7 +50,7 @@
50
50
  "typecheck": "echo \"No typecheck configured yet (coclaw openclaw plugin)\"",
51
51
  "check": "pnpm lint && pnpm typecheck",
52
52
  "test:plugin": "node --test src/plugin-mode.test.js",
53
- "test": "c8 --check-coverage --lines 100 --functions 100 --branches 95 --statements 100 --reporter=text --reporter=lcov bash -c 'for f in src/**/*.test.js src/*.test.js index.test.js; do node --test \"$f\" || exit 1; done'",
53
+ "test": "c8 --check-coverage --lines 100 --functions 100 --branches 95 --statements 100 --reporter=text --reporter=lcov bash -c 'node --test src/**/*.test.js src/*.test.js index.test.js'",
54
54
  "e2e": "node --test --test-concurrency=1 e2e/*.e2e.spec.js",
55
55
  "verify": "pnpm check && pnpm test",
56
56
  "link": "bash ./scripts/link.sh",
@@ -191,7 +191,11 @@ function loadInstallRecordFromLegacyConfig(pluginId) {
191
191
  const config = getClawConfig();
192
192
  return config?.plugins?.installs?.[pluginId] ?? null;
193
193
  }
194
- catch {
194
+ catch (err) {
195
+ // 与同函数其他 catch 风格对齐:旧版账本读取异常也外推诊断信号,
196
+ // 否则下游只能看到笼统的 "Skipping: not an npm-installed plugin",无定位线索
197
+ /* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
198
+ remoteLog(`upgrade.legacy-config-read-failed msg=${err?.message ?? String(err)}`);
195
199
  return null;
196
200
  }
197
201
  }
@@ -39,6 +39,8 @@ export function classifyChatHistorySessionKey(sessionKey) {
39
39
  // 由 cron 守卫挡住即可,避免与 IM per-account DM accountId="cron"/"subagent" 形态冲突
40
40
  // (accountId 仅按 [a-z0-9_-]{1,64} 校验,"cron"/"subagent" 都是合法账户名)。
41
41
  if (parts[2] === 'explicit') return { ok: false, reason: 'explicit' };
42
+ // agent:<id>:cron:<jobId>:subagent:<uuid> 形态命中 cron 守卫返回 reason='cron',
43
+ // 丢一层嵌套语义;上报信号目前无下游按 reason 细分计数/告警,留作识别到才说
42
44
  if (parts[2] === 'cron') return { ok: false, reason: 'cron' };
43
45
  if (parts[2] === 'subagent') return { ok: false, reason: 'subagent' };
44
46
  return { ok: true, reason: null };
@@ -193,6 +195,7 @@ export class ChatHistoryManager {
193
195
  for (let i = 1; i < list.length; i++) {
194
196
  const item = list[i];
195
197
  if (!item || typeof item !== 'object' || item.archivedAt) continue;
198
+ // 复用 archivedAt 写补登时间;事后与正常归档无法区分,线下分析靠下方 chat-history.sanitize-coerce 上报信号
196
199
  item.archivedAt = now;
197
200
  this.__logger.warn?.(
198
201
  `[coclaw] chat-history sanitize: non-head unarchived entry coerced sessionKey=${sessionKey} sid=${item.sessionId}`,
@@ -352,6 +355,8 @@ export class ChatHistoryManager {
352
355
  } catch (err) {
353
356
  this.__reportLoadError(filePath, err, '__reloadFromDisk');
354
357
  }
358
+ // 读盘失败 + cache 已热身 → 保留旧 cache(best-effort 容忍)。下次写盘会把旧数据回灌磁盘自愈;
359
+ // 但若长期无新写入,磁盘上的损坏内容不会被主动修复
355
360
  if (!this.__cache.has(agentId)) {
356
361
  this.__cache.set(agentId, emptyStore());
357
362
  }
@@ -643,6 +643,17 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
643
643
  let wsBackpressureCount = 0;
644
644
  let wsError = false;
645
645
  let finishing = false;
646
+ // 一次性 cleanup attach 守卫:dc.onerror 后 pion 通常紧跟 dc.onclose,
647
+ // 两条 cleanup 路径若都向 ws 挂 'close' 监听会重复 safeUnlink。
648
+ // 用 flag 而不是 wsError 守卫,是为了不破坏 SIZE_EXCEEDED 等"先同步 unlink、
649
+ // 再触发 dc.close → dc.onclose"路径:那条路径同样设 wsError=true 但**没**
650
+ // attach 过,仍需 dc.onclose 兜底 attach(fopen 完成后才删到真文件)
651
+ let cleanupAttached = false;
652
+ function attachTmpCleanupOnce() {
653
+ if (cleanupAttached) return;
654
+ cleanupAttached = true;
655
+ ws.on('close', () => safeUnlink(tmpPath));
656
+ }
646
657
 
647
658
  // --- 受控写入:中间缓冲 + drain 循环 ---
648
659
  const pendingQueue = [];
@@ -672,9 +683,11 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
672
683
  draining = false;
673
684
  pendingQueue.length = 0;
674
685
  log.warn?.(`[coclaw/file] drainLoop write error: ${err.message}`);
686
+ // 走安全网而非同步 safeUnlink:fopen 未完成时 sync unlink 扑空被吞,
687
+ // fopen 后留孤儿。attach 在 destroy 之前确保 'close' emit 时 listener 已就位
688
+ attachTmpCleanupOnce();
675
689
  ws.destroy();
676
690
  if (!dcClosed) sendError(dc, 'WRITE_FAILED', err.message);
677
- safeUnlink(tmpPath);
678
691
  const elapsed = Date.now() - startTime;
679
692
  remoteLog(`file.up.fail ${logTag}id=${transferId} reason=drain-write-error err=${err.message} received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms`);
680
693
  return;
@@ -692,8 +705,17 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
692
705
  function finishUpload() {
693
706
  if (finishing) return;
694
707
  finishing = true;
695
- ws.end(async () => {
708
+ ws.end(async (err) => {
696
709
  const elapsed = Date.now() - startTime;
710
+ // ws 在 'finish' 之前被 destroy 时,Node Writable 会用 err 调 pending end cb
711
+ //(destroy(e) 用 e;destroy() 用 ERR_STREAM_DESTROYED)。任何 ws 中途夭折的
712
+ // 产线路径都已经在 ws.on('error') / dc.onclose / dc.onerror / drainLoop catch
713
+ // 里 attach 过清理 + 打过更具体的 fail log;这里只需兜底 attach 一次(idempotent)
714
+ // 防御未来路径漏挂,并跳过 rename / size 校验 / 成功响应
715
+ if (err) {
716
+ attachTmpCleanupOnce();
717
+ return;
718
+ }
697
719
  if (dcClosed) {
698
720
  safeUnlink(tmpPath);
699
721
  remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed-before-flush received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
@@ -795,15 +817,20 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
795
817
  if (doneReceived) {
796
818
  // done 已收到但 drain 未完成 — finishUpload 中会检测 dcClosed 并清理 tmp
797
819
  if (!finishing) finishUpload();
798
- } else {
799
- // 等 ws 关闭后再 unlink——fopen 未完成时直接 safeUnlink 会扑空被吞,
800
- // 随后 fopen 完成创建文件却没人清,留下孤儿 tmp
801
- ws.on('close', () => safeUnlink(tmpPath));
802
- ws.destroy();
803
- const elapsed = Date.now() - startTime;
804
- remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
805
- log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
820
+ return;
806
821
  }
822
+ // 等 ws 关闭后再 unlink——fopen 未完成时直接 safeUnlink 会扑空被吞,
823
+ // 随后 fopen 完成创建文件却没人清,留下孤儿 tmp。flag 守卫保证 dc.onerror
824
+ // 已 attach 过时不会重挂第二个 listener
825
+ attachTmpCleanupOnce();
826
+ ws.destroy();
827
+ // dc.onerror 已打过 reason=dc-error fail 日志 → 跳过 reason=dc-closed
828
+ // 避免双触发时 fail 日志重复(SIZE_EXCEEDED 路径打的是 reason=size-exceeded
829
+ // 的 reject 日志,不在此 fail 命名空间,不受影响)
830
+ if (wsError) return;
831
+ const elapsed = Date.now() - startTime;
832
+ remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
833
+ log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
807
834
  };
808
835
 
809
836
  // pion 异步 send 错误经此回调上报;触发已有清理路径
@@ -813,7 +840,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
813
840
  draining = false;
814
841
  pendingQueue.length = 0;
815
842
  // 同上:等 ws 关闭后再 unlink,避开 fopen-vs-unlink race
816
- ws.on('close', () => safeUnlink(tmpPath));
843
+ attachTmpCleanupOnce();
817
844
  ws.destroy();
818
845
  const elapsed = Date.now() - startTime;
819
846
  /* c8 ignore next -- ?? fallback for non-Error throw */
@@ -832,11 +859,16 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
832
859
  draining = false;
833
860
  pendingQueue.length = 0;
834
861
  log.warn?.(`[coclaw/file] write stream error: ${err.message}`);
862
+ // 安全网先装:sendError 会同步 dc.close → dc.onclose,doneReceived=true 且
863
+ // !finishing 时会同步重入 finishUpload → ws.end,整条 sync 链全跑完才回到这里。
864
+ // 与 drainLoop catch 顺序对齐。显式 ws.destroy() 不依赖 Node autoDestroy
865
+ // (注入的非标准 stream 可能不触发)触发 ws 关闭流程;listener 在 'close' emit 时 fire
866
+ attachTmpCleanupOnce();
867
+ ws.destroy();
835
868
  if (!dcClosed) {
836
869
  const code = err.code === 'ENOSPC' ? 'DISK_FULL' : 'WRITE_FAILED';
837
870
  sendError(dc, code, err.message);
838
871
  }
839
- safeUnlink(tmpPath);
840
872
  const elapsed = Date.now() - startTime;
841
873
  remoteLog(`file.up.fail ${logTag}id=${transferId} reason=write-error err=${err.code || err.message} received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms`);
842
874
  log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=write-error received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms err=${err.code || err.message}`);
@@ -904,7 +936,13 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
904
936
  }
905
937
 
906
938
  function safeUnlink(filePath) {
907
- _unlink(filePath).catch(() => {});
939
+ // ENOENT 静默:tmp 可能尚未创建(fopen 未完成)或已被另一路径删过,均属预期。
940
+ // 其余错(EACCES / EBUSY / EIO)若完全无声会让孤儿 tmp 悄悄积累——warn 一下,
941
+ // 仍保留 fire-and-forget 语义;scheduleTmpCleanup 仍是最终兜底
942
+ _unlink(filePath).catch((err) => {
943
+ if (err?.code === 'ENOENT') return;
944
+ log.warn?.(`[coclaw/file] safeUnlink failed: ${filePath} ${err?.code || err?.message}`);
945
+ });
908
946
  }
909
947
 
910
948
  return {
@@ -105,11 +105,13 @@ export function buildModelDefaultHandlers({ sdk, loadConfig, resolveAgentDir })
105
105
  respondInvalid(respond, 'primary is required');
106
106
  return;
107
107
  }
108
- const primary = params.primary;
109
- if (primary !== null && typeof primary !== 'string') {
108
+ const rawPrimary = params.primary;
109
+ if (rawPrimary !== null && typeof rawPrimary !== 'string') {
110
110
  respondInvalid(respond, 'primary must be a string or null');
111
111
  return;
112
112
  }
113
+ // 复制粘贴常带前后空白;trim 后再做形态/凭据/catalog 校验,并按 trim 后的值落盘
114
+ const primary = rawPrimary === null ? null : rawPrimary.trim();
113
115
  if (primary !== null && primary.length === 0) {
114
116
  respondInvalid(respond, 'primary must be a non-empty string or null');
115
117
  return;
@@ -1,5 +1,6 @@
1
1
  /**
2
- * provider-auth handlers —— `coclaw.providerAuth.*` 三个 RPC 的纯函数实现
2
+ * provider-auth handlers —— `coclaw.providerAuth.*` RPC 的纯函数实现
3
+ * (setApiKey / list / remove + OAuth 的 loginOauth / cancelOauth)
3
4
  *
4
5
  * 设计要点(详见 docs/model-config-api.md § 2 + § 6):
5
6
  * - 通过 dependency injection 拿 SDK / agentDir 解析器,便于单测;产线注入在 ./index.js
@@ -20,9 +21,25 @@
20
21
  * 与 plugin 既有 `respondError` / `respondInvalid`(在 plugins/openclaw/index.js)的关系:
21
22
  * 既有 helper 用 `INVALID_INPUT` / `INTERNAL_ERROR`,与本节 RPC 契约(`INVALID_ARGS` /
22
23
  * `IO_FAILED`)不一致——所以本模块自带局部 helper,避免改既有 helper 影响所有现存 RPC。
24
+ *
25
+ * OAuth(loginOauth / cancelOauth)补充:
26
+ * - **真·两阶段 res**(plugin respond 可多调,详见 docs/model-config-api.md § 2.3.2):
27
+ * phase-1 同步 respond accepted 帧(payload 必带 `status:'accepted'` 否则中继提前清路由),
28
+ * phase-2 后台轮询出结果后用同一 reqId respond 终态帧
29
+ * - phase-1 之前的失败(region 非法 / 设备码请求失败)走单帧错误响应(INVALID_ARGS / IO_FAILED)
30
+ * - phase-2 失败用 payload.status 区分语义(error / timeout / cancelled),结构化 error.code
31
+ * 按语义给 OAUTH_FAILED / OAUTH_TIMEOUT / OAUTH_CANCELLED;写凭据 null / 写配置抛错走 IO_FAILED
32
+ * - 后台轮询 fire-and-forget,但 runOAuthBackground 内全程 try/catch 保证恰好 respond 一次且不外抛;
33
+ * 终态在 finally 清 registry
23
34
  */
24
35
 
36
+ import { randomUUID } from 'node:crypto';
37
+ import { PORTAL_PROVIDER_ID, CONFIG_DEFAULT_BASE_URL, VALID_REGIONS } from './minimax-oauth.js';
38
+ import { getPortalModels } from './portal-model-catalog.js';
39
+ import { remoteLog } from '../remote-log.js';
40
+
25
41
  const VALID_CRED_TYPES = new Set(['api_key', 'oauth', 'token']);
42
+ const PORTAL_PROFILE_ID = `${PORTAL_PROVIDER_ID}:default`;
26
43
 
27
44
  function respondInvalid(respond, message) {
28
45
  respond(false, undefined, { code: 'INVALID_ARGS', message });
@@ -40,7 +57,7 @@ function isNonEmptyString(v) {
40
57
  }
41
58
 
42
59
  /**
43
- * 构造三个 handler
60
+ * 构造 handler 集合。
44
61
  *
45
62
  * @param {object} opts
46
63
  * @param {object} opts.sdk - openclaw/plugin-sdk/provider-auth 命名空间(或 stub)
@@ -49,10 +66,24 @@ function isNonEmptyString(v) {
49
66
  * @param {Function} opts.sdk.ensureAuthProfileStore - 位置参数 (agentDir, options?)
50
67
  * @param {Function} opts.sdk.removeProviderAuthProfilesWithLock - async;返回 store(成功)/ null(锁/磁盘失败)
51
68
  * @param {Function} opts.sdk.formatApiKeyPreview - 遮蔽显示 helper
69
+ * @param {Function} [opts.sdk.mutateConfigFile] - async;OAuth 写 cfg(openclaw/plugin-sdk/config-mutation)
52
70
  * @param {Function} opts.resolveAgentDir - 返回 main agent 完整路径(含 /agent 子目录)
53
- * @returns {{ setApiKey: Function, list: Function, remove: Function }}
71
+ * @param {object} [opts.oauth] - createMiniMaxOAuth 实例(requestDeviceCode / pollUntilSettled);OAuth handler 才用
72
+ * @param {object} [opts.registry] - oauth-registry(registerLogin / getLogin / removeLogin)
73
+ * @param {Function} [opts.genLoginId] - () → loginId,默认 randomUUID
74
+ * @param {Function} [opts.scheduleBackground] - (promise) → void,挂后台轮询;默认 fire-and-forget + .catch
75
+ * @param {Function} [opts.logRemote] - (text) → void,OAuth 终态诊断推送;默认模块级 remoteLog(测试注入 spy)
76
+ * @returns {{ setApiKey, list, remove, loginOauth, cancelOauth }}
54
77
  */
55
- export function buildProviderAuthHandlers({ sdk, resolveAgentDir }) {
78
+ export function buildProviderAuthHandlers({
79
+ sdk,
80
+ resolveAgentDir,
81
+ oauth,
82
+ registry,
83
+ genLoginId = randomUUID,
84
+ scheduleBackground = (p) => { p.catch(() => {}); },
85
+ logRemote = remoteLog,
86
+ }) {
56
87
  // TODO: 将来若要支持"设默认模型 / 多账号顺序"等需要写 cfg 的操作,会撞上
57
88
  // gateway 重启窗口的 UX 问题——参 docs/model-config-api.md § 3 / § 5(占位章节)。
58
89
  // 当前三个 RPC 都只动 secret 不动 cfg,零重启。
@@ -144,7 +175,179 @@ export function buildProviderAuthHandlers({ sdk, resolveAgentDir }) {
144
175
  }
145
176
  }
146
177
 
147
- return { setApiKey, list, remove };
178
+ // --- OAuth(MiniMax device-code,真·两阶段 res) ---
179
+
180
+ // 写凭据 + 写 cfg;恰好 respond 一次,不外抛(成功 ok / 失败 IO_FAILED 都在内部消化)
181
+ async function persistOAuthSuccess({ region, token, loginId, respond }) {
182
+ try {
183
+ const credential = {
184
+ type: 'oauth',
185
+ provider: PORTAL_PROVIDER_ID,
186
+ access: token.access,
187
+ refresh: token.refresh,
188
+ expires: token.expires,
189
+ };
190
+ const result = await sdk.upsertAuthProfileWithLock({
191
+ profileId: PORTAL_PROFILE_ID,
192
+ credential,
193
+ agentDir: resolveAgentDir(),
194
+ });
195
+ // 同 setApiKey:锁/磁盘失败时上游静默返回 null
196
+ if (result === null) {
197
+ respond(false, { status: 'error' }, {
198
+ code: 'IO_FAILED',
199
+ message: 'failed to write auth-profiles store',
200
+ });
201
+ logRemote(`providerAuth.oauth.io-failed loginId=${loginId} stage=credential`);
202
+ return;
203
+ }
204
+ // 写 provider 节点 baseUrl —— hot-reload 路径,零打断(afterWrite:auto,禁传 restart)。
205
+ // baseUrl 优先用服务端动态返回的 resourceUrl,缺省回落 cn/global 默认(带 /anthropic 后缀)
206
+ const baseUrl = token.resourceUrl || CONFIG_DEFAULT_BASE_URL[region];
207
+ // 写模型清单进 provider 节点:上游对 minimax-portal 用写死静态清单且第三方触发不到其
208
+ // catalog discovery,不写则 catalog 为空、模型不可用。直接取内置静态表(与上游对齐),
209
+ // 不再网络拉取——避免登录拉一次后静态过时 + 带进旧模型。后续升级新模型靠启动对账补。
210
+ // 详见 docs/model-config-api.md § 2.3
211
+ const models = getPortalModels(PORTAL_PROVIDER_ID);
212
+ await sdk.mutateConfigFile({
213
+ afterWrite: { mode: 'auto' },
214
+ mutate(draft) {
215
+ if (!draft.models || typeof draft.models !== 'object' || Array.isArray(draft.models)) {
216
+ draft.models = {};
217
+ }
218
+ const p = draft.models.providers;
219
+ if (!p || typeof p !== 'object' || Array.isArray(p)) {
220
+ draft.models.providers = {};
221
+ }
222
+ draft.models.providers[PORTAL_PROVIDER_ID] = {
223
+ baseUrl,
224
+ api: 'anthropic-messages',
225
+ authHeader: true,
226
+ models,
227
+ };
228
+ },
229
+ });
230
+ respond(true, { status: 'ok', profileId: PORTAL_PROFILE_ID });
231
+ logRemote(`providerAuth.oauth.ok loginId=${loginId} profileId=${PORTAL_PROFILE_ID} models=${models.length}`);
232
+ }
233
+ catch (err) {
234
+ respond(false, { status: 'error' }, {
235
+ code: 'IO_FAILED',
236
+ message: String(err?.message ?? err),
237
+ });
238
+ logRemote(`providerAuth.oauth.io-failed loginId=${loginId} stage=config msg=${String(err?.message ?? err)}`);
239
+ }
240
+ }
241
+
242
+ // 后台轮询循环 → 终态 respond(phase-2)。全程 try/catch,保证恰好 respond 一次且不外抛;
243
+ // finally 清 registry,无论成功/失败/取消
244
+ async function runOAuthBackground({ region, loginId, deviceCode, abortController, respond }) {
245
+ try {
246
+ const outcome = await oauth.pollUntilSettled({
247
+ region,
248
+ userCode: deviceCode.userCode,
249
+ verifier: deviceCode.verifier,
250
+ expiresAt: deviceCode.expiresAt,
251
+ interval: deviceCode.interval,
252
+ signal: abortController.signal,
253
+ });
254
+ if (outcome.status === 'cancelled') {
255
+ respond(false, { status: 'cancelled' }, {
256
+ code: 'OAUTH_CANCELLED',
257
+ message: 'MiniMax OAuth login was cancelled',
258
+ });
259
+ logRemote(`providerAuth.oauth.cancelled loginId=${loginId}`);
260
+ return;
261
+ }
262
+ if (outcome.status === 'timeout') {
263
+ respond(false, { status: 'timeout' }, {
264
+ code: 'OAUTH_TIMEOUT',
265
+ message: 'MiniMax OAuth timed out before authorization completed',
266
+ });
267
+ logRemote(`providerAuth.oauth.timeout loginId=${loginId}`);
268
+ return;
269
+ }
270
+ if (outcome.status === 'error') {
271
+ respond(false, { status: 'error' }, {
272
+ code: 'OAUTH_FAILED',
273
+ message: outcome.message || 'MiniMax OAuth authorization failed',
274
+ });
275
+ logRemote(`providerAuth.oauth.error loginId=${loginId} msg=${outcome.message || 'authorization failed'}`);
276
+ return;
277
+ }
278
+ // success:persistOAuthSuccess 内部恰好 respond 一次,不外抛
279
+ await persistOAuthSuccess({ region, token: outcome.token, loginId, respond });
280
+ }
281
+ catch (err) {
282
+ // 防御:pollUntilSettled 未预期抛错(多半是 /oauth/token 轮询期的网络/传输失败)。
283
+ // 终态帧回 error + OAUTH_FAILED——属轮询阶段失败,区别于写盘失败的 IO_FAILED(见 docs § 2.3.6);
284
+ // 避免发起方永远挂着
285
+ respond(false, { status: 'error' }, {
286
+ code: 'OAUTH_FAILED',
287
+ message: String(err?.message ?? err),
288
+ });
289
+ logRemote(`providerAuth.oauth.error loginId=${loginId} stage=poll msg=${String(err?.message ?? err)}`);
290
+ }
291
+ finally {
292
+ registry.removeLogin(loginId);
293
+ }
294
+ }
295
+
296
+ async function loginOauth({ params, respond }) {
297
+ try {
298
+ const region = params?.region ?? 'cn';
299
+ if (!VALID_REGIONS.has(region)) {
300
+ respondInvalid(respond, 'region must be "cn" or "global"');
301
+ return;
302
+ }
303
+ let deviceCode;
304
+ try {
305
+ deviceCode = await oauth.requestDeviceCode({ region });
306
+ }
307
+ catch (err) {
308
+ // phase-1 之前失败(网络 / HTTP / 响应不全):单帧错误响应
309
+ respondIoFailed(respond, err);
310
+ return;
311
+ }
312
+ const loginId = genLoginId();
313
+ const abortController = new AbortController();
314
+ // 先登记再 respond accepted:让紧随其后的 cancelOauth 一定能找到该 loginId
315
+ registry.registerLogin(loginId, { abortController });
316
+ respond(true, {
317
+ status: 'accepted',
318
+ loginId,
319
+ verificationUri: deviceCode.verificationUri,
320
+ userCode: deviceCode.userCode,
321
+ expiresAt: deviceCode.expiresAt,
322
+ interval: deviceCode.interval,
323
+ });
324
+ scheduleBackground(
325
+ runOAuthBackground({ region, loginId, deviceCode, abortController, respond }),
326
+ );
327
+ }
328
+ catch (err) {
329
+ respondIoFailed(respond, err);
330
+ }
331
+ }
332
+
333
+ async function cancelOauth({ params, respond }) {
334
+ try {
335
+ const loginId = params?.loginId;
336
+ if (!isNonEmptyString(loginId)) {
337
+ respondInvalid(respond, 'loginId must be a non-empty string');
338
+ return;
339
+ }
340
+ const entry = registry.getLogin(loginId);
341
+ // 幂等:未知 loginId 也回 {}(可能已终态自清,或从来没有)
342
+ if (entry) entry.abortController.abort();
343
+ respond(true, {});
344
+ }
345
+ catch (err) {
346
+ respondIoFailed(respond, err);
347
+ }
348
+ }
349
+
350
+ return { setApiKey, list, remove, loginOauth, cancelOauth };
148
351
  }
149
352
 
150
353
  /**
@@ -1,38 +1,54 @@
1
1
  /**
2
- * provider-auth 注册入口 —— 把三个 handler 接到 gateway。
2
+ * provider-auth 注册入口 —— handler 接到 gateway。
3
+ * (coclaw.providerAuth.setApiKey / list / remove / loginOauth / cancelOauth)
3
4
  *
4
5
  * 设计:
5
6
  * - SDK 通过**懒加载 dynamic import** 拿,避免本模块在测试环境(无 openclaw npm 包)下
6
7
  * 一加载就崩。第一次 RPC 调用时才解析;后续调用复用缓存的 promise
8
+ * - OAuth 额外需要 `mutateConfigFile`(config-mutation 子入口)写 provider 节点 baseUrl;
9
+ * PKCE / 表单编码器从 provider-auth 子入口拿(同一 barrel 已导出)
7
10
  * - `mainAgentDir` 走 claw-paths.js 统一入口,handler 每次调用都现拿(state-dir 由 runtime 决定)
8
11
  * - `opts` 主要给单测用:可注入 fake sdk / agentDir resolver / loader
12
+ *
13
+ * 生产路径上 loadSdk / loadConfigMutation 必须由入口(plugins/openclaw/index.js)注入字面量
14
+ * dynamic import —— OpenClaw plugin loader 只扫入口源码识别 `openclaw/plugin-sdk/*` 字面量并
15
+ * 触发 jiti 重写;藏在本子模块的字面量 loader 看不到 → 原生 Node 解析必败。
9
16
  */
10
17
  import { buildProviderAuthHandlers } from './handlers.js';
18
+ import { createMiniMaxOAuth } from './minimax-oauth.js';
19
+ import { registerLogin, getLogin, removeLogin } from './oauth-registry.js';
11
20
  import { mainAgentDir } from '../claw-paths.js';
12
21
 
13
22
  // link-UNSAFE:模块级 dedup 缓存。`--link` 模式下两实例各自 lazy-load 一次
14
23
  // SDK(结果一致、运行无伤但去重失效)。当前仅 RPC handler 走该路径——
15
24
  // 不要在 hook 回调里访问本模块的 export。详见 docs/module-boundaries.md。
16
25
  let _sdkPromise;
26
+ let _configMutationPromise;
17
27
 
18
- // 默认 loader 仅作 fallback:生产路径必须由入口(plugins/openclaw/index.js)注入 loadSdk,
28
+ // 默认 loader 仅作 fallback:生产路径必须由入口(plugins/openclaw/index.js)注入,
19
29
  // 因为 OpenClaw plugin loader 只扫入口源码识别 `openclaw/plugin-sdk/*` 字面量并触发 jiti 重写;
20
30
  // 字面量留在本子模块里 loader 看不到 → 原生 Node 解析必败。
21
- // 此处的 import 在生产环境永不被调用;保留只为测试在不注入 opts.loadSdk 时仍能拿到一个失败路径
31
+ // 此处的 import 在生产环境永不被调用;保留只为测试在不注入 opts.load* 时仍能拿到一个失败路径
22
32
  function defaultLoadSdk() {
23
33
  _sdkPromise ??= import('openclaw/plugin-sdk/provider-auth');
24
34
  return _sdkPromise;
25
35
  }
26
36
 
37
+ function defaultLoadConfigMutation() {
38
+ _configMutationPromise ??= import('openclaw/plugin-sdk/config-mutation');
39
+ return _configMutationPromise;
40
+ }
41
+
27
42
  /**
28
43
  * 测试辅助:清掉懒加载 SDK 缓存。
29
44
  */
30
45
  export function __resetSdkCache() {
31
46
  _sdkPromise = undefined;
47
+ _configMutationPromise = undefined;
32
48
  }
33
49
 
34
50
  /**
35
- * 在 gateway api 上注册 `coclaw.providerAuth.setApiKey` / `list` / `remove`。
51
+ * 在 gateway api 上注册 `coclaw.providerAuth.*`。
36
52
  *
37
53
  * 仅 `register(api)` 的 `if (api.registrationMode === 'full')` 分支调;
38
54
  * 其它 mode 注册副作用违规(参 plugins/openclaw/CLAUDE.md "Service / register 副作用边界")。
@@ -41,17 +57,33 @@ export function __resetSdkCache() {
41
57
  * @param {object} [opts]
42
58
  * @param {Function} [opts.resolveAgentDir] - 覆盖 agentDir 解析(默认 mainAgentDir)
43
59
  * @param {Function} [opts.loadSdk] - 必传(生产由入口注入字面量 dynamic import);缺省回退仅为测试兜底
60
+ * @param {Function} [opts.loadConfigMutation] - 必传(同上,OAuth 写 cfg 用)
61
+ * @param {object} [opts.registry] - 覆盖 oauth-registry(默认模块级单例)
44
62
  */
45
63
  export function registerProviderAuthHandlers(api, opts = {}) {
46
64
  const resolveAgentDir = opts.resolveAgentDir ?? mainAgentDir;
47
65
  const loadSdk = opts.loadSdk ?? defaultLoadSdk;
66
+ const loadConfigMutation = opts.loadConfigMutation ?? defaultLoadConfigMutation;
67
+ const registry = opts.registry ?? { registerLogin, getLogin, removeLogin };
48
68
 
49
69
  let handlersPromise;
50
70
  async function getHandlers() {
51
71
  if (!handlersPromise) {
52
72
  handlersPromise = (async () => {
53
- const sdk = await loadSdk();
54
- return buildProviderAuthHandlers({ sdk, resolveAgentDir });
73
+ const [providerAuthSdk, configMutation] = await Promise.all([
74
+ loadSdk(),
75
+ loadConfigMutation(),
76
+ ]);
77
+ const sdk = {
78
+ ...providerAuthSdk,
79
+ mutateConfigFile: configMutation.mutateConfigFile,
80
+ };
81
+ // PKCE / 表单编码器从 provider-auth barrel 取,注入给设备码流原语
82
+ const oauth = createMiniMaxOAuth({
83
+ generatePkce: providerAuthSdk.generatePkceVerifierChallenge,
84
+ toForm: providerAuthSdk.toFormUrlEncoded,
85
+ });
86
+ return buildProviderAuthHandlers({ sdk, resolveAgentDir, oauth, registry });
55
87
  })();
56
88
  }
57
89
  return handlersPromise;
@@ -78,4 +110,6 @@ export function registerProviderAuthHandlers(api, opts = {}) {
78
110
  api.registerGatewayMethod('coclaw.providerAuth.setApiKey', wrap('setApiKey'));
79
111
  api.registerGatewayMethod('coclaw.providerAuth.list', wrap('list'));
80
112
  api.registerGatewayMethod('coclaw.providerAuth.remove', wrap('remove'));
113
+ api.registerGatewayMethod('coclaw.providerAuth.loginOauth', wrap('loginOauth'));
114
+ api.registerGatewayMethod('coclaw.providerAuth.cancelOauth', wrap('cancelOauth'));
81
115
  }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * minimax-oauth.js —— 复刻 MiniMax 设备码(device-code)OAuth 流
3
+ *
4
+ * 上游无给第三方插件用的"发起 OAuth 登录"入口(登录是 provider 私有 auth method,
5
+ * 由 CLI 交互式 prompter 驱动),所以 CoClaw 自己复刻这套标准设备码流。端点 / client_id /
6
+ * scope / 轮询语义全部抄 openclaw-repo/extensions/minimax/oauth.ts,**共用同一 client_id**
7
+ * (token 最终要被 OpenClaw 自带的 minimax bundled 扩展认)。详见 docs/model-config-api.md § 2.3。
8
+ *
9
+ * 设计要点:
10
+ * - **注入式依赖**:fetch / PKCE 生成器 / 表单编码器 / 随机数 / sleep / now 全部可注入,
11
+ * 单测免网、不误触 global fetch;生产由 ./index.js 用 SDK + 全局 fetch 装配
12
+ * - `requestDeviceCode`:PKCE → POST /oauth/code,拿 user_code / verification_uri /
13
+ * expired_in(**绝对 ms epoch 截止时刻**,与上游 `while (Date.now() < expired_in)` 同义)/ interval
14
+ * - `pollUntilSettled`:单 async 循环轮询 POST /oauth/token,pending→sleep 再轮;
15
+ * success / error / 到期 / abort 四个出口。超时取 expired_in 与本地 MAX_LOGIN_WINDOW 的较早者——
16
+ * 服务端给离谱大 expired_in 也由本地硬窗口兜住,循环必定自我终止(不会永久挂死/泄漏)
17
+ *
18
+ * 注意两个 baseUrl 不是一回事:
19
+ * - OAuth 端点 base(建 /oauth/code、/oauth/token):cn `https://api.minimaxi.com`
20
+ * - provider 配置 baseUrl 兜底(写 cfg 的 models.providers):cn `https://api.minimaxi.com/anthropic`
21
+ * (登录成功优先用服务端动态返回的 resourceUrl,缺省才回落到这个)
22
+ */
23
+
24
+ import { randomBytes, randomUUID } from 'node:crypto';
25
+
26
+ const OAUTH_REGION_CONFIG = {
27
+ cn: { baseUrl: 'https://api.minimaxi.com', clientId: '78257093-7e40-4613-99e0-527b14b39113' },
28
+ global: { baseUrl: 'https://api.minimax.io', clientId: '78257093-7e40-4613-99e0-527b14b39113' },
29
+ };
30
+
31
+ const OAUTH_SCOPE = 'group_id profile model.completion';
32
+ const OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:user_code';
33
+
34
+ // 轮询间隔下限:服务端可能给更小或不给,统一兜到 2s(与上游一致)
35
+ const MIN_POLL_INTERVAL = 2000;
36
+
37
+ // 轮询间隔上限:服务端给离谱大值(误用单位 / 恶意)时兜住——否则单轮 sleep 可跨越数天,
38
+ // 或 >2^31ms 时 setTimeout 被钳成 1ms 变成热轮询。60s 远高于任何正常设备码轮询间隔
39
+ // (典型 ≤10s),不会误伤合法值。导出供测试引用。
40
+ export const MAX_POLL_INTERVAL = 60_000;
41
+
42
+ // 登录轮询的独立硬窗口:不论服务端给的 expired_in 多离谱,轮询最多跑这么久就必定超时收尾。
43
+ // 杜绝"有限但巨大的 expired_in 让 now()>=expiresAt 恒假 → 永不超时 + registry 泄漏 + 发起方挂死"
44
+ // (round-1 的 expired_in 守卫只挡了非数/NaN/Infinity,挡不住有限巨大值)。1h 远高于任何正常
45
+ // 设备码寿命(典型分钟级),合法登录到不了上限,只有离谱值才会被它兜住。导出供测试引用。
46
+ export const MAX_LOGIN_WINDOW = 60 * 60 * 1000;
47
+
48
+ // 把服务端给的轮询间隔规整到 [MIN, MAX] 的有限值;非数 / NaN / Infinity → MIN
49
+ function clampInterval(raw) {
50
+ const n = (typeof raw === 'number' && Number.isFinite(raw)) ? raw : MIN_POLL_INTERVAL;
51
+ return Math.min(Math.max(n, MIN_POLL_INTERVAL), MAX_POLL_INTERVAL);
52
+ }
53
+
54
+ // 跑模型用的 provider 节点 id("token plan" 无独立 id,凭据 + 配置都落这)
55
+ export const PORTAL_PROVIDER_ID = 'minimax-portal';
56
+
57
+ // 写 cfg 的 baseUrl 兜底(带 /anthropic 后缀,区别于 OAuth 端点 base)
58
+ export const CONFIG_DEFAULT_BASE_URL = {
59
+ cn: 'https://api.minimaxi.com/anthropic',
60
+ global: 'https://api.minimax.io/anthropic',
61
+ };
62
+
63
+ export const VALID_REGIONS = new Set(['cn', 'global']);
64
+
65
+ function getEndpoints(region) {
66
+ const cfg = OAUTH_REGION_CONFIG[region];
67
+ return {
68
+ codeEndpoint: `${cfg.baseUrl}/oauth/code`,
69
+ tokenEndpoint: `${cfg.baseUrl}/oauth/token`,
70
+ clientId: cfg.clientId,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * 默认 sleep:到点或 abort 任一即 resolve。abort 时立即清 timer 提前返回,
76
+ * 让轮询循环回到顶部判定 signal.aborted。
77
+ * 导出供单测直接覆盖三条路径(已 abort / 正常到点 / sleep 中途 abort)。
78
+ */
79
+ export function defaultSleep(ms, signal) {
80
+ return new Promise((resolve) => {
81
+ if (signal?.aborted) {
82
+ resolve();
83
+ return;
84
+ }
85
+ const onAbort = () => {
86
+ clearTimeout(timer);
87
+ signal?.removeEventListener?.('abort', onAbort);
88
+ resolve();
89
+ };
90
+ const timer = setTimeout(() => {
91
+ signal?.removeEventListener?.('abort', onAbort);
92
+ resolve();
93
+ }, ms);
94
+ signal?.addEventListener?.('abort', onAbort, { once: true });
95
+ });
96
+ }
97
+
98
+ /**
99
+ * 解析 /oauth/token 响应(抄上游 pollOAuthToken 的容错语义)。
100
+ * @returns {{status:'pending'}|{status:'success',token:object}|{status:'error',message:string}}
101
+ */
102
+ async function parseTokenResponse(response) {
103
+ const text = await response.text();
104
+ let payload;
105
+ if (text) {
106
+ try { payload = JSON.parse(text); }
107
+ catch { payload = undefined; }
108
+ }
109
+ if (!response.ok) {
110
+ return {
111
+ status: 'error',
112
+ message: (payload?.base_resp?.status_msg ?? text) || 'MiniMax OAuth failed to parse response.',
113
+ };
114
+ }
115
+ if (!payload) {
116
+ return { status: 'error', message: 'MiniMax OAuth failed to parse response.' };
117
+ }
118
+ if (payload.status === 'error') {
119
+ return { status: 'error', message: 'An error occurred. Please try again later' };
120
+ }
121
+ if (payload.status !== 'success') {
122
+ return { status: 'pending' };
123
+ }
124
+ // token 的 expired_in 是凭据自身有效期,下游 OpenClaw 刷新逻辑按数字时间戳比较;
125
+ // 非有限正数(字符串 / NaN / 0 / 负)一律视为不全,避免把脏值落进凭据(与设备码 expired_in 守卫对齐)
126
+ const tokenExpires = payload.expired_in;
127
+ if (
128
+ !payload.access_token
129
+ || !payload.refresh_token
130
+ || typeof tokenExpires !== 'number'
131
+ || !Number.isFinite(tokenExpires)
132
+ || tokenExpires <= 0
133
+ ) {
134
+ return { status: 'error', message: 'MiniMax OAuth returned incomplete token payload.' };
135
+ }
136
+ return {
137
+ status: 'success',
138
+ token: {
139
+ access: payload.access_token,
140
+ refresh: payload.refresh_token,
141
+ expires: tokenExpires,
142
+ // resource_url 会成为 provider 配置的 baseUrl;非字符串丢弃,让 handler 回落区域默认
143
+ resourceUrl: typeof payload.resource_url === 'string' ? payload.resource_url : undefined,
144
+ },
145
+ };
146
+ }
147
+
148
+ /**
149
+ * 构造一套设备码流原语,依赖全部可注入。
150
+ *
151
+ * @param {object} deps
152
+ * @param {Function} deps.generatePkce - () → { verifier, challenge }(来自 SDK generatePkceVerifierChallenge)
153
+ * @param {Function} deps.toForm - (obj) → x-www-form-urlencoded 串(来自 SDK toFormUrlEncoded)
154
+ * @param {Function} [deps.fetchImpl] - fetch 实现,默认 globalThis.fetch
155
+ * @param {Function} [deps.randomState] - () → state 串,默认 crypto 16 字节 base64url
156
+ * @param {Function} [deps.randomRequestId] - () → x-request-id,默认 randomUUID
157
+ * @param {Function} [deps.sleep] - (ms, signal) → Promise,默认到点/abort resolve
158
+ * @param {Function} [deps.now] - () → ms epoch,默认 Date.now
159
+ * @returns {{ requestDeviceCode: Function, pollUntilSettled: Function }}
160
+ */
161
+ export function createMiniMaxOAuth(deps) {
162
+ const {
163
+ generatePkce,
164
+ toForm,
165
+ fetchImpl = globalThis.fetch,
166
+ randomState = () => randomBytes(16).toString('base64url'),
167
+ randomRequestId = () => randomUUID(),
168
+ sleep = defaultSleep,
169
+ now = () => Date.now(),
170
+ } = deps;
171
+
172
+ /**
173
+ * 发起设备码请求。
174
+ * @param {object} args
175
+ * @param {string} args.region - 'cn' | 'global'
176
+ * @returns {Promise<{verifier:string, userCode:string, verificationUri:string, expiresAt:number, interval:number}>}
177
+ */
178
+ async function requestDeviceCode({ region }) {
179
+ const { verifier, challenge } = generatePkce();
180
+ const state = randomState();
181
+ const endpoints = getEndpoints(region);
182
+ const response = await fetchImpl(endpoints.codeEndpoint, {
183
+ method: 'POST',
184
+ headers: {
185
+ 'Content-Type': 'application/x-www-form-urlencoded',
186
+ Accept: 'application/json',
187
+ 'x-request-id': randomRequestId(),
188
+ },
189
+ body: toForm({
190
+ response_type: 'code',
191
+ client_id: endpoints.clientId,
192
+ scope: OAUTH_SCOPE,
193
+ code_challenge: challenge,
194
+ code_challenge_method: 'S256',
195
+ state,
196
+ }),
197
+ });
198
+ if (!response.ok) {
199
+ const text = await response.text();
200
+ throw new Error(`MiniMax OAuth authorization failed: ${text || response.statusText}`);
201
+ }
202
+ const payload = await response.json();
203
+ if (!payload?.user_code || !payload?.verification_uri) {
204
+ throw new Error(payload?.error ?? 'MiniMax OAuth authorization returned an incomplete payload.');
205
+ }
206
+ // expired_in 是绝对 ms epoch 截止时刻;缺失/非数会让轮询里 now()>=expiresAt 恒为 false →
207
+ // 永不超时、phase-2 永不 fire、registry 泄漏。fail-closed:缺则抛,走 phase-1 之前的单帧错误
208
+ // (镜像上游 `while (Date.now() < expireTimeMs)` 在 expired_in 非数时的隐式立即终止)
209
+ if (typeof payload.expired_in !== 'number' || !Number.isFinite(payload.expired_in)) {
210
+ throw new Error('MiniMax OAuth authorization returned an invalid expiry.');
211
+ }
212
+ if (payload.state !== state) {
213
+ throw new Error('MiniMax OAuth state mismatch: possible CSRF or session corruption.');
214
+ }
215
+ return {
216
+ verifier,
217
+ userCode: payload.user_code,
218
+ verificationUri: payload.verification_uri,
219
+ expiresAt: payload.expired_in,
220
+ // 规整到 [MIN, MAX] 有限值:服务端给非数会变 NaN 透到 UI,给离谱大值会拖垮轮询
221
+ interval: clampInterval(payload.interval),
222
+ };
223
+ }
224
+
225
+ /**
226
+ * 单 async 轮询循环,直到出终态。四个出口:success / error / timeout / cancelled。
227
+ * @param {object} args
228
+ * @param {string} args.region
229
+ * @param {string} args.userCode
230
+ * @param {string} args.verifier
231
+ * @param {number} args.expiresAt - 绝对 ms epoch 截止时刻
232
+ * @param {number} args.interval - 轮询间隔 ms(已兜底 ≥2s)
233
+ * @param {AbortSignal} [args.signal]
234
+ * @returns {Promise<{status:'success',token:object}|{status:'error',message:string}|{status:'timeout'}|{status:'cancelled'}>}
235
+ */
236
+ async function pollUntilSettled({ region, userCode, verifier, expiresAt, interval, signal }) {
237
+ const endpoints = getEndpoints(region);
238
+ const pollInterval = clampInterval(interval);
239
+ // 独立硬上限:真实截止取服务端 expiresAt 与本地 now()+MAX_LOGIN_WINDOW 的较早者。
240
+ // 服务端给离谱大 expiresAt 时由本地窗口兜住,循环必定自我终止(不再永久挂死/泄漏)。
241
+ const deadline = Math.min(expiresAt, now() + MAX_LOGIN_WINDOW);
242
+ for (;;) {
243
+ if (signal?.aborted) return { status: 'cancelled' };
244
+ if (now() >= deadline) return { status: 'timeout' };
245
+ const response = await fetchImpl(endpoints.tokenEndpoint, {
246
+ method: 'POST',
247
+ headers: {
248
+ 'Content-Type': 'application/x-www-form-urlencoded',
249
+ Accept: 'application/json',
250
+ },
251
+ body: toForm({
252
+ grant_type: OAUTH_GRANT_TYPE,
253
+ client_id: endpoints.clientId,
254
+ user_code: userCode,
255
+ code_verifier: verifier,
256
+ }),
257
+ });
258
+ const result = await parseTokenResponse(response);
259
+ if (result.status === 'success') return { status: 'success', token: result.token };
260
+ if (result.status === 'error') return { status: 'error', message: result.message };
261
+ // pending:等一个间隔再轮(abort 会让 sleep 提前返回,回顶判 aborted)。
262
+ // pollInterval 已被 clampInterval 兜在 ≤MAX_POLL_INTERVAL,故循环必在 deadline+一个间隔内终止
263
+ await sleep(pollInterval, signal);
264
+ }
265
+ }
266
+
267
+ return { requestDeviceCode, pollUntilSettled };
268
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * oauth-registry.js —— 进行中的 OAuth 登录的模块级登记表
3
+ *
4
+ * `loginOauth` 启动后台轮询时把 loginId → { abortController } 登记进来;
5
+ * `cancelOauth` 按 loginId 查到后 abort();轮询循环终态时自行移除。
6
+ *
7
+ * link-safety:登录登记 / 取消 / 移除都只在 RPC handler 路径触发(由同一次
8
+ * registerProviderAuthHandlers 注册的 loginOauth + cancelOauth 共享同一模块实例),
9
+ * 不被任何 hook 回调访问——所以这个模块级单例对本用法是安全的。
10
+ * 详见 docs/module-boundaries.md 的双实例陷阱说明(hook ↔ RPC 才会分叉)。
11
+ */
12
+
13
+ const __registry = new Map();
14
+
15
+ /**
16
+ * 登记一个进行中的登录。
17
+ * @param {string} loginId
18
+ * @param {{ abortController: AbortController }} entry
19
+ */
20
+ export function registerLogin(loginId, entry) {
21
+ __registry.set(loginId, entry);
22
+ }
23
+
24
+ /**
25
+ * 查登记项;未知 loginId 返回 undefined。
26
+ * @param {string} loginId
27
+ * @returns {{ abortController: AbortController } | undefined}
28
+ */
29
+ export function getLogin(loginId) {
30
+ return __registry.get(loginId);
31
+ }
32
+
33
+ /**
34
+ * 移除登记项(终态清理)。
35
+ * @param {string} loginId
36
+ */
37
+ export function removeLogin(loginId) {
38
+ __registry.delete(loginId);
39
+ }
40
+
41
+ /**
42
+ * 测试辅助:清空登记表。
43
+ */
44
+ export function __resetRegistry() {
45
+ __registry.clear();
46
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * portal-model-catalog.js —— OAuth/token-plan provider 的静态模型清单表
3
+ *
4
+ * 背景:上游对 minimax-portal 这类 provider 的模型走**写死的静态清单**,且第三方插件触发不到
5
+ * 它的 catalog discovery——实测 `models.list view:'all'` 也只为默认 provider + 声明了
6
+ * discovery-source 的插件跑发现,扫码 provider 永远是空。所以不把清单写进 OpenClaw 配置,
7
+ * catalog 就为空:UI 选不到、agent 用不了。CoClaw 在这里维护一份与上游对齐的静态表,
8
+ * 登录成功 + gateway 启动对账时写进配置。详见 docs/model-config-api.md § 2.3。
9
+ *
10
+ * 维护约定:
11
+ * - MiniMax 升代时**手动**更新本表(与上游 bundled `MINIMAX_TEXT_MODEL_ORDER` 对齐——
12
+ * 上游那份也是手填手维护的源码常量,本表负担与之持平)。
13
+ * - 将来再遇到同类"扫码/token-plan 但 catalog 不可达"的 provider,在此加一行即可。
14
+ * - `id` / `name` 必填非空(OpenClaw config model 条目 zod schema 要求);id 用 provider
15
+ * 返回的 proper-case,name 用展示名。
16
+ * - 只维护**最必须的运行元数据**:`reasoning`(是否推理模型——缺省会被当成 false,导致推理
17
+ * 模型被按普通模型处理、思考模式出错)、`contextWindow`、`maxTokens`。**不写 `cost`**:
18
+ * portal 走 token plan、不按量计费,价格无意义;`input` 也不写(系统默认即 `['text']`)。
19
+ * 这几个值与上游 `model-definitions.ts`(DEFAULT_MINIMAX_CONTEXT_WINDOW=204800 /
20
+ * DEFAULT_MINIMAX_MAX_TOKENS=131072)+ `provider-models.ts`(reasoning 标记)对齐。
21
+ */
22
+
23
+ export const PORTAL_MODEL_CATALOG = {
24
+ // 与 openclaw-repo/extensions/minimax/ 的 provider-models.ts(reasoning) +
25
+ // model-definitions.ts(contextWindow/maxTokens) 对齐
26
+ 'minimax-portal': [
27
+ { id: 'MiniMax-M2.7', name: 'MiniMax M2.7', reasoning: true, contextWindow: 204800, maxTokens: 131072 },
28
+ { id: 'MiniMax-M2.7-highspeed', name: 'MiniMax M2.7 Highspeed', reasoning: true, contextWindow: 204800, maxTokens: 131072 },
29
+ ],
30
+ };
31
+
32
+ /**
33
+ * 取某 provider 的静态清单。返回**深拷贝**,避免调用方改到共享常量。
34
+ * 未知 provider → 空数组。条目字段为扁平基本类型,`{ ...m }` 即为完整深拷贝。
35
+ *
36
+ * @param {string} providerId
37
+ * @returns {{id:string, name:string, reasoning?:boolean, contextWindow?:number, maxTokens?:number}[]}
38
+ */
39
+ export function getPortalModels(providerId) {
40
+ const list = PORTAL_MODEL_CATALOG[providerId];
41
+ if (!Array.isArray(list)) return [];
42
+ return list.map((m) => ({ ...m }));
43
+ }
44
+
45
+ /**
46
+ * 判断配置里现有清单是否已**覆盖**目标的全部模型——**只按 id**,顺序无关。
47
+ * 启动对账靠它决定"要不要写":目标里每个 id 都已在现有清单出现 → 视为已同步、一字不写。
48
+ *
49
+ * 只按 id(不连 name / 其它字段)是有意为之:模型能不能被选、被用由 id 决定。这样别的来源
50
+ * (如官方 MiniMax 插件)往同一 provider 写一份更大的清单(只要含我们的 id)时,配置成我们的
51
+ * 超集也判已覆盖、不去覆盖它——避免每次重启都把它改回我们这份、和它来回打架。name / 参数即便
52
+ * 与我们不同也不触发写:只保证我们的 id 在,别人的多余条目随它去。
53
+ *
54
+ * @param {unknown} current - 配置里现有 models(可能缺失/非数组/脏条目)
55
+ * @param {{id:string}[]} target - 目标清单(内置表,id 必为非空字符串)
56
+ * @returns {boolean} target 的每个 id 都在 current 出现 → true(空 target 天然被覆盖)
57
+ */
58
+ export function portalModelsCoveredById(current, target) {
59
+ if (!Array.isArray(target)) return false;
60
+ const have = new Set(Array.isArray(current) ? current.map((m) => m?.id) : []);
61
+ return target.every((m) => have.has(m?.id));
62
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * reconcile.js —— gateway 启动时把已绑定 provider 的配置模型清单对账成内置表
3
+ *
4
+ * 为什么需要:模型清单只在"登录成功那一刻"写一次。插件升级后表里补了新 MiniMax 模型,
5
+ * 但老用户早已绑定、不会重新扫码——配置里还是旧清单,新模型永远不出现。启动对账补上这条:
6
+ * 升级装新版必然重启 gateway,重启时拿表跟配置比,不一致就刷新,用户零操作。
7
+ *
8
+ * **关键防御(一致就一字不写)**:mutateConfigFile 是无条件写盘的(克隆→改→写,不做 diff 短路)。
9
+ * 而"写配置"将来万一被上游改成触发 gateway 重启,无脑每次启动都写就会反复重启。所以这里
10
+ * **先比对、只在真不一致时才写**——即便上游哪天那么干,也只会重启一次(写完即一致,下次启动
11
+ * 判定 in-sync 不写、不重启)。
12
+ */
13
+
14
+ import { PORTAL_PROVIDER_ID } from './minimax-oauth.js';
15
+ import { getPortalModels, portalModelsCoveredById } from './portal-model-catalog.js';
16
+
17
+ /**
18
+ * 对账某个 portal-style provider 的配置模型清单。
19
+ *
20
+ * @param {object} opts
21
+ * @param {Function} opts.getConfig - () → 当前 cfg 快照(getClawConfig);null/缺时跳过
22
+ * @param {Function} opts.mutateConfigFile - openclaw/plugin-sdk/config-mutation 的写盘入口
23
+ * @param {string} [opts.providerId] - 默认 minimax-portal
24
+ * @returns {Promise<{changed:boolean, reason:string}>} reason: no-config|not-bound|no-catalog|in-sync|updated
25
+ */
26
+ export async function reconcilePortalModels({ getConfig, mutateConfigFile, providerId = PORTAL_PROVIDER_ID }) {
27
+ const cfg = getConfig?.();
28
+ // runtime 未注入 / config 不可读:跳过,下次启动再对
29
+ if (!cfg || typeof cfg !== 'object') return { changed: false, reason: 'no-config' };
30
+ const node = cfg.models?.providers?.[providerId];
31
+ // 未绑定(无 provider 节点)→ 不碰。登录成功时已写过节点 + 清单,绑定后才谈得上对账
32
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return { changed: false, reason: 'not-bound' };
33
+ const target = getPortalModels(providerId);
34
+ // 表里没这个 provider(理论不该发生)→ 不动用户已有清单
35
+ if (target.length === 0) return { changed: false, reason: 'no-catalog' };
36
+ // 只按 id 判"已覆盖":目标里每个 model id 都已在配置现有清单出现 → 视为已同步、零写入。
37
+ // 比"全等"宽容——配置是我们的超集(别的来源,如官方 MiniMax 插件,多写了几个模型)时也判已覆盖、
38
+ // 不去动它,避免和它来回覆盖、反复重启。仅当配置缺了我们某个 id(升级新增模型 / 老配置不全)才写。
39
+ // 顺带说清读/写不对称:getConfig 读「解析后」配置(config.current()),mutateConfigFile 默认写
40
+ // 「源」配置。即便上游将来在解析期给第三方 portal 注入额外模型,那也只是让配置成超集、我们的 id 仍在
41
+ // → 判已覆盖 → 不写,不会触发"永远判不一致、每次启动都写"的循环。
42
+ if (portalModelsCoveredById(node.models, target)) return { changed: false, reason: 'in-sync' };
43
+
44
+ await mutateConfigFile({
45
+ afterWrite: { mode: 'auto' },
46
+ mutate(draft) {
47
+ const p = draft.models?.providers?.[providerId];
48
+ // 读后到写之间被并发删(极少)→ 不无中生有重建节点,只刷新已存在的
49
+ if (!p || typeof p !== 'object' || Array.isArray(p)) return;
50
+ p.models = getPortalModels(providerId);
51
+ },
52
+ });
53
+ return { changed: true, reason: 'updated' };
54
+ }
@@ -112,6 +112,7 @@ export function defaultResolveGatewayAuthToken() {
112
112
  }
113
113
  }
114
114
  catch (err) {
115
+ // register 早期 / runtime 未注入完成时也可能被触达,注入 logger 此时未必可用,用 console.warn 兜底
115
116
  console.warn?.(`[coclaw] resolve gateway auth token failed: ${String(err?.message ?? err)}`);
116
117
  }
117
118
  const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim();
@@ -584,12 +585,11 @@ export class RealtimeBridge {
584
585
  const hostName = getHostName();
585
586
  const pluginVersion = await getPluginVersion();
586
587
  const agentModels = await this.__collectAgentModels();
587
- broadcastPluginEvent('coclaw.info.updated', {
588
- name,
589
- hostName,
590
- pluginVersion,
591
- agentModels,
592
- });
588
+ // 采集失败时漏报 agentModels(而非显式 null):server / UI 走 patch 语义
589
+ // 保留旧值,避免 admin 仪表盘瞬时清空(OpenClaw manifest cache 偶发卡顿时易触发)
590
+ const payload = { name, hostName, pluginVersion };
591
+ if (agentModels !== null) payload.agentModels = agentModels;
592
+ broadcastPluginEvent('coclaw.info.updated', payload);
593
593
  }
594
594
  catch (err) {
595
595
  /* c8 ignore next 2 -- 防御性兜底 */
@@ -603,7 +603,9 @@ export class RealtimeBridge {
603
603
  */
604
604
  async __collectAgentModels() {
605
605
  try {
606
- const result = await this.__gatewayRpc('agents.list', {}, { timeoutMs: 3000 });
606
+ // OpenClaw agents.list manifest cache 加载链路,偶发 cache mismatch 时 ~10s 才回
607
+ // (issue #80697);timeout 给到 30s 让本地 cache miss 有恢复窗口
608
+ const result = await this.__gatewayRpc('agents.list', {}, { timeoutMs: 30000 });
607
609
  if (result?.ok !== true) return null;
608
610
  const agents = result?.response?.payload?.agents;
609
611
  if (!Array.isArray(agents)) return null;
@@ -1128,9 +1130,11 @@ export class RealtimeBridge {
1128
1130
  this.logger.warn?.(
1129
1131
  `[coclaw] dc gateway req invalid: id=${typeof payload?.id} method=${typeof payload?.method}`,
1130
1132
  );
1131
- // 有合法 id 时回 INVALID_REQUEST 让发起方尽快放弃等待;id 不合法时只能 drop
1133
+ // 有合法 id 时回 INVALID_REQUEST 让发起方尽快放弃等待;id 不合法时只能 drop
1134
+ // 单播:connId 来自每条连接的闭包必非空,只发给发出乱码请求的连接,
1135
+ // 不打扰其他客户端(旁边 OFFLINE / SEND_FAILED 分支属系统级公告保留广播语义)
1132
1136
  if (hasValidId) {
1133
- this.webrtcPeer?.broadcast({
1137
+ await this.webrtcPeer?.sendTo(connId, {
1134
1138
  type: 'res',
1135
1139
  id: payload.id,
1136
1140
  ok: false,
@@ -1604,6 +1608,8 @@ export class RealtimeBridge {
1604
1608
  });
1605
1609
  this.__runEventRoutes.init();
1606
1610
  // 外线(plugin↔远端 server)先发起 connectIfNeeded:仅创建 WebSocket 即返回,不阻塞内线。
1611
+ // 不包 try/catch:__connectIfNeeded → getBindingsPath → pluginDir 链路依赖 runtime 已注入,
1612
+ // register full 模式下 setRuntime 必然先于 service.start 完成,pluginDir 不会抛
1607
1613
  await this.__connectIfNeeded();
1608
1614
  // 三条线各自独立启动:内线(plugin↔本机 gateway)由 start() 主动触发,
1609
1615
  // 不再依附于外线 open。即便外线建连失败/未配置 token,内线仍能起来支撑 DC RPC。
@@ -229,6 +229,7 @@ export class TopicManager {
229
229
  const srcPath = nodePath.join(this.__sessionsDir(agentId), `${topicId}.jsonl`);
230
230
  const tempId = randomUUID();
231
231
  const tempPath = nodePath.join(this.__sessionsDir(agentId), `${tempId}.jsonl`);
232
+ // fs.copyFile 非 atomic;崩在半路的临时 *.jsonl 可能被 session-manager 扫到列为幽灵会话,用户重试导出即恢复
232
233
  await this.__copyFile(srcPath, tempPath);
233
234
  return { tempId, tempPath };
234
235
  }
@@ -282,8 +282,11 @@ class MemoryQueue {
282
282
  return this.tag ? ` ${this.tag}` : '';
283
283
  }
284
284
 
285
+ // AGENTS.md §"日志器" 一般规则是"调用点不要再包 try/catch",此处特例:
286
+ // __safeWarn 是 enqueue 同步入口的兜底,enqueue 对外承诺不抛业务无关错误,
287
+ // 哪怕 logger 自身坏了也不能传染到 RPC 数据通路,故保留 swallow
285
288
  __safeWarn(msg) {
286
- try { this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] ${msg}`); } catch { /* logger 自身坏了也不能让 enqueue 抛 */ }
289
+ try { this.logger.warn?.(`[rpc-queue${this.__tagSuffix()}] ${msg}`); } catch { /* swallow */ }
287
290
  }
288
291
  }
289
292
 
@@ -60,29 +60,6 @@ export function buildChunks(jsonStr, maxMessageSize, getNextMsgId) {
60
60
  return chunks;
61
61
  }
62
62
 
63
- /**
64
- * 按需分片并发送消息(薄包装:buildChunks + dc.send)
65
- * 注意:无应用层流控;生产路径请使用 MemoryQueue + RpcDcSender
66
- * @param {object} dc - DataChannel
67
- * @param {string} jsonStr - 已序列化的 JSON 字符串
68
- * @param {number} maxMessageSize - 对端声明的 maxMessageSize
69
- * @param {() => number} getNextMsgId - 获取下一个 msgId
70
- * @param {object} [logger] - 可选 logger
71
- */
72
- export function chunkAndSend(dc, jsonStr, maxMessageSize, getNextMsgId, logger) {
73
- const chunks = buildChunks(jsonStr, maxMessageSize, getNextMsgId);
74
- if (!chunks) {
75
- dc.send(jsonStr);
76
- return;
77
- }
78
- const msgId = chunks[0].readUInt32BE(1);
79
- const totalBytes = chunks.reduce((n, c) => n + (c.length - HEADER_SIZE), 0);
80
- logger?.info?.(`[dc-chunking] chunking msgId=${msgId}: ${totalBytes} bytes → ${chunks.length} chunks (maxMsgSize=${maxMessageSize})`);
81
- for (const chunk of chunks) {
82
- dc.send(chunk);
83
- }
84
- }
85
-
86
63
  /**
87
64
  * 创建分片重组器
88
65
  * @param {(jsonStr: string) => void} onComplete - 完整消息回调
@@ -160,8 +160,11 @@ class RpcDcSender {
160
160
  return this.tag ? ` ${this.tag}` : '';
161
161
  }
162
162
 
163
+ // AGENTS.md §"日志器" 一般规则是"调用点不要再包 try/catch",此处特例:
164
+ // __safeWarn 在 send/BAL 热路径里调,logger 异常不能升级为协议错(caller 只
165
+ // 等 RPC 应答,不该收到 logger 自身的 stack),故保留 swallow
163
166
  __safeWarn(msg) {
164
- try { this.logger.warn?.(`[rpc-dc-sender${this.__tagSuffix()}] ${msg}`); } catch { /* logger 自身坏了不能让 send 抛非协议错 */ }
167
+ try { this.logger.warn?.(`[rpc-dc-sender${this.__tagSuffix()}] ${msg}`); } catch { /* swallow */ }
165
168
  }
166
169
 
167
170
  __safeRemoteLog(text) {