@vellumai/assistant 0.9.1-staging.1 → 0.10.0-staging.2

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 (433) hide show
  1. package/docs/activation-funnel-telemetry.md +24 -18
  2. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +97 -0
  3. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +10 -0
  4. package/node_modules/@vellumai/gateway-client/src/index.ts +15 -0
  5. package/openapi.yaml +852 -15
  6. package/package.json +1 -1
  7. package/src/__tests__/access-request-card-view.test.ts +98 -0
  8. package/src/__tests__/access-request-seed-content-blocks.test.ts +2 -4
  9. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +59 -7
  10. package/src/__tests__/agent-loop-compaction-strip.test.ts +17 -16
  11. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +16 -13
  12. package/src/__tests__/app-compiler.test.ts +15 -1
  13. package/src/__tests__/auth-fallback-events-store.test.ts +6 -14
  14. package/src/__tests__/call-pointer-messages.test.ts +28 -0
  15. package/src/__tests__/cancel-clears-processing.test.ts +89 -0
  16. package/src/__tests__/channel-approval-routes.test.ts +0 -4
  17. package/src/__tests__/channel-inbound-disk-pressure.test.ts +5 -15
  18. package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +3 -4
  19. package/src/__tests__/compactor-image-manifest-trust.test.ts +21 -1
  20. package/src/__tests__/compactor-summary-call-truncation.test.ts +223 -0
  21. package/src/__tests__/config-loader-backfill.test.ts +174 -30
  22. package/src/__tests__/config-schema.test.ts +35 -0
  23. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +2 -2
  24. package/src/__tests__/contact-store-user-file.test.ts +0 -6
  25. package/src/__tests__/contacts-tools.test.ts +29 -0
  26. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
  27. package/src/__tests__/conversation-agent-loop.test.ts +58 -0
  28. package/src/__tests__/conversation-attention-telegram.test.ts +0 -1
  29. package/src/__tests__/conversation-lifecycle.test.ts +7 -9
  30. package/src/__tests__/conversation-load-history-repair.test.ts +101 -0
  31. package/src/__tests__/conversation-routes-guardian-reply.test.ts +15 -12
  32. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +6 -3
  33. package/src/__tests__/conversation-title-service.test.ts +62 -0
  34. package/src/__tests__/credential-execution-shell-lockdown.test.ts +18 -11
  35. package/src/__tests__/credential-prompt-route.test.ts +1 -0
  36. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  37. package/src/__tests__/disk-pressure-policy.test.ts +12 -0
  38. package/src/__tests__/disk-usage.test.ts +65 -0
  39. package/src/__tests__/dynamic-page-surface.test.ts +51 -0
  40. package/src/__tests__/gateway-flag-listener.test.ts +110 -1
  41. package/src/__tests__/guardian-binding-drift-heal.test.ts +1 -1
  42. package/src/__tests__/guardian-card-withdrawal.test.ts +403 -0
  43. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +5 -3
  44. package/src/__tests__/guardian-grant-minting.test.ts +3 -35
  45. package/src/__tests__/guardian-routing-invariants.test.ts +64 -26
  46. package/src/__tests__/guardian-routing-state.test.ts +0 -1
  47. package/src/__tests__/headless-browser-mode.test.ts +10 -0
  48. package/src/__tests__/headless-browser-navigate.test.ts +8 -3
  49. package/src/__tests__/helpers/create-guardian-binding.ts +0 -1
  50. package/src/__tests__/host-browser-proxy.test.ts +87 -0
  51. package/src/__tests__/injector-v3-suppression.test.ts +27 -20
  52. package/src/__tests__/internal-telemetry-routes.test.ts +6 -14
  53. package/src/__tests__/invite-redemption-service.test.ts +0 -3
  54. package/src/__tests__/llm-catalog-parity.test.ts +30 -1
  55. package/src/__tests__/llm-resolver.test.ts +21 -0
  56. package/src/__tests__/llm-schema.test.ts +1 -0
  57. package/src/__tests__/managed-profile-guard.test.ts +163 -4
  58. package/src/__tests__/mcp-health-check.test.ts +6 -7
  59. package/src/__tests__/media-stream-server-integration.test.ts +317 -13
  60. package/src/__tests__/path-policy.test.ts +34 -0
  61. package/src/__tests__/persona-resolver.test.ts +38 -0
  62. package/src/__tests__/plugin-api-provider.test.ts +24 -0
  63. package/src/__tests__/plugin-tool-contribution.test.ts +6 -3
  64. package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +214 -0
  65. package/src/__tests__/provider-send-message-override-profile.test.ts +76 -0
  66. package/src/__tests__/reaction-persistence.test.ts +150 -29
  67. package/src/__tests__/relay-server.test.ts +285 -0
  68. package/src/__tests__/runtime-attachment-metadata.test.ts +0 -1
  69. package/src/__tests__/scheduler-reuse-conversation.test.ts +8 -5
  70. package/src/__tests__/skill-execute-input.test.ts +5 -0
  71. package/src/__tests__/skills.test.ts +51 -0
  72. package/src/__tests__/slack-notification-approval-card.test.ts +176 -0
  73. package/src/__tests__/slack-reaction-canonical-approval.test.ts +285 -0
  74. package/src/__tests__/subagent-tools.test.ts +150 -0
  75. package/src/__tests__/task-progress-nudge-hook.test.ts +1 -1
  76. package/src/__tests__/title-generate-hook.test.ts +100 -3
  77. package/src/__tests__/tool-approval-seed-content-blocks.test.ts +1 -1
  78. package/src/__tests__/tool-audit-listener.test.ts +7 -7
  79. package/src/__tests__/tool-executor-lifecycle-events.test.ts +6 -3
  80. package/src/__tests__/trusted-contact-approval-notifier.test.ts +4 -2
  81. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +220 -3
  82. package/src/__tests__/trusted-contact-verification.test.ts +2 -4
  83. package/src/__tests__/twilio-routes.test.ts +81 -1
  84. package/src/__tests__/voice-invite-redemption.test.ts +0 -1
  85. package/src/__tests__/weak-open-model.test.ts +30 -0
  86. package/src/__tests__/workspace-migration-105-enable-memory-v3-live-for-new-workspaces.test.ts +149 -0
  87. package/src/__tests__/workspace-migration-108-drop-balanced-economy-profile.test.ts +285 -0
  88. package/src/__tests__/workspace-migration-add-send-diagnostics.test.ts +1 -1
  89. package/src/__tests__/workspace-migration-drop-collect-usage-data.test.ts +118 -0
  90. package/src/__tests__/workspace-migration-drop-send-diagnostics.test.ts +118 -0
  91. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +0 -4
  92. package/src/agent/loop.ts +33 -33
  93. package/src/api/events/tool-result.ts +6 -0
  94. package/src/api/events/workflow-completed.ts +53 -0
  95. package/src/api/events/workflow-leaf-finished.ts +38 -0
  96. package/src/api/events/workflow-leaf-started.ts +35 -0
  97. package/src/api/events/workflow-progress.ts +32 -0
  98. package/src/api/events/workflow-started.ts +31 -0
  99. package/src/api/index.ts +40 -0
  100. package/src/api/responses/conversation-message.ts +26 -0
  101. package/src/api/responses/home.ts +26 -0
  102. package/src/api/responses/workflow-journal.ts +53 -0
  103. package/src/approvals/guardian-card-withdrawal.ts +145 -0
  104. package/src/approvals/guardian-decision-primitive.ts +26 -3
  105. package/src/approvals/guardian-request-resolvers.ts +181 -78
  106. package/src/calls/__tests__/channel-admission-reader.test.ts +132 -0
  107. package/src/calls/__tests__/relay-setup-router.test.ts +350 -0
  108. package/src/calls/call-pointer-messages.ts +10 -4
  109. package/src/calls/channel-admission-reader.ts +104 -0
  110. package/src/calls/guardian-dispatch.ts +17 -45
  111. package/src/calls/media-stream-server.ts +84 -2
  112. package/src/calls/relay-server.ts +66 -0
  113. package/src/calls/relay-setup-router.ts +82 -1
  114. package/src/calls/twilio-routes.ts +17 -8
  115. package/src/cli/commands/clients.ts +3 -0
  116. package/src/cli/commands/{__tests__ → memory/__tests__}/memory-v2-compare-render.test.ts +1 -1
  117. package/src/cli/commands/{__tests__ → memory/__tests__}/memory-v2.test.ts +8 -7
  118. package/src/cli/commands/{__tests__ → memory/__tests__}/memory-v3.test.ts +5 -4
  119. package/src/cli/commands/memory/index.ts +30 -0
  120. package/src/cli/commands/{memory-v2-compare-render.ts → memory/memory-v2-compare-render.ts} +1 -1
  121. package/src/cli/commands/{memory-v2.ts → memory/memory-v2.ts} +6 -15
  122. package/src/cli/commands/{memory-v3.ts → memory/memory-v3.ts} +97 -11
  123. package/src/cli/commands/oauth/status.test.ts +36 -0
  124. package/src/cli/commands/oauth/status.ts +23 -3
  125. package/src/cli/commands/plugins.ts +57 -5
  126. package/src/cli/lib/__tests__/inspect-plugin.test.ts +54 -0
  127. package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +134 -4
  128. package/src/cli/lib/__tests__/plugin-surfaces.test.ts +111 -0
  129. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +53 -11
  130. package/src/cli/lib/inspect-plugin.ts +12 -1
  131. package/src/cli/lib/merge-plugin-tree.ts +149 -49
  132. package/src/cli/lib/plugin-surfaces.ts +104 -0
  133. package/src/cli/lib/upgrade-plugin.ts +64 -36
  134. package/src/cli/program.ts +2 -4
  135. package/src/config/__tests__/sync-gated-profiles.test.ts +368 -0
  136. package/src/config/assistant-feature-flags.ts +22 -7
  137. package/src/config/bundled-skills/contacts/tools/contact-search.ts +0 -1
  138. package/src/config/bundled-skills/messaging/SKILL.md +6 -4
  139. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +9 -8
  140. package/src/config/bundled-skills/workflows/SKILL.md +14 -7
  141. package/src/config/call-site-defaults.ts +3 -0
  142. package/src/config/feature-flag-registry.json +49 -18
  143. package/src/config/llm-resolver.ts +3 -0
  144. package/src/config/memory-v3-gate.ts +11 -0
  145. package/src/config/schema.ts +8 -6
  146. package/src/config/schemas/__tests__/memory-v3.test.ts +1 -0
  147. package/src/config/schemas/call-site-catalog.ts +7 -0
  148. package/src/config/schemas/channels.ts +11 -0
  149. package/src/config/schemas/llm.ts +31 -0
  150. package/src/config/schemas/memory-lifecycle.ts +3 -7
  151. package/src/config/schemas/memory-v3.ts +6 -0
  152. package/src/config/schemas/services.ts +18 -0
  153. package/src/config/seed-inference-profiles.ts +94 -34
  154. package/src/config/skills.ts +21 -0
  155. package/src/config/sync-gated-profiles.ts +220 -0
  156. package/src/contacts/contact-store.ts +2 -10
  157. package/src/contacts/contacts-write.ts +1 -2
  158. package/src/contacts/types.ts +0 -1
  159. package/src/context/compactor.ts +86 -52
  160. package/src/context/strip-injections.ts +58 -10
  161. package/src/context/token-estimator.ts +1 -1
  162. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +3 -2
  163. package/src/daemon/conversation-agent-loop-handlers.ts +2 -0
  164. package/src/daemon/conversation-agent-loop.ts +100 -19
  165. package/src/daemon/conversation-history.ts +1 -1
  166. package/src/daemon/conversation-lifecycle.ts +3 -5
  167. package/src/daemon/conversation-process.ts +13 -5
  168. package/src/daemon/conversation-runtime-assembly.ts +13 -15
  169. package/src/daemon/conversation-surfaces.ts +26 -0
  170. package/src/daemon/conversation-tool-setup.ts +16 -11
  171. package/src/daemon/conversation.ts +64 -14
  172. package/src/daemon/disk-pressure-policy.ts +5 -3
  173. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +0 -1
  174. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -1
  175. package/src/daemon/handlers/config-a2a.ts +0 -2
  176. package/src/daemon/handlers/config-channels.ts +5 -10
  177. package/src/daemon/handlers/config-slack-channel.ts +20 -0
  178. package/src/daemon/handlers/conversations.ts +107 -0
  179. package/src/daemon/host-browser-proxy.ts +41 -0
  180. package/src/daemon/lifecycle.ts +55 -20
  181. package/src/daemon/message-provenance.ts +2 -0
  182. package/src/daemon/message-types/contacts.ts +0 -1
  183. package/src/daemon/message-types/web-activity.ts +7 -1
  184. package/src/daemon/message-types/workflows.ts +83 -1
  185. package/src/daemon/tool-setup-types.ts +4 -0
  186. package/src/daemon/trust-context.ts +1 -1
  187. package/src/events/tool-audit-listener.ts +2 -2
  188. package/src/home/feed-source-enrichment.test.ts +151 -0
  189. package/src/home/feed-source-enrichment.ts +176 -0
  190. package/src/instrument.ts +18 -6
  191. package/src/ipc/__tests__/binary-result-ipc.test.ts +81 -0
  192. package/src/ipc/__tests__/clients-list-ipc.test.ts +20 -0
  193. package/src/ipc/assistant-server.ts +37 -4
  194. package/src/ipc/gateway-flag-listener.ts +18 -2
  195. package/src/memory/__tests__/auto-analysis-enqueue.test.ts +5 -16
  196. package/src/memory/__tests__/jobs-store-enqueue-gate.test.ts +7 -11
  197. package/src/memory/__tests__/memory-retrospective-enqueue.test.ts +37 -7
  198. package/src/memory/__tests__/memory-retrospective-job.test.ts +34 -0
  199. package/src/memory/__tests__/onboarding-events-store.test.ts +7 -7
  200. package/src/memory/auth-fallback-events-store.ts +2 -2
  201. package/src/memory/auto-analysis-enqueue.ts +3 -5
  202. package/src/memory/canonical-guardian-store.ts +39 -1
  203. package/src/memory/conversation-crud.ts +9 -4
  204. package/src/memory/conversation-key-store.ts +17 -2
  205. package/src/memory/conversation-title-service.ts +64 -7
  206. package/src/memory/db-init.ts +10 -0
  207. package/src/memory/embedding-backend.ts +15 -1
  208. package/src/memory/jobs-worker.ts +2 -1
  209. package/src/memory/lifecycle-events-store.ts +2 -2
  210. package/src/memory/memory-retrospective-enqueue.ts +31 -6
  211. package/src/memory/memory-retrospective-job.ts +9 -0
  212. package/src/memory/migrations/129-contact-channels-access-fields.ts +18 -9
  213. package/src/memory/migrations/131-drop-legacy-member-guardian-tables.ts +14 -2
  214. package/src/memory/migrations/289-contact-channels-unique-ext-user.ts +10 -0
  215. package/src/memory/migrations/291-contact-channels-renormalize-addresses.ts +10 -0
  216. package/src/memory/migrations/292-schedule-default-no-reuse-conversation.test.ts +67 -0
  217. package/src/memory/migrations/292-schedule-default-no-reuse-conversation.ts +25 -0
  218. package/src/memory/migrations/293-workflow-journal-leaf-tokens.ts +32 -0
  219. package/src/memory/migrations/294-drop-external-user-id.ts +31 -0
  220. package/src/memory/migrations/295-drop-approval-prompt-ts-tracker.ts +20 -0
  221. package/src/memory/migrations/296-rewrite-balanced-economy-profile-pins.test.ts +110 -0
  222. package/src/memory/migrations/296-rewrite-balanced-economy-profile-pins.ts +68 -0
  223. package/src/memory/migrations/__tests__/131-drop-legacy-member-guardian-tables.test.ts +154 -0
  224. package/src/memory/migrations/__tests__/289-contact-channels-unique-ext-user.test.ts +31 -0
  225. package/src/memory/migrations/__tests__/291-contact-channels-renormalize-addresses.test.ts +30 -0
  226. package/src/memory/migrations/index.ts +5 -0
  227. package/src/memory/onboarding-events-store.ts +3 -3
  228. package/src/memory/schema/contacts.ts +0 -1
  229. package/src/memory/skill-loaded-events-store.test.ts +7 -15
  230. package/src/memory/skill-loaded-events-store.ts +2 -2
  231. package/src/memory/tool-executed-events-store.test.ts +7 -7
  232. package/src/memory/turn-trace-store.test.ts +736 -0
  233. package/src/memory/turn-trace-store.ts +364 -0
  234. package/src/memory/v2/__tests__/consolidation-job.test.ts +8 -0
  235. package/src/memory/v2/__tests__/skill-content.test.ts +30 -0
  236. package/src/memory/v2/consolidation-job.ts +2 -2
  237. package/src/memory/v2/skill-content.ts +25 -7
  238. package/src/memory/v2/skill-store.ts +7 -1
  239. package/src/memory/v3-eval/__tests__/eval-packets.test.ts +248 -0
  240. package/src/memory/v3-eval/eval-packets.ts +546 -0
  241. package/src/messaging/providers/slack/api.ts +31 -0
  242. package/src/messaging/providers/slack/send.test.ts +114 -2
  243. package/src/messaging/providers/slack/send.ts +30 -7
  244. package/src/messaging/providers/slack/withdraw.test.ts +200 -0
  245. package/src/messaging/providers/slack/withdraw.ts +161 -0
  246. package/src/notifications/AGENTS.md +2 -0
  247. package/src/notifications/access-request-copy.ts +72 -59
  248. package/src/notifications/adapters/slack.ts +55 -73
  249. package/src/notifications/approval-card-data.ts +333 -0
  250. package/src/notifications/broadcaster.ts +6 -2
  251. package/src/notifications/canonical-delivery-recorder.ts +139 -0
  252. package/src/notifications/copy-composer.ts +3 -3
  253. package/src/notifications/decision-engine.ts +4 -2
  254. package/src/notifications/destination-resolver.ts +4 -6
  255. package/src/notifications/guardian-question-mode.ts +10 -0
  256. package/src/notifications/home-feed-side-effect.ts +3 -13
  257. package/src/notifications/notification-utils.ts +2 -1
  258. package/src/notifications/signal.ts +79 -43
  259. package/src/notifications/types.ts +98 -128
  260. package/src/permissions/checker.test.ts +51 -0
  261. package/src/permissions/checker.ts +185 -26
  262. package/src/permissions/ipc-risk-types.ts +24 -0
  263. package/src/permissions/question-prompter.test.ts +27 -0
  264. package/src/permissions/question-prompter.ts +4 -0
  265. package/src/platform/client.test.ts +119 -0
  266. package/src/platform/client.ts +66 -0
  267. package/src/platform/consent-cache.test.ts +267 -0
  268. package/src/platform/consent-cache.ts +174 -0
  269. package/src/plugin-api/index.ts +27 -0
  270. package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +56 -0
  271. package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +43 -0
  272. package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +137 -0
  273. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +153 -0
  274. package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +138 -0
  275. package/src/plugins/defaults/advisor/__tests__/transcript.test.ts +147 -0
  276. package/src/plugins/defaults/advisor/advisor-gate.ts +29 -0
  277. package/src/plugins/defaults/advisor/advisor-state-store.ts +94 -0
  278. package/src/plugins/defaults/advisor/config.ts +21 -0
  279. package/src/plugins/defaults/advisor/consult.ts +93 -0
  280. package/src/plugins/defaults/advisor/hooks/post-model-call.ts +34 -0
  281. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +30 -0
  282. package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +19 -0
  283. package/src/plugins/defaults/advisor/package.json +14 -0
  284. package/src/plugins/defaults/advisor/steering.ts +67 -0
  285. package/src/plugins/defaults/advisor/tools/advisor.ts +65 -0
  286. package/src/plugins/defaults/advisor/transcript.ts +76 -0
  287. package/src/plugins/defaults/index.ts +35 -0
  288. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +22 -9
  289. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +2 -2
  290. package/src/plugins/defaults/memory-retrieval/tail-reinjection-strip.ts +64 -0
  291. package/src/plugins/defaults/memory-retrieval/unified-turn-context.ts +29 -21
  292. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +1 -0
  293. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  294. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +75 -7
  295. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +31 -4
  296. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +77 -2
  297. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +1 -0
  298. package/src/plugins/defaults/memory-v3-shadow/injector.ts +7 -10
  299. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +37 -4
  300. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +32 -20
  301. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +56 -3
  302. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +23 -2
  303. package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +2 -12
  304. package/src/plugins/defaults/title-generate/hooks/stop.ts +56 -21
  305. package/src/prompts/persona-resolver.ts +12 -2
  306. package/src/prompts/templates/system-sections.ts +7 -2
  307. package/src/providers/__tests__/provider-env-vars.test.ts +6 -0
  308. package/src/providers/__tests__/provider-secret-catalog.test.ts +1 -0
  309. package/src/providers/__tests__/retry-callsite.test.ts +176 -0
  310. package/src/providers/atlascloud/client.ts +85 -0
  311. package/src/providers/fetch-provider-catalog.ts +85 -0
  312. package/src/providers/inference/adapter-factory.ts +3 -0
  313. package/src/providers/model-catalog.ts +58 -0
  314. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +33 -0
  315. package/src/providers/openai/chat-completions-provider.ts +7 -0
  316. package/src/providers/openai/responses-provider.ts +10 -0
  317. package/src/providers/provider-send-message.ts +11 -3
  318. package/src/providers/retry.ts +53 -12
  319. package/src/providers/search-provider-catalog.ts +10 -0
  320. package/src/providers/weak-open-model.ts +22 -0
  321. package/src/runtime/__tests__/agent-wake.test.ts +181 -0
  322. package/src/runtime/__tests__/client-health.test.ts +44 -0
  323. package/src/runtime/access-request-helper.ts +21 -53
  324. package/src/runtime/actor-trust-resolver.ts +49 -21
  325. package/src/runtime/agent-wake.ts +52 -0
  326. package/src/runtime/assistant-event-hub.ts +18 -4
  327. package/src/runtime/auth/__tests__/route-policy.test.ts +12 -0
  328. package/src/runtime/auth/require-bound-guardian.ts +1 -4
  329. package/src/runtime/capabilities.test.ts +120 -0
  330. package/src/runtime/capabilities.ts +197 -0
  331. package/src/runtime/channel-approval-types.ts +5 -1
  332. package/src/runtime/channel-retry-sweep.ts +1 -0
  333. package/src/runtime/channel-verification-service.ts +1 -2
  334. package/src/runtime/client-health.ts +26 -0
  335. package/src/runtime/confirmation-request-guardian-bridge.ts +38 -29
  336. package/src/runtime/effective-capabilities.test.ts +128 -0
  337. package/src/runtime/effective-capabilities.ts +84 -0
  338. package/src/runtime/guardian-reply-router.ts +106 -21
  339. package/src/runtime/invite-redemption-service.ts +6 -22
  340. package/src/runtime/migrations/__tests__/vbundle-builder-fd-leak.test.ts +123 -0
  341. package/src/runtime/migrations/vbundle-builder.ts +49 -20
  342. package/src/runtime/pending-interactions.ts +15 -0
  343. package/src/runtime/routes/__tests__/client-routes.test.ts +13 -0
  344. package/src/runtime/routes/__tests__/conversation-management-routes.test.ts +67 -0
  345. package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -13
  346. package/src/runtime/routes/browser-tabs-routes.ts +9 -0
  347. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +17 -8
  348. package/src/runtime/routes/client-routes.ts +10 -0
  349. package/src/runtime/routes/contact-routes.ts +31 -8
  350. package/src/runtime/routes/conversation-management-routes.ts +80 -1
  351. package/src/runtime/routes/conversation-query-routes.ts +68 -22
  352. package/src/runtime/routes/conversation-routes.ts +37 -12
  353. package/src/runtime/routes/events-routes.ts +1 -3
  354. package/src/runtime/routes/guardian-approval-interception.ts +14 -73
  355. package/src/runtime/routes/guardian-approval-prompt.ts +22 -4
  356. package/src/runtime/routes/home-feed-routes.ts +8 -3
  357. package/src/runtime/routes/inbound-message-handler.ts +214 -228
  358. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +88 -6
  359. package/src/runtime/routes/inbound-stages/admission-policy.test.ts +154 -0
  360. package/src/runtime/routes/inbound-stages/admission-policy.ts +140 -0
  361. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +3 -3
  362. package/src/runtime/routes/inbound-stages/background-dispatch.ts +11 -6
  363. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +1 -2
  364. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +1 -2
  365. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +7 -7
  366. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +47 -28
  367. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +358 -0
  368. package/src/runtime/routes/index.ts +2 -0
  369. package/src/runtime/routes/integrations/slack/__tests__/channel.test.ts +8 -0
  370. package/src/runtime/routes/integrations/slack/channel.ts +36 -0
  371. package/src/runtime/routes/internal-telemetry-routes.ts +1 -1
  372. package/src/runtime/routes/mcp-auth-routes.ts +233 -41
  373. package/src/runtime/routes/memory-eval-routes.ts +87 -0
  374. package/src/runtime/routes/notification-routes.ts +122 -133
  375. package/src/runtime/routes/platform-routes.ts +2 -2
  376. package/src/runtime/routes/plugins-routes.ts +40 -7
  377. package/src/runtime/routes/secret-routes.ts +10 -0
  378. package/src/runtime/routes/surface-action-routes.ts +2 -1
  379. package/src/runtime/routes/tool-call-question-enrichment.test.ts +146 -0
  380. package/src/runtime/routes/tool-call-question-enrichment.ts +66 -0
  381. package/src/runtime/routes/workflow-routes.test.ts +225 -1
  382. package/src/runtime/routes/workflow-routes.ts +131 -1
  383. package/src/runtime/tool-grant-request-helper.ts +18 -16
  384. package/src/runtime/trust-context-resolver.ts +8 -5
  385. package/src/schedule/schedule-store.ts +1 -1
  386. package/src/schedule/scheduler-types.ts +5 -1
  387. package/src/security/__tests__/provider-key-env-fallback.test.ts +6 -0
  388. package/src/security/secret-patterns.ts +3 -0
  389. package/src/subagent/manager.ts +11 -4
  390. package/src/telemetry/trace-collection-policy.test.ts +28 -0
  391. package/src/telemetry/trace-collection-policy.ts +30 -0
  392. package/src/telemetry/types.ts +89 -0
  393. package/src/telemetry/usage-telemetry-reporter.test.ts +586 -36
  394. package/src/telemetry/usage-telemetry-reporter.ts +148 -41
  395. package/src/tools/browser/__tests__/browser-execution-acquire.test.ts +31 -0
  396. package/src/tools/browser/browser-execution.ts +29 -18
  397. package/src/tools/document/document-tool.ts +2 -3
  398. package/src/tools/executor.ts +5 -3
  399. package/src/tools/host-terminal/host-shell.ts +5 -4
  400. package/src/tools/memory/register.ts +2 -2
  401. package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +360 -0
  402. package/src/tools/network/__tests__/web-search.test.ts +143 -0
  403. package/src/tools/network/web-fetch.ts +372 -1
  404. package/src/tools/network/web-search.ts +213 -10
  405. package/src/tools/permission-checker.ts +3 -2
  406. package/src/tools/registry.ts +20 -0
  407. package/src/tools/schedule/create.ts +4 -3
  408. package/src/tools/schedule/update.ts +2 -1
  409. package/src/tools/shared/filesystem/path-policy.ts +39 -13
  410. package/src/tools/skills/execute.ts +1 -2
  411. package/src/tools/subagent/spawn.ts +37 -13
  412. package/src/tools/terminal/shell.ts +10 -4
  413. package/src/tools/tool-approval-handler.ts +17 -10
  414. package/src/tools/types.ts +9 -0
  415. package/src/tools/ui-surface/definitions.ts +25 -2
  416. package/src/tools/verification-control-plane-policy.ts +3 -1
  417. package/src/tools/workflows/run-workflow.ts +1 -0
  418. package/src/util/disk-usage.ts +78 -23
  419. package/src/util/platform.ts +8 -1
  420. package/src/watcher/telemetry.ts +2 -2
  421. package/src/workflows/engine.test.ts +175 -1
  422. package/src/workflows/engine.ts +82 -0
  423. package/src/workflows/journal-store.test.ts +70 -0
  424. package/src/workflows/journal-store.ts +18 -3
  425. package/src/workflows/run-manager.test.ts +171 -3
  426. package/src/workflows/run-manager.ts +64 -0
  427. package/src/workspace/migrations/105-enable-memory-v3-live-for-new-workspaces.ts +63 -0
  428. package/src/workspace/migrations/106-drop-collect-usage-data.ts +47 -0
  429. package/src/workspace/migrations/107-drop-send-diagnostics.ts +47 -0
  430. package/src/workspace/migrations/108-drop-balanced-economy-profile.ts +129 -0
  431. package/src/workspace/migrations/registry.ts +8 -0
  432. package/src/notifications/tool-approval-copy.ts +0 -142
  433. package/src/runtime/routes/approval-prompt-ts-tracker.ts +0 -78
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Effective capabilities — trust-class capabilities COMPOSED with runtime context.
3
+ *
4
+ * `resolveCapabilities` (`capabilities.ts`) answers "what may this trust class
5
+ * do" from the actor's class alone, and stays deliberately pure (no context
6
+ * dependencies) so it remains the single fail-closed trust boundary. Some real
7
+ * decisions additionally depend on runtime context — the channel a request
8
+ * arrived on, surface actions, task authorization, a feature flag. Those
9
+ * compositions belong here, in named/testable helpers, rather than re-derived
10
+ * inline at each call site (which scatters a single policy across the codebase).
11
+ *
12
+ * Scope: this module composes capabilities with *actor/request* context. It does
13
+ * not read global config or feature flags itself — callers resolve those and
14
+ * pass the result in (e.g. `lockdownEnabled`), keeping this layer focused on the
15
+ * capability composition. `resolveRoutingState` in `trust-context-resolver.ts`
16
+ * is the same shape (capability + guardian-route context → `promptWaitingAllowed`)
17
+ * and predates this module; it stays where it is.
18
+ */
19
+ import type { TrustClass } from "./actor-trust-resolver.js";
20
+ import { resolveCapabilities } from "./capabilities.js";
21
+
22
+ type RawTrustClass = TrustClass | (string & {}) | undefined;
23
+
24
+ /**
25
+ * Channels that are themselves privileged document surfaces. Actors on these get
26
+ * privileged document access regardless of trust class — the `vellum` first-party
27
+ * console is the operator's own surface, not an external contact channel.
28
+ */
29
+ const PRIVILEGED_DOCUMENT_CHANNELS = new Set<string>(["vellum"]);
30
+
31
+ /**
32
+ * Whether an actor may perform privileged (non-conversation-scoped) document
33
+ * operations. True when the trust class grants it OR the request arrived on a
34
+ * privileged channel.
35
+ */
36
+ export function canActOnPrivilegedDocuments(actor: {
37
+ trustClass: RawTrustClass;
38
+ executionChannel?: string;
39
+ }): boolean {
40
+ return (
41
+ resolveCapabilities(actor.trustClass).canAccessPrivilegedDocuments ||
42
+ (actor.executionChannel != null &&
43
+ PRIVILEGED_DOCUMENT_CHANNELS.has(actor.executionChannel))
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Whether the CES shell lockdown applies to this actor. Active when the lockdown
49
+ * flag is enabled AND the actor's trust class cannot run an unsandboxed shell.
50
+ * Callers resolve the flag (`isCesShellLockdownEnabled(config)`) and pass it in.
51
+ */
52
+ export function isUntrustedShellLockdownActive(actor: {
53
+ trustClass: RawTrustClass;
54
+ lockdownEnabled: boolean;
55
+ }): boolean {
56
+ return (
57
+ actor.lockdownEnabled &&
58
+ !resolveCapabilities(actor.trustClass).canRunUnsandboxedShell
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Whether an archive-by-sender invocation is authorized. Any one of a surface
64
+ * action, a task-batch authorization, or an explicit prompt approval suffices.
65
+ * Absent those, the actor's own `user_approved` flag only counts when its trust
66
+ * class may self-authorize archive-by-sender.
67
+ */
68
+ export function isArchiveBySenderAuthorized(args: {
69
+ trustClass: RawTrustClass;
70
+ triggeredBySurfaceAction?: boolean;
71
+ batchAuthorizedByTask?: boolean;
72
+ approvedViaPrompt?: boolean;
73
+ userApproved?: boolean;
74
+ }): boolean {
75
+ const selfAuthorized =
76
+ args.userApproved === true &&
77
+ resolveCapabilities(args.trustClass).canSelfAuthorizeArchiveBySender;
78
+ return (
79
+ args.triggeredBySurfaceAction === true ||
80
+ args.batchAuthorizedByTask === true ||
81
+ args.approvedViaPrompt === true ||
82
+ selfAuthorized
83
+ );
84
+ }
@@ -30,6 +30,7 @@ import {
30
30
  type CanonicalGuardianRequest,
31
31
  getCanonicalGuardianRequest,
32
32
  getCanonicalGuardianRequestByCode,
33
+ getPendingCanonicalRequestByDestinationMessage,
33
34
  isRequestExpired,
34
35
  listCanonicalGuardianRequests,
35
36
  } from "../memory/canonical-guardian-store.js";
@@ -47,6 +48,7 @@ import type {
47
48
  ApprovalConversationContext,
48
49
  ApprovalConversationGenerator,
49
50
  } from "./http-types.js";
51
+ import { parseReactionCallbackData } from "./routes/channel-route-shared.js";
50
52
 
51
53
  const log = getLogger("guardian-reply-router");
52
54
 
@@ -54,6 +56,26 @@ const log = getLogger("guardian-reply-router");
54
56
  // Types
55
57
  // ---------------------------------------------------------------------------
56
58
 
59
+ /**
60
+ * How to scope a guardian's pending requests when resolving an inbound reply.
61
+ * The three states are mutually exclusive and named so the security-critical
62
+ * `blocked` cannot be confused with the absence of a hint:
63
+ *
64
+ * - `scoped`: resolve only these request ids, and constrain request-code
65
+ * routing to this set (delivery-/conversation-scoped hints).
66
+ * - `blocked`: fail closed — no pending requests and no identity fallback.
67
+ * The Slack cross-chat guard: a guardian's unrelated message in a chat
68
+ * where no card was delivered must not resolve a request delivered
69
+ * elsewhere. Explicit callbacks and request codes still work (they carry
70
+ * their own request id).
71
+ * - `identity-fallback`: discover pending requests by guardian identity,
72
+ * conversation, or principal. The default when no scope is supplied.
73
+ */
74
+ export type GuardianPendingScope =
75
+ | { mode: "scoped"; requestIds: string[] }
76
+ | { mode: "blocked" }
77
+ | { mode: "identity-fallback" };
78
+
57
79
  /** Context for an inbound message that may be a guardian reply. */
58
80
  export interface GuardianReplyContext {
59
81
  /** The raw message text (trimmed). */
@@ -66,8 +88,18 @@ export interface GuardianReplyContext {
66
88
  conversationId: string;
67
89
  /** Callback data from button presses (e.g. `apr:<requestId>:<action>`). */
68
90
  callbackData?: string;
69
- /** IDs of known pending canonical requests for this guardian. */
70
- pendingRequestIds?: string[];
91
+ /**
92
+ * For emoji-reaction decisions (`callbackData` of `reaction:<emoji>`): the
93
+ * channel-native id (e.g. Slack `ts`) of the message the reaction was
94
+ * attached to. Used to recover the target request from its delivery record.
95
+ */
96
+ reactedMessageTs?: string;
97
+ /**
98
+ * How to scope this guardian's pending requests (see {@link
99
+ * GuardianPendingScope}). Omitted is equivalent to
100
+ * `{ mode: "identity-fallback" }`.
101
+ */
102
+ pendingScope?: GuardianPendingScope;
71
103
  /** Conversation generator for NL classification (injected by daemon). */
72
104
  approvalConversationGenerator?: ApprovalConversationGenerator;
73
105
  /** Optional channel delivery context for resolver-driven side effects. */
@@ -204,30 +236,33 @@ function parseRequestCode(
204
236
  /** Find all pending canonical requests for a guardian actor. */
205
237
  function findPendingCanonicalRequests(
206
238
  actor: ActorContext,
207
- pendingRequestIds?: string[],
239
+ scope: GuardianPendingScope,
208
240
  conversationId?: string,
209
241
  ): CanonicalGuardianRequest[] {
242
+ // `blocked` fails closed: no pending requests and no identity fallback — the
243
+ // Slack cross-chat hijack guard.
244
+ if (scope.mode === "blocked") {
245
+ return [];
246
+ }
247
+
210
248
  let results: CanonicalGuardianRequest[];
211
249
 
212
- // When explicit IDs are provided, look them up directly
213
- if (pendingRequestIds) {
214
- if (pendingRequestIds.length === 0) {
215
- return [];
216
- }
217
- results = pendingRequestIds
250
+ if (scope.mode === "scoped") {
251
+ // Resolve exactly the supplied ids.
252
+ results = scope.requestIds
218
253
  .map(getCanonicalGuardianRequest)
219
254
  .filter((r): r is CanonicalGuardianRequest => r?.status === "pending");
220
255
  } else if (actor.actorExternalUserId) {
221
- // Query by guardian identity when available
256
+ // identity-fallback: query by guardian identity when available
222
257
  results = listCanonicalGuardianRequests({
223
258
  status: "pending",
224
259
  guardianExternalUserId: actor.actorExternalUserId,
225
260
  });
226
261
  } else if (conversationId) {
227
- // Actors without an actorExternalUserId: scope by conversationId so the NL
228
- // path can discover pending requests bound to this conversation.
229
- // Include guardianPrincipalId filter when available so the guardian only
230
- // sees requests they are authorized to act on.
262
+ // identity-fallback without an actorExternalUserId: scope by conversationId
263
+ // so the NL path can discover pending requests bound to this conversation.
264
+ // Include guardianPrincipalId when available so the guardian only sees
265
+ // requests they are authorized to act on.
231
266
  results = listCanonicalGuardianRequests({
232
267
  status: "pending",
233
268
  conversationId,
@@ -236,9 +271,8 @@ function findPendingCanonicalRequests(
236
271
  : {}),
237
272
  });
238
273
  } else if (actor.guardianPrincipalId) {
239
- // Actors with a guardianPrincipalId but no actorExternalUserId or
240
- // conversationId: query by principal so desktop sessions can still
241
- // discover pending guardian work via their bound principal.
274
+ // identity-fallback by principal: desktop sessions discover pending
275
+ // guardian work via their bound principal.
242
276
  results = listCanonicalGuardianRequests({
243
277
  status: "pending",
244
278
  guardianPrincipalId: actor.guardianPrincipalId,
@@ -284,22 +318,68 @@ export async function routeGuardianReply(
284
318
  ): Promise<GuardianReplyResult> {
285
319
  const {
286
320
  messageText,
321
+ channel,
287
322
  actor,
288
323
  conversationId,
289
324
  callbackData,
325
+ reactedMessageTs,
290
326
  approvalConversationGenerator,
291
327
  channelDeliveryContext,
292
328
  emissionContext,
293
329
  } = ctx;
330
+
331
+ // ── 0. Reaction decisions (emoji on a delivered approval card) ──
332
+ // A reaction carries an emoji plus the message it is attached to. Map the
333
+ // emoji to an action and recover the target request from that card's
334
+ // delivery record. Addressing by the reacted message disambiguates precisely
335
+ // even when several cards are pending in the same chat, so — unlike the
336
+ // text/NL paths — no clarification prompt is ever needed. `reaction_removed`
337
+ // never expresses intent and is filtered out before reaching the router.
338
+ if (
339
+ callbackData?.startsWith("reaction:") &&
340
+ !callbackData.startsWith("reaction_removed:")
341
+ ) {
342
+ const reaction = parseReactionCallbackData(callbackData);
343
+ const guardianChatId = channelDeliveryContext?.guardianChatId;
344
+ if (!reaction || !reactedMessageTs || !guardianChatId) {
345
+ // Unknown emoji, or missing addressing context — not an actionable
346
+ // approval reaction. Leave it for the caller to persist as a transcript
347
+ // signal (it must not trigger an agent turn).
348
+ return notConsumed();
349
+ }
350
+ const request = getPendingCanonicalRequestByDestinationMessage(
351
+ channel,
352
+ guardianChatId,
353
+ reactedMessageTs,
354
+ );
355
+ if (!request) {
356
+ // The reacted message is not a known pending approval card (a stray
357
+ // reaction, or one whose request was already resolved). Never approve
358
+ // off an unrecognized message.
359
+ return notConsumed();
360
+ }
361
+ return applyDecision(
362
+ request.id,
363
+ reaction.action,
364
+ actor,
365
+ undefined,
366
+ channelDeliveryContext,
367
+ emissionContext,
368
+ );
369
+ }
370
+
371
+ const pendingScope: GuardianPendingScope = ctx.pendingScope ?? {
372
+ mode: "identity-fallback",
373
+ };
294
374
  const pendingRequests = findPendingCanonicalRequests(
295
375
  actor,
296
- ctx.pendingRequestIds,
376
+ pendingScope,
297
377
  conversationId,
298
378
  );
379
+ // Request codes carry their own id, so constrain them only under an explicit
380
+ // scope; under blocked/identity-fallback they still resolve cross-chat.
299
381
  const scopedPendingRequestIds =
300
- ctx.pendingRequestIds && ctx.pendingRequestIds.length > 0
301
- ? new Set(ctx.pendingRequestIds)
302
- : null;
382
+ pendingScope.mode === "scoped" ? new Set(pendingScope.requestIds) : null;
303
383
 
304
384
  // ── 1. Deterministic callback parsing (button presses) ──
305
385
  // No conversationId scoping here — the guardian's reply comes from a
@@ -800,6 +880,7 @@ function resolveRequestInstructionMode(
800
880
  type CanonicalFailureReason =
801
881
  | "already_resolved"
802
882
  | "identity_mismatch"
883
+ | "request_misconfigured"
803
884
  | "invalid_action"
804
885
  | "expired";
805
886
 
@@ -819,6 +900,10 @@ function failureReplyText(
819
900
  return "This request has expired.";
820
901
  case "identity_mismatch":
821
902
  return "You don't have permission to decide on this request.";
903
+ case "request_misconfigured":
904
+ // The actor is authorized; the request record itself is incomplete
905
+ // (e.g. missing its bound principal). Do not imply a permission problem.
906
+ return "Something went wrong with this request on our end, so I couldn't apply your decision.";
822
907
  case "invalid_action":
823
908
  return buildGuardianInvalidActionReply(
824
909
  resolveRequestInstructionMode(request),
@@ -164,20 +164,12 @@ export function redeemInvite(params: {
164
164
  // Sentinel error used to trigger a transaction rollback when the invite
165
165
  // was concurrently revoked/expired between pre-validation and write time.
166
166
  const STALE_INVITE = Symbol("stale_invite");
167
- const canonicalMemberId = existingChannel.externalUserId
168
- ? canonicalizeInboundIdentity(
169
- sourceChannel as ChannelId,
170
- existingChannel.externalUserId,
171
- )
172
- : null;
167
+ const canonicalMemberId = existingChannel.address;
173
168
  const canonicalCallerId = externalUserId
174
169
  ? canonicalizeInboundIdentity(sourceChannel as ChannelId, externalUserId)
175
170
  : null;
176
- const memberMatchesSender = !!(
177
- canonicalMemberId &&
178
- canonicalCallerId &&
179
- canonicalMemberId === canonicalCallerId
180
- );
171
+ const memberMatchesSender =
172
+ !!canonicalCallerId && canonicalMemberId === canonicalCallerId;
181
173
  const preservedDisplayName =
182
174
  memberMatchesSender && existingContact?.displayName?.trim().length
183
175
  ? existingContact.displayName
@@ -542,20 +534,12 @@ export function redeemInviteByCode(params: {
542
534
  // an invite use atomically.
543
535
  if (existingChannel && !targetMismatch) {
544
536
  const STALE_INVITE_REACTIVATE = Symbol("stale_invite_reactivate");
545
- const canonicalMemberId = existingChannel.externalUserId
546
- ? canonicalizeInboundIdentity(
547
- sourceChannel as ChannelId,
548
- existingChannel.externalUserId,
549
- )
550
- : null;
537
+ const canonicalMemberId = existingChannel.address;
551
538
  const canonicalCallerId = externalUserId
552
539
  ? canonicalizeInboundIdentity(sourceChannel as ChannelId, externalUserId)
553
540
  : null;
554
- const memberMatchesSender = !!(
555
- canonicalMemberId &&
556
- canonicalCallerId &&
557
- canonicalMemberId === canonicalCallerId
558
- );
541
+ const memberMatchesSender =
542
+ !!canonicalCallerId && canonicalMemberId === canonicalCallerId;
559
543
  const preservedDisplayName =
560
544
  memberMatchesSender && existingContact?.displayName?.trim().length
561
545
  ? existingContact.displayName
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Regression test for descriptor hygiene in the vbundle export path.
3
+ *
4
+ * `streamExportVBundle` opens every file in the workspace twice — once to hash
5
+ * it (Pass 1) and once to stream it into the tar (Pass 2). This test pins the
6
+ * invariant that those reads release their descriptors: the number of open
7
+ * descriptors held by the process must not grow proportionally to the number
8
+ * of files exported. Without that guarantee a workspace-sized export exhausts
9
+ * the daemon's descriptor limit (EMFILE) and every subsequent subprocess spawn
10
+ * fails.
11
+ *
12
+ * Descriptor counting uses `/proc/self/fd`, which only exists on Linux. The
13
+ * assertion is skipped on platforms without it (e.g. macOS dev machines); CI
14
+ * runs on Linux, so the regression stays covered there.
15
+ */
16
+
17
+ import {
18
+ existsSync,
19
+ mkdirSync,
20
+ readdirSync,
21
+ rmSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { describe, expect, test } from "bun:test";
27
+
28
+ import { assertNotLiveDb } from "../../../__tests__/assert-not-live-db.js";
29
+ import { streamExportVBundle } from "../vbundle-builder.js";
30
+ import { defaultV1Options } from "./v1-test-helpers.js";
31
+
32
+ const PROC_SELF_FD = "/proc/self/fd";
33
+ const hasProcFd = existsSync(PROC_SELF_FD);
34
+
35
+ /** Count the descriptors currently open by this process. */
36
+ function openFdCount(): number {
37
+ return readdirSync(PROC_SELF_FD).length;
38
+ }
39
+
40
+ /**
41
+ * Build a workspace with enough distinct files (across nested directories) that
42
+ * a per-file descriptor leak would be obvious against the assertion bound.
43
+ */
44
+ function createPopulatedWorkspace(fileCount: number): {
45
+ dir: string;
46
+ cleanup: () => void;
47
+ } {
48
+ const dir = join(
49
+ tmpdir(),
50
+ `vbundle-fd-leak-${Date.now()}-${Math.random().toString(36).slice(2)}`,
51
+ );
52
+ mkdirSync(dir, { recursive: true });
53
+ writeFileSync(join(dir, "config.json"), JSON.stringify({ test: true }));
54
+ const dbDir = join(dir, "data", "db");
55
+ mkdirSync(dbDir, { recursive: true });
56
+ writeFileSync(join(dbDir, "assistant.db"), "fake-db-content");
57
+
58
+ // A spread of files under a few nested directories, each with non-trivial
59
+ // (multi-chunk) content so the read loop iterates more than once.
60
+ const filler = "x".repeat(200 * 1024);
61
+ for (let i = 0; i < fileCount; i++) {
62
+ const sub = join(dir, "data", "files", `dir-${i % 5}`);
63
+ mkdirSync(sub, { recursive: true });
64
+ writeFileSync(join(sub, `file-${i}.txt`), `${filler}\n${i}`);
65
+ }
66
+
67
+ return {
68
+ dir,
69
+ cleanup: () => {
70
+ assertNotLiveDb(dir);
71
+ rmSync(dir, { recursive: true, force: true });
72
+ },
73
+ };
74
+ }
75
+
76
+ describe("streamExportVBundle — descriptor lifecycle", () => {
77
+ test.skipIf(!hasProcFd)(
78
+ "does not leak a file descriptor per exported file",
79
+ async () => {
80
+ const fileCount = 60;
81
+ const workspace = createPopulatedWorkspace(fileCount);
82
+
83
+ // Warm-up export: first run can open and cache descriptors that legitimately
84
+ // persist (loggers, the daemon DB, etc.). Measuring the delta around a
85
+ // second export isolates per-file leakage from one-time setup.
86
+ let warmup: Awaited<ReturnType<typeof streamExportVBundle>> | undefined;
87
+ try {
88
+ warmup = await streamExportVBundle({
89
+ workspaceDir: workspace.dir,
90
+ ...defaultV1Options(),
91
+ });
92
+ } finally {
93
+ await warmup?.cleanup();
94
+ }
95
+
96
+ const before = openFdCount();
97
+
98
+ let result: Awaited<ReturnType<typeof streamExportVBundle>> | undefined;
99
+ try {
100
+ result = await streamExportVBundle({
101
+ workspaceDir: workspace.dir,
102
+ ...defaultV1Options(),
103
+ });
104
+ // Manifest covers every file we wrote — proves the walk actually read
105
+ // them (otherwise a no-op export would trivially leak nothing).
106
+ expect(result.manifest.contents.length).toBeGreaterThanOrEqual(
107
+ fileCount,
108
+ );
109
+ } finally {
110
+ await result?.cleanup();
111
+ workspace.cleanup();
112
+ }
113
+
114
+ const after = openFdCount();
115
+ const delta = after - before;
116
+
117
+ // Each export reads 2×fileCount files (hash pass + tar pass). A per-file
118
+ // leak would push the delta past `fileCount`. Allow a small constant for
119
+ // incidental descriptors (temp output file already cleaned up, jitter).
120
+ expect(delta).toBeLessThan(8);
121
+ },
122
+ );
123
+ });
@@ -11,7 +11,6 @@
11
11
  import { createHash, randomUUID } from "node:crypto";
12
12
  import {
13
13
  closeSync,
14
- createReadStream,
15
14
  createWriteStream,
16
15
  existsSync,
17
16
  lstatSync,
@@ -21,7 +20,7 @@ import {
21
20
  readSync,
22
21
  realpathSync,
23
22
  } from "node:fs";
24
- import { stat, unlink } from "node:fs/promises";
23
+ import { type FileHandle, open, stat, unlink } from "node:fs/promises";
25
24
  import { tmpdir } from "node:os";
26
25
  import { dirname, join, relative, resolve, sep } from "node:path";
27
26
  import { Readable } from "node:stream";
@@ -886,9 +885,10 @@ export function walkDirectoryForMetadata(
886
885
  }
887
886
 
888
887
  /**
889
- * Compute SHA-256 hex digest of a file by streaming never buffers the
890
- * entire file in memory. When `size` is provided, only hashes the first
891
- * `size` bytes to match what will be archived in the tar entry.
888
+ * Compute SHA-256 hex digest of a file by reading it in bounded chunks —
889
+ * never buffers the entire file in memory. When `size` is provided, only
890
+ * hashes the first `size` bytes to match what will be archived in the tar
891
+ * entry.
892
892
  */
893
893
  async function computeFileSha256(
894
894
  filePath: string,
@@ -896,11 +896,28 @@ async function computeFileSha256(
896
896
  ): Promise<string> {
897
897
  const hash = createHash("sha256");
898
898
  if (size === 0) return hash.digest("hex");
899
- const streamOpts =
900
- size !== undefined ? { start: 0, end: size - 1 } : undefined;
901
- const stream = createReadStream(filePath, streamOpts);
902
- for await (const chunk of stream) {
903
- hash.update(chunk);
899
+
900
+ // Read through an explicitly-managed FileHandle so the descriptor is
901
+ // always released in `finally`. This pass opens every file in the
902
+ // workspace, so any per-file descriptor leak here would exhaust the
903
+ // daemon's file-descriptor limit (EMFILE). A bounded read loop keeps peak
904
+ // memory flat regardless of file size.
905
+ const readChunkBytes = 64 * 1024;
906
+ const handle = await open(filePath, "r");
907
+ try {
908
+ const chunk = Buffer.allocUnsafe(readChunkBytes);
909
+ let position = 0;
910
+ for (;;) {
911
+ const remaining = size !== undefined ? size - position : Infinity;
912
+ if (remaining <= 0) break;
913
+ const toRead = Math.min(chunk.length, remaining);
914
+ const { bytesRead } = await handle.read(chunk, 0, toRead, position);
915
+ if (bytesRead === 0) break;
916
+ hash.update(chunk.subarray(0, bytesRead));
917
+ position += bytesRead;
918
+ }
919
+ } finally {
920
+ await handle.close();
904
921
  }
905
922
  return hash.digest("hex");
906
923
  }
@@ -1056,21 +1073,33 @@ async function* generateTarStream(
1056
1073
  // alignment. The WAL checkpoint before export is the primary
1057
1074
  // consistency mechanism for the database.
1058
1075
  let bytesWritten = 0;
1059
- if (file.size > 0) {
1076
+ if (entrySize > 0) {
1077
+ // Read through an explicitly-managed FileHandle so the descriptor is
1078
+ // released in `finally` even when the consumer abandons the generator
1079
+ // mid-file. Each read uses a fresh buffer so a yielded chunk is never
1080
+ // overwritten by the next read before the consumer drains it.
1081
+ let handle: FileHandle | undefined;
1060
1082
  try {
1061
- const stream = createReadStream(file.diskPath, {
1062
- start: 0,
1063
- end: file.size - 1,
1064
- });
1065
- for await (const chunk of stream) {
1066
- const data =
1067
- chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
1068
- bytesWritten += data.length;
1069
- yield data;
1083
+ handle = await open(file.diskPath, "r");
1084
+ const readChunkBytes = 64 * 1024;
1085
+ while (bytesWritten < entrySize) {
1086
+ const toRead = Math.min(readChunkBytes, entrySize - bytesWritten);
1087
+ const buf = Buffer.allocUnsafe(toRead);
1088
+ const { bytesRead } = await handle.read(
1089
+ buf,
1090
+ 0,
1091
+ toRead,
1092
+ bytesWritten,
1093
+ );
1094
+ if (bytesRead === 0) break;
1095
+ bytesWritten += bytesRead;
1096
+ yield bytesRead === buf.length ? buf : buf.subarray(0, bytesRead);
1070
1097
  }
1071
1098
  } catch {
1072
1099
  // File was deleted or rotated between passes — emit zeros for
1073
1100
  // the full declared size so the tar structure stays valid
1101
+ } finally {
1102
+ if (handle) await handle.close();
1074
1103
  }
1075
1104
  }
1076
1105
 
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import type { InteractionResolutionState } from "../api/events/interaction-resolved.js";
22
+ import type { QuestionEntry } from "../api/events/question-request.js";
22
23
  import type { UserDecision } from "../permissions/types.js";
23
24
  import { getLogger } from "../util/logger.js";
24
25
  import { broadcastMessage } from "./assistant-event-hub.js";
@@ -50,6 +51,18 @@ export interface ConfirmationDetails {
50
51
  }>;
51
52
  }
52
53
 
54
+ /**
55
+ * Full batched question payload carried on a pending `question` interaction, so
56
+ * a history-load render can stamp the outstanding prompt back onto its tool
57
+ * call and rehydrate the question card on a cold reconnect. Mirrors the
58
+ * `question_request` event's `questions[]` — `metadata` only retains the
59
+ * `orderedIds`/`optionsById` the response route needs to validate submissions,
60
+ * which is insufficient to reconstruct the card.
61
+ */
62
+ export interface QuestionDetails {
63
+ entries: QuestionEntry[];
64
+ }
65
+
53
66
  export interface PendingInteraction {
54
67
  /**
55
68
  * Owning conversation, when the interaction was raised inside one. Absent
@@ -69,6 +82,8 @@ export interface PendingInteraction {
69
82
  | "host_transfer"
70
83
  | "acp_confirmation";
71
84
  confirmationDetails?: ConfirmationDetails;
85
+ /** For a pending `question`: the full batched entries, so a history-load render can rehydrate the question card. */
86
+ questionDetails?: QuestionDetails;
72
87
  /** For ACP permissions: resolves directly without a Conversation object. */
73
88
  directResolve?: (decision: UserDecision) => void;
74
89
  /** When set, the host_bash request should be routed to this specific client. */
@@ -51,6 +51,7 @@ type ListClientsResponse = {
51
51
  machineName?: string;
52
52
  connectedAt: string;
53
53
  lastActiveAt: string;
54
+ degraded?: boolean;
54
55
  }>;
55
56
  };
56
57
 
@@ -152,4 +153,16 @@ describe("list_clients route — same-user filter", () => {
152
153
  const ids = result.clients.map((c) => c.clientId).sort();
153
154
  expect(ids).toEqual(["client-A1", "client-B1", "client-noprincipal"]);
154
155
  });
156
+
157
+ test("includes a degraded flag (false for a freshly connected client)", () => {
158
+ registerClient({ clientId: "client-A1", actorPrincipalId: "user-A" });
159
+
160
+ const handler = findHandler("list_clients");
161
+ const result = handler({
162
+ headers: { "x-vellum-actor-principal-id": "user-A" },
163
+ }) as ListClientsResponse;
164
+
165
+ expect(result.clients).toHaveLength(1);
166
+ expect(result.clients[0].degraded).toBe(false);
167
+ });
155
168
  });