@vellumai/assistant 0.3.4 → 0.3.6

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 (506) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +88 -2
  3. package/eslint.config.mjs +31 -0
  4. package/package.json +1 -1
  5. package/scripts/ipc/check-swift-decoder-drift.ts +4 -1
  6. package/scripts/ipc/generate-swift.ts +31 -2
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +438 -1
  8. package/src/__tests__/approval-conversation-turn.test.ts +214 -0
  9. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  10. package/src/__tests__/approval-message-composer.test.ts +253 -0
  11. package/src/__tests__/browser-manager.test.ts +1 -0
  12. package/src/__tests__/call-conversation-messages.test.ts +130 -0
  13. package/src/__tests__/call-domain.test.ts +12 -2
  14. package/src/__tests__/call-orchestrator.test.ts +799 -249
  15. package/src/__tests__/call-pointer-messages.test.ts +148 -0
  16. package/src/__tests__/call-recovery.test.ts +3 -0
  17. package/src/__tests__/call-routes-http.test.ts +32 -2
  18. package/src/__tests__/call-store.test.ts +3 -0
  19. package/src/__tests__/channel-approval-routes.test.ts +1277 -98
  20. package/src/__tests__/channel-approval.test.ts +37 -0
  21. package/src/__tests__/channel-approvals.test.ts +36 -50
  22. package/src/__tests__/channel-guardian.test.ts +630 -22
  23. package/src/__tests__/channel-readiness-service.test.ts +324 -0
  24. package/src/__tests__/checker.test.ts +14 -7
  25. package/src/__tests__/clarification-resolver.test.ts +44 -24
  26. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -4
  27. package/src/__tests__/computer-use-session-working-dir.test.ts +8 -0
  28. package/src/__tests__/config-schema.test.ts +14 -8
  29. package/src/__tests__/context-window-manager.test.ts +30 -2
  30. package/src/__tests__/contradiction-checker.test.ts +20 -5
  31. package/src/__tests__/credential-security-invariants.test.ts +7 -2
  32. package/src/__tests__/daemon-lifecycle.test.ts +13 -12
  33. package/src/__tests__/db-migration-rollback.test.ts +752 -0
  34. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  35. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +2 -0
  36. package/src/__tests__/entity-search.test.ts +615 -0
  37. package/src/__tests__/fuzzy-match-property.test.ts +5 -5
  38. package/src/__tests__/guardian-action-store.test.ts +123 -0
  39. package/src/__tests__/guardian-action-sweep.test.ts +277 -0
  40. package/src/__tests__/guardian-dispatch.test.ts +389 -0
  41. package/src/__tests__/guardian-question-copy.test.ts +47 -0
  42. package/src/__tests__/handlers-telegram-config.test.ts +4 -2
  43. package/src/__tests__/handlers-twilio-config.test.ts +533 -0
  44. package/src/__tests__/intent-routing.test.ts +2 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +291 -1
  46. package/src/__tests__/memory-upsert-concurrency.test.ts +828 -0
  47. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  48. package/src/__tests__/model-intents.test.ts +96 -0
  49. package/src/__tests__/no-direct-anthropic-sdk-imports.test.ts +42 -0
  50. package/src/__tests__/oauth2-gateway-transport.test.ts +130 -0
  51. package/src/__tests__/onboarding-starter-tasks.test.ts +2 -0
  52. package/src/__tests__/provider-commit-message-generator.test.ts +89 -13
  53. package/src/__tests__/provider-error-scenarios.test.ts +621 -0
  54. package/src/__tests__/provider-fail-open-selection.test.ts +119 -0
  55. package/src/__tests__/qdrant-manager.test.ts +27 -20
  56. package/src/__tests__/relay-server.test.ts +779 -40
  57. package/src/__tests__/run-orchestrator-assistant-events.test.ts +6 -0
  58. package/src/__tests__/run-orchestrator.test.ts +42 -4
  59. package/src/__tests__/runtime-runs-http.test.ts +17 -1
  60. package/src/__tests__/runtime-runs.test.ts +16 -0
  61. package/src/__tests__/schedule-store.test.ts +18 -4
  62. package/src/__tests__/scheduler-recurrence.test.ts +13 -4
  63. package/src/__tests__/session-abort-tool-results.test.ts +6 -0
  64. package/src/__tests__/session-agent-loop.test.ts +857 -0
  65. package/src/__tests__/session-conflict-gate.test.ts +6 -0
  66. package/src/__tests__/session-pre-run-repair.test.ts +6 -0
  67. package/src/__tests__/session-profile-injection.test.ts +6 -0
  68. package/src/__tests__/session-provider-retry-repair.test.ts +6 -0
  69. package/src/__tests__/session-queue.test.ts +6 -0
  70. package/src/__tests__/session-runtime-assembly.test.ts +321 -13
  71. package/src/__tests__/session-slash-known.test.ts +6 -0
  72. package/src/__tests__/session-slash-queue.test.ts +6 -0
  73. package/src/__tests__/session-slash-unknown.test.ts +6 -0
  74. package/src/__tests__/session-surfaces-task-progress.test.ts +2 -0
  75. package/src/__tests__/session-tool-setup-app-refresh.test.ts +1 -0
  76. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -0
  77. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -0
  78. package/src/__tests__/session-workspace-injection.test.ts +6 -0
  79. package/src/__tests__/session-workspace-tool-tracking.test.ts +6 -0
  80. package/src/__tests__/skills.test.ts +2 -0
  81. package/src/__tests__/sms-messaging-provider.test.ts +126 -0
  82. package/src/__tests__/starter-task-flow.test.ts +2 -0
  83. package/src/__tests__/swarm-dag-pathological.test.ts +535 -0
  84. package/src/__tests__/system-prompt.test.ts +2 -0
  85. package/src/__tests__/task-management-tools.test.ts +2 -2
  86. package/src/__tests__/task-runner.test.ts +14 -4
  87. package/src/__tests__/terminal-tools.test.ts +25 -19
  88. package/src/__tests__/tool-execution-abort-cleanup.test.ts +545 -0
  89. package/src/__tests__/tool-executor-shell-integration.test.ts +11 -11
  90. package/src/__tests__/tool-executor.test.ts +23 -24
  91. package/src/__tests__/trust-store.test.ts +3 -3
  92. package/src/__tests__/twilio-rest.test.ts +29 -0
  93. package/src/__tests__/twilio-routes-elevenlabs.test.ts +3 -0
  94. package/src/__tests__/twilio-routes-twiml.test.ts +11 -0
  95. package/src/__tests__/twilio-routes.test.ts +167 -11
  96. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  97. package/src/__tests__/user-reference.test.ts +2 -0
  98. package/src/__tests__/voice-quality.test.ts +222 -0
  99. package/src/__tests__/web-search.test.ts +46 -30
  100. package/src/__tests__/work-item-output.test.ts +110 -0
  101. package/src/agent/loop.ts +1 -1
  102. package/src/agent-heartbeat/agent-heartbeat-service.ts +2 -10
  103. package/src/amazon/client.ts +1418 -0
  104. package/src/amazon/request-extractor.ts +135 -0
  105. package/src/amazon/session.ts +109 -0
  106. package/src/autonomy/autonomy-store.ts +5 -5
  107. package/src/browser-extension-relay/client.ts +124 -0
  108. package/src/browser-extension-relay/protocol.ts +63 -0
  109. package/src/browser-extension-relay/server.ts +177 -0
  110. package/src/bundler/app-bundler.ts +3 -3
  111. package/src/bundler/bundle-signer.ts +1 -1
  112. package/src/bundler/signature-verifier.ts +1 -1
  113. package/src/calls/call-conversation-messages.ts +33 -0
  114. package/src/calls/call-domain.ts +114 -10
  115. package/src/calls/call-orchestrator.ts +268 -59
  116. package/src/calls/call-pointer-messages.ts +53 -0
  117. package/src/calls/call-recovery.ts +3 -8
  118. package/src/calls/call-store.ts +69 -87
  119. package/src/calls/elevenlabs-config.ts +3 -2
  120. package/src/calls/guardian-action-sweep.ts +105 -0
  121. package/src/calls/guardian-dispatch.ts +203 -0
  122. package/src/calls/guardian-question-copy.ts +133 -0
  123. package/src/calls/relay-server.ts +466 -8
  124. package/src/calls/speaker-identification.ts +1 -1
  125. package/src/calls/twilio-config.ts +22 -14
  126. package/src/calls/twilio-provider.ts +6 -4
  127. package/src/calls/twilio-rest.ts +308 -7
  128. package/src/calls/twilio-routes.ts +65 -12
  129. package/src/calls/types.ts +3 -1
  130. package/src/channels/types.ts +25 -0
  131. package/src/cli/amazon.ts +815 -0
  132. package/src/cli/config-commands.ts +2 -2
  133. package/src/cli/core-commands.ts +4 -3
  134. package/src/cli/influencer.ts +244 -0
  135. package/src/cli/map.ts +89 -6
  136. package/src/cli.ts +1 -1
  137. package/src/config/agent-schema.ts +171 -0
  138. package/src/config/bundled-skills/amazon/SKILL.md +127 -0
  139. package/src/config/bundled-skills/amazon/icon.svg +13 -0
  140. package/src/config/bundled-skills/api-mapping/SKILL.md +78 -0
  141. package/src/config/bundled-skills/browser/SKILL.md +1 -0
  142. package/src/config/bundled-skills/browser/TOOLS.json +17 -0
  143. package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +25 -0
  144. package/src/config/bundled-skills/doordash/SKILL.md +51 -51
  145. package/src/config/bundled-skills/email-setup/SKILL.md +14 -5
  146. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +183 -0
  147. package/src/config/bundled-skills/influencer/SKILL.md +144 -0
  148. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  149. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  150. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  151. package/src/config/bundled-skills/macos-automation/icon.svg +12 -0
  152. package/src/config/bundled-skills/media-processing/SKILL.md +176 -0
  153. package/src/config/bundled-skills/media-processing/TOOLS.json +230 -0
  154. package/src/config/bundled-skills/media-processing/__tests__/concurrency-pool.test.ts +77 -0
  155. package/src/config/bundled-skills/media-processing/__tests__/cost-tracker.test.ts +69 -0
  156. package/src/config/bundled-skills/media-processing/__tests__/preprocess.test.ts +303 -0
  157. package/src/config/bundled-skills/media-processing/services/concurrency-pool.ts +55 -0
  158. package/src/config/bundled-skills/media-processing/services/cost-tracker.ts +86 -0
  159. package/src/config/bundled-skills/media-processing/services/gemini-map.ts +339 -0
  160. package/src/config/bundled-skills/media-processing/services/preprocess.ts +551 -0
  161. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +259 -0
  162. package/src/config/bundled-skills/media-processing/services/reduce.ts +197 -0
  163. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +136 -0
  164. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +59 -0
  165. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  166. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  167. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +143 -0
  168. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  169. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +65 -0
  170. package/src/config/bundled-skills/messaging/SKILL.md +33 -8
  171. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +4 -7
  172. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +2 -1
  173. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  174. package/src/config/bundled-skills/phone-calls/SKILL.md +88 -23
  175. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  176. package/src/config/bundled-skills/twitter/icon.svg +14 -0
  177. package/src/config/bundled-tool-registry.ts +310 -0
  178. package/src/config/calls-schema.ts +181 -0
  179. package/src/config/core-schema.ts +309 -0
  180. package/src/config/defaults.ts +28 -3
  181. package/src/config/env-registry.ts +162 -0
  182. package/src/config/env.ts +175 -0
  183. package/src/config/loader.ts +6 -6
  184. package/src/config/memory-schema.ts +528 -0
  185. package/src/config/sandbox-schema.ts +55 -0
  186. package/src/config/schema.ts +158 -1133
  187. package/src/config/skill-state.ts +1 -1
  188. package/src/config/skills-schema.ts +32 -0
  189. package/src/config/skills.ts +35 -24
  190. package/src/config/system-prompt.ts +131 -56
  191. package/src/config/templates/IDENTITY.md +2 -2
  192. package/src/config/templates/SOUL.md +1 -1
  193. package/src/config/types.ts +1 -0
  194. package/src/config/user-reference.ts +4 -9
  195. package/src/config/vellum-skills/catalog.json +6 -7
  196. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +5 -1
  197. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -3
  198. package/src/config/vellum-skills/sms-setup/SKILL.md +216 -0
  199. package/src/config/vellum-skills/twilio-setup/SKILL.md +40 -8
  200. package/src/context/window-manager.ts +27 -7
  201. package/src/daemon/approval-generators.ts +186 -0
  202. package/src/daemon/approved-devices-store.ts +140 -0
  203. package/src/daemon/assistant-attachments.ts +1 -1
  204. package/src/daemon/classifier.ts +35 -32
  205. package/src/daemon/config-watcher.ts +1 -1
  206. package/src/daemon/daemon-control.ts +217 -0
  207. package/src/daemon/handlers/apps.ts +2 -3
  208. package/src/daemon/handlers/config-channels.ts +158 -0
  209. package/src/daemon/handlers/config-inbox.ts +540 -0
  210. package/src/daemon/handlers/config-ingress.ts +231 -0
  211. package/src/daemon/handlers/config-integrations.ts +258 -0
  212. package/src/daemon/handlers/config-model.ts +143 -0
  213. package/src/daemon/handlers/config-parental.ts +163 -0
  214. package/src/daemon/handlers/config-scheduling.ts +172 -0
  215. package/src/daemon/handlers/config-slack.ts +92 -0
  216. package/src/daemon/handlers/config-telegram.ts +301 -0
  217. package/src/daemon/handlers/config-tools.ts +177 -0
  218. package/src/daemon/handlers/config-trust.ts +104 -0
  219. package/src/daemon/handlers/config-twilio.ts +1080 -0
  220. package/src/daemon/handlers/config.ts +53 -1689
  221. package/src/daemon/handlers/diagnostics.ts +1 -1
  222. package/src/daemon/handlers/dictation.ts +180 -0
  223. package/src/daemon/handlers/documents.ts +18 -32
  224. package/src/daemon/handlers/identity.ts +14 -23
  225. package/src/daemon/handlers/index.ts +11 -0
  226. package/src/daemon/handlers/misc.ts +3 -5
  227. package/src/daemon/handlers/pairing.ts +98 -0
  228. package/src/daemon/handlers/sessions.ts +56 -5
  229. package/src/daemon/handlers/shared.ts +6 -1
  230. package/src/daemon/handlers/skills.ts +1 -1
  231. package/src/daemon/handlers/twitter-auth.ts +2 -0
  232. package/src/daemon/handlers/work-items.ts +17 -9
  233. package/src/daemon/handlers/workspace-files.ts +4 -3
  234. package/src/daemon/install-cli-launchers.ts +113 -0
  235. package/src/daemon/ipc-contract/apps.ts +356 -0
  236. package/src/daemon/ipc-contract/browser.ts +74 -0
  237. package/src/daemon/ipc-contract/computer-use.ts +151 -0
  238. package/src/daemon/ipc-contract/diagnostics.ts +56 -0
  239. package/src/daemon/ipc-contract/documents.ts +74 -0
  240. package/src/daemon/ipc-contract/inbox.ts +209 -0
  241. package/src/daemon/ipc-contract/integrations.ts +284 -0
  242. package/src/daemon/ipc-contract/memory.ts +48 -0
  243. package/src/daemon/ipc-contract/messages.ts +211 -0
  244. package/src/daemon/ipc-contract/pairing.ts +45 -0
  245. package/src/daemon/ipc-contract/parental-control.ts +95 -0
  246. package/src/daemon/ipc-contract/schedules.ts +97 -0
  247. package/src/daemon/ipc-contract/sessions.ts +315 -0
  248. package/src/daemon/ipc-contract/shared.ts +42 -0
  249. package/src/daemon/ipc-contract/skills.ts +120 -0
  250. package/src/daemon/ipc-contract/subagents.ts +58 -0
  251. package/src/daemon/ipc-contract/surfaces.ts +250 -0
  252. package/src/daemon/ipc-contract/trust.ts +60 -0
  253. package/src/daemon/ipc-contract/work-items.ts +225 -0
  254. package/src/daemon/ipc-contract/workspace.ts +113 -0
  255. package/src/daemon/ipc-contract-inventory.json +70 -0
  256. package/src/daemon/ipc-contract-inventory.ts +55 -29
  257. package/src/daemon/ipc-contract.ts +229 -2426
  258. package/src/daemon/ipc-protocol.ts +1 -1
  259. package/src/daemon/ipc-validate.ts +7 -0
  260. package/src/daemon/lifecycle.ts +97 -377
  261. package/src/daemon/pairing-store.ts +177 -0
  262. package/src/daemon/providers-setup.ts +43 -0
  263. package/src/daemon/ride-shotgun-handler.ts +68 -3
  264. package/src/daemon/server.ts +66 -46
  265. package/src/daemon/session-agent-loop-handlers.ts +421 -0
  266. package/src/daemon/session-agent-loop.ts +117 -275
  267. package/src/daemon/session-dynamic-profile.ts +1 -1
  268. package/src/daemon/session-history.ts +1 -1
  269. package/src/daemon/session-media-retry.ts +1 -1
  270. package/src/daemon/session-messaging.ts +37 -2
  271. package/src/daemon/session-notifiers.ts +5 -25
  272. package/src/daemon/session-process.ts +99 -59
  273. package/src/daemon/session-queue-manager.ts +96 -4
  274. package/src/daemon/session-runtime-assembly.ts +199 -10
  275. package/src/daemon/session-surfaces.ts +19 -4
  276. package/src/daemon/session-tool-setup.ts +30 -30
  277. package/src/daemon/session-workspace.ts +1 -1
  278. package/src/daemon/session.ts +35 -2
  279. package/src/daemon/shutdown-handlers.ts +122 -0
  280. package/src/daemon/trace-emitter.ts +1 -1
  281. package/src/daemon/watch-handler.ts +36 -33
  282. package/src/doordash/cart-queries.ts +787 -0
  283. package/src/doordash/client.ts +144 -127
  284. package/src/doordash/order-queries.ts +85 -0
  285. package/src/doordash/queries.ts +10 -1308
  286. package/src/doordash/search-queries.ts +203 -0
  287. package/src/doordash/session.ts +3 -2
  288. package/src/doordash/store-queries.ts +246 -0
  289. package/src/doordash/types.ts +367 -0
  290. package/src/email/providers/agentmail.ts +2 -1
  291. package/src/email/providers/index.ts +3 -2
  292. package/src/email/service.ts +3 -2
  293. package/src/errors.ts +43 -0
  294. package/src/home-base/prebuilt/seed.ts +1 -1
  295. package/src/hooks/cli.ts +6 -5
  296. package/src/hooks/config.ts +6 -8
  297. package/src/hooks/discovery.ts +6 -5
  298. package/src/hooks/manager.ts +4 -3
  299. package/src/hooks/runner.ts +2 -2
  300. package/src/hooks/templates.ts +5 -5
  301. package/src/inbound/public-ingress-urls.ts +6 -4
  302. package/src/index.ts +4 -2
  303. package/src/influencer/client.ts +1104 -0
  304. package/src/instrument.ts +4 -3
  305. package/src/logfire.ts +4 -3
  306. package/src/memory/admin.ts +25 -35
  307. package/src/memory/attachments-store.ts +4 -7
  308. package/src/memory/channel-delivery-store.ts +30 -1
  309. package/src/memory/channel-guardian-store.ts +202 -2
  310. package/src/memory/clarification-resolver.ts +37 -33
  311. package/src/memory/conflict-store.ts +67 -61
  312. package/src/memory/contradiction-checker.ts +141 -117
  313. package/src/memory/conversation-store.ts +335 -51
  314. package/src/memory/db-connection.ts +27 -4
  315. package/src/memory/db-init.ts +265 -4
  316. package/src/memory/db.ts +14 -1
  317. package/src/memory/embedding-backend.ts +27 -5
  318. package/src/memory/embedding-ollama.ts +2 -1
  319. package/src/memory/entity-extractor.ts +38 -35
  320. package/src/memory/guardian-action-store.ts +430 -0
  321. package/src/memory/inbox-escalation-projection.ts +59 -0
  322. package/src/memory/inbox-thread-store.ts +218 -0
  323. package/src/memory/ingress-invite-store.ts +338 -0
  324. package/src/memory/ingress-member-store.ts +350 -0
  325. package/src/memory/items-extractor.ts +91 -97
  326. package/src/memory/job-handlers/index-maintenance.ts +3 -3
  327. package/src/memory/job-handlers/media-processing.ts +69 -0
  328. package/src/memory/job-handlers/summarization.ts +32 -26
  329. package/src/memory/job-utils.ts +3 -10
  330. package/src/memory/jobs-store.ts +8 -10
  331. package/src/memory/jobs-worker.ts +55 -36
  332. package/src/memory/media-store.ts +759 -0
  333. package/src/memory/migrations/001-job-deferrals.ts +45 -0
  334. package/src/memory/migrations/002-tool-invocations-fk.ts +43 -0
  335. package/src/memory/migrations/003-memory-fts-backfill.ts +24 -0
  336. package/src/memory/migrations/004-entity-relation-dedup.ts +87 -0
  337. package/src/memory/migrations/005-fingerprint-scope-unique.ts +80 -0
  338. package/src/memory/migrations/006-scope-salted-fingerprints.ts +62 -0
  339. package/src/memory/migrations/007-assistant-id-to-self.ts +254 -0
  340. package/src/memory/migrations/008-remove-assistant-id-columns.ts +208 -0
  341. package/src/memory/migrations/009-llm-usage-events-drop-assistant-id.ts +83 -0
  342. package/src/memory/migrations/010-ext-conv-bindings-channel-chat-unique.ts +56 -0
  343. package/src/memory/migrations/011-call-sessions-provider-sid-dedup.ts +63 -0
  344. package/src/memory/migrations/012-call-sessions-add-initiated-from.ts +19 -0
  345. package/src/memory/migrations/013-guardian-action-tables.ts +68 -0
  346. package/src/memory/migrations/014-backfill-inbox-thread-state.ts +76 -0
  347. package/src/memory/migrations/015-drop-active-search-index.ts +27 -0
  348. package/src/memory/migrations/016-memory-segments-indexes.ts +11 -0
  349. package/src/memory/migrations/017-memory-items-indexes.ts +10 -0
  350. package/src/memory/migrations/018-remaining-table-indexes.ts +13 -0
  351. package/src/memory/migrations/index.ts +24 -0
  352. package/src/memory/migrations/registry.ts +79 -0
  353. package/src/memory/migrations/validate-migration-state.ts +69 -0
  354. package/src/memory/qdrant-manager.ts +49 -8
  355. package/src/memory/query-builder.ts +1 -1
  356. package/src/memory/raw-query.ts +119 -0
  357. package/src/memory/recall-cache.ts +4 -1
  358. package/src/memory/retriever.ts +165 -47
  359. package/src/memory/schema-migration.ts +25 -984
  360. package/src/memory/schema.ts +228 -7
  361. package/src/memory/search/entity.ts +205 -31
  362. package/src/memory/search/lexical.ts +81 -52
  363. package/src/memory/search/ranking.ts +27 -23
  364. package/src/memory/search/semantic.ts +157 -19
  365. package/src/memory/search/types.ts +24 -0
  366. package/src/memory/shared-app-links-store.ts +4 -5
  367. package/src/memory/validation.ts +19 -0
  368. package/src/messaging/draft-store.ts +5 -6
  369. package/src/messaging/provider-types.ts +2 -0
  370. package/src/messaging/providers/sms/adapter.ts +201 -0
  371. package/src/messaging/providers/sms/client.ts +93 -0
  372. package/src/messaging/providers/sms/types.ts +7 -0
  373. package/src/messaging/providers/telegram-bot/adapter.ts +2 -5
  374. package/src/messaging/providers/whatsapp/adapter.ts +136 -0
  375. package/src/messaging/providers/whatsapp/client.ts +67 -0
  376. package/src/messaging/style-analyzer.ts +5 -4
  377. package/src/messaging/thread-summarizer.ts +61 -69
  378. package/src/messaging/triage-engine.ts +62 -71
  379. package/src/migrations/config-merge.ts +53 -0
  380. package/src/migrations/data-layout.ts +68 -0
  381. package/src/migrations/data-merge.ts +33 -0
  382. package/src/migrations/hooks-merge.ts +90 -0
  383. package/src/migrations/index.ts +6 -0
  384. package/src/migrations/log.ts +23 -0
  385. package/src/migrations/skills-merge.ts +33 -0
  386. package/src/migrations/workspace-layout.ts +79 -0
  387. package/src/permissions/checker.ts +133 -11
  388. package/src/permissions/prompter.ts +14 -0
  389. package/src/permissions/shell-identity.ts +31 -1
  390. package/src/permissions/trust-store.ts +21 -1
  391. package/src/providers/anthropic/client.ts +4 -4
  392. package/src/providers/failover.ts +2 -2
  393. package/src/providers/model-intents.ts +70 -0
  394. package/src/providers/ollama/client.ts +2 -1
  395. package/src/providers/provider-send-message.ts +176 -0
  396. package/src/providers/registry.ts +71 -30
  397. package/src/providers/retry.ts +35 -1
  398. package/src/providers/types.ts +12 -1
  399. package/src/runtime/approval-conversation-turn.ts +97 -0
  400. package/src/runtime/approval-message-composer.ts +253 -0
  401. package/src/runtime/channel-approval-parser.ts +36 -2
  402. package/src/runtime/channel-approvals.ts +11 -24
  403. package/src/runtime/channel-guardian-service.ts +88 -21
  404. package/src/runtime/channel-readiness-service.ts +418 -0
  405. package/src/runtime/channel-readiness-types.ts +35 -0
  406. package/src/runtime/channel-retry-sweep.ts +184 -0
  407. package/src/runtime/guardian-context-resolver.ts +108 -0
  408. package/src/runtime/http-server.ts +275 -717
  409. package/src/runtime/http-types.ts +59 -3
  410. package/src/runtime/middleware/auth.ts +116 -0
  411. package/src/runtime/middleware/error-handler.ts +33 -0
  412. package/src/runtime/middleware/twilio-validation.ts +127 -0
  413. package/src/runtime/routes/app-routes.ts +1 -1
  414. package/src/runtime/routes/call-routes.ts +51 -7
  415. package/src/runtime/routes/channel-delivery-routes.ts +170 -0
  416. package/src/runtime/routes/channel-guardian-routes.ts +1191 -0
  417. package/src/runtime/routes/channel-inbound-routes.ts +1152 -0
  418. package/src/runtime/routes/channel-route-shared.ts +144 -0
  419. package/src/runtime/routes/channel-routes.ts +32 -1588
  420. package/src/runtime/routes/conversation-routes.ts +50 -7
  421. package/src/runtime/routes/events-routes.ts +2 -2
  422. package/src/runtime/routes/identity-routes.ts +126 -0
  423. package/src/runtime/routes/pairing-routes.ts +143 -0
  424. package/src/runtime/routes/run-routes.ts +15 -1
  425. package/src/runtime/run-orchestrator.ts +86 -35
  426. package/src/schedule/schedule-store.ts +36 -32
  427. package/src/schedule/scheduler.ts +3 -3
  428. package/src/security/encrypted-store.ts +5 -7
  429. package/src/security/oauth2.ts +45 -15
  430. package/src/security/parental-control-store.ts +183 -0
  431. package/src/security/secret-allowlist.ts +4 -3
  432. package/src/security/secret-scanner.ts +5 -5
  433. package/src/security/secure-keys.ts +1 -1
  434. package/src/security/token-manager.ts +3 -2
  435. package/src/services/vercel-deploy.ts +6 -2
  436. package/src/skills/tool-manifest.ts +3 -3
  437. package/src/skills/vellum-catalog-remote.ts +75 -16
  438. package/src/slack/slack-webhook.ts +2 -1
  439. package/src/swarm/orchestrator.ts +92 -1
  440. package/src/swarm/router-planner.ts +6 -9
  441. package/src/swarm/worker-prompts.ts +9 -12
  442. package/src/tasks/task-compiler.ts +19 -28
  443. package/src/tasks/task-runner.ts +1 -1
  444. package/src/tools/assets/materialize.ts +2 -2
  445. package/src/tools/assets/search.ts +15 -14
  446. package/src/tools/browser/__tests__/auth-detector.test.ts +1 -0
  447. package/src/tools/browser/auto-navigate.ts +1 -0
  448. package/src/tools/browser/browser-execution.ts +10 -1
  449. package/src/tools/browser/browser-manager.ts +119 -4
  450. package/src/tools/browser/network-recorder.ts +5 -0
  451. package/src/tools/calls/call-start.ts +1 -0
  452. package/src/tools/credentials/broker.ts +11 -2
  453. package/src/tools/credentials/metadata-store.ts +18 -14
  454. package/src/tools/credentials/post-connect-hooks.ts +61 -0
  455. package/src/tools/credentials/vault.ts +49 -23
  456. package/src/tools/execution-target.ts +11 -1
  457. package/src/tools/executor.ts +68 -9
  458. package/src/tools/host-terminal/cli-discover.ts +1 -1
  459. package/src/tools/network/script-proxy/http-forwarder.ts +1 -1
  460. package/src/tools/network/script-proxy/mitm-handler.ts +1 -1
  461. package/src/tools/network/script-proxy/server.ts +1 -1
  462. package/src/tools/network/script-proxy/session-manager.ts +6 -5
  463. package/src/tools/network/web-fetch.ts +18 -2
  464. package/src/tools/network/web-search.ts +8 -4
  465. package/src/tools/reminder/reminder-store.ts +14 -15
  466. package/src/tools/schedule/create.ts +1 -0
  467. package/src/tools/schedule/list.ts +2 -1
  468. package/src/tools/shared/filesystem/file-ops-service.ts +5 -7
  469. package/src/tools/skills/skill-script-runner.ts +24 -9
  470. package/src/tools/skills/skill-tool-factory.ts +1 -0
  471. package/src/tools/tasks/work-item-enqueue.ts +2 -2
  472. package/src/tools/terminal/evaluate-typescript.ts +21 -12
  473. package/src/tools/terminal/parser.ts +50 -0
  474. package/src/tools/types.ts +2 -0
  475. package/src/tools/watcher/delete.ts +6 -0
  476. package/src/tools/weather/service.ts +1 -1
  477. package/src/twitter/client.ts +190 -24
  478. package/src/twitter/router.ts +1 -1
  479. package/src/twitter/session.ts +4 -3
  480. package/src/util/clipboard.ts +1 -1
  481. package/src/util/errors.ts +65 -8
  482. package/src/util/fs.ts +40 -0
  483. package/src/util/json.ts +10 -0
  484. package/src/util/log-redact.ts +189 -0
  485. package/src/util/logger.ts +19 -17
  486. package/src/util/object.ts +3 -0
  487. package/src/util/platform.ts +105 -363
  488. package/src/util/pricing.ts +1 -1
  489. package/src/util/promise-guard.ts +1 -1
  490. package/src/util/retry.ts +19 -0
  491. package/src/util/row-mapper.ts +79 -0
  492. package/src/util/silently.ts +21 -0
  493. package/src/watcher/engine.ts +5 -1
  494. package/src/watcher/provider-types.ts +20 -0
  495. package/src/watcher/providers/github.ts +156 -0
  496. package/src/watcher/providers/gmail.ts +1 -0
  497. package/src/watcher/providers/google-calendar.ts +1 -0
  498. package/src/watcher/providers/linear.ts +460 -0
  499. package/src/watcher/providers/slack.ts +1 -0
  500. package/src/work-items/work-item-runner.ts +1 -1
  501. package/src/workspace/git-service.ts +1 -1
  502. package/src/workspace/provider-commit-message-generator.ts +51 -22
  503. package/src/__tests__/call-bridge.test.ts +0 -517
  504. package/src/__tests__/session-process-bridge.test.ts +0 -244
  505. package/src/calls/call-bridge.ts +0 -168
  506. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +0 -199
