@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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Tests for the live-voice preflight route.
2
+ * Tests for the live-voice preflight and session-end routes.
3
3
  *
4
4
  * The handler must run managed-speech defaulting first, then return the
5
5
  * live-voice credential readiness verdict verbatim:
@@ -7,6 +7,9 @@
7
7
  * - not-ready → passes through the missing gaps + userMessage
8
8
  * - defaulting is invoked BEFORE readiness is resolved
9
9
  *
10
+ * The session-end route is the out-of-band recovery path (LUM-3440): it
11
+ * reports whether it actually reclaimed the daemon's single live-voice slot.
12
+ *
10
13
  * NOTE: `bun mock.module` leaks across files. Run this file on its own
11
14
  * (`bun test <thisfile>`) — a multi-file run may report a spurious failure.
12
15
  */
@@ -14,9 +17,11 @@
14
17
  import { describe, expect, mock, test } from "bun:test";
15
18
 
16
19
  import type { LiveVoiceCredentialReadiness } from "../../live-voice/live-voice-credential-preflight.js";
20
+ import type { LiveVoiceSessionReleaseResult } from "../../live-voice/live-voice-session-manager.js";
17
21
 
18
22
  const calls: string[] = [];
19
23
  let readiness: LiveVoiceCredentialReadiness = { status: "ready" };
24
+ let releaseResult: LiveVoiceSessionReleaseResult = { released: false };
20
25
 
