@0xmaxma/claude-gateway 1.3.24 → 1.3.31

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 (92) hide show
  1. package/README.md +172 -50
  2. package/config.template.json +6 -2
  3. package/dist/agent/incident-store.d.ts +89 -0
  4. package/dist/agent/incident-store.d.ts.map +1 -0
  5. package/dist/agent/incident-store.js +299 -0
  6. package/dist/agent/incident-store.js.map +1 -0
  7. package/dist/agent/incident.d.ts +156 -0
  8. package/dist/agent/incident.d.ts.map +1 -0
  9. package/dist/agent/incident.js +177 -0
  10. package/dist/agent/incident.js.map +1 -0
  11. package/dist/agent/recovery-executor.d.ts +117 -0
  12. package/dist/agent/recovery-executor.d.ts.map +1 -0
  13. package/dist/agent/recovery-executor.js +168 -0
  14. package/dist/agent/recovery-executor.js.map +1 -0
  15. package/dist/agent/recovery-policy.d.ts +97 -0
  16. package/dist/agent/recovery-policy.d.ts.map +1 -0
  17. package/dist/agent/recovery-policy.js +164 -0
  18. package/dist/agent/recovery-policy.js.map +1 -0
  19. package/dist/agent/runner.d.ts +44 -0
  20. package/dist/agent/runner.d.ts.map +1 -1
  21. package/dist/agent/runner.js +272 -2
  22. package/dist/agent/runner.js.map +1 -1
  23. package/dist/agent/safe-mode.d.ts +61 -0
  24. package/dist/agent/safe-mode.d.ts.map +1 -0
  25. package/dist/agent/safe-mode.js +102 -0
  26. package/dist/agent/safe-mode.js.map +1 -0
  27. package/dist/agent/triage.d.ts +94 -0
  28. package/dist/agent/triage.d.ts.map +1 -0
  29. package/dist/agent/triage.js +209 -0
  30. package/dist/agent/triage.js.map +1 -0
  31. package/dist/agent/turn-trace.d.ts +120 -0
  32. package/dist/agent/turn-trace.d.ts.map +1 -0
  33. package/dist/agent/turn-trace.js +122 -0
  34. package/dist/agent/turn-trace.js.map +1 -0
  35. package/dist/api/gateway-router.d.ts +21 -0
  36. package/dist/api/gateway-router.d.ts.map +1 -1
  37. package/dist/api/gateway-router.js +58 -17
  38. package/dist/api/gateway-router.js.map +1 -1
  39. package/dist/api/line-pending-senders.d.ts +7 -2
  40. package/dist/api/line-pending-senders.d.ts.map +1 -1
  41. package/dist/api/line-pending-senders.js +7 -2
  42. package/dist/api/line-pending-senders.js.map +1 -1
  43. package/dist/api/router.d.ts.map +1 -1
  44. package/dist/api/router.js +563 -172
  45. package/dist/api/router.js.map +1 -1
  46. package/dist/api/wizard-state.d.ts +1 -4
  47. package/dist/api/wizard-state.d.ts.map +1 -1
  48. package/dist/api/wizard-state.js.map +1 -1
  49. package/dist/config/migrator.d.ts +4 -0
  50. package/dist/config/migrator.d.ts.map +1 -1
  51. package/dist/config/migrator.js +60 -3
  52. package/dist/config/migrator.js.map +1 -1
  53. package/dist/discord/receiver.d.ts +1 -0
  54. package/dist/discord/receiver.d.ts.map +1 -1
  55. package/dist/discord/receiver.js +24 -6
  56. package/dist/discord/receiver.js.map +1 -1
  57. package/dist/index.js +3 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/session/process.d.ts +18 -0
  60. package/dist/session/process.d.ts.map +1 -1
  61. package/dist/session/process.js +61 -1
  62. package/dist/session/process.js.map +1 -1
  63. package/dist/shell/claude-pty-shell.js +59 -0
  64. package/dist/shell/claude-pty-shell.js.map +1 -1
  65. package/dist/shell/control-channel.d.ts +74 -0
  66. package/dist/shell/control-channel.d.ts.map +1 -0
  67. package/dist/shell/control-channel.js +114 -0
  68. package/dist/shell/control-channel.js.map +1 -0
  69. package/dist/telegram/receiver.d.ts +1 -0
  70. package/dist/telegram/receiver.d.ts.map +1 -1
  71. package/dist/telegram/receiver.js +18 -6
  72. package/dist/telegram/receiver.js.map +1 -1
  73. package/dist/types.d.ts +21 -0
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/ui/web-ui.d.ts.map +1 -1
  76. package/dist/ui/web-ui.js +103 -2
  77. package/dist/ui/web-ui.js.map +1 -1
  78. package/mcp/tools/discord/access.ts +136 -20
  79. package/mcp/tools/discord/client.ts +3 -1
  80. package/mcp/tools/discord/module.ts +75 -4
  81. package/mcp/tools/discord/skills/access/SKILL.md +66 -8
  82. package/mcp/tools/discord/skills/configure/SKILL.md +11 -1
  83. package/mcp/tools/discord/types.ts +21 -2
  84. package/mcp/tools/skills/handlers.ts +4 -2
  85. package/mcp/tools/telegram/dedup.ts +4 -1
  86. package/mcp/tools/telegram/module.ts +5 -14
  87. package/mcp/tools/telegram/pure.ts +169 -31
  88. package/mcp/tools/telegram/receiver-server.ts +271 -78
  89. package/mcp/tools/telegram/skills/access/SKILL.md +93 -23
  90. package/mcp/tools/telegram/skills/configure/SKILL.md +31 -26
  91. package/mcp/tools/telegram/typing.ts +124 -0
  92. package/package.json +3 -1
