@wenbin_wb/dsh-bridge 2.10.8 → 2.10.10

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/lib/bridge-rpc.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import QRCode from 'qrcode';
5
5
  import { BRIDGE_RPC_CHANNEL, BRIDGE_ENDPOINTS } from './bridge-rpc-constants.js';
6
+ import { registerRpcChannel } from './connection-compat.js';
6
7
  import { RateLimiter } from './security/rate-limiter.js';
7
8
 
8
9
  export { BRIDGE_RPC_CHANNEL, BRIDGE_ENDPOINTS };
@@ -87,7 +88,10 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
87
88
  return () => {};
88
89
  }
89
90
 
90
- return ctx.connection.rpc.handle(
91
+ // connection-compat 注册:兼容 DSH ≥ 0.1.5-alpha.1 的 webServer 注入回归
92
+ // (上游 register() 里对 connection 自身 ctx 取 webServer 而未加 inject 作用域)
93
+ return registerRpcChannel(
94
+ ctx,
91
95
  BRIDGE_RPC_CHANNEL,
92
96
  async (endpoint, payload = {}, signal) => {
93
97
  if (signal?.aborted) return fail('cancelled', 'Request was cancelled');
@@ -374,6 +378,29 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
374
378
 
375
379
  // ---- 平台管理器(多 IM 平台)----
376
380
 
381
+ // 本机可用的 DSH agent preset(设置页下拉用)。
382
+ // 只读、不含敏感信息:DSH 未提供 agentPresets 服务(旧版本)或名册读取失败时
383
+ // 返回 available:false,前端退化为手填,不影响会话创建。
384
+ if (endpoint === BRIDGE_ENDPOINTS.listAgentPresets) {
385
+ try {
386
+ const presets = ctx.get?.('agentPresets');
387
+ if (!presets?.list) return ok({ available: false, default: null, presets: [] });
388
+ const rows = await presets.list();
389
+ return ok({
390
+ available: true,
391
+ default: typeof presets.defaultId === 'string' ? presets.defaultId : null,
392
+ presets: (Array.isArray(rows) ? rows : [])
393
+ // 坏掉的预设不列出来(DSH 自己也会在挂载前拒绝)
394
+ .filter((p) => p && typeof p.id === 'string' && p.broken === undefined)
395
+ .map((p) => ({ id: p.id, name: typeof p.name === 'string' ? p.name : p.id })),
396
+ });
397
+ } catch (err) {
398
+ // 服务被卸载 / 名册不可读:这是设置页的辅助信息,不该让整个请求失败
399
+ logger?.warn?.('dsh-bridge: listAgentPresets failed: %s', err?.message ?? err);
400
+ return ok({ available: false, default: null, presets: [] });
401
+ }
402
+ }
403
+
377
404
  if (endpoint === BRIDGE_ENDPOINTS.listPlatforms) {
378
405
  if (!platformManager) return ok({});
379
406
  // 每个平台的 login.qrPayload 渲染为 dataURL 后返回
@@ -486,7 +513,10 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
486
513
  return fail('bad-request', err.message);
487
514
  }
488
515
  },
489
- { authority: 'loopback' }
516
+ // DSH 0.1.0/0.1.1 支持 authority 选项(仅回环可达);0.1.2+ 忽略之,透传无副作用。
517
+ // 见 lib/connection-compat.js 的 registerRpcChannel 说明。
518
+ { authority: 'loopback' },
519
+ logger
490
520
  );
491
521
  }
492
522
 
