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.
Files changed (60) hide show
  1. package/bun.lock +68 -100
  2. package/package.json +3 -3
  3. package/src/__tests__/asset-materialize-tool.test.ts +2 -2
  4. package/src/__tests__/checker.test.ts +104 -0
  5. package/src/__tests__/config-schema.test.ts +6 -0
  6. package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
  7. package/src/__tests__/handlers-twilio-config.test.ts +221 -0
  8. package/src/__tests__/ipc-snapshot.test.ts +20 -0
  9. package/src/__tests__/memory-regressions.test.ts +100 -2
  10. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  11. package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
  12. package/src/__tests__/provider-commit-message-generator.test.ts +342 -0
  13. package/src/__tests__/public-ingress-urls.test.ts +206 -0
  14. package/src/__tests__/session-conflict-gate.test.ts +28 -25
  15. package/src/__tests__/tool-executor.test.ts +88 -0
  16. package/src/__tests__/turn-commit.test.ts +64 -0
  17. package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
  18. package/src/calls/call-domain.ts +3 -3
  19. package/src/calls/twilio-config.ts +25 -9
  20. package/src/calls/twilio-provider.ts +4 -4
  21. package/src/calls/twilio-routes.ts +10 -2
  22. package/src/calls/twilio-webhook-urls.ts +47 -0
  23. package/src/cli/map.ts +30 -6
  24. package/src/config/defaults.ts +5 -0
  25. package/src/config/schema.ts +34 -2
  26. package/src/config/system-prompt.ts +1 -1
  27. package/src/config/types.ts +1 -0
  28. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
  29. package/src/daemon/computer-use-session.ts +2 -1
  30. package/src/daemon/handlers/config.ts +95 -4
  31. package/src/daemon/handlers/sessions.ts +2 -2
  32. package/src/daemon/handlers/work-items.ts +1 -1
  33. package/src/daemon/ipc-contract-inventory.json +8 -0
  34. package/src/daemon/ipc-contract.ts +39 -1
  35. package/src/daemon/ride-shotgun-handler.ts +2 -1
  36. package/src/daemon/session-agent-loop.ts +37 -2
  37. package/src/daemon/session-conflict-gate.ts +18 -109
  38. package/src/daemon/session-tool-setup.ts +7 -0
  39. package/src/inbound/public-ingress-urls.ts +106 -0
  40. package/src/memory/attachments-store.ts +0 -1
  41. package/src/memory/channel-delivery-store.ts +0 -1
  42. package/src/memory/conflict-intent.ts +114 -0
  43. package/src/memory/conversation-key-store.ts +0 -1
  44. package/src/memory/db.ts +346 -149
  45. package/src/memory/job-handlers/conflict.ts +23 -1
  46. package/src/memory/runs-store.ts +0 -3
  47. package/src/memory/schema.ts +0 -4
  48. package/src/runtime/gateway-client.ts +36 -0
  49. package/src/runtime/http-server.ts +140 -2
  50. package/src/runtime/routes/channel-routes.ts +121 -79
  51. package/src/security/oauth-callback-registry.ts +56 -0
  52. package/src/security/oauth2.ts +174 -58
  53. package/src/swarm/backend-claude-code.ts +1 -1
  54. package/src/tools/assets/search.ts +1 -36
  55. package/src/tools/browser/api-map.ts +123 -50
  56. package/src/tools/claude-code/claude-code.ts +131 -1
  57. package/src/tools/tasks/work-item-list.ts +16 -2
  58. package/src/workspace/commit-message-enrichment-service.ts +3 -3
  59. package/src/workspace/provider-commit-message-generator.ts +57 -14
  60. package/src/workspace/turn-commit.ts +6 -2
@@ -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,
@@ -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
- const publicBaseUrl = process.env.TWILIO_WEBHOOK_BASE_URL;
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.replace(/\/$/, '') + parsedUrl.pathname + parsedUrl.search
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
- // For new (non-duplicate) messages, run the agent loop to generate a reply.
189
- let processingSucceeded = false;
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
- // Persist the raw payload first so dead-lettered events can always be
193
- // replayed. If the ingress check later detects secrets we clear it
194
- // before throwing, so secret-bearing content is never left on disk.
195
- channelDeliveryStore.storePayload(result.eventId, {
196
- sourceChannel, externalChatId, externalMessageId, content,
197
- attachmentIds, sourceMetadata: body.sourceMetadata,
198
- senderName: body.senderName,
199
- senderExternalUserId: body.senderExternalUserId,
200
- senderUsername: body.senderUsername,
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
- const contentToCheck = content ?? '';
204
- let ingressCheck: ReturnType<typeof checkIngressForSecrets>;
205
- try {
206
- ingressCheck = checkIngressForSecrets(contentToCheck);
207
- } catch (checkErr) {
208
- // If the secret check itself throws (e.g. ConfigError from corrupt
209
- // config), clear the stored payload so secret-bearing content is
210
- // never left on disk.
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
- }
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
- result.conversationId,
221
- content ?? '',
222
- hasAttachments ? attachmentIds : undefined,
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
- // Link the user message to the inbound event so edits can find it later
233
- channelDeliveryStore.linkMessage(result.eventId, userMessageId);
234
- channelDeliveryStore.markProcessed(result.eventId);
235
- processingSucceeded = true;
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
- // Secret ingress blocks are not retryable let the top-level handler return 422
238
- if (err instanceof IngressBlockedError) throw err;
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
- // Only look up the assistant reply when processing succeeded for a new
245
- // (non-duplicate) message. For duplicates or failed processing, returning
246
- // a stale assistant message could cause the caller to resend old replies.
247
- let assistantMessage: RuntimeMessagePayload | undefined;
248
- if (processingSucceeded) {
249
- const msgs = conversationStore.getMessages(result.conversationId);
250
- for (let i = msgs.length - 1; i >= 0; i--) {
251
- if (msgs[i].role === 'assistant') {
252
- let parsed: unknown;
253
- try { parsed = JSON.parse(msgs[i].content); } catch { parsed = msgs[i].content; }
254
- const rendered = renderHistoryContent(parsed);
255
-
256
- const linked = attachmentsStore.getAttachmentMetadataForMessage(msgs[i].id);
257
- const replyAttachments: RuntimeAttachmentMetadata[] = linked.map((a) => ({
258
- id: a.id,
259
- filename: a.originalFilename,
260
- mimeType: a.mimeType,
261
- sizeBytes: a.sizeBytes,
262
- kind: a.kind,
263
- }));
264
-
265
- // Include the reply if it has text or attachments
266
- if (rendered.text || replyAttachments.length > 0) {
267
- assistantMessage = {
268
- id: msgs[i].id,
269
- role: 'assistant',
270
- content: rendered.text,
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
+ }