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.
Files changed (81) hide show
  1. package/CHANGELOG.en.json +50 -0
  2. package/CHANGELOG.json +50 -0
  3. package/README.md +138 -20
  4. package/README.zh-CN.md +144 -20
  5. package/assets/design-styles/acid-pop.md +155 -0
  6. package/assets/design-styles/archive-index.md +161 -0
  7. package/assets/design-styles/art-deco.md +164 -0
  8. package/assets/design-styles/cinematic-noir.md +178 -0
  9. package/assets/design-styles/editorial.md +121 -0
  10. package/assets/design-styles/geometric-pop.md +122 -0
  11. package/assets/design-styles/hand-drawn.md +117 -0
  12. package/assets/design-styles/islamic-geometry.md +155 -0
  13. package/assets/design-styles/liquid-glass.md +183 -0
  14. package/assets/design-styles/neo-chinese.md +136 -0
  15. package/assets/design-styles/precision-mono.md +156 -0
  16. package/assets/design-styles/quiet-luxury.md +163 -0
  17. package/assets/design-styles/riso-print.md +116 -0
  18. package/assets/design-styles/soft-friendly.md +174 -0
  19. package/assets/design-styles/swiss-grid.md +156 -0
  20. package/assets/design-styles/tech-dark.md +182 -0
  21. package/bin/sintbot-v2.js +291 -263
  22. package/docs/AI-ASSISTED-INSTALL.md +8 -8
  23. package/docs/install.md +80 -48
  24. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
  25. package/docs/zh-CN/install.md +80 -48
  26. package/install.sh +34 -81
  27. package/package.json +60 -5
  28. package/src/agent/claudecode.js +32 -3
  29. package/src/agent/claudemodels.js +60 -0
  30. package/src/agent/claudestatus.js +74 -0
  31. package/src/agent/claudeusage.js +200 -0
  32. package/src/agent/codexapp.js +21 -4
  33. package/src/agent/codexcode.js +8 -3
  34. package/src/agent/codexstatus.js +181 -0
  35. package/src/agent/privatecore.js +1 -1
  36. package/src/agent/privatecore.wasm +0 -0
  37. package/src/c2configcontrol.js +214 -0
  38. package/src/c2console.js +149 -0
  39. package/src/c2incident.js +125 -0
  40. package/src/c2install.js +247 -37
  41. package/src/c2migration.js +100 -0
  42. package/src/c2multigroupcapacity.js +103 -0
  43. package/src/c2multigroupservice.js +418 -0
  44. package/src/cdp.js +254 -0
  45. package/src/config.js +13 -2
  46. package/src/core/c2durablejournal.js +26 -9
  47. package/src/core/c2durableruntime.js +104 -1
  48. package/src/core/c2inputrejection.js +45 -0
  49. package/src/core/c2localeffectexecutor.js +12 -1
  50. package/src/core/c2payloadhandles.js +11 -0
  51. package/src/core/c2persistrelation.js +20 -0
  52. package/src/core/c2persiststate.js +28 -2
  53. package/src/core/c2platformrecoverysender.js +1 -1
  54. package/src/core/c2sourcequarantine.js +56 -1
  55. package/src/core/c2terminalprune.js +2 -1
  56. package/src/core/engineslotsadapter.js +108 -0
  57. package/src/core/issues.js +185 -0
  58. package/src/core/ledger.js +30 -0
  59. package/src/core/proxy.js +424 -0
  60. package/src/core/store.js +12 -0
  61. package/src/core/v2engine.js +427 -31
  62. package/src/design.js +5161 -0
  63. package/src/gitrepo.js +150 -0
  64. package/src/leftover.js +174 -0
  65. package/src/platform/slack.js +1294 -0
  66. package/src/platform/wecom.js +961 -0
  67. package/src/platform/wecomcli.js +128 -0
  68. package/src/platform/wecomname.js +43 -0
  69. package/src/platform/wecompoll.js +241 -0
  70. package/src/preview.js +581 -0
  71. package/src/projectmemory.js +175 -0
  72. package/src/ui/auth.js +31 -0
  73. package/src/ui/browser.html +392 -0
  74. package/src/ui/browserroutes.js +234 -0
  75. package/src/ui/console.html +5719 -0
  76. package/src/ui/dashboard.js +168 -0
  77. package/src/ui/feishulogin.js +136 -0
  78. package/src/ui/serverbase.js +2371 -0
  79. package/src/ui/sessions.js +78 -0
  80. package/src/ui/takeover.js +108 -0
  81. 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 { loadConfig, platformsConfigured } = require('./config')
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
- function loadInstallation(explicitConfig) {
16
- const stateDir = path.join(os.homedir(), '.sbot')
17
- const file = process.env.SBOT_C2_V2_JOURNAL || path.join(stateDir, 'c2-v2.log')
18
- if (!path.isAbsolute(file) || file !== path.resolve(file) ||
19
- path.dirname(file) !== stateDir || !/^c2-v2(?:[._-][a-zA-Z0-9._-]+)?\.log$/.test(path.basename(file))) {
20
- throw new Error('The journal must be a c2-v2*.log file directly in the installation state directory')
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
- const cfg = loadConfig(configFile)
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 !== 1) throw new Error('This release requires exactly one explicit Feishu group')
30
- const [serviceChatId, group] = groups[0]
31
- const provider = cfg.providers?.[group?.provider || cfg.defaultProvider]
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
- !/^oc_[A-Za-z0-9_-]{1,124}$/.test(serviceChatId) || !group ||
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 for the dedicated group')
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
- if (typeof group.workdir !== 'string' || !path.isAbsolute(group.workdir) ||
47
- !fs.existsSync(group.workdir) || !fs.statSync(group.workdir).isDirectory()) {
48
- throw new Error('The group workdir must be an existing absolute project directory')
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 { stateDir, file } = loadInstallation(explicitConfig)
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
- C2DurableJournal.initialize(file, {
96
- state: { version: 2, seq: 0, lanes: {} },
97
- receipts: { pending: [], completed: [] },
98
- }, VALIDATORS)
99
- const journal = C2DurableJournal.open(file, VALIDATORS)
100
- const snapshot = journal.snapshot()
101
- if (journal.blocked || snapshot.cursor !== 0 || snapshot.eventCount !== 0 ||
102
- snapshot.receipts.pending.length || snapshot.receipts.completed.length ||
103
- snapshot.effects.size || snapshot.notices.size || Object.keys(snapshot.state.lanes).length) {
104
- throw new Error('New journal verification failed; preserve the file for review')
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
- module.exports = { loadInstallation, initializeInstallation, assertPrivateDirectory }
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 }