@@ -0,0 +1,115 @@
1
+ // 运行时兼容垫片:DSH ≥ 0.1.5-alpha.1 的 connection RPC 通道注册回归
2
+ // (lib/connection-compat.js)
3
+ //
4
+ // 背景(上游回归,非本插件声明问题)
5
+ // ---------------------------------------------------------------
6
+ // @deepseek-ai/dsh-client-connection 在 0.1.5-alpha.1 改了插件级服务声明:
7
+ // ≤ 0.1.3-alpha.2 const inject = ["webServer", "credentials"];
8
+ // ≥ 0.1.5-alpha.1 const inject = ["credentials"]; // webServer 改为可选
9
+ // 同时把 /api 路由改用作用域注入 ctx.inject(["webServer"], (webCtx) => …)。
10
+ // 但 HostConnectionService.register() 里那一句没有同步改(各版本行号均为 618):
11
+ // return owner.effect(() => owner.webServer.register(route),
12
+ // `client-connection: ${channel} rpc channel`);
13
+ // 其中 owner === this.ctx,即 connection 插件自己的 ctx。该 ctx 的 fiber 不再把
14
+ // webServer 记入 inject,于是 cordis 4 的服务守卫直接抛:
15
+ // cannot get property "webServer" without inject
16
+ // 注册发生在插件树加载期,异常向上冒泡 → 整棵插件树加载失败,`dsh web` 起不来:
17
+ // Error: dsh: plugin tree failed to load: failed to apply loader entry
18
+ // dsh-bridge (@wenbin_wb/dsh-bridge): cannot get property "webServer" without inject
19
+ //
20
+ // 影响面:任何调用 ctx.connection.rpc.handle() 的插件都会踩中(dsh-bridge 只是第一个撞上的)。
21
+ // 已逐一核对 npm 上的版本:0.1.5-alpha.1 / alpha.2 / rc.1 / rc.2 全部未修
22
+ // (0.1.5-rc.2 为当前已发布的最新版),0.1.3-alpha.2 及更早正常。
23
+ //
24
+ // 本垫片的做法
25
+ // ---------------------------------------------------------------
26
+ // 不改上游代码、不猜内部私有方法,只走两个公开面:
27
+ // 1. 正常路径:ctx.connection.rpc.handle(channel, handler)(公开 API,未来上游修好即恢复);
28
+ // 2. 兜底路径:仅当该调用抛出 "webServer … without inject" 时,在 root 的
29
+ // `internal/get` 瀑布事件上临时挂一个监听器 —— 先让默认解析跑,默认解析失败
30
+ // (抛出的正是同一个 error 对象)才补上本插件已通过 inject 拿到的 webServer 引用。
31
+ // webServer 是单例服务,语义等价;默认解析成功时零干预。
32
+ // 监听器只在这一次同步注册调用期间存在,返回后立即摘除(cordis 的 effect 同步执行,
33
+ // 注册完成后 owner.webServer 不再被访问),因此对宿主其余部分完全无副作用。
34
+ //
35
+ // 上游修好后本模块自动空转:首次调用不再抛错,兜底分支根本不会进入。
36
+
37
+ const MISSING_INJECT = /without inject/;
38
+
39
+ /**
40
+ * 判断错误是否为"跨服务取属性缺 inject 作用域"的守卫错误,且对象是 webServer。
41
+ * @param {unknown} err 捕获到的异常
42
+ * @returns {boolean} 是否命中该回归
43
+ */
44
+ export function isMissingWebServerInjectError(err) {
45
+ if (!(err instanceof Error)) return false;
46
+ return MISSING_INJECT.test(err.message) && /webServer/.test(err.message);
47
+ }
48
+
49
+ /**
50
+ * 在 root 的 `internal/get` 瀑布事件上挂一个临时兜底:默认解析失败时提供 webServer。
51
+ *
52
+ * cordis 4 的 `ctx.events` 是所有子 ctx 共享的同一个实例,且 `internal/get` 派发时
53
+ * 不带 context 过滤条件(thisArg 为 null),因此在本插件 ctx 上注册的监听器同样会对
54
+ * connection 插件 ctx 的查找生效。监听器由 root fiber 持有,须手工摘除。
55
+ *
56
+ * @param {object} ctx 本插件的 ctx
57
+ * @returns {() => void} 摘除函数
58
+ */
59
+ export function installWebServerResolutionFallback(ctx) {
60
+ const webServer = ctx?.webServer;
61
+ if (webServer === undefined) return () => {};
62
+
63
+ const listener = function (lookupCtx, prop, error, next) {
64
+ if (prop !== 'webServer') return next();
65
+ try {
66
+ return next();
67
+ } catch (err) {
68
+ // 默认解析抛的不是本次守卫错误 → 原样上抛,绝不掩盖真实故障
69
+ if (err !== error) throw err;
70
+ }
71
+ // 默认解析确认失败:补上本插件已 inject 的单例引用(语义等价)
72
+ return webServer;
73
+ };
74
+
75
+ const off = ctx.on?.('internal/get', listener, { global: true });
76
+ return typeof off === 'function' ? off : () => {};
77
+ }
78
+
79
+ /**
80
+ * 注册 connection RPC 通道,并兼容 DSH ≥ 0.1.5-alpha.1 的 webServer 注入回归。
81
+ *
82
+ * @param {object} ctx 本插件的 ctx(须已 inject `connection` 与 `webServer`)
83
+ * @param {string} channel RPC 通道名(如 `/dsh-bridge`)
84
+ * @param {Function} handler 通道处理器
85
+ * @param {object} [rpcOptions] 透传给 connection 的通道选项。
86
+ * DSH 0.1.0/0.1.1 的 `rpc.handle(channel, handler, options)` 支持
87
+ * `{ authority: 'loopback' }`(仅回环可达,非回环请求 403);
88
+ * 0.1.2-rc.1 起该第三参数已被移除(更高版本忽略它,传入无副作用)。
89
+ * 本插件依赖它来做回环加固,因此必须原样透传、不得丢失。
90
+ * @param {{warn?: Function}} [logger] 可选日志器
91
+ * @returns {Function} 由 connection 服务返回的处置函数
92
+ */
93
+ export function registerRpcChannel(ctx, channel, handler, rpcOptions, logger) {
94
+ try {
95
+ return ctx.connection.rpc.handle(channel, handler, rpcOptions);
96
+ } catch (err) {
97
+ if (!isMissingWebServerInjectError(err)) throw err;
98
+
99
+ // 上游回归:owner(connection 插件自己的 ctx)解析不到 webServer。
100
+ // 该调用在 owner.webServer 处、于 webServer.register() 之前就抛,未产生任何副作用,
101
+ // 因此可以安全地补上兜底后重试一次。
102
+ const dispose = installWebServerResolutionFallback(ctx);
103
+ try {
104
+ const registered = ctx.connection.rpc.handle(channel, handler, rpcOptions);
105
+ logger?.warn?.(
106
+ 'dsh-bridge: 检测到宿主 DSH 的 connection RPC 注册回归(cannot get property "webServer" without inject,'
107
+ + '见 dsh-client-connection 的 register());已用 webServer 解析兜底完成通道注册。'
108
+ + '此为宿主侧问题(DSH 0.1.5-alpha.1 起),插件侧垫片会在上游修复后自动空转。'
109
+ );
110
+ return registered;
111
+ } finally {
112
+ dispose();
113
+ }
114
+ }
115
+ }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import QRCode from 'qrcode'
6
6
  import { Platform } from '../platform/base.js'
