vellum 0.2.2 → 0.2.8
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/bun.lock +68 -100
- package/package.json +3 -3
- package/src/__tests__/asset-materialize-tool.test.ts +2 -2
- package/src/__tests__/checker.test.ts +104 -0
- package/src/__tests__/config-schema.test.ts +6 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
- package/src/__tests__/handlers-twilio-config.test.ts +221 -0
- package/src/__tests__/ipc-snapshot.test.ts +20 -0
- package/src/__tests__/memory-regressions.test.ts +100 -2
- package/src/__tests__/oauth-callback-registry.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +342 -0
- package/src/__tests__/public-ingress-urls.test.ts +206 -0
- package/src/__tests__/session-conflict-gate.test.ts +28 -25
- package/src/__tests__/tool-executor.test.ts +88 -0
- package/src/__tests__/turn-commit.test.ts +64 -0
- package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
- package/src/calls/call-domain.ts +3 -3
- package/src/calls/twilio-config.ts +25 -9
- package/src/calls/twilio-provider.ts +4 -4
- package/src/calls/twilio-routes.ts +10 -2
- package/src/calls/twilio-webhook-urls.ts +47 -0
- package/src/cli/map.ts +30 -6
- package/src/config/defaults.ts +5 -0
- package/src/config/schema.ts +34 -2
- package/src/config/system-prompt.ts +1 -1
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
- package/src/daemon/computer-use-session.ts +2 -1
- package/src/daemon/handlers/config.ts +95 -4
- package/src/daemon/handlers/sessions.ts +2 -2
- package/src/daemon/handlers/work-items.ts +1 -1
- package/src/daemon/ipc-contract-inventory.json +8 -0
- package/src/daemon/ipc-contract.ts +39 -1
- package/src/daemon/ride-shotgun-handler.ts +2 -1
- package/src/daemon/session-agent-loop.ts +37 -2
- package/src/daemon/session-conflict-gate.ts +18 -109
- package/src/daemon/session-tool-setup.ts +7 -0
- package/src/inbound/public-ingress-urls.ts +106 -0
- package/src/memory/attachments-store.ts +0 -1
- package/src/memory/channel-delivery-store.ts +0 -1
- package/src/memory/conflict-intent.ts +114 -0
- package/src/memory/conversation-key-store.ts +0 -1
- package/src/memory/db.ts +346 -149
- package/src/memory/job-handlers/conflict.ts +23 -1
- package/src/memory/runs-store.ts +0 -3
- package/src/memory/schema.ts +0 -4
- package/src/runtime/gateway-client.ts +36 -0
- package/src/runtime/http-server.ts +140 -2
- package/src/runtime/routes/channel-routes.ts +121 -79
- package/src/security/oauth-callback-registry.ts +56 -0
- package/src/security/oauth2.ts +174 -58
- package/src/swarm/backend-claude-code.ts +1 -1
- package/src/tools/assets/search.ts +1 -36
- package/src/tools/browser/api-map.ts +123 -50
- package/src/tools/claude-code/claude-code.ts +131 -1
- package/src/tools/tasks/work-item-list.ts +16 -2
- package/src/workspace/commit-message-enrichment-service.ts +3 -3
- package/src/workspace/provider-commit-message-generator.ts +57 -14
- package/src/workspace/turn-commit.ts +6 -2
package/src/memory/runs-store.ts
CHANGED
|
@@ -36,7 +36,6 @@ export interface PendingConfirmation {
|
|
|
36
36
|
|
|
37
37
|
export interface Run {
|
|
38
38
|
id: string;
|
|
39
|
-
assistantId: string;
|
|
40
39
|
conversationId: string;
|
|
41
40
|
messageId: string | null;
|
|
42
41
|
status: RunStatus;
|
|
@@ -66,7 +65,6 @@ function rowToRun(row: typeof messageRuns.$inferSelect): Run {
|
|
|
66
65
|
}
|
|
67
66
|
return {
|
|
68
67
|
id: row.id,
|
|
69
|
-
assistantId: row.assistantId,
|
|
70
68
|
conversationId: row.conversationId,
|
|
71
69
|
messageId: row.messageId,
|
|
72
70
|
status: row.status as RunStatus,
|
|
@@ -94,7 +92,6 @@ export function createRun(
|
|
|
94
92
|
|
|
95
93
|
const row = {
|
|
96
94
|
id,
|
|
97
|
-
assistantId: 'self',
|
|
98
95
|
conversationId,
|
|
99
96
|
messageId: messageId ?? null,
|
|
100
97
|
status: 'running' as const,
|
package/src/memory/schema.ts
CHANGED
|
@@ -148,7 +148,6 @@ export const memoryJobs = sqliteTable('memory_jobs', {
|
|
|
148
148
|
|
|
149
149
|
export const conversationKeys = sqliteTable('conversation_keys', {
|
|
150
150
|
id: text('id').primaryKey(),
|
|
151
|
-
assistantId: text('assistant_id').notNull(),
|
|
152
151
|
conversationKey: text('conversation_key').notNull(),
|
|
153
152
|
conversationId: text('conversation_id')
|
|
154
153
|
.notNull()
|
|
@@ -158,7 +157,6 @@ export const conversationKeys = sqliteTable('conversation_keys', {
|
|
|
158
157
|
|
|
159
158
|
export const attachments = sqliteTable('attachments', {
|
|
160
159
|
id: text('id').primaryKey(),
|
|
161
|
-
assistantId: text('assistant_id').notNull(),
|
|
162
160
|
originalFilename: text('original_filename').notNull(),
|
|
163
161
|
mimeType: text('mime_type').notNull(),
|
|
164
162
|
sizeBytes: integer('size_bytes').notNull(),
|
|
@@ -183,7 +181,6 @@ export const messageAttachments = sqliteTable('message_attachments', {
|
|
|
183
181
|
|
|
184
182
|
export const channelInboundEvents = sqliteTable('channel_inbound_events', {
|
|
185
183
|
id: text('id').primaryKey(),
|
|
186
|
-
assistantId: text('assistant_id').notNull(),
|
|
187
184
|
sourceChannel: text('source_channel').notNull(),
|
|
188
185
|
externalChatId: text('external_chat_id').notNull(),
|
|
189
186
|
externalMessageId: text('external_message_id').notNull(),
|
|
@@ -207,7 +204,6 @@ export const channelInboundEvents = sqliteTable('channel_inbound_events', {
|
|
|
207
204
|
|
|
208
205
|
export const messageRuns = sqliteTable('message_runs', {
|
|
209
206
|
id: text('id').primaryKey(),
|
|
210
|
-
assistantId: text('assistant_id').notNull(),
|
|
211
207
|
conversationId: text('conversation_id')
|
|
212
208
|
.notNull()
|
|
213
209
|
.references(() => conversations.id, { onDelete: 'cascade' }),
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getLogger } from '../util/logger.js';
|
|
2
|
+
import type { RuntimeAttachmentMetadata } from './http-types.js';
|
|
3
|
+
|
|
4
|
+
const log = getLogger('gateway-client');
|
|
5
|
+
|
|
6
|
+
const DELIVERY_TIMEOUT_MS = 30_000;
|
|
7
|
+
|
|
8
|
+
export interface ChannelReplyPayload {
|
|
9
|
+
chatId: string;
|
|
10
|
+
text?: string;
|
|
11
|
+
assistantId?: string;
|
|
12
|
+
attachments?: RuntimeAttachmentMetadata[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function deliverChannelReply(
|
|
16
|
+
callbackUrl: string,
|
|
17
|
+
payload: ChannelReplyPayload,
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
const response = await fetch(callbackUrl, {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { 'Content-Type': 'application/json' },
|
|
22
|
+
body: JSON.stringify(payload),
|
|
23
|
+
signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
const body = await response.text().catch(() => '<unreadable>');
|
|
28
|
+
log.error(
|
|
29
|
+
{ status: response.status, body, callbackUrl, chatId: payload.chatId },
|
|
30
|
+
'Channel reply delivery failed',
|
|
31
|
+
);
|
|
32
|
+
throw new Error(`Channel reply delivery failed (${response.status}): ${body}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
log.info({ chatId: payload.chatId, callbackUrl }, 'Channel reply delivered');
|
|
36
|
+
}
|
|
@@ -11,6 +11,8 @@ import { timingSafeEqual } from 'node:crypto';
|
|
|
11
11
|
import { ConfigError, IngressBlockedError } from '../util/errors.js';
|
|
12
12
|
import { getLogger } from '../util/logger.js';
|
|
13
13
|
import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js';
|
|
14
|
+
import { loadConfig } from '../config/loader.js';
|
|
15
|
+
import { getPublicBaseUrl } from '../inbound/public-ingress-urls.js';
|
|
14
16
|
import type { RunOrchestrator } from './run-orchestrator.js';
|
|
15
17
|
|
|
16
18
|
// Route handlers — grouped by domain
|
|
@@ -38,6 +40,10 @@ import {
|
|
|
38
40
|
handleReplayDeadLetters,
|
|
39
41
|
} from './routes/channel-routes.js';
|
|
40
42
|
import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
|
|
43
|
+
import * as conversationStore from '../memory/conversation-store.js';
|
|
44
|
+
import * as attachmentsStore from '../memory/attachments-store.js';
|
|
45
|
+
import { renderHistoryContent } from '../daemon/handlers.js';
|
|
46
|
+
import { deliverChannelReply } from './gateway-client.js';
|
|
41
47
|
import {
|
|
42
48
|
handleServePage,
|
|
43
49
|
handleShareApp,
|
|
@@ -59,6 +65,7 @@ import {
|
|
|
59
65
|
} from '../calls/twilio-routes.js';
|
|
60
66
|
import { RelayConnection, activeRelayConnections } from '../calls/relay-server.js';
|
|
61
67
|
import type { RelayWebSocketData } from '../calls/relay-server.js';
|
|
68
|
+
import { consumeCallback, consumeCallbackError } from '../security/oauth-callback-registry.js';
|
|
62
69
|
|
|
63
70
|
// Re-export shared types so existing consumers don't need to update imports
|
|
64
71
|
export type {
|
|
@@ -131,6 +138,35 @@ const GATEWAY_SUBPATH_MAP: Record<string, string> = {
|
|
|
131
138
|
'connect-action': 'connect-action',
|
|
132
139
|
};
|
|
133
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Direct Twilio webhook subpaths that are blocked in gateway_only mode.
|
|
143
|
+
* Internal forwarding endpoints (gateway→runtime) are unaffected.
|
|
144
|
+
*/
|
|
145
|
+
const GATEWAY_ONLY_BLOCKED_SUBPATHS = new Set(['voice-webhook', 'status', 'connect-action']);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check if a request origin is from localhost / loopback.
|
|
149
|
+
*/
|
|
150
|
+
function isLoopbackOrigin(req: Request): boolean {
|
|
151
|
+
const origin = req.headers.get('origin');
|
|
152
|
+
// No origin header (e.g., server-initiated or same-origin) — allow
|
|
153
|
+
if (!origin) return true;
|
|
154
|
+
try {
|
|
155
|
+
const url = new URL(origin);
|
|
156
|
+
const host = url.hostname;
|
|
157
|
+
return host === '127.0.0.1' || host === '::1' || host === 'localhost';
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if a hostname is a loopback address.
|
|
165
|
+
*/
|
|
166
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
167
|
+
return hostname === '127.0.0.1' || hostname === '::1' || hostname === 'localhost';
|
|
168
|
+
}
|
|
169
|
+
|
|
134
170
|
/**
|
|
135
171
|
* Validate a Twilio webhook request's X-Twilio-Signature header.
|
|
136
172
|
*
|
|
@@ -178,10 +214,15 @@ async function validateTwilioWebhook(
|
|
|
178
214
|
// Behind proxies/gateways, req.url is the local server URL (e.g.
|
|
179
215
|
// http://127.0.0.1:7821/...) which differs from the public URL Twilio
|
|
180
216
|
// used to compute the HMAC-SHA1 signature.
|
|
181
|
-
|
|
217
|
+
let publicBaseUrl: string | undefined;
|
|
218
|
+
try {
|
|
219
|
+
publicBaseUrl = getPublicBaseUrl(loadConfig());
|
|
220
|
+
} catch {
|
|
221
|
+
// No webhook base URL configured — fall back to using req.url as-is
|
|
222
|
+
}
|
|
182
223
|
const parsedUrl = new URL(req.url);
|
|
183
224
|
const publicUrl = publicBaseUrl
|
|
184
|
-
? publicBaseUrl
|
|
225
|
+
? publicBaseUrl + parsedUrl.pathname + parsedUrl.search
|
|
185
226
|
: req.url;
|
|
186
227
|
|
|
187
228
|
const isValid = TwilioConversationRelayProvider.verifyWebhookSignature(
|
|
@@ -278,6 +319,19 @@ export class RuntimeHttpServer {
|
|
|
278
319
|
}, 30_000);
|
|
279
320
|
}
|
|
280
321
|
|
|
322
|
+
// Startup guard: log gateway-only mode warnings
|
|
323
|
+
try {
|
|
324
|
+
const config = loadConfig();
|
|
325
|
+
if (config.ingress.mode === 'gateway_only') {
|
|
326
|
+
log.info('Running in gateway-only ingress mode. Direct webhook routes disabled.');
|
|
327
|
+
if (!isLoopbackHost(this.hostname)) {
|
|
328
|
+
log.warn('gateway-only mode is enabled but RUNTIME_HTTP_HOST is not bound to loopback. This may expose the runtime to direct public access.');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
// Config loading may fail during startup — don't block server start
|
|
333
|
+
}
|
|
334
|
+
|
|
281
335
|
log.info({ port: this.port, hostname: this.hostname, auth: !!this.bearerToken }, 'Runtime HTTP server listening');
|
|
282
336
|
}
|
|
283
337
|
|
|
@@ -316,6 +370,15 @@ export class RuntimeHttpServer {
|
|
|
316
370
|
// WebSocket upgrade for ConversationRelay — before auth check because
|
|
317
371
|
// Twilio WebSocket connections don't use bearer tokens.
|
|
318
372
|
if (path.startsWith('/v1/calls/relay') && req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
373
|
+
// In gateway_only mode, only allow relay connections from localhost
|
|
374
|
+
const config = loadConfig();
|
|
375
|
+
if (config.ingress.mode === 'gateway_only' && !isLoopbackOrigin(req)) {
|
|
376
|
+
return Response.json(
|
|
377
|
+
{ error: 'Direct relay access disabled in gateway-only mode', code: 'GATEWAY_ONLY' },
|
|
378
|
+
{ status: 403 },
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
319
382
|
const wsUrl = new URL(req.url);
|
|
320
383
|
const callSessionId = wsUrl.searchParams.get('callSessionId');
|
|
321
384
|
if (!callSessionId) {
|
|
@@ -345,6 +408,15 @@ export class RuntimeHttpServer {
|
|
|
345
408
|
if (resolvedTwilioSubpath && req.method === 'POST') {
|
|
346
409
|
const twilioSubpath = resolvedTwilioSubpath;
|
|
347
410
|
|
|
411
|
+
// In gateway_only mode, block direct Twilio webhook routes
|
|
412
|
+
const ingressConfig = loadConfig();
|
|
413
|
+
if (ingressConfig.ingress.mode === 'gateway_only' && GATEWAY_ONLY_BLOCKED_SUBPATHS.has(twilioSubpath)) {
|
|
414
|
+
return Response.json(
|
|
415
|
+
{ error: 'Direct webhook access disabled in gateway-only mode. Use the gateway.', code: 'GATEWAY_ONLY' },
|
|
416
|
+
{ status: 410 },
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
348
420
|
// Validate Twilio request signature before dispatching
|
|
349
421
|
const validation = await validateTwilioWebhook(req);
|
|
350
422
|
if (validation instanceof Response) return validation;
|
|
@@ -617,6 +689,27 @@ export class RuntimeHttpServer {
|
|
|
617
689
|
return await handleConnectAction(fakeReq);
|
|
618
690
|
}
|
|
619
691
|
|
|
692
|
+
// ── Internal OAuth callback endpoint (gateway → runtime) ──
|
|
693
|
+
if (endpoint === 'internal/oauth/callback' && req.method === 'POST') {
|
|
694
|
+
const json = await req.json() as { state: string; code?: string; error?: string };
|
|
695
|
+
if (!json.state) {
|
|
696
|
+
return Response.json({ error: 'Missing state parameter' }, { status: 400 });
|
|
697
|
+
}
|
|
698
|
+
if (json.error) {
|
|
699
|
+
const consumed = consumeCallbackError(json.state, json.error);
|
|
700
|
+
return consumed
|
|
701
|
+
? Response.json({ ok: true })
|
|
702
|
+
: Response.json({ error: 'Unknown state' }, { status: 404 });
|
|
703
|
+
}
|
|
704
|
+
if (json.code) {
|
|
705
|
+
const consumed = consumeCallback(json.state, json.code);
|
|
706
|
+
return consumed
|
|
707
|
+
? Response.json({ ok: true })
|
|
708
|
+
: Response.json({ error: 'Unknown state' }, { status: 404 });
|
|
709
|
+
}
|
|
710
|
+
return Response.json({ error: 'Missing code or error parameter' }, { status: 400 });
|
|
711
|
+
}
|
|
712
|
+
|
|
620
713
|
return Response.json({ error: 'Not found', source: 'runtime' }, { status: 404 });
|
|
621
714
|
} catch (err) {
|
|
622
715
|
if (err instanceof IngressBlockedError) {
|
|
@@ -695,6 +788,18 @@ export class RuntimeHttpServer {
|
|
|
695
788
|
channelDeliveryStore.linkMessage(event.id, userMessageId);
|
|
696
789
|
channelDeliveryStore.markProcessed(event.id);
|
|
697
790
|
log.info({ eventId: event.id }, 'Successfully replayed failed channel event');
|
|
791
|
+
|
|
792
|
+
const replyCallbackUrl = typeof payload.replyCallbackUrl === 'string'
|
|
793
|
+
? payload.replyCallbackUrl
|
|
794
|
+
: undefined;
|
|
795
|
+
if (replyCallbackUrl) {
|
|
796
|
+
const externalChatId = typeof payload.externalChatId === 'string'
|
|
797
|
+
? payload.externalChatId
|
|
798
|
+
: undefined;
|
|
799
|
+
if (externalChatId) {
|
|
800
|
+
await this.deliverReplyViaCallback(event.conversationId, externalChatId, replyCallbackUrl);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
698
803
|
} catch (err) {
|
|
699
804
|
log.error({ err, eventId: event.id }, 'Retry failed for channel event');
|
|
700
805
|
channelDeliveryStore.recordProcessingFailure(event.id, err);
|
|
@@ -702,6 +807,39 @@ export class RuntimeHttpServer {
|
|
|
702
807
|
}
|
|
703
808
|
}
|
|
704
809
|
|
|
810
|
+
private async deliverReplyViaCallback(
|
|
811
|
+
conversationId: string,
|
|
812
|
+
externalChatId: string,
|
|
813
|
+
callbackUrl: string,
|
|
814
|
+
): Promise<void> {
|
|
815
|
+
const msgs = conversationStore.getMessages(conversationId);
|
|
816
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
817
|
+
if (msgs[i].role === 'assistant') {
|
|
818
|
+
let parsed: unknown;
|
|
819
|
+
try { parsed = JSON.parse(msgs[i].content); } catch { parsed = msgs[i].content; }
|
|
820
|
+
const rendered = renderHistoryContent(parsed);
|
|
821
|
+
|
|
822
|
+
const linked = attachmentsStore.getAttachmentMetadataForMessage(msgs[i].id);
|
|
823
|
+
const replyAttachments = linked.map((a) => ({
|
|
824
|
+
id: a.id,
|
|
825
|
+
filename: a.originalFilename,
|
|
826
|
+
mimeType: a.mimeType,
|
|
827
|
+
sizeBytes: a.sizeBytes,
|
|
828
|
+
kind: a.kind,
|
|
829
|
+
}));
|
|
830
|
+
|
|
831
|
+
if (rendered.text || replyAttachments.length > 0) {
|
|
832
|
+
await deliverChannelReply(callbackUrl, {
|
|
833
|
+
chatId: externalChatId,
|
|
834
|
+
text: rendered.text || undefined,
|
|
835
|
+
attachments: replyAttachments.length > 0 ? replyAttachments : undefined,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
705
843
|
private handleHealth(): Response {
|
|
706
844
|
return Response.json({
|
|
707
845
|
status: 'healthy',
|
|
@@ -10,10 +10,10 @@ import { renderHistoryContent } from '../../daemon/handlers.js';
|
|
|
10
10
|
import { checkIngressForSecrets } from '../../security/secret-ingress.js';
|
|
11
11
|
import { IngressBlockedError } from '../../util/errors.js';
|
|
12
12
|
import { getLogger } from '../../util/logger.js';
|
|
13
|
+
import { deliverChannelReply } from '../gateway-client.js';
|
|
13
14
|
import type {
|
|
14
15
|
MessageProcessor,
|
|
15
16
|
RuntimeAttachmentMetadata,
|
|
16
|
-
RuntimeMessagePayload,
|
|
17
17
|
} from '../http-types.js';
|
|
18
18
|
|
|
19
19
|
const log = getLogger('runtime-http');
|
|
@@ -54,6 +54,7 @@ export async function handleChannelInbound(
|
|
|
54
54
|
senderExternalUserId?: string;
|
|
55
55
|
senderUsername?: string;
|
|
56
56
|
sourceMetadata?: Record<string, unknown>;
|
|
57
|
+
replyCallbackUrl?: string;
|
|
57
58
|
};
|
|
58
59
|
|
|
59
60
|
const {
|
|
@@ -185,41 +186,92 @@ export async function handleChannelInbound(
|
|
|
185
186
|
? sourceMetadata.uxBrief.trim()
|
|
186
187
|
: undefined;
|
|
187
188
|
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
const replyCallbackUrl = body.replyCallbackUrl;
|
|
190
|
+
|
|
191
|
+
// For new (non-duplicate) messages, run the secret ingress check
|
|
192
|
+
// synchronously, then fire off the agent loop in the background.
|
|
190
193
|
if (!result.duplicate && processMessage) {
|
|
194
|
+
// Persist the raw payload first so dead-lettered events can always be
|
|
195
|
+
// replayed. If the ingress check later detects secrets we clear it
|
|
196
|
+
// before throwing, so secret-bearing content is never left on disk.
|
|
197
|
+
channelDeliveryStore.storePayload(result.eventId, {
|
|
198
|
+
sourceChannel, externalChatId, externalMessageId, content,
|
|
199
|
+
attachmentIds, sourceMetadata: body.sourceMetadata,
|
|
200
|
+
senderName: body.senderName,
|
|
201
|
+
senderExternalUserId: body.senderExternalUserId,
|
|
202
|
+
senderUsername: body.senderUsername,
|
|
203
|
+
replyCallbackUrl,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const contentToCheck = content ?? '';
|
|
207
|
+
let ingressCheck: ReturnType<typeof checkIngressForSecrets>;
|
|
191
208
|
try {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
});
|
|
209
|
+
ingressCheck = checkIngressForSecrets(contentToCheck);
|
|
210
|
+
} catch (checkErr) {
|
|
211
|
+
channelDeliveryStore.clearPayload(result.eventId);
|
|
212
|
+
throw checkErr;
|
|
213
|
+
}
|
|
214
|
+
if (ingressCheck.blocked) {
|
|
215
|
+
channelDeliveryStore.clearPayload(result.eventId);
|
|
216
|
+
throw new IngressBlockedError(ingressCheck.userNotice!, ingressCheck.detectedTypes);
|
|
217
|
+
}
|
|
202
218
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
219
|
+
// Fire-and-forget: process the message and deliver the reply in the background.
|
|
220
|
+
// The HTTP response returns immediately so the gateway webhook is not blocked.
|
|
221
|
+
processChannelMessageInBackground({
|
|
222
|
+
processMessage,
|
|
223
|
+
conversationId: result.conversationId,
|
|
224
|
+
eventId: result.eventId,
|
|
225
|
+
content: content ?? '',
|
|
226
|
+
attachmentIds: hasAttachments ? attachmentIds : undefined,
|
|
227
|
+
sourceChannel,
|
|
228
|
+
externalChatId,
|
|
229
|
+
metadataHints,
|
|
230
|
+
metadataUxBrief,
|
|
231
|
+
replyCallbackUrl,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return Response.json({
|
|
236
|
+
accepted: result.accepted,
|
|
237
|
+
duplicate: result.duplicate,
|
|
238
|
+
eventId: result.eventId,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
218
241
|
|
|
242
|
+
interface BackgroundProcessingParams {
|
|
243
|
+
processMessage: MessageProcessor;
|
|
244
|
+
conversationId: string;
|
|
245
|
+
eventId: string;
|
|
246
|
+
content: string;
|
|
247
|
+
attachmentIds?: string[];
|
|
248
|
+
sourceChannel: string;
|
|
249
|
+
externalChatId: string;
|
|
250
|
+
metadataHints: string[];
|
|
251
|
+
metadataUxBrief?: string;
|
|
252
|
+
replyCallbackUrl?: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function processChannelMessageInBackground(params: BackgroundProcessingParams): void {
|
|
256
|
+
const {
|
|
257
|
+
processMessage,
|
|
258
|
+
conversationId,
|
|
259
|
+
eventId,
|
|
260
|
+
content,
|
|
261
|
+
attachmentIds,
|
|
262
|
+
sourceChannel,
|
|
263
|
+
externalChatId,
|
|
264
|
+
metadataHints,
|
|
265
|
+
metadataUxBrief,
|
|
266
|
+
replyCallbackUrl,
|
|
267
|
+
} = params;
|
|
268
|
+
|
|
269
|
+
(async () => {
|
|
270
|
+
try {
|
|
219
271
|
const { messageId: userMessageId } = await processMessage(
|
|
220
|
-
|
|
221
|
-
content
|
|
222
|
-
|
|
272
|
+
conversationId,
|
|
273
|
+
content,
|
|
274
|
+
attachmentIds,
|
|
223
275
|
{
|
|
224
276
|
transport: {
|
|
225
277
|
channelId: sourceChannel,
|
|
@@ -229,60 +281,50 @@ export async function handleChannelInbound(
|
|
|
229
281
|
},
|
|
230
282
|
sourceChannel,
|
|
231
283
|
);
|
|
232
|
-
|
|
233
|
-
channelDeliveryStore.
|
|
234
|
-
|
|
235
|
-
|
|
284
|
+
channelDeliveryStore.linkMessage(eventId, userMessageId);
|
|
285
|
+
channelDeliveryStore.markProcessed(eventId);
|
|
286
|
+
|
|
287
|
+
if (replyCallbackUrl) {
|
|
288
|
+
await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl);
|
|
289
|
+
}
|
|
236
290
|
} catch (err) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
log.error({ err, conversationId: result.conversationId }, 'Failed to process channel inbound message');
|
|
240
|
-
channelDeliveryStore.recordProcessingFailure(result.eventId, err);
|
|
291
|
+
log.error({ err, conversationId }, 'Background channel message processing failed');
|
|
292
|
+
channelDeliveryStore.recordProcessingFailure(eventId, err);
|
|
241
293
|
}
|
|
242
|
-
}
|
|
294
|
+
})();
|
|
295
|
+
}
|
|
243
296
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
timestamp: new Date(msgs[i].createdAt).toISOString(),
|
|
272
|
-
attachments: replyAttachments,
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
break;
|
|
297
|
+
async function deliverReplyViaCallback(
|
|
298
|
+
conversationId: string,
|
|
299
|
+
externalChatId: string,
|
|
300
|
+
callbackUrl: string,
|
|
301
|
+
): Promise<void> {
|
|
302
|
+
const msgs = conversationStore.getMessages(conversationId);
|
|
303
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
304
|
+
if (msgs[i].role === 'assistant') {
|
|
305
|
+
let parsed: unknown;
|
|
306
|
+
try { parsed = JSON.parse(msgs[i].content); } catch { parsed = msgs[i].content; }
|
|
307
|
+
const rendered = renderHistoryContent(parsed);
|
|
308
|
+
|
|
309
|
+
const linked = attachmentsStore.getAttachmentMetadataForMessage(msgs[i].id);
|
|
310
|
+
const replyAttachments: RuntimeAttachmentMetadata[] = linked.map((a) => ({
|
|
311
|
+
id: a.id,
|
|
312
|
+
filename: a.originalFilename,
|
|
313
|
+
mimeType: a.mimeType,
|
|
314
|
+
sizeBytes: a.sizeBytes,
|
|
315
|
+
kind: a.kind,
|
|
316
|
+
}));
|
|
317
|
+
|
|
318
|
+
if (rendered.text || replyAttachments.length > 0) {
|
|
319
|
+
await deliverChannelReply(callbackUrl, {
|
|
320
|
+
chatId: externalChatId,
|
|
321
|
+
text: rendered.text || undefined,
|
|
322
|
+
attachments: replyAttachments.length > 0 ? replyAttachments : undefined,
|
|
323
|
+
});
|
|
276
324
|
}
|
|
325
|
+
break;
|
|
277
326
|
}
|
|
278
327
|
}
|
|
279
|
-
|
|
280
|
-
return Response.json({
|
|
281
|
-
accepted: result.accepted,
|
|
282
|
-
duplicate: result.duplicate,
|
|
283
|
-
eventId: result.eventId,
|
|
284
|
-
...(assistantMessage ? { assistantMessage } : {}),
|
|
285
|
-
});
|
|
286
328
|
}
|
|
287
329
|
|
|
288
330
|
export function handleListDeadLetters(): Response {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory registry for pending OAuth callback states.
|
|
3
|
+
* Used by the gateway-routed OAuth flow to resolve authorization codes
|
|
4
|
+
* back to the runtime code that initiated the OAuth handshake.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
interface PendingCallback {
|
|
8
|
+
resolve: (code: string) => void;
|
|
9
|
+
reject: (error: Error) => void;
|
|
10
|
+
timer: ReturnType<typeof setTimeout>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const pendingCallbacks = new Map<string, PendingCallback>();
|
|
14
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
15
|
+
|
|
16
|
+
export function registerPendingCallback(
|
|
17
|
+
state: string,
|
|
18
|
+
resolve: (code: string) => void,
|
|
19
|
+
reject: (error: Error) => void,
|
|
20
|
+
ttlMs = DEFAULT_TTL_MS,
|
|
21
|
+
): void {
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
const entry = pendingCallbacks.get(state);
|
|
24
|
+
if (entry) {
|
|
25
|
+
pendingCallbacks.delete(state);
|
|
26
|
+
entry.reject(new Error('OAuth callback timed out'));
|
|
27
|
+
}
|
|
28
|
+
}, ttlMs);
|
|
29
|
+
|
|
30
|
+
pendingCallbacks.set(state, { resolve, reject, timer });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function consumeCallback(state: string, code: string): boolean {
|
|
34
|
+
const entry = pendingCallbacks.get(state);
|
|
35
|
+
if (!entry) return false;
|
|
36
|
+
clearTimeout(entry.timer);
|
|
37
|
+
pendingCallbacks.delete(state);
|
|
38
|
+
entry.resolve(code);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function consumeCallbackError(state: string, error: string): boolean {
|
|
43
|
+
const entry = pendingCallbacks.get(state);
|
|
44
|
+
if (!entry) return false;
|
|
45
|
+
clearTimeout(entry.timer);
|
|
46
|
+
pendingCallbacks.delete(state);
|
|
47
|
+
entry.reject(new Error(error));
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function clearAllCallbacks(): void {
|
|
52
|
+
for (const entry of pendingCallbacks.values()) {
|
|
53
|
+
clearTimeout(entry.timer);
|
|
54
|
+
}
|
|
55
|
+
pendingCallbacks.clear();
|
|
56
|
+
}
|