@vellumai/assistant 0.11.4 → 0.11.5-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 (358) hide show
  1. package/AGENTS.md +2 -2
  2. package/ARCHITECTURE.md +29 -4
  3. package/README.md +1 -1
  4. package/docs/architecture/memory.md +17 -5
  5. package/docs/architecture/security.md +96 -152
  6. package/docs/guardian-request-flow.md +13 -2
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  9. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  10. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  15. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +166 -1
  16. package/node_modules/@vellumai/gateway-client/src/http-delivery.ts +7 -14
  17. package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
  18. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +29 -31
  19. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  20. package/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  21. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  22. package/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  23. package/openapi.yaml +193 -3
  24. package/package.json +1 -1
  25. package/src/__tests__/anthropic-provider.test.ts +10 -3
  26. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  27. package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
  28. package/src/__tests__/channel-approval-routes.test.ts +4 -0
  29. package/src/__tests__/channel-inbound-disk-pressure.test.ts +5 -6
  30. package/src/__tests__/channel-reply-delivery.test.ts +26 -13
  31. package/src/__tests__/checker.test.ts +20 -314
  32. package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +6 -2
  33. package/src/__tests__/client-os-metadata-persistence.test.ts +16 -0
  34. package/src/__tests__/compactor-retained-image-validation.test.ts +142 -0
  35. package/src/__tests__/container-cpu-sampler.test.ts +153 -0
  36. package/src/__tests__/context-search-memory-v2-source.test.ts +0 -1
  37. package/src/__tests__/conversation-attachments.test.ts +0 -1
  38. package/src/__tests__/conversation-error.test.ts +17 -0
  39. package/src/__tests__/conversation-lifecycle.test.ts +20 -26
  40. package/src/__tests__/conversation-pairing.test.ts +186 -0
  41. package/src/__tests__/conversation-runtime-assembly.test.ts +21 -2
  42. package/src/__tests__/credential-broker-server-use.test.ts +24 -5
  43. package/src/__tests__/credential-routes.test.ts +22 -3
  44. package/src/__tests__/credential-security-invariants.test.ts +2 -1
  45. package/src/__tests__/daemon-credential-client.test.ts +88 -0
  46. package/src/__tests__/default-plugin-names-guard.test.ts +12 -1
  47. package/src/__tests__/dm-backfill.test.ts +63 -0
  48. package/src/__tests__/dynamic-skill-background-guard.test.ts +0 -2
  49. package/src/__tests__/edit-propagation.test.ts +107 -4
  50. package/src/__tests__/events-client-registration.test.ts +28 -0
  51. package/src/__tests__/file-write-tool.test.ts +4 -2
  52. package/src/__tests__/guardian-prompt-notice-privacy.test.ts +133 -0
  53. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +143 -97
  54. package/src/__tests__/helpers/gateway-classify-mock.ts +27 -6
  55. package/src/__tests__/host-proxy-interface.test.ts +11 -1
  56. package/src/__tests__/host-shell-tool.test.ts +23 -4
  57. package/src/__tests__/image-conversion.test.ts +143 -1
  58. package/src/__tests__/inline-skill-load-permissions.test.ts +47 -36
  59. package/src/__tests__/input-repairs.test.ts +207 -0
  60. package/src/__tests__/live-workspace-guard.test.ts +75 -0
  61. package/src/__tests__/mcp-abort-signal.test.ts +1 -1
  62. package/src/__tests__/mcp-client-auth.test.ts +1 -1
  63. package/src/__tests__/mcp-tool-annotations-risk.test.ts +1 -1
  64. package/src/__tests__/media-resolve-image-validation.test.ts +310 -0
  65. package/src/__tests__/mtime-cache.test.ts +2 -0
  66. package/src/__tests__/notification-vellum-adapter.test.ts +124 -1
  67. package/src/__tests__/openai-provider.test.ts +22 -0
  68. package/src/__tests__/personal-memory-auth-bypass.test.ts +22 -6
  69. package/src/__tests__/platform-bash-auto-approve.test.ts +0 -4
  70. package/src/__tests__/platform.test.ts +16 -1
  71. package/src/__tests__/plugin-api-resolve-credential.test.ts +22 -13
  72. package/src/__tests__/plugin-api-shim.test.ts +5 -0
  73. package/src/__tests__/plugin-api-store-credential.test.ts +268 -0
  74. package/src/__tests__/plugin-config-data-migration.test.ts +8 -1
  75. package/src/__tests__/plugin-disabled-state.test.ts +2 -0
  76. package/src/__tests__/plugin-effective-enabled-set.test.ts +55 -3
  77. package/src/__tests__/plugin-execution-context.test.ts +73 -0
  78. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  79. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +0 -1
  80. package/src/__tests__/reaction-persistence.test.ts +200 -7
  81. package/src/__tests__/require-fresh-approval.test.ts +0 -4
  82. package/src/__tests__/resource-pressure-guard.test.ts +428 -0
  83. package/src/__tests__/resource-pressure-routes.test.ts +113 -0
  84. package/src/__tests__/risk-classification-boundary-guard.test.ts +115 -0
  85. package/src/__tests__/run-conversation-turn-persistence.test.ts +27 -1
  86. package/src/__tests__/secret-routes-platform-proxy.test.ts +7 -0
  87. package/src/__tests__/skill-tool-factory.test.ts +161 -0
  88. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -1
  89. package/src/__tests__/tool-executor-lifecycle-events.test.ts +132 -5
  90. package/src/__tests__/tool-executor.test.ts +63 -38
  91. package/src/__tests__/tool-policy.test.ts +97 -1
  92. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +7 -4
  93. package/src/__tests__/user-plugin-loader.test.ts +2 -0
  94. package/src/__tests__/validate-input.test.ts +243 -7
  95. package/src/__tests__/verification-control-plane-policy.test.ts +0 -2
  96. package/src/acp/__tests__/acp-claude-oauth.test.ts +97 -29
  97. package/src/acp/__tests__/prepare-agent-env.test.ts +376 -153
  98. package/src/acp/acp-claude-oauth.ts +38 -21
  99. package/src/acp/prepare-agent-env.ts +146 -55
  100. package/src/agent/loop-tool-dedup.test.ts +161 -0
  101. package/src/agent/loop.ts +68 -22
  102. package/src/api/constants/app-tools.ts +34 -0
  103. package/src/api/events/resource-pressure-status-changed.ts +53 -0
  104. package/src/api/index.ts +19 -0
  105. package/src/api/responses/resource-pressure-status.ts +25 -0
  106. package/src/approvals/guardian-channel-delivery.ts +70 -6
  107. package/src/approvals/guardian-request-resolvers.ts +32 -66
  108. package/src/channels/__tests__/message-audience.test.ts +49 -0
  109. package/src/channels/__tests__/types.test.ts +17 -3
  110. package/src/channels/message-audience.ts +40 -0
  111. package/src/channels/types.ts +16 -12
  112. package/src/cli/__tests__/catalog-search-help.test.ts +50 -0
  113. package/src/cli/commands/__tests__/channel-verification-sessions.test.ts +31 -0
  114. package/src/cli/commands/__tests__/conversations-search.test.ts +289 -0
  115. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
  116. package/src/cli/commands/__tests__/inference-providers.test.ts +68 -0
  117. package/src/cli/commands/__tests__/keys.test.ts +89 -8
  118. package/src/cli/commands/__tests__/plugins.test.ts +57 -1
  119. package/src/cli/commands/channel-verification-sessions.help.ts +11 -9
  120. package/src/cli/commands/channel-verification-sessions.ts +11 -21
  121. package/src/cli/commands/conversations.help.ts +34 -0
  122. package/src/cli/commands/conversations.ts +125 -0
  123. package/src/cli/commands/inference-providers.ts +9 -4
  124. package/src/cli/commands/inference.help.ts +9 -4
  125. package/src/cli/commands/keys.help.ts +13 -1
  126. package/src/cli/commands/keys.ts +26 -15
  127. package/src/cli/commands/memory/__tests__/memory-v2.test.ts +57 -7
  128. package/src/cli/commands/memory/index.help.ts +41 -27
  129. package/src/cli/commands/memory/index.ts +2 -0
  130. package/src/cli/commands/memory/memory-v2.ts +58 -54
  131. package/src/cli/commands/memory/memory-validate.ts +18 -0
  132. package/src/cli/commands/monitoring.ts +1 -0
  133. package/src/cli/commands/plugins.help.ts +28 -31
  134. package/src/cli/commands/plugins.ts +10 -0
  135. package/src/cli/commands/skills.help.ts +13 -16
  136. package/src/cli/commands/trust.ts +3 -14
  137. package/src/cli/lib/__tests__/install-from-github.test.ts +38 -0
  138. package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +34 -0
  139. package/src/cli/lib/__tests__/plugin-surfaces.test.ts +32 -1
  140. package/src/cli/lib/__tests__/toggle-plugin.test.ts +2 -1
  141. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +64 -0
  142. package/src/cli/lib/bundled-marketplace.json +13 -0
  143. package/src/cli/lib/daemon-credential-client.ts +25 -3
  144. package/src/cli/lib/install-from-github.ts +35 -24
  145. package/src/cli/lib/merge-plugin-tree.ts +22 -4
  146. package/src/cli/lib/plugin-surfaces.ts +27 -0
  147. package/src/config/feature-flag-registry.json +35 -19
  148. package/src/config/webhook-routing.ts +8 -0
  149. package/src/context/compactor.ts +31 -2
  150. package/src/daemon/__tests__/provider-rejection-log-fields.test.ts +272 -0
  151. package/src/daemon/conversation-agent-loop-handlers.ts +4 -1
  152. package/src/daemon/conversation-error.ts +13 -0
  153. package/src/daemon/conversation-messaging.ts +22 -2
  154. package/src/daemon/conversation-process.ts +11 -0
  155. package/src/daemon/conversation-store.ts +10 -0
  156. package/src/daemon/conversation-surfaces.ts +21 -8
  157. package/src/daemon/conversation.ts +12 -10
  158. package/src/daemon/lifecycle.ts +6 -2
  159. package/src/daemon/message-types/conversations.ts +5 -7
  160. package/src/daemon/provider-rejection-log-fields.ts +124 -0
  161. package/src/daemon/resource-pressure-guard-lifecycle.ts +66 -0
  162. package/src/daemon/resource-pressure-guard.ts +387 -0
  163. package/src/daemon/shutdown-handlers.ts +2 -0
  164. package/src/daemon/startup-error.ts +16 -0
  165. package/src/daemon/trust-context.ts +14 -10
  166. package/src/daemon/unsendable-image-notice.ts +76 -0
  167. package/src/hooks/registry.ts +65 -51
  168. package/src/ipc/__tests__/socket-path.test.ts +15 -7
  169. package/src/ipc/gateway-client.test.ts +1 -1
  170. package/src/ipc/gateway-client.ts +14 -21
  171. package/src/ipc/socket-cleanup.ts +2 -11
  172. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +1 -1
  173. package/src/messaging/provider-message-metadata.ts +128 -0
  174. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +116 -68
  175. package/src/messaging/providers/channel-transport.ts +90 -13
  176. package/src/messaging/providers/discord/send.ts +16 -0
  177. package/src/messaging/providers/discord/transport.ts +11 -1
  178. package/src/messaging/providers/index.ts +109 -16
  179. package/src/messaging/providers/slack/message-metadata.ts +45 -0
  180. package/src/messaging/providers/slack/render-transcript.ts +1 -4
  181. package/src/messaging/providers/slack/send.test.ts +6 -9
  182. package/src/messaging/providers/slack/send.ts +40 -28
  183. package/src/messaging/providers/slack/transport.ts +22 -26
  184. package/src/messaging/providers/telegram-bot/send.test.ts +2 -8
  185. package/src/messaging/providers/telegram-bot/transport.ts +3 -6
  186. package/src/messaging/read-provider-metadata.test.ts +157 -0
  187. package/src/messaging/read-provider-metadata.ts +51 -0
  188. package/src/notifications/__tests__/assistant-reply-producer.test.ts +94 -0
  189. package/src/notifications/__tests__/edit-notification.test.ts +174 -2
  190. package/src/notifications/__tests__/home-feed-side-effect.test.ts +536 -4
  191. package/src/notifications/adapters/macos.ts +55 -0
  192. package/src/notifications/adapters/slack.ts +10 -5
  193. package/src/notifications/assistant-reply-producer.ts +37 -4
  194. package/src/notifications/conversation-pairing.ts +111 -21
  195. package/src/notifications/edit-notification.ts +38 -8
  196. package/src/notifications/emit-signal.ts +12 -14
  197. package/src/notifications/home-feed-side-effect.ts +218 -15
  198. package/src/notifications/types.ts +5 -0
  199. package/src/permissions/AGENTS.md +16 -0
  200. package/src/permissions/checker.test.ts +75 -122
  201. package/src/permissions/checker.ts +44 -560
  202. package/src/permissions/confirmation-guardian-request.test.ts +28 -3
  203. package/src/permissions/confirmation-guardian-request.ts +5 -1
  204. package/src/persistence/conversation-crud.ts +23 -20
  205. package/src/persistence/conversation-queries.ts +14 -1
  206. package/src/persistence/conversation-types.ts +22 -0
  207. package/src/persistence/delivery-crud.ts +122 -15
  208. package/src/plugin-api/__tests__/import-graph-partial-mock.test.ts +67 -0
  209. package/src/plugin-api/conversation-turn.ts +27 -0
  210. package/src/plugin-api/credential-scope.test.ts +15 -0
  211. package/src/plugin-api/credential-scope.ts +14 -0
  212. package/src/plugin-api/index.ts +19 -1
  213. package/src/plugin-api/resolve-credential.ts +15 -10
  214. package/src/plugin-api/store-credential.ts +148 -0
  215. package/src/plugin-api/system-card.ts +36 -0
  216. package/src/plugin-api/vision-support.test.ts +82 -0
  217. package/src/plugin-api/vision-support.ts +14 -2
  218. package/src/plugins/defaults/image-fallback/__tests__/image-fallback.test.ts +361 -110
  219. package/src/plugins/defaults/image-fallback/__tests__/vision-recovery.test.ts +4 -0
  220. package/src/plugins/defaults/image-fallback/hooks/user-prompt-submit.ts +58 -0
  221. package/src/plugins/defaults/image-fallback/src/caption-blocks.ts +64 -23
  222. package/src/plugins/defaults/image-recovery/detect.ts +24 -1
  223. package/src/plugins/defaults/main.ts +15 -0
  224. package/src/plugins/defaults/memory/AGENTS.md +11 -3
  225. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +0 -1
  226. package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +9 -12
  227. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
  228. package/src/plugins/defaults/memory/src/memory-v2-routes.ts +12 -10
  229. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +114 -0
  230. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +8 -0
  231. package/src/plugins/defaults/memory/substrate/__tests__/edge-index.test.ts +0 -30
  232. package/src/plugins/defaults/memory/substrate/__tests__/ingest.test.ts +73 -1
  233. package/src/plugins/defaults/memory/substrate/__tests__/page-index.test.ts +37 -0
  234. package/src/plugins/defaults/memory/substrate/__tests__/page-links.test.ts +208 -0
  235. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +158 -1
  236. package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +1 -61
  237. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +68 -6
  238. package/src/plugins/defaults/memory/substrate/edge-index.ts +0 -31
  239. package/src/plugins/defaults/memory/substrate/ingest.ts +59 -0
  240. package/src/plugins/defaults/memory/substrate/page-index.ts +35 -8
  241. package/src/plugins/defaults/memory/substrate/page-links.ts +133 -0
  242. package/src/plugins/defaults/memory/substrate/page-store.ts +10 -0
  243. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +124 -34
  244. package/src/plugins/defaults/memory/substrate/static-context.ts +0 -29
  245. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +3 -1
  246. package/src/plugins/defaults/memory/v3/card.ts +9 -9
  247. package/src/plugins/defaults/memory/v3/core-set.test.ts +7 -0
  248. package/src/plugins/defaults/memory/v3/core-set.ts +5 -1
  249. package/src/plugins/defaults/memory/v3/edge.ts +13 -57
  250. package/src/plugins/mtime-cache.ts +1 -1
  251. package/src/plugins/pipeline.ts +14 -7
  252. package/src/plugins/plugin-execution-context.ts +35 -11
  253. package/src/plugins/plugin-tree-walk.ts +63 -4
  254. package/src/providers/anthropic/__tests__/pause-turn-continuation.test.ts +170 -0
  255. package/src/providers/anthropic/client.ts +332 -241
  256. package/src/providers/connection-resolution.ts +2 -1
  257. package/src/providers/content-blocks.ts +22 -0
  258. package/src/providers/gemini/client.ts +5 -2
  259. package/src/providers/inference/__tests__/adapter-factory-litellm.test.ts +47 -1
  260. package/src/providers/inference/__tests__/adapter-factory-ollama.test.ts +83 -0
  261. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +98 -1
  262. package/src/providers/inference/__tests__/base-url-route-validation.test.ts +38 -0
  263. package/src/providers/inference/__tests__/base-url-security.test.ts +10 -0
  264. package/src/providers/inference/__tests__/connections-ollama.test.ts +90 -0
  265. package/src/providers/inference/__tests__/missing-credential-guard.test.ts +117 -0
  266. package/src/providers/inference/adapter-factory.ts +33 -2
  267. package/src/providers/inference/auth.ts +13 -2
  268. package/src/providers/inference/credential-usage.ts +37 -0
  269. package/src/providers/inference/missing-credential-guard.ts +110 -0
  270. package/src/providers/inference/resolve-auth.ts +7 -7
  271. package/src/providers/media-resolve.ts +176 -15
  272. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +576 -46
  273. package/src/providers/openai/__tests__/tool-choice-mapping.test.ts +125 -2
  274. package/src/providers/openai/chat-completions-provider.ts +322 -42
  275. package/src/routes/route-host-protocol.ts +7 -0
  276. package/src/routes/worker.ts +31 -10
  277. package/src/runtime/AGENTS.md +35 -0
  278. package/src/runtime/__tests__/runtime-http-port-collision.test.ts +131 -0
  279. package/src/runtime/__tests__/web-presence.test.ts +234 -0
  280. package/src/runtime/assistant-event-hub.ts +38 -0
  281. package/src/runtime/channel-reply-delivery.ts +54 -31
  282. package/src/runtime/channel-retry-sweep.ts +6 -4
  283. package/src/runtime/effective-capabilities.test.ts +19 -0
  284. package/src/runtime/effective-capabilities.ts +25 -0
  285. package/src/runtime/guardian-reply-router.ts +6 -2
  286. package/src/runtime/http-errors.ts +1 -0
  287. package/src/runtime/http-server.ts +20 -5
  288. package/src/runtime/routes/__tests__/client-routes.test.ts +150 -0
  289. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +133 -0
  290. package/src/runtime/routes/__tests__/credential-delete-in-use.test.ts +155 -0
  291. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +52 -1
  292. package/src/runtime/routes/__tests__/monitoring-routes.test.ts +1 -0
  293. package/src/runtime/routes/__tests__/pending-interactions-route.test.ts +175 -0
  294. package/src/runtime/routes/__tests__/plugins-routes.test.ts +88 -35
  295. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +55 -6
  296. package/src/runtime/routes/__tests__/system-card-turn-termination.test.ts +107 -0
  297. package/src/runtime/routes/__tests__/user-route-dispatcher-host.test.ts +52 -1
  298. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +75 -4
  299. package/src/runtime/routes/approval-routes.ts +37 -4
  300. package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +16 -22
  301. package/src/runtime/routes/canned-message-complete.ts +68 -29
  302. package/src/runtime/routes/client-routes.ts +113 -0
  303. package/src/runtime/routes/conversation-list-routes.ts +22 -7
  304. package/src/runtime/routes/conversation-management-routes.ts +15 -9
  305. package/src/runtime/routes/conversation-routes.ts +36 -22
  306. package/src/runtime/routes/credential-in-use.ts +85 -0
  307. package/src/runtime/routes/credential-routes.ts +55 -85
  308. package/src/runtime/routes/guardian-approval-interception.ts +22 -22
  309. package/src/runtime/routes/guardian-approval-reply-helpers.ts +14 -12
  310. package/src/runtime/routes/identity-routes.ts +5 -121
  311. package/src/runtime/routes/inbound-message-handler.ts +71 -27
  312. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -17
  313. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +77 -50
  314. package/src/runtime/routes/inbound-stages/background-dispatch.ts +61 -97
  315. package/src/runtime/routes/inbound-stages/edit-intercept.ts +101 -77
  316. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +17 -8
  317. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +91 -7
  318. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +72 -70
  319. package/src/runtime/routes/index.ts +2 -0
  320. package/src/runtime/routes/inference-provider-connection-routes.ts +9 -7
  321. package/src/runtime/routes/monitoring-routes.ts +9 -1
  322. package/src/runtime/routes/plugins-routes.ts +17 -5
  323. package/src/runtime/routes/resource-pressure-routes.ts +22 -0
  324. package/src/runtime/routes/secret-routes.ts +39 -8
  325. package/src/runtime/routes/settings-routes.ts +6 -6
  326. package/src/runtime/routes/surface-action-routes.ts +20 -19
  327. package/src/runtime/routes/user-route-dispatcher.ts +31 -2
  328. package/src/runtime/routes/user-route-resolution.ts +18 -1
  329. package/src/runtime/slack-reply-session.test.ts +23 -13
  330. package/src/runtime/slack-reply-session.ts +38 -55
  331. package/src/runtime/web-presence.ts +90 -0
  332. package/src/skills/validate-input.ts +177 -40
  333. package/src/subagent/__tests__/consult-context-gating.test.ts +6 -10
  334. package/src/subagent/consult-context.ts +7 -23
  335. package/src/tools/credentials/broker.ts +24 -79
  336. package/src/tools/credentials/ref-parse.ts +35 -0
  337. package/src/tools/credentials/resolve.ts +4 -10
  338. package/src/tools/credentials/store.ts +168 -0
  339. package/src/tools/credentials/tool-policy.ts +67 -0
  340. package/src/tools/executor.ts +53 -15
  341. package/src/tools/network/__tests__/web-search.test.ts +41 -1
  342. package/src/tools/network/url-safety.ts +9 -16
  343. package/src/tools/network/web-search.ts +21 -3
  344. package/src/tools/permission-checker.ts +43 -52
  345. package/src/tools/schema-transforms.ts +40 -5
  346. package/src/tools/shared/input-repairs.ts +160 -0
  347. package/src/tools/skills/skill-tool-factory.ts +18 -4
  348. package/src/tools/subagent/spawn.ts +0 -1
  349. package/src/tools/tool-approval-handler.ts +16 -10
  350. package/src/tools/tool-types.ts +26 -13
  351. package/src/tools/types.ts +6 -6
  352. package/src/util/__tests__/cgroup-memory.test.ts +3 -0
  353. package/src/util/cgroup-memory.ts +3 -0
  354. package/src/util/container-cpu-sampler.ts +250 -0
  355. package/src/util/image-conversion.ts +178 -14
  356. package/src/util/platform.ts +93 -10
  357. package/src/permissions/ipc-risk-types.ts +0 -143
  358. package/src/permissions/risk-types.ts +0 -76
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Rolling container CPU sampler.
3
+ *
4
+ * Tracks CPU usage over a rolling window so consumers (e.g. /v1/health) report
5
+ * near-real-time utilization instead of a lifetime average (total CPU time /
6
+ * total uptime). The sampler starts on module import and runs on an unref'd
7
+ * interval, so it never prevents process exit.
8
+ */
9
+
10
+ import { readFileSync } from "node:fs";
11
+
12
+ import { getIsPlatform } from "../config/env-registry.js";
13
+ import { getContainerCpuCores } from "./cgroup-cpu.js";
14
+
15
+ /**
16
+ * Read the container's CPU usage from cgroup accounting files.
17
+ *
18
+ * Returns total CPU microseconds consumed by the container since boot.
19
+ * We use the delta between two samples to compute percentage.
20
+ */
21
+ function getContainerCpuUsageUs(): number | null {
22
+ // cgroups v2: cpu.stat has a "usage_usec" line.
23
+ try {
24
+ const stat = readFileSync("/sys/fs/cgroup/cpu.stat", "utf-8");
25
+ for (const line of stat.split("\n")) {
26
+ if (line.startsWith("usage_usec")) {
27
+ const val = parseInt(line.split(/\s+/)[1], 10);
28
+ if (!isNaN(val) && val > 0) {
29
+ return val;
30
+ }
31
+ }
32
+ }
33
+ } catch {
34
+ /* not available */
35
+ }
36
+
37
+ // cgroups v1: cpuacct.usage is in nanoseconds.
38
+ try {
39
+ const ns = parseInt(
40
+ readFileSync("/sys/fs/cgroup/cpuacct/cpuacct.usage", "utf-8").trim(),
41
+ 10,
42
+ );
43
+ if (!isNaN(ns) && ns > 0) {
44
+ return ns / 1000;
45
+ } // convert ns → µs
46
+ } catch {
47
+ /* not available */
48
+ }
49
+
50
+ return null;
51
+ }
52
+
53
+ const CPU_SAMPLE_INTERVAL_MS = 5_000;
54
+
55
+ /**
56
+ * Sample this process's cumulative CPU time, returning null when the
57
+ * underlying syscall fails.
58
+ */
59
+ function sampleProcessCpuUsage(): NodeJS.CpuUsage | null {
60
+ try {
61
+ return process.cpuUsage();
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Convert a CPU-time delta into a percent of the container's full allocation,
69
+ * rounded to 2 decimal places. Callers must guard `numCores > 0` and
70
+ * `elapsedMs > 0`; a zero divisor yields a non-finite result.
71
+ */
72
+ export function computeCpuPercent(
73
+ deltaCpuUs: number,
74
+ elapsedMs: number,
75
+ numCores: number,
76
+ ): number {
77
+ const deltaCpuMs = deltaCpuUs / 1000;
78
+ return Math.round((deltaCpuMs / (elapsedMs * numCores)) * 10000) / 100;
79
+ }
80
+
81
+ // Recent per-tick percents are retained so consumers with a coarser cadence
82
+ // (the resource-pressure guard samples every 30s) can average the 5s windows
83
+ // inside their own interval instead of reading one instantaneous value; a
84
+ // workload that spikes in phase with the coarser cadence would otherwise
85
+ // alias into a sustained-high reading.
86
+ const RECENT_SAMPLE_RETENTION_MS = 5 * 60 * 1000;
87
+
88
+ let _lastProcessCpuUsage: NodeJS.CpuUsage | null = sampleProcessCpuUsage();
89
+ let _lastCgroupCpuUs: number | null = getContainerCpuUsageUs();
90
+ let _lastCpuTime: number = Date.now();
91
+ let _cachedCpuPercent = 0;
92
+ // Whether the sampler has ever computed a real delta. Until then the cache
93
+ // is a placeholder 0 (still warming up, or neither cgroup counters nor
94
+ // process.cpuUsage() deltas are readable), not a measurement.
95
+ let _hasCpuSample = false;
96
+ let _recentSamples: { at: number; percent: number; elapsedMs: number }[] = [];
97
+
98
+ function setCachedCpuPercent(
99
+ percent: number,
100
+ at: number,
101
+ elapsedMs: number,
102
+ ): void {
103
+ _cachedCpuPercent = percent;
104
+ _hasCpuSample = true;
105
+ _recentSamples.push({ at, percent, elapsedMs });
106
+ const cutoff = at - RECENT_SAMPLE_RETENTION_MS;
107
+ while (_recentSamples.length > 0 && _recentSamples[0].at < cutoff) {
108
+ _recentSamples.shift();
109
+ }
110
+ }
111
+
112
+ function runCpuSamplerTick(now: number): void {
113
+ const elapsedMs = now - _lastCpuTime;
114
+ if (elapsedMs <= 0) {
115
+ return;
116
+ }
117
+
118
+ const numCores = getContainerCpuCores();
119
+ if (numCores <= 0) {
120
+ _lastCpuTime = now;
121
+ return;
122
+ }
123
+
124
+ // Always sample process-level CPU so the baseline stays fresh. This
125
+ // prevents a spike if the platform cgroup path later falls back to
126
+ // process.cpuUsage() after cgroup stats were previously available.
127
+ const newProcessUsage = sampleProcessCpuUsage();
128
+ const processDeltaUs =
129
+ newProcessUsage !== null && _lastProcessCpuUsage !== null
130
+ ? newProcessUsage.user -
131
+ _lastProcessCpuUsage.user +
132
+ (newProcessUsage.system - _lastProcessCpuUsage.system)
133
+ : null;
134
+ if (newProcessUsage !== null) {
135
+ _lastProcessCpuUsage = newProcessUsage;
136
+ }
137
+
138
+ if (getIsPlatform()) {
139
+ // In platform mode, prefer cgroup-level CPU usage so we see the full
140
+ // container footprint, not just this process.
141
+ const cgroupUs = getContainerCpuUsageUs();
142
+ if (cgroupUs !== null && _lastCgroupCpuUs !== null) {
143
+ setCachedCpuPercent(
144
+ computeCpuPercent(cgroupUs - _lastCgroupCpuUs, elapsedMs, numCores),
145
+ now,
146
+ elapsedMs,
147
+ );
148
+ } else if (processDeltaUs !== null) {
149
+ // cgroup CPU stats unavailable (e.g. gVisor) – fall back to process-level.
150
+ setCachedCpuPercent(
151
+ computeCpuPercent(processDeltaUs, elapsedMs, numCores),
152
+ now,
153
+ elapsedMs,
154
+ );
155
+ }
156
+ _lastCgroupCpuUs = cgroupUs;
157
+ } else if (processDeltaUs !== null) {
158
+ // Non-platform: use process.cpuUsage() (accurate for single-process mode).
159
+ setCachedCpuPercent(
160
+ computeCpuPercent(processDeltaUs, elapsedMs, numCores),
161
+ now,
162
+ elapsedMs,
163
+ );
164
+ }
165
+
166
+ _lastCpuTime = now;
167
+ }
168
+
169
+ // Kick off the background sampler. unref() so it never prevents process exit.
170
+ setInterval(() => {
171
+ runCpuSamplerTick(Date.now());
172
+ }, CPU_SAMPLE_INTERVAL_MS).unref();
173
+
174
+ /**
175
+ * Near-real-time CPU utilization as a percent of the container's full
176
+ * allocation, refreshed every {@link CPU_SAMPLE_INTERVAL_MS}.
177
+ *
178
+ * Reports 0 until the sampler computes its first delta; callers that must
179
+ * distinguish "idle" from "no data yet" use
180
+ * {@link getCachedContainerCpuPercentOrNull}.
181
+ */
182
+ export function getCachedContainerCpuPercent(): number {
183
+ return _cachedCpuPercent;
184
+ }
185
+
186
+ /**
187
+ * Like {@link getCachedContainerCpuPercent}, but null until the sampler has
188
+ * computed at least one real delta, so warm-up and unreadable CPU accounting
189
+ * read as "signal unavailable" instead of a genuine-looking 0%.
190
+ */
191
+ export function getCachedContainerCpuPercentOrNull(): number | null {
192
+ return _hasCpuSample ? _cachedCpuPercent : null;
193
+ }
194
+
195
+ /**
196
+ * Duration-weighted mean of per-tick percents over the window starting at
197
+ * `windowStartMs`. Each tick's percent covers the wall time its delta
198
+ * spanned (ending at `at`), so a delayed tick must weigh proportionally
199
+ * more than an on-schedule one, and only the part of a tick's interval
200
+ * that overlaps the window may contribute: a long delayed tick ending just
201
+ * inside the window would otherwise import load that predates it.
202
+ */
203
+ export function computeDurationWeightedMeanPercent(
204
+ samples: readonly { at: number; percent: number; elapsedMs: number }[],
205
+ windowStartMs: number,
206
+ ): number | null {
207
+ let weightedSum = 0;
208
+ let totalOverlapMs = 0;
209
+ for (const sample of samples) {
210
+ const intervalStart = sample.at - sample.elapsedMs;
211
+ const overlapMs = sample.at - Math.max(intervalStart, windowStartMs);
212
+ if (overlapMs > 0) {
213
+ weightedSum += sample.percent * overlapMs;
214
+ totalOverlapMs += overlapMs;
215
+ }
216
+ }
217
+ if (totalOverlapMs <= 0) {
218
+ return null;
219
+ }
220
+ return Math.round((weightedSum / totalOverlapMs) * 100) / 100;
221
+ }
222
+
223
+ /**
224
+ * Duration-weighted mean of the per-tick percents recorded within the
225
+ * trailing `windowMs`, rounded to 2 decimal places, or null when no tick
226
+ * overlaps the window. Coarser-cadence consumers use this instead of the
227
+ * instantaneous cache so a short spike aligned with their cadence cannot
228
+ * read as sustained load.
229
+ */
230
+ export function getAverageContainerCpuPercentOrNull(
231
+ windowMs: number,
232
+ ): number | null {
233
+ return computeDurationWeightedMeanPercent(
234
+ _recentSamples,
235
+ Date.now() - windowMs,
236
+ );
237
+ }
238
+
239
+ export function __resetContainerCpuSamplerForTests(): void {
240
+ _lastProcessCpuUsage = sampleProcessCpuUsage();
241
+ _lastCgroupCpuUs = getContainerCpuUsageUs();
242
+ _lastCpuTime = Date.now();
243
+ _cachedCpuPercent = 0;
244
+ _hasCpuSample = false;
245
+ _recentSamples = [];
246
+ }
247
+
248
+ export function __runContainerCpuSamplerTickForTests(nowMs: number): void {
249
+ runCpuSamplerTick(nowMs);
250
+ }
@@ -22,6 +22,7 @@ import {
22
22
  readdirSync,
23
23
  readFileSync,
24
24
  readSync,
25
+ renameSync,
25
26
  statSync,
26
27
  unlinkSync,
27
28
  writeFileSync,
@@ -42,26 +43,151 @@ function getCacheDir(): string {
42
43
  return join(tmpdir(), "vellum-optimized-images");
43
44
  }
44
45
 
46
+ /**
47
+ * Structural completeness check for a JPEG payload: SOI marker (FF D8) at the
48
+ * head and EOI marker (FF D9) at the tail. Every cache entry and every sips
49
+ * output is a JPEG, so a payload failing this is truncated or empty (the
50
+ * signature of a torn cache write) and must never reach a provider, which
51
+ * rejects it with a 400 that wedges the conversation (the corrupt block is
52
+ * resent on every subsequent turn).
53
+ */
54
+ export function isCompleteJpeg(bytes: Uint8Array): boolean {
55
+ return (
56
+ bytes.length >= 4 &&
57
+ bytes[0] === 0xff &&
58
+ bytes[1] === 0xd8 &&
59
+ bytes[bytes.length - 2] === 0xff &&
60
+ bytes[bytes.length - 1] === 0xd9
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Structural JPEG truncation check for payloads of arbitrary origin: walk the
66
+ * marker segments from SOI and report whether a terminal EOI is reached.
67
+ * Unlike {@link isCompleteJpeg} (exact tail framing, for sips output and
68
+ * cache entries, which always end on EOI), this tolerates encoders that
69
+ * append padding or metadata after the EOI.
70
+ *
71
+ * A raw byte search for FF D9 is not enough: length-delimited APP/COM
72
+ * segments (an EXIF thumbnail is itself a complete embedded JPEG) may contain
73
+ * FF D9, so a truncated file with an intact metadata prefix would pass.
74
+ * Walking segment boundaries skips those payloads entirely; only an EOI at
75
+ * the top level of the marker stream counts. Entropy-coded scan data is
76
+ * traversed byte-wise, where FF is stuffed as FF 00 and restart markers
77
+ * (D0-D7) continue the scan, so a marker byte there is unambiguous.
78
+ *
79
+ * The EOI only counts after at least one frame header (SOF) and one scan
80
+ * (SOS) have been seen: a degenerate payload such as bare SOI+EOI carries no
81
+ * image data and providers reject it.
82
+ */
83
+ export function hasValidJpegStructure(bytes: Uint8Array): boolean {
84
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
85
+ return false;
86
+ }
87
+ let sawFrame = false;
88
+ let sawScan = false;
89
+ let i = 2;
90
+ while (i + 1 < bytes.length) {
91
+ if (bytes[i] !== 0xff) {
92
+ return false;
93
+ }
94
+ // FF fill bytes before a marker are legal padding.
95
+ let j = i + 1;
96
+ while (j < bytes.length && bytes[j] === 0xff) {
97
+ j++;
98
+ }
99
+ if (j >= bytes.length) {
100
+ return false;
101
+ }
102
+ const marker = bytes[j];
103
+ i = j + 1;
104
+ if (marker === 0xd9) {
105
+ return sawFrame && sawScan;
106
+ }
107
+ // SOF0-SOF15 occupy C0-CF, excluding DHT (C4), JPG (C8), and DAC (CC).
108
+ if (
109
+ marker >= 0xc0 &&
110
+ marker <= 0xcf &&
111
+ marker !== 0xc4 &&
112
+ marker !== 0xc8 &&
113
+ marker !== 0xcc
114
+ ) {
115
+ sawFrame = true;
116
+ }
117
+ // Standalone markers carry no length field: repeated SOI, TEM, RST0-7.
118
+ if (
119
+ marker === 0xd8 ||
120
+ marker === 0x01 ||
121
+ (marker >= 0xd0 && marker <= 0xd7)
122
+ ) {
123
+ continue;
124
+ }
125
+ if (i + 1 >= bytes.length) {
126
+ return false;
127
+ }
128
+ const segmentLength = (bytes[i] << 8) | bytes[i + 1];
129
+ if (segmentLength < 2) {
130
+ return false;
131
+ }
132
+ i += segmentLength;
133
+ if (marker === 0xda) {
134
+ sawScan = true;
135
+ // SOS: entropy-coded data follows the header. Scan to the next real
136
+ // marker; FF 00 (stuffed data byte) and FF D0-D7 (restart) stay inside
137
+ // the scan.
138
+ while (i + 1 < bytes.length) {
139
+ if (
140
+ bytes[i] === 0xff &&
141
+ bytes[i + 1] !== 0x00 &&
142
+ !(bytes[i + 1] >= 0xd0 && bytes[i + 1] <= 0xd7)
143
+ ) {
144
+ break;
145
+ }
146
+ i++;
147
+ }
148
+ }
149
+ }
150
+ return false;
151
+ }
152
+
45
153
  function readFromCache(key: string): Buffer | null {
154
+ const cachePath = join(getCacheDir(), `${key}.jpg`);
46
155
  try {
47
- const cachePath = join(getCacheDir(), `${key}.jpg`);
48
156
  if (!existsSync(cachePath)) {
49
157
  return null;
50
158
  }
51
- return readFileSync(cachePath) as Buffer;
159
+ const bytes = readFileSync(cachePath) as Buffer;
160
+ if (!isCompleteJpeg(bytes)) {
161
+ // Poisoned entry (torn write, disk full, etc.): drop it so the caller
162
+ // re-converts and re-caches.
163
+ unlinkSync(cachePath);
164
+ return null;
165
+ }
166
+ return bytes;
52
167
  } catch {
53
168
  return null;
54
169
  }
55
170
  }
56
171
 
57
172
  function writeToCache(key: string, convertedBytes: Buffer): void {
173
+ const dir = getCacheDir();
174
+ // Write-then-rename so concurrent readers (the cache dir is shared across
175
+ // daemon processes) never observe a partially written entry; rename within
176
+ // the same directory is atomic. The pid+uuid suffix keeps concurrent
177
+ // writers of the same key off each other's temp file.
178
+ const tmpPath = join(dir, `${key}.${process.pid}-${uuid().slice(0, 8)}.tmp`);
58
179
  try {
59
- const dir = getCacheDir();
60
180
  mkdirSync(dir, { recursive: true });
61
- writeFileSync(join(dir, `${key}.jpg`), convertedBytes);
181
+ writeFileSync(tmpPath, convertedBytes);
182
+ renameSync(tmpPath, join(dir, `${key}.jpg`));
62
183
  evictIfNeeded(dir);
63
184
  } catch {
64
185
  // Cache write failure is non-fatal.
186
+ try {
187
+ unlinkSync(tmpPath);
188
+ } catch {
189
+ /* ignore */
190
+ }
65
191
  }
66
192
  }
67
193
 
@@ -141,7 +267,14 @@ async function runSips(
141
267
  if (proc.exitCode !== 0) {
142
268
  return null;
143
269
  }
144
- return readFileSync(outPath) as Buffer;
270
+ const out = readFileSync(outPath) as Buffer;
271
+ // A zero-exit sips can still leave a truncated file (disk full, timeout
272
+ // kill racing the write). Returning it would poison the cache and every
273
+ // downstream provider call. Treat it as a failed conversion instead.
274
+ if (!isCompleteJpeg(out)) {
275
+ return null;
276
+ }
277
+ return out;
145
278
  } catch {
146
279
  return null;
147
280
  } finally {
@@ -206,14 +339,22 @@ const HEIF_FTYP_BRANDS = new Set([
206
339
  "msf1",
207
340
  ]);
208
341
 
209
- /**
210
- * Content-based HEIF/HEIC detection. MIME metadata is unreliable here:
211
- * Chromium reports an empty `file.type` for `.heic`, which clients coerce to
212
- * `application/octet-stream`.
213
- */
214
- export function isHeifImage(bytes: Uint8Array): boolean {
342
+ // Brands whose canonical MIME is `image/heic`; the remaining HEIF brands
343
+ // (`heif`, `mif1`, `msf1`) are `image/heif`.
344
+ const HEIC_FTYP_BRANDS = new Set([
345
+ "heic",
346
+ "heix",
347
+ "hevc",
348
+ "hevx",
349
+ "heim",
350
+ "heis",
351
+ "hevm",
352
+ "hevs",
353
+ ]);
354
+
355
+ function heifFtypBrand(bytes: Uint8Array): string | null {
215
356
  if (bytes.length < 12) {
216
- return false;
357
+ return null;
217
358
  }
218
359
  // ISO BMFF layout: bytes 4-8 are "ftyp", bytes 8-12 the major brand.
219
360
  if (
@@ -222,7 +363,7 @@ export function isHeifImage(bytes: Uint8Array): boolean {
222
363
  bytes[6] !== 0x79 || // y
223
364
  bytes[7] !== 0x70 // p
224
365
  ) {
225
- return false;
366
+ return null;
226
367
  }
227
368
  const brand = String.fromCharCode(
228
369
  bytes[8]!,
@@ -230,7 +371,30 @@ export function isHeifImage(bytes: Uint8Array): boolean {
230
371
  bytes[10]!,
231
372
  bytes[11]!,
232
373
  );
233
- return HEIF_FTYP_BRANDS.has(brand);
374
+ return HEIF_FTYP_BRANDS.has(brand) ? brand : null;
375
+ }
376
+
377
+ /**
378
+ * Content-based HEIF/HEIC detection. MIME metadata is unreliable here:
379
+ * Chromium reports an empty `file.type` for `.heic`, which clients coerce to
380
+ * `application/octet-stream`.
381
+ */
382
+ export function isHeifImage(bytes: Uint8Array): boolean {
383
+ return heifFtypBrand(bytes) !== null;
384
+ }
385
+
386
+ /**
387
+ * Canonical HEIF MIME type for bytes whose container brand names one, and null
388
+ * for everything else. For providers that read HEIF directly and key on the
389
+ * declared media type (Gemini accepts `image/heic` and `image/heif`:
390
+ * https://ai.google.dev/gemini-api/docs/image-understanding).
391
+ */
392
+ export function heifImageMimeType(bytes: Uint8Array): string | null {
393
+ const brand = heifFtypBrand(bytes);
394
+ if (!brand) {
395
+ return null;
396
+ }
397
+ return HEIC_FTYP_BRANDS.has(brand) ? "image/heic" : "image/heif";
234
398
  }
235
399
 
236
400
  /**
@@ -1,6 +1,6 @@
1
- import { chmodSync, existsSync, mkdirSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
1
+ import { chmodSync, existsSync, mkdirSync, realpathSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { basename, dirname, join, sep } from "node:path";
4
4
 
5
5
  import { SEEDS } from "@vellumai/environments";
6
6
 
@@ -24,13 +24,17 @@ const VELLUM_ROOT = join(homedir(), ".vellum");
24
24
  */
25
25
  export function vellumRoot(): string {
26
26
  const override = getWorkspaceDirOverride();
27
+ let root = VELLUM_ROOT;
27
28
  if (override) {
28
29
  const parent = dirname(override);
29
30
  if (parent !== "/") {
30
- return parent;
31
+ root = parent;
31
32
  }
32
33
  }
33
- return VELLUM_ROOT;
34
+ // Same containment rule as getWorkspaceDir(): root-derived paths (protected
35
+ // dir, .env) must stay ephemeral in test processes too.
36
+ assertTestPathIsEphemeral(root);
37
+ return root;
34
38
  }
35
39
 
36
40
  export function isMacOS(): boolean {
@@ -334,19 +338,98 @@ export function getProcPidPath(name: string): string {
334
338
  return join(getProcDir(name), `${name}.pid`);
335
339
  }
336
340
 
341
+ // --- Live-workspace guard for test processes --------------------------------
342
+ //
343
+ // A test process must never resolve the workspace (or the vellum root) to a
344
+ // real, non-temp directory: production code exercised by a test would then
345
+ // read and destructively write live state. The tmpdir redirection normally
346
+ // comes from the bunfig.toml test preload, but bun only loads bunfig from the
347
+ // cwd, so `bun test` run from any other directory (for example a source
348
+ // checkout inside a deployed container's workspace) skips the preload and
349
+ // inherits the ambient VELLUM_WORKSPACE_DIR. The containment assertion
350
+ // therefore lives here, in production code, where it fires no matter how the
351
+ // test process was launched.
352
+ //
353
+ // Containment logic mirrors src/__tests__/assert-not-live-db.ts, which cannot
354
+ // be imported here (production code must not depend on test machinery).
355
+
356
+ /** Lazily computed: is this process a `bun test` run? */
357
+ let isTestProcess: boolean | undefined;
358
+
359
+ /**
360
+ * Resolve symlinks in the deepest existing ancestor of `p`, then re-append
361
+ * the not-yet-created tail. This keeps the containment check honest both for
362
+ * paths under a symlinked temp root (macOS /var/folders) and for symlinks
363
+ * that point outside it, whether or not the leaf exists yet.
364
+ */
365
+ function canonicalizeForWorkspaceGuard(p: string): string {
366
+ let cur = p;
367
+ const tail: string[] = [];
368
+ for (;;) {
369
+ try {
370
+ return join(realpathSync(cur), ...tail);
371
+ } catch {
372
+ const parent = dirname(cur);
373
+ if (parent === cur) {
374
+ return p;
375
+ }
376
+ tail.unshift(basename(cur));
377
+ cur = parent;
378
+ }
379
+ }
380
+ }
381
+
382
+ function assertTestPathIsEphemeral(dir: string): void {
383
+ isTestProcess ??=
384
+ process.env.NODE_ENV === "test" ||
385
+ process.env.BUN_TEST === "1" ||
386
+ // `bun test` sets NODE_ENV=test only when unset; Bun.main being the test
387
+ // file itself is the backstop signal that survives a preset NODE_ENV.
388
+ (typeof Bun !== "undefined" &&
389
+ /\.(test|spec)\.[cm]?[jt]sx?$/.test(Bun.main));
390
+ if (!isTestProcess) {
391
+ return;
392
+ }
393
+ // Escape hatch for the rare intentional run against a real workspace,
394
+ // shared with assertTestDbIsIsolated() in persistence/db-connection.ts.
395
+ // Deliberately NOT added to tools/terminal/safe-env.ts: a daemon-level
396
+ // opt-out must not propagate into agent-spawned shells and disarm the
397
+ // guard for tests run from there.
398
+ if (process.env.VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS === "1") {
399
+ return;
400
+ }
401
+ const tmpRoot = canonicalizeForWorkspaceGuard(tmpdir());
402
+ const resolved = canonicalizeForWorkspaceGuard(dir);
403
+ if (resolved !== tmpRoot && !resolved.startsWith(tmpRoot + sep)) {
404
+ throw new Error(
405
+ [
406
+ `Refusing to use ${dir} (resolves to ${resolved}) in a test process: it is not under the temp directory (${tmpRoot}).`,
407
+ "",
408
+ "Tests must only touch an ephemeral workspace; a real one would expose",
409
+ "live assistant state to destructive test fixtures. This usually means",
410
+ "`bun test` ran from a cwd without the repo bunfig.toml, so the test",
411
+ "preload that redirects VELLUM_WORKSPACE_DIR to a tmpdir never loaded.",
412
+ "Run tests from the assistant package root, or set",
413
+ "VELLUM_ALLOW_REAL_WORKSPACE_IN_TESTS=1 to bypass deliberately.",
414
+ ].join("\n"),
415
+ );
416
+ }
417
+ }
418
+
337
419
  /**
338
420
  * Returns the workspace root for user-facing state.
339
421
  *
340
422
  * When the VELLUM_WORKSPACE_DIR env var is set, returns that value (used in
341
423
  * containerized deployments where the workspace is a separate volume).
342
424
  * Otherwise falls back to ~/.vellum/workspace.
425
+ *
426
+ * In test processes the resolved directory must live under `os.tmpdir()`
427
+ * (see the live-workspace guard above); anything else throws.
343
428
  */
344
429
  export function getWorkspaceDir(): string {
345
- const override = getWorkspaceDirOverride();
346
- if (override) {
347
- return override;
348
- }
349
- return join(VELLUM_ROOT, "workspace");
430
+ const dir = getWorkspaceDirOverride() ?? join(VELLUM_ROOT, "workspace");
431
+ assertTestPathIsEphemeral(dir);
432
+ return dir;
350
433
  }
351
434
 
352
435
  /**