aegis-bridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +404 -0
- package/dashboard/dist/assets/index-BoZwGLAx.css +32 -0
- package/dashboard/dist/assets/index-C61BkKH-.js +312 -0
- package/dashboard/dist/assets/index-C61BkKH-.js.map +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/api-contracts.d.ts +229 -0
- package/dist/api-contracts.js +7 -0
- package/dist/api-contracts.typecheck.d.ts +14 -0
- package/dist/api-contracts.typecheck.js +1 -0
- package/dist/api-error-envelope.d.ts +15 -0
- package/dist/api-error-envelope.js +80 -0
- package/dist/auth.d.ts +87 -0
- package/dist/auth.js +276 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +8 -0
- package/dist/channels/manager.d.ts +47 -0
- package/dist/channels/manager.js +115 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +202 -0
- package/dist/channels/telegram.d.ts +91 -0
- package/dist/channels/telegram.js +1518 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +8 -0
- package/dist/channels/webhook.d.ts +60 -0
- package/dist/channels/webhook.js +216 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +252 -0
- package/dist/config.d.ts +90 -0
- package/dist/config.js +214 -0
- package/dist/consensus.d.ts +16 -0
- package/dist/consensus.js +19 -0
- package/dist/continuation-pointer.d.ts +11 -0
- package/dist/continuation-pointer.js +65 -0
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.js +95 -0
- package/dist/error-categories.d.ts +39 -0
- package/dist/error-categories.js +73 -0
- package/dist/events.d.ts +133 -0
- package/dist/events.js +389 -0
- package/dist/fault-injection.d.ts +29 -0
- package/dist/fault-injection.js +115 -0
- package/dist/file-utils.d.ts +2 -0
- package/dist/file-utils.js +37 -0
- package/dist/handshake.d.ts +60 -0
- package/dist/handshake.js +124 -0
- package/dist/hook-settings.d.ts +80 -0
- package/dist/hook-settings.js +272 -0
- package/dist/hook.d.ts +19 -0
- package/dist/hook.js +231 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +364 -0
- package/dist/jsonl-watcher.d.ts +59 -0
- package/dist/jsonl-watcher.js +166 -0
- package/dist/logger.d.ts +35 -0
- package/dist/logger.js +65 -0
- package/dist/mcp-server.d.ts +123 -0
- package/dist/mcp-server.js +869 -0
- package/dist/memory-bridge.d.ts +27 -0
- package/dist/memory-bridge.js +137 -0
- package/dist/memory-routes.d.ts +3 -0
- package/dist/memory-routes.js +100 -0
- package/dist/metrics.d.ts +126 -0
- package/dist/metrics.js +286 -0
- package/dist/model-router.d.ts +53 -0
- package/dist/model-router.js +150 -0
- package/dist/monitor.d.ts +103 -0
- package/dist/monitor.js +820 -0
- package/dist/path-utils.d.ts +11 -0
- package/dist/path-utils.js +21 -0
- package/dist/permission-evaluator.d.ts +10 -0
- package/dist/permission-evaluator.js +48 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +196 -0
- package/dist/permission-request-manager.d.ts +12 -0
- package/dist/permission-request-manager.js +36 -0
- package/dist/permission-routes.d.ts +7 -0
- package/dist/permission-routes.js +28 -0
- package/dist/pipeline.d.ts +97 -0
- package/dist/pipeline.js +291 -0
- package/dist/process-utils.d.ts +4 -0
- package/dist/process-utils.js +73 -0
- package/dist/question-manager.d.ts +54 -0
- package/dist/question-manager.js +80 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +34 -0
- package/dist/safe-json.d.ts +12 -0
- package/dist/safe-json.js +22 -0
- package/dist/screenshot.d.ts +28 -0
- package/dist/screenshot.js +60 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1973 -0
- package/dist/session-cleanup.d.ts +18 -0
- package/dist/session-cleanup.js +11 -0
- package/dist/session.d.ts +379 -0
- package/dist/session.js +1568 -0
- package/dist/shutdown-utils.d.ts +5 -0
- package/dist/shutdown-utils.js +24 -0
- package/dist/signal-cleanup-helper.d.ts +48 -0
- package/dist/signal-cleanup-helper.js +117 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +61 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +94 -0
- package/dist/ssrf.d.ts +102 -0
- package/dist/ssrf.js +267 -0
- package/dist/startup.d.ts +6 -0
- package/dist/startup.js +162 -0
- package/dist/suppress.d.ts +33 -0
- package/dist/suppress.js +79 -0
- package/dist/swarm-monitor.d.ts +117 -0
- package/dist/swarm-monitor.js +300 -0
- package/dist/template-store.d.ts +45 -0
- package/dist/template-store.js +142 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +346 -0
- package/dist/tmux-capture-cache.d.ts +18 -0
- package/dist/tmux-capture-cache.js +34 -0
- package/dist/tmux.d.ts +183 -0
- package/dist/tmux.js +906 -0
- package/dist/tool-registry.d.ts +40 -0
- package/dist/tool-registry.js +83 -0
- package/dist/transcript.d.ts +63 -0
- package/dist/transcript.js +284 -0
- package/dist/utils/circular-buffer.d.ts +11 -0
- package/dist/utils/circular-buffer.js +37 -0
- package/dist/utils/redact-headers.d.ts +13 -0
- package/dist/utils/redact-headers.js +54 -0
- package/dist/validation.d.ts +406 -0
- package/dist/validation.js +415 -0
- package/dist/verification.d.ts +2 -0
- package/dist/verification.js +72 -0
- package/dist/worktree-lookup.d.ts +24 -0
- package/dist/worktree-lookup.js +71 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +348 -0
- package/package.json +83 -0
package/dist/auth.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth.ts — API key management and authentication middleware.
|
|
3
|
+
*
|
|
4
|
+
* Issue #39: Multi-key auth with rate limiting.
|
|
5
|
+
* Keys are hashed with SHA-256 (no bcrypt dependency needed).
|
|
6
|
+
* Backward compatible with single authToken from config.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
10
|
+
import { authStoreSchema } from './validation.js';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { dirname } from 'node:path';
|
|
13
|
+
import { secureFilePermissions } from './file-utils.js';
|
|
14
|
+
/** Default SSE token lifetime: 60 seconds. */
|
|
15
|
+
const SSE_TOKEN_TTL_MS = 60_000;
|
|
16
|
+
/** Max SSE tokens per bearer token to prevent abuse. */
|
|
17
|
+
const SSE_TOKEN_MAX_PER_KEY = 5;
|
|
18
|
+
/** #583: Minimum interval between batch creation requests per key (5 seconds). */
|
|
19
|
+
const BATCH_COOLDOWN_MS = 5_000;
|
|
20
|
+
/** Route-level auth policy for bearer tokens. */
|
|
21
|
+
export function classifyBearerTokenForRoute(token, isSSERoute) {
|
|
22
|
+
if (!isSSERoute)
|
|
23
|
+
return 'bearer';
|
|
24
|
+
return token.startsWith('sse_') ? 'sse' : 'reject';
|
|
25
|
+
}
|
|
26
|
+
export class AuthManager {
|
|
27
|
+
keysFile;
|
|
28
|
+
store = { keys: [] };
|
|
29
|
+
rateLimits = new Map();
|
|
30
|
+
masterToken;
|
|
31
|
+
/** #297: Short-lived SSE tokens. Keyed by token string for O(1) lookup. */
|
|
32
|
+
sseTokens = new Map();
|
|
33
|
+
/** Track how many SSE tokens each bearer key has outstanding. */
|
|
34
|
+
sseTokenCounts = new Map();
|
|
35
|
+
/** #414: Mutex to prevent concurrent SSE token generation from exceeding per-key limits. */
|
|
36
|
+
sseMutex = Promise.resolve();
|
|
37
|
+
/** #583: Last batch creation timestamp per key ID. */
|
|
38
|
+
batchRateLimits = new Map();
|
|
39
|
+
constructor(keysFile, masterToken = '') {
|
|
40
|
+
this.keysFile = keysFile;
|
|
41
|
+
this.masterToken = masterToken;
|
|
42
|
+
}
|
|
43
|
+
/** Load keys from disk. */
|
|
44
|
+
async load() {
|
|
45
|
+
if (existsSync(this.keysFile)) {
|
|
46
|
+
try {
|
|
47
|
+
const raw = await readFile(this.keysFile, 'utf-8');
|
|
48
|
+
const parsed = authStoreSchema.safeParse(JSON.parse(raw));
|
|
49
|
+
if (parsed.success) {
|
|
50
|
+
this.store = parsed.data;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch { /* corrupted or unreadable keys file — start fresh */
|
|
54
|
+
this.store = { keys: [] };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Save keys to disk. */
|
|
59
|
+
async save() {
|
|
60
|
+
const dir = dirname(this.keysFile);
|
|
61
|
+
if (!existsSync(dir)) {
|
|
62
|
+
await mkdir(dir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
await writeFile(this.keysFile, JSON.stringify(this.store, null, 2), { mode: 0o600 });
|
|
65
|
+
await secureFilePermissions(this.keysFile);
|
|
66
|
+
}
|
|
67
|
+
/** Create a new API key. Returns the plaintext key (only shown once). */
|
|
68
|
+
async createKey(name, rateLimit = 100) {
|
|
69
|
+
const id = randomBytes(8).toString('hex');
|
|
70
|
+
const key = `aegis_${randomBytes(32).toString('hex')}`;
|
|
71
|
+
const hash = AuthManager.hashKey(key);
|
|
72
|
+
const apiKey = {
|
|
73
|
+
id,
|
|
74
|
+
name,
|
|
75
|
+
hash,
|
|
76
|
+
createdAt: Date.now(),
|
|
77
|
+
lastUsedAt: 0,
|
|
78
|
+
rateLimit,
|
|
79
|
+
};
|
|
80
|
+
this.store.keys.push(apiKey);
|
|
81
|
+
await this.save();
|
|
82
|
+
return { id, key, name };
|
|
83
|
+
}
|
|
84
|
+
/** List keys (without hashes). */
|
|
85
|
+
listKeys() {
|
|
86
|
+
return this.store.keys.map(({ hash: _, ...rest }) => rest);
|
|
87
|
+
}
|
|
88
|
+
/** Revoke a key by ID. */
|
|
89
|
+
async revokeKey(id) {
|
|
90
|
+
const idx = this.store.keys.findIndex(k => k.id === id);
|
|
91
|
+
if (idx === -1)
|
|
92
|
+
return false;
|
|
93
|
+
this.store.keys.splice(idx, 1);
|
|
94
|
+
this.rateLimits.delete(id);
|
|
95
|
+
await this.save();
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Validate a bearer token.
|
|
100
|
+
* Returns { valid, keyId, rateLimited } or null if no auth configured.
|
|
101
|
+
*/
|
|
102
|
+
validate(token) {
|
|
103
|
+
// No auth configured and no keys → allow all
|
|
104
|
+
if (!this.masterToken && this.store.keys.length === 0) {
|
|
105
|
+
return { valid: true, keyId: null, rateLimited: false };
|
|
106
|
+
}
|
|
107
|
+
// Check master token (backward compat) — timing-safe comparison (#402)
|
|
108
|
+
if (this.masterToken && AuthManager.timingSafeStringEqual(token, this.masterToken)) {
|
|
109
|
+
return { valid: true, keyId: 'master', rateLimited: false };
|
|
110
|
+
}
|
|
111
|
+
// Check API keys
|
|
112
|
+
const hash = AuthManager.hashKey(token);
|
|
113
|
+
const key = this.store.keys.find(k => k.hash === hash);
|
|
114
|
+
if (!key) {
|
|
115
|
+
return { valid: false, keyId: null, rateLimited: false };
|
|
116
|
+
}
|
|
117
|
+
// Rate limiting
|
|
118
|
+
const bucket = this.rateLimits.get(key.id) || { count: 0, windowStart: Date.now() };
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
const windowMs = 60_000; // 1 minute
|
|
121
|
+
if (now - bucket.windowStart > windowMs) {
|
|
122
|
+
// New window
|
|
123
|
+
bucket.count = 1;
|
|
124
|
+
bucket.windowStart = now;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
bucket.count++;
|
|
128
|
+
}
|
|
129
|
+
this.rateLimits.set(key.id, bucket);
|
|
130
|
+
if (bucket.count > key.rateLimit) {
|
|
131
|
+
return { valid: true, keyId: key.id, rateLimited: true };
|
|
132
|
+
}
|
|
133
|
+
// Issue #841: Only update lastUsedAt for accepted requests, not rate-limited ones
|
|
134
|
+
key.lastUsedAt = Date.now();
|
|
135
|
+
return { valid: true, keyId: key.id, rateLimited: false };
|
|
136
|
+
}
|
|
137
|
+
/** Hash a key with SHA-256. */
|
|
138
|
+
static hashKey(key) {
|
|
139
|
+
return createHash('sha256').update(key).digest('hex');
|
|
140
|
+
}
|
|
141
|
+
/** Constant-time equality check for secret strings. */
|
|
142
|
+
static timingSafeStringEqual(a, b) {
|
|
143
|
+
if (a.length !== b.length)
|
|
144
|
+
return false;
|
|
145
|
+
return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
|
|
146
|
+
}
|
|
147
|
+
/** #583: Check and update batch rate limit for a key. Returns true if rate-limited. */
|
|
148
|
+
checkBatchRateLimit(keyId) {
|
|
149
|
+
const id = keyId ?? 'anonymous';
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
const lastBatch = this.batchRateLimits.get(id);
|
|
152
|
+
if (lastBatch !== undefined && now - lastBatch < BATCH_COOLDOWN_MS) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
this.batchRateLimits.set(id, now);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
/** #398: Sweep stale rate limit buckets. Prune entries with expired windows. */
|
|
159
|
+
sweepStaleRateLimits() {
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
const windowMs = 60_000; // 1 minute
|
|
162
|
+
for (const [keyId, bucket] of this.rateLimits) {
|
|
163
|
+
if (now - bucket.windowStart > windowMs) {
|
|
164
|
+
this.rateLimits.delete(keyId);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// #583: Prune expired batch rate limit entries
|
|
168
|
+
for (const [keyId, ts] of this.batchRateLimits) {
|
|
169
|
+
if (now - ts > BATCH_COOLDOWN_MS) {
|
|
170
|
+
this.batchRateLimits.delete(keyId);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Check if auth is enabled (master token or any keys). */
|
|
175
|
+
get authEnabled() {
|
|
176
|
+
return !!this.masterToken || this.store.keys.length > 0;
|
|
177
|
+
}
|
|
178
|
+
// ── SSE Token Management (Issue #297) ────────────────────────
|
|
179
|
+
/**
|
|
180
|
+
* Generate a short-lived, single-use SSE token.
|
|
181
|
+
* The caller must already be authenticated (validated via bearer token).
|
|
182
|
+
* Returns the token string and its expiry timestamp.
|
|
183
|
+
* #414: Async with mutex to prevent concurrent calls from exceeding per-key limits.
|
|
184
|
+
*/
|
|
185
|
+
async generateSSEToken(keyId) {
|
|
186
|
+
// Acquire mutex — chain onto the previous operation
|
|
187
|
+
let release = () => { };
|
|
188
|
+
const lock = new Promise((resolve) => { release = resolve; });
|
|
189
|
+
const previous = this.sseMutex;
|
|
190
|
+
this.sseMutex = lock;
|
|
191
|
+
// #509: await + try/finally together so release() fires even if previous rejects
|
|
192
|
+
// #573: catch prior rejection so it doesn't propagate and block subsequent callers
|
|
193
|
+
try {
|
|
194
|
+
await previous.catch(() => { });
|
|
195
|
+
// Cleanup expired tokens first
|
|
196
|
+
this.cleanExpiredSSETokens();
|
|
197
|
+
// Enforce per-key limit
|
|
198
|
+
const current = this.sseTokenCounts.get(keyId) ?? 0;
|
|
199
|
+
if (current >= SSE_TOKEN_MAX_PER_KEY) {
|
|
200
|
+
throw new Error(`SSE token limit reached (${SSE_TOKEN_MAX_PER_KEY} outstanding)`);
|
|
201
|
+
}
|
|
202
|
+
const token = `sse_${randomBytes(32).toString('hex')}`;
|
|
203
|
+
const expiresAt = Date.now() + SSE_TOKEN_TTL_MS;
|
|
204
|
+
this.sseTokens.set(token, { token, expiresAt, used: false, keyId });
|
|
205
|
+
this.sseTokenCounts.set(keyId, current + 1);
|
|
206
|
+
return { token, expiresAt };
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
release();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Validate and consume a short-lived SSE token.
|
|
214
|
+
* Returns true if valid (and marks it as used), false otherwise.
|
|
215
|
+
* #826: Async with mutex to prevent concurrent validation/generation from
|
|
216
|
+
* racing on shared state (sseTokens, sseTokenCounts).
|
|
217
|
+
*/
|
|
218
|
+
async validateSSEToken(token) {
|
|
219
|
+
// Acquire mutex — chain onto the previous operation
|
|
220
|
+
let release = () => { };
|
|
221
|
+
const lock = new Promise((resolve) => { release = resolve; });
|
|
222
|
+
const previous = this.sseMutex;
|
|
223
|
+
this.sseMutex = lock;
|
|
224
|
+
// #573: catch prior rejection so it doesn't propagate and block subsequent callers
|
|
225
|
+
try {
|
|
226
|
+
await previous.catch(() => { });
|
|
227
|
+
const entry = this.sseTokens.get(token);
|
|
228
|
+
if (!entry)
|
|
229
|
+
return false;
|
|
230
|
+
// Already used
|
|
231
|
+
if (entry.used) {
|
|
232
|
+
this.sseTokens.delete(token);
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
// Expired
|
|
236
|
+
if (Date.now() > entry.expiresAt) {
|
|
237
|
+
this.sseTokens.delete(token);
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
// Valid — consume it
|
|
241
|
+
entry.used = true;
|
|
242
|
+
const keyId = entry.keyId;
|
|
243
|
+
this.sseTokens.delete(token);
|
|
244
|
+
// #357: Decrement outstanding count so generateSSEToken doesn't over-limit
|
|
245
|
+
const count = this.sseTokenCounts.get(keyId);
|
|
246
|
+
if (count !== undefined) {
|
|
247
|
+
if (count <= 1) {
|
|
248
|
+
this.sseTokenCounts.delete(keyId);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
this.sseTokenCounts.set(keyId, count - 1);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
release();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** Remove expired SSE tokens and recount per-key outstanding. */
|
|
261
|
+
cleanExpiredSSETokens() {
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
// Remove expired
|
|
264
|
+
for (const [key, entry] of this.sseTokens) {
|
|
265
|
+
if (now > entry.expiresAt) {
|
|
266
|
+
this.sseTokens.delete(key);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// Rebuild counts from surviving tokens
|
|
270
|
+
this.sseTokenCounts.clear();
|
|
271
|
+
for (const entry of this.sseTokens.values()) {
|
|
272
|
+
const count = this.sseTokenCounts.get(entry.keyId) ?? 0;
|
|
273
|
+
this.sseTokenCounts.set(entry.keyId, count + 1);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels/index.ts — Re-exports for the channel plugin system.
|
|
3
|
+
*/
|
|
4
|
+
export type { Channel, SessionEvent, SessionEventPayload, InboundCommand, InboundHandler, } from './types.js';
|
|
5
|
+
export { ChannelManager } from './manager.js';
|
|
6
|
+
export { TelegramChannel, type TelegramChannelConfig } from './telegram.js';
|
|
7
|
+
export { WebhookChannel, type WebhookChannelConfig, type WebhookEndpoint, type DeadLetterEntry } from './webhook.js';
|
|
8
|
+
export { quickUpdate, quickUpdateCode, taskComplete, alert, yesNo, decision, progress, esc, bold, code, italic, statusEmoji, type StyledMessage, type InlineButton, type StatusEmoji, type TaskCompleteData, type AlertData, type AlertButtons, type DecisionOption, type ProgressStep, } from './telegram-style.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels/index.ts — Re-exports for the channel plugin system.
|
|
3
|
+
*/
|
|
4
|
+
export { ChannelManager } from './manager.js';
|
|
5
|
+
export { TelegramChannel } from './telegram.js';
|
|
6
|
+
export { WebhookChannel } from './webhook.js';
|
|
7
|
+
// Telegram Style Guide — 6 standard message types
|
|
8
|
+
export { quickUpdate, quickUpdateCode, taskComplete, alert, yesNo, decision, progress, esc, bold, code, italic, statusEmoji, } from './telegram-style.js';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels/manager.ts — Routes events to all registered channels.
|
|
3
|
+
*
|
|
4
|
+
* The bridge calls ChannelManager methods. The manager fans out
|
|
5
|
+
* to every registered channel, swallowing per-channel errors so
|
|
6
|
+
* one broken channel never kills the bridge.
|
|
7
|
+
*/
|
|
8
|
+
import type { Channel, SessionEventPayload, InboundHandler } from './types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Thrown for retriable failures (5xx server errors, network timeouts).
|
|
11
|
+
* Only these increment the circuit breaker failure count.
|
|
12
|
+
* 4xx client errors are thrown as plain Error and do NOT trip the breaker.
|
|
13
|
+
*/
|
|
14
|
+
export declare class RetriableError extends Error {
|
|
15
|
+
constructor(message: string);
|
|
16
|
+
}
|
|
17
|
+
export declare class ChannelManager {
|
|
18
|
+
private channels;
|
|
19
|
+
private inboundHandler;
|
|
20
|
+
private health;
|
|
21
|
+
/** Consecutive failures before disabling a channel. */
|
|
22
|
+
static readonly FAILURE_THRESHOLD = 5;
|
|
23
|
+
/** Cooldown period in ms when a channel is disabled (5 min). */
|
|
24
|
+
static readonly COOLDOWN_MS: number;
|
|
25
|
+
/** Register a channel. Must be called before init(). */
|
|
26
|
+
register(channel: Channel): void;
|
|
27
|
+
/** Initialize all channels. Pass the inbound handler for bidirectional channels. */
|
|
28
|
+
init(onInbound: InboundHandler): Promise<void>;
|
|
29
|
+
/** Shut down all channels. */
|
|
30
|
+
destroy(): Promise<void>;
|
|
31
|
+
/** Fan out a session-created event. */
|
|
32
|
+
sessionCreated(payload: SessionEventPayload): Promise<void>;
|
|
33
|
+
/** Fan out a session-ended event. */
|
|
34
|
+
sessionEnded(payload: SessionEventPayload): Promise<void>;
|
|
35
|
+
/** Fan out a message event. */
|
|
36
|
+
message(payload: SessionEventPayload): Promise<void>;
|
|
37
|
+
/** Fan out a status change event. */
|
|
38
|
+
statusChange(payload: SessionEventPayload): Promise<void>;
|
|
39
|
+
/** Fan out a swarm teammate event. */
|
|
40
|
+
swarmEvent(payload: SessionEventPayload): Promise<void>;
|
|
41
|
+
/** How many channels are registered. */
|
|
42
|
+
get count(): number;
|
|
43
|
+
/** Get all registered channels (for wiring optional dependencies). */
|
|
44
|
+
getChannels(): readonly Channel[];
|
|
45
|
+
/** Fan out to channels, respecting filters, swallowing errors. */
|
|
46
|
+
private fanOut;
|
|
47
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels/manager.ts — Routes events to all registered channels.
|
|
3
|
+
*
|
|
4
|
+
* The bridge calls ChannelManager methods. The manager fans out
|
|
5
|
+
* to every registered channel, swallowing per-channel errors so
|
|
6
|
+
* one broken channel never kills the bridge.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Thrown for retriable failures (5xx server errors, network timeouts).
|
|
10
|
+
* Only these increment the circuit breaker failure count.
|
|
11
|
+
* 4xx client errors are thrown as plain Error and do NOT trip the breaker.
|
|
12
|
+
*/
|
|
13
|
+
export class RetriableError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'RetriableError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class ChannelManager {
|
|
20
|
+
channels = [];
|
|
21
|
+
inboundHandler = null;
|
|
22
|
+
health = new Map();
|
|
23
|
+
/** Consecutive failures before disabling a channel. */
|
|
24
|
+
static FAILURE_THRESHOLD = 5;
|
|
25
|
+
/** Cooldown period in ms when a channel is disabled (5 min). */
|
|
26
|
+
static COOLDOWN_MS = 5 * 60 * 1000;
|
|
27
|
+
/** Register a channel. Must be called before init(). */
|
|
28
|
+
register(channel) {
|
|
29
|
+
this.channels.push(channel);
|
|
30
|
+
}
|
|
31
|
+
/** Initialize all channels. Pass the inbound handler for bidirectional channels. */
|
|
32
|
+
async init(onInbound) {
|
|
33
|
+
this.inboundHandler = onInbound;
|
|
34
|
+
for (const ch of this.channels) {
|
|
35
|
+
try {
|
|
36
|
+
await ch.init?.(onInbound);
|
|
37
|
+
console.log(`Channel initialized: ${ch.name}`);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
console.error(`Channel ${ch.name} failed to init:`, e);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Shut down all channels. */
|
|
45
|
+
async destroy() {
|
|
46
|
+
for (const ch of this.channels) {
|
|
47
|
+
try {
|
|
48
|
+
await ch.destroy?.();
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
console.error(`Channel ${ch.name} failed to destroy:`, e);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Fan out a session-created event. */
|
|
56
|
+
async sessionCreated(payload) {
|
|
57
|
+
await this.fanOut(payload, ch => ch.onSessionCreated?.(payload));
|
|
58
|
+
}
|
|
59
|
+
/** Fan out a session-ended event. */
|
|
60
|
+
async sessionEnded(payload) {
|
|
61
|
+
await this.fanOut(payload, ch => ch.onSessionEnded?.(payload));
|
|
62
|
+
}
|
|
63
|
+
/** Fan out a message event. */
|
|
64
|
+
async message(payload) {
|
|
65
|
+
await this.fanOut(payload, ch => ch.onMessage?.(payload));
|
|
66
|
+
}
|
|
67
|
+
/** Fan out a status change event. */
|
|
68
|
+
async statusChange(payload) {
|
|
69
|
+
await this.fanOut(payload, ch => ch.onStatusChange?.(payload));
|
|
70
|
+
}
|
|
71
|
+
/** Fan out a swarm teammate event. */
|
|
72
|
+
async swarmEvent(payload) {
|
|
73
|
+
await this.fanOut(payload, ch => ch.onStatusChange?.(payload));
|
|
74
|
+
}
|
|
75
|
+
/** How many channels are registered. */
|
|
76
|
+
get count() {
|
|
77
|
+
return this.channels.length;
|
|
78
|
+
}
|
|
79
|
+
/** Get all registered channels (for wiring optional dependencies). */
|
|
80
|
+
getChannels() {
|
|
81
|
+
return this.channels;
|
|
82
|
+
}
|
|
83
|
+
/** Fan out to channels, respecting filters, swallowing errors. */
|
|
84
|
+
async fanOut(payload, call) {
|
|
85
|
+
const promises = this.channels.map(async (ch) => {
|
|
86
|
+
// Circuit breaker: skip disabled channels during cooldown
|
|
87
|
+
const health = this.health.get(ch.name);
|
|
88
|
+
if (health && Date.now() < health.disabledUntil)
|
|
89
|
+
return;
|
|
90
|
+
try {
|
|
91
|
+
// Check filter
|
|
92
|
+
if (ch.filter && !ch.filter(payload.event))
|
|
93
|
+
return;
|
|
94
|
+
await call(ch);
|
|
95
|
+
// Success — reset failure count (channel may have been in cooldown)
|
|
96
|
+
this.health.set(ch.name, { failCount: 0, disabledUntil: 0 });
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
console.error(`Channel ${ch.name} error on ${payload.event}:`, e);
|
|
100
|
+
// Only count retriable errors (5xx, network) toward circuit breaker.
|
|
101
|
+
// 4xx client errors are non-retriable — the server is healthy.
|
|
102
|
+
if (!(e instanceof RetriableError))
|
|
103
|
+
return;
|
|
104
|
+
const h = this.health.get(ch.name) ?? { failCount: 0, disabledUntil: 0 };
|
|
105
|
+
h.failCount++;
|
|
106
|
+
if (h.failCount >= ChannelManager.FAILURE_THRESHOLD) {
|
|
107
|
+
h.disabledUntil = Date.now() + ChannelManager.COOLDOWN_MS;
|
|
108
|
+
console.warn(`Channel ${ch.name} disabled after ${h.failCount} consecutive failures, cooldown until ${new Date(h.disabledUntil).toISOString()}`);
|
|
109
|
+
}
|
|
110
|
+
this.health.set(ch.name, h);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
await Promise.allSettled(promises);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels/telegram-style.ts — Telegram Message Style Guide
|
|
3
|
+
*
|
|
4
|
+
* 6 standard message types for clean, consistent Telegram UX.
|
|
5
|
+
* Rule: readable in 2 seconds. Max 2 button rows. Always an escape hatch.
|
|
6
|
+
*
|
|
7
|
+
* Types:
|
|
8
|
+
* ① quickUpdate — one-liner (70% of messages)
|
|
9
|
+
* ② taskComplete — post-merge quality gate card
|
|
10
|
+
* ③ alert — error/crash requiring action
|
|
11
|
+
* ④ yesNo — binary question
|
|
12
|
+
* ⑤ decision — technical choice with context + escape hatch
|
|
13
|
+
* ⑥ progress — pipeline/deploy with ASCII progress bar
|
|
14
|
+
*/
|
|
15
|
+
export declare function esc(text: string): string;
|
|
16
|
+
export declare function bold(text: string): string;
|
|
17
|
+
export declare function code(text: string): string;
|
|
18
|
+
export declare function italic(text: string): string;
|
|
19
|
+
/** Telegram inline keyboard button. */
|
|
20
|
+
export interface InlineButton {
|
|
21
|
+
text: string;
|
|
22
|
+
callback_data: string;
|
|
23
|
+
}
|
|
24
|
+
/** Styled message ready to send via Telegram API. */
|
|
25
|
+
export interface StyledMessage {
|
|
26
|
+
text: string;
|
|
27
|
+
parse_mode: 'HTML';
|
|
28
|
+
reply_markup?: {
|
|
29
|
+
inline_keyboard: InlineButton[][];
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export type StatusEmoji = '🟢' | '✅' | '⚠️' | '🔴' | '❌' | '🔄' | '🔨' | '🚀';
|
|
33
|
+
export declare function statusEmoji(status: string): StatusEmoji;
|
|
34
|
+
/**
|
|
35
|
+
* One-liner status update. 70% of all messages.
|
|
36
|
+
* No buttons, no separators. Emoji + text + optional data.
|
|
37
|
+
*/
|
|
38
|
+
export declare function quickUpdate(emoji: StatusEmoji | string, message: string): StyledMessage;
|
|
39
|
+
/**
|
|
40
|
+
* Quick update with inline code for technical data.
|
|
41
|
+
* e.g. quickUpdateCode('🟢', 'CI verde su', 'main', '— 150/150 test ✅')
|
|
42
|
+
*/
|
|
43
|
+
export declare function quickUpdateCode(emoji: StatusEmoji | string, prefix: string, codeText: string, suffix?: string): StyledMessage;
|
|
44
|
+
export interface TaskCompleteData {
|
|
45
|
+
/** Issue/task reference, e.g. "issue-7" */
|
|
46
|
+
taskRef: string;
|
|
47
|
+
/** Short description */
|
|
48
|
+
title: string;
|
|
49
|
+
/** Duration string, e.g. "28 min" */
|
|
50
|
+
duration: string;
|
|
51
|
+
/** Branch name */
|
|
52
|
+
branch: string;
|
|
53
|
+
/** Quality gate results: [label, passed][] */
|
|
54
|
+
checks: Array<[string, boolean]>;
|
|
55
|
+
/** Optional PR URL */
|
|
56
|
+
prUrl?: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Post-merge/completion card with quality gate.
|
|
60
|
+
* 3 lines max + 1 row of buttons.
|
|
61
|
+
*/
|
|
62
|
+
export declare function taskComplete(data: TaskCompleteData, buttons?: {
|
|
63
|
+
merge?: string;
|
|
64
|
+
review?: string;
|
|
65
|
+
close?: string;
|
|
66
|
+
}): StyledMessage;
|
|
67
|
+
export interface AlertData {
|
|
68
|
+
/** Short title, e.g. "Session crash" */
|
|
69
|
+
title: string;
|
|
70
|
+
/** Session/resource id, e.g. "sess-4a7b" */
|
|
71
|
+
resourceId: string;
|
|
72
|
+
/** Technical details (max 3 lines, shown in monospace) */
|
|
73
|
+
details: string;
|
|
74
|
+
}
|
|
75
|
+
export interface AlertButtons {
|
|
76
|
+
restart?: string;
|
|
77
|
+
log?: string;
|
|
78
|
+
ignore?: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Error/crash alert requiring action.
|
|
82
|
+
* Monospace block for technical details + 1 row of buttons.
|
|
83
|
+
*/
|
|
84
|
+
export declare function alert(data: AlertData, buttons?: AlertButtons): StyledMessage;
|
|
85
|
+
/**
|
|
86
|
+
* Binary question. 2 buttons, zero ambiguity.
|
|
87
|
+
* The "no" label should describe what happens if declined.
|
|
88
|
+
*/
|
|
89
|
+
export declare function yesNo(question: string, yesLabel: string, noLabel: string, yesCallback: string, noCallback: string): StyledMessage;
|
|
90
|
+
export interface DecisionOption {
|
|
91
|
+
emoji: string;
|
|
92
|
+
label: string;
|
|
93
|
+
description: string;
|
|
94
|
+
callback: string;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Technical decision with context + escape hatch.
|
|
98
|
+
* The ONLY type allowed 2 rows of buttons.
|
|
99
|
+
* Max 3 options + always "Decidi tu" / "Parliamone".
|
|
100
|
+
*/
|
|
101
|
+
export declare function decision(question: string, options: DecisionOption[], escapeCallbacks?: {
|
|
102
|
+
decideTu?: string;
|
|
103
|
+
parliamone?: string;
|
|
104
|
+
}): StyledMessage;
|
|
105
|
+
export interface ProgressStep {
|
|
106
|
+
label: string;
|
|
107
|
+
/** Duration string if done, e.g. "2.1s" */
|
|
108
|
+
duration?: string;
|
|
109
|
+
done: boolean;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Progress/pipeline card with ASCII bar.
|
|
113
|
+
* Updates via editMessageText — never send a new message for refresh.
|
|
114
|
+
*/
|
|
115
|
+
export declare function progress(title: string, steps: ProgressStep[], percent: number, buttons?: {
|
|
116
|
+
pause?: string;
|
|
117
|
+
cancel?: string;
|
|
118
|
+
}): StyledMessage;
|