@coclaw/openclaw-coclaw 0.23.0 → 0.25.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
@@ -809,16 +809,20 @@ 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
- // 模型默认配置 RPC(coclaw.model.set / list)。三个 SDK 子入口的字面量
817
+ // 模型默认配置 RPC(coclaw.model.set / list / listUsable)。三个 SDK 子入口的字面量
815
818
  // dynamic import 必须留在本入口源码——OpenClaw plugin loader 只扫入口源码
816
819
  // 命中 `openclaw/plugin-sdk/*` 字面量并触发 jiti 重写;藏在子模块的字面量
817
820
  // loader 看不到 → 原生 Node 解析必败。
818
821
  registerModelDefaultHandlers(api, {
819
822
  loadConfigMutation: () => import('openclaw/plugin-sdk/config-mutation'),
820
- loadModelsProviderRuntime: () => import('openclaw/plugin-sdk/models-provider-runtime'),
821
823
  loadProviderAuth: () => import('openclaw/plugin-sdk/provider-auth'),
824
+ // agent-runtime barrel:resolveProviderIdForAuth(内联别名归一)+ loadModelCatalog(选模型器枚举 / set 存在性干净目录)
825
+ loadAgentRuntime: () => import('openclaw/plugin-sdk/agent-runtime'),
822
826
  });
823
827
 
824
828
  const scheduler = new AutoUpgradeScheduler({ pluginId: api.id, logger });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.23.0",
3
+ "version": "0.25.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.",
@@ -1,22 +1,25 @@
1
1
  /**
2
- * model-default/handlers.js —— coclaw.model.set / list 两个 RPC 的纯函数实现
2
+ * model-default/handlers.js —— coclaw.model.set / list / listUsable 三个 RPC 的纯函数实现
3
3
  *
4
4
  * 设计要点(详见 docs/model-config-api.md § 3):
5
- * - DI 注入 sdk(mutateConfigFile / buildModelsProviderData / isProviderAuthProfileConfigured
5
+ * - DI 注入 sdk(mutateConfigFile / loadModelCatalog / provider-auth 凭据探针 / resolveProviderIdForAuth
6
6
  * + loadConfig + resolveAgentDir,便于单测;产线注入在 ./index.js
7
- * - **出参不加 status wrap**(gateway-method-design skill 新约定):set → {};list → { default, agents }
7
+ * - **出参不加 status wrap**(gateway-method-design skill 约定):set → {};list → { default, agents }
8
+ * listUsable → { byProvider, configuredProviders }
8
9
  * - 错误码只用 INVALID_ARGS / IO_FAILED,参考 provider-auth/handlers.js
9
10
  * 既有 plugin 的 respondError 用 INTERNAL_ERROR 与本节契约不一致,所以本模块自带局部 helper
10
11
  * - set 校验 fail-fast 顺序:params shape → 拒未知字段 → agentId → primary 类型 → primary 形态
11
- * (纯字符串:含 '/'、'/' 不在端点;不依赖 cfg)→ loadConfig → provider 凭据 catalog
12
+ * (纯字符串:含 '/'、'/' 不在端点;不依赖 cfg)→ loadConfig → 凭据门存在性
12
13
  * 形态校验**前置在 loadConfig 之前**,cfg 不可读时非法形态仍是 INVALID_ARGS 而非 IO_FAILED
13
- * - catalog 校验用 `view: 'all'`:picker 可见性过滤会误判某些合法 provider 不存在(subagent 调研结论)
14
+ * - 凭据门 + 选模型器枚举 + list 信号全部走统一别名感知原语(resolve.js),杜绝跨界面口径分叉(§ 3.2.1)
15
+ * - set 存在性 + listUsable 枚举走同一干净目录 loadModelCatalog({readOnly:true}):选得到 ⇒ 设得上(红线天然成立)
14
16
  */
15
17
 
16
- import { listAllPrimaries } from './resolve.js';
18
+ import { listAllPrimariesWithCredentials, computeProviderUsable, enumerateUsableModels } from './resolve.js';
17
19
  import { writePrimary } from './persist.js';
18
20
 
19
- const ALLOWED_KEYS = new Set(['agentId', 'primary']);
21
+ const SET_ALLOWED_KEYS = new Set(['agentId', 'primary']);
22
+ const LISTUSABLE_ALLOWED_KEYS = new Set(['agentId']);
20
23
 
