vellum 0.2.7 → 0.2.9

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 (76) hide show
  1. package/bun.lock +4 -4
  2. package/package.json +4 -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 +0 -6
  6. package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
  7. package/src/__tests__/gateway-only-enforcement.test.ts +538 -0
  8. package/src/__tests__/ingress-url-consistency.test.ts +214 -0
  9. package/src/__tests__/ipc-snapshot.test.ts +17 -5
  10. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  11. package/src/__tests__/oauth2-gateway-transport.test.ts +304 -0
  12. package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
  13. package/src/__tests__/public-ingress-urls.test.ts +222 -0
  14. package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
  15. package/src/__tests__/runtime-events-sse.test.ts +162 -0
  16. package/src/__tests__/tool-executor.test.ts +88 -0
  17. package/src/__tests__/turn-commit.test.ts +64 -0
  18. package/src/__tests__/twilio-provider.test.ts +1 -1
  19. package/src/__tests__/twilio-routes.test.ts +4 -4
  20. package/src/__tests__/twitter-auth-handler.test.ts +87 -2
  21. package/src/calls/call-domain.ts +8 -6
  22. package/src/calls/twilio-config.ts +18 -3
  23. package/src/calls/twilio-routes.ts +10 -2
  24. package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
  25. package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
  26. package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
  27. package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
  28. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
  29. package/src/config/defaults.ts +4 -1
  30. package/src/config/schema.ts +30 -6
  31. package/src/config/system-prompt.ts +1 -1
  32. package/src/config/types.ts +1 -0
  33. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
  34. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
  35. package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
  36. package/src/daemon/computer-use-session.ts +2 -1
  37. package/src/daemon/handlers/config.ts +49 -17
  38. package/src/daemon/handlers/sessions.ts +2 -2
  39. package/src/daemon/handlers/shared.ts +1 -0
  40. package/src/daemon/handlers/subagents.ts +85 -2
  41. package/src/daemon/handlers/twitter-auth.ts +31 -2
  42. package/src/daemon/handlers/work-items.ts +1 -1
  43. package/src/daemon/ipc-contract-inventory.json +8 -4
  44. package/src/daemon/ipc-contract.ts +34 -15
  45. package/src/daemon/lifecycle.ts +9 -4
  46. package/src/daemon/server.ts +7 -0
  47. package/src/daemon/session-tool-setup.ts +8 -1
  48. package/src/inbound/public-ingress-urls.ts +112 -0
  49. package/src/memory/attachments-store.ts +0 -1
  50. package/src/memory/channel-delivery-store.ts +0 -1
  51. package/src/memory/conversation-key-store.ts +0 -1
  52. package/src/memory/db.ts +472 -148
  53. package/src/memory/llm-usage-store.ts +0 -1
  54. package/src/memory/runs-store.ts +51 -6
  55. package/src/memory/schema.ts +2 -6
  56. package/src/runtime/gateway-client.ts +7 -1
  57. package/src/runtime/http-server.ts +174 -7
  58. package/src/runtime/routes/channel-routes.ts +7 -2
  59. package/src/runtime/routes/events-routes.ts +79 -0
  60. package/src/runtime/routes/run-routes.ts +43 -0
  61. package/src/runtime/run-orchestrator.ts +64 -7
  62. package/src/security/oauth-callback-registry.ts +66 -0
  63. package/src/security/oauth2.ts +208 -58
  64. package/src/subagent/manager.ts +3 -1
  65. package/src/swarm/backend-claude-code.ts +1 -1
  66. package/src/tools/assets/search.ts +1 -36
  67. package/src/tools/claude-code/claude-code.ts +3 -3
  68. package/src/tools/tasks/work-item-list.ts +16 -2
  69. package/src/tools/tasks/work-item-run.ts +78 -0
  70. package/src/util/platform.ts +1 -1
  71. package/src/work-items/work-item-runner.ts +171 -0
  72. package/src/workspace/provider-commit-message-generator.ts +39 -23
  73. package/src/workspace/turn-commit.ts +6 -2
  74. package/src/__tests__/handlers-twilio-config.test.ts +0 -221
  75. package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
  76. package/src/calls/twilio-webhook-urls.ts +0 -50
