@coclaw/openclaw-coclaw 0.21.5 → 0.22.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/index.js CHANGED
@@ -17,6 +17,8 @@ import { createFileHandler } from './src/file-manager/handler.js';
17
17
  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
+ import { registerProviderAuthHandlers } from './src/provider-auth/index.js';
21
+ import { registerModelDefaultHandlers } from './src/model-default/index.js';
20
22
 
21
23
  import { getPluginVersion, __resetPluginVersion } from './src/plugin-version.js';
22
24
  export { getPluginVersion, __resetPluginVersion };
@@ -132,7 +134,7 @@ function respondInvalid(respond, message) {
132
134
  const plugin = {
133
135
  id: 'openclaw-coclaw',
134
136
  name: 'CoClaw',
135
- description: 'OpenClaw CoClaw channel plugin for remote chat',
137
+ description: 'OpenClaw plugin for remote chat over WebRTC',
136
138
  register(api) {
137
139
  // 按 OpenClaw SDK 入口模式分叉(参照 defineChannelPluginEntry,见上游 plugin-sdk/core.ts 的
138
140
  // defineChannelPluginEntry 实现 与 docs/plugins/sdk-entrypoints.md):
@@ -177,32 +179,94 @@ const plugin = {
177
179
  logger.warn?.(`[coclaw] chat history manager load failed: ${String(err?.message ?? err)}`);
178
180
  });
179
181
 
180
- // 追踪 chat 因 reset 产生的孤儿 session
182
+ // 追踪 chat 因 reset 产生的 session 流水。双源回调(hook + sessions.changed)共用 helper。
183
+ // recordSessionTransition 内部已 __reloadFromDisk + mutex,外层无需再 cache.has + load。
184
+ //
185
+ // agentId 解析:hook 路径有 ctx.agentId(显式契约,优先用);sessions.changed 路径
186
+ // gateway broadcast payload 不含 agentId(见 openclaw-repo emitSessionsChanged),
187
+ // 只能 fallback 切 sessionKey(`agent:<agentId>:*` → parts[1])。当前 sessionKey schema
188
+ // 下两路径解析结果等价;多 agent topic 启用后若上游 sessionKey schema 加前缀会需复评。
189
+ //
190
+ // archivedSessionId 解析:hook 路径来自 event.resumedFrom;sessions.changed payload
191
+ // 不带(无 previousSessionId 字段)→ 进 manager 时为 undefined。manager 不会去
192
+ // "推断字段值",而是把文件首位未归档头直接翻为归档(补 archivedAt)后再 unshift
193
+ // 新头——等价于"以文件首位 sessionId 作为前任"。
194
+ //
195
+ // 该 helper 可直接作为 bridge.onSessionCreated 回调(签名兼容;缺失字段走兜底:
196
+ // agentId 走 parts[1] fallback、archivedSessionId 走 manager 翻 head 路径)。
197
+ async function handleSessionCreated({ agentId, sessionKey, sessionId, archivedSessionId }) {
198
+ if (!sessionKey || !sessionId) {
199
+ // 早返值得警惕:上游事件 schema 异常,或 topic(无 sessionKey)误入双源链路。
200
+ // 打 log + remoteLog 让运维能定位事件源;不影响其他通道。
201
+ logger.warn?.(
202
+ `[coclaw] chat history early-return: missing sessionKey/sessionId`,
203
+ );
204
+ remoteLog(
205
+ `chat-history.missing-keys sessionKey=${sessionKey ?? 'null'} sessionId=${sessionId ?? 'null'}`,
206
+ );
207
+ return;
208
+ }
209
+ // topic 上游伪造的 explicit fake sessionKey(形态 `agent:<agentId>:explicit:<sid>`)
210
+ // 不属于 chat 流水范畴:CoClaw 自管 topic 元信息,不应进 chat-history 桶。
211
+ // 当前 F1 实验已证明该路径不触发本回调,此守卫属防御性兜底。
212
+ // 前提假设:(a) sessionKey 首段是 `agent`;(b) `explicit` 占第 3 段(即 parts[2],
213
+ // 0-indexed 数)。两条同时成立才命中本守卫;若上游 schema 演进(如挪位置 / 增前缀 /
214
+ // 改首段名),需复评本守卫。
215
+ const parts = sessionKey.split(':');
216
+ if (parts[0] === 'agent' && parts[2] === 'explicit') {
217
+ remoteLog(`chat-history.skip-explicit sessionKey=${sessionKey}`);
218
+ return;
219
+ }
220
+ // subagent 是 OpenClaw 程序自起的子任务 run(mode=run 一次性 / mode=session 持久绑定),
221
+ // 形态 `agent:<id>:subagent:<uuid>`,嵌套子代理为 `agent:<id>:subagent:<uuid>:subagent:<uuid2>`。
222
+ // 它不是人机对话流;父 agent 的 transcript 里已含子代理最终输出(作为 user message 回流),
223
+ // 因此不入 chat-history。
224
+ // 判定从 parts[2] 起找 'subagent' 段,避免 agentId 恰好叫 'subagent' 时误伤。
225
+ if (parts[0] === 'agent' && parts.indexOf('subagent', 2) >= 0) {
226
+ remoteLog(`chat-history.skip-subagent sessionKey=${sessionKey}`);
227
+ return;
228
+ }
229
+ let resolvedAgentId = agentId;
230
+ if (!resolvedAgentId) {
231
+ resolvedAgentId = (parts[0] === 'agent' && parts[1]) ? parts[1] : 'main';
232
+ }
233
+ try {
234
+ await chatHistoryManager.recordSessionTransition({
235
+ agentId: resolvedAgentId,
236
+ sessionKey,
237
+ currentSessionId: sessionId,
238
+ archivedSessionId,
239
+ });
240
+ } catch (err) {
241
+ logger.warn?.(`[coclaw] chat history record failed: ${String(err?.message ?? err)}`);
242
+ }
243
+ }
181
244
  if (typeof api.on === 'function') {
182
245
  api.on('session_start', async (event, ctx) => {
183
- if (!event.resumedFrom) return; // 首次创建,无前任
184
- const agentId = ctx?.agentId ?? 'main';
185
- const sessionKey = event.sessionKey;
186
- if (!sessionKey) return;
187
- try {
188
- if (!chatHistoryManager.__cache.has(agentId)) {
189
- await chatHistoryManager.load(agentId);
190
- }
191
- await chatHistoryManager.recordArchived({
192
- agentId,
193
- sessionKey,
194
- sessionId: event.resumedFrom,
195
- });
196
- } catch (err) {
197
- logger.warn?.(`[coclaw] chat history record failed: ${String(err?.message ?? err)}`);
198
- }
246
+ // event.sessionId 是新 sid(必填),event.resumedFrom 是旧 sid(可选),ctx.agentId 可信
247
+ await handleSessionCreated({
248
+ agentId: ctx?.agentId,
249
+ sessionKey: event?.sessionKey,
250
+ sessionId: event?.sessionId,
251
+ archivedSessionId: event?.resumedFrom,
252
+ });
253
+ });
254
+ }
255
+
256
+ // bridge 启动/重启的闭包 helper:把 onSessionCreated 接到 handleSessionCreated。
257
+ // 所有 restartRealtimeBridge 调用必须走这个 helper,避免漏接回调。
258
+ async function restartBridge() {
259
+ await restartRealtimeBridge({
260
+ logger,
261
+ pluginConfig: api.pluginConfig,
262
+ onSessionCreated: handleSessionCreated,
199
263
  });
200
264
  }
201
265
 
202
266
  api.registerService({
203
267
  id: 'coclaw-realtime-bridge',
204
268
  async start() {
205
- await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig });
269
+ await restartBridge();
206
270
  },
207
271
  async stop() {
208
272
  await stopRealtimeBridge();
@@ -239,11 +303,11 @@ const plugin = {
239
303
  });
240
304
  } catch (err) {
241
305
  // bind 失败时恢复 bridge(best-effort,不覆盖原始错误)
242
- await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig }).catch(() => {});
306
+ await restartBridge().catch(() => {});
243
307
  throw err;
