@vellumai/assistant 0.11.5 → 0.11.6-staging.1

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 (233) hide show
  1. package/AGENTS.md +5 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  4. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  6. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
  7. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
  9. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
  10. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  11. package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  12. package/openapi.yaml +421 -15
  13. package/package.json +1 -1
  14. package/scripts/sync-web-search-catalog.ts +6 -0
  15. package/src/__tests__/app-pin-store.test.ts +149 -0
  16. package/src/__tests__/channel-availability-routes.test.ts +23 -1
  17. package/src/__tests__/channel-readiness-discord.test.ts +231 -0
  18. package/src/__tests__/channel-readiness-service.test.ts +126 -0
  19. package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
  20. package/src/__tests__/channel-reply-delivery.test.ts +4 -4
  21. package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
  22. package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
  23. package/src/__tests__/conversation-error.test.ts +17 -0
  24. package/src/__tests__/conversation-seed-composer.test.ts +8 -0
  25. package/src/__tests__/conversation-slash-commands.test.ts +8 -0
  26. package/src/__tests__/disk-pressure-policy.test.ts +6 -0
  27. package/src/__tests__/gemini-provider.test.ts +138 -0
  28. package/src/__tests__/history-repair.test.ts +105 -3
  29. package/src/__tests__/identity-routes.test.ts +1 -0
  30. package/src/__tests__/llm-catalog-parity.test.ts +45 -0
  31. package/src/__tests__/migration-import-from-path.test.ts +349 -0
  32. package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
  33. package/src/__tests__/oauth-commands-routes.test.ts +89 -0
  34. package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
  35. package/src/__tests__/openai-provider.test.ts +18 -0
  36. package/src/__tests__/openai-responses-provider.test.ts +18 -0
  37. package/src/__tests__/platform-callback-registration.test.ts +184 -0
  38. package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
  39. package/src/__tests__/pricing.test.ts +2 -2
  40. package/src/__tests__/public-ingress-urls.test.ts +36 -0
  41. package/src/__tests__/resolve-trust-class.test.ts +0 -48
  42. package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
  43. package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
  44. package/src/__tests__/settings-routes.test.ts +85 -3
  45. package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
  46. package/src/agent/history-repair/history-repair.ts +45 -14
  47. package/src/agent/loop.ts +4 -1
  48. package/src/api/constants/profile-config-validation.ts +60 -0
  49. package/src/api/events/tool-result.ts +6 -1
  50. package/src/api/events/watch-retro-completed.ts +52 -0
  51. package/src/api/index.ts +11 -0
  52. package/src/apps/app-pin-reconciler.ts +92 -0
  53. package/src/apps/app-pin-store.ts +125 -0
  54. package/src/channels/gateway-channel-socket-health.ts +32 -0
  55. package/src/channels/gateway-discord-admission.ts +32 -0
  56. package/src/channels/types.ts +20 -0
  57. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
  58. package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
  59. package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
  60. package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
  61. package/src/cli/commands/channels/index.ts +45 -31
  62. package/src/cli/commands/inference-profiles.ts +56 -3
  63. package/src/cli/commands/inference-providers.ts +28 -2
  64. package/src/cli/commands/oauth/index.help.ts +7 -1
  65. package/src/cli/commands/oauth/request.test.ts +290 -0
  66. package/src/cli/commands/oauth/request.ts +57 -41
  67. package/src/cli/lib/bundled-marketplace.json +14 -1
  68. package/src/cli/lib/open-browser.test.ts +67 -0
  69. package/src/cli/lib/open-browser.ts +24 -5
  70. package/src/config/__tests__/profile-materialization.test.ts +26 -0
  71. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
  72. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  73. package/src/config/feature-flag-registry.json +17 -1
  74. package/src/config/profile-materialization.ts +29 -0
  75. package/src/config/sanitize-for-transfer.ts +16 -0
  76. package/src/config/schemas/llm.ts +7 -0
  77. package/src/config/schemas/services.ts +6 -0
  78. package/src/context/outbound-sanitize.ts +6 -0
  79. package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
  80. package/src/daemon/conversation-error.ts +24 -2
  81. package/src/daemon/conversation-slash.ts +6 -15
  82. package/src/daemon/daemon-control.ts +1 -0
  83. package/src/daemon/disk-pressure-policy.ts +7 -1
  84. package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
  85. package/src/daemon/handlers/config-ingress.ts +115 -5
  86. package/src/daemon/lifecycle.ts +26 -0
  87. package/src/daemon/message-types/web-activity.ts +3 -2
  88. package/src/daemon/trust-context.ts +0 -37
  89. package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
  90. package/src/inbound/platform-callback-registration.ts +28 -2
  91. package/src/inbound/public-ingress-urls.ts +12 -0
  92. package/src/inbound/tunnel-probe.ts +261 -0
  93. package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
  94. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
  95. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
  97. package/src/live-voice/live-voice-connection.ts +46 -8
  98. package/src/live-voice/live-voice-manager.ts +25 -0
  99. package/src/live-voice/live-voice-session-manager.ts +318 -2
  100. package/src/live-voice/live-voice-session.ts +52 -2
  101. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
  102. package/src/messaging/providers/channel-transport.ts +64 -47
  103. package/src/messaging/providers/discord/send.test.ts +46 -1
  104. package/src/messaging/providers/discord/send.ts +51 -0
  105. package/src/messaging/providers/discord/transport.ts +26 -3
  106. package/src/messaging/providers/index.ts +22 -47
  107. package/src/messaging/providers/slack/send.test.ts +83 -26
  108. package/src/messaging/providers/slack/send.ts +120 -51
  109. package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
  110. package/src/messaging/providers/slack/stream-tasks.ts +39 -0
  111. package/src/messaging/providers/slack/transport.ts +24 -22
  112. package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
  113. package/src/messaging/providers/telegram-bot/send.ts +43 -0
  114. package/src/messaging/providers/telegram-bot/transport.ts +25 -8
  115. package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
  116. package/src/notifications/adapters/telegram.ts +48 -1
  117. package/src/notifications/assistant-reply-producer.ts +7 -7
  118. package/src/notifications/conversation-seed-composer.ts +7 -2
  119. package/src/oauth/byo-connection.test.ts +63 -0
  120. package/src/oauth/byo-connection.ts +16 -15
  121. package/src/oauth/connection.test.ts +111 -0
  122. package/src/oauth/connection.ts +142 -1
  123. package/src/oauth/platform-connection.test.ts +34 -0
  124. package/src/oauth/platform-connection.ts +28 -5
  125. package/src/oauth/seed-providers.ts +15 -1
  126. package/src/permissions/types.ts +3 -1
  127. package/src/persistence/conversation-crud.ts +46 -0
  128. package/src/persistence/conversation-types.ts +11 -9
  129. package/src/persistence/db-async-query.ts +2 -1
  130. package/src/persistence/db-maintenance.ts +15 -0
  131. package/src/persistence/embeddings/qdrant-manager.ts +1 -0
  132. package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
  133. package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
  134. package/src/persistence/migrations/369-create-app-pins.ts +37 -0
  135. package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
  136. package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
  137. package/src/persistence/schema/index.ts +1 -0
  138. package/src/persistence/schema/infrastructure.ts +17 -0
  139. package/src/persistence/schema/watch.ts +29 -0
  140. package/src/persistence/steps.ts +6 -0
  141. package/src/plugins/mtime-cache.ts +11 -0
  142. package/src/providers/__tests__/retry-network-error.test.ts +84 -0
  143. package/src/providers/connection-resolution.ts +23 -1
  144. package/src/providers/content-blocks.ts +9 -0
  145. package/src/providers/fetch-provider-catalog.ts +19 -0
  146. package/src/providers/gemini/client.ts +13 -5
  147. package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
  148. package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
  149. package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
  150. package/src/providers/inference/adapter-factory.ts +0 -9
  151. package/src/providers/inference/credential-rotation.ts +61 -0
  152. package/src/providers/inference/endpoint-probe.ts +115 -0
  153. package/src/providers/inference/profile-probe.ts +256 -0
  154. package/src/providers/model-catalog.ts +170 -125
  155. package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
  156. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
  157. package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
  158. package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
  159. package/src/providers/openai/api-error-normalization.ts +16 -2
  160. package/src/providers/openai/chat-completions-provider.ts +75 -29
  161. package/src/providers/openai/responses-provider.ts +5 -2
  162. package/src/providers/openrouter/client.ts +0 -1
  163. package/src/providers/provider-send-message.ts +11 -0
  164. package/src/providers/retry.ts +6 -0
  165. package/src/providers/search-provider-catalog.ts +20 -0
  166. package/src/providers/vercel-ai-gateway/client.ts +0 -1
  167. package/src/runtime/AGENTS.md +1 -0
  168. package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
  169. package/src/runtime/__tests__/host-observe.test.ts +302 -0
  170. package/src/runtime/channel-readiness-service.ts +214 -14
  171. package/src/runtime/channel-readiness-types.ts +49 -2
  172. package/src/runtime/channel-reply-delivery.ts +2 -2
  173. package/src/runtime/desktop-presence.ts +24 -21
  174. package/src/runtime/host-observe.ts +246 -0
  175. package/src/runtime/http-server.ts +181 -1
  176. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
  177. package/src/runtime/migrations/staged-import-path.ts +116 -0
  178. package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
  179. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
  180. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
  181. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
  182. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
  183. package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
  184. package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
  185. package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
  186. package/src/runtime/routes/app-management-routes.ts +140 -29
  187. package/src/runtime/routes/channel-availability-routes.ts +1 -0
  188. package/src/runtime/routes/channel-readiness-routes.ts +14 -2
  189. package/src/runtime/routes/conversation-query-routes.ts +10 -0
  190. package/src/runtime/routes/guardian-approval-interception.ts +24 -33
  191. package/src/runtime/routes/host-cu-routes.ts +18 -0
  192. package/src/runtime/routes/identity-routes.ts +2 -0
  193. package/src/runtime/routes/inbound-message-handler.ts +10 -7
  194. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
  195. package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
  196. package/src/runtime/routes/index.ts +2 -0
  197. package/src/runtime/routes/inference-profiles-routes.ts +232 -31
  198. package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
  199. package/src/runtime/routes/ingress-status-routes.ts +180 -0
  200. package/src/runtime/routes/live-voice-routes.test.ts +40 -1
  201. package/src/runtime/routes/live-voice-routes.ts +34 -0
  202. package/src/runtime/routes/migration-routes.ts +218 -10
  203. package/src/runtime/routes/oauth-commands-routes.ts +23 -16
  204. package/src/runtime/routes/plugins-routes.ts +12 -28
  205. package/src/runtime/routes/question-routes.ts +6 -0
  206. package/src/runtime/routes/secret-routes.ts +7 -27
  207. package/src/runtime/routes/settings-routes.ts +9 -6
  208. package/src/runtime/routes/watch-routes.ts +807 -0
  209. package/src/runtime/slack-reply-session.test.ts +230 -121
  210. package/src/runtime/slack-reply-session.ts +113 -81
  211. package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
  212. package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
  213. package/src/security/__tests__/untrusted-content.test.ts +42 -0
  214. package/src/security/untrusted-content.ts +28 -9
  215. package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
  216. package/src/telemetry/live-voice-funnel.ts +75 -8
  217. package/src/tools/credentials/store.ts +18 -6
  218. package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
  219. package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
  220. package/src/tools/network/__tests__/web-search.test.ts +97 -2
  221. package/src/tools/network/firecrawl-compat.ts +90 -0
  222. package/src/tools/network/web-fetch.ts +142 -62
  223. package/src/tools/network/web-search.ts +141 -55
  224. package/src/tools/types.ts +2 -1
  225. package/src/util/oauth-request-body.test.ts +74 -0
  226. package/src/util/oauth-request-body.ts +60 -0
  227. package/src/util/worker-process.ts +1 -0
  228. package/src/watch/__tests__/watch-retro.test.ts +665 -0
  229. package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
  230. package/src/watch/__tests__/watch-timeline.test.ts +670 -0
  231. package/src/watch/watch-retro.ts +480 -0
  232. package/src/watch/watch-session-manager.ts +575 -0
  233. package/src/watch/watch-timeline.ts +848 -0
