@coclaw/openclaw-coclaw 0.22.4 → 0.24.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,6 +18,7 @@ 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';
22
23
  import { getClawConfig } from './src/claw-config.js';
23
24
 
@@ -203,7 +204,16 @@ const plugin = {
203
204
  .catch((err) => {
204
205
  logger.warn?.(`[coclaw] chat history manager load/reconcile failed: ${String(err?.message ?? err)}`);
205
206
  });
206
- __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]);
207
217
 
208
218
  // 追踪 chat 因 reset 产生的 session 流水。双源回调(hook + sessions.changed)共用 helper。
209
219
  // recordSessionTransition 内部已 __reloadFromDisk + mutex,外层无需再 cache.has + load。
@@ -790,13 +800,18 @@ const plugin = {
790
800
  }
791
801
  });
792
802
 
793
- // provider 认证管理 RPC(API key 写入 / 列表 / 撤销)。SDK 走懒加载 dynamic import,
794
- // 不增加本插件 cold-load 开销,也让测试环境无需 openclaw 包就能加载 index.js。
795
- // loadSdk 字面量必须留在本入口源码里:OpenClaw plugin loader 只扫入口文件识别
803
+ // provider 认证管理 RPC(API key 写入 / 列表 / 撤销 + OAuth 登录/取消)。SDK 走懒加载
804
+ // dynamic import,不增加本插件 cold-load 开销,也让测试环境无需 openclaw 包就能加载 index.js。
805
+ // load* 字面量必须留在本入口源码里:OpenClaw plugin loader 只扫入口文件识别
796
806
  // `openclaw/plugin-sdk/*` 字符串字面量、命中后才把整张依赖图过 jiti 改写到自家 dist;
797
- // 字面量留在子模块里 loader 看不到 → 整张图走原生 Node 解析必败(plugin 部署目录不带 openclaw 包)
807
+ // 字面量留在子模块里 loader 看不到 → 整张图走原生 Node 解析必败(plugin 部署目录不带 openclaw 包)。
808
+ // config-mutation 供 OAuth 写 provider 节点 baseUrl(hot-reload,零打断)
798
809
  registerProviderAuthHandlers(api, {
799
810
  loadSdk: () => import('openclaw/plugin-sdk/provider-auth'),
811
+ loadConfigMutation: () => import('openclaw/plugin-sdk/config-mutation'),
812
+ // provider-catalog-runtime 供通用 device-code 扫码登录(B1)拿 resolvePluginProviders,
813
+ // 驱动 provider 自带的 device_code 登录方法(codex/copilot 及以后任意 device_code provider)
814
+ loadProviderCatalogRuntime: () => import('openclaw/plugin-sdk/provider-catalog-runtime'),
800
815
  });
801
816
 
802
817
  // 模型默认配置 RPC(coclaw.model.set / list)。三个 SDK 子入口的字面量
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.22.4",
3
+ "version": "0.24.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.",
@@ -13,7 +13,7 @@
13
13
  * - catalog 校验用 `view: 'all'`:picker 可见性过滤会误判某些合法 provider 不存在(subagent 调研结论)
14
14
  */
15
15
 
16
- import { listAllPrimaries } from './resolve.js';
16
+ import { listAllPrimariesWithCredentials } from './resolve.js';
17
17
  import { writePrimary } from './persist.js';
18
18
 
19
19
  const ALLOWED_KEYS = new Set(['agentId', 'primary']);
@@ -76,7 +76,10 @@ async function validateProviderCredAndCatalog({ provider, model, primary, cfg, s
76
76
  * @param {object} opts.sdk
77
77
  * @param {Function} opts.sdk.mutateConfigFile - openclaw/plugin-sdk/config-mutation
78
78
  * @param {Function} opts.sdk.buildModelsProviderData - openclaw/plugin-sdk/models-provider-runtime
79
- * @param {Function} opts.sdk.isProviderAuthProfileConfigured - openclaw/plugin-sdk/provider-auth
79
+ * @param {Function} opts.sdk.isProviderAuthProfileConfigured - openclaw/plugin-sdk/provider-auth(set 用)
80
+ * @param {Function} opts.sdk.isProviderApiKeyConfigured - openclaw/plugin-sdk/provider-auth(list 凭据信号用)
81
+ * @param {Function} opts.sdk.hasConfiguredSecretInput - openclaw/plugin-sdk/provider-auth(list 内联 key 判定)
82
+ * @param {Function} opts.sdk.ensureAuthProfileStore - openclaw/plugin-sdk/provider-auth(list 账本非空判定)
80
83
  * @param {Function} opts.loadConfig - 返回当前 cfg snapshot;缺失时返回 null
81
84
  * @param {Function} opts.resolveAgentDir - 返回 main agent /agent 子目录全路径
82
85
  * @returns {{ set: Function, list: Function }}
@@ -168,7 +171,16 @@ export function buildModelDefaultHandlers({ sdk, loadConfig, resolveAgentDir })
168
171
  respondIoFailed(respond, new Error('runtime config not available'));
169
172
  return;
170
173
  }
171
- respond(true, listAllPrimaries(cfg));
174
+ // 凭据信号(providerUsable / hasAnyUsableCredential)借三源判定:
175
+ // env+账本 走 isProviderApiKeyConfigured(别名归一化其内部完成),
176
+ // 内联 key 走 hasConfiguredSecretInput,账本非空走 ensureAuthProfileStore。
177
+ const deps = {
178
+ agentDir: resolveAgentDir(),
179
+ isProviderApiKeyConfigured: sdk.isProviderApiKeyConfigured,
180
+ hasConfiguredSecretInput: sdk.hasConfiguredSecretInput,
181
+ ensureAuthProfileStore: sdk.ensureAuthProfileStore,
182
+ };
183
+ respond(true, listAllPrimariesWithCredentials(cfg, deps));
172
184
  }