244
308
  }
245
309
  // bind 已持久化,restart 失败不影响结果
246
- await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig }).catch((err) => {
310
+ await restartBridge().catch((err) => {
247
311
  logger.warn?.(`[coclaw] bridge restart failed after bind: ${err?.message ?? err}`);
248
312
  });
249
313
  return result;
@@ -276,11 +340,9 @@ const plugin = {
276
340
  serverUrl: params?.serverUrl,
277
341
  });
278
342
  respond(true, {
279
- status: {
280
- clawId: result.clawId,
281
- rebound: result.rebound,
282
- previousClawId: result.previousClawId,
283
- },
343
+ clawId: result.clawId,
344
+ rebound: result.rebound,
345
+ previousClawId: result.previousClawId,
284
346
  });
285
347
  }
286
348
  catch (err) {
@@ -296,7 +358,7 @@ const plugin = {
296
358
  return;
297
359
  }
298
360
  const result = await doUnbind({ serverUrl: params?.serverUrl });
299
- respond(true, { status: { clawId: result.clawId } });
361
+ respond(true, { clawId: result.clawId });
300
362
  }
301
363
  catch (err) {
302
364
  respondError(respond, err);
@@ -325,12 +387,10 @@ const plugin = {
325
387
 
326
388
  // 立即返回认领码给 CLI
327
389
  respond(true, {
328
- status: {
329
- code: result.code,
330
- appUrl: result.appUrl,
331
- expiresAt: result.expiresAt,
332
- expiresMinutes,
333
- },
390
+ code: result.code,
391
+ appUrl: result.appUrl,
392
+ expiresAt: result.expiresAt,
393
+ expiresMinutes,
334
394
  });
335
395
 
336
396
  // 后台 fire-and-forget:等待认领并保存 config + 启 bridge
@@ -341,7 +401,7 @@ const plugin = {
341
401
  signal: abortController.signal,
342
402
  }).then(async () => {
343
403
  if (abortController.signal.aborted) return;
344
- await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig });
404
+ await restartBridge();
345
405
  logger.info?.('[coclaw] enroll completed, bridge restarted');
346
406
  }).catch((err) => {
347
407
  if (abortController.signal.aborted) return;
@@ -691,6 +751,25 @@ const plugin = {
691
751
  }
692
752
  });
