vellum 0.2.8 → 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 (55) hide show
  1. package/bun.lock +2 -2
  2. package/package.json +3 -2
  3. package/src/__tests__/config-schema.test.ts +0 -6
  4. package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
  5. package/src/__tests__/gateway-only-enforcement.test.ts +91 -11
  6. package/src/__tests__/ingress-url-consistency.test.ts +214 -0
  7. package/src/__tests__/ipc-snapshot.test.ts +17 -16
  8. package/src/__tests__/oauth2-gateway-transport.test.ts +7 -1
  9. package/src/__tests__/public-ingress-urls.test.ts +50 -34
  10. package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
  11. package/src/__tests__/runtime-events-sse.test.ts +162 -0
  12. package/src/__tests__/twilio-provider.test.ts +1 -1
  13. package/src/__tests__/twilio-routes.test.ts +4 -4
  14. package/src/__tests__/twitter-auth-handler.test.ts +87 -2
  15. package/src/calls/call-domain.ts +8 -6
  16. package/src/calls/twilio-config.ts +2 -3
  17. package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
  18. package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
  19. package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
  20. package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
  21. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
  22. package/src/config/defaults.ts +1 -2
  23. package/src/config/schema.ts +2 -6
  24. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
  25. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
  26. package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
  27. package/src/daemon/handlers/config.ts +33 -50
  28. package/src/daemon/handlers/shared.ts +1 -0
  29. package/src/daemon/handlers/subagents.ts +85 -2
  30. package/src/daemon/handlers/twitter-auth.ts +31 -2
  31. package/src/daemon/ipc-contract-inventory.json +4 -4
  32. package/src/daemon/ipc-contract.ts +25 -21
  33. package/src/daemon/lifecycle.ts +9 -4
  34. package/src/daemon/server.ts +7 -0
  35. package/src/daemon/session-tool-setup.ts +1 -1
  36. package/src/inbound/public-ingress-urls.ts +36 -30
  37. package/src/memory/db.ts +132 -5
  38. package/src/memory/llm-usage-store.ts +0 -1
  39. package/src/memory/runs-store.ts +51 -3
  40. package/src/memory/schema.ts +2 -2
  41. package/src/runtime/gateway-client.ts +7 -1
  42. package/src/runtime/http-server.ts +95 -10
  43. package/src/runtime/routes/channel-routes.ts +7 -2
  44. package/src/runtime/routes/events-routes.ts +79 -0
  45. package/src/runtime/routes/run-routes.ts +43 -0
  46. package/src/runtime/run-orchestrator.ts +64 -7
  47. package/src/security/oauth-callback-registry.ts +10 -0
  48. package/src/security/oauth2.ts +41 -7
  49. package/src/subagent/manager.ts +3 -1
  50. package/src/tools/tasks/work-item-run.ts +78 -0
  51. package/src/util/platform.ts +1 -1
  52. package/src/work-items/work-item-runner.ts +171 -0
  53. package/src/__tests__/handlers-twilio-config.test.ts +0 -221
  54. package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
  55. package/src/calls/twilio-webhook-urls.ts +0 -47