21
24
  function respondInvalid(respond, message) {
22
25
  respond(false, undefined, { code: 'INVALID_ARGS', message });
@@ -47,39 +50,61 @@ function parseProviderModel(primary) {
47
50
  }
48
51
 
49
52
  /**
50
- * cfg 相关的 primary 校验:provider 凭据 + catalog 存在性。
53
+ * 构造 set / listUsable 用的统一别名感知凭据 deps。
54
+ * @param {object} sdk
55
+ * @param {string} agentDir
56
+ * @returns {object}
57
+ */
58
+ function buildCredDeps(sdk, agentDir) {
59
+ return {
60
+ agentDir,
61
+ isProviderApiKeyConfigured: sdk.isProviderApiKeyConfigured,
62
+ hasConfiguredSecretInput: sdk.hasConfiguredSecretInput,
63
+ ensureAuthProfileStore: sdk.ensureAuthProfileStore,
64
+ resolveProviderIdForAuth: sdk.resolveProviderIdForAuth,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * cfg 相关的 primary 校验:凭据门 + 干净目录存在性。
51
70
  * 形态拆分由调用方完成(fail-fast 前置在 loadConfig 之前)。
52
71
  *
72
+ * - 凭据门走统一原语 computeProviderUsable(取代旧 ledger-only isProviderAuthProfileConfigured):
73
+ * 覆盖 env + 账本 + 内联 + 别名套餐,修「内联/env/别名 provider 选得到设不上」,且继续拒幽灵
74
+ * (无任何源凭据的 openai/gpt-5.5 被门挡住)。cooldown 中凭据仍算已配置(沿用 isProviderApiKeyConfigured 立场)。
75
+ * - 存在性走干净目录 loadModelCatalog({readOnly:true})(与选模型器枚举同源 → 「选得到设不上」红线天然成立);
76
+ * 去掉旧 buildModelsProviderData view:'all'(~1076 模型/~10s、鉴权盲、过松)。
77
+ * loadModelCatalog readOnly 自带「持久化失败→静态 manifest」兜底;整体抛错由外层 catch 映射 IO_FAILED(set 是写操作,失败安全为先)。
78
+ *
53
79
  * @returns {Promise<string|null>} 错误 message;null 表通过
54
80
  */
55
- async function validateProviderCredAndCatalog({ provider, model, primary, cfg, sdk, agentDir }) {
56
- // 凭据校验:isProviderAuthProfileConfigured 内部就是 listUsableProviderAuthProfileIds().length > 0
57
- // cooldown profile 仍算"已配置"(cooldown 是临时态,上游 fallback 主循环会主动跳)
58
- const hasCred = sdk.isProviderAuthProfileConfigured({ provider, cfg, agentDir });
59
- if (!hasCred) {
60
- return `provider "${provider}" has no usable auth profile`;
81
+ async function validateProviderCredAndCatalog({ provider, model, primary, cfg, sdk, deps }) {
82
+ if (!computeProviderUsable(primary, cfg, deps)) {
83
+ return `provider "${provider}" has no usable credential`;
61
84
  }
62
-
63
- // catalog 校验:view: 'all' 绕开 picker 可见性过滤(picker 过滤掉的 provider 不影响合法性)
64
- const data = await sdk.buildModelsProviderData(cfg, undefined, { view: 'all' });
65
- const modelSet = data?.byProvider?.get(provider);
66
- if (!modelSet || !modelSet.has(model)) {
85
+ const entries = await sdk.loadModelCatalog({ readOnly: true });
86
+ const exists = Array.isArray(entries)
87
+ && entries.some((e) => e && e.provider === provider && e.id === model);
88
+ if (!exists) {
67
89
  return `model "${primary}" not found in catalog`;
68
90
  }
69
91
  return null;
70
92
  }
71
93
 
72
94
  /**
73
- * 构造 set + list 两个 handler。
95
+ * 构造 set / list / listUsable 三个 handler。
74
96
  *
75
97
  * @param {object} opts
76
98
  * @param {object} opts.sdk
77
- * @param {Function} opts.sdk.mutateConfigFile - openclaw/plugin-sdk/config-mutation
78
- * @param {Function} opts.sdk.buildModelsProviderData - openclaw/plugin-sdk/models-provider-runtime
79
- * @param {Function} opts.sdk.isProviderAuthProfileConfigured - openclaw/plugin-sdk/provider-auth
99
+ * @param {Function} opts.sdk.mutateConfigFile - openclaw/plugin-sdk/config-mutation(set 写盘)
100
+ * @param {Function} opts.sdk.loadModelCatalog - openclaw/plugin-sdk/agent-runtime(set 存在性 + listUsable 枚举的干净目录)
101
+ * @param {Function} opts.sdk.isProviderApiKeyConfigured - openclaw/plugin-sdk/provider-auth(env+账本凭据信号,别名感知)
102
+ * @param {Function} opts.sdk.hasConfiguredSecretInput - openclaw/plugin-sdk/provider-auth(内联 key 判定)
103
+ * @param {Function} opts.sdk.ensureAuthProfileStore - openclaw/plugin-sdk/provider-auth(账本非空 / configuredProviders)
104
+ * @param {Function} opts.sdk.resolveProviderIdForAuth - openclaw/plugin-sdk/agent-runtime(别名归一)
80
105
  * @param {Function} opts.loadConfig - 返回当前 cfg snapshot;缺失时返回 null
81
- * @param {Function} opts.resolveAgentDir - 返回 main agent /agent 子目录全路径
82
- * @returns {{ set: Function, list: Function }}
106
+ * @param {Function} opts.resolveAgentDir - 返回 agent /agent 子目录全路径(默认 main agent;agentId 贯穿)
107
+ * @returns {{ set: Function, list: Function, listUsable: Function }}
83
108
  */
84
109
  export function buildModelDefaultHandlers({ sdk, loadConfig, resolveAgentDir }) {
85
110
  async function set({ params, respond }) {
@@ -89,7 +114,7 @@ export function buildModelDefaultHandlers({ sdk, loadConfig, resolveAgentDir })
89
114
  return;
90
115
  }
91
116
  for (const key of Object.keys(params)) {
92
- if (!ALLOWED_KEYS.has(key)) {
117
+ if (!SET_ALLOWED_KEYS.has(key)) {
93
118
  respondInvalid(respond, `unknown field: ${key}`);
94
119
  return;
95
120
  }
@@ -134,7 +159,7 @@ export function buildModelDefaultHandlers({ sdk, loadConfig, resolveAgentDir })
134
159
  primary,
135
160
  cfg,
136
161
  sdk,
137
- agentDir: resolveAgentDir(),
162
+ deps: buildCredDeps(sdk, resolveAgentDir()),
138
163
  });
139
164
  if (validationError) {
140
165
  respondInvalid(respond, validationError);
@@ -168,12 +193,63 @@ export function buildModelDefaultHandlers({ sdk, loadConfig, resolveAgentDir })
168
193
  respondIoFailed(respond, new Error('runtime config not available'));
169
194
  return;
170
195
  }
171
- respond(true, listAllPrimaries(cfg));
196
+ // 凭据信号(providerUsable / hasAnyUsableCredential)借统一别名感知原语:
197
+ // env+账本 走 isProviderApiKeyConfigured(别名归一其内部完成),
198
+ // 内联 key 走 hasConfiguredSecretInput + resolveProviderIdForAuth 两侧归一,
199
+ // 账本非空走 ensureAuthProfileStore。
200
+ respond(true, listAllPrimariesWithCredentials(cfg, buildCredDeps(sdk, resolveAgentDir())));
201
+ }
202
+ catch (err) {
203
+ respondIoFailed(respond, err);
204
+ }
205
+ }
206
+
207
+ async function listUsable({ params, respond }) {
208
+ try {
209
+ if (!params || typeof params !== 'object' || Array.isArray(params)) {
210
+ respondInvalid(respond, 'params must be an object');
211
+ return;
212
+ }
213
+ for (const key of Object.keys(params)) {
214
+ if (!LISTUSABLE_ALLOWED_KEYS.has(key)) {
215
+ respondInvalid(respond, `unknown field: ${key}`);
216
+ return;
217
+ }
218
+ }
219
+ const { agentId } = params;
220
+ if (agentId !== undefined && !isNonEmptyString(agentId)) {
221
+ respondInvalid(respond, 'agentId must be a non-empty string when provided');
222
+ return;
223
+ }
224
+
225
+ const cfg = loadConfig();
226
+ if (!cfg) {
227
+ respondIoFailed(respond, new Error('runtime config not available'));
228
+ return;
229
+ }
230
+
231
+ // agentId 一路贯穿到凭据判定(与 set/providerUsable 同 agent 的 agentDir,不分叉)。
232
+ // 产线 resolveAgentDir=mainAgentDir 忽略入参恒 main(凭据按设计统一落 main、各 agent 层叠可见),
233
+ // 故四个消费点在产线天然同 dir;测试可注入按 agentId 分目录的 resolver 钉住贯穿。
234
+ const deps = buildCredDeps(sdk, resolveAgentDir(agentId));
235
+
236
+ // 干净目录 loadModelCatalog({readOnly:true}) 自带「持久化失败→静态 manifest」兜底(model-catalog.ts)。
237
+ // 仍整体抛错(罕见,如 runtime config 取不到)→ 兜空 entries:byProvider 退化为空,
238
+ // 但 configuredProviders 不依赖目录仍可算 → 「不空白」,UI 加 provider 排除照常工作(§ 3.2.1 降级)。
239
+ let entries;
240
+ try {
241
+ entries = await sdk.loadModelCatalog({ readOnly: true });
242
+ }
243
+ catch {
244
+ entries = [];
245
+ }
246
+
247
+ respond(true, enumerateUsableModels(entries, cfg, deps));
172
248
  }
173
249
  catch (err) {
174
250
  respondIoFailed(respond, err);
175
251
  }
176
252
  }
177
253
 
178
- return { set, list };
254
+ return { set, list, listUsable };
179
255
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * model-default 注册入口 —— 把 coclaw.model.set / list 接到 gateway。
2
+ * model-default 注册入口 —— 把 coclaw.model.set / list / listUsable 接到 gateway。
3
3
  *
4
4
  * 设计(同 provider-auth/index.js):
5
- * - 三个 SDK 子入口(config-mutation / models-provider-runtime / provider-auth)懒加载,
5
+ * - 三个 SDK 子入口(config-mutation / provider-auth / agent-runtime)懒加载,
6
6
  * 首次 RPC 调用才解析;失败硬编码 IO_FAILED 透 message
7
7
  * - mainAgentDir 走 claw-paths.js;loadConfig 走 claw-config.js
8
8
  * - opts 主要给单测用:可注入 fake sdk 工厂 / fake agentDir resolver / fake loadConfig
@@ -21,33 +21,35 @@ import { getClawConfig } from '../claw-config.js';
21
21
  // SDK(结果一致、运行无伤但去重失效)。当前仅 RPC handler 走该路径——
22
22
  // 不要在 hook 回调里访问本模块的 export。详见 docs/module-boundaries.md。
23
23
  let _configMutationP;
24
- let _modelsP;
25
24
  let _providerAuthP;
25
+ let _agentRuntimeP;
26
26
 
27
27
  function defaultLoadConfigMutation() {
28
28
  _configMutationP ??= import('openclaw/plugin-sdk/config-mutation');
29
29
  return _configMutationP;
30
30
  }
31
- function defaultLoadModelsProviderRuntime() {
32
- _modelsP ??= import('openclaw/plugin-sdk/models-provider-runtime');
33
- return _modelsP;
34
- }
35
31
  function defaultLoadProviderAuth() {
36
32
  _providerAuthP ??= import('openclaw/plugin-sdk/provider-auth');
37
33
  return _providerAuthP;
38
34
  }
35
+ function defaultLoadAgentRuntime() {
36
+ // agent-runtime barrel 同时给:resolveProviderIdForAuth(别名归一)+ loadModelCatalog(干净目录,
37
+ // set 存在性 / listUsable 选模型器枚举同源)。barrel re-export provider-auth-aliases.js + model-catalog.js。
38
+ _agentRuntimeP ??= import('openclaw/plugin-sdk/agent-runtime');
39
+ return _agentRuntimeP;
40
+ }
39
41
 
40
42
  /**
41
43
  * 测试辅助:清掉懒加载 SDK 缓存。
42
44
  */
43
45
  export function __resetSdkCaches() {
44
46
  _configMutationP = undefined;
45
- _modelsP = undefined;
46
47
  _providerAuthP = undefined;
48
+ _agentRuntimeP = undefined;
47
49
  }
48
50
 
49
51
  /**
50
- * 在 gateway api 上注册 `coclaw.model.set` / `coclaw.model.list`。
52
+ * 在 gateway api 上注册 `coclaw.model.set` / `coclaw.model.list` / `coclaw.model.listUsable`。
51
53
  *
52
54
  * 仅 `register(api)` 的 `if (api.registrationMode === 'full')` 分支调;
53
55
  * 其它 mode 注册副作用违规(参 plugins/openclaw/CLAUDE.md "Service / register 副作用边界")。
@@ -57,29 +59,35 @@ export function __resetSdkCaches() {
57
59
  * @param {Function} [opts.resolveAgentDir] - 覆盖 agentDir 解析(默认 mainAgentDir)
58
60
  * @param {Function} [opts.loadConfig] - 覆盖 cfg 读取(默认 getClawConfig)
59
61
  * @param {Function} [opts.loadConfigMutation] - 必传(生产由入口注入字面量 dynamic import)
60
- * @param {Function} [opts.loadModelsProviderRuntime] - 必传(同上)
61
62
  * @param {Function} [opts.loadProviderAuth] - 必传(同上)
63
+ * @param {Function} [opts.loadAgentRuntime] - 必传(同上;resolveProviderIdForAuth + loadModelCatalog)
62
64
  */
63
65
  export function registerModelDefaultHandlers(api, opts = {}) {
64
66
  const resolveAgentDir = opts.resolveAgentDir ?? mainAgentDir;
65
67
  const loadConfig = opts.loadConfig ?? getClawConfig;
66
68
  const loadConfigMutation = opts.loadConfigMutation ?? defaultLoadConfigMutation;
67
- const loadModelsProviderRuntime = opts.loadModelsProviderRuntime ?? defaultLoadModelsProviderRuntime;
68
69
  const loadProviderAuth = opts.loadProviderAuth ?? defaultLoadProviderAuth;
70
+ const loadAgentRuntime = opts.loadAgentRuntime ?? defaultLoadAgentRuntime;
69
71
 
70
72
  let handlersPromise;
71
73
  async function getHandlers() {
72
74
  if (!handlersPromise) {
73
75
  handlersPromise = (async () => {
74
- const [configMutation, modelsRuntime, providerAuth] = await Promise.all([
76
+ const [configMutation, providerAuth, agentRuntime] = await Promise.all([
75
77
  loadConfigMutation(),
76
- loadModelsProviderRuntime(),
77
78
  loadProviderAuth(),
79
+ loadAgentRuntime(),
78
80
  ]);
79
81
  const sdk = {
80
82
  mutateConfigFile: configMutation.mutateConfigFile,
81
- buildModelsProviderData: modelsRuntime.buildModelsProviderData,
82
- isProviderAuthProfileConfigured: providerAuth.isProviderAuthProfileConfigured,
83
+ // 干净目录(set 存在性 + listUsable 枚举同源):agent-runtime barrel re-export model-catalog.js
84
+ loadModelCatalog: agentRuntime.loadModelCatalog,
85
+ // 凭据信号(providerUsable / hasAnyUsableCredential / 凭据门 / configuredProviders)
86
+ isProviderApiKeyConfigured: providerAuth.isProviderApiKeyConfigured,
87
+ hasConfiguredSecretInput: providerAuth.hasConfiguredSecretInput,
88
+ ensureAuthProfileStore: providerAuth.ensureAuthProfileStore,
89
+ // 别名归一(内联凭据信号 + 选模型器枚举 + configuredProviders)
90
+ resolveProviderIdForAuth: agentRuntime.resolveProviderIdForAuth,
83
91
  };
84
92
  return buildModelDefaultHandlers({ sdk, loadConfig, resolveAgentDir });
85
93
  })();
@@ -106,4 +114,5 @@ export function registerModelDefaultHandlers(api, opts = {}) {
106
114
 
107
115
  api.registerGatewayMethod('coclaw.model.set', wrap('set'));
108
116
  api.registerGatewayMethod('coclaw.model.list', wrap('list'));
117
+ api.registerGatewayMethod('coclaw.model.listUsable', wrap('listUsable'));
109
118
  }
@@ -86,3 +86,228 @@ 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.<id>.apiKey)。
106
+ * 别名感知:查询名与各内联节点 id 两侧都过 resolveProviderIdForAuth 归一后比较,
107
+ * 故持基座 volcengine 内联 key 的用户查 volcengine-plan(套餐变体)也命中。
108
+ * 仅是"配置信号"(hasConfiguredSecretInput 不验证 env 引用能否真解析,
109
+ * 见心智模型典型陷阱 #20),方向偏向少误报。
110
+ * @param {object} cfg
111
+ * @param {string} provider - 裸 provider 名
112
+ * @param {object} deps - { hasConfiguredSecretInput, resolveProviderIdForAuth }
113
+ * @returns {boolean}
114
+ */
115
+ function hasInlineKey(cfg, provider, deps) {
116
+ const providers = cfg?.models?.providers;
117
+ if (!providers || typeof providers !== 'object') return false;
118
+ const targetId = deps.resolveProviderIdForAuth(provider);
119
+ for (const [nodeId, entry] of Object.entries(providers)) {
120
+ if (!entry || !deps.hasConfiguredSecretInput(entry.apiKey)) continue;
121
+ if (deps.resolveProviderIdForAuth(nodeId) === targetId) return true;
122
+ }
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * 某 provider(裸名,无斜杠)有没有可用凭据 —— 统一别名感知原语。
128
+ * 判定 = isProviderApiKeyConfigured(覆盖 env + 自管账本,别名归一其内部完成)
129
+ * ∪ hasInlineKey(内联 key,别名归一)。
130
+ * 覆盖 env + 内联 + 账本 + 别名套餐;统一漏 IAM/本地(hasAuthForModelProvider 未导出 plugin-sdk,接受)。
131
+ * 选模型器枚举 / model.set 门 / providerUsable / noKey 四个消费点同吃这一个原语,杜绝跨界面口径分叉。
132
+ * @param {string|null} provider - 裸 provider 名(如 'openai' / 'volcengine-plan')
133
+ * @param {object} cfg
134
+ * @param {object} deps - { agentDir, isProviderApiKeyConfigured, hasConfiguredSecretInput, resolveProviderIdForAuth }
135
+ * @returns {boolean}
136
+ */
137
+ export function computeProviderUsableByName(provider, cfg, deps) {
138
+ if (typeof provider !== 'string' || provider.length === 0) return false;
139
+ if (deps.isProviderApiKeyConfigured({ provider, agentDir: deps.agentDir })) return true;
140
+ return hasInlineKey(cfg, provider, deps);
141
+ }
142
+
143
+ /**
144
+ * 该 primary 那家 provider 有没有可用凭据:取 provider 段后委托 computeProviderUsableByName。
145
+ * primary 解析不出 provider 段(含 null)时恒 false(UI 此时走 noPrimary,不看它)。
146
+ * @param {string|null} primary
147
+ * @param {object} cfg
148
+ * @param {object} deps - 同 computeProviderUsableByName
149
+ * @returns {boolean}
150
+ */
151
+ export function computeProviderUsable(primary, cfg, deps) {
152
+ return computeProviderUsableByName(providerSegmentOf(primary), cfg, deps);
153
+ }
154
+
155
+ /**
156
+ * 收集"候选 provider 名"(未归一、原始拼写)供 env 探测:
157
+ * default + 各 agent primary 的 provider 段 ∪ 内联节点 id ∪ 账本各 profile 的 provider。
158
+ * env-only 凭据无法穷举所有环境变量名,只在这些候选上用 isProviderApiKeyConfigured 探测,
159
+ * 与 providerAuth.list 的 env 口径一致(纯 env、又非主模型/账本/内联的 provider 不计,接受残留)。
160
+ * @param {object} cfg
161
+ * @param {object|null} store - ensureAuthProfileStore 返回值(可能为 null)
162
+ * @returns {Set<string>}
163
+ */
164
+ function collectCandidateProviders(cfg, store) {
165
+ const out = new Set();
166
+ const base = listAllPrimaries(cfg);
167
+ const seg = providerSegmentOf(base.default.primary);
168
+ if (seg) out.add(seg);
169
+ for (const v of Object.values(base.agents)) {
170
+ const s = providerSegmentOf(v.primary);
171
+ if (s) out.add(s);
172
+ }
173
+ const providers = cfg?.models?.providers;
174
+ if (providers && typeof providers === 'object') {
175
+ for (const id of Object.keys(providers)) out.add(id);
176
+ }
177
+ if (store && store.profiles && typeof store.profiles === 'object') {
178
+ for (const cred of Object.values(store.profiles)) {
179
+ if (cred && typeof cred.provider === 'string' && cred.provider.length > 0) {
180
+ out.add(cred.provider);
181
+ }
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /**
188
+ * 这台 claw 有没有任何可用凭据:自管账本非空 OR 任一内联 key OR 任一候选 provider 有 env key。
189
+ * 驱动 UI 的 noKey 引导。必补 C:补 env(候选集口径见 collectCandidateProviders),
190
+ * 与 per-provider 的 providerUsable 口径对齐,根治"纯 env-only 用户被误弹『还没加 key』"。
191
+ * 补后仅漏纯 IAM-only/本地(pro,接受 spurious noKey)。
192
+ * @param {object} cfg
193
+ * @param {object} deps - { agentDir, ensureAuthProfileStore, hasConfiguredSecretInput, isProviderApiKeyConfigured }
194
+ * @returns {boolean}
195
+ */
196
+ export function computeHasAnyUsableCredential(cfg, deps) {
197
+ const store = deps.ensureAuthProfileStore(deps.agentDir, { allowKeychainPrompt: false });
198
+ if (store && store.profiles && Object.keys(store.profiles).length > 0) return true;
199
+ const providers = cfg?.models?.providers;
200
+ if (providers && typeof providers === 'object') {
201
+ for (const entry of Object.values(providers)) {
202
+ if (entry && deps.hasConfiguredSecretInput(entry.apiKey)) return true;
203
+ }
204
+ }
205
+ for (const provider of collectCandidateProviders(cfg, store)) {
206
+ if (deps.isProviderApiKeyConfigured({ provider, agentDir: deps.agentDir })) return true;
207
+ }
208
+ return false;
209
+ }
210
+
211
+ /**
212
+ * 别名归一的"已配 provider"集 = 用户已持任一来源凭据的基座 provider id 集,
213
+ * 供 UI 加 provider 时排除(套餐用户持 volcengine key 后不再被叫去加 volcengine/volcengine-plan)。
214
+ * 三源(每个都过 resolveProviderIdForAuth 归一后去重):
215
+ * ① 账本各 profile 的 cred.provider
216
+ * ② 内联各带 apiKey 的节点 id
217
+ * ③ env 候选(collectCandidateProviders 口径)中 isProviderApiKeyConfigured 命中的
218
+ * @param {object} cfg
219
+ * @param {object} deps - { agentDir, ensureAuthProfileStore, hasConfiguredSecretInput, isProviderApiKeyConfigured, resolveProviderIdForAuth }
220
+ * @returns {string[]} 升序去重
221
+ */
222
+ export function computeConfiguredProviders(cfg, deps) {
223
+ const store = deps.ensureAuthProfileStore(deps.agentDir, { allowKeychainPrompt: false });
224
+ const out = new Set();
225
+ const add = (raw) => {
226
+ const id = deps.resolveProviderIdForAuth(raw);
227
+ if (id) out.add(id);
228
+ };
229
+ if (store && store.profiles && typeof store.profiles === 'object') {
230
+ for (const cred of Object.values(store.profiles)) {
231
+ if (cred && typeof cred.provider === 'string' && cred.provider.length > 0) add(cred.provider);
232
+ }
233
+ }
234
+ const providers = cfg?.models?.providers;
235
+ if (providers && typeof providers === 'object') {
236
+ for (const [id, entry] of Object.entries(providers)) {
237
+ if (entry && deps.hasConfiguredSecretInput(entry.apiKey)) add(id);
238
+ }
239
+ }
240
+ for (const provider of collectCandidateProviders(cfg, store)) {
241
+ if (deps.isProviderApiKeyConfigured({ provider, agentDir: deps.agentDir })) add(provider);
242
+ }
243
+ return [...out].sort();
244
+ }
245
+
246
+ /**
247
+ * 选模型器枚举(纯同步):把干净目录按 entry.provider 分组,留 computeProviderUsableByName 为真的 provider。
248
+ * catalogEntries 由调用方传入(子任务 2 的 handler 调 loadModelCatalog({readOnly:true}) 后传进来),
249
+ * 本函数不自己 await loadModelCatalog;空 / 非数组 entries → 空 byProvider。
250
+ * 变体 provider(如 volcengine-plan)经 manifest 目录行进入 entries、再经基座 key 别名感知保留;
251
+ * 无凭据 provider 被丢(含幽灵——幽灵根本不在 loadModelCatalog 这个源里)。
252
+ *
253
+ * 文本模态过滤:核实结论 = 不过滤。loadModelCatalog 输出的 ModelCatalogEntry 无 output kind 字段
254
+ * (image_generation 等是网关响应的另一类型;imageModel 注入只在 buildModelsProviderData 尾部、不在此源),
255
+ * 故无"纯图像/视频生成"条目混入;entry.input 是"输入"模态而非输出 kind,按它滤会误删多模态文本模型。
256
+ *
257
+ * @param {object[]} catalogEntries - loadModelCatalog({readOnly:true}) 的结果(ModelCatalogEntry[])
258
+ * @param {object} cfg
259
+ * @param {object} deps - { agentDir, isProviderApiKeyConfigured, hasConfiguredSecretInput, resolveProviderIdForAuth, ensureAuthProfileStore }
260
+ * @returns {{ byProvider: Record<string, string[]>, configuredProviders: string[] }}
261
+ */
262
+ export function enumerateUsableModels(catalogEntries, cfg, deps) {
263
+ const grouped = new Map(); // provider -> Set<modelId>
264
+ if (Array.isArray(catalogEntries)) {
265
+ for (const entry of catalogEntries) {
266
+ if (!entry || typeof entry.provider !== 'string' || entry.provider.length === 0) continue;
267
+ if (typeof entry.id !== 'string' || entry.id.length === 0) continue;
268
+ let set = grouped.get(entry.provider);
269
+ if (!set) {
270
+ set = new Set();
271
+ grouped.set(entry.provider, set);
272
+ }
273
+ set.add(entry.id);
274
+ }
275
+ }
276
+ const byProvider = {};
277
+ for (const [provider, ids] of grouped) {
278
+ if (computeProviderUsableByName(provider, cfg, deps)) {
279
+ byProvider[provider] = [...ids].sort();
280
+ }
281
+ }
282
+ return { byProvider, configuredProviders: computeConfiguredProviders(cfg, deps) };
283
+ }
284
+
285
+ /**
286
+ * 装配带凭据信号的 list 出参(docs/model-config-api.md § 3.4「凭据信号」)。
287
+ * 在 listAllPrimaries 基础上给每个 scope 加 providerUsable,并加顶层 hasAnyUsableCredential。
288
+ * @param {object} cfg
289
+ * @param {object} deps - { agentDir, isProviderApiKeyConfigured, hasConfiguredSecretInput, ensureAuthProfileStore, resolveProviderIdForAuth }
290
+ * @returns {{
291
+ * default: { primary: string|null, providerUsable: boolean },
292
+ * agents: Record<string, { primary: string|null, providerUsable: boolean }>,
293
+ * hasAnyUsableCredential: boolean,
294
+ * }}
295
+ */
296
+ export function listAllPrimariesWithCredentials(cfg, deps) {
297
+ const base = listAllPrimaries(cfg);
298
+ const out = {
299
+ default: {
300
+ primary: base.default.primary,
301
+ providerUsable: computeProviderUsable(base.default.primary, cfg, deps),
302
+ },
303
+ agents: {},
304
+ hasAnyUsableCredential: computeHasAnyUsableCredential(cfg, deps),
305
+ };
306
+ for (const [id, v] of Object.entries(base.agents)) {
307
+ out.agents[id] = {
308
+ primary: v.primary,
309
+ providerUsable: computeProviderUsable(v.primary, cfg, deps),
310
+ };
311
+ }
312
+ return out;
313
+ }
@@ -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,15 @@ 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 { listAllPrimaries, providerSegmentOf } from '../model-default/resolve.js';
42
+ import { deepMergeInto } from '../utils/deep-merge.js';
43
+ import {
44
+ isVerificationNote,
45
+ extractVerification,
46
+ findDeviceCodeMethod,
47
+ makeDeviceCodeCtx,
48
+ } from './device-code-login.js';
40
49
 
41
50
  const VALID_CRED_TYPES = new Set(['api_key', 'oauth', 'token']);
42
51
  const PORTAL_PROFILE_ID = `${PORTAL_PROVIDER_ID}:default`;
@@ -68,11 +77,13 @@ function isNonEmptyString(v) {
68
77
  * @param {Function} opts.sdk.formatApiKeyPreview - 遮蔽显示 helper
69
78
  * @param {Function} [opts.sdk.mutateConfigFile] - async;OAuth 写 cfg(openclaw/plugin-sdk/config-mutation)
70
79
  * @param {Function} opts.resolveAgentDir - 返回 main agent 完整路径(含 /agent 子目录)
71
- * @param {object} [opts.oauth] - createMiniMaxOAuth 实例(requestDeviceCode / pollUntilSettled);OAuth handler 才用
72
- * @param {object} [opts.registry] - oauth-registry(registerLogin / getLogin / removeLogin
80
+ * @param {object} [opts.oauth] - createMiniMaxOAuth 实例(requestDeviceCode / pollUntilSettled);MiniMax B2 才用
81
+ * @param {object} [opts.registry] - oauth-registry(registerLogin / getLogin / removeLogin);B2/B1 共用
73
82
  * @param {Function} [opts.genLoginId] - () → loginId,默认 randomUUID
74
- * @param {Function} [opts.scheduleBackground] - (promise) → void,挂后台轮询;默认 fire-and-forget + .catch
83
+ * @param {Function} [opts.scheduleBackground] - (promise) → void,挂后台任务;默认 fire-and-forget + .catch
75
84
  * @param {Function} [opts.logRemote] - (text) → void,OAuth 终态诊断推送;默认模块级 remoteLog(测试注入 spy)
85
+ * @param {Function} [opts.resolveConfig] - () → OpenClaw runtime config 快照;通用 device-code 登录(B1)拿 config 用,默认 getClawConfig
86
+ * @param {Function} [opts.resolveProviders] - ({ config, providerRefs }) → ProviderPlugin[];B1 经它拿 provider 的 auth 方法(生产由入口注入,内部 activate:false),默认抛错
76
87
  * @returns {{ setApiKey, list, remove, loginOauth, cancelOauth }}
77
88
  */
78
89
  export function buildProviderAuthHandlers({
@@ -83,6 +94,8 @@ export function buildProviderAuthHandlers({
83
94
  genLoginId = randomUUID,
84
95
  scheduleBackground = (p) => { p.catch(() => {}); },
85
96
  logRemote = remoteLog,
97
+ resolveConfig = getClawConfig,
98
+ resolveProviders = () => { throw new Error('provider catalog runtime not injected'); },
86
99
  }) {
87
100
  // TODO: 将来若要支持"设默认模型 / 多账号顺序"等需要写 cfg 的操作,会撞上
88
101
  // gateway 重启窗口的 UX 问题——参 docs/model-config-api.md § 3 / § 5(占位章节)。
@@ -137,14 +150,29 @@ export function buildProviderAuthHandlers({
137
150
  respondInvalid(respond, 'provider must be a non-empty string when provided');
138
151
  return;
139
152
  }
153
+ // 账本来源(source='profile')
140
154
  const store = sdk.ensureAuthProfileStore(resolveAgentDir());
141
- const profiles = [];
155
+ const ledger = [];
142
156
  const raw = store?.profiles ?? {};
143
157
  for (const [profileId, cred] of Object.entries(raw)) {
144
158
  if (!isWellFormedCredential(cred)) continue;
145
- if (filterProvider && cred.provider !== filterProvider) continue;
146
- profiles.push(toListEntry(profileId, cred, sdk.formatApiKeyPreview));
159
+ ledger.push(toListEntry(profileId, cred, sdk.formatApiKeyPreview));
147
160
  }
161
+ // 内联来源(source='inline'):读 cfg;cfg 读不到时退化为仅账本,不连累 ledger 路径
162
+ let cfg = null;
163
+ try { cfg = resolveConfig(); }
164
+ catch { cfg = null; }
165
+ const inline = listInlineEntries(cfg, {
166
+ hasConfiguredSecretInput: sdk.hasConfiguredSecretInput,
167
+ formatApiKeyPreview: sdk.formatApiKeyPreview,
168
+ });
169
+ // env 来源(source='env'):候选=账本∪内联∪主模型段;仅未被账本/内联覆盖的 sole-source 才列
170
+ const covered = new Set([...ledger, ...inline].map((e) => e.provider));
171
+ const candidates = new Set([...covered, ...collectPrimaryProviderSegments(cfg)]);
172
+ const env = listEnvEntries(candidates, covered, { resolveEnvApiKey: sdk.resolveEnvApiKey });
173
+
174
+ let profiles = [...ledger, ...inline, ...env];
175
+ if (filterProvider) profiles = profiles.filter((e) => e.provider === filterProvider);
148
176
  respond(true, { profiles });
149
177
  }
150
178
  catch (err) {
@@ -159,16 +187,40 @@ export function buildProviderAuthHandlers({
159
187
  respondInvalid(respond, 'provider must be a non-empty string');
160
188
  return;
161
189
  }
162
- const result = await sdk.removeProviderAuthProfilesWithLock({
163
- provider,
164
- agentDir: resolveAgentDir(),
165
- });
166
- // 同 setApiKey:锁/磁盘失败时上游返回 null
167
- if (result === null) {
168
- respondIoFailed(respond, new Error('failed to update auth-profiles store'));
190
+ const source = params?.source ?? 'profile';
191
+ if (source === 'profile') {
192
+ const result = await sdk.removeProviderAuthProfilesWithLock({
193
+ provider,
194
+ agentDir: resolveAgentDir(),
195
+ });
196
+ // setApiKey:锁/磁盘失败时上游返回 null
197
+ if (result === null) {
198
+ respondIoFailed(respond, new Error('failed to update auth-profiles store'));
199
+ return;
200
+ }
201
+ respond(true, {});
169
202
  return;
170
203
  }
171
- respond(true, {});
204
+ if (source === 'inline') {
205
+ // 内联撤销:只删 cfg.models.providers[provider].apiKey 字段,保留节点其余内容
206
+ //(baseUrl/api/models 是用户的自定义 provider 定义,删整节点会把"没 key"恶化成"模型不存在")。
207
+ // 删 key 后节点变空 {} → 顺手清掉空节点。afterWrite:auto → hot 路径零打断(docs § 2.5 / § 6.14)。
208
+ await sdk.mutateConfigFile({
209
+ afterWrite: { mode: 'auto' },
210
+ mutate(draft) {
211
+ const providers = draft?.models?.providers;
212
+ if (!providers || typeof providers !== 'object' || Array.isArray(providers)) return;
213
+ const node = providers[provider];
214
+ if (!node || typeof node !== 'object') return; // 幂等:无该节点视为已撤销
215
+ delete node.apiKey;
216
+ if (Object.keys(node).length === 0) delete providers[provider];
217
+ },
218
+ });
219
+ respond(true, {});
220
+ return;
221
+ }
222
+ // env 及未知 source:插件无法撤销(env 在进程环境里)→ 后端兜底拒绝
223
+ respondInvalid(respond, `cannot remove credential with source "${source}"`);
172
224
  }
173
225
  catch (err) {
174
226
  respondIoFailed(respond, err);
@@ -293,7 +345,10 @@ export function buildProviderAuthHandlers({
293
345
  }
294
346
  }
295
347
 
296
- async function loginOauth({ params, respond }) {
348
+ // MiniMax 设备码登录(B2:自家复刻设备码流,码已嵌在 verification URL + 写静态模型清单)。
349
+ // 不并入通用 B1:MiniMax 不在 OpenClaw 内置 provider 字典,登录后还要补写 models.providers 清单
350
+ // (上游对 portal 不做 catalog discovery),与 codex/copilot「内置、模型自带」不同——见 docs § 6.16。
351
+ async function loginOauthMiniMax({ params, respond }) {
297
352
  try {
298
353
  const region = params?.region ?? 'cn';
299
354
  if (!VALID_REGIONS.has(region)) {
@@ -330,6 +385,157 @@ export function buildProviderAuthHandlers({
330
385
  }
331
386
  }
332
387
 
388
+ // --- 通用设备码登录(B1:驱动上游 provider 的 device_code run,跟随上游同步) ---
389
+
390
+ // 设备码失败的终态响应:phase-1 已发过 accepted → 发 phase-2 错误帧(带 status);
391
+ // phase-1 之前就失败 → 单帧错误(payload undefined,与 MiniMax phase-1 之前失败同形)
392
+ function respondDeviceFailure(respond, phase1Sent, code, message, status = 'error') {
393
+ if (phase1Sent) respond(false, { status }, { code, message });
394
+ else respond(false, undefined, { code, message });
395
+ }
396
+
397
+ // 设备码登录成功:逐个写凭据 + 有 configPatch 就深合并进 cfg(hot-reload,零打断),恰好 respond 一次
398
+ async function persistDeviceCodeSuccess({ provider, result, loginId, phase1Sent, respond }) {
399
+ try {
400
+ const profileIds = [];
401
+ for (const profile of result.profiles) {
402
+ // 同一 auth-profiles 文件,顺序写避免锁竞争(device-code 通常仅 1 个 profile)
403
+
404
+ const r = await sdk.upsertAuthProfileWithLock({
405
+ profileId: profile.profileId,
406
+ credential: profile.credential,
407
+ agentDir: resolveAgentDir(),
408
+ });
409
+ if (r === null) {
410
+ respondDeviceFailure(respond, phase1Sent, 'IO_FAILED', 'failed to write auth-profiles store');
411
+ logRemote(`providerAuth.deviceCode.io-failed provider=${provider} loginId=${loginId} stage=credential`);
412
+ return;
413
+ }
414
+ profileIds.push(profile.profileId);
415
+ }
416
+ const patch = result.configPatch;
417
+ if (patch && typeof patch === 'object' && !Array.isArray(patch)) {
418
+ // configPatch 是 provider 自带的 onboarding 默认(如 codex 写 agents.defaults.models 别名);
419
+ // 深合并保留其它 provider,afterWrite:auto 走 hot-reload 不重启(与 MiniMax 写 cfg 一致)
420
+ await sdk.mutateConfigFile({
421
+ afterWrite: { mode: 'auto' },
422
+ mutate(draft) { deepMergeInto(draft, patch); },
423
+ });
424
+ }
425
+ respond(true, { status: 'ok', provider, profileIds });
426
+ logRemote(`providerAuth.deviceCode.ok provider=${provider} loginId=${loginId} profiles=${profileIds.length}`);
427
+ }
428
+ catch (err) {
429
+ respondDeviceFailure(respond, phase1Sent, 'IO_FAILED', String(err?.message ?? err));
430
+ logRemote(`providerAuth.deviceCode.io-failed provider=${provider} loginId=${loginId} stage=config msg=${String(err?.message ?? err)}`);
431
+ }
432
+ }
433
+
434
+ async function loginOauthDeviceCode({ provider, respond }) {
435
+ try {
436
+ const config = resolveConfig() ?? {};
437
+ let providers;
438
+ try {
439
+ // resolveProviders 可能 async(生产侧惰性加载 catalog-runtime SDK 后再 resolve)
440
+ providers = await resolveProviders({ config, providerRefs: [provider] });
441
+ }
442
+ catch (err) {
443
+ // 加载器异常(SDK import 失败 / resolvePluginProviders 抛错):phase-1 之前,单帧错误
444
+ respondIoFailed(respond, err);
445
+ return;
446
+ }
447
+ const method = findDeviceCodeMethod(providers, provider);
448
+ if (!method) {
449
+ respond(false, undefined, {
450
+ code: 'NOT_FOUND',
451
+ message: `provider "${provider}" has no device-code login method`,
452
+ });
453
+ return;
454
+ }
455
+
456
+ const loginId = genLoginId();
457
+ const abortController = new AbortController();
458
+ let phase1Sent = false;
459
+
460
+ // run 内 prompter.note 吐出「含 URL 的验证 note」→ 触发 phase-1 accepted(仅一次)。
461
+ // 结构化字段抠不到给 null,rawText 永远带上全文交前端兜底。登记发生在 respond accepted 之前,
462
+ // 让紧随其后的 cancelOauth 一定能按 loginId 找到该登录。
463
+ const ctx = makeDeviceCodeCtx({
464
+ config,
465
+ agentDir: resolveAgentDir(),
466
+ onNote: (text) => {
467
+ if (phase1Sent || !isVerificationNote(text)) return;
468
+ phase1Sent = true;
469
+ registry.registerLogin(loginId, { abortController });
470
+ const { verificationUri, userCode } = extractVerification(text);
471
+ respond(true, {
472
+ status: 'accepted',
473
+ loginId,
474
+ provider,
475
+ verificationUri,
476
+ userCode,
477
+ rawText: text,
478
+ });
479
+ },
480
+ });
481
+
482
+ // 起 run(不 await 整体);run 仅跑一次,resolve/reject 都到这里恰好终态一次。
483
+ // run 无 abort 钩子:取消停不掉上游后台轮询,cancelOauth 只 abort 信号 → run 到期自己 settle
484
+ // 时这里识别 aborted、回 cancelled 终态、不写凭据(终态必达 + 清理,不做复查骚操作)。
485
+ async function runAndSettle() {
486
+ let result;
487
+ let runErr;
488
+ try {
489
+ result = await Promise.resolve().then(() => method.run(ctx));
490
+ }
491
+ catch (err) {
492
+ runErr = err;
493
+ }
494
+ if (phase1Sent) registry.removeLogin(loginId);
495
+
496
+ if (runErr) {
497
+ respondDeviceFailure(respond, phase1Sent, 'OAUTH_FAILED', String(runErr?.message ?? runErr));
498
+ logRemote(`providerAuth.deviceCode.error provider=${provider} loginId=${loginId} stage=run msg=${String(runErr?.message ?? runErr)}`);
499
+ return;
500
+ }
501
+ if (abortController.signal.aborted) {
502
+ respondDeviceFailure(respond, phase1Sent, 'OAUTH_CANCELLED', `device-code login for ${provider} was cancelled`, 'cancelled');
503
+ logRemote(`providerAuth.deviceCode.cancelled provider=${provider} loginId=${loginId}`);
504
+ return;
505
+ }
506
+ // 上游会把中途失败吞成空 profiles(如 copilot access_denied / expired)→ 空即失败
507
+ const profiles = Array.isArray(result?.profiles) ? result.profiles : [];
508
+ if (profiles.length === 0) {
509
+ respondDeviceFailure(respond, phase1Sent, 'OAUTH_FAILED', `device-code login for ${provider} returned no credentials`);
510
+ logRemote(`providerAuth.deviceCode.error provider=${provider} loginId=${loginId} stage=empty-profiles`);
511
+ return;
512
+ }
513
+ await persistDeviceCodeSuccess({ provider, result, loginId, phase1Sent, respond });
514
+ }
515
+
516
+ scheduleBackground(runAndSettle());
517
+ }
518
+ catch (err) {
519
+ respondIoFailed(respond, err);
520
+ }
521
+ }
522
+
523
+ // 登录入口路由:minimax-portal(或缺省,向后兼容)→ B2 自家流;其它任何带 device_code 方法的
524
+ // provider → 通用 B1 驱动。不针对 codex/copilot 硬编码,后续 OpenClaw 新增 device_code provider 自动适用。
525
+ async function loginOauth({ params, respond }) {
526
+ const provider = params?.provider;
527
+ // provider 给了就必须是非空串(缺省保留给 MiniMax B2 向后兼容):空串 / 非串在边界挡掉,
528
+ // 不让其漏进 B1 当 NOT_FOUND,也不把非串塞给上游 resolvePluginProviders(providerRefs 期望串)
529
+ if (provider !== undefined && !isNonEmptyString(provider)) {
530
+ respondInvalid(respond, 'provider must be a non-empty string when provided');
531
+ return;
532
+ }
533
+ if (provider === undefined || provider === PORTAL_PROVIDER_ID) {
534
+ return loginOauthMiniMax({ params, respond });
535
+ }
536
+ return loginOauthDeviceCode({ provider, respond });
537
+ }
538
+
333
539
  async function cancelOauth({ params, respond }) {
334
540
  try {
335
541
  const loginId = params?.loginId;
@@ -362,13 +568,15 @@ function isWellFormedCredential(cred) {
362
568
  }
363
569
 
364
570
  /**
365
- * 把单条 credential 转成 list RPC 出参元素。
571
+ * 把单条账本 credential 转成 list RPC 出参元素(source='profile',可撤销)。
366
572
  * 关键:原始 key / token / OAuth access/refresh 绝不出 handler。
367
573
  */
368
574
  function toListEntry(profileId, cred, formatApiKeyPreview) {
369
575
  const out = {
370
576
  profileId,
371
577
  provider: cred.provider,
578
+ source: 'profile',
579
+ removable: true,
372
580
  type: cred.type,
373
581
  };
374
582
  if (cred.type === 'api_key' && typeof cred.key === 'string' && cred.key.length > 0) {
@@ -381,3 +589,78 @@ function toListEntry(profileId, cred, formatApiKeyPreview) {
381
589
  }
382
590
  return out;
383
591
  }
592
+
593
+ /**
594
+ * 内联来源:cfg.models.providers 里 apiKey 配了的节点(用户手写 / OAuth 写的节点若带 key)。
595
+ * 只看 apiKey 字段——OAuth 登录写的节点无 apiKey,天然不会被误收(docs § 6.14)。
596
+ * @param {object} cfg
597
+ * @param {object} deps - { hasConfiguredSecretInput, formatApiKeyPreview }
598
+ * @returns {object[]} source='inline'、removable=true 的出参元素
599
+ */
600
+ function listInlineEntries(cfg, { hasConfiguredSecretInput, formatApiKeyPreview }) {
601
+ const out = [];
602
+ const providers = cfg?.models?.providers;
603
+ if (!providers || typeof providers !== 'object' || Array.isArray(providers)) return out;
604
+ for (const [provider, node] of Object.entries(providers)) {
605
+ if (!node || typeof node !== 'object') continue;
606
+ if (!hasConfiguredSecretInput(node.apiKey)) continue;
607
+ const entry = {
608
+ profileId: `${provider}#inline`,
609
+ provider,
610
+ source: 'inline',
611
+ removable: true,
612
+ type: 'api_key',
613
+ };
614
+ // 仅明文 string key 给预览;{env}/{file} 引用形态不预览(避免怪异展示)
615
+ if (typeof node.apiKey === 'string' && !node.apiKey.startsWith('{')) {
616
+ entry.keyPreview = formatApiKeyPreview(node.apiKey);
617
+ }
618
+ out.push(entry);
619
+ }
620
+ return out;
621
+ }
622
+
623
+ /**
624
+ * env 来源:对候选 provider 集合探测环境变量;仅当该 provider 未被账本/内联覆盖(sole source)才列,
625
+ * 避免与已可撤销的行重复添噪。env 来源不可撤销(removable=false),仅展示。
626
+ * @param {Iterable<string>} candidates - 候选 provider(账本∪内联∪主模型段)
627
+ * @param {Set<string>} covered - 已由账本/内联列出的 provider(原始拼写)
628
+ * @param {object} deps - { resolveEnvApiKey }
629
+ * @returns {object[]} source='env'、removable=false 的出参元素
630
+ */
631
+ function listEnvEntries(candidates, covered, { resolveEnvApiKey }) {
632
+ const out = [];
633
+ const seen = new Set();
634
+ for (const provider of candidates) {
635
+ if (!provider || covered.has(provider) || seen.has(provider)) continue;
636
+ const hit = resolveEnvApiKey(provider);
637
+ if (!hit?.apiKey) continue;
638
+ seen.add(provider);
639
+ out.push({
640
+ profileId: `${provider}#env`,
641
+ provider,
642
+ source: 'env',
643
+ removable: false,
644
+ type: 'api_key',
645
+ });
646
+ }
647
+ return out;
648
+ }
649
+
650
+ /**
651
+ * 收集 default + 各 agent 主模型的 provider 段(去重交给调用方的 Set)。
652
+ * 复用 model-default/resolve.js,不重复造主模型读取逻辑。
653
+ * @param {object} cfg
654
+ * @returns {string[]}
655
+ */
656
+ function collectPrimaryProviderSegments(cfg) {
657
+ const all = listAllPrimaries(cfg);
658
+ const segs = [];
659
+ const push = (primary) => {
660
+ const seg = providerSegmentOf(primary);
661
+ if (seg) segs.push(seg);
662
+ };
663
+ push(all.default.primary);
664
+ for (const v of Object.values(all.agents)) push(v.primary);
665
+ return segs;
666
+ }
@@ -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;
@@ -714,8 +714,9 @@ export class RealtimeBridge {
714
714
  try {
715
715
  const authToken = this.__resolveGatewayAuthToken();
716
716
  const params = {
717
+ // 声明支持 v3–v4:OpenClaw 网关自 v4 起要求协议范围含 4,旧网关仍可协商回 v3
717
718
  minProtocol: 3,
718
- maxProtocol: 3,
719
+ maxProtocol: 4,
719
720
  client: {
720
721
  id: 'gateway-client',
721
722
  version: this.__pluginVersion ?? 'unknown',
@@ -300,8 +300,18 @@ export function createSessionManager(options = {}) {
300
300
  * 按 sessionId 获取消息,返回完整 JSONL 行级结构。
301
301
  * 只返回 type==="message" 且有合法 message.role 的行。
302
302
  * limit 语义:不传/null/非 number/NaN/Infinity/<1 → 返回全部;>=1 的有限 number → 取最后 Math.trunc(limit) 条。无默认/最大值。
303
+ *
304
+ * 取不到正文时用错误码区分成因,调用方据此精确处置(不再统一塌缩成空数组):
305
+ * - transcript 文件不存在(裸名 / .reset. / .deleted. 变体全无)→ 抛 code='NOT_FOUND'
306
+ * - 文件在但一行都解析不出(非空却零行成功 JSON.parse)→ 抛 code='PARSE_FAILED'
307
+ * - 真·读盘 IO 错误:readTranscriptText 原样上抛(由 RPC 层映射)
308
+ * 空文件 / 全空白行(含仅含空格、制表符的行)/ 文件在但无 message 行 → 正常返回 { messages: [] }(良性空,非失败)。
309
+ *
310
+ * 部分坏行(有成功解析的行 + 个别 JSON.parse 失败)走容错:返回解析出的消息,
311
+ * 并在 payload 平行附 badLines(仅 >0 时)记录坏行原文供排障,不丢整段。
312
+ * badLines[].index 是坏行在「非空白内容行」序列中的 0-based 位置(空白行已被跳过,故非原始文件行号)。
303
313
  * @param {{ sessionId: string, agentId?: string, limit?: number }} params
304
- * @returns {Promise<{ messages: object[] }>}
314
+ * @returns {Promise<{ messages: object[], badLines?: { index: number, raw: string, error: string }[] }>}
305
315
  */
306
316
  async function getById(params = {}) {
307
317
  const agentId = typeof params.agentId === 'string' && params.agentId.trim() ? params.agentId.trim() : 'main';
@@ -313,25 +323,42 @@ export function createSessionManager(options = {}) {
313
323
  const limitNum = useLimit ? Math.trunc(params.limit) : 0;
314
324
  const file = await resolveTranscriptFile(agentId, sessionId);
315
325
  if (!file) {
316
- return { messages: [] };
326
+ throw Object.assign(new Error(`session transcript not found: ${sessionId}`), { code: 'NOT_FOUND' });
317
327
  }
318
328
 
319
329
  const text = await readTranscriptText(file);
320
330
  const messages = [];
331
+ const badLines = [];
332
+ // parseOk 在 JSON.parse 成功后立即 +1,必须在 type 过滤之前——
333
+ // 否则"全合法 JSON 但无 message 行"会被错判 PARSE_FAILED
334
+ let parseOk = 0;
335
+ let index = -1;
321
336
  for await (const line of iterTextLines(text)) {
337
+ // 纯空白行(仅空格/制表符等,iterTextLines 只跳零长度段)视同空行:
338
+ // 既不计入 parseOk 也不进 badLines,保证"全空白文件 → 良性空"不变量、不误判 PARSE_FAILED
339
+ if (line.trim() === '') continue;
340
+ index++;
341
+ let row;
322
342
  try {
323
- const row = JSON.parse(line);
324
- if (row?.type !== 'message') continue;
325
- const msg = row?.message;
326
- if (!msg || typeof msg !== 'object' || !msg.role) continue;
327
- messages.push(row);
343
+ row = JSON.parse(line);
328
344
  }
329
345
  catch (err) {
346
+ badLines.push({ index, raw: line, error: String(err?.message ?? err) });
330
347
  logger.warn?.(`[session-manager] bad json line skipped: ${String(err?.message ?? err)}`);
348
+ continue;
331
349
  }
350
+ parseOk++;
351
+ if (row?.type !== 'message') continue;
352
+ const msg = row?.message;
353
+ if (!msg || typeof msg !== 'object' || !msg.role) continue;
354
+ messages.push(row);
355
+ }
356
+ // 非空文件却一行都没解析出 = 整文损坏;空 / 全空白文件(含纯空格行)跳过后零内容行 → parseOk=0 且 badLines=[],不算损坏
357
+ if (parseOk === 0 && badLines.length > 0) {
358
+ throw Object.assign(new Error(`session transcript unparseable: ${sessionId}`), { code: 'PARSE_FAILED' });
332
359
  }
333
360
  const sliced = (useLimit && messages.length > limitNum) ? messages.slice(-limitNum) : messages;
334
- return { messages: sliced };
361
+ return badLines.length > 0 ? { messages: sliced, badLines } : { messages: sliced };
335
362
  }
336
363
 
337
364
  return { listAll, listAllEntries, get, getById };
@@ -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
+ }