@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
@@ -213,6 +213,20 @@ describe("POST inference/profiles (create) validation", () => {
213
213
  });
214
214
  });
215
215
 
216
+ test("rejects a vellum profile whose maxTokens exceeds the routed model's limits", async () => {
217
+ seedVellumConnection();
218
+ await expect(
219
+ call("inference_profiles_create", {
220
+ body: {
221
+ name: "my-managed-big",
222
+ provider: "vellum",
223
+ model: "claude-opus-4-8",
224
+ maxTokens: 999999999,
225
+ },
226
+ }),
227
+ ).rejects.toThrow(/maxTokens/);
228
+ });
229
+
216
230
  test("rejects a vellum profile whose model has no managed upstream", async () => {
217
231
  await expect(
218
232
  call("inference_profiles_create", {
@@ -715,6 +729,110 @@ describe("GET inference/profiles honors llm.defaultProvider", () => {
715
729
 
716
730
  // ── active-profile setter validation (Finding 3) ──────────────────────────────
717
731
 
732
+ describe("POST inference/profiles/:name/validate billing guards", () => {
733
+ test("gives no verdict for a mix whose winning arm rides the managed route", async () => {
734
+ seedVellumConnection();
735
+ setConfig("llm", {
736
+ profiles: {
737
+ "managed-arm": { provider: "vellum", model: "claude-opus-4-8" },
738
+ "my-mix": {
739
+ mix: [
740
+ { profile: "managed-arm", weight: 1 },
741
+ { profile: "managed-arm", weight: 1 },
742
+ ],
743
+ },
744
+ },
745
+ });
746
+ const result = (await call("inference_profiles_validate", {
747
+ pathParams: { name: "my-mix" },
748
+ })) as { check: unknown };
749
+ expect(result.check).toBeNull();
750
+ });
751
+
752
+ test("gives no verdict when a call-site override routes the probe onto the managed identity", async () => {
753
+ seedVellumConnection();
754
+ setConfig("llm", {
755
+ profiles: {
756
+ "my-byok": { provider: "openai", model: "gpt-5.2" },
757
+ },
758
+ callSites: {
759
+ inference: { provider: "vellum", model: "claude-opus-4-8" },
760
+ },
761
+ });
762
+ const result = (await call("inference_profiles_validate", {
763
+ pathParams: { name: "my-byok" },
764
+ })) as { check: unknown };
765
+ expect(result.check).toBeNull();
766
+ });
767
+ });
768
+
769
+ describe("GET inference/profiles config_issue verdict", () => {
770
+ test("flags a stored profile whose model the provider does not serve", async () => {
771
+ seedConnection("anthropic-personal", "anthropic", {
772
+ type: "api_key",
773
+ credential: "credential/anthropic/api_key",
774
+ });
775
+ setConfig("llm", {
776
+ profiles: {
777
+ busted: { provider: "anthropic", model: "bub bub" },
778
+ fine: { provider: "anthropic", model: "claude-opus-4-8" },
779
+ },
780
+ });
781
+ const result = (await call("inference_profiles_list", {})) as {
782
+ profiles: Array<{
783
+ name: string;
784
+ config_issue?: { code: string; message: string };
785
+ }>;
786
+ };
787
+ const busted = result.profiles.find((p) => p.name === "busted");
788
+ expect(busted?.config_issue?.code).toBe("model_unknown");
789
+ expect(busted?.config_issue?.message).toContain("bub bub");
790
+ const fine = result.profiles.find((p) => p.name === "fine");
791
+ expect(fine?.config_issue).toBeUndefined();
792
+ });
793
+
794
+ test("flags an impossible stored token budget and mirrors on the detail route", async () => {
795
+ setConfig("llm", {
796
+ profiles: {
797
+ overweight: {
798
+ provider: "anthropic",
799
+ model: "claude-opus-4-8",
800
+ maxTokens: 999999999,
801
+ },
802
+ },
803
+ });
804
+ const detail = (await call("inference_profiles_get", {
805
+ pathParams: { name: "overweight" },
806
+ })) as { config_issue?: { code: string } };
807
+ expect(detail.config_issue?.code).toBe("over_output_cap");
808
+ });
809
+
810
+ test("does not flag a deliberately allowUnlisted profile", async () => {
811
+ seedConnection("anthropic-personal", "anthropic", {
812
+ type: "api_key",
813
+ credential: "credential/anthropic/api_key",
814
+ });
815
+ await call("inference_profiles_create", {
816
+ body: {
817
+ name: "early-adopter",
818
+ provider: "anthropic",
819
+ model: "claude-6-preview",
820
+ connection: "anthropic-personal",
821
+ allowUnlisted: true,
822
+ allowUnavailable: true,
823
+ },
824
+ });
825
+ const result = (await call("inference_profiles_list", {})) as {
826
+ profiles: Array<{
827
+ name: string;
828
+ config_issue?: { code: string };
829
+ }>;
830
+ };
831
+ const row = result.profiles.find((p) => p.name === "early-adopter");
832
+ expect(row?.config_issue).toBeUndefined();
833
+ });
834
+ });
835
+
718
836
  describe("PUT inference/active-profile validation", () => {
719
837
  test("sets a valid profile", async () => {
720
838
  seedKeyedConnection("anthropic");
@@ -565,6 +565,26 @@ describe("POST inference/provider-connections (create)", () => {
565
565
  ).rejects.toThrow(/belongs to a built-in provider/);
566
566
  });
567
567
 
568
+ test("attaches a failed endpoint_check when the custom base URL is unreachable", async () => {
569
+ // Port 1 is never listening, so the probe fails fast with a network error.
570
+ const result = (await call(
571
+ findHandler("inference_provider_connections_create"),
572
+ {
573
+ body: {
574
+ name: "probe-dead-endpoint",
575
+ provider: "openai-compatible",
576
+ auth: { type: "none" },
577
+ base_url: "http://127.0.0.1:1",
578
+ models: [{ id: "my-model" }],
579
+ },
580
+ },
581
+ )) as { endpoint_check?: { ok: boolean; resolved_url: string } };
582
+ expect(result.endpoint_check).toMatchObject({
583
+ ok: false,
584
+ resolved_url: "http://127.0.0.1:1/chat/completions",
585
+ });
586
+ });
587
+
568
588
  test("derives none auth for openai-compatible without a credential", async () => {
569
589
  const result = (await call(
570
590
  findHandler("inference_provider_connections_create"),
@@ -0,0 +1,508 @@
1
+ /**
2
+ * Unit tests for the integrations_ingress_status route handler.
3
+ *
4
+ * The workspace config reads and the probe are both mocked, so the six
5
+ * states are exercised without a config file or network access.
6
+ */
7
+
8
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
9
+
10
+ import type { TunnelRecord } from "@vellumai/service-contracts/ingress";
11
+
12
+ import type { TunnelProbeResult } from "../../../inbound/tunnel-probe.js";
13
+ import { ACTOR_PRINCIPALS } from "../../auth/route-policy.js";
14
+
15
+ let ingressConfig = {
16
+ enabled: true,
17
+ explicitlyDisabled: false,
18
+ publicBaseUrl: "",
19
+ localGatewayTarget: "http://127.0.0.1:4000",
20
+ managedCallbacks: false,
21
+ success: true,
22
+ };
23
+ let velayManaged = false;
24
+ let lastTunnel: TunnelRecord | null = null;
25
+ let pairingTunnel: TunnelRecord | null = null;
26
+ let recordedAssistantId: string | null = null;
27
+ let probeResult: TunnelProbeResult = { kind: "healthy" };
28
+
29
+ const probeTunnelMock = mock(
30
+ async (_args: {
31
+ publicBaseUrl: string;
32
+ expectedAssistantId?: string;
33
+ }): Promise<TunnelProbeResult> => probeResult,
34
+ );
35
+
36
+ mock.module("../../../daemon/handlers/config-ingress.js", () => ({
37
+ getIngressConfigResult: () => ingressConfig,
38
+ isVelayManagedIngress: () => velayManaged,
39
+ loadLastTunnelRecord: () => lastTunnel,
40
+ loadPairingTunnelRecord: () => pairingTunnel,
41
+ loadRecordedAssistantId: () => recordedAssistantId,
42
+ }));
43
+
44
+ mock.module("../../../inbound/tunnel-probe.js", () => ({
45
+ probeTunnel: probeTunnelMock,
46
+ }));
47
+
48
+ import { ROUTES } from "../ingress-status-routes.js";
49
+
50
+ const route = ROUTES.find(
51
+ (r) => r.operationId === "integrations_ingress_status",
52
+ )!;
53
+
54
+ const TUNNEL_URL = "https://assistant-1.example.ts.net";
55
+ const PAIRING_URL = "https://assistant-1.pairing.example.ts.net";
56
+
57
+ describe("integrations_ingress_status route", () => {
58
+ beforeEach(() => {
59
+ ingressConfig = {
60
+ enabled: true,
61
+ explicitlyDisabled: false,
62
+ publicBaseUrl: "",
63
+ localGatewayTarget: "http://127.0.0.1:4000",
64
+ managedCallbacks: false,
65
+ success: true,
66
+ };
67
+ velayManaged = false;
68
+ lastTunnel = null;
69
+ pairingTunnel = null;
70
+ recordedAssistantId = null;
71
+ probeResult = { kind: "healthy" };
72
+ probeTunnelMock.mockClear();
73
+ });
74
+
75
+ test("is registered with the expected operationId, method, and endpoint", () => {
76
+ expect(route).toBeDefined();
77
+ expect(route.method).toBe("GET");
78
+ expect(route.endpoint).toBe("integrations/ingress/status");
79
+ expect(route.policy).toEqual({
80
+ requiredScopes: ["settings.read"],
81
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
82
+ });
83
+ });
84
+
85
+ test("reports unconfigured for a platform-hosted assistant", async () => {
86
+ ingressConfig = {
87
+ ...ingressConfig,
88
+ publicBaseUrl: "https://platform.example.com/gateway/callbacks/a-1",
89
+ managedCallbacks: true,
90
+ };
91
+ lastTunnel = { provider: "tailscale", publicBaseUrl: TUNNEL_URL };
92
+
93
+ expect(await route.handler({})).toEqual({ state: "unconfigured" });
94
+ expect(probeTunnelMock).not.toHaveBeenCalled();
95
+ });
96
+
97
+ test("reports unconfigured with no URL and no recorded tunnel", async () => {
98
+ ingressConfig = { ...ingressConfig, publicBaseUrl: "" };
99
+
100
+ expect(await route.handler({})).toEqual({ state: "unconfigured" });
101
+ expect(probeTunnelMock).not.toHaveBeenCalled();
102
+ });
103
+
104
+ test("reports stopped with the recorded tunnel when the URL is gone", async () => {
105
+ ingressConfig = { ...ingressConfig, publicBaseUrl: "" };
106
+ lastTunnel = { provider: "tailscale", publicBaseUrl: TUNNEL_URL };
107
+
108
+ expect(await route.handler({})).toEqual({
109
+ state: "stopped",
110
+ lastTunnel: { provider: "tailscale", publicBaseUrl: TUNNEL_URL },
111
+ });
112
+ expect(probeTunnelMock).not.toHaveBeenCalled();
113
+ });
114
+
115
+ test("reports stopped without probing when ingress is switched off", async () => {
116
+ ingressConfig = {
117
+ ...ingressConfig,
118
+ enabled: false,
119
+ explicitlyDisabled: true,
120
+ publicBaseUrl: TUNNEL_URL,
121
+ };
122
+ lastTunnel = { provider: "tailscale", publicBaseUrl: TUNNEL_URL };
123
+
124
+ expect(await route.handler({})).toEqual({
125
+ state: "stopped",
126
+ lastTunnel: { provider: "tailscale", publicBaseUrl: TUNNEL_URL },
127
+ });
128
+ expect(probeTunnelMock).not.toHaveBeenCalled();
129
+ });
130
+
131
+ test("reports stopped for a disabled URL with no recorded tunnel", async () => {
132
+ ingressConfig = {
133
+ ...ingressConfig,
134
+ enabled: false,
135
+ explicitlyDisabled: true,
136
+ publicBaseUrl: TUNNEL_URL,
137
+ };
138
+
139
+ expect(await route.handler({})).toEqual({ state: "stopped" });
140
+ expect(probeTunnelMock).not.toHaveBeenCalled();
141
+ });
142
+
143
+ test("probes a URL configured without the enabled flag", async () => {
144
+ // A bring-your-own-HTTPS front is set up with `config set
145
+ // ingress.publicBaseUrl`, which never writes `ingress.enabled`. Only an
146
+ // explicit opt-out means stopped.
147
+ ingressConfig = {
148
+ ...ingressConfig,
149
+ enabled: false,
150
+ explicitlyDisabled: false,
151
+ publicBaseUrl: TUNNEL_URL,
152
+ };
153
+
154
+ const result = (await route.handler({})) as Record<string, unknown>;
155
+
156
+ expect(result.state).toBe("healthy");
157
+ expect(probeTunnelMock).toHaveBeenCalledWith({
158
+ publicBaseUrl: TUNNEL_URL,
159
+ });
160
+ });
161
+
162
+ test("reports healthy with a timestamp when the probe succeeds", async () => {
163
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
164
+ recordedAssistantId = "assistant-1";
165
+
166
+ const result = (await route.handler({})) as Record<string, unknown>;
167
+
168
+ expect(result.state).toBe("healthy");
169
+ expect(result.publicBaseUrl).toBe(TUNNEL_URL);
170
+ expect(new Date(result.checkedAt as string).getTime()).not.toBeNaN();
171
+ expect(probeTunnelMock).toHaveBeenCalledWith({
172
+ publicBaseUrl: TUNNEL_URL,
173
+ expectedAssistantId: "assistant-1",
174
+ });
175
+ });
176
+
177
+ test("omits expectedAssistantId when no id was recorded", async () => {
178
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
179
+
180
+ await route.handler({});
181
+
182
+ expect(probeTunnelMock).toHaveBeenCalledWith({
183
+ publicBaseUrl: TUNNEL_URL,
184
+ });
185
+ });
186
+
187
+ test("reports unreachable with the probe's detail", async () => {
188
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
189
+ probeResult = { kind: "unreachable", detail: "HTTP 502" };
190
+
191
+ const result = (await route.handler({})) as Record<string, unknown>;
192
+
193
+ expect(result.state).toBe("unreachable");
194
+ expect(result.detail).toBe("HTTP 502");
195
+ expect(result.publicBaseUrl).toBe(TUNNEL_URL);
196
+ expect(result.checkedAt).toBeString();
197
+ });
198
+
199
+ test("reports unpairable with the probe's detail", async () => {
200
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
201
+ probeResult = { kind: "unpairable", detail: "HTTP 404" };
202
+
203
+ const result = (await route.handler({})) as Record<string, unknown>;
204
+
205
+ expect(result.state).toBe("unpairable");
206
+ expect(result.detail).toBe("HTTP 404");
207
+ expect(result.publicBaseUrl).toBe(TUNNEL_URL);
208
+ expect(result.checkedAt).toBeString();
209
+ });
210
+
211
+ test("omits the detail when the unpairable edge gave no reason", async () => {
212
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
213
+ probeResult = { kind: "unpairable" };
214
+
215
+ const result = (await route.handler({})) as Record<string, unknown>;
216
+
217
+ expect(result.state).toBe("unpairable");
218
+ expect(result).not.toHaveProperty("detail");
219
+ });
220
+
221
+ test("names the tunnel to restart when the URL cannot pair", async () => {
222
+ // Same remedy as an unreachable address: start a tunnel that serves the
223
+ // web app in front of this assistant.
224
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
225
+ lastTunnel = { provider: "tailscale", publicBaseUrl: TUNNEL_URL };
226
+ probeResult = { kind: "unpairable", detail: "HTTP 404" };
227
+
228
+ const result = (await route.handler({})) as Record<string, unknown>;
229
+
230
+ expect(result.state).toBe("unpairable");
231
+ expect(result.lastTunnel).toEqual({
232
+ provider: "tailscale",
233
+ publicBaseUrl: TUNNEL_URL,
234
+ });
235
+ });
236
+
237
+ test("reports foreign with the name the edge serves under", async () => {
238
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
239
+ recordedAssistantId = "assistant-1";
240
+ probeResult = {
241
+ kind: "foreign",
242
+ assistantId: "assistant-2",
243
+ assistantName: "Other Assistant",
244
+ };
245
+
246
+ const result = (await route.handler({})) as Record<string, unknown>;
247
+
248
+ expect(result.state).toBe("foreign");
249
+ expect(result.servingAssistantName).toBe("Other Assistant");
250
+ expect(result.publicBaseUrl).toBe(TUNNEL_URL);
251
+ });
252
+
253
+ test("omits servingAssistantName when the edge reports no name", async () => {
254
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
255
+ probeResult = { kind: "foreign", assistantId: "assistant-2" };
256
+
257
+ const result = (await route.handler({})) as Record<string, unknown>;
258
+
259
+ expect(result.state).toBe("foreign");
260
+ expect(result).not.toHaveProperty("servingAssistantName");
261
+ });
262
+
263
+ test("names the tunnel to restart when the URL is unreachable", async () => {
264
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
265
+ lastTunnel = { provider: "ngrok", publicBaseUrl: TUNNEL_URL };
266
+ probeResult = { kind: "unreachable", detail: "HTTP 502" };
267
+
268
+ const result = (await route.handler({})) as Record<string, unknown>;
269
+
270
+ expect(result.state).toBe("unreachable");
271
+ expect(result.lastTunnel).toEqual({
272
+ provider: "ngrok",
273
+ publicBaseUrl: TUNNEL_URL,
274
+ });
275
+ });
276
+
277
+ test("names the tunnel to restart when the URL is foreign", async () => {
278
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
279
+ lastTunnel = { provider: "ngrok", publicBaseUrl: TUNNEL_URL };
280
+ probeResult = { kind: "foreign", assistantId: "assistant-2" };
281
+
282
+ const result = (await route.handler({})) as Record<string, unknown>;
283
+
284
+ expect(result.state).toBe("foreign");
285
+ expect(result.lastTunnel).toEqual({
286
+ provider: "ngrok",
287
+ publicBaseUrl: TUNNEL_URL,
288
+ });
289
+ });
290
+
291
+ test("ignores trailing slashes when matching the recorded tunnel", async () => {
292
+ ingressConfig = { ...ingressConfig, publicBaseUrl: `${TUNNEL_URL}/` };
293
+ lastTunnel = { provider: "ngrok", publicBaseUrl: TUNNEL_URL };
294
+ probeResult = { kind: "unreachable", detail: "HTTP 502" };
295
+
296
+ const result = (await route.handler({})) as Record<string, unknown>;
297
+
298
+ expect(result.lastTunnel).toEqual({
299
+ provider: "ngrok",
300
+ publicBaseUrl: TUNNEL_URL,
301
+ });
302
+ });
303
+
304
+ test("omits a recorded tunnel that fronted a different URL", async () => {
305
+ // `saveIngressUrl` keeps `lastTunnel` when it replaces the URL without a
306
+ // provider, so the record can outlive the address it described. Restarting
307
+ // it would not bring the configured URL back.
308
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
309
+ lastTunnel = { provider: "ngrok", publicBaseUrl: "https://old.ngrok.app" };
310
+ probeResult = { kind: "unreachable", detail: "HTTP 502" };
311
+
312
+ const result = (await route.handler({})) as Record<string, unknown>;
313
+
314
+ expect(result.state).toBe("unreachable");
315
+ expect(result).not.toHaveProperty("lastTunnel");
316
+ });
317
+
318
+ test("omits a stale recorded tunnel from a foreign response", async () => {
319
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
320
+ lastTunnel = { provider: "ngrok", publicBaseUrl: "https://old.ngrok.app" };
321
+ probeResult = { kind: "foreign", assistantId: "assistant-2" };
322
+
323
+ const result = (await route.handler({})) as Record<string, unknown>;
324
+
325
+ expect(result.state).toBe("foreign");
326
+ expect(result).not.toHaveProperty("lastTunnel");
327
+ });
328
+
329
+ test("reports unconfigured for a Velay-managed ingress with no URL", async () => {
330
+ // The gateway clears the URL when its tunnel drops and reconnects on its
331
+ // own, so there is no tunnel command to offer the user.
332
+ velayManaged = true;
333
+ ingressConfig = { ...ingressConfig, publicBaseUrl: "" };
334
+ lastTunnel = { provider: "ngrok", publicBaseUrl: TUNNEL_URL };
335
+
336
+ expect(await route.handler({})).toEqual({ state: "unconfigured" });
337
+ expect(probeTunnelMock).not.toHaveBeenCalled();
338
+ });
339
+
340
+ test("reports a Velay-managed URL with no pairing tunnel as unconfigured", async () => {
341
+ // Velay's allowlist (`gateway/src/velay/allowed-paths.ts`) carries neither
342
+ // `/healthz` nor the pairing surface, so the callback URL it owns could
343
+ // only ever probe as dead. The card asks for a first tunnel instead.
344
+ velayManaged = true;
345
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
346
+ lastTunnel = { provider: "ngrok", publicBaseUrl: TUNNEL_URL };
347
+
348
+ expect(await route.handler({})).toEqual({ state: "unconfigured" });
349
+ expect(probeTunnelMock).not.toHaveBeenCalled();
350
+ });
351
+
352
+ test("omits lastTunnel from a healthy response", async () => {
353
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
354
+ lastTunnel = { provider: "ngrok", publicBaseUrl: TUNNEL_URL };
355
+
356
+ const result = (await route.handler({})) as Record<string, unknown>;
357
+
358
+ expect(result.state).toBe("healthy");
359
+ expect(result).not.toHaveProperty("lastTunnel");
360
+ });
361
+
362
+ test("probes the pairing tunnel over the webhook callback base", async () => {
363
+ // `vellum tunnel --provider tailscale` beside configured webhooks leaves
364
+ // the callback base alone, so the address the user started for pairing is
365
+ // the one this route exists to report on.
366
+ ingressConfig = {
367
+ ...ingressConfig,
368
+ publicBaseUrl: "https://webhooks.ngrok.app",
369
+ };
370
+ pairingTunnel = { provider: "tailscale", publicBaseUrl: PAIRING_URL };
371
+ lastTunnel = {
372
+ provider: "ngrok",
373
+ publicBaseUrl: "https://webhooks.ngrok.app",
374
+ };
375
+ recordedAssistantId = "assistant-1";
376
+
377
+ const result = (await route.handler({})) as Record<string, unknown>;
378
+
379
+ expect(result.state).toBe("healthy");
380
+ expect(result.publicBaseUrl).toBe(PAIRING_URL);
381
+ expect(probeTunnelMock).toHaveBeenCalledWith({
382
+ publicBaseUrl: PAIRING_URL,
383
+ expectedAssistantId: "assistant-1",
384
+ });
385
+ });
386
+
387
+ test("names the pairing tunnel to restart when its address is dead", async () => {
388
+ ingressConfig = {
389
+ ...ingressConfig,
390
+ publicBaseUrl: "https://webhooks.ngrok.app",
391
+ };
392
+ pairingTunnel = { provider: "tailscale", publicBaseUrl: PAIRING_URL };
393
+ lastTunnel = {
394
+ provider: "ngrok",
395
+ publicBaseUrl: "https://webhooks.ngrok.app",
396
+ };
397
+ probeResult = { kind: "unreachable", detail: "HTTP 502" };
398
+
399
+ const result = (await route.handler({})) as Record<string, unknown>;
400
+
401
+ expect(result.state).toBe("unreachable");
402
+ expect(result.publicBaseUrl).toBe(PAIRING_URL);
403
+ expect(result.lastTunnel).toEqual({
404
+ provider: "tailscale",
405
+ publicBaseUrl: PAIRING_URL,
406
+ });
407
+ });
408
+
409
+ test("probes the pairing tunnel while callback ingress is switched off", async () => {
410
+ // `ingress.enabled` governs webhook callbacks. `vellum tunnel --provider
411
+ // tailscale` records a pairing tunnel without touching it, so the opt-out
412
+ // must not discard the address the command just established.
413
+ ingressConfig = {
414
+ ...ingressConfig,
415
+ enabled: false,
416
+ explicitlyDisabled: true,
417
+ publicBaseUrl: "https://webhooks.ngrok.app",
418
+ };
419
+ pairingTunnel = { provider: "tailscale", publicBaseUrl: PAIRING_URL };
420
+
421
+ const result = (await route.handler({})) as Record<string, unknown>;
422
+
423
+ expect(result.state).toBe("healthy");
424
+ expect(result.publicBaseUrl).toBe(PAIRING_URL);
425
+ expect(probeTunnelMock).toHaveBeenCalledWith({
426
+ publicBaseUrl: PAIRING_URL,
427
+ });
428
+ });
429
+
430
+ test("probes a pairing tunnel recorded without a callback base", async () => {
431
+ ingressConfig = { ...ingressConfig, publicBaseUrl: "" };
432
+ pairingTunnel = { provider: "tailscale", publicBaseUrl: PAIRING_URL };
433
+
434
+ const result = (await route.handler({})) as Record<string, unknown>;
435
+
436
+ expect(result.state).toBe("healthy");
437
+ expect(result.publicBaseUrl).toBe(PAIRING_URL);
438
+ });
439
+
440
+ test("prefers the pairing tunnel over a Velay-managed callback base", async () => {
441
+ // Velay owns the callback URL, not the tailnet address the user started
442
+ // for pairing, and the Velay URL exposes neither `/healthz` nor the
443
+ // pairing surface (`gateway/src/velay/allowed-paths.ts`).
444
+ velayManaged = true;
445
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
446
+ pairingTunnel = { provider: "tailscale", publicBaseUrl: PAIRING_URL };
447
+
448
+ const result = (await route.handler({})) as Record<string, unknown>;
449
+
450
+ expect(result.publicBaseUrl).toBe(PAIRING_URL);
451
+ expect(probeTunnelMock).toHaveBeenCalledWith({
452
+ publicBaseUrl: PAIRING_URL,
453
+ });
454
+ });
455
+
456
+ test("probes a Velay-managed ingress only through its pairing tunnel", async () => {
457
+ // The pairing record is the one address on a Velay-managed workspace that
458
+ // a device can reach, so it is probed while the callback base is not.
459
+ velayManaged = true;
460
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
461
+ pairingTunnel = { provider: "tailscale", publicBaseUrl: PAIRING_URL };
462
+ recordedAssistantId = "assistant-1";
463
+
464
+ const result = (await route.handler({})) as Record<string, unknown>;
465
+
466
+ expect(result.state).toBe("healthy");
467
+ expect(result.publicBaseUrl).toBe(PAIRING_URL);
468
+ expect(probeTunnelMock).toHaveBeenCalledTimes(1);
469
+ expect(probeTunnelMock).toHaveBeenCalledWith({
470
+ publicBaseUrl: PAIRING_URL,
471
+ expectedAssistantId: "assistant-1",
472
+ });
473
+ });
474
+
475
+ test("names the pairing tunnel to restart on a Velay-managed ingress", async () => {
476
+ // The restart guidance Velay must never get is for its own ingress; the
477
+ // pairing tunnel beside it is the user's to start again.
478
+ velayManaged = true;
479
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
480
+ pairingTunnel = { provider: "tailscale", publicBaseUrl: PAIRING_URL };
481
+ probeResult = { kind: "unreachable", detail: "HTTP 502" };
482
+
483
+ const result = (await route.handler({})) as Record<string, unknown>;
484
+
485
+ expect(result.state).toBe("unreachable");
486
+ expect(result.lastTunnel).toEqual({
487
+ provider: "tailscale",
488
+ publicBaseUrl: PAIRING_URL,
489
+ });
490
+ });
491
+
492
+ test("reports the configured URL when no pairing tunnel is recorded", async () => {
493
+ ingressConfig = { ...ingressConfig, publicBaseUrl: TUNNEL_URL };
494
+ lastTunnel = { provider: "ngrok", publicBaseUrl: TUNNEL_URL };
495
+
496
+ const result = (await route.handler({})) as Record<string, unknown>;
497
+
498
+ expect(result.state).toBe("healthy");
499
+ expect(result.publicBaseUrl).toBe(TUNNEL_URL);
500
+ });
501
+
502
+ test("treats a whitespace-only URL as no URL", async () => {
503
+ ingressConfig = { ...ingressConfig, publicBaseUrl: " " };
504
+
505
+ expect(await route.handler({})).toEqual({ state: "unconfigured" });
506
+ expect(probeTunnelMock).not.toHaveBeenCalled();
507
+ });
508
+ });