sintbot 1.0.16 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.en.json +99 -0
  2. package/CHANGELOG.json +99 -0
  3. package/README.md +20 -158
  4. package/README.zh-CN.md +20 -166
  5. package/THIRD-PARTY-NOTICES.md +394 -0
  6. package/bin/sintbot-v2.js +363 -0
  7. package/docs/AI-ASSISTED-INSTALL.md +11 -35
  8. package/docs/install.md +61 -167
  9. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +11 -35
  10. package/docs/zh-CN/install.md +61 -165
  11. package/install.sh +84 -0
  12. package/package.json +73 -37
  13. package/src/agent/claudecode.js +149 -108
  14. package/src/agent/codexapp.js +458 -0
  15. package/src/agent/codexcode.js +210 -107
  16. package/src/agent/errtag.js +34 -0
  17. package/src/agent/privatecore.js +790 -0
  18. package/src/agent/privatecore.wasm +0 -0
  19. package/src/agent/runner.js +10 -8
  20. package/src/agent/winexec.js +35 -5
  21. package/src/c2install.js +117 -0
  22. package/src/config.js +2 -1
  23. package/src/core/c2cardeffectbridge.js +51 -0
  24. package/src/core/c2cardterminalpath.js +216 -0
  25. package/src/core/c2durableingress.js +148 -0
  26. package/src/core/c2durablejournal.js +1560 -0
  27. package/src/core/c2durableruntime.js +2361 -0
  28. package/src/core/c2eventidentity.js +90 -0
  29. package/src/core/c2globalfault.js +401 -0
  30. package/src/core/c2localeffectexecutor.js +193 -0
  31. package/src/core/c2payloadhandles.js +333 -0
  32. package/src/core/c2persistrelation.js +179 -0
  33. package/src/core/c2persiststate.js +342 -0
  34. package/src/core/c2platformrecoverysender.js +76 -0
  35. package/src/core/c2recoverygate.js +385 -0
  36. package/src/core/c2runnerinput.js +63 -0
  37. package/src/core/c2servicestartgate.js +103 -0
  38. package/src/core/c2sessionbridge.js +72 -0
  39. package/src/core/c2sourcequarantine.js +30 -0
  40. package/src/core/c2startuprecovery.js +105 -0
  41. package/src/core/c2successorbridge.js +151 -0
  42. package/src/core/c2terminaleffectbridge.js +154 -0
  43. package/src/core/c2terminalprune.js +108 -0
  44. package/src/core/c2turnprojection.js +177 -0
  45. package/src/core/enginecheckpoint.js +211 -0
  46. package/src/core/engineeffectcursor.js +394 -0
  47. package/src/core/enginestepcoordinator.js +183 -0
  48. package/src/core/store.js +52 -8
  49. package/src/core/v2engine.js +1469 -0
  50. package/src/platform/feishu.js +324 -67
  51. package/src/plugins.js +1 -9
  52. package/assets/design-styles/acid-pop.md +0 -155
  53. package/assets/design-styles/archive-index.md +0 -161
  54. package/assets/design-styles/art-deco.md +0 -164
  55. package/assets/design-styles/cinematic-noir.md +0 -178
  56. package/assets/design-styles/editorial.md +0 -121
  57. package/assets/design-styles/extra/README.md +0 -23
  58. package/assets/design-styles/extra/constructivism.md +0 -146
  59. package/assets/design-styles/extra/neo-brutalism.md +0 -175
  60. package/assets/design-styles/extra/new-wave.md +0 -166
  61. package/assets/design-styles/extra/nordic-material.md +0 -162
  62. package/assets/design-styles/extra/paris-couture.md +0 -162
  63. package/assets/design-styles/extra/tropical-modern.md +0 -168
  64. package/assets/design-styles/extra/ulm-functional.md +0 -182
  65. package/assets/design-styles/extra/wa-shikimen.md +0 -160
  66. package/assets/design-styles/extra/y2k-chrome.md +0 -172
  67. package/assets/design-styles/geometric-pop.md +0 -122
  68. package/assets/design-styles/hand-drawn.md +0 -117
  69. package/assets/design-styles/islamic-geometry.md +0 -155
  70. package/assets/design-styles/liquid-glass.md +0 -183
  71. package/assets/design-styles/neo-chinese.md +0 -136
  72. package/assets/design-styles/precision-mono.md +0 -156
  73. package/assets/design-styles/quiet-luxury.md +0 -163
  74. package/assets/design-styles/riso-print.md +0 -116
  75. package/assets/design-styles/soft-friendly.md +0 -174
  76. package/assets/design-styles/swiss-grid.md +0 -156
  77. package/assets/design-styles/tech-dark.md +0 -182
  78. package/assets/favicon.svg +0 -6
  79. package/assets/logo.svg +0 -24
  80. package/bin/sbot.js +0 -4716
  81. package/docs/slack-app-manifest.yaml +0 -104
  82. package/sbot.config.example.json +0 -235
  83. package/src/agent/askonce.js +0 -76
  84. package/src/agent/claudemodels.js +0 -60
  85. package/src/agent/claudestatus.js +0 -74
  86. package/src/agent/claudeusage.js +0 -200
  87. package/src/agent/codexstatus.js +0 -181
  88. package/src/agent/imagen.js +0 -458
  89. package/src/agent/quotaerr.js +0 -44
  90. package/src/agent/transienterr.js +0 -32
  91. package/src/cdp.js +0 -254
  92. package/src/cli-secret.js +0 -36
  93. package/src/clilang.js +0 -31
  94. package/src/core/cron.js +0 -182
  95. package/src/core/cronfire.js +0 -120
  96. package/src/core/cronrun.js +0 -116
  97. package/src/core/engine.js +0 -2758
  98. package/src/core/interfaces.js +0 -114
  99. package/src/core/issues.js +0 -185
  100. package/src/core/ledger.js +0 -30
  101. package/src/core/opsalert.js +0 -214
  102. package/src/core/pending.js +0 -123
  103. package/src/core/proxy.js +0 -424
  104. package/src/core/quotaalert.js +0 -79
  105. package/src/core/quotaerr.js +0 -55
  106. package/src/core/scheduler.js +0 -59
  107. package/src/design.js +0 -5161
  108. package/src/gitrepo.js +0 -150
  109. package/src/health.js +0 -353
  110. package/src/installdeps.js +0 -72
  111. package/src/leftover.js +0 -174
  112. package/src/platform/boardnotice.js +0 -77
  113. package/src/platform/boardops.js +0 -36
  114. package/src/platform/boardwatch.js +0 -447
  115. package/src/platform/cli.js +0 -45
  116. package/src/platform/slack.js +0 -1289
  117. package/src/platform/takeovercard.js +0 -37
  118. package/src/platform/wecom.js +0 -938
  119. package/src/platform/wecomcli.js +0 -128
  120. package/src/platform/wecomgate.js +0 -108
  121. package/src/platform/wecomname.js +0 -43
  122. package/src/platform/wecompoll.js +0 -241
  123. package/src/preview.js +0 -581
  124. package/src/projectmemory.js +0 -175
  125. package/src/service.js +0 -317
  126. package/src/ui/auth.js +0 -31
  127. package/src/ui/browser.html +0 -392
  128. package/src/ui/browserroutes.js +0 -234
  129. package/src/ui/console.html +0 -5526
  130. package/src/ui/dashboard.js +0 -168
  131. package/src/ui/feishulogin.js +0 -136
  132. package/src/ui/server.js +0 -2314
  133. package/src/ui/sessions.js +0 -78
  134. package/src/ui/takeover.js +0 -108
  135. package/src/updater.js +0 -243
  136. package/src/upgrade.js +0 -35
  137. package/src/usage.js +0 -144
