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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels/types.ts — Notification channel interface.
|
|
3
|
+
*
|
|
4
|
+
* Every notification channel (Telegram, Discord, webhook, Slack, etc.)
|
|
5
|
+
* implements this interface. The bridge doesn't know or care which
|
|
6
|
+
* channels are active — it fires events, channels decide what to do.
|
|
7
|
+
*/
|
|
8
|
+
/** Health status for a channel. */
|
|
9
|
+
export interface ChannelHealthStatus {
|
|
10
|
+
channel: string;
|
|
11
|
+
healthy: boolean;
|
|
12
|
+
lastSuccess: number | null;
|
|
13
|
+
lastError: string | null;
|
|
14
|
+
pendingCount: number;
|
|
15
|
+
}
|
|
16
|
+
/** Events a channel can subscribe to. */
|
|
17
|
+
export type SessionEvent = 'session.created' | 'session.ended' | 'message.user' | 'message.assistant' | 'message.thinking' | 'message.tool_use' | 'message.tool_result' | 'status.idle' | 'status.working' | 'status.permission' | 'status.question' | 'status.plan' | 'status.stall' | 'status.dead' | 'status.stopped' | 'status.error' | 'status.rate_limited' | 'status.permission_timeout' | 'status.recovered' | 'swarm.teammate_spawned' | 'swarm.teammate_finished';
|
|
18
|
+
/** Payload for all session events. */
|
|
19
|
+
export interface SessionEventPayload {
|
|
20
|
+
event: SessionEvent;
|
|
21
|
+
timestamp: string;
|
|
22
|
+
session: {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
workDir: string;
|
|
26
|
+
};
|
|
27
|
+
detail: string;
|
|
28
|
+
/** Contextual data — depends on event type. */
|
|
29
|
+
meta?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
/** Inbound command from a channel (user replied in Telegram, webhook callback, etc.) */
|
|
32
|
+
export interface InboundCommand {
|
|
33
|
+
sessionId: string;
|
|
34
|
+
action: 'approve' | 'reject' | 'escape' | 'kill' | 'message' | 'command';
|
|
35
|
+
text?: string;
|
|
36
|
+
}
|
|
37
|
+
/** Callback for inbound commands. */
|
|
38
|
+
export type InboundHandler = (cmd: InboundCommand) => Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* A notification channel.
|
|
41
|
+
*
|
|
42
|
+
* Channels are initialized once and receive events for the lifetime
|
|
43
|
+
* of the bridge. They can optionally accept inbound commands
|
|
44
|
+
* (bidirectional channels like Telegram).
|
|
45
|
+
*/
|
|
46
|
+
export interface Channel {
|
|
47
|
+
/** Human-readable channel name (for logging). */
|
|
48
|
+
readonly name: string;
|
|
49
|
+
/** Initialize the channel. Called once at startup. */
|
|
50
|
+
init?(onInbound: InboundHandler): Promise<void>;
|
|
51
|
+
/** Tear down the channel. Called on shutdown. */
|
|
52
|
+
destroy?(): Promise<void>;
|
|
53
|
+
/** Called when a new session is created. */
|
|
54
|
+
onSessionCreated?(payload: SessionEventPayload): Promise<void>;
|
|
55
|
+
/** Called when a session ends. */
|
|
56
|
+
onSessionEnded?(payload: SessionEventPayload): Promise<void>;
|
|
57
|
+
/** Called when a message is sent to/from CC. */
|
|
58
|
+
onMessage?(payload: SessionEventPayload): Promise<void>;
|
|
59
|
+
/** Called when session status changes (idle, working, permission, question, plan). */
|
|
60
|
+
onStatusChange?(payload: SessionEventPayload): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Optional: filter which events this channel cares about.
|
|
63
|
+
* Return true to receive the event, false to skip.
|
|
64
|
+
* If not implemented, receives all events.
|
|
65
|
+
*/
|
|
66
|
+
filter?(event: SessionEvent): boolean;
|
|
67
|
+
/** Return entries from the dead letter queue (failed deliveries). */
|
|
68
|
+
getDeadLetterQueue?(): Array<{
|
|
69
|
+
timestamp: string;
|
|
70
|
+
endpoint: string;
|
|
71
|
+
event: SessionEvent;
|
|
72
|
+
error: string;
|
|
73
|
+
attempts: number;
|
|
74
|
+
}>;
|
|
75
|
+
/** Return channel health status. */
|
|
76
|
+
getHealth?(): ChannelHealthStatus;
|
|
77
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels/types.ts — Notification channel interface.
|
|
3
|
+
*
|
|
4
|
+
* Every notification channel (Telegram, Discord, webhook, Slack, etc.)
|
|
5
|
+
* implements this interface. The bridge doesn't know or care which
|
|
6
|
+
* channels are active — it fires events, channels decide what to do.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels/webhook.ts — Generic webhook notification channel.
|
|
3
|
+
*
|
|
4
|
+
* Fires HTTP POST to configured URLs on session events.
|
|
5
|
+
* Configure via AEGIS_WEBHOOKS (or legacy MANUS_WEBHOOKS) env var or config file.
|
|
6
|
+
*/
|
|
7
|
+
import type { Channel, SessionEvent, SessionEventPayload } from './types.js';
|
|
8
|
+
export interface WebhookEndpoint {
|
|
9
|
+
/** URL to POST to. */
|
|
10
|
+
url: string;
|
|
11
|
+
/** Filter: only fire on these events. Omit = all events. */
|
|
12
|
+
events?: SessionEvent[];
|
|
13
|
+
/** Custom headers (e.g. Authorization). */
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
/** Timeout in ms (default: 5000). */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface WebhookChannelConfig {
|
|
19
|
+
endpoints: WebhookEndpoint[];
|
|
20
|
+
}
|
|
21
|
+
/** Dead letter queue entry for a failed webhook delivery. */
|
|
22
|
+
export interface DeadLetterEntry {
|
|
23
|
+
timestamp: string;
|
|
24
|
+
endpoint: string;
|
|
25
|
+
event: SessionEvent;
|
|
26
|
+
error: string;
|
|
27
|
+
attempts: number;
|
|
28
|
+
}
|
|
29
|
+
export declare class WebhookChannel implements Channel {
|
|
30
|
+
readonly name = "webhook";
|
|
31
|
+
private endpoints;
|
|
32
|
+
/** Issue #89 L14: In-memory dead letter queue for failed deliveries. Max 100 items. */
|
|
33
|
+
private deadLetterQueue;
|
|
34
|
+
static readonly DLQ_MAX_SIZE = 100;
|
|
35
|
+
constructor(config: WebhookChannelConfig);
|
|
36
|
+
/** Create from AEGIS_WEBHOOKS (or legacy MANUS_WEBHOOKS) env var. Returns null if not set or invalid. */
|
|
37
|
+
static fromEnv(): WebhookChannel | null;
|
|
38
|
+
filter(event: SessionEvent): boolean;
|
|
39
|
+
onSessionCreated(payload: SessionEventPayload): Promise<void>;
|
|
40
|
+
onSessionEnded(payload: SessionEventPayload): Promise<void>;
|
|
41
|
+
onMessage(payload: SessionEventPayload): Promise<void>;
|
|
42
|
+
onStatusChange(payload: SessionEventPayload): Promise<void>;
|
|
43
|
+
/** Maximum retry attempts per webhook delivery. */
|
|
44
|
+
static readonly MAX_RETRIES = 5;
|
|
45
|
+
/** Base delay for exponential backoff (ms). */
|
|
46
|
+
static readonly BASE_DELAY_MS = 1000;
|
|
47
|
+
/** Exponential backoff with jitter: delay * (0.5 + Math.random() * 0.5). */
|
|
48
|
+
static backoff(attempt: number): number;
|
|
49
|
+
/** Redact sensitive session metadata from webhook payloads. */
|
|
50
|
+
private static redactPayload;
|
|
51
|
+
private fire;
|
|
52
|
+
/** Issue #25: Deliver webhook with retry + exponential backoff. */
|
|
53
|
+
private deliverWithRetry;
|
|
54
|
+
/** Issue #89 L14: Add a failed delivery to the dead letter queue. */
|
|
55
|
+
private addToDeadLetterQueue;
|
|
56
|
+
/** Issue #89 L14: Get all entries in the dead letter queue. */
|
|
57
|
+
getDeadLetterQueue(): DeadLetterEntry[];
|
|
58
|
+
/** Issue #89 L14: Clear the dead letter queue. Returns number of entries cleared. */
|
|
59
|
+
clearDeadLetterQueue(): number;
|
|
60
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels/webhook.ts — Generic webhook notification channel.
|
|
3
|
+
*
|
|
4
|
+
* Fires HTTP POST to configured URLs on session events.
|
|
5
|
+
* Configure via AEGIS_WEBHOOKS (or legacy MANUS_WEBHOOKS) env var or config file.
|
|
6
|
+
*/
|
|
7
|
+
import { webhookEndpointSchema, getErrorMessage } from '../validation.js';
|
|
8
|
+
import { validateWebhookUrl, resolveAndCheckIp, buildConnectionUrl } from '../ssrf.js';
|
|
9
|
+
import { redactSecretsFromText } from '../utils/redact-headers.js';
|
|
10
|
+
import { RetriableError } from './manager.js';
|
|
11
|
+
export class WebhookChannel {
|
|
12
|
+
name = 'webhook';
|
|
13
|
+
endpoints;
|
|
14
|
+
/** Issue #89 L14: In-memory dead letter queue for failed deliveries. Max 100 items. */
|
|
15
|
+
deadLetterQueue = [];
|
|
16
|
+
static DLQ_MAX_SIZE = 100;
|
|
17
|
+
constructor(config) {
|
|
18
|
+
this.endpoints = config.endpoints;
|
|
19
|
+
}
|
|
20
|
+
/** Create from AEGIS_WEBHOOKS (or legacy MANUS_WEBHOOKS) env var. Returns null if not set or invalid. */
|
|
21
|
+
static fromEnv() {
|
|
22
|
+
const raw = process.env.AEGIS_WEBHOOKS ?? process.env.MANUS_WEBHOOKS;
|
|
23
|
+
if (!raw)
|
|
24
|
+
return null;
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
28
|
+
return null;
|
|
29
|
+
// Validate each endpoint with Zod schema + SSRF URL check
|
|
30
|
+
const endpoints = [];
|
|
31
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
32
|
+
const result = webhookEndpointSchema.safeParse(parsed[i]);
|
|
33
|
+
if (!result.success) {
|
|
34
|
+
console.error(`Webhook URL validation failed for endpoint ${i}: schema error`, result.error.message);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const urlError = validateWebhookUrl(result.data.url);
|
|
38
|
+
if (urlError) {
|
|
39
|
+
console.error(`Webhook URL validation failed for endpoint ${i}: ${urlError}`, result.data.url);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
endpoints.push(result.data);
|
|
43
|
+
}
|
|
44
|
+
return new WebhookChannel({ endpoints });
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
console.error('Failed to parse AEGIS_WEBHOOKS:', e);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
filter(event) {
|
|
52
|
+
// Accept if ANY endpoint wants this event
|
|
53
|
+
return this.endpoints.some(ep => !ep.events || ep.events.length === 0 || ep.events.includes(event));
|
|
54
|
+
}
|
|
55
|
+
async onSessionCreated(payload) {
|
|
56
|
+
await this.fire(payload);
|
|
57
|
+
}
|
|
58
|
+
async onSessionEnded(payload) {
|
|
59
|
+
await this.fire(payload);
|
|
60
|
+
}
|
|
61
|
+
async onMessage(payload) {
|
|
62
|
+
await this.fire(payload);
|
|
63
|
+
}
|
|
64
|
+
async onStatusChange(payload) {
|
|
65
|
+
await this.fire(payload);
|
|
66
|
+
}
|
|
67
|
+
/** Maximum retry attempts per webhook delivery. */
|
|
68
|
+
static MAX_RETRIES = 5;
|
|
69
|
+
/** Base delay for exponential backoff (ms). */
|
|
70
|
+
static BASE_DELAY_MS = 1000;
|
|
71
|
+
/** Exponential backoff with jitter: delay * (0.5 + Math.random() * 0.5). */
|
|
72
|
+
static backoff(attempt) {
|
|
73
|
+
const base = WebhookChannel.BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
74
|
+
return base * (0.5 + Math.random() * 0.5);
|
|
75
|
+
}
|
|
76
|
+
/** Redact sensitive session metadata from webhook payloads. */
|
|
77
|
+
static redactPayload(payload) {
|
|
78
|
+
const { session, ...rest } = payload;
|
|
79
|
+
return {
|
|
80
|
+
...rest,
|
|
81
|
+
session: {
|
|
82
|
+
id: '[REDACTED]',
|
|
83
|
+
name: '[REDACTED]',
|
|
84
|
+
workDir: '[REDACTED]',
|
|
85
|
+
},
|
|
86
|
+
api: {
|
|
87
|
+
read: 'GET /sessions/[REDACTED]/read',
|
|
88
|
+
send: 'POST /sessions/[REDACTED]/send',
|
|
89
|
+
kill: 'DELETE /sessions/[REDACTED]',
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async fire(payload) {
|
|
94
|
+
const body = JSON.stringify(WebhookChannel.redactPayload(payload));
|
|
95
|
+
const promises = this.endpoints.map(async (ep) => {
|
|
96
|
+
// Skip if endpoint filters and this event isn't in the list
|
|
97
|
+
if (ep.events && ep.events.length > 0 && !ep.events.includes(payload.event))
|
|
98
|
+
return;
|
|
99
|
+
await this.deliverWithRetry(ep, body, payload.event);
|
|
100
|
+
});
|
|
101
|
+
const results = await Promise.allSettled(promises);
|
|
102
|
+
const failed = results.filter((r) => r.status === 'rejected');
|
|
103
|
+
if (failed.length > 0) {
|
|
104
|
+
const reasons = failed.map(r => String(r.reason)).join('; ');
|
|
105
|
+
const allFailed = failed.length === results.length;
|
|
106
|
+
if (allFailed) {
|
|
107
|
+
console.error(`Webhook: ${failed.length}/${results.length} endpoint(s) failed (total): ${reasons}`);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
console.warn(`Webhook: ${failed.length}/${results.length} endpoint(s) failed: ${reasons}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Issue #25: Deliver webhook with retry + exponential backoff. */
|
|
115
|
+
async deliverWithRetry(ep, body, event, maxRetries = WebhookChannel.MAX_RETRIES) {
|
|
116
|
+
let lastError = '';
|
|
117
|
+
const hostname = new URL(ep.url).hostname;
|
|
118
|
+
const bareHost = hostname.replace(/^\[|\]$/g, '');
|
|
119
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
120
|
+
try {
|
|
121
|
+
// DNS rebinding protection: resolve and validate IP before each fetch.
|
|
122
|
+
// Skip for literal IPs (already validated at config time).
|
|
123
|
+
let fetchUrl = ep.url;
|
|
124
|
+
let headers = {
|
|
125
|
+
'Content-Type': 'application/json',
|
|
126
|
+
...(ep.headers || {}),
|
|
127
|
+
};
|
|
128
|
+
if (bareHost !== '127.0.0.1' && bareHost !== '::1' && bareHost !== 'localhost') {
|
|
129
|
+
const dnsResult = await resolveAndCheckIp(bareHost);
|
|
130
|
+
if (dnsResult.error) {
|
|
131
|
+
lastError = dnsResult.error;
|
|
132
|
+
if (attempt < maxRetries) {
|
|
133
|
+
const delay = WebhookChannel.backoff(attempt);
|
|
134
|
+
console.warn(`Webhook ${ep.url} DNS check failed for ${event} (attempt ${attempt}/${maxRetries}): ${lastError}, retrying in ${Math.round(delay)}ms`);
|
|
135
|
+
await new Promise(r => setTimeout(r, delay));
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
console.error(`Webhook ${ep.url} DNS check failed after ${maxRetries} attempts for ${event}: ${lastError}`);
|
|
139
|
+
this.addToDeadLetterQueue(ep.url, event, lastError, maxRetries);
|
|
140
|
+
throw new RetriableError(lastError);
|
|
141
|
+
}
|
|
142
|
+
if (dnsResult.resolvedIp) {
|
|
143
|
+
const { connectionUrl, hostHeader } = buildConnectionUrl(ep.url, dnsResult.resolvedIp);
|
|
144
|
+
fetchUrl = connectionUrl;
|
|
145
|
+
headers['Host'] = hostHeader;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const res = await fetch(fetchUrl, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers,
|
|
151
|
+
body,
|
|
152
|
+
signal: AbortSignal.timeout(ep.timeoutMs || 5000),
|
|
153
|
+
});
|
|
154
|
+
if (res.ok)
|
|
155
|
+
return; // Success
|
|
156
|
+
lastError = `HTTP ${res.status}`;
|
|
157
|
+
// Server error (5xx) — retry; client error (4xx) — don't
|
|
158
|
+
if (res.status >= 500 && attempt < maxRetries) {
|
|
159
|
+
const delay = WebhookChannel.backoff(attempt);
|
|
160
|
+
console.warn(`Webhook ${ep.url} returned ${res.status} for ${event} (attempt ${attempt}/${maxRetries}), retrying in ${Math.round(delay)}ms`);
|
|
161
|
+
await new Promise(r => setTimeout(r, delay));
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
console.error(`Webhook ${ep.url} returned ${res.status} for ${event} (attempt ${attempt}/${maxRetries})`);
|
|
165
|
+
// Issue #89 L14: Only add to DLQ for 5xx (server) errors, not 4xx client errors
|
|
166
|
+
if (res.status >= 500) {
|
|
167
|
+
this.addToDeadLetterQueue(ep.url, event, lastError, attempt);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
lastError = redactSecretsFromText(getErrorMessage(e), ep.headers);
|
|
172
|
+
if (attempt < maxRetries) {
|
|
173
|
+
const delay = WebhookChannel.backoff(attempt);
|
|
174
|
+
console.warn(`Webhook ${ep.url} error for ${event} (attempt ${attempt}/${maxRetries}): ${lastError}, retrying in ${Math.round(delay)}ms`);
|
|
175
|
+
await new Promise(r => setTimeout(r, delay));
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
console.error(`Webhook ${ep.url} failed after ${maxRetries} attempts for ${event}: ${lastError}`);
|
|
179
|
+
this.addToDeadLetterQueue(ep.url, event, lastError, maxRetries);
|
|
180
|
+
}
|
|
181
|
+
// Final failure — throw so fire() can aggregate.
|
|
182
|
+
// Use RetriableError for 5xx/network (circuit breaker counts these),
|
|
183
|
+
// plain Error for 4xx (circuit breaker ignores these).
|
|
184
|
+
if (lastError.startsWith('HTTP ') && parseInt(lastError.slice(5)) < 500) {
|
|
185
|
+
throw new Error(lastError);
|
|
186
|
+
}
|
|
187
|
+
throw new RetriableError(lastError);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** Issue #89 L14: Add a failed delivery to the dead letter queue. */
|
|
191
|
+
addToDeadLetterQueue(endpoint, event, error, attempts) {
|
|
192
|
+
const entry = {
|
|
193
|
+
timestamp: new Date().toISOString(),
|
|
194
|
+
endpoint,
|
|
195
|
+
event,
|
|
196
|
+
error,
|
|
197
|
+
attempts,
|
|
198
|
+
};
|
|
199
|
+
this.deadLetterQueue.push(entry);
|
|
200
|
+
// Evict oldest entries if over max size
|
|
201
|
+
if (this.deadLetterQueue.length > WebhookChannel.DLQ_MAX_SIZE) {
|
|
202
|
+
this.deadLetterQueue = this.deadLetterQueue.slice(-WebhookChannel.DLQ_MAX_SIZE);
|
|
203
|
+
}
|
|
204
|
+
console.warn(`Webhook DLQ: added failed delivery for ${event} to ${endpoint} after ${attempts} attempts`);
|
|
205
|
+
}
|
|
206
|
+
/** Issue #89 L14: Get all entries in the dead letter queue. */
|
|
207
|
+
getDeadLetterQueue() {
|
|
208
|
+
return [...this.deadLetterQueue];
|
|
209
|
+
}
|
|
210
|
+
/** Issue #89 L14: Clear the dead letter queue. Returns number of entries cleared. */
|
|
211
|
+
clearDeadLetterQueue() {
|
|
212
|
+
const count = this.deadLetterQueue.length;
|
|
213
|
+
this.deadLetterQueue = [];
|
|
214
|
+
return count;
|
|
215
|
+
}
|
|
216
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cli.ts — CLI entry point for Aegis.
|
|
4
|
+
*
|
|
5
|
+
* `npx aegis-bridge` or `aegis-bridge` starts the server with sensible defaults.
|
|
6
|
+
* Auto-detects tmux and claude CLI, prints helpful startup message.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { parseIntSafe, getErrorMessage } from './validation.js';
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'));
|
|
15
|
+
/** Current aegis-bridge version read from package.json at startup. */
|
|
16
|
+
const VERSION = pkg.version;
|
|
17
|
+
/** Check whether a required external dependency can be executed. */
|
|
18
|
+
function checkDependency(command, args) {
|
|
19
|
+
try {
|
|
20
|
+
execFileSync(command, args, { stdio: 'ignore', timeout: 5000 });
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch { /* command not found or exited non-zero */
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Parse tmux -V output and enforce minimum supported version. */
|
|
28
|
+
function checkTmuxVersion(minMajor = 3, minMinor = 3) {
|
|
29
|
+
try {
|
|
30
|
+
const out = execFileSync('tmux', ['-V'], { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
31
|
+
const m = out.match(/tmux\s+(\d+)\.(\d+)/i);
|
|
32
|
+
if (!m)
|
|
33
|
+
return { ok: false, version: null };
|
|
34
|
+
const major = parseInt(m[1], 10);
|
|
35
|
+
const minor = parseInt(m[2], 10);
|
|
36
|
+
const ok = major > minMajor || (major === minMajor && minor >= minMinor);
|
|
37
|
+
return { ok, version: `${major}.${minor}` };
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return { ok: false, version: null };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Render the startup banner shown when launching the HTTP server. */
|
|
44
|
+
function printBanner(port) {
|
|
45
|
+
console.log(`
|
|
46
|
+
┌─────────────────────────────────────────┐
|
|
47
|
+
│ ⚡ Aegis v${VERSION} │
|
|
48
|
+
│ Claude Code Session Bridge │
|
|
49
|
+
└─────────────────────────────────────────┘
|
|
50
|
+
`);
|
|
51
|
+
}
|
|
52
|
+
/** Issue #5 stretch: create a session from CLI. */
|
|
53
|
+
async function handleCreate(args) {
|
|
54
|
+
// Parse brief text (first non-flag argument)
|
|
55
|
+
let brief = '';
|
|
56
|
+
let cwd = process.cwd();
|
|
57
|
+
let port = parseIntSafe(process.env.AEGIS_PORT, 9100);
|
|
58
|
+
for (let i = 0; i < args.length; i++) {
|
|
59
|
+
if (args[i] === '--cwd' && args[i + 1]) {
|
|
60
|
+
cwd = args[++i];
|
|
61
|
+
}
|
|
62
|
+
else if (args[i] === '--port' && args[i + 1]) {
|
|
63
|
+
port = parseIntSafe(args[++i], 9100);
|
|
64
|
+
}
|
|
65
|
+
else if (!args[i].startsWith('-')) {
|
|
66
|
+
brief = args[i];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!brief) {
|
|
70
|
+
console.error(' ❌ Missing brief. Usage: aegis-bridge create "Build a login page"');
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
74
|
+
const sessionName = `cc-${brief.slice(0, 20).replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase()}`;
|
|
75
|
+
// Create session
|
|
76
|
+
let sessionId;
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(`${baseUrl}/v1/sessions`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'Content-Type': 'application/json' },
|
|
81
|
+
body: JSON.stringify({ workDir: cwd, name: sessionName }),
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
85
|
+
console.error(` ❌ Failed to create session: ${err.error || res.statusText}`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
const session = await res.json();
|
|
89
|
+
sessionId = session.id;
|
|
90
|
+
console.log(` ✅ Session created: ${session.windowName}`);
|
|
91
|
+
console.log(` ID: ${sessionId}`);
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
const cause = e.cause;
|
|
95
|
+
if (cause?.code === 'ECONNREFUSED') {
|
|
96
|
+
console.error(` ❌ Cannot connect to Aegis on port ${port}.`);
|
|
97
|
+
console.error(` Start the server first: aegis-bridge`);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
console.error(` ❌ ${getErrorMessage(e)}`);
|
|
101
|
+
}
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
// Send brief
|
|
105
|
+
try {
|
|
106
|
+
const res = await fetch(`${baseUrl}/v1/sessions/${sessionId}/send`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
body: JSON.stringify({ text: brief }),
|
|
110
|
+
});
|
|
111
|
+
const result = await res.json();
|
|
112
|
+
if (result.delivered) {
|
|
113
|
+
console.log(` ✅ Brief delivered (attempt ${result.attempts})`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
console.log(` ⚠️ Brief sent but delivery not confirmed after ${result.attempts} attempts`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
console.error(` ⚠️ Failed to send brief: ${getErrorMessage(e)}`);
|
|
121
|
+
}
|
|
122
|
+
// Print next steps
|
|
123
|
+
console.log('');
|
|
124
|
+
console.log(' Next steps:');
|
|
125
|
+
console.log(` Status: curl ${baseUrl}/v1/sessions/${sessionId}/health`);
|
|
126
|
+
console.log(` Read: curl ${baseUrl}/v1/sessions/${sessionId}/read`);
|
|
127
|
+
console.log(` Kill: curl -X DELETE ${baseUrl}/v1/sessions/${sessionId}`);
|
|
128
|
+
}
|
|
129
|
+
/** Main CLI entry point that dispatches subcommands and bootstraps the server. */
|
|
130
|
+
async function main() {
|
|
131
|
+
const args = process.argv.slice(2);
|
|
132
|
+
// Help
|
|
133
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
134
|
+
console.log(`
|
|
135
|
+
aegis-bridge — Claude Code session bridge
|
|
136
|
+
|
|
137
|
+
Usage:
|
|
138
|
+
aegis-bridge Start the server (port 9100)
|
|
139
|
+
aegis-bridge --port 3000 Custom port
|
|
140
|
+
aegis-bridge create "brief" Create a session and send brief
|
|
141
|
+
aegis-bridge mcp Start MCP server (stdio transport)
|
|
142
|
+
aegis-bridge --help Show this help
|
|
143
|
+
|
|
144
|
+
Create:
|
|
145
|
+
aegis-bridge create "Build a login page" --cwd /path/to/project
|
|
146
|
+
aegis-bridge create "Fix the tests" (uses current directory)
|
|
147
|
+
|
|
148
|
+
MCP server:
|
|
149
|
+
aegis-bridge mcp Start MCP stdio server
|
|
150
|
+
aegis-bridge mcp --port 3000 Custom Aegis API port
|
|
151
|
+
claude mcp add aegis -- npx aegis-bridge mcp
|
|
152
|
+
|
|
153
|
+
Environment variables:
|
|
154
|
+
AEGIS_PORT Server port (default: 9100)
|
|
155
|
+
AEGIS_HOST Server host (default: 127.0.0.1)
|
|
156
|
+
AEGIS_AUTH_TOKEN Bearer token for API auth
|
|
157
|
+
AEGIS_TMUX_SESSION tmux session name (default: aegis)
|
|
158
|
+
AEGIS_STATE_DIR State directory (default: ~/.aegis)
|
|
159
|
+
AEGIS_TG_TOKEN Telegram bot token
|
|
160
|
+
AEGIS_TG_GROUP Telegram group chat ID
|
|
161
|
+
AEGIS_TG_ALLOWED_USERS Allowed Telegram user IDs (comma-separated)
|
|
162
|
+
AEGIS_WEBHOOKS Webhook URLs (comma-separated)
|
|
163
|
+
|
|
164
|
+
API:
|
|
165
|
+
POST /v1/sessions Create a session
|
|
166
|
+
GET /v1/sessions List sessions
|
|
167
|
+
GET /v1/sessions/:id Get session
|
|
168
|
+
POST /v1/sessions/:id/send Send message
|
|
169
|
+
GET /v1/sessions/:id/read Read messages
|
|
170
|
+
GET /v1/sessions/:id/health Health check
|
|
171
|
+
DEL /v1/sessions/:id Kill session
|
|
172
|
+
GET /v1/health Server health
|
|
173
|
+
|
|
174
|
+
Docs: https://github.com/OneStepAt4time/aegis
|
|
175
|
+
`);
|
|
176
|
+
process.exit(0);
|
|
177
|
+
}
|
|
178
|
+
// Version
|
|
179
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
180
|
+
console.log(`aegis-bridge v${VERSION}`);
|
|
181
|
+
process.exit(0);
|
|
182
|
+
}
|
|
183
|
+
// Subcommand: mcp
|
|
184
|
+
if (args[0] === 'mcp') {
|
|
185
|
+
const mcpArgs = args.slice(1);
|
|
186
|
+
let mcpPort = parseIntSafe(process.env.AEGIS_PORT, 9100);
|
|
187
|
+
const mcpPortIdx = mcpArgs.indexOf('--port');
|
|
188
|
+
if (mcpPortIdx !== -1 && mcpArgs[mcpPortIdx + 1]) {
|
|
189
|
+
mcpPort = parseIntSafe(mcpArgs[mcpPortIdx + 1], 9100);
|
|
190
|
+
}
|
|
191
|
+
const mcpAuth = process.env.AEGIS_AUTH_TOKEN || process.env.AEGIS_TOKEN;
|
|
192
|
+
const { startMcpServer } = await import('./mcp-server.js');
|
|
193
|
+
await startMcpServer(mcpPort, mcpAuth);
|
|
194
|
+
return; // stdio server runs until stdin closes
|
|
195
|
+
}
|
|
196
|
+
// Subcommand: create
|
|
197
|
+
if (args[0] === 'create') {
|
|
198
|
+
await handleCreate(args.slice(1));
|
|
199
|
+
process.exit(0);
|
|
200
|
+
}
|
|
201
|
+
// Port override from CLI
|
|
202
|
+
const portIdx = args.indexOf('--port');
|
|
203
|
+
if (portIdx !== -1 && args[portIdx + 1]) {
|
|
204
|
+
process.env.AEGIS_PORT = args[portIdx + 1];
|
|
205
|
+
}
|
|
206
|
+
// Check dependencies
|
|
207
|
+
const hasTmux = checkDependency('tmux', ['-V']);
|
|
208
|
+
const hasClaude = checkDependency('claude', ['--version']);
|
|
209
|
+
const tmuxVersion = hasTmux ? checkTmuxVersion(3, 3) : { ok: false, version: null };
|
|
210
|
+
if (!hasTmux) {
|
|
211
|
+
console.error(`
|
|
212
|
+
❌ tmux not found.
|
|
213
|
+
|
|
214
|
+
Install tmux:
|
|
215
|
+
Ubuntu/Debian: sudo apt install tmux
|
|
216
|
+
macOS: brew install tmux
|
|
217
|
+
Windows: winget install psmux
|
|
218
|
+
`);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
if (!tmuxVersion.ok) {
|
|
222
|
+
console.error(`
|
|
223
|
+
❌ Unsupported tmux version${tmuxVersion.version ? ` (${tmuxVersion.version})` : ''}.
|
|
224
|
+
|
|
225
|
+
Aegis requires tmux/psmux 3.3 or newer.
|
|
226
|
+
`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
if (!hasClaude) {
|
|
230
|
+
console.error(`
|
|
231
|
+
⚠️ Claude Code CLI not found.
|
|
232
|
+
|
|
233
|
+
Install Claude Code:
|
|
234
|
+
curl -fsSL https://claude.ai/install.sh | bash
|
|
235
|
+
|
|
236
|
+
Sessions will fail to start without the 'claude' command.
|
|
237
|
+
`);
|
|
238
|
+
// Don't exit — server can still start, just sessions won't work
|
|
239
|
+
}
|
|
240
|
+
const port = parseIntSafe(process.env.AEGIS_PORT, 9100);
|
|
241
|
+
printBanner(port);
|
|
242
|
+
console.log(` Dependencies:`);
|
|
243
|
+
console.log(` tmux: ${hasTmux ? '✅' : '❌'}`);
|
|
244
|
+
console.log(` claude: ${hasClaude ? '✅' : '❌'}`);
|
|
245
|
+
console.log('');
|
|
246
|
+
// Start the server
|
|
247
|
+
await import('./server.js');
|
|
248
|
+
}
|
|
249
|
+
main().catch(err => {
|
|
250
|
+
console.error('Failed to start Aegis:', err);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
});
|