@@ -16,7 +16,6 @@ export function recordUsageEvent(input: UsageEventInput, pricing: PricingResult)
16
16
  db.insert(llmUsageEvents).values({
17
17
  id: event.id,
18
18
  createdAt: event.createdAt,
19
- assistantId: 'self',
20
19
  conversationId: event.conversationId,
21
20
  runId: event.runId,
22
21
  requestId: event.requestId,
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Runs track the lifecycle of an agent loop triggered by a user message:
5
5
  * running → needs_confirmation → running → completed | failed
6
+ * running → needs_secret → running → completed | failed
6
7
  */
7
8
 
8
9
  import { eq, inArray } from 'drizzle-orm';
@@ -14,7 +15,7 @@ import { messageRuns } from './schema.js';
14
15
  // Types
15
16
  // ---------------------------------------------------------------------------
16
17
 
17
- export type RunStatus = 'running' | 'needs_confirmation' | 'completed' | 'failed';
18
+ export type RunStatus = 'running' | 'needs_confirmation' | 'needs_secret' | 'completed' | 'failed';
18
19
 
19
20
  export interface PendingConfirmation {
20
21
  toolName: string;
@@ -34,13 +35,24 @@ export interface PendingConfirmation {
34
35
  persistentDecisionsAllowed?: boolean;
35
36
  }
36
37
 
38
+ export interface PendingSecret {
39
+ requestId: string;
40
+ service: string;
41
+ field: string;
42
+ label: string;
43
+ description?: string;
44
+ placeholder?: string;
45
+ purpose?: string;
46
+ allowOneTimeSend?: boolean;
47
+ }
48
+
37
49
  export interface Run {
38
50
  id: string;
39
- assistantId: string;
40
51
  conversationId: string;
41
52
  messageId: string | null;
42
53
  status: RunStatus;
43
54
  pendingConfirmation: PendingConfirmation | null;
55
+ pendingSecret: PendingSecret | null;
44
56
  inputTokens: number;
45
57
  outputTokens: number;
46
58
  estimatedCost: number;
@@ -64,13 +76,17 @@ function rowToRun(row: typeof messageRuns.$inferSelect): Run {
64
76
  if (row.pendingConfirmation) {
65
77
  try { pendingConfirmation = JSON.parse(row.pendingConfirmation); } catch { /* malformed */ }
66
78
  }
79
+ let pendingSecret: PendingSecret | null = null;
80
+ if (row.pendingSecret) {
81
+ try { pendingSecret = JSON.parse(row.pendingSecret); } catch { /* malformed */ }
82
+ }
67
83
  return {
68
84
  id: row.id,
69
- assistantId: row.assistantId,
70
85
  conversationId: row.conversationId,
71
86
  messageId: row.messageId,
72
87
  status: row.status as RunStatus,
73
88
  pendingConfirmation,
89
+ pendingSecret,
74
90
  inputTokens: row.inputTokens,
75
91
  outputTokens: row.outputTokens,
76
92
  estimatedCost: row.estimatedCost,
@@ -94,11 +110,11 @@ export function createRun(
94
110
 
95
111
  const row = {
96
112
  id,
97
- assistantId: 'self',
98
113
  conversationId,
99
114
  messageId: messageId ?? null,
100
115
  status: 'running' as const,
101
116
  pendingConfirmation: null,
117
+ pendingSecret: null,
102
118
  inputTokens: 0,
103
119
  outputTokens: 0,
104
120
  estimatedCost: 0,
@@ -147,6 +163,35 @@ export function clearRunConfirmation(runId: string): void {
147
163
  .run();
148
164
  }
149
165
 
166
+ export function setRunSecret(
167
+ runId: string,
168
+ secret: PendingSecret,
169
+ ): void {
170
+ const db = getDb();
171
+ const now = Date.now();
172
+ db.update(messageRuns)
173
+ .set({
174
+ status: 'needs_secret',
175
+ pendingSecret: JSON.stringify(secret),
176
+ updatedAt: now,
177
+ })
178
+ .where(eq(messageRuns.id, runId))
179
+ .run();
180
+ }
181
+
182
+ export function clearRunSecret(runId: string): void {
183
+ const db = getDb();
184
+ const now = Date.now();
185
+ db.update(messageRuns)
186
+ .set({
187
+ status: 'running',
188
+ pendingSecret: null,
189
+ updatedAt: now,
190
+ })
191
+ .where(eq(messageRuns.id, runId))
192
+ .run();
193
+ }
194
+
150
195
  export function completeRun(runId: string, usage?: RunUsage): void {
151
196
  const db = getDb();
152
197
  const now = Date.now();
@@ -180,13 +225,13 @@ export function failRun(runId: string, error: string): void {
180
225
  /**
181
226
  * Mark all non-terminal runs as failed.
182
227
  * Called on startup to recover from daemon restarts that left runs
183
- * in running/needs_confirmation with no in-memory state to resolve them.
228
+ * in running/needs_confirmation/needs_secret with no in-memory state to resolve them.
184
229
  * Returns the number of rows affected.
185
230
  */
186
231
  export function failOrphanedRuns(): number {
187
232
  const db = getDb();
188
233
  const now = Date.now();
189
- const activeStatuses = ['running', 'needs_confirmation'];
234
+ const activeStatuses = ['running', 'needs_confirmation', 'needs_secret'];
190
235
 
191
236
  // Count first so we can report how many were recovered.
192
237
  const active = db.select({ id: messageRuns.id })
@@ -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,14 +204,14 @@ 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' }),
214
210
  messageId: text('message_id')
215
211
  .references(() => messages.id, { onDelete: 'cascade' }),
216
- status: text('status').notNull().default('running'), // running | needs_confirmation | completed | failed
212
+ status: text('status').notNull().default('running'), // running | needs_confirmation | needs_secret | completed | failed
217
213
  pendingConfirmation: text('pending_confirmation'), // JSON when status=needs_confirmation
214
+ pendingSecret: text('pending_secret'), // JSON when status=needs_secret
218
215
  inputTokens: integer('input_tokens').notNull().default(0),
219
216
  outputTokens: integer('output_tokens').notNull().default(0),
220
217
  estimatedCost: real('estimated_cost').notNull().default(0),
@@ -523,7 +520,6 @@ export const llmRequestLogs = sqliteTable('llm_request_logs', {
523
520
  export const llmUsageEvents = sqliteTable('llm_usage_events', {
524
521
  id: text('id').primaryKey(),
525
522
  createdAt: integer('created_at').notNull(),
526
- assistantId: text('assistant_id'),
527
523
  conversationId: text('conversation_id'),
528
524
  runId: text('run_id'),
529
525
  requestId: text('request_id'),
@@ -15,10 +15,16 @@ export interface ChannelReplyPayload {
15
15
  export async function deliverChannelReply(
16
16
  callbackUrl: string,
17
17
  payload: ChannelReplyPayload,
18
+ bearerToken?: string,
18
19
  ): Promise<void> {
20
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
21
+ if (bearerToken) {
22
+ headers['Authorization'] = `Bearer ${bearerToken}`;
23
+ }
24
+
19
25
  const response = await fetch(callbackUrl, {
20
26
  method: 'POST',
21
- headers: { 'Content-Type': 'application/json' },
27
+ headers,
22
28
  body: JSON.stringify(payload),
23
29
  signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
24
30
  });
@@ -12,7 +12,7 @@ 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
14
  import { loadConfig } from '../config/loader.js';
15
- import { getWebhookBaseUrl } from '../calls/twilio-webhook-urls.js';
15
+ import { getPublicBaseUrl } from '../inbound/public-ingress-urls.js';
16
16
  import type { RunOrchestrator } from './run-orchestrator.js';
17
17
 
18
18
  // Route handlers — grouped by domain
@@ -30,6 +30,7 @@ import {
30
30
  handleCreateRun,
31
31
  handleGetRun,
32
32
  handleRunDecision,
33
+ handleRunSecret,
33
34
  handleAddTrustRule,
34
35
  } from './routes/run-routes.js';
35
36
  import {
@@ -65,6 +66,8 @@ import {
65
66
  } from '../calls/twilio-routes.js';
66
67
  import { RelayConnection, activeRelayConnections } from '../calls/relay-server.js';
67
68
  import type { RelayWebSocketData } from '../calls/relay-server.js';
69
+ import { handleSubscribeAssistantEvents } from './routes/events-routes.js';
70
+ import { consumeCallback, consumeCallbackError } from '../security/oauth-callback-registry.js';
68
71
 
69
72
  // Re-export shared types so existing consumers don't need to update imports
70
73
  export type {
@@ -137,6 +140,103 @@ const GATEWAY_SUBPATH_MAP: Record<string, string> = {
137
140
  'connect-action': 'connect-action',
138
141
  };
139
142
 
143
+ /**
144
+ * Direct Twilio webhook subpaths that are blocked in gateway_only mode.
145
+ * Internal forwarding endpoints (gateway→runtime) are unaffected.
146
+ */
147
+ const GATEWAY_ONLY_BLOCKED_SUBPATHS = new Set(['voice-webhook', 'status', 'connect-action']);
148
+
149
+ /**
150
+ * Check if a request origin is from a private/internal network address.
151
+ * Extracts the hostname from the Origin header and validates it against
152
+ * isPrivateAddress(), consistent with the isPrivateNetworkPeer check.
153
+ */
154
+ function isPrivateNetworkOrigin(req: Request): boolean {
155
+ const origin = req.headers.get('origin');
156
+ // No origin header (e.g., server-initiated or same-origin) — allow
157
+ if (!origin) return true;
158
+ try {
159
+ const url = new URL(origin);
160
+ const host = url.hostname;
161
+ if (host === 'localhost') return true;
162
+ // URL.hostname wraps IPv6 addresses in brackets (e.g. "[::1]") — strip them
163
+ const rawHost = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
164
+ return isPrivateAddress(rawHost);
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Check if a hostname is a loopback address.
172
+ */
173
+ function isLoopbackHost(hostname: string): boolean {
174
+ return hostname === '127.0.0.1' || hostname === '::1' || hostname === 'localhost';
175
+ }
176
+
177
+ /**
178
+ * Check if the actual peer/remote address of a connection is from a
179
+ * private/internal network. Uses Bun's server.requestIP() to get the
180
+ * real peer address, which cannot be spoofed unlike the Origin header.
181
+ *
182
+ * Accepts loopback, RFC 1918 private IPv4, link-local, and RFC 4193
183
+ * unique-local IPv6 — including their IPv4-mapped IPv6 forms. This
184
+ * supports container/pod deployments (e.g. Kubernetes sidecars) where
185
+ * gateway and runtime communicate over pod-internal private IPs.
186
+ */
187
+ function isPrivateNetworkPeer(server: { requestIP(req: Request): { address: string; family: string; port: number } | null }, req: Request): boolean {
188
+ const ip = server.requestIP(req);
189
+ if (!ip) return false;
190
+ return isPrivateAddress(ip.address);
191
+ }
192
+
193
+ /**
194
+ * @internal Exported for testing.
195
+ *
196
+ * Determine whether an IP address string belongs to a private/internal
197
+ * network range:
198
+ * - Loopback: 127.0.0.0/8, ::1
199
+ * - RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
200
+ * - Link-local: 169.254.0.0/16
201
+ * - IPv6 unique local: fc00::/7 (fc00::–fdff::)
202
+ * - IPv4-mapped IPv6 variants of all of the above (::ffff:x.x.x.x)
203
+ */
204
+ export function isPrivateAddress(addr: string): boolean {
205
+ // Handle IPv4-mapped IPv6 (e.g. ::ffff:10.0.0.1) — extract the IPv4 part
206
+ const v4Mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
207
+ const normalized = v4Mapped ? v4Mapped[1] : addr;
208
+
209
+ // IPv4 checks
210
+ if (normalized.includes('.')) {
211
+ const parts = normalized.split('.').map(Number);
212
+ if (parts.length !== 4 || parts.some(p => isNaN(p) || p < 0 || p > 255)) return false;
213
+
214
+ // Loopback: 127.0.0.0/8
215
+ if (parts[0] === 127) return true;
216
+ // 10.0.0.0/8
217
+ if (parts[0] === 10) return true;
218
+ // 172.16.0.0/12 (172.16.x.x – 172.31.x.x)
219
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
220
+ // 192.168.0.0/16
221
+ if (parts[0] === 192 && parts[1] === 168) return true;
222
+ // Link-local: 169.254.0.0/16
223
+ if (parts[0] === 169 && parts[1] === 254) return true;
224
+
225
+ return false;
226
+ }
227
+
228
+ // IPv6 checks
229
+ const lower = normalized.toLowerCase();
230
+ // Loopback
231
+ if (lower === '::1') return true;
232
+ // Unique local: fc00::/7 (fc00:: through fdff::)
233
+ if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
234
+ // Link-local: fe80::/10
235
+ if (lower.startsWith('fe80')) return true;
236
+
237
+ return false;
238
+ }
239
+
140
240
  /**
141
241
  * Validate a Twilio webhook request's X-Twilio-Signature header.
142
242
  *
@@ -186,7 +286,7 @@ async function validateTwilioWebhook(
186
286
  // used to compute the HMAC-SHA1 signature.
187
287
  let publicBaseUrl: string | undefined;
188
288
  try {
189
- publicBaseUrl = getWebhookBaseUrl(loadConfig());
289
+ publicBaseUrl = getPublicBaseUrl(loadConfig());
190
290
  } catch {
191
291
  // No webhook base URL configured — fall back to using req.url as-is
192
292
  }
@@ -246,6 +346,11 @@ export class RuntimeHttpServer {
246
346
  this.interfacesDir = options.interfacesDir ?? null;
247
347
  }
248
348
 
349
+ /** The port the server is actually listening on (resolved after start). */
350
+ get actualPort(): number {
351
+ return this.server?.port ?? this.port;
352
+ }
353
+
249
354
  async start(): Promise<void> {
250
355
  this.server = Bun.serve<RelayWebSocketData>({
251
356
  port: this.port,
@@ -289,7 +394,20 @@ export class RuntimeHttpServer {
289
394
  }, 30_000);
290
395
  }
291
396
 
292
- log.info({ port: this.port, hostname: this.hostname, auth: !!this.bearerToken }, 'Runtime HTTP server listening');
397
+ // Startup guard: log gateway-only mode warnings
398
+ try {
399
+ const config = loadConfig();
400
+ if (config.ingress.mode === 'gateway_only') {
401
+ log.info('Running in gateway-only ingress mode. Direct webhook routes disabled.');
402
+ if (!isLoopbackHost(this.hostname)) {
403
+ 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.');
404
+ }
405
+ }
406
+ } catch {
407
+ // Config loading may fail during startup — don't block server start
408
+ }
409
+
410
+ log.info({ port: this.actualPort, hostname: this.hostname, auth: !!this.bearerToken }, 'Runtime HTTP server listening');
293
411
  }
294
412
 
295
413
  async stop(): Promise<void> {
@@ -327,6 +445,18 @@ export class RuntimeHttpServer {
327
445
  // WebSocket upgrade for ConversationRelay — before auth check because
328
446
  // Twilio WebSocket connections don't use bearer tokens.
329
447
  if (path.startsWith('/v1/calls/relay') && req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
448
+ // In gateway_only mode, only allow relay connections from private network peers.
449
+ // Primary check: actual peer address (cannot be spoofed) — accepts loopback
450
+ // and RFC 1918/4193 private addresses to support container deployments.
451
+ // Secondary check: Origin header (defense in depth).
452
+ const config = loadConfig();
453
+ if (config.ingress.mode === 'gateway_only' && (!isPrivateNetworkPeer(server, req) || !isPrivateNetworkOrigin(req))) {
454
+ return Response.json(
455
+ { error: 'Direct relay access disabled in gateway-only mode', code: 'GATEWAY_ONLY' },
456
+ { status: 403 },
457
+ );
458
+ }
459
+
330
460
  const wsUrl = new URL(req.url);
331
461
  const callSessionId = wsUrl.searchParams.get('callSessionId');
332
462
  if (!callSessionId) {
@@ -356,6 +486,15 @@ export class RuntimeHttpServer {
356
486
  if (resolvedTwilioSubpath && req.method === 'POST') {
357
487
  const twilioSubpath = resolvedTwilioSubpath;
358
488
 
489
+ // In gateway_only mode, block direct Twilio webhook routes
490
+ const ingressConfig = loadConfig();
491
+ if (ingressConfig.ingress.mode === 'gateway_only' && GATEWAY_ONLY_BLOCKED_SUBPATHS.has(twilioSubpath)) {
492
+ return Response.json(
493
+ { error: 'Direct webhook access disabled in gateway-only mode. Use the gateway.', code: 'GATEWAY_ONLY' },
494
+ { status: 410 },
495
+ );
496
+ }
497
+
359
498
  // Validate Twilio request signature before dispatching
360
499
  const validation = await validateTwilioWebhook(req);
361
500
  if (validation instanceof Response) return validation;
@@ -519,8 +658,8 @@ export class RuntimeHttpServer {
519
658
  return await handleCreateRun(req, this.runOrchestrator);
520
659
  }
521
660
 
522
- // Match runs/:runId, runs/:runId/decision, runs/:runId/trust-rule
523
- const runsMatch = endpoint.match(/^runs\/([^/]+)(\/decision|\/trust-rule)?$/);
661
+ // Match runs/:runId, runs/:runId/decision, runs/:runId/trust-rule, runs/:runId/secret
662
+ const runsMatch = endpoint.match(/^runs\/([^/]+)(\/decision|\/trust-rule|\/secret)?$/);
524
663
  if (runsMatch) {
525
664
  if (!this.runOrchestrator) {
526
665
  return Response.json({ error: 'Run orchestration not configured' }, { status: 503 });
@@ -529,6 +668,9 @@ export class RuntimeHttpServer {
529
668
  if (runsMatch[2] === '/decision' && req.method === 'POST') {
530
669
  return await handleRunDecision(runId, req, this.runOrchestrator);
531
670
  }
671
+ if (runsMatch[2] === '/secret' && req.method === 'POST') {
672
+ return await handleRunSecret(runId, req, this.runOrchestrator);
673
+ }
532
674
  if (runsMatch[2] === '/trust-rule' && req.method === 'POST') {
533
675
  const run = this.runOrchestrator.getRun(runId);
534
676
  if (!run) {
@@ -551,7 +693,7 @@ export class RuntimeHttpServer {
551
693
  }
552
694
 
553
695
  if (endpoint === 'channels/inbound' && req.method === 'POST') {
554
- return await handleChannelInbound(req, this.processMessage);
696
+ return await handleChannelInbound(req, this.processMessage, this.bearerToken);
555
697
  }
556
698
 
557
699
  if (endpoint === 'channels/delivery-ack' && req.method === 'POST') {
@@ -628,6 +770,31 @@ export class RuntimeHttpServer {
628
770
  return await handleConnectAction(fakeReq);
629
771
  }
630
772
 
773
+ if (endpoint === 'events' && req.method === 'GET') {
774
+ return handleSubscribeAssistantEvents(req, url);
775
+ }
776
+
777
+ // ── Internal OAuth callback endpoint (gateway → runtime) ──
778
+ if (endpoint === 'internal/oauth/callback' && req.method === 'POST') {
779
+ const json = await req.json() as { state: string; code?: string; error?: string };
780
+ if (!json.state) {
781
+ return Response.json({ error: 'Missing state parameter' }, { status: 400 });
782
+ }
783
+ if (json.error) {
784
+ const consumed = consumeCallbackError(json.state, json.error);
785
+ return consumed
786
+ ? Response.json({ ok: true })
787
+ : Response.json({ error: 'Unknown state' }, { status: 404 });
788
+ }
789
+ if (json.code) {
790
+ const consumed = consumeCallback(json.state, json.code);
791
+ return consumed
792
+ ? Response.json({ ok: true })
793
+ : Response.json({ error: 'Unknown state' }, { status: 404 });
794
+ }
795
+ return Response.json({ error: 'Missing code or error parameter' }, { status: 400 });
796
+ }
797
+
631
798
  return Response.json({ error: 'Not found', source: 'runtime' }, { status: 404 });
632
799
  } catch (err) {
633
800
  if (err instanceof IngressBlockedError) {
@@ -751,7 +918,7 @@ export class RuntimeHttpServer {
751
918
  chatId: externalChatId,
752
919
  text: rendered.text || undefined,
753
920
  attachments: replyAttachments.length > 0 ? replyAttachments : undefined,
754
- });
921
+ }, this.bearerToken);
755
922
  }
756
923
  break;
757
924
  }
@@ -42,6 +42,7 @@ export async function handleDeleteConversation(req: Request): Promise<Response>
42
42
  export async function handleChannelInbound(
43
43
  req: Request,
44
44
  processMessage?: MessageProcessor,
45
+ bearerToken?: string,
45
46
  ): Promise<Response> {
46
47
  const body = await req.json() as {
47
48
  sourceChannel?: string;
@@ -229,6 +230,7 @@ export async function handleChannelInbound(
229
230
  metadataHints,
230
231
  metadataUxBrief,
231
232
  replyCallbackUrl,
233
+ bearerToken,
232
234
  });
233
235
  }
234
236
 
@@ -250,6 +252,7 @@ interface BackgroundProcessingParams {
250
252
  metadataHints: string[];
251
253
  metadataUxBrief?: string;
252
254
  replyCallbackUrl?: string;
255
+ bearerToken?: string;
253
256
  }
254
257
 
255
258
  function processChannelMessageInBackground(params: BackgroundProcessingParams): void {
@@ -264,6 +267,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
264
267
  metadataHints,
265
268
  metadataUxBrief,
266
269
  replyCallbackUrl,
270
+ bearerToken,
267
271
  } = params;
268
272
 
269
273
  (async () => {
@@ -285,7 +289,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
285
289
  channelDeliveryStore.markProcessed(eventId);
286
290
 
287
291
  if (replyCallbackUrl) {
288
- await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl);
292
+ await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
289
293
  }
290
294
  } catch (err) {
291
295
  log.error({ err, conversationId }, 'Background channel message processing failed');
@@ -298,6 +302,7 @@ async function deliverReplyViaCallback(
298
302
  conversationId: string,
299
303
  externalChatId: string,
300
304
  callbackUrl: string,
305
+ bearerToken?: string,
301
306
  ): Promise<void> {
302
307
  const msgs = conversationStore.getMessages(conversationId);
303
308
  for (let i = msgs.length - 1; i >= 0; i--) {
@@ -320,7 +325,7 @@ async function deliverReplyViaCallback(
320
325
  chatId: externalChatId,
321
326
  text: rendered.text || undefined,
322
327
  attachments: replyAttachments.length > 0 ? replyAttachments : undefined,
323
- });
328
+ }, bearerToken);
324
329
  }
325
330
  break;
326
331
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Route handler for the assistant-events SSE endpoint.
3
+ *
4
+ * GET /v1/events?conversationKey=...
5
+ *
6
+ * Auth is enforced by RuntimeHttpServer before this handler is called.
7
+ * Subscribers receive all assistant events scoped to the given conversation.
8
+ */
9
+
10
+ import { getOrCreateConversation } from '../../memory/conversation-key-store.js';
11
+ import { assistantEventHub } from '../assistant-event-hub.js';
12
+ import { formatSseFrame } from '../assistant-event.js';
13
+ import type { AssistantEventSubscription } from '../assistant-event-hub.js';
14
+
15
+ /**
16
+ * Stream assistant events as Server-Sent Events for a specific conversation.
17
+ *
18
+ * Query params:
19
+ * conversationKey — required; scopes the stream to one conversation.
20
+ */
21
+ export function handleSubscribeAssistantEvents(
22
+ req: Request,
23
+ url: URL,
24
+ ): Response {
25
+ const conversationKey = url.searchParams.get('conversationKey');
26
+ if (!conversationKey) {
27
+ return Response.json({ error: 'conversationKey is required' }, { status: 400 });
28
+ }
29
+
30
+ const mapping = getOrCreateConversation(conversationKey);
31
+ const encoder = new TextEncoder();
32
+ let sub: AssistantEventSubscription | null = null;
33
+
34
+ // Allow up to 16 queued frames before treating the consumer as stalled.
35
+ // This absorbs normal token-stream bursts without prematurely closing the
36
+ // connection, while still shedding genuinely slow clients.
37
+ const stream = new ReadableStream({
38
+ start(controller) {
39
+ // 'self' is the assistantId that RunOrchestrator assigns to all HTTP-run events
40
+ // (see buildAssistantEvent('self', ...) in run-orchestrator.ts). This endpoint
41
+ // is part of the HTTP runtime API, so only HTTP-run events are relevant here.
42
+ // IPC/daemon events use a different assistantId ('default') and reach desktop
43
+ // clients through a separate channel — they are intentionally excluded.
44
+ sub = assistantEventHub.subscribe(
45
+ { assistantId: 'self', sessionId: mapping.conversationId },
46
+ (event) => {
47
+ try {
48
+ // Shed stalled consumers: desiredSize <= 0 means the 16-event buffer
49
+ // is full and the client isn't draining it.
50
+ if (controller.desiredSize !== null && controller.desiredSize <= 0) {
51
+ sub?.dispose();
52
+ try { controller.close(); } catch { /* already closed */ }
53
+ return;
54
+ }
55
+ controller.enqueue(encoder.encode(formatSseFrame(event)));
56
+ } catch {
57
+ sub?.dispose();
58
+ }
59
+ },
60
+ );
61
+
62
+ req.signal.addEventListener('abort', () => {
63
+ sub?.dispose();
64
+ try { controller.close(); } catch { /* already closed */ }
65
+ }, { once: true });
66
+ },
67
+ cancel() {
68
+ sub?.dispose();
69
+ },
70
+ }, new CountQueuingStrategy({ highWaterMark: 16 }));
71
+
72
+ return new Response(stream, {
73
+ headers: {
74
+ 'Content-Type': 'text/event-stream',
75
+ 'Cache-Control': 'no-cache',
76
+ 'Connection': 'keep-alive',
77
+ },
78
+ });
79
+ }
@@ -88,6 +88,7 @@ export function handleGetRun(
88
88
  status: run.status,
89
89
  messageId: run.messageId,
90
90
  pendingConfirmation: run.pendingConfirmation,
91
+ pendingSecret: run.pendingSecret,
91
92
  error: run.error,
92
93
  createdAt: new Date(run.createdAt).toISOString(),
93
94
  updatedAt: new Date(run.updatedAt).toISOString(),
@@ -217,3 +218,45 @@ export async function handleAddTrustRule(
217
218
  return Response.json({ error: 'Failed to add trust rule' }, { status: 500 });
218
219
  }
219
220
  }
221
+
222
+ export async function handleRunSecret(
223
+ runId: string,
224
+ req: Request,
225
+ runOrchestrator: RunOrchestrator,
226
+ ): Promise<Response> {
227
+ const run = runOrchestrator.getRun(runId);
228
+ if (!run) {
229
+ return Response.json({ error: 'Run not found' }, { status: 404 });
230
+ }
231
+
232
+ const body = await req.json() as {
233
+ value?: string;
234
+ delivery?: string;
235
+ };
236
+
237
+ const { value, delivery } = body;
238
+
239
+ if (delivery !== undefined && delivery !== 'store' && delivery !== 'transient_send') {
240
+ return Response.json(
241
+ { error: 'delivery must be "store" or "transient_send"' },
242
+ { status: 400 },
243
+ );
244
+ }
245
+
246
+ const result = runOrchestrator.submitSecret(
247
+ runId,
248
+ value,
249
+ delivery as 'store' | 'transient_send' | undefined,
250
+ );
251
+ if (result === 'run_not_found') {
252
+ return Response.json({ error: 'Run not found' }, { status: 404 });
253
+ }
254
+ if (result === 'no_pending_secret') {
255
+ return Response.json(
256
+ { error: 'No secret pending for this run' },
257
+ { status: 409 },
258
+ );
259
+ }
260
+
261
+ return Response.json({ accepted: true });
262
+ }