173
185
  catch (err) {
174
186
  respondIoFailed(respond, err);
@@ -80,6 +80,10 @@ export function registerModelDefaultHandlers(api, opts = {}) {
80
80
  mutateConfigFile: configMutation.mutateConfigFile,
81
81
  buildModelsProviderData: modelsRuntime.buildModelsProviderData,
82
82
  isProviderAuthProfileConfigured: providerAuth.isProviderAuthProfileConfigured,
83
+ // list 凭据信号用(provider-auth barrel 已加载,无需新增子入口字面量)
84
+ isProviderApiKeyConfigured: providerAuth.isProviderApiKeyConfigured,
85
+ hasConfiguredSecretInput: providerAuth.hasConfiguredSecretInput,
86
+ ensureAuthProfileStore: providerAuth.ensureAuthProfileStore,
83
87
  };
84
88
  return buildModelDefaultHandlers({ sdk, loadConfig, resolveAgentDir });
85
89
  })();
@@ -86,3 +86,93 @@ export function listAllPrimaries(cfg) {
86
86
  }
87
87
  return out;
88
88
  }
89
+
90
+ /**
91
+ * 取 primary 的 provider 段('<provider>/<model>' 中第一个 '/' 之前)。
92
+ * 不含 '/' 或 '/' 在开头(provider 段为空)时返回 null。
93
+ * 不做别名归一化——交给下游 isProviderApiKeyConfigured 内部完成。
94
+ * @param {string|null} primary
95
+ * @returns {string|null}
96
+ */
97
+ export function providerSegmentOf(primary) {
98
+ if (typeof primary !== 'string') return null;
99
+ const slashIdx = primary.indexOf('/');
100
+ if (slashIdx <= 0) return null;
101
+ return primary.slice(0, slashIdx);
102
+ }
103
+
104
+ /**
105
+ * 某 provider 是否配了内联 key(cfg.models.providers[provider].apiKey)。
106
+ * 仅是"配置信号"(hasConfiguredSecretInput 不验证 env 引用能否真解析,
107
+ * 见心智模型典型陷阱 #20),方向偏向少误报。
108
+ */
109
+ function hasInlineKey(cfg, provider, hasConfiguredSecretInput) {
110
+ const entry = cfg?.models?.providers?.[provider];
111
+ return entry ? hasConfiguredSecretInput(entry.apiKey) : false;
112
+ }
113
+
114
+ /**
115
+ * 该 primary 那家 provider 有没有可用凭据。
116
+ * 判定 = isProviderApiKeyConfigured(覆盖环境变量 + 自管账本,别名归一化其内部完成)
117
+ * 或 该 provider 配了内联 key。
118
+ * primary 解析不出 provider 段(含 null)时恒 false(UI 此时走 noPrimary,不看它)。
119
+ * @param {string|null} primary
120
+ * @param {object} cfg
121
+ * @param {object} deps - { agentDir, isProviderApiKeyConfigured, hasConfiguredSecretInput }
122
+ * @returns {boolean}
123
+ */
124
+ export function computeProviderUsable(primary, cfg, deps) {
125
+ const provider = providerSegmentOf(primary);
126
+ if (!provider) return false;
127
+ if (deps.isProviderApiKeyConfigured({ provider, agentDir: deps.agentDir })) return true;
128
+ return hasInlineKey(cfg, provider, deps.hasConfiguredSecretInput);
129
+ }
130
+
131
+ /**
132
+ * 这台 claw 有没有任何可用凭据:自管账本非空 或 任一 provider 节点有内联 key。
133
+ * 驱动 UI 的 noKey 引导。
134
+ * @param {object} cfg
135
+ * @param {object} deps - { agentDir, ensureAuthProfileStore, hasConfiguredSecretInput }
136
+ * @returns {boolean}
137
+ */
138
+ export function computeHasAnyUsableCredential(cfg, deps) {
139
+ const store = deps.ensureAuthProfileStore(deps.agentDir, { allowKeychainPrompt: false });
140
+ if (store && store.profiles && Object.keys(store.profiles).length > 0) return true;
141
+ const providers = cfg?.models?.providers;
142
+ if (providers && typeof providers === 'object') {
143
+ for (const entry of Object.values(providers)) {
144
+ if (entry && deps.hasConfiguredSecretInput(entry.apiKey)) return true;
145
+ }
146
+ }
147
+ return false;
148
+ }
149
+
150
+ /**
151
+ * 装配带凭据信号的 list 出参(docs/model-config-api.md § 3.4「凭据信号」)。
152
+ * 在 listAllPrimaries 基础上给每个 scope 加 providerUsable,并加顶层 hasAnyUsableCredential。
153
+ * @param {object} cfg
154
+ * @param {object} deps - { agentDir, isProviderApiKeyConfigured, hasConfiguredSecretInput, ensureAuthProfileStore }
155
+ * @returns {{
156
+ * default: { primary: string|null, providerUsable: boolean },
157
+ * agents: Record<string, { primary: string|null, providerUsable: boolean }>,
158
+ * hasAnyUsableCredential: boolean,
159
+ * }}
160
+ */
161
+ export function listAllPrimariesWithCredentials(cfg, deps) {
162
+ const base = listAllPrimaries(cfg);
163
+ const out = {
164
+ default: {
165
+ primary: base.default.primary,
166
+ providerUsable: computeProviderUsable(base.default.primary, cfg, deps),
167
+ },
168
+ agents: {},
169
+ hasAnyUsableCredential: computeHasAnyUsableCredential(cfg, deps),
170
+ };
171
+ for (const [id, v] of Object.entries(base.agents)) {
172
+ out.agents[id] = {
173
+ primary: v.primary,
174
+ providerUsable: computeProviderUsable(v.primary, cfg, deps),
175
+ };
176
+ }
177
+ return out;
178
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * device-code-login.js —— 通用「设备码扫码登录」驱动的可复用零件(B1:驱动上游 run)
3
+ *
4
+ * 不复刻任何一家的登录流程,而是经 plugin-sdk 的 resolvePluginProviders 拿到 provider 自己的
5
+ * device_code 登录方法,用一个「捕获型 prompter」的 ctx 去驱动它的 run(ctx),跟随上游同步。
6
+ * 适用于**任何**暴露了 kind==='device_code' auth 方法的 provider(codex / copilot / 以后新增的),
7
+ * 不针对某一家硬编码。minimax-portal 例外(走自家 B2 复刻流,见 handlers.js 路由 + minimax-oauth.js)。
8
+ *
9
+ * 本模块只放**纯函数 + ctx 工厂**(无 I/O、无 respond / 落盘),编排与两阶段响应在 handlers.js。
10
+ *
11
+ * 关键事实(核实自 openclaw-repo,详见 docs/model-config-api.md § 6.16):
12
+ * - device_code 方法的 run(ctx) 是「纯输出 + 后台轮询、零用户实时输入」:先 prompter.note 亮出
13
+ * 「URL + 码」,再内部轮询,拿到 token 才 resolve。故套得进 CoClaw 现有两阶段 RPC,无需多轮 prompt 管道。
14
+ * - codex / copilot 的验证 note 用同一套模板(`URL: <url>` 行 + `Code: <code>` 行),一条正则通吃。
15
+ * 抠不到也不报错——把 note 全文作为 rawText 交前端兜底(用户明确要求)。
16
+ * - codex 失败时会再发一条含 docs URL 的「帮助 note」;copilot 首条 note 是无 URL 的前导语。
17
+ * 故「是否验证 note」判定 = 含 URL 且非帮助/FAQ 文案,不能只看「第一条 note」。
18
+ * - copilot 已登录会先 confirm「是否重登」→ 捕获型 prompter 答 true(强制走一遍登录拿码)。
19
+ * - 输出型 prompter:text / select / multiselect / oauth.createVpsAwareHandlers 一旦被调即抛错
20
+ * (= 该方法需要交互/回环、本通道不支持),错误经 run reject 暴露。
21
+ */
22
+
23
+ // note 里的 URL:取到空白 / 右括号为止
24
+ const URL_RE = /https?:\/\/[^\s)]+/;
25
+ // 帮助 / FAQ 类 note 也含 URL,但不是验证信息,必须排除
26
+ const HELP_NOTE_RE = /faq|help|trouble|docs\.openclaw/i;
27
+ // 设备码样式短码兜底(如 ABCD-1234),仅在没有 `Code:` 行时用
28
+ const DEVICE_CODE_RE = /\b[A-Z0-9]{4,}-[A-Z0-9]{4,}\b/;
29
+
30
+ /**
31
+ * 判断一条 note 文本是否「验证信息 note」(亮 URL+码 的那条)。
32
+ * @param {string} text
33
+ * @returns {boolean}
34
+ */
35
+ export function isVerificationNote(text) {
36
+ const t = String(text ?? '');
37
+ if (!URL_RE.test(t)) return false;
38
+ if (HELP_NOTE_RE.test(t)) return false;
39
+ return true;
40
+ }
41
+
42
+ /**
43
+ * 从验证 note 文本里尽力抠出结构化字段;抠不到的返回 null(绝不抛错)。
44
+ *
45
+ * URL:优先 `URL:` 行,回退首个 http(s) 链接。
46
+ * Code:优先 `Code:` 行,回退设备码样式短码。
47
+ *
48
+ * @param {string} text
49
+ * @returns {{ verificationUri: string|null, userCode: string|null }}
50
+ */
51
+ export function extractVerification(text) {
52
+ const t = String(text ?? '');
53
+ let verificationUri = null;
54
+ const urlLine = t.match(/^[ \t]*URL:[ \t]*(\S+)/im);
55
+ if (urlLine) verificationUri = urlLine[1];
56
+ else {
57
+ const m = t.match(URL_RE);
58
+ if (m) verificationUri = m[0];
59
+ }
60
+ let userCode = null;
61
+ const codeLine = t.match(/^[ \t]*Code:[ \t]*(\S+)/im);
62
+ if (codeLine) userCode = codeLine[1];
63
+ else {
64
+ const m = t.match(DEVICE_CODE_RE);
65
+ if (m) userCode = m[0];
66
+ }
67
+ return { verificationUri, userCode };
68
+ }
69
+
70
+ /**
71
+ * 在 resolvePluginProviders 结果里找指定 provider 的 device_code 登录方法。
72
+ * @param {Array<{id:string, auth?:Array<{kind:string, run?:Function}>}>} providers
73
+ * @param {string} providerId
74
+ * @returns {{ id:string, run:Function }|null}
75
+ */
76
+ export function findDeviceCodeMethod(providers, providerId) {
77
+ const provider = (providers ?? []).find((p) => p?.id === providerId);
78
+ if (!provider) return null;
79
+ const method = (provider.auth ?? []).find(
80
+ (m) => m?.kind === 'device_code' && typeof m.run === 'function',
81
+ );
82
+ return method ?? null;
83
+ }
84
+
85
+ /**
86
+ * 造一个「捕获型」ProviderAuthContext 去驱动 run。
87
+ *
88
+ * note 转发给 onNote(验证信息从这里来);progress 空操作;confirm 答 true(copilot 重登放行);
89
+ * 真交互(text / select / multiselect / 回环 handler)一旦被调即抛,标记为「需交互、不支持」。
90
+ * isRemote=true(codex 据此跳过本地 openUrl);openUrl 空操作(copilot 会无条件调,安全吞掉)。
91
+ *
92
+ * @param {object} args
93
+ * @param {object} args.config - OpenClaw 运行时配置快照
94
+ * @param {string} [args.agentDir] - 凭据目录(copilot 据此探测已有登录)
95
+ * @param {(text:string, title?:string)=>void} args.onNote - 每条 note 的回调
96
+ * @returns {object} ProviderAuthContext 形状
97
+ */
98
+ export function makeDeviceCodeCtx({ config, agentDir, onNote }) {
99
+ return {
100
+ config: config ?? {},
101
+ env: process.env,
102
+ agentDir,
103
+ prompter: {
104
+ intro: async () => {},
105
+ outro: async () => {},
106
+ plain: async () => {},
107
+ note: async (message, title) => { onNote(String(message ?? ''), title); },
108
+ progress: () => ({ update() {}, stop() {} }),
109
+ confirm: async () => true,
110
+ text: async () => { throw new Error('device-code login requires no text input'); },
111
+ select: async () => { throw new Error('device-code login requires no selection'); },
112
+ multiselect: async () => { throw new Error('device-code login requires no multiselect'); },
113
+ },
114
+ runtime: { log: () => {}, error: () => {}, exit: () => {} },
115
+ isRemote: true,
116
+ openUrl: async () => {},
117
+ oauth: {
118
+ createVpsAwareHandlers: () => {
119
+ throw new Error('device-code login does not support loopback handlers');
120
+ },
121
+ },
122
+ };
123
+ }