@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.
@@ -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
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * deep-merge.js —— 把 patch 递归并入 target(就地修改)
3
+ *
4
+ * 语义(与上游 config patch 合并对齐 openclaw-repo/src/plugins/provider-auth-choice-helpers.ts
5
+ * 的 mergeConfigPatch):
6
+ * - 两边都是 plain object → 递归并入;
7
+ * - 否则(数组 / 原始值 / null)→ patch 值直接覆盖;
8
+ * - 原型污染键(__proto__ / constructor / prototype)一律跳过。
9
+ *
10
+ * 用途:provider 登录返回的 configPatch(Partial<OpenClawConfig>)合并进 mutateConfigFile 的
11
+ * draft——不能裸 Object.assign(会把 models / agents 整段顶掉),必须逐层深合并保留其它 provider。
12
+ */
13
+
14
+ const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
15
+
16
+ function isPlainObject(v) {
17
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
18
+ }
19
+
20
+ /**
21
+ * 把 patch 深合并进 target(就地修改 target)。
22
+ *
23
+ * target 非 plain object 时不做任何事(无处可并)。
24
+ *
25
+ * @param {object} target - 被修改的目标对象(如 mutateConfigFile 的 draft)
26
+ * @param {unknown} patch - 要并入的补丁;非 plain object 时整体忽略
27
+ */
28
+ export function deepMergeInto(target, patch) {
29
+ if (!isPlainObject(target) || !isPlainObject(patch)) return;
30
+ for (const [key, value] of Object.entries(patch)) {
31
+ if (BLOCKED_KEYS.has(key)) continue;
32
+ if (isPlainObject(value)) {
33
+ if (!isPlainObject(target[key])) target[key] = {};
34
+ deepMergeInto(target[key], value);
35
+ }
36
+ else {
37
+ target[key] = value;
38
+ }
39
+ }
40
+ }