@vellumai/assistant 0.3.5 → 0.3.7

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 (487) 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 +27 -3
  164. package/src/config/env-registry.ts +169 -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 +157 -1138
  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 +254 -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 +74 -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 +321 -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 +62 -0
  236. package/src/daemon/ipc-contract-inventory.ts +55 -29
  237. package/src/daemon/ipc-contract.ts +227 -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 +98 -4
  254. package/src/daemon/session-runtime-assembly.ts +149 -15
  255. package/src/daemon/session-surfaces.ts +26 -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 +12 -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 +163 -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 +126 -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/assistant-event-hub.ts +3 -1
  377. package/src/runtime/channel-approval-parser.ts +36 -2
  378. package/src/runtime/channel-approvals.ts +0 -21
  379. package/src/runtime/channel-guardian-service.ts +48 -7
  380. package/src/runtime/channel-readiness-service.ts +160 -34
  381. package/src/runtime/channel-readiness-types.ts +10 -4
  382. package/src/runtime/channel-retry-sweep.ts +184 -0
  383. package/src/runtime/guardian-context-resolver.ts +108 -0
  384. package/src/runtime/http-server.ts +289 -745
  385. package/src/runtime/http-types.ts +56 -3
  386. package/src/runtime/middleware/auth.ts +116 -0
  387. package/src/runtime/middleware/error-handler.ts +33 -0
  388. package/src/runtime/middleware/twilio-validation.ts +127 -0
  389. package/src/runtime/routes/app-routes.ts +1 -1
  390. package/src/runtime/routes/call-routes.ts +49 -6
  391. package/src/runtime/routes/channel-delivery-routes.ts +170 -0
  392. package/src/runtime/routes/channel-guardian-routes.ts +1191 -0
  393. package/src/runtime/routes/channel-inbound-routes.ts +1152 -0
  394. package/src/runtime/routes/channel-route-shared.ts +144 -0
  395. package/src/runtime/routes/channel-routes.ts +32 -1634
  396. package/src/runtime/routes/conversation-routes.ts +50 -7
  397. package/src/runtime/routes/events-routes.ts +2 -2
  398. package/src/runtime/routes/identity-routes.ts +126 -0
  399. package/src/runtime/routes/pairing-routes.ts +144 -0
  400. package/src/runtime/routes/run-routes.ts +15 -1
  401. package/src/runtime/run-orchestrator.ts +52 -34
  402. package/src/schedule/schedule-store.ts +36 -32
  403. package/src/schedule/scheduler.ts +3 -3
  404. package/src/security/encrypted-store.ts +5 -7
  405. package/src/security/oauth2.ts +45 -15
  406. package/src/security/parental-control-store.ts +183 -0
  407. package/src/security/secret-allowlist.ts +4 -3
  408. package/src/security/secret-scanner.ts +5 -5
  409. package/src/security/secure-keys.ts +1 -1
  410. package/src/security/token-manager.ts +3 -2
  411. package/src/services/vercel-deploy.ts +6 -2
  412. package/src/skills/tool-manifest.ts +3 -3
  413. package/src/skills/vellum-catalog-remote.ts +75 -16
  414. package/src/slack/slack-webhook.ts +2 -1
  415. package/src/swarm/orchestrator.ts +92 -1
  416. package/src/swarm/router-planner.ts +6 -9
  417. package/src/swarm/worker-prompts.ts +9 -12
  418. package/src/tasks/task-compiler.ts +19 -28
  419. package/src/tasks/task-runner.ts +1 -1
  420. package/src/tools/assets/search.ts +15 -14
  421. package/src/tools/browser/__tests__/auth-detector.test.ts +1 -0
  422. package/src/tools/browser/auto-navigate.ts +1 -0
  423. package/src/tools/browser/browser-execution.ts +13 -1
  424. package/src/tools/browser/browser-manager.ts +119 -4
  425. package/src/tools/browser/network-recorder.ts +5 -0
  426. package/src/tools/credentials/broker.ts +11 -2
  427. package/src/tools/credentials/metadata-store.ts +18 -14
  428. package/src/tools/credentials/post-connect-hooks.ts +61 -0
  429. package/src/tools/credentials/vault.ts +49 -23
  430. package/src/tools/executor.ts +80 -18
  431. package/src/tools/host-terminal/cli-discover.ts +1 -1
  432. package/src/tools/network/script-proxy/http-forwarder.ts +1 -1
  433. package/src/tools/network/script-proxy/mitm-handler.ts +1 -1
  434. package/src/tools/network/script-proxy/server.ts +1 -1
  435. package/src/tools/network/script-proxy/session-manager.ts +6 -5
  436. package/src/tools/network/web-fetch.ts +18 -2
  437. package/src/tools/network/web-search.ts +7 -3
  438. package/src/tools/reminder/reminder-store.ts +14 -15
  439. package/src/tools/schedule/create.ts +1 -0
  440. package/src/tools/schedule/list.ts +2 -1
  441. package/src/tools/shared/filesystem/file-ops-service.ts +5 -7
  442. package/src/tools/skills/skill-script-runner.ts +24 -9
  443. package/src/tools/skills/skill-tool-factory.ts +1 -0
  444. package/src/tools/tasks/work-item-enqueue.ts +2 -2
  445. package/src/tools/terminal/evaluate-typescript.ts +21 -12
  446. package/src/tools/terminal/parser.ts +50 -0
  447. package/src/tools/watcher/delete.ts +6 -0
  448. package/src/tools/weather/service.ts +1 -1
  449. package/src/twitter/client.ts +190 -24
  450. package/src/twitter/session.ts +4 -3
  451. package/src/util/clipboard.ts +1 -1
  452. package/src/util/errors.ts +65 -8
  453. package/src/util/fs.ts +40 -0
  454. package/src/util/json.ts +10 -0
  455. package/src/util/log-redact.ts +189 -0
  456. package/src/util/logger.ts +25 -18
  457. package/src/util/object.ts +3 -0
  458. package/src/util/platform.ts +72 -365
  459. package/src/util/pricing.ts +1 -1
  460. package/src/util/promise-guard.ts +1 -1
  461. package/src/util/retry.ts +19 -0
  462. package/src/util/row-mapper.ts +79 -0
  463. package/src/util/silently.ts +21 -0
  464. package/src/watcher/engine.ts +5 -1
  465. package/src/watcher/provider-types.ts +20 -0
  466. package/src/watcher/providers/github.ts +156 -0
  467. package/src/watcher/providers/gmail.ts +1 -0
  468. package/src/watcher/providers/google-calendar.ts +1 -0
  469. package/src/watcher/providers/linear.ts +460 -0
  470. package/src/watcher/providers/slack.ts +1 -0
  471. package/src/work-items/work-item-runner.ts +1 -1
  472. package/src/workspace/git-service.ts +1 -1
  473. package/src/workspace/provider-commit-message-generator.ts +51 -22
  474. package/src/__tests__/call-bridge.test.ts +0 -517
  475. package/src/__tests__/session-process-bridge.test.ts +0 -244
  476. package/src/calls/call-bridge.ts +0 -168
  477. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +0 -137
  478. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +0 -280
  479. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +0 -144
  480. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +0 -136
  481. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +0 -95
  482. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +0 -267
  483. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +0 -110
  484. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +0 -235
  485. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +0 -142
  486. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +0 -150
  487. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +0 -199