package/AGENTS.md CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  For error handling conventions (throw vs result objects vs null), see [docs/error-handling.md](docs/error-handling.md).
4
4
 
5
- Subdirectory-scoped rules live in local AGENTS.md files: `src/cli/`, `src/runtime/`, `src/approvals/`, `src/notifications/`, `src/permissions/`, `src/plugins/`, `src/workspace/migrations/`.
5
+ Subdirectory-scoped rules live in local AGENTS.md files throughout `src/`, some
6
+ of them nested several levels down. Read the one nearest the code you are
7
+ changing plus any above it, and follow the nearest where two disagree.
8
+ `git ls-files ':(top)assistant/src/**/AGENTS.md'` lists them from anywhere in
9
+ the tree.
6
10
 
7
11
  ## Adding new environment variables
8
12
 
@@ -2,7 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
4
  normalizeHttpPublicBaseUrl,
5
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash,
5
6
  normalizePublicBaseUrl,
7
+ parseRecordedAssistantId,
8
+ parseTunnelRecord,
9
+ trimmedNonEmptyString,
10
+ TUNNEL_PROVIDERS,
6
11
  velayHostForPlatformHost,
7
12
  } from "../ingress.js";
8
13
  import {
@@ -85,6 +90,35 @@ describe("normalizeHttpPublicBaseUrl", () => {
85
90
  });
86
91
  });