693
753
 
754
+ // provider 认证管理 RPC(API key 写入 / 列表 / 撤销)。SDK 走懒加载 dynamic import,
755
+ // 不增加本插件 cold-load 开销,也让测试环境无需 openclaw 包就能加载 index.js。
756
+ // loadSdk 字面量必须留在本入口源码里:OpenClaw plugin loader 只扫入口文件识别
757
+ // `openclaw/plugin-sdk/*` 字符串字面量、命中后才把整张依赖图过 jiti 改写到自家 dist;
758
+ // 字面量留在子模块里 loader 看不到 → 整张图走原生 Node 解析必败(plugin 部署目录不带 openclaw 包)
759
+ registerProviderAuthHandlers(api, {
760
+ loadSdk: () => import('openclaw/plugin-sdk/provider-auth'),
761
+ });
762
+
763
+ // 模型默认配置 RPC(coclaw.model.set / list)。三个 SDK 子入口的字面量
764
+ // dynamic import 必须留在本入口源码——OpenClaw plugin loader 只扫入口源码
765
+ // 命中 `openclaw/plugin-sdk/*` 字面量并触发 jiti 重写;藏在子模块的字面量
766
+ // loader 看不到 → 原生 Node 解析必败。
767
+ registerModelDefaultHandlers(api, {
768
+ loadConfigMutation: () => import('openclaw/plugin-sdk/config-mutation'),
769
+ loadModelsProviderRuntime: () => import('openclaw/plugin-sdk/models-provider-runtime'),
770
+ loadProviderAuth: () => import('openclaw/plugin-sdk/provider-auth'),
771
+ });
772
+
694
773
  const scheduler = new AutoUpgradeScheduler({ pluginId: api.id, logger });
