sintbot 1.3.0 → 1.4.1
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 +50 -0
- package/CHANGELOG.json +50 -0
- package/README.md +138 -20
- package/README.zh-CN.md +144 -20
- package/assets/design-styles/acid-pop.md +155 -0
- package/assets/design-styles/archive-index.md +161 -0
- package/assets/design-styles/art-deco.md +164 -0
- package/assets/design-styles/cinematic-noir.md +178 -0
- package/assets/design-styles/editorial.md +121 -0
- package/assets/design-styles/geometric-pop.md +122 -0
- package/assets/design-styles/hand-drawn.md +117 -0
- package/assets/design-styles/islamic-geometry.md +155 -0
- package/assets/design-styles/liquid-glass.md +183 -0
- package/assets/design-styles/neo-chinese.md +136 -0
- package/assets/design-styles/precision-mono.md +156 -0
- package/assets/design-styles/quiet-luxury.md +163 -0
- package/assets/design-styles/riso-print.md +116 -0
- package/assets/design-styles/soft-friendly.md +174 -0
- package/assets/design-styles/swiss-grid.md +156 -0
- package/assets/design-styles/tech-dark.md +182 -0
- package/bin/sintbot-v2.js +291 -263
- package/docs/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/install.md +80 -48
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/zh-CN/install.md +80 -48
- package/install.sh +34 -81
- package/package.json +60 -5
- package/src/agent/claudecode.js +32 -3
- package/src/agent/claudemodels.js +60 -0
- package/src/agent/claudestatus.js +74 -0
- package/src/agent/claudeusage.js +200 -0
- package/src/agent/codexapp.js +21 -4
- package/src/agent/codexcode.js +8 -3
- package/src/agent/codexstatus.js +181 -0
- package/src/agent/privatecore.js +1 -1
- package/src/agent/privatecore.wasm +0 -0
- package/src/c2configcontrol.js +214 -0
- package/src/c2console.js +149 -0
- package/src/c2incident.js +125 -0
- package/src/c2install.js +247 -37
- package/src/c2migration.js +100 -0
- package/src/c2multigroupcapacity.js +103 -0
- package/src/c2multigroupservice.js +418 -0
- package/src/cdp.js +254 -0
- package/src/config.js +13 -2
- package/src/core/c2durablejournal.js +26 -9
- package/src/core/c2durableruntime.js +104 -1
- package/src/core/c2inputrejection.js +45 -0
- package/src/core/c2localeffectexecutor.js +12 -1
- package/src/core/c2payloadhandles.js +11 -0
- package/src/core/c2persistrelation.js +20 -0
- package/src/core/c2persiststate.js +28 -2
- package/src/core/c2platformrecoverysender.js +1 -1
- package/src/core/c2sourcequarantine.js +56 -1
- package/src/core/c2terminalprune.js +2 -1
- package/src/core/engineslotsadapter.js +108 -0
- package/src/core/issues.js +185 -0
- package/src/core/ledger.js +30 -0
- package/src/core/proxy.js +424 -0
- package/src/core/store.js +12 -0
- package/src/core/v2engine.js +427 -31
- package/src/design.js +5161 -0
- package/src/gitrepo.js +150 -0
- package/src/leftover.js +174 -0
- package/src/platform/slack.js +1294 -0
- package/src/platform/wecom.js +961 -0
- package/src/platform/wecomcli.js +128 -0
- package/src/platform/wecomname.js +43 -0
- package/src/platform/wecompoll.js +241 -0
- package/src/preview.js +581 -0
- package/src/projectmemory.js +175 -0
- package/src/ui/auth.js +31 -0
- package/src/ui/browser.html +392 -0
- package/src/ui/browserroutes.js +234 -0
- package/src/ui/console.html +5719 -0
- package/src/ui/dashboard.js +168 -0
- package/src/ui/feishulogin.js +136 -0
- package/src/ui/serverbase.js +2371 -0
- package/src/ui/sessions.js +78 -0
- package/src/ui/takeover.js +108 -0
- package/src/usage.js +144 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Operator tool for a group the service holds after an unresolved fault.
|
|
4
|
+
// With the service stopped and the writer lock held, it moves that group's
|
|
5
|
+
// journal unchanged under <data>/incidents/, records the source messages it
|
|
6
|
+
// left unsettled and the receipts it had settled, then gives the group a new
|
|
7
|
+
// empty journal under the same mapped name. Nothing is settled, rewritten,
|
|
8
|
+
// replayed or notified; every later start refuses those messages from
|
|
9
|
+
// incident.json (see loadC2IncidentQuarantine).
|
|
10
|
+
const fs = require('node:fs')
|
|
11
|
+
const path = require('node:path')
|
|
12
|
+
const { createHash } = require('node:crypto')
|
|
13
|
+
const { C2DurableJournal } = require('./core/c2durablejournal')
|
|
14
|
+
const { assertJournalScope, assertPrivateDirectory, VALIDATORS } = require('./c2install')
|
|
15
|
+
|
|
16
|
+
const GROUP_ID = /^oc_[A-Za-z0-9_-]{1,124}$/
|
|
17
|
+
const FEISHU_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,124}$/
|
|
18
|
+
const SCHEMA = 'sbot-c2-incident-seal-v1'
|
|
19
|
+
|
|
20
|
+
const sha256 = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex')
|
|
21
|
+
function privateFile(file, label) {
|
|
22
|
+
const st = fs.lstatSync(file)
|
|
23
|
+
if (!st.isFile() || st.isSymbolicLink() ||
|
|
24
|
+
typeof process.getuid === 'function' && st.uid !== process.getuid() ||
|
|
25
|
+
process.platform !== 'win32' && (st.mode & 0o077)) {
|
|
26
|
+
throw new Error(`${label} is not a private regular file owned by this user; preserve it and review`)
|
|
27
|
+
}
|
|
28
|
+
return st
|
|
29
|
+
}
|
|
30
|
+
function writeNew(file, value) {
|
|
31
|
+
const fd = fs.openSync(file, 'wx', 0o600)
|
|
32
|
+
try { fs.writeSync(fd, JSON.stringify(value, null, 2) + '\n'); fs.fsyncSync(fd) }
|
|
33
|
+
finally { fs.closeSync(fd) }
|
|
34
|
+
}
|
|
35
|
+
function fsyncDir(dir) {
|
|
36
|
+
const fd = fs.openSync(dir, 'r')
|
|
37
|
+
try { fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
38
|
+
}
|
|
39
|
+
const pad = n => String(n).padStart(2, '0')
|
|
40
|
+
function stamp(ms) {
|
|
41
|
+
const d = new Date(ms)
|
|
42
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sealC2GroupIncident({ installation, chatId, reason = 'operator sealed a held group',
|
|
46
|
+
now = Date.now, acquireLock = require('./core/instancelock').acquire, version = '' } = {}) {
|
|
47
|
+
if (typeof chatId !== 'string' || !GROUP_ID.test(chatId)) {
|
|
48
|
+
throw new Error('Name the held group with --group oc_…')
|
|
49
|
+
}
|
|
50
|
+
if (typeof reason !== 'string' || !reason.trim() || reason.length > 500) {
|
|
51
|
+
throw new Error('The incident reason must be 1 to 500 characters')
|
|
52
|
+
}
|
|
53
|
+
const { stateDir, routes } = installation || {}
|
|
54
|
+
const route = routes?.find(item => item.chatId === chatId)
|
|
55
|
+
if (!route) throw new Error('This group is not in the configuration')
|
|
56
|
+
if ((installation.addedRoutes || []).some(item => item.chatId === chatId)) {
|
|
57
|
+
throw new Error('This group has no journal yet; nothing to seal')
|
|
58
|
+
}
|
|
59
|
+
assertPrivateDirectory(stateDir)
|
|
60
|
+
const lockFile = path.join(stateDir, 'daemon.lock')
|
|
61
|
+
const lock = acquireLock(lockFile)
|
|
62
|
+
if (!lock?.ok) throw new Error('The service is running; stop it before sealing an incident')
|
|
63
|
+
const held = () => {
|
|
64
|
+
try { return fs.readFileSync(lockFile, 'utf8').trim() === String(process.pid) } catch { return false }
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const file = route.file
|
|
68
|
+
const stat = privateFile(file, 'The group journal')
|
|
69
|
+
const before = { sha256: sha256(file), bytes: stat.size }
|
|
70
|
+
let journal
|
|
71
|
+
try { journal = C2DurableJournal.open(file, VALIDATORS) } catch (error) {
|
|
72
|
+
throw new Error(`The group journal cannot be read (${error.message}); preserve it unchanged and review it manually`)
|
|
73
|
+
}
|
|
74
|
+
const snapshot = assertJournalScope(journal, chatId)
|
|
75
|
+
let settled = true
|
|
76
|
+
try { assertJournalScope(journal, chatId, { requireSettled: true }) } catch { settled = false }
|
|
77
|
+
if (settled) throw new Error('This group journal is settled; nothing to seal. Start the service normally')
|
|
78
|
+
if (sha256(file) !== before.sha256) {
|
|
79
|
+
throw new Error('Opening the group journal changed it; preserve every file for review')
|
|
80
|
+
}
|
|
81
|
+
const pending = snapshot.receipts.pending
|
|
82
|
+
if (pending.some(item => item.source !== 'message' || !FEISHU_MESSAGE_ID.test(item.sourceId))) {
|
|
83
|
+
throw new Error('The journal holds a receipt that is not a Feishu message; review it manually')
|
|
84
|
+
}
|
|
85
|
+
const incidents = path.join(stateDir, 'incidents')
|
|
86
|
+
fs.mkdirSync(incidents, { recursive: true, mode: 0o700 })
|
|
87
|
+
fs.chmodSync(incidents, 0o700)
|
|
88
|
+
const sealedAt = now()
|
|
89
|
+
const dir = path.join(incidents, `${stamp(sealedAt)}-group-${chatId.slice(-8)}`)
|
|
90
|
+
fs.mkdirSync(dir, { mode: 0o700 })
|
|
91
|
+
writeNew(path.join(dir, 'incident.json'), { schema: SCHEMA, chatId,
|
|
92
|
+
journal: path.basename(file), sha256: before.sha256, bytes: before.bytes,
|
|
93
|
+
cursor: snapshot.cursor,
|
|
94
|
+
pending: pending.map(item => ({ id: item.id, sourceId: item.sourceId })),
|
|
95
|
+
completedReceiptIds: snapshot.receipts.completed.map(item => item.id),
|
|
96
|
+
sealedAt, reason: reason.trim(),
|
|
97
|
+
handling: 'frozen unchanged; receipts not settled; turn not replayed; source messages refused at admission from incident.json',
|
|
98
|
+
tool: 'sintbot incident seal', version })
|
|
99
|
+
if (!held()) throw new Error('The writer lock was lost before sealing; preserve every file for review')
|
|
100
|
+
const sealed = path.join(dir, path.basename(file))
|
|
101
|
+
fs.renameSync(file, sealed)
|
|
102
|
+
fsyncDir(stateDir); fsyncDir(dir)
|
|
103
|
+
if (sha256(sealed) !== before.sha256 || fs.existsSync(file)) {
|
|
104
|
+
throw new Error('The sealed journal differs; preserve every file for review')
|
|
105
|
+
}
|
|
106
|
+
if (!held()) throw new Error('The writer lock was lost before the new journal; preserve every file for review')
|
|
107
|
+
C2DurableJournal.initialize(file, { state: { version: 2, seq: 0, lanes: {} },
|
|
108
|
+
receipts: { pending: [], completed: [] } }, VALIDATORS)
|
|
109
|
+
fs.chmodSync(file, 0o600)
|
|
110
|
+
const fresh = C2DurableJournal.open(file, VALIDATORS)
|
|
111
|
+
const empty = assertJournalScope(fresh, chatId, { requireSettled: true })
|
|
112
|
+
if (fresh.blocked || empty.cursor !== 0 || empty.eventCount !== 0 ||
|
|
113
|
+
empty.receipts.pending.length || empty.receipts.completed.length ||
|
|
114
|
+
empty.effects.size || empty.notices.size || Object.keys(empty.state.lanes).length) {
|
|
115
|
+
throw new Error('New group journal verification failed; preserve every file for review')
|
|
116
|
+
}
|
|
117
|
+
const result = Object.freeze({ incident: dir, sealed, sealedSha256: before.sha256,
|
|
118
|
+
newJournal: file, newJournalSha256: sha256(file),
|
|
119
|
+
refusedSources: pending.length, settledRemembered: snapshot.receipts.completed.length })
|
|
120
|
+
writeNew(path.join(dir, 'result.json'), result)
|
|
121
|
+
return result
|
|
122
|
+
} finally { lock.release() }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { sealC2GroupIncident }
|
package/src/c2install.js
CHANGED
|
@@ -5,49 +5,222 @@
|
|
|
5
5
|
const fs = require('node:fs')
|
|
6
6
|
const os = require('node:os')
|
|
7
7
|
const path = require('node:path')
|
|
8
|
-
const {
|
|
8
|
+
const { createHash } = require('node:crypto')
|
|
9
|
+
const { isDeepStrictEqual } = require('node:util')
|
|
10
|
+
const { loadConfig, normalizeConfig, platformsConfigured } = require('./config')
|
|
9
11
|
const { C2DurableJournal } = require('./core/c2durablejournal')
|
|
10
12
|
const { validatePersistentState } = require('./core/c2persiststate')
|
|
11
13
|
const { validatePersistentReceipts, validatePersistentRelation } = require('./core/c2persistrelation')
|
|
12
14
|
const VALIDATORS = { validateState: validatePersistentState,
|
|
13
15
|
validateReceipts: validatePersistentReceipts, validateRelation: validatePersistentRelation }
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
const GROUP_ID = /^oc_[A-Za-z0-9_-]{1,124}$/
|
|
18
|
+
const MANIFEST = 'c2-groups.json'
|
|
19
|
+
const SUPPORTED_RUNNERS = Object.freeze(['codex', 'claude'])
|
|
20
|
+
|
|
21
|
+
// The executable a route will spawn must answer --version before the route
|
|
22
|
+
// can serve. Successful probes are cached per executable for this process.
|
|
23
|
+
const probedExecutables = new Set()
|
|
24
|
+
function assertRunnerExecutable(provider, cfg, { spawnSync = require('node:child_process').spawnSync } = {}) {
|
|
25
|
+
const isCodex = provider?.runner === 'codex'
|
|
26
|
+
if (!isCodex && provider?.runner !== 'claude') throw new Error('Unsupported model runner')
|
|
27
|
+
const bin = isCodex ? (provider.bin || 'codex') : (cfg?.agent?.bin || 'claude')
|
|
28
|
+
const key = `${provider.runner}\0${bin}`
|
|
29
|
+
if (probedExecutables.has(key)) return true
|
|
30
|
+
const probe = spawnSync(bin, ['--version'], {
|
|
31
|
+
encoding: 'utf8', timeout: 10_000, maxBuffer: 64 * 1024,
|
|
32
|
+
env: { ...process.env, ...(provider.env || {}) } })
|
|
33
|
+
if (probe.error || probe.status !== 0 || !(isCodex ? /codex/i : /claude/i).test(probe.stdout || '')) {
|
|
34
|
+
throw new Error(isCodex
|
|
35
|
+
? 'Codex executable preflight failed; install and sign in to Codex, then check the configured executable.'
|
|
36
|
+
: 'Claude Code executable preflight failed; install and sign in to Claude Code, then check the configured executable.')
|
|
37
|
+
}
|
|
38
|
+
probedExecutables.add(key)
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function installationStateDir(dataDir) {
|
|
43
|
+
const stateDir = dataDir === undefined ? path.join(os.homedir(), '.sbot') : dataDir
|
|
44
|
+
if (typeof stateDir !== 'string' || !path.isAbsolute(stateDir) ||
|
|
45
|
+
stateDir !== path.resolve(stateDir) || stateDir === path.parse(stateDir).root) {
|
|
46
|
+
throw new Error('The data directory must be a normalized absolute private directory')
|
|
21
47
|
}
|
|
48
|
+
return stateDir
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function loadInstallation(explicitConfig, { dataDir, allowAddedGroups = false } = {}) {
|
|
52
|
+
const stateDir = installationStateDir(dataDir)
|
|
22
53
|
const configFile = explicitConfig || process.env.SBOT_CONFIG || path.join(stateDir, 'config.json')
|
|
23
54
|
if (!path.isAbsolute(configFile) || !fs.existsSync(configFile)) {
|
|
24
55
|
throw new Error('An existing configuration file with an absolute path is required')
|
|
25
56
|
}
|
|
26
|
-
|
|
57
|
+
return resolveInstallation(loadConfig(configFile), configFile, stateDir, { allowAddedGroups })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function loadInstallationFromRaw(raw, configFile, { dataDir, allowAddedGroups = false } = {}) {
|
|
61
|
+
const stateDir = installationStateDir(dataDir)
|
|
62
|
+
if (typeof configFile !== 'string' || !path.isAbsolute(configFile)) {
|
|
63
|
+
throw new Error('Configuration requires an absolute path')
|
|
64
|
+
}
|
|
65
|
+
return resolveInstallation(normalizeConfig(raw, configFile, { registerSecrets: false }), configFile, stateDir,
|
|
66
|
+
{ allowAddedGroups })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// An online group addition may extend the persisted mapping: every mapped
|
|
70
|
+
// group keeps its exact journal and each new group has no journal file yet.
|
|
71
|
+
function mappingExtends(persisted, manifest, stateDir) {
|
|
72
|
+
const groups = Array.isArray(persisted?.groups) ? persisted.groups : null
|
|
73
|
+
if (!groups || persisted.version !== 1 ||
|
|
74
|
+
!isDeepStrictEqual({ ...persisted, groups: manifest.groups }, manifest) ||
|
|
75
|
+
!groups.every(group => manifest.groups.some(item => isDeepStrictEqual(item, group)))) return false
|
|
76
|
+
return manifest.groups.filter(item => !groups.some(group => group.chatId === item.chatId))
|
|
77
|
+
.every(item => !fs.existsSync(path.join(stateDir, item.journal)))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resolveInstallation(cfg, configFile, stateDir, { allowAddedGroups = false } = {}) {
|
|
27
81
|
const configured = platformsConfigured(cfg)
|
|
28
|
-
const groups = Object.entries(cfg.groups || {})
|
|
29
|
-
if (groups.length
|
|
30
|
-
|
|
31
|
-
|
|
82
|
+
const groups = Object.entries(cfg.groups || {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
83
|
+
if (groups.length < 1 || groups.length > 32) {
|
|
84
|
+
throw new Error('This release requires 1 to 32 explicit Feishu groups')
|
|
85
|
+
}
|
|
32
86
|
if (path.resolve(cfg._path || '') !== path.resolve(configFile) ||
|
|
33
87
|
!configured.feishu || configured.wecom || configured.slack || cfg.wecom || cfg.slack ||
|
|
34
|
-
(cfg._groupPlatform?.inferred || []).length ||
|
|
35
|
-
|
|
36
|
-
group.platform !== 'feishu' || group.paused || group.board ||
|
|
37
|
-
group.crons && (!Array.isArray(group.crons) || group.crons.length) ||
|
|
38
|
-
provider?.runner !== 'codex' || group.codexSteer !== false || group.codexApp === true ||
|
|
39
|
-
cfg.alerts?.chatId || cfg.ui && cfg.ui.enabled !== false) {
|
|
40
|
-
throw new Error('This release accepts one explicit Feishu Codex message route, with codexSteer:false and without canvas, scheduled tasks, console or synthetic sources')
|
|
88
|
+
(cfg._groupPlatform?.inferred || []).length || cfg.alerts?.chatId) {
|
|
89
|
+
throw new Error('This release accepts explicit Feishu message routes without synthetic sources')
|
|
41
90
|
}
|
|
91
|
+
if (!Number.isSafeInteger(cfg.agent.maxConcurrent) || cfg.agent.maxConcurrent < 1 ||
|
|
92
|
+
cfg.agent.maxConcurrent > 32) throw new Error('maxConcurrent must be an integer from 1 to 32')
|
|
42
93
|
if (cfg.feishu.onlyWhenMentioned === true &&
|
|
43
94
|
!/^ou_[a-zA-Z0-9_-]+$/.test(cfg.feishu.botOpenId || '')) {
|
|
44
|
-
throw new Error('Mention-only messages require the Feishu botOpenId; otherwise set onlyWhenMentioned:false
|
|
95
|
+
throw new Error('Mention-only messages require the Feishu botOpenId; otherwise set onlyWhenMentioned:false')
|
|
96
|
+
}
|
|
97
|
+
const override = process.env.SBOT_C2_V2_JOURNAL
|
|
98
|
+
if (override && groups.length !== 1) throw new Error('A shared journal override cannot be used for multiple groups')
|
|
99
|
+
// A persisted mapping keeps each group's journal name, so a one-group
|
|
100
|
+
// installation (c2-v2.log) can grow without renaming live authority.
|
|
101
|
+
const manifestFile = path.join(stateDir, MANIFEST)
|
|
102
|
+
let persisted = null
|
|
103
|
+
if (fs.existsSync(manifestFile)) {
|
|
104
|
+
const st = fs.lstatSync(manifestFile)
|
|
105
|
+
if (!st.isFile() || st.isSymbolicLink() ||
|
|
106
|
+
typeof process.getuid === 'function' && st.uid !== process.getuid()) {
|
|
107
|
+
throw new Error('Group journal mapping is not an owned regular file')
|
|
108
|
+
}
|
|
109
|
+
persisted = JSON.parse(fs.readFileSync(manifestFile, 'utf8'))
|
|
45
110
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
111
|
+
const mappedJournal = new Map((Array.isArray(persisted?.groups) ? persisted.groups : [])
|
|
112
|
+
.filter(item => item && typeof item.chatId === 'string' && typeof item.journal === 'string')
|
|
113
|
+
.map(item => [item.chatId, item.journal]))
|
|
114
|
+
// A provider without an explicit runner has always meant Claude Code. The
|
|
115
|
+
// durable route requires the explicit name, so normalize it in memory only.
|
|
116
|
+
for (const provider of Object.values(cfg.providers || {})) {
|
|
117
|
+
if (provider && typeof provider === 'object' && provider.runner === undefined) provider.runner = 'claude'
|
|
118
|
+
}
|
|
119
|
+
const routes = groups.map(([chatId, group]) => {
|
|
120
|
+
if (!GROUP_ID.test(chatId) || !group || group.platform !== 'feishu') {
|
|
121
|
+
throw new Error('Every configured group must have an explicit Feishu identity')
|
|
122
|
+
}
|
|
123
|
+
if (group.paused !== undefined && typeof group.paused !== 'boolean') {
|
|
124
|
+
throw new Error('Group paused must be a boolean')
|
|
125
|
+
}
|
|
126
|
+
if (group.codexSteer !== undefined && typeof group.codexSteer !== 'boolean') {
|
|
127
|
+
throw new Error('Group codexSteer must be a boolean')
|
|
128
|
+
}
|
|
129
|
+
const provider = cfg.providers?.[group.provider || cfg.defaultProvider]
|
|
130
|
+
// Paused routes keep their original metadata, but cannot execute it. They
|
|
131
|
+
// must pass the full active-route checks before a later restart enables them.
|
|
132
|
+
if (!group.paused && (group.board ||
|
|
133
|
+
group.crons && (!Array.isArray(group.crons) || group.crons.length) ||
|
|
134
|
+
!SUPPORTED_RUNNERS.includes(provider?.runner) || group.codexApp === true)) {
|
|
135
|
+
throw new Error('Active groups require Codex or Claude Code without canvas, scheduled tasks or desktop App integration')
|
|
136
|
+
}
|
|
137
|
+
if (!group.paused && (typeof group.workdir !== 'string' || !path.isAbsolute(group.workdir) ||
|
|
138
|
+
!fs.existsSync(group.workdir) || !fs.statSync(group.workdir).isDirectory())) {
|
|
139
|
+
throw new Error('Each active group workdir must be an existing absolute project directory')
|
|
140
|
+
}
|
|
141
|
+
const base = mappedJournal.get(chatId) || (groups.length === 1 ? 'c2-v2.log' :
|
|
142
|
+
`c2-v2-group-${createHash('sha256').update(chatId).digest('hex').slice(0, 24)}.log`)
|
|
143
|
+
const file = override || path.join(stateDir, base)
|
|
144
|
+
if (!path.isAbsolute(file) || file !== path.resolve(file) ||
|
|
145
|
+
path.dirname(file) !== stateDir || !/^c2-v2(?:[._-][a-zA-Z0-9._-]+)?\.log$/.test(path.basename(file))) {
|
|
146
|
+
throw new Error('The journal must be a c2-v2*.log file directly in the installation state directory')
|
|
147
|
+
}
|
|
148
|
+
return { chatId, group, provider, file, paused: group.paused === true }
|
|
149
|
+
})
|
|
150
|
+
if (!routes.some(route => !route.paused)) throw new Error('At least one configured group must be active')
|
|
151
|
+
const manifest = { version: 1, groups: routes.map(({ chatId, file }) => ({ chatId, journal: path.basename(file) })) }
|
|
152
|
+
let addedRoutes = []
|
|
153
|
+
if (persisted) {
|
|
154
|
+
if (persisted.migrationSha256 !== undefined) {
|
|
155
|
+
if (!/^[a-f0-9]{64}$/.test(persisted.migrationSha256)) {
|
|
156
|
+
throw new Error('Group journal migration binding is invalid')
|
|
157
|
+
}
|
|
158
|
+
manifest.migrationSha256 = persisted.migrationSha256
|
|
159
|
+
}
|
|
160
|
+
if (!isDeepStrictEqual(persisted, manifest)) {
|
|
161
|
+
if (!(allowAddedGroups && mappingExtends(persisted, manifest, stateDir))) {
|
|
162
|
+
throw new Error('Group journal mapping differs; preserve existing authority and review configuration migration')
|
|
163
|
+
}
|
|
164
|
+
addedRoutes = routes.filter(route => !mappedJournal.has(route.chatId))
|
|
165
|
+
}
|
|
166
|
+
const expected = new Set(routes.map(route => path.basename(route.file)))
|
|
167
|
+
if (fs.readdirSync(stateDir).some(name => /^c2-v2.*\.log$/.test(name) && !expected.has(name))) {
|
|
168
|
+
throw new Error('Unmapped existing journal requires review; no authority may be silently ignored')
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (routes.length === 1 && !persisted && fs.existsSync(stateDir)) {
|
|
172
|
+
const selected = path.basename(routes[0].file)
|
|
173
|
+
if (fs.readdirSync(stateDir).some(name => /^c2-v2.*\.log$/.test(name) && name !== selected)) {
|
|
174
|
+
throw new Error('Unmapped existing journal requires review; legacy single-group authority cannot hide other logs')
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const first = routes[0]
|
|
178
|
+
return { stateDir, file: first.file, cfg, groups, routes, addedRoutes,
|
|
179
|
+
serviceChatId: first.chatId, provider: first.provider, manifestFile, manifest }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function assertJournalScope(journal, chatId, { requireSettled = false } = {}) {
|
|
183
|
+
if (!GROUP_ID.test(chatId)) throw new Error('Journal group identity invalid')
|
|
184
|
+
const snap = journal.snapshot()
|
|
185
|
+
for (const lanes of Object.values(snap.state.lanes)) {
|
|
186
|
+
if (Object.keys(lanes).some(scope => scope !== chatId)) throw new Error('Journal contains another group scope')
|
|
187
|
+
}
|
|
188
|
+
if (snap.receipts.pending.some(receipt => receipt.chatId !== chatId) ||
|
|
189
|
+
[...snap.notices.values()].some(notice => notice.chatId !== chatId) ||
|
|
190
|
+
[...snap.effects.keys()].some(id => journal.effectDescriptor(id)?.scope !== chatId) ||
|
|
191
|
+
journal.runnerClosedProofs().some(proof => proof.chatId !== chatId) ||
|
|
192
|
+
journal.runnerNotStartedProofs().some(proof => proof.chatId !== chatId)) {
|
|
193
|
+
throw new Error('Journal receipts or effects belong to another group')
|
|
194
|
+
}
|
|
195
|
+
if (requireSettled && (snap.receipts.pending.length ||
|
|
196
|
+
[...snap.effects.values()].some(status => status !== 'acked') ||
|
|
197
|
+
[...snap.notices.values()].some(notice => ['attempting', 'unknown'].includes(notice.status)) ||
|
|
198
|
+
Object.values(snap.state.lanes.turn || {}).some(lane => lane.active !== null) ||
|
|
199
|
+
Object.values(snap.state.lanes.handoff || {}).some(lane => lane.phase !== 'idle') ||
|
|
200
|
+
Object.values(snap.state.lanes.lifecycle || {}).some(lane => lane.busy || lane.controller) ||
|
|
201
|
+
Object.values(snap.state.lanes.queue || {}).some(lane => lane.queue.length) ||
|
|
202
|
+
Object.values(snap.state.lanes.session || {}).some(lane => lane.settled !== true) ||
|
|
203
|
+
['terminal', 'stopIntent', 'cardCleanup', 'steer'].some(domain => Object.keys(snap.state.lanes[domain] || {}).length))) {
|
|
204
|
+
throw new Error('Group journal has unresolved work; preserve it and explicitly review recovery')
|
|
205
|
+
}
|
|
206
|
+
return snap
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function assertInstallationJournals(installation) {
|
|
210
|
+
const { routes, manifestFile } = installation
|
|
211
|
+
if (routes.length > 1 && !fs.existsSync(manifestFile)) {
|
|
212
|
+
throw new Error('No initialized journal mapping; initialize a new data directory or review existing state migration')
|
|
213
|
+
}
|
|
214
|
+
const adding = new Set((installation.addedRoutes || []).map(route => route.chatId))
|
|
215
|
+
for (const route of routes) {
|
|
216
|
+
if (adding.has(route.chatId)) continue
|
|
217
|
+
let st
|
|
218
|
+
try { st = fs.lstatSync(route.file) } catch {}
|
|
219
|
+
if (!st?.isFile() || st.isSymbolicLink() ||
|
|
220
|
+
typeof process.getuid === 'function' && st.uid !== process.getuid()) {
|
|
221
|
+
throw new Error('No initialized journal; initialize a new installation and preserve existing state')
|
|
222
|
+
}
|
|
49
223
|
}
|
|
50
|
-
return { stateDir, file, cfg, groups, serviceChatId, provider }
|
|
51
224
|
}
|
|
52
225
|
|
|
53
226
|
function assertPrivateDirectory(dir) {
|
|
@@ -68,6 +241,7 @@ function assertFirstInstall(stateDir, file) {
|
|
|
68
241
|
for (const name of ['state.json', 'pending-turns.json', 'engine-receipts.json']) {
|
|
69
242
|
absent(path.join(stateDir, name), name)
|
|
70
243
|
}
|
|
244
|
+
absent(path.join(stateDir, MANIFEST), 'Group journal mapping')
|
|
71
245
|
absent(file, 'Journal')
|
|
72
246
|
// An independently named journal must not make existing authority disappear.
|
|
73
247
|
if (fs.readdirSync(stateDir).some(name => /^c2-v2.*\.log$/.test(name))) {
|
|
@@ -75,8 +249,9 @@ function assertFirstInstall(stateDir, file) {
|
|
|
75
249
|
}
|
|
76
250
|
}
|
|
77
251
|
|
|
78
|
-
function initializeInstallation(explicitConfig) {
|
|
79
|
-
const
|
|
252
|
+
function initializeInstallation(explicitConfig, options) {
|
|
253
|
+
const installation = loadInstallation(explicitConfig, options)
|
|
254
|
+
const { stateDir, file, routes, manifestFile, manifest } = installation
|
|
80
255
|
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 })
|
|
81
256
|
assertFirstInstall(stateDir, file)
|
|
82
257
|
const lockFile = path.join(stateDir, 'daemon.lock')
|
|
@@ -92,17 +267,25 @@ function initializeInstallation(explicitConfig) {
|
|
|
92
267
|
fs.closeSync(fd); fd = undefined
|
|
93
268
|
assertFirstInstall(stateDir, file)
|
|
94
269
|
fs.chmodSync(stateDir, 0o700)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
270
|
+
for (const route of routes) {
|
|
271
|
+
C2DurableJournal.initialize(route.file, {
|
|
272
|
+
state: { version: 2, seq: 0, lanes: {} },
|
|
273
|
+
receipts: { pending: [], completed: [] },
|
|
274
|
+
}, VALIDATORS)
|
|
275
|
+
const journal = C2DurableJournal.open(route.file, VALIDATORS)
|
|
276
|
+
const snapshot = assertJournalScope(journal, route.chatId, { requireSettled: true })
|
|
277
|
+
if (journal.blocked || snapshot.cursor !== 0 || snapshot.eventCount !== 0 ||
|
|
278
|
+
snapshot.receipts.pending.length || snapshot.receipts.completed.length ||
|
|
279
|
+
snapshot.effects.size || snapshot.notices.size || Object.keys(snapshot.state.lanes).length) {
|
|
280
|
+
throw new Error('New journal verification failed; preserve the file for review')
|
|
281
|
+
}
|
|
105
282
|
}
|
|
283
|
+
// Last write commits the complete mapping. A partial initialization is
|
|
284
|
+
// deliberately preserved and rejected, never silently retried or erased.
|
|
285
|
+
const mappingFd = fs.openSync(manifestFile, 'wx', 0o600)
|
|
286
|
+
try { fs.writeSync(mappingFd, JSON.stringify(manifest, null, 2) + '\n'); fs.fsyncSync(mappingFd) }
|
|
287
|
+
finally { fs.closeSync(mappingFd) }
|
|
288
|
+
|
|
106
289
|
return file
|
|
107
290
|
} finally {
|
|
108
291
|
if (fd !== undefined) fs.closeSync(fd)
|
|
@@ -114,4 +297,31 @@ function initializeInstallation(explicitConfig) {
|
|
|
114
297
|
}
|
|
115
298
|
}
|
|
116
299
|
|
|
117
|
-
|
|
300
|
+
// Start-time registration of groups added to the configuration: each gets an
|
|
301
|
+
// empty verified journal, then the mapping is extended in one replacement.
|
|
302
|
+
// Existing journals are never touched; a group with legacy history is refused.
|
|
303
|
+
function registerAddedGroups(installation) {
|
|
304
|
+
const { stateDir, addedRoutes = [], manifestFile, manifest } = installation
|
|
305
|
+
if (!addedRoutes.length) return []
|
|
306
|
+
const { assertNoLegacyGroupState } = require('./c2migration')
|
|
307
|
+
for (const route of addedRoutes) assertNoLegacyGroupState({ stateDir, chatId: route.chatId })
|
|
308
|
+
for (const route of addedRoutes) {
|
|
309
|
+
absent(route.file, 'Journal for a newly added group')
|
|
310
|
+
C2DurableJournal.initialize(route.file, {
|
|
311
|
+
state: { version: 2, seq: 0, lanes: {} }, receipts: { pending: [], completed: [] },
|
|
312
|
+
}, VALIDATORS)
|
|
313
|
+
const journal = C2DurableJournal.open(route.file, VALIDATORS)
|
|
314
|
+
const snapshot = assertJournalScope(journal, route.chatId, { requireSettled: true })
|
|
315
|
+
if (journal.blocked || snapshot.cursor !== 0 || snapshot.eventCount !== 0) {
|
|
316
|
+
throw new Error('New group journal verification failed; preserve the file for review')
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const { atomicWriteJSON } = require('./core/store')
|
|
320
|
+
atomicWriteJSON(manifestFile, manifest, { mode: 0o600 })
|
|
321
|
+
installation.addedRoutes = []
|
|
322
|
+
return addedRoutes.map(route => route.chatId)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
module.exports = { registerAddedGroups, loadInstallation, loadInstallationFromRaw, initializeInstallation, assertPrivateDirectory,
|
|
326
|
+
assertJournalScope, assertInstallationJournals, assertRunnerExecutable, SUPPORTED_RUNNERS, MANIFEST,
|
|
327
|
+
VALIDATORS }
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Admission boundary for a reviewed legacy cut. This never settles an old
|
|
4
|
+
// receipt or replays an effect; it only verifies immutable migration evidence
|
|
5
|
+
// and excludes pre-cut platform messages before durable receipt admission.
|
|
6
|
+
const fs = require('node:fs')
|
|
7
|
+
const path = require('node:path')
|
|
8
|
+
const { createHash } = require('node:crypto')
|
|
9
|
+
const { isDeepStrictEqual } = require('node:util')
|
|
10
|
+
const SHA = /^[a-f0-9]{64}$/
|
|
11
|
+
const CHAT = /^oc_[A-Za-z0-9_-]{1,124}$/
|
|
12
|
+
const SOURCE = /^om_[A-Za-z0-9_-]{1,124}$/
|
|
13
|
+
const sha = bytes => createHash('sha256').update(bytes).digest('hex')
|
|
14
|
+
const fail = () => { throw new Error('C2 migration evidence or admission fence differs; preserve all authorities') }
|
|
15
|
+
function readPrivate(file) {
|
|
16
|
+
const stat = fs.lstatSync(file)
|
|
17
|
+
if (!stat.isFile() || stat.isSymbolicLink() ||
|
|
18
|
+
typeof process.getuid === 'function' && stat.uid !== process.getuid() ||
|
|
19
|
+
process.platform !== 'win32' && (stat.mode & 0o077)) fail()
|
|
20
|
+
return fs.readFileSync(file)
|
|
21
|
+
}
|
|
22
|
+
function loadC2MigrationFence({ stateDir, routes } = {}) {
|
|
23
|
+
if (!path.isAbsolute(stateDir || '') || !Array.isArray(routes) || !routes.length) fail()
|
|
24
|
+
const mappingFile = path.join(stateDir, 'c2-groups.json')
|
|
25
|
+
const fenceFile = path.join(stateDir, 'c2-migration.json')
|
|
26
|
+
let mapping
|
|
27
|
+
if (fs.existsSync(mappingFile)) mapping = JSON.parse(readPrivate(mappingFile))
|
|
28
|
+
const expectedHash = mapping?.migrationSha256
|
|
29
|
+
if (expectedHash === undefined && !fs.existsSync(fenceFile) &&
|
|
30
|
+
!fs.existsSync(path.join(stateDir, 'legacy-evidence'))) return null
|
|
31
|
+
if (!SHA.test(expectedHash || '')) fail()
|
|
32
|
+
const bytes = readPrivate(fenceFile)
|
|
33
|
+
if (sha(bytes) !== expectedHash) fail()
|
|
34
|
+
const fence = JSON.parse(bytes)
|
|
35
|
+
const expectedGroups = routes.map(route => ({ chatId: route.chatId, journal: path.basename(route.file) }))
|
|
36
|
+
.sort((a, b) => a.chatId < b.chatId ? -1 : a.chatId > b.chatId ? 1 : 0)
|
|
37
|
+
// Groups bound after the cut extend the mapping; every migrated group must
|
|
38
|
+
// keep its exact journal, and an added group must have no legacy state.
|
|
39
|
+
const fenced = Array.isArray(fence.groups) ? fence.groups : []
|
|
40
|
+
const expectedById = new Map(expectedGroups.map(group => [group.chatId, group]))
|
|
41
|
+
if (mapping.version !== 1 || fence.schema !== 'sbot-c2-legacy-migration-v1' ||
|
|
42
|
+
!fenced.length || new Set(fenced.map(group => group?.chatId)).size !== fenced.length ||
|
|
43
|
+
fenced.some(group => !isDeepStrictEqual(expectedById.get(group?.chatId), group)) ||
|
|
44
|
+
!isDeepStrictEqual(mapping.groups, expectedGroups) ||
|
|
45
|
+
!Number.isSafeInteger(fence.createdAt) || fence.createdAt <= 0 ||
|
|
46
|
+
!Array.isArray(fence.evidence) || !fence.evidence.length ||
|
|
47
|
+
!Array.isArray(fence.fences) || fence.fences.length !== fenced.length) fail()
|
|
48
|
+
const evidenceDir = path.join(stateDir, 'legacy-evidence')
|
|
49
|
+
const dirStat = fs.lstatSync(evidenceDir)
|
|
50
|
+
if (!dirStat.isDirectory() || dirStat.isSymbolicLink() ||
|
|
51
|
+
typeof process.getuid === 'function' && dirStat.uid !== process.getuid() ||
|
|
52
|
+
process.platform !== 'win32' && (dirStat.mode & 0o077)) fail()
|
|
53
|
+
const names = new Set()
|
|
54
|
+
for (const item of fence.evidence) {
|
|
55
|
+
if (!item || typeof item.file !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,160}$/.test(item.file) ||
|
|
56
|
+
!SHA.test(item.sha256 || '') || names.has(item.file)) fail()
|
|
57
|
+
names.add(item.file)
|
|
58
|
+
if (sha(readPrivate(path.join(evidenceDir, item.file))) !== item.sha256) fail()
|
|
59
|
+
}
|
|
60
|
+
for (const required of ['state-before.json', 'config-before.json', 'service-before.plist', 'review.json']) {
|
|
61
|
+
if (!names.has(required)) fail()
|
|
62
|
+
}
|
|
63
|
+
const legacy = JSON.parse(readPrivate(path.join(evidenceDir, 'state-before.json')))
|
|
64
|
+
const watermarks = new Map(), excluded = new Map()
|
|
65
|
+
for (const item of fence.fences) {
|
|
66
|
+
if (!item || !CHAT.test(item.chatId || '') || watermarks.has(item.chatId) ||
|
|
67
|
+
!fenced.some(group => group.chatId === item.chatId) ||
|
|
68
|
+
!Number.isSafeInteger(item.watermark) || item.watermark < 0 ||
|
|
69
|
+
item.watermark !== (legacy.groups?.[item.chatId]?.watermark || 0) ||
|
|
70
|
+
!Array.isArray(item.sourceIds) || item.sourceIds.length > 100_000 ||
|
|
71
|
+
item.sourceIds.some(id => !SOURCE.test(id)) || new Set(item.sourceIds).size !== item.sourceIds.length) fail()
|
|
72
|
+
watermarks.set(item.chatId, item.watermark)
|
|
73
|
+
excluded.set(item.chatId, new Set(item.sourceIds))
|
|
74
|
+
}
|
|
75
|
+
for (const group of expectedGroups) {
|
|
76
|
+
if (!watermarks.has(group.chatId) && legacy.groups?.[group.chatId] !== undefined) fail()
|
|
77
|
+
}
|
|
78
|
+
return Object.freeze({
|
|
79
|
+
sourceIds: Object.freeze([...new Set(fence.fences.flatMap(item => item.sourceIds))]),
|
|
80
|
+
watermarks: Object.freeze(Object.fromEntries(watermarks)),
|
|
81
|
+
rejects(message) {
|
|
82
|
+
if (!watermarks.has(message?.chatId)) return false
|
|
83
|
+
if (excluded.get(message.chatId).has(message.msgId)) return true
|
|
84
|
+
return !Number.isSafeInteger(message.createTimeMs) || message.createTimeMs <= watermarks.get(message.chatId)
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
// A group bound online after a reviewed cut must not carry legacy state:
|
|
89
|
+
// its old messages would have no admission fence.
|
|
90
|
+
function assertNoLegacyGroupState({ stateDir, chatId } = {}) {
|
|
91
|
+
if (!path.isAbsolute(stateDir || '') || !CHAT.test(chatId || '')) fail()
|
|
92
|
+
const file = path.join(stateDir, 'legacy-evidence', 'state-before.json')
|
|
93
|
+
if (!fs.existsSync(path.join(stateDir, 'c2-migration.json')) && !fs.existsSync(file)) return true
|
|
94
|
+
const legacy = JSON.parse(readPrivate(file))
|
|
95
|
+
if (legacy.groups?.[chatId] !== undefined) {
|
|
96
|
+
throw new Error('This group has legacy history; review its migration before binding it again')
|
|
97
|
+
}
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
module.exports = { loadC2MigrationFence, assertNoLegacyGroupState }
|