@@ -5,16 +5,15 @@
5
5
  * `RUNTIME_HTTP_PORT` is set (default: disabled).
6
6
  */
7
7
 
8
- import { existsSync, readFileSync, statSync, statfsSync } from 'node:fs';
9
- import { resolve, join, dirname } from 'node:path';
10
- import { fileURLToPath } from 'node:url';
11
- import { timingSafeEqual } from 'node:crypto';
12
- import { ConfigError, IngressBlockedError } from '../util/errors.js';
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { resolve } from 'node:path';
10
+ import { parseChannelId } from '../channels/types.js';
13
11
  import { getLogger } from '../util/logger.js';
14
- import { getWorkspacePromptPath } from '../util/platform.js';
15
- import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js';
16
- import { loadConfig } from '../config/loader.js';
17
- import { getPublicBaseUrl } from '../inbound/public-ingress-urls.js';
12
+ import {
13
+ getGatewayInternalBaseUrl,
14
+ isHttpAuthDisabled,
15
+ getRuntimeGatewayOriginSecret,
16
+ } from '../config/env.js';
18
17
  import type { RunOrchestrator } from './run-orchestrator.js';
19
18
 
20
19
  // Route handlers — grouped by domain
@@ -22,6 +21,7 @@ import {
22
21
  handleListMessages,
23
22
  handleSendMessage,
24
23
  handleGetSuggestion,
24
+ handleSearchConversations,
25
25
  } from './routes/conversation-routes.js';