695
774
  api.registerService({
696
775
  id: 'coclaw-auto-upgrade',
@@ -738,7 +817,7 @@ const plugin = {
738
817
  signal: abortController.signal,
739
818
  }).then(async () => {
740
819
  if (abortController.signal.aborted) return;
741
- await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig });
820
+ await restartBridge();
742
821
  logger.info?.('[coclaw] enroll completed via slash command, bridge restarted');
743
822
  }).catch((err) => {
744
823
  if (abortController.signal.aborted) return;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-coclaw",
3
3
  "name": "CoClaw",
4
- "description": "OpenClaw CoClaw channel plugin for remote chat. Run `openclaw coclaw enroll` after install to connect to CoClaw.",
4
+ "description": "OpenClaw plugin for remote chat over WebRTC. Run `openclaw coclaw enroll` after install.",
5
5
  "activation": {
6
6
  "onStartup": true
7
7
  },
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.21.5",
3
+ "version": "0.22.1",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
- "description": "OpenClaw CoClaw channel plugin for remote chat. Run `openclaw coclaw enroll` after install to connect to CoClaw.",
6
+ "description": "OpenClaw plugin for remote chat over WebRTC. Run `openclaw coclaw enroll` after install.",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "https://github.com/coclaw/coclaw.git",
@@ -41,7 +41,7 @@
41
41
  "./index.js"
42
42
  ],
43
43
  "install": {
44
- "minHostVersion": ">=2026.2.19"
44
+ "minHostVersion": ">=2026.3.22"
45
45
  }
46
46
  },
47
47
  "scripts": {
@@ -51,6 +51,7 @@
51
51
  "check": "pnpm lint && pnpm typecheck",
52
52
  "test:plugin": "node --test src/plugin-mode.test.js",
53
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'",
54
+ "e2e": "node --test --test-concurrency=1 e2e/*.e2e.spec.js",
54
55
  "verify": "pnpm check && pnpm test",
55
56
  "link": "bash ./scripts/link.sh",
56
57
  "unlink": "bash ./scripts/unlink.sh",
@@ -62,7 +63,7 @@
62
63
  "release:versions": "npm view @coclaw/openclaw-coclaw versions --json --registry=https://registry.npmjs.org/ && npm view @coclaw/openclaw-coclaw versions --json"
63
64
  },
64
65
  "dependencies": {
65
- "@coclaw/pion-node": "^0.3.0",
66
+ "@coclaw/pion-node": "^0.4.0",
66
67
  "werift": "^0.19.0",
67
68
  "ws": "^8.19.0"
68
69
  },
@@ -17,7 +17,7 @@ export const coclawChannelPlugin = {
17
17
  label: 'CoClaw',
18
18
  selectionLabel: 'CoClaw',
19
19
  docsPath: 'https://docs.coclaw.net',
20
- blurb: 'CoClaw channel plugin for remote chat',
20
+ blurb: 'CoClaw channel plugin for remote chat over WebRTC',
21
21
  },