7
+ import { applySessionConfig, readSessionConfig } from '../platform/session-config.js'
7
8
  import { FeishuGateway } from './gateway.js'
8
9
  import { FeishuConversationNode } from './node.js'
9
10
 
@@ -38,6 +39,11 @@ export class FeishuService extends Platform {
38
39
  maxMessageChars: config.maxMessageChars || 2000,
39
40
  sendChunkDelayMs: config.sendChunkDelayMs,
40
41
  activeSessionId: config.activeSessionId,
42
+ // 会话级配置:工作区 / Agent 预设 / 模型路由(构造期来自 cordis 配置,运行期由设置页写入)
43
+ cwd: config.cwd,
44
+ agentPreset: config.agentPreset,
45
+ agentProvider: config.agentProvider,
46
+ agentModel: config.agentModel,
41
47
  }, logger, {
42
48
  onFirstSender: () => this.persist({ allowFrom: [...(this.node?.config?.allowFrom ?? [])] }),
43
49
  onActiveSessionChange: (sessionId) => this.persist({ activeSessionId: sessionId }),
@@ -164,6 +170,7 @@ export class FeishuService extends Platform {
164
170
  appId: this.gateway.config.appId,
165
171
  appSecret: '',
166
172
  domain: this.gateway.config.domain,
173
+ ...readSessionConfig(this.node?.config),
167
174
  },
168
175
  botInfo: this.gateway.botInfo,
169
176
  botLink,
@@ -179,7 +186,7 @@ export class FeishuService extends Platform {
179
186
  return { success: true, allowFrom: list }
180
187
  }
181
188
 
182
- async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, groupAutoApprove, appId, appSecret, domain } = {}) {
189
+ async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, groupAutoApprove, appId, appSecret, domain, agentPreset, cwd, agentProvider, agentModel } = {}) {
183
190
  if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
184
191
  if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
185
192
  if (maxMessageChars != null) {
@@ -188,6 +195,7 @@ export class FeishuService extends Platform {
188
195
  }
189
196
  if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
190
197
  if (groupAutoApprove != null) this.node.config.groupAutoApprove = groupAutoApprove === true
198
+ applySessionConfig(this.node.config, { agentPreset, cwd, agentProvider, agentModel })
191
199
  if (appId !== undefined || appSecret !== undefined || domain !== undefined) {
192
200
  this.gateway.updateConfig({
193
201
  appId: appId !== undefined ? appId.trim() : this.gateway.config.appId,
@@ -201,6 +209,7 @@ export class FeishuService extends Platform {
201
209
  maxMessageChars: this.node.config.maxMessageChars,
202
210
  sendChunkDelayMs: this.node.config.sendChunkDelayMs,
203
211
  groupAutoApprove: this.node.config.groupAutoApprove === true,
212
+ ...readSessionConfig(this.node.config),
204
213
  }
205
214
  if (appId !== undefined || appSecret !== undefined || domain !== undefined) {
206
215
  patch.appId = this.gateway.config.appId
package/lib/index.js CHANGED
@@ -13,17 +13,18 @@ import { fileURLToPath } from 'node:url';
13
13
  import { readFileSync, existsSync, realpathSync } from 'node:fs';
14
14
  import { readFile, writeFile, mkdir, unlink, readdir, stat, access } from 'node:fs/promises';
15
15
  import { spawn } from 'node:child_process';
16
- import { createHash, createHmac } from 'node:crypto';
17
16
  import QRCode from 'qrcode';
18
17
  import { installBridgeRpc } from './bridge-rpc.js';
19
18
  import { CustomTunnelClient } from './tunnel-client.mjs';
20
19
  import { CloudflaredManager } from './cloudflared-manager.mjs';
21
20
  import { PlatformManager } from './platform/manager.js';
21
+ import { applyRestoredPlatformConfig, RESTORED_STRING_FIELDS, PLATFORM_TIMING_FIELDS } from './platform/config-restore.js';
22
22
  import { WechatService } from './wechat/index.js';
23
23
  import { QqService } from './qq/index.js';
24
24
  import { FeishuService } from './feishu/index.js';
25
25
  import { TelegramService } from './telegram/index.js';
26
26
  import { AuthManager } from './auth/manager.js';
27
+ import { getDshLoopbackCookie as getDshLoopbackCookieImpl } from './auth/dsh-native-cookie.js';
27
28
  import { renderLoginPage } from './auth/login-template.js';
28
29
  import { isSafeWorkspacePath, isSensitiveFolderName } from './security/path-validator.js';
29
30
  import { stripSessionProjections } from './session-strip.js';
@@ -193,35 +194,14 @@ function isCompressed(headers) {
193
194
  return /(^|,\s*)(gzip|br|deflate)(\s*,|$)/i.test(String(headers['content-encoding'] ?? ''));
194
195
  }
195
196
 
196
- function encodeBase64Url(value) {
197
- return Buffer.from(value).toString('base64url');
198
- }
199
-
200
197
  /**
201
198
  * 读取 DSH 本地凭证并生成 loopback dsh-auth 认证签名 Cookie (适配 DSH 新版原生认证)
199
+ *
200
+ * 实现见 lib/auth/dsh-native-cookie.js(含 cookie 名称/载荷/签名的格式说明与
201
+ * 凭据记录定位策略)。此处仅做 thin wrapper,保持既有调用点不变。
202
202
  */
203
203
  function getDshLoopbackCookie(targetPort) {
204
- try {
205
- const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
206
- const credPath = join(dshHome, '.credentials.yaml');
207
- if (!existsSync(credPath)) return '';
208
- const content = readFileSync(credPath, 'utf8');
209
- const match = content.match(/secret:\s*([A-Za-z0-9_-]+)/);
210
- if (!match) return '';
211
- const secret = Buffer.from(match[1], 'base64url');
212
-
213
- const authority = `127.0.0.1:${targetPort}`;
214
- const name = 'dsh-auth-' + encodeBase64Url(createHash('sha256').update(authority).digest());
215
- const issuedAt = Date.now() - 1000;
216
- const expiresAt = issuedAt + 30 * 24 * 3600 * 1000;
217
- const body = encodeBase64Url(Buffer.from(JSON.stringify({
218
- version: 1, authority, issuedAt, expiresAt,
219
- }), 'utf8'));
220
- const sig = encodeBase64Url(createHmac('sha256', secret).update(body).digest());
221
- return `${name}=v1.${body}.${sig}`;
222
- } catch {
223
- return '';
224
- }
204
+ return getDshLoopbackCookieImpl(targetPort);
225
205
  }
226
206
 
227
207
  /** 把请求头中的 Host 和 Origin 改写成 loopback,让 DSH 的安全栅栏放行 */
@@ -1975,15 +1955,13 @@ function apply(ctx, config = {}) {
1975
1955
  const cfg = stored?.[platformKey];
1976
1956
  if (!cfg) return;
1977
1957
  const node = service.node;
1978
- node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
1979
- for (const field of numericFields) {
1980
- if (cfg[field] != null) node.config[field] = Number(cfg[field]);
1981
- }
1982
- if (cfg.maxMessageChars != null) {
1983
- const val = Number(cfg.maxMessageChars);
1984
- node.config.maxMessageChars = (val >= 200) ? val : defaultMaxMessageChars;
1985
- }
1986
- if (cfg.groupAutoApprove != null) node.config.groupAutoApprove = cfg.groupAutoApprove === true;
1958
+ // 白名单/数值字段 + 会话字符串字段(cwd/agentPreset/agentProvider/agentModel)一并回写,
1959
+ // 否则"设置里配了、重启就丢",远程会话会掉到默认或空 preset
1960
+ applyRestoredPlatformConfig(node.config, cfg, {
1961
+ numericFields,
1962
+ stringFields: RESTORED_STRING_FIELDS,
1963
+ defaultMaxMessageChars,
1964
+ });
1987
1965
 
1988
1966
  node._restoringConfig = (async () => {
1989
1967
  if (cfg.activeSessionId) {
@@ -2008,7 +1986,7 @@ function apply(ctx, config = {}) {
2008
1986
 
2009
1987
  restorePlatform(wechat, {
2010
1988
  platformKey: 'wechat',
2011
- numericFields: ['digestIntervalSec', 'approvalTimeoutSec', 'sendChunkDelayMs'],
1989
+ numericFields: PLATFORM_TIMING_FIELDS,
2012
1990
  hasCredentials: (cfg) => Boolean(cfg.token && cfg.accountId),
2013
1991
  applyCredentials: (cfg) => wechat.gateway.setCredentials({
2014
1992
  token: cfg.token,
@@ -2019,6 +1997,7 @@ function apply(ctx, config = {}) {
2019
1997
 
2020
1998
  restorePlatform(qq, {
2021
1999
  platformKey: 'qq',
2000
+ numericFields: PLATFORM_TIMING_FIELDS,
2022
2001
  hasCredentials: (cfg) => Boolean(cfg.appId && cfg.clientSecret),
2023
2002
  applyCredentials: (cfg) => qq.gateway.setCredentials({
2024
2003
  appId: cfg.appId,
@@ -2032,6 +2011,7 @@ function apply(ctx, config = {}) {
2032
2011
 
2033
2012
  restorePlatform(feishu, {
2034
2013
  platformKey: 'feishu',
2014
+ numericFields: PLATFORM_TIMING_FIELDS,
2035
2015
  hasCredentials: (cfg) => Boolean(cfg.appId && cfg.appSecret),
2036
2016
  applyCredentials: (cfg) => feishu.gateway.updateConfig({
2037
2017
  appId: cfg.appId,
@@ -2042,7 +2022,7 @@ function apply(ctx, config = {}) {
2042
2022
 
2043
2023
  restorePlatform(telegram, {
2044
2024
  platformKey: 'telegram',
2045
- numericFields: ['digestIntervalSec', 'approvalTimeoutSec', 'sendChunkDelayMs'],
2025
+ numericFields: PLATFORM_TIMING_FIELDS,
2046
2026
  defaultMaxMessageChars: 4096,
2047
2027
  hasCredentials: (cfg) => Boolean(cfg.botToken),
2048
2028
  applyCredentials: (cfg) => telegram.gateway.setCredentials({
@@ -2107,22 +2087,24 @@ function apply(ctx, config = {}) {
2107
2087
  if (incoming.customTunnel) {
2108
2088
  service.customTunnelConfig = incoming.customTunnel;
2109
2089
  }
2110
- // 重新载入各 IM 平台白名单与配置
2111
- if (incoming.wechat) wechat.node.config.allowFrom = incoming.wechat.allowFrom ?? [];
2090
+ // 重新载入各 IM 平台白名单与配置(含 cwd/agentPreset/agentProvider/agentModel)
2091
+ if (incoming.wechat) {
2092
+ applyRestoredPlatformConfig(wechat.node.config, incoming.wechat, { stringFields: RESTORED_STRING_FIELDS });
2093
+ }
2112
2094
  if (incoming.qq) {
2113
- qq.node.config.allowFrom = incoming.qq.allowFrom ?? [];
2095
+ applyRestoredPlatformConfig(qq.node.config, incoming.qq, { stringFields: RESTORED_STRING_FIELDS });
2114
2096
  if (incoming.qq.appId && incoming.qq.clientSecret) {
2115
2097
  qq.gateway.setCredentials({ appId: incoming.qq.appId, clientSecret: incoming.qq.clientSecret });
2116
2098
  }
2117
2099
  }
2118
2100
  if (incoming.feishu) {
2119
- feishu.node.config.allowFrom = incoming.feishu.allowFrom ?? [];
2101
+ applyRestoredPlatformConfig(feishu.node.config, incoming.feishu, { stringFields: RESTORED_STRING_FIELDS });
2120
2102
  if (incoming.feishu.appId && incoming.feishu.appSecret) {
2121
2103
  feishu.gateway.setCredentials({ appId: incoming.feishu.appId, appSecret: incoming.feishu.appSecret });
2122
2104
  }
2123
2105
  }
2124
2106
  if (incoming.telegram) {
2125
- telegram.node.config.allowFrom = incoming.telegram.allowFrom ?? [];
2107
+ applyRestoredPlatformConfig(telegram.node.config, incoming.telegram, { stringFields: RESTORED_STRING_FIELDS });
2126
2108
  if (incoming.telegram.botToken) {
2127
2109
  telegram.gateway.setCredentials({ botToken: incoming.telegram.botToken, proxy: incoming.telegram.proxy || '' });
2128
2110
  }
@@ -55,11 +55,25 @@ export async function routeCommand(node, text, senderId = null) {
55
55
 
56
56
  try {
57
57
  const session = node.activeSession()
58
- if (session) {
59
- session.title = newTitle
58
+ if (!session) {
59
+ // 会话尚未在内存中恢复(如宿主刚重启):DSH 的会话标题服务只接受 live 会话,
60
+ // 此时无法真正改名,明确告知而不是回复一句"成功"却什么都没发生
61
+ await node.sendText(`❌ **无法重命名**:当前会话尚未恢复(宿主重启后需先发一条消息重新挂载)。\n\n> 发送任意消息后再次执行 \`/rename <新标题>\` 即可。`)
62
+ return true
60
63
  }
61
- if (node.ctx.sessionPersistence?.update) {
62
- await node.ctx.sessionPersistence.update(node.activeSessionId, { title: newTitle }).catch(() => {})
64
+ // 首选 DSH 原生会话标题服务:rename() 会追加 session/title 事件并落盘,
65
+ // 宿主重启与 Web 侧边栏都能看到。注意 sessionPersistence 并不提供 update()
66
+ // (真实方法只有 create/open/stat/list/...),旧写法被可选链静默跳过,
67
+ // 导致 /rename 只改了内存对象、重启即失效。
68
+ const titleService = node.ctx.get?.('sessionTitle')
69
+ if (titleService?.rename) {
70
+ titleService.rename(session, newTitle)
71
+ } else {
72
+ // 旧版 DSH 回退:内存标题立即可见,并尝试历史持久化接口(存在才用)
73
+ session.title = newTitle
74
+ if (node.ctx.sessionPersistence?.update) {
75
+ await node.ctx.sessionPersistence.update(node.activeSessionId, { title: newTitle }).catch(() => {})
76
+ }
63
77
  }
64
78
  await node.sendText(`✓ **会话重命名成功**\n- **会话 ID**:\`${fmtSessionId(node.activeSessionId)}\`\n- **新标题**:${newTitle}`)
65
79
  } catch (err) {
@@ -0,0 +1,69 @@
1
+ // 平台配置恢复(从 ~/.dsh/dsh-bridge/config.json 回写到会话桥 config)
2
+ //
3
+ // 背景:平台配置的写入链路把整段平台配置落在 config.json 里,但启动/重启时的
4
+ // 读回链路只挑了几个白名单字段,导致 agentPreset / cwd / agentProvider / agentModel
5
+ // 这类字符串配置"配了、重启就丢",会话随后落到 DSH 默认 preset 或空 preset 层。
6
+ //
7
+ // 这里把恢复规则收敛成一个纯函数,既供 lib/index.js 的 restorePlatform 使用,
8
+ // 也让"只恢复白名单字段"这条不变量可以被单测钉住。
9
+
10
+ /**
11
+ * 恢复平台配置时必须透传的字符串字段白名单。
12
+ *
13
+ * 这四个键直接决定远程会话在哪里、以什么预设和模型启动:
14
+ * - cwd 会话工作区(`/new` 建在哪个目录)
15
+ * - agentPreset DSH agent preset(决定该会话挂载的工具/提示词/技能目录)
16
+ * - agentProvider / agentModel 会话默认模型路由
17
+ */
18
+ export const RESTORED_STRING_FIELDS = ['agentPreset', 'cwd', 'agentProvider', 'agentModel']
19
+
20
+ /**
21
+ * 各平台 `setConfig()` 会持久化、因此恢复时必须一并回写的数值字段。
22
+ *
23
+ * 写入侧(wechat / qq / feishu / telegram 的 setConfig)统一持久化这三个会话节奏参数;
24
+ * 恢复侧若漏掉,用户在设置页调好的摘要间隔 / 审批超时 / 分块延时只活到下一次重启。
25
+ * 写入侧新增数值字段时,必须同步这里。
26
+ */
27
+ export const PLATFORM_TIMING_FIELDS = ['digestIntervalSec', 'approvalTimeoutSec', 'sendChunkDelayMs']
28
+
29
+ /**
30
+ * 把 config.json 中某个平台的持久化配置回写到会话桥的 node.config。
31
+ *
32
+ * 规则(与磁盘上的写入契约一致):
33
+ * - allowFrom 一律以数组形式落回,缺失即空数组;
34
+ * - numericFields 与 maxMessageChars 走数值归一,maxMessageChars 低于 200 视为无效并回落默认值;
35
+ * - groupAutoApprove 仅在显式写入时按布尔解释;
36
+ * - stringFields 只接受非空字符串,空串/非字符串一律忽略(保留构造期配置)。
37
+ *
38
+ * @param {object} nodeConfig 会话桥的 config 对象(就地修改)
39
+ * @param {object} cfg config.json 里该平台的持久化配置
40
+ * @param {object} [opts]
41
+ * @param {string[]} [opts.numericFields] 需要数值归一化的字段名
42
+ * @param {string[]} [opts.stringFields] 需要字符串透传的字段名
43
+ * @param {number} [opts.defaultMaxMessageChars] maxMessageChars 无效时的默认值
44
+ * @returns {object} 同一个 nodeConfig(便于串联)
45
+ */
46
+ export function applyRestoredPlatformConfig(nodeConfig, cfg, {
47
+ numericFields = [],
48
+ stringFields = [],
49
+ defaultMaxMessageChars = 2000,
50
+ } = {}) {
51
+ if (!nodeConfig || !cfg) return nodeConfig
52
+
53
+ nodeConfig.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : []
54
+ for (const field of numericFields) {
55
+ if (cfg[field] != null) nodeConfig[field] = Number(cfg[field])
56
+ }
57
+ if (cfg.maxMessageChars != null) {
58
+ const val = Number(cfg.maxMessageChars)
59
+ nodeConfig.maxMessageChars = (val >= 200) ? val : defaultMaxMessageChars
60
+ }
61
+ if (cfg.groupAutoApprove != null) nodeConfig.groupAutoApprove = cfg.groupAutoApprove === true
62
+
63
+ for (const field of stringFields) {
64
+ const val = cfg[field]
65
+ // 只接受非空(且非纯空白)字符串:空串/垃圾值不应清掉或污染构造期配置
66
+ if (typeof val === 'string' && val.trim().length > 0) nodeConfig[field] = val
67
+ }
68
+ return nodeConfig
69
+ }