87
92
 
93
+ describe("normalizeHttpPublicBaseUrlWithoutTrailingSlash", () => {
94
+ test("drops the root path its sibling always emits", () => {
95
+ expect(
96
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash("https://x.test"),
97
+ ).toBe("https://x.test");
98
+ expect(
99
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash("https://x.test/"),
100
+ ).toBe("https://x.test");
101
+ expect(
102
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash(" https://x.test/api/// "),
103
+ ).toBe("https://x.test/api");
104
+ });
105
+
106
+ test("rejects everything its sibling rejects", () => {
107
+ for (const value of [
108
+ "",
109
+ " ",
110
+ "notaurl",
111
+ "ftp://x.test",
112
+ "https://x.test?a=b",
113
+ 42,
114
+ ]) {
115
+ expect(
116
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash(value),
117
+ ).toBeUndefined();
118
+ }
119
+ });
120
+ });
121
+
88
122
  describe("Twilio ingress helpers", () => {
89
123
  test("resolves public base URL with fallback", () => {
90
124
  expect(
@@ -123,3 +157,87 @@ describe("Twilio ingress helpers", () => {
123
157
  });
124
158
  });
125
159
  });
160
+
161
+ describe("parseTunnelRecord", () => {
162
+ const TUNNEL_URL = "https://assistant-1.example.ts.net";
163
+
164
+ test("accepts every provider in the registry", () => {
165
+ for (const provider of TUNNEL_PROVIDERS) {
166
+ expect(
167
+ parseTunnelRecord({ provider, publicBaseUrl: TUNNEL_URL }),
168
+ ).toEqual({ provider, publicBaseUrl: TUNNEL_URL });
169
+ }
170
+ });
171
+
172
+ test("returns the URL in the shape its own validator produces", () => {
173
+ // Padding and trailing slashes are normalized away rather than handed
174
+ // back, so readers never re-normalize what they were given.
175
+ for (const publicBaseUrl of [
176
+ ` ${TUNNEL_URL} `,
177
+ `${TUNNEL_URL}/`,
178
+ `${TUNNEL_URL}///`,
179
+ ]) {
180
+ expect(
181
+ parseTunnelRecord({ provider: "ngrok", publicBaseUrl }),
182
+ ).toEqual({ provider: "ngrok", publicBaseUrl: TUNNEL_URL });
183
+ }
184
+ });
185
+
186
+ test("rejects values that are not a record", () => {
187
+ for (const value of [undefined, null, "nonsense", 42, []]) {
188
+ expect(parseTunnelRecord(value)).toBeNull();
189
+ }
190
+ });
191
+
192
+ test("rejects a record missing either field", () => {
193
+ expect(parseTunnelRecord({ provider: "ngrok" })).toBeNull();
194
+ expect(parseTunnelRecord({ publicBaseUrl: TUNNEL_URL })).toBeNull();
195
+ });
196
+
197
+ test("rejects a provider outside the registry", () => {
198
+ // Readers render the provider into a `vellum tunnel --provider <name>`
199
+ // command, so a hand-edited config must not reach a terminal.
200
+ for (const provider of ["wireguard", "; rm -rf /", "", 42]) {
201
+ expect(
202
+ parseTunnelRecord({ provider, publicBaseUrl: TUNNEL_URL }),
203
+ ).toBeNull();
204
+ }
205
+ });
206
+
207
+ test("rejects a URL that is not absolute HTTP(S)", () => {
208
+ for (const publicBaseUrl of [
209
+ "",
210
+ " ",
211
+ "not-a-url",
212
+ "example.ts.net",
213
+ "ftp://x.test",
214
+ "https://one.ngrok.app?token=x",
215
+ 42,
216
+ ]) {
217
+ expect(
218
+ parseTunnelRecord({ provider: "ngrok", publicBaseUrl }),
219
+ ).toBeNull();
220
+ }
221
+ });
222
+ });
223
+
224
+ describe("trimmedNonEmptyString", () => {
225
+ test("trims and rejects blank or non-string values", () => {
226
+ expect(trimmedNonEmptyString(" assistant-1 ")).toBe("assistant-1");
227
+ for (const value of ["", " ", undefined, null, 42]) {
228
+ expect(trimmedNonEmptyString(value)).toBeUndefined();
229
+ }
230
+ });
231
+ });
232
+
233
+ describe("parseRecordedAssistantId", () => {
234
+ test("trims a recorded id", () => {
235
+ expect(parseRecordedAssistantId(" assistant-1 ")).toBe("assistant-1");
236
+ });
237
+
238
+ test("rejects blank and non-string values", () => {
239
+ for (const value of ["", " ", undefined, null, 42]) {
240
+ expect(parseRecordedAssistantId(value)).toBeNull();
241
+ }
242
+ });
243
+ });
@@ -48,3 +48,106 @@ export function normalizeHttpPublicBaseUrl(value: unknown): string | undefined {
48
48
  return undefined;
49
49
  }
50
50
  }
