mslxdff 0.1.135 → 0.1.138

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.
Files changed (45) hide show
  1. package/package.json +2 -2
  2. package/src/chat/gateway.js +1 -1
  3. package/src/chat/prompt.js +1 -1
  4. package/src/chat/terminal.js +1 -1
  5. package/src/chat-pipeline/serial-trial.js +11 -3
  6. package/src/cli/commands/model/list-providers.js +1 -1
  7. package/src/cli/commands/model/list-render.js +2 -2
  8. package/src/cli/commands/provider/add.js +2 -1
  9. package/src/cli/commands/provider/bench-via.js +1 -1
  10. package/src/cli/commands/provider/cline-free.js +94 -0
  11. package/src/cli/commands/provider/cline-login.js +20 -15
  12. package/src/cli/commands/provider/index.js +22 -0
  13. package/src/cli/commands/provider/models.js +4 -1
  14. package/src/cli/commands/provider/workbuddy-login.js +1 -1
  15. package/src/cli/commands/use-group.js +8 -4
  16. package/src/cli/commands/workbuddy.js +14 -21
  17. package/src/cli/index.js +3 -0
  18. package/src/compat.js +23 -5
  19. package/src/model-capabilities/index.js +16 -3
  20. package/src/model-capabilities/merge.js +127 -0
  21. package/src/providers/classify.js +2 -2
  22. package/src/providers/cline/auth.js +1 -1
  23. package/src/providers/cline/chat.js +14 -11
  24. package/src/providers/cline/free-catalog.js +71 -0
  25. package/src/providers/cline/index.js +2 -16
  26. package/src/providers/cline/models.js +28 -51
  27. package/src/providers/dispatcher.js +1 -1
  28. package/src/providers/generic.js +47 -9
  29. package/src/providers/model-id.js +4 -4
  30. package/src/providers/registry.js +1 -1
  31. package/src/providers/share-keys.js +7 -2
  32. package/src/providers/workbuddy/account-store.js +73 -7
  33. package/src/providers/workbuddy/chat.js +1 -1
  34. package/src/providers/workbuddy/index.js +10 -20
  35. package/src/readline-compat.js +9 -10
  36. package/src/routes/models-route.js +11 -1
  37. package/src/runtime/providers-setup.js +9 -0
  38. package/src/runtime/server-lifecycle.js +2 -2
  39. package/src/state/migrations/cline-unify.js +94 -0
  40. package/src/state/migrations.js +33 -0
  41. package/src/state/schemas/token.js +6 -2
  42. package/src/state/schemas/use-group.js +47 -6
  43. package/src/sync-workbuddy.js +1 -1
  44. package/src/upstream-engine/index.js +1 -1
  45. package/src/upstream-probe/rotate.js +1 -1
@@ -1,17 +1,83 @@
1
1
  // WorkBuddy 账号落盘单一源:auths/workbuddy-<uid>.json(0600 tmp+rename)+ state.json keys/auths 按 uid 去重。
2
2
  // token-auto / device-login / 刷新(服务端 saveFn 与 CLI 定时任务)共用同一实现,安全细节只维护一份。
3
3
  // Note: 刷新落盘只走 applyTokenRefresh,别再各自内联一套 — 见 .agents/notes/implemented/architecture/2026-09-17-workbuddy-persist-single-seam.md
