@coclaw/openclaw-coclaw 0.23.0 → 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 +3 -0
- 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 +170 -4
- package/src/provider-auth/index.js +25 -1
- package/src/utils/deep-merge.js +40 -0
package/index.js
CHANGED
|
@@ -809,6 +809,9 @@ const plugin = {
|
|
|
809
809
|
registerProviderAuthHandlers(api, {
|
|
810
810
|
loadSdk: () => import('openclaw/plugin-sdk/provider-auth'),
|
|
811
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'),
|
|
812
815
|
});
|
|
813
816
|
|
|
814
817
|
// 模型默认配置 RPC(coclaw.model.set / list)。三个 SDK 子入口的字面量
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* - catalog 校验用 `view: 'all'`:picker 可见性过滤会误判某些合法 provider 不存在(subagent 调研结论)
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -37,6 +37,14 @@ import { randomUUID } from 'node:crypto';
|
|
|
37
37
|
import { PORTAL_PROVIDER_ID, CONFIG_DEFAULT_BASE_URL, VALID_REGIONS } from './minimax-oauth.js';
|
|
38
38
|
import { getPortalModels } from './portal-model-catalog.js';
|
|
39
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';
|
|
40
48
|
|
|
41
49
|
const VALID_CRED_TYPES = new Set(['api_key', 'oauth', 'token']);
|
|
42
50
|
const PORTAL_PROFILE_ID = `${PORTAL_PROVIDER_ID}:default`;
|
|
@@ -68,11 +76,13 @@ function isNonEmptyString(v) {
|
|
|
68
76
|
* @param {Function} opts.sdk.formatApiKeyPreview - 遮蔽显示 helper
|
|
69
77
|
* @param {Function} [opts.sdk.mutateConfigFile] - async;OAuth 写 cfg(openclaw/plugin-sdk/config-mutation)
|
|
70
78
|
* @param {Function} opts.resolveAgentDir - 返回 main agent 完整路径(含 /agent 子目录)
|
|
71
|
-
* @param {object} [opts.oauth] - createMiniMaxOAuth 实例(requestDeviceCode / pollUntilSettled);
|
|
72
|
-
* @param {object} [opts.registry] - oauth-registry(registerLogin / getLogin / removeLogin
|
|
79
|
+
* @param {object} [opts.oauth] - createMiniMaxOAuth 实例(requestDeviceCode / pollUntilSettled);MiniMax B2 才用
|
|
80
|
+
* @param {object} [opts.registry] - oauth-registry(registerLogin / getLogin / removeLogin);B2/B1 共用
|
|
73
81
|
* @param {Function} [opts.genLoginId] - () → loginId,默认 randomUUID
|
|
74
|
-
* @param {Function} [opts.scheduleBackground] - (promise) → void
|
|
82
|
+
* @param {Function} [opts.scheduleBackground] - (promise) → void,挂后台任务;默认 fire-and-forget + .catch
|
|
75
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),默认抛错
|
|
76
86
|
* @returns {{ setApiKey, list, remove, loginOauth, cancelOauth }}
|
|
77
87
|
*/
|
|
78
88
|
export function buildProviderAuthHandlers({
|
|
@@ -83,6 +93,8 @@ export function buildProviderAuthHandlers({
|
|
|
83
93
|
genLoginId = randomUUID,
|
|
84
94
|
scheduleBackground = (p) => { p.catch(() => {}); },
|
|
85
95
|
logRemote = remoteLog,
|
|
96
|
+
resolveConfig = getClawConfig,
|
|
97
|
+
resolveProviders = () => { throw new Error('provider catalog runtime not injected'); },
|
|
86
98
|
}) {
|
|
87
99
|
// TODO: 将来若要支持"设默认模型 / 多账号顺序"等需要写 cfg 的操作,会撞上
|
|
88
100
|
// gateway 重启窗口的 UX 问题——参 docs/model-config-api.md § 3 / § 5(占位章节)。
|
|
@@ -293,7 +305,10 @@ export function buildProviderAuthHandlers({
|
|
|
293
305
|
}
|
|
294
306
|
}
|
|
295
307
|
|
|
296
|
-
|
|
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 }) {
|
|
297
312
|
try {
|
|
298
313
|
const region = params?.region ?? 'cn';
|
|
299
314
|
if (!VALID_REGIONS.has(region)) {
|
|
@@ -330,6 +345,157 @@ export function buildProviderAuthHandlers({
|
|
|
330
345
|
}
|
|
331
346
|
}
|
|
332
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
|
+
|
|
333
499
|
async function cancelOauth({ params, respond }) {
|
|
334
500
|
try {
|
|
335
501
|
const loginId = params?.loginId;
|
|
@@ -24,6 +24,7 @@ import { mainAgentDir } from '../claw-paths.js';
|
|
|
24
24
|
// 不要在 hook 回调里访问本模块的 export。详见 docs/module-boundaries.md。
|
|
25
25
|
let _sdkPromise;
|
|
26
26
|
let _configMutationPromise;
|
|
27
|
+
let _catalogRuntimePromise;
|
|
27
28
|
|
|
28
29
|
// 默认 loader 仅作 fallback:生产路径必须由入口(plugins/openclaw/index.js)注入,
|
|
29
30
|
// 因为 OpenClaw plugin loader 只扫入口源码识别 `openclaw/plugin-sdk/*` 字面量并触发 jiti 重写;
|
|
@@ -39,12 +40,18 @@ function defaultLoadConfigMutation() {
|
|
|
39
40
|
return _configMutationPromise;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
function defaultLoadProviderCatalogRuntime() {
|
|
44
|
+
_catalogRuntimePromise ??= import('openclaw/plugin-sdk/provider-catalog-runtime');
|
|
45
|
+
return _catalogRuntimePromise;
|
|
46
|
+
}
|
|
47
|
+
|
|
42
48
|
/**
|
|
43
49
|
* 测试辅助:清掉懒加载 SDK 缓存。
|
|
44
50
|
*/
|
|
45
51
|
export function __resetSdkCache() {
|
|
46
52
|
_sdkPromise = undefined;
|
|
47
53
|
_configMutationPromise = undefined;
|
|
54
|
+
_catalogRuntimePromise = undefined;
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
/**
|
|
@@ -58,14 +65,31 @@ export function __resetSdkCache() {
|
|
|
58
65
|
* @param {Function} [opts.resolveAgentDir] - 覆盖 agentDir 解析(默认 mainAgentDir)
|
|
59
66
|
* @param {Function} [opts.loadSdk] - 必传(生产由入口注入字面量 dynamic import);缺省回退仅为测试兜底
|
|
60
67
|
* @param {Function} [opts.loadConfigMutation] - 必传(同上,OAuth 写 cfg 用)
|
|
68
|
+
* @param {Function} [opts.loadProviderCatalogRuntime] - 必传(同上,通用 device-code 登录 B1 拿 resolvePluginProviders 用)
|
|
61
69
|
* @param {object} [opts.registry] - 覆盖 oauth-registry(默认模块级单例)
|
|
62
70
|
*/
|
|
63
71
|
export function registerProviderAuthHandlers(api, opts = {}) {
|
|
64
72
|
const resolveAgentDir = opts.resolveAgentDir ?? mainAgentDir;
|
|
65
73
|
const loadSdk = opts.loadSdk ?? defaultLoadSdk;
|
|
66
74
|
const loadConfigMutation = opts.loadConfigMutation ?? defaultLoadConfigMutation;
|
|
75
|
+
const loadProviderCatalogRuntime = opts.loadProviderCatalogRuntime ?? defaultLoadProviderCatalogRuntime;
|
|
67
76
|
const registry = opts.registry ?? { registerLogin, getLogin, removeLogin };
|
|
68
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
|
+
};
|
|
92
|
+
|
|
69
93
|
let handlersPromise;
|
|
70
94
|
async function getHandlers() {
|
|
71
95
|
if (!handlersPromise) {
|
|
@@ -83,7 +107,7 @@ export function registerProviderAuthHandlers(api, opts = {}) {
|
|
|
83
107
|
generatePkce: providerAuthSdk.generatePkceVerifierChallenge,
|
|
84
108
|
toForm: providerAuthSdk.toFormUrlEncoded,
|
|
85
109
|
});
|
|
86
|
-
return buildProviderAuthHandlers({ sdk, resolveAgentDir, oauth, registry });
|
|
110
|
+
return buildProviderAuthHandlers({ sdk, resolveAgentDir, oauth, registry, resolveProviders });
|
|
87
111
|
})();
|
|
88
112
|
}
|
|
89
113
|
return handlersPromise;
|
|
@@ -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
|
+
}
|