51
+
52
+ /**
53
+ * The same validation as `normalizeHttpPublicBaseUrl`, minus the root path it
54
+ * always emits (`https://x` becomes `https://x/`). Callers that append a path
55
+ * or persist the result want this shape, so they neither join through a double
56
+ * slash nor re-strip what the normalizer just added.
57
+ */
58
+ export function normalizeHttpPublicBaseUrlWithoutTrailingSlash(
59
+ value: unknown,
60
+ ): string | undefined {
61
+ return normalizePublicBaseUrl(normalizeHttpPublicBaseUrl(value));
62
+ }
63
+
64
+ /**
65
+ * A trimmed string, or undefined when the value is not a non-blank string.
66
+ *
67
+ * Exported because the identity check in the daemon's tunnel probe compares an
68
+ * id recorded here against one an edge serves, and the two sides have to trim
69
+ * alike: a padded served id must read as a match, not as a different
70
+ * assistant.
71
+ */
72
+ export function trimmedNonEmptyString(value: unknown): string | undefined {
73
+ if (typeof value !== "string") {
74
+ return undefined;
75
+ }
76
+ const trimmed = value.trim();
77
+ return trimmed.length > 0 ? trimmed : undefined;
78
+ }
79
+
80
+ // Records `vellum tunnel` leaves in the workspace config's `ingress` section.
81
+ // The CLI writes them and the daemon reads them, and the two are separate
82
+ // build units that cannot import each other, so the key names, the provider
83
+ // allowlist, and the validation all live here rather than in a copy on each
84
+ // side.
85
+
86
+ /** Key under `ingress` holding the tunnel that most recently ran. */
87
+ export const INGRESS_LAST_TUNNEL_KEY = "lastTunnel";
88
+
89
+ /**
90
+ * Key under `ingress` holding a tunnel published for device pairing alone.
91
+ *
92
+ * A tailnet-only tunnel started while webhook integrations are configured
93
+ * cannot carry their callbacks, so it leaves `publicBaseUrl` (the callback
94
+ * base) as it is and records itself here. Readers that answer the pairing card
95
+ * prefer this address over `publicBaseUrl`; teardown drops the key.
96
+ */
97
+ export const INGRESS_PAIRING_TUNNEL_KEY = "pairingTunnel";
98
+
99
+ /** Key under `ingress` holding the assistant id that tunnel fronted. */
100
+ export const INGRESS_ASSISTANT_ID_KEY = "assistantId";
101
+
102
+ /**
103
+ * The local tunnel providers the CLI can start, in the order they are offered.
104
+ * `vellum tunnel`'s accepted `--provider` values and the persisted-record
105
+ * validation below both derive from it.
106
+ */
107
+ export const TUNNEL_PROVIDERS = ["ngrok", "cloudflare", "tailscale"] as const;
108
+
109
+ export type TunnelProviderName = (typeof TUNNEL_PROVIDERS)[number];
110
+
111
+ /** A recorded tunnel, kept after teardown so UIs can name the command to restart. */
112
+ export interface TunnelRecord {
113
+ provider: TunnelProviderName;
114
+ publicBaseUrl: string;
115
+ }
116
+
117
+ function isTunnelProviderName(value: unknown): value is TunnelProviderName {
118
+ return (
119
+ typeof value === "string" &&
120
+ (TUNNEL_PROVIDERS as readonly string[]).includes(value)
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Parse an `ingress.lastTunnel` or `ingress.pairingTunnel` value, or null when
126
+ * it is absent or malformed.
127
+ *
128
+ * A hand-edited config must not hand callers an unusable address or a provider
129
+ * name no command accepts: the provider is checked against the allowlist
130
+ * because readers render it into a restart command, and the URL faces the same
131
+ * absolute HTTP(S) constraint as every other public base URL. The URL comes
132
+ * back in the shape the validator produced, so readers never re-normalize it.
133
+ */
134
+ export function parseTunnelRecord(value: unknown): TunnelRecord | null {
135
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
136
+ return null;
137
+ }
138
+ const { provider, publicBaseUrl } = value as Record<string, unknown>;
139
+ if (!isTunnelProviderName(provider)) {
140
+ return null;
141
+ }
142
+ const normalized =
143
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash(publicBaseUrl);
144
+ if (normalized === undefined) {
145
+ return null;
146
+ }
147
+ return { provider, publicBaseUrl: normalized };
148
+ }
149
+
150
+ /** Parse an `ingress.assistantId` value, or null when it is absent or blank. */
151
+ export function parseRecordedAssistantId(value: unknown): string | null {
152
+ return trimmedNonEmptyString(value) ?? null;
153
+ }
@@ -2,7 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
4
  normalizeHttpPublicBaseUrl,
5
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash,
5
6
  normalizePublicBaseUrl,
7
+ parseRecordedAssistantId,
8
+ parseTunnelRecord,
9
+ trimmedNonEmptyString,
10
+ TUNNEL_PROVIDERS,
6
11
  velayHostForPlatformHost,
7
12
  } from "../ingress.js";
