@vellumai/assistant 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (338) hide show
  1. package/ARCHITECTURE.md +54 -54
  2. package/docs/architecture/integrations.md +62 -67
  3. package/docs/credential-execution-service.md +3 -3
  4. package/package.json +1 -1
  5. package/src/__tests__/agent-loop.test.ts +111 -0
  6. package/src/__tests__/always-loaded-tools-guard.test.ts +3 -4
  7. package/src/__tests__/app-builder-tool-scripts.test.ts +13 -151
  8. package/src/__tests__/app-dir-path-guard.test.ts +78 -0
  9. package/src/__tests__/app-executors.test.ts +1 -291
  10. package/src/__tests__/app-git-history.test.ts +4 -4
  11. package/src/__tests__/app-routes-csp.test.ts +1 -0
  12. package/src/__tests__/app-store-dir-names.test.ts +426 -0
  13. package/src/__tests__/attachments-store.test.ts +169 -21
  14. package/src/__tests__/attachments.test.ts +115 -1
  15. package/src/__tests__/btw-routes.test.ts +1 -0
  16. package/src/__tests__/canonical-guardian-store.test.ts +38 -0
  17. package/src/__tests__/channel-reply-delivery.test.ts +55 -0
  18. package/src/__tests__/checker.test.ts +54 -0
  19. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  20. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  21. package/src/__tests__/compaction.benchmark.test.ts +2 -1
  22. package/src/__tests__/config-schema-cmd.test.ts +68 -21
  23. package/src/__tests__/config-schema.test.ts +1 -1
  24. package/src/__tests__/conversation-agent-loop-overflow.test.ts +149 -5
  25. package/src/__tests__/conversation-agent-loop.test.ts +290 -2
  26. package/src/__tests__/conversation-attachments.test.ts +17 -19
  27. package/src/__tests__/conversation-disk-view-integration.test.ts +277 -0
  28. package/src/__tests__/conversation-disk-view.test.ts +810 -0
  29. package/src/__tests__/conversation-error.test.ts +1 -1
  30. package/src/__tests__/conversation-fork-crud.test.ts +551 -0
  31. package/src/__tests__/conversation-fork-route.test.ts +386 -0
  32. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  33. package/src/__tests__/conversation-key-store-disk-view.test.ts +130 -0
  34. package/src/__tests__/conversation-media-retry.test.ts +8 -2
  35. package/src/__tests__/conversation-queue.test.ts +36 -1
  36. package/src/__tests__/conversation-routes-disk-view.test.ts +439 -0
  37. package/src/__tests__/conversation-routes-guardian-reply.test.ts +2 -2
  38. package/src/__tests__/conversation-routes-slash-commands.test.ts +2 -7
  39. package/src/__tests__/conversation-runtime-assembly.test.ts +17 -2
  40. package/src/__tests__/conversation-skill-tools.test.ts +4 -9
  41. package/src/__tests__/conversation-slash-commands.test.ts +149 -0
  42. package/src/__tests__/conversation-store.test.ts +24 -21
  43. package/src/__tests__/conversation-surfaces-state-update.test.ts +246 -0
  44. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -0
  45. package/src/__tests__/conversation-title-service.test.ts +137 -0
  46. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +25 -315
  47. package/src/__tests__/conversation-tool-setup-memory-scope.test.ts +1 -0
  48. package/src/__tests__/conversation-tool-setup-side-effect-flag.test.ts +1 -0
  49. package/src/__tests__/conversation-workspace-cache-state.test.ts +44 -2
  50. package/src/__tests__/conversation-workspace-injection.test.ts +11 -0
  51. package/src/__tests__/credential-security-invariants.test.ts +3 -0
  52. package/src/__tests__/credential-vault-unit.test.ts +5 -10
  53. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  54. package/src/__tests__/db-conversation-fork-lineage-migration.test.ts +241 -0
  55. package/src/__tests__/db-llm-request-log-provider-migration.test.ts +214 -0
  56. package/src/__tests__/diagnostics-export.test.ts +70 -1
  57. package/src/__tests__/first-greeting.test.ts +80 -0
  58. package/src/__tests__/gateway-only-guard.test.ts +1 -0
  59. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +3 -7
  60. package/src/__tests__/history-repair.test.ts +32 -10
  61. package/src/__tests__/http-conversation-lineage.test.ts +251 -0
  62. package/src/__tests__/image-source-path-reinject.test.ts +136 -0
  63. package/src/__tests__/llm-context-normalization.test.ts +1116 -0
  64. package/src/__tests__/llm-context-route-provider.test.ts +217 -0
  65. package/src/__tests__/llm-request-log-turn-query.test.ts +270 -0
  66. package/src/__tests__/media-generate-image.test.ts +47 -94
  67. package/src/__tests__/memory-lifecycle-e2e.test.ts +3 -1
  68. package/src/__tests__/memory-recall-quality.test.ts +5 -5
  69. package/src/__tests__/migration-cross-version-compatibility.test.ts +4 -1
  70. package/src/__tests__/migration-export-http.test.ts +3 -1
  71. package/src/__tests__/migration-import-commit-http.test.ts +18 -4
  72. package/src/__tests__/migration-import-preflight-http.test.ts +1 -3
  73. package/src/__tests__/mime-builder.test.ts +3 -2
  74. package/src/__tests__/non-member-access-request.test.ts +12 -1
  75. package/src/__tests__/notification-decision-identity.test.ts +52 -0
  76. package/src/__tests__/oauth-apps-routes.test.ts +103 -0
  77. package/src/__tests__/oauth-store.test.ts +115 -0
  78. package/src/__tests__/provider-error-scenarios.test.ts +1 -3
  79. package/src/__tests__/provider-failover-actual-provider.test.ts +66 -0
  80. package/src/__tests__/recording-handler.test.ts +17 -0
  81. package/src/__tests__/registry.test.ts +3 -8
  82. package/src/__tests__/relay-server.test.ts +1 -1
  83. package/src/__tests__/runtime-attachment-metadata.test.ts +7 -3
  84. package/src/__tests__/schema-transforms.test.ts +165 -5
  85. package/src/__tests__/server-history-render.test.ts +2 -2
  86. package/src/__tests__/slack-app-setup-skill-regression.test.ts +3 -1
  87. package/src/__tests__/slack-inbound-verification.test.ts +2 -2
  88. package/src/__tests__/starter-task-flow.test.ts +1 -0
  89. package/src/__tests__/suggestion-routes.test.ts +443 -0
  90. package/src/__tests__/swarm-conversation-integration.test.ts +1 -0
  91. package/src/__tests__/swarm-recursion.test.ts +1 -0
  92. package/src/__tests__/swarm-tool.test.ts +1 -0
  93. package/src/__tests__/tool-execution-abort-cleanup.test.ts +1 -0
  94. package/src/__tests__/tool-preview-lifecycle.test.ts +32 -5
  95. package/src/__tests__/top-level-renderer.test.ts +22 -0
  96. package/src/__tests__/turn-boundary-resolution.test.ts +243 -0
  97. package/src/__tests__/web-fetch.test.ts +6 -2
  98. package/src/__tests__/workspace-migration-006-services-config.test.ts +335 -0
  99. package/src/__tests__/workspace-migration-007-web-search-provider-rename.test.ts +312 -0
  100. package/src/__tests__/workspace-migration-009-backfill-conversation-disk-view.test.ts +278 -0
  101. package/src/__tests__/workspace-migration-010-app-dir-rename.test.ts +275 -0
  102. package/src/__tests__/workspace-migration-012-rename-conversation-disk-view-dirs.test.ts +77 -0
  103. package/src/__tests__/workspace-migration-013-repair-conversation-disk-view.test.ts +401 -0
  104. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +328 -0
  105. package/src/__tests__/workspace-migration-seed-device-id.test.ts +6 -10
  106. package/src/agent/attachments.ts +27 -1
  107. package/src/agent/loop.ts +29 -1
  108. package/src/avatar/traits-png-sync.ts +80 -25
  109. package/src/bundler/app-bundler.ts +4 -4
  110. package/src/calls/call-domain.ts +1 -0
  111. package/src/calls/voice-session-bridge.ts +1 -0
  112. package/src/cli/commands/auth.ts +92 -0
  113. package/src/cli/commands/avatar.ts +7 -6
  114. package/src/cli/commands/config.ts +2 -0
  115. package/src/cli/commands/oauth/providers.ts +29 -0
  116. package/src/cli/program.ts +12 -0
  117. package/src/cli.ts +15 -48
  118. package/src/config/bundled-skills/app-builder/SKILL.md +103 -28
  119. package/src/config/bundled-skills/app-builder/TOOLS.json +5 -199
  120. package/src/config/bundled-skills/app-builder/tools/{app-query.ts → app-refresh.ts} +2 -2
  121. package/src/config/bundled-skills/contacts/tools/google-contacts.ts +2 -3
  122. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +6 -9
  123. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +4 -6
  124. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +2 -3
  125. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +2 -3
  126. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +2 -3
  127. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +2 -3
  128. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +4 -6
  129. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +2 -3
  130. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +2 -3
  131. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +2 -3
  132. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +2 -3
  133. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +2 -3
  134. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +2 -3
  135. package/src/config/bundled-skills/google-calendar/tools/shared.ts +1 -1
  136. package/src/config/bundled-skills/image-studio/SKILL.md +2 -2
  137. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  138. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +45 -72
  139. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +2 -2
  140. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -1
  141. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -3
  142. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  143. package/src/config/bundled-skills/slack/tools/shared.ts +19 -4
  144. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +2 -3
  145. package/src/config/bundled-skills/transcribe/SKILL.md +1 -1
  146. package/src/config/bundled-skills/transcribe/TOOLS.json +2 -6
  147. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +19 -83
  148. package/src/config/bundled-tool-registry.ts +2 -14
  149. package/src/config/feature-flag-registry.json +8 -0
  150. package/src/config/loader.ts +64 -0
  151. package/src/config/raw-config-utils.ts +30 -0
  152. package/src/config/schema-utils.ts +28 -7
  153. package/src/config/schema.ts +8 -0
  154. package/src/config/schemas/elevenlabs.ts +18 -0
  155. package/src/config/schemas/memory-lifecycle.ts +4 -2
  156. package/src/config/schemas/memory-storage.ts +1 -1
  157. package/src/config/schemas/services.ts +8 -6
  158. package/src/contacts/contact-store.ts +13 -6
  159. package/src/contacts/contacts-write.ts +0 -1
  160. package/src/context/window-manager.ts +13 -2
  161. package/src/daemon/conversation-agent-loop-handlers.ts +48 -7
  162. package/src/daemon/conversation-agent-loop.ts +56 -19
  163. package/src/daemon/conversation-attachments.ts +18 -36
  164. package/src/daemon/conversation-error.ts +2 -1
  165. package/src/daemon/conversation-history.ts +18 -4
  166. package/src/daemon/conversation-lifecycle.ts +39 -15
  167. package/src/daemon/conversation-messaging.ts +70 -26
  168. package/src/daemon/conversation-process.ts +58 -34
  169. package/src/daemon/conversation-runtime-assembly.ts +21 -38
  170. package/src/daemon/conversation-slash.ts +121 -256
  171. package/src/daemon/conversation-surfaces.ts +143 -20
  172. package/src/daemon/conversation-tool-setup.ts +0 -6
  173. package/src/daemon/conversation-workspace.ts +21 -1
  174. package/src/daemon/conversation.ts +51 -29
  175. package/src/daemon/first-greeting.ts +35 -0
  176. package/src/daemon/handlers/config-embeddings.ts +148 -0
  177. package/src/daemon/handlers/config-model.ts +71 -26
  178. package/src/daemon/handlers/conversations.ts +0 -23
  179. package/src/daemon/handlers/recording.ts +26 -21
  180. package/src/daemon/host-cu-proxy.ts +2 -2
  181. package/src/daemon/lifecycle.ts +106 -64
  182. package/src/daemon/message-protocol.ts +3 -0
  183. package/src/daemon/message-types/conversations.ts +19 -0
  184. package/src/daemon/message-types/messages.ts +1 -0
  185. package/src/daemon/message-types/shared.ts +2 -0
  186. package/src/daemon/message-types/surfaces.ts +2 -0
  187. package/src/daemon/message-types/upgrades.ts +23 -0
  188. package/src/daemon/server.ts +83 -12
  189. package/src/daemon/shutdown-handlers.ts +8 -5
  190. package/src/daemon/startup-error.ts +9 -0
  191. package/src/daemon/tool-side-effects.ts +11 -28
  192. package/src/events/tool-permission-telemetry-listener.ts +1 -3
  193. package/src/instrument.ts +0 -4
  194. package/src/media/app-icon-generator.ts +2 -2
  195. package/src/memory/app-git-service.ts +28 -16
  196. package/src/memory/app-store.ts +230 -41
  197. package/src/memory/attachments-store.ts +558 -130
  198. package/src/memory/conversation-attention-store.ts +70 -0
  199. package/src/memory/conversation-crud.ts +442 -3
  200. package/src/memory/conversation-directories.ts +125 -0
  201. package/src/memory/conversation-disk-view.ts +390 -0
  202. package/src/memory/conversation-key-store.ts +17 -5
  203. package/src/memory/conversation-queries.ts +5 -1
  204. package/src/memory/conversation-title-service.ts +21 -49
  205. package/src/memory/db-init.ts +28 -0
  206. package/src/memory/embedding-backend.ts +42 -53
  207. package/src/memory/embedding-gemini.test.ts +4 -4
  208. package/src/memory/embedding-local.ts +1 -3
  209. package/src/memory/embedding-ollama.ts +1 -3
  210. package/src/memory/embedding-openai.ts +1 -3
  211. package/src/memory/indexer.ts +9 -7
  212. package/src/memory/items-extractor.ts +42 -13
  213. package/src/memory/job-handlers/conversation-starters.ts +6 -1
  214. package/src/memory/job-handlers/embedding.test.ts +1 -4
  215. package/src/memory/llm-request-log-store.ts +100 -1
  216. package/src/memory/migrations/102-alter-table-columns.ts +5 -0
  217. package/src/memory/migrations/146-schedule-oneshot-routing.ts +3 -3
  218. package/src/memory/migrations/147-migrate-reminders-to-schedules.ts +66 -70
  219. package/src/memory/migrations/148-drop-reminders-table.ts +5 -9
  220. package/src/memory/migrations/160-drop-loopback-port-column.ts +1 -3
  221. package/src/memory/migrations/174-rename-thread-starters-table.ts +0 -7
  222. package/src/memory/migrations/178-oauth-providers-managed-service-config-key.ts +15 -0
  223. package/src/memory/migrations/179-llm-request-log-message-id.ts +16 -0
  224. package/src/memory/migrations/180-backfill-inline-attachments-to-disk.ts +66 -0
  225. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +46 -0
  226. package/src/memory/migrations/182-oauth-providers-display-metadata.ts +20 -0
  227. package/src/memory/migrations/183-add-conversation-fork-lineage.ts +22 -0
  228. package/src/memory/migrations/184-llm-request-log-provider.ts +12 -0
  229. package/src/memory/migrations/index.ts +7 -0
  230. package/src/memory/migrations/registry.ts +13 -0
  231. package/src/memory/retriever.test.ts +601 -2
  232. package/src/memory/retriever.ts +85 -9
  233. package/src/memory/schema/conversations.ts +6 -0
  234. package/src/memory/schema/infrastructure.ts +13 -7
  235. package/src/memory/schema/oauth.ts +6 -0
  236. package/src/messaging/providers/gmail/mime-builder.ts +3 -1
  237. package/src/notifications/copy-composer.ts +26 -0
  238. package/src/notifications/decision-engine.ts +14 -1
  239. package/src/notifications/emit-signal.ts +1 -1
  240. package/src/notifications/signal.ts +36 -0
  241. package/src/oauth/byo-connection.test.ts +1 -45
  242. package/src/oauth/byo-connection.ts +2 -8
  243. package/src/oauth/connect-orchestrator.ts +15 -11
  244. package/src/oauth/connection-resolver.test.ts +191 -0
  245. package/src/oauth/connection-resolver.ts +66 -38
  246. package/src/oauth/connection.ts +0 -1
  247. package/src/oauth/oauth-store.ts +97 -47
  248. package/src/oauth/platform-connection.test.ts +0 -1
  249. package/src/oauth/platform-connection.ts +11 -3
  250. package/src/oauth/seed-providers.ts +78 -3
  251. package/src/oauth/token-persistence.ts +16 -10
  252. package/src/permissions/checker.ts +71 -8
  253. package/src/prompts/templates/BOOTSTRAP.md +2 -0
  254. package/src/providers/anthropic/client.ts +8 -1
  255. package/src/providers/failover.ts +4 -1
  256. package/src/providers/gemini/client.ts +50 -0
  257. package/src/providers/model-catalog.ts +92 -0
  258. package/src/providers/model-intents.ts +29 -20
  259. package/src/providers/openai/client.ts +49 -0
  260. package/src/providers/types.ts +2 -0
  261. package/src/runtime/access-request-helper.ts +16 -7
  262. package/src/runtime/auth/credential-service.ts +3 -1
  263. package/src/runtime/auth/route-policy.ts +14 -1
  264. package/src/runtime/btw-sidechain.ts +101 -0
  265. package/src/runtime/channel-reply-delivery.ts +17 -1
  266. package/src/runtime/http-router.ts +3 -1
  267. package/src/runtime/http-server.ts +196 -141
  268. package/src/runtime/http-types.ts +1 -0
  269. package/src/runtime/migrations/vbundle-builder.ts +5 -1
  270. package/src/runtime/routes/access-request-decision.ts +41 -0
  271. package/src/runtime/routes/app-management-routes.ts +6 -3
  272. package/src/runtime/routes/app-routes.ts +7 -3
  273. package/src/runtime/routes/approval-routes.ts +1 -0
  274. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +34 -2
  275. package/src/runtime/routes/attachment-routes.ts +45 -15
  276. package/src/runtime/routes/btw-routes.ts +21 -61
  277. package/src/runtime/routes/conversation-management-routes.ts +68 -0
  278. package/src/runtime/routes/conversation-query-routes.ts +180 -10
  279. package/src/runtime/routes/conversation-routes.ts +222 -28
  280. package/src/runtime/routes/conversation-starter-routes.ts +9 -11
  281. package/src/runtime/routes/diagnostics-routes.ts +1 -0
  282. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +2 -2
  283. package/src/runtime/routes/llm-context-normalization.ts +1199 -0
  284. package/src/runtime/routes/log-export-routes.ts +3 -0
  285. package/src/runtime/routes/memory-item-routes.test.ts +34 -0
  286. package/src/runtime/routes/memory-item-routes.ts +4 -0
  287. package/src/runtime/routes/migration-routes.ts +4 -1
  288. package/src/runtime/routes/oauth-apps.ts +291 -0
  289. package/src/runtime/routes/secret-routes.ts +28 -1
  290. package/src/runtime/routes/settings-routes.ts +14 -0
  291. package/src/runtime/routes/trace-event-routes.ts +4 -1
  292. package/src/schedule/schedule-store.ts +9 -21
  293. package/src/security/secure-keys.ts +21 -0
  294. package/src/signals/bash.ts +1 -1
  295. package/src/swarm/backend-claude-code.ts +3 -6
  296. package/src/telemetry/usage-telemetry-reporter.test.ts +3 -2
  297. package/src/telemetry/usage-telemetry-reporter.ts +3 -1
  298. package/src/tools/AGENTS.md +6 -10
  299. package/src/tools/apps/executors.ts +17 -232
  300. package/src/tools/claude-code/claude-code.ts +2 -3
  301. package/src/tools/credentials/vault.ts +7 -12
  302. package/src/tools/host-filesystem/read.ts +13 -10
  303. package/src/tools/network/__tests__/web-search.test.ts +4 -2
  304. package/src/tools/schedule/list.ts +2 -7
  305. package/src/tools/schema-transforms.ts +5 -0
  306. package/src/tools/shared/filesystem/format-diff.ts +2 -7
  307. package/src/tools/skills/execute.ts +1 -1
  308. package/src/tools/tool-manifest.ts +0 -6
  309. package/src/tools/ui-surface/definitions.ts +2 -2
  310. package/src/util/device-id.ts +28 -5
  311. package/src/util/platform.ts +6 -0
  312. package/src/util/pricing.ts +1 -0
  313. package/src/util/retry.ts +1 -3
  314. package/src/workspace/migrations/002-backfill-installation-id.ts +23 -12
  315. package/src/workspace/migrations/003-seed-device-id.ts +3 -4
  316. package/src/workspace/migrations/006-services-config.ts +5 -0
  317. package/src/workspace/migrations/008-voice-timeout-and-max-steps.ts +12 -0
  318. package/src/workspace/migrations/009-backfill-conversation-disk-view.ts +10 -0
  319. package/src/workspace/migrations/010-app-dir-rename.ts +223 -0
  320. package/src/workspace/migrations/012-rename-conversation-disk-view-dirs.ts +64 -0
  321. package/src/workspace/migrations/013-repair-conversation-disk-view.ts +11 -0
  322. package/src/workspace/migrations/rebuild-conversation-disk-view.ts +186 -0
  323. package/src/workspace/migrations/registry.ts +10 -0
  324. package/src/workspace/top-level-renderer.ts +12 -0
  325. package/src/__tests__/asset-materialize-tool.test.ts +0 -523
  326. package/src/__tests__/asset-search-tool.test.ts +0 -536
  327. package/src/__tests__/fixtures/media-reuse-fixtures.ts +0 -56
  328. package/src/__tests__/media-reuse-story.e2e.test.ts +0 -762
  329. package/src/__tests__/media-visibility-policy.test.ts +0 -190
  330. package/src/config/bundled-skills/app-builder/tools/app-file-edit.ts +0 -14
  331. package/src/config/bundled-skills/app-builder/tools/app-file-list.ts +0 -13
  332. package/src/config/bundled-skills/app-builder/tools/app-file-read.ts +0 -21
  333. package/src/config/bundled-skills/app-builder/tools/app-file-write.ts +0 -14
  334. package/src/config/bundled-skills/app-builder/tools/app-list.ts +0 -13
  335. package/src/config/bundled-skills/app-builder/tools/app-update.ts +0 -23
  336. package/src/daemon/media-visibility-policy.ts +0 -59
  337. package/src/tools/assets/materialize.ts +0 -248
  338. package/src/tools/assets/search.ts +0 -400
