sintbot 1.0.13 → 1.0.14

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/CHANGELOG.en.json CHANGED
@@ -1,4 +1,23 @@
1
1
  [
2
+ {
3
+ "version": "1.0.14",
4
+ "date": "2026-10-30",
5
+ "name": "Steadier service installs and better console controls",
6
+ "items": [
7
+ {
8
+ "t": "More reliable service reinstalls",
9
+ "d": "A reinstall now waits for the old background service to fully unload before starting the new one, reducing cases where the service is left unloaded."
10
+ },
11
+ {
12
+ "t": "Software updates from the console",
13
+ "d": "Stable releases are checked at startup and daily, with a manual check available too. An administrator confirms an upgrade, which waits for every group task and queue to clear before installing the exact version."
14
+ },
15
+ {
16
+ "t": "Manage console access in Settings",
17
+ "d": "Set a password for LAN access and toggle LAN binding or public Feishu sign-in. Turning off public sign-in revokes existing Feishu sessions; a failed binding change restores the previous settings."
18
+ }
19
+ ]
20
+ },
2
21
  {
3
22
  "version": "1.0.13",
4
23
  "name": "An even shorter install: almost nothing to answer",
package/CHANGELOG.json CHANGED
@@ -1,4 +1,23 @@
1
1
  [
2
+ {
3
+ "version": "1.0.14",
4
+ "date": "2026-10-30",
5
+ "name": "服务重装与控制台管理更完善",
6
+ "items": [
7
+ {
8
+ "t": "服务重装更稳定",
9
+ "d": "重装时会等旧后台服务彻底退出后再启动,减少服务卡在未加载状态的情况。"
10
+ },
11
+ {
12
+ "t": "控制台可管理软件更新",
13
+ "d": "启动时及每天自动检查稳定版,也可手动检查。发现新版后由管理员确认,等所有群任务和队列空闲后再安装指定版本。"
14
+ },
15
+ {
16
+ "t": "访问地址可在控制台管理",
17
+ "d": "可以设置局域网访问密码、切换局域网监听和公网飞书登录。关闭公网登录会让现有飞书登录失效;切换监听失败时会恢复原设置。"
18
+ }
19
+ ]
20
+ },
2
21
  {
3
22
  "version": "1.0.13",
4
23
  "date": "2026-10-28",
package/bin/sbot.js CHANGED
@@ -61,6 +61,7 @@ const feishulogin = require('../src/ui/feishulogin') // 公网地址归一(ui.pu
61
61
  const { buildTakeoverCard } = require('../src/platform/takeovercard') // 「接管浏览器」的群内卡片(四态:等人来 / 接管中 / 已结束 / 已过期)
62
62
  const cdp = require('../src/cdp') // 接管卡片上的页面截图:CLI 直接连常驻浏览器截一张
63
63
  const { hashPassword } = require('../src/ui/auth')
64
+ const updateFeature = require('../src/updater')
64
65
 
65
66
  const dim = (s) => `\x1b[2m${s}\x1b[0m`
66
67
  const cyan = (s) => `\x1b[36m${s}\x1b[0m`
@@ -487,7 +488,7 @@ async function cmdStart(rest) {
487
488
  const plat = platformsConfigured(cfg)
488
489
  if (!plat.feishu && !plat.wecom && !plat.slack) {
489
490
  // 带 --ui(后台服务就是这么起的):只开控制台,等人在「设置」里接平台 —— 1.0.12 起没平台也装服务,装完就有地方可去
490
- if (wantUi) return serveSetupConsole(cfg, { port: Number(values['ui-port']) || 4600, host: values['ui-host'] || '127.0.0.1', noOpen })
491
+ if (wantUi) return serveSetupConsole(cfg, { port: Number(values['ui-port']) || 4600, host: (cfg.ui && cfg.ui.host) || values['ui-host'] || '127.0.0.1', noOpen })
491
492
  if (cfg.wecom && (cfg.wecom.botId || cfg.wecom.secret)) return die('sbot.config.json 的 wecom 块缺 ' + (cfg.wecom.botId ? 'secret' : 'botId') + '(机器人 API 配置页「使用长连接」处)')
492
493
  return die('没有配置任何聊天平台 — 在 sbot.config.json 里加 "feishu" 块(appId/appSecret)、"wecom" 块(botId/secret)或 "slack" 块(botToken/appToken),配几个接几个')
493
494
  }
@@ -524,6 +525,7 @@ async function cmdStart(rest) {
524
525
  const log = (m) => process.stderr.write(gray(`[${logStamp()}] · ` + m) + '\n')
525
526
  if (tightened.length) log(`状态目录权限已收紧(目录 700、文件 600):${tightened.length} 项`)
526
527
  let ui = null
528
+ let updaterManager = null
527
529
  let platform = null // set below; the UI (created first) reads WS health through this ref(多平台路由器,src/platform/multi.js)
528
530
  // 把一条消息交给引擎:**合成消息**(控制台插话 / 定时任务 / 续做 / 画布确认)没有真实适配器,按 chatId 查出它所属平台的
529
531
  // 适配器再喂 —— 引擎读的 skillCmd / features / chatLabel 是适配器上的,喂路由器会让企微群的清单按飞书口径写。
@@ -709,7 +711,7 @@ async function cmdStart(rest) {
709
711
  const reloadCfg = () => {
710
712
  try {
711
713
  const c = loadConfig(values.config); applyProxyEnv(c); engine.reload(c)
712
- if (ui) engine.publicUrl = feishulogin.normalizePublicUrl((c.ui || {}).publicUrl) // 公网地址跟着配置热更(控制台设置页改了即生效)
714
+ if (ui) engine.publicUrl = (c.ui || {}).publicEnabled === false ? null : feishulogin.normalizePublicUrl((c.ui || {}).publicUrl) // 公网入口关闭时不再向群内清单宣传
713
715
  log('config reloaded (hot)')
714
716
  } catch (e) { log('reload failed: ' + (e && e.message)) }
715
717
  }
@@ -720,7 +722,8 @@ async function cmdStart(rest) {
720
722
  if (wantUi) {
721
723
  const configPath = cfg._path || path.join(process.cwd(), 'sbot.config.json')
722
724
  const uiPort = Number(values['ui-port']) || 4600
723
- const uiHost = values['ui-host'] || '127.0.0.1'
725
+ // 控制台设置页可持久切换绑定地址;保存的 host 优先于服务单元里的旧参数。
726
+ const uiHost = (cfg.ui && cfg.ui.host) || values['ui-host'] || '127.0.0.1'
724
727
  const uiLoopback = isLoopbackHost(uiHost)
725
728
  // Advertise the console address in the agent's self-knowledge manifest (群项目基础能力).
726
729
  // For LAN binding, use the first LAN IP so group members get a reachable URL, not 127.0.0.1.
@@ -729,9 +732,20 @@ async function cmdStart(rest) {
729
732
  engine.uiLocalUrl = `http://127.0.0.1:${uiPort}`
730
733
  // 公网地址(v0.11.72 起的入口):群里 /help 与清单给人的是它,不是局域网 IP —— 出了局域网 LAN 地址
731
734
  // 打不开、还要密码,而公网入口只认飞书登录。归一判据与控制台「飞书登录」就绪判据同源(feishulogin)。
732
- engine.publicUrl = feishulogin.normalizePublicUrl((cfg.ui || {}).publicUrl)
735
+ engine.publicUrl = (cfg.ui || {}).publicEnabled === false ? null : feishulogin.normalizePublicUrl((cfg.ui || {}).publicUrl)
733
736
  // Fail-closed: LAN exposure requires a console password (set via `sbot ui --set-password`).
734
737
  if (!uiLoopback && !((cfg.ui || {}).auth)) die(`控制台对外(--ui-host ${uiHost})需先设密码:sintbot ui --set-password`)
738
+ const service = require('../src/service')
739
+ updaterManager = updateFeature.createUpdater({
740
+ currentVersion: require('../package.json').version,
741
+ stateFile: path.join(os.homedir(), '.sbot', 'update.json'),
742
+ getLiveState: () => engine.liveState(),
743
+ fetchImpl: require('../src/core/proxy').getProxyFetch() || globalThis.fetch,
744
+ getManagedService: () => { const st = service.status(); return !!st.running && st.pid === process.pid },
745
+ restartManagedService: () => service.restartAsync(),
746
+ setAnnouncementBaseline: (version) => store.setLastVersion(version),
747
+ log,
748
+ })
735
749
  // 接管卡片翻状态:有人兑换 ⇒「正在接管中」;点「结束」/ 到时 ⇒「接管已结束」;票过期没人来 ⇒「已过期」。boardClient 在下面才定义,hook 只在运行时才调,不踩 TDZ。
736
750
  const flipTakeoverCard = (state) => (meta) => {
737
751
  if (!meta || !meta.cardId || !boardClient) return
@@ -748,6 +762,13 @@ async function cmdStart(rest) {
748
762
  getLiveState: () => engine.liveState(),
749
763
  getConnHealth: () => (platform && platform.connHealth ? platform.connHealth() : null), // 各平台连接健康度({ feishu, wecom } 字典,路由器出)
750
764
  onConfigChange: reloadCfg,
765
+ onHostChange: (boundHost, address) => {
766
+ const port = address && typeof address === 'object' ? address.port : uiPort
767
+ const loopback = isLoopbackHost(boundHost)
768
+ engine.uiUrl = `http://${loopback ? '127.0.0.1' : (lanIPv4()[0] || '127.0.0.1')}:${port}`
769
+ engine.uiLocalUrl = `http://127.0.0.1:${port}`
770
+ },
771
+ updater: updaterManager,
751
772
  control: {
752
773
  setProvider: (chatId, name) => store.setProvider(chatId, name), // 实时切模型(下一轮生效)
753
774
  // 某群**实际在用**的模型(临时切换到备用时是备用,与 /status · 账本同口径)—— 控制台改 provider 型号时判「受影响群」
@@ -803,6 +824,7 @@ async function cmdStart(rest) {
803
824
  }
804
825
  process.stderr.write('\n')
805
826
  if (!noOpen) tryOpen(url)
827
+ if (updaterManager) void updaterManager.start().catch((e) => log('update check: ' + (e && e.message)))
806
828
  })
807
829
  }
808
830
  // 配了哪个平台就建哪个适配器;路由器按群分发(连接状态事件带 platform 名,控制台一行一个平台)。
@@ -1267,9 +1289,9 @@ async function cmdService(rest) {
1267
1289
  const configPath = findConfigPath(values.config)
1268
1290
  if (!configPath) return die('没找到配置文件 —— 先跑 sintbot setup(或 --config 指定)')
1269
1291
  // 控制台对外又没设密码:daemon 一启动就会拒绝退出,装成服务就是每 15 秒拉起一次的死循环 ⇒ 装之前就拦
1270
- const uiHost = values['ui-host']
1271
1292
  let raw
1272
1293
  try { raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) } catch (e) { return die(`读不了配置 ${configPath}:${e.message}`) }
1294
+ const uiHost = values['ui-host'] || ((raw.ui || {}).host)
1273
1295
  if (uiHost && !isLoopbackHost(uiHost) && !((raw.ui || {}).auth)) return die(`控制台对外(--ui-host ${uiHost})要先设密码:sintbot ui --set-password${values.config ? ' --config ' + values.config : ''} —— 否则服务一启动就退出、被反复拉起`)
1274
1296
  // 一个聊天平台都没接也照装(1.0.12 起):服务先只开控制台,在「设置」里接好平台后自己重启连上(cmdStart 的配置模式)。
1275
1297
  // 1.0.10–1.0.11 在这里拦下(退出码 3),结果装完没有任何地方可去 —— 控制台也打不开
@@ -1974,7 +1996,7 @@ async function cmdUi(rest) {
1974
1996
 
1975
1997
  applyProxyEnv(cfg)
1976
1998
  const port = Number(values.port) || 4600
1977
- const host = values.host || '127.0.0.1'
1999
+ const host = values.host || (cfg.ui && cfg.ui.host) || '127.0.0.1'
1978
2000
  const loopback = isLoopbackHost(host)
1979
2001
  // Fail-closed: never expose the operator console to the LAN without a password.
1980
2002
  if (!loopback) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sintbot",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "Drive headless Claude Code (or Codex) from Slack / Feishu / WeCom group chats — one group, one project, swappable model brain. A standalone daemon, not a Claude Code plugin.",
5
5
  "bin": {
6
6
  "sbot": "bin/sbot.js",
package/src/service.js CHANGED
@@ -20,7 +20,7 @@
20
20
  const fs = require('node:fs')
21
21
  const os = require('node:os')
22
22
  const path = require('node:path')
23
- const { execFileSync } = require('node:child_process')
23
+ const { execFileSync, execFile } = require('node:child_process')
24
24
 
25
25
  const LABEL = 'com.sbot.daemon' // launchd 标签(与早期手写 plist 同名 ⇒ 装新版直接替换旧的)
26
26
  const UNIT = 'sbot.service' // systemd --user 单元名
@@ -176,6 +176,35 @@ function run(bin, args, opts = {}) {
176
176
 
177
177
  const uid = () => (typeof process.getuid === 'function' ? process.getuid() : 0)
178
178
 
179
+ // launchctl bootout may return before the old job has fully left the GUI domain.
180
+ // Keep the wait synchronous and strictly bounded: service install is a CLI action,
181
+ // and a failed unload must never race straight into bootstrap.
182
+ function sleepSync(ms) {
183
+ if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
184
+ }
185
+
186
+ function launchdServiceMissing(message) {
187
+ return /could not find service|service[^\n]*not found|no such service|not found in domain/i.test(String(message || ''))
188
+ }
189
+
190
+ function launchdBootstrapRace(result) {
191
+ return result && (result.code === 5 || /bootstrap failed:\s*5|input\/output error/i.test(String(result.err || '')))
192
+ }
193
+
194
+ // { ok:true, waitedMs } when unloaded; otherwise a bounded, actionable failure.
195
+ function waitForLaunchdUnload(target, { runCommand = run, wait = sleepSync, timeoutMs = 2000, intervalMs = 100 } = {}) {
196
+ const tries = Math.floor(Math.max(0, timeoutMs) / Math.max(1, intervalMs)) + 1
197
+ for (let i = 0; i < tries; i++) {
198
+ const r = runCommand('launchctl', ['print', target])
199
+ if (!r.ok) {
200
+ if (launchdServiceMissing(r.err)) return { ok: true, waitedMs: i * intervalMs }
201
+ return { ok: false, waitedMs: i * intervalMs, err: r.err || 'launchctl print 失败,无法确认服务状态' }
202
+ }
203
+ if (i < tries - 1) wait(intervalMs)
204
+ }
205
+ return { ok: false, waitedMs: (tries - 1) * intervalMs, err: `等待 launchd 卸载超时(${timeoutMs}ms),未尝试重新加载` }
206
+ }
207
+
179
208
  // 状态(只读):{ installed, running, pid, detail }
180
209
  function status({ platform = process.platform, home = os.homedir() } = {}) {
181
210
  const file = unitPath(platform, home)
@@ -197,7 +226,7 @@ function status({ platform = process.platform, home = os.homedir() } = {}) {
197
226
  }
198
227
 
199
228
  // 写单元文件(已有且内容不同 ⇒ 先备份)并加载。返回 { file, backup, steps: [{cmd, ok, err}] }
200
- function install(spec, { platform = process.platform, home = os.homedir() } = {}) {
229
+ function install(spec, { platform = process.platform, home = os.homedir(), runCommand = run, wait = sleepSync, launchdTimeoutMs = 2000, launchdPollMs = 100 } = {}) {
201
230
  const file = unitPath(platform, home)
202
231
  if (!file) throw new Error('这个平台不支持自动安装服务')
203
232
  const text = render(platform, spec)
@@ -212,10 +241,38 @@ function install(spec, { platform = process.platform, home = os.homedir() } = {}
212
241
  // 单元文件里写了环境变量的值(多半是 key)⇒ 只给自己读写;没写就是普通权限
213
242
  if (Object.keys(spec.env || {}).length) fs.chmodSync(file, 0o600)
214
243
  const steps = []
215
- const step = (bin, args, tolerate) => { const r = run(bin, args); steps.push({ cmd: [bin, ...args].join(' '), ok: r.ok || !!tolerate, err: r.ok ? '' : r.err }); return r }
244
+ const step = (bin, args, tolerate) => { const r = runCommand(bin, args); steps.push({ cmd: [bin, ...args].join(' '), ok: r.ok || !!tolerate, err: r.ok ? '' : r.err }); return r }
216
245
  if (platform === 'darwin') {
217
- step('launchctl', ['bootout', `gui/${uid()}/${LABEL}`], true) // 没加载过会报错,忽略
218
- step('launchctl', ['bootstrap', `gui/${uid()}`, file])
246
+ const target = `gui/${uid()}/${LABEL}`
247
+ const bootoutArgs = ['bootout', target]
248
+ const out = runCommand('launchctl', bootoutArgs)
249
+ const absentBefore = !out.ok && launchdServiceMissing(out.err)
250
+ steps.push({ cmd: ['launchctl', ...bootoutArgs].join(' '), ok: out.ok || absentBefore, err: out.ok || absentBefore ? '' : out.err })
251
+ // A missing job is normal on first install; any other bootout failure is not.
252
+ if (!out.ok && !absentBefore) return { file, backup, steps }
253
+ const unloaded = waitForLaunchdUnload(target, { runCommand, wait, timeoutMs: launchdTimeoutMs, intervalMs: launchdPollMs })
254
+ steps.push({ cmd: 'launchctl wait for bootout', ok: unloaded.ok, err: unloaded.ok ? '' : unloaded.err })
255
+ if (!unloaded.ok) return { file, backup, steps }
256
+
257
+ const bootstrapArgs = ['bootstrap', `gui/${uid()}`, file]
258
+ const first = runCommand('launchctl', bootstrapArgs)
259
+ if (first.ok) steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: true, err: '' })
260
+ else if (launchdBootstrapRace(first)) {
261
+ // A race may report EIO even though launchd accepted the job. Since the
262
+ // previous job was confirmed absent, a now-loaded job is the new one.
263
+ const after = runCommand('launchctl', ['print', target])
264
+ if (after.ok) steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: true, err: '' })
265
+ else if (launchdServiceMissing(after.err)) {
266
+ wait(100)
267
+ const retry = runCommand('launchctl', bootstrapArgs)
268
+ if (retry.ok) steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: true, err: '' })
269
+ else {
270
+ const afterRetry = runCommand('launchctl', ['print', target])
271
+ const loaded = afterRetry.ok
272
+ steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: loaded, err: loaded ? '' : retry.err })
273
+ }
274
+ } else steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: false, err: after.err || first.err })
275
+ } else steps.push({ cmd: ['launchctl', ...bootstrapArgs].join(' '), ok: false, err: first.err })
219
276
  } else {
220
277
  step('systemctl', ['--user', 'daemon-reload'])
221
278
  step('systemctl', ['--user', 'enable', UNIT])
@@ -230,6 +287,19 @@ function restart({ platform = process.platform } = {}) {
230
287
  return { ok: false, err: '这个平台不支持' }
231
288
  }
232
289
 
290
+ // Async form for a daemon restarting itself after an in-place package upgrade.
291
+ // systemctl restart waits for the old process to exit, so the daemon must keep
292
+ // its event loop free to handle SIGTERM while this command is in flight.
293
+ function restartAsync({ platform = process.platform } = {}) {
294
+ let bin, args
295
+ if (platform === 'darwin') { bin = 'launchctl'; args = ['kickstart', '-k', `gui/${uid()}/${LABEL}`] }
296
+ else if (platform === 'linux') { bin = 'systemctl'; args = ['--user', 'restart', UNIT] }
297
+ else return Promise.resolve({ ok: false, err: '这个平台不支持' })
298
+ return new Promise((resolve) => execFile(bin, args, { encoding: 'utf8', windowsHide: true }, (e, stdout, stderr) => {
299
+ resolve(e ? { ok: false, out: String(stdout || ''), err: String(stderr || e.message || e).trim(), code: e.code ?? e.status } : { ok: true, out: String(stdout || '') })
300
+ }))
301
+ }
302
+
233
303
  function uninstall({ platform = process.platform, home = os.homedir() } = {}) {
234
304
  const file = unitPath(platform, home)
235
305
  const steps = []
@@ -244,4 +314,4 @@ function uninstall({ platform = process.platform, home = os.homedir() } = {}) {
244
314
  return { file, removed, steps }
245
315
  }
246
316
 
247
- module.exports = { LABEL, UNIT, SUPERVISED_ENV, xmlEsc, sdQuote, buildPath, versionedPath, stableNodeBin, envRefs, serviceSpec, launchdPlist, systemdUnit, unitPath, render, windowsHint, status, install, restart, uninstall }
317
+ module.exports = { LABEL, UNIT, SUPERVISED_ENV, xmlEsc, sdQuote, buildPath, versionedPath, stableNodeBin, envRefs, serviceSpec, launchdPlist, systemdUnit, unitPath, render, windowsHint, status, install, restart, restartAsync, uninstall, launchdServiceMissing, launchdBootstrapRace, waitForLaunchdUnload }
@@ -1164,6 +1164,7 @@ function inboxHtml(){
1164
1164
  `<a class="iconbtn sm" href="/browser" target="_blank" rel="noopener" title="打开接管页" style="flex:none">${IC.ext}</a>`,
1165
1165
  (it.pageTitle||it.pageUrl)?full(esc(it.pageTitle||it.pageUrl)):'');
1166
1166
  if(it.kind==='issues')return line('反馈','--warn',`${it.count} 条反馈待处理`,go('ovIssues','滚到反馈本'));
1167
+ if(it.kind==='update')return line('更新','--ember',`发现新版 v${esc(it.to)}`,go('updateCard','打开版本更新'));
1167
1168
  if(it.kind==='leftovers')return line('进程','--warn',`${it.count} 个后台进程还在跑`,go('ovLeftovers','滚到后台进程'));
1168
1169
  if(it.kind==='preview')return line('预览','--fail','预览在服务整个项目目录',go('ovPreviews','滚到预览服务'));
1169
1170
  // 每个检查项单独一个节点:英文界面按整段文字逐个翻译
@@ -1175,7 +1176,7 @@ function inboxHtml(){
1175
1176
  <div class="card ovgroups" style="margin-bottom:22px">${items.map(row).join('')}</div>`;
1176
1177
  }
1177
1178
  function bindInbox(){
1178
- $$('#overview [data-ibgo]').forEach(b=>b.onclick=()=>{const el=$('#'+b.dataset.ibgo);if(el)el.scrollIntoView({behavior:'smooth',block:'start'});});
1179
+ $$('#overview [data-ibgo]').forEach(b=>b.onclick=()=>{if(b.dataset.ibgo==='updateCard'){$$('nav button').forEach(x=>x.classList.toggle('active',x.dataset.tab==='settings'));$$('.tab').forEach(t=>t.classList.toggle('on',t.id==='settings'));}const el=$('#'+b.dataset.ibgo);if(el)requestAnimationFrame(()=>el.scrollIntoView({behavior:'smooth',block:'start'}));});
1179
1180
  $$('#overview [data-ibdis]').forEach(b=>b.onclick=async()=>{
1180
1181
  try{const r=await api('/api/inbox/dismiss','POST',{key:b.dataset.ibdis});S.inbox=r.inbox||[];paintInbox();toast('已忽略','ok');}catch(e){toast(e.message,'err');}});
1181
1182
  }
@@ -3215,6 +3216,7 @@ async function pgSend(){
3215
3216
  /* ── settings ── */
3216
3217
  function renderSettings(){
3217
3218
  const a=S.agent,fe=S.feishu,px=S.proxy,net=S.net||{};
3219
+ const upd=S.update||null;
3218
3220
  // 项目根目录:控制台**只读**(产品决定 —— 它是围栏的策略,登上控制台就能改等于围栏只剩一次点击)。
3219
3221
  // 这里只把现状说清:每条根在不在、配置里被忽略的值、哪些群在根外;改要走安装向导或机器上的配置文件。
3220
3222
  const pri=S.projectsRootsInfo||{roots:[],dropped:[],outside:[],configured:null,fromConfig:false};
@@ -3235,6 +3237,14 @@ function renderSettings(){
3235
3237
  <div class="hint" style="margin-top:9px">${net.lan
3236
3238
  ?`<b style="color:var(--risk)">已对局域网开放</b> · ${net.authOn?'需密码':'<b style="color:var(--fail)">未设密码(异常)</b>'} · HTTP 明文,仅可信网络用(最稳走 SSH 隧道)。`
3237
3239
  :'仅本机可访问 · 要对外开放:先设访问密码,再把绑定地址改为 0.0.0.0。'}</div>
3240
+ <div class="inline" style="gap:14px;margin-top:13px;flex-wrap:wrap">
3241
+ <label class="inline" style="gap:6px;cursor:pointer"><input type="checkbox" id="netLan" ${net.lan?'checked':''} style="width:auto"/> <span class="dim">局域网访问</span></label>
3242
+ <label class="inline" style="gap:6px;cursor:pointer"><input type="checkbox" id="netPublic" ${net.publicEnabled?'checked':''} style="width:auto"/> <span class="dim">公网飞书登录</span></label>
3243
+ </div>
3244
+ ${!net.authOn?`<div class="field" style="margin:10px 0 0;max-width:320px"><label class="f">访问密码</label><input id="netPass" type="password" autocomplete="new-password" placeholder="局域网开启时设置,至少 6 位"/><div class="hint" style="margin-top:4px">只保存 scrypt 哈希;开启局域网前必填</div></div>`:''}
3245
+ <div class="hint" style="margin-top:7px">${net.publicEnabled?(net.publicReady?'公网登录已启用':'公网登录待配置'): '公网登录已关闭'}${!net.publicReady&&net.feishuLogin&&net.feishuLogin.why?` · ${esc(net.feishuLogin.why)}`:''} · 开关只控制本程序入口,不会改动反向代理或隧道。</div>
3246
+ <button class="btn primary" id="netSave" style="margin-top:12px">保存访问设置</button>
3247
+ <div class="hint" style="margin-top:7px">局域网监听按当前端口即时切换;关闭后只允许本机访问。系统防火墙可能仍会拦截。</div>
3238
3248
  ${INJECTED_TOKEN?'':'<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap"><button class="btn" id="logoutBtn" title="作废这次登录;其它设备上的登录不受影响">退出登录</button><button class="btn" id="logoutAllBtn" title="作废这台机器签发过的全部登录(所有设备都要重新登录),手机丢了用这个">退出所有设备</button></div>'}
3239
3249
  ${prSection}
3240
3250
  </div>`;
@@ -3262,6 +3272,23 @@ function renderSettings(){
3262
3272
  </div>`;
3263
3273
  const verhistCard=`<div class="card" style="grid-column:1/-1" id="verhistCard"><div class="lbl">版本历史</div>
3264
3274
  <div id="verhistBox"><div class="empty">加载中…</div></div></div>`;
3275
+ const updateJob=upd&&upd.job;
3276
+ const updateBusy=!!(updateJob&&['queued','installing','restarting','restart-needed'].includes(updateJob.state));
3277
+ const updateText=!upd?'后台版本检查不可用':updateJob?esc(updateJob.message||updateJob.state):upd.available?`检测到新版本 v${esc(upd.latestVersion)} · 当前 v${esc(upd.currentVersion)}`:upd.checkedAt?`当前为最新版本 v${esc(upd.currentVersion)}`:'尚未检查新版';
3278
+ const updateCard=`<div class="card" style="grid-column:1/-1" id="updateCard"><div class="lbl">软件更新</div>
3279
+ <div id="updateStatus" class="hint" style="margin:0 0 12px;color:var(--ink)">${updateText}</div>
3280
+ ${upd&&upd.available?`<div class="hint" style="margin:-4px 0 12px">版本差异与发布说明:<a href="https://github.com/sintlab/sintbot/releases/tag/v${encodeURIComponent(upd.latestVersion)}" target="_blank" rel="noopener" style="color:var(--ember-soft)">查看 v${esc(upd.latestVersion)} 发布说明</a></div>`:''}
3281
+ <div class="inline" style="gap:14px;flex-wrap:wrap;margin-bottom:12px">
3282
+ <span class="dim">自动检查稳定版(启动时及每天)</span>
3283
+ <label class="inline" style="gap:6px;cursor:pointer"><input type="checkbox" id="updAnnounce" ${upd&&upd.announce!==false?'checked':''} style="width:auto"/> <span class="dim">升级后向群里播报(记住选择)</span></label>
3284
+ </div>
3285
+ <div class="inline" style="gap:8px;flex-wrap:wrap">
3286
+ <button class="btn" id="updCheck" ${!upd?'disabled':''}>检查新版</button>
3287
+ <button class="btn primary" id="updInstall" ${!upd||!upd.available||updateBusy?'disabled':''}>现在升级</button>
3288
+ <button class="btn" id="updIgnore" ${!upd||!upd.available?'disabled':''}>忽略此版本</button>
3289
+ </div>
3290
+ <div class="hint" style="margin-top:10px">只安装检测到的稳定版本;所有群任务与队列清空后才会安装。需要可写的全局安装目录,不会提权;后台服务会自动重启,前台运行需手动重启。</div>
3291
+ </div>`;
3265
3292
  $('#settings').innerHTML=`<div class="grid cols2 stagger">
3266
3293
  ${netCard}
3267
3294
  <div class="card"><div class="lbl">运行限制</div>
@@ -3284,6 +3311,7 @@ function renderSettings(){
3284
3311
  ${fxFeishuCard(fe)}
3285
3312
  ${fxSlackCard(S.slack||null)}
3286
3313
  ${fxWecomCard(S.wecom||null)}
3314
+ ${updateCard}
3287
3315
  ${aboutCard}
3288
3316
  ${verhistCard}
3289
3317
  </div>`;
@@ -3291,6 +3319,33 @@ function renderSettings(){
3291
3319
  $('#bSave').onclick=async()=>{const brand=$('#bBrand').value.trim(),http=$('#pxHttp').value.trim();
3292
3320
  if(http&&!/^https?:\/\/\S+$/i.test(http)){toast('代理地址要像 http://127.0.0.1:7890','err');return;}
3293
3321
  try{await api('/api/agent','POST',{brand});S=await api('/api/proxy','POST',{enabled:!!http,http});toast('已保存','ok');renderSettings();}catch(e){toast(e.message,'err');}};
3322
+ $('#netSave').onclick=async()=>{
3323
+ const lanEnabled=$('#netLan').checked,publicEnabled=$('#netPublic').checked;
3324
+ const patch={lanEnabled,publicEnabled};
3325
+ const pw=$('#netPass');if(pw&&pw.value)patch.password=pw.value;
3326
+ if(lanEnabled&&!net.lan&&!net.authOn&&(!patch.password||patch.password.length<6)){toast('开启局域网访问前要设置至少 6 位访问密码','err');return;}
3327
+ if(lanEnabled!==!!net.lan&&!confirm(lanEnabled?'开启后,同一可信局域网内可访问登录页;继续?':'关闭后,远程设备将无法访问控制台;继续?'))return;
3328
+ if(publicEnabled!==!!net.publicEnabled&&!confirm(publicEnabled?'启用公网飞书登录入口?这不会修改反向代理或隧道。':'关闭公网入口并让现有飞书登录退出?'))return;
3329
+ const b=$('#netSave');b.disabled=true;
3330
+ try{const r=await api('/api/ui/network','POST',patch);
3331
+ if(r.rebinding){toast('访问地址正在切换;页面即将重载','ok');setTimeout(()=>location.reload(),1200);return;}
3332
+ S=r;toast('访问设置已保存','ok');renderSettings();
3333
+ }catch(e){toast(e.message,'err');b.disabled=false;}
3334
+ };
3335
+ const saveUpdatePrefs=async(patch)=>{try{S=await api('/api/ui/update-settings','POST',patch);renderSettings();}catch(e){toast(e.message,'err');}};
3336
+ $('#updAnnounce').onchange=()=>saveUpdatePrefs({announce:$('#updAnnounce').checked});
3337
+ $('#updCheck').onclick=async()=>{const b=$('#updCheck');b.disabled=true;b.textContent='检查中…';
3338
+ try{await api('/api/update/check','POST',{});S=await api('/api/state');await refreshInbox();renderSettings();renderOverview();toast('版本检查完成','ok');}
3339
+ catch(e){toast(e.message,'err');b.disabled=false;b.textContent='检查新版';}};
3340
+ $('#updInstall').onclick=async()=>{const u=S.update||{},version=u.latestVersion;if(!version)return;
3341
+ const announce=$('#updAnnounce').checked;
3342
+ if(!confirm(`将从 v${u.currentVersion} 升级到 v${version}。${announce?'安装完成后会向未暂停的群播报;':'本次不向群播报。'}所有群任务结束后才开始安装,后台服务会重启。继续?`))return;
3343
+ const b=$('#updInstall');b.disabled=true;
3344
+ try{const r=await api('/api/update/start','POST',{version,announce});S.update={...r,announce};await refreshInbox();renderSettings();renderOverview();toast('更新已排队,群任务空闲后开始','ok');}
3345
+ catch(e){toast(e.message,'err');b.disabled=false;}};
3346
+ $('#updIgnore').onclick=async()=>{const version=(S.update||{}).latestVersion;if(!version)return;
3347
+ try{const r=await api('/api/update/ignore','POST',{version});S.update={...r,announce:$('#updAnnounce').checked};await refreshInbox();renderSettings();renderOverview();toast('已忽略此版本','ok');}
3348
+ catch(e){toast(e.message,'err');}};
3294
3349
  // 只开着控制台的守护进程(还没接平台):第一次接上平台时说一句接下来会发生什么(服务托管的会自己重启连上)
3295
3350
  {const pl=S.platforms||{};if(S.setupMode&&(pl.feishu||pl.slack||pl.wecom)&&!window._sbRestartToast){window._sbRestartToast=1;toast(S.setupMode.supervised?'已接上聊天平台 —— 后台服务几秒后自动重启并连上':'已接上聊天平台 —— 在终端里重启 sintbot start --ui 连上','ok');}}
3296
3351
  bindWecomCard();
@@ -4440,6 +4495,38 @@ const I18N_EN=[
4440
4495
  [";改后即时生效,不用重启。","; changes apply immediately, no restart needed."],
4441
4496
  ["公网","Public"],
4442
4497
  ["局域网","LAN"],
4498
+ ["局域网访问","LAN access"],
4499
+ ["公网飞书登录","Public Feishu sign-in"],
4500
+ ["访问密码","Access password"],
4501
+ ["局域网开启时设置,至少 6 位","Set when enabling LAN access; at least 6 characters"],
4502
+ ["只保存 scrypt 哈希;开启局域网前必填","Only a scrypt hash is stored; required before enabling LAN access"],
4503
+ ["公网登录已启用","Public sign-in is enabled"],
4504
+ ["公网登录待配置","Public sign-in needs configuration"],
4505
+ ["公网登录已关闭","Public sign-in is off"],
4506
+ ["保存访问设置","Save access settings"],
4507
+ ["开关只控制本程序入口,不会改动反向代理或隧道。","These switches control this app only; they do not change the reverse proxy or tunnel."],
4508
+ ["局域网监听按当前端口即时切换;关闭后只允许本机访问。系统防火墙可能仍会拦截。","LAN binding changes immediately on the current port; when off, only this machine can connect. The system firewall may still block access."],
4509
+ ["软件更新","Software updates"],
4510
+ ["后台版本检查不可用","Version checks are unavailable in this console"],
4511
+ ["自动检查稳定版(启动时及每天)","Automatically checks for stable releases at startup and daily"],
4512
+ ["升级后向群里播报(记住选择)","Announce upgrades in groups (remember this choice)"],
4513
+ ["检查新版","Check for updates"],
4514
+ ["现在升级","Upgrade now"],
4515
+ ["忽略此版本","Ignore this version"],
4516
+ ["版本差异与发布说明:","Changes and release notes: "],
4517
+ ["尚未检查新版","No version check has run yet"],
4518
+ ["当前为最新版本","This is the latest version"],
4519
+ ["检测到新版本","A new version is available"],
4520
+ ["更新已排队,群任务空闲后开始","Update queued; installation will start when all group tasks are idle"],
4521
+ ["已忽略此版本","This version was ignored"],
4522
+ ["版本检查完成","Version check complete"],
4523
+ ["访问地址正在切换;页面即将重载","Changing the access address; this page will reload"],
4524
+ ["访问设置已保存","Access settings saved"],
4525
+ ["开启局域网访问前要设置至少 6 位访问密码","Set an access password of at least 6 characters before enabling LAN access"],
4526
+ ["开启后,同一可信局域网内可访问登录页;继续?","Enabling lets devices on the trusted LAN reach the sign-in page. Continue?"],
4527
+ ["关闭后,远程设备将无法访问控制台;继续?","Remote devices will lose console access. Continue?"],
4528
+ ["启用公网飞书登录入口?这不会修改反向代理或隧道。","Enable public Feishu sign-in? This will not change the reverse proxy or tunnel."],
4529
+ ["关闭公网入口并让现有飞书登录退出?","Disable public access and sign out existing Feishu sessions?"],
4443
4530
  ["访问地址","Access URLs"],
4444
4531
  ["已对外,未检测到局域网 IPv4","Open to the network, but no LAN IPv4 was detected"],
4445
4532
  ["已对局域网开放","Open on the LAN"],
@@ -5046,6 +5133,18 @@ const I18N_EN_RX=[
5046
5133
  [/^✕ 加载失败:([\s\S]+)$/,"✕ Failed to load: $1"],
5047
5134
  [/^加载失败:([\s\S]+)$/,"Failed to load: $1"],
5048
5135
  [/^登录失败 \(HTTP (\d+)\)$/,"Sign-in failed (HTTP $1)"],
5136
+ [/^检测到新版本 v([\d.]+) · 当前 v([\d.]+)$/,"New version v$1 · current v$2"],
5137
+ [/^当前为最新版本 v([\d.]+)$/,"v$1 is the latest version"],
5138
+ [/^等待所有群任务结束后更新$/,"Waiting for all group tasks to finish"],
5139
+ [/^正在检查安装位置并安装指定版本$/,"Checking the install location and installing the selected version"],
5140
+ [/^已安装,正在重启后台服务$/,"Installed; restarting the background service"],
5141
+ [/^已安装新版;后台服务未能自动重启,请手动重启$/,"The new version is installed, but the background service did not restart; restart it manually"],
5142
+ [/^已安装新版;当前不是受托管服务,请手动重启 SintBot$/,"The new version is installed; restart SintBot manually"],
5143
+ [/^更新完成并已重启$/,"Update completed and restarted"],
5144
+ [/^更新后未能自动重启,请手动重启服务$/,"The update could not restart automatically; restart the service manually"],
5145
+ [/^等待群任务结束超时;可在任务空闲后重新发起$/,"Timed out waiting for group tasks; retry when they are idle"],
5146
+ [/^当前运行的是开发目录或非全局副本;请在终端手动更新$/,"This is a development or non-global install; update it from a terminal"],
5147
+ [/^将从 v([\d.]+) 升级到 v([\d.]+)。([\s\S]*)$/,(a,from,to,what)=>`Upgrade from v${from} to v${to}. ${what.replace('所有群任务结束后才开始安装,后台服务会重启。','Installation waits until all group tasks finish, then restarts the background service.').replace('本次不向群播报。','This update will not be announced in groups.').replace('安装完成后会向未暂停的群播报;','After installation, it will be announced in active groups;')}`],
5049
5148
  ];
5050
5149
  /* I18N_DICT_END */
5051
5150
 
@@ -101,8 +101,11 @@ function failedTurns(rows, now) {
101
101
  // leftovers 残留的后台进程(同上)
102
102
  // preview 服务整个项目目录的预览(全部代码都在局域网上可读)
103
103
  // health 健康检查没通过的项(一行汇总,排最前:它是「现在就坏着」,没有发生时刻可排)
104
- function buildInbox({ ledgerRows = [], issues = null, previews = [], leftovers = null, health = null, takeovers = [], groups = {}, dismissed = new Set(), now = Date.now() } = {}) {
104
+ function buildInbox({ ledgerRows = [], issues = null, previews = [], leftovers = null, health = null, update = null, takeovers = [], groups = {}, dismissed = new Set(), now = Date.now() } = {}) {
105
105
  const items = []
106
+ if (update && update.available && update.latestVersion) {
107
+ items.push({ key: `update:${update.latestVersion}`, kind: 'update', from: update.currentVersion || '', to: update.latestVersion, ts: update.checkedAt || now, dismissable: true })
108
+ }
106
109
  for (const f of failedTurns(ledgerRows, now)) {
107
110
  items.push({ key: `failed:${f.chatId}:${f.ts}`, kind: 'failed', chatId: f.chatId, group: groupLabel(groups, f.chatId), ts: f.ts,
108
111
  sender: f.sender || '', text: f.text || '', err: f.err ? String(f.err) : '', dismissable: true })
package/src/ui/server.js CHANGED
@@ -32,7 +32,7 @@ const { createIssues, summarize, fingerprint } = require('../core/issues') //
32
32
  const leftover = require('../leftover') // 遗留进程:对话轮次结束后仍在跑的后台 shell —— 同一条道理,那条每 5 秒打线上接口的循环跑了 35 天就是因为没处看见(v0.11.108)
33
33
  const { withFileLock } = require('../core/store') // 反馈本改状态要与 CLI/群命令共用同一把跨进程锁
34
34
  const { redact } = require('../core/redact') // 出站脱敏:预览停止通知里含 agent 提供的 label
35
- const { verifyPassword } = require('./auth')
35
+ const { hashPassword, verifyPassword } = require('./auth')
36
36
  const feishulogin = require('./feishulogin') // 控制台「用飞书登录」:OAuth 授权码流程的纯逻辑(公网入口;只放行管理员名单)
37
37
  const { createSessionStore } = require('./sessions') // 登录会话落盘 + 30 天过期(守护进程重启不再把人登出)
38
38
  const { listWecomSessions, wecomAuthShow } = require('../platform/wecom')
@@ -208,7 +208,7 @@ function bindNoticeText(workdir, platform = 'feishu', cmd = '/help') {
208
208
  return `✅ 本群已绑定项目「${path.basename(String(workdir || '')) || '(未命名)'}」\n${platform === 'wecom' ? '@我 派单(只有 @ 我的消息我才收得到)' : '直接发消息或 @我 派单'};${cmd} 查看命令`
209
209
  }
210
210
 
211
- function createUiServer({ configPath, getLiveState = null, getConnHealth = null, onConfigChange = null, control = null, host = '127.0.0.1', log = () => {}, issuesPath = null, sessionsPath = null, auditPath = null, ledgerPath = null, inboxDismissPath = null, fetchImpl = null, cdpImpl = null, onTakeoverClaim = null, onTakeoverExpire = null, onTakeoverEnd = null, takeoverNow = null, wecomExec = null, verifyFile = null, setupMode = null } = {}) {
211
+ function createUiServer({ configPath, getLiveState = null, getConnHealth = null, onConfigChange = null, onHostChange = null, control = null, updater = null, host = '127.0.0.1', log = () => {}, issuesPath = null, sessionsPath = null, auditPath = null, ledgerPath = null, inboxDismissPath = null, fetchImpl = null, cdpImpl = null, onTakeoverClaim = null, onTakeoverExpire = null, onTakeoverEnd = null, takeoverNow = null, wecomExec = null, verifyFile = null, setupMode = null } = {}) {
212
212
  // wecomExec:企业微信命令行工具的执行器注入(只为可测;生产走默认 execFile)
213
213
  // verifyFile:凭据验证记录的落点注入(只为可测;生产 = ~/.sbot/verify.json)
214
214
  const vopts = verifyFile ? { file: verifyFile } : undefined
@@ -257,14 +257,18 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
257
257
  const publicUrl = feishulogin.normalizePublicUrl(ui.publicUrl)
258
258
  const appId = resolveRef(fe.appId), appSecret = resolveRef(fe.appSecret), admins = adminList(fe)
259
259
  const r = feishulogin.feishuLoginReadiness({ publicUrl, appId, appSecret, admins })
260
- return { ready: r.ready, why: r.why, publicUrl: publicUrl || '', redirectUri: publicUrl ? feishulogin.redirectUri(publicUrl) : '', appId, appSecret, admins }
260
+ const enabled = ui.publicEnabled !== false // 未出现这个字段的旧配置保持原行为
261
+ return { ready: enabled && r.ready, configured: r.ready, enabled, why: !enabled ? '公网登录已关闭' : r.why,
262
+ publicUrl: publicUrl || '', redirectUri: publicUrl ? feishulogin.redirectUri(publicUrl) : '', appId, appSecret, admins }
261
263
  }
262
264
  // Host 白名单:环回时只认 loopback(防 DNS-rebinding);对外(--host 非环回)额外放行私网段
263
265
  // IPv4(10./192.168./172.16-31./169.254.)。攻击者域名不匹配私网 IP 模式 → rebinding 仍挡住。
264
- const loopbackBind = /^(127\.0\.0\.1|localhost|::1)$/i.test(String(host))
265
- const HOST_OK = loopbackBind
266
+ let activeHost = String(host)
267
+ let loopbackBind = /^(127\.0\.0\.1|localhost|::1)$/i.test(activeHost)
268
+ const hostPattern = () => loopbackBind
266
269
  ? /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i
267
270
  : /^(127\.0\.0\.1|localhost|\[::1\]|10(\.\d{1,3}){3}|192\.168(\.\d{1,3}){2}|172\.(1[6-9]|2\d|3[01])(\.\d{1,3}){2}|169\.254(\.\d{1,3}){2})(:\d+)?$/i
271
+ let HOST_OK = hostPattern()
268
272
  const authed = (req, url) => { const t = req.headers['x-sbot-token'] || url.searchParams.get('token'); return (!!t && t === token) || sessions.has(t) }
269
273
  // 是否已配访问密码(配了 ⇒ 页面不发 token,走登录)。config 读坏时的裁决见 authConfiguredFrom。
270
274
  const authConfigured = () => authConfiguredFrom(() => (readRaw().ui || {}).auth, loopbackBind)
@@ -329,9 +333,11 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
329
333
  // 「需要你处理」:从 state 各区取数(反馈本 / 预览 / 残留进程 / 健康)+ 账本 + 接管票,不再各算一遍
330
334
  const buildInboxFor = ({ issues, previews, leftovers, health }, raw) => {
331
335
  try {
336
+ const update = updater ? { ...updater.status(), announce: !raw || !raw.ui || raw.ui.announceUpdates !== false } : null
332
337
  return dashboard.buildInbox({
333
338
  ledgerRows: ledgerTail.rows(),
334
339
  issues, previews, leftovers, health,
340
+ update,
335
341
  takeovers: takeoverStore.pending(),
336
342
  groups: (raw && raw.groups) || {},
337
343
  dismissed: inboxDismiss.set(),
@@ -606,14 +612,18 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
606
612
  const port = addr && typeof addr === 'object' ? addr.port : null
607
613
  return {
608
614
  lan: !loopbackBind,
615
+ host: activeHost,
609
616
  port,
610
617
  local: port ? `http://127.0.0.1:${port}` : null,
611
618
  lanUrls: (!loopbackBind && port) ? lanIPv4().map((ip) => `http://${ip}:${port}`) : [],
612
619
  authOn: authConfigured(),
613
620
  publicUrl: fli.publicUrl, // 公网地址(飞书登录回调的根;设置页可填)
621
+ publicEnabled: fli.enabled,
622
+ publicReady: fli.configured,
614
623
  feishuLogin: { ready: fli.ready, why: fli.why, redirectUri: fli.redirectUri },
615
624
  }
616
625
  })(),
626
+ update: updater ? { ...updater.status(), announce: !raw.ui || raw.ui.announceUpdates !== false } : null,
617
627
  }
618
628
  state.inbox = buildInboxFor(state, raw)
619
629
  return state
@@ -806,6 +816,31 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
806
816
  if (p === '/api/changelog' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelog() })
807
817
  // 同一份版本历史的英文本(界面切英文时才拉;缺的版本前端按 version 回退中文)
808
818
  if (p === '/api/changelog-en' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelogEn() })
819
+ if (p === '/api/update/check' && req.method === 'POST') {
820
+ if (!updater) return sendJSON(res, 503, { error: '版本检查暂不可用' })
821
+ return sendJSON(res, 200, await updater.check())
822
+ }
823
+ if (p === '/api/update/start' && req.method === 'POST') {
824
+ if (!updater) return sendJSON(res, 503, { error: '版本升级暂不可用' })
825
+ const { version, announce } = await body(req)
826
+ try { return sendJSON(res, 202, updater.startUpgrade({ version, announce })) }
827
+ catch (e) { return sendJSON(res, 409, { error: e.message, update: updater.status() }) }
828
+ }
829
+ if (p === '/api/update/ignore' && req.method === 'POST') {
830
+ if (!updater) return sendJSON(res, 503, { error: '版本升级暂不可用' })
831
+ const { version } = await body(req)
832
+ try { return sendJSON(res, 200, updater.ignore(version)) }
833
+ catch (e) { return sendJSON(res, 400, { error: e.message }) }
834
+ }
835
+ if (p === '/api/ui/update-settings' && req.method === 'POST') {
836
+ const patch = await body(req)
837
+ if (typeof patch.announce !== 'boolean') return sendJSON(res, 400, { error: '请提供升级播报偏好' })
838
+ const raw = readRaw(); raw.ui = raw.ui || {}
839
+ raw.ui.announceUpdates = patch.announce
840
+ delete raw.ui.autoUpdateCheck
841
+ writeRaw(raw)
842
+ return sendJSON(res, 200, await buildState())
843
+ }
809
844
  // codex 可用模型(codex 面板「模型」下拉):app-server model/list 实时探测(当前
810
845
  // 账号真实可用集,含各模型支持的推理强度),失败回退内置预设;探测要 spawn 进程
811
846
  // +触网 → 10 分钟缓存。
@@ -1058,6 +1093,67 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1058
1093
  if (t) { const n = feishulogin.normalizePublicUrl(t); if (!n) return sendJSON(res, 400, { error: '公网地址要写成 https://域名(可带端口),不带路径' }); raw.ui.publicUrl = n } else delete raw.ui.publicUrl
1059
1094
  writeRaw(raw); return sendJSON(res, 200, await buildState())
1060
1095
  }
1096
+ if (p === '/api/ui/network' && req.method === 'POST') {
1097
+ const patch = await body(req); const raw = readRaw(); raw.ui = raw.ui || {}
1098
+ const previousHost = activeHost
1099
+ let nextHost = activeHost
1100
+ if (typeof patch.lanEnabled === 'boolean') {
1101
+ if (patch.lanEnabled && patch.password) {
1102
+ const password = String(patch.password)
1103
+ if (password.length < 6) return sendJSON(res, 400, { error: '访问密码至少 6 位' })
1104
+ raw.ui.auth = hashPassword(password)
1105
+ }
1106
+ if (patch.lanEnabled && !raw.ui.auth) return sendJSON(res, 400, { error: '开启局域网访问前要先设置访问密码' })
1107
+ if (patch.lanEnabled !== !loopbackBind) {
1108
+ nextHost = patch.lanEnabled ? '0.0.0.0' : '127.0.0.1'
1109
+ raw.ui.host = nextHost
1110
+ }
1111
+ }
1112
+ if (typeof patch.publicEnabled === 'boolean') {
1113
+ const wasEnabled = raw.ui.publicEnabled !== false
1114
+ if (patch.publicEnabled) {
1115
+ const fe = raw.feishu || {}; const publicUrl = feishulogin.normalizePublicUrl(raw.ui.publicUrl)
1116
+ const ready = feishulogin.feishuLoginReadiness({ publicUrl, appId: resolveRef(fe.appId), appSecret: resolveRef(fe.appSecret), admins: adminList(fe) })
1117
+ if (!ready.ready) return sendJSON(res, 400, { error: `公网登录尚未就绪:${ready.why}` })
1118
+ }
1119
+ raw.ui.publicEnabled = patch.publicEnabled
1120
+ if (wasEnabled && !patch.publicEnabled) {
1121
+ const n = sessions.revokeWhere((session) => session && session.via === 'feishu')
1122
+ if (n) audit.append({ kind: 'ui-public-access-revoked', sessions: n, ts: Date.now() })
1123
+ }
1124
+ }
1125
+ writeRaw(raw)
1126
+ audit.append({ kind: 'ui-network-settings', lan: nextHost !== '127.0.0.1', publicEnabled: raw.ui.publicEnabled !== false,
1127
+ hostChanged: nextHost !== previousHost, ts: Date.now() })
1128
+ if (nextHost !== previousHost) {
1129
+ const responseSocket = res.socket
1130
+ let rebindStarted = false
1131
+ const startRebind = () => {
1132
+ if (rebindStarted) return
1133
+ rebindStarted = true
1134
+ clearTimeout(rebindFallback)
1135
+ setImmediate(() => rebindHost(nextHost).catch((e) => {
1136
+ try {
1137
+ const latest = readRaw(); latest.ui = latest.ui || {}; latest.ui.host = previousHost
1138
+ writeRaw(latest)
1139
+ } catch (rollbackErr) { log('ui host rollback failed: ' + (rollbackErr && rollbackErr.message)) }
1140
+ audit.append({ kind: 'ui-network-rebind', ok: false, from: nextHost, to: previousHost, ts: Date.now() })
1141
+ log('ui host change failed: ' + (e && e.message))
1142
+ }))
1143
+ }
1144
+ // Wait for the switching response's connection to close before closing
1145
+ // the listener; a fixed delay can still reset the response under load.
1146
+ const rebindFallback = setTimeout(startRebind, 1000)
1147
+ if (rebindFallback.unref) rebindFallback.unref()
1148
+ if (responseSocket && !responseSocket.destroyed) responseSocket.once('close', startRebind)
1149
+ res.writeHead(202, { 'Content-Type': 'application/json; charset=utf-8', Connection: 'close' })
1150
+ res.end(JSON.stringify({ ok: true, rebinding: true, lan: nextHost !== '127.0.0.1' }), () => {
1151
+ if (!responseSocket || responseSocket.destroyed) startRebind()
1152
+ })
1153
+ return
1154
+ }
1155
+ return sendJSON(res, 200, await buildState())
1156
+ }
1061
1157
  if (p === '/api/feishu' && req.method === 'POST') {
1062
1158
  const { appId, appSecret, botOpenId, adminOpenId, adminName, onlyWhenMentioned } = await body(req); const raw = readRaw()
1063
1159
  raw.feishu = raw.feishu || { onlyWhenMentioned: false }
@@ -2152,6 +2248,40 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2152
2248
  }
2153
2249
  })
2154
2250
 
2251
+ function setActiveHost(value) {
2252
+ const changed = activeHost !== String(value)
2253
+ activeHost = String(value)
2254
+ loopbackBind = /^(127\.0\.0\.1|localhost|::1)$/i.test(activeHost)
2255
+ HOST_OK = hostPattern()
2256
+ if (changed && onHostChange) { try { onHostChange(activeHost, server.address()) } catch (e) { log('ui host callback: ' + (e && e.message)) } }
2257
+ }
2258
+ function listenAgain(bindHost, port) {
2259
+ return new Promise((resolve, reject) => {
2260
+ const onError = (e) => { server.removeListener('listening', onListening); reject(e) }
2261
+ const onListening = () => { server.removeListener('error', onError); setActiveHost(bindHost); resolve() }
2262
+ server.once('error', onError)
2263
+ server.once('listening', onListening)
2264
+ server.listen(port, bindHost)
2265
+ })
2266
+ }
2267
+ async function rebindHost(nextHost) {
2268
+ if (!['127.0.0.1', '0.0.0.0'].includes(nextHost)) throw new Error('只支持本机或全部局域网接口')
2269
+ const address = server.address()
2270
+ if (!address || typeof address !== 'object') throw new Error('控制台尚未监听端口')
2271
+ const oldHost = activeHost
2272
+ if (nextHost === oldHost) return
2273
+ for (const client of sseClients) { try { client.end() } catch {} }
2274
+ sseClients.clear()
2275
+ const stopped = new Promise((resolve, reject) => server.close((e) => e ? reject(e) : resolve()))
2276
+ if (typeof server.closeAllConnections === 'function') server.closeAllConnections()
2277
+ await stopped
2278
+ try { await listenAgain(nextHost, address.port) }
2279
+ catch (e) {
2280
+ try { await listenAgain(oldHost, address.port) } catch (restoreErr) { log('ui host restore listen failed: ' + (restoreErr && restoreErr.message)) }
2281
+ throw e
2282
+ }
2283
+ }
2284
+
2155
2285
  // Broadcast live state + heartbeat over SSE (cheap; no health probe here).
2156
2286
  const tick = setInterval(() => {
2157
2287
  if (!sseClients.size) return
@@ -2164,6 +2294,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2164
2294
  return {
2165
2295
  server,
2166
2296
  listen(port, host, cb) { server.listen(port, host, cb) },
2297
+ rebind: rebindHost,
2167
2298
  // let a daemon push turn events into the console's activity feed
2168
2299
  pushEvent(type, data) {
2169
2300
  if (type === 'turn') recordActivity(data) // also buffer it for replay (下一步 ②)
@@ -2175,7 +2306,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2175
2306
  }
2176
2307
  for (const c of sseClients) { try { c.write(msg) } catch { sseClients.delete(c) } }
2177
2308
  },
2178
- close() { clearInterval(tick); for (const c of sseClients) { try { c.end() } catch {} } try { browserRoutes.close() } catch {} server.close() },
2309
+ close() { clearInterval(tick); for (const c of sseClients) { try { c.end() } catch {} } try { browserRoutes.close() } catch {} server.close(); if (typeof server.closeAllConnections === 'function') server.closeAllConnections(); if (updater) updater.close() },
2179
2310
  _browserRoutes: browserRoutes, // 测试探 CDP 会话注册表用
2180
2311
  }
2181
2312
  }
package/src/updater.js ADDED
@@ -0,0 +1,243 @@
1
+ // Stable-version checks and guarded in-place upgrades for the global package.
2
+ // Checking is read-only. Installation is explicit, exact-version only, waits for
3
+ // every group queue to drain, and never escalates privileges.
4
+
5
+ const fs = require('node:fs')
6
+ const path = require('node:path')
7
+ const { execFile } = require('node:child_process')
8
+ const { promisify } = require('node:util')
9
+ const { atomicWriteJSON } = require('./core/store')
10
+
11
+ const execFileAsync = promisify(execFile)
12
+ const PACKAGE = 'sintbot'
13
+ const REGISTRIES = [
14
+ { url: 'https://registry.npmjs.org/sintbot/latest', source: 'npm' },
15
+ { url: 'https://registry.npmmirror.com/sintbot/latest', source: 'mirror' },
16
+ ]
17
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
18
+ const CHECK_TIMEOUT_MS = 10_000
19
+ const QUEUE_TIMEOUT_MS = 24 * 60 * 60 * 1000
20
+
21
+ function stableVersion(value) {
22
+ const s = String(value || '').trim().replace(/^v/, '')
23
+ return /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(s) ? s : null
24
+ }
25
+
26
+ function compareVersion(a, b) {
27
+ const av = stableVersion(a); const bv = stableVersion(b)
28
+ if (!av || !bv) return 0
29
+ const aa = av.split('.').map(BigInt); const bb = bv.split('.').map(BigInt)
30
+ for (let i = 0; i < 3; i++) if (aa[i] !== bb[i]) return aa[i] > bb[i] ? 1 : -1
31
+ return 0
32
+ }
33
+
34
+ function hasPendingWork(live) {
35
+ // Updating while the engine is missing or its snapshot is incomplete is unsafe:
36
+ // a busy group could be hidden by a failed state read. Treat unknown as busy.
37
+ if (!live || live.running !== true || !Number.isFinite(Number(live.queue)) || !live.groups || typeof live.groups !== 'object') return true
38
+ if (Number(live.queue) > 0) return true
39
+ return Object.values(live.groups).some((g) => !g || g.busy || !Number.isFinite(Number(g.queue)) || Number(g.queue) > 0)
40
+ }
41
+
42
+ async function fetchVersion({ fetchImpl = globalThis.fetch, url, timeoutMs = CHECK_TIMEOUT_MS } = {}) {
43
+ const ac = new AbortController()
44
+ const timer = setTimeout(() => ac.abort(), timeoutMs)
45
+ try {
46
+ const res = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: ac.signal, redirect: 'error' })
47
+ if (!res || !res.ok) throw new Error(`HTTP ${res && res.status || 'error'}`)
48
+ const data = await res.json()
49
+ const version = stableVersion(data && (data.version || (data['dist-tags'] && data['dist-tags'].latest)))
50
+ if (!version) throw new Error('registry returned no stable version')
51
+ return version
52
+ } finally { clearTimeout(timer) }
53
+ }
54
+
55
+ function readState(file) {
56
+ try {
57
+ const v = JSON.parse(fs.readFileSync(file, 'utf8'))
58
+ return v && typeof v === 'object' ? v : {}
59
+ } catch { return {} }
60
+ }
61
+
62
+ function createUpdater({
63
+ currentVersion,
64
+ stateFile,
65
+ getLiveState = () => null,
66
+ fetchImpl = globalThis.fetch,
67
+ registries = REGISTRIES,
68
+ now = Date.now,
69
+ waitMs = 5000,
70
+ queueTimeoutMs = QUEUE_TIMEOUT_MS,
71
+ checkIntervalMs = CHECK_INTERVAL_MS,
72
+ isGlobalInstall = defaultGlobalInstall,
73
+ installExact = defaultInstallExact,
74
+ readInstalledVersion = defaultReadInstalledVersion,
75
+ getManagedService = () => false,
76
+ restartManagedService = async () => ({ ok: false }),
77
+ setAnnouncementBaseline = () => {},
78
+ log = () => {},
79
+ } = {}) {
80
+ const file = stateFile || path.join(require('node:os').homedir(), '.sbot', 'update.json')
81
+ const current = stableVersion(currentVersion) || '0.0.0'
82
+ let data = readState(file)
83
+ let queueTimer = null
84
+ let checking = null
85
+ let closed = false
86
+
87
+ // If a previous daemon was replaced during the restart step, the new package
88
+ // version is the evidence that installation and restart both completed.
89
+ if (data.job && data.job.state === 'restarting') {
90
+ data.job = data.job.to === current
91
+ ? { ...data.job, state: 'done', message: '更新完成并已重启', updatedAt: now() }
92
+ : { ...data.job, state: 'failed', message: '更新后未能自动重启,请手动重启服务', updatedAt: now() }
93
+ persist()
94
+ }
95
+
96
+ function persist() { try { atomicWriteJSON(file, data, { mode: 0o600 }) } catch (e) { log('update state: ' + (e && e.message)) } }
97
+ function status() {
98
+ const latest = stableVersion(data.latestVersion)
99
+ return {
100
+ currentVersion: current,
101
+ latestVersion: latest,
102
+ checkedAt: Number(data.checkedAt) || null,
103
+ source: data.source || '',
104
+ ignoredVersion: data.ignoredVersion || '',
105
+ available: !!latest && compareVersion(latest, current) > 0 && data.ignoredVersion !== latest,
106
+ job: data.job ? { ...data.job } : null,
107
+ }
108
+ }
109
+
110
+ async function check() {
111
+ if (closed) return status()
112
+ if (checking) return checking
113
+ checking = (async () => {
114
+ let lastError = null
115
+ for (const r of registries) {
116
+ try {
117
+ const version = await fetchVersion({ fetchImpl, url: r.url })
118
+ data.latestVersion = version
119
+ data.checkedAt = now()
120
+ data.source = r.source || ''
121
+ if (data.ignoredVersion && data.ignoredVersion !== version) data.ignoredVersion = ''
122
+ persist()
123
+ return status()
124
+ } catch (e) { lastError = e }
125
+ }
126
+ // Network errors are deliberately quiet and never erase the last success.
127
+ if (lastError) log('update check unavailable: ' + (lastError && lastError.message || 'network error'))
128
+ return status()
129
+ })().finally(() => { checking = null })
130
+ return checking
131
+ }
132
+
133
+ function ignore(version) {
134
+ const v = stableVersion(version)
135
+ if (!v || v !== stableVersion(data.latestVersion)) throw new Error('只能忽略当前检测到的版本')
136
+ data.ignoredVersion = v
137
+ persist()
138
+ return status()
139
+ }
140
+
141
+ function startUpgrade({ version, announce = true } = {}) {
142
+ const v = stableVersion(version)
143
+ if (!v || v !== stableVersion(data.latestVersion) || compareVersion(v, current) <= 0) throw new Error('版本信息已变化,请先重新检查')
144
+ if (data.job && ['queued', 'installing', 'restarting', 'restart-needed'].includes(data.job.state)) throw new Error('已有更新需要完成或重启后再试')
145
+ data.job = { id: `${now()}-${Math.random().toString(36).slice(2, 8)}`, state: 'queued', from: current, to: v,
146
+ announce: announce !== false, createdAt: now(), updatedAt: now(), message: '等待所有群任务结束后更新' }
147
+ persist()
148
+ void processQueue()
149
+ return status()
150
+ }
151
+
152
+ function scheduleQueueCheck() {
153
+ if (closed || queueTimer || !data.job || data.job.state !== 'queued') return
154
+ queueTimer = setTimeout(() => { queueTimer = null; void processQueue() }, waitMs)
155
+ if (queueTimer.unref) queueTimer.unref()
156
+ }
157
+
158
+ async function processQueue() {
159
+ const job = data.job
160
+ if (closed || !job || job.state !== 'queued') return
161
+ if (now() - job.createdAt > queueTimeoutMs) {
162
+ data.job = { ...job, state: 'failed', message: '等待群任务结束超时;可在任务空闲后重新发起', updatedAt: now() }
163
+ persist(); return
164
+ }
165
+ let live
166
+ try { live = getLiveState() }
167
+ catch (e) { log('update queue: live state unavailable: ' + (e && e.message)); return scheduleQueueCheck() }
168
+ if (hasPendingWork(live)) return scheduleQueueCheck()
169
+ if (queueTimer) { clearInterval(queueTimer); queueTimer = null }
170
+ data.job = { ...job, state: 'installing', message: '正在检查安装位置并安装指定版本', updatedAt: now() }
171
+ persist()
172
+ try {
173
+ const checkResult = await isGlobalInstall()
174
+ if (!checkResult || !checkResult.ok) throw new Error((checkResult && checkResult.reason) || '当前运行位置不是可写的全局安装')
175
+ const result = await installExact(PACKAGE, job.to, checkResult)
176
+ if (!result || result.ok === false) throw new Error((result && result.reason) || '安装失败;检查包管理器日志后可手动重试')
177
+ const installed = await readInstalledVersion(checkResult)
178
+ if (stableVersion(installed) !== job.to) throw new Error('安装完成后的版本核对不符;请手动检查后重启')
179
+ if (!job.announce) { try { setAnnouncementBaseline(job.to) } catch (e) { log('update announcement baseline: ' + (e && e.message)) } }
180
+ const managed = await getManagedService()
181
+ if (managed) {
182
+ data.job = { ...job, state: 'restarting', message: '已安装,正在重启后台服务', updatedAt: now() }
183
+ persist()
184
+ const restarted = await restartManagedService()
185
+ if (restarted && restarted.ok) return status()
186
+ data.job = { ...job, state: 'restart-needed', message: '已安装新版;后台服务未能自动重启,请手动重启', updatedAt: now() }
187
+ } else {
188
+ data.job = { ...job, state: 'restart-needed', message: '已安装新版;当前不是受托管服务,请手动重启 SintBot', updatedAt: now() }
189
+ }
190
+ persist()
191
+ } catch (e) {
192
+ data.job = { ...job, state: 'failed', message: String(e && e.message || e).slice(0, 300), updatedAt: now() }
193
+ persist()
194
+ }
195
+ return status()
196
+ }
197
+
198
+ if (data.job && data.job.state === 'queued') void processQueue()
199
+ const checkTimer = setInterval(() => { void check() }, checkIntervalMs)
200
+ if (checkTimer.unref) checkTimer.unref()
201
+
202
+ return {
203
+ status,
204
+ check,
205
+ ignore,
206
+ startUpgrade,
207
+ start() { return check() },
208
+ close() { closed = true; clearInterval(checkTimer); if (queueTimer) clearTimeout(queueTimer); queueTimer = null },
209
+ }
210
+ }
211
+
212
+ function defaultGlobalInstall() {
213
+ const { execFileSync } = require('node:child_process')
214
+ const rootOut = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] }).trim()
215
+ const globalRoot = fs.realpathSync(rootOut)
216
+ const globalPackage = path.join(globalRoot, PACKAGE)
217
+ const runningPackage = fs.realpathSync(path.resolve(__dirname, '..'))
218
+ if (runningPackage !== fs.realpathSync(globalPackage)) return { ok: false, reason: '当前运行的是开发目录或非全局副本;请在终端手动更新' }
219
+ fs.accessSync(globalRoot, fs.constants.W_OK | fs.constants.X_OK)
220
+ fs.accessSync(path.dirname(globalPackage), fs.constants.W_OK | fs.constants.X_OK)
221
+ return { ok: true, globalRoot, globalPackage }
222
+ }
223
+
224
+ async function defaultInstallExact(name, version) {
225
+ const env = {}
226
+ const allowed = ['PATH', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'TEMP', 'TMP', 'SystemRoot', 'PATHEXT',
227
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy', 'LANG', 'LC_ALL']
228
+ for (const k of allowed) if (process.env[k] !== undefined) env[k] = process.env[k]
229
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
230
+ try {
231
+ await execFileAsync(npm, ['install', '--global', `${name}@${version}`, '--omit=dev', '--no-audit', '--no-fund'],
232
+ { timeout: 10 * 60 * 1000, maxBuffer: 2 * 1024 * 1024, windowsHide: true, env })
233
+ return { ok: true }
234
+ } catch (e) {
235
+ return { ok: false, reason: `安装失败(退出码 ${e && e.code != null ? e.code : '未知'});未提权,也未改用其他版本` }
236
+ }
237
+ }
238
+
239
+ function defaultReadInstalledVersion(info) {
240
+ try { return JSON.parse(fs.readFileSync(path.join(info.globalPackage, 'package.json'), 'utf8')).version || null } catch { return null }
241
+ }
242
+
243
+ module.exports = { PACKAGE, REGISTRIES, CHECK_INTERVAL_MS, stableVersion, compareVersion, hasPendingWork, fetchVersion, createUpdater, defaultGlobalInstall, defaultInstallExact, defaultReadInstalledVersion }