@@ -9,17 +9,31 @@ export type PendingEntry = {
9
9
  createdAt: number
10
10
  expiresAt: number
11
11
  replies: number
12
- }
13
-
14
- export type GroupPolicy = {
15
- requireMention: boolean
16
- allowFrom: string[]
12
+ // Absent ⇒ 'dm'. A 'group' entry is a group knock: chatId holds the group id
13
+ // and approval pushes that id into groupAllowlist (mirrors LINE).
14
+ kind?: 'dm' | 'group'
17
15
  }
18
16
 
19
17
  export type Access = {
20
- dmPolicy: 'pairing' | 'allowlist' | 'disabled'
18
+ dmPolicy: 'open' | 'allowlist' | 'disabled'
19
+ // Orthogonal to dmPolicy (mirrors LINE): when the base policy is 'allowlist',
20
+ // `pairing: true` means an unknown sender gets a one-time code and lands in
21
+ // pending for the admin to approve; `pairing: false` means they're silently
22
+ // dropped (pure allowlist). Ignored when dmPolicy is 'open'/'disabled'.
23
+ pairing: boolean
21
24
  allowFrom: string[]
22
- groups: Record<string, GroupPolicy>
25
+ // Group access tier (mirrors LINE): base policy for groups, the allowlisted
26
+ // group ids, and a single requireMention gate. `pairing` (above) governs
27
+ // group code-minting too, exactly like DMs.
28
+ groupPolicy: 'open' | 'allowlist' | 'disabled'
29
+ groupAllowlist: string[]
30
+ requireMention: boolean
31
+ // Migration-only artifact: a pre-split file's per-group `allowFrom` (the old
32
+ // schema could restrict a group to specific senders). The new model has no
33
+ // per-user group tier, so this is preserved but never written by any current
34
+ // API/CLI — enforced in gateLogic() as an extra filter so migrating doesn't
35
+ // silently widen a previously-restricted group to every member.
36
+ legacyGroupAllowFrom?: Record<string, string[]>
23
37
  pending: Record<string, PendingEntry>
24
38
  mentionPatterns?: string[]
25
39
  ackReaction?: string
@@ -28,15 +42,101 @@ export type Access = {
28
42
  chunkMode?: 'length' | 'newline'
29
43
  }
30
44
 
45
+ // Default for a brand-new agent (no access.json yet): closed base + pairing on,
46
+ // so the owner can DM the bot and capture their own id via a code. This matches
47
+ // the pre-split behavior where a missing file defaulted to dmPolicy:'pairing'.
31
48
  export function defaultAccess(): Access {
32
49
  return {
33
- dmPolicy: 'pairing',
50
+ dmPolicy: 'allowlist',
51
+ pairing: true,
34
52
  allowFrom: [],
35
- groups: {},
53
+ groupPolicy: 'allowlist',
54
+ groupAllowlist: [],
55
+ requireMention: true,
36
56
  pending: {},
37
57
  }
38
58
  }
39
59
 
60
+ /**
61
+ * Normalize a parsed access.json into the current shape, migrating the legacy
62
+ * 4-value dmPolicy (which folded pairing in) to the split model.
63
+ *
64
+ * SECURITY: a legacy `allowlist` file was deliberately locked down — it must
65
+ * migrate to `pairing:false`, NOT true, or it would start minting codes for
66
+ * strangers. `pairing ?? true` is only correct for the brand-new/ENOENT path
67
+ * (see defaultAccess). Here an absent `pairing` on an existing file means a
68
+ * pre-split file → pairing off.
69
+ */
70
+ /**
71
+ * Extract per-group sender restrictions from a legacy `groups` map. Only a
72
+ * non-empty `allowFrom` counts as a restriction — an empty array meant
73
+ * "unrestricted" under the old schema, same as it does now.
74
+ */
75
+ export function deriveLegacyGroupAllowFrom(
76
+ groups?: Record<string, { requireMention?: boolean; allowFrom?: string[] }>,
77
+ ): Record<string, string[]> | undefined {
78
+ if (!groups) return undefined
79
+ const out: Record<string, string[]> = {}
80
+ for (const [groupId, g] of Object.entries(groups)) {
81
+ if (g.allowFrom && g.allowFrom.length > 0) out[groupId] = [...g.allowFrom]
82
+ }
83
+ return Object.keys(out).length > 0 ? out : undefined
84
+ }
85
+
86
+ export function migrateAccess(parsed: {
87
+ dmPolicy?: string
88
+ pairing?: boolean
89
+ allowFrom?: string[]
90
+ groups?: Record<string, { requireMention?: boolean; allowFrom?: string[] }>
91
+ groupPolicy?: string
92
+ groupAllowlist?: string[]
93
+ requireMention?: boolean
94
+ legacyGroupAllowFrom?: Record<string, string[]>
95
+ pending?: Record<string, PendingEntry>
96
+ mentionPatterns?: string[]
97
+ ackReaction?: string
98
+ replyToMode?: 'off' | 'first' | 'all'
99
+ textChunkLimit?: number
100
+ chunkMode?: 'length' | 'newline'
101
+ }): Access {
102
+ const legacy = parsed.dmPolicy
103
+ let dmPolicy: Access['dmPolicy']
104
+ let pairing: boolean
105
+ if (legacy === 'pairing') {
106
+ dmPolicy = 'allowlist'
107
+ pairing = true
108
+ } else {
109
+ dmPolicy = (legacy as Access['dmPolicy']) ?? 'allowlist'
110
+ pairing = parsed.pairing ?? false
111
+ }
112
+ // Group tier: flatten legacy per-group `groups` map to a flat allowlist
113
+ // (mirrors LINE). Legacy groups were closed-by-default → migrate to
114
+ // 'allowlist' + requireMention:true, behavior-preserving. A group's
115
+ // per-user `allowFrom` override has no equivalent in the new flat model,
116
+ // but it's still a real restriction — preserve it in legacyGroupAllowFrom
117
+ // and enforce it in gateLogic() rather than silently dropping it (that
118
+ // would widen a restricted group to every member).
119
+ const groupAllowlist = parsed.groupAllowlist ?? Object.keys(parsed.groups ?? {})
120
+ const groupPolicy = (parsed.groupPolicy as Access['groupPolicy']) ?? 'allowlist'
121
+ const requireMention = parsed.requireMention ?? true
122
+ const legacyGroupAllowFrom = parsed.legacyGroupAllowFrom ?? deriveLegacyGroupAllowFrom(parsed.groups)
123
+ return {
124
+ dmPolicy,
125
+ pairing,
126
+ allowFrom: parsed.allowFrom ?? [],
127
+ groupPolicy,
128
+ groupAllowlist,
129
+ requireMention,
130
+ legacyGroupAllowFrom,
131
+ pending: parsed.pending ?? {},
132
+ mentionPatterns: parsed.mentionPatterns,
133
+ ackReaction: parsed.ackReaction,
134
+ replyToMode: parsed.replyToMode,
135
+ textChunkLimit: parsed.textChunkLimit,
136
+ chunkMode: parsed.chunkMode,
137
+ }
138
+ }
139
+
40
140
  export const MAX_CHUNK_LIMIT = 4096
41
141
 
42
142
  export function pruneExpired(a: Access, now?: number): boolean {
@@ -81,18 +181,8 @@ import { join } from 'path'
81
181
  export function readAccessFile(accessFile: string): Access {
82
182
  try {
83
183
  const raw = readFileSync(accessFile, 'utf8')
84
- const parsed = JSON.parse(raw) as Partial<Access>
85
- return {
86
- dmPolicy: parsed.dmPolicy ?? 'pairing',
87
- allowFrom: parsed.allowFrom ?? [],
88
- groups: parsed.groups ?? {},
89
- pending: parsed.pending ?? {},
90
- mentionPatterns: parsed.mentionPatterns,
91
- ackReaction: parsed.ackReaction,
92
- replyToMode: parsed.replyToMode,
93
- textChunkLimit: parsed.textChunkLimit,
94
- chunkMode: parsed.chunkMode,
95
- }
184
+ const parsed = JSON.parse(raw) as Partial<Access> & { dmPolicy?: string }
185
+ return migrateAccess(parsed)
96
186
  } catch (err) {
97
187
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') return defaultAccess()
98
188
  try {
@@ -124,7 +214,7 @@ export type GateInput = {
124
214
  export type GateResult =
125
215
  | { action: 'deliver'; access: Access }
126
216
  | { action: 'drop' }
127
- | { action: 'pair'; code: string; isResend: boolean }
217
+ | { action: 'pair'; code: string; isResend: boolean; isGroup?: boolean }
128
218
 
129
219
  /**
130
220
  * Pure gate logic (for testing without Grammy Context).
@@ -149,19 +239,29 @@ export function gateLogic(
149
239
  const chatType = input.chatType
150
240
 
151
241
  if (chatType === 'private') {
242
+ if (access.dmPolicy === 'open') {
243
+ if (!access.allowFrom.includes(senderId)) {
244
+ access.allowFrom.push(senderId)
245
+ saveAccessFn(access)
246
+ }
247
+ return { action: 'deliver', access }
248
+ }
152
249
  if (access.allowFrom.includes(senderId)) return { action: 'deliver', access }
153
- if (access.dmPolicy === 'allowlist') return { action: 'drop' }
250
+ // Base policy is 'allowlist' ('disabled' already dropped above). Pairing is
251
+ // the orthogonal toggle: off ⇒ pure allowlist (drop strangers, no code).
252
+ if (!access.pairing) return { action: 'drop' }
154
253
 
155
254
  // pairing mode
156
255
  for (const [code, p] of Object.entries(access.pending)) {
157
- if (p.senderId === senderId) {
256
+ if ((p.kind ?? 'dm') === 'dm' && p.senderId === senderId) {
158
257
  if ((p.replies ?? 1) >= 2) return { action: 'drop' }
159
258
  p.replies = (p.replies ?? 1) + 1
160
259
  saveAccessFn(access)
161
260
  return { action: 'pair', code, isResend: true }
162
261
  }
163
262
  }
164
- if (Object.keys(access.pending).length >= 3) return { action: 'drop' }
263
+ // Cap pending per-kind so group knocks and DM knocks don't starve each other.
264
+ if (countPending(access, 'dm') >= 5) return { action: 'drop' }
165
265
 
166
266
  const code = generateCode()
167
267
  const ts = now ?? Date.now()
@@ -171,6 +271,7 @@ export function gateLogic(
171
271
  createdAt: ts,
172
272
  expiresAt: ts + 60 * 60 * 1000,
173
273
  replies: 1,
274
+ kind: 'dm',
174
275
  }
175
276
  saveAccessFn(access)
176
277
  return { action: 'pair', code, isResend: false }
@@ -178,14 +279,42 @@ export function gateLogic(
178
279
 
179
280
  if (chatType === 'group' || chatType === 'supergroup') {
180
281
  const groupId = input.chatId ?? ''
181
- const policy = access.groups[groupId]
182
- if (!policy) return { action: 'drop' }
183
- const groupAllowFrom = policy.allowFrom ?? []
184
- const requireMention = policy.requireMention ?? true
185
- if (groupAllowFrom.length > 0 && !groupAllowFrom.includes(senderId)) {
282
+ if (access.groupPolicy === 'disabled') return { action: 'drop' }
283
+
284
+ if (access.groupPolicy === 'allowlist' && !access.groupAllowlist.includes(groupId)) {
285
+ // Unknown group. Pairing off ⇒ silent drop (pure allowlist). On ⇒ mint a
286
+ // code keyed on the group id; a member relays it to the admin (mirrors LINE).
287
+ if (!access.pairing) return { action: 'drop' }
288
+ for (const [code, p] of Object.entries(access.pending)) {
289
+ if (p.kind === 'group' && p.chatId === groupId) {
290
+ if ((p.replies ?? 1) >= 2) return { action: 'drop' }
291
+ p.replies = (p.replies ?? 1) + 1
292
+ saveAccessFn(access)
293
+ return { action: 'pair', code, isResend: true, isGroup: true }
294
+ }
295
+ }
296
+ if (countPending(access, 'group') >= 5) return { action: 'drop' }
297
+ const code = generateCode()
298
+ const ts = now ?? Date.now()
299
+ access.pending[code] = {
300
+ senderId,
301
+ chatId: groupId,
302
+ createdAt: ts,
303
+ expiresAt: ts + 60 * 60 * 1000,
304
+ replies: 1,
305
+ kind: 'group',
306
+ }
307
+ saveAccessFn(access)
308
+ return { action: 'pair', code, isResend: false, isGroup: true }
309
+ }
310
+
311
+ // Allowlisted (or open policy) → enforce any legacy per-sender restriction
312
+ // that survived migration, then the single mention gate.
313
+ const legacyAllowed = access.legacyGroupAllowFrom?.[groupId]
314
+ if (legacyAllowed && legacyAllowed.length > 0 && !legacyAllowed.includes(senderId)) {
186
315
  return { action: 'drop' }
187
316
  }
188
- if (requireMention && !isMentionedPure(input, access.mentionPatterns)) {
317
+ if (access.requireMention !== false && !isMentionedPure(input, access.mentionPatterns)) {
189
318
  return { action: 'drop' }
190
319
  }
191
320
  return { action: 'deliver', access }
@@ -194,6 +323,15 @@ export function gateLogic(
194
323
  return { action: 'drop' }
195
324
  }
196
325
 
326
+ /** Count pending entries of a given kind (absent kind ⇒ 'dm'). */
327
+ function countPending(access: Access, kind: 'dm' | 'group'): number {
328
+ let n = 0
329
+ for (const p of Object.values(access.pending)) {
330
+ if ((p.kind ?? 'dm') === kind) n++
331
+ }
332
+ return n
333
+ }
334
+
197
335
  export { hasMarkdown, toTelegramHtml } from './lib/markdown'
198
336
 
199
337
  export function isMentionedPure(input: GateInput, extraPatterns?: string[]): boolean {