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