26
26
  import {
27
27
  handleUploadAttachment,
@@ -44,12 +44,12 @@ import {
44
44
  startGuardianExpirySweep,
45
45
  stopGuardianExpirySweep,
46
46
  } from './routes/channel-routes.js';
47
- import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
47
+ import {
48
+ startGuardianActionSweep,
49
+ stopGuardianActionSweep,
50
+ } from '../calls/guardian-action-sweep.js';
48
51
  import * as conversationStore from '../memory/conversation-store.js';
49
52
  import * as externalConversationStore from '../memory/external-conversation-store.js';
50
- import * as attachmentsStore from '../memory/attachments-store.js';
51
- import { renderHistoryContent } from '../daemon/handlers.js';
52
- import { deliverChannelReply } from './gateway-client.js';
53
53
  import {
54
54
  handleServePage,
55
55
  handleShareApp,
@@ -72,8 +72,42 @@ import {
72
72
  } from '../calls/twilio-routes.js';
73
73
  import { RelayConnection, activeRelayConnections } from '../calls/relay-server.js';
74
74
  import type { RelayWebSocketData } from '../calls/relay-server.js';
75
+ import { extensionRelayServer } from '../browser-extension-relay/server.js';
76
+ import type { BrowserRelayWebSocketData } from '../browser-extension-relay/server.js';
75
77
  import { handleSubscribeAssistantEvents } from './routes/events-routes.js';
76
78
  import { consumeCallback, consumeCallbackError } from '../security/oauth-callback-registry.js';
79
+ import { PairingStore } from '../daemon/pairing-store.js';
80
+
81
+ // Middleware
82
+ import {
83
+ verifyBearerToken,
84
+ isLoopbackHost,
85
+ isPrivateNetworkPeer,
86
+ isPrivateNetworkOrigin,
87
+ extractBearerToken,
88
+ } from './middleware/auth.js';
89
+ import { withErrorHandling } from './middleware/error-handler.js';
90
+ import {
91
+ TWILIO_WEBHOOK_RE,
92
+ TWILIO_GATEWAY_WEBHOOK_RE,
93
+ GATEWAY_SUBPATH_MAP,
94
+ GATEWAY_ONLY_BLOCKED_SUBPATHS,
95
+ validateTwilioWebhook,
96
+ cloneRequestWithBody,
97
+ } from './middleware/twilio-validation.js';
98
+
99
+ // Extracted route handlers
100
+ import {
101
+ handlePairingRegister,
102
+ handlePairingRequest,
103
+ handlePairingStatus,
104
+ } from './routes/pairing-routes.js';
105
+ import type { PairingHandlerContext } from './routes/pairing-routes.js';
106
+ import { handleHealth, handleGetIdentity } from './routes/identity-routes.js';
107
+ import { sweepFailedEvents } from './channel-retry-sweep.js';
108
+
109
+ // Re-export for consumers
110
+ export { isPrivateAddress } from './middleware/auth.js';
77
111
 
78
112
  // Re-export shared types so existing consumers don't need to update imports
79
113
  export type {
@@ -82,12 +116,16 @@ export type {
82
116
  NonBlockingMessageProcessor,
83
117
  RuntimeHttpServerOptions,
84
118
  RuntimeAttachmentMetadata,
119
+ ApprovalCopyGenerator,
120
+ ApprovalConversationGenerator,
85
121
  } from './http-types.js';
86
122
 
87
123
  import type {
88
124
  MessageProcessor,
89
125
  NonBlockingMessageProcessor,
90
126
  RuntimeHttpServerOptions,
127
+ ApprovalCopyGenerator,
128
+ ApprovalConversationGenerator,
91
129
  } from './http-types.js';
92
130
 
93
131
  const log = getLogger('runtime-http');
@@ -95,251 +133,9 @@ const log = getLogger('runtime-http');
95
133
  const DEFAULT_PORT = 7821;
96
134
  const DEFAULT_HOSTNAME = '127.0.0.1';
97
135
 
98
- /** Resolve the gateway base URL for internal delivery callbacks. */
99
- function getGatewayBaseUrl(): string {
100
- if (process.env.GATEWAY_INTERNAL_BASE_URL) {
101
- return process.env.GATEWAY_INTERNAL_BASE_URL.replace(/\/+$/, '');
102
- }
103
- const port = Number(process.env.GATEWAY_PORT) || 7830;
104
- return `http://127.0.0.1:${port}`;
105
- }
106
-
107
- /** Global hard cap on request body size (50 MB). Bun rejects larger payloads before they reach handlers. */
136
+ /** Global hard cap on request body size (50 MB). */
108
137
  const MAX_REQUEST_BODY_BYTES = 50 * 1024 * 1024;
109
138
 
110
- interface DiskSpaceInfo {
111
- path: string;
112
- totalMb: number;
113
- usedMb: number;
114
- freeMb: number;
115
- }
116
-
117
- function getDiskSpaceInfo(): DiskSpaceInfo | null {
118
- try {
119
- const baseDataDir = process.env.BASE_DATA_DIR?.trim();
120
- const diskPath = baseDataDir && existsSync(baseDataDir) ? baseDataDir : '/';
121
- const stats = statfsSync(diskPath);
122
- const totalBytes = stats.bsize * stats.blocks;
123
- const freeBytes = stats.bsize * stats.bavail;
124
- const bytesToMb = (b: number) => Math.round((b / (1024 * 1024)) * 100) / 100;
125
- return {
126
- path: diskPath,
127
- totalMb: bytesToMb(totalBytes),
128
- usedMb: bytesToMb(totalBytes - freeBytes),
129
- freeMb: bytesToMb(freeBytes),
130
- };
131
- } catch {
132
- return null;
133
- }
134
- }
135
-
136
- /**
137
- * Regex to extract the Twilio webhook subpath from both top-level and
138
- * assistant-scoped route shapes:
139
- * /v1/calls/twilio/<subpath>
140
- * /v1/assistants/<id>/calls/twilio/<subpath>
141
- */
142
- const TWILIO_WEBHOOK_RE = /^\/v1\/(?:assistants\/[^/]+\/)?calls\/twilio\/(.+)$/;
143
-
144
- /**
145
- * Gateway-compatible Twilio webhook paths:
146
- * /webhooks/twilio/<subpath>
147
- *
148
- * Maps gateway path segments to the internal subpath names used by the
149
- * dispatcher below (e.g. "voice" -> "voice-webhook").
150
- */
151
- const TWILIO_GATEWAY_WEBHOOK_RE = /^\/webhooks\/twilio\/(.+)$/;
152
- const GATEWAY_SUBPATH_MAP: Record<string, string> = {
153
- voice: 'voice-webhook',
154
- status: 'status',
155
- 'connect-action': 'connect-action',
156
- sms: 'sms',
157
- };
158
-
159
- /**
160
- * Direct Twilio webhook subpaths that are blocked in gateway_only mode.
161
- * Includes all public-facing webhook paths (voice, status, connect-action, SMS)
162
- * because the runtime must never serve as a direct ingress for external webhooks.
163
- * Internal forwarding endpoints (gateway→runtime) are unaffected.
164
- */
165
- const GATEWAY_ONLY_BLOCKED_SUBPATHS = new Set(['voice-webhook', 'status', 'connect-action', 'sms']);
166
-
167
- /**
168
- * Check if a request origin is from a private/internal network address.
169
- * Extracts the hostname from the Origin header and validates it against
170
- * isPrivateAddress(), consistent with the isPrivateNetworkPeer check.
171
- */
172
- function isPrivateNetworkOrigin(req: Request): boolean {
173
- const origin = req.headers.get('origin');
174
- // No origin header (e.g., server-initiated or same-origin) — allow
175
- if (!origin) return true;
176
- try {
177
- const url = new URL(origin);
178
- const host = url.hostname;
179
- if (host === 'localhost') return true;
180
- // URL.hostname wraps IPv6 addresses in brackets (e.g. "[::1]") — strip them
181
- const rawHost = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
182
- return isPrivateAddress(rawHost);
183
- } catch {
184
- return false;
185
- }
186
- }
187
-
188
- /**
189
- * Check if a hostname is a loopback address.
190
- */
191
- function isLoopbackHost(hostname: string): boolean {
192
- return hostname === '127.0.0.1' || hostname === '::1' || hostname === 'localhost';
193
- }
194
-
195
- /**
196
- * Check if the actual peer/remote address of a connection is from a
197
- * private/internal network. Uses Bun's server.requestIP() to get the
198
- * real peer address, which cannot be spoofed unlike the Origin header.
199
- *
200
- * Accepts loopback, RFC 1918 private IPv4, link-local, and RFC 4193
201
- * unique-local IPv6 — including their IPv4-mapped IPv6 forms. This
202
- * supports container/pod deployments (e.g. Kubernetes sidecars) where
203
- * gateway and runtime communicate over pod-internal private IPs.
204
- */
205
- function isPrivateNetworkPeer(server: { requestIP(req: Request): { address: string; family: string; port: number } | null }, req: Request): boolean {
206
- const ip = server.requestIP(req);
207
- if (!ip) return false;
208
- return isPrivateAddress(ip.address);
209
- }
210
-
211
- /**
212
- * @internal Exported for testing.
213
- *
214
- * Determine whether an IP address string belongs to a private/internal
215
- * network range:
216
- * - Loopback: 127.0.0.0/8, ::1
217
- * - RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
218
- * - Link-local: 169.254.0.0/16
219
- * - IPv6 unique local: fc00::/7 (fc00::–fdff::)
220
- * - IPv4-mapped IPv6 variants of all of the above (::ffff:x.x.x.x)
221
- */
222
- export function isPrivateAddress(addr: string): boolean {
223
- // Handle IPv4-mapped IPv6 (e.g. ::ffff:10.0.0.1) — extract the IPv4 part
224
- const v4Mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
225
- const normalized = v4Mapped ? v4Mapped[1] : addr;
226
-
227
- // IPv4 checks
228
- if (normalized.includes('.')) {
229
- const parts = normalized.split('.').map(Number);
230
- if (parts.length !== 4 || parts.some(p => isNaN(p) || p < 0 || p > 255)) return false;
231
-
232
- // Loopback: 127.0.0.0/8
233
- if (parts[0] === 127) return true;
234
- // 10.0.0.0/8
235
- if (parts[0] === 10) return true;
236
- // 172.16.0.0/12 (172.16.x.x – 172.31.x.x)
237
- if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
238
- // 192.168.0.0/16
239
- if (parts[0] === 192 && parts[1] === 168) return true;
240
- // Link-local: 169.254.0.0/16
241
- if (parts[0] === 169 && parts[1] === 254) return true;
242
-
243
- return false;
244
- }
245
-
246
- // IPv6 checks
247
- const lower = normalized.toLowerCase();
248
- // Loopback
249
- if (lower === '::1') return true;
250
- // Unique local: fc00::/7 (fc00:: through fdff::)
251
- if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
252
- // Link-local: fe80::/10
253
- if (lower.startsWith('fe80')) return true;
254
-
255
- return false;
256
- }
257
-
258
- /**
259
- * Validate a Twilio webhook request's X-Twilio-Signature header.
260
- *
261
- * Returns the raw body text on success so callers can reconstruct the Request
262
- * for downstream handlers (which also need to read the body).
263
- * Returns a 403 Response if signature validation fails.
264
- *
265
- * Fail-closed: if the auth token is not configured, the request is rejected
266
- * with 403 rather than silently skipping validation. An explicit local-dev
267
- * bypass is available via TWILIO_WEBHOOK_VALIDATION_DISABLED=true.
268
- */
269
- async function validateTwilioWebhook(
270
- req: Request,
271
- ): Promise<{ body: string } | Response> {
272
- const rawBody = await req.text();
273
-
274
- // Allow explicit local-dev bypass — must be exactly "true"
275
- if (process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED === 'true') {
276
- log.warn('Twilio webhook signature validation explicitly disabled via TWILIO_WEBHOOK_VALIDATION_DISABLED');
277
- return { body: rawBody };
278
- }
279
-
280
- const authToken = TwilioConversationRelayProvider.getAuthToken();
281
-
282
- // Fail-closed: reject if no auth token is configured
283
- if (!authToken) {
284
- log.error('Twilio auth token not configured — rejecting webhook request (fail-closed)');
285
- return Response.json({ error: 'Forbidden' }, { status: 403 });
286
- }
287
-
288
- const signature = req.headers.get('x-twilio-signature');
289
- if (!signature) {
290
- log.warn('Twilio webhook request missing X-Twilio-Signature header');
291
- return Response.json({ error: 'Forbidden' }, { status: 403 });
292
- }
293
-
294
- // Parse form-urlencoded body into key-value params for signature computation
295
- const params: Record<string, string> = {};
296
- const formData = new URLSearchParams(rawBody);
297
- for (const [key, value] of formData.entries()) {
298
- params[key] = value;
299
- }
300
-
301
- // Reconstruct the public-facing URL that Twilio signed against.
302
- // Behind proxies/gateways, req.url is the local server URL (e.g.
303
- // http://127.0.0.1:7821/...) which differs from the public URL Twilio
304
- // used to compute the HMAC-SHA1 signature.
305
- let publicBaseUrl: string | undefined;
306
- try {
307
- publicBaseUrl = getPublicBaseUrl(loadConfig());
308
- } catch {
309
- // No webhook base URL configured — fall back to using req.url as-is
310
- }
311
- const parsedUrl = new URL(req.url);
312
- const publicUrl = publicBaseUrl
313
- ? publicBaseUrl + parsedUrl.pathname + parsedUrl.search
314
- : req.url;
315
-
316
- const isValid = TwilioConversationRelayProvider.verifyWebhookSignature(
317
- publicUrl,
318
- params,
319
- signature,
320
- authToken,
321
- );
322
-
323
- if (!isValid) {
324
- log.warn('Twilio webhook signature validation failed');
325
- return Response.json({ error: 'Forbidden' }, { status: 403 });
326
- }
327
-
328
- return { body: rawBody };
329
- }
330
-
331
- /**
332
- * Re-create a Request with the same method, headers, and URL but with a
333
- * pre-read body string so downstream handlers can call req.text() again.
334
- */
335
- function cloneRequestWithBody(original: Request, body: string): Request {
336
- return new Request(original.url, {
337
- method: original.method,
338
- headers: original.headers,
339
- body,
340
- });
341
- }
342
-
343
139
  export class RuntimeHttpServer {
344
140
  private server: ReturnType<typeof Bun.serve> | null = null;
345
141
  private port: number;
@@ -348,11 +144,15 @@ export class RuntimeHttpServer {
348
144
  private processMessage?: MessageProcessor;
349
145
  private persistAndProcessMessage?: NonBlockingMessageProcessor;
350
146
  private runOrchestrator?: RunOrchestrator;
147
+ private approvalCopyGenerator?: ApprovalCopyGenerator;
148
+ private approvalConversationGenerator?: ApprovalConversationGenerator;
351
149
  private interfacesDir: string | null;
352
150
  private suggestionCache = new Map<string, string>();
353
151
  private suggestionInFlight = new Map<string, Promise<string | null>>();
354
152
  private retrySweepTimer: ReturnType<typeof setInterval> | null = null;
355
153
  private sweepInProgress = false;
154
+ private pairingStore = new PairingStore();
155
+ private pairingBroadcast?: (msg: { type: string; [key: string]: unknown }) => void;
356
156
 
357
157
  constructor(options: RuntimeHttpServerOptions = {}) {
358
158
  this.port = options.port ?? DEFAULT_PORT;
@@ -361,6 +161,8 @@ export class RuntimeHttpServer {
361
161
  this.processMessage = options.processMessage;
362
162
  this.persistAndProcessMessage = options.persistAndProcessMessage;
363
163
  this.runOrchestrator = options.runOrchestrator;
164
+ this.approvalCopyGenerator = options.approvalCopyGenerator;
165
+ this.approvalConversationGenerator = options.approvalConversationGenerator;
364
166
  this.interfacesDir = options.interfacesDir ?? null;
365
167
  }
366
168
 
@@ -369,30 +171,69 @@ export class RuntimeHttpServer {
369
171
  return this.server?.port ?? this.port;
370
172
  }
371
173
 
174
+ /** Expose the pairing store so the daemon server can wire IPC handlers. */
175
+ getPairingStore(): PairingStore {
176
+ return this.pairingStore;
177
+ }
178
+
179
+ /** Set a callback for broadcasting IPC messages (wired by daemon server). */
180
+ setPairingBroadcast(fn: (msg: { type: string; [key: string]: unknown }) => void): void {
181
+ this.pairingBroadcast = fn;
182
+ }
183
+
184
+ private get pairingContext(): PairingHandlerContext {
185
+ return {
186
+ pairingStore: this.pairingStore,
187
+ bearerToken: this.bearerToken,
188
+ pairingBroadcast: this.pairingBroadcast,
189
+ };
190
+ }
191
+
372
192
  async start(): Promise<void> {
373
- this.server = Bun.serve<RelayWebSocketData>({
193
+ type AllWebSocketData = RelayWebSocketData | BrowserRelayWebSocketData;
194
+ this.server = Bun.serve<AllWebSocketData>({
374
195
  port: this.port,
375
196
  hostname: this.hostname,
376
197
  maxRequestBodySize: MAX_REQUEST_BODY_BYTES,
377
198
  fetch: (req, server) => this.handleRequest(req, server),
378
199
  websocket: {
379
200
  open(ws) {
380
- const callSessionId = ws.data?.callSessionId;
201
+ const data = ws.data as AllWebSocketData;
202
+ if ('wsType' in data && data.wsType === 'browser-relay') {
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
+ extensionRelayServer.handleOpen(ws as any);
205
+ return;
206
+ }
207
+ const callSessionId = (data as RelayWebSocketData).callSessionId;
381
208
  log.info({ callSessionId }, 'ConversationRelay WebSocket opened');
382
209
  if (callSessionId) {
383
- const connection = new RelayConnection(ws, callSessionId);
210
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
211
+ const connection = new RelayConnection(ws as any, callSessionId);
384
212
  activeRelayConnections.set(callSessionId, connection);
385
213
  }
386
214
  },
387
215
  message(ws, message) {
388
- const callSessionId = ws.data?.callSessionId;
216
+ const data = ws.data as AllWebSocketData;
217
+ const raw = typeof message === 'string' ? message : new TextDecoder().decode(message);
218
+ if ('wsType' in data && data.wsType === 'browser-relay') {
219
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
220
+ extensionRelayServer.handleMessage(ws as any, raw);
221
+ return;
222
+ }
223
+ const callSessionId = (data as RelayWebSocketData).callSessionId;
389
224
  if (callSessionId) {
390
225
  const connection = activeRelayConnections.get(callSessionId);
391
- connection?.handleMessage(typeof message === 'string' ? message : new TextDecoder().decode(message));
226
+ connection?.handleMessage(raw);
392
227
  }
393
228
  },
394
229
  close(ws, code, reason) {
395
- const callSessionId = ws.data?.callSessionId;
230
+ const data = ws.data as AllWebSocketData;
231
+ if ('wsType' in data && data.wsType === 'browser-relay') {
232
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
233
+ extensionRelayServer.handleClose(ws as any, code, reason?.toString());
234
+ return;
235
+ }
236
+ const callSessionId = (data as RelayWebSocketData).callSessionId;
396
237
  log.info({ callSessionId, code, reason: reason?.toString() }, 'ConversationRelay WebSocket closed');
397
238
  if (callSessionId) {
398
239
  const connection = activeRelayConnections.get(callSessionId);
@@ -404,34 +245,38 @@ export class RuntimeHttpServer {
404
245
  },
405
246
  });
406
247
 
407
- // Sweep failed channel inbound events for retry every 30 seconds
408
248
  if (this.processMessage) {
249
+ const pm = this.processMessage;
250
+ const bt = this.bearerToken;
409
251
  this.retrySweepTimer = setInterval(() => {
410
252
  if (this.sweepInProgress) return;
411
253
  this.sweepInProgress = true;
412
- this.sweepFailedEvents().finally(() => { this.sweepInProgress = false; });
254
+ sweepFailedEvents(pm, bt).finally(() => { this.sweepInProgress = false; });
413
255
  }, 30_000);
414
256
  }
415
257
 
416
- // Start proactive guardian approval expiry sweep whenever orchestrator
417
- // support is available. Guardian approvals can be created even when the
418
- // generic channel-approval UX flag is disabled.
419
258
  if (this.runOrchestrator) {
420
- startGuardianExpirySweep(this.runOrchestrator, getGatewayBaseUrl(), this.bearerToken);
259
+ startGuardianExpirySweep(this.runOrchestrator, getGatewayInternalBaseUrl(), this.bearerToken, this.approvalCopyGenerator);
421
260
  log.info('Guardian approval expiry sweep started');
422
261
  }
423
262
 
424
- // Startup guard: log gateway-only mode warnings
263
+ startGuardianActionSweep(getGatewayInternalBaseUrl(), this.bearerToken);
264
+ log.info('Guardian action expiry sweep started');
265
+
425
266
  log.info('Running in gateway-only ingress mode. Direct webhook routes disabled.');
426
267
  if (!isLoopbackHost(this.hostname)) {
427
268
  log.warn('RUNTIME_HTTP_HOST is not bound to loopback. This may expose the runtime to direct public access.');
428
269
  }
429
270
 
271
+ this.pairingStore.start();
272
+
430
273
  log.info({ port: this.actualPort, hostname: this.hostname, auth: !!this.bearerToken }, 'Runtime HTTP server listening');
431
274
  }
432
275
 
433
276
  async stop(): Promise<void> {
277
+ this.pairingStore.stop();
434
278
  stopGuardianExpirySweep();
279
+ stopGuardianActionSweep();
435
280
  if (this.retrySweepTimer) {
436
281
  clearInterval(this.retrySweepTimer);
437
282
  this.retrySweepTimer = null;
@@ -443,104 +288,51 @@ export class RuntimeHttpServer {
443
288
  }
444
289
  }
445
290
 
446
- /**
447
- * Constant-time comparison of two bearer tokens to prevent timing attacks.
448
- */
449
- private verifyToken(provided: string): boolean {
450
- const expected = this.bearerToken!;
451
- const a = Buffer.from(provided);
452
- const b = Buffer.from(expected);
453
- if (a.length !== b.length) return false;
454
- return timingSafeEqual(a, b);
455
- }
456
-
457
291
  private async handleRequest(req: Request, server: ReturnType<typeof Bun.serve>): Promise<Response> {
458
292
  const url = new URL(req.url);
459
293
  const path = url.pathname;
460
294
 
461
- // Health checks are unauthenticated — they expose no sensitive data.
462
295
  if (path === '/healthz' && req.method === 'GET') {
463
- return this.handleHealth();
296
+ return handleHealth();
297
+ }
298
+
299
+ // WebSocket upgrade for the Chrome extension browser relay.
300
+ if (path === '/v1/browser-relay' && req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
301
+ return this.handleBrowserRelayUpgrade(req, server);
464
302
  }
465
303
 
466
304
  // WebSocket upgrade for ConversationRelay — before auth check because
467
305
  // Twilio WebSocket connections don't use bearer tokens.
468
306
  if (path.startsWith('/v1/calls/relay') && req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
469
- // Only allow relay connections from private network peers.
470
- // Primary check: actual peer address (cannot be spoofed) — accepts loopback
471
- // and RFC 1918/4193 private addresses to support container deployments.
472
- // Secondary check: Origin header (defense in depth).
473
- if (!isPrivateNetworkPeer(server, req) || !isPrivateNetworkOrigin(req)) {
474
- return Response.json(
475
- { error: 'Direct relay access disabled — only private network peers allowed', code: 'GATEWAY_ONLY' },
476
- { status: 403 },
477
- );
478
- }
479
-
480
- const wsUrl = new URL(req.url);
481
- const callSessionId = wsUrl.searchParams.get('callSessionId');
482
- if (!callSessionId) {
483
- return new Response('Missing callSessionId', { status: 400 });
484
- }
485
- const upgraded = server.upgrade(req, { data: { callSessionId } });
486
- if (!upgraded) {
487
- return new Response('WebSocket upgrade failed', { status: 500 });
488
- }
489
- // Bun handles the response after a successful upgrade.
490
- // The RelayConnection is created in the websocket.open handler.
491
- return undefined as unknown as Response;
307
+ return this.handleRelayUpgrade(req, server);
492
308
  }
493
309
 
494
- // ── Twilio webhook endpoints — before auth check because Twilio
495
- // webhook POSTs don't include bearer tokens.
496
- // Supports /v1/calls/twilio/*, /v1/assistants/:id/calls/twilio/*,
497
- // and gateway-compatible /webhooks/twilio/* paths.
498
- // Validates X-Twilio-Signature to prevent unauthorized access. ──
499
- const twilioMatch = path.match(TWILIO_WEBHOOK_RE);
500
- const gatewayTwilioMatch = !twilioMatch ? path.match(TWILIO_GATEWAY_WEBHOOK_RE) : null;
501
- const resolvedTwilioSubpath = twilioMatch
502
- ? twilioMatch[1]
503
- : gatewayTwilioMatch
504
- ? GATEWAY_SUBPATH_MAP[gatewayTwilioMatch[1]]
505
- : null;
506
- if (resolvedTwilioSubpath && req.method === 'POST') {
507
- const twilioSubpath = resolvedTwilioSubpath;
508
-
509
- // Block direct Twilio webhook routes — must go through the gateway
510
- if (GATEWAY_ONLY_BLOCKED_SUBPATHS.has(twilioSubpath)) {
511
- return Response.json(
512
- { error: 'Direct webhook access disabled. Use the gateway.', code: 'GATEWAY_ONLY' },
513
- { status: 410 },
514
- );
515
- }
516
-
517
- // Validate Twilio request signature before dispatching
518
- const validation = await validateTwilioWebhook(req);
519
- if (validation instanceof Response) return validation;
520
-
521
- // Reconstruct request so handlers can read the body
522
- const validatedReq = cloneRequestWithBody(req, validation.body);
310
+ // Twilio webhook endpoints — before auth check because Twilio
311
+ // webhook POSTs don't include bearer tokens.
312
+ const twilioResponse = await this.handleTwilioWebhook(req, path);
313
+ if (twilioResponse) return twilioResponse;
523
314
 
524
- if (twilioSubpath === 'voice-webhook') {
525
- return await handleVoiceWebhook(validatedReq);
526
- }
527
- if (twilioSubpath === 'status') {
528
- return await handleStatusCallback(validatedReq);
529
- }
530
- if (twilioSubpath === 'connect-action') {
531
- return await handleConnectAction(validatedReq);
532
- }
315
+ // Pairing endpoints (unauthenticated, secret-gated)
316
+ if (path === '/v1/pairing/request' && req.method === 'POST') {
317
+ return await handlePairingRequest(req, this.pairingContext);
318
+ }
319
+ if (path === '/v1/pairing/status' && req.method === 'GET') {
320
+ return handlePairingStatus(url, this.pairingContext);
533
321
  }
534
322
 
535
323
  // Require bearer token when configured
536
- if ((process.env.DISABLE_HTTP_AUTH ?? "").toLowerCase() !== "true" && this.bearerToken) {
537
- const authHeader = req.headers.get('authorization');
538
- const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
539
- if (!token || !this.verifyToken(token)) {
324
+ if (!isHttpAuthDisabled() && this.bearerToken) {
325
+ const token = extractBearerToken(req);
326
+ if (!token || !verifyBearerToken(token, this.bearerToken)) {
540
327
  return Response.json({ error: 'Unauthorized' }, { status: 401 });
541
328
  }
542
329
  }
543
330
 
331
+ // Pairing registration (bearer-authenticated)
332
+ if (path === '/v1/pairing/register' && req.method === 'POST') {
333
+ return await handlePairingRegister(req, this.pairingContext);
334
+ }
335
+
544
336
  // Serve shareable app pages
545
337
  const pagesMatch = path.match(/^\/pages\/([^/]+)$/);
546
338
  if (pagesMatch && req.method === 'GET') {
@@ -552,11 +344,9 @@ export class RuntimeHttpServer {
552
344
  }
553
345
  }
554
346
 
555
- // ── Cloud sharing endpoints ───────────────────────────────────────
347
+ // Cloud sharing endpoints
556
348
  if (path === '/v1/apps/share' && req.method === 'POST') {
557
- try {
558
- return await handleShareApp(req);
559
- } catch (err) {
349
+ try { return await handleShareApp(req); } catch (err) {
560
350
  log.error({ err }, 'Runtime HTTP handler error sharing app');
561
351
  return Response.json({ error: 'Internal server error' }, { status: 500 });
562
352
  }
@@ -566,17 +356,13 @@ export class RuntimeHttpServer {
566
356
  if (sharedTokenMatch) {
567
357
  const shareToken = sharedTokenMatch[1];
568
358
  if (req.method === 'GET') {
569
- try {
570
- return handleDownloadSharedApp(shareToken);
571
- } catch (err) {
359
+ try { return handleDownloadSharedApp(shareToken); } catch (err) {
572
360
  log.error({ err, shareToken }, 'Runtime HTTP handler error downloading shared app');
573
361
  return Response.json({ error: 'Internal server error' }, { status: 500 });
574
362
  }
575
363
  }
576
364
  if (req.method === 'DELETE') {
577
- try {
578
- return handleDeleteSharedApp(shareToken);
579
- } catch (err) {
365
+ try { return handleDeleteSharedApp(shareToken); } catch (err) {
580
366
  log.error({ err, shareToken }, 'Runtime HTTP handler error deleting shared app');
581
367
  return Response.json({ error: 'Internal server error' }, { status: 500 });
582
368
  }
@@ -585,27 +371,21 @@ export class RuntimeHttpServer {
585
371
 
586
372
  const sharedMetadataMatch = path.match(/^\/v1\/apps\/shared\/([^/]+)\/metadata$/);
587
373
  if (sharedMetadataMatch && req.method === 'GET') {
588
- try {
589
- return handleGetSharedAppMetadata(sharedMetadataMatch[1]);
590
- } catch (err) {
374
+ try { return handleGetSharedAppMetadata(sharedMetadataMatch[1]); } catch (err) {
591
375
  log.error({ err, shareToken: sharedMetadataMatch[1] }, 'Runtime HTTP handler error getting shared app metadata');
592
376
  return Response.json({ error: 'Internal server error' }, { status: 500 });
593
377
  }
594
378
  }
595
379
 
596
- // ── Secret management endpoint ─────────────────────────────────────
380
+ // Secret management endpoint
597
381
  if (path === '/v1/secrets' && req.method === 'POST') {
598
- try {
599
- return await handleAddSecret(req);
600
- } catch (err) {
382
+ try { return await handleAddSecret(req); } catch (err) {
601
383
  log.error({ err }, 'Runtime HTTP handler error adding secret');
602
384
  return Response.json({ error: 'Internal server error' }, { status: 500 });
603
385
  }
604
386
  }
605
387
 
606
388
  // New assistant-less runtime routes: /v1/<endpoint>
607
- // These supersede the legacy /v1/assistants/:assistantId/... shape.
608
- // Paths already handled above (/v1/apps/..., /v1/secrets) will never reach here.
609
389
  const newRouteMatch = path.match(/^\/v1\/(?!assistants\/)(.+)$/);
610
390
  if (newRouteMatch) {
611
391
  return this.dispatchEndpoint(newRouteMatch[1], req, url);
@@ -623,10 +403,85 @@ export class RuntimeHttpServer {
623
403
  return this.dispatchEndpoint(endpoint, req, url, assistantId);
624
404
  }
625
405
 
406
+ private handleBrowserRelayUpgrade(req: Request, server: ReturnType<typeof Bun.serve>): Response {
407
+ if (!isLoopbackHost(new URL(req.url).hostname) && !isPrivateNetworkPeer(server, req)) {
408
+ return Response.json(
409
+ { error: 'Browser relay only accepts connections from localhost', code: 'LOCALHOST_ONLY' },
410
+ { status: 403 },
411
+ );
412
+ }
413
+
414
+ if ((process.env.DISABLE_HTTP_AUTH ?? '').toLowerCase() !== 'true' && this.bearerToken) {
415
+ const wsUrl = new URL(req.url);
416
+ const token = wsUrl.searchParams.get('token');
417
+ if (!token || !verifyBearerToken(token, this.bearerToken)) {
418
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
419
+ }
420
+ }
421
+
422
+ const connectionId = crypto.randomUUID();
423
+ const upgraded = server.upgrade(req, {
424
+ data: { wsType: 'browser-relay', connectionId } satisfies BrowserRelayWebSocketData,
425
+ });
426
+ if (!upgraded) {
427
+ return new Response('WebSocket upgrade failed', { status: 500 });
428
+ }
429
+ return undefined as unknown as Response;
430
+ }
431
+
432
+ private handleRelayUpgrade(req: Request, server: ReturnType<typeof Bun.serve>): Response {
433
+ if (!isPrivateNetworkPeer(server, req) || !isPrivateNetworkOrigin(req)) {
434
+ return Response.json(
435
+ { error: 'Direct relay access disabled — only private network peers allowed', code: 'GATEWAY_ONLY' },
436
+ { status: 403 },
437
+ );
438
+ }
439
+
440
+ const wsUrl = new URL(req.url);
441
+ const callSessionId = wsUrl.searchParams.get('callSessionId');
442
+ if (!callSessionId) {
443
+ return new Response('Missing callSessionId', { status: 400 });
444
+ }
445
+ const upgraded = server.upgrade(req, { data: { callSessionId } });
446
+ if (!upgraded) {
447
+ return new Response('WebSocket upgrade failed', { status: 500 });
448
+ }
449
+ return undefined as unknown as Response;
450
+ }
451
+
452
+ private async handleTwilioWebhook(req: Request, path: string): Promise<Response | null> {
453
+ const twilioMatch = path.match(TWILIO_WEBHOOK_RE);
454
+ const gatewayTwilioMatch = !twilioMatch ? path.match(TWILIO_GATEWAY_WEBHOOK_RE) : null;
455
+ const resolvedTwilioSubpath = twilioMatch
456
+ ? twilioMatch[1]
457
+ : gatewayTwilioMatch
458
+ ? GATEWAY_SUBPATH_MAP[gatewayTwilioMatch[1]]
459
+ : null;
460
+ if (!resolvedTwilioSubpath || req.method !== 'POST') return null;
461
+
462
+ const twilioSubpath = resolvedTwilioSubpath;
463
+
464
+ if (GATEWAY_ONLY_BLOCKED_SUBPATHS.has(twilioSubpath)) {
465
+ return Response.json(
466
+ { error: 'Direct webhook access disabled. Use the gateway.', code: 'GATEWAY_ONLY' },
467
+ { status: 410 },
468
+ );
469
+ }
470
+
471
+ const validation = await validateTwilioWebhook(req);
472
+ if (validation instanceof Response) return validation;
473
+
474
+ const validatedReq = cloneRequestWithBody(req, validation.body);
475
+
476
+ if (twilioSubpath === 'voice-webhook') return await handleVoiceWebhook(validatedReq);
477
+ if (twilioSubpath === 'status') return await handleStatusCallback(validatedReq);
478
+ if (twilioSubpath === 'connect-action') return await handleConnectAction(validatedReq);
479
+
480
+ return null;
481
+ }
482
+
626
483
  /**
627
484
  * Dispatch a request to the appropriate endpoint handler.
628
- * Used by both the new assistant-less routes (/v1/<endpoint>) and the
629
- * legacy assistant-scoped routes (/v1/assistants/:assistantId/<endpoint>).
630
485
  */
631
486
  private async dispatchEndpoint(
632
487
  endpoint: string,
@@ -634,9 +489,21 @@ export class RuntimeHttpServer {
634
489
  url: URL,
635
490
  assistantId: string = 'self',
636
491
  ): Promise<Response> {
637
- try {
638
- if (endpoint === 'health' && req.method === 'GET') {
639
- return this.handleHealth();
492
+ return withErrorHandling(endpoint, async () => {
493
+ if (endpoint === 'health' && req.method === 'GET') return handleHealth();
494
+
495
+ if (endpoint === 'browser-relay/status' && req.method === 'GET') {
496
+ return Response.json(extensionRelayServer.getStatus());
497
+ }
498
+
499
+ if (endpoint === 'browser-relay/command' && req.method === 'POST') {
500
+ try {
501
+ const body = await req.json() as Record<string, unknown>;
502
+ const resp = await extensionRelayServer.sendCommand(body as Omit<import('../browser-extension-relay/protocol.js').ExtensionCommand, 'id'>);
503
+ return Response.json(resp);
504
+ } catch (err) {
505
+ return Response.json({ success: false, error: err instanceof Error ? err.message : String(err) }, { status: 500 });
506
+ }
640
507
  }
641
508
 
642
509
  if (endpoint === 'conversations' && req.method === 'GET') {
@@ -650,6 +517,7 @@ export class RuntimeHttpServer {
650
517
  return Response.json({
651
518
  sessions: conversations.map((c) => {
652
519
  const binding = bindings.get(c.id);
520
+ const originChannel = parseChannelId(c.originChannel);
653
521
  return {
654
522
  id: c.id,
655
523
  title: c.title ?? 'Untitled',
@@ -664,15 +532,15 @@ export class RuntimeHttpServer {
664
532
  username: binding.username,
665
533
  },
666
534
  } : {}),
535
+ ...(originChannel ? { conversationOriginChannel: originChannel } : {}),
667
536
  };
668
537
  }),
669
538
  hasMore: offset + conversations.length < totalCount,
670
539
  });
671
540
  }
672
541
 
673
- if (endpoint === 'messages' && req.method === 'GET') {
674
- return handleListMessages(url, this.interfacesDir);
675
- }
542
+ if (endpoint === 'messages' && req.method === 'GET') return handleListMessages(url, this.interfacesDir);
543
+ if (endpoint === 'search' && req.method === 'GET') return handleSearchConversations(url);
676
544
 
677
545
  if (endpoint === 'messages' && req.method === 'POST') {
678
546
  return await handleSendMessage(req, {
@@ -681,19 +549,11 @@ export class RuntimeHttpServer {
681
549
  });
682
550
  }
683
551
 
684
- if (endpoint === 'attachments' && req.method === 'POST') {
685
- return await handleUploadAttachment(req);
686
- }
687
-
688
- if (endpoint === 'attachments' && req.method === 'DELETE') {
689
- return await handleDeleteAttachment(req);
690
- }
552
+ if (endpoint === 'attachments' && req.method === 'POST') return await handleUploadAttachment(req);
553
+ if (endpoint === 'attachments' && req.method === 'DELETE') return await handleDeleteAttachment(req);
691
554
 
692
- // Match attachments/:attachmentId
693
555
  const attachmentMatch = endpoint.match(/^attachments\/([^/]+)$/);
694
- if (attachmentMatch && req.method === 'GET') {
695
- return handleGetAttachment(attachmentMatch[1]);
696
- }
556
+ if (attachmentMatch && req.method === 'GET') return handleGetAttachment(attachmentMatch[1]);
697
557
 
698
558
  if (endpoint === 'suggestion' && req.method === 'GET') {
699
559
  return await handleGetSuggestion(url, {
@@ -703,394 +563,93 @@ export class RuntimeHttpServer {
703
563
  }
704
564
 
705
565
  if (endpoint === 'runs' && req.method === 'POST') {
706
- if (!this.runOrchestrator) {
707
- return Response.json({ error: 'Run orchestration not configured' }, { status: 503 });
708
- }
566
+ if (!this.runOrchestrator) return Response.json({ error: 'Run orchestration not configured' }, { status: 503 });
709
567
  return await handleCreateRun(req, this.runOrchestrator);
710
568
  }
711
569
 
712
- // Match runs/:runId, runs/:runId/decision, runs/:runId/trust-rule, runs/:runId/secret
713
570
  const runsMatch = endpoint.match(/^runs\/([^/]+)(\/decision|\/trust-rule|\/secret)?$/);
714
571
  if (runsMatch) {
715
- if (!this.runOrchestrator) {
716
- return Response.json({ error: 'Run orchestration not configured' }, { status: 503 });
717
- }
572
+ if (!this.runOrchestrator) return Response.json({ error: 'Run orchestration not configured' }, { status: 503 });
718
573
  const runId = runsMatch[1];
719
- if (runsMatch[2] === '/decision' && req.method === 'POST') {
720
- return await handleRunDecision(runId, req, this.runOrchestrator);
721
- }
722
- if (runsMatch[2] === '/secret' && req.method === 'POST') {
723
- return await handleRunSecret(runId, req, this.runOrchestrator);
724
- }
574
+ if (runsMatch[2] === '/decision' && req.method === 'POST') return await handleRunDecision(runId, req, this.runOrchestrator);
575
+ if (runsMatch[2] === '/secret' && req.method === 'POST') return await handleRunSecret(runId, req, this.runOrchestrator);
725
576
  if (runsMatch[2] === '/trust-rule' && req.method === 'POST') {
726
577
  const run = this.runOrchestrator.getRun(runId);
727
- if (!run) {
728
- return Response.json({ error: 'Run not found' }, { status: 404 });
729
- }
578
+ if (!run) return Response.json({ error: 'Run not found' }, { status: 404 });
730
579
  return await handleAddTrustRule(runId, req);
731
580
  }
732
- if (req.method === 'GET') {
733
- return handleGetRun(runId, this.runOrchestrator);
734
- }
581
+ if (req.method === 'GET') return handleGetRun(runId, this.runOrchestrator);
735
582
  }
736
583
 
737
584
  const interfacesMatch = endpoint.match(/^interfaces\/(.+)$/);
738
- if (interfacesMatch && req.method === 'GET') {
739
- return this.handleGetInterface(interfacesMatch[1]);
740
- }
585
+ if (interfacesMatch && req.method === 'GET') return this.handleGetInterface(interfacesMatch[1]);
741
586
 
742
- if (endpoint === 'channels/conversation' && req.method === 'DELETE') {
743
- return await handleDeleteConversation(req, assistantId);
744
- }
587
+ if (endpoint === 'channels/conversation' && req.method === 'DELETE') return await handleDeleteConversation(req, assistantId);
745
588
 
746
589
  if (endpoint === 'channels/inbound' && req.method === 'POST') {
747
- const gatewayOriginSecret = process.env.RUNTIME_GATEWAY_ORIGIN_SECRET || undefined;
748
- return await handleChannelInbound(req, this.processMessage, this.bearerToken, this.runOrchestrator, assistantId, gatewayOriginSecret);
590
+ const gatewayOriginSecret = getRuntimeGatewayOriginSecret();
591
+ return await handleChannelInbound(req, this.processMessage, this.bearerToken, this.runOrchestrator, assistantId, gatewayOriginSecret, this.approvalCopyGenerator, this.approvalConversationGenerator);
749
592
  }
750
593
 
751
- if (endpoint === 'channels/delivery-ack' && req.method === 'POST') {
752
- return await handleChannelDeliveryAck(req);
753
- }
594
+ if (endpoint === 'channels/delivery-ack' && req.method === 'POST') return await handleChannelDeliveryAck(req);
595
+ if (endpoint === 'channels/dead-letters' && req.method === 'GET') return handleListDeadLetters();
596
+ if (endpoint === 'channels/replay' && req.method === 'POST') return await handleReplayDeadLetters(req);
754
597
 
755
- if (endpoint === 'channels/dead-letters' && req.method === 'GET') {
756
- return handleListDeadLetters();
757
- }
598
+ if (endpoint === 'calls/start' && req.method === 'POST') return await handleStartCall(req, assistantId);
758
599
 
759
- if (endpoint === 'channels/replay' && req.method === 'POST') {
760
- return await handleReplayDeadLetters(req);
761
- }
762
-
763
- // ── Call API routes ───────────────────────────────────────────
764
- if (endpoint === 'calls/start' && req.method === 'POST') {
765
- return await handleStartCall(req);
766
- }
767
-
768
- // Match calls/:callSessionId and calls/:callSessionId/cancel, calls/:callSessionId/answer, calls/:callSessionId/instruction
769
600
  const callsMatch = endpoint.match(/^calls\/([^/]+?)(\/cancel|\/answer|\/instruction)?$/);
770
601
  if (callsMatch) {
771
602
  const callSessionId = callsMatch[1];
772
- // Skip known sub-paths that are handled elsewhere (twilio, relay)
773
603
  if (callSessionId !== 'twilio' && callSessionId !== 'relay' && callSessionId !== 'start') {
774
- if (callsMatch[2] === '/cancel' && req.method === 'POST') {
775
- return await handleCancelCall(req, callSessionId);
776
- }
777
- if (callsMatch[2] === '/answer' && req.method === 'POST') {
778
- return await handleAnswerCall(req, callSessionId);
779
- }
780
- if (callsMatch[2] === '/instruction' && req.method === 'POST') {
781
- return await handleInstructionCall(req, callSessionId);
782
- }
783
- if (!callsMatch[2] && req.method === 'GET') {
784
- return handleGetCallStatus(callSessionId);
785
- }
604
+ if (callsMatch[2] === '/cancel' && req.method === 'POST') return await handleCancelCall(req, callSessionId);
605
+ if (callsMatch[2] === '/answer' && req.method === 'POST') return await handleAnswerCall(req, callSessionId);
606
+ if (callsMatch[2] === '/instruction' && req.method === 'POST') return await handleInstructionCall(req, callSessionId);
607
+ if (!callsMatch[2] && req.method === 'GET') return handleGetCallStatus(callSessionId);
786
608
  }
787
609
  }
788
610
 
789
- // ── Internal Twilio forwarding endpoints (gateway runtime) ──
790
- // These accept JSON payloads from the gateway (which already validated
791
- // the Twilio signature) and reconstruct requests for the existing
792
- // Twilio route handlers.
611
+ // Internal Twilio forwarding endpoints (gateway -> runtime)
793
612
  if (endpoint === 'internal/twilio/voice-webhook' && req.method === 'POST') {
794
- const json = await req.json() as { params: Record<string, string>; originalUrl?: string };
613
+ const json = await req.json() as { params: Record<string, string>; originalUrl?: string; assistantId?: string };
795
614
  const formBody = new URLSearchParams(json.params).toString();
796
- // Reconstruct request URL: keep the original URL query string (callSessionId)
797
615
  const reconstructedUrl = json.originalUrl ?? req.url;
798
- const fakeReq = new Request(reconstructedUrl, {
799
- method: 'POST',
800
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
801
- body: formBody,
802
- });
803
- return await handleVoiceWebhook(fakeReq);
616
+ const fakeReq = new Request(reconstructedUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formBody });
617
+ return await handleVoiceWebhook(fakeReq, json.assistantId);
804
618
  }
805
619
 
806
620
  if (endpoint === 'internal/twilio/status' && req.method === 'POST') {
807
621
  const json = await req.json() as { params: Record<string, string> };
808
622
  const formBody = new URLSearchParams(json.params).toString();
809
- const fakeReq = new Request(req.url, {
810
- method: 'POST',
811
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
812
- body: formBody,
813
- });
623
+ const fakeReq = new Request(req.url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formBody });
814
624
  return await handleStatusCallback(fakeReq);
815
625
  }
816
626
 
817
627
  if (endpoint === 'internal/twilio/connect-action' && req.method === 'POST') {
818
628
  const json = await req.json() as { params: Record<string, string> };
819
629
  const formBody = new URLSearchParams(json.params).toString();
820
- const fakeReq = new Request(req.url, {
821
- method: 'POST',
822
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
823
- body: formBody,
824
- });
630
+ const fakeReq = new Request(req.url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formBody });
825
631
  return await handleConnectAction(fakeReq);
826
632
  }
827
633
 
828
- if (endpoint === 'identity' && req.method === 'GET') {
829
- return this.handleGetIdentity();
830
- }
831
-
832
- if (endpoint === 'events' && req.method === 'GET') {
833
- return handleSubscribeAssistantEvents(req, url);
834
- }
634
+ if (endpoint === 'identity' && req.method === 'GET') return handleGetIdentity();
635
+ if (endpoint === 'events' && req.method === 'GET') return handleSubscribeAssistantEvents(req, url);
835
636
 
836
- // ── Internal OAuth callback endpoint (gateway runtime) ──
637
+ // Internal OAuth callback endpoint (gateway -> runtime)
837
638
  if (endpoint === 'internal/oauth/callback' && req.method === 'POST') {
838
639
  const json = await req.json() as { state: string; code?: string; error?: string };
839
- if (!json.state) {
840
- return Response.json({ error: 'Missing state parameter' }, { status: 400 });
841
- }
640
+ if (!json.state) return Response.json({ error: 'Missing state parameter' }, { status: 400 });
842
641
  if (json.error) {
843
642
  const consumed = consumeCallbackError(json.state, json.error);
844
- return consumed
845
- ? Response.json({ ok: true })
846
- : Response.json({ error: 'Unknown state' }, { status: 404 });
643
+ return consumed ? Response.json({ ok: true }) : Response.json({ error: 'Unknown state' }, { status: 404 });
847
644
  }
848
645
  if (json.code) {
849
646
  const consumed = consumeCallback(json.state, json.code);
850
- return consumed
851
- ? Response.json({ ok: true })
852
- : Response.json({ error: 'Unknown state' }, { status: 404 });
647
+ return consumed ? Response.json({ ok: true }) : Response.json({ error: 'Unknown state' }, { status: 404 });
853
648
  }
854
649
  return Response.json({ error: 'Missing code or error parameter' }, { status: 400 });
855
650
  }
856
651
 
857
652
  return Response.json({ error: 'Not found', source: 'runtime' }, { status: 404 });
858
- } catch (err) {
859
- if (err instanceof IngressBlockedError) {
860
- log.warn({ endpoint, detectedTypes: err.detectedTypes }, 'Blocked HTTP request containing secrets');
861
- return Response.json({ error: err.message, code: err.code }, { status: 422 });
862
- }
863
- if (err instanceof ConfigError) {
864
- log.warn({ err, endpoint }, 'Runtime HTTP config error');
865
- return Response.json({ error: err.message, code: err.code }, { status: 422 });
866
- }
867
- log.error({ err, endpoint }, 'Runtime HTTP handler error');
868
- const message = err instanceof Error ? err.message : 'Internal server error';
869
- return Response.json({ error: message }, { status: 500 });
870
- }
871
- }
872
-
873
- /**
874
- * Periodically retry failed channel inbound events that have passed
875
- * their exponential backoff delay.
876
- */
877
- private async sweepFailedEvents(): Promise<void> {
878
- if (!this.processMessage) return;
879
-
880
- const events = channelDeliveryStore.getRetryableEvents();
881
- if (events.length === 0) return;
882
-
883
- log.info({ count: events.length }, 'Retrying failed channel inbound events');
884
-
885
- for (const event of events) {
886
- if (!event.rawPayload) {
887
- // No payload stored — can't replay, move to dead letter
888
- channelDeliveryStore.recordProcessingFailure(
889
- event.id,
890
- new Error('No raw payload stored for replay'),
891
- );
892
- continue;
893
- }
894
-
895
- let payload: Record<string, unknown>;
896
- try {
897
- payload = JSON.parse(event.rawPayload) as Record<string, unknown>;
898
- } catch {
899
- channelDeliveryStore.recordProcessingFailure(
900
- event.id,
901
- new Error('Failed to parse stored raw payload'),
902
- );
903
- continue;
904
- }
905
-
906
- const content = typeof payload.content === 'string' ? payload.content.trim() : '';
907
- const attachmentIds = Array.isArray(payload.attachmentIds) ? payload.attachmentIds as string[] : undefined;
908
- const sourceChannel = payload.sourceChannel as string;
909
- const sourceMetadata = payload.sourceMetadata as Record<string, unknown> | undefined;
910
-
911
- const metadataHintsRaw = sourceMetadata?.hints;
912
- const metadataHints = Array.isArray(metadataHintsRaw)
913
- ? metadataHintsRaw.filter((h): h is string => typeof h === 'string' && h.trim().length > 0)
914
- : [];
915
- const metadataUxBrief = typeof sourceMetadata?.uxBrief === 'string' && sourceMetadata.uxBrief.trim().length > 0
916
- ? sourceMetadata.uxBrief.trim()
917
- : undefined;
918
-
919
- try {
920
- const { messageId: userMessageId } = await this.processMessage(
921
- event.conversationId,
922
- content,
923
- attachmentIds,
924
- {
925
- transport: {
926
- channelId: sourceChannel,
927
- hints: metadataHints.length > 0 ? metadataHints : undefined,
928
- uxBrief: metadataUxBrief,
929
- },
930
- },
931
- );
932
- channelDeliveryStore.linkMessage(event.id, userMessageId);
933
- channelDeliveryStore.markProcessed(event.id);
934
- log.info({ eventId: event.id }, 'Successfully replayed failed channel event');
935
-
936
- const replyCallbackUrl = typeof payload.replyCallbackUrl === 'string'
937
- ? payload.replyCallbackUrl
938
- : undefined;
939
- if (replyCallbackUrl) {
940
- const externalChatId = typeof payload.externalChatId === 'string'
941
- ? payload.externalChatId
942
- : undefined;
943
- const assistantId = typeof payload.assistantId === 'string'
944
- ? payload.assistantId
945
- : undefined;
946
- if (externalChatId) {
947
- await this.deliverReplyViaCallback(
948
- event.conversationId,
949
- externalChatId,
950
- replyCallbackUrl,
951
- assistantId,
952
- );
953
- }
954
- }
955
- } catch (err) {
956
- log.error({ err, eventId: event.id }, 'Retry failed for channel event');
957
- channelDeliveryStore.recordProcessingFailure(event.id, err);
958
- }
959
- }
960
- }
961
-
962
- private async deliverReplyViaCallback(
963
- conversationId: string,
964
- externalChatId: string,
965
- callbackUrl: string,
966
- assistantId?: string,
967
- ): Promise<void> {
968
- const msgs = conversationStore.getMessages(conversationId);
969
- for (let i = msgs.length - 1; i >= 0; i--) {
970
- if (msgs[i].role === 'assistant') {
971
- let parsed: unknown;
972
- try { parsed = JSON.parse(msgs[i].content); } catch { parsed = msgs[i].content; }
973
- const rendered = renderHistoryContent(parsed);
974
-
975
- const linked = attachmentsStore.getAttachmentMetadataForMessage(msgs[i].id);
976
- const replyAttachments = linked.map((a) => ({
977
- id: a.id,
978
- filename: a.originalFilename,
979
- mimeType: a.mimeType,
980
- sizeBytes: a.sizeBytes,
981
- kind: a.kind,
982
- }));
983
-
984
- if (rendered.text || replyAttachments.length > 0) {
985
- await deliverChannelReply(callbackUrl, {
986
- chatId: externalChatId,
987
- text: rendered.text || undefined,
988
- attachments: replyAttachments.length > 0 ? replyAttachments : undefined,
989
- assistantId,
990
- }, this.bearerToken);
991
- }
992
- break;
993
- }
994
- }
995
- }
996
-
997
- private handleGetIdentity(): Response {
998
- const identityPath = getWorkspacePromptPath('IDENTITY.md');
999
- if (!existsSync(identityPath)) {
1000
- return Response.json({ error: 'IDENTITY.md not found' }, { status: 404 });
1001
- }
1002
-
1003
- const content = readFileSync(identityPath, 'utf-8');
1004
- const fields: Record<string, string> = {};
1005
- for (const line of content.split('\n')) {
1006
- const trimmed = line.trim();
1007
- const lower = trimmed.toLowerCase();
1008
- const extract = (prefix: string): string | null => {
1009
- if (!lower.startsWith(prefix)) return null;
1010
- return trimmed.split(':**').pop()?.trim() ?? null;
1011
- };
1012
-
1013
- const name = extract('- **name:**');
1014
- if (name) { fields.name = name; continue; }
1015
- const role = extract('- **role:**');
1016
- if (role) { fields.role = role; continue; }
1017
- const personality = extract('- **personality:**') ?? extract('- **vibe:**');
1018
- if (personality) { fields.personality = personality; continue; }
1019
- const emoji = extract('- **emoji:**');
1020
- if (emoji) { fields.emoji = emoji; continue; }
1021
- const home = extract('- **home:**');
1022
- if (home) { fields.home = home; continue; }
1023
- }
1024
-
1025
- // Read version from package.json
1026
- let version: string | undefined;
1027
- try {
1028
- const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
1029
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
1030
- version = pkg.version;
1031
- } catch {
1032
- // ignore
1033
- }
1034
-
1035
- // Read createdAt from IDENTITY.md file birthtime
1036
- let createdAt: string | undefined;
1037
- try {
1038
- const stats = statSync(identityPath);
1039
- createdAt = stats.birthtime.toISOString();
1040
- } catch {
1041
- // ignore
1042
- }
1043
-
1044
- // Read lockfile for assistantId, cloud, and originSystem
1045
- let assistantId: string | undefined;
1046
- let cloud: string | undefined;
1047
- let originSystem: string | undefined;
1048
- try {
1049
- const homedir = process.env.HOME ?? process.env.USERPROFILE ?? '';
1050
- const lockfilePaths = [
1051
- join(homedir, '.vellum.lock.json'),
1052
- join(homedir, '.vellum.lockfile.json'),
1053
- ];
1054
- for (const lockPath of lockfilePaths) {
1055
- if (!existsSync(lockPath)) continue;
1056
- const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'));
1057
- const assistants = lockData.assistants as Array<Record<string, unknown>> | undefined;
1058
- if (assistants && assistants.length > 0) {
1059
- // Use the most recently hatched assistant
1060
- const sorted = [...assistants].sort((a, b) => {
1061
- const dateA = new Date(a.hatchedAt as string || 0).getTime();
1062
- const dateB = new Date(b.hatchedAt as string || 0).getTime();
1063
- return dateB - dateA;
1064
- });
1065
- const latest = sorted[0];
1066
- assistantId = latest.assistantId as string | undefined;
1067
- cloud = latest.cloud as string | undefined;
1068
- originSystem = cloud === 'local' ? 'local' : cloud;
1069
- }
1070
- break;
1071
- }
1072
- } catch {
1073
- // ignore — lockfile may not exist
1074
- }
1075
-
1076
- return Response.json({
1077
- name: fields.name ?? '',
1078
- role: fields.role ?? '',
1079
- personality: fields.personality ?? '',
1080
- emoji: fields.emoji ?? '',
1081
- home: fields.home ?? '',
1082
- version,
1083
- assistantId,
1084
- createdAt,
1085
- originSystem,
1086
- });
1087
- }
1088
-
1089
- private handleHealth(): Response {
1090
- return Response.json({
1091
- status: 'healthy',
1092
- timestamp: new Date().toISOString(),
1093
- disk: getDiskSpaceInfo(),
1094
653
  });
1095
654
  }
1096
655
 
@@ -1099,7 +658,6 @@ export class RuntimeHttpServer {
1099
658
  return Response.json({ error: 'Interface not found' }, { status: 404 });
1100
659
  }
1101
660
  const fullPath = resolve(this.interfacesDir, interfacePath);
1102
- // Enforce directory boundary so prefix-sibling paths (e.g. "interfaces-other/") are rejected
1103
661
  if (
1104
662
  (fullPath !== this.interfacesDir && !fullPath.startsWith(this.interfacesDir + '/')) ||
1105
663
  !existsSync(fullPath)