@@ -1,175 +0,0 @@
1
- // 项目长期记忆与约定文件的探测(跨模型记忆交接)。
2
- //
3
- // 由来:一个群换了模型大脑之后,上一任攒下的东西并不跟着走。⭐ 但**缺的不是能力,是可达** ——
4
- // 群都是完全访问(codex 侧 = danger-full-access),那些 markdown 它本来就读得到;只是没有任何
5
- // 地方告诉过它「有这东西、在哪、什么规矩」。所以正解落在**自知清单**上,不是搬运数据、
6
- // 不是第三本账、也不是切换那一刻补一轮握手 —— 交接该是持续发生的。
7
- //
8
- // 已验死的事实:
9
- // · codex 运行时**不读 CLAUDE.md**(二进制里 AGENTS.md 出现 77 次,CLAUDE.md 只出现在
10
- // 「从 Claude Code 一次性导入」的向导串里)⇒ claude 群里天天更新的那份约定,换成 codex
11
- // 就等于不存在。反过来 claude 不读 AGENTS.md。⇒ **约定文件那一行**无条件点名存在的每一份,
12
- // 对本来就会自动加载的那一方是无害冗余,对另一方是救命。
13
- // · 私有记忆目录 = <配置目录>/projects/<slug>/memory。slug 规则见 memorySlug —— **从实物
14
- // 反推,不是猜**。
15
- //
16
- // ⚠ **路径只能探测,不许推算** —— 给错路径比不给更糟:agent 会照着去读,读不到就学会
17
- // 「这项目没有记忆」这条错信念(一次失败的交互会在会话里固化成信念,之后它绕路不再试),
18
- // 而更坏的一头是读到**别的项目**的记忆还照着做。所以:每条路径都 stat 过才敢往外报,
19
- // workdir 本身必须是真实目录,归属还要再过一道权威源。
20
- const fs = require('node:fs')
21
- const path = require('node:path')
22
- const os = require('node:os')
23
-
24
- // 项目根的约定文件。⚠ 顺序有语义(清单按它挑「往哪写」的落点),故测试里用字面量断言,
25
- // 不许拿这个常量自己当期望 —— 那样顺序怎么改都测不出来。
26
- const CONVENTION_FILES = ['CLAUDE.md', 'AGENTS.md']
27
-
28
- // 记忆条数只报**档位**,不报精确值。
29
- // ⚠ 这是清单指纹的硬约束,不是偷懒:engine 靠「清单文本变了没」决定要不要重注入,并在变了时
30
- // 对 agent 说一句「你的能力/配置已更新」。而真实项目**每天都在长记忆**(实测四天 +1),报精确
31
- // 值 ⇒ 指纹天天变 ⇒ 每天白重注入一份几 KB 的清单,还对 agent 说了假话(能力根本没更新)。
32
- // ⚠ 残留(已知、接受):claude 侧框架会**自己**往记忆目录写 ⇒ 跨档那一刻仍会翻一次指纹。
33
- // 这是清单里第一个 agent 自己就能触发的字段。档位只把频率从「每天」压到「跨档时」,没有消除。
34
- const BUCKETS = [500, 200, 100, 50, 20, 10]
35
- function countBucket(n) {
36
- for (const b of BUCKETS) if (n >= b) return `${b}+ 条`
37
- return '不到 10 条' // 10 以下不报数:小项目每加一条都会翻指纹,而 3 条与 5 条对读不读毫无区别
38
- }
39
-
40
- // slug 规则 = **realpath 之后,把 `/` 和 `_` 都压成 `-`**。本机 20 个记忆目录逐一对账反推得出,
41
- // 不是猜。两条都有实物,且都是对抗审查逮出来的真事故:
42
- // ⚠ 漏掉 `_`:`…/Doc-TTS`(压根不存在)会命中 `…/Doc_TTS` 的记忆 ⇒ 报出**别的项目**的记忆,
43
- // 而真主人(带 `_` 的那个)自己反而永远探不到。两头都坏,且「没有记忆」与「slug 算错了」
44
- // 在清单里长得一模一样。
45
- // ⚠ 漏掉 realpath:`/tmp/x` 被记成 `-private-tmp-x`(macOS 上 /tmp、/var 都是软链)⇒ workdir
46
- // 填软链路径的群,记忆永远探不到,不出任何声。
47
- // ⚠ Windows 规则**无样本**:这里照同一套算,算错的后果是 stat 不到 ⇒ 返回 null(fail-safe),
48
- // 不会报出错路径 —— 但也等于该平台上这个功能不生效。
49
- function memorySlug(realPath) {
50
- return String(realPath).replace(/\\/g, '/').replace(/[/_]/g, '-')
51
- }
52
-
53
- // 归属的权威源:claude.json 的 `projects` 以**真实绝对路径**为键(本机实测 18 键)。
54
- // ⭐ 用查表替代「猜一条替代路径存不存在」的启发式 —— 一次读文件(实测 98KB / 0.29ms,与同一
55
- // 函数里已有的读 .git/config、读风格档同量级)就把 `_`、`/`、软链三个维度一次覆盖;而启发式
56
- // 只覆盖其中一维的一个位置,还会被一个**跟 claude 毫无关系的普通兄弟目录**误判成歧义
57
- // (旧实现:项目里新建个 demo/ 子目录,整段记忆就静默消失)。
58
- // ⚠ 它权威但**不完备** —— 2026-09-01 实测:18 个带记忆的目录里有 4 个在 claude.json 里没有键
59
- // 认领。所以「没人认领」不算歧义(照报);只有「别人认领了」或「两个键撞同一 slug」才闭嘴。
60
- // ⚠ 读不到权威源时返回 null = 「无归属信息」,按不阻断处理:realpath + `_` 已经消掉了现实中
61
- // 真正发生过的那类错配,而两个键撞同一 slug 实测为 0 次。
62
- // ⚠ 候选路径要先归一 —— cfgDir 带个尾斜杠就会让两个候选双双落空,然后**静默** fail-open。
63
- function claudeJsonPath(cfgDir) {
64
- const base = path.resolve(String(cfgDir))
65
- for (const p of [base + '.json', path.join(base, '.claude.json')]) {
66
- try { if (fs.statSync(p).isFile()) return p } catch { /* 换下一个候选 */ }
67
- }
68
- return null
69
- }
70
-
71
- function slugOwners(cfgDir, slug) {
72
- const p = claudeJsonPath(cfgDir)
73
- if (!p) return null
74
- try {
75
- const projects = JSON.parse(fs.readFileSync(p, 'utf8')).projects || {}
76
- const out = []
77
- for (const k of Object.keys(projects)) {
78
- // ⚠ 键**不随目录删除而清理**(实测 18 个键里 6 个是幽灵键)。目录都不在了的键认领不了
79
- // 任何东西 —— 留着它参与匹配,会让一个早就删掉的老项目**压制住一个活着的项目**的记忆,
80
- // 而且不出任何声。方向虽是 fail-safe,但这是旧实现没有的新压制面。
81
- let real
82
- try { real = fs.realpathSync(k) } catch { continue }
83
- if (memorySlug(real) === slug) out.push(real)
84
- }
85
- return out
86
- } catch { return null }
87
- }
88
-
89
- function probeMemoryDir(workdir, opts = {}) {
90
- // workdir 必须是**真实存在的目录**,并走 realpath(claude 记的就是 realpath)。
91
- // 顺带挡掉「控制台把路径填错 / 项目被移走 / 盘没挂上」这一整类 —— 旧实现对一个压根不存在
92
- // 的 workdir 也能报出一个真实可读的记忆目录。path.resolve 先吃掉尾斜杠、`.`、`..`、重复斜杠。
93
- let real
94
- try {
95
- real = fs.realpathSync(path.resolve(String(workdir)))
96
- if (!fs.statSync(real).isDirectory()) return null
97
- } catch { return null }
98
- const home = opts.home || os.homedir()
99
- const cfgDir = opts.configDir || (opts.env || process.env).CLAUDE_CONFIG_DIR || path.join(home, '.claude')
100
- const slug = memorySlug(real)
101
- const dir = path.join(cfgDir, 'projects', slug, 'memory')
102
- let names
103
- try {
104
- if (!fs.statSync(dir).isDirectory()) return null
105
- // withFileTypes:名字以 .md 结尾的**子目录**不该被算成一条记忆(真实记忆目录里就有
106
- // _archive/ 这类子目录)。软链不排除 —— 软链到记忆文件是正当用法。
107
- names = fs.readdirSync(dir, { withFileTypes: true })
108
- .filter((e) => e.name.endsWith('.md') && !e.isDirectory()).map((e) => e.name)
109
- } catch { return null }
110
- const index = names.includes('MEMORY.md') ? 'MEMORY.md' : null
111
- const count = names.length - (index ? 1 : 0)
112
- // 空目录 / 只剩一份索引 ⇒ 没东西可交接。清单每轮都付 token,不为「这里什么都没有」占篇幅。
113
- if (count <= 0) return null
114
- const owners = slugOwners(cfgDir, slug)
115
- if (owners && (owners.length > 1 || (owners.length === 1 && owners[0] !== real))) return null
116
- return { dir, count, bucket: countBucket(count), index }
117
- }
118
-
119
- // 原始层判据:「这个项目有没有东西可交接」。只服务 probeProjectMemory 自己 —— 保证它返回非 null
120
- // 时一定有东西。⚠ 消费者不该用这一条(它不知道这一轮的权限/runner 够不够得着),消费者用 probeHandover。
121
- function hasHandover(pm) { return !!(pm && (pm.convention.length || pm.memory)) }
122
-
123
- /**
124
- * 探测一个项目目录里「跨会话还留得住的东西」。
125
- * @returns {{convention: string[], memory: {dir,count,bucket,index}|null}|null}
126
- * 两样都没有时返回 null —— 探测式:项目里没有这些东西,清单里就一个字都不出现。
127
- */
128
- function probeProjectMemory(workdir, opts = {}) {
129
- const wd = workdir && String(workdir).trim()
130
- if (!wd) return null
131
- const convention = []
132
- for (const f of CONVENTION_FILES) {
133
- try { if (fs.statSync(path.join(wd, f)).isFile()) convention.push(f) } catch { /* 不存在 ⇒ 不点名 */ }
134
- }
135
- const out = { convention, memory: probeMemoryDir(wd, opts) }
136
- return hasHandover(out) ? out : null
137
- }
138
-
139
- // ⭐ 换脑时对群成员说的那一句。**控制台切模型与群内 `/model` 都要说它** —— 各写一份必然漂移
140
- // (教训 26/35),故收在这里当唯一来源,经 probeHandover 的 `note` 交给两个消费者。
141
- // ⚠ **只陈述事实(东西还在),不承诺行为**(「它会先读」):读不读是半可信 agent 的判断,编排层
142
- // 保证不了 —— 清单自己都写着「上下文里没有就自己读一遍」。
143
- // ⚠ 用户面零工具名:不提约定文件名、更不提机器上的目录。
144
- const HANDOVER_NOTE = '本项目的长期约定与积累都还在,不必从头交代'
145
-
146
- /**
147
- * ⭐ **消费者唯一入口**:这一轮**实际给得出去**的交接信息。自知清单与切模型通知都调它。
148
- *
149
- * 为什么必须收成一个函数(教训 26/35 家族):门控之前两个消费者天然一致,加了门控就多出第三种
150
- * 状态 ——「探到了东西,但这一轮一条都给不出去」。各写各的判据 ⇒ 控制台在群里说「之前交代过的
151
- * 都还在」,而清单里一个字都没有、那个目录 agent 还够不着。**注释挡不住复发,得收成一个被调用的
152
- * 东西**;而落点选哪份、够不够得着,都属于「哪个 runner 会自动加载什么、文件工具被围在哪」
153
- * 这类**具体 agent 的行为知识**,本就不该写进 core。
154
- *
155
- * @param {string} workdir
156
- * @param {{full?: boolean, isCodex?: boolean}} opts full = 该群完全访问;isCodex = 走 codex runner
157
- * @returns {{convention: string[], memory: object|null, landing: string|null}|null}
158
- */
159
- function probeHandover(workdir, opts = {}) {
160
- const pm = probeProjectMemory(workdir, opts)
161
- if (!pm) return null
162
- // 记忆目录在 workdir **之外**,而 claude runner 唯一的 --add-dir 就是 workdir 本身 ⇒ 非完全
163
- // 访问的 claude 群读不到。指了它读不到的路,只会**亲手教出**「这项目没有记忆」那条错信念
164
- // (一次失败的交互会在会话里固化成信念,之后它绕开不再试)—— 比从没告诉过它更糟。
165
- // codex 的 read-only 是只读**文件系统策略**、不是路径牢笼,够得着。
166
- const memory = pm.memory && (opts.full || opts.isCodex) ? pm.memory : null
167
- // 落点:两份都在时选它**自己会自动加载**的那一份 —— 让 codex 把交接写进一份它不读的档,等于白写。
168
- const want = opts.isCodex ? 'AGENTS.md' : 'CLAUDE.md'
169
- const landing = pm.convention.length ? (pm.convention.find((f) => f === want) || pm.convention[0]) : null
170
- // 探到了但一条都给不出去 ⇒ 两个消费者一致地闭嘴(清单不留光秃秃的标题,群里也不说那句话)。
171
- if (!landing && !memory) return null
172
- return { convention: pm.convention, memory, landing, note: HANDOVER_NOTE }
173
- }
174
-
175
- module.exports = { probeHandover, probeProjectMemory, probeMemoryDir, memorySlug, slugOwners, hasHandover, countBucket, CONVENTION_FILES, HANDOVER_NOTE }
package/src/service.js DELETED
@@ -1,317 +0,0 @@
1
- // `sbot service` —— 把 `sbot start` 装成常驻服务:崩溃自动拉起、登录(或开机)自启。
2
- //
3
- // 平台:macOS = launchd LaunchAgent(GUI 域,随用户登录起)· Linux = systemd --user。
4
- // Windows 不自动装:服务化要第三方托管器(NSSM / pm2),这里只打印现成命令(docs/zh-CN/install.md)。
5
- //
6
- // 结构:单元文件全由纯函数生成(可单测、`--dry-run` 原样打印);真正动系统的只有 install /
7
- // uninstall / restart / status 里那几行 launchctl / systemctl,出错如实报、不吞。
8
- //
9
- // ⚠ 服务里跑的是**当前这份** node 与 sbot 入口的绝对路径,并且显式带 `--config <绝对路径>` ——
10
- // 常驻进程的 cwd 与 PATH 都不是装的人那一刻的 shell,靠「从当前目录找配置」或「PATH 里找 node」
11
- // 在常驻环境里会静默找错 / 找不到。
12
- // ⚠ node 路径不能直接用 process.execPath:Homebrew 下它是带版本号的 Cellar 路径
13
- // (/opt/homebrew/Cellar/node/26.0.0/bin/node),升级 node 后旧目录被删,服务就再也起不来 ⇒
14
- // 换成指向同一个可执行文件、但不带版本号的路径(stableNodeBin);找不到就照用并提醒。
15
- // ⚠ 服务进程拿不到装的人 shell 里的环境变量:配置里 `env:VAR` 引用的 key 在终端里解析得到,
16
- // 到了 launchd / systemd 里就是空的 ⇒ install 时点名;`--pass-env` 才把值写进单元文件(权限 600)。
17
- // ⚠ PATH 取装的那一刻的 shell PATH,并把 node / claude / codex 所在目录排在最前:agent 要 spawn
18
- // `claude` / `codex`,launchd 给的默认 PATH 只有 /usr/bin:/bin,nvm / homebrew 装的都找不到。
19
-
20
- const fs = require('node:fs')
21
- const os = require('node:os')
22
- const path = require('node:path')
23
- const { execFileSync, execFile } = require('node:child_process')
24
-
25
- const LABEL = 'com.sbot.daemon' // launchd 标签(与早期手写 plist 同名 ⇒ 装新版直接替换旧的)
26
- const UNIT = 'sbot.service' // systemd --user 单元名
27
-
28
- function xmlEsc(s) {
29
- return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
30
- }
31
-
32
- // systemd ExecStart 的单个参数:含空白 / 引号 / 反斜杠 / $ / % 才加双引号并转义
33
- function sdQuote(s) {
34
- s = String(s)
35
- if (!/[\s"'\\$%;]/.test(s) && s !== '') return s
36
- return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '$$$$').replace(/%/g, '%%') + '"'
37
- }
38
-
39
- // PATH:优先目录在前,其后是现有 PATH,去重去空
40
- function buildPath(firstDirs, existing, delimiter = ':') {
41
- const out = []
42
- for (const d of [...firstDirs, ...String(existing || '').split(delimiter)]) {
43
- if (d && !out.includes(d)) out.push(d)
44
- }
45
- return out.join(delimiter)
46
- }
47
-
48
- // 路径里带版本号的(Homebrew Cellar / nvm / volta / fnm / asdf / n,或任何 x.y.z 目录段):
49
- // 换版本后这个路径就不在了
50
- function versionedPath(p) {
51
- return /\/Cellar\/|\/\.nvm\/versions\/|\/\.volta\/tools\/image\/|\/fnm\/node-versions\/|\/\.asdf\/installs\/|\/n\/versions\//.test(String(p))
52
- || /\/[^/]*\d+\.\d+\.\d+[^/]*\//.test(String(p))
53
- }
54
-
55
- // 给服务用的 node 路径:与 execPath 是同一个可执行文件、但不带版本号。候选 = PATH 上的 node,
56
- // 以及 Homebrew 的 <prefix>/bin/node 与 <prefix>/opt/<formula>/bin/node(升级后会跟着指向新版)。
57
- // → { bin, warn? };哪个都对不上就照用 execPath 并给一句提醒。
58
- function stableNodeBin({ execPath, candidates = [], exists = fs.existsSync, realpath = fs.realpathSync }) {
59
- if (!versionedPath(execPath)) return { bin: execPath }
60
- const real = (p) => { try { return realpath(p) } catch { return null } }
61
- const target = real(execPath) || execPath
62
- const m = String(execPath).match(/^(.*)\/Cellar\/([^/]+)\/[^/]+\/bin\/node$/)
63
- const brew = m ? [path.join(m[1], 'bin', 'node'), path.join(m[1], 'opt', m[2], 'bin', 'node')] : []
64
- for (const c of [...candidates, ...brew]) {
65
- if (c && exists(c) && !versionedPath(c) && real(c) === target) return { bin: c }
66
- }
67
- return { bin: execPath, warn: `node 的路径带版本号(${execPath}),换 node 版本后要重跑 sintbot service install` }
68
- }
69
-
70
- // 配置里所有 `env:VAR` 引用的变量名(去重,按出现顺序)。下划线开头的键是说明 / 示例(`_slack_example` 这类),
71
- // 运行时不读,不算
72
- function envRefs(obj, out = []) {
73
- if (typeof obj === 'string') {
74
- const m = obj.match(/^env:([A-Za-z_][A-Za-z0-9_]*)$/)
75
- if (m && !out.includes(m[1])) out.push(m[1])
76
- } else if (Array.isArray(obj)) obj.forEach((v) => envRefs(v, out))
77
- else if (obj && typeof obj === 'object') for (const [k, v] of Object.entries(obj)) { if (!k.startsWith('_')) envRefs(v, out) }
78
- return out
79
- }
80
-
81
- // 服务托管标记:守护进程据此知道「退出了会被 launchd / systemd 拉起」—— 还没接平台时只开控制台,
82
- // 第一次接上平台就自己退出、重启后连上(bin/sbot.js cmdStart)。前台跑的 sintbot start 没有它
83
- const SUPERVISED_ENV = 'SBOT_SUPERVISED'
84
-
85
- // 服务的全部事实(纯数据):各平台单元文件都从这里生成。env = 额外写进单元文件的环境变量(--pass-env)
86
- function serviceSpec({ nodeBin, sbotBin, configPath, uiHost, home, pathEnv, env = {} }) {
87
- const args = [sbotBin, 'start', '--ui', '--no-open']
88
- if (uiHost) args.push('--ui-host', uiHost)
89
- if (configPath) args.push('--config', configPath)
90
- const workdir = configPath ? path.dirname(configPath) : path.join(home, '.sbot')
91
- const logDir = path.join(home, '.sbot', 'logs')
92
- return { nodeBin, args, workdir, logDir, pathEnv, env, outLog: path.join(logDir, 'out.log'), errLog: path.join(logDir, 'err.log') }
93
- }
94
-
95
- function launchdPlist(spec) {
96
- const arr = [spec.nodeBin, ...spec.args].map((a) => ` <string>${xmlEsc(a)}</string>`).join('\n')
97
- return `<?xml version="1.0" encoding="UTF-8"?>
98
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
99
- <plist version="1.0">
100
- <dict>
101
- <key>Label</key><string>${LABEL}</string>
102
- <key>ProgramArguments</key>
103
- <array>
104
- ${arr}
105
- </array>
106
- <key>WorkingDirectory</key><string>${xmlEsc(spec.workdir)}</string>
107
- <key>EnvironmentVariables</key>
108
- <dict>
109
- <key>PATH</key><string>${xmlEsc(spec.pathEnv)}</string>
110
- ${Object.entries(spec.env || {}).map(([k, v]) => ` <key>${xmlEsc(k)}</key><string>${xmlEsc(v)}</string>\n`).join('')} <key>${SUPERVISED_ENV}</key><string>1</string>
111
- </dict>
112
- <key>RunAtLoad</key><true/>
113
- <key>KeepAlive</key><true/>
114
- <key>ThrottleInterval</key><integer>15</integer>
115
- <key>StandardOutPath</key><string>${xmlEsc(spec.outLog)}</string>
116
- <key>StandardErrorPath</key><string>${xmlEsc(spec.errLog)}</string>
117
- </dict>
118
- </plist>
119
- `
120
- }
121
-
122
- function systemdUnit(spec) {
123
- return `[Unit]
124
- Description=SintBot daemon (group chat -> headless Claude Code)
125
- After=network-online.target
126
- Wants=network-online.target
127
-
128
- [Service]
129
- ExecStart=${[spec.nodeBin, ...spec.args].map(sdQuote).join(' ')}
130
- WorkingDirectory=${sdQuote(spec.workdir)}
131
- Environment=${sdQuote('PATH=' + spec.pathEnv)}
132
- ${Object.entries(spec.env || {}).map(([k, v]) => `Environment=${sdQuote(k + '=' + v)}\n`).join('')}Environment=${SUPERVISED_ENV}=1
133
- Restart=always
134
- RestartSec=15
135
- KillSignal=SIGTERM
136
- TimeoutStopSec=30
137
- StandardOutput=append:${spec.outLog}
138
- StandardError=append:${spec.errLog}
139
-
140
- [Install]
141
- WantedBy=default.target
142
- `
143
- }
144
-
145
- function unitPath(platform, home) {
146
- if (platform === 'darwin') return path.join(home, 'Library', 'LaunchAgents', LABEL + '.plist')
147
- if (platform === 'linux') return path.join(home, '.config', 'systemd', 'user', UNIT)
148
- return null
149
- }
150
-
151
- function render(platform, spec) {
152
- if (platform === 'darwin') return launchdPlist(spec)
153
- if (platform === 'linux') return systemdUnit(spec)
154
- return null
155
- }
156
-
157
- // Windows:不自动装,给现成命令(两种托管器任选)
158
- function windowsHint(spec) {
159
- const cmd = [spec.nodeBin, ...spec.args].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(' ')
160
- return [
161
- 'Windows 上请用托管器把下面这条命令装成服务(任选其一,详见 https://github.com/sintlab/sintbot/blob/main/docs/zh-CN/install.md):',
162
- '',
163
- ` 命令:${cmd}`,
164
- '',
165
- ' · NSSM:nssm install SintBot ' + `"${spec.nodeBin}" ` + spec.args.map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(' '),
166
- ` nssm set SintBot AppDirectory "${spec.workdir}"`,
167
- ' nssm start SintBot',
168
- ' · pm2: pm2 start ' + `"${spec.args[0]}" --name sbot -- ` + spec.args.slice(1).join(' ') + ' && pm2 save',
169
- ].join('\n')
170
- }
171
-
172
- function run(bin, args, opts = {}) {
173
- try { return { ok: true, out: execFileSync(bin, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }) } }
174
- catch (e) { return { ok: false, out: String(e.stdout || ''), err: String(e.stderr || e.message || e).trim(), code: e.status } }
175
- }
176
-
177
- const uid = () => (typeof process.getuid === 'function' ? process.getuid() : 0)
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
-
208
- // 状态(只读):{ installed, running, pid, detail }
209
- function status({ platform = process.platform, home = os.homedir() } = {}) {
210
- const file = unitPath(platform, home)
211
- const installed = !!(file && fs.existsSync(file))
212
- if (platform === 'darwin') {
213
- const r = run('launchctl', ['print', `gui/${uid()}/${LABEL}`])
214
- if (!r.ok) return { installed, loaded: false, running: false, pid: null, file }
215
- const pid = (r.out.match(/^\s*pid = (\d+)/m) || [])[1]
216
- const state = (r.out.match(/^\s*state = (\S+)/m) || [])[1] || ''
217
- return { installed, loaded: true, running: state === 'running', pid: pid ? Number(pid) : null, state, file }
218
- }
219
- if (platform === 'linux') {
220
- const r = run('systemctl', ['--user', 'show', UNIT, '--property=ActiveState,MainPID'])
221
- const active = (r.out.match(/ActiveState=(\S+)/) || [])[1] || ''
222
- const pid = Number((r.out.match(/MainPID=(\d+)/) || [])[1] || 0) || null
223
- return { installed, loaded: active !== '' && active !== 'inactive', running: active === 'active', pid, state: active, file }
224
- }
225
- return { installed: false, loaded: false, running: false, pid: null, file: null }
226
- }
227
-
228
- // 写单元文件(已有且内容不同 ⇒ 先备份)并加载。返回 { file, backup, steps: [{cmd, ok, err}] }
229
- function install(spec, { platform = process.platform, home = os.homedir(), runCommand = run, wait = sleepSync, launchdTimeoutMs = 2000, launchdPollMs = 100 } = {}) {
230
- const file = unitPath(platform, home)
231
- if (!file) throw new Error('这个平台不支持自动安装服务')
232
- const text = render(platform, spec)
233
- fs.mkdirSync(path.dirname(file), { recursive: true })
234
- fs.mkdirSync(spec.logDir, { recursive: true, mode: 0o700 }) // 日志里有群消息摘要;顺带建出的 ~/.sbot 也是 700
235
- let backup = null
236
- if (fs.existsSync(file) && fs.readFileSync(file, 'utf8') !== text) {
237
- backup = `${file}.bak-${new Date().toISOString().replace(/[:.]/g, '').slice(0, 15)}`
238
- fs.copyFileSync(file, backup)
239
- }
240
- fs.writeFileSync(file, text)
241
- // 单元文件里写了环境变量的值(多半是 key)⇒ 只给自己读写;没写就是普通权限
242
- if (Object.keys(spec.env || {}).length) fs.chmodSync(file, 0o600)
243
- const steps = []
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 }
245
- if (platform === 'darwin') {
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 })
276
- } else {
277
- step('systemctl', ['--user', 'daemon-reload'])
278
- step('systemctl', ['--user', 'enable', UNIT])
279
- step('systemctl', ['--user', 'restart', UNIT])
280
- }
281
- return { file, backup, steps }
282
- }
283
-
284
- function restart({ platform = process.platform } = {}) {
285
- if (platform === 'darwin') return run('launchctl', ['kickstart', '-k', `gui/${uid()}/${LABEL}`])
286
- if (platform === 'linux') return run('systemctl', ['--user', 'restart', UNIT])
287
- return { ok: false, err: '这个平台不支持' }
288
- }
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
-
303
- function uninstall({ platform = process.platform, home = os.homedir() } = {}) {
304
- const file = unitPath(platform, home)
305
- const steps = []
306
- if (platform === 'darwin') {
307
- const r = run('launchctl', ['bootout', `gui/${uid()}/${LABEL}`]); steps.push({ cmd: 'launchctl bootout', ok: true, err: r.ok ? '' : r.err })
308
- } else if (platform === 'linux') {
309
- const r = run('systemctl', ['--user', 'disable', '--now', UNIT]); steps.push({ cmd: 'systemctl --user disable --now', ok: r.ok, err: r.ok ? '' : r.err })
310
- }
311
- let removed = false
312
- if (file && fs.existsSync(file)) { fs.unlinkSync(file); removed = true }
313
- if (platform === 'linux') run('systemctl', ['--user', 'daemon-reload'])
314
- return { file, removed, steps }
315
- }
316
-
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 }
package/src/ui/auth.js DELETED
@@ -1,31 +0,0 @@
1
- // 控制台访问密码:scrypt 哈希 + 常量时间校验(零依赖)。
2
- //
3
- // 精简口径(局域网访问加固):控制台默认绑 loopback 免密;一旦对外(--host
4
- // 非环回)就要求登录。密码只干一件事 —— 换取一个本进程 session token,其余鉴权管线原样
5
- // 复用(token 放 x-sbot-token 头,天然免 CSRF)。密码永不明文落盘,存成 `scrypt$salt$hash`。
6
-
7
- const crypto = require('node:crypto')
8
-
9
- const KEYLEN = 32 // 派生密钥字节数
10
-
11
- // 明文密码 → `scrypt$<saltHex>$<hashHex>`(salt 随机,每次不同)
12
- function hashPassword(pw) {
13
- const salt = crypto.randomBytes(16)
14
- const hash = crypto.scryptSync(String(pw), salt, KEYLEN)
15
- return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`
16
- }
17
-
18
- // 明文密码 vs 存储串;格式不符 / 长度不符 / 派生失败 → false(绝不抛)。timingSafeEqual 防计时侧信道。
19
- function verifyPassword(pw, stored) {
20
- if (typeof stored !== 'string') return false
21
- const parts = stored.split('$')
22
- if (parts.length !== 3 || parts[0] !== 'scrypt') return false
23
- let expected
24
- try { expected = Buffer.from(parts[1] && parts[2] ? parts[2] : '', 'hex') } catch { return false }
25
- if (expected.length !== KEYLEN) return false
26
- let hash
27
- try { hash = crypto.scryptSync(String(pw), Buffer.from(parts[1], 'hex'), KEYLEN) } catch { return false }
28
- return hash.length === expected.length && crypto.timingSafeEqual(hash, expected)
29
- }
30
-
31
- module.exports = { hashPassword, verifyPassword }