22
22
  capabilities: {
23
23
  chatTypes: ['direct'],
@@ -4,6 +4,7 @@ import nodePath from 'node:path';
4
4
  import { agentSessionsDir } from '../claw-paths.js';
5
5
  import { atomicWriteJsonFile } from '../utils/atomic-write.js';
6
6
  import { createMutex } from '../utils/mutex.js';
7
+ import { remoteLog } from '../remote-log.js';
7
8
 
8
9
  const HISTORY_FILE = 'coclaw-chat-history.json';
9
10
 
@@ -12,18 +13,25 @@ function emptyStore() {
12
13
  }
13
14
 
14
15
  /**
15
- * Chat History 管理器:追踪 chat(sessionKey)因 reset 产生的孤儿 session。
16
+ * Chat History 管理器:追踪 chat(sessionKey)下的 session 流水。
16
17
  *
17
18
  * 每个 agentId 对应一份 coclaw-chat-history.json,按需懒加载到内存。
18
19
  * 写操作通过 mutex + atomicWriteJsonFile 保证一致性。
19
20
  *
20
- * 文件结构示例:
21
+ * 文件结构示例(archivedAt 是 Date.now() 落的 13 位毫秒时间戳):
21
22
  * {
22
23
  * "version": 1,
23
24
  * "agent:main:main": [
24
- * { "sessionId": "xxx", "archivedAt": 1742003000000 }
25
+ * { "sessionId": "current-sid" }, // 首位:未归档头 = 当前活跃 session
26
+ * { "sessionId": "older", "archivedAt": 1742003000000 } // 第二位起:已归档(新→旧)
25
27
  * ]
26
28
  * }
29
+ *
30
+ * 详见 plugins/openclaw/docs/architecture.md
31
+ *
32
+ * 双源事件供给:
33
+ * - session_start hook:event 同时含新 sid (currentSessionId) + 旧 sid (archivedSessionId)
34
+ * - sessions.changed reason=create:payload 含 sessionKey + 新 sid,旧 sid 从文件首位推断
27
35
  */
28
36
  export class ChatHistoryManager {
29
37
  /**
@@ -87,14 +95,29 @@ export class ChatHistoryManager {
87
95
  this.__cache.set(agentId, data);
88
96
  return;
89
97
  }
90
- } catch {
91
- // 文件不存在或解析失败,初始化空数据
98
+ } catch (err) {
99
+ this.__reportLoadError(filePath, err, '__doLoad');
92
100
  }
93
101
  this.__cache.set(agentId, emptyStore());
94
102
  }
95
103
 
104
+ /**
105
+ * 读盘失败分流:ENOENT 是正常情况(首次启动文件不存在)静默;
106
+ * 其他错误(权限、磁盘损坏、JSON 破损)有诊断价值,打 warn + remoteLog 标识可疑。
107
+ */
108
+ __reportLoadError(filePath, err, callsite) {
109
+ if (err?.code === 'ENOENT') return;
110
+ const fname = nodePath.basename(filePath);
111
+ this.__logger.warn?.(
112
+ `[coclaw] chat-history ${callsite} read failed for ${fname}: ${String(err?.message ?? err)}`,
113
+ );
114
+ remoteLog(
115
+ `chat-history.reload-error site=${callsite} file=${fname} msg=${String(err?.message ?? err)}`,
116
+ );
117
+ }
118
+
96
119
  __ensureLoaded(agentId) {
97
- /* c8 ignore start -- recordArchived/list 均先 __reloadFromDisk,此分支为防御性守卫 */
120
+ /* c8 ignore start -- recordSessionTransition/list 均先 __reloadFromDisk,此分支为防御性守卫 */
98
121
  if (!this.__cache.has(agentId)) {
99
122
  throw new Error(`ChatHistoryManager: agent "${agentId}" not loaded, call load() first`);
100
123
  }
@@ -112,11 +135,32 @@ export class ChatHistoryManager {
112
135
  }
113
136
 
114
137
  /**
115
- * 记录一个被抛弃的孤儿 session
116
- * @param {{ agentId: string, sessionKey: string, sessionId: string }} params
138
+ * 记录一次 session 转换。双源事件共用:
139
+ * - session_start hook:
140
+ * currentSessionId = event.sessionId,
141
+ * archivedSessionId = event.resumedFrom
142
+ * - sessions.changed reason=create:
143
+ * currentSessionId = payload.sessionId,
144
+ * archivedSessionId 不提供(从文件首位推断)
145
+ *
146
+ * 文件首位(list[0])维护当前活跃 session(未归档),其后是已归档 item(新→旧)。
147
+ *
148
+ * 幂等:head 已是 currentSessionId 且
149
+ * - 无 archivedSessionId,或
150
+ * - archivedSessionId 已存在于 list 中
151
+ * 时整体 no-op(不写盘)。
152
+ *
153
+ * @param {{ agentId: string, sessionKey: string, currentSessionId: string, archivedSessionId?: string }} params
117
154
  */
118
- async recordArchived({ agentId, sessionKey, sessionId }) {
119
- if (!sessionKey || !sessionId) return;
155
+ async recordSessionTransition({ agentId, sessionKey, currentSessionId, archivedSessionId }) {
156
+ if (!sessionKey || !currentSessionId) return;
157
+ // 规范化:archivedSessionId 与 currentSessionId 相同属上游契约异常(resumedFrom 不应等于 sessionId),
158
+ // 丢弃避免在空 list 起手时写出"同 sid 既是头又是归档"的双份记录。打 remoteLog 暴露信号
159
+ // 让运维捕捉到上游可能的回归——只在真触发时打一次,正常路径噪声为零。
160
+ if (archivedSessionId === currentSessionId) {
161
+ remoteLog(`chat-history.archived-equals-current sessionKey=${sessionKey} sid=${currentSessionId}`);
162
+ archivedSessionId = undefined;
163
+ }
120
164
  await this.__mutex(agentId).withLock(async () => {
121
165
  // 从磁盘重载确保最新状态:list() 无锁覆写 __cache 可能导致缓存过期
122
166
  await this.__reloadFromDisk(agentId);
@@ -124,23 +168,57 @@ export class ChatHistoryManager {
124
168
  if (!Array.isArray(store[sessionKey])) {
125
169
  store[sessionKey] = [];
126
170
  }
127
- // 去重:同一 sessionId 不重复记录
128
- if (store[sessionKey].some((r) => r.sessionId === sessionId)) return;
129
- // 头部插入(最近的在前)
130
- store[sessionKey].unshift({
131
- sessionId,
132
- archivedAt: Date.now(),
133
- });
171
+ const list = store[sessionKey];
172
+ const head = list[0];
173
+ const headIsCurrent = head && !head.archivedAt && head.sessionId === currentSessionId;
174
+ const archivedAlreadyInList = archivedSessionId
175
+ && list.some((it) => it.sessionId === archivedSessionId);
176
+ // 完全 no-op:head 已是 current 且无新 archived 要追加
177
+ if (headIsCurrent && (!archivedSessionId || archivedAlreadyInList)) return;
178
+
179
+ // head 已是 current(双源到达:第二个事件提供了之前未带的 archivedSessionId)
180
+ // → 不动 head,仅在第二位插入 archivedSessionId
181
+ if (headIsCurrent) {
182
+ list.splice(1, 0, { sessionId: archivedSessionId, archivedAt: Date.now() });
183
+ await this.__persist(agentId);
184
+ return;
185
+ }
186
+
187
+ // stale 事件防御:currentSessionId 已存在于 list 其他位置(即已被归档)。
188
+ // 此时若继续走"翻 head"会把真正活跃的头错翻成归档,并让该 sid 在 list 中重复出现。
189
+ // 触发场景:A→B→C 快速连续 reset 时,hook 与 sessions.changed 跨通道乱序到达,
190
+ // 旧 transition 的事件晚于新 transition 的事件被处理。
191
+ if (list.some((it) => it.sessionId === currentSessionId)) return;
192
+
193
+ // 一般路径:翻 head 为归档(若未归档),然后处理 archivedSessionId,最后头插新 head
194
+ // head 已归档(老格式磁盘数据 / 已正常归档的 list)→ 跳过翻动直接 unshift
195
+ if (head && !head.archivedAt) {
196
+ head.archivedAt = Date.now();
197
+ }
198
+ // archivedSessionId 与 head 不同且不在 list → 在第二位追加(保证不丢前任记录)
199
+ if (archivedSessionId
200
+ && archivedSessionId !== head?.sessionId
201
+ && !list.some((it) => it.sessionId === archivedSessionId)) {
202
+ list.splice(1, 0, { sessionId: archivedSessionId, archivedAt: Date.now() });
203
+ }
204
+ list.unshift({ sessionId: currentSessionId });
134
205
  await this.__persist(agentId);
135
206
  });
136
207
  }
137
208
 
138
209
  /**
139
- * 获取指定 chat 的孤儿 session 列表。
210
+ * 获取指定 chat session 列表(原始数组:首位可能是未归档的当前活跃 session)。
140
211
  * 每次调用从磁盘重载,确保跨模块实例一致性
141
212
  * (OpenClaw 的 hook 和 gateway method 可能在不同 ESM 模块实例中运行)。
213
+ *
214
+ * RPC 契约:`coclaw.chatHistory.list` 直接透传本返回值,不做服务端过滤;调用方
215
+ * (UI / 其它消费者)按 `archivedAt != null` 自行过滤未归档头与孤儿历史段。
216
+ *
217
+ * **返回值是 cache 引用,调用方禁止 mutate**(不要 splice / sort / 改 item 字段);
218
+ * RPC handler 立刻 JSON 序列化所以无副作用,进程内消费者若需要修改请先 deep copy。
219
+ *
142
220
  * @param {{ agentId: string, sessionKey: string }} params
143
- * @returns {Promise<{ history: { sessionId: string, archivedAt: number }[] }>}
221
+ * @returns {Promise<{ history: { sessionId: string, archivedAt?: number }[] }>}
144
222
  */
145
223
  async list({ agentId, sessionKey }) {
146
224
  await this.__reloadFromDisk(agentId);
@@ -162,8 +240,8 @@ export class ChatHistoryManager {
162
240
  this.__cache.set(agentId, data);
163
241
  return;
164
242
  }
165
- } catch {
166
- // 文件不存在或解析失败
243
+ } catch (err) {
244
+ this.__reportLoadError(filePath, err, '__reloadFromDisk');
167
245
  }
168
246
  if (!this.__cache.has(agentId)) {
169
247
  this.__cache.set(agentId, emptyStore());
package/src/claw-paths.js CHANGED
@@ -16,6 +16,7 @@ import nodePath from 'node:path';
16
16
  import { getRuntime } from './runtime.js';
17
17
 
18
18
  const CHANNEL_ID = 'coclaw';
19
+ const MAIN_AGENT_ID = 'main';
19
20
 
20
21
  /**
21
22
  * OpenClaw 真实 state 目录
@@ -81,4 +82,17 @@ export function sessionTranscriptPath(sessionId, agentId, entry) {
81
82
  return nodePath.join(agentSessionsDir(agentId), `${sessionId}.jsonl`);
82
83
  }
83
84
 
85
+ /**
86
+ * main agent 的 agentDir(auth-profiles.json / auth-state.json 等凭据文件所在目录)
87
+ *
88
+ * OpenClaw provider-auth helper 的 `agentDir` 入参约定**含 `/agent` 子目录**
89
+ * (上游 `resolveOpenClawAgentDir` 内部就是拼到这一层),见 mental-model 陷阱 #14。
90
+ * 凭据相关 RPC(provider-auth setApiKey / list / remove)一律走 main agent
91
+ * 一份就够——所有 agent 通过 OpenClaw 的层叠合并自动可见(mental-model § 4.2-4.3)。
92
+ * @returns {string}
93
+ */
94
+ export function mainAgentDir() {
95
+ return nodePath.join(clawStateDir(), 'agents', MAIN_AGENT_ID, 'agent');
96
+ }
97
+
84
98
  export { CHANNEL_ID };
@@ -3,6 +3,7 @@ import { callGatewayMethod } from './common/gateway-notify.js';
3
3
  import {
4
4
  notBound, bindOk, unbindOk,
5
5
  claimCodeCreated,
6
+ apiKeySetOk, authListEntries, authListEmpty, authRemoveOk,
6
7
  } from './common/messages.js';
7
8
 
8
9
  /**
@@ -113,7 +114,7 @@ export function registerCoclawCli({ program, logger: _logger }, deps = {}) {
113
114
  return;
114
115
  }
115
116
 
116
- const data = result.status;
117
+ const data = result.payload;
117
118
  console.log(bindOk(data));
118
119
  }
119
120
  /* c8 ignore next 4 -- callGatewayMethod 不会抛异常,纯防御 */
@@ -139,8 +140,7 @@ export function registerCoclawCli({ program, logger: _logger }, deps = {}) {
139
140
  }
140
141
 
141
142
  // RPC 成功:输出认领码信息
142
- // gateway method 的 respond 数据包含 status 字段
143
- const data = result.status;
143
+ const data = result.payload;
144
144
  if (data?.code && data?.appUrl) {
145
145
  console.log(claimCodeCreated({
146
146
  code: data.code,
@@ -179,7 +179,7 @@ export function registerCoclawCli({ program, logger: _logger }, deps = {}) {
179
179
  return;
180
180
  }
181
181
 
182
- const data = result.status;
182
+ const data = result.payload;
183
183
  console.log(unbindOk(data));
184
184
  }
185
185
  /* c8 ignore next 4 -- callGatewayMethod 不会抛异常,纯防御 */
@@ -188,4 +188,84 @@ export function registerCoclawCli({ program, logger: _logger }, deps = {}) {
188
188
  process.exitCode = 1;
189
189
  }
190
190
  });
191
+
192
+ // 开发期辅助:provider-auth 三个 RPC 的瘦 CLI。
193
+ // 与 bind/unbind 一致——参数解析后调 gateway RPC,不重复业务逻辑。
194
+ const auth = coclaw
195
+ .command('auth')
196
+ .description('Manage provider auth credentials (developer helper)');
197
+
198
+ auth
199
+ .command('set-api-key <provider>')
200
+ .description('Store an API key for a provider')
201
+ .requiredOption('--key <key>', 'API key value (plaintext)')
202
+ .option('--profile-id <id>', 'Override profileId (default: <provider>:default)')
203
+ .action(async (provider, opts) => {
204
+ try {
205
+ const params = { provider, apiKey: opts.key };
206
+ if (opts.profileId) params.profileId = opts.profileId;
207
+ const result = await callWithRetry('coclaw.providerAuth.setApiKey', deps, {
208
+ params, timeoutMs: RPC_TIMEOUT_MS,
209
+ });
210
+ if (!result.ok) {
211
+ handleRpcError(result, 'set-api-key failed');
212
+ return;
213
+ }
214
+ const data = result.payload;
215
+ console.log(apiKeySetOk({ provider, profileId: data?.profileId ?? `${provider}:default` }));
216
+ }
217
+ /* c8 ignore next 4 -- callGatewayMethod 不会抛异常,纯防御 */
218
+ catch (err) {
219
+ console.error(`Error: ${resolveErrorMessage(err)}`);
220
+ process.exitCode = 1;
221
+ }
222
+ });
223
+
224
+ auth
225
+ .command('list')
226
+ .description('List stored auth profiles')
227
+ .option('--provider <provider>', 'Filter by provider id')
228
+ .action(async (opts) => {
229
+ try {
230
+ const rpcOpts = { timeoutMs: RPC_TIMEOUT_MS };
231
+ if (opts.provider) rpcOpts.params = { provider: opts.provider };
232
+ const result = await callWithRetry('coclaw.providerAuth.list', deps, rpcOpts);
233
+ if (!result.ok) {
234
+ handleRpcError(result, 'list failed');
235
+ return;
236
+ }
237
+ const profiles = result.payload?.profiles ?? [];
238
+ if (profiles.length === 0) {
239
+ console.log(authListEmpty(opts.provider));
240
+ return;
241
+ }
242
+ console.log(authListEntries(profiles));
243
+ }
244
+ /* c8 ignore next 4 -- callGatewayMethod 不会抛异常,纯防御 */
245
+ catch (err) {
246
+ console.error(`Error: ${resolveErrorMessage(err)}`);
247
+ process.exitCode = 1;
248
+ }
249
+ });
250
+
251
+ auth
252
+ .command('remove <provider>')
253
+ .description('Remove all stored auth profiles for a provider')
254
+ .action(async (provider) => {
255
+ try {
256
+ const result = await callWithRetry('coclaw.providerAuth.remove', deps, {
257
+ params: { provider }, timeoutMs: RPC_TIMEOUT_MS,
258
+ });
259
+ if (!result.ok) {
260
+ handleRpcError(result, 'remove failed');
261
+ return;
262
+ }
263
+ console.log(authRemoveOk(provider));
264
+ }
265
+ /* c8 ignore next 4 -- callGatewayMethod 不会抛异常,纯防御 */
266
+ catch (err) {
267
+ console.error(`Error: ${resolveErrorMessage(err)}`);
268
+ process.exitCode = 1;
269
+ }
270
+ });
191
271
  }
@@ -46,7 +46,7 @@ export function escapeJsonForCmd(json) {
46
46
  * @param {Function} [spawnFn] - 可注入的 spawn 函数(测试用)
47
47
  * @param {object} [opts] - 可选配置(测试用)
48
48
  * @param {number} [opts.timeoutMs] - 总超时毫秒数
49
- * @returns {Promise<{ ok: boolean, status?: string, error?: string }>}
49
+ * @returns {Promise<{ ok: boolean, payload?: unknown, error?: string, message?: string }>}
50
50
  */
51
51
  export function callGatewayMethod(method, spawnFn, opts) {
52
52
  /* c8 ignore next -- ?? fallback */
@@ -95,9 +95,9 @@ export function callGatewayMethod(method, spawnFn, opts) {
95
95
  if (!trimmed) return { ok: false, error: 'empty_output' };
96
96
  try {
97
97
  const parsed = JSON.parse(trimmed);
98
- // openclaw gateway call --json 直接输出 methodresult payload
99
- // 有合法 JSON 输出即视为 RPC 成功;失败时 CLI 会抛异常并以非零码退出
100
- return { ok: true, status: parsed.status };
98
+ // openclaw gateway call --json 直接把 handlerwire payload 打到 stdout
99
+ // 整体 payload 原样透出,调用方自行读取业务字段(不再抠 .status 一层)
100
+ return { ok: true, payload: parsed };
101
101
  } catch {
102
102
  // 非 JSON 输出也视为成功(openclaw 非 --json 模式的兜底)
103
103
  return { ok: true };