8
13
  import {
@@ -85,6 +90,35 @@ describe("normalizeHttpPublicBaseUrl", () => {
85
90
  });
86
91
  });
87
92
 
93
+ describe("normalizeHttpPublicBaseUrlWithoutTrailingSlash", () => {
94
+ test("drops the root path its sibling always emits", () => {
95
+ expect(
96
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash("https://x.test"),
97
+ ).toBe("https://x.test");
98
+ expect(
99
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash("https://x.test/"),
100
+ ).toBe("https://x.test");
101
+ expect(
102
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash(" https://x.test/api/// "),
103
+ ).toBe("https://x.test/api");
104
+ });
105
+
106
+ test("rejects everything its sibling rejects", () => {
107
+ for (const value of [
108
+ "",
109
+ " ",
110
+ "notaurl",
111
+ "ftp://x.test",
112
+ "https://x.test?a=b",
113
+ 42,
114
+ ]) {
115
+ expect(
116
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash(value),
117
+ ).toBeUndefined();
118
+ }
119
+ });
120
+ });
121
+
88
122
  describe("Twilio ingress helpers", () => {
89
123
  test("resolves public base URL with fallback", () => {
90
124
  expect(
@@ -123,3 +157,87 @@ describe("Twilio ingress helpers", () => {
123
157
  });
124
158
  });
125
159
  });
160
+
161
+ describe("parseTunnelRecord", () => {
162
+ const TUNNEL_URL = "https://assistant-1.example.ts.net";
163
+
164
+ test("accepts every provider in the registry", () => {
165
+ for (const provider of TUNNEL_PROVIDERS) {
166
+ expect(
167
+ parseTunnelRecord({ provider, publicBaseUrl: TUNNEL_URL }),
168
+ ).toEqual({ provider, publicBaseUrl: TUNNEL_URL });
169
+ }
170
+ });
171
+
172
+ test("returns the URL in the shape its own validator produces", () => {
173
+ // Padding and trailing slashes are normalized away rather than handed
174
+ // back, so readers never re-normalize what they were given.
175
+ for (const publicBaseUrl of [
176
+ ` ${TUNNEL_URL} `,
177
+ `${TUNNEL_URL}/`,
178
+ `${TUNNEL_URL}///`,
179
+ ]) {
180
+ expect(
181
+ parseTunnelRecord({ provider: "ngrok", publicBaseUrl }),
182
+ ).toEqual({ provider: "ngrok", publicBaseUrl: TUNNEL_URL });
183
+ }
184
+ });
185
+
186
+ test("rejects values that are not a record", () => {
187
+ for (const value of [undefined, null, "nonsense", 42, []]) {
188
+ expect(parseTunnelRecord(value)).toBeNull();
189
+ }
190
+ });
191
+
192
+ test("rejects a record missing either field", () => {
193
+ expect(parseTunnelRecord({ provider: "ngrok" })).toBeNull();
194
+ expect(parseTunnelRecord({ publicBaseUrl: TUNNEL_URL })).toBeNull();
195
+ });
196
+
197
+ test("rejects a provider outside the registry", () => {
198
+ // Readers render the provider into a `vellum tunnel --provider <name>`
199
+ // command, so a hand-edited config must not reach a terminal.
200
+ for (const provider of ["wireguard", "; rm -rf /", "", 42]) {
201
+ expect(
202
+ parseTunnelRecord({ provider, publicBaseUrl: TUNNEL_URL }),
203
+ ).toBeNull();
204
+ }
205
+ });
206
+
207
+ test("rejects a URL that is not absolute HTTP(S)", () => {
208
+ for (const publicBaseUrl of [
209
+ "",
210
+ " ",
211
+ "not-a-url",
212
+ "example.ts.net",
213
+ "ftp://x.test",
214
+ "https://one.ngrok.app?token=x",
215
+ 42,
216
+ ]) {
217
+ expect(
218
+ parseTunnelRecord({ provider: "ngrok", publicBaseUrl }),
219
+ ).toBeNull();
220
+ }
221
+ });
222
+ });
223
+
224
+ describe("trimmedNonEmptyString", () => {
225
+ test("trims and rejects blank or non-string values", () => {
226
+ expect(trimmedNonEmptyString(" assistant-1 ")).toBe("assistant-1");
227
+ for (const value of ["", " ", undefined, null, 42]) {
228
+ expect(trimmedNonEmptyString(value)).toBeUndefined();
229
+ }
230
+ });
231
+ });
232
+
233
+ describe("parseRecordedAssistantId", () => {
234
+ test("trims a recorded id", () => {
235
+ expect(parseRecordedAssistantId(" assistant-1 ")).toBe("assistant-1");
236
+ });
237
+
238
+ test("rejects blank and non-string values", () => {
239
+ for (const value of ["", " ", undefined, null, 42]) {
240
+ expect(parseRecordedAssistantId(value)).toBeNull();
241
+ }
242
+ });
243
+ });
@@ -48,3 +48,106 @@ export function normalizeHttpPublicBaseUrl(value: unknown): string | undefined {
48
48
  return undefined;
49
49
  }
50
50
  }