21
26
  mock.module("../../config/managed-speech-defaults.js", () => ({
22
27
  maybeDefaultSpeechToManaged: async () => {
@@ -31,12 +36,25 @@ mock.module("../../live-voice/live-voice-credential-preflight.js", () => ({
31
36
  },
32
37
  }));
33
38
 
39
+ mock.module("../../live-voice/live-voice-manager.js", () => ({
40
+ getLiveVoiceSessionManager: () => ({
41
+ endActiveSession: async () => {
42
+ calls.push("endActiveSession");
43
+ return releaseResult;
44
+ },
45
+ }),
46
+ }));
47
+
34
48
  const { ROUTES } = await import("./live-voice-routes.js");
35
49
 
36
50
  const preflightRoute = ROUTES.find(
37
51
  (r) => r.operationId === "live_voice_preflight_post",
38
52
  )!;
39
53
 
54
+ const sessionEndRoute = ROUTES.find(
55
+ (r) => r.operationId === "live_voice_session_end_post",
56
+ )!;
57
+
40
58
  describe("live_voice_preflight_post", () => {
41
59
  test("returns { status: 'ready' } when readiness reports ready", async () => {
42
60
  calls.length = 0;
@@ -71,3 +89,24 @@ describe("live_voice_preflight_post", () => {
71
89
  expect(calls).toEqual(["default", "resolve"]);
72
90
  });
73
91
  });
92
+
93
+ describe("live_voice_session_end_post", () => {
94
+ test("reports the session it reclaimed", async () => {
95
+ calls.length = 0;
96
+ releaseResult = { released: true, sessionId: "session-1" };
97
+
98
+ const result = await sessionEndRoute.handler({});
99
+
100
+ expect(result).toEqual({ ended: true, sessionId: "session-1" });
101
+ expect(calls).toEqual(["endActiveSession"]);
102
+ });
103
+
104
+ test("reports no session rather than failing when the slot is free", async () => {
105
+ calls.length = 0;
106
+ releaseResult = { released: false };
107
+
108
+ const result = await sessionEndRoute.handler({});
109
+
110
+ expect(result).toEqual({ ended: false, sessionId: null });
111
+ });
112
+ });
@@ -5,6 +5,11 @@
5
5
  * then report whether the daemon can run both audio legs of a live voice
6
6
  * session. Lets the web client verify voice is configured BEFORE opening the
7
7
  * voice-room WebSocket, instead of opening it and reacting to an error frame.
8
+ *
9
+ * POST /v1/live-voice/session/end releases whatever session holds the
10
+ * daemon's single live-voice slot. The out-of-band half of session closure:
11
+ * the in-band `end` frame needs a working transport, and the case that needs
12
+ * ending most is the one where the transport is already gone.
8
13
  */
9
14
 
10
15
  import { z } from "zod";
@@ -26,6 +31,17 @@ async function handleLiveVoicePreflight() {
26
31
  return resolveLiveVoiceCredentialReadiness();
27
32
  }
28
33
 
34
+ async function handleLiveVoiceSessionEnd() {
35
+ const { getLiveVoiceSessionManager } =
36
+ await import("../../live-voice/live-voice-manager.js");
37
+
38
+ const result = await getLiveVoiceSessionManager().endActiveSession();
39
+ return {
40
+ ended: result.released,
41
+ sessionId: result.released ? result.sessionId : null,
42
+ };
43
+ }
44
+
29
45
  // ---------------------------------------------------------------------------
30
46
  // Route definitions
31
47
  // ---------------------------------------------------------------------------
@@ -62,4 +78,22 @@ export const ROUTES: RouteDefinition[] = [
62
78
  }),
63
79
  handler: handleLiveVoicePreflight,
64
80
  },
81
+ {
82
+ operationId: "live_voice_session_end_post",
83
+ endpoint: "live-voice/session/end",
84
+ method: "POST",
85
+ policy: {
86
+ requiredScopes: ["chat.write"],
87
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
88
+ },
89
+ summary: "End the active live voice session",
90
+ description:
91
+ "Release the assistant's single live-voice session slot, whichever client holds it. Reports whether a session was actually ended; a slot already tearing down reports false, since that teardown releases it on its own.",
92
+ tags: ["live-voice"],
93
+ responseBody: z.object({
94
+ ended: z.boolean(),
95
+ sessionId: z.string().nullable(),
96
+ }),
97
+ handler: handleLiveVoiceSessionEnd,
98
+ },
65
99
  ];
@@ -7,9 +7,11 @@
7
7
  * POST /v1/migrations/import — commit a .vbundle archive import to disk.
8
8
  *
9
9
  * Accepts raw binary body (Content-Type: application/octet-stream),
10
- * multipart form data with a "file" field, or — on /import only — a JSON
11
- * body of shape `{ "url": "<signed-gcs-url>" }` that causes the daemon to
12
- * fetch the bundle from GCS and stream it through `streamCommitImport`.
10
+ * multipart form data with a "file" field, or a JSON body of shape
11
+ * `{ "url": "<signed-gcs-url>" }` (import only) or `{ "path": "<staged>" }`
12
+ * (import and import-preflight). The path form streams a file the CLI
13
+ * staged under `${workspace}/.restore-staging/`. The URL form fetches the
14
+ * bundle from GCS and streams it through `streamCommitImport`.
13
15
  * Returns structured validation results with is_valid flag and detailed
14
16
  * error descriptions.
15
17
  */
@@ -58,6 +60,10 @@ import {
58
60
  migrationJobs,
59
61
  } from "../migrations/job-registry.js";
60
62
  import { getOriginMode } from "../migrations/origin-mode.js";
63
+ import {
64
+ resolveStagedImportPath,
65
+ StagedImportPathError,
66
+ } from "../migrations/staged-import-path.js";
61
67
  import type {
62
68
  VBundleAssistantInfo,
63
69
  VBundleCompatibility,
@@ -81,6 +87,11 @@ import {
81
87
  type ImportCommitResult,
82
88
  } from "../migrations/vbundle-importer.js";
83
89
  import { streamCommitImport } from "../migrations/vbundle-streaming-importer.js";
90
+ import {
91
+ readAndValidateManifest,
92
+ StreamingValidationError,
93
+ } from "../migrations/vbundle-streaming-validator.js";
94
+ import { parseVBundleStream } from "../migrations/vbundle-tar-stream.js";
84
95
  import { validateVBundle } from "../migrations/vbundle-validator.js";
85
96
  import {
86
97
  BadGatewayError,
@@ -806,6 +817,8 @@ async function extractFileData(
806
817
  * The file can be sent as:
807
818
  * - Raw binary body with Content-Type: application/octet-stream
808
819
  * - Multipart form data with a "file" field
820
+ * - JSON body `{ "path": "<staged-relative-or-absolute>" }` pointing at a
821
+ * `.vbundle` the caller placed under `${workspace}/.restore-staging/`
809
822
  *
810
823
  * Returns:
811
824
  * 200: {
@@ -825,7 +838,13 @@ async function extractFileData(
825
838
  export async function handleMigrationImportPreflight({
826
839
  rawBody,
827
840
  headers,
841
+ body,
828
842
  }: RouteHandlerArgs) {
843
+ const contentType = headers?.["content-type"] ?? "";
844
+ if (contentType.includes("application/json")) {
845
+ return handleMigrationPreflightFromPath(body);
846
+ }
847
+
829
848
  const fileData = await extractFileData(rawBody, headers);
830
849
 
831
850
  try {
@@ -872,15 +891,18 @@ export async function handleMigrationImportPreflight({
872
891
  * 5. Verifies post-write integrity (SHA-256 check)
873
892
  * 6. Returns a detailed report of what was imported
874
893
  *
875
- * The bundle can be supplied in any of three ways:
894
+ * The bundle can be supplied as:
876
895
  * - Raw binary body with Content-Type: application/octet-stream
877
896
  * - Multipart form data with a "file" field
878
897
  * - JSON body `{ "url": "<signed-gcs-url>" }` (Content-Type:
879
898
  * application/json). The daemon fetches and streams the archive
880
899
  * through `streamCommitImport`, so peak memory stays bounded by a
881
900
  * single tar entry rather than bundle size.
901
+ * - JSON body `{ "path": "<staged>" }` pointing at a `.vbundle` the
902
+ * caller placed under `${workspace}/.restore-staging/`. The daemon
903
+ * streams that file through `streamCommitImport`.
882
904
  *
883
- * Returns (all three paths):
905
+ * Returns:
884
906
  * 200: {
885
907
  * success: true,
886
908
  * summary: { total_files, files_created, files_overwritten, files_skipped, backups_created },
@@ -900,11 +922,10 @@ export async function handleMigrationImport(
900
922
  args: RouteHandlerArgs,
901
923
  ): Promise<unknown> {
902
924
  const { body, rawBody, headers } = args;
903
- // JSON body means the caller is asking us to fetch the bundle from a
904
- // signed URL and stream it through the importer.
925
+ // JSON body is either a signed URL fetch or a staged local-file stream.
905
926
  const contentType = headers?.["content-type"] ?? "";
906
927
  if (contentType.includes("application/json")) {
907
- return handleMigrationImportFromUrl(body);
928
+ return handleMigrationImportFromJson(body);
908
929
  }
909
930
 
910
931
  const fileData = await extractFileData(rawBody, headers);
@@ -1015,6 +1036,8 @@ const URL_FETCH_TIMEOUT_MS = 60 * 60 * 1000;
1015
1036
 
1016
1037
  const MigrationImportUrlBody = z.object({ url: z.string().min(1) });
1017
1038
 
1039
+ const MigrationImportPathBody = z.object({ path: z.string().min(1) });
1040
+
1018
1041
  const MigrationImportFromGcsBody = z.object({ bundle_url: z.string().url() });
1019
1042
 
1020
1043
  /**
@@ -1591,6 +1614,174 @@ async function runGcsImport(
1591
1614
  : { ...result.report };
1592
1615
  }
1593
1616
 
1617
+ /**
1618
+ * Dispatch a JSON import body to the URL-fetch or staged-path streamer.
1619
+ * The body must include exactly one of `url` or `path`.
1620
+ */
1621
+ async function handleMigrationImportFromJson(
1622
+ body: Record<string, unknown> | undefined,
1623
+ ): Promise<unknown> {
1624
+ const hasUrl = typeof body?.url === "string";
1625
+ const hasPath = typeof body?.path === "string";
1626
+ if (hasUrl && hasPath) {
1627
+ throw new BadRequestError(
1628
+ "Request body must include exactly one of url or path",
1629
+ );
1630
+ }
1631
+ if (hasPath) {
1632
+ return handleMigrationImportFromPath(body);
1633
+ }
1634
+ return handleMigrationImportFromUrl(body);
1635
+ }
1636
+
1637
+ async function handleMigrationImportFromPath(
1638
+ body: Record<string, unknown> | undefined,
1639
+ ): Promise<unknown> {
1640
+ const parsed = MigrationImportPathBody.safeParse(body);
1641
+ if (!parsed.success) {
1642
+ throw new BadRequestError(
1643
+ "Request body must be { path: string } with a non-empty path",
1644
+ );
1645
+ }
1646
+
1647
+ let resolvedPath: string;
1648
+ try {
1649
+ resolvedPath = resolveStagedImportPath(parsed.data.path, getWorkspaceDir());
1650
+ } catch (err) {
1651
+ if (err instanceof StagedImportPathError) {
1652
+ throw new BadRequestError(err.message);
1653
+ }
1654
+ throw err;
1655
+ }
1656
+
1657
+ const source = createReadStream(resolvedPath);
1658
+ const pathResolver = new DefaultPathResolver(
1659
+ getWorkspaceDir(),
1660
+ getWorkspaceHooksDir(),
1661
+ );
1662
+ let credentialsImported: CredentialImportSummary | undefined;
1663
+ const credentialImportWarningSink: CredentialWarningSink = { warnings: [] };
1664
+
1665
+ try {
1666
+ const result = await streamCommitImport({
1667
+ source,
1668
+ pathResolver,
1669
+ workspaceDir: getWorkspaceDir(),
1670
+ importCredentials: async (bundleCredentials) => {
1671
+ credentialsImported = await importBundleCredentialsIntoCes(
1672
+ bundleCredentials,
1673
+ credentialImportWarningSink,
1674
+ );
1675
+ },
1676
+ });
1677
+
1678
+ if (!result.ok) {
1679
+ throwImportCommitFailure(result);
1680
+ }
1681
+
1682
+ if (credentialImportWarningSink.warnings.length > 0) {
1683
+ result.report.warnings.push(...credentialImportWarningSink.warnings);
1684
+ }
1685
+
1686
+ await reconcileVellumMetadataFromCes(result.report);
1687
+ appendNewerMigrationWarningsIfAny(result.report);
1688
+ return importCommitSuccessResult(result.report, credentialsImported);
1689
+ } catch (err) {
1690
+ if (err instanceof RouteError) {
1691
+ throw err;
1692
+ }
1693
+ log.error({ err }, "Unexpected error during staged-path import");
1694
+ throw new InternalError(
1695
+ err instanceof Error ? err.message : "Unexpected import error",
1696
+ );
1697
+ } finally {
1698
+ source.destroy();
1699
+ }
1700
+ }
1701
+
1702
+ async function handleMigrationPreflightFromPath(
1703
+ body: Record<string, unknown> | undefined,
1704
+ ): Promise<unknown> {
1705
+ const parsed = MigrationImportPathBody.safeParse(body);
1706
+ if (!parsed.success) {
1707
+ throw new BadRequestError(
1708
+ "Request body must be { path: string } with a non-empty path",
1709
+ );
1710
+ }
1711
+
1712
+ let resolvedPath: string;
1713
+ try {
1714
+ resolvedPath = resolveStagedImportPath(parsed.data.path, getWorkspaceDir());
1715
+ } catch (err) {
1716
+ if (err instanceof StagedImportPathError) {
1717
+ throw new BadRequestError(err.message);
1718
+ }
1719
+ throw err;
1720
+ }
1721
+
1722
+ const source = createReadStream(resolvedPath);
1723
+ try {
1724
+ const entries = parseVBundleStream(source);
1725
+ const first = await entries.next();
1726
+ if (first.done) {
1727
+ return {
1728
+ can_import: false,
1729
+ validation: {
1730
+ is_valid: false as const,
1731
+ errors: [
1732
+ {
1733
+ code: "empty_archive",
1734
+ message: "Bundle archive is empty",
1735
+ },
1736
+ ],
1737
+ },
1738
+ };
1739
+ }
1740
+
1741
+ let manifest;
1742
+ try {
1743
+ ({ manifest } = await readAndValidateManifest(first.value));
1744
+ } catch (err) {
1745
+ if (err instanceof StreamingValidationError) {
1746
+ return {
1747
+ can_import: false,
1748
+ validation: {
1749
+ is_valid: false as const,
1750
+ errors: [
1751
+ {
1752
+ code: err.code,
1753
+ message: err.message,
1754
+ ...(err.archivePath !== undefined && { path: err.archivePath }),
1755
+ },
1756
+ ],
1757
+ },
1758
+ };
1759
+ }
1760
+ throw err;
1761
+ }
1762
+
1763
+ for await (const entry of entries) {
1764
+ entry.body.resume();
1765
+ }
1766
+
1767
+ const pathResolver = new DefaultPathResolver(
1768
+ getWorkspaceDir(),
1769
+ getWorkspaceHooksDir(),
1770
+ );
1771
+ return analyzeImport({ manifest, pathResolver });
1772
+ } catch (err) {
1773
+ if (err instanceof RouteError) {
1774
+ throw err;
1775
+ }
1776
+ log.error({ err }, "Unexpected error during staged-path preflight");
1777
+ throw new InternalError(
1778
+ err instanceof Error ? err.message : "Unexpected import preflight error",
1779
+ );
1780
+ } finally {
1781
+ source.destroy();
1782
+ }
1783
+ }
1784
+
1594
1785
  /**
1595
1786
  * Handle a JSON `{ "url": "..." }` body on POST /v1/migrations/import.
1596
1787
  *
@@ -2142,8 +2333,17 @@ export const ROUTES: RouteDefinition[] = [
2142
2333
  },
2143
2334
  summary: "Dry-run import analysis",
2144
2335
  description:
2145
- "Validate a .vbundle archive and return a report of what would change on import without modifying data.",
2336
+ "Validate a .vbundle archive and return a report of what would change on import without modifying data. Accepts raw bytes, multipart form data, or JSON `{ path }` pointing at a file staged under the workspace `.restore-staging` directory.",
2146
2337
  tags: ["migrations"],
2338
+ requestBody: z.object({
2339
+ path: z
2340
+ .string()
2341
+ .min(1)
2342
+ .optional()
2343
+ .describe(
2344
+ "Workspace-relative or absolute path to a staged .vbundle under .restore-staging (JSON body path only).",
2345
+ ),
2346
+ }),
2147
2347
  responseBody: z.object({
2148
2348
  can_import: z.boolean(),
2149
2349
  summary: z.object({}).passthrough(),
@@ -2163,15 +2363,23 @@ export const ROUTES: RouteDefinition[] = [
2163
2363
  },
2164
2364
  summary: "Import a .vbundle archive",
2165
2365
  description:
2166
- "Commit a .vbundle archive import to disk — destructive. Accepts the bundle as raw bytes (application/octet-stream), multipart/form-data, or a JSON body with `{ url }` carrying a signed URL the daemon fetches.",
2366
+ "Commit a .vbundle archive import to disk — destructive. Accepts the bundle as raw bytes (application/octet-stream), multipart/form-data, a JSON body with `{ url }` carrying a signed URL the daemon fetches, or a JSON body with `{ path }` pointing at a file staged under the workspace `.restore-staging` directory.",
2167
2367
  tags: ["migrations"],
2168
2368
  requestBody: z.object({
2169
2369
  url: z
2170
2370
  .string()
2171
2371
  .url()
2372
+ .optional()
2172
2373
  .describe(
2173
2374
  "A signed GCS URL pointing to the .vbundle archive (JSON body path only).",
2174
2375
  ),
2376
+ path: z
2377
+ .string()
2378
+ .min(1)
2379
+ .optional()
2380
+ .describe(
2381
+ "Workspace-relative or absolute path to a staged .vbundle under .restore-staging (JSON body path only).",
2382
+ ),
2175
2383
  }),
2176
2384
  additionalResponses: {
2177
2385
  "502": {
@@ -19,6 +19,7 @@ import {
19
19
  ServicesSchema,
20
20
  } from "../../config/schemas/services.js";
21
21
  import type { OAuthConnectionRequest } from "../../oauth/connection.js";
22
+ import { jsonSafeOAuthBody } from "../../oauth/connection.js";
22
23
  import {
23
24
  resolveOAuthConnection,
24
25
  type ResolveOAuthConnectionOptions,
@@ -39,6 +40,10 @@ import { VellumPlatformClient } from "../../platform/client.js";
39
40
  import { withValidToken } from "../../security/token-manager.js";
40
41
  import { matchHostPattern } from "../../tools/credentials/host-pattern-match.js";
41
42
  import { getLogger } from "../../util/logger.js";
43
+ import {
44
+ findContentTypeHeader,
45
+ parseRequestBodyData,
46
+ } from "../../util/oauth-request-body.js";
42
47
  import { LOCAL_PRINCIPALS } from "../auth/route-policy.js";
43
48
  import { BadRequestError, InternalError, NotFoundError } from "./errors.js";
44
49
  import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
@@ -721,15 +726,12 @@ async function handleToken({ body = {} }: RouteHandlerArgs) {
721
726
  // Request handler
722
727
  // ---------------------------------------------------------------------------
723
728
 
724
- function tryJsonParse(raw: string): unknown {
725
- try {
726
- return JSON.parse(raw);
727
- } catch {
728
- return raw;
729
- }
730
- }
731
-
732
- function readBodyData(data: string): unknown {
729
+ /**
730
+ * Resolve a raw `data` string into a request body. A non-JSON Content-Type
731
+ * keeps the payload as the caller's exact string so multipart and other
732
+ * byte-sensitive payloads survive; files are read as UTF-8 text.
733
+ */
734
+ function readBodyData(data: string, contentType: string | undefined): unknown {
733
735
  if (data === "@-") {
734
736
  // This handler runs inside the daemon, whose stdin is a supervisor pipe
735
737
  // or /dev/null — never the caller's terminal. Stdin-based body input is
@@ -742,14 +744,13 @@ function readBodyData(data: string): unknown {
742
744
 
743
745
  if (data.startsWith("@")) {
744
746
  const filePath = data.slice(1);
745
- const raw = readFileSync(filePath, "utf-8");
746
- return tryJsonParse(raw);
747
+ return parseRequestBodyData(readFileSync(filePath, "utf-8"), contentType);
747
748
  }
748
749
 
749
- return tryJsonParse(data);
750
+ return parseRequestBodyData(data, contentType);
750
751
  }
751
752
 
752
- async function handleRequest({ body = {} }: RouteHandlerArgs) {
753
+ export async function handleRequest({ body = {} }: RouteHandlerArgs) {
753
754
  const b = body as {
754
755
  provider: string;
755
756
  url: string;
@@ -852,12 +853,14 @@ async function handleRequest({ body = {} }: RouteHandlerArgs) {
852
853
  let reqBody: unknown = undefined;
853
854
  const query: Record<string, string | string[]> = { ...queryFromUrl };
854
855
 
855
- // Use pre-parsed data from CLI, or fall back to raw data string for direct API callers
856
+ // Use pre-parsed data from CLI, or fall back to raw data string for direct
857
+ // API callers. A string here (a multipart or form-encoded payload) stays a
858
+ // string all the way to the provider; only objects are serialized as JSON.
856
859
  const resolvedData =
857
860
  b.parsed_data !== undefined
858
861
  ? b.parsed_data
859
862
  : b.data !== undefined
860
- ? readBodyData(b.data)
863
+ ? readBodyData(b.data, findContentTypeHeader(b.headers))
861
864
  : undefined;
862
865
 
863
866
  if (resolvedData !== undefined) {
@@ -924,16 +927,20 @@ async function handleRequest({ body = {} }: RouteHandlerArgs) {
924
927
  };
925
928
 
926
929
  const response = await connection.request(req);
930
+ const encodedBody = jsonSafeOAuthBody(response.body);
927
931
 
928
932
  const result: Record<string, unknown> = {
929
933
  ok: response.status >= 200 && response.status < 300,
930
934
  status: response.status,
931
935
  headers: response.headers,
932
- body: response.body,
936
+ body: encodedBody.body,
933
937
  // Which connected account actually served the request, so the caller can
934
938
  // tell whether the intended account was used.
935
939
  account: connection.accountInfo,
936
940
  };
941
+ if (encodedBody.bodyEncoding) {
942
+ result.bodyEncoding = encodedBody.bodyEncoding;
943
+ }
937
944
 
938
945
  // Surface a caller-visible warning when the provider had several active
939
946
  // connections and no account was pinned — the model must see that a
@@ -1641,29 +1641,6 @@ function mapTogglePluginError(err: unknown): RouteError {
1641
1641
  );
1642
1642
  }
1643
1643
 
1644
- /**
1645
- * Converge plugin-declared schedules against the sentinel this route just
1646
- * wrote, so a disabled plugin's rows disarm now rather than at the
1647
- * reconciler's next backstop sweep.
1648
- *
1649
- * Fire-and-forget: the toggle itself already succeeded on disk, so a reconcile
1650
- * failure must not turn it into a route error. Imported lazily, matching the
1651
- * plugin source reconcile's own hook, to keep the schedule and notification
1652
- * modules out of this route module's static graph. The scheduler applies the
1653
- * sentinel at fire time as well, so a slow or failed pass here delays the row
1654
- * bookkeeping without letting a disabled plugin run.
1655
- */
1656
- function reconcilePluginSchedulesInBackground(): void {
1657
- void import("../../schedule/plugin-schedule-reconciler.js")
1658
- .then(({ reconcilePluginSchedules }) => reconcilePluginSchedules())
1659
- .catch((err: unknown) => {
1660
- log.error(
1661
- { err },
1662
- "plugin schedule reconcile after a plugin toggle failed",
1663
- );
1664
- });
1665
- }
1666
-
1667
1644
  /**
1668
1645
  * Toggle a plugin's `.disabled` sentinel through the shared toggle-plugin lib,
1669
1646
  * then publish a generic `sync_changed(plugins:list)` so every client refetches
@@ -1694,15 +1671,22 @@ async function handleEnablePlugin({
1694
1671
  return { ok: true };
1695
1672
  }
1696
1673
 
1697
- function handleDisablePlugin({ pathParams = {}, headers }: RouteHandlerArgs) {
1674
+ async function handleDisablePlugin({
1675
+ pathParams = {},
1676
+ headers,
1677
+ }: RouteHandlerArgs) {
1698
1678
  try {
1699
1679
  disablePlugin(pathParams.name ?? "");
1700
- publishPluginsChanged(getOriginClientId(headers));
1701
- reconcilePluginSchedulesInBackground();
1702
- return { ok: true };
1703
1680
  } catch (err) {
1704
1681
  throw mapTogglePluginError(err);
1705
1682
  }
1683
+ // Same poke enable uses. The sentinel hides tools and hooks at read time,
1684
+ // but a plugin that already ran `init` still owns in-process work (a live
1685
+ // gRPC subscribe, a poll worker). The source reconcile is what runs
1686
+ // `shutdown` and drops that work before this route returns.
1687
+ await reconcilePluginSourcesNow();
1688
+ publishPluginsChanged(getOriginClientId(headers));
1689
+ return { ok: true };
1706
1690
  }
1707
1691
 
1708
1692
  // ---------------------------------------------------------------------------
@@ -2146,7 +2130,7 @@ export const ROUTES: RouteDefinition[] = [
2146
2130
  },
2147
2131
  summary: "Disable a plugin",
2148
2132
  description:
2149
- "Disable a plugin in this workspace by dropping a `.disabled` sentinel, mirroring the CLI's `assistant plugins disable <name>`. The change is honored live at read time by every tool / injector / hook gate — no restart required. Broadcasts a `sync_changed` invalidation carrying the `plugins:list` tag so other clients refetch `GET /v1/plugins`. An already-disabled plugin returns 409; a user plugin with no directory returns 404 (default plugins are stubbed on demand via the `default-` prefix); a malformed name returns 400.",
2133
+ "Disable a plugin in this workspace by dropping a `.disabled` sentinel, mirroring the CLI's `assistant plugins disable <name>`. Awaits the same source reconcile enable uses so the plugin's shutdown hook runs. Tools, injectors, and hook gates also honor the sentinel at read time. Broadcasts a `sync_changed` invalidation carrying the `plugins:list` tag so other clients refetch `GET /v1/plugins`. An already-disabled plugin returns 409; a user plugin with no directory returns 404 (default plugins are stubbed on demand via the `default-` prefix); a malformed name returns 400.",
2150
2134
  tags: ["plugins"],
2151
2135
  pathParams: [
2152
2136
  {
@@ -16,6 +16,10 @@
16
16
  * one-element batch. That branch only succeeds against a single-question
17
17
  * batch — multi-question batches reject it with a helpful error.
18
18
  *
19
+ * Any assistant serving this route also accepts `submit`, so the shim serves
20
+ * only clients that cannot tell which assistant they are talking to: a stale
21
+ * browser bundle posts the single-question body against a batch-capable route.
22
+ *
19
23
  * Cross-talk safety: pending interactions of other kinds (`confirmation`,
20
24
  * `secret`, host_*, etc.) return 404 here rather than being mis-resolved.
21
25
  *
@@ -194,6 +198,8 @@ function buildSubmissions(
194
198
  // prompter stashed the ordered ids on the interaction metadata so we can
195
199
  // pick the (single) target questionId here.
196
200
  const { orderedIds } = readBatchMetadata(interaction);
201
+ // `QuestionPrompter` is the only registrar of a `question` interaction and
202
+ // refuses an empty `questions` array, so this guard is unreachable.
197
203
  if (orderedIds.length === 0) {
198
204
  throw new QuestionBatchValidationError(
199
205
  "Legacy single-question payload requires a registered batch with at least one question",