@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 +20 -5
- package/package.json +1 -1
- package/src/model-default/handlers.js +15 -3
- package/src/model-default/index.js +4 -0
- package/src/model-default/resolve.js +90 -0
- package/src/provider-auth/device-code-login.js +123 -0
- package/src/provider-auth/handlers.js +374 -5
- package/src/provider-auth/index.js +64 -6
- package/src/provider-auth/minimax-oauth.js +268 -0
- package/src/provider-auth/oauth-registry.js +46 -0
- package/src/provider-auth/portal-model-catalog.js +62 -0
- package/src/provider-auth/reconcile.js +54 -0
- package/src/utils/deep-merge.js +40 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* provider-auth handlers —— `coclaw.providerAuth.*`
|
|
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,33 @@
|
|
|
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
|
+
import { getClawConfig } from '../claw-config.js';
|
|
41
|
+
import { deepMergeInto } from '../utils/deep-merge.js';
|
|
42
|
+
import {
|
|
43
|
+
isVerificationNote,
|
|
44
|
+
extractVerification,
|
|
45
|
+
findDeviceCodeMethod,
|
|
46
|
+
makeDeviceCodeCtx,
|
|
47
|
+
} from './device-code-login.js';
|
|
48
|
+
|
|
25
49
|
const VALID_CRED_TYPES = new Set(['api_key', 'oauth', 'token']);
|
|
50
|
+
const PORTAL_PROFILE_ID = `${PORTAL_PROVIDER_ID}:default`;
|
|
26
51
|
|
|
27
52
|
function respondInvalid(respond, message) {
|
|
28
53
|
respond(false, undefined, { code: 'INVALID_ARGS', message });
|
|
@@ -40,7 +65,7 @@ function isNonEmptyString(v) {
|
|
|
40
65
|
}
|
|
41
66
|
|
|
42
67
|
/**
|
|
43
|
-
*
|
|
68
|
+
* 构造 handler 集合。
|
|
44
69
|
*
|
|
45
70
|
* @param {object} opts
|
|
46
71
|
* @param {object} opts.sdk - openclaw/plugin-sdk/provider-auth 命名空间(或 stub)
|
|
@@ -49,10 +74,28 @@ function isNonEmptyString(v) {
|
|
|
49
74
|
* @param {Function} opts.sdk.ensureAuthProfileStore - 位置参数 (agentDir, options?)
|
|
50
75
|
* @param {Function} opts.sdk.removeProviderAuthProfilesWithLock - async;返回 store(成功)/ null(锁/磁盘失败)
|
|
51
76
|
* @param {Function} opts.sdk.formatApiKeyPreview - 遮蔽显示 helper
|
|
77
|
+
* @param {Function} [opts.sdk.mutateConfigFile] - async;OAuth 写 cfg(openclaw/plugin-sdk/config-mutation)
|
|
52
78
|
* @param {Function} opts.resolveAgentDir - 返回 main agent 完整路径(含 /agent 子目录)
|
|
53
|
-
* @
|
|
79
|
+
* @param {object} [opts.oauth] - createMiniMaxOAuth 实例(requestDeviceCode / pollUntilSettled);MiniMax B2 才用
|
|
80
|
+
* @param {object} [opts.registry] - oauth-registry(registerLogin / getLogin / removeLogin);B2/B1 共用
|
|
81
|
+
* @param {Function} [opts.genLoginId] - () → loginId,默认 randomUUID
|
|
82
|
+
* @param {Function} [opts.scheduleBackground] - (promise) → void,挂后台任务;默认 fire-and-forget + .catch
|
|
83
|
+
* @param {Function} [opts.logRemote] - (text) → void,OAuth 终态诊断推送;默认模块级 remoteLog(测试注入 spy)
|
|
84
|
+
* @param {Function} [opts.resolveConfig] - () → OpenClaw runtime config 快照;通用 device-code 登录(B1)拿 config 用,默认 getClawConfig
|
|
85
|
+
* @param {Function} [opts.resolveProviders] - ({ config, providerRefs }) → ProviderPlugin[];B1 经它拿 provider 的 auth 方法(生产由入口注入,内部 activate:false),默认抛错
|
|
86
|
+
* @returns {{ setApiKey, list, remove, loginOauth, cancelOauth }}
|
|
54
87
|
*/
|
|
55
|
-
export function buildProviderAuthHandlers({
|
|
88
|
+
export function buildProviderAuthHandlers({
|
|
89
|
+
sdk,
|
|
90
|
+
resolveAgentDir,
|
|
91
|
+
oauth,
|
|
92
|
+
registry,
|
|
93
|
+
genLoginId = randomUUID,
|
|
94
|
+
scheduleBackground = (p) => { p.catch(() => {}); },
|
|
95
|
+
logRemote = remoteLog,
|
|
96
|
+
resolveConfig = getClawConfig,
|
|
97
|
+
resolveProviders = () => { throw new Error('provider catalog runtime not injected'); },
|
|
98
|
+
}) {
|
|
56
99
|
// TODO: 将来若要支持"设默认模型 / 多账号顺序"等需要写 cfg 的操作,会撞上
|
|
57
100
|
// gateway 重启窗口的 UX 问题——参 docs/model-config-api.md § 3 / § 5(占位章节)。
|
|
58
101
|
// 当前三个 RPC 都只动 secret 不动 cfg,零重启。
|
|
@@ -144,7 +187,333 @@ export function buildProviderAuthHandlers({ sdk, resolveAgentDir }) {
|
|
|
144
187
|
}
|
|
145
188
|
}
|
|
146
189
|
|
|
147
|
-
|
|
190
|
+
// --- OAuth(MiniMax device-code,真·两阶段 res) ---
|
|
191
|
+
|
|
192
|
+
// 写凭据 + 写 cfg;恰好 respond 一次,不外抛(成功 ok / 失败 IO_FAILED 都在内部消化)
|
|
193
|
+
async function persistOAuthSuccess({ region, token, loginId, respond }) {
|
|
194
|
+
try {
|
|
195
|
+
const credential = {
|
|
196
|
+
type: 'oauth',
|
|
197
|
+
provider: PORTAL_PROVIDER_ID,
|
|
198
|
+
access: token.access,
|
|
199
|
+
refresh: token.refresh,
|
|
200
|
+
expires: token.expires,
|
|
201
|
+
};
|
|
202
|
+
const result = await sdk.upsertAuthProfileWithLock({
|
|
203
|
+
profileId: PORTAL_PROFILE_ID,
|
|
204
|
+
credential,
|
|
205
|
+
agentDir: resolveAgentDir(),
|
|
206
|
+
});
|
|
207
|
+
// 同 setApiKey:锁/磁盘失败时上游静默返回 null
|
|
208
|
+
if (result === null) {
|
|
209
|
+
respond(false, { status: 'error' }, {
|
|
210
|
+
code: 'IO_FAILED',
|
|
211
|
+
message: 'failed to write auth-profiles store',
|
|
212
|
+
});
|
|
213
|
+
logRemote(`providerAuth.oauth.io-failed loginId=${loginId} stage=credential`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// 写 provider 节点 baseUrl —— hot-reload 路径,零打断(afterWrite:auto,禁传 restart)。
|
|
217
|
+
// baseUrl 优先用服务端动态返回的 resourceUrl,缺省回落 cn/global 默认(带 /anthropic 后缀)
|
|
218
|
+
const baseUrl = token.resourceUrl || CONFIG_DEFAULT_BASE_URL[region];
|
|
219
|
+
// 写模型清单进 provider 节点:上游对 minimax-portal 用写死静态清单且第三方触发不到其
|
|
220
|
+
// catalog discovery,不写则 catalog 为空、模型不可用。直接取内置静态表(与上游对齐),
|
|
221
|
+
// 不再网络拉取——避免登录拉一次后静态过时 + 带进旧模型。后续升级新模型靠启动对账补。
|
|
222
|
+
// 详见 docs/model-config-api.md § 2.3
|
|
223
|
+
const models = getPortalModels(PORTAL_PROVIDER_ID);
|
|
224
|
+
await sdk.mutateConfigFile({
|
|
225
|
+
afterWrite: { mode: 'auto' },
|
|
226
|
+
mutate(draft) {
|
|
227
|
+
if (!draft.models || typeof draft.models !== 'object' || Array.isArray(draft.models)) {
|
|
228
|
+
draft.models = {};
|
|
229
|
+
}
|
|
230
|
+
const p = draft.models.providers;
|
|
231
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) {
|
|
232
|
+
draft.models.providers = {};
|
|
233
|
+
}
|
|
234
|
+
draft.models.providers[PORTAL_PROVIDER_ID] = {
|
|
235
|
+
baseUrl,
|
|
236
|
+
api: 'anthropic-messages',
|
|
237
|
+
authHeader: true,
|
|
238
|
+
models,
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
respond(true, { status: 'ok', profileId: PORTAL_PROFILE_ID });
|
|
243
|
+
logRemote(`providerAuth.oauth.ok loginId=${loginId} profileId=${PORTAL_PROFILE_ID} models=${models.length}`);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
respond(false, { status: 'error' }, {
|
|
247
|
+
code: 'IO_FAILED',
|
|
248
|
+
message: String(err?.message ?? err),
|
|
249
|
+
});
|
|
250
|
+
logRemote(`providerAuth.oauth.io-failed loginId=${loginId} stage=config msg=${String(err?.message ?? err)}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 后台轮询循环 → 终态 respond(phase-2)。全程 try/catch,保证恰好 respond 一次且不外抛;
|
|
255
|
+
// finally 清 registry,无论成功/失败/取消
|
|
256
|
+
async function runOAuthBackground({ region, loginId, deviceCode, abortController, respond }) {
|
|
257
|
+
try {
|
|
258
|
+
const outcome = await oauth.pollUntilSettled({
|
|
259
|
+
region,
|
|
260
|
+
userCode: deviceCode.userCode,
|
|
261
|
+
verifier: deviceCode.verifier,
|
|
262
|
+
expiresAt: deviceCode.expiresAt,
|
|
263
|
+
interval: deviceCode.interval,
|
|
264
|
+
signal: abortController.signal,
|
|
265
|
+
});
|
|
266
|
+
if (outcome.status === 'cancelled') {
|
|
267
|
+
respond(false, { status: 'cancelled' }, {
|
|
268
|
+
code: 'OAUTH_CANCELLED',
|
|
269
|
+
message: 'MiniMax OAuth login was cancelled',
|
|
270
|
+
});
|
|
271
|
+
logRemote(`providerAuth.oauth.cancelled loginId=${loginId}`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (outcome.status === 'timeout') {
|
|
275
|
+
respond(false, { status: 'timeout' }, {
|
|
276
|
+
code: 'OAUTH_TIMEOUT',
|
|
277
|
+
message: 'MiniMax OAuth timed out before authorization completed',
|
|
278
|
+
});
|
|
279
|
+
logRemote(`providerAuth.oauth.timeout loginId=${loginId}`);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (outcome.status === 'error') {
|
|
283
|
+
respond(false, { status: 'error' }, {
|
|
284
|
+
code: 'OAUTH_FAILED',
|
|
285
|
+
message: outcome.message || 'MiniMax OAuth authorization failed',
|
|
286
|
+
});
|
|
287
|
+
logRemote(`providerAuth.oauth.error loginId=${loginId} msg=${outcome.message || 'authorization failed'}`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
// success:persistOAuthSuccess 内部恰好 respond 一次,不外抛
|
|
291
|
+
await persistOAuthSuccess({ region, token: outcome.token, loginId, respond });
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
// 防御:pollUntilSettled 未预期抛错(多半是 /oauth/token 轮询期的网络/传输失败)。
|
|
295
|
+
// 终态帧回 error + OAUTH_FAILED——属轮询阶段失败,区别于写盘失败的 IO_FAILED(见 docs § 2.3.6);
|
|
296
|
+
// 避免发起方永远挂着
|
|
297
|
+
respond(false, { status: 'error' }, {
|
|
298
|
+
code: 'OAUTH_FAILED',
|
|
299
|
+
message: String(err?.message ?? err),
|
|
300
|
+
});
|
|
301
|
+
logRemote(`providerAuth.oauth.error loginId=${loginId} stage=poll msg=${String(err?.message ?? err)}`);
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
registry.removeLogin(loginId);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// MiniMax 设备码登录(B2:自家复刻设备码流,码已嵌在 verification URL 里 + 写静态模型清单)。
|
|
309
|
+
// 不并入通用 B1:MiniMax 不在 OpenClaw 内置 provider 字典,登录后还要补写 models.providers 清单
|
|
310
|
+
// (上游对 portal 不做 catalog discovery),与 codex/copilot「内置、模型自带」不同——见 docs § 6.16。
|
|
311
|
+
async function loginOauthMiniMax({ params, respond }) {
|
|
312
|
+
try {
|
|
313
|
+
const region = params?.region ?? 'cn';
|
|
314
|
+
if (!VALID_REGIONS.has(region)) {
|
|
315
|
+
respondInvalid(respond, 'region must be "cn" or "global"');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
let deviceCode;
|
|
319
|
+
try {
|
|
320
|
+
deviceCode = await oauth.requestDeviceCode({ region });
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
// phase-1 之前失败(网络 / HTTP / 响应不全):单帧错误响应
|
|
324
|
+
respondIoFailed(respond, err);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const loginId = genLoginId();
|
|
328
|
+
const abortController = new AbortController();
|
|
329
|
+
// 先登记再 respond accepted:让紧随其后的 cancelOauth 一定能找到该 loginId
|
|
330
|
+
registry.registerLogin(loginId, { abortController });
|
|
331
|
+
respond(true, {
|
|
332
|
+
status: 'accepted',
|
|
333
|
+
loginId,
|
|
334
|
+
verificationUri: deviceCode.verificationUri,
|
|
335
|
+
userCode: deviceCode.userCode,
|
|
336
|
+
expiresAt: deviceCode.expiresAt,
|
|
337
|
+
interval: deviceCode.interval,
|
|
338
|
+
});
|
|
339
|
+
scheduleBackground(
|
|
340
|
+
runOAuthBackground({ region, loginId, deviceCode, abortController, respond }),
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
respondIoFailed(respond, err);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// --- 通用设备码登录(B1:驱动上游 provider 的 device_code run,跟随上游同步) ---
|
|
349
|
+
|
|
350
|
+
// 设备码失败的终态响应:phase-1 已发过 accepted → 发 phase-2 错误帧(带 status);
|
|
351
|
+
// phase-1 之前就失败 → 单帧错误(payload undefined,与 MiniMax phase-1 之前失败同形)
|
|
352
|
+
function respondDeviceFailure(respond, phase1Sent, code, message, status = 'error') {
|
|
353
|
+
if (phase1Sent) respond(false, { status }, { code, message });
|
|
354
|
+
else respond(false, undefined, { code, message });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 设备码登录成功:逐个写凭据 + 有 configPatch 就深合并进 cfg(hot-reload,零打断),恰好 respond 一次
|
|
358
|
+
async function persistDeviceCodeSuccess({ provider, result, loginId, phase1Sent, respond }) {
|
|
359
|
+
try {
|
|
360
|
+
const profileIds = [];
|
|
361
|
+
for (const profile of result.profiles) {
|
|
362
|
+
// 同一 auth-profiles 文件,顺序写避免锁竞争(device-code 通常仅 1 个 profile)
|
|
363
|
+
|
|
364
|
+
const r = await sdk.upsertAuthProfileWithLock({
|
|
365
|
+
profileId: profile.profileId,
|
|
366
|
+
credential: profile.credential,
|
|
367
|
+
agentDir: resolveAgentDir(),
|
|
368
|
+
});
|
|
369
|
+
if (r === null) {
|
|
370
|
+
respondDeviceFailure(respond, phase1Sent, 'IO_FAILED', 'failed to write auth-profiles store');
|
|
371
|
+
logRemote(`providerAuth.deviceCode.io-failed provider=${provider} loginId=${loginId} stage=credential`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
profileIds.push(profile.profileId);
|
|
375
|
+
}
|
|
376
|
+
const patch = result.configPatch;
|
|
377
|
+
if (patch && typeof patch === 'object' && !Array.isArray(patch)) {
|
|
378
|
+
// configPatch 是 provider 自带的 onboarding 默认(如 codex 写 agents.defaults.models 别名);
|
|
379
|
+
// 深合并保留其它 provider,afterWrite:auto 走 hot-reload 不重启(与 MiniMax 写 cfg 一致)
|
|
380
|
+
await sdk.mutateConfigFile({
|
|
381
|
+
afterWrite: { mode: 'auto' },
|
|
382
|
+
mutate(draft) { deepMergeInto(draft, patch); },
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
respond(true, { status: 'ok', provider, profileIds });
|
|
386
|
+
logRemote(`providerAuth.deviceCode.ok provider=${provider} loginId=${loginId} profiles=${profileIds.length}`);
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
respondDeviceFailure(respond, phase1Sent, 'IO_FAILED', String(err?.message ?? err));
|
|
390
|
+
logRemote(`providerAuth.deviceCode.io-failed provider=${provider} loginId=${loginId} stage=config msg=${String(err?.message ?? err)}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function loginOauthDeviceCode({ provider, respond }) {
|
|
395
|
+
try {
|
|
396
|
+
const config = resolveConfig() ?? {};
|
|
397
|
+
let providers;
|
|
398
|
+
try {
|
|
399
|
+
// resolveProviders 可能 async(生产侧惰性加载 catalog-runtime SDK 后再 resolve)
|
|
400
|
+
providers = await resolveProviders({ config, providerRefs: [provider] });
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
// 加载器异常(SDK import 失败 / resolvePluginProviders 抛错):phase-1 之前,单帧错误
|
|
404
|
+
respondIoFailed(respond, err);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const method = findDeviceCodeMethod(providers, provider);
|
|
408
|
+
if (!method) {
|
|
409
|
+
respond(false, undefined, {
|
|
410
|
+
code: 'NOT_FOUND',
|
|
411
|
+
message: `provider "${provider}" has no device-code login method`,
|
|
412
|
+
});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const loginId = genLoginId();
|
|
417
|
+
const abortController = new AbortController();
|
|
418
|
+
let phase1Sent = false;
|
|
419
|
+
|
|
420
|
+
// run 内 prompter.note 吐出「含 URL 的验证 note」→ 触发 phase-1 accepted(仅一次)。
|
|
421
|
+
// 结构化字段抠不到给 null,rawText 永远带上全文交前端兜底。登记发生在 respond accepted 之前,
|
|
422
|
+
// 让紧随其后的 cancelOauth 一定能按 loginId 找到该登录。
|
|
423
|
+
const ctx = makeDeviceCodeCtx({
|
|
424
|
+
config,
|
|
425
|
+
agentDir: resolveAgentDir(),
|
|
426
|
+
onNote: (text) => {
|
|
427
|
+
if (phase1Sent || !isVerificationNote(text)) return;
|
|
428
|
+
phase1Sent = true;
|
|
429
|
+
registry.registerLogin(loginId, { abortController });
|
|
430
|
+
const { verificationUri, userCode } = extractVerification(text);
|
|
431
|
+
respond(true, {
|
|
432
|
+
status: 'accepted',
|
|
433
|
+
loginId,
|
|
434
|
+
provider,
|
|
435
|
+
verificationUri,
|
|
436
|
+
userCode,
|
|
437
|
+
rawText: text,
|
|
438
|
+
});
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// 起 run(不 await 整体);run 仅跑一次,resolve/reject 都到这里恰好终态一次。
|
|
443
|
+
// run 无 abort 钩子:取消停不掉上游后台轮询,cancelOauth 只 abort 信号 → run 到期自己 settle
|
|
444
|
+
// 时这里识别 aborted、回 cancelled 终态、不写凭据(终态必达 + 清理,不做复查骚操作)。
|
|
445
|
+
async function runAndSettle() {
|
|
446
|
+
let result;
|
|
447
|
+
let runErr;
|
|
448
|
+
try {
|
|
449
|
+
result = await Promise.resolve().then(() => method.run(ctx));
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
runErr = err;
|
|
453
|
+
}
|
|
454
|
+
if (phase1Sent) registry.removeLogin(loginId);
|
|
455
|
+
|
|
456
|
+
if (runErr) {
|
|
457
|
+
respondDeviceFailure(respond, phase1Sent, 'OAUTH_FAILED', String(runErr?.message ?? runErr));
|
|
458
|
+
logRemote(`providerAuth.deviceCode.error provider=${provider} loginId=${loginId} stage=run msg=${String(runErr?.message ?? runErr)}`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (abortController.signal.aborted) {
|
|
462
|
+
respondDeviceFailure(respond, phase1Sent, 'OAUTH_CANCELLED', `device-code login for ${provider} was cancelled`, 'cancelled');
|
|
463
|
+
logRemote(`providerAuth.deviceCode.cancelled provider=${provider} loginId=${loginId}`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
// 上游会把中途失败吞成空 profiles(如 copilot access_denied / expired)→ 空即失败
|
|
467
|
+
const profiles = Array.isArray(result?.profiles) ? result.profiles : [];
|
|
468
|
+
if (profiles.length === 0) {
|
|
469
|
+
respondDeviceFailure(respond, phase1Sent, 'OAUTH_FAILED', `device-code login for ${provider} returned no credentials`);
|
|
470
|
+
logRemote(`providerAuth.deviceCode.error provider=${provider} loginId=${loginId} stage=empty-profiles`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
await persistDeviceCodeSuccess({ provider, result, loginId, phase1Sent, respond });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
scheduleBackground(runAndSettle());
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
respondIoFailed(respond, err);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// 登录入口路由:minimax-portal(或缺省,向后兼容)→ B2 自家流;其它任何带 device_code 方法的
|
|
484
|
+
// provider → 通用 B1 驱动。不针对 codex/copilot 硬编码,后续 OpenClaw 新增 device_code provider 自动适用。
|
|
485
|
+
async function loginOauth({ params, respond }) {
|
|
486
|
+
const provider = params?.provider;
|
|
487
|
+
// provider 给了就必须是非空串(缺省保留给 MiniMax B2 向后兼容):空串 / 非串在边界挡掉,
|
|
488
|
+
// 不让其漏进 B1 当 NOT_FOUND,也不把非串塞给上游 resolvePluginProviders(providerRefs 期望串)
|
|
489
|
+
if (provider !== undefined && !isNonEmptyString(provider)) {
|
|
490
|
+
respondInvalid(respond, 'provider must be a non-empty string when provided');
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (provider === undefined || provider === PORTAL_PROVIDER_ID) {
|
|
494
|
+
return loginOauthMiniMax({ params, respond });
|
|
495
|
+
}
|
|
496
|
+
return loginOauthDeviceCode({ provider, respond });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function cancelOauth({ params, respond }) {
|
|
500
|
+
try {
|
|
501
|
+
const loginId = params?.loginId;
|
|
502
|
+
if (!isNonEmptyString(loginId)) {
|
|
503
|
+
respondInvalid(respond, 'loginId must be a non-empty string');
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const entry = registry.getLogin(loginId);
|
|
507
|
+
// 幂等:未知 loginId 也回 {}(可能已终态自清,或从来没有)
|
|
508
|
+
if (entry) entry.abortController.abort();
|
|
509
|
+
respond(true, {});
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
respondIoFailed(respond, err);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return { setApiKey, list, remove, loginOauth, cancelOauth };
|
|
148
517
|
}
|
|
149
518
|
|
|
150
519
|
/**
|
|
@@ -1,38 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* provider-auth 注册入口 ——
|
|
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;
|
|
27
|
+
let _catalogRuntimePromise;
|
|
17
28
|
|
|
18
|
-
// 默认 loader 仅作 fallback:生产路径必须由入口(plugins/openclaw/index.js
|
|
29
|
+
// 默认 loader 仅作 fallback:生产路径必须由入口(plugins/openclaw/index.js)注入,
|
|
19
30
|
// 因为 OpenClaw plugin loader 只扫入口源码识别 `openclaw/plugin-sdk/*` 字面量并触发 jiti 重写;
|
|
20
31
|
// 字面量留在本子模块里 loader 看不到 → 原生 Node 解析必败。
|
|
21
|
-
// 此处的 import 在生产环境永不被调用;保留只为测试在不注入 opts.
|
|
32
|
+
// 此处的 import 在生产环境永不被调用;保留只为测试在不注入 opts.load* 时仍能拿到一个失败路径
|
|
22
33
|
function defaultLoadSdk() {
|
|
23
34
|
_sdkPromise ??= import('openclaw/plugin-sdk/provider-auth');
|
|
24
35
|
return _sdkPromise;
|
|
25
36
|
}
|
|
26
37
|
|
|
38
|
+
function defaultLoadConfigMutation() {
|
|
39
|
+
_configMutationPromise ??= import('openclaw/plugin-sdk/config-mutation');
|
|
40
|
+
return _configMutationPromise;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function defaultLoadProviderCatalogRuntime() {
|
|
44
|
+
_catalogRuntimePromise ??= import('openclaw/plugin-sdk/provider-catalog-runtime');
|
|
45
|
+
return _catalogRuntimePromise;
|
|
46
|
+
}
|
|
47
|
+
|
|
27
48
|
/**
|
|
28
49
|
* 测试辅助:清掉懒加载 SDK 缓存。
|
|
29
50
|
*/
|
|
30
51
|
export function __resetSdkCache() {
|
|
31
52
|
_sdkPromise = undefined;
|
|
53
|
+
_configMutationPromise = undefined;
|
|
54
|
+
_catalogRuntimePromise = undefined;
|
|
32
55
|
}
|
|
33
56
|
|
|
34
57
|
/**
|
|
35
|
-
* 在 gateway api 上注册 `coclaw.providerAuth
|
|
58
|
+
* 在 gateway api 上注册 `coclaw.providerAuth.*`。
|
|
36
59
|
*
|
|
37
60
|
* 仅 `register(api)` 的 `if (api.registrationMode === 'full')` 分支调;
|
|
38
61
|
* 其它 mode 注册副作用违规(参 plugins/openclaw/CLAUDE.md "Service / register 副作用边界")。
|
|
@@ -41,17 +64,50 @@ export function __resetSdkCache() {
|
|
|
41
64
|
* @param {object} [opts]
|
|
42
65
|
* @param {Function} [opts.resolveAgentDir] - 覆盖 agentDir 解析(默认 mainAgentDir)
|
|
43
66
|
* @param {Function} [opts.loadSdk] - 必传(生产由入口注入字面量 dynamic import);缺省回退仅为测试兜底
|
|
67
|
+
* @param {Function} [opts.loadConfigMutation] - 必传(同上,OAuth 写 cfg 用)
|
|
68
|
+
* @param {Function} [opts.loadProviderCatalogRuntime] - 必传(同上,通用 device-code 登录 B1 拿 resolvePluginProviders 用)
|
|
69
|
+
* @param {object} [opts.registry] - 覆盖 oauth-registry(默认模块级单例)
|
|
44
70
|
*/
|
|
45
71
|
export function registerProviderAuthHandlers(api, opts = {}) {
|
|
46
72
|
const resolveAgentDir = opts.resolveAgentDir ?? mainAgentDir;
|
|
47
73
|
const loadSdk = opts.loadSdk ?? defaultLoadSdk;
|
|
74
|
+
const loadConfigMutation = opts.loadConfigMutation ?? defaultLoadConfigMutation;
|
|
75
|
+
const loadProviderCatalogRuntime = opts.loadProviderCatalogRuntime ?? defaultLoadProviderCatalogRuntime;
|
|
76
|
+
const registry = opts.registry ?? { registerLogin, getLogin, removeLogin };
|
|
77
|
+
|
|
78
|
+
// catalog-runtime 仅通用 device-code 登录(B1)才需要,独立惰性加载——不耦合进 getHandlers
|
|
79
|
+
// 的 Promise.all,避免 setApiKey / list / remove / minimax-oauth 因这个 SDK 子入口缺失而连带失败。
|
|
80
|
+
let catalogRuntimePromise;
|
|
81
|
+
const resolveProviders = async ({ config, providerRefs }) => {
|
|
82
|
+
catalogRuntimePromise ??= loadProviderCatalogRuntime();
|
|
83
|
+
const catalogRuntime = await catalogRuntimePromise;
|
|
84
|
+
// 铁律:activate:false —— 只读拿 method.run,不激活 provider,零副作用(不动 gateway 活跃插件名册)。
|
|
85
|
+
return catalogRuntime.resolvePluginProviders({
|
|
86
|
+
config,
|
|
87
|
+
providerRefs,
|
|
88
|
+
activate: false,
|
|
89
|
+
mode: 'runtime',
|
|
90
|
+
});
|
|
91
|
+
};
|
|
48
92
|
|
|
49
93
|
let handlersPromise;
|
|
50
94
|
async function getHandlers() {
|
|
51
95
|
if (!handlersPromise) {
|
|
52
96
|
handlersPromise = (async () => {
|
|
53
|
-
const
|
|
54
|
-
|
|
97
|
+
const [providerAuthSdk, configMutation] = await Promise.all([
|
|
98
|
+
loadSdk(),
|
|
99
|
+
loadConfigMutation(),
|
|
100
|
+
]);
|
|
101
|
+
const sdk = {
|
|
102
|
+
...providerAuthSdk,
|
|
103
|
+
mutateConfigFile: configMutation.mutateConfigFile,
|
|
104
|
+
};
|
|
105
|
+
// PKCE / 表单编码器从 provider-auth barrel 取,注入给设备码流原语
|
|
106
|
+
const oauth = createMiniMaxOAuth({
|
|
107
|
+
generatePkce: providerAuthSdk.generatePkceVerifierChallenge,
|
|
108
|
+
toForm: providerAuthSdk.toFormUrlEncoded,
|
|
109
|
+
});
|
|
110
|
+
return buildProviderAuthHandlers({ sdk, resolveAgentDir, oauth, registry, resolveProviders });
|
|
55
111
|
})();
|
|
56
112
|
}
|
|
57
113
|
return handlersPromise;
|
|
@@ -78,4 +134,6 @@ export function registerProviderAuthHandlers(api, opts = {}) {
|
|
|
78
134
|
api.registerGatewayMethod('coclaw.providerAuth.setApiKey', wrap('setApiKey'));
|
|
79
135
|
api.registerGatewayMethod('coclaw.providerAuth.list', wrap('list'));
|
|
80
136
|
api.registerGatewayMethod('coclaw.providerAuth.remove', wrap('remove'));
|
|
137
|
+
api.registerGatewayMethod('coclaw.providerAuth.loginOauth', wrap('loginOauth'));
|
|
138
|
+
api.registerGatewayMethod('coclaw.providerAuth.cancelOauth', wrap('cancelOauth'));
|
|
81
139
|
}
|