@@ -1,19 +1,33 @@
1
1
  /**
2
2
  * Centralized URL builders for all public-facing ingress endpoints.
3
3
  *
4
- * Resolves the canonical public base URL via a fallback chain:
5
- * ingress.publicBaseUrl → calls.webhookBaseUrl → env TWILIO_WEBHOOK_BASE_URL
4
+ * ## Source-of-truth precedence
6
5
  *
7
- * Supersedes the per-domain URL helpers in calls/twilio-webhook-urls.ts.
6
+ * The canonical public base URL is resolved through a two-level chain:
7
+ *
8
+ * 1. **User Settings** (`config.ingress.publicBaseUrl`) — set via the
9
+ * Settings UI or `config set ingress.publicBaseUrl`. This is the
10
+ * primary source of truth. When the assistant spawns or restarts
11
+ * the gateway, this value is forwarded as the `INGRESS_PUBLIC_BASE_URL`
12
+ * environment variable so both processes agree on the same URL.
13
+ *
14
+ * 2. **Environment variable** (`INGRESS_PUBLIC_BASE_URL`) — serves as a
15
+ * fallback for operational use (e.g. direct gateway-only deployments
16
+ * without the assistant, or CI overrides). When the assistant is
17
+ * managing the gateway, the env var is set automatically from (1).
18
+ *
19
+ * This chain ensures that:
20
+ * - The assistant's outbound callback URLs (Twilio webhooks, OAuth
21
+ * redirect URIs, etc.) match the gateway's inbound signature
22
+ * reconstruction URL.
23
+ * - Changing the URL in Settings propagates to the gateway on restart,
24
+ * eliminating Twilio signature mismatch risk.
25
+ *
26
+ * All public-facing ingress URL construction is centralized here.
8
27
  */
9
28
 
10
- import { getLogger } from '../util/logger.js';
11
-
12
- const log = getLogger('public-ingress-urls');
13
-
14
29
  export interface IngressConfig {
15
30
  ingress?: { publicBaseUrl?: string };
16
- calls?: { webhookBaseUrl?: string };
17
31
  }
18
32
 
19
33
  /**
@@ -24,10 +38,8 @@ function normalizeUrl(url: string): string {
24
38
  }
25
39
 
26
40
  /**
27
- * Resolve the canonical public base URL with a three-level fallback chain:
28
- * 1. ingress.publicBaseUrl (preferred)
29
- * 2. calls.webhookBaseUrl (backward compat)
30
- * 3. TWILIO_WEBHOOK_BASE_URL env var (legacy, deprecated)
41
+ * Resolve the canonical public base URL using the precedence chain
42
+ * documented at the top of this module.
31
43
  *
32
44
  * Throws if no source provides a non-empty value.
33
45
  */
@@ -38,28 +50,14 @@ export function getPublicBaseUrl(config: IngressConfig): string {
38
50
  if (normalized) return normalized;
39
51
  }
40
52
 
41
- const callsValue = config.calls?.webhookBaseUrl;
42
- if (callsValue) {
43
- const normalized = normalizeUrl(callsValue);
44
- if (normalized) {
45
- log.warn(
46
- 'Using calls.webhookBaseUrl as public base URL — set ingress.publicBaseUrl instead.',
47
- );
48
- return normalized;
49
- }
50
- }
51
-
52
- const envValue = process.env.TWILIO_WEBHOOK_BASE_URL;
53
- if (envValue) {
54
- log.warn(
55
- 'TWILIO_WEBHOOK_BASE_URL env var is deprecated — set ingress.publicBaseUrl in config instead.',
56
- );
57
- const normalized = normalizeUrl(envValue);
53
+ const ingressEnvValue = process.env.INGRESS_PUBLIC_BASE_URL;
54
+ if (ingressEnvValue) {
55
+ const normalized = normalizeUrl(ingressEnvValue);
58
56
  if (normalized) return normalized;
59
57
  }
60
58
 
61
59
  throw new Error(
62
- 'No public base URL configured. Set ingress.publicBaseUrl in config, calls.webhookBaseUrl, or TWILIO_WEBHOOK_BASE_URL env var.',
60
+ 'No public base URL configured. Set ingress.publicBaseUrl in config or INGRESS_PUBLIC_BASE_URL env var.',
63
61
  );
64
62
  }
65
63
 
@@ -104,3 +102,11 @@ export function getOAuthCallbackUrl(config: IngressConfig): string {
104
102
  const base = getPublicBaseUrl(config);
105
103
  return `${base}/webhooks/oauth/callback`;
106
104
  }
