@vellumai/assistant 0.6.1 → 0.6.3

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 (463) hide show
  1. package/bun.lock +40 -40
  2. package/bunfig.toml +3 -0
  3. package/docker-entrypoint.sh +12 -2
  4. package/docs/architecture/memory.md +1 -1
  5. package/node_modules/@vellumai/ces-contracts/src/handles.ts +7 -9
  6. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +42 -0
  7. package/openapi.yaml +184 -69
  8. package/package.json +41 -41
  9. package/scripts/generate-openapi.ts +1 -2
  10. package/src/__tests__/acp-session.test.ts +43 -0
  11. package/src/__tests__/app-builder-tool-scripts.test.ts +1 -0
  12. package/src/__tests__/app-executors.test.ts +1 -0
  13. package/src/__tests__/app-source-watcher.test.ts +37 -11
  14. package/src/__tests__/approval-routes-http.test.ts +178 -1
  15. package/src/__tests__/assistant-event-hub.test.ts +30 -0
  16. package/src/__tests__/browser-fill-credential.test.ts +229 -94
  17. package/src/__tests__/browser-manager.test.ts +40 -27
  18. package/src/__tests__/catalog-files.test.ts +862 -0
  19. package/src/__tests__/channel-approvals.test.ts +53 -0
  20. package/src/__tests__/checker.test.ts +104 -170
  21. package/src/__tests__/cli-command-risk-guard.test.ts +1 -1
  22. package/src/__tests__/config-managed-gemini-defaults.test.ts +326 -0
  23. package/src/__tests__/config-schema-cmd.test.ts +2 -2
  24. package/src/__tests__/config-schema.test.ts +125 -48
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +23 -0
  26. package/src/__tests__/context-overflow-approval.test.ts +21 -6
  27. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -1
  28. package/src/__tests__/conversation-agent-loop.test.ts +1 -1
  29. package/src/__tests__/conversation-analysis-routes.test.ts +169 -0
  30. package/src/__tests__/conversation-attachments.test.ts +80 -4
  31. package/src/__tests__/conversation-confirmation-signals.test.ts +155 -0
  32. package/src/__tests__/conversation-directories-parse.test.ts +105 -0
  33. package/src/__tests__/conversation-fork-crud.test.ts +17 -0
  34. package/src/__tests__/conversation-history-web-search.test.ts +1 -0
  35. package/src/__tests__/conversation-host-access-routes.test.ts +229 -0
  36. package/src/__tests__/conversation-inject-context.test.ts +103 -0
  37. package/src/__tests__/conversation-queue.test.ts +45 -2
  38. package/src/__tests__/conversation-routes-disk-view.test.ts +5 -0
  39. package/src/__tests__/conversation-routes-guardian-reply.test.ts +16 -0
  40. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  41. package/src/__tests__/conversation-runtime-assembly.test.ts +269 -46
  42. package/src/__tests__/conversation-starter-routes.test.ts +126 -0
  43. package/src/__tests__/conversation-starters-cadence.test.ts +161 -0
  44. package/src/__tests__/conversation-store.test.ts +195 -0
  45. package/src/__tests__/conversation-workspace-cache-state.test.ts +193 -0
  46. package/src/__tests__/credential-execution-approval-bridge.test.ts +32 -3
  47. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  48. package/src/__tests__/credential-vault-unit.test.ts +4 -4
  49. package/src/__tests__/credential-vault.test.ts +152 -13
  50. package/src/__tests__/credentials-cli.test.ts +2 -2
  51. package/src/__tests__/date-context.test.ts +4 -4
  52. package/src/__tests__/embedding-managed-proxy-selection.test.ts +256 -0
  53. package/src/__tests__/extension-id-sync-guard.test.ts +155 -0
  54. package/src/__tests__/fixtures/mock-chrome-extension.ts +375 -0
  55. package/src/__tests__/gateway-only-guard.test.ts +3 -0
  56. package/src/__tests__/gemini-provider.test.ts +2 -2
  57. package/src/__tests__/guardian-routing-invariants.test.ts +70 -2
  58. package/src/__tests__/headless-browser-interactions.test.ts +707 -371
  59. package/src/__tests__/headless-browser-navigate.test.ts +389 -47
  60. package/src/__tests__/headless-browser-read-tools.test.ts +266 -103
  61. package/src/__tests__/headless-browser-snapshot.test.ts +240 -77
  62. package/src/__tests__/host-bash-proxy.test.ts +150 -1
  63. package/src/__tests__/host-browser-e2e-cloud.test.ts +462 -0
  64. package/src/__tests__/host-browser-e2e-self-hosted-capability.test.ts +286 -0
  65. package/src/__tests__/host-browser-e2e-self-hosted.test.ts +374 -0
  66. package/src/__tests__/host-browser-event-routes.test.ts +350 -0
  67. package/src/__tests__/host-browser-proxy.test.ts +444 -0
  68. package/src/__tests__/host-browser-routes.test.ts +198 -0
  69. package/src/__tests__/host-browser-ws-events-e2e.test.ts +320 -0
  70. package/src/__tests__/host-cu-proxy.test.ts +171 -1
  71. package/src/__tests__/host-file-proxy.test.ts +185 -1
  72. package/src/__tests__/host-file-read-tool.test.ts +52 -0
  73. package/src/__tests__/host-proxy-interface.test.ts +165 -0
  74. package/src/__tests__/host-shell-tool.test.ts +1 -11
  75. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  76. package/src/__tests__/init-feature-flag-overrides.test.ts +167 -0
  77. package/src/__tests__/inline-command-runner.test.ts +7 -5
  78. package/src/__tests__/integration-status.test.ts +6 -7
  79. package/src/__tests__/list-messages-tool-merge.test.ts +37 -12
  80. package/src/__tests__/log-export-workspace.test.ts +190 -0
  81. package/src/__tests__/managed-credential-catalog-cli.test.ts +12 -14
  82. package/src/__tests__/mcp-client-auth.test.ts +40 -4
  83. package/src/__tests__/mcp-health-check.test.ts +10 -3
  84. package/src/__tests__/migration-cross-version-compatibility.test.ts +3 -1
  85. package/src/__tests__/migration-export-http.test.ts +61 -2
  86. package/src/__tests__/migration-export-streaming.test.ts +66 -0
  87. package/src/__tests__/migration-import-commit-http.test.ts +101 -1
  88. package/src/__tests__/native-host-marker-sync-guard.test.ts +157 -0
  89. package/src/__tests__/navigate-settings-tab.test.ts +14 -1
  90. package/src/__tests__/notification-broadcaster.test.ts +65 -0
  91. package/src/__tests__/oauth-apps-routes.test.ts +17 -12
  92. package/src/__tests__/oauth-cli.test.ts +707 -60
  93. package/src/__tests__/oauth-connect-orchestrator.test.ts +116 -24
  94. package/src/__tests__/oauth-provider-seed-logos.test.ts +23 -0
  95. package/src/__tests__/oauth-provider-serializer.test.ts +146 -10
  96. package/src/__tests__/oauth-provider-visibility.test.ts +19 -21
  97. package/src/__tests__/oauth-providers-routes.test.ts +50 -14
  98. package/src/__tests__/oauth-store.test.ts +1386 -182
  99. package/src/__tests__/oauth2-gateway-transport.test.ts +211 -20
  100. package/src/__tests__/onboarding-template-contract.test.ts +74 -55
  101. package/src/__tests__/openai-provider.test.ts +2 -2
  102. package/src/__tests__/outlook-categories.test.ts +1 -1
  103. package/src/__tests__/outlook-client-automation.test.ts +1 -1
  104. package/src/__tests__/outlook-compose-tools.test.ts +1 -1
  105. package/src/__tests__/outlook-email-watcher.test.ts +1 -1
  106. package/src/__tests__/outlook-follow-up.test.ts +1 -1
  107. package/src/__tests__/outlook-messaging-provider.test.ts +2 -2
  108. package/src/__tests__/outlook-trash.test.ts +1 -1
  109. package/src/__tests__/outlook-unsubscribe.test.ts +1 -1
  110. package/src/__tests__/permission-checker-host-gate.test.ts +74 -14
  111. package/src/__tests__/permission-mode.test.ts +28 -56
  112. package/src/__tests__/pkb-autoinject.test.ts +96 -0
  113. package/src/__tests__/platform-callback-registration.test.ts +19 -0
  114. package/src/__tests__/post-turn-tool-result-truncation.test.ts +296 -0
  115. package/src/__tests__/proxy-approval-callback.test.ts +18 -0
  116. package/src/__tests__/require-fresh-approval.test.ts +40 -3
  117. package/src/__tests__/sandbox-diagnostics.test.ts +1 -32
  118. package/src/__tests__/sanitize-config-for-transfer.test.ts +132 -0
  119. package/src/__tests__/schedule-routes.test.ts +162 -0
  120. package/src/__tests__/secret-detection-handler.test.ts +84 -0
  121. package/src/__tests__/secret-ingress-http.test.ts +1 -0
  122. package/src/__tests__/send-endpoint-busy.test.ts +3 -0
  123. package/src/__tests__/set-permission-mode.test.ts +13 -250
  124. package/src/__tests__/skills-file-content-endpoint.test.ts +670 -0
  125. package/src/__tests__/skills-files-catalog-fallback.test.ts +450 -0
  126. package/src/__tests__/slack-channel-config.test.ts +12 -15
  127. package/src/__tests__/subagent-detail.test.ts +44 -2
  128. package/src/__tests__/subagent-disposal.test.ts +1 -0
  129. package/src/__tests__/subagent-fork-notifications.test.ts +291 -0
  130. package/src/__tests__/subagent-fork-spawn.test.ts +384 -0
  131. package/src/__tests__/subagent-manager-notify.test.ts +1 -0
  132. package/src/__tests__/subagent-notify-parent.test.ts +1 -0
  133. package/src/__tests__/subagent-spawn-tool-fork.test.ts +411 -0
  134. package/src/__tests__/subagent-tools.test.ts +1 -0
  135. package/src/__tests__/subagent-types.test.ts +1 -0
  136. package/src/__tests__/system-prompt-ask-mode.test.ts +27 -71
  137. package/src/__tests__/system-prompt.test.ts +72 -1
  138. package/src/__tests__/task-scheduler.test.ts +32 -6
  139. package/src/__tests__/telegram-config.test.ts +10 -13
  140. package/src/__tests__/terminal-sandbox.test.ts +1 -1
  141. package/src/__tests__/terminal-tools.test.ts +11 -5
  142. package/src/__tests__/test-preload.ts +14 -0
  143. package/src/__tests__/tool-approval-handler.test.ts +73 -0
  144. package/src/__tests__/tool-domain-event-publisher.test.ts +0 -1
  145. package/src/__tests__/tool-executor-lifecycle-events.test.ts +1 -8
  146. package/src/__tests__/tool-executor.test.ts +0 -1
  147. package/src/__tests__/tool-side-effects-slack-dm.test.ts +22 -0
  148. package/src/__tests__/top-level-renderer.test.ts +73 -1
  149. package/src/__tests__/transport-hints-queue.test.ts +62 -0
  150. package/src/__tests__/trust-store.test.ts +4 -4
  151. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +109 -0
  152. package/src/__tests__/v2-consent-policy.test.ts +103 -0
  153. package/src/__tests__/workspace-migration-030-seed-pkb-autoinject.test.ts +168 -0
  154. package/src/__tests__/workspace-policy.test.ts +2 -7
  155. package/src/acp/client-handler.ts +30 -4
  156. package/src/agent/loop.ts +12 -35
  157. package/src/approvals/guardian-request-resolvers.ts +21 -15
  158. package/src/browser-session/__tests__/manager.test.ts +297 -0
  159. package/src/browser-session/backends/cdp-inspect.ts +30 -0
  160. package/src/browser-session/backends/extension.ts +26 -0
  161. package/src/browser-session/backends/local.ts +24 -0
  162. package/src/browser-session/events.ts +164 -0
  163. package/src/browser-session/index.ts +27 -0
  164. package/src/browser-session/manager.ts +159 -0
  165. package/src/browser-session/types.ts +28 -0
  166. package/src/channels/__tests__/types.test.ts +134 -0
  167. package/src/channels/types.ts +55 -0
  168. package/src/cli/__tests__/run-assistant-command.ts +34 -7
  169. package/src/cli/__tests__/unknown-command.test.ts +33 -0
  170. package/src/cli/commands/browser-relay.ts +339 -409
  171. package/src/cli/commands/credentials.ts +3 -3
  172. package/src/cli/commands/default-action.ts +68 -1
  173. package/src/cli/commands/email.ts +18 -13
  174. package/src/cli/commands/mcp.ts +16 -4
  175. package/src/cli/commands/oauth/__tests__/connect.test.ts +68 -41
  176. package/src/cli/commands/oauth/__tests__/disconnect.test.ts +21 -21
  177. package/src/cli/commands/oauth/__tests__/mode.test.ts +17 -17
  178. package/src/cli/commands/oauth/__tests__/ping.test.ts +16 -16
  179. package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +31 -33
  180. package/src/cli/commands/oauth/__tests__/providers-register.test.ts +329 -0
  181. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +116 -12
  182. package/src/cli/commands/oauth/__tests__/status.test.ts +10 -10
  183. package/src/cli/commands/oauth/__tests__/token.test.ts +7 -7
  184. package/src/cli/commands/oauth/apps.ts +7 -4
  185. package/src/cli/commands/oauth/connect.ts +16 -2
  186. package/src/cli/commands/oauth/disconnect.ts +1 -1
  187. package/src/cli/commands/oauth/providers.ts +200 -36
  188. package/src/cli/commands/oauth/shared.ts +5 -5
  189. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +259 -0
  190. package/src/cli/commands/platform/__tests__/connect.test.ts +1 -1
  191. package/src/cli/commands/platform/__tests__/disconnect.test.ts +1 -1
  192. package/src/cli/commands/platform/__tests__/status.test.ts +1 -1
  193. package/src/cli/commands/platform/index.ts +107 -10
  194. package/src/cli/commands/usage.ts +10 -9
  195. package/src/cli/lib/daemon-credential-client.ts +4 -0
  196. package/src/cli/program.ts +10 -3
  197. package/src/config/assistant-feature-flags.ts +59 -55
  198. package/src/config/bundled-skills/app-builder/SKILL.md +33 -173
  199. package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +105 -0
  200. package/src/config/bundled-skills/app-builder/references/INTERACTION_HOOKS.md +56 -0
  201. package/src/config/bundled-skills/app-builder/references/WIDGETS.md +125 -0
  202. package/src/config/bundled-skills/contacts/SKILL.md +3 -0
  203. package/src/config/bundled-skills/document/SKILL.md +4 -0
  204. package/src/config/bundled-skills/gmail/SKILL.md +12 -7
  205. package/src/config/bundled-skills/gmail/TOOLS.json +1 -1
  206. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +2 -1
  207. package/src/config/bundled-skills/outlook/SKILL.md +7 -0
  208. package/src/config/bundled-skills/settings/TOOLS.json +1 -1
  209. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +8 -3
  210. package/src/config/bundled-skills/subagent/SKILL.md +21 -0
  211. package/src/config/bundled-skills/subagent/TOOLS.json +8 -4
  212. package/src/config/bundled-skills/tasks/SKILL.md +5 -0
  213. package/src/config/env-registry.ts +14 -0
  214. package/src/config/env.ts +21 -0
  215. package/src/config/feature-flag-registry.json +46 -7
  216. package/src/config/loader.ts +56 -1
  217. package/src/config/sanitize-for-transfer.ts +47 -0
  218. package/src/config/schema.ts +46 -5
  219. package/src/config/schemas/host-browser.ts +66 -0
  220. package/src/config/schemas/memory-lifecycle.ts +1 -1
  221. package/src/config/schemas/memory-retrieval.ts +103 -0
  222. package/src/config/schemas/security.ts +0 -6
  223. package/src/config/schemas/services.ts +16 -0
  224. package/src/config/types.ts +0 -1
  225. package/src/context/post-turn-tool-result-truncation.ts +176 -0
  226. package/src/context/window-manager.ts +19 -1
  227. package/src/credential-execution/approval-bridge.ts +49 -16
  228. package/src/credential-execution/managed-catalog.ts +3 -7
  229. package/src/daemon/__tests__/conversation-tool-setup.test.ts +186 -0
  230. package/src/daemon/app-source-watcher.ts +35 -0
  231. package/src/daemon/config-watcher.ts +6 -2
  232. package/src/daemon/context-overflow-approval.ts +5 -1
  233. package/src/daemon/conversation-agent-loop-handlers.ts +17 -2
  234. package/src/daemon/conversation-agent-loop.ts +74 -19
  235. package/src/daemon/conversation-attachments.ts +40 -1
  236. package/src/daemon/conversation-messaging.ts +3 -0
  237. package/src/daemon/conversation-process.ts +66 -3
  238. package/src/daemon/conversation-queue-manager.ts +8 -0
  239. package/src/daemon/conversation-runtime-assembly.ts +159 -20
  240. package/src/daemon/conversation-surfaces.ts +78 -12
  241. package/src/daemon/conversation-tool-setup.ts +74 -11
  242. package/src/daemon/conversation-workspace.ts +12 -0
  243. package/src/daemon/conversation.ts +227 -11
  244. package/src/daemon/date-context.ts +10 -10
  245. package/src/daemon/first-greeting.ts +3 -2
  246. package/src/daemon/handlers/conversations.ts +9 -139
  247. package/src/daemon/handlers/shared.ts +65 -0
  248. package/src/daemon/handlers/skills.ts +232 -37
  249. package/src/daemon/host-bash-proxy.ts +48 -13
  250. package/src/daemon/host-browser-proxy.ts +191 -0
  251. package/src/daemon/host-cu-proxy.ts +36 -11
  252. package/src/daemon/host-file-proxy.ts +57 -9
  253. package/src/daemon/lifecycle.ts +86 -12
  254. package/src/daemon/message-protocol.ts +7 -0
  255. package/src/daemon/message-types/conversations.ts +59 -13
  256. package/src/daemon/message-types/host-browser.ts +100 -0
  257. package/src/daemon/message-types/messages.ts +5 -6
  258. package/src/daemon/message-types/notifications.ts +12 -0
  259. package/src/daemon/message-types/settings.ts +12 -0
  260. package/src/daemon/message-types/skills.ts +10 -0
  261. package/src/daemon/message-types/subagents.ts +2 -0
  262. package/src/daemon/server.ts +112 -35
  263. package/src/daemon/tool-side-effects.ts +6 -0
  264. package/src/daemon/transport-hints.ts +14 -0
  265. package/src/inbound/platform-callback-registration.ts +18 -17
  266. package/src/index.ts +1 -1
  267. package/src/mcp/client.ts +59 -24
  268. package/src/memory/app-store.ts +31 -1
  269. package/src/memory/conversation-crud.ts +38 -10
  270. package/src/memory/conversation-directories.ts +39 -0
  271. package/src/memory/conversation-group-migration.ts +65 -5
  272. package/src/memory/conversation-starters-cadence.ts +76 -0
  273. package/src/memory/conversation-title-service.ts +5 -2
  274. package/src/memory/db-init.ts +12 -0
  275. package/src/memory/embedding-backend.test.ts +75 -0
  276. package/src/memory/embedding-backend.ts +131 -5
  277. package/src/memory/embedding-gemini.test.ts +54 -0
  278. package/src/memory/embedding-gemini.ts +20 -9
  279. package/src/memory/embedding-local.ts +177 -18
  280. package/src/memory/graph/capability-seed.ts +3 -5
  281. package/src/memory/graph/consolidation.ts +10 -23
  282. package/src/memory/graph/extraction-job.ts +15 -0
  283. package/src/memory/graph/retriever.ts +40 -22
  284. package/src/memory/graph/store.test.ts +7 -3
  285. package/src/memory/graph/store.ts +47 -12
  286. package/src/memory/group-crud.ts +25 -9
  287. package/src/memory/llm-usage-store.ts +45 -4
  288. package/src/memory/migrations/213-oauth-providers-scope-separator.ts +13 -0
  289. package/src/memory/migrations/214-oauth-providers-refresh-url.ts +11 -0
  290. package/src/memory/migrations/215-oauth-providers-revoke.ts +14 -0
  291. package/src/memory/migrations/216-oauth-providers-token-auth-method.ts +30 -0
  292. package/src/memory/migrations/217-conversation-host-access.ts +40 -0
  293. package/src/memory/migrations/218-oauth-providers-logo-url.ts +11 -0
  294. package/src/memory/migrations/index.ts +6 -0
  295. package/src/memory/migrations/registry.ts +8 -0
  296. package/src/memory/schema/conversations.ts +1 -0
  297. package/src/memory/schema/oauth.ts +18 -13
  298. package/src/messaging/provider.ts +1 -1
  299. package/src/notifications/broadcaster.ts +6 -0
  300. package/src/notifications/conversation-pairing.ts +12 -4
  301. package/src/notifications/emit-signal.ts +14 -0
  302. package/src/notifications/signal.ts +11 -0
  303. package/src/oauth/AGENTS.md +76 -0
  304. package/src/oauth/__tests__/identity-verifier.test.ts +24 -19
  305. package/src/oauth/__tests__/seed-providers-managed.test.ts +32 -0
  306. package/src/oauth/byo-connection.test.ts +8 -8
  307. package/src/oauth/byo-connection.ts +7 -7
  308. package/src/oauth/connect-orchestrator.ts +23 -21
  309. package/src/oauth/connect-types.ts +3 -3
  310. package/src/oauth/connection-resolver.test.ts +17 -4
  311. package/src/oauth/connection-resolver.ts +16 -16
  312. package/src/oauth/connection.ts +1 -1
  313. package/src/oauth/manual-token-connection.ts +13 -13
  314. package/src/oauth/oauth-store.ts +214 -100
  315. package/src/oauth/platform-connection.test.ts +5 -5
  316. package/src/oauth/platform-connection.ts +4 -4
  317. package/src/oauth/provider-serializer.ts +31 -5
  318. package/src/oauth/revoke.ts +76 -0
  319. package/src/oauth/seed-providers.ts +127 -87
  320. package/src/oauth/token-persistence.ts +1 -1
  321. package/src/permissions/checker.ts +3 -3
  322. package/src/permissions/defaults.ts +7 -8
  323. package/src/permissions/permission-mode.ts +4 -11
  324. package/src/permissions/prompter.ts +13 -3
  325. package/src/permissions/v2-consent-policy.ts +87 -0
  326. package/src/platform/client.ts +1 -1
  327. package/src/prompts/system-prompt.ts +18 -21
  328. package/src/prompts/templates/BOOTSTRAP-REFERENCE.md +3 -65
  329. package/src/prompts/templates/BOOTSTRAP.md +59 -96
  330. package/src/prompts/templates/SOUL.md +11 -11
  331. package/src/providers/anthropic/client.ts +1 -0
  332. package/src/providers/types.ts +1 -1
  333. package/src/runtime/AGENTS.md +23 -0
  334. package/src/runtime/__tests__/browser-extension-pair-routes.test.ts +715 -0
  335. package/src/runtime/__tests__/capability-tokens.test.ts +258 -0
  336. package/src/runtime/__tests__/chrome-extension-registry.test.ts +518 -0
  337. package/src/runtime/assistant-event-hub.ts +24 -2
  338. package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -0
  339. package/src/runtime/auth/__tests__/middleware.test.ts +116 -1
  340. package/src/runtime/auth/__tests__/route-policy.test.ts +8 -0
  341. package/src/runtime/auth/middleware.ts +98 -0
  342. package/src/runtime/auth/route-policy.ts +6 -7
  343. package/src/runtime/auth/token-service.ts +8 -0
  344. package/src/runtime/capability-tokens.ts +414 -0
  345. package/src/runtime/channel-approvals.ts +18 -5
  346. package/src/runtime/chrome-extension-registry.ts +332 -0
  347. package/src/runtime/confirmation-request-guardian-bridge.ts +6 -0
  348. package/src/runtime/guardian-decision-types.ts +7 -0
  349. package/src/runtime/http-server.ts +425 -70
  350. package/src/runtime/migrations/__tests__/rebind-secrets-credentials.test.ts +172 -0
  351. package/src/runtime/migrations/__tests__/vbundle-builder-credentials.test.ts +276 -0
  352. package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +162 -0
  353. package/src/runtime/migrations/migration-transport.ts +6 -0
  354. package/src/runtime/migrations/migration-wizard.ts +22 -2
  355. package/src/runtime/migrations/rebind-secrets-screen.ts +76 -15
  356. package/src/runtime/migrations/vbundle-builder.ts +145 -38
  357. package/src/runtime/migrations/vbundle-import-analyzer.ts +19 -0
  358. package/src/runtime/migrations/vbundle-importer.ts +55 -5
  359. package/src/runtime/pending-interactions.ts +29 -13
  360. package/src/runtime/routes/approval-routes.ts +90 -16
  361. package/src/runtime/routes/browser-cdp-routes.ts +229 -0
  362. package/src/runtime/routes/browser-extension-pair-routes.ts +497 -0
  363. package/src/runtime/routes/conversation-analysis-routes.ts +18 -5
  364. package/src/runtime/routes/conversation-management-routes.ts +108 -0
  365. package/src/runtime/routes/conversation-routes.ts +308 -28
  366. package/src/runtime/routes/conversation-starter-routes.ts +78 -16
  367. package/src/runtime/routes/group-routes.ts +22 -8
  368. package/src/runtime/routes/guardian-action-routes.ts +24 -13
  369. package/src/runtime/routes/host-browser-routes.ts +279 -0
  370. package/src/runtime/routes/host-file-routes.ts +9 -1
  371. package/src/runtime/routes/identity-routes.ts +259 -16
  372. package/src/runtime/routes/log-export/AGENTS.md +104 -0
  373. package/src/runtime/routes/log-export/__tests__/workspace-allowlist-error-contract.test.ts +103 -0
  374. package/src/runtime/routes/log-export/__tests__/workspace-allowlist.test.ts +716 -0
  375. package/src/runtime/routes/log-export/workspace-allowlist.ts +458 -0
  376. package/src/runtime/routes/log-export-routes.ts +60 -25
  377. package/src/runtime/routes/memory-item-routes.ts +1 -7
  378. package/src/runtime/routes/migration-routes.ts +87 -2
  379. package/src/runtime/routes/oauth-apps.ts +15 -17
  380. package/src/runtime/routes/oauth-providers.ts +4 -0
  381. package/src/runtime/routes/schedule-routes.ts +24 -11
  382. package/src/runtime/routes/settings-routes.ts +9 -97
  383. package/src/runtime/routes/skills-routes.ts +52 -2
  384. package/src/runtime/routes/subagents-routes.ts +14 -10
  385. package/src/runtime/routes/usage-routes.ts +8 -7
  386. package/src/runtime/routes/workspace-routes.test.ts +22 -0
  387. package/src/runtime/routes/workspace-routes.ts +8 -1
  388. package/src/runtime/routes/workspace-utils.ts +2 -0
  389. package/src/schedule/scheduler.ts +7 -5
  390. package/src/security/ces-credential-client.ts +20 -0
  391. package/src/security/ces-rpc-credential-backend.ts +17 -0
  392. package/src/security/credential-backend.ts +5 -0
  393. package/src/security/oauth2.ts +42 -25
  394. package/src/security/secure-keys.ts +118 -25
  395. package/src/security/token-manager.ts +23 -10
  396. package/src/skills/catalog-files.ts +492 -0
  397. package/src/skills/inline-command-runner.ts +12 -14
  398. package/src/subagent/manager.ts +131 -26
  399. package/src/subagent/types.ts +19 -0
  400. package/src/tools/apps/executors.ts +11 -2
  401. package/src/tools/browser/__tests__/auth-detector.test.ts +202 -108
  402. package/src/tools/browser/auth-detector.ts +43 -12
  403. package/src/tools/browser/browser-execution.ts +645 -340
  404. package/src/tools/browser/browser-manager.ts +36 -12
  405. package/src/tools/browser/cdp-client/__tests__/accessibility-snapshot.test.ts +318 -0
  406. package/src/tools/browser/cdp-client/__tests__/cdp-dom-helpers.test.ts +1175 -0
  407. package/src/tools/browser/cdp-client/__tests__/cdp-inspect-client.test.ts +870 -0
  408. package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +330 -0
  409. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +377 -0
  410. package/src/tools/browser/cdp-client/__tests__/fixtures/ax-tree-nested-frames.json +64 -0
  411. package/src/tools/browser/cdp-client/__tests__/fixtures/ax-tree-simple.json +69 -0
  412. package/src/tools/browser/cdp-client/__tests__/local-cdp-client.test.ts +310 -0
  413. package/src/tools/browser/cdp-client/__tests__/types.test.ts +96 -0
  414. package/src/tools/browser/cdp-client/accessibility-snapshot.ts +387 -0
  415. package/src/tools/browser/cdp-client/cdp-dom-helpers.ts +695 -0
  416. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/discovery.test.ts +743 -0
  417. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +580 -0
  418. package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +578 -0
  419. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +579 -0
  420. package/src/tools/browser/cdp-client/cdp-inspect-client.ts +635 -0
  421. package/src/tools/browser/cdp-client/errors.ts +34 -0
  422. package/src/tools/browser/cdp-client/extension-cdp-client.ts +125 -0
  423. package/src/tools/browser/cdp-client/factory.ts +204 -0
  424. package/src/tools/browser/cdp-client/index.ts +14 -0
  425. package/src/tools/browser/cdp-client/local-cdp-client.ts +187 -0
  426. package/src/tools/browser/cdp-client/types.ts +52 -0
  427. package/src/tools/filesystem/edit.ts +1 -1
  428. package/src/tools/filesystem/list.ts +1 -1
  429. package/src/tools/filesystem/read.ts +1 -1
  430. package/src/tools/filesystem/write.ts +2 -1
  431. package/src/tools/host-filesystem/edit.ts +1 -1
  432. package/src/tools/host-filesystem/read.ts +12 -15
  433. package/src/tools/host-filesystem/write.ts +1 -1
  434. package/src/tools/host-terminal/host-shell.ts +21 -16
  435. package/src/tools/permission-checker.ts +77 -100
  436. package/src/tools/registry.ts +0 -2
  437. package/src/tools/secret-detection-handler.ts +34 -1
  438. package/src/tools/shared/filesystem/image-read.ts +61 -40
  439. package/src/tools/skills/sandbox-runner.ts +3 -6
  440. package/src/tools/subagent/spawn.ts +47 -3
  441. package/src/tools/subagent/status.ts +2 -0
  442. package/src/tools/system/register.ts +2 -16
  443. package/src/tools/terminal/safe-env.ts +7 -0
  444. package/src/tools/terminal/sandbox-diagnostics.ts +4 -4
  445. package/src/tools/terminal/sandbox.ts +4 -1
  446. package/src/tools/terminal/shell.ts +24 -21
  447. package/src/tools/tool-approval-handler.ts +48 -2
  448. package/src/tools/types.ts +2 -3
  449. package/src/util/platform.ts +14 -19
  450. package/src/watcher/provider-types.ts +1 -1
  451. package/src/workspace/migrations/029-seed-pkb.ts +1 -0
  452. package/src/workspace/migrations/030-seed-pkb-autoinject.ts +73 -0
  453. package/src/workspace/migrations/registry.ts +2 -0
  454. package/src/workspace/top-level-renderer.ts +19 -1
  455. package/src/__tests__/chrome-cdp.test.ts +0 -419
  456. package/src/__tests__/permission-mode-sse.test.ts +0 -418
  457. package/src/__tests__/permission-mode-store.test.ts +0 -277
  458. package/src/browser-extension-relay/protocol.ts +0 -63
  459. package/src/browser-extension-relay/server.ts +0 -203
  460. package/src/config/schemas/sandbox.ts +0 -14
  461. package/src/permissions/permission-mode-store.ts +0 -180
  462. package/src/tools/browser/chrome-cdp.ts +0 -239
  463. package/src/tools/system/set-permission-mode.ts +0 -103