4
- import { writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from "node:fs";
4
+ // Note: 凭据目录别再用 cwd 兜底 — 见 .agents/notes/implemented/architecture/2026-09-20-workbuddy-authdir-follows-state.md
5
+ import { writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync, readdirSync, readFileSync } from "node:fs";
5
6
  import { join, dirname } from "node:path";
6
7
  import { tmpdir } from "node:os";
7
- import { isTestEnv } from "../../state/store.js";
8
+ import { isTestEnv, defaultStateFile } from "../../state/store.js";
9
+
10
+ // 凭据目录单一真相:跟着「账本」(state 文件)走 —— 默认 ~/.config/mslxdff/auths。
11
+ // 旧实现末尾兜底 `join(process.cwd(), "auths")`:在任意目录里起服务就把企业长效
12
+ // refreshToken 写进那个目录,而 .gitignore 只管自己所在的仓库 → 凭据落到不受保护的目录
13
+ // (本机实测:两个真实账号因此分居 `~/.config/mslxdff/auths` 与 `项目根/auths` 两处)。已修。
14
+ // 纯策略:写入目录的唯一目标(explicit > 测试隔离 > 「跟账本走」= state 文件同目录/auths)。
15
+ // 抽成纯函数是为了可测 —— `node --test` 下 isTestEnv() 恒真,环境态没法在测试里翻面。
16
+ export function authDirFor({ explicit = "", testEnv = false, stateFile } = {}) {
17
+ if (explicit) return explicit;
18
+ if (testEnv) return join(tmpdir(), "mslxdff-test-auths");
19
+ return join(dirname(stateFile), "auths");
20
+ }
8
21
 
9
22
  export function resolveAuthDir() {
10
- if (process.env.WORKBUDDY_AUTH_DIR) return process.env.WORKBUDDY_AUTH_DIR;
11
- const sf = process.env.MSLXDFF_STATE_FILE || "";
12
- if (sf.includes("mslxdff-test") || isTestEnv()) return join(tmpdir(), "mslxdff-test-auths");
13
- if (sf && sf.includes("mslxdff-")) return join(dirname(sf), "auths");
14
- return join(process.cwd(), "auths");
23
+ return authDirFor({
24
+ explicit: process.env.WORKBUDDY_AUTH_DIR || "",
25
+ testEnv: isTestEnv(),
26
+ stateFile: defaultStateFile(),
27
+ });
28
+ }
29
+
30
+ // 纯策略:算出读取候选目录(主位置优先,历史 cwd/auths 只读兜底,相同则去重)。
31
+ // 抽成纯函数是为了可测 —— `node --test` 下 isTestEnv() 恒真,环境态没法在测试里翻面。
32
+ export function authDirCandidates({ primary, explicit = "", testEnv = false, cwdDir = "" } = {}) {
33
+ const dirs = [primary];
34
+ if (!explicit && !testEnv && cwdDir && cwdDir !== primary) dirs.push(cwdDir);
35
+ return dirs;
36
+ }
37
+
38
+ // 读取候选(迁移期兼容):主位置优先,历史 `cwd()/auths` 只读兜底。
39
+ // 显式 WORKBUDDY_AUTH_DIR 或测试环境不兜底:既避免测试扫到真实目录,也避免重新引入 cwd 依赖。
40
+ export function resolveAuthDirs() {
41
+ return authDirCandidates({
42
+ primary: resolveAuthDir(),
43
+ explicit: process.env.WORKBUDDY_AUTH_DIR || "",
44
+ testEnv: isTestEnv(),
45
+ cwdDir: join(process.cwd(), "auths"),
46
+ });
47
+ }
48
+
49
+ // 按 uid 定位账号文件(主位置优先,旧位置兜底);找不到返回 null。
50
+ export function findAccountFile(uid) {
51
+ for (const dir of resolveAuthDirs()) {
52
+ const fp = join(dir, `workbuddy-${uid}.json`);
53
+ if (existsSync(fp)) return fp;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ // 扫描账号文件(主位置先扫,同 uid 以主位置为准),返回已校验的凭证行。
59
+ // 读取侧单一真相:provider 构造、CLI 账号加载、token-auto 都走这里,别再各自 readdir 一套。
60
+ export function listAccountDocs({ dirs } = {}) {
61
+ const out = [];
62
+ const seen = new Set();
63
+ for (const dir of dirs || resolveAuthDirs()) {
64
+ let files = [];
65
+ try {
66
+ if (!existsSync(dir)) continue;
67
+ files = readdirSync(dir).filter((f) => f.startsWith("workbuddy-") && f.endsWith(".json"));
68
+ } catch { continue; }
69
+ for (const f of files) {
70
+ try {
71
+ const doc = JSON.parse(readFileSync(join(dir, f), "utf8"));
72
+ const uid = doc?.account?.uid;
73
+ if (!uid || !doc?.auth?.accessToken) continue;
74
+ if (seen.has(String(uid))) continue;
75
+ seen.add(String(uid));
76
+ out.push({ uid: String(uid), file: join(dir, f), dir, doc });
77
+ } catch {}
78
+ }
79
+ }
80
+ return out;
15
81
  }
16
82
 
17
83
  function jwtExp(token) {
@@ -102,7 +102,7 @@ export function createChatService({
102
102
 
103
103
  // SDK 通道(缺省启用):缺省底层走 @ai-sdk/openai-compatible,`legacy`/关闭词
104
104
  // (局部 MSLXDFF_WORKBUDDY_SDK,未设则继承全局 MSLXDFF_UPSTREAM_ENGINE)回退原生 transport;
105
- // 不可用(Node16/未安装)自动回退并告警一次。上层轮换/刷新/reshape 全链复用。
105
+ // 不可用(未安装)自动回退并告警一次。上层轮换/刷新/reshape 全链复用。
106
106
  // Note: 为什么默认 SDK、翻译层代价与回退语义 — 见 .agents/notes/implemented/feature/2026-09-12-workbuddy-sdk-channel.md
107
107
  // 上游非标字段(thinking / reasoning_effort / reasoning_content / tool_choice 字符串)不依赖 SDK 白名单:
108
108
  // payload.js 前置语义改写 + AI SDK providerOptions 透传(非白名单 key 原样 spread 进 body,2.0.41 实测)。
@@ -1,11 +1,9 @@
1
1
  import { createKeyRing } from "../keyring.js";
2
2
  import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, WORKBUDDY_DEFAULT_BASE_URL, loadProviderModelsPath, loadProviderChatPath } from "../../state.js";
3
- import { existsSync, readdirSync, readFileSync } from "node:fs";
4
3
  import { compatFetch } from "../../compat.js";
5
- import { join } from "node:path";
6
4
  import { envInt, joinUrl, getUndici, createAgent } from "../base.js";
7
5
  import { createAuthService, isAuthError, isInsufficientStatus, decodeJwtExp } from "./auth.js";
8
- import { applyTokenRefresh, resolveAuthDir } from "./account-store.js";
6
+ import { applyTokenRefresh, listAccountDocs } from "./account-store.js";
9
7
  import { createChatService } from "./chat.js";
10
8
  import { createModelsService } from "./models.js";
11
9
  import { createBalanceCache, getCachedBalance as defaultGetCached, setCachedBalance as defaultSetCached } from "./balance.js";
@@ -61,24 +59,16 @@ export function createWorkbuddyProvider({
61
59
  let authList = Array.isArray(auths) && auths.length ? auths : authsFromState;
62
60
 
63
61
  if (!authList.length && !keys.length) {
62
+ // 账号目录读取统一走 listAccountDocs:主位置(跟 state 走)优先,旧 cwd/auths 只读兜底
64
63
  try {
65
- const authDir = resolveAuthDir();
66
- if (existsSync(authDir)) {
67
- const files = readdirSync(authDir).filter((f) => f.startsWith("workbuddy-") && f.endsWith(".json"));
68
- for (const f of files) {
69
- try {
70
- const j = JSON.parse(readFileSync(join(authDir, f), "utf8"));
71
- if (j?.auth?.accessToken && j?.account?.uid) {
72
- if (!keys.includes(j.auth.accessToken)) keys.push(j.auth.accessToken);
73
- authList.push({
74
- uid: j.account.uid,
75
- domain: j.auth.domain || "www.codebuddy.cn",
76
- enterpriseId: j.account.enterpriseId || "",
77
- refreshToken: j.auth.refreshToken || "",
78
- });
79
- }
80
- } catch {}
81
- }
64
+ for (const { uid, doc } of listAccountDocs()) {
65
+ if (!keys.includes(doc.auth.accessToken)) keys.push(doc.auth.accessToken);
66
+ authList.push({
67
+ uid,
68
+ domain: doc.auth.domain || "www.codebuddy.cn",
69
+ enterpriseId: doc.account.enterpriseId || "",
70
+ refreshToken: doc.auth.refreshToken || "",
71
+ });
82
72
  }
83
73
  } catch {}
84
74
  }
@@ -1,8 +1,9 @@
1
- // readline 兼容层:`node:readline/promises` 需要 Node 17+,老 Node(如 VPS 的 16)
2
- // 会 `ERR_UNKNOWN_BUILTIN_MODULE` 直接崩在 import 期,连人话报错都来不及打。
3
- // 这里只用回调版 `node:readline`(Node 12+ 全有),把 `question` 包成 Promise;
4
- // `prompt()/close()/for await` 回调版原生即有,对我们用到的部分行为一致。
1
+ // readline 统一走回调版 `node:readline`(question 包成 Promise),
2
+ // 顺带给 -chat 一个人话版本门(项目要求 Node >=18,ADR-0024)。
3
+ // 保留理由:回调版在 18+ 行为一致且无 import 期风险(`node:readline/promises`
4
+ // 若被误用于旧运行时会直接 ERR_UNKNOWN_BUILTIN_MODULE,连报错都来不及打)。
5
5
  import readline from "node:readline";
6
+ import { MIN_NODE_MAJOR, nodeMajor } from "./compat.js";
6
7
 
7
8
  export function createInterface(opts) {
8
9
  const rl = readline.createInterface(opts);
@@ -11,13 +12,11 @@ export function createInterface(opts) {
11
12
  return rl;
12
13
  }
13
14
 
14
- export function nodeMajor() {
15
- return Number(String(process.versions?.node || "0").split(".")[0]) || 0;
16
- }
15
+ export { nodeMajor };
17
16
 
18
- // -chat 依赖 fetch(<18 由 src/compat.js 用 undici polyfill),低于 engines 下限
19
- // 直接给一句人话。返回 true=通过,false=已打印升级指引。
20
- export function assertChatNode({ min = 16 } = {}) {
17
+ // -chat 版本门:项目要求 Node >=18(ADR-0024),不满足直接给人话 + 升级指引。
18
+ // 返回 true=通过,false=已打印升级指引。
19
+ export function assertChatNode({ min = MIN_NODE_MAJOR } = {}) {
21
20
  const major = nodeMajor();
22
21
  if (major >= min) return true;
23
22
  console.error(`Node 版本过旧(当前 v${process.versions.node}),-chat 需要 Node ${min}+(推荐 20+)。`);
@@ -1,6 +1,7 @@
1
1
  import { json, errMsg } from "./helpers.js";
2
2
  import { runHook } from "../plugins.js";
3
3
  import { isModelAllowed } from "../state.js";
4
+ import { mergeModelsList } from "../model-capabilities/merge.js";
4
5
  import { globalCapabilities } from "../model-capabilities/index.js";
5
6
 
6
7
  // Codex 自定义 provider 拉目录要顶层 `models` 数组(codex-rs endpoint/models.rs 解 ModelsResponse{models}),
@@ -14,12 +15,21 @@ export function isCodexModelsCaller(req) {
14
15
  return /(^|&)client_version=/.test(q);
15
16
  }
16
17
 
17
- export async function modelsHandler({ req, res, models, plugins }) {
18
+ export async function modelsHandler({ req, res, models, plugins, capabilities, wbSource }) {
18
19
  if (!models) return json(res, 501, { error: "Models service not configured" });
19
20
  const codex = isCodexModelsCaller(req);
20
21
  const withCodex = (out) => (codex && out && typeof out === "object" ? { ...out, models: [] } : out);
21
22
  try {
22
23
  let data = await models.get();
24
+ // ADR-0022:默认把能力富化进每条条目(capabilities 子对象);?raw=1 逃生门回原始形状,
25
+ // codex 调用者保持原始 + 顶层空 models:[](富化白做)。best-effort,失败原样返回。
26
+ const qs = String(req?.url || "").split("?")[1] || "";
27
+ const rawMode = /(^|&)raw=1(&|$)/.test(qs);
28
+ if (!codex && !rawMode) {
29
+ try {
30
+ data = await mergeModelsList(data, { capsSvc: capabilities, wbSource });
31
+ } catch { /* 富化失败不阻塞 /models */ }
32
+ }
23
33
  // 插件 hook:models:list — 返回数组可替换对外模型列表({object:"list",data:[...]} 或纯 id 数组)
24
34
  if (plugins?.length) {
25
35
  const ml = await runHook(plugins, "models:list", { data });
@@ -19,6 +19,15 @@ import { errMsg } from "../cli/util.js";
19
19
  * 无参(读 env/state 自身),返回 ctx 由门面接力 server-lifecycle。
20
20
  */
21
21
  export async function setupProviders() {
22
+ // 供应商 id 统一(旧 cline id → cline):必须在实例化任何 provider 之前跑;幂等,失败不阻断启动
23
+ try {
24
+ const { runStateMigrations } = await import("../state/migrations.js");
25
+ const mig = await runStateMigrations({ file: defaultStateFile() });
26
+ if (mig.applied.length) console.log(`state migrations applied: ${mig.applied.join(", ")}`);
27
+ for (const e of mig.errors) console.log(`state migration failed: ${e.id} — ${e.error}`);
28
+ } catch (err) {
29
+ console.log(`state migrations failed: ${errMsg(err)}`);
30
+ }
22
31
  const { token, created } = await loadToken();
23
32
  const pkgRoot2 = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
24
33
  const pluginDirs = resolvePluginDirs({ pkgRoot: pkgRoot2 });
@@ -123,10 +123,10 @@ export async function startServerLifecycle({ VERSION, token, created, upstream,
123
123
  else if (r.ok) console.log(`[preheat] opencode models ok ${r.status} ${r.ms}ms`);
124
124
  else console.log(`[preheat] opencode models failed ${r.error || r.status || ""} ${r.ms || 0}ms`);
125
125
  }).catch(() => {});
126
- // clinebot free 目录自检(独立于 opencode 连接预热:dispatcher.preheat 只做默认供应商)
126
+ // cline free 目录自检(独立于 opencode 连接预热:dispatcher.preheat 只做默认供应商)
127
127
  // 见 .agents/notes/implemented/simplification/2026-09-16-preheat-opencode-only.md
128
128
  try {
129
- const cline = upstream?.byId?.get?.("clinebot") ?? upstream?.byId?.get?.("cline");
129
+ const cline = upstream?.byId?.get?.("cline");
130
130
  if (cline?.checkFreeUpdates) void cline.checkFreeUpdates().catch(() => {});
131
131
  } catch {}
132
132
  }, 100).unref?.();
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 幂等状态迁移:把遗留 id 的 cline 配置合并进 `cline` 并删除旧键(供应商 id 统一)。
3
+ * 见 .scratch/cline-unify/SPEC.md §4.5;语义:keys 去重 + 剔除 sk_ 旧式 key、allowlist 求并、baseUrl 收敛为根。
4
+ * 绝不触碰 modelPicks / modelErrors / modelLatencies(旧 clinebot/* 键交给既有 prune --orphans)。
5
+ */
6
+ import { chmodSync, copyFileSync, existsSync } from "node:fs";
7
+ import { defaultStateFile, readState, writeStateImmediate } from "../store.js";
8
+ import { normalizeAllowedModel, normalizeEndpointPath } from "../provider-config.js";
9
+ import { appendEvent } from "../../logs.js";
10
+
11
+ export const CLINE_DEFAULT_BASE_URL = "https://api.cline.bot";
12
+ export const LEGACY_CLINE_IDS = ["clinebot", "cline-bot"];
13
+
14
+ /**
15
+ * cline 的 chat URL = `baseUrl` + `loadProviderChatPath()`;而 loadProviderChatPath 缺省**恒**返回
16
+ * `/chat/completions`(src/providers/cline/index.js 里那个按 `/api/v1` 智能兜底的 defaultChat 是死代码,
17
+ * 永远轮不到它生效)。所以 baseUrl 必须自带 `/api/v1`,否则会拼成 `https://api.cline.bot/chat/completions`
18
+ * → 上游 404(2026-09-20 端到端实测)。只对 cline 官方域补全,自定义/代理 baseUrl 不做猜测。
19
+ */
20
+ export function normalizeClineBaseUrl(u) {
21
+ const s = String(u || "").trim().replace(/\/+$/, "");
22
+ if (!s) return `${CLINE_DEFAULT_BASE_URL}/api/v1`;
23
+ if (/\/api\/v1$/i.test(s)) return s;
24
+ if (/^https?:\/\/api\.cline\.bot$/i.test(s)) return `${s}/api/v1`;
25
+ return s;
26
+ }
27
+
28
+ function uniq(list) { return [...new Set(list)]; }
29
+ function strList(v) { return Array.isArray(v) ? v.map((x) => String(x || "").trim()).filter(Boolean) : []; }
30
+ // sk_ 旧式 API key(D3:cline 只认 refreshToken,数据层剔除)
31
+ function isLegacySkKey(k) { return /^sk[-_]/i.test(k); }
32
+
33
+ /** 合并后的 cline 配置:只重建 cline 自己的字段,不吸收旧 id 的 allowAnyModels(避免意外放宽安全默认) */
34
+ function mergeConfig(cl, bot) {
35
+ const next = {
36
+ baseUrl: normalizeClineBaseUrl(cl.baseUrl || bot.baseUrl),
37
+ keys: uniq([...strList(cl.keys), ...strList(bot.keys)]).filter((k) => !isLegacySkKey(k)),
38
+ allowedModels: uniq([...strList(cl.allowedModels), ...strList(bot.allowedModels)]
39
+ .map((m) => normalizeAllowedModel(m, "cline")).filter(Boolean)),
40
+ };
41
+ if (Array.isArray(cl.auths) && cl.auths.length) next.auths = cl.auths;
42
+ if (typeof cl.allowAnyModels === "boolean") next.allowAnyModels = cl.allowAnyModels;
43
+ const modelsPath = normalizeEndpointPath(cl.modelsPath || bot.modelsPath || "");
44
+ if (modelsPath) next.modelsPath = modelsPath;
45
+ const chatPath = normalizeEndpointPath(cl.chatPath || bot.chatPath || "");
46
+ if (chatPath) next.chatPath = chatPath;
47
+ return next;
48
+ }
49
+
50
+ /** 备份到同目录 state.json.bak-<ISO时间戳>,权限收紧 0600(含凭据,绝不外泄) */
51
+ function backupState(file, now = new Date()) {
52
+ const stamp = now.toISOString().replace(/[:.]/g, "-");
53
+ const target = `${file}.bak-${stamp}`;
54
+ copyFileSync(file, target);
55
+ try { chmodSync(target, 0o600); } catch {}
56
+ return target;
57
+ }
58
+
59
+ /**
60
+ * @param {{file?:string, dryRun?:boolean, backup?:boolean}} opts
61
+ * @returns {{applied:boolean, changed:boolean, reason?:string, before?:object, after?:object, backup?:string}}
62
+ * `applied` 仅在真正落盘时为 true;before/after 只含计数,绝不含凭据明文。
63
+ */
64
+ export function clineUnifyMigration({ file = defaultStateFile(), dryRun = false, backup = true } = {}) {
65
+ const configs = readState(file).providerConfigs || {};
66
+ const legacyId = LEGACY_CLINE_IDS.find((pid) => configs[pid] && typeof configs[pid] === "object");
67
+ if (!legacyId) return { applied: false, changed: false, reason: "no legacy cline id" };
68
+ const bot = configs[legacyId];
69
+ const cl = configs.cline && typeof configs.cline === "object" ? configs.cline : {};
70
+ const next = mergeConfig(cl, bot);
71
+ const before = {
72
+ keys: strList(cl.keys).length + strList(bot.keys).length,
73
+ allowedModels: uniq([...strList(cl.allowedModels), ...strList(bot.allowedModels)]).length,
74
+ };
75
+ const after = { keys: next.keys.length, allowedModels: next.allowedModels.length };
76
+ if (dryRun) return { applied: false, changed: true, reason: "dry-run", before, after };
77
+ let backupPath = null;
78
+ if (backup) {
79
+ try { if (existsSync(file)) backupPath = backupState(file); } catch {}
80
+ }
81
+ const merged = { ...configs, cline: next };
82
+ for (const pid of LEGACY_CLINE_IDS) delete merged[pid];
83
+ writeStateImmediate(file, { providerConfigs: merged });
84
+ try {
85
+ appendEvent({
86
+ type: "cline-unify-migrated",
87
+ from: legacyId,
88
+ keys: after.keys,
89
+ allowedModels: after.allowedModels,
90
+ backup: backupPath ? backupPath.replace(/\\/g, "/").split("/").pop() : null,
91
+ });
92
+ } catch {}
93
+ return { applied: true, changed: true, reason: `merged ${legacyId}`, before, after, ...(backupPath ? { backup: backupPath } : {}) };
94
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 状态迁移运行器:按注册顺序执行(每个迁移自身幂等),供 daemon bootstrap
3
+ * (src/runtime/providers-setup.js,实例化任何 provider 之前)与 CLI 单点调用。
4
+ * 失败只收集不抛出:迁移不该拖垮 daemon 启动。
5
+ */
6
+ import { defaultStateFile } from "./store.js";
7
+ import { clineUnifyMigration } from "./migrations/cline-unify.js";
8
+
9
+ export const STATE_MIGRATIONS = [
10
+ { id: "cline-unify", run: (opts) => clineUnifyMigration(opts) },
11
+ ];
12
+
13
+ /**
14
+ * @param {{file?:string, dryRun?:boolean, backup?:boolean}} opts
15
+ * @returns {Promise<{applied:string[], skipped:string[], errors:object[], details:object}>}
16
+ */
17
+ export async function runStateMigrations({ file = defaultStateFile(), dryRun = false, backup = true } = {}) {
18
+ const applied = [];
19
+ const skipped = [];
20
+ const errors = [];
21
+ const details = {};
22
+ for (const m of STATE_MIGRATIONS) {
23
+ try {
24
+ const r = await m.run({ file, dryRun, backup });
25
+ details[m.id] = r;
26
+ if (r?.applied) applied.push(m.id);
27
+ else skipped.push(m.id);
28
+ } catch (err) {
29
+ errors.push({ id: m.id, error: String(err?.message || err) });
30
+ }
31
+ }
32
+ return { applied, skipped, errors, details };
33
+ }
@@ -1,13 +1,17 @@
1
- import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync, chmodSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { defaultStateFile, tokenFile, generateToken, readState, writeStateImmediate, getEntry } from "../store.js";
4
4
  import { fmtShanghaiYMDHMS } from "../../time.js";
5
5
 
6
+ // 主 Bearer token 镜像落盘:0600 + 显式 chmod。
7
+ // 注意 writeFileSync 的 mode 只在“新建文件”时生效,已存在的文件权限不会被它改;
8
+ // 升级前生成的老文件会永远停在 0644(同机其他用户可读),所以必须再 chmod 一次兜住。
6
9
  function syncTokenFile(token, file) {
7
10
  try {
8
11
  const tf = tokenFile(file);
9
12
  mkdirSync(dirname(tf), { recursive: true });
10
- writeFileSync(tf, String(token || "").trim() + "\n", "utf8");
13
+ writeFileSync(tf, String(token || "").trim() + "\n", { encoding: "utf8", mode: 0o600 });
14
+ try { chmodSync(tf, 0o600); } catch {}
11
15
  } catch {}
12
16
  }
13
17
 
@@ -1,5 +1,6 @@
1
1
  import { defaultStateFile, readState, writeStateImmediate } from "../store.js";
2
2
  import { classifyProvider } from "../../providers/classify.js";
3
+ import { getModelAlias, normalizeProviderId, DEFAULT_PROVIDER } from "../../providers/model-id.js";
3
4
 
4
5
  function parseBool(v) {
5
6
  if (typeof v === "boolean") return v;
@@ -36,23 +37,63 @@ export function getEffectiveUseGroup({ file = defaultStateFile() } = {}) {
36
37
  return loadUseGroup({ file });
37
38
  }
38
39
 
40
+ // key 供应商组员开关:默认 off(仅 opencode 免费池走 peer 兜底,其他带 key 上游恒直连)。
41
+ // MSLXDFF_USE_GROUP_KEYS=1/on 可显式开回来(调试/弱网应急);同样受全局 off 约束。
42
+ export function getUseGroupKeysEnv() {
43
+ const raw = process.env.MSLXDFF_USE_GROUP_KEYS;
44
+ if (raw === undefined || raw === null || raw === "") return false;
45
+ return parseBool(raw) === true;
46
+ }
47
+
48
+ // 取模型对应的供应商 head:兼容 canonical(workbuddy/xxx、cline/xxx)、
49
+ // dash 别名(cline-xxx → 还原后取 head)、裸 id(归 opencode)。
50
+ // oc/ 别名归一到 opencode(与 splitModelId 一致:别名表小写 key,先小写再归一)。
51
+ export function providerHeadOf(model) {
52
+ let s = String(model || "").trim();
53
+ if (!s) return "";
54
+ try {
55
+ const aliased = getModelAlias(s);
56
+ if (aliased) s = aliased;
57
+ } catch {}
58
+ const slash = s.indexOf("/");
59
+ if (slash > 0) {
60
+ try {
61
+ const head = normalizeProviderId(s.slice(0, slash).toLowerCase());
62
+ return String(head || "").toLowerCase();
63
+ } catch {
64
+ return s.slice(0, slash).toLowerCase();
65
+ }
66
+ }
67
+ return DEFAULT_PROVIDER;
68
+ }
69
+
39
70
  // 全局开关:off 则所有供应商都不走组员网络(via-route/hedge/peer/broadband 全禁),仅本机直连
40
- // workbuddy 硬禁组员(ADR-0015 local-only):本机账号绑定(auths/workbuddy-*.json + uid),
71
+ // workbuddy/cline 系硬禁组员(ADR-0015 local-only):本机账号绑定(workbuddy auths/*.json + uid / cline refreshToken),
41
72
  // 组员没有该账号转过去也用不了,且本地直连最快——无论全局开关一律仅本机直连。
42
- // model 兼容 canonical(workbuddy/xxx)与 dash(workbuddy-xxx)两种形态。
73
+ // model 兼容 canonical(workbuddy/xxx、cline/xxx)与 dash(workbuddy-xxx、cline-xxx)两种形态。
43
74
  export function isHardLocalOnly(model) {
44
- const s = String(model || "").trim().toLowerCase();
45
- const slash = s.indexOf("/");
46
- const head = slash > 0 ? s.slice(0, slash) : s.split("-")[0];
75
+ const head = providerHeadOf(model);
76
+ if (!head) return false;
47
77
  try {
48
78
  return classifyProvider(head) === "local-only";
49
79
  } catch {
50
- return head === "workbuddy";
80
+ return head === "workbuddy" || head === "cline";
51
81
  }
52
82
  }
53
83
 
84
+ // key 供应商默认直连(ADR-0023):opencode(quota-pool,裸 id/oc 前缀)沿用全局开关;
85
+ // 其余带前缀的一律仅本机直连,除非 MSLXDFF_USE_GROUP_KEYS=1 显式开回。
86
+ export function isKeyProviderDirectOnly(model) {
87
+ const head = providerHeadOf(model);
88
+ if (!head || head === "opencode") return false;
89
+ if (isHardLocalOnly(model)) return true;
90
+ if (getUseGroupKeysEnv()) return false;
91
+ return true;
92
+ }
93
+
54
94
  export function shouldUseGroupForModel(model, { file = defaultStateFile() } = {}) {
55
95
  if (isHardLocalOnly(model)) return false;
96
+ if (isKeyProviderDirectOnly(model)) return false;
56
97
  return getEffectiveUseGroup({ file });
57
98
  }
58
99
 
@@ -119,7 +119,7 @@ export async function syncToWorkbuddy({ id, token, port, file, keep } = {}) {
119
119
  if (arr[idx].useCustomProtocol === undefined) arr[idx].useCustomProtocol = false;
120
120
  action = "updated";
121
121
  } else {
122
- // 检查归一化 id 是否与已有条目冲突(如已存在 clinebot-z-ai-glm-5.3-flash 但不是本地 URL)
122
+ // 检查归一化 id 是否与已有条目冲突(如已存在 cline-z-ai-glm-5.3-flash 但不是本地 URL)
123
123
  const conflict = arr.findIndex((m) => m && m.id === wbId);
124
124
  if (conflict >= 0) {
125
125
  // 已有同名条目且非我们写的,跳过不覆盖
@@ -1,4 +1,4 @@
1
- // 上游引擎选择(ADR-0017):sdk=AI SDK wire 层(缺省),legacy=原实现(显式/Node16 回退)。
1
+ // 上游引擎选择(ADR-0017):sdk=AI SDK wire 层(缺省),legacy=原实现(显式指定或 SDK 装载失败回退)。
2
2
  // Note: 缺省即 sdk;chat 类走 /chat/completions(@ai-sdk/openai-compatible),
3
3
  // responses 类(muse-spark*)走 /responses(@ai-sdk/openai);非流式与 anon 重试委派 legacy,
4
4
  // SDK 不可用自动回退并告警一次 — 见 .scratch/ai-sdk-upstream/{SPEC.md,SPEC-p4-turnon.md,SPEC-p3-responses.md} 与 docs/adr/0017
@@ -7,7 +7,7 @@ import { errMsg } from "../cli/util.js";
7
7
 
8
8
  export const PROBE_DELAY_MS = Number(process.env.MSLXDFF_BENCH_DELAY_MS || 120) || 0;
9
9
 
10
- // 从 providerConfigs 收集探针目标:local-only(workbuddy)/quota-pool(opencode) 排除,无 baseUrl 跳过
10
+ // 从 providerConfigs 收集探针目标:local-only(workbuddy/cline系)/quota-pool(opencode) 排除,无 baseUrl 跳过
11
11
  export function probeTargetsFromState({ loadProviderConfigs, loadProviderKeys, loadProviderBaseUrl } = {}) {
12
12
  const out = [];
13
13
  const ids = new Set(Object.keys(loadProviderConfigs?.() || {}));