@@ -1,2467 +1,57 @@
1
- import * as net from 'node:net';
2
- import { getConfig, loadRawConfig, saveRawConfig } from '../../config/loader.js';
3
- import { initializeProviders } from '../../providers/registry.js';
4
- import { addRule, removeRule, updateRule, getAllRules, acceptStarterBundle } from '../../permissions/trust-store.js';
5
- import { classifyRisk, check, generateAllowlistOptions, generateScopeOptions } from '../../permissions/checker.js';
6
- import { isSideEffectTool } from '../../tools/executor.js';
7
- import { resolveExecutionTarget, type ManifestOverride } from '../../tools/execution-target.js';
8
- import { getAllTools, getTool } from '../../tools/registry.js';
9
- import { loadSkillCatalog } from '../../config/skills.js';
10
- import { parseToolManifestFile } from '../../skills/tool-manifest.js';
11
- import { join } from 'node:path';
12
- import { listSchedules, updateSchedule, deleteSchedule, describeCronExpression } from '../../schedule/schedule-store.js';
13
- import { listReminders, cancelReminder } from '../../tools/reminder/reminder-store.js';
14
- import { getSecureKey, setSecureKey, deleteSecureKey } from '../../security/secure-keys.js';
15
- import { upsertCredentialMetadata, deleteCredentialMetadata, getCredentialMetadata } from '../../tools/credentials/metadata-store.js';
16
- import { postToSlackWebhook } from '../../slack/slack-webhook.js';
17
- import { getApp } from '../../memory/app-store.js';
18
- import * as externalConversationStore from '../../memory/external-conversation-store.js';
19
- import { readHttpToken } from '../../util/platform.js';
20
- import type {
21
- ModelSetRequest,
22
- ImageGenModelSetRequest,
23
- AddTrustRule,
24
- RemoveTrustRule,
25
- UpdateTrustRule,
26
- ScheduleToggle,
27
- ScheduleRemove,
28
- ReminderCancel,
29
- ShareToSlackRequest,
30
- SlackWebhookConfigRequest,
31
- IngressConfigRequest,
32
- VercelApiConfigRequest,
33
- TwitterIntegrationConfigRequest,
34
- TelegramConfigRequest,
35
- TwilioConfigRequest,
36
- ChannelReadinessRequest,
37
- GuardianVerificationRequest,
38
- ToolPermissionSimulateRequest,
39
- } from '../ipc-protocol.js';
40
- import {
41
- hasTwilioCredentials,
42
- listIncomingPhoneNumbers,
43
- searchAvailableNumbers,
44
- provisionPhoneNumber,
45
- updatePhoneNumberWebhooks,
46
- getTollFreeVerificationStatus,
47
- submitTollFreeVerification,
48
- updateTollFreeVerification,
49
- deleteTollFreeVerification,
50
- getPhoneNumberSid,
51
- releasePhoneNumber,
52
- fetchMessageStatus,
53
- type TollFreeVerificationSubmitParams,
54
- } from '../../calls/twilio-rest.js';
55
- import {
56
- getTwilioVoiceWebhookUrl,
57
- getTwilioStatusCallbackUrl,
58
- getTwilioSmsWebhookUrl,
59
- type IngressConfig,
60
- } from '../../inbound/public-ingress-urls.js';
61
- import { createVerificationChallenge, getGuardianBinding, revokeBinding as revokeGuardianBinding } from '../../runtime/channel-guardian-service.js';
62
- import { createReadinessService, type ChannelReadinessService } from '../../runtime/channel-readiness-service.js';
63
- import { log, CONFIG_RELOAD_DEBOUNCE_MS, defineHandlers, type HandlerContext } from './shared.js';
64
- import { MODEL_TO_PROVIDER } from '../session-slash.js';
65
-
66
- // Lazily capture the env-provided INGRESS_PUBLIC_BASE_URL on first access
67
- // rather than at module load time. The daemon loads ~/.vellum/.env inside
68
- // runDaemon() (see lifecycle.ts), which runs AFTER static ES module imports
69
- // resolve. A module-level snapshot would miss dotenv-provided values.
70
- let _originalIngressEnvCaptured = false;
71
- let _originalIngressEnv: string | undefined;
72
- function getOriginalIngressEnv(): string | undefined {
73
- if (!_originalIngressEnvCaptured) {
74
- _originalIngressEnv = process.env.INGRESS_PUBLIC_BASE_URL;
75
- _originalIngressEnvCaptured = true;
76
- }
77
- return _originalIngressEnv;
78
- }
79
-
80
- const TELEGRAM_BOT_TOKEN_IN_URL_PATTERN = /\/bot\d{8,10}:[A-Za-z0-9_-]{30,120}\//g;
81
- const TELEGRAM_BOT_TOKEN_PATTERN = /(?<![A-Za-z0-9_])\d{8,10}:[A-Za-z0-9_-]{30,120}(?![A-Za-z0-9_])/g;
82
-
83
- function redactTelegramBotTokens(value: string): string {
84
- return value
85
- .replace(TELEGRAM_BOT_TOKEN_IN_URL_PATTERN, '/bot[REDACTED]/')
86
- .replace(TELEGRAM_BOT_TOKEN_PATTERN, '[REDACTED]');
87
- }
88
-
89
- function summarizeTelegramError(err: unknown): string {
90
- const parts: string[] = [];
91
- if (err instanceof Error) {
92
- parts.push(err.message);
93
- } else {
94
- parts.push(String(err));
95
- }
96
- const path = (err as { path?: unknown })?.path;
97
- if (typeof path === 'string' && path.length > 0) {
98
- parts.push(`path=${path}`);
99
- }
100
- const code = (err as { code?: unknown })?.code;
101
- if (typeof code === 'string' && code.length > 0) {
102
- parts.push(`code=${code}`);
103
- }
104
- return redactTelegramBotTokens(parts.join(' '));
105
- }
106
-
107
- export function handleModelGet(socket: net.Socket, ctx: HandlerContext): void {
108
- const config = getConfig();
109
- const configured = Object.keys(config.apiKeys).filter((k) => !!config.apiKeys[k]);
110
- if (!configured.includes('ollama')) configured.push('ollama');
111
- ctx.send(socket, {
112
- type: 'model_info',
113
- model: config.model,
114
- provider: config.provider,
115
- configuredProviders: configured,
116
- });
117
- }
118
-
119
- export function handleModelSet(
120
- msg: ModelSetRequest,
121
- socket: net.Socket,
122
- ctx: HandlerContext,
123
- ): void {
124
- try {
125
- // If the requested model is already the current model AND the provider
126
- // is already aligned with what MODEL_TO_PROVIDER expects, skip expensive
127
- // reinitialization but still send model_info so the client confirms.
128
- // If the provider has drifted (e.g. manual config edit), fall through
129
- // so the full reinit path can repair it.
130
- {
131
- const current = getConfig();
132
- const expectedProvider = MODEL_TO_PROVIDER[msg.model];
133
- const providerAligned = !expectedProvider || current.provider === expectedProvider;
134
- if (msg.model === current.model && providerAligned) {
135
- const configured = Object.keys(current.apiKeys).filter((k) => !!current.apiKeys[k]);
136
- if (!configured.includes('ollama')) configured.push('ollama');
137
- ctx.send(socket, {
138
- type: 'model_info',
139
- model: current.model,
140
- provider: current.provider,
141
- configuredProviders: configured,
142
- });
143
- return;
144
- }
145
- }
146
-
147
- // Validate API key before switching
148
- const provider = MODEL_TO_PROVIDER[msg.model];
149
- if (provider && provider !== 'ollama') {
150
- const currentConfig = getConfig();
151
- if (!currentConfig.apiKeys[provider]) {
152
- // Send current model_info so the client resyncs its optimistic state
153
- // (don't use generic 'error' type — it would interrupt in-flight chat)
154
- const configured = Object.keys(currentConfig.apiKeys).filter((k) => !!currentConfig.apiKeys[k]);
155
- if (!configured.includes('ollama')) configured.push('ollama');
156
- ctx.send(socket, { type: 'model_info', model: currentConfig.model, provider: currentConfig.provider, configuredProviders: configured });
157
- return;
158
- }
159
- }
160
-
161
- // Use raw config to avoid persisting env-var API keys to disk
162
- const raw = loadRawConfig();
163
- raw.model = msg.model;
164
- // Infer provider from model ID to keep provider and model in sync
165
- raw.provider = provider ?? raw.provider;
166
-
167
- // Suppress the file watcher callback — handleModelSet already does
168
- // the full reload sequence; a redundant watcher-triggered reload
169
- // would incorrectly evict sessions created after this method returns.
170
- const wasSuppressed = ctx.suppressConfigReload;
171
- ctx.setSuppressConfigReload(true);
172
- try {
173
- saveRawConfig(raw);
174
- } catch (err) {
175
- ctx.setSuppressConfigReload(wasSuppressed);
176
- throw err;
177
- }
178
- ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
179
-
180
- // Re-initialize provider with the new model so LLM calls use it
181
- const config = getConfig();
182
- initializeProviders(config);
183
-
184
- // Evict idle sessions immediately; mark busy ones as stale so they
185
- // get recreated with the new provider once they finish processing.
186
- for (const [id, session] of ctx.sessions) {
187
- if (!session.isProcessing()) {
188
- session.dispose();
189
- ctx.sessions.delete(id);
190
- } else {
191
- session.markStale();
192
- }
193
- }
194
-
195
- ctx.updateConfigFingerprint();
196
-
197
- ctx.send(socket, {
198
- type: 'model_info',
199
- model: config.model,
200
- provider: config.provider,
201
- });
202
- } catch (err) {
203
- const message = err instanceof Error ? err.message : String(err);
204
- ctx.send(socket, { type: 'error', message: `Failed to set model: ${message}` });
205
- }
206
- }
207
-
208
- export function handleImageGenModelSet(
209
- msg: ImageGenModelSetRequest,
210
- _socket: net.Socket,
211
- ctx: HandlerContext,
212
- ): void {
213
- try {
214
- const raw = loadRawConfig();
215
- raw.imageGenModel = msg.model;
216
-
217
- const wasSuppressed = ctx.suppressConfigReload;
218
- ctx.setSuppressConfigReload(true);
219
- try {
220
- saveRawConfig(raw);
221
- } catch (err) {
222
- ctx.setSuppressConfigReload(wasSuppressed);
223
- throw err;
224
- }
225
- ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
226
-
227
- ctx.updateConfigFingerprint();
228
- log.info({ model: msg.model }, 'Image generation model updated');
229
- } catch (err) {
230
- const message = err instanceof Error ? err.message : String(err);
231
- log.error({ err }, `Failed to set image gen model: ${message}`);
232
- }
233
- }
234
-
235
- export function handleAddTrustRule(
236
- msg: AddTrustRule,
237
- _socket: net.Socket,
238
- _ctx: HandlerContext,
239
- ): void {
240
- try {
241
- const hasMetadata = msg.allowHighRisk != null
242
- || msg.executionTarget != null;
243
-
244
- addRule(
245
- msg.toolName,
246
- msg.pattern,
247
- msg.scope,
248
- msg.decision,
249
- undefined, // priority — use default
250
- hasMetadata
251
- ? {
252
- allowHighRisk: msg.allowHighRisk,
253
- executionTarget: msg.executionTarget,
254
- }
255
- : undefined,
256
- );
257
- log.info({ toolName: msg.toolName, pattern: msg.pattern, scope: msg.scope, decision: msg.decision }, 'Trust rule added via client');
258
- } catch (err) {
259
- log.error({ err, toolName: msg.toolName, pattern: msg.pattern, scope: msg.scope }, 'Failed to add trust rule via client');
260
- }
261
- }
262
-
263
- export function handleTrustRulesList(socket: net.Socket, ctx: HandlerContext): void {
264
- const rules = getAllRules();
265
- ctx.send(socket, { type: 'trust_rules_list_response', rules });
266
- }
267
-
268
- export function handleRemoveTrustRule(
269
- msg: RemoveTrustRule,
270
- _socket: net.Socket,
271
- _ctx: HandlerContext,
272
- ): void {
273
- try {
274
- const removed = removeRule(msg.id);
275
- if (!removed) {
276
- log.warn({ id: msg.id }, 'Trust rule not found for removal');
277
- } else {
278
- log.info({ id: msg.id }, 'Trust rule removed via client');
279
- }
280
- } catch (err) {
281
- log.error({ err }, 'Failed to remove trust rule');
282
- }
283
- }
284
-
285
- export function handleUpdateTrustRule(
286
- msg: UpdateTrustRule,
287
- _socket: net.Socket,
288
- _ctx: HandlerContext,
289
- ): void {
290
- try {
291
- updateRule(msg.id, {
292
- tool: msg.tool,
293
- pattern: msg.pattern,
294
- scope: msg.scope,
295
- decision: msg.decision,
296
- priority: msg.priority,
297
- });
298
- log.info({ id: msg.id }, 'Trust rule updated via client');
299
- } catch (err) {
300
- log.error({ err }, 'Failed to update trust rule');
301
- }
302
- }
303
-
304
- export function handleAcceptStarterBundle(
305
- socket: net.Socket,
306
- ctx: HandlerContext,
307
- ): void {
308
- try {
309
- const result = acceptStarterBundle();
310
- ctx.send(socket, {
311
- type: 'accept_starter_bundle_response',
312
- accepted: result.accepted,
313
- rulesAdded: result.rulesAdded,
314
- alreadyAccepted: result.alreadyAccepted,
315
- });
316
- log.info({ rulesAdded: result.rulesAdded, alreadyAccepted: result.alreadyAccepted }, 'Starter bundle accepted via client');
317
- } catch (err) {
318
- log.error({ err }, 'Failed to accept starter bundle');
319
- ctx.send(socket, { type: 'error', message: 'Failed to accept starter bundle' });
320
- }
321
- }
322
-
323
- export function handleSchedulesList(socket: net.Socket, ctx: HandlerContext): void {
324
- const jobs = listSchedules();
325
- ctx.send(socket, {
326
- type: 'schedules_list_response',
327
- schedules: jobs.map((j) => ({
328
- id: j.id,
329
- name: j.name,
330
- enabled: j.enabled,
331
- syntax: j.syntax,
332
- expression: j.expression,
333
- cronExpression: j.cronExpression,
334
- timezone: j.timezone,
335
- message: j.message,
336
- nextRunAt: j.nextRunAt,
337
- lastRunAt: j.lastRunAt,
338
- lastStatus: j.lastStatus,
339
- description: j.syntax === 'cron' ? describeCronExpression(j.cronExpression) : j.expression,
340
- })),
341
- });
342
- }
343
-
344
- export function handleScheduleToggle(
345
- msg: ScheduleToggle,
346
- socket: net.Socket,
347
- ctx: HandlerContext,
348
- ): void {
349
- try {
350
- updateSchedule(msg.id, { enabled: msg.enabled });
351
- log.info({ id: msg.id, enabled: msg.enabled }, 'Schedule toggled via client');
352
- } catch (err) {
353
- log.error({ err }, 'Failed to toggle schedule');
354
- }
355
- handleSchedulesList(socket, ctx);
356
- }
357
-
358
- export function handleScheduleRemove(
359
- msg: ScheduleRemove,
360
- socket: net.Socket,
361
- ctx: HandlerContext,
362
- ): void {
363
- try {
364
- const removed = deleteSchedule(msg.id);
365
- if (!removed) {
366
- log.warn({ id: msg.id }, 'Schedule not found for removal');
367
- } else {
368
- log.info({ id: msg.id }, 'Schedule removed via client');
369
- }
370
- } catch (err) {
371
- log.error({ err }, 'Failed to remove schedule');
372
- }
373
- handleSchedulesList(socket, ctx);
374
- }
375
-
376
- export function handleRemindersList(socket: net.Socket, ctx: HandlerContext): void {
377
- const items = listReminders();
378
- ctx.send(socket, {
379
- type: 'reminders_list_response',
380
- reminders: items.map((r) => ({
381
- id: r.id,
382
- label: r.label,
383
- message: r.message,
384
- fireAt: r.fireAt,
385
- mode: r.mode,
386
- status: r.status,
387
- firedAt: r.firedAt,
388
- createdAt: r.createdAt,
389
- })),
390
- });
391
- }
392
-
393
- export function handleReminderCancel(
394
- msg: ReminderCancel,
395
- socket: net.Socket,
396
- ctx: HandlerContext,
397
- ): void {
398
- try {
399
- const cancelled = cancelReminder(msg.id);
400
- if (!cancelled) {
401
- log.warn({ id: msg.id }, 'Reminder not found or already fired/cancelled');
402
- } else {
403
- log.info({ id: msg.id }, 'Reminder cancelled via client');
404
- }
405
- } catch (err) {
406
- log.error({ err }, 'Failed to cancel reminder');
407
- }
408
- handleRemindersList(socket, ctx);
409
- }
410
-
411
- export async function handleShareToSlack(
412
- msg: ShareToSlackRequest,
413
- socket: net.Socket,
414
- ctx: HandlerContext,
415
- ): Promise<void> {
416
- try {
417
- const config = loadRawConfig();
418
- const webhookUrl = config.slackWebhookUrl as string | undefined;
419
- if (!webhookUrl) {
420
- ctx.send(socket, {
421
- type: 'share_to_slack_response',
422
- success: false,
423
- error: 'No Slack webhook URL configured. Provide one here in the chat, or set it from the Settings page.',
424
- });
425
- return;
426
- }
427
-
428
- const app = getApp(msg.appId);
429
- if (!app) {
430
- ctx.send(socket, {
431
- type: 'share_to_slack_response',
432
- success: false,
433
- error: `App not found: ${msg.appId}`,
434
- });
435
- return;
436
- }
437
-
438
- await postToSlackWebhook(
439
- webhookUrl,
440
- app.name,
441
- app.description ?? '',
442
- '\u{1F4F1}',
443
- );
444
-
445
- ctx.send(socket, { type: 'share_to_slack_response', success: true });
446
- } catch (err) {
447
- const message = err instanceof Error ? err.message : String(err);
448
- log.error({ err, appId: msg.appId }, 'Failed to share app to Slack');
449
- ctx.send(socket, {
450
- type: 'share_to_slack_response',
451
- success: false,
452
- error: message,
453
- });
454
- }
455
- }
456
-
457
- export function handleSlackWebhookConfig(
458
- msg: SlackWebhookConfigRequest,
459
- socket: net.Socket,
460
- ctx: HandlerContext,
461
- ): void {
462
- try {
463
- const config = loadRawConfig();
464
- if (msg.action === 'get') {
465
- ctx.send(socket, {
466
- type: 'slack_webhook_config_response',
467
- webhookUrl: (config.slackWebhookUrl as string) ?? undefined,
468
- success: true,
469
- });
470
- } else {
471
- config.slackWebhookUrl = msg.webhookUrl ?? '';
472
- saveRawConfig(config);
473
- ctx.send(socket, {
474
- type: 'slack_webhook_config_response',
475
- success: true,
476
- });
477
- }
478
- } catch (err) {
479
- const message = err instanceof Error ? err.message : String(err);
480
- log.error({ err }, 'Failed to handle Slack webhook config');
481
- ctx.send(socket, {
482
- type: 'slack_webhook_config_response',
483
- success: false,
484
- error: message,
485
- });
486
- }
487
- }
488
-
489
- function computeGatewayTarget(): string {
490
- if (process.env.GATEWAY_INTERNAL_BASE_URL) {
491
- return process.env.GATEWAY_INTERNAL_BASE_URL.replace(/\/+$/, '');
492
- }
493
- const portRaw = process.env.GATEWAY_PORT || '7830';
494
- const port = Number(portRaw) || 7830;
495
- return `http://127.0.0.1:${port}`;
496
- }
497
-
498
- /**
499
- * Best-effort call to the gateway's internal reconcile endpoint so that
500
- * Telegram webhook registration is updated immediately when the ingress
501
- * URL changes, without requiring a gateway restart.
502
- */
503
- function triggerGatewayReconcile(ingressPublicBaseUrl: string | undefined): void {
504
- const gatewayBase = computeGatewayTarget();
505
- const token = readHttpToken();
506
- if (!token) {
507
- log.debug('Skipping gateway reconcile trigger: no HTTP bearer token available');
508
- return;
509
- }
510
-
511
- const url = `${gatewayBase}/internal/telegram/reconcile`;
512
- const body = JSON.stringify({ ingressPublicBaseUrl: ingressPublicBaseUrl ?? '' });
513
-
514
- fetch(url, {
515
- method: 'POST',
516
- headers: {
517
- 'Content-Type': 'application/json',
518
- 'Authorization': `Bearer ${token}`,
519
- },
520
- body,
521
- signal: AbortSignal.timeout(5_000),
522
- }).then((res) => {
523
- if (res.ok) {
524
- log.info('Gateway Telegram webhook reconcile triggered successfully');
525
- } else {
526
- log.warn({ status: res.status }, 'Gateway Telegram webhook reconcile returned non-OK status');
527
- }
528
- }).catch((err) => {
529
- log.debug({ err }, 'Gateway Telegram webhook reconcile failed (gateway may not be running)');
530
- });
531
- }
532
-
533
1
  /**
534
- * Best-effort Twilio webhook sync helper.
535
- *
536
- * Computes the voice, status-callback, and SMS webhook URLs from the current
537
- * ingress config and pushes them to the Twilio IncomingPhoneNumber API.
2
+ * Config handler barrel — re-exports all config domain handlers and assembles
3
+ * the combined `configHandlers` dispatch map.
538
4
  *
539
- * Returns `{ success, warning }`. When the update fails, `success` is false
540
- * and `warning` contains a human-readable message. Callers should treat
541
- * failure as non-fatal so that the primary operation (provision, assign,
542
- * ingress save) still succeeds.
543
- */
544
- async function syncTwilioWebhooks(
545
- phoneNumber: string,
546
- accountSid: string,
547
- authToken: string,
548
- ingressConfig: IngressConfig,
549
- ): Promise<{ success: boolean; warning?: string }> {
550
- try {
551
- const voiceUrl = getTwilioVoiceWebhookUrl(ingressConfig);
552
- const statusCallbackUrl = getTwilioStatusCallbackUrl(ingressConfig);
553
- const smsUrl = getTwilioSmsWebhookUrl(ingressConfig);
554
- await updatePhoneNumberWebhooks(accountSid, authToken, phoneNumber, {
555
- voiceUrl,
556
- statusCallbackUrl,
557
- smsUrl,
558
- });
559
- log.info({ phoneNumber }, 'Twilio webhooks configured successfully');
560
- return { success: true };
561
- } catch (err) {
562
- const message = err instanceof Error ? err.message : String(err);
563
- log.warn({ err, phoneNumber }, `Webhook configuration skipped: ${message}`);
564
- return { success: false, warning: `Webhook configuration skipped: ${message}` };
565
- }
566
- }
567
-
568
- export async function handleIngressConfig(
569
- msg: IngressConfigRequest,
570
- socket: net.Socket,
571
- ctx: HandlerContext,
572
- ): Promise<void> {
573
- const localGatewayTarget = computeGatewayTarget();
574
- try {
575
- if (msg.action === 'get') {
576
- const raw = loadRawConfig();
577
- const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
578
- const publicBaseUrl = (ingress.publicBaseUrl as string) ?? '';
579
- // Backward compatibility: if `enabled` was never explicitly set,
580
- // infer from whether a publicBaseUrl is configured so existing users
581
- // who predate the toggle aren't silently disabled.
582
- const enabled = (ingress.enabled as boolean | undefined) ?? (publicBaseUrl ? true : false);
583
- ctx.send(socket, { type: 'ingress_config_response', enabled, publicBaseUrl, localGatewayTarget, success: true });
584
- } else if (msg.action === 'set') {
585
- const value = (msg.publicBaseUrl ?? '').trim().replace(/\/+$/, '');
586
- // Ensure we capture the original env value before any mutation below
587
- getOriginalIngressEnv();
588
- const raw = loadRawConfig();
589
-
590
- // Update ingress.publicBaseUrl — this is the single source of truth for
591
- // the canonical public ingress URL. The gateway receives this value via
592
- // the INGRESS_PUBLIC_BASE_URL env var at spawn time (see hatch.ts).
593
- // The gateway also validates Twilio signatures against forwarded public
594
- // URL headers, so local tunnel updates generally apply without restarts.
595
- const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
596
- ingress.publicBaseUrl = value || undefined;
597
- if (msg.enabled !== undefined) {
598
- ingress.enabled = msg.enabled;
599
- }
600
-
601
- const wasSuppressed = ctx.suppressConfigReload;
602
- ctx.setSuppressConfigReload(true);
603
- try {
604
- saveRawConfig({ ...raw, ingress });
605
- } catch (err) {
606
- ctx.setSuppressConfigReload(wasSuppressed);
607
- throw err;
608
- }
609
- ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
610
-
611
- // Propagate to the gateway's process environment so it picks up the
612
- // new URL when it is restarted. For the local-deployment path the
613
- // gateway runs as a child process that inherited the assistant's env,
614
- // so updating process.env here ensures the value is visible when the
615
- // gateway is restarted (e.g. by the self-upgrade skill or a manual
616
- // `pkill -f gateway`).
617
- // Only export the URL when ingress is enabled; clearing it when
618
- // disabled ensures the gateway stops accepting inbound webhooks.
619
- const isEnabled = (ingress.enabled as boolean | undefined) ?? (value ? true : false);
620
- if (value && isEnabled) {
621
- process.env.INGRESS_PUBLIC_BASE_URL = value;
622
- } else if (isEnabled && getOriginalIngressEnv() !== undefined) {
623
- // Ingress is enabled but the user cleared the URL — fall back to the
624
- // env var that was present when the process started.
625
- process.env.INGRESS_PUBLIC_BASE_URL = getOriginalIngressEnv()!;
626
- } else {
627
- // Ingress is disabled or no URL is configured and no startup env var
628
- // exists — remove the env var so the gateway stops accepting webhooks.
629
- delete process.env.INGRESS_PUBLIC_BASE_URL;
630
- }
631
-
632
- ctx.send(socket, { type: 'ingress_config_response', enabled: isEnabled, publicBaseUrl: value, localGatewayTarget, success: true });
633
-
634
- // Trigger immediate Telegram webhook reconcile on the gateway so
635
- // that changing the ingress URL takes effect without a restart.
636
- // Called unconditionally so the gateway clears its in-memory URL
637
- // when ingress is disabled, preventing stale re-registration on
638
- // credential rotation.
639
- // Use the effective URL from process.env (which accounts for the
640
- // fallback branch above) rather than the raw `value` from the UI.
641
- const effectiveUrl = isEnabled ? process.env.INGRESS_PUBLIC_BASE_URL : undefined;
642
- triggerGatewayReconcile(effectiveUrl);
643
-
644
- // Best-effort Twilio webhook reconciliation: when ingress is being
645
- // enabled/updated and Twilio numbers are assigned with valid credentials,
646
- // push the new webhook URLs to Twilio so calls and SMS route correctly.
647
- if (isEnabled && hasTwilioCredentials()) {
648
- const currentConfig = loadRawConfig();
649
- const smsConfig = (currentConfig?.sms ?? {}) as Record<string, unknown>;
650
- const assignedNumbers = new Set<string>();
651
- const legacyNumber = (smsConfig.phoneNumber as string) ?? '';
652
- if (legacyNumber) assignedNumbers.add(legacyNumber);
653
-
654
- const assistantPhoneNumbers = smsConfig.assistantPhoneNumbers;
655
- if (assistantPhoneNumbers && typeof assistantPhoneNumbers === 'object' && !Array.isArray(assistantPhoneNumbers)) {
656
- for (const number of Object.values(assistantPhoneNumbers as Record<string, unknown>)) {
657
- if (typeof number === 'string' && number) {
658
- assignedNumbers.add(number);
659
- }
660
- }
661
- }
662
-
663
- if (assignedNumbers.size > 0) {
664
- const acctSid = getSecureKey('credential:twilio:account_sid')!;
665
- const acctToken = getSecureKey('credential:twilio:auth_token')!;
666
- // Fire-and-forget: webhook sync failure must not block the ingress save.
667
- // Reconcile every assigned number so assistant-scoped mappings do not
668
- // retain stale Twilio webhook URLs after ingress URL changes.
669
- for (const assignedNumber of assignedNumbers) {
670
- syncTwilioWebhooks(assignedNumber, acctSid, acctToken, currentConfig as IngressConfig)
671
- .catch(() => {
672
- // Already logged inside syncTwilioWebhooks
673
- });
674
- }
675
- }
676
- }
677
- } else {
678
- ctx.send(socket, { type: 'ingress_config_response', enabled: false, publicBaseUrl: '', localGatewayTarget, success: false, error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}` });
679
- }
680
- } catch (err) {
681
- const message = err instanceof Error ? err.message : String(err);
682
- ctx.send(socket, { type: 'ingress_config_response', enabled: false, publicBaseUrl: '', localGatewayTarget, success: false, error: message });
683
- }
684
- }
685
-
686
- export function handleVercelApiConfig(
687
- msg: VercelApiConfigRequest,
688
- socket: net.Socket,
689
- ctx: HandlerContext,
690
- ): void {
691
- try {
692
- if (msg.action === 'get') {
693
- const existing = getSecureKey('credential:vercel:api_token');
694
- ctx.send(socket, {
695
- type: 'vercel_api_config_response',
696
- hasToken: !!existing,
697
- success: true,
698
- });
699
- } else if (msg.action === 'set') {
700
- if (!msg.apiToken) {
701
- ctx.send(socket, {
702
- type: 'vercel_api_config_response',
703
- hasToken: false,
704
- success: false,
705
- error: 'apiToken is required for set action',
706
- });
707
- return;
708
- }
709
- const stored = setSecureKey('credential:vercel:api_token', msg.apiToken);
710
- if (!stored) {
711
- ctx.send(socket, {
712
- type: 'vercel_api_config_response',
713
- hasToken: false,
714
- success: false,
715
- error: 'Failed to store API token in secure storage',
716
- });
717
- return;
718
- }
719
- upsertCredentialMetadata('vercel', 'api_token', {
720
- allowedTools: ['publish_page', 'unpublish_page'],
721
- });
722
- ctx.send(socket, {
723
- type: 'vercel_api_config_response',
724
- hasToken: true,
725
- success: true,
726
- });
727
- } else {
728
- deleteSecureKey('credential:vercel:api_token');
729
- deleteCredentialMetadata('vercel', 'api_token');
730
- ctx.send(socket, {
731
- type: 'vercel_api_config_response',
732
- hasToken: false,
733
- success: true,
734
- });
735
- }
736
- } catch (err) {
737
- const message = err instanceof Error ? err.message : String(err);
738
- log.error({ err }, 'Failed to handle Vercel API config');
739
- ctx.send(socket, {
740
- type: 'vercel_api_config_response',
741
- hasToken: false,
742
- success: false,
743
- error: message,
744
- });
745
- }
746
- }
747
-
748
- export function handleTwitterIntegrationConfig(
749
- msg: TwitterIntegrationConfigRequest,
750
- socket: net.Socket,
751
- ctx: HandlerContext,
752
- ): void {
753
- try {
754
- if (msg.action === 'get') {
755
- const raw = loadRawConfig();
756
- const mode = (raw.twitterIntegrationMode as 'local_byo' | 'managed' | undefined) ?? 'local_byo';
757
- const strategy = (raw.twitterOperationStrategy as 'oauth' | 'browser' | 'auto' | undefined) ?? 'auto';
758
- const strategyConfigured = Object.prototype.hasOwnProperty.call(raw, 'twitterOperationStrategy');
759
- const localClientConfigured = !!getSecureKey('credential:integration:twitter:oauth_client_id');
760
- const connected = !!getSecureKey('credential:integration:twitter:access_token');
761
- const meta = getCredentialMetadata('integration:twitter', 'access_token');
762
- ctx.send(socket, {
763
- type: 'twitter_integration_config_response',
764
- success: true,
765
- mode,
766
- managedAvailable: false,
767
- localClientConfigured,
768
- connected,
769
- accountInfo: meta?.accountInfo ?? undefined,
770
- strategy,
771
- strategyConfigured,
772
- });
773
- } else if (msg.action === 'get_strategy') {
774
- const raw = loadRawConfig();
775
- const strategy = (raw.twitterOperationStrategy as 'oauth' | 'browser' | 'auto' | undefined) ?? 'auto';
776
- const strategyConfigured = Object.prototype.hasOwnProperty.call(raw, 'twitterOperationStrategy');
777
- ctx.send(socket, {
778
- type: 'twitter_integration_config_response',
779
- success: true,
780
- managedAvailable: false,
781
- localClientConfigured: !!getSecureKey('credential:integration:twitter:oauth_client_id'),
782
- connected: !!getSecureKey('credential:integration:twitter:access_token'),
783
- strategy,
784
- strategyConfigured,
785
- });
786
- } else if (msg.action === 'set_strategy') {
787
- const valid = ['oauth', 'browser', 'auto'];
788
- const value = msg.strategy;
789
- if (!value || !valid.includes(value)) {
790
- ctx.send(socket, {
791
- type: 'twitter_integration_config_response',
792
- success: false,
793
- managedAvailable: false,
794
- localClientConfigured: false,
795
- connected: false,
796
- error: `Invalid strategy value: ${String(value)}. Must be one of: ${valid.join(', ')}`,
797
- });
798
- return;
799
- }
800
- const raw = loadRawConfig();
801
- raw.twitterOperationStrategy = value;
802
- saveRawConfig(raw);
803
- ctx.send(socket, {
804
- type: 'twitter_integration_config_response',
805
- success: true,
806
- managedAvailable: false,
807
- localClientConfigured: !!getSecureKey('credential:integration:twitter:oauth_client_id'),
808
- connected: !!getSecureKey('credential:integration:twitter:access_token'),
809
- strategy: value as 'oauth' | 'browser' | 'auto',
810
- strategyConfigured: true,
811
- });
812
- } else if (msg.action === 'set_mode') {
813
- const raw = loadRawConfig();
814
- raw.twitterIntegrationMode = msg.mode ?? 'local_byo';
815
- saveRawConfig(raw);
816
- ctx.send(socket, {
817
- type: 'twitter_integration_config_response',
818
- success: true,
819
- mode: msg.mode ?? 'local_byo',
820
- managedAvailable: false,
821
- localClientConfigured: !!getSecureKey('credential:integration:twitter:oauth_client_id'),
822
- connected: !!getSecureKey('credential:integration:twitter:access_token'),
823
- });
824
- } else if (msg.action === 'set_local_client') {
825
- if (!msg.clientId) {
826
- ctx.send(socket, {
827
- type: 'twitter_integration_config_response',
828
- success: false,
829
- managedAvailable: false,
830
- localClientConfigured: false,
831
- connected: false,
832
- error: 'clientId is required for set_local_client action',
833
- });
834
- return;
835
- }
836
- const previousClientId = getSecureKey('credential:integration:twitter:oauth_client_id');
837
- const storedId = setSecureKey('credential:integration:twitter:oauth_client_id', msg.clientId);
838
- if (!storedId) {
839
- ctx.send(socket, {
840
- type: 'twitter_integration_config_response',
841
- success: false,
842
- managedAvailable: false,
843
- localClientConfigured: false,
844
- connected: false,
845
- error: 'Failed to store client ID in secure storage',
846
- });
847
- return;
848
- }
849
- if (msg.clientSecret) {
850
- const storedSecret = setSecureKey('credential:integration:twitter:oauth_client_secret', msg.clientSecret);
851
- if (!storedSecret) {
852
- // Roll back the client ID to its previous value to avoid inconsistent OAuth state
853
- if (previousClientId) {
854
- setSecureKey('credential:integration:twitter:oauth_client_id', previousClientId);
855
- } else {
856
- deleteSecureKey('credential:integration:twitter:oauth_client_id');
857
- }
858
- ctx.send(socket, {
859
- type: 'twitter_integration_config_response',
860
- success: false,
861
- managedAvailable: false,
862
- localClientConfigured: !!previousClientId,
863
- connected: false,
864
- error: 'Failed to store client secret in secure storage',
865
- });
866
- return;
867
- }
868
- } else {
869
- // Clear any stale secret when updating client without a secret (e.g. switching to PKCE)
870
- deleteSecureKey('credential:integration:twitter:oauth_client_secret');
871
- }
872
- ctx.send(socket, {
873
- type: 'twitter_integration_config_response',
874
- success: true,
875
- managedAvailable: false,
876
- localClientConfigured: true,
877
- connected: !!getSecureKey('credential:integration:twitter:access_token'),
878
- });
879
- } else if (msg.action === 'clear_local_client') {
880
- // If connected, disconnect first
881
- if (getSecureKey('credential:integration:twitter:access_token')) {
882
- deleteSecureKey('credential:integration:twitter:access_token');
883
- deleteSecureKey('credential:integration:twitter:refresh_token');
884
- deleteCredentialMetadata('integration:twitter', 'access_token');
885
- }
886
- deleteSecureKey('credential:integration:twitter:oauth_client_id');
887
- deleteSecureKey('credential:integration:twitter:oauth_client_secret');
888
- ctx.send(socket, {
889
- type: 'twitter_integration_config_response',
890
- success: true,
891
- managedAvailable: false,
892
- localClientConfigured: false,
893
- connected: false,
894
- });
895
- } else if (msg.action === 'disconnect') {
896
- deleteSecureKey('credential:integration:twitter:access_token');
897
- deleteSecureKey('credential:integration:twitter:refresh_token');
898
- deleteCredentialMetadata('integration:twitter', 'access_token');
899
- ctx.send(socket, {
900
- type: 'twitter_integration_config_response',
901
- success: true,
902
- managedAvailable: false,
903
- localClientConfigured: !!getSecureKey('credential:integration:twitter:oauth_client_id'),
904
- connected: false,
905
- });
906
- } else {
907
- ctx.send(socket, {
908
- type: 'twitter_integration_config_response',
909
- success: false,
910
- managedAvailable: false,
911
- localClientConfigured: false,
912
- connected: false,
913
- error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}`,
914
- });
915
- }
916
- } catch (err) {
917
- const message = err instanceof Error ? err.message : String(err);
918
- log.error({ err }, 'Failed to handle Twitter integration config');
919
- ctx.send(socket, {
920
- type: 'twitter_integration_config_response',
921
- success: false,
922
- managedAvailable: false,
923
- localClientConfigured: false,
924
- connected: false,
925
- error: message,
926
- });
927
- }
928
- }
929
-
930
- export async function handleTelegramConfig(
931
- msg: TelegramConfigRequest,
932
- socket: net.Socket,
933
- ctx: HandlerContext,
934
- ): Promise<void> {
935
- try {
936
- if (msg.action === 'get') {
937
- const hasBotToken = !!getSecureKey('credential:telegram:bot_token');
938
- const hasWebhookSecret = !!getSecureKey('credential:telegram:webhook_secret');
939
- const meta = getCredentialMetadata('telegram', 'bot_token');
940
- const botUsername = meta?.accountInfo ?? undefined;
941
- ctx.send(socket, {
942
- type: 'telegram_config_response',
943
- success: true,
944
- hasBotToken,
945
- botUsername,
946
- connected: hasBotToken && hasWebhookSecret,
947
- hasWebhookSecret,
948
- });
949
- } else if (msg.action === 'set') {
950
- // Resolve token: prefer explicit msg.botToken, fall back to secure storage.
951
- // Track provenance so we only rollback tokens that were freshly provided.
952
- const isNewToken = !!msg.botToken;
953
- const botToken = msg.botToken || getSecureKey('credential:telegram:bot_token');
954
- if (!botToken) {
955
- ctx.send(socket, {
956
- type: 'telegram_config_response',
957
- success: false,
958
- hasBotToken: false,
959
- connected: false,
960
- hasWebhookSecret: false,
961
- error: 'botToken is required for set action',
962
- });
963
- return;
964
- }
965
-
966
- // Validate token via Telegram getMe API
967
- let botUsername: string;
968
- try {
969
- const res = await fetch(`https://api.telegram.org/bot${botToken}/getMe`);
970
- if (!res.ok) {
971
- const body = await res.text();
972
- ctx.send(socket, {
973
- type: 'telegram_config_response',
974
- success: false,
975
- hasBotToken: false,
976
- connected: false,
977
- hasWebhookSecret: false,
978
- error: `Telegram API validation failed: ${body}`,
979
- });
980
- return;
981
- }
982
- const data = await res.json() as { ok: boolean; result?: { username?: string } };
983
- if (!data.ok || !data.result?.username) {
984
- ctx.send(socket, {
985
- type: 'telegram_config_response',
986
- success: false,
987
- hasBotToken: false,
988
- connected: false,
989
- hasWebhookSecret: false,
990
- error: 'Telegram API returned unexpected response',
991
- });
992
- return;
993
- }
994
- botUsername = data.result.username;
995
- } catch (err) {
996
- const message = summarizeTelegramError(err);
997
- ctx.send(socket, {
998
- type: 'telegram_config_response',
999
- success: false,
1000
- hasBotToken: false,
1001
- connected: false,
1002
- hasWebhookSecret: false,
1003
- error: `Failed to validate bot token: ${message}`,
1004
- });
1005
- return;
1006
- }
1007
-
1008
- // Store bot token securely
1009
- const stored = setSecureKey('credential:telegram:bot_token', botToken);
1010
- if (!stored) {
1011
- ctx.send(socket, {
1012
- type: 'telegram_config_response',
1013
- success: false,
1014
- hasBotToken: false,
1015
- connected: false,
1016
- hasWebhookSecret: false,
1017
- error: 'Failed to store bot token in secure storage',
1018
- });
1019
- return;
1020
- }
1021
-
1022
- // Store metadata with bot username
1023
- upsertCredentialMetadata('telegram', 'bot_token', {
1024
- accountInfo: botUsername,
1025
- });
1026
-
1027
- // Ensure webhook secret exists (generate if missing)
1028
- let hasWebhookSecret = !!getSecureKey('credential:telegram:webhook_secret');
1029
- if (!hasWebhookSecret) {
1030
- const { randomUUID } = await import('node:crypto');
1031
- const webhookSecret = randomUUID();
1032
- const secretStored = setSecureKey('credential:telegram:webhook_secret', webhookSecret);
1033
- if (secretStored) {
1034
- upsertCredentialMetadata('telegram', 'webhook_secret', {});
1035
- hasWebhookSecret = true;
1036
- } else {
1037
- // Only roll back the bot token if it was freshly provided.
1038
- // When the token came from secure storage it was already valid
1039
- // configuration; deleting it would destroy working state.
1040
- if (isNewToken) {
1041
- deleteSecureKey('credential:telegram:bot_token');
1042
- deleteCredentialMetadata('telegram', 'bot_token');
1043
- }
1044
- ctx.send(socket, {
1045
- type: 'telegram_config_response',
1046
- success: false,
1047
- hasBotToken: !isNewToken,
1048
- connected: false,
1049
- hasWebhookSecret: false,
1050
- error: 'Failed to store webhook secret',
1051
- });
1052
- return;
1053
- }
1054
- } else {
1055
- // Self-heal: ensure metadata exists even when the secret was
1056
- // already present (covers previously lost/corrupted metadata).
1057
- upsertCredentialMetadata('telegram', 'webhook_secret', {});
1058
- }
1059
-
1060
- ctx.send(socket, {
1061
- type: 'telegram_config_response',
1062
- success: true,
1063
- hasBotToken: true,
1064
- botUsername,
1065
- connected: true,
1066
- hasWebhookSecret,
1067
- });
1068
-
1069
- // Trigger gateway reconcile so the webhook registration updates immediately
1070
- const effectiveUrl = process.env.INGRESS_PUBLIC_BASE_URL;
1071
- if (effectiveUrl) {
1072
- triggerGatewayReconcile(effectiveUrl);
1073
- }
1074
- } else if (msg.action === 'clear') {
1075
- // Deregister the Telegram webhook before deleting credentials.
1076
- // The gateway reconcile short-circuits when credentials are absent,
1077
- // so we must call the Telegram API directly while the token is still
1078
- // available.
1079
- const botToken = getSecureKey('credential:telegram:bot_token');
1080
- if (botToken) {
1081
- try {
1082
- await fetch(`https://api.telegram.org/bot${botToken}/deleteWebhook`);
1083
- } catch (err) {
1084
- log.warn(
1085
- { error: summarizeTelegramError(err) },
1086
- 'Failed to deregister Telegram webhook (proceeding with credential cleanup)',
1087
- );
1088
- }
1089
- }
1090
-
1091
- deleteSecureKey('credential:telegram:bot_token');
1092
- deleteCredentialMetadata('telegram', 'bot_token');
1093
- deleteSecureKey('credential:telegram:webhook_secret');
1094
- deleteCredentialMetadata('telegram', 'webhook_secret');
1095
-
1096
- ctx.send(socket, {
1097
- type: 'telegram_config_response',
1098
- success: true,
1099
- hasBotToken: false,
1100
- connected: false,
1101
- hasWebhookSecret: false,
1102
- });
1103
-
1104
- // Trigger reconcile to deregister webhook
1105
- const effectiveUrl = process.env.INGRESS_PUBLIC_BASE_URL;
1106
- if (effectiveUrl) {
1107
- triggerGatewayReconcile(effectiveUrl);
1108
- }
1109
- } else if (msg.action === 'set_commands') {
1110
- const storedToken = getSecureKey('credential:telegram:bot_token');
1111
- if (!storedToken) {
1112
- ctx.send(socket, {
1113
- type: 'telegram_config_response',
1114
- success: false,
1115
- hasBotToken: false,
1116
- connected: false,
1117
- hasWebhookSecret: false,
1118
- error: 'Bot token not configured. Run set action first.',
1119
- });
1120
- return;
1121
- }
1122
-
1123
- const commands = msg.commands ?? [
1124
- { command: 'new', description: 'Start a new conversation' },
1125
- { command: 'guardian_verify', description: 'Verify your guardian identity' },
1126
- ];
1127
-
1128
- try {
1129
- const res = await fetch(`https://api.telegram.org/bot${storedToken}/setMyCommands`, {
1130
- method: 'POST',
1131
- headers: { 'Content-Type': 'application/json' },
1132
- body: JSON.stringify({ commands }),
1133
- });
1134
- if (!res.ok) {
1135
- const body = await res.text();
1136
- ctx.send(socket, {
1137
- type: 'telegram_config_response',
1138
- success: false,
1139
- hasBotToken: true,
1140
- connected: !!getSecureKey('credential:telegram:webhook_secret'),
1141
- hasWebhookSecret: !!getSecureKey('credential:telegram:webhook_secret'),
1142
- error: `Failed to set bot commands: ${body}`,
1143
- });
1144
- return;
1145
- }
1146
- } catch (err) {
1147
- const message = summarizeTelegramError(err);
1148
- ctx.send(socket, {
1149
- type: 'telegram_config_response',
1150
- success: false,
1151
- hasBotToken: true,
1152
- connected: !!getSecureKey('credential:telegram:webhook_secret'),
1153
- hasWebhookSecret: !!getSecureKey('credential:telegram:webhook_secret'),
1154
- error: `Failed to set bot commands: ${message}`,
1155
- });
1156
- return;
1157
- }
1158
-
1159
- const hasBotToken = !!getSecureKey('credential:telegram:bot_token');
1160
- const hasWebhookSecret = !!getSecureKey('credential:telegram:webhook_secret');
1161
- ctx.send(socket, {
1162
- type: 'telegram_config_response',
1163
- success: true,
1164
- hasBotToken,
1165
- connected: hasBotToken && hasWebhookSecret,
1166
- hasWebhookSecret,
1167
- });
1168
- } else {
1169
- ctx.send(socket, {
1170
- type: 'telegram_config_response',
1171
- success: false,
1172
- hasBotToken: false,
1173
- connected: false,
1174
- hasWebhookSecret: false,
1175
- error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}`,
1176
- });
1177
- }
1178
- } catch (err) {
1179
- const message = err instanceof Error ? err.message : String(err);
1180
- log.error({ err }, 'Failed to handle Telegram config');
1181
- ctx.send(socket, {
1182
- type: 'telegram_config_response',
1183
- success: false,
1184
- hasBotToken: false,
1185
- connected: false,
1186
- hasWebhookSecret: false,
1187
- error: message,
1188
- });
1189
- }
1190
- }
1191
-
1192
- /** In-memory store for the last SMS send test result. Shared between sms_send_test and sms_doctor. */
1193
- let _lastTestResult: {
1194
- messageSid: string;
1195
- to: string;
1196
- initialStatus: string;
1197
- finalStatus: string;
1198
- errorCode?: string;
1199
- errorMessage?: string;
1200
- timestamp: number;
1201
- } | undefined;
1202
-
1203
- /** Map a Twilio error code to a human-readable remediation suggestion. */
1204
- function mapTwilioErrorRemediation(errorCode: string | undefined): string | undefined {
1205
- if (!errorCode) return undefined;
1206
- const map: Record<string, string> = {
1207
- '30003': 'Unreachable destination. The handset may be off or out of service.',
1208
- '30004': 'Message blocked by carrier or recipient.',
1209
- '30005': 'Unknown destination phone number. Verify the number is valid.',
1210
- '30006': 'Landline or unreachable carrier. SMS cannot be delivered to this number.',
1211
- '30007': 'Message flagged as spam by carrier. Adjust content or register for A2P.',
1212
- '30008': 'Unknown error from the carrier network.',
1213
- '21610': 'Recipient has opted out (STOP). Cannot send until they opt back in.',
1214
- };
1215
- return map[errorCode];
1216
- }
1217
-
1218
- export async function handleTwilioConfig(
1219
- msg: TwilioConfigRequest,
1220
- socket: net.Socket,
1221
- ctx: HandlerContext,
1222
- ): Promise<void> {
1223
- try {
1224
- if (msg.action === 'get') {
1225
- const hasCredentials = hasTwilioCredentials();
1226
- const raw = loadRawConfig();
1227
- const sms = (raw?.sms ?? {}) as Record<string, unknown>;
1228
- // When assistantId is provided, look up in assistantPhoneNumbers first,
1229
- // fall back to the legacy phoneNumber field
1230
- let phoneNumber: string;
1231
- if (msg.assistantId) {
1232
- const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
1233
- phoneNumber = mapping[msg.assistantId] ?? (sms.phoneNumber as string) ?? '';
1234
- } else {
1235
- phoneNumber = (sms.phoneNumber as string) ?? '';
1236
- }
1237
- ctx.send(socket, {
1238
- type: 'twilio_config_response',
1239
- success: true,
1240
- hasCredentials,
1241
- phoneNumber: phoneNumber || undefined,
1242
- });
1243
- } else if (msg.action === 'set_credentials') {
1244
- if (!msg.accountSid || !msg.authToken) {
1245
- ctx.send(socket, {
1246
- type: 'twilio_config_response',
1247
- success: false,
1248
- hasCredentials: hasTwilioCredentials(),
1249
- error: 'accountSid and authToken are required for set_credentials action',
1250
- });
1251
- return;
1252
- }
1253
-
1254
- // Validate credentials by calling the Twilio API
1255
- const authHeader = 'Basic ' + Buffer.from(`${msg.accountSid}:${msg.authToken}`).toString('base64');
1256
- try {
1257
- const res = await fetch(
1258
- `https://api.twilio.com/2010-04-01/Accounts/${msg.accountSid}.json`,
1259
- {
1260
- method: 'GET',
1261
- headers: { Authorization: authHeader },
1262
- },
1263
- );
1264
- if (!res.ok) {
1265
- const body = await res.text();
1266
- ctx.send(socket, {
1267
- type: 'twilio_config_response',
1268
- success: false,
1269
- hasCredentials: hasTwilioCredentials(),
1270
- error: `Twilio API validation failed (${res.status}): ${body}`,
1271
- });
1272
- return;
1273
- }
1274
- } catch (err) {
1275
- const message = err instanceof Error ? err.message : String(err);
1276
- ctx.send(socket, {
1277
- type: 'twilio_config_response',
1278
- success: false,
1279
- hasCredentials: hasTwilioCredentials(),
1280
- error: `Failed to validate Twilio credentials: ${message}`,
1281
- });
1282
- return;
1283
- }
1284
-
1285
- // Store credentials securely
1286
- const sidStored = setSecureKey('credential:twilio:account_sid', msg.accountSid);
1287
- if (!sidStored) {
1288
- ctx.send(socket, {
1289
- type: 'twilio_config_response',
1290
- success: false,
1291
- hasCredentials: false,
1292
- error: 'Failed to store Account SID in secure storage',
1293
- });
1294
- return;
1295
- }
1296
-
1297
- const tokenStored = setSecureKey('credential:twilio:auth_token', msg.authToken);
1298
- if (!tokenStored) {
1299
- // Roll back the Account SID
1300
- deleteSecureKey('credential:twilio:account_sid');
1301
- ctx.send(socket, {
1302
- type: 'twilio_config_response',
1303
- success: false,
1304
- hasCredentials: false,
1305
- error: 'Failed to store Auth Token in secure storage',
1306
- });
1307
- return;
1308
- }
1309
-
1310
- upsertCredentialMetadata('twilio', 'account_sid', {});
1311
- upsertCredentialMetadata('twilio', 'auth_token', {});
1312
-
1313
- ctx.send(socket, {
1314
- type: 'twilio_config_response',
1315
- success: true,
1316
- hasCredentials: true,
1317
- });
1318
- } else if (msg.action === 'clear_credentials') {
1319
- // Only clear authentication credentials (Account SID and Auth Token).
1320
- // Preserve the phone number in both config (sms.phoneNumber) and secure
1321
- // key (credential:twilio:phone_number) so that re-entering credentials
1322
- // resumes working without needing to reassign the number.
1323
- deleteSecureKey('credential:twilio:account_sid');
1324
- deleteSecureKey('credential:twilio:auth_token');
1325
- deleteCredentialMetadata('twilio', 'account_sid');
1326
- deleteCredentialMetadata('twilio', 'auth_token');
1327
-
1328
- ctx.send(socket, {
1329
- type: 'twilio_config_response',
1330
- success: true,
1331
- hasCredentials: false,
1332
- });
1333
- } else if (msg.action === 'provision_number') {
1334
- if (!hasTwilioCredentials()) {
1335
- ctx.send(socket, {
1336
- type: 'twilio_config_response',
1337
- success: false,
1338
- hasCredentials: false,
1339
- error: 'Twilio credentials not configured. Set credentials first.',
1340
- });
1341
- return;
1342
- }
1343
-
1344
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
1345
- const authToken = getSecureKey('credential:twilio:auth_token')!;
1346
- const country = msg.country ?? 'US';
1347
-
1348
- // Search for an available number
1349
- const available = await searchAvailableNumbers(accountSid, authToken, country, msg.areaCode);
1350
- if (available.length === 0) {
1351
- ctx.send(socket, {
1352
- type: 'twilio_config_response',
1353
- success: false,
1354
- hasCredentials: true,
1355
- error: `No available phone numbers found for country=${country}${msg.areaCode ? ` areaCode=${msg.areaCode}` : ''}`,
1356
- });
1357
- return;
1358
- }
1359
-
1360
- // Purchase the first available number
1361
- const purchased = await provisionPhoneNumber(accountSid, authToken, available[0].phoneNumber);
1362
-
1363
- // Auto-assign: persist the purchased number in secure storage and config
1364
- // (same persistence as assign_number for consistency)
1365
- const phoneStored = setSecureKey('credential:twilio:phone_number', purchased.phoneNumber);
1366
- if (!phoneStored) {
1367
- ctx.send(socket, {
1368
- type: 'twilio_config_response',
1369
- success: false,
1370
- hasCredentials: hasTwilioCredentials(),
1371
- phoneNumber: purchased.phoneNumber,
1372
- error: `Phone number ${purchased.phoneNumber} was purchased but could not be saved. Use assign_number to assign it manually.`,
1373
- });
1374
- return;
1375
- }
1376
-
1377
- const raw = loadRawConfig();
1378
- const sms = (raw?.sms ?? {}) as Record<string, unknown>;
1379
- // When assistantId is provided, only set the legacy global phoneNumber
1380
- // if it's not already set — this prevents multi-assistant assignments
1381
- // from clobbering each other's outbound SMS number.
1382
- if (msg.assistantId) {
1383
- if (!sms.phoneNumber) {
1384
- sms.phoneNumber = purchased.phoneNumber;
1385
- }
1386
- } else {
1387
- sms.phoneNumber = purchased.phoneNumber;
1388
- }
1389
- // When assistantId is provided, also persist into the per-assistant mapping
1390
- if (msg.assistantId) {
1391
- const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
1392
- mapping[msg.assistantId] = purchased.phoneNumber;
1393
- sms.assistantPhoneNumbers = mapping;
1394
- }
1395
-
1396
- const wasSuppressed = ctx.suppressConfigReload;
1397
- ctx.setSuppressConfigReload(true);
1398
- try {
1399
- saveRawConfig({ ...raw, sms });
1400
- } catch (err) {
1401
- ctx.setSuppressConfigReload(wasSuppressed);
1402
- throw err;
1403
- }
1404
- ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
1405
-
1406
- // Best-effort webhook configuration — non-fatal so the number is
1407
- // still usable even if ingress isn't configured yet.
1408
- const webhookResult = await syncTwilioWebhooks(
1409
- purchased.phoneNumber,
1410
- accountSid,
1411
- authToken,
1412
- loadRawConfig() as IngressConfig,
1413
- );
1414
-
1415
- ctx.send(socket, {
1416
- type: 'twilio_config_response',
1417
- success: true,
1418
- hasCredentials: true,
1419
- phoneNumber: purchased.phoneNumber,
1420
- warning: webhookResult.warning,
1421
- });
1422
- } else if (msg.action === 'assign_number') {
1423
- if (!msg.phoneNumber) {
1424
- ctx.send(socket, {
1425
- type: 'twilio_config_response',
1426
- success: false,
1427
- hasCredentials: hasTwilioCredentials(),
1428
- error: 'phoneNumber is required for assign_number action',
1429
- });
1430
- return;
1431
- }
1432
-
1433
- // Persist the phone number in the secure credential store so the
1434
- // active Twilio runtime can read it via credential:twilio:phone_number
1435
- const phoneStored = setSecureKey('credential:twilio:phone_number', msg.phoneNumber);
1436
- if (!phoneStored) {
1437
- ctx.send(socket, {
1438
- type: 'twilio_config_response',
1439
- success: false,
1440
- hasCredentials: hasTwilioCredentials(),
1441
- error: 'Failed to store phone number in secure storage',
1442
- });
1443
- return;
1444
- }
1445
-
1446
- // Also persist in assistant config (non-secret) for the UI
1447
- const raw = loadRawConfig();
1448
- const sms = (raw?.sms ?? {}) as Record<string, unknown>;
1449
- // When assistantId is provided, only set the legacy global phoneNumber
1450
- // if it's not already set — this prevents multi-assistant assignments
1451
- // from clobbering each other's outbound SMS number.
1452
- if (msg.assistantId) {
1453
- if (!sms.phoneNumber) {
1454
- sms.phoneNumber = msg.phoneNumber;
1455
- }
1456
- } else {
1457
- sms.phoneNumber = msg.phoneNumber;
1458
- }
1459
- // When assistantId is provided, also persist into the per-assistant mapping
1460
- if (msg.assistantId) {
1461
- const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
1462
- mapping[msg.assistantId] = msg.phoneNumber;
1463
- sms.assistantPhoneNumbers = mapping;
1464
- }
1465
-
1466
- const wasSuppressed = ctx.suppressConfigReload;
1467
- ctx.setSuppressConfigReload(true);
1468
- try {
1469
- saveRawConfig({ ...raw, sms });
1470
- } catch (err) {
1471
- ctx.setSuppressConfigReload(wasSuppressed);
1472
- throw err;
1473
- }
1474
- ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
1475
-
1476
- // Best-effort webhook configuration when credentials are available
1477
- let webhookWarning: string | undefined;
1478
- if (hasTwilioCredentials()) {
1479
- const acctSid = getSecureKey('credential:twilio:account_sid')!;
1480
- const acctToken = getSecureKey('credential:twilio:auth_token')!;
1481
- const webhookResult = await syncTwilioWebhooks(
1482
- msg.phoneNumber,
1483
- acctSid,
1484
- acctToken,
1485
- loadRawConfig() as IngressConfig,
1486
- );
1487
- webhookWarning = webhookResult.warning;
1488
- }
1489
-
1490
- ctx.send(socket, {
1491
- type: 'twilio_config_response',
1492
- success: true,
1493
- hasCredentials: hasTwilioCredentials(),
1494
- phoneNumber: msg.phoneNumber,
1495
- warning: webhookWarning,
1496
- });
1497
- } else if (msg.action === 'list_numbers') {
1498
- if (!hasTwilioCredentials()) {
1499
- ctx.send(socket, {
1500
- type: 'twilio_config_response',
1501
- success: false,
1502
- hasCredentials: false,
1503
- error: 'Twilio credentials not configured. Set credentials first.',
1504
- });
1505
- return;
1506
- }
1507
-
1508
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
1509
- const authToken = getSecureKey('credential:twilio:auth_token')!;
1510
- const numbers = await listIncomingPhoneNumbers(accountSid, authToken);
1511
-
1512
- ctx.send(socket, {
1513
- type: 'twilio_config_response',
1514
- success: true,
1515
- hasCredentials: true,
1516
- numbers,
1517
- });
1518
- } else if (msg.action === 'sms_compliance_status') {
1519
- if (!hasTwilioCredentials()) {
1520
- ctx.send(socket, {
1521
- type: 'twilio_config_response',
1522
- success: false,
1523
- hasCredentials: false,
1524
- error: 'Twilio credentials not configured. Set credentials first.',
1525
- });
1526
- return;
1527
- }
1528
-
1529
- const raw = loadRawConfig();
1530
- const sms = (raw?.sms ?? {}) as Record<string, unknown>;
1531
- let phoneNumber: string;
1532
- if (msg.assistantId) {
1533
- const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
1534
- phoneNumber = mapping[msg.assistantId] ?? (sms.phoneNumber as string) ?? '';
1535
- } else {
1536
- phoneNumber = (sms.phoneNumber as string) ?? '';
1537
- }
1538
-
1539
- if (!phoneNumber) {
1540
- ctx.send(socket, {
1541
- type: 'twilio_config_response',
1542
- success: false,
1543
- hasCredentials: true,
1544
- error: 'No phone number assigned. Assign a number first.',
1545
- });
1546
- return;
1547
- }
1548
-
1549
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
1550
- const authToken = getSecureKey('credential:twilio:auth_token')!;
1551
-
1552
- // Determine number type from prefix
1553
- const tollFreePrefixes = ['+1800', '+1833', '+1844', '+1855', '+1866', '+1877', '+1888'];
1554
- const isTollFree = tollFreePrefixes.some((prefix) => phoneNumber.startsWith(prefix));
1555
- const numberType = isTollFree ? 'toll_free' : 'local_10dlc';
1556
-
1557
- if (!isTollFree) {
1558
- // Non-toll-free numbers don't need toll-free verification
1559
- ctx.send(socket, {
1560
- type: 'twilio_config_response',
1561
- success: true,
1562
- hasCredentials: true,
1563
- phoneNumber,
1564
- compliance: { numberType },
1565
- });
1566
- return;
1567
- }
1568
-
1569
- // Look up the phone number SID and check verification status
1570
- const phoneSid = await getPhoneNumberSid(accountSid, authToken, phoneNumber);
1571
- if (!phoneSid) {
1572
- ctx.send(socket, {
1573
- type: 'twilio_config_response',
1574
- success: false,
1575
- hasCredentials: true,
1576
- phoneNumber,
1577
- error: `Phone number ${phoneNumber} not found on Twilio account`,
1578
- });
1579
- return;
1580
- }
1581
-
1582
- const verification = await getTollFreeVerificationStatus(accountSid, authToken, phoneSid);
1583
-
1584
- ctx.send(socket, {
1585
- type: 'twilio_config_response',
1586
- success: true,
1587
- hasCredentials: true,
1588
- phoneNumber,
1589
- compliance: {
1590
- numberType,
1591
- verificationSid: verification?.sid,
1592
- verificationStatus: verification?.status,
1593
- rejectionReason: verification?.rejectionReason,
1594
- rejectionReasons: verification?.rejectionReasons,
1595
- errorCode: verification?.errorCode,
1596
- editAllowed: verification?.editAllowed,
1597
- editExpiration: verification?.editExpiration,
1598
- },
1599
- });
1600
- } else if (msg.action === 'sms_submit_tollfree_verification') {
1601
- if (!hasTwilioCredentials()) {
1602
- ctx.send(socket, {
1603
- type: 'twilio_config_response',
1604
- success: false,
1605
- hasCredentials: false,
1606
- error: 'Twilio credentials not configured. Set credentials first.',
1607
- });
1608
- return;
1609
- }
1610
-
1611
- const vp = msg.verificationParams;
1612
- if (!vp) {
1613
- ctx.send(socket, {
1614
- type: 'twilio_config_response',
1615
- success: false,
1616
- hasCredentials: true,
1617
- error: 'verificationParams is required for sms_submit_tollfree_verification action',
1618
- });
1619
- return;
1620
- }
1621
-
1622
- // Validate required fields
1623
- const requiredFields: Array<[string, unknown]> = [
1624
- ['tollfreePhoneNumberSid', vp.tollfreePhoneNumberSid],
1625
- ['businessName', vp.businessName],
1626
- ['businessWebsite', vp.businessWebsite],
1627
- ['notificationEmail', vp.notificationEmail],
1628
- ['useCaseCategories', vp.useCaseCategories],
1629
- ['useCaseSummary', vp.useCaseSummary],
1630
- ['productionMessageSample', vp.productionMessageSample],
1631
- ['optInImageUrls', vp.optInImageUrls],
1632
- ['optInType', vp.optInType],
1633
- ['messageVolume', vp.messageVolume],
1634
- ];
1635
-
1636
- const missing = requiredFields
1637
- .filter(([, v]) => v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0))
1638
- .map(([name]) => name);
1639
-
1640
- if (missing.length > 0) {
1641
- ctx.send(socket, {
1642
- type: 'twilio_config_response',
1643
- success: false,
1644
- hasCredentials: true,
1645
- error: `Missing required verification fields: ${missing.join(', ')}`,
1646
- });
1647
- return;
1648
- }
1649
-
1650
- // Validate enum values
1651
- const validUseCaseCategories = [
1652
- 'TWO_FACTOR_AUTHENTICATION', 'ACCOUNT_NOTIFICATION', 'CUSTOMER_CARE',
1653
- 'DELIVERY_NOTIFICATION', 'FRAUD_ALERT', 'HIGHER_EDUCATION', 'MARKETING',
1654
- 'POLLING_AND_VOTING', 'PUBLIC_SERVICE_ANNOUNCEMENT', 'SECURITY_ALERT',
1655
- ];
1656
- const invalidCategories = (vp.useCaseCategories ?? []).filter((c) => !validUseCaseCategories.includes(c));
1657
- if (invalidCategories.length > 0) {
1658
- ctx.send(socket, {
1659
- type: 'twilio_config_response',
1660
- success: false,
1661
- hasCredentials: true,
1662
- error: `Invalid useCaseCategories: ${invalidCategories.join(', ')}. Valid values: ${validUseCaseCategories.join(', ')}`,
1663
- });
1664
- return;
1665
- }
1666
-
1667
- const validOptInTypes = ['VERBAL', 'WEB_FORM', 'PAPER_FORM', 'VIA_TEXT', 'MOBILE_QR_CODE'];
1668
- if (!validOptInTypes.includes(vp.optInType!)) {
1669
- ctx.send(socket, {
1670
- type: 'twilio_config_response',
1671
- success: false,
1672
- hasCredentials: true,
1673
- error: `Invalid optInType: ${vp.optInType}. Valid values: ${validOptInTypes.join(', ')}`,
1674
- });
1675
- return;
1676
- }
1677
-
1678
- const validMessageVolumes = [
1679
- '10', '100', '1,000', '10,000', '100,000', '250,000',
1680
- '500,000', '750,000', '1,000,000', '5,000,000', '10,000,000+',
1681
- ];
1682
- if (!validMessageVolumes.includes(vp.messageVolume!)) {
1683
- ctx.send(socket, {
1684
- type: 'twilio_config_response',
1685
- success: false,
1686
- hasCredentials: true,
1687
- error: `Invalid messageVolume: ${vp.messageVolume}. Valid values: ${validMessageVolumes.join(', ')}`,
1688
- });
1689
- return;
1690
- }
1691
-
1692
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
1693
- const authToken = getSecureKey('credential:twilio:auth_token')!;
1694
-
1695
- const submitParams: TollFreeVerificationSubmitParams = {
1696
- tollfreePhoneNumberSid: vp.tollfreePhoneNumberSid!,
1697
- businessName: vp.businessName!,
1698
- businessWebsite: vp.businessWebsite!,
1699
- notificationEmail: vp.notificationEmail!,
1700
- useCaseCategories: vp.useCaseCategories!,
1701
- useCaseSummary: vp.useCaseSummary!,
1702
- productionMessageSample: vp.productionMessageSample!,
1703
- optInImageUrls: vp.optInImageUrls!,
1704
- optInType: vp.optInType!,
1705
- messageVolume: vp.messageVolume!,
1706
- businessType: vp.businessType ?? 'SOLE_PROPRIETOR',
1707
- customerProfileSid: vp.customerProfileSid,
1708
- };
1709
-
1710
- const verification = await submitTollFreeVerification(accountSid, authToken, submitParams);
1711
-
1712
- ctx.send(socket, {
1713
- type: 'twilio_config_response',
1714
- success: true,
1715
- hasCredentials: true,
1716
- compliance: {
1717
- numberType: 'toll_free',
1718
- verificationSid: verification.sid,
1719
- verificationStatus: verification.status,
1720
- },
1721
- });
1722
- } else if (msg.action === 'sms_update_tollfree_verification') {
1723
- if (!hasTwilioCredentials()) {
1724
- ctx.send(socket, {
1725
- type: 'twilio_config_response',
1726
- success: false,
1727
- hasCredentials: false,
1728
- error: 'Twilio credentials not configured. Set credentials first.',
1729
- });
1730
- return;
1731
- }
1732
-
1733
- if (!msg.verificationSid) {
1734
- ctx.send(socket, {
1735
- type: 'twilio_config_response',
1736
- success: false,
1737
- hasCredentials: true,
1738
- error: 'verificationSid is required for sms_update_tollfree_verification action',
1739
- });
1740
- return;
1741
- }
1742
-
1743
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
1744
- const authToken = getSecureKey('credential:twilio:auth_token')!;
1745
-
1746
- const verification = await updateTollFreeVerification(
1747
- accountSid,
1748
- authToken,
1749
- msg.verificationSid,
1750
- msg.verificationParams ?? {},
1751
- );
1752
-
1753
- ctx.send(socket, {
1754
- type: 'twilio_config_response',
1755
- success: true,
1756
- hasCredentials: true,
1757
- compliance: {
1758
- numberType: 'toll_free',
1759
- verificationSid: verification.sid,
1760
- verificationStatus: verification.status,
1761
- editAllowed: verification.editAllowed,
1762
- editExpiration: verification.editExpiration,
1763
- },
1764
- });
1765
- } else if (msg.action === 'sms_delete_tollfree_verification') {
1766
- if (!hasTwilioCredentials()) {
1767
- ctx.send(socket, {
1768
- type: 'twilio_config_response',
1769
- success: false,
1770
- hasCredentials: false,
1771
- error: 'Twilio credentials not configured. Set credentials first.',
1772
- });
1773
- return;
1774
- }
1775
-
1776
- if (!msg.verificationSid) {
1777
- ctx.send(socket, {
1778
- type: 'twilio_config_response',
1779
- success: false,
1780
- hasCredentials: true,
1781
- error: 'verificationSid is required for sms_delete_tollfree_verification action',
1782
- });
1783
- return;
1784
- }
1785
-
1786
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
1787
- const authToken = getSecureKey('credential:twilio:auth_token')!;
1788
-
1789
- await deleteTollFreeVerification(accountSid, authToken, msg.verificationSid);
1790
-
1791
- ctx.send(socket, {
1792
- type: 'twilio_config_response',
1793
- success: true,
1794
- hasCredentials: true,
1795
- warning: 'Toll-free verification deleted. Re-submitting may reset your position in the review queue.',
1796
- });
1797
- } else if (msg.action === 'release_number') {
1798
- if (!hasTwilioCredentials()) {
1799
- ctx.send(socket, {
1800
- type: 'twilio_config_response',
1801
- success: false,
1802
- hasCredentials: false,
1803
- error: 'Twilio credentials not configured. Set credentials first.',
1804
- });
1805
- return;
1806
- }
1807
-
1808
- const raw = loadRawConfig();
1809
- const sms = (raw?.sms ?? {}) as Record<string, unknown>;
1810
- let phoneNumber: string;
1811
- if (msg.phoneNumber) {
1812
- phoneNumber = msg.phoneNumber;
1813
- } else if (msg.assistantId) {
1814
- const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
1815
- phoneNumber = mapping[msg.assistantId] ?? (sms.phoneNumber as string) ?? '';
1816
- } else {
1817
- phoneNumber = (sms.phoneNumber as string) ?? '';
1818
- }
1819
-
1820
- if (!phoneNumber) {
1821
- ctx.send(socket, {
1822
- type: 'twilio_config_response',
1823
- success: false,
1824
- hasCredentials: true,
1825
- error: 'No phone number to release. Specify phoneNumber or ensure one is assigned.',
1826
- });
1827
- return;
1828
- }
1829
-
1830
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
1831
- const authToken = getSecureKey('credential:twilio:auth_token')!;
1832
-
1833
- await releasePhoneNumber(accountSid, authToken, phoneNumber);
1834
-
1835
- // Clear the number from config and secure key store
1836
- if (sms.phoneNumber === phoneNumber) {
1837
- delete sms.phoneNumber;
1838
- }
1839
- const assistantPhoneNumbers = sms.assistantPhoneNumbers as Record<string, string> | undefined;
1840
- if (assistantPhoneNumbers) {
1841
- for (const [id, num] of Object.entries(assistantPhoneNumbers)) {
1842
- if (num === phoneNumber) {
1843
- delete assistantPhoneNumbers[id];
1844
- }
1845
- }
1846
- if (Object.keys(assistantPhoneNumbers).length === 0) {
1847
- delete sms.assistantPhoneNumbers;
1848
- }
1849
- }
1850
-
1851
- const wasSuppressed = ctx.suppressConfigReload;
1852
- ctx.setSuppressConfigReload(true);
1853
- try {
1854
- saveRawConfig({ ...raw, sms });
1855
- } catch (err) {
1856
- ctx.setSuppressConfigReload(wasSuppressed);
1857
- throw err;
1858
- }
1859
- ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
1860
-
1861
- // Clear the phone number from secure key store if it matches
1862
- const storedPhone = getSecureKey('credential:twilio:phone_number');
1863
- if (storedPhone === phoneNumber) {
1864
- deleteSecureKey('credential:twilio:phone_number');
1865
- }
1866
-
1867
- ctx.send(socket, {
1868
- type: 'twilio_config_response',
1869
- success: true,
1870
- hasCredentials: true,
1871
- warning: 'Phone number released from Twilio. Any associated toll-free verification context is lost.',
1872
- });
1873
- } else if (msg.action === 'sms_send_test') {
1874
- // ── SMS send test ────────────────────────────────────────────────
1875
- if (!hasTwilioCredentials()) {
1876
- ctx.send(socket, {
1877
- type: 'twilio_config_response',
1878
- success: false,
1879
- hasCredentials: false,
1880
- error: 'Twilio credentials not configured. Set credentials first.',
1881
- });
1882
- return;
1883
- }
1884
-
1885
- const to = msg.phoneNumber;
1886
- if (!to) {
1887
- ctx.send(socket, {
1888
- type: 'twilio_config_response',
1889
- success: false,
1890
- hasCredentials: true,
1891
- error: 'phoneNumber is required for sms_send_test action.',
1892
- });
1893
- return;
1894
- }
1895
-
1896
- const raw = loadRawConfig();
1897
- const smsSection = (raw?.sms ?? {}) as Record<string, unknown>;
1898
- const from = (smsSection.phoneNumber as string | undefined)
1899
- || getSecureKey('credential:twilio:phone_number')
1900
- || '';
1901
- if (!from) {
1902
- ctx.send(socket, {
1903
- type: 'twilio_config_response',
1904
- success: false,
1905
- hasCredentials: true,
1906
- error: 'No phone number assigned. Run the twilio-setup skill to assign a number.',
1907
- });
1908
- return;
1909
- }
1910
-
1911
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
1912
- const authToken = getSecureKey('credential:twilio:auth_token')!;
1913
- const text = msg.text || 'Test SMS from your Vellum assistant';
1914
-
1915
- // Send via gateway's /deliver/sms endpoint
1916
- const bearerToken = readHttpToken();
1917
- const gatewayPort = Number(process.env.GATEWAY_PORT) || 7830;
1918
- const gatewayUrl = process.env.GATEWAY_INTERNAL_BASE_URL?.replace(/\/+$/, '') || `http://127.0.0.1:${gatewayPort}`;
1919
-
1920
- const sendResp = await fetch(`${gatewayUrl}/deliver/sms`, {
1921
- method: 'POST',
1922
- headers: {
1923
- 'Content-Type': 'application/json',
1924
- ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
1925
- },
1926
- body: JSON.stringify({ to, text }),
1927
- signal: AbortSignal.timeout(30_000),
1928
- });
1929
-
1930
- if (!sendResp.ok) {
1931
- const errBody = await sendResp.text().catch(() => '<unreadable>');
1932
- ctx.send(socket, {
1933
- type: 'twilio_config_response',
1934
- success: false,
1935
- hasCredentials: true,
1936
- error: `SMS send failed (${sendResp.status}): ${errBody}`,
1937
- });
1938
- return;
1939
- }
1940
-
1941
- const sendData = await sendResp.json().catch(() => ({})) as {
1942
- messageSid?: string;
1943
- status?: string;
1944
- };
1945
- const messageSid = sendData.messageSid || '';
1946
- const initialStatus = sendData.status || 'unknown';
1947
-
1948
- // Poll Twilio for final status (up to 3 times, 2s apart)
1949
- let finalStatus = initialStatus;
1950
- let errorCode: string | undefined;
1951
- let errorMessage: string | undefined;
1952
-
1953
- if (messageSid) {
1954
- for (let i = 0; i < 3; i++) {
1955
- await new Promise((r) => setTimeout(r, 2000));
1956
- try {
1957
- const pollResult = await fetchMessageStatus(accountSid, authToken, messageSid);
1958
- finalStatus = pollResult.status;
1959
- errorCode = pollResult.errorCode;
1960
- errorMessage = pollResult.errorMessage;
1961
- // Stop polling if we've reached a terminal status
1962
- if (['delivered', 'undelivered', 'failed'].includes(finalStatus)) break;
1963
- } catch {
1964
- // Polling failure is non-fatal; we'll use the last known status
1965
- break;
1966
- }
1967
- }
1968
- }
1969
-
1970
- const testResult = {
1971
- messageSid,
1972
- to,
1973
- initialStatus,
1974
- finalStatus,
1975
- ...(errorCode ? { errorCode } : {}),
1976
- ...(errorMessage ? { errorMessage } : {}),
1977
- };
1978
-
1979
- // Store for sms_doctor
1980
- _lastTestResult = { ...testResult, timestamp: Date.now() };
1981
-
1982
- ctx.send(socket, {
1983
- type: 'twilio_config_response',
1984
- success: true,
1985
- hasCredentials: true,
1986
- testResult,
1987
- });
1988
-
1989
- } else if (msg.action === 'sms_doctor') {
1990
- // ── SMS doctor diagnostic ────────────────────────────────────────
1991
- const hasCredentials = hasTwilioCredentials();
1992
-
1993
- // 1. Channel readiness check
1994
- let readinessReady = false;
1995
- const readinessIssues: string[] = [];
1996
- try {
1997
- const readinessService = getReadinessService();
1998
- const snapshot = await readinessService.getReadiness('sms', { includeRemote: false });
1999
- readinessReady = snapshot.ready;
2000
- for (const r of snapshot.reasons) {
2001
- readinessIssues.push(r.text);
2002
- }
2003
- } catch (err) {
2004
- readinessIssues.push(`Readiness check failed: ${err instanceof Error ? err.message : String(err)}`);
2005
- }
2006
-
2007
- // 2. Compliance status
2008
- let complianceStatus = 'unknown';
2009
- let complianceDetail: string | undefined;
2010
- let complianceRemediation: string | undefined;
2011
- if (hasCredentials) {
2012
- try {
2013
- const raw = loadRawConfig();
2014
- const smsSection = (raw?.sms ?? {}) as Record<string, unknown>;
2015
- const phoneNumber = (smsSection.phoneNumber as string | undefined) || getSecureKey('credential:twilio:phone_number') || '';
2016
- if (phoneNumber) {
2017
- const accountSid = getSecureKey('credential:twilio:account_sid')!;
2018
- const authToken = getSecureKey('credential:twilio:auth_token')!;
2019
- // Determine number type and verification status
2020
- const isTollFree = phoneNumber.startsWith('+1') && ['800','888','877','866','855','844','833'].some(
2021
- (p) => phoneNumber.startsWith(`+1${p}`),
2022
- );
2023
- if (isTollFree) {
2024
- try {
2025
- const verification = await getTollFreeVerificationStatus(accountSid, authToken, phoneNumber);
2026
- if (verification) {
2027
- const status = verification.status;
2028
- complianceStatus = status;
2029
- complianceDetail = `Toll-free verification: ${status}`;
2030
- if (status === 'TWILIO_APPROVED') {
2031
- complianceRemediation = undefined;
2032
- } else if (status === 'PENDING_REVIEW' || status === 'IN_REVIEW') {
2033
- complianceRemediation = 'Toll-free verification is pending. Messaging may have limited throughput until approved.';
2034
- } else if (status === 'TWILIO_REJECTED') {
2035
- complianceRemediation = 'Toll-free verification was rejected. Check rejection reasons and resubmit.';
2036
- } else {
2037
- complianceRemediation = 'Submit a toll-free verification to enable full messaging throughput.';
2038
- }
2039
- } else {
2040
- complianceStatus = 'unverified';
2041
- complianceDetail = 'Toll-free number without verification';
2042
- complianceRemediation = 'Submit a toll-free verification request to avoid filtering.';
2043
- }
2044
- } catch {
2045
- complianceStatus = 'check_failed';
2046
- complianceDetail = 'Could not retrieve toll-free verification status';
2047
- }
2048
- } else {
2049
- complianceStatus = 'local_10dlc';
2050
- complianceDetail = 'Local/10DLC number — carrier registration handled externally';
2051
- }
2052
- } else {
2053
- complianceStatus = 'no_number';
2054
- complianceDetail = 'No phone number assigned';
2055
- complianceRemediation = 'Assign a phone number via the twilio-setup skill.';
2056
- }
2057
- } catch {
2058
- complianceStatus = 'check_failed';
2059
- complianceDetail = 'Could not determine compliance status';
2060
- }
2061
- } else {
2062
- complianceStatus = 'no_credentials';
2063
- complianceDetail = 'Twilio credentials are not configured';
2064
- complianceRemediation = 'Set Twilio credentials via the twilio-setup skill.';
2065
- }
2066
-
2067
- // 3. Last send test result
2068
- let lastSend: { status: string; errorCode?: string; remediation?: string } | undefined;
2069
- if (_lastTestResult) {
2070
- lastSend = {
2071
- status: _lastTestResult.finalStatus,
2072
- ...((_lastTestResult.errorCode) ? { errorCode: _lastTestResult.errorCode } : {}),
2073
- ...((_lastTestResult.errorCode) ? { remediation: mapTwilioErrorRemediation(_lastTestResult.errorCode) } : {}),
2074
- };
2075
- }
2076
-
2077
- // 4. Determine overall status
2078
- const actionItems: string[] = [];
2079
- let overallStatus: 'healthy' | 'degraded' | 'broken' = 'healthy';
2080
-
2081
- if (!hasCredentials) {
2082
- overallStatus = 'broken';
2083
- actionItems.push('Configure Twilio credentials.');
2084
- }
2085
- if (!readinessReady) {
2086
- overallStatus = 'broken';
2087
- for (const issue of readinessIssues) actionItems.push(issue);
2088
- }
2089
- if (complianceStatus === 'unverified' || complianceStatus === 'PENDING_REVIEW' || complianceStatus === 'IN_REVIEW') {
2090
- if (overallStatus === 'healthy') overallStatus = 'degraded';
2091
- if (complianceRemediation) actionItems.push(complianceRemediation);
2092
- }
2093
- if (complianceStatus === 'TWILIO_REJECTED' || complianceStatus === 'no_number') {
2094
- overallStatus = 'broken';
2095
- if (complianceRemediation) actionItems.push(complianceRemediation);
2096
- }
2097
- if (_lastTestResult && ['failed', 'undelivered'].includes(_lastTestResult.finalStatus)) {
2098
- if (overallStatus === 'healthy') overallStatus = 'degraded';
2099
- const remediation = mapTwilioErrorRemediation(_lastTestResult.errorCode);
2100
- actionItems.push(remediation || `Last test SMS ${_lastTestResult.finalStatus}. Check Twilio logs for details.`);
2101
- }
2102
-
2103
- ctx.send(socket, {
2104
- type: 'twilio_config_response',
2105
- success: true,
2106
- hasCredentials,
2107
- diagnostics: {
2108
- readiness: { ready: readinessReady, issues: readinessIssues },
2109
- compliance: {
2110
- status: complianceStatus,
2111
- ...(complianceDetail ? { detail: complianceDetail } : {}),
2112
- ...(complianceRemediation ? { remediation: complianceRemediation } : {}),
2113
- },
2114
- ...(lastSend ? { lastSend } : {}),
2115
- overallStatus,
2116
- actionItems,
2117
- },
2118
- });
2119
-
2120
- } else {
2121
- ctx.send(socket, {
2122
- type: 'twilio_config_response',
2123
- success: false,
2124
- hasCredentials: hasTwilioCredentials(),
2125
- error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}`,
2126
- });
2127
- }
2128
- } catch (err) {
2129
- const message = err instanceof Error ? err.message : String(err);
2130
- log.error({ err }, 'Failed to handle Twilio config');
2131
- ctx.send(socket, {
2132
- type: 'twilio_config_response',
2133
- success: false,
2134
- hasCredentials: hasTwilioCredentials(),
2135
- error: message,
2136
- });
2137
- }
2138
- }
2139
-
2140
- export function handleGuardianVerification(
2141
- msg: GuardianVerificationRequest,
2142
- socket: net.Socket,
2143
- ctx: HandlerContext,
2144
- ): void {
2145
- // Use the assistant ID from the request when available; fall back to
2146
- // 'self' for backward compatibility with single-assistant mode.
2147
- const assistantId = msg.assistantId ?? 'self';
2148
- const channel = msg.channel ?? 'telegram';
2149
-
2150
- try {
2151
- if (msg.action === 'create_challenge') {
2152
- const result = createVerificationChallenge(assistantId, channel, msg.sessionId);
2153
-
2154
- ctx.send(socket, {
2155
- type: 'guardian_verification_response',
2156
- success: true,
2157
- secret: result.secret,
2158
- instruction: result.instruction,
2159
- channel,
2160
- });
2161
- } else if (msg.action === 'status') {
2162
- const binding = getGuardianBinding(assistantId, channel);
2163
- let guardianUsername: string | undefined;
2164
- let guardianDisplayName: string | undefined;
2165
- if (binding?.metadataJson) {
2166
- try {
2167
- const parsed = JSON.parse(binding.metadataJson) as Record<string, unknown>;
2168
- if (typeof parsed.username === 'string' && parsed.username.trim().length > 0) {
2169
- guardianUsername = parsed.username.trim();
2170
- }
2171
- if (typeof parsed.displayName === 'string' && parsed.displayName.trim().length > 0) {
2172
- guardianDisplayName = parsed.displayName.trim();
2173
- }
2174
- } catch {
2175
- // ignore malformed metadata
2176
- }
2177
- }
2178
- if (binding?.guardianDeliveryChatId && (!guardianUsername || !guardianDisplayName)) {
2179
- const ext = externalConversationStore.getBindingByChannelChat(
2180
- channel,
2181
- binding.guardianDeliveryChatId,
2182
- );
2183
- if (!guardianUsername && ext?.username) {
2184
- guardianUsername = ext.username;
2185
- }
2186
- if (!guardianDisplayName && ext?.displayName) {
2187
- guardianDisplayName = ext.displayName;
2188
- }
2189
- }
2190
- ctx.send(socket, {
2191
- type: 'guardian_verification_response',
2192
- success: true,
2193
- bound: binding !== null,
2194
- guardianExternalUserId: binding?.guardianExternalUserId,
2195
- guardianUsername,
2196
- guardianDisplayName,
2197
- channel,
2198
- assistantId,
2199
- guardianDeliveryChatId: binding?.guardianDeliveryChatId,
2200
- });
2201
- } else if (msg.action === 'revoke') {
2202
- revokeGuardianBinding(assistantId, channel);
2203
- ctx.send(socket, {
2204
- type: 'guardian_verification_response',
2205
- success: true,
2206
- bound: false,
2207
- channel,
2208
- });
2209
- } else {
2210
- ctx.send(socket, {
2211
- type: 'guardian_verification_response',
2212
- success: false,
2213
- error: `Unknown action: ${String(msg.action)}`,
2214
- channel,
2215
- });
2216
- }
2217
- } catch (err) {
2218
- const message = err instanceof Error ? err.message : String(err);
2219
- log.error({ err }, 'Failed to handle guardian verification');
2220
- ctx.send(socket, {
2221
- type: 'guardian_verification_response',
2222
- success: false,
2223
- error: message,
2224
- channel,
2225
- });
2226
- }
2227
- }
2228
-
2229
- // Lazy singleton — created on first use so module-load stays lightweight.
2230
- let _readinessService: ChannelReadinessService | undefined;
2231
- function getReadinessService(): ChannelReadinessService {
2232
- if (!_readinessService) {
2233
- _readinessService = createReadinessService();
2234
- }
2235
- return _readinessService;
2236
- }
2237
-
2238
- export async function handleChannelReadiness(
2239
- msg: ChannelReadinessRequest,
2240
- socket: net.Socket,
2241
- ctx: HandlerContext,
2242
- ): Promise<void> {
2243
- try {
2244
- const service = getReadinessService();
2245
-
2246
- if (msg.action === 'refresh') {
2247
- if (msg.channel) {
2248
- service.invalidateChannel(msg.channel);
2249
- } else {
2250
- service.invalidateAll();
2251
- }
2252
- }
2253
-
2254
- const snapshots = await service.getReadiness(msg.channel, msg.includeRemote);
2255
-
2256
- ctx.send(socket, {
2257
- type: 'channel_readiness_response',
2258
- success: true,
2259
- snapshots: snapshots.map((s) => ({
2260
- channel: s.channel,
2261
- ready: s.ready,
2262
- checkedAt: s.checkedAt,
2263
- stale: s.stale,
2264
- reasons: s.reasons,
2265
- localChecks: s.localChecks,
2266
- remoteChecks: s.remoteChecks,
2267
- })),
2268
- });
2269
- } catch (err) {
2270
- const message = err instanceof Error ? err.message : String(err);
2271
- log.error({ err }, 'Failed to handle channel readiness');
2272
- ctx.send(socket, {
2273
- type: 'channel_readiness_response',
2274
- success: false,
2275
- error: message,
2276
- });
2277
- }
2278
- }
2279
-
2280
- export function handleEnvVarsRequest(socket: net.Socket, ctx: HandlerContext): void {
2281
- const vars: Record<string, string> = {};
2282
- for (const [key, value] of Object.entries(process.env)) {
2283
- if (value !== undefined) vars[key] = value;
2284
- }
2285
- ctx.send(socket, { type: 'env_vars_response', vars });
2286
- }
2287
-
2288
- /**
2289
- * Look up manifest metadata for a tool that isn't in the live registry.
2290
- * Searches all installed skills' TOOLS.json manifests for a matching tool name.
5
+ * Individual handlers live in domain-specific files:
6
+ * config-model.ts — Model selection (LLM + image gen)
7
+ * config-trust.ts — Trust rules (permissions allowlist)
8
+ * config-scheduling.ts — Schedules & reminders
9
+ * config-slack.ts — Slack webhook sharing
10
+ * config-ingress.ts — Public ingress URL & gateway reconciliation
11
+ * config-integrations.ts — Vercel API & Twitter integration
12
+ * config-telegram.ts — Telegram bot configuration
13
+ * config-twilio.ts — Twilio SMS/voice configuration
14
+ * config-channels.ts — Channel guardian & readiness
15
+ * config-tools.ts — Env vars, tool permission simulation, tool names
16
+ * config-parental.ts — Parental control PIN + content/tool restrictions
2291
17
  */
2292
- function resolveManifestOverride(toolName: string): ManifestOverride | undefined {
2293
- if (getTool(toolName)) return undefined;
2294
- try {
2295
- const catalog = loadSkillCatalog();
2296
- for (const skill of catalog) {
2297
- if (!skill.toolManifest?.present || !skill.toolManifest.valid) continue;
2298
- try {
2299
- const manifest = parseToolManifestFile(join(skill.directoryPath, 'TOOLS.json'));
2300
- const entry = manifest.tools.find((t) => t.name === toolName);
2301
- if (entry) {
2302
- return { risk: entry.risk, execution_target: entry.execution_target };
2303
- }
2304
- } catch {
2305
- // Skip unparseable manifests
2306
- }
2307
- }
2308
- } catch {
2309
- // Non-fatal
2310
- }
2311
- return undefined;
2312
- }
2313
-
2314
- export async function handleToolPermissionSimulate(
2315
- msg: ToolPermissionSimulateRequest,
2316
- socket: net.Socket,
2317
- ctx: HandlerContext,
2318
- ): Promise<void> {
2319
- try {
2320
- if (!msg.toolName || typeof msg.toolName !== 'string') {
2321
- ctx.send(socket, {
2322
- type: 'tool_permission_simulate_response',
2323
- success: false,
2324
- error: 'toolName is required',
2325
- });
2326
- return;
2327
- }
2328
- if (!msg.input || typeof msg.input !== 'object') {
2329
- ctx.send(socket, {
2330
- type: 'tool_permission_simulate_response',
2331
- success: false,
2332
- error: 'input is required and must be an object',
2333
- });
2334
- return;
2335
- }
2336
-
2337
- const workingDir = msg.workingDir ?? process.cwd();
2338
-
2339
- // For unregistered skill tools, resolve manifest metadata so the simulation
2340
- // uses accurate risk/execution_target values instead of falling back to defaults.
2341
- const manifestOverride = resolveManifestOverride(msg.toolName);
2342
-
2343
- const executionTarget = resolveExecutionTarget(msg.toolName, manifestOverride);
2344
- const policyContext = { executionTarget };
2345
-
2346
- const riskLevel = await classifyRisk(msg.toolName, msg.input, workingDir, undefined, manifestOverride);
2347
- const result = await check(msg.toolName, msg.input, workingDir, policyContext, manifestOverride);
2348
-
2349
- // Private-thread override: promote allow → prompt for side-effect tools
2350
- if (
2351
- msg.forcePromptSideEffects
2352
- && result.decision === 'allow'
2353
- && isSideEffectTool(msg.toolName, msg.input)
2354
- ) {
2355
- result.decision = 'prompt';
2356
- result.reason = 'Private thread: side-effect tools require explicit approval';
2357
- }
2358
-
2359
- // Non-interactive override: convert prompt → deny
2360
- if (msg.isInteractive === false && result.decision === 'prompt') {
2361
- result.decision = 'deny';
2362
- result.reason = 'Non-interactive session: no client to approve prompt';
2363
- }
2364
-
2365
- // When decision is prompt, generate the full payload the UI needs
2366
- let promptPayload: {
2367
- allowlistOptions: Array<{ label: string; description: string; pattern: string }>;
2368
- scopeOptions: Array<{ label: string; scope: string }>;
2369
- persistentDecisionsAllowed: boolean;
2370
- } | undefined;
2371
-
2372
- if (result.decision === 'prompt') {
2373
- const allowlistOptions = await generateAllowlistOptions(msg.toolName, msg.input);
2374
- const scopeOptions = generateScopeOptions(workingDir, msg.toolName);
2375
- const persistentDecisionsAllowed = !(
2376
- msg.toolName === 'bash'
2377
- && msg.input.network_mode === 'proxied'
2378
- );
2379
- promptPayload = { allowlistOptions, scopeOptions, persistentDecisionsAllowed };
2380
- }
2381
-
2382
- ctx.send(socket, {
2383
- type: 'tool_permission_simulate_response',
2384
- success: true,
2385
- decision: result.decision,
2386
- riskLevel,
2387
- reason: result.reason,
2388
- executionTarget,
2389
- matchedRuleId: result.matchedRule?.id,
2390
- promptPayload,
2391
- });
2392
- } catch (err) {
2393
- const message = err instanceof Error ? err.message : String(err);
2394
- log.error({ err }, 'Failed to simulate tool permission');
2395
- ctx.send(socket, {
2396
- type: 'tool_permission_simulate_response',
2397
- success: false,
2398
- error: message,
2399
- });
2400
- }
2401
- }
2402
-
2403
- export function handleToolNamesList(socket: net.Socket, ctx: HandlerContext): void {
2404
- const tools = getAllTools();
2405
- const nameSet = new Set(tools.map((t) => t.name));
2406
- const schemas: Record<string, import('../ipc-contract.js').ToolInputSchema> = {};
2407
- for (const tool of tools) {
2408
- try {
2409
- const def = tool.getDefinition();
2410
- schemas[tool.name] = def.input_schema as import('../ipc-contract.js').ToolInputSchema;
2411
- } catch {
2412
- // Skip tools whose definitions can't be resolved
2413
- }
2414
- }
2415
-
2416
- // Include tools from all installed skills, even those not currently
2417
- // activated in any session.
2418
- try {
2419
- const catalog = loadSkillCatalog();
2420
- for (const skill of catalog) {
2421
- if (!skill.toolManifest?.present || !skill.toolManifest.valid) continue;
2422
- try {
2423
- const manifest = parseToolManifestFile(join(skill.directoryPath, 'TOOLS.json'));
2424
- for (const entry of manifest.tools) {
2425
- if (nameSet.has(entry.name)) continue;
2426
- nameSet.add(entry.name);
2427
- schemas[entry.name] = entry.input_schema as unknown as import('../ipc-contract.js').ToolInputSchema;
2428
- }
2429
- } catch {
2430
- // Skip skills whose manifests can't be parsed
2431
- }
2432
- }
2433
- } catch {
2434
- // Non-fatal — fall back to registered tools only
2435
- }
2436
-
2437
- const names = Array.from(nameSet).sort((a, b) => a.localeCompare(b));
2438
- ctx.send(socket, { type: 'tool_names_list_response', names, schemas });
2439
- }
2440
18
 
2441
- export const configHandlers = defineHandlers({
2442
- model_get: (_msg, socket, ctx) => handleModelGet(socket, ctx),
2443
- model_set: handleModelSet,
2444
- image_gen_model_set: handleImageGenModelSet,
2445
- add_trust_rule: handleAddTrustRule,
2446
- trust_rules_list: (_msg, socket, ctx) => handleTrustRulesList(socket, ctx),
2447
- remove_trust_rule: handleRemoveTrustRule,
2448
- update_trust_rule: handleUpdateTrustRule,
2449
- accept_starter_bundle: (_msg, socket, ctx) => handleAcceptStarterBundle(socket, ctx),
2450
- schedules_list: (_msg, socket, ctx) => handleSchedulesList(socket, ctx),
2451
- schedule_toggle: handleScheduleToggle,
2452
- schedule_remove: handleScheduleRemove,
2453
- reminders_list: (_msg, socket, ctx) => handleRemindersList(socket, ctx),
2454
- reminder_cancel: handleReminderCancel,
2455
- share_to_slack: handleShareToSlack,
2456
- slack_webhook_config: handleSlackWebhookConfig,
2457
- ingress_config: handleIngressConfig,
2458
- vercel_api_config: handleVercelApiConfig,
2459
- twitter_integration_config: handleTwitterIntegrationConfig,
2460
- telegram_config: handleTelegramConfig,
2461
- twilio_config: handleTwilioConfig,
2462
- channel_readiness: handleChannelReadiness,
2463
- guardian_verification: handleGuardianVerification,
2464
- env_vars_request: (_msg, socket, ctx) => handleEnvVarsRequest(socket, ctx),
2465
- tool_permission_simulate: handleToolPermissionSimulate,
2466
- tool_names_list: (_msg, socket, ctx) => handleToolNamesList(socket, ctx),
2467
- });
19
+ // Re-export individual handlers for direct import by tests and other modules
20
+ export { handleModelGet, handleModelSet, handleImageGenModelSet } from './config-model.js';
21
+ export { handleAddTrustRule, handleTrustRulesList, handleRemoveTrustRule, handleUpdateTrustRule, handleAcceptStarterBundle } from './config-trust.js';
22
+ export { handleSchedulesList, handleScheduleToggle, handleScheduleRemove, handleScheduleRunNow, handleRemindersList, handleReminderCancel } from './config-scheduling.js';
23
+ export { handleShareToSlack, handleSlackWebhookConfig } from './config-slack.js';
24
+ export { handleIngressConfig, computeGatewayTarget, triggerGatewayReconcile, syncTwilioWebhooks } from './config-ingress.js';
25
+ export { handleVercelApiConfig, handleTwitterIntegrationConfig } from './config-integrations.js';
26
+ export { handleTelegramConfig, summarizeTelegramError } from './config-telegram.js';
27
+ export { handleTwilioConfig } from './config-twilio.js';
28
+ export { handleGuardianVerification, handleChannelReadiness, getReadinessService } from './config-channels.js';
29
+ export { handleEnvVarsRequest, handleToolPermissionSimulate, handleToolNamesList } from './config-tools.js';
30
+ export { handleParentalControlGet, handleParentalControlVerifyPin, handleParentalControlSetPin, handleParentalControlUpdate } from './config-parental.js';
31
+
32
+ // Assemble the combined dispatch map from domain-specific handler groups
33
+ import { modelHandlers } from './config-model.js';
34
+ import { trustHandlers } from './config-trust.js';
35
+ import { schedulingHandlers } from './config-scheduling.js';
36
+ import { slackHandlers } from './config-slack.js';
37
+ import { ingressHandlers } from './config-ingress.js';
38
+ import { integrationHandlers } from './config-integrations.js';
39
+ import { telegramHandlers } from './config-telegram.js';
40
+ import { twilioHandlers } from './config-twilio.js';
41
+ import { channelHandlers } from './config-channels.js';
42
+ import { toolHandlers } from './config-tools.js';
43
+ import { parentalControlHandlers } from './config-parental.js';
44
+
45
+ export const configHandlers = {
46
+ ...modelHandlers,
47
+ ...trustHandlers,
48
+ ...schedulingHandlers,
49
+ ...slackHandlers,
50
+ ...ingressHandlers,
51
+ ...integrationHandlers,
52
+ ...telegramHandlers,
53
+ ...twilioHandlers,
54
+ ...channelHandlers,
55
+ ...toolHandlers,
56
+ ...parentalControlHandlers,
57
+ };