@@ -1,762 +0,0 @@
1
- /**
2
- * Story E2E Test: "selfie yesterday -> generated image today"
3
- *
4
- * Exercises the full media-reuse user story end-to-end:
5
- *
6
- * 1. A fal.ai credential is stored with an injection template.
7
- * 2. User uploads a selfie in Conversation A (standard conversation).
8
- * 3. In Conversation B (standard), the agent uses asset_search to find the selfie,
9
- * then asset_materialize to write it to disk.
10
- * 4. A proxied bash command calls the provider API through a real proxy
11
- * session with credential injection against a local mock endpoint.
12
- * 5. The generated result is saved back.
13
- *
14
- * Also verifies that private-conversation isolation blocks cross-conversation media access.
15
- */
16
-
17
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
18
- import * as http from "node:http";
19
- import { tmpdir } from "node:os";
20
- import { join } from "node:path";
21
- import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
22
-
23
- // ---------------------------------------------------------------------------
24
- // Test directory and mocks (must precede any source imports)
25
- // ---------------------------------------------------------------------------
26
-
27
- const testDir = mkdtempSync(join(tmpdir(), "media-reuse-story-e2e-"));
28
- const sandboxDir = join(testDir, "sandbox");
29
-
30
- mock.module("../util/platform.js", () => ({
31
- getDataDir: () => testDir,
32
- isMacOS: () => process.platform === "darwin",
33
- isLinux: () => process.platform === "linux",
34
- isWindows: () => process.platform === "win32",
35
- getPidPath: () => join(testDir, "test.pid"),
36
- getDbPath: () => join(testDir, "test.db"),
37
- getLogPath: () => join(testDir, "test.log"),
38
- ensureDataDir: () => {},
39
- getRootDir: () => testDir,
40
- }));
41
-
42
- mock.module("../util/logger.js", () => ({
43
- getLogger: () =>
44
- new Proxy({} as Record<string, unknown>, {
45
- get: () => () => {},
46
- }),
47
- }));
48
-
49
- mock.module("../config/loader.js", () => ({
50
- getConfig: () => ({
51
- ui: {},
52
-
53
- model: "test",
54
- provider: "test",
55
- memory: { enabled: false },
56
- rateLimit: { maxRequestsPerMinute: 0 },
57
- timeouts: { shellDefaultTimeoutSec: 30, shellMaxTimeoutSec: 60 },
58
- sandbox: { enabled: false, backend: "native" },
59
- }),
60
- }));
61
-
62
- // Credential resolver and secure key mocks — must be set up before
63
- // session-manager is imported so the proxy uses our test data.
64
- import { credentialKey } from "../security/credential-key.js";
65
- import type { ResolvedCredential } from "../tools/credentials/resolve.js";
66
-
67
- let resolveByIdResults = new Map<string, ResolvedCredential | undefined>();
68
- let secureKeyValues = new Map<string, string | undefined>();
69
-
70
- mock.module("../tools/credentials/resolve.js", () => ({
71
- resolveById: (credentialId: string) => resolveByIdResults.get(credentialId),
72
- resolveByServiceField: () => undefined,
73
- resolveForDomain: () => [],
74
- }));
75
-
76
- mock.module("../tools/credentials/metadata-store.js", () => ({
77
- listCredentialMetadata: () => [],
78
- }));
79
-
80
- mock.module("../security/secure-keys.js", () => ({
81
- getSecureKeyAsync: async (account: string) => secureKeyValues.get(account),
82
- listSecureKeysAsync: async () => [],
83
- _resetBackend: () => {},
84
- }));
85
-
86
- // Stub ensureLocalCA / certs so tests never run openssl
87
- mock.module("../tools/network/script-proxy/certs.js", () => ({
88
- ensureLocalCA: async () => {},
89
- issueLeafCert: async () => ({ cert: "", key: "" }),
90
- getCAPath: (dataDir: string) => `${dataDir}/proxy-ca/ca.pem`,
91
- }));
92
-
93
- // ---------------------------------------------------------------------------
94
- // Source imports (after mocks)
95
- // ---------------------------------------------------------------------------
96
-
97
- import { mkdirSync } from "node:fs";
98
-
99
- import {
100
- type AttachmentContext,
101
- filterVisibleAttachments,
102
- isAttachmentVisible,
103
- } from "../daemon/media-visibility-policy.js";
104
- import {
105
- linkAttachmentToMessage,
106
- uploadAttachment,
107
- } from "../memory/attachments-store.js";
108
- import { addMessage, createConversation } from "../memory/conversation-crud.js";
109
- import { getDb, initializeDb, resetDb } from "../memory/db.js";
110
- import { assetMaterializeTool } from "../tools/assets/materialize.js";
111
- import { assetSearchTool, searchAttachments } from "../tools/assets/search.js";
112
- import type { CredentialInjectionTemplate } from "../tools/credentials/policy-types.js";
113
- import { stopAllSessions } from "../tools/network/script-proxy/index.js";
114
- import { shellTool } from "../tools/terminal/shell.js";
115
- import type { ToolContext } from "../tools/types.js";
116
- import {
117
- FAKE_SELFIE_ATTACHMENT,
118
- fakeAllowOnce,
119
- fakeDeny,
120
- TINY_PNG_BASE64,
121
- } from "./fixtures/media-reuse-fixtures.js";
122
-
123
- initializeDb();
124
- mkdirSync(sandboxDir, { recursive: true });
125
-
126
- afterAll(async () => {
127
- resetDb();
128
- await stopAllSessions();
129
- resolveByIdResults = new Map();
130
- secureKeyValues = new Map();
131
- try {
132
- rmSync(testDir, { recursive: true });
133
- } catch {
134
- /* best effort */
135
- }
136
- });
137
-
138
- // ---------------------------------------------------------------------------
139
- // Helpers
140
- // ---------------------------------------------------------------------------
141
-
142
- /** Resolved credential factory matching the pattern in script-proxy-injection-runtime.test.ts */
143
- function makeResolved(
144
- credentialId: string,
145
- templates: CredentialInjectionTemplate[],
146
- service = "test-service",
147
- field = "api-key",
148
- ): ResolvedCredential {
149
- return {
150
- credentialId,
151
- service,
152
- field,
153
- storageKey: credentialKey(service, field),
154
- injectionTemplates: templates,
155
- metadata: {
156
- credentialId,
157
- service,
158
- field,
159
- allowedTools: [],
160
- allowedDomains: [],
161
- createdAt: Date.now(),
162
- updatedAt: Date.now(),
163
- injectionTemplates: templates,
164
- },
165
- };
166
- }
167
-
168
- /**
169
- * Start a local HTTP echo server that captures received headers and returns
170
- * them as JSON. Returns the server and its port.
171
- */
172
- function startEchoServer(): Promise<{ server: http.Server; port: number }> {
173
- return new Promise((resolve) => {
174
- const server = http.createServer((req, res) => {
175
- res.writeHead(200, { "Content-Type": "application/json" });
176
- res.end(JSON.stringify({ headers: req.headers, url: req.url }));
177
- });
178
- server.listen(0, "127.0.0.1", () => {
179
- const addr = server.address() as { port: number };
180
- resolve({ server, port: addr.port });
181
- });
182
- });
183
- }
184
-
185
- function resetTables() {
186
- const db = getDb();
187
- db.run("DELETE FROM message_attachments");
188
- db.run("DELETE FROM attachments");
189
- db.run("DELETE FROM messages");
190
- db.run("DELETE FROM conversations");
191
- }
192
-
193
- // ---------------------------------------------------------------------------
194
- // Story E2E: selfie yesterday, generated image today
195
- // ---------------------------------------------------------------------------
196
-
197
- describe("Story E2E: selfie yesterday -> generated image today", () => {
198
- // Shared state across the story steps
199
- let conversationA: ReturnType<typeof createConversation>;
200
- let conversationB: ReturnType<typeof createConversation>;
201
- let selfieId: string;
202
- let selfieAttachment: ReturnType<typeof uploadAttachment>;
203
-
204
- beforeEach(async () => {
205
- resetTables();
206
- // Clear sandbox so stale files from prior tests don't mask regressions
207
- rmSync(sandboxDir, { recursive: true, force: true });
208
- mkdirSync(sandboxDir, { recursive: true });
209
-
210
- // -- Step 1: Credential with injection template (simulated) --
211
- // In a real flow, the user stores a fal.ai credential via credential_store.
212
- // Here we only need to verify the injection template structure is valid --
213
- // the actual credential broker is tested in dedicated tests. We construct
214
- // the template to verify the data shape used downstream.
215
- const falInjectionTemplate: CredentialInjectionTemplate = {
216
- hostPattern: "*.fal.ai",
217
- injectionType: "header",
218
- headerName: "Authorization",
219
- valuePrefix: "Key ",
220
- };
221
- // Sanity check the template shape
222
- expect(falInjectionTemplate.hostPattern).toBe("*.fal.ai");
223
- expect(falInjectionTemplate.injectionType).toBe("header");
224
- expect(falInjectionTemplate.headerName).toBe("Authorization");
225
- expect(falInjectionTemplate.valuePrefix).toBe("Key ");
226
-
227
- // -- Step 2: Selfie uploaded in Conversation A (standard) --
228
- conversationA = createConversation({
229
- title: "Conversation A — selfie upload",
230
- });
231
- selfieAttachment = uploadAttachment(
232
- "selfie.png",
233
- "image/png",
234
- TINY_PNG_BASE64,
235
- );
236
- selfieId = selfieAttachment.id;
237
-
238
- const msgA = await addMessage(
239
- conversationA.id,
240
- "user",
241
- "Here is my selfie from yesterday",
242
- );
243
- linkAttachmentToMessage(msgA.id, selfieId, 0);
244
-
245
- // Backdate the selfie to "yesterday" for realism
246
- const yesterday = Date.now() - 24 * 60 * 60 * 1000 - 5000;
247
- const db = getDb();
248
- db.run(
249
- `UPDATE attachments SET created_at = ${yesterday} WHERE id = '${selfieId}'`,
250
- );
251
-
252
- // -- Step 3: Conversation B is a new standard conversation --
253
- conversationB = createConversation({
254
- title: "Conversation B — generate image",
255
- });
256
- });
257
-
258
- test("asset_search discovers the selfie from Conversation B (cross-conversation)", async () => {
259
- const context: ToolContext = {
260
- workingDir: sandboxDir,
261
- conversationId: conversationB.id,
262
- trustClass: "guardian",
263
- };
264
-
265
- const result = await assetSearchTool.execute(
266
- { mime_type: "image/*", filename: "selfie" },
267
- context,
268
- );
269
-
270
- expect(result.isError).toBe(false);
271
- expect(result.content).toContain("selfie.png");
272
- expect(result.content).toContain(selfieId);
273
- expect(result.content).toContain("Found 1 asset(s)");
274
- });
275
-
276
- test("asset_search with recency last_7_days finds the selfie uploaded yesterday", async () => {
277
- const context: ToolContext = {
278
- workingDir: sandboxDir,
279
- conversationId: conversationB.id,
280
- trustClass: "guardian",
281
- };
282
-
283
- const result = await assetSearchTool.execute(
284
- { mime_type: "image/*", recency: "last_7_days" },
285
- context,
286
- );
287
-
288
- expect(result.isError).toBe(false);
289
- expect(result.content).toContain("selfie.png");
290
- });
291
-
292
- test("asset_materialize writes the selfie to disk in Conversation B sandbox", async () => {
293
- const context: ToolContext = {
294
- workingDir: sandboxDir,
295
- conversationId: conversationB.id,
296
- trustClass: "guardian",
297
- };
298
-
299
- const result = await assetMaterializeTool.execute(
300
- { attachment_id: selfieId, destination_path: "inputs/selfie.png" },
301
- context,
302
- );
303
-
304
- expect(result.isError).toBe(false);
305
- expect(result.content).toContain("Materialized");
306
- expect(result.content).toContain("selfie.png");
307
- expect(result.content).toContain("image/png");
308
-
309
- // Verify the file actually exists on disk
310
- const materializedPath = join(sandboxDir, "inputs", "selfie.png");
311
- expect(existsSync(materializedPath)).toBe(true);
312
-
313
- // Verify the content matches the original base64-decoded data
314
- const writtenBytes = readFileSync(materializedPath);
315
- const expectedBytes = Buffer.from(TINY_PNG_BASE64, "base64");
316
- expect(Buffer.compare(writtenBytes, expectedBytes)).toBe(0);
317
- });
318
-
319
- test("full story: search -> materialize -> proxied provider call -> output saved", async () => {
320
- const contextB: ToolContext = {
321
- workingDir: sandboxDir,
322
- conversationId: conversationB.id,
323
- trustClass: "guardian",
324
- };
325
-
326
- // Step 3a: Search for the selfie
327
- const searchResult = await assetSearchTool.execute(
328
- { mime_type: "image/*", filename: "selfie" },
329
- contextB,
330
- );
331
- expect(searchResult.isError).toBe(false);
332
- expect(searchResult.content).toContain(selfieId);
333
-
334
- // Step 3b: Materialize the selfie to disk
335
- const materializeResult = await assetMaterializeTool.execute(
336
- { attachment_id: selfieId, destination_path: "inputs/selfie.png" },
337
- contextB,
338
- );
339
- expect(materializeResult.isError).toBe(false);
340
- const inputPath = join(sandboxDir, "inputs", "selfie.png");
341
- expect(existsSync(inputPath)).toBe(true);
342
-
343
- // Step 4: Invoke the bash tool with network_mode='proxied', just as the
344
- // agent loop would. The shell tool internally creates a proxy session,
345
- // injects proxy env vars, spawns curl, and the proxy injects credentials.
346
- const echo = await startEchoServer();
347
- try {
348
- const tpl: CredentialInjectionTemplate = {
349
- hostPattern: "127.0.0.1",
350
- injectionType: "header",
351
- headerName: "Authorization",
352
- valuePrefix: "Key ",
353
- };
354
- const resolved = makeResolved("cred-story", [tpl]);
355
- resolveByIdResults.set("cred-story", resolved);
356
- secureKeyValues.set(
357
- credentialKey("test-service", "api-key"),
358
- "fal_test_secret",
359
- );
360
-
361
- // Drive the proxy step through the bash tool — the actual integration path.
362
- // -x "$HTTP_PROXY" forces curl to use the proxy explicitly (macOS curl
363
- // ignores the uppercase HTTP_PROXY env var). --noproxy "" overrides the
364
- // NO_PROXY list which excludes 127.0.0.1 by default.
365
- const bashResult = await shellTool.execute(
366
- {
367
- command: `curl -s -x "$HTTP_PROXY" --noproxy "" http://127.0.0.1:${echo.port}/v1/generate`,
368
- network_mode: "proxied",
369
- credential_ids: ["cred-story"],
370
- },
371
- contextB,
372
- );
373
- expect(bashResult.isError).toBeFalsy();
374
-
375
- // The echo server returns JSON with the headers it received.
376
- // When exit code is 0 and there's no stderr, formatShellOutput
377
- // returns raw stdout as the content — so JSON.parse works directly.
378
- const echoResponse = JSON.parse(bashResult.content);
379
- expect(echoResponse.headers.authorization).toBe("Key fal_test_secret");
380
-
381
- await stopAllSessions();
382
- resolveByIdResults.delete("cred-story");
383
- secureKeyValues.delete(credentialKey("test-service", "api-key"));
384
- } finally {
385
- echo.server.close();
386
- }
387
-
388
- // Step 5: Save the generated result back as an attachment.
389
- // Use different content than the selfie to avoid content-hash deduplication
390
- // in the attachment store (same hash = returns existing row).
391
- const generatedImageBase64 = Buffer.from(
392
- "generated-portrait-data-unique",
393
- ).toString("base64");
394
- const outputAttachment = uploadAttachment(
395
- "generated-portrait.png",
396
- "image/png",
397
- generatedImageBase64,
398
- );
399
-
400
- const msgB = await addMessage(
401
- conversationB.id,
402
- "assistant",
403
- "Here is your generated portrait!",
404
- );
405
- linkAttachmentToMessage(msgB.id, outputAttachment.id, 0);
406
-
407
- // Verify the output attachment exists in the DB via raw search
408
- const rawResults = searchAttachments({ filename: "generated-portrait" });
409
- expect(rawResults.length).toBe(1);
410
- expect(rawResults[0].originalFilename).toBe("generated-portrait.png");
411
- expect(rawResults[0].id).toBe(outputAttachment.id);
412
-
413
- // Verify it's also findable via the tool (with visibility filtering)
414
- const outputSearchResult = await assetSearchTool.execute(
415
- { filename: "generated-portrait" },
416
- contextB,
417
- );
418
- expect(outputSearchResult.isError).toBe(false);
419
- expect(outputSearchResult.content).toContain("generated-portrait.png");
420
- expect(outputSearchResult.content).toContain(outputAttachment.id);
421
- });
422
- });
423
-
424
- // ---------------------------------------------------------------------------
425
- // Credential injection template validation
426
- // ---------------------------------------------------------------------------
427
-
428
- describe("Credential injection template structure", () => {
429
- test("fal.ai header injection template has all required fields", () => {
430
- const template: CredentialInjectionTemplate = {
431
- hostPattern: "*.fal.ai",
432
- injectionType: "header",
433
- headerName: "Authorization",
434
- valuePrefix: "Key ",
435
- };
436
-
437
- expect(template.hostPattern).toBe("*.fal.ai");
438
- expect(template.injectionType).toBe("header");
439
- expect(template.headerName).toBe("Authorization");
440
- expect(template.valuePrefix).toBe("Key ");
441
- expect(template.queryParamName).toBeUndefined();
442
- });
443
-
444
- test("query-param injection template shape is valid", () => {
445
- const template: CredentialInjectionTemplate = {
446
- hostPattern: "api.example.com",
447
- injectionType: "query",
448
- queryParamName: "api_key",
449
- };
450
-
451
- expect(template.injectionType).toBe("query");
452
- expect(template.queryParamName).toBe("api_key");
453
- expect(template.headerName).toBeUndefined();
454
- });
455
-
456
- test("host pattern matching for fal.ai subdomains", async () => {
457
- // The proxy uses minimatch for host patterns. Verify the pattern shape.
458
- const { minimatch } = await import("minimatch");
459
- const pattern = "*.fal.ai";
460
- expect(minimatch("api.fal.ai", pattern)).toBe(true);
461
- expect(minimatch("v1.fal.ai", pattern)).toBe(true);
462
- expect(minimatch("fal.ai", pattern)).toBe(false); // No subdomain
463
- expect(minimatch("evil.fal.ai.attacker.com", pattern)).toBe(false);
464
- });
465
- });
466
-
467
- // ---------------------------------------------------------------------------
468
- // Proxied bash approval is per-invocation (not persistent)
469
- // ---------------------------------------------------------------------------
470
-
471
- describe("Proxied bash activation requires per-invocation approval", () => {
472
- test("one-time allow decision has no pattern or scope (cannot create persistent rule)", () => {
473
- const approval = fakeAllowOnce();
474
- expect(approval.decision).toBe("allow");
475
- expect(approval.pattern).toBeUndefined();
476
- expect(approval.scope).toBeUndefined();
477
- });
478
-
479
- test("deny decision also has no pattern or scope", () => {
480
- const denial = fakeDeny();
481
- expect(denial.decision).toBe("deny");
482
- expect(denial.pattern).toBeUndefined();
483
- expect(denial.scope).toBeUndefined();
484
- });
485
-
486
- test("consecutive approval checks produce independent decisions", () => {
487
- // Simulate multiple invocations — each must be independently approved
488
- const decisions = [fakeAllowOnce(), fakeAllowOnce(), fakeDeny()];
489
- expect(decisions[0].decision).toBe("allow");
490
- expect(decisions[1].decision).toBe("allow");
491
- expect(decisions[2].decision).toBe("deny");
492
- // No decision carries over from a previous one
493
- for (const d of decisions) {
494
- expect(d.pattern).toBeUndefined();
495
- }
496
- });
497
- });
498
-
499
- // ---------------------------------------------------------------------------
500
- // Private-conversation variant: cross-conversation blocking
501
- // ---------------------------------------------------------------------------
502
-
503
- describe("Private-conversation variant: cross-conversation media blocking", () => {
504
- beforeEach(() => {
505
- resetTables();
506
- rmSync(sandboxDir, { recursive: true, force: true });
507
- mkdirSync(sandboxDir, { recursive: true });
508
- });
509
-
510
- test("selfie in private conversation A is NOT discoverable via search from Conversation B", async () => {
511
- // Upload selfie in a private conversation
512
- const privateConversation = createConversation({
513
- title: "Private selfie conversation",
514
- conversationType: "private",
515
- });
516
- const selfie = uploadAttachment(
517
- "private-selfie.png",
518
- "image/png",
519
- TINY_PNG_BASE64,
520
- );
521
- const msg = await addMessage(
522
- privateConversation.id,
523
- "user",
524
- "My private selfie",
525
- );
526
- linkAttachmentToMessage(msg.id, selfie.id, 0);
527
-
528
- // Search from a standard conversation
529
- const standardConversation = createConversation({
530
- title: "Standard conversation B",
531
- });
532
- const context: ToolContext = {
533
- workingDir: sandboxDir,
534
- conversationId: standardConversation.id,
535
- trustClass: "guardian",
536
- };
537
-
538
- const result = await assetSearchTool.execute(
539
- { filename: "private-selfie" },
540
- context,
541
- );
542
-
543
- expect(result.isError).toBe(false);
544
- expect(result.content).toContain("No assets found");
545
- });
546
-
547
- test("selfie in private conversation A is NOT materializable from Conversation B", async () => {
548
- const privateConversation = createConversation({
549
- title: "Private selfie conversation",
550
- conversationType: "private",
551
- });
552
- const base64 = Buffer.from("private image data").toString("base64");
553
- const selfie = uploadAttachment("private-selfie.png", "image/png", base64);
554
- const msg = await addMessage(
555
- privateConversation.id,
556
- "user",
557
- "My private selfie",
558
- );
559
- linkAttachmentToMessage(msg.id, selfie.id, 0);
560
-
561
- // Try to materialize from a standard conversation
562
- const standardConversation = createConversation({
563
- title: "Standard conversation B",
564
- });
565
- const context: ToolContext = {
566
- workingDir: sandboxDir,
567
- conversationId: standardConversation.id,
568
- trustClass: "guardian",
569
- };
570
-
571
- const result = await assetMaterializeTool.execute(
572
- { attachment_id: selfie.id, destination_path: "stolen-selfie.png" },
573
- context,
574
- );
575
-
576
- expect(result.isError).toBe(true);
577
- expect(result.content).toContain("private conversation");
578
- expect(result.content).toContain("cannot be accessed");
579
- });
580
-
581
- test("selfie in private conversation IS accessible from the same private conversation", async () => {
582
- const privateConversation = createConversation({
583
- title: "Private selfie conversation",
584
- conversationType: "private",
585
- });
586
- const selfie = uploadAttachment(
587
- "private-selfie.png",
588
- "image/png",
589
- TINY_PNG_BASE64,
590
- );
591
- const msg = await addMessage(
592
- privateConversation.id,
593
- "user",
594
- "My private selfie",
595
- );
596
- linkAttachmentToMessage(msg.id, selfie.id, 0);
597
-
598
- // Search from the same private conversation
599
- const context: ToolContext = {
600
- workingDir: sandboxDir,
601
- conversationId: privateConversation.id,
602
- trustClass: "guardian",
603
- };
604
-
605
- const searchResult = await assetSearchTool.execute(
606
- { filename: "private-selfie" },
607
- context,
608
- );
609
- expect(searchResult.isError).toBe(false);
610
- expect(searchResult.content).toContain("private-selfie.png");
611
-
612
- // Materialize from the same private conversation
613
- const materializeResult = await assetMaterializeTool.execute(
614
- { attachment_id: selfie.id, destination_path: "my-selfie.png" },
615
- context,
616
- );
617
- expect(materializeResult.isError).toBe(false);
618
- expect(materializeResult.content).toContain("Materialized");
619
- });
620
-
621
- test("selfie in private conversation A is NOT accessible from private conversation B", async () => {
622
- const privateConversationA = createConversation({
623
- title: "Private conversation A",
624
- conversationType: "private",
625
- });
626
- const selfie = uploadAttachment(
627
- "conversation-a-selfie.png",
628
- "image/png",
629
- TINY_PNG_BASE64,
630
- );
631
- const msgA = await addMessage(
632
- privateConversationA.id,
633
- "user",
634
- "Selfie in conversation A",
635
- );
636
- linkAttachmentToMessage(msgA.id, selfie.id, 0);
637
-
638
- // Search from a different private conversation
639
- const privateConversationB = createConversation({
640
- title: "Private conversation B",
641
- conversationType: "private",
642
- });
643
- const context: ToolContext = {
644
- workingDir: sandboxDir,
645
- conversationId: privateConversationB.id,
646
- trustClass: "guardian",
647
- };
648
-
649
- const searchResult = await assetSearchTool.execute(
650
- { filename: "conversation-a-selfie" },
651
- context,
652
- );
653
- expect(searchResult.isError).toBe(false);
654
- expect(searchResult.content).toContain("No assets found");
655
-
656
- // Also verify materialize is blocked
657
- const materializeResult = await assetMaterializeTool.execute(
658
- { attachment_id: selfie.id, destination_path: "cross-conversation.png" },
659
- context,
660
- );
661
- expect(materializeResult.isError).toBe(true);
662
- expect(materializeResult.content).toContain("private conversation");
663
- });
664
-
665
- test("visibility policy unit check: private attachment blocked from standard context", () => {
666
- const privateAttachment: AttachmentContext = {
667
- conversationId: "conv-private-001",
668
- isPrivate: true,
669
- };
670
- const standardContext: AttachmentContext = {
671
- conversationId: "conv-standard-001",
672
- isPrivate: false,
673
- };
674
-
675
- expect(isAttachmentVisible(privateAttachment, standardContext)).toBe(false);
676
- });
677
-
678
- test("visibility policy unit check: standard attachment visible from any context", () => {
679
- const standardAttachment: AttachmentContext = {
680
- conversationId: "conv-standard-001",
681
- isPrivate: false,
682
- };
683
- const otherContext: AttachmentContext = {
684
- conversationId: "conv-other",
685
- isPrivate: false,
686
- };
687
- const privateContext: AttachmentContext = {
688
- conversationId: "conv-private-001",
689
- isPrivate: true,
690
- };
691
-
692
- expect(isAttachmentVisible(standardAttachment, otherContext)).toBe(true);
693
- expect(isAttachmentVisible(standardAttachment, privateContext)).toBe(true);
694
- });
695
-
696
- test("filterVisibleAttachments correctly partitions mixed standard/private attachments", () => {
697
- interface TestItem {
698
- id: string;
699
- conversationId: string;
700
- isPrivate: boolean;
701
- }
702
-
703
- const items: TestItem[] = [
704
- { id: "std-1", conversationId: "conv-std", isPrivate: false },
705
- { id: "priv-a", conversationId: "conv-priv-a", isPrivate: true },
706
- { id: "priv-b", conversationId: "conv-priv-b", isPrivate: true },
707
- ];
708
-
709
- const getCtx = (item: TestItem): AttachmentContext => ({
710
- conversationId: item.conversationId,
711
- isPrivate: item.isPrivate,
712
- });
713
-
714
- // From a standard context, only standard attachments are visible
715
- const fromStandard = filterVisibleAttachments(
716
- items,
717
- { conversationId: "conv-other", isPrivate: false },
718
- getCtx,
719
- );
720
- expect(fromStandard.map((i) => i.id)).toEqual(["std-1"]);
721
-
722
- // From private conversation A, standard + A's private attachment are visible
723
- const fromPrivA = filterVisibleAttachments(
724
- items,
725
- { conversationId: "conv-priv-a", isPrivate: true },
726
- getCtx,
727
- );
728
- expect(fromPrivA.map((i) => i.id)).toEqual(["std-1", "priv-a"]);
729
-
730
- // From private conversation B, standard + B's private attachment are visible
731
- const fromPrivB = filterVisibleAttachments(
732
- items,
733
- { conversationId: "conv-priv-b", isPrivate: true },
734
- getCtx,
735
- );
736
- expect(fromPrivB.map((i) => i.id)).toEqual(["std-1", "priv-b"]);
737
- });
738
- });
739
-
740
- // ---------------------------------------------------------------------------
741
- // Fixture data integrity checks
742
- // ---------------------------------------------------------------------------
743
-
744
- describe("Fixture data integrity", () => {
745
- test("FAKE_SELFIE_ATTACHMENT has consistent metadata", () => {
746
- expect(FAKE_SELFIE_ATTACHMENT.originalFilename).toBe("selfie.png");
747
- expect(FAKE_SELFIE_ATTACHMENT.mimeType).toBe("image/png");
748
- expect(FAKE_SELFIE_ATTACHMENT.kind).toBe("image");
749
- expect(FAKE_SELFIE_ATTACHMENT.sizeBytes).toBe(
750
- Buffer.from(TINY_PNG_BASE64, "base64").length,
751
- );
752
- });
753
-
754
- test("TINY_PNG_BASE64 decodes to valid PNG header bytes", () => {
755
- const bytes = Buffer.from(TINY_PNG_BASE64, "base64");
756
- // PNG magic bytes: 137 80 78 71 13 10 26 10
757
- expect(bytes[0]).toBe(0x89);
758
- expect(bytes[1]).toBe(0x50); // P
759
- expect(bytes[2]).toBe(0x4e); // N
760
- expect(bytes[3]).toBe(0x47); // G
761
- });
762
- });