@@ -0,0 +1,716 @@
1
+ /**
2
+ * Tests for the workspace allowlist module used by `POST /v1/export`.
3
+ *
4
+ * Validates that `collectWorkspaceData` honors the time + conversationId
5
+ * filters, enforces the workspace cap, ignores malformed conversation
6
+ * directory names, and never throws.
7
+ *
8
+ * The shared `test-preload.ts` sets `VELLUM_WORKSPACE_DIR` to a per-file
9
+ * temp directory before any test code runs, so `getConversationsDir()`
10
+ * already resolves under our temp workspace. We just seed the
11
+ * `conversations/` subdirectory before each test and tear it down
12
+ * afterwards.
13
+ */
14
+
15
+ import {
16
+ existsSync,
17
+ mkdirSync,
18
+ mkdtempSync,
19
+ readdirSync,
20
+ rmSync,
21
+ symlinkSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
27
+
28
+ import { getConversationsDir } from "../../../../util/platform.js";
29
+ import { collectWorkspaceData } from "../workspace-allowlist.js";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const CONV_DIRS = {
36
+ jan10: "2025-01-10T00-00-00.000Z_conv-jan10",
37
+ jan15: "2025-01-15T00-00-00.000Z_conv-jan15",
38
+ jan20: "2025-01-20T00-00-00.000Z_conv-jan20",
39
+ jan25: "2025-01-25T00-00-00.000Z_conv-jan25",
40
+ invalid: "not-a-valid-name",
41
+ jan15Attachments: "2025-01-15T00-00-00.000Z_conv-jan15-with-attachments",
42
+ } as const;
43
+
44
+ function seedConversations(): void {
45
+ const conversationsDir = getConversationsDir();
46
+ mkdirSync(conversationsDir, { recursive: true });
47
+
48
+ // Four canonical conversation dirs with a meta + messages file each.
49
+ for (const name of [
50
+ CONV_DIRS.jan10,
51
+ CONV_DIRS.jan15,
52
+ CONV_DIRS.jan20,
53
+ CONV_DIRS.jan25,
54
+ ]) {
55
+ const dir = join(conversationsDir, name);
56
+ mkdirSync(dir, { recursive: true });
57
+ writeFileSync(
58
+ join(dir, "meta.json"),
59
+ JSON.stringify({ name }, null, 2),
60
+ "utf-8",
61
+ );
62
+ writeFileSync(
63
+ join(dir, "messages.jsonl"),
64
+ `{"role":"user","content":"hi from ${name}"}\n`,
65
+ "utf-8",
66
+ );
67
+ }
68
+
69
+ // Malformed dir — should be skipped because parseConversationDirName
70
+ // returns null for it.
71
+ const invalidDir = join(conversationsDir, CONV_DIRS.invalid);
72
+ mkdirSync(invalidDir, { recursive: true });
73
+ writeFileSync(join(invalidDir, "junk.txt"), "should not be copied", "utf-8");
74
+
75
+ // A separate canonical conversation dir whose id is *not* an exact match
76
+ // for "conv-jan15" — used to verify that the conversationId filter does
77
+ // exact matching, not substring matching.
78
+ const attachmentsDir = join(conversationsDir, CONV_DIRS.jan15Attachments);
79
+ mkdirSync(join(attachmentsDir, "attachments"), { recursive: true });
80
+ writeFileSync(
81
+ join(attachmentsDir, "meta.json"),
82
+ JSON.stringify({ name: CONV_DIRS.jan15Attachments }, null, 2),
83
+ "utf-8",
84
+ );
85
+ writeFileSync(
86
+ join(attachmentsDir, "attachments", "photo.png"),
87
+ "PNGDATA",
88
+ "utf-8",
89
+ );
90
+ }
91
+
92
+ let staging: string;
93
+
94
+ beforeEach(() => {
95
+ // Fresh staging directory for each test.
96
+ staging = mkdtempSync(join(tmpdir(), "ws-allowlist-staging-"));
97
+ // Reset the workspace's conversations dir between tests.
98
+ const conversationsDir = getConversationsDir();
99
+ rmSync(conversationsDir, { recursive: true, force: true });
100
+ });
101
+
102
+ afterEach(() => {
103
+ try {
104
+ rmSync(staging, { recursive: true, force: true });
105
+ } catch {
106
+ /* best-effort cleanup */
107
+ }
108
+ // Wipe the workspace's conversations dir so test files can't bleed into
109
+ // each other.
110
+ try {
111
+ rmSync(getConversationsDir(), { recursive: true, force: true });
112
+ } catch {
113
+ /* best-effort cleanup */
114
+ }
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Tests
119
+ // ---------------------------------------------------------------------------
120
+
121
+ describe("collectWorkspaceData — conversations entry", () => {
122
+ test("copies all valid conversation dirs when no filters are set", () => {
123
+ seedConversations();
124
+
125
+ const result = collectWorkspaceData({ staging });
126
+
127
+ expect(result.entries).toHaveLength(1);
128
+ const [entry] = result.entries;
129
+ expect(entry.entry).toBe("conversations");
130
+ // Four valid + one extra canonical (jan15-with-attachments) = 5
131
+ expect(entry.itemCount).toBe(5);
132
+ expect(entry.skippedDueToCap).toBe(0);
133
+ expect(entry.bytes).toBeGreaterThan(0);
134
+ expect(result.totalBytes).toBe(entry.bytes);
135
+
136
+ const copied = readdirSync(join(staging, "workspace", "conversations"));
137
+ expect(copied).toContain(CONV_DIRS.jan10);
138
+ expect(copied).toContain(CONV_DIRS.jan15);
139
+ expect(copied).toContain(CONV_DIRS.jan20);
140
+ expect(copied).toContain(CONV_DIRS.jan25);
141
+ expect(copied).toContain(CONV_DIRS.jan15Attachments);
142
+ // Malformed dir is skipped.
143
+ expect(copied).not.toContain(CONV_DIRS.invalid);
144
+ });
145
+
146
+ test("startTime filter excludes earlier conversations", () => {
147
+ seedConversations();
148
+ const startTime = Date.parse("2025-01-14T00:00:00Z");
149
+
150
+ const result = collectWorkspaceData({ staging, startTime });
151
+
152
+ const copied = readdirSync(join(staging, "workspace", "conversations"));
153
+ expect(copied).not.toContain(CONV_DIRS.jan10);
154
+ expect(copied).toContain(CONV_DIRS.jan15);
155
+ expect(copied).toContain(CONV_DIRS.jan20);
156
+ expect(copied).toContain(CONV_DIRS.jan25);
157
+ // jan15-with-attachments has the same timestamp as jan15 → still included.
158
+ expect(copied).toContain(CONV_DIRS.jan15Attachments);
159
+ expect(result.entries[0].itemCount).toBe(4);
160
+ });
161
+
162
+ test("endTime filter excludes later conversations", () => {
163
+ seedConversations();
164
+ const endTime = Date.parse("2025-01-22T00:00:00Z");
165
+
166
+ const result = collectWorkspaceData({ staging, endTime });
167
+
168
+ const copied = readdirSync(join(staging, "workspace", "conversations"));
169
+ expect(copied).toContain(CONV_DIRS.jan10);
170
+ expect(copied).toContain(CONV_DIRS.jan15);
171
+ expect(copied).toContain(CONV_DIRS.jan20);
172
+ expect(copied).not.toContain(CONV_DIRS.jan25);
173
+ expect(copied).toContain(CONV_DIRS.jan15Attachments);
174
+ expect(result.entries[0].itemCount).toBe(4);
175
+ });
176
+
177
+ test("startTime + endTime keeps only conversations inside the window", () => {
178
+ seedConversations();
179
+ const startTime = Date.parse("2025-01-14T00:00:00Z");
180
+ const endTime = Date.parse("2025-01-22T00:00:00Z");
181
+
182
+ const result = collectWorkspaceData({ staging, startTime, endTime });
183
+
184
+ const copied = readdirSync(join(staging, "workspace", "conversations"));
185
+ expect(copied).not.toContain(CONV_DIRS.jan10);
186
+ expect(copied).toContain(CONV_DIRS.jan15);
187
+ expect(copied).toContain(CONV_DIRS.jan20);
188
+ expect(copied).not.toContain(CONV_DIRS.jan25);
189
+ // jan15-with-attachments shares the Jan 15 timestamp → still included.
190
+ expect(copied).toContain(CONV_DIRS.jan15Attachments);
191
+ expect(result.entries[0].itemCount).toBe(3);
192
+ });
193
+
194
+ test("conversationId filter matches exactly (no substrings)", () => {
195
+ seedConversations();
196
+
197
+ const result = collectWorkspaceData({
198
+ staging,
199
+ conversationId: "conv-jan15",
200
+ });
201
+
202
+ const copied = readdirSync(join(staging, "workspace", "conversations"));
203
+ expect(copied).toEqual([CONV_DIRS.jan15]);
204
+ // Crucially, the substring-match attachments dir is NOT included.
205
+ expect(copied).not.toContain(CONV_DIRS.jan15Attachments);
206
+ expect(result.entries[0].itemCount).toBe(1);
207
+ });
208
+
209
+ test("conversationId + time filter intersection can be empty", () => {
210
+ seedConversations();
211
+
212
+ const result = collectWorkspaceData({
213
+ staging,
214
+ conversationId: "conv-jan15",
215
+ // Window that excludes Jan 15.
216
+ startTime: Date.parse("2025-01-16T00:00:00Z"),
217
+ endTime: Date.parse("2025-01-22T00:00:00Z"),
218
+ });
219
+
220
+ expect(result.entries).toHaveLength(1);
221
+ expect(result.entries[0].itemCount).toBe(0);
222
+ expect(result.entries[0].bytes).toBe(0);
223
+ expect(result.totalBytes).toBe(0);
224
+ // No directory should have been created because nothing was copied.
225
+ expect(existsSync(join(staging, "workspace", "conversations"))).toBe(false);
226
+ });
227
+
228
+ test("includes conversation when createdAt is outside window but a message ts is inside", () => {
229
+ // Conversation was created on Jan 10 but received a message on
230
+ // Jan 18. With a [Jan 14, Jan 22] window, the directory-name parse
231
+ // says "out of window" but the message scan should keep it.
232
+ const conversationsDir = getConversationsDir();
233
+ mkdirSync(conversationsDir, { recursive: true });
234
+ const dir = join(conversationsDir, CONV_DIRS.jan10);
235
+ mkdirSync(dir, { recursive: true });
236
+ writeFileSync(
237
+ join(dir, "meta.json"),
238
+ JSON.stringify({ name: CONV_DIRS.jan10 }, null, 2),
239
+ "utf-8",
240
+ );
241
+ writeFileSync(
242
+ join(dir, "messages.jsonl"),
243
+ [
244
+ '{"role":"user","ts":"2025-01-10T00:00:00.000Z","content":"created"}',
245
+ '{"role":"user","ts":"2025-01-18T12:00:00.000Z","content":"in window"}',
246
+ "",
247
+ ].join("\n"),
248
+ "utf-8",
249
+ );
250
+
251
+ const result = collectWorkspaceData({
252
+ staging,
253
+ startTime: Date.parse("2025-01-14T00:00:00Z"),
254
+ endTime: Date.parse("2025-01-22T00:00:00Z"),
255
+ });
256
+
257
+ expect(result.entries[0].itemCount).toBe(1);
258
+ const copied = readdirSync(join(staging, "workspace", "conversations"));
259
+ expect(copied).toEqual([CONV_DIRS.jan10]);
260
+ });
261
+
262
+ test("excludes conversation when createdAt and every message ts are outside the window", () => {
263
+ // Conversation created on Jan 10 with messages only before/after the
264
+ // [Jan 14, Jan 22] window. Both filters miss → directory must be skipped.
265
+ const conversationsDir = getConversationsDir();
266
+ mkdirSync(conversationsDir, { recursive: true });
267
+ const dir = join(conversationsDir, CONV_DIRS.jan10);
268
+ mkdirSync(dir, { recursive: true });
269
+ writeFileSync(
270
+ join(dir, "meta.json"),
271
+ JSON.stringify({ name: CONV_DIRS.jan10 }, null, 2),
272
+ "utf-8",
273
+ );
274
+ writeFileSync(
275
+ join(dir, "messages.jsonl"),
276
+ [
277
+ '{"role":"user","ts":"2025-01-10T00:00:00.000Z","content":"too early"}',
278
+ '{"role":"user","ts":"2025-01-12T00:00:00.000Z","content":"still early"}',
279
+ '{"role":"user","ts":"2025-01-25T00:00:00.000Z","content":"too late"}',
280
+ "",
281
+ ].join("\n"),
282
+ "utf-8",
283
+ );
284
+
285
+ const result = collectWorkspaceData({
286
+ staging,
287
+ startTime: Date.parse("2025-01-14T00:00:00Z"),
288
+ endTime: Date.parse("2025-01-22T00:00:00Z"),
289
+ });
290
+
291
+ expect(result.entries[0].itemCount).toBe(0);
292
+ expect(existsSync(join(staging, "workspace", "conversations"))).toBe(false);
293
+ });
294
+
295
+ test("includes conversation when createdAt is in window even if messages.jsonl is missing", () => {
296
+ // Conversation created on Jan 15 with no messages.jsonl yet (e.g.
297
+ // brand-new conversation). The cheap createdAt check is enough; we
298
+ // should never even open messages.jsonl.
299
+ const conversationsDir = getConversationsDir();
300
+ mkdirSync(conversationsDir, { recursive: true });
301
+ const dir = join(conversationsDir, CONV_DIRS.jan15);
302
+ mkdirSync(dir, { recursive: true });
303
+ writeFileSync(
304
+ join(dir, "meta.json"),
305
+ JSON.stringify({ name: CONV_DIRS.jan15 }, null, 2),
306
+ "utf-8",
307
+ );
308
+
309
+ const result = collectWorkspaceData({
310
+ staging,
311
+ startTime: Date.parse("2025-01-14T00:00:00Z"),
312
+ endTime: Date.parse("2025-01-22T00:00:00Z"),
313
+ });
314
+
315
+ expect(result.entries[0].itemCount).toBe(1);
316
+ const copied = readdirSync(join(staging, "workspace", "conversations"));
317
+ expect(copied).toEqual([CONV_DIRS.jan15]);
318
+ });
319
+
320
+ test("excludes conversation when createdAt is out of window and messages.jsonl is missing", () => {
321
+ // Conversation created on Jan 10 (out of window) with no
322
+ // messages.jsonl at all. Both checks miss → skip.
323
+ const conversationsDir = getConversationsDir();
324
+ mkdirSync(conversationsDir, { recursive: true });
325
+ const dir = join(conversationsDir, CONV_DIRS.jan10);
326
+ mkdirSync(dir, { recursive: true });
327
+ writeFileSync(
328
+ join(dir, "meta.json"),
329
+ JSON.stringify({ name: CONV_DIRS.jan10 }, null, 2),
330
+ "utf-8",
331
+ );
332
+
333
+ const result = collectWorkspaceData({
334
+ staging,
335
+ startTime: Date.parse("2025-01-14T00:00:00Z"),
336
+ endTime: Date.parse("2025-01-22T00:00:00Z"),
337
+ });
338
+
339
+ expect(result.entries[0].itemCount).toBe(0);
340
+ expect(existsSync(join(staging, "workspace", "conversations"))).toBe(false);
341
+ });
342
+
343
+ test("canonical-named symlinks are rejected before the message-window scan runs", () => {
344
+ // Symlink creation requires elevated permissions on Windows; skip
345
+ // there to avoid spurious failures in CI on Windows hosts.
346
+ if (process.platform === "win32") return;
347
+
348
+ // Create an external directory containing a messages.jsonl whose
349
+ // single message timestamp falls inside the requested window. If
350
+ // the message-window scan ever follows the symlink, it would
351
+ // mistakenly include the symlink because the in-window message
352
+ // would "match". The boundary guard must reject the symlink first
353
+ // so the scan never reads outside `conversations/`.
354
+ const externalTarget = mkdtempSync(
355
+ join(tmpdir(), "ws-allowlist-symlink-msg-"),
356
+ );
357
+ try {
358
+ writeFileSync(
359
+ join(externalTarget, "meta.json"),
360
+ JSON.stringify({ name: "evil" }, null, 2),
361
+ "utf-8",
362
+ );
363
+ writeFileSync(
364
+ join(externalTarget, "messages.jsonl"),
365
+ '{"role":"user","ts":"2025-01-18T12:00:00.000Z","content":"in window"}\n',
366
+ "utf-8",
367
+ );
368
+
369
+ const conversationsDir = getConversationsDir();
370
+ mkdirSync(conversationsDir, { recursive: true });
371
+
372
+ // Canonical name with createdAt OUTSIDE the window so the cheap
373
+ // check fails and the message-window fallback would normally fire.
374
+ const evilName = "2025-01-10T00-00-00.000Z_evil-target";
375
+ symlinkSync(externalTarget, join(conversationsDir, evilName), "dir");
376
+
377
+ const result = collectWorkspaceData({
378
+ staging,
379
+ startTime: Date.parse("2025-01-14T00:00:00Z"),
380
+ endTime: Date.parse("2025-01-22T00:00:00Z"),
381
+ });
382
+
383
+ // The boundary guard must reject the symlink before the message
384
+ // scan ever opens the external messages.jsonl. Nothing must land
385
+ // in the staging directory.
386
+ expect(result.entries).toHaveLength(1);
387
+ const [entry] = result.entries;
388
+ expect(entry.itemCount).toBe(0);
389
+ expect(entry.bytes).toBe(0);
390
+ expect(entry.skippedDueToCap).toBe(0);
391
+ expect(existsSync(join(staging, "workspace", "conversations"))).toBe(
392
+ false,
393
+ );
394
+ } finally {
395
+ rmSync(externalTarget, { recursive: true, force: true });
396
+ }
397
+ });
398
+
399
+ test("streaming scan finds an in-window message in a large messages.jsonl", () => {
400
+ // Build a messages.jsonl that's large enough to span multiple
401
+ // 64 KB read chunks. Padding messages have an out-of-window ts; a
402
+ // single in-window message is buried near the end so the scan must
403
+ // actually traverse most of the file to find it. This exercises
404
+ // the streaming + UTF-8 boundary handling without ever loading
405
+ // the whole file into a single string.
406
+ const conversationsDir = getConversationsDir();
407
+ mkdirSync(conversationsDir, { recursive: true });
408
+ const dir = join(conversationsDir, CONV_DIRS.jan10);
409
+ mkdirSync(dir, { recursive: true });
410
+ writeFileSync(
411
+ join(dir, "meta.json"),
412
+ JSON.stringify({ name: CONV_DIRS.jan10 }, null, 2),
413
+ "utf-8",
414
+ );
415
+
416
+ const padLine = `{"role":"user","ts":"2025-01-10T00:00:00.000Z","content":"${"x".repeat(500)}"}`;
417
+ const matchLine =
418
+ '{"role":"user","ts":"2025-01-18T12:00:00.000Z","content":"hit"}';
419
+ // ~500 padding lines + 1 match line ≈ 250 KB, well over a single
420
+ // 64 KB chunk.
421
+ const lines: string[] = [];
422
+ for (let i = 0; i < 500; i++) lines.push(padLine);
423
+ lines.push(matchLine);
424
+ writeFileSync(
425
+ join(dir, "messages.jsonl"),
426
+ lines.join("\n") + "\n",
427
+ "utf-8",
428
+ );
429
+
430
+ const result = collectWorkspaceData({
431
+ staging,
432
+ startTime: Date.parse("2025-01-14T00:00:00Z"),
433
+ endTime: Date.parse("2025-01-22T00:00:00Z"),
434
+ });
435
+
436
+ expect(result.entries[0].itemCount).toBe(1);
437
+ const copied = readdirSync(join(staging, "workspace", "conversations"));
438
+ expect(copied).toEqual([CONV_DIRS.jan10]);
439
+ });
440
+
441
+ test("malformed messages.jsonl lines are silently skipped during the window scan", () => {
442
+ // Conversation created on Jan 10 (out of window). messages.jsonl
443
+ // has garbage on most lines but ONE valid line whose ts is in
444
+ // window — that single valid line should be enough to keep the dir.
445
+ const conversationsDir = getConversationsDir();
446
+ mkdirSync(conversationsDir, { recursive: true });
447
+ const dir = join(conversationsDir, CONV_DIRS.jan10);
448
+ mkdirSync(dir, { recursive: true });
449
+ writeFileSync(
450
+ join(dir, "meta.json"),
451
+ JSON.stringify({ name: CONV_DIRS.jan10 }, null, 2),
452
+ "utf-8",
453
+ );
454
+ writeFileSync(
455
+ join(dir, "messages.jsonl"),
456
+ [
457
+ "not json at all",
458
+ '{"role":"user"}', // missing ts
459
+ '{"role":"user","ts":"not-a-date"}', // ts isn't parseable
460
+ '{"role":"user","ts":42}', // ts is wrong type
461
+ '{"role":"user","ts":"2025-01-18T12:00:00.000Z","content":"valid"}',
462
+ "",
463
+ ].join("\n"),
464
+ "utf-8",
465
+ );
466
+
467
+ const result = collectWorkspaceData({
468
+ staging,
469
+ startTime: Date.parse("2025-01-14T00:00:00Z"),
470
+ endTime: Date.parse("2025-01-22T00:00:00Z"),
471
+ });
472
+
473
+ expect(result.entries[0].itemCount).toBe(1);
474
+ const copied = readdirSync(join(staging, "workspace", "conversations"));
475
+ expect(copied).toEqual([CONV_DIRS.jan10]);
476
+ });
477
+
478
+ test("byte cap enforcement skips every conversation when too tight", () => {
479
+ seedConversations();
480
+
481
+ // 1 byte cap is impossible to fit any seeded dir into.
482
+ const result = collectWorkspaceData({ staging, maxBytes: 1 });
483
+
484
+ expect(result.entries).toHaveLength(1);
485
+ const [entry] = result.entries;
486
+ expect(entry.itemCount).toBe(0);
487
+ expect(entry.bytes).toBe(0);
488
+ expect(entry.skippedDueToCap).toBe(5);
489
+ expect(result.totalBytes).toBe(0);
490
+ expect(existsSync(join(staging, "workspace", "conversations"))).toBe(false);
491
+ });
492
+
493
+ test("byte cap keeps the newest conversations first", () => {
494
+ // Seed three dirs with distinct, non-trivial sizes and known
495
+ // timestamps. Use a padding file per dir so the per-dir byte total
496
+ // is predictable and large enough to push us past the cap after a
497
+ // couple entries have been copied.
498
+ const conversationsDir = getConversationsDir();
499
+ mkdirSync(conversationsDir, { recursive: true });
500
+
501
+ const PADDING_BYTES = 4000;
502
+ const padding = "a".repeat(PADDING_BYTES);
503
+ for (const name of [CONV_DIRS.jan10, CONV_DIRS.jan15, CONV_DIRS.jan20]) {
504
+ const dir = join(conversationsDir, name);
505
+ mkdirSync(dir, { recursive: true });
506
+ writeFileSync(join(dir, "pad.txt"), padding, "utf-8");
507
+ }
508
+
509
+ // Each dir weighs ~PADDING_BYTES. A 10 KB cap fits exactly 2 dirs
510
+ // (but not the third).
511
+ const result = collectWorkspaceData({
512
+ staging,
513
+ maxBytes: PADDING_BYTES * 2 + 500,
514
+ });
515
+
516
+ expect(result.entries).toHaveLength(1);
517
+ const [entry] = result.entries;
518
+ expect(entry.itemCount).toBe(2);
519
+ expect(entry.skippedDueToCap).toBe(1);
520
+
521
+ // The two newest dirs (jan20 and jan15) should have been copied;
522
+ // jan10 (oldest) should be the one skipped due to the cap.
523
+ const copied = readdirSync(join(staging, "workspace", "conversations"));
524
+ expect(copied).toContain(CONV_DIRS.jan20);
525
+ expect(copied).toContain(CONV_DIRS.jan15);
526
+ expect(copied).not.toContain(CONV_DIRS.jan10);
527
+ });
528
+
529
+ test("non-directory entries with canonical-looking names are skipped", () => {
530
+ // Make sure the conversations dir exists and seed one valid dir so
531
+ // we can confirm the function still copies legit entries alongside
532
+ // the bogus regular file.
533
+ const conversationsDir = getConversationsDir();
534
+ mkdirSync(conversationsDir, { recursive: true });
535
+
536
+ // Seed a real canonical conversation dir so there's something to copy.
537
+ const validDir = join(conversationsDir, CONV_DIRS.jan20);
538
+ mkdirSync(validDir, { recursive: true });
539
+ writeFileSync(
540
+ join(validDir, "meta.json"),
541
+ JSON.stringify({ name: CONV_DIRS.jan20 }, null, 2),
542
+ "utf-8",
543
+ );
544
+
545
+ // Seed a REGULAR FILE whose name matches the canonical
546
+ // `<ISO>_<conversationId>` pattern. Fill it with data that is big
547
+ // enough to exceed the cap, to prove that the non-dir guard bails
548
+ // before `dirSizeWithinBudget`/`cpSync` could silently copy it.
549
+ const bogusName = "2025-01-15T00-00-00.000Z_conv-jan15-as-file";
550
+ const bogusPath = join(conversationsDir, bogusName);
551
+ writeFileSync(bogusPath, "x".repeat(1024 * 1024), "utf-8"); // 1 MB
552
+
553
+ // Use a tight cap (larger than the valid dir but smaller than the
554
+ // bogus file) to prove the bogus file is skipped before copying.
555
+ const result = collectWorkspaceData({
556
+ staging,
557
+ maxBytes: 100 * 1024, // 100 KB
558
+ });
559
+
560
+ expect(result.entries).toHaveLength(1);
561
+ const [entry] = result.entries;
562
+ // Only the real conversation dir should have been copied.
563
+ expect(entry.itemCount).toBe(1);
564
+ // skippedDueToCap should NOT include the bogus file — it's rejected
565
+ // by the non-dir guard, not by the cap.
566
+ expect(entry.skippedDueToCap).toBe(0);
567
+
568
+ const copied = readdirSync(join(staging, "workspace", "conversations"));
569
+ expect(copied).toEqual([CONV_DIRS.jan20]);
570
+ expect(copied).not.toContain(bogusName);
571
+ });
572
+
573
+ test("missing conversations dir returns an empty entry summary", () => {
574
+ // Do NOT seed — workspace has no conversations/ subdir.
575
+ const conversationsDir = getConversationsDir();
576
+ rmSync(conversationsDir, { recursive: true, force: true });
577
+ expect(existsSync(conversationsDir)).toBe(false);
578
+
579
+ const result = collectWorkspaceData({ staging });
580
+
581
+ expect(result.entries).toHaveLength(1);
582
+ expect(result.entries[0]).toEqual({
583
+ entry: "conversations",
584
+ itemCount: 0,
585
+ bytes: 0,
586
+ skippedDueToCap: 0,
587
+ });
588
+ expect(result.totalBytes).toBe(0);
589
+ expect(existsSync(join(staging, "workspace"))).toBe(false);
590
+ });
591
+
592
+ test("recursive copy preserves nested attachments", () => {
593
+ seedConversations();
594
+
595
+ collectWorkspaceData({
596
+ staging,
597
+ conversationId: "conv-jan15-with-attachments",
598
+ });
599
+
600
+ const copied = readdirSync(join(staging, "workspace", "conversations"));
601
+ expect(copied).toEqual([CONV_DIRS.jan15Attachments]);
602
+ const photoPath = join(
603
+ staging,
604
+ "workspace",
605
+ "conversations",
606
+ CONV_DIRS.jan15Attachments,
607
+ "attachments",
608
+ "photo.png",
609
+ );
610
+ expect(existsSync(photoPath)).toBe(true);
611
+ });
612
+
613
+ test("skips symlinked directories to avoid infinite loops", () => {
614
+ // Symlink creation requires elevated permissions on Windows; skip
615
+ // there to avoid spurious failures in CI on Windows hosts.
616
+ if (process.platform === "win32") return;
617
+
618
+ // Seed a single canonical conversation directory and stick a
619
+ // symlink loop inside it (`loop -> .`). `dirSizeWithinBudget` uses
620
+ // `lstatSync` so that symlinks are skipped rather than dereferenced;
621
+ // without this, following the symlink would recurse infinitely and
622
+ // hang the export. We expect the function to return promptly and
623
+ // still process the conversation directory.
624
+ const conversationsDir = getConversationsDir();
625
+ mkdirSync(conversationsDir, { recursive: true });
626
+
627
+ const convDir = join(conversationsDir, CONV_DIRS.jan20);
628
+ mkdirSync(convDir, { recursive: true });
629
+ writeFileSync(
630
+ join(convDir, "meta.json"),
631
+ JSON.stringify({ name: CONV_DIRS.jan20 }, null, 2),
632
+ "utf-8",
633
+ );
634
+
635
+ // Create the loop: <conv-dir>/loop -> .
636
+ symlinkSync(".", join(convDir, "loop"), "dir");
637
+
638
+ const startMs = Date.now();
639
+ const result = collectWorkspaceData({ staging });
640
+ const elapsedMs = Date.now() - startMs;
641
+
642
+ // Sanity check: the call must complete quickly. The `lstatSync`
643
+ // guard is what keeps this from hanging — if the recursive walker
644
+ // were to dereference the symlink, the bun test runner would time
645
+ // out long before this assertion ever fired.
646
+ expect(elapsedMs).toBeLessThan(5000);
647
+
648
+ expect(result.entries).toHaveLength(1);
649
+ const [entry] = result.entries;
650
+ expect(entry.entry).toBe("conversations");
651
+ // The conversation directory should still be processed and copied;
652
+ // we don't care whether the symlink itself was reproduced in the
653
+ // copy — the key invariant is that the function completed.
654
+ expect(entry.itemCount).toBe(1);
655
+ expect(entry.skippedDueToCap).toBe(0);
656
+
657
+ const copied = readdirSync(join(staging, "workspace", "conversations"));
658
+ expect(copied).toContain(CONV_DIRS.jan20);
659
+ });
660
+
661
+ test("rejects top-level symlinks pointing outside the conversations dir", () => {
662
+ // Symlink creation requires elevated permissions on Windows; skip
663
+ // there to avoid spurious failures in CI on Windows hosts.
664
+ if (process.platform === "win32") return;
665
+
666
+ // Create a directory OUTSIDE `conversations/` that masquerades as a
667
+ // valid conversation dir (with a `meta.json`). The allowlist guard
668
+ // must not allow a symlink with a canonical name to escape the
669
+ // `conversations/` boundary by dereferencing into this external
670
+ // target.
671
+ const externalTarget = mkdtempSync(
672
+ join(tmpdir(), "ws-allowlist-external-"),
673
+ );
674
+ try {
675
+ writeFileSync(
676
+ join(externalTarget, "meta.json"),
677
+ JSON.stringify({ name: "evil" }, null, 2),
678
+ "utf-8",
679
+ );
680
+ writeFileSync(
681
+ join(externalTarget, "secret.txt"),
682
+ "should never be copied",
683
+ "utf-8",
684
+ );
685
+
686
+ // Seed the conversations dir and add a symlink with a canonical
687
+ // name pointing at the external target.
688
+ const conversationsDir = getConversationsDir();
689
+ mkdirSync(conversationsDir, { recursive: true });
690
+
691
+ const evilName = "2025-01-30T00-00-00.000Z_evil-target";
692
+ symlinkSync(externalTarget, join(conversationsDir, evilName), "dir");
693
+
694
+ const result = collectWorkspaceData({ staging });
695
+
696
+ // The symlink must be skipped by the top-level `lstatSync` guard.
697
+ // Nothing from the external target should land in the staging
698
+ // directory and the entry summary should not count it.
699
+ expect(result.entries).toHaveLength(1);
700
+ const [entry] = result.entries;
701
+ expect(entry.entry).toBe("conversations");
702
+ expect(entry.itemCount).toBe(0);
703
+ expect(entry.skippedDueToCap).toBe(0);
704
+ expect(entry.bytes).toBe(0);
705
+ expect(result.totalBytes).toBe(0);
706
+
707
+ // No staging directory should have been created because nothing
708
+ // qualified for copying.
709
+ expect(existsSync(join(staging, "workspace", "conversations"))).toBe(
710
+ false,
711
+ );
712
+ } finally {
713
+ rmSync(externalTarget, { recursive: true, force: true });
714
+ }
715
+ });
716
+ });