switchroom 0.20.0 → 0.20.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/bin/handoff-briefing.sh +213 -74
- package/dist/agent-scheduler/index.js +1 -1
- package/dist/auth-broker/index.js +1 -1
- package/dist/buzz-gateway/index.js +166 -6
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +24701 -16397
- package/dist/host-control/main.js +41 -8
- package/dist/vault/approvals/kernel-server.js +1 -1
- package/dist/vault/broker/server.js +1 -1
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +79 -10
- package/telegram-plugin/dist/gateway/gateway.js +1397 -962
- package/telegram-plugin/gateway/access-store.test.ts +234 -0
- package/telegram-plugin/gateway/access-store.ts +194 -0
- package/telegram-plugin/gateway/boot-briefing-builder.ts +135 -7
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +166 -4
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +177 -12
- package/telegram-plugin/gateway/gateway.ts +43 -123
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +48 -1
- package/telegram-plugin/gateway/pending-turn-env.ts +10 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +422 -31
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +297 -1
- package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
- package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for access-store.ts — the access/allowlist file layer extracted
|
|
3
|
+
* from gateway.ts (switchroom#4248).
|
|
4
|
+
*
|
|
5
|
+
* These lock in the behavior that used to be inline in gateway.ts, in
|
|
6
|
+
* particular the init-time-only static-mode snapshot (BOOT_ACCESS): in static
|
|
7
|
+
* mode the allowlist is read ONCE when the store is built and frozen for the
|
|
8
|
+
* life of the store, so a later edit to access.json on disk is NOT observed.
|
|
9
|
+
* That timing is the whole reason the store is a factory rather than a set of
|
|
10
|
+
* lazy free functions — this suite is the outcome guard for it.
|
|
11
|
+
*
|
|
12
|
+
* Run with: npx vitest run telegram-plugin/gateway/access-store.test.ts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
16
|
+
import { mkdtempSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs'
|
|
17
|
+
import { tmpdir } from 'node:os'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { createAccessStore, type AccessStoreDeps } from './access-store.js'
|
|
20
|
+
import type { Access } from './gateway.js'
|
|
21
|
+
|
|
22
|
+
function makeDeps(dir: string, isStatic: boolean): AccessStoreDeps {
|
|
23
|
+
return {
|
|
24
|
+
accessFile: join(dir, 'access.json'),
|
|
25
|
+
peopleFile: join(dir, 'people.json'),
|
|
26
|
+
stateDir: dir,
|
|
27
|
+
isStatic,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('access-store', () => {
|
|
32
|
+
let dir: string
|
|
33
|
+
let stderrSpy: ReturnType<typeof vi.spyOn>
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
dir = mkdtempSync(join(tmpdir(), 'access-store-'))
|
|
37
|
+
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
stderrSpy.mockRestore()
|
|
42
|
+
rmSync(dir, { recursive: true, force: true })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// ─── defaultAccess ─────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
it('defaultAccess returns a pairing-mode empty allowlist', () => {
|
|
48
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
49
|
+
expect(store.defaultAccess()).toEqual({
|
|
50
|
+
dmPolicy: 'pairing',
|
|
51
|
+
allowFrom: [],
|
|
52
|
+
groups: {},
|
|
53
|
+
pending: {},
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// ─── readAccessFile ────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
it('readAccessFile returns defaultAccess when the file is missing (ENOENT)', () => {
|
|
60
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
61
|
+
expect(store.readAccessFile()).toEqual(store.defaultAccess())
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('readAccessFile parses a valid access.json and projects known fields', () => {
|
|
65
|
+
writeFileSync(
|
|
66
|
+
join(dir, 'access.json'),
|
|
67
|
+
JSON.stringify({
|
|
68
|
+
dmPolicy: 'allowlist',
|
|
69
|
+
allowFrom: ['111'],
|
|
70
|
+
groups: { '-100': { requireMention: true, allowFrom: ['222'] } },
|
|
71
|
+
pending: {},
|
|
72
|
+
parseMode: 'text',
|
|
73
|
+
historyEnabled: false,
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
77
|
+
const a = store.readAccessFile()
|
|
78
|
+
expect(a.dmPolicy).toBe('allowlist')
|
|
79
|
+
expect(a.allowFrom).toEqual(['111'])
|
|
80
|
+
expect(a.groups['-100']).toEqual({ requireMention: true, allowFrom: ['222'] })
|
|
81
|
+
expect(a.parseMode).toBe('text')
|
|
82
|
+
expect(a.historyEnabled).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('readAccessFile fails closed on number-array fields (the hand-edit bug)', () => {
|
|
86
|
+
// Unquoted IDs parse as numbers; validateStringArray must drop them.
|
|
87
|
+
writeFileSync(
|
|
88
|
+
join(dir, 'access.json'),
|
|
89
|
+
JSON.stringify({ dmPolicy: 'allowlist', allowFrom: [12345], groups: {}, pending: {} }),
|
|
90
|
+
)
|
|
91
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
92
|
+
expect(store.readAccessFile().allowFrom).toEqual([])
|
|
93
|
+
expect(stderrSpy).toHaveBeenCalled()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('readAccessFile moves a corrupt file aside and returns defaultAccess', () => {
|
|
97
|
+
writeFileSync(join(dir, 'access.json'), '{ not valid json')
|
|
98
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
99
|
+
expect(store.readAccessFile()).toEqual(store.defaultAccess())
|
|
100
|
+
// Original replaced by a .corrupt-* sibling.
|
|
101
|
+
expect(existsSync(join(dir, 'access.json'))).toBe(false)
|
|
102
|
+
const corrupt = readdirSync(dir).filter((f) => f.startsWith('access.json.corrupt-'))
|
|
103
|
+
expect(corrupt.length).toBe(1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ─── loadAccess — non-static re-reads live ─────────────────────────────────
|
|
107
|
+
|
|
108
|
+
it('loadAccess re-reads the file on every call in non-static mode', () => {
|
|
109
|
+
writeFileSync(
|
|
110
|
+
join(dir, 'access.json'),
|
|
111
|
+
JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['111'], groups: {}, pending: {} }),
|
|
112
|
+
)
|
|
113
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
114
|
+
expect(store.loadAccess().allowFrom).toEqual(['111'])
|
|
115
|
+
// Edit on disk is observed immediately (no snapshot in non-static mode).
|
|
116
|
+
writeFileSync(
|
|
117
|
+
join(dir, 'access.json'),
|
|
118
|
+
JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['222'], groups: {}, pending: {} }),
|
|
119
|
+
)
|
|
120
|
+
expect(store.loadAccess().allowFrom).toEqual(['222'])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// ─── Static-mode BOOT_ACCESS snapshot (init-time semantics) ────────────────
|
|
124
|
+
|
|
125
|
+
it('static mode snapshots at build time: downgrades pairing→allowlist and clears pending', () => {
|
|
126
|
+
writeFileSync(
|
|
127
|
+
join(dir, 'access.json'),
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
dmPolicy: 'pairing',
|
|
130
|
+
allowFrom: ['111'],
|
|
131
|
+
groups: {},
|
|
132
|
+
pending: { code123: { senderId: 's', chatId: 'c', createdAt: 1, expiresAt: 2, replies: 0 } },
|
|
133
|
+
}),
|
|
134
|
+
)
|
|
135
|
+
const store = createAccessStore(makeDeps(dir, true))
|
|
136
|
+
const a = store.loadAccess()
|
|
137
|
+
expect(a.dmPolicy).toBe('allowlist')
|
|
138
|
+
expect(a.pending).toEqual({})
|
|
139
|
+
expect(a.allowFrom).toEqual(['111'])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('static mode freezes the allowlist: an on-disk edit after build is NOT observed', () => {
|
|
143
|
+
writeFileSync(
|
|
144
|
+
join(dir, 'access.json'),
|
|
145
|
+
JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['111'], groups: {}, pending: {} }),
|
|
146
|
+
)
|
|
147
|
+
const store = createAccessStore(makeDeps(dir, true))
|
|
148
|
+
expect(store.loadAccess().allowFrom).toEqual(['111'])
|
|
149
|
+
// Change the file after the store was built.
|
|
150
|
+
writeFileSync(
|
|
151
|
+
join(dir, 'access.json'),
|
|
152
|
+
JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['999'], groups: {}, pending: {} }),
|
|
153
|
+
)
|
|
154
|
+
// Still the boot snapshot — this is the init-time-only guarantee.
|
|
155
|
+
expect(store.loadAccess().allowFrom).toEqual(['111'])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// ─── saveAccess ────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
it('saveAccess writes access.json atomically in non-static mode', () => {
|
|
161
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
162
|
+
const a: Access = { dmPolicy: 'allowlist', allowFrom: ['abc'], groups: {}, pending: {} }
|
|
163
|
+
store.saveAccess(a)
|
|
164
|
+
const written = JSON.parse(readFileSync(join(dir, 'access.json'), 'utf8'))
|
|
165
|
+
expect(written.allowFrom).toEqual(['abc'])
|
|
166
|
+
// No leftover temp file.
|
|
167
|
+
expect(existsSync(join(dir, 'access.json.tmp'))).toBe(false)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('saveAccess is a no-op in static mode', () => {
|
|
171
|
+
const store = createAccessStore(makeDeps(dir, true))
|
|
172
|
+
store.saveAccess({ dmPolicy: 'allowlist', allowFrom: ['abc'], groups: {}, pending: {} })
|
|
173
|
+
expect(existsSync(join(dir, 'access.json'))).toBe(false)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// ─── pruneExpired ──────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
it('pruneExpired removes expired pending entries and reports whether it changed', () => {
|
|
179
|
+
const now = Date.now()
|
|
180
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
181
|
+
const a: Access = {
|
|
182
|
+
dmPolicy: 'pairing',
|
|
183
|
+
allowFrom: [],
|
|
184
|
+
groups: {},
|
|
185
|
+
pending: {
|
|
186
|
+
old: { senderId: 's', chatId: 'c', createdAt: 1, expiresAt: now - 1000, replies: 0 },
|
|
187
|
+
fresh: { senderId: 's', chatId: 'c', createdAt: 1, expiresAt: now + 100000, replies: 0 },
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
expect(store.pruneExpired(a)).toBe(true)
|
|
191
|
+
expect(Object.keys(a.pending)).toEqual(['fresh'])
|
|
192
|
+
// Second pass: nothing expired now → no change.
|
|
193
|
+
expect(store.pruneExpired(a)).toBe(false)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// ─── assertAllowedChat ─────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
it('assertAllowedChat permits an allowFrom chat, a group chat, and rejects others', () => {
|
|
199
|
+
writeFileSync(
|
|
200
|
+
join(dir, 'access.json'),
|
|
201
|
+
JSON.stringify({
|
|
202
|
+
dmPolicy: 'allowlist',
|
|
203
|
+
allowFrom: ['111'],
|
|
204
|
+
groups: { '-100': { requireMention: false, allowFrom: [] } },
|
|
205
|
+
pending: {},
|
|
206
|
+
}),
|
|
207
|
+
)
|
|
208
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
209
|
+
expect(() => store.assertAllowedChat('111')).not.toThrow()
|
|
210
|
+
expect(() => store.assertAllowedChat(111)).not.toThrow() // number coerced
|
|
211
|
+
expect(() => store.assertAllowedChat('-100')).not.toThrow()
|
|
212
|
+
expect(() => store.assertAllowedChat('404')).toThrow(/not allowlisted/)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
// ─── readPeopleFile ────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
it('readPeopleFile returns entries for a valid people.json', () => {
|
|
218
|
+
writeFileSync(
|
|
219
|
+
join(dir, 'people.json'),
|
|
220
|
+
JSON.stringify({ entries: [{ telegramUserId: '5', personId: 'p1' }] }),
|
|
221
|
+
)
|
|
222
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
223
|
+
expect(store.readPeopleFile()).toEqual([{ telegramUserId: '5', personId: 'p1' }])
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('readPeopleFile fails open to [] on missing or corrupt file', () => {
|
|
227
|
+
const store = createAccessStore(makeDeps(dir, false))
|
|
228
|
+
expect(store.readPeopleFile()).toEqual([]) // ENOENT
|
|
229
|
+
writeFileSync(join(dir, 'people.json'), 'not json')
|
|
230
|
+
expect(store.readPeopleFile()).toEqual([]) // corrupt
|
|
231
|
+
writeFileSync(join(dir, 'people.json'), JSON.stringify({ entries: 'nope' }))
|
|
232
|
+
expect(store.readPeopleFile()).toEqual([]) // entries not an array
|
|
233
|
+
})
|
|
234
|
+
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access / allowlist file layer for the Telegram gateway.
|
|
3
|
+
*
|
|
4
|
+
* This module owns the read/write side of `access.json` (the per-agent DM
|
|
5
|
+
* allowlist + group policy + pairing state) and the read-only `people.json`
|
|
6
|
+
* projection. It was extracted verbatim out of `gateway.ts` (switchroom#4248)
|
|
7
|
+
* to relieve the gateway line-ratchet; the behavior is byte-identical to the
|
|
8
|
+
* inline version.
|
|
9
|
+
*
|
|
10
|
+
* Init-time semantics preserved: in static-access mode the gateway snapshots
|
|
11
|
+
* `access.json` ONCE at module-init time (the `BOOT_ACCESS` constant below),
|
|
12
|
+
* downgrades a `pairing` dmPolicy to `allowlist`, and clears pending pairings —
|
|
13
|
+
* so a static agent's allowlist is frozen for the life of the process. That
|
|
14
|
+
* snapshot must happen at the same point in startup as before, so the store is
|
|
15
|
+
* built by a factory (`createAccessStore`) that the gateway calls at the
|
|
16
|
+
* original `BOOT_ACCESS` site; the snapshot runs eagerly inside the factory,
|
|
17
|
+
* NOT lazily on first `loadAccess()`.
|
|
18
|
+
*
|
|
19
|
+
* The gateway-internal dependencies (`ACCESS_FILE`, `PEOPLE_FILE`, `STATE_DIR`
|
|
20
|
+
* path constants and the `STATIC` mode flag) are injected as explicit params so
|
|
21
|
+
* this module holds no runtime import of gateway.ts. The only back-reference is
|
|
22
|
+
* the `Access` / `GroupPolicy` types, imported type-only (erased under
|
|
23
|
+
* `isolatedModules`, so no runtime cycle) — the same seam
|
|
24
|
+
* `turn-start-surfaces.ts` already uses.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
28
|
+
import { validateStringArray } from './access-validator.js'
|
|
29
|
+
import type { RawPersonEntry } from './resolve-person.js'
|
|
30
|
+
import type { Access } from './gateway.js'
|
|
31
|
+
|
|
32
|
+
/** The per-group policy shape, derived from the exported `Access` type so the
|
|
33
|
+
* store needs no extra symbol export from gateway.ts. */
|
|
34
|
+
type GroupPolicy = Access['groups'][string]
|
|
35
|
+
|
|
36
|
+
/** Gateway-internal deps the store needs, injected at build time. */
|
|
37
|
+
export interface AccessStoreDeps {
|
|
38
|
+
/** Absolute path to `access.json` (gateway's `ACCESS_FILE`). */
|
|
39
|
+
accessFile: string
|
|
40
|
+
/** Absolute path to `people.json` (gateway's `PEOPLE_FILE`). */
|
|
41
|
+
peopleFile: string
|
|
42
|
+
/** State dir created (mode 0o700) before an atomic access.json write. */
|
|
43
|
+
stateDir: string
|
|
44
|
+
/** Static-access mode (`TELEGRAM_ACCESS_MODE === 'static'`). Freezes the
|
|
45
|
+
* allowlist at init and turns `saveAccess` into a no-op. */
|
|
46
|
+
isStatic: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The access/allowlist file layer, bound to one set of paths + mode. */
|
|
50
|
+
export interface AccessStore {
|
|
51
|
+
defaultAccess(): Access
|
|
52
|
+
readAccessFile(): Access
|
|
53
|
+
loadAccess(): Access
|
|
54
|
+
readPeopleFile(): RawPersonEntry[]
|
|
55
|
+
assertAllowedChat(chat_id: string | number): void
|
|
56
|
+
saveAccess(a: Access): void
|
|
57
|
+
pruneExpired(a: Access): boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the access store. The `BOOT_ACCESS` snapshot happens eagerly here, so
|
|
62
|
+
* call this at the same point in gateway startup where `BOOT_ACCESS` used to be
|
|
63
|
+
* initialized — that keeps the static-mode freeze timing identical.
|
|
64
|
+
*/
|
|
65
|
+
export function createAccessStore(deps: AccessStoreDeps): AccessStore {
|
|
66
|
+
const { accessFile, peopleFile, stateDir, isStatic } = deps
|
|
67
|
+
|
|
68
|
+
function defaultAccess(): Access {
|
|
69
|
+
return { dmPolicy: 'pairing', allowFrom: [], groups: {}, pending: {} }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readAccessFile(): Access {
|
|
73
|
+
try {
|
|
74
|
+
const raw = readFileSync(accessFile, 'utf8')
|
|
75
|
+
const parsed = JSON.parse(raw) as Partial<Access>
|
|
76
|
+
const allowFrom = validateStringArray('allowFrom', parsed.allowFrom ?? [])
|
|
77
|
+
const groups: Record<string, GroupPolicy> = {}
|
|
78
|
+
for (const [chatId, policy] of Object.entries(parsed.groups ?? {})) {
|
|
79
|
+
groups[chatId] = {
|
|
80
|
+
...policy,
|
|
81
|
+
allowFrom: validateStringArray(`groups.${chatId}.allowFrom`, policy.allowFrom ?? []),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
dmPolicy: parsed.dmPolicy ?? 'pairing',
|
|
86
|
+
allowFrom,
|
|
87
|
+
groups,
|
|
88
|
+
pending: parsed.pending ?? {},
|
|
89
|
+
mentionPatterns: parsed.mentionPatterns,
|
|
90
|
+
ackReaction: parsed.ackReaction,
|
|
91
|
+
replyToMode: parsed.replyToMode,
|
|
92
|
+
textChunkLimit: parsed.textChunkLimit,
|
|
93
|
+
chunkMode: parsed.chunkMode,
|
|
94
|
+
parseMode: parsed.parseMode,
|
|
95
|
+
disableLinkPreview: parsed.disableLinkPreview,
|
|
96
|
+
coalescingGapMs: parsed.coalescingGapMs,
|
|
97
|
+
litellmNoticeWindowMs: parsed.litellmNoticeWindowMs,
|
|
98
|
+
coalesceMaxAttachments: parsed.coalesceMaxAttachments,
|
|
99
|
+
interruptSafeBoundary: parsed.interruptSafeBoundary,
|
|
100
|
+
interruptMaxWaitMs: parsed.interruptMaxWaitMs,
|
|
101
|
+
statusReactions: parsed.statusReactions,
|
|
102
|
+
historyEnabled: parsed.historyEnabled,
|
|
103
|
+
historyRetentionDays: parsed.historyRetentionDays,
|
|
104
|
+
// #596: telegram features projected into access.json by scaffold.
|
|
105
|
+
// Without these passthroughs, gateway readers (`access.voice_in`,
|
|
106
|
+
// `access.telegraph`, `access.stickers`) silently see undefined.
|
|
107
|
+
stickers: parsed.stickers,
|
|
108
|
+
voice_in: parsed.voice_in,
|
|
109
|
+
voice_out: parsed.voice_out,
|
|
110
|
+
telegraph: parsed.telegraph,
|
|
111
|
+
// #789: button-choice-confirmation config projected by scaffold.
|
|
112
|
+
button_choice_confirmation: parsed.button_choice_confirmation,
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return defaultAccess()
|
|
116
|
+
try { renameSync(accessFile, `${accessFile}.corrupt-${Date.now()}`) } catch {}
|
|
117
|
+
process.stderr.write(`telegram gateway: access.json is corrupt, moved aside. Starting fresh.\n`)
|
|
118
|
+
return defaultAccess()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const BOOT_ACCESS: Access | null = isStatic
|
|
123
|
+
? (() => {
|
|
124
|
+
const a = readAccessFile()
|
|
125
|
+
if (a.dmPolicy === 'pairing') {
|
|
126
|
+
process.stderr.write('telegram gateway: static mode — dmPolicy "pairing" downgraded to "allowlist"\n')
|
|
127
|
+
a.dmPolicy = 'allowlist'
|
|
128
|
+
}
|
|
129
|
+
a.pending = {}
|
|
130
|
+
return a
|
|
131
|
+
})()
|
|
132
|
+
: null
|
|
133
|
+
|
|
134
|
+
function loadAccess(): Access {
|
|
135
|
+
return BOOT_ACCESS ?? readAccessFile()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read `people.json` (the scaffold's plain projection of `users:` entries
|
|
140
|
+
* that carry a `person_id`). Fail-open: ENOENT or corrupt/malformed JSON
|
|
141
|
+
* returns an empty array rather than throwing — this feature must never
|
|
142
|
+
* block startup. Unlike `access.json` this file is never gateway-mutated,
|
|
143
|
+
* so there's no "move corrupt file aside" concern; the scaffold owns and
|
|
144
|
+
* regenerates it on every reconcile.
|
|
145
|
+
*/
|
|
146
|
+
function readPeopleFile(): RawPersonEntry[] {
|
|
147
|
+
try {
|
|
148
|
+
const raw = readFileSync(peopleFile, 'utf8')
|
|
149
|
+
const parsed = JSON.parse(raw) as { entries?: unknown }
|
|
150
|
+
if (!Array.isArray(parsed.entries)) return []
|
|
151
|
+
return parsed.entries as RawPersonEntry[]
|
|
152
|
+
} catch {
|
|
153
|
+
return []
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function assertAllowedChat(chat_id: string | number): void {
|
|
158
|
+
const id = String(chat_id)
|
|
159
|
+
const access = loadAccess()
|
|
160
|
+
if (access.allowFrom.includes(id)) return
|
|
161
|
+
if (id in access.groups) return
|
|
162
|
+
throw new Error(`chat ${id} is not allowlisted — add via /telegram:access`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function saveAccess(a: Access): void {
|
|
166
|
+
if (isStatic) return
|
|
167
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 })
|
|
168
|
+
const tmp = accessFile + '.tmp'
|
|
169
|
+
writeFileSync(tmp, JSON.stringify(a, null, 2) + '\n', { mode: 0o600 })
|
|
170
|
+
renameSync(tmp, accessFile)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function pruneExpired(a: Access): boolean {
|
|
174
|
+
const now = Date.now()
|
|
175
|
+
let changed = false
|
|
176
|
+
for (const [code, p] of Object.entries(a.pending)) {
|
|
177
|
+
if (p.expiresAt < now) {
|
|
178
|
+
delete a.pending[code]
|
|
179
|
+
changed = true
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return changed
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
defaultAccess,
|
|
187
|
+
readAccessFile,
|
|
188
|
+
loadAccess,
|
|
189
|
+
readPeopleFile,
|
|
190
|
+
assertAllowedChat,
|
|
191
|
+
saveAccess,
|
|
192
|
+
pruneExpired,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -244,15 +244,37 @@ export function collectBriefingSurfaces(
|
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
/**
|
|
248
|
-
*
|
|
249
|
-
*
|
|
247
|
+
/**
|
|
248
|
+
* Slice `s` to AT MOST `maxUnits` UTF-16 code units without ever bisecting a
|
|
249
|
+
* surrogate pair. `max` in this module is a UTF-16 `.length` budget (that is
|
|
250
|
+
* what Telegram / the char-budget invariant measure), so truncation MUST be
|
|
251
|
+
* expressed in UTF-16 units — measuring codepoints against a UTF-16 budget
|
|
252
|
+
* lets astral-plane input (each 😀 is 1 codepoint but 2 UTF-16 units) blow the
|
|
253
|
+
* bound. If the cut would land between a high and low surrogate we step back
|
|
254
|
+
* one unit, dropping the lone high surrogate rather than emitting a broken
|
|
255
|
+
* pair. Guarantees `result.length <= maxUnits`.
|
|
256
|
+
*/
|
|
257
|
+
function sliceUtf16Safe(s: string, maxUnits: number): string {
|
|
258
|
+
if (maxUnits <= 0) return ''
|
|
259
|
+
if (s.length <= maxUnits) return s
|
|
260
|
+
let end = maxUnits
|
|
261
|
+
// A high surrogate (0xD800–0xDBFF) at the last kept position means its low
|
|
262
|
+
// half sits at index `end` and would be severed — drop the high surrogate.
|
|
263
|
+
const code = s.charCodeAt(end - 1)
|
|
264
|
+
if (code >= 0xd800 && code <= 0xdbff) end -= 1
|
|
265
|
+
return s.slice(0, end)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** UTF-16-safe truncation (a naive .slice can split a surrogate pair, and
|
|
269
|
+
* measuring codepoints against a UTF-16 `max` can overflow it for astral
|
|
270
|
+
* input). Also collapses whitespace runs so each message renders as one
|
|
271
|
+
* line. Guarantees `result.length <= max`. */
|
|
250
272
|
function truncateOneLine(s: string, max: number): string {
|
|
251
273
|
const t = s.replace(/\s+/g, ' ').trim()
|
|
252
274
|
if (t.length <= max) return t
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return
|
|
275
|
+
if (max <= 0) return ''
|
|
276
|
+
// Reserve one UTF-16 unit for the ellipsis ('…' is a single BMP unit).
|
|
277
|
+
return sliceUtf16Safe(t, max - 1).trimEnd() + '…'
|
|
256
278
|
}
|
|
257
279
|
|
|
258
280
|
function surfaceLabel(s: { chatId: string; threadId: number | null }): string {
|
|
@@ -269,6 +291,35 @@ function renderMessageLine(
|
|
|
269
291
|
return `- [${age} ago] ${label}: ${truncateOneLine(m.text, perMessageMax)}`
|
|
270
292
|
}
|
|
271
293
|
|
|
294
|
+
/**
|
|
295
|
+
* One Hindsight recall result, as folded into the briefing's Hindsight
|
|
296
|
+
* section. Mirrors the `.results[]` shape `bin/handoff-briefing.sh` reads:
|
|
297
|
+
* a `text` body and an optional `timestamp` string. `text` may be empty —
|
|
298
|
+
* rendered as `(no text)` to match the shell's `.text // "(no text)"`.
|
|
299
|
+
*/
|
|
300
|
+
export interface HindsightRecallResult {
|
|
301
|
+
text: string
|
|
302
|
+
timestamp?: string | null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Today's daily-memory input for the briefing — the `date` (agent-local
|
|
307
|
+
* `YYYY-MM-DD`, used only in the section header) and the file `content`.
|
|
308
|
+
* Absent/empty content renders no section (no empty header).
|
|
309
|
+
*/
|
|
310
|
+
export interface BriefingDailyMemory {
|
|
311
|
+
date: string
|
|
312
|
+
content: string
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Section title for the Hindsight-recall block (mirrors the shell). */
|
|
316
|
+
const HINDSIGHT_SECTION_TITLE = '## Hindsight recall (recent context)'
|
|
317
|
+
|
|
318
|
+
/** Minimum chars of room a trailing (Hindsight / daily-memory) section
|
|
319
|
+
* needs before it is worth appending at all — below this, a truncated
|
|
320
|
+
* section would be just a header + a sliver, so skip it whole. */
|
|
321
|
+
const TRAILING_SECTION_MIN_ROOM = 64
|
|
322
|
+
|
|
272
323
|
export interface RenderBriefingOptions {
|
|
273
324
|
nowMs: number
|
|
274
325
|
/** Restart-reason breadcrumb (`.restart-reason` / SWITCHROOM_PENDING_*),
|
|
@@ -276,6 +327,64 @@ export interface RenderBriefingOptions {
|
|
|
276
327
|
restartReason?: string | null
|
|
277
328
|
charBudget?: number
|
|
278
329
|
perMessageMax?: number
|
|
330
|
+
/** Hindsight recall results (source 2 of the legacy handoff contract).
|
|
331
|
+
* Empty / absent → no Hindsight section. */
|
|
332
|
+
hindsight?: HindsightRecallResult[] | null
|
|
333
|
+
/** Today's daily memory (source 3). Absent / empty content → no section. */
|
|
334
|
+
dailyMemory?: BriefingDailyMemory | null
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Render the Hindsight recall body (the lines under the section header).
|
|
338
|
+
* Mirrors the shell's jq: `- <text> (<timestamp>)`, `(no text)` when the
|
|
339
|
+
* text is blank, and no trailing ` (…)` when there is no timestamp.
|
|
340
|
+
* Returns '' when there are no results (caller then emits no header). */
|
|
341
|
+
function renderHindsightBody(results: HindsightRecallResult[]): string {
|
|
342
|
+
const lines: string[] = []
|
|
343
|
+
for (const r of results) {
|
|
344
|
+
const text = (r.text ?? '').replace(/\s+/g, ' ').trim() || '(no text)'
|
|
345
|
+
const ts = r.timestamp && String(r.timestamp).trim() ? ` (${String(r.timestamp).trim()})` : ''
|
|
346
|
+
lines.push(`- ${text}${ts}`)
|
|
347
|
+
}
|
|
348
|
+
return lines.join('\n')
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Append a `## title` + body section to `base`, but only within the char
|
|
353
|
+
* budget. If the whole section fits, append it. If not, append the header
|
|
354
|
+
* plus as much body as fits (so an over-long daily memory truncates rather
|
|
355
|
+
* than vanishing) — unless there isn't even `TRAILING_SECTION_MIN_ROOM`
|
|
356
|
+
* left, in which case the section is skipped whole. Guarantees the result
|
|
357
|
+
* is always <= budget. A blank body is a no-op (no empty header).
|
|
358
|
+
*/
|
|
359
|
+
function appendSectionWithinBudget(
|
|
360
|
+
base: string,
|
|
361
|
+
title: string,
|
|
362
|
+
body: string,
|
|
363
|
+
budget: number,
|
|
364
|
+
): string {
|
|
365
|
+
const trimmedBody = body.trimEnd()
|
|
366
|
+
if (!trimmedBody) return base
|
|
367
|
+
const sep = '\n\n'
|
|
368
|
+
const full = `${base}${sep}${title}${sep}${trimmedBody}`
|
|
369
|
+
if (full.length <= budget) return full
|
|
370
|
+
// Room left for the body after the base + separators + title.
|
|
371
|
+
const room = budget - base.length - sep.length - title.length - sep.length
|
|
372
|
+
if (room < TRAILING_SECTION_MIN_ROOM) return base
|
|
373
|
+
const truncated = truncateOneLineOrBlock(trimmedBody, room)
|
|
374
|
+
return `${base}${sep}${title}${sep}${truncated}`
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** UTF-16-safe hard truncation preserving newlines (unlike `truncateOneLine`,
|
|
378
|
+
* which collapses whitespace to one line). Used for block sections (daily
|
|
379
|
+
* memory / Hindsight) where line structure matters. Bounds the result in
|
|
380
|
+
* UTF-16 units — measuring codepoints against a UTF-16 `max` overflows the
|
|
381
|
+
* budget for astral input (each 😀 is 2 UTF-16 units). Guarantees
|
|
382
|
+
* `result.length <= max`. */
|
|
383
|
+
function truncateOneLineOrBlock(s: string, max: number): string {
|
|
384
|
+
if (s.length <= max) return s
|
|
385
|
+
if (max <= 0) return ''
|
|
386
|
+
// Reserve one UTF-16 unit for the ellipsis ('…' is a single BMP unit).
|
|
387
|
+
return sliceUtf16Safe(s, max - 1).trimEnd() + '…'
|
|
279
388
|
}
|
|
280
389
|
|
|
281
390
|
/**
|
|
@@ -351,7 +460,26 @@ export function renderBootBriefing(
|
|
|
351
460
|
if (assemble(kept, [...secondaries, block]).length > charBudget) break
|
|
352
461
|
secondaries.push(block)
|
|
353
462
|
}
|
|
354
|
-
|
|
463
|
+
|
|
464
|
+
// Sources 2 + 3 of the legacy handoff contract, appended after the durable
|
|
465
|
+
// Telegram slice and within the SAME char budget (Telegram history is the
|
|
466
|
+
// freshest context, so it keeps priority; Hindsight then daily memory fill
|
|
467
|
+
// the remaining room, truncating rather than blowing the budget). Order
|
|
468
|
+
// mirrors bin/handoff-briefing.sh: telegram → hindsight → daily memory.
|
|
469
|
+
let out = assemble(kept, secondaries)
|
|
470
|
+
const hindsightBody = opts.hindsight && opts.hindsight.length > 0
|
|
471
|
+
? renderHindsightBody(opts.hindsight)
|
|
472
|
+
: ''
|
|
473
|
+
out = appendSectionWithinBudget(out, HINDSIGHT_SECTION_TITLE, hindsightBody, charBudget)
|
|
474
|
+
if (opts.dailyMemory && opts.dailyMemory.content.trim()) {
|
|
475
|
+
out = appendSectionWithinBudget(
|
|
476
|
+
out,
|
|
477
|
+
`## Today's memory (${opts.dailyMemory.date})`,
|
|
478
|
+
opts.dailyMemory.content,
|
|
479
|
+
charBudget,
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
return out
|
|
355
483
|
}
|
|
356
484
|
|
|
357
485
|
/**
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability sentinel for the start.sh ↔ gateway-bundle version handshake
|
|
3
|
+
* (#4245). This is a DEPENDENCY-FREE leaf module on purpose: it is imported
|
|
4
|
+
* both by the gateway (`boot-briefing-wiring.ts`, which references it so the
|
|
5
|
+
* un-minified bundle carries the literal) AND by `src/agents/scaffold.ts`
|
|
6
|
+
* (which templates the value into the generated start.sh). Keeping it free of
|
|
7
|
+
* `bun:sqlite` / history imports lets the node-side scaffold import it without
|
|
8
|
+
* pulling in bun-only runtime deps.
|
|
9
|
+
*
|
|
10
|
+
* ## The skew it closes
|
|
11
|
+
*
|
|
12
|
+
* A freshly-scaffolded start.sh sets `SWITCHROOM_SESSION_BRIEFING=gateway` AND
|
|
13
|
+
* skips the legacy shell handoff-briefing assembler for that mode. If the
|
|
14
|
+
* deployed `/opt/switchroom/telegram-plugin/dist/gateway/gateway.js` bundle
|
|
15
|
+
* PREDATES the boot-briefing builder, the gateway path is dead too — the agent
|
|
16
|
+
* gets a SILENT no-briefing until the image is updated. The stale bundle cannot
|
|
17
|
+
* self-report a feature it doesn't contain, so the FRESH component (start.sh)
|
|
18
|
+
* performs the handshake: it greps the deployed bundle for this literal before
|
|
19
|
+
* trusting the flag. On a miss it warns loudly and drops a marker so the inner
|
|
20
|
+
* pass runs the legacy handoff assembler as a fallback (see
|
|
21
|
+
* `profiles/_base/start.sh.hbs`), rather than leaving the boot with nothing.
|
|
22
|
+
*
|
|
23
|
+
* The gateway bundle is built UN-minified (`telegram-plugin/scripts/build.mjs`:
|
|
24
|
+
* `bun build --target node`, no `--minify`), so this string literal survives
|
|
25
|
+
* verbatim into `gateway.js`.
|
|
26
|
+
*
|
|
27
|
+
* Bump the version suffix ONLY on a breaking change to the boot-briefing
|
|
28
|
+
* capability contract that an older start.sh must not treat as present. Both
|
|
29
|
+
* sides of the handshake read this single constant, so a bump stays in sync.
|
|
30
|
+
*/
|
|
31
|
+
export const GATEWAY_BOOT_BRIEFING_CAPABILITY = 'switchroom-cap:boot-briefing:v1'
|