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.
- package/CHANGELOG.en.json +99 -0
- package/CHANGELOG.json +99 -0
- package/README.md +20 -158
- package/README.zh-CN.md +20 -166
- package/THIRD-PARTY-NOTICES.md +394 -0
- package/bin/sintbot-v2.js +363 -0
- package/docs/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/install.md +61 -167
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/zh-CN/install.md +61 -165
- package/install.sh +84 -0
- package/package.json +73 -37
- package/src/agent/claudecode.js +149 -108
- package/src/agent/codexapp.js +458 -0
- package/src/agent/codexcode.js +210 -107
- package/src/agent/errtag.js +34 -0
- package/src/agent/privatecore.js +790 -0
- package/src/agent/privatecore.wasm +0 -0
- package/src/agent/runner.js +10 -8
- package/src/agent/winexec.js +35 -5
- package/src/c2install.js +117 -0
- package/src/config.js +2 -1
- package/src/core/c2cardeffectbridge.js +51 -0
- package/src/core/c2cardterminalpath.js +216 -0
- package/src/core/c2durableingress.js +148 -0
- package/src/core/c2durablejournal.js +1560 -0
- package/src/core/c2durableruntime.js +2361 -0
- package/src/core/c2eventidentity.js +90 -0
- package/src/core/c2globalfault.js +401 -0
- package/src/core/c2localeffectexecutor.js +193 -0
- package/src/core/c2payloadhandles.js +333 -0
- package/src/core/c2persistrelation.js +179 -0
- package/src/core/c2persiststate.js +342 -0
- package/src/core/c2platformrecoverysender.js +76 -0
- package/src/core/c2recoverygate.js +385 -0
- package/src/core/c2runnerinput.js +63 -0
- package/src/core/c2servicestartgate.js +103 -0
- package/src/core/c2sessionbridge.js +72 -0
- package/src/core/c2sourcequarantine.js +30 -0
- package/src/core/c2startuprecovery.js +105 -0
- package/src/core/c2successorbridge.js +151 -0
- package/src/core/c2terminaleffectbridge.js +154 -0
- package/src/core/c2terminalprune.js +108 -0
- package/src/core/c2turnprojection.js +177 -0
- package/src/core/enginecheckpoint.js +211 -0
- package/src/core/engineeffectcursor.js +394 -0
- package/src/core/enginestepcoordinator.js +183 -0
- package/src/core/store.js +52 -8
- package/src/core/v2engine.js +1469 -0
- package/src/platform/feishu.js +324 -67
- package/src/plugins.js +1 -9
- package/assets/design-styles/acid-pop.md +0 -155
- package/assets/design-styles/archive-index.md +0 -161
- package/assets/design-styles/art-deco.md +0 -164
- package/assets/design-styles/cinematic-noir.md +0 -178
- package/assets/design-styles/editorial.md +0 -121
- package/assets/design-styles/extra/README.md +0 -23
- package/assets/design-styles/extra/constructivism.md +0 -146
- package/assets/design-styles/extra/neo-brutalism.md +0 -175
- package/assets/design-styles/extra/new-wave.md +0 -166
- package/assets/design-styles/extra/nordic-material.md +0 -162
- package/assets/design-styles/extra/paris-couture.md +0 -162
- package/assets/design-styles/extra/tropical-modern.md +0 -168
- package/assets/design-styles/extra/ulm-functional.md +0 -182
- package/assets/design-styles/extra/wa-shikimen.md +0 -160
- package/assets/design-styles/extra/y2k-chrome.md +0 -172
- package/assets/design-styles/geometric-pop.md +0 -122
- package/assets/design-styles/hand-drawn.md +0 -117
- package/assets/design-styles/islamic-geometry.md +0 -155
- package/assets/design-styles/liquid-glass.md +0 -183
- package/assets/design-styles/neo-chinese.md +0 -136
- package/assets/design-styles/precision-mono.md +0 -156
- package/assets/design-styles/quiet-luxury.md +0 -163
- package/assets/design-styles/riso-print.md +0 -116
- package/assets/design-styles/soft-friendly.md +0 -174
- package/assets/design-styles/swiss-grid.md +0 -156
- package/assets/design-styles/tech-dark.md +0 -182
- package/assets/favicon.svg +0 -6
- package/assets/logo.svg +0 -24
- package/bin/sbot.js +0 -4716
- package/docs/slack-app-manifest.yaml +0 -104
- package/sbot.config.example.json +0 -235
- package/src/agent/askonce.js +0 -76
- package/src/agent/claudemodels.js +0 -60
- package/src/agent/claudestatus.js +0 -74
- package/src/agent/claudeusage.js +0 -200
- package/src/agent/codexstatus.js +0 -181
- package/src/agent/imagen.js +0 -458
- package/src/agent/quotaerr.js +0 -44
- package/src/agent/transienterr.js +0 -32
- package/src/cdp.js +0 -254
- package/src/cli-secret.js +0 -36
- package/src/clilang.js +0 -31
- package/src/core/cron.js +0 -182
- package/src/core/cronfire.js +0 -120
- package/src/core/cronrun.js +0 -116
- package/src/core/engine.js +0 -2758
- package/src/core/interfaces.js +0 -114
- package/src/core/issues.js +0 -185
- package/src/core/ledger.js +0 -30
- package/src/core/opsalert.js +0 -214
- package/src/core/pending.js +0 -123
- package/src/core/proxy.js +0 -424
- package/src/core/quotaalert.js +0 -79
- package/src/core/quotaerr.js +0 -55
- package/src/core/scheduler.js +0 -59
- package/src/design.js +0 -5161
- package/src/gitrepo.js +0 -150
- package/src/health.js +0 -353
- package/src/installdeps.js +0 -72
- package/src/leftover.js +0 -174
- package/src/platform/boardnotice.js +0 -77
- package/src/platform/boardops.js +0 -36
- package/src/platform/boardwatch.js +0 -447
- package/src/platform/cli.js +0 -45
- package/src/platform/slack.js +0 -1289
- package/src/platform/takeovercard.js +0 -37
- package/src/platform/wecom.js +0 -938
- package/src/platform/wecomcli.js +0 -128
- package/src/platform/wecomgate.js +0 -108
- package/src/platform/wecomname.js +0 -43
- package/src/platform/wecompoll.js +0 -241
- package/src/preview.js +0 -581
- package/src/projectmemory.js +0 -175
- package/src/service.js +0 -317
- package/src/ui/auth.js +0 -31
- package/src/ui/browser.html +0 -392
- package/src/ui/browserroutes.js +0 -234
- package/src/ui/console.html +0 -5526
- package/src/ui/dashboard.js +0 -168
- package/src/ui/feishulogin.js +0 -136
- package/src/ui/server.js +0 -2314
- package/src/ui/sessions.js +0 -78
- package/src/ui/takeover.js +0 -108
- package/src/updater.js +0 -243
- package/src/upgrade.js +0 -35
- package/src/usage.js +0 -144
package/src/preview.js
DELETED
|
@@ -1,581 +0,0 @@
|
|
|
1
|
-
// 群预览服务(`sbot preview`)—— 让群成员在局域网打开 agent 做的 HTML 原型,受管、可回收。
|
|
2
|
-
//
|
|
3
|
-
// 病史(docs/PREVIEW.md 有全文):agent 为了让群里打开原型,先是把静态服务装成 launchctl 常驻
|
|
4
|
-
// 作业(挂了一周、整个项目目录暴露在局域网),v0.8.1 禁了常驻作业之后,它改用**故意不结束本轮**
|
|
5
|
-
// 给服务续命(占住群锁、群成员消息全排队)—— 比原问题更糟。v0.8.2 只补了清单文案(首选 send-file、
|
|
6
|
-
// 真脱离用 screen、严禁拖轮次),剩下三个软肋全靠自觉:关服务靠跨轮记忆、operator 看不见、
|
|
7
|
-
// screen 是能起任意进程的通用逃生阀。本模块把这件事收成一个受管动作。
|
|
8
|
-
//
|
|
9
|
-
// 形态(立项措辞的订正):**socket 在 detached worker,生命周期策略在 daemon**。daemon 是全群单点,
|
|
10
|
-
// 把吃 LAN 请求的 HTTP handler 塞进去 ⇒ 任何解析 bug 都是「整台 bot 下线」;且生产机每次发版都
|
|
11
|
-
// kickstart daemon ⇒ in-daemon 托管的预览每次升级都断,「跨轮存活」名存实亡。立项要的「有人替它
|
|
12
|
-
// 记得关」是**策略**不是套接字。worker 自己也会闲置自尽 ⇒ 不装 daemon / daemon 崩了,回收仍成立。
|
|
13
|
-
//
|
|
14
|
-
// 记录所有权铁律:**谁杀谁写**。worker 只写自己的生命周期跃迁(running / 自己闲置停 / 绑定失败);
|
|
15
|
-
// 被别人 kill 时不写(收割方已经把 reason 写进去了,worker 再写会把 reason 覆盖成 'signal')。
|
|
16
|
-
//
|
|
17
|
-
// 本文件只放可单测纯逻辑(端口序 / 围栏裁决 / 请求路径 / 记录 / 回收判定 / 文案);http 服务、
|
|
18
|
-
// spawn、发群在 bin(项目纪律:src 可测,bin 接线)。
|
|
19
|
-
const fs = require('node:fs')
|
|
20
|
-
const os = require('node:os')
|
|
21
|
-
const path = require('node:path')
|
|
22
|
-
const crypto = require('node:crypto')
|
|
23
|
-
const { atomicWriteJSON } = require('./core/store')
|
|
24
|
-
|
|
25
|
-
// 端口段:紧邻控制台 4600 便于记忆与防火墙整段放行,且躲开 3000/5173/8000/8080/9222(常驻 Chrome CDP)
|
|
26
|
-
const PORT_FROM = 4610
|
|
27
|
-
const PORT_TO = 4629
|
|
28
|
-
const IDLE_MIN_DEFAULT = 30 // 闲置回收(分钟);0 = 不按闲置回收(仍受 maxHours 顶)
|
|
29
|
-
const MAX_HOURS_DEFAULT = 8 // 总时长硬顶(小时)
|
|
30
|
-
const LASTREQ_DEBOUNCE_MS = 5_000 // lastReqAt 落盘去抖:静态页一次加载几十个请求,不能每个都打一次盘
|
|
31
|
-
const KEEP_STOPPED = 20 // 历史记录保留条数(status 可回看,又不无限堆积)
|
|
32
|
-
|
|
33
|
-
function previewDir() {
|
|
34
|
-
return path.join(os.homedir(), '.sbot', 'preview')
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// 配置归一(顶层 `preview` 块 + 可选的按群覆盖)。缺省即默认;非法值一律回默认(fail-safe,同
|
|
38
|
-
// normalizeTz 口径)。**按群覆盖只由 operator 设**(控制台群卡 / 配置文件 `groups[chatId].preview`)——
|
|
39
|
-
// 真机由来:群里要求「随时都能打开」,agent 自己造了份配置用 `--config` 把回收全关(2026-07-30)。
|
|
40
|
-
// 「要不要长期把一个目录暴露在局域网」是 operator 的决定,不该被一句话说服;故给正规入口 = 按群覆盖,
|
|
41
|
-
// 同时禁掉 agent 用 `--config` 换策略(bin 侧)。
|
|
42
|
-
function normalizeOptions(raw, perGroup) {
|
|
43
|
-
const base = raw && typeof raw === 'object' ? raw : {}
|
|
44
|
-
const over = perGroup && typeof perGroup === 'object' ? perGroup : {}
|
|
45
|
-
const o = { ...base, ...over }
|
|
46
|
-
const num = (v, d) => (Number.isFinite(Number(v)) && Number(v) >= 0 ? Number(v) : d)
|
|
47
|
-
let from = Math.trunc(num(o.portFrom, PORT_FROM))
|
|
48
|
-
let to = Math.trunc(num(o.portTo, PORT_TO))
|
|
49
|
-
if (from < 1 || from > 65535) from = PORT_FROM
|
|
50
|
-
if (to < from || to > 65535) to = Math.min(65535, Math.max(from, PORT_TO > from ? PORT_TO : from))
|
|
51
|
-
const idleMin = Math.trunc(num(o.idleMin, IDLE_MIN_DEFAULT))
|
|
52
|
-
const maxHours = num(o.maxHours, MAX_HOURS_DEFAULT)
|
|
53
|
-
return {
|
|
54
|
-
from, to, idleMin, maxHours,
|
|
55
|
-
host: typeof o.host === 'string' && o.host.trim() ? o.host.trim() : '0.0.0.0',
|
|
56
|
-
idleMs: idleMin > 0 ? idleMin * 60_000 : 0,
|
|
57
|
-
maxMs: maxHours > 0 ? Math.round(maxHours * 3_600_000) : 0,
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ── 端口 ─────────────────────────────────────────────────────────────────────
|
|
62
|
-
// FNV-1a:要的只是「同 chatId 恒同起点、不同 chatId 散得开」,不是密码学强度。
|
|
63
|
-
function _hash(s) {
|
|
64
|
-
let h = 0x811c9dc5
|
|
65
|
-
const str = String(s == null ? '' : s)
|
|
66
|
-
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0 }
|
|
67
|
-
return h >>> 0
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// 该群的首选端口:同群恒定 ⇒ 「同一项目固定复用一个端口」在机制层兑现(v0.8.1 清单只写了约定)。
|
|
71
|
-
function preferredPort(chatId, { from = PORT_FROM, to = PORT_TO } = {}) {
|
|
72
|
-
const lo = Math.min(from, to); const hi = Math.max(from, to)
|
|
73
|
-
const span = hi - lo + 1
|
|
74
|
-
return lo + (_hash(chatId) % span)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// 绑定候选序:首选端口起环形扫全段。**由 worker 按序试绑**(不在受理侧先探再交给 worker 绑 ——
|
|
78
|
-
// 那中间有竞态窗,受理侧打印出去的 URL 可能绑不上,agent 就把死链发进群了)。受理侧改为回读
|
|
79
|
-
// worker 写下的实际端口再打印,URL 恒真。`only` = --port 显式指定:候选只有它,占用就报人话。
|
|
80
|
-
function portCandidates(chatId, { from = PORT_FROM, to = PORT_TO, only } = {}) {
|
|
81
|
-
const lo = Math.min(from, to); const hi = Math.max(from, to)
|
|
82
|
-
if (only != null) return [Number(only)]
|
|
83
|
-
const span = hi - lo + 1
|
|
84
|
-
const start = preferredPort(chatId, { from: lo, to: hi })
|
|
85
|
-
const out = []
|
|
86
|
-
for (let i = 0; i < span; i++) out.push(lo + ((start - lo + i) % span))
|
|
87
|
-
return out
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ── 围栏 ─────────────────────────────────────────────────────────────────────
|
|
91
|
-
// 无论在哪一层目录都不服务的东西。点开头的一律拒(覆盖 .git/.env/.ssh/.DS_Store…),这里只列
|
|
92
|
-
// 点号之外的。⚖ 拍板 B:denylist 起步而非扩展名白名单 —— 白名单会挡掉正常原型资源(wasm/字体/
|
|
93
|
-
// 视频/自造扩展名),真机踩到再收紧。
|
|
94
|
-
const DENY_NAMES = new Set(['node_modules'])
|
|
95
|
-
const DENY_EXT = new Set(['.pem', '.key', '.p12', '.pfx', '.keystore', '.jks', '.asc', '.ppk'])
|
|
96
|
-
const DENY_FILE_RE = [/^sbot\.config.*\.json$/i, /^id_(rsa|dsa|ecdsa|ed25519)$/i]
|
|
97
|
-
|
|
98
|
-
// 单个路径段是否拒绝服务。段级判定(不是整串正则)⇒ 任何层级的 node_modules/.git 都挡得住。
|
|
99
|
-
function isDeniedSegment(seg) {
|
|
100
|
-
const s = String(seg || '')
|
|
101
|
-
if (!s || s === '.' || s === '..') return true
|
|
102
|
-
if (s.startsWith('.')) return true // 点文件/点目录:.git/.env/.ssh/.claude…
|
|
103
|
-
if (DENY_NAMES.has(s.toLowerCase())) return true
|
|
104
|
-
if (DENY_EXT.has(path.extname(s).toLowerCase())) return true
|
|
105
|
-
return DENY_FILE_RE.some((re) => re.test(s))
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const isDenied = (rel) => String(rel || '').split(/[\\/]/).filter(Boolean).some(isDeniedSegment)
|
|
109
|
-
|
|
110
|
-
// serveRoot 裁决。**故意是 resolveSendPath(feishu.js)的超集**:同样的 cwd 围栏 + 多两层 ——
|
|
111
|
-
// ① realpath 判定:resolveSendPath 不解析符号链接。单文件外发时无所谓,但一个要被 HTTP handler
|
|
112
|
-
// **遍历**的目录里,只要有一条指向 ~/ 的软链就是逃逸。故受理时判 realpath 仍在 workdir 内。
|
|
113
|
-
// ② 默认拒绝项目根:v0.8.1 那起事故的实质就是「整个项目目录被暴露一周」。要这么干必须显式
|
|
114
|
-
// --project-root,且受理时往群里发一行告知(bin 侧),不静默放行。
|
|
115
|
-
// 不 require feishu.js 复用那 4 行:那会把 lark SDK 拖进一个完全不需要它的长命 worker 进程。
|
|
116
|
-
// 注入 realpathSync/statSync ⇒ 纯逻辑可单测。
|
|
117
|
-
function resolveServeTarget({ cwd, rel, projectRoot = false, realpathSync = fs.realpathSync, statSync = fs.statSync } = {}) {
|
|
118
|
-
if (!rel || !String(rel).trim()) throw new Error('缺少路径:sbot preview start <项目内相对路径>')
|
|
119
|
-
const p = String(rel)
|
|
120
|
-
if (path.isAbsolute(p)) throw new Error('只允许项目目录内的相对路径,拒绝绝对路径:' + p)
|
|
121
|
-
if (p.split(/[\\/]/).includes('..')) throw new Error("路径不得包含 '..':" + p)
|
|
122
|
-
const root = path.resolve(cwd || process.cwd())
|
|
123
|
-
const abs = path.resolve(root, p)
|
|
124
|
-
if (abs !== root && !abs.startsWith(root + path.sep)) throw new Error('路径超出项目目录:' + p)
|
|
125
|
-
let st
|
|
126
|
-
try { st = statSync(abs) } catch { throw new Error('路径不存在:' + p) }
|
|
127
|
-
// realpath 容器判定(软链逃逸):两边都解析后再比,否则 workdir 自身带软链时会误判越界
|
|
128
|
-
let realRoot; let realAbs
|
|
129
|
-
try { realRoot = realpathSync(root); realAbs = realpathSync(abs) } catch (e) {
|
|
130
|
-
throw new Error(`路径解析失败(${(e && e.code) || (e && e.message)})—— 无法确认它在项目目录内,不敢服务`)
|
|
131
|
-
}
|
|
132
|
-
if (realAbs !== realRoot && !realAbs.startsWith(realRoot + path.sep)) throw new Error('路径经符号链接指到了项目目录之外,拒绝服务:' + p)
|
|
133
|
-
const serveRoot = st.isDirectory() ? realAbs : path.dirname(realAbs)
|
|
134
|
-
const entry = st.isDirectory() ? 'index.html' : path.basename(realAbs)
|
|
135
|
-
// ⚠ 必须查**整串**而不只是 entry 的文件名:只查文件名的话,`start .private/proto` 会把一个点目录
|
|
136
|
-
// 下的整棵子树挂上局域网(denied 的那几段已经烘进 serveRoot,每请求的判定再也看不到它们)。
|
|
137
|
-
// 文档承诺的是「无论在哪一层」,这里就得真按整串兑现。以 realRoot 起算,projectRoot 情形是空串。
|
|
138
|
-
const relFromRoot = path.relative(realRoot, realAbs)
|
|
139
|
-
if (isDenied(relFromRoot)) throw new Error('这个路径不在可服务范围内(点目录/点文件、node_modules、密钥、配置一律不服务):' + p)
|
|
140
|
-
if (serveRoot === realRoot && !projectRoot) {
|
|
141
|
-
throw new Error(`会把整个项目目录暴露到局域网 —— 把原型放到子目录(如 design/prototype/)再起;\n确实要服务项目根请显式加 --project-root(会在群里告知)`)
|
|
142
|
-
}
|
|
143
|
-
if (st.isDirectory()) {
|
|
144
|
-
// 目录形态必须有 index.html:不做目录列表(列表 = 把目录结构送出去),也不猜首页
|
|
145
|
-
let ist
|
|
146
|
-
try { ist = statSync(path.join(serveRoot, 'index.html')) } catch { ist = null }
|
|
147
|
-
if (!ist || !ist.isFile()) throw new Error(`${p} 里没有 index.html —— 目录形态需要它当首页(不做目录列表);或直接把那个 .html 文件传给 start`)
|
|
148
|
-
}
|
|
149
|
-
// single = 传进来的是**一个文件** ⇒ 只服务这一个文件,兄弟文件一律 404。
|
|
150
|
-
// 真机由来(2026-07-30):agent 把原型放在 design/ 下、传的是文件,结果整个 design 目录(20 个文件:
|
|
151
|
-
// board-history.jsonl、diagrams 全部源码、十几张历史图)都上了局域网 —— 而它对用户说的是「仅暴露
|
|
152
|
-
// design 目录中的原型文件」。这不只是暴露面问题,是**让 agent 对用户说了假话**。自包含单文件本来
|
|
153
|
-
// 不需要任何兄弟文件(那正是推荐路径);要带 assets 的原型就传目录,语义与暴露面从此一致。
|
|
154
|
-
return { serveRoot, entry, single: !st.isDirectory(), projectRoot: serveRoot === realRoot }
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// worker 侧的记录复核。**为什么需要**:`--_worker` 名义上是内部入口,但 `BASE_SKILL_ALLOWS` 放行的是
|
|
158
|
-
// `Bash(sbot preview *)` ⇒ agent 够得着,而 worker 原本对记录内容一个字都不校验(围栏只在受理侧跑过)。
|
|
159
|
-
// 实测(v0.9.0 提交后自查):自造一份 `{serveRoot:"$HOME", projectRoot:true}` 的记录喂给 `--_worker`,
|
|
160
|
-
// 整个家目录就被挂上去了(`~/Projects/sbot/package.json` 返 200)—— 受理侧那套「拒项目根 / 整串
|
|
161
|
-
// denylist / workdir 围栏」被完整绕过。**记录是不可信输入**,围栏必须在这里重跑一遍。
|
|
162
|
-
//
|
|
163
|
-
// 锚点取 **operator 配置里该群的 workdir**,而不是记录自称的 workdir —— 后者同样是攻击者可写的
|
|
164
|
-
// (伪造 `workdir:"/" + projectRoot:true` 就能自洽通过)。残余风险:能往 `~/.sbot/preview/` 写文件
|
|
165
|
-
// 或能伪造配置文件的对手仍可绕过 —— 但那种对手已经能直接读文件了,围栏的职责是不让 sbot 自己的
|
|
166
|
-
// 工具变成「把任意目录挂上局域网」的便捷入口。
|
|
167
|
-
function verifyRecordFence(rec, { anchorWorkdir, realpathSync = fs.realpathSync, statSync = fs.statSync } = {}) {
|
|
168
|
-
if (!rec || !rec.serveRoot || !rec.entry) throw new Error('记录缺 serveRoot/entry')
|
|
169
|
-
const root = anchorWorkdir || rec.workdir
|
|
170
|
-
if (!root) throw new Error('记录缺 workdir 锚点')
|
|
171
|
-
// ⚠ 锚点必须先 realpath 再算相对路径:`serveRoot` 是受理时 realpath 过的,而锚点来自
|
|
172
|
-
// **operator 手写的配置**(或 rec.workdir),完全可能走软链(`/Users/x/proj` → `/Volumes/data/proj`)。
|
|
173
|
-
// 两者文本根不同 ⇒ path.relative 会吐出 `../../..`,把**合法**预览一并拒掉(误杀,实现时被单测抓到)。
|
|
174
|
-
let base = root
|
|
175
|
-
try { base = realpathSync(root) } catch { /* 锚点目录不在了:用原值,下面 statSync 会拒 */ }
|
|
176
|
-
// ⚠ 必须**按记录声明的形态**去重跑裁决,不能一律拼 serveRoot+entry:那样算出来的永远是「文件
|
|
177
|
-
// 路径」⇒ 裁决恒返回 single=true,目录形态的记录必然被判「与裁决不符」而全部起不来(实现时
|
|
178
|
-
// 单文件/目录两种模式都跑了一遍才抓到 —— 只测一种就会带着「目录预览全挂」发版)。
|
|
179
|
-
// single=true(文件形态)⇒ 用 serveRoot/entry 这个文件复核,期望裁决也说 single=true
|
|
180
|
-
// single=false(目录形态)⇒ 用 serveRoot 这个目录复核,期望裁决说 single=false 且 entry=index.html
|
|
181
|
-
// 谎报形态也逃不掉:目录形态照样要过 index.html 存在、denylist、拒项目根、realpath 容器判定。
|
|
182
|
-
const rel = rec.single ? path.relative(base, path.join(rec.serveRoot, rec.entry)) : path.relative(base, rec.serveRoot)
|
|
183
|
-
const t = resolveServeTarget({ cwd: base, rel, projectRoot: !!rec.projectRoot, realpathSync, statSync })
|
|
184
|
-
if (t.serveRoot !== rec.serveRoot || t.entry !== rec.entry) throw new Error('serveRoot/entry 与围栏裁决不符')
|
|
185
|
-
// single 要与裁决自洽(文件形态的 entry 必须真是文件、目录形态必须真是目录且有 index.html)。
|
|
186
|
-
// ⚠ 别把它当安全边界:目录形态本来就是**合法可选**的(agent 直接 `start <目录>` 即可拿整个目录),
|
|
187
|
-
// 所以改记录里的 single 并不能拿到「本来拿不到的东西」。single 的价值是**最小暴露的默认值** ——
|
|
188
|
-
// 要一个文件就只给这一个文件,好让 agent 那句「仅暴露原型文件」是真话(2026-07-30 真机:它这么
|
|
189
|
-
// 说了,而实际整个 design 目录 20 个文件都在局域网上)。
|
|
190
|
-
if (!!rec.single !== !!t.single) throw new Error('single(单文件模式)与围栏裁决不符')
|
|
191
|
-
return t
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// 每个请求的路径裁决(worker 热路径)。token 做**路径前缀**而非 query,是为了让页面里的**相对**
|
|
195
|
-
// 资源引用照常工作(绝对引用 /assets/x.css 会失效 —— 与「首选自包含单文件」的约定一致)。
|
|
196
|
-
// 返回 { ok:true, abs } 或 { ok:false, code, reason }:一律 404 不回显细节,免得成探测器。
|
|
197
|
-
function resolveRequestPath({ serveRoot, urlPath, token, entry = 'index.html', single = false, realpathSync = fs.realpathSync, statSync = fs.statSync } = {}) {
|
|
198
|
-
let raw = String(urlPath || '')
|
|
199
|
-
const q = raw.search(/[?#]/)
|
|
200
|
-
if (q >= 0) raw = raw.slice(0, q)
|
|
201
|
-
let segs
|
|
202
|
-
try { segs = raw.split('/').filter((s) => s !== '').map((s) => decodeURIComponent(s)) } catch { return { ok: false, code: 400, reason: 'bad-encoding' } }
|
|
203
|
-
if (!segs.length || segs[0] !== token) return { ok: false, code: 404, reason: 'token' } // 局域网扫到端口也拿不到内容
|
|
204
|
-
const rest = segs.slice(1)
|
|
205
|
-
// 目录形态的裸访问(/<token> 或 /<token>/)⇒ 首页;其余逐段过围栏(编码后的 ..%2f 在这里已解码)
|
|
206
|
-
const parts = rest.length ? rest : [entry]
|
|
207
|
-
if (parts.some(isDeniedSegment)) return { ok: false, code: 404, reason: 'denied' }
|
|
208
|
-
// 单文件模式:只有 entry 本身可取,兄弟文件一律 404(见 resolveServeTarget 的 single 注释)
|
|
209
|
-
if (single && !(parts.length === 1 && parts[0] === entry)) return { ok: false, code: 404, reason: 'not-entry' }
|
|
210
|
-
const abs = path.resolve(serveRoot, ...parts)
|
|
211
|
-
if (abs !== serveRoot && !abs.startsWith(serveRoot + path.sep)) return { ok: false, code: 404, reason: 'escape' }
|
|
212
|
-
let real
|
|
213
|
-
try { real = realpathSync(abs) } catch { return { ok: false, code: 404, reason: 'missing' } }
|
|
214
|
-
if (real !== serveRoot && !real.startsWith(serveRoot + path.sep)) return { ok: false, code: 404, reason: 'symlink-escape' }
|
|
215
|
-
let st
|
|
216
|
-
try { st = statSync(real) } catch { return { ok: false, code: 404, reason: 'missing' } }
|
|
217
|
-
if (st.isDirectory()) return { ok: false, code: 404, reason: 'directory' } // 无目录列表
|
|
218
|
-
if (!st.isFile()) return { ok: false, code: 404, reason: 'not-file' }
|
|
219
|
-
return { ok: true, abs: real, size: st.size, mtimeMs: st.mtimeMs }
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// ── 记录 ─────────────────────────────────────────────────────────────────────
|
|
223
|
-
function newRecord({ chatId, workdir, serveRoot, entry, label, port, candidates, host, idleMs, maxMs, projectRoot, single, configPath }) {
|
|
224
|
-
const cands = Array.isArray(candidates) && candidates.length ? candidates.slice() : (port ? [port] : [])
|
|
225
|
-
return {
|
|
226
|
-
id: crypto.randomBytes(5).toString('hex'),
|
|
227
|
-
token: crypto.randomBytes(5).toString('hex'), // 能力 URL:扫到端口≠拿到内容
|
|
228
|
-
chatId, workdir, serveRoot, entry,
|
|
229
|
-
configPath: configPath || null, // worker 复核围栏时要读同一份配置取该群 workdir 作锚点(imagen 同款)
|
|
230
|
-
label: label || '', host: host || '0.0.0.0',
|
|
231
|
-
// 候选序在受理侧算好(纯函数可单测),worker 只按序试绑 —— 绑定权在 worker,受理侧回读实际端口
|
|
232
|
-
candidates: cands,
|
|
233
|
-
port: port || cands[0] || null, // 受理时是首选值,worker 绑成功后写实际值
|
|
234
|
-
projectRoot: !!projectRoot,
|
|
235
|
-
single: !!single, // 只服务 entry 这一个文件(文件形态);目录形态为 false
|
|
236
|
-
idleMs: idleMs == null ? IDLE_MIN_DEFAULT * 60_000 : idleMs,
|
|
237
|
-
maxMs: maxMs == null ? MAX_HOURS_DEFAULT * 3_600_000 : maxMs,
|
|
238
|
-
pid: null, startedAt: Date.now(), lastReqAt: null, reqCount: 0,
|
|
239
|
-
status: 'starting', // starting → running → stopped | fail
|
|
240
|
-
stoppedReason: null, notifyPending: false,
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const recordFile = (dir, id) => path.join(dir, id + '.json')
|
|
245
|
-
|
|
246
|
-
function writeRecord(dir, rec) {
|
|
247
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
248
|
-
atomicWriteJSON(recordFile(dir, rec.id), rec)
|
|
249
|
-
return rec
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function readRecord(file) {
|
|
253
|
-
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function listRecords(dir) {
|
|
257
|
-
let names
|
|
258
|
-
try { names = fs.readdirSync(dir) } catch { return [] }
|
|
259
|
-
const out = []
|
|
260
|
-
for (const n of names) {
|
|
261
|
-
if (!/^[0-9a-f]+\.json$/.test(n)) continue // 跳过 .log 等
|
|
262
|
-
try { out.push(readRecord(path.join(dir, n))) } catch { /* 损坏条目跳过 */ }
|
|
263
|
-
}
|
|
264
|
-
return out.sort((a, b) => (a.startedAt || 0) - (b.startedAt || 0))
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function pidAlive(pid) {
|
|
268
|
-
if (!pid) return false
|
|
269
|
-
try { process.kill(pid, 0); return true } catch { return false }
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// 「活着的」= starting/running 且(未接手 or 进程在)。starting 也算活 —— 防受理竞态起两个。
|
|
273
|
-
function isLive(rec, { isAlive = pidAlive } = {}) {
|
|
274
|
-
if (!rec || (rec.status !== 'running' && rec.status !== 'starting')) return false
|
|
275
|
-
if (rec.pid == null) return true
|
|
276
|
-
return isAlive(rec.pid)
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// 本群当前那一个(一群最多一个,见 docs/PREVIEW.md)。多条并存(异常残留)取最新,旧的交给 reaper。
|
|
280
|
-
function findLive(dir, chatId, { isAlive = pidAlive } = {}) {
|
|
281
|
-
const live = listRecords(dir).filter((r) => r && r.chatId === chatId && isLive(r, { isAlive }))
|
|
282
|
-
return live.length ? live[live.length - 1] : null
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// 杀 worker。它没有子进程(就是个 node http 服务),故 POSIX 一发 SIGTERM 即可;win32 沿用仓库
|
|
286
|
-
// 既有的 taskkill 惯例。**杀完由调用方 markStopped**(「谁杀谁写」)。bin 与控制台共用这一份。
|
|
287
|
-
function killWorker(pid) {
|
|
288
|
-
if (!pid) return
|
|
289
|
-
try {
|
|
290
|
-
if (process.platform === 'win32') require('node:child_process').spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true })
|
|
291
|
-
else process.kill(pid, 'SIGTERM')
|
|
292
|
-
} catch { /* 已经死了 */ }
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// 标记停止(收割方调用 —— 「谁杀谁写」)。notify=true 的会被 reaper 拾起发群一行。
|
|
296
|
-
function markStopped(dir, rec, reason, { notify = false } = {}) {
|
|
297
|
-
const next = { ...rec, status: 'stopped', stoppedReason: reason, stoppedAt: Date.now(), notifyPending: !!notify }
|
|
298
|
-
writeRecord(dir, next)
|
|
299
|
-
return next
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// 历史记录修剪:只删已停的老条目,活的一概不动。
|
|
303
|
-
function pruneRecords(dir, { keep = KEEP_STOPPED, isAlive = pidAlive } = {}) {
|
|
304
|
-
const stopped = listRecords(dir).filter((r) => r && !isLive(r, { isAlive }))
|
|
305
|
-
const drop = stopped.slice(0, Math.max(0, stopped.length - keep))
|
|
306
|
-
for (const r of drop) {
|
|
307
|
-
try { fs.unlinkSync(recordFile(dir, r.id)) } catch {}
|
|
308
|
-
try { fs.unlinkSync(path.join(dir, r.id + '.log')) } catch {} // worker 的 stdio 落盘,别只删记录留一堆日志
|
|
309
|
-
}
|
|
310
|
-
return drop.length
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// ── URL 与展示 ───────────────────────────────────────────────────────────────
|
|
314
|
-
function buildUrl(rec, hostAddr) {
|
|
315
|
-
const h = hostAddr || (rec && rec.host && rec.host !== '0.0.0.0' && rec.host !== '::' ? rec.host : '127.0.0.1')
|
|
316
|
-
return `http://${h}:${rec.port}/${rec.token}/${rec.entry}`
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const LOOPBACK_RE = /^(127\.0\.0\.1|localhost|::1)$/i
|
|
320
|
-
|
|
321
|
-
// 可点链接用哪个地址:绑了环回就只能是 127.0.0.1;绑 0.0.0.0 则取本机第一个 LAN IP(群成员够得着,
|
|
322
|
-
// 同控制台口径)。**CLI / doctor / 控制台三处共用这一份** —— 此前是三处各自内联同一段判断(其中
|
|
323
|
-
// 控制台还把正则重抄了一遍),属「CLI 与控制台同源,改一处两边同步」要防的那种漂移。
|
|
324
|
-
function hostForUrl(rec, ips = []) {
|
|
325
|
-
if (rec && rec.host && LOOPBACK_RE.test(String(rec.host))) return '127.0.0.1'
|
|
326
|
-
return (Array.isArray(ips) && ips[0]) || '127.0.0.1'
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// serveRoot 的展示形态:项目内则给相对路径(`.` = 项目根),否则原样。
|
|
330
|
-
// 两处讲究:① 前缀比对**带分隔符守卫**(否则 workdir `/a/proj` 会把 `/a/project-x` 当成自己的子目录);
|
|
331
|
-
// ② serveRoot 是 realpath 过的、workdir 是 process.cwd() 原样 —— workdir 带软链时两者前缀对不上,
|
|
332
|
-
// 故 realpath 一次再比(比不出来就退绝对路径,只是显示难看,不影响判定)。
|
|
333
|
-
function displayRoot(rec, { realpathSync = fs.realpathSync } = {}) {
|
|
334
|
-
const abs = (rec && rec.serveRoot) || ''
|
|
335
|
-
const root = (rec && rec.workdir) || ''
|
|
336
|
-
if (!abs || !root) return abs
|
|
337
|
-
let real = root
|
|
338
|
-
try { real = realpathSync(root) } catch { /* 目录没了/读不动:用原值试 */ }
|
|
339
|
-
for (const base of root === real ? [root] : [root, real]) {
|
|
340
|
-
if (!base) continue
|
|
341
|
-
if (abs === base) return '.'
|
|
342
|
-
const withSep = base.endsWith(path.sep) ? base : base + path.sep
|
|
343
|
-
if (abs.startsWith(withSep)) return abs.slice(withSep.length) || '.'
|
|
344
|
-
}
|
|
345
|
-
return abs
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// 「实际服务到哪」的展示:single(文件形态)⇒ 到那个文件;目录形态 ⇒ 到目录。
|
|
349
|
-
// 控制台/doctor 都用它 —— 只显示 serveRoot 会让单文件模式看着像在服务整个目录(说谎)。
|
|
350
|
-
function displayServed(rec, opts) {
|
|
351
|
-
const root = displayRoot(rec, opts)
|
|
352
|
-
if (!rec || !rec.single || !rec.entry) return root
|
|
353
|
-
return root === '.' ? rec.entry : root + '/' + rec.entry
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// ── 回收判定 ─────────────────────────────────────────────────────────────────
|
|
357
|
-
// 顺序有讲究:先看进程死没死(dead 之后其它判断都没意义)→ 群还在不在 → 群暂停 → 总时长 → 闲置。
|
|
358
|
-
// 返回 null = 不动它。reason 决定要不要发群通知(unbound 不发 —— 群都没了)。
|
|
359
|
-
//
|
|
360
|
-
// `policy`(v0.9.3):**回收策略以配置为准,不用记录里的快照**。真机踩到(2026-07-31)——
|
|
361
|
-
// operator 在控制台勾了「常驻」、群里也收到「已设为常驻」通知,预览却照样按闲置 30 分钟停了:
|
|
362
|
-
// idleMs/maxMs 是 start 那一刻快照进记录的,之后改配置没人回头改它。而同一个收割器里 `paused`
|
|
363
|
-
// 一直是**每 tick 现读配置**的 ⇒ 同一个判定函数里两套口径,漏的是这一半。
|
|
364
|
-
// 缺省(policy=null)退回记录快照:CLI/测试直接调用时不必凭空造策略,行为与旧版一致。
|
|
365
|
-
function reapDecision(rec, { now = Date.now(), bound = true, paused = false, isAlive = pidAlive, policy = null } = {}) {
|
|
366
|
-
if (!rec || (rec.status !== 'running' && rec.status !== 'starting')) return null
|
|
367
|
-
const t = typeof now === 'function' ? now() : now
|
|
368
|
-
if (rec.pid != null && !isAlive(rec.pid)) return { reason: 'dead', notify: true }
|
|
369
|
-
// starting 迟迟不上位(worker 没起来/秒崩):给 60s 宽限,过后按 dead 收,免得永久占着「本群那一个」
|
|
370
|
-
if (rec.status === 'starting' && t - (rec.startedAt || 0) > 60_000) return { reason: 'dead', notify: false }
|
|
371
|
-
if (!bound) return { reason: 'unbound', notify: false }
|
|
372
|
-
// 群暂停 ⇒ 收但**不发消息**:「暂停 = 群里安静」是既有口径(cron 到点跳过、画布轮询跳过),
|
|
373
|
-
// 而暂停通知本身已经说了「期间消息不处理、定时任务与画布播报停发」。这里再补一条属于打扰。
|
|
374
|
-
if (paused) return { reason: 'paused', notify: false }
|
|
375
|
-
// 配置值优先(见上);只认有限数,坏配置退回快照而不是当成 0 —— 0 的语义是「永不回收」,
|
|
376
|
-
// 让一个写坏的字段把服务变成常驻,方向正好反了(fail-safe 要往「会停」那边倒)。
|
|
377
|
-
const pol = policy && typeof policy === 'object' ? policy : null
|
|
378
|
-
const maxMs = pol && Number.isFinite(pol.maxMs) ? pol.maxMs : rec.maxMs
|
|
379
|
-
const idleMs = pol && Number.isFinite(pol.idleMs) ? pol.idleMs : rec.idleMs
|
|
380
|
-
if (maxMs > 0 && t - (rec.startedAt || 0) > maxMs) return { reason: 'max', notify: true }
|
|
381
|
-
if (idleMs > 0 && t - (rec.lastReqAt || rec.startedAt || 0) > idleMs) return { reason: 'idle', notify: true }
|
|
382
|
-
return null
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// daemon 侧收割器(工厂全注入,同 createScheduler/createBoardWatch 风格)。职责三件:
|
|
386
|
-
// ① 该收的收(kill + 写 stopped) ② 拾起 notifyPending 发群一行(worker 自己闲置停下的那些也在这)
|
|
387
|
-
// ③ 修剪历史记录
|
|
388
|
-
// worker 自尽路径为什么还要 ② :worker 不带飞书 SDK(它只是个静态服务,不该为发一句话拖进 SDK)
|
|
389
|
-
// ⇒ 它把 notifyPending 立起来,由 daemon 代发。没 daemon 就没通知 —— 没 daemon 时群消息本来也发不出。
|
|
390
|
-
const NOTIFY_STUCK_MS = 5 * 60_000 // 一条通知发超过这么久仍未 settle ⇒ 认作卡死,放行重试
|
|
391
|
-
|
|
392
|
-
function createPreviewReaper({ dir, groupState, kill, notify, now = Date.now, isAlive = pidAlive, log = () => {}, tickMs = 60_000, setIntervalFn = setInterval, clearIntervalFn = clearInterval } = {}) {
|
|
393
|
-
let timer = null
|
|
394
|
-
// 正在发的 id → 开始时刻。清标志是异步的,慢发送遇上下一 tick 会重发,故要去重;但**不能只用
|
|
395
|
-
// Set** —— 若 platform.send 永不 settle(SDK 卡住),那条 id 会永久留在集合里,通知既不重试也不
|
|
396
|
-
// 上报、群里永远收不到。项目其它等待都有兜底(waitPreviewBound 4s / withFileLock / hardReleaseMs),
|
|
397
|
-
// 这里同理:超过 NOTIFY_STUCK_MS 就当它废了,放行重试。
|
|
398
|
-
const notifying = new Map()
|
|
399
|
-
const nowMs = () => (typeof now === 'function' ? now() : now)
|
|
400
|
-
function tick() {
|
|
401
|
-
let recs
|
|
402
|
-
try { recs = listRecords(dir) } catch (e) { log('preview reaper 读记录失败: ' + (e && e.message)); return }
|
|
403
|
-
for (const rec of recs) {
|
|
404
|
-
try {
|
|
405
|
-
const gs = (groupState && groupState(rec.chatId)) || { bound: true, paused: false }
|
|
406
|
-
const d = reapDecision(rec, { now: nowMs(), bound: gs.bound !== false, paused: !!gs.paused, isAlive, policy: gs })
|
|
407
|
-
if (d) {
|
|
408
|
-
// ⚠ 顺序要紧:**先落判定再杀**。反过来的话,worker 在收到信号前的那点时间里若还有 in-flight
|
|
409
|
-
// 请求,它会把 status 写回 'running' 并抹掉 notifyPending(实现期实测复现过)⇒ 群里收不到
|
|
410
|
-
// 「预览已停止」。先落盘,worker 那侧的写前回读就能看见判定并自己退场(两层防线)。
|
|
411
|
-
markStopped(dir, rec, d.reason, { notify: d.notify })
|
|
412
|
-
if (rec.pid != null && isAlive(rec.pid)) { try { kill(rec.pid) } catch (e) { log('preview kill 失败: ' + (e && e.message)) } }
|
|
413
|
-
log(`preview 已回收(${d.reason}): ${rec.label || rec.entry} :${rec.port}`)
|
|
414
|
-
continue // 通知留到下一 tick 统一走 ② —— 一个动作只做一件事,失败也只重试通知
|
|
415
|
-
}
|
|
416
|
-
// 策略对齐(v0.9.3):配置里的回收策略变了 ⇒ 写进记录,让 **worker 侧**的自尽判定也跟上。
|
|
417
|
-
// 收割方这边已经用配置值判过了(上面的 policy),但 worker 不认配置、只认磁盘这份记录 ——
|
|
418
|
-
// 少了这一步,operator 勾了常驻、daemon 这边不收了,worker 自己还是会到点把它停掉。
|
|
419
|
-
// 只动活记录;回读合并,别把 worker 刚写的 lastReqAt/reqCount 冲掉。
|
|
420
|
-
if (rec.status === 'running' || rec.status === 'starting') {
|
|
421
|
-
const wantIdle = Number.isFinite(gs.idleMs) ? gs.idleMs : rec.idleMs
|
|
422
|
-
const wantMax = Number.isFinite(gs.maxMs) ? gs.maxMs : rec.maxMs
|
|
423
|
-
if (wantIdle !== rec.idleMs || wantMax !== rec.maxMs) {
|
|
424
|
-
const cur = readRecordSafe(dir, rec)
|
|
425
|
-
// 回读后可能已被判停(worker 自尽/另一路收割);那就别把它写回活的样子
|
|
426
|
-
if (cur && (cur.status === 'running' || cur.status === 'starting')) {
|
|
427
|
-
writeRecord(dir, { ...cur, idleMs: wantIdle, maxMs: wantMax })
|
|
428
|
-
log(`preview 策略跟随配置更新(${rec.label || rec.entry} :${rec.port}): 闲置 ${wantIdle > 0 ? Math.round(wantIdle / 60_000) + ' 分钟' : '不回收'} · 时长 ${wantMax > 0 ? Math.round(wantMax / 3_600_000) + ' 小时' : '不限'}`)
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
if (rec.notifyPending) {
|
|
433
|
-
// 群暂停期间不发(同上口径)—— 且**清掉标志**而不是攒着:等群恢复时这条早就过时了,
|
|
434
|
-
// 攒着只会在恢复瞬间倒出一堆陈旧的「预览已停止」。
|
|
435
|
-
if (gs.paused) { writeRecord(dir, { ...rec, notifyPending: false }); continue }
|
|
436
|
-
const started = notifying.get(rec.id)
|
|
437
|
-
if (started != null && nowMs() - started < NOTIFY_STUCK_MS) continue // 上一轮还在发,别发第二条
|
|
438
|
-
if (started != null) log(`preview 通知疑似卡死(${Math.round((nowMs() - started) / 1000)}s),重试一次`)
|
|
439
|
-
notifying.set(rec.id, nowMs())
|
|
440
|
-
const text = buildStoppedMessage(rec, rec.stoppedReason)
|
|
441
|
-
Promise.resolve(notify(rec.chatId, text))
|
|
442
|
-
.then(() => writeRecord(dir, { ...readRecordSafe(dir, rec), notifyPending: false }))
|
|
443
|
-
.catch((e) => log('preview 通知失败(下轮重试): ' + (e && e.message)))
|
|
444
|
-
.finally(() => notifying.delete(rec.id))
|
|
445
|
-
}
|
|
446
|
-
} catch (e) { log('preview reaper: ' + (e && e.message)) }
|
|
447
|
-
}
|
|
448
|
-
try { pruneRecords(dir, { isAlive }) } catch {}
|
|
449
|
-
}
|
|
450
|
-
// 清 notifyPending 前重读:通知是异步的,期间记录可能被别处改过(比如同群又起了新预览)
|
|
451
|
-
function readRecordSafe(d, rec) {
|
|
452
|
-
try { return readRecord(recordFile(d, rec.id)) } catch { return rec }
|
|
453
|
-
}
|
|
454
|
-
return {
|
|
455
|
-
tick,
|
|
456
|
-
// 定时器注入(默认真 setInterval)⇒ 「start 幂等」「unref 了」这两条主张可被单测真验证,
|
|
457
|
-
// 而不是靠一条零断言的测试装作验过。
|
|
458
|
-
start() { if (timer) return; timer = setIntervalFn(tick, tickMs); if (timer && timer.unref) timer.unref() },
|
|
459
|
-
stop() { if (timer) clearIntervalFn(timer); timer = null },
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// ── 文案 ─────────────────────────────────────────────────────────────────────
|
|
464
|
-
// 自动停止**必须**发群一行:不通知的自动回收 = 群成员点链接打不开、以为 bot 坏了。
|
|
465
|
-
function buildStoppedMessage(rec, reason) {
|
|
466
|
-
const name = rec && (rec.label || rec.entry) ? (rec.label || rec.entry) : '预览'
|
|
467
|
-
const idleMin = Math.round(((rec && rec.idleMs) || 0) / 60_000)
|
|
468
|
-
const hours = Math.round((((rec && rec.maxMs) || 0) / 3_600_000) * 10) / 10
|
|
469
|
-
const again = '\n还要看就在群里说一声,我再起一个。'
|
|
470
|
-
switch (reason) {
|
|
471
|
-
case 'idle': return `⏹ 预览已停止:${name} — 闲置超过 ${idleMin} 分钟自动回收。${again}`
|
|
472
|
-
case 'max': return `⏹ 预览已停止:${name} — 已运行满 ${hours} 小时(单次上限)。${again}`
|
|
473
|
-
case 'paused': return `⏹ 预览已停止:${name} — 本群项目已暂停,预览一并停掉了。`
|
|
474
|
-
case 'dead': return `⚠ 预览意外停止:${name} — 链接暂时打不开了。${again}`
|
|
475
|
-
case 'console': return `⏹ 预览已停止:${name} — 由控制台手动停止。${again}`
|
|
476
|
-
default: return `⏹ 预览已停止:${name}。${again}`
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// 受理成功的 stdout(给 agent 看)。**这段话是本技能的主要交付物** —— 它替掉的正是「拖着本轮
|
|
481
|
-
// 不结束给服务续命」那个反射,所以「不要等待/不要拖轮次」必须写在最显眼处。
|
|
482
|
-
function buildStartedText(rec, url, { replaced = null, idleMin = 30, maxHours = 0, lan = true } = {}) {
|
|
483
|
-
const L = []
|
|
484
|
-
if (replaced) L.push(`(已替换上一个预览:${replaced.label || replaced.entry} :${replaced.port})`)
|
|
485
|
-
L.push(`预览已启动:${url}`)
|
|
486
|
-
// 绑定地址决定这句话的真假:环回绑定(preview.host 设成 127.0.0.1)时群成员**打不开**,
|
|
487
|
-
// 不能照抄「手机也能打开」—— 承诺必须与实际绑定一致,否则 agent 会把打不开的链接发进群。
|
|
488
|
-
L.push(lan
|
|
489
|
-
? `把这个链接发到群里即可(同一局域网的手机/电脑都能打开)。`
|
|
490
|
-
: `⚠ 本机预览只绑了环回地址,**局域网里的群成员打不开** —— 要给群里看,请让管理员把预览绑定地址改成 0.0.0.0;或者直接把 html 当文件发群里。`)
|
|
491
|
-
L.push(`服务已脱离本轮独立运行 —— **不要等待、不要轮询、更不要为了让链接有效而拖着本轮不结束**,现在就可以正常收尾。`)
|
|
492
|
-
// ⚠ 真机(2026-07-30):idleMin=0 时这里只说「用完请 stop」,没提总时长上限仍在 ⇒ agent 以为完全
|
|
493
|
-
// 不回收了,进而对用户说「不再按闲置或时长自动停止」。回收策略必须**逐项如实**,否则它照着编。
|
|
494
|
-
const hours = maxHours > 0 ? (Math.round(maxHours * 10) / 10) : 0
|
|
495
|
-
const stopHint = ';要提前停:preview stop 动作'
|
|
496
|
-
if (idleMin > 0) L.push(`闲置 ${idleMin} 分钟后自动停止(会在群里说一声),不用你记着关` + (hours ? `;最长运行 ${hours} 小时` : '') + stopHint)
|
|
497
|
-
else if (hours) L.push(`本群未设闲置回收,但**仍会在运行满 ${hours} 小时后自动停止**(会在群里说一声)` + stopHint)
|
|
498
|
-
else L.push('本群预览**不会自动停止**(闲置与时长上限都已由管理员关闭)—— 用完必须自己停掉,别把它一直挂在局域网上')
|
|
499
|
-
// 这整段是给 agent 看的操作反馈,而 agent 会把其中的要点转告群里 ⇒ 明确划线:能转的是
|
|
500
|
-
// 「链接 + 何时自动关」,工具名/命令/机器路径一律留在自己这边(产品对外只有一个身份)。
|
|
501
|
-
L.push(`转告群里时:只说链接和「什么时候会自动关」—— **不要把命令、工具名、机器上的目录路径写进群消息**。`)
|
|
502
|
-
return L.join('\n') + '\n'
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
function buildStatusLines(recs, { now = Date.now(), isAlive = pidAlive } = {}) {
|
|
506
|
-
const t = typeof now === 'function' ? now() : now
|
|
507
|
-
const live = recs.filter((r) => isLive(r, { isAlive }))
|
|
508
|
-
if (!live.length) return ['(当前没有运行中的预览)']
|
|
509
|
-
return live.map((r) => {
|
|
510
|
-
const mins = (ms) => (ms == null ? '—' : Math.round(ms / 60_000) + ' 分钟')
|
|
511
|
-
const idleLeft = r.idleMs > 0 ? Math.max(0, r.idleMs - (t - (r.lastReqAt || r.startedAt || 0))) : null
|
|
512
|
-
// 常驻(两个上限都关)必须明说 —— 否则 operator 巡检时看不出「这个永远不会自己停」
|
|
513
|
-
const policy = idleLeft != null ? `,闲置回收剩 ${mins(idleLeft)}`
|
|
514
|
-
: r.maxMs > 0 ? `,最长运行 ${Math.round(r.maxMs / 3_600_000)} 小时` : ',⚠ 常驻(不会自动停)'
|
|
515
|
-
return `${r.id} :${r.port} ${r.label || r.entry} 已运行 ${mins(t - (r.startedAt || 0))}` +
|
|
516
|
-
` 访问 ${r.reqCount || 0} 次` +
|
|
517
|
-
(r.lastReqAt ? `,最后 ${mins(t - r.lastReqAt)}前` : ',尚无访问') + policy
|
|
518
|
-
})
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// ── 单文件预览的关联素材(v0.11.29)────────────────────
|
|
522
|
-
// 反馈原话:「单文件预览未校验关联素材,导致资源 404」。**不是 bug 是设计**:v0.9.0 起
|
|
523
|
-
// 文件形态只服务 entry 那一个文件、兄弟文件一律 404(最小暴露面 —— 当时的教训是 agent 把
|
|
524
|
-
// 原型丢在 design/ 下,整个设计资产目录被挂上局域网)。⇒ 收紧本身要保留,缺的是**没人
|
|
525
|
-
// 告诉用户素材会丢**:链接发出去了,打开一看图全裂,而受理时一句提示都没有。
|
|
526
|
-
//
|
|
527
|
-
// 做法与本项目其它几处一致:**不阻断、把问题在受理那一刻就说清楚**,并给出两条正路
|
|
528
|
-
// (改服务目录 / 把资源内联)。⚠ 故意**不自动改成目录形态** —— 那会扩大暴露面,而暴露面
|
|
529
|
-
// 的变化必须是人的显式决定(v0.9.0/v0.9.1 两次教训都指向这条)。
|
|
530
|
-
|
|
531
|
-
// 从 HTML 里扫出「会被 single 形态 404 掉」的本地引用。
|
|
532
|
-
// 只认真正会发请求的那几种:link[href] / script[src] / img|video|audio|source|iframe[src]
|
|
533
|
-
// / srcset / 内联样式里的 url()。跳过:协议地址(http、//)、data:/blob:、#锚点、
|
|
534
|
-
// 绝对路径(/x —— 那本来也不归这个服务管)、以及模板占位({{x}}、${x})。
|
|
535
|
-
function scanLocalRefs(html, { max = 12 } = {}) {
|
|
536
|
-
const src = String(html == null ? '' : html)
|
|
537
|
-
const out = []
|
|
538
|
-
const push = (raw) => {
|
|
539
|
-
const u = String(raw || '').trim().replace(/^['"]|['"]$/g, '')
|
|
540
|
-
if (!u) return
|
|
541
|
-
if (/^(https?:)?\/\//i.test(u)) return // 外链
|
|
542
|
-
if (/^(data|blob|mailto|tel|javascript):/i.test(u)) return
|
|
543
|
-
if (u.startsWith('#') || u.startsWith('/')) return
|
|
544
|
-
if (/[{}$]/.test(u)) return // 模板占位,不是真路径
|
|
545
|
-
const clean = u.split(/[?#]/)[0]
|
|
546
|
-
if (!clean) return
|
|
547
|
-
if (!out.includes(clean)) out.push(clean)
|
|
548
|
-
}
|
|
549
|
-
for (const m of src.matchAll(/<link\b[^>]*?\bhref\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi)) push(m[1])
|
|
550
|
-
for (const m of src.matchAll(/<(?:script|img|video|audio|source|iframe|embed)\b[^>]*?\bsrc\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi)) push(m[1])
|
|
551
|
-
for (const m of src.matchAll(/\bsrcset\s*=\s*("[^"]*"|'[^']*')/gi)) {
|
|
552
|
-
for (const part of m[1].replace(/^['"]|['"]$/g, '').split(',')) push(part.trim().split(/\s+/)[0])
|
|
553
|
-
}
|
|
554
|
-
for (const m of src.matchAll(/url\(\s*("[^"]*"|'[^']*'|[^)]+)\s*\)/gi)) push(m[1])
|
|
555
|
-
return out.slice(0, max)
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// 受理时的提示。⚠ 措辞要点:①说清后果(打开会裂)而不是只报「有引用」②两条正路都给全
|
|
559
|
-
// ③**不说「失败」** —— 服务确实起来了,只是这些资源取不到。
|
|
560
|
-
function localRefsWarning(refs, { entry = '', dirHint = '' } = {}) {
|
|
561
|
-
if (!refs || !refs.length) return ''
|
|
562
|
-
const L = ['⚠ 这是**单文件**预览:只有 ' + entry + ' 本身能取到,它引用的下面这些会 404(页面打开会缺样式/图片):']
|
|
563
|
-
for (const r of refs) L.push(' · ' + r)
|
|
564
|
-
L.push('两条路二选一:')
|
|
565
|
-
L.push(' ① 想让它们也能取到 ⇒ 改成服务它们所在的目录:`sbot preview start ' + (dirHint || '<该文件所在目录>') + '`')
|
|
566
|
-
L.push(' ⚠ 那会把该目录下**所有**文件挂到局域网上 —— 先确认里面没有不该给人看的东西(设计资产、导出的数据、备份)')
|
|
567
|
-
L.push(' ② 想保持只暴露一个文件 ⇒ 把样式/脚本内联进 HTML、图片转成 data: URI,再重新起')
|
|
568
|
-
L.push('把这件事**在群里说清楚再发链接** —— 别让人点开看到一个裂的页面。')
|
|
569
|
-
return L.join('\n')
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
module.exports = {
|
|
573
|
-
PORT_FROM, PORT_TO, IDLE_MIN_DEFAULT, MAX_HOURS_DEFAULT, LASTREQ_DEBOUNCE_MS, KEEP_STOPPED,
|
|
574
|
-
previewDir, normalizeOptions,
|
|
575
|
-
preferredPort, portCandidates,
|
|
576
|
-
isDeniedSegment, isDenied, resolveServeTarget, verifyRecordFence, resolveRequestPath,
|
|
577
|
-
newRecord, recordFile, writeRecord, readRecord, listRecords, pidAlive, isLive, findLive, markStopped, pruneRecords, killWorker,
|
|
578
|
-
buildUrl, hostForUrl, displayRoot, displayServed, reapDecision, createPreviewReaper, NOTIFY_STUCK_MS,
|
|
579
|
-
buildStoppedMessage, buildStartedText, buildStatusLines,
|
|
580
|
-
scanLocalRefs, localRefsWarning,
|
|
581
|
-
}
|