105
+
106
+ /**
107
+ * Build the Telegram webhook URL.
108
+ */
109
+ export function getTelegramWebhookUrl(config: IngressConfig): string {
110
+ const base = getPublicBaseUrl(config);
111
+ return `${base}/webhooks/telegram`;
112
+ }
package/src/memory/db.ts CHANGED
@@ -241,6 +241,7 @@ export function initializeDb(): void {
241
241
  message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
242
242
  status TEXT NOT NULL DEFAULT 'running',
243
243
  pending_confirmation TEXT,
244
+ pending_secret TEXT,
244
245
  input_tokens INTEGER NOT NULL DEFAULT 0,
245
246
  output_tokens INTEGER NOT NULL DEFAULT 0,
246
247
  estimated_cost REAL NOT NULL DEFAULT 0,
@@ -250,6 +251,8 @@ export function initializeDb(): void {
250
251
  )
251
252
  `);
252
253
 
254
+ try { database.run(/*sql*/ `ALTER TABLE message_runs ADD COLUMN pending_secret TEXT`); } catch (e) { log.debug({ err: e }, 'ALTER TABLE message_runs ADD COLUMN pending_secret (likely already exists)'); }
255
+
253
256
  database.run(/*sql*/ `
254
257
  CREATE TABLE IF NOT EXISTS reminders (
255
258
  id TEXT PRIMARY KEY,
@@ -418,7 +421,6 @@ export function initializeDb(): void {
418
421
  CREATE TABLE IF NOT EXISTS llm_usage_events (
419
422
  id TEXT PRIMARY KEY,
420
423
  created_at INTEGER NOT NULL,
421
- assistant_id TEXT,
422
424
  conversation_id TEXT,
423
425
  run_id TEXT,
424
426
  request_id TEXT,
@@ -548,6 +550,7 @@ export function initializeDb(): void {
548
550
  migrateMemoryItemsScopeSaltedFingerprints(database);
549
551
  migrateAssistantIdToSelf(database);
550
552
  migrateRemoveAssistantIdColumns(database);
553
+ migrateLlmUsageEventsDropAssistantId(database);
551
554
 
552
555
  // Indexes for query performance on large datasets
553
556
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_request_logs_conv_created ON llm_request_logs(conversation_id, created_at)`);
@@ -610,7 +613,6 @@ export function initializeDb(): void {
610
613
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status)`);
611
614
 
612
615
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_created_at ON llm_usage_events(created_at)`);
613
- database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_assistant_id ON llm_usage_events(assistant_id)`);
614
616
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_provider ON llm_usage_events(provider)`);
615
617
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_model ON llm_usage_events(model)`);
616
618
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_actor ON llm_usage_events(actor)`);
@@ -1438,19 +1440,22 @@ function migrateAssistantIdToSelf(database: ReturnType<typeof drizzle<typeof sch
1438
1440
  }
1439
1441
 
1440
1442
  /**
1441
- * One-shot migration: rebuild the four tables that previously stored assistant_id
1442
- * to remove that column now that all rows are keyed to the implicit single-tenant
1443
- * identity ("self").
1443
+ * One-shot migration: rebuild tables that previously stored assistant_id to remove
1444
+ * that column now that all rows are keyed to the implicit single-tenant identity ("self").
1444
1445
  *
1445
1446
  * Must run AFTER migrateAssistantIdToSelf (which normalises all values to "self")
1446
1447
  * so there are no constraint violations when recreating the tables without the
1447
1448
  * assistant_id dimension.
1448
1449
  *
1450
+ * Each table section is guarded by a DDL check so this is safe on fresh installs
1451
+ * where the column was never created in the first place.
1452
+ *
1449
1453
  * Tables rebuilt:
1450
1454
  * - conversation_keys UNIQUE (conversation_key)
1451
1455
  * - attachments no structural unique; content-dedup index updated
1452
1456
  * - channel_inbound_events UNIQUE (source_channel, external_chat_id, external_message_id)
1453
1457
  * - message_runs no unique constraint on assistant_id
1458
+ * - llm_usage_events nullable column with no constraint
1454
1459
  */
1455
1460
  function migrateRemoveAssistantIdColumns(database: ReturnType<typeof drizzle<typeof schema>>): void {
1456
1461
  const raw = (database as unknown as { $client: Database }).$client;
@@ -1588,6 +1593,128 @@ function migrateRemoveAssistantIdColumns(database: ReturnType<typeof drizzle<typ
1588
1593
  raw.exec(/*sql*/ `ALTER TABLE message_runs_new RENAME TO message_runs`);
1589
1594
  }
1590
1595
 
1596
+ // --- llm_usage_events ---
1597
+ const lueDdl = raw.query(
1598
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'llm_usage_events'`,
1599
+ ).get() as { sql: string } | null;
1600
+ if (lueDdl?.sql.includes('assistant_id')) {
1601
+ raw.exec(/*sql*/ `
1602
+ CREATE TABLE llm_usage_events_new (
1603
+ id TEXT PRIMARY KEY,
1604
+ created_at INTEGER NOT NULL,
1605
+ conversation_id TEXT,
1606
+ run_id TEXT,
1607
+ request_id TEXT,
1608
+ actor TEXT NOT NULL,
1609
+ provider TEXT NOT NULL,
1610
+ model TEXT NOT NULL,
1611
+ input_tokens INTEGER NOT NULL,
1612
+ output_tokens INTEGER NOT NULL,
1613
+ cache_creation_input_tokens INTEGER,
1614
+ cache_read_input_tokens INTEGER,
1615
+ estimated_cost_usd REAL,
1616
+ pricing_status TEXT NOT NULL,
1617
+ metadata_json TEXT
1618
+ )
1619
+ `);
1620
+ raw.exec(/*sql*/ `
1621
+ INSERT INTO llm_usage_events_new (
1622
+ id, created_at, conversation_id, run_id, request_id, actor, provider, model,
1623
+ input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
1624
+ estimated_cost_usd, pricing_status, metadata_json
1625
+ )
1626
+ SELECT
1627
+ id, created_at, conversation_id, run_id, request_id, actor, provider, model,
1628
+ input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
1629
+ estimated_cost_usd, pricing_status, metadata_json
1630
+ FROM llm_usage_events
1631
+ `);
1632
+ raw.exec(/*sql*/ `DROP TABLE llm_usage_events`);
1633
+ raw.exec(/*sql*/ `ALTER TABLE llm_usage_events_new RENAME TO llm_usage_events`);
1634
+ }
1635
+
1636
+ raw.query(
1637
+ `INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
1638
+ ).run(checkpointKey, Date.now());
1639
+
1640
+ raw.exec('COMMIT');
1641
+ } catch (e) {
1642
+ try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
1643
+ throw e;
1644
+ } finally {
1645
+ raw.exec('PRAGMA foreign_keys = ON');
1646
+ }
1647
+ }
1648
+
1649
+ /**
1650
+ * One-shot migration: rebuild llm_usage_events to drop the assistant_id column.
1651
+ *
1652
+ * This is a SEPARATE migration from migrateRemoveAssistantIdColumns so that installs
1653
+ * where the 4-table version of that migration already ran (checkpoint already set)
1654
+ * still get the llm_usage_events column removed. Without a separate checkpoint key,
1655
+ * those installs would skip the llm_usage_events rebuild entirely.
1656
+ *
1657
+ * Safe on fresh installs (DDL guard exits early) and idempotent via checkpoint.
1658
+ */
1659
+ function migrateLlmUsageEventsDropAssistantId(database: ReturnType<typeof drizzle<typeof schema>>): void {
1660
+ const raw = (database as unknown as { $client: Database }).$client;
1661
+ const checkpointKey = 'migration_remove_assistant_id_lue_v1';
1662
+ const checkpoint = raw.query(
1663
+ `SELECT 1 FROM memory_checkpoints WHERE key = ?`,
1664
+ ).get(checkpointKey);
1665
+ if (checkpoint) return;
1666
+
1667
+ // DDL guard: if the column was already removed (fresh install or migrateRemoveAssistantIdColumns
1668
+ // ran with the llm_usage_events block), just record the checkpoint and exit.
1669
+ const lueDdl = raw.query(
1670
+ `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'llm_usage_events'`,
1671
+ ).get() as { sql: string } | null;
1672
+
1673
+ if (!lueDdl?.sql.includes('assistant_id')) {
1674
+ raw.query(
1675
+ `INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
1676
+ ).run(checkpointKey, Date.now());
1677
+ return;
1678
+ }
1679
+
1680
+ raw.exec('PRAGMA foreign_keys = OFF');
1681
+ try {
1682
+ raw.exec('BEGIN');
1683
+
1684
+ raw.exec(/*sql*/ `
1685
+ CREATE TABLE llm_usage_events_new (
1686
+ id TEXT PRIMARY KEY,
1687
+ created_at INTEGER NOT NULL,
1688
+ conversation_id TEXT,
1689
+ run_id TEXT,
1690
+ request_id TEXT,
1691
+ actor TEXT NOT NULL,
1692
+ provider TEXT NOT NULL,
1693
+ model TEXT NOT NULL,
1694
+ input_tokens INTEGER NOT NULL,
1695
+ output_tokens INTEGER NOT NULL,
1696
+ cache_creation_input_tokens INTEGER,
1697
+ cache_read_input_tokens INTEGER,
1698
+ estimated_cost_usd REAL,
1699
+ pricing_status TEXT NOT NULL,
1700
+ metadata_json TEXT
1701
+ )
1702
+ `);
1703
+ raw.exec(/*sql*/ `
1704
+ INSERT INTO llm_usage_events_new (
1705
+ id, created_at, conversation_id, run_id, request_id, actor, provider, model,
1706
+ input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
1707
+ estimated_cost_usd, pricing_status, metadata_json
1708
+ )
1709
+ SELECT
1710
+ id, created_at, conversation_id, run_id, request_id, actor, provider, model,
1711
+ input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
1712
+ estimated_cost_usd, pricing_status, metadata_json
1713
+ FROM llm_usage_events
1714
+ `);
1715
+ raw.exec(/*sql*/ `DROP TABLE llm_usage_events`);
1716
+ raw.exec(/*sql*/ `ALTER TABLE llm_usage_events_new RENAME TO llm_usage_events`);
1717
+
1591
1718
  raw.query(
1592
1719
  `INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
1593
1720
  ).run(checkpointKey, Date.now());
@@ -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,12 +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
51
  conversationId: string;
40
52
  messageId: string | null;
41
53
  status: RunStatus;
42
54
  pendingConfirmation: PendingConfirmation | null;
55
+ pendingSecret: PendingSecret | null;
43
56
  inputTokens: number;
44
57
  outputTokens: number;
45
58
  estimatedCost: number;
@@ -63,12 +76,17 @@ function rowToRun(row: typeof messageRuns.$inferSelect): Run {
63
76
  if (row.pendingConfirmation) {
64
77
  try { pendingConfirmation = JSON.parse(row.pendingConfirmation); } catch { /* malformed */ }
65
78
  }
79
+ let pendingSecret: PendingSecret | null = null;
80
+ if (row.pendingSecret) {
81
+ try { pendingSecret = JSON.parse(row.pendingSecret); } catch { /* malformed */ }
82
+ }
66
83
  return {
67
84
  id: row.id,
68
85
  conversationId: row.conversationId,
69
86
  messageId: row.messageId,
70
87
  status: row.status as RunStatus,
71
88
  pendingConfirmation,
89
+ pendingSecret,
72
90
  inputTokens: row.inputTokens,
73
91
  outputTokens: row.outputTokens,
74
92
  estimatedCost: row.estimatedCost,
@@ -96,6 +114,7 @@ export function createRun(
96
114
  messageId: messageId ?? null,
97
115
  status: 'running' as const,
98
116
  pendingConfirmation: null,
117
+ pendingSecret: null,
99
118
  inputTokens: 0,
100
119
  outputTokens: 0,
101
120
  estimatedCost: 0,
@@ -144,6 +163,35 @@ export function clearRunConfirmation(runId: string): void {
144
163
  .run();
145
164
  }
146
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
+
147
195
  export function completeRun(runId: string, usage?: RunUsage): void {
148
196
  const db = getDb();
149
197
  const now = Date.now();
@@ -177,13 +225,13 @@ export function failRun(runId: string, error: string): void {
177
225
  /**
178
226
  * Mark all non-terminal runs as failed.
179
227
  * Called on startup to recover from daemon restarts that left runs
180
- * 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.
181
229
  * Returns the number of rows affected.
182
230
  */
183
231
  export function failOrphanedRuns(): number {
184
232
  const db = getDb();
185
233
  const now = Date.now();
186
- const activeStatuses = ['running', 'needs_confirmation'];
234
+ const activeStatuses = ['running', 'needs_confirmation', 'needs_secret'];
187
235
 
188
236
  // Count first so we can report how many were recovered.
189
237
  const active = db.select({ id: messageRuns.id })
@@ -209,8 +209,9 @@ export const messageRuns = sqliteTable('message_runs', {
209
209
  .references(() => conversations.id, { onDelete: 'cascade' }),
210
210
  messageId: text('message_id')
211
211
  .references(() => messages.id, { onDelete: 'cascade' }),
212
- 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
213
213
  pendingConfirmation: text('pending_confirmation'), // JSON when status=needs_confirmation
214
+ pendingSecret: text('pending_secret'), // JSON when status=needs_secret
214
215
  inputTokens: integer('input_tokens').notNull().default(0),
215
216
  outputTokens: integer('output_tokens').notNull().default(0),
216
217
  estimatedCost: real('estimated_cost').notNull().default(0),
@@ -519,7 +520,6 @@ export const llmRequestLogs = sqliteTable('llm_request_logs', {
519
520
  export const llmUsageEvents = sqliteTable('llm_usage_events', {
520
521
  id: text('id').primaryKey(),
521
522
  createdAt: integer('created_at').notNull(),
522
- assistantId: text('assistant_id'),
523
523
  conversationId: text('conversation_id'),
524
524
  runId: text('run_id'),
525
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
  });
@@ -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,7 @@ 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';
68
70
  import { consumeCallback, consumeCallbackError } from '../security/oauth-callback-registry.js';
69
71
 
70
72
  // Re-export shared types so existing consumers don't need to update imports
@@ -145,16 +147,21 @@ const GATEWAY_SUBPATH_MAP: Record<string, string> = {
145
147
  const GATEWAY_ONLY_BLOCKED_SUBPATHS = new Set(['voice-webhook', 'status', 'connect-action']);
146
148
 
147
149
  /**
148
- * Check if a request origin is from localhost / loopback.
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.
149
153
  */
150
- function isLoopbackOrigin(req: Request): boolean {
154
+ function isPrivateNetworkOrigin(req: Request): boolean {
151
155
  const origin = req.headers.get('origin');
152
156
  // No origin header (e.g., server-initiated or same-origin) — allow
153
157
  if (!origin) return true;
154
158
  try {
155
159
  const url = new URL(origin);
156
160
  const host = url.hostname;
157
- return host === '127.0.0.1' || host === '::1' || host === 'localhost';
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);
158
165
  } catch {
159
166
  return false;
160
167
  }
@@ -167,6 +174,69 @@ function isLoopbackHost(hostname: string): boolean {
167
174
  return hostname === '127.0.0.1' || hostname === '::1' || hostname === 'localhost';
168
175
  }
169
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
+
170
240
  /**
171
241
  * Validate a Twilio webhook request's X-Twilio-Signature header.
172
242
  *
@@ -276,6 +346,11 @@ export class RuntimeHttpServer {
276
346
  this.interfacesDir = options.interfacesDir ?? null;
277
347
  }
278
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
+
279
354
  async start(): Promise<void> {
280
355
  this.server = Bun.serve<RelayWebSocketData>({
281
356
  port: this.port,
@@ -332,7 +407,7 @@ export class RuntimeHttpServer {
332
407
  // Config loading may fail during startup — don't block server start
333
408
  }
334
409
 
335
- log.info({ port: this.port, hostname: this.hostname, auth: !!this.bearerToken }, 'Runtime HTTP server listening');
410
+ log.info({ port: this.actualPort, hostname: this.hostname, auth: !!this.bearerToken }, 'Runtime HTTP server listening');
336
411
  }
337
412
 
338
413
  async stop(): Promise<void> {
@@ -370,9 +445,12 @@ export class RuntimeHttpServer {
370
445
  // WebSocket upgrade for ConversationRelay — before auth check because
371
446
  // Twilio WebSocket connections don't use bearer tokens.
372
447
  if (path.startsWith('/v1/calls/relay') && req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
373
- // In gateway_only mode, only allow relay connections from localhost
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).
374
452
  const config = loadConfig();
375
- if (config.ingress.mode === 'gateway_only' && !isLoopbackOrigin(req)) {
453
+ if (config.ingress.mode === 'gateway_only' && (!isPrivateNetworkPeer(server, req) || !isPrivateNetworkOrigin(req))) {
376
454
  return Response.json(
377
455
  { error: 'Direct relay access disabled in gateway-only mode', code: 'GATEWAY_ONLY' },
378
456
  { status: 403 },
@@ -580,8 +658,8 @@ export class RuntimeHttpServer {
580
658
  return await handleCreateRun(req, this.runOrchestrator);
581
659
  }
582
660
 
583
- // Match runs/:runId, runs/:runId/decision, runs/:runId/trust-rule
584
- 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)?$/);
585
663
  if (runsMatch) {
586
664
  if (!this.runOrchestrator) {
587
665
  return Response.json({ error: 'Run orchestration not configured' }, { status: 503 });
@@ -590,6 +668,9 @@ export class RuntimeHttpServer {
590
668
  if (runsMatch[2] === '/decision' && req.method === 'POST') {
591
669
  return await handleRunDecision(runId, req, this.runOrchestrator);
592
670
  }
671
+ if (runsMatch[2] === '/secret' && req.method === 'POST') {
672
+ return await handleRunSecret(runId, req, this.runOrchestrator);
673
+ }
593
674
  if (runsMatch[2] === '/trust-rule' && req.method === 'POST') {
594
675
  const run = this.runOrchestrator.getRun(runId);
595
676
  if (!run) {
@@ -612,7 +693,7 @@ export class RuntimeHttpServer {
612
693
  }
613
694
 
614
695
  if (endpoint === 'channels/inbound' && req.method === 'POST') {
615
- return await handleChannelInbound(req, this.processMessage);
696
+ return await handleChannelInbound(req, this.processMessage, this.bearerToken);
616
697
  }
617
698
 
618
699
  if (endpoint === 'channels/delivery-ack' && req.method === 'POST') {
@@ -689,6 +770,10 @@ export class RuntimeHttpServer {
689
770
  return await handleConnectAction(fakeReq);
690
771
  }
691
772
 
773
+ if (endpoint === 'events' && req.method === 'GET') {
774
+ return handleSubscribeAssistantEvents(req, url);
775
+ }
776
+
692
777
  // ── Internal OAuth callback endpoint (gateway → runtime) ──
693
778
  if (endpoint === 'internal/oauth/callback' && req.method === 'POST') {
694
779
  const json = await req.json() as { state: string; code?: string; error?: string };
@@ -833,7 +918,7 @@ export class RuntimeHttpServer {
833
918
  chatId: externalChatId,
834
919
  text: rendered.text || undefined,
835
920
  attachments: replyAttachments.length > 0 ? replyAttachments : undefined,
836
- });
921
+ }, this.bearerToken);
837
922
  }
838
923
  break;
839
924
  }