51
+
52
+ /**
53
+ * The same validation as `normalizeHttpPublicBaseUrl`, minus the root path it
54
+ * always emits (`https://x` becomes `https://x/`). Callers that append a path
55
+ * or persist the result want this shape, so they neither join through a double
56
+ * slash nor re-strip what the normalizer just added.
57
+ */
58
+ export function normalizeHttpPublicBaseUrlWithoutTrailingSlash(
59
+ value: unknown,
60
+ ): string | undefined {
61
+ return normalizePublicBaseUrl(normalizeHttpPublicBaseUrl(value));
62
+ }
63
+
64
+ /**
65
+ * A trimmed string, or undefined when the value is not a non-blank string.
66
+ *
67
+ * Exported because the identity check in the daemon's tunnel probe compares an
68
+ * id recorded here against one an edge serves, and the two sides have to trim
69
+ * alike: a padded served id must read as a match, not as a different
70
+ * assistant.
71
+ */
72
+ export function trimmedNonEmptyString(value: unknown): string | undefined {
73
+ if (typeof value !== "string") {
74
+ return undefined;
75
+ }
76
+ const trimmed = value.trim();
77
+ return trimmed.length > 0 ? trimmed : undefined;
78
+ }
79
+
80
+ // Records `vellum tunnel` leaves in the workspace config's `ingress` section.
81
+ // The CLI writes them and the daemon reads them, and the two are separate
82
+ // build units that cannot import each other, so the key names, the provider
83
+ // allowlist, and the validation all live here rather than in a copy on each
84
+ // side.
85
+
86
+ /** Key under `ingress` holding the tunnel that most recently ran. */
87
+ export const INGRESS_LAST_TUNNEL_KEY = "lastTunnel";
88
+
89
+ /**
90
+ * Key under `ingress` holding a tunnel published for device pairing alone.
91
+ *
92
+ * A tailnet-only tunnel started while webhook integrations are configured
93
+ * cannot carry their callbacks, so it leaves `publicBaseUrl` (the callback
94
+ * base) as it is and records itself here. Readers that answer the pairing card
95
+ * prefer this address over `publicBaseUrl`; teardown drops the key.
96
+ */
97
+ export const INGRESS_PAIRING_TUNNEL_KEY = "pairingTunnel";
98
+
99
+ /** Key under `ingress` holding the assistant id that tunnel fronted. */
100
+ export const INGRESS_ASSISTANT_ID_KEY = "assistantId";
101
+
102
+ /**
103
+ * The local tunnel providers the CLI can start, in the order they are offered.
104
+ * `vellum tunnel`'s accepted `--provider` values and the persisted-record
105
+ * validation below both derive from it.
106
+ */
107
+ export const TUNNEL_PROVIDERS = ["ngrok", "cloudflare", "tailscale"] as const;
108
+
109
+ export type TunnelProviderName = (typeof TUNNEL_PROVIDERS)[number];
110
+
111
+ /** A recorded tunnel, kept after teardown so UIs can name the command to restart. */
112
+ export interface TunnelRecord {
113
+ provider: TunnelProviderName;
114
+ publicBaseUrl: string;
115
+ }
116
+
117
+ function isTunnelProviderName(value: unknown): value is TunnelProviderName {
118
+ return (
119
+ typeof value === "string" &&
120
+ (TUNNEL_PROVIDERS as readonly string[]).includes(value)
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Parse an `ingress.lastTunnel` or `ingress.pairingTunnel` value, or null when
126
+ * it is absent or malformed.
127
+ *
128
+ * A hand-edited config must not hand callers an unusable address or a provider
129
+ * name no command accepts: the provider is checked against the allowlist
130
+ * because readers render it into a restart command, and the URL faces the same
131
+ * absolute HTTP(S) constraint as every other public base URL. The URL comes
132
+ * back in the shape the validator produced, so readers never re-normalize it.
133
+ */
134
+ export function parseTunnelRecord(value: unknown): TunnelRecord | null {
135
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
136
+ return null;
137
+ }
138
+ const { provider, publicBaseUrl } = value as Record<string, unknown>;
139
+ if (!isTunnelProviderName(provider)) {
140
+ return null;
141
+ }
142
+ const normalized =
143
+ normalizeHttpPublicBaseUrlWithoutTrailingSlash(publicBaseUrl);
144
+ if (normalized === undefined) {
145
+ return null;
146
+ }
147
+ return { provider, publicBaseUrl: normalized };
148
+ }
149
+
150
+ /** Parse an `ingress.assistantId` value, or null when it is absent or blank. */
151
+ export function parseRecordedAssistantId(value: unknown): string | null {
152
+ return trimmedNonEmptyString(value) ?? null;
153
+ }
@@ -2,6 +2,7 @@
2
2
  * Shared IPC contracts for assistant-to-gateway gateway-owned reads.
3
3
  */
4
4
 
5
+ import { CHANNEL_IDS } from "@vellumai/service-contracts/channels";
5
6
  import { z } from "zod";
6
7
 
7
8
  export const GATEWAY_LOG_LEVEL_NAMES = [
@@ -506,3 +507,72 @@ export const ClassifyRiskIpcResponseSchema = z.object({
506
507
  export type ClassifyRiskIpcResponse = z.infer<
507
508
  typeof ClassifyRiskIpcResponseSchema
508
509
  >;
510
+
511
+ // ── Channel socket health ────────────────────────────────────────────────────
512
+
513
+ /**
514
+ * Whether a channel that holds a long-lived inbound socket is currently
515
+ * receiving.
516
+ *
517
+ * Only channels whose ingress is a socket the gateway owns can answer this.
518
+ * Webhook channels answer the same question from the daemon side by checking
519
+ * their registration instead, so they report `unsupported` here rather than a
520
+ * misleading `disconnected`.
521
+ */
522
+ export const CHANNEL_SOCKET_HEALTH_STATUSES = [
523
+ /** A live connection the liveness watchdog is vouching for. */
524
+ "connected",
525
+ /** The channel is configured and running, but holds no live connection. */
526
+ "disconnected",
527
+ /** No client exists, because the channel's credentials are not configured. */
528
+ "not_configured",
529
+ /** This channel's ingress is not a gateway-owned socket. */
530
+ "unsupported",
531
+ ] as const;
532
+
533
+ export const ChannelSocketHealthIpcParamsSchema = z.object({
534
+ channel: z.enum(CHANNEL_IDS),
535
+ });
536
+
537
+ export type ChannelSocketHealthIpcParams = z.infer<
538
+ typeof ChannelSocketHealthIpcParamsSchema
539
+ >;
540
+
541
+ export const ChannelSocketHealthIpcResponseSchema = z.object({
542
+ channel: z.enum(CHANNEL_IDS),
543
+ status: z.enum(CHANNEL_SOCKET_HEALTH_STATUSES),
544
+ /**
545
+ * Epoch millis when the transport last proved it was alive, by whatever
546
+ * means that transport proves it: a pong on Slack, an op 11 ACK on Discord.
547
+ *
548
+ * Absent means "not proven yet", never "proven dead". A connection's first
549
+ * proof is one full interval after it opens, so a healthy reconnect reports
550
+ * `connected` with no timestamp. Corroborating evidence, not a verdict.
551
+ */
552
+ lastLivenessAt: z.number().optional(),
553
+ });
554
+
555
+ export type ChannelSocketHealthIpcResponse = z.infer<
556
+ typeof ChannelSocketHealthIpcResponseSchema
557
+ >;
558
+
559
+ /**
560
+ * Whether Discord admits anything in the guilds it has joined.
561
+ *
562
+ * Discord's allow-list is fail-closed and lives in gateway-owned config: an
563
+ * absent or empty setting admits nothing, because being invited to a guild is
564
+ * not consent to every channel in it. The daemon owns the readiness surface
565
+ * that reports this, so the count has to cross the boundary, the same way a
566
+ * socket's liveness does.
567
+ *
568
+ * Discord-shaped rather than channel-keyed, because Discord is the only
569
+ * channel with an allow-list. A second one generalizes this.
570
+ */
571
+ export const DiscordAdmissionIpcResponseSchema = z.object({
572
+ /** How many channel ids the bot may act in. Zero admits no guild message. */
573
+ admittedChannelCount: z.number().int().nonnegative(),
574
+ });
575
+
576
+ export type DiscordAdmissionIpcResponse = z.infer<
577
+ typeof DiscordAdmissionIpcResponseSchema
578
+ >;
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { z } from "zod";
14
14
 
15
+ import { ChannelConversationTypeSchema } from "./channel-permission-contract.js";
16
+
15
17
  import { AdmissionPolicySchema } from "./admission-policy-contract.js";
16
18
  import { TrustVerdictSchema } from "./trust-verdict-contract.js";
17
19
 
@@ -37,8 +39,21 @@ export const SourceMetadataSchema = z
37
39
  updateId: z.string().optional(),
38
40
  /** Provider message ID (e.g. Slack message `ts`). */
39
41
  messageId: z.string().optional(),
40
- /** Provider chat type (e.g. Telegram "private", "group"). */
42
+ /**
43
+ * Provider chat type (e.g. Telegram "private", "group"). Answers whether a
44
+ * room is multi-party, which drives group etiquette.
45
+ */
41
46
  chatType: z.string().optional(),
47
+ /**
48
+ * How visible the conversation is, on the permission matrix's axis, as
49
+ * decided by the sending channel's own normalizer. A different question
50
+ * from `chatType`: a group DM is multi-party and private, a public channel
51
+ * is multi-party and public.
52
+ *
53
+ * Absent means not established rather than public, so a permissive rule
54
+ * cannot reach a room whose visibility nobody proved.
55
+ */
56
+ conversationType: ChannelConversationTypeSchema.optional(),
42
57
  /** Thread/conversation-group ID (e.g. Slack `thread_ts`). */
43
58
  threadId: z.string().optional(),
44
59
  /** Channel name (e.g. Slack channel display name). */
@@ -28,7 +28,9 @@ export {
28
28
  ChannelReplyPayloadSchema,
29
29
  MessageAudienceSchema,
30
30
  PermissionRequestDetailsSchema,
31
- SlackStreamOpSchema,
31
+ StreamOpSchema,
32
+ StreamPlanSchema,
33
+ StreamPlanStepSchema,
32
34
  SlackStreamTaskSchema,
33
35
  } from "./outbound-contract.js";
34
36
 
@@ -40,7 +42,9 @@ export type {
40
42
  ChannelReplyPayload,
41
43
  MessageAudience,
42
44
  PermissionRequestDetails,
43
- SlackStreamOp,
45
+ StreamOp,
46
+ StreamPlan,
47
+ StreamPlanStep,
44
48
  SlackStreamTask,
45
49
  } from "./outbound-contract.js";
46
50