vellum 0.2.1 → 0.2.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 (349) hide show
  1. package/README.md +15 -2
  2. package/bun.lock +5 -2
  3. package/package.json +4 -2
  4. package/scripts/capture-x-graphql.ts +562 -0
  5. package/scripts/ipc/check-swift-decoder-drift.ts +2 -1
  6. package/scripts/test.sh +5 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +133 -34
  8. package/src/__tests__/account-registry.test.ts +2 -1
  9. package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
  10. package/src/__tests__/asset-materialize-tool.test.ts +16 -15
  11. package/src/__tests__/asset-search-tool.test.ts +23 -22
  12. package/src/__tests__/attachments-store.test.ts +56 -127
  13. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +5 -4
  14. package/src/__tests__/browser-skill-endstate.test.ts +4 -3
  15. package/src/__tests__/call-bridge.test.ts +385 -0
  16. package/src/__tests__/call-constants.test.ts +40 -0
  17. package/src/__tests__/call-orchestrator.test.ts +130 -4
  18. package/src/__tests__/call-recovery.test.ts +518 -0
  19. package/src/__tests__/call-routes-http.test.ts +459 -0
  20. package/src/__tests__/call-state-machine.test.ts +143 -0
  21. package/src/__tests__/call-store.test.ts +216 -1
  22. package/src/__tests__/cli-discover.test.ts +1 -1
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +148 -7
  24. package/src/__tests__/compaction.benchmark.test.ts +176 -0
  25. package/src/__tests__/computer-use-tools.test.ts +250 -0
  26. package/src/__tests__/config-schema.test.ts +299 -3
  27. package/src/__tests__/conflict-store.test.ts +2 -1
  28. package/src/__tests__/contacts-tools.test.ts +331 -0
  29. package/src/__tests__/conversation-store.test.ts +30 -32
  30. package/src/__tests__/credential-security-invariants.test.ts +4 -0
  31. package/src/__tests__/date-context.test.ts +373 -0
  32. package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
  33. package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -3
  34. package/src/__tests__/followup-tools.test.ts +303 -0
  35. package/src/__tests__/handlers-twitter-config.test.ts +718 -0
  36. package/src/__tests__/intent-routing.test.ts +64 -57
  37. package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
  38. package/src/__tests__/ipc-snapshot.test.ts +62 -28
  39. package/src/__tests__/llm-usage-store.test.ts +3 -8
  40. package/src/__tests__/media-generate-image.test.ts +1 -1
  41. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  42. package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
  43. package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
  44. package/src/__tests__/playbook-tools.test.ts +342 -0
  45. package/src/__tests__/profile-compiler.test.ts +2 -1
  46. package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
  47. package/src/__tests__/recurrence-engine-rruleset.test.ts +78 -0
  48. package/src/__tests__/recurrence-engine.test.ts +69 -0
  49. package/src/__tests__/recurrence-types.test.ts +71 -0
  50. package/src/__tests__/registry.test.ts +5 -3
  51. package/src/__tests__/relay-server.test.ts +633 -0
  52. package/src/__tests__/reminder-store.test.ts +6 -3
  53. package/src/__tests__/reminder.test.ts +43 -77
  54. package/src/__tests__/run-orchestrator-assistant-events.test.ts +8 -4
  55. package/src/__tests__/run-orchestrator.test.ts +4 -4
  56. package/src/__tests__/runtime-attachment-metadata.test.ts +7 -6
  57. package/src/__tests__/runtime-runs-http.test.ts +4 -4
  58. package/src/__tests__/runtime-runs.test.ts +4 -4
  59. package/src/__tests__/schedule-store.test.ts +482 -0
  60. package/src/__tests__/schedule-tools.test.ts +700 -0
  61. package/src/__tests__/scheduler-recurrence.test.ts +329 -0
  62. package/src/__tests__/server-history-render.test.ts +14 -13
  63. package/src/__tests__/session-error.test.ts +28 -0
  64. package/src/__tests__/session-init.benchmark.test.ts +462 -0
  65. package/src/__tests__/session-queue.test.ts +71 -48
  66. package/src/__tests__/session-runtime-assembly.test.ts +161 -0
  67. package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
  68. package/src/__tests__/signup-e2e.test.ts +2 -1
  69. package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
  70. package/src/__tests__/skill-script-runner.test.ts +159 -0
  71. package/src/__tests__/speaker-identification.test.ts +52 -0
  72. package/src/__tests__/subagent-manager-notify.test.ts +42 -10
  73. package/src/__tests__/subagent-tools.test.ts +141 -41
  74. package/src/__tests__/task-compiler.test.ts +2 -1
  75. package/src/__tests__/task-runner.test.ts +2 -1
  76. package/src/__tests__/task-scheduler.test.ts +2 -1
  77. package/src/__tests__/task-tools.test.ts +49 -56
  78. package/src/__tests__/tool-audit-listener.test.ts +1 -0
  79. package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
  80. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
  81. package/src/__tests__/tool-executor.test.ts +13 -17
  82. package/src/__tests__/turn-commit.test.ts +218 -3
  83. package/src/__tests__/twilio-provider.test.ts +143 -0
  84. package/src/__tests__/twilio-routes.test.ts +789 -0
  85. package/src/__tests__/twitter-auth-handler.test.ts +581 -0
  86. package/src/__tests__/view-image-tool.test.ts +217 -0
  87. package/src/__tests__/workspace-git-service.test.ts +186 -0
  88. package/src/__tests__/workspace-heartbeat-service.test.ts +13 -3
  89. package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
  90. package/src/bundler/app-bundler.ts +12 -8
  91. package/src/calls/call-bridge.ts +95 -0
  92. package/src/calls/call-constants.ts +43 -5
  93. package/src/calls/call-domain.ts +276 -0
  94. package/src/calls/call-orchestrator.ts +43 -17
  95. package/src/calls/call-recovery.ts +207 -0
  96. package/src/calls/call-state-machine.ts +68 -0
  97. package/src/calls/call-store.ts +192 -5
  98. package/src/calls/relay-server.ts +41 -4
  99. package/src/calls/speaker-identification.ts +213 -0
  100. package/src/calls/twilio-provider.ts +10 -6
  101. package/src/calls/twilio-routes.ts +90 -76
  102. package/src/calls/types.ts +1 -1
  103. package/src/cli/config-commands.ts +334 -0
  104. package/src/cli/core-commands.ts +776 -0
  105. package/src/cli/doordash.ts +251 -1
  106. package/src/cli/ipc-client.ts +82 -0
  107. package/src/cli/map.ts +246 -0
  108. package/src/cli/twitter.ts +575 -0
  109. package/src/cli.ts +7 -5
  110. package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
  111. package/src/commands/cc-command-registry.ts +209 -0
  112. package/src/config/bundled-skills/contacts/SKILL.md +39 -0
  113. package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
  114. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
  115. package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
  116. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
  117. package/src/config/bundled-skills/document/SKILL.md +18 -0
  118. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  119. package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
  120. package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
  121. package/src/config/bundled-skills/doordash/SKILL.md +82 -23
  122. package/src/config/bundled-skills/followups/SKILL.md +32 -0
  123. package/src/config/bundled-skills/followups/TOOLS.json +100 -0
  124. package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
  125. package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
  126. package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
  127. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -23
  128. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
  129. package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
  130. package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
  131. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +9 -0
  132. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +9 -0
  133. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +9 -0
  134. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +9 -0
  135. package/src/config/bundled-skills/reminder/SKILL.md +20 -0
  136. package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
  137. package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
  138. package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
  139. package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
  140. package/src/config/bundled-skills/schedule/SKILL.md +74 -0
  141. package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
  142. package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
  143. package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
  144. package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
  145. package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
  146. package/src/config/bundled-skills/subagent/SKILL.md +25 -0
  147. package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
  148. package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
  149. package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
  150. package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
  151. package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
  152. package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
  153. package/src/config/bundled-skills/tasks/SKILL.md +28 -0
  154. package/src/config/bundled-skills/tasks/TOOLS.json +256 -0
  155. package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
  156. package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
  157. package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
  158. package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
  159. package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
  160. package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
  161. package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
  162. package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
  163. package/src/config/bundled-skills/twitter/SKILL.md +134 -0
  164. package/src/config/bundled-skills/watcher/SKILL.md +27 -0
  165. package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
  166. package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
  167. package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
  168. package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
  169. package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
  170. package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
  171. package/src/config/defaults.ts +33 -0
  172. package/src/config/loader.ts +4 -1
  173. package/src/config/schema.ts +161 -1
  174. package/src/config/system-prompt.ts +61 -16
  175. package/src/config/templates/IDENTITY.md +7 -0
  176. package/src/config/types.ts +4 -0
  177. package/src/contacts/contact-store.ts +4 -4
  178. package/src/daemon/assistant-attachments.ts +10 -0
  179. package/src/daemon/classifier.ts +3 -1
  180. package/src/daemon/computer-use-session.ts +3 -1
  181. package/src/daemon/date-context.ts +136 -0
  182. package/src/daemon/handlers/apps.ts +16 -1
  183. package/src/daemon/handlers/browser.ts +54 -0
  184. package/src/daemon/handlers/computer-use.ts +7 -1
  185. package/src/daemon/handlers/config.ts +163 -5
  186. package/src/daemon/handlers/diagnostics.ts +5 -1
  187. package/src/daemon/handlers/documents.ts +18 -29
  188. package/src/daemon/handlers/home-base.ts +5 -1
  189. package/src/daemon/handlers/index.ts +40 -277
  190. package/src/daemon/handlers/misc.ts +9 -1
  191. package/src/daemon/handlers/publish.ts +6 -1
  192. package/src/daemon/handlers/sessions.ts +65 -12
  193. package/src/daemon/handlers/shared.ts +36 -1
  194. package/src/daemon/handlers/signing.ts +37 -0
  195. package/src/daemon/handlers/skills.ts +20 -6
  196. package/src/daemon/handlers/subagents.ts +8 -3
  197. package/src/daemon/handlers/twitter-auth.ts +169 -0
  198. package/src/daemon/handlers/work-items.ts +384 -68
  199. package/src/daemon/ipc-contract-inventory.json +28 -4
  200. package/src/daemon/ipc-contract.ts +133 -37
  201. package/src/daemon/ipc-protocol.ts +7 -2
  202. package/src/daemon/lifecycle.ts +21 -0
  203. package/src/daemon/main.ts +10 -4
  204. package/src/daemon/ride-shotgun-handler.ts +74 -10
  205. package/src/daemon/server.ts +143 -26
  206. package/src/daemon/session-agent-loop.ts +887 -0
  207. package/src/daemon/session-attachments.ts +28 -5
  208. package/src/daemon/session-error.ts +24 -3
  209. package/src/daemon/session-lifecycle.ts +147 -0
  210. package/src/daemon/session-media-retry.ts +147 -0
  211. package/src/daemon/session-messaging.ts +145 -0
  212. package/src/daemon/session-notifiers.ts +164 -0
  213. package/src/daemon/session-process.ts +2 -2
  214. package/src/daemon/session-queue-manager.ts +1 -0
  215. package/src/daemon/session-runtime-assembly.ts +52 -0
  216. package/src/daemon/session-skill-tools.ts +124 -5
  217. package/src/daemon/session-slash.ts +3 -0
  218. package/src/daemon/session-surfaces.ts +77 -2
  219. package/src/daemon/session-tool-setup.ts +216 -2
  220. package/src/daemon/session-usage.ts +0 -2
  221. package/src/daemon/session.ts +114 -1404
  222. package/src/daemon/video-thumbnail.ts +60 -0
  223. package/src/doordash/client.ts +121 -27
  224. package/src/doordash/queries.ts +1 -2
  225. package/src/export/formatter.ts +3 -1
  226. package/src/followups/followup-store.ts +4 -2
  227. package/src/followups/types.ts +6 -0
  228. package/src/hooks/templates.ts +1 -1
  229. package/src/index.ts +32 -1153
  230. package/src/memory/attachments-store.ts +28 -83
  231. package/src/memory/channel-delivery-store.ts +7 -21
  232. package/src/memory/clarification-resolver.ts +6 -5
  233. package/src/memory/contradiction-checker.ts +3 -2
  234. package/src/memory/conversation-key-store.ts +10 -29
  235. package/src/memory/conversation-store.ts +2 -1
  236. package/src/memory/db.ts +96 -2
  237. package/src/memory/entity-extractor.ts +6 -3
  238. package/src/memory/items-extractor.ts +5 -4
  239. package/src/memory/jobs-store.ts +3 -2
  240. package/src/memory/llm-usage-store.ts +1 -2
  241. package/src/memory/runs-store.ts +1 -2
  242. package/src/memory/schema.ts +23 -2
  243. package/src/messaging/style-analyzer.ts +3 -2
  244. package/src/messaging/thread-summarizer.ts +8 -12
  245. package/src/messaging/triage-engine.ts +4 -2
  246. package/src/providers/openrouter/client.ts +20 -0
  247. package/src/providers/registry.ts +8 -0
  248. package/src/runtime/http-server.ts +108 -20
  249. package/src/runtime/routes/attachment-routes.ts +2 -3
  250. package/src/runtime/routes/call-routes.ts +140 -0
  251. package/src/runtime/routes/channel-routes.ts +5 -10
  252. package/src/runtime/routes/conversation-routes.ts +5 -5
  253. package/src/runtime/routes/run-routes.ts +2 -2
  254. package/src/runtime/run-orchestrator.ts +9 -3
  255. package/src/schedule/recurrence-engine.ts +138 -0
  256. package/src/schedule/recurrence-types.ts +67 -0
  257. package/src/schedule/schedule-store.ts +102 -57
  258. package/src/schedule/scheduler.ts +9 -6
  259. package/src/security/oauth2.ts +29 -4
  260. package/src/security/secret-allowlist.ts +46 -0
  261. package/src/skills/clawhub.ts +1 -1
  262. package/src/subagent/manager.ts +40 -8
  263. package/src/swarm/backend-claude-code.ts +64 -9
  264. package/src/swarm/worker-prompts.ts +2 -1
  265. package/src/tasks/SPEC.md +34 -28
  266. package/src/tasks/ephemeral-permissions.ts +16 -7
  267. package/src/tasks/task-compiler.ts +5 -4
  268. package/src/tasks/task-runner.ts +10 -5
  269. package/src/tasks/task-scheduler.ts +1 -1
  270. package/src/tasks/tool-sanitizer.ts +36 -0
  271. package/src/tools/assets/search.ts +4 -4
  272. package/src/tools/browser/api-map.ts +220 -0
  273. package/src/tools/browser/auto-navigate.ts +270 -0
  274. package/src/tools/browser/browser-execution.ts +2 -1
  275. package/src/tools/browser/browser-manager.ts +2 -2
  276. package/src/tools/browser/network-recorder.ts +5 -4
  277. package/src/tools/browser/x-auto-navigate.ts +207 -0
  278. package/src/tools/calls/call-end.ts +17 -67
  279. package/src/tools/calls/call-start.ts +24 -85
  280. package/src/tools/calls/call-status.ts +35 -51
  281. package/src/tools/claude-code/claude-code.ts +77 -11
  282. package/src/tools/contacts/contact-merge.ts +46 -78
  283. package/src/tools/contacts/contact-search.ts +35 -79
  284. package/src/tools/contacts/contact-upsert.ts +35 -108
  285. package/src/tools/credentials/vault.ts +20 -4
  286. package/src/tools/document/document-tool.ts +71 -144
  287. package/src/tools/executor.ts +129 -10
  288. package/src/tools/followups/followup_create.ts +46 -88
  289. package/src/tools/followups/followup_list.ts +34 -74
  290. package/src/tools/followups/followup_resolve.ts +31 -66
  291. package/src/tools/host-terminal/cli-discover.ts +2 -1
  292. package/src/tools/host-terminal/host-shell.ts +10 -0
  293. package/src/tools/memory/handlers.ts +5 -4
  294. package/src/tools/network/__tests__/web-search.test.ts +427 -0
  295. package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
  296. package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
  297. package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
  298. package/src/tools/network/web-fetch.ts +18 -6
  299. package/src/tools/playbooks/index.ts +4 -5
  300. package/src/tools/playbooks/playbook-create.ts +3 -47
  301. package/src/tools/playbooks/playbook-delete.ts +1 -25
  302. package/src/tools/playbooks/playbook-list.ts +1 -28
  303. package/src/tools/playbooks/playbook-update.ts +3 -51
  304. package/src/tools/reminder/reminder.ts +5 -78
  305. package/src/tools/schedule/create.ts +69 -74
  306. package/src/tools/schedule/delete.ts +21 -47
  307. package/src/tools/schedule/list.ts +55 -74
  308. package/src/tools/schedule/update.ts +77 -84
  309. package/src/tools/subagent/abort.ts +29 -58
  310. package/src/tools/subagent/message.ts +30 -63
  311. package/src/tools/subagent/read.ts +53 -84
  312. package/src/tools/subagent/spawn.ts +43 -82
  313. package/src/tools/subagent/status.ts +42 -71
  314. package/src/tools/swarm/delegate.ts +2 -1
  315. package/src/tools/tasks/index.ts +8 -8
  316. package/src/tools/tasks/task-delete.ts +60 -88
  317. package/src/tools/tasks/task-list.ts +31 -52
  318. package/src/tools/tasks/task-run.ts +72 -108
  319. package/src/tools/tasks/task-save.ts +33 -65
  320. package/src/tools/tasks/work-item-enqueue.ts +183 -215
  321. package/src/tools/tasks/work-item-list.ts +33 -63
  322. package/src/tools/tasks/work-item-remove.ts +45 -97
  323. package/src/tools/tasks/work-item-update.ts +91 -163
  324. package/src/tools/terminal/backends/native.ts +3 -1
  325. package/src/tools/tool-manifest.ts +0 -62
  326. package/src/tools/types.ts +6 -0
  327. package/src/tools/ui-surface/definitions.ts +3 -1
  328. package/src/tools/watch/screen-watch.ts +3 -1
  329. package/src/tools/watcher/create.ts +52 -98
  330. package/src/tools/watcher/delete.ts +20 -46
  331. package/src/tools/watcher/digest.ts +36 -70
  332. package/src/tools/watcher/list.ts +49 -79
  333. package/src/tools/watcher/update.ts +45 -91
  334. package/src/twitter/client.ts +690 -0
  335. package/src/twitter/session.ts +91 -0
  336. package/src/usage/types.ts +0 -1
  337. package/src/util/truncate.ts +6 -0
  338. package/src/watcher/providers/slack.ts +2 -1
  339. package/src/watcher/watcher-store.ts +3 -2
  340. package/src/work-items/work-item-store.ts +27 -2
  341. package/src/workspace/commit-message-enrichment-service.ts +31 -7
  342. package/src/workspace/git-service.ts +87 -22
  343. package/src/workspace/provider-commit-message-generator.ts +242 -0
  344. package/src/workspace/turn-commit.ts +62 -3
  345. package/src/tools/contacts/index.ts +0 -4
  346. package/src/tools/document/index.ts +0 -5
  347. package/src/tools/followups/index.ts +0 -3
  348. package/src/tools/subagent/index.ts +0 -5
  349. /package/src/__tests__/{memory-context-benchmark.test.ts → memory-context-benchmark.benchmark.test.ts} +0 -0
@@ -0,0 +1,427 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
3
+
4
+ // Mutable mock state — set per test
5
+ let mockWebSearchProvider: string | undefined = 'perplexity';
6
+ let mockBraveConfigKey: string | undefined;
7
+ let mockPerplexityConfigKey: string | undefined;
8
+ let mockBraveSecureKey: string | undefined;
9
+ let mockPerplexitySecureKey: string | undefined;
10
+
11
+ // Capture the registered tool
12
+ let capturedTool: any = null;
13
+
14
+ mock.module('../../registry.js', () => ({
15
+ registerTool: (tool: any) => { capturedTool = tool; },
16
+ }));
17
+
18
+ mock.module('../../../config/loader.js', () => ({
19
+ getConfig: () => ({
20
+ webSearchProvider: mockWebSearchProvider,
21
+ apiKeys: { brave: mockBraveConfigKey, perplexity: mockPerplexityConfigKey },
22
+ }),
23
+ }));
24
+
25
+ mock.module('../../../security/secure-keys.js', () => ({
26
+ getSecureKey: (provider: string) => {
27
+ if (provider === 'brave') return mockBraveSecureKey;
28
+ if (provider === 'perplexity') return mockPerplexitySecureKey;
29
+ return undefined;
30
+ },
31
+ }));
32
+
33
+ mock.module('../../../util/logger.js', () => ({
34
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
35
+ get: () => () => {},
36
+ }),
37
+ }));
38
+
39
+ mock.module('../../../permissions/types.js', () => ({
40
+ RiskLevel: { Low: 'low', Medium: 'medium', High: 'high' },
41
+ }));
42
+
43
+ // Force the module to load (triggers registerTool)
44
+ await import('../web-search.js');
45
+
46
+ describe('web_search tool', () => {
47
+ let originalFetch: typeof globalThis.fetch;
48
+ let savedBraveKey: string | undefined;
49
+ let savedPerplexityKey: string | undefined;
50
+
51
+ beforeEach(() => {
52
+ originalFetch = globalThis.fetch;
53
+ mockWebSearchProvider = 'perplexity';
54
+ mockBraveConfigKey = undefined;
55
+ mockPerplexityConfigKey = undefined;
56
+ mockBraveSecureKey = undefined;
57
+ mockPerplexitySecureKey = undefined;
58
+
59
+ // Isolate from host env so getApiKey() doesn't short-circuit on real keys
60
+ savedBraveKey = process.env.BRAVE_API_KEY;
61
+ savedPerplexityKey = process.env.PERPLEXITY_API_KEY;
62
+ delete process.env.BRAVE_API_KEY;
63
+ delete process.env.PERPLEXITY_API_KEY;
64
+ });
65
+
66
+ afterEach(() => {
67
+ globalThis.fetch = originalFetch;
68
+
69
+ if (savedBraveKey !== undefined) process.env.BRAVE_API_KEY = savedBraveKey;
70
+ else delete process.env.BRAVE_API_KEY;
71
+
72
+ if (savedPerplexityKey !== undefined) process.env.PERPLEXITY_API_KEY = savedPerplexityKey;
73
+ else delete process.env.PERPLEXITY_API_KEY;
74
+ });
75
+
76
+ function execute(input: Record<string, unknown>) {
77
+ return capturedTool.execute(input, {} as any);
78
+ }
79
+
80
+ // ---- Input validation ---------------------------------------------------
81
+
82
+ test('rejects missing query', async () => {
83
+ const result = await execute({});
84
+ expect(result.isError).toBe(true);
85
+ expect(result.content).toContain('query is required');
86
+ });
87
+
88
+ test('rejects non-string query', async () => {
89
+ const result = await execute({ query: 42 });
90
+ expect(result.isError).toBe(true);
91
+ expect(result.content).toContain('query is required');
92
+ });
93
+
94
+ // ---- No API key configured ----------------------------------------------
95
+
96
+ test('returns error when no API key is available', async () => {
97
+ const result = await execute({ query: 'test' });
98
+ expect(result.isError).toBe(true);
99
+ expect(result.content).toContain('No web search API key configured');
100
+ });
101
+
102
+ // ---- Perplexity provider ------------------------------------------------
103
+
104
+ test('executes Perplexity search successfully', async () => {
105
+ mockPerplexityConfigKey = 'pplx-test-key';
106
+ globalThis.fetch = (async (_url: string, _init?: RequestInit) => {
107
+ return new Response(JSON.stringify({
108
+ choices: [{ message: { content: 'Perplexity answer about TypeScript' } }],
109
+ citations: ['https://typescriptlang.org', 'https://example.com/ts'],
110
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
111
+ }) as any;
112
+
113
+ const result = await execute({ query: 'what is TypeScript' });
114
+ expect(result.isError).toBe(false);
115
+ expect(result.content).toContain('Perplexity answer about TypeScript');
116
+ expect(result.content).toContain('Sources:');
117
+ expect(result.content).toContain('typescriptlang.org');
118
+ });
119
+
120
+ test('Perplexity sends correct request format', async () => {
121
+ mockPerplexityConfigKey = 'pplx-test-key';
122
+ let capturedUrl = '';
123
+ let capturedBody: any = null;
124
+ let capturedHeaders: any = null;
125
+ globalThis.fetch = (async (url: string, init?: RequestInit) => {
126
+ capturedUrl = url;
127
+ capturedBody = JSON.parse(init?.body as string);
128
+ capturedHeaders = new Headers(init?.headers);
129
+ return new Response(JSON.stringify({
130
+ choices: [{ message: { content: 'answer' } }],
131
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
132
+ }) as any;
133
+
134
+ await execute({ query: 'test query' });
135
+ expect(capturedUrl).toContain('perplexity.ai');
136
+ expect(capturedBody.model).toBe('sonar');
137
+ expect(capturedBody.messages[0].content).toBe('test query');
138
+ expect(capturedHeaders.get('authorization')).toBe('Bearer pplx-test-key');
139
+ });
140
+
141
+ test('Perplexity returns no results message when response is empty', async () => {
142
+ mockPerplexityConfigKey = 'pplx-test-key';
143
+ globalThis.fetch = (async () => {
144
+ return new Response(JSON.stringify({ choices: [] }), {
145
+ status: 200, headers: { 'content-type': 'application/json' },
146
+ });
147
+ }) as any;
148
+
149
+ const result = await execute({ query: 'obscure query' });
150
+ expect(result.isError).toBe(false);
151
+ expect(result.content).toContain('No results found');
152
+ });
153
+
154
+ test('Perplexity handles 401/403 auth errors', async () => {
155
+ mockPerplexityConfigKey = 'bad-key';
156
+ globalThis.fetch = (async () => {
157
+ return new Response('Unauthorized', { status: 401 });
158
+ }) as any;
159
+
160
+ const result = await execute({ query: 'test' });
161
+ expect(result.isError).toBe(true);
162
+ expect(result.content).toContain('Invalid or expired Perplexity API key');
163
+ });
164
+
165
+ test('Perplexity handles 429 rate limit after max retries', async () => {
166
+ mockPerplexityConfigKey = 'pplx-key';
167
+ let callCount = 0;
168
+ globalThis.fetch = (async () => {
169
+ callCount++;
170
+ return new Response('Too Many Requests', {
171
+ status: 429,
172
+ headers: { 'retry-after': '0' },
173
+ });
174
+ }) as any;
175
+
176
+ const result = await execute({ query: 'test' });
177
+ expect(result.isError).toBe(true);
178
+ expect(result.content).toContain('rate limit exceeded');
179
+ // 1 initial + 3 retries = 4 calls
180
+ expect(callCount).toBe(4);
181
+ });
182
+
183
+ test('Perplexity handles generic server error', async () => {
184
+ mockPerplexityConfigKey = 'pplx-key';
185
+ globalThis.fetch = (async () => {
186
+ return new Response('Internal Server Error', { status: 500 });
187
+ }) as any;
188
+
189
+ const result = await execute({ query: 'test' });
190
+ expect(result.isError).toBe(true);
191
+ expect(result.content).toContain('status 500');
192
+ });
193
+
194
+ // ---- Brave provider -----------------------------------------------------
195
+
196
+ test('executes Brave search successfully', async () => {
197
+ mockWebSearchProvider = 'brave';
198
+ mockBraveConfigKey = 'brave-test-key';
199
+ globalThis.fetch = (async (_url: string) => {
200
+ return new Response(JSON.stringify({
201
+ web: {
202
+ results: [
203
+ { title: 'Result 1', url: 'https://example.com/1', description: 'First result', age: '2 days ago' },
204
+ { title: 'Result 2', url: 'https://example.com/2', description: 'Second result', extra_snippets: ['Extra info'] },
205
+ ],
206
+ },
207
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
208
+ }) as any;
209
+
210
+ const result = await execute({ query: 'test search' });
211
+ expect(result.isError).toBe(false);
212
+ expect(result.content).toContain('Result 1');
213
+ expect(result.content).toContain('https://example.com/1');
214
+ expect(result.content).toContain('2 days ago');
215
+ expect(result.content).toContain('Result 2');
216
+ expect(result.content).toContain('Extra info');
217
+ });
218
+
219
+ test('Brave sends correct query parameters', async () => {
220
+ mockWebSearchProvider = 'brave';
221
+ mockBraveConfigKey = 'brave-key';
222
+ let capturedUrl = '';
223
+ globalThis.fetch = (async (url: string) => {
224
+ capturedUrl = url;
225
+ return new Response(JSON.stringify({ web: { results: [] } }), {
226
+ status: 200, headers: { 'content-type': 'application/json' },
227
+ });
228
+ }) as any;
229
+
230
+ await execute({ query: 'test query', count: 5, offset: 2, freshness: 'pw' });
231
+ const parsed = new URL(capturedUrl);
232
+ expect(parsed.searchParams.get('q')).toBe('test query');
233
+ expect(parsed.searchParams.get('count')).toBe('5');
234
+ expect(parsed.searchParams.get('offset')).toBe('2');
235
+ expect(parsed.searchParams.get('freshness')).toBe('pw');
236
+ });
237
+
238
+ test('Brave clamps count and offset', async () => {
239
+ mockWebSearchProvider = 'brave';
240
+ mockBraveConfigKey = 'brave-key';
241
+ let capturedUrl = '';
242
+ globalThis.fetch = (async (url: string) => {
243
+ capturedUrl = url;
244
+ return new Response(JSON.stringify({ web: { results: [] } }), {
245
+ status: 200, headers: { 'content-type': 'application/json' },
246
+ });
247
+ }) as any;
248
+
249
+ await execute({ query: 'test', count: 100, offset: 50 });
250
+ const parsed = new URL(capturedUrl);
251
+ expect(parsed.searchParams.get('count')).toBe('20');
252
+ expect(parsed.searchParams.get('offset')).toBe('9');
253
+ });
254
+
255
+ test('Brave skips invalid freshness values', async () => {
256
+ mockWebSearchProvider = 'brave';
257
+ mockBraveConfigKey = 'brave-key';
258
+ let capturedUrl = '';
259
+ globalThis.fetch = (async (url: string) => {
260
+ capturedUrl = url;
261
+ return new Response(JSON.stringify({ web: { results: [] } }), {
262
+ status: 200, headers: { 'content-type': 'application/json' },
263
+ });
264
+ }) as any;
265
+
266
+ await execute({ query: 'test', freshness: 'invalid' });
267
+ const parsed = new URL(capturedUrl);
268
+ expect(parsed.searchParams.has('freshness')).toBe(false);
269
+ });
270
+
271
+ test('Brave handles empty results', async () => {
272
+ mockWebSearchProvider = 'brave';
273
+ mockBraveConfigKey = 'brave-key';
274
+ globalThis.fetch = (async () => {
275
+ return new Response(JSON.stringify({ web: { results: [] } }), {
276
+ status: 200, headers: { 'content-type': 'application/json' },
277
+ });
278
+ }) as any;
279
+
280
+ const result = await execute({ query: 'no results for this' });
281
+ expect(result.isError).toBe(false);
282
+ expect(result.content).toContain('No results found');
283
+ });
284
+
285
+ test('Brave handles 401 auth error', async () => {
286
+ mockWebSearchProvider = 'brave';
287
+ mockBraveConfigKey = 'bad-key';
288
+ globalThis.fetch = (async () => {
289
+ return new Response('Forbidden', { status: 403 });
290
+ }) as any;
291
+
292
+ const result = await execute({ query: 'test' });
293
+ expect(result.isError).toBe(true);
294
+ expect(result.content).toContain('Invalid or expired Brave Search API key');
295
+ });
296
+
297
+ test('Brave handles 429 rate limit with Retry-After header', async () => {
298
+ mockWebSearchProvider = 'brave';
299
+ mockBraveConfigKey = 'brave-key';
300
+ let callCount = 0;
301
+ globalThis.fetch = (async () => {
302
+ callCount++;
303
+ if (callCount <= 3) {
304
+ return new Response('Rate Limited', {
305
+ status: 429,
306
+ headers: { 'retry-after': '0' },
307
+ });
308
+ }
309
+ return new Response(JSON.stringify({ web: { results: [{ title: 'Success', url: 'https://example.com', description: 'Got it' }] } }), {
310
+ status: 200, headers: { 'content-type': 'application/json' },
311
+ });
312
+ }) as any;
313
+
314
+ const result = await execute({ query: 'test' });
315
+ expect(result.isError).toBe(false);
316
+ expect(result.content).toContain('Success');
317
+ expect(callCount).toBe(4);
318
+ });
319
+
320
+ // ---- Provider fallback --------------------------------------------------
321
+
322
+ test('falls back from perplexity to brave when perplexity has no key', async () => {
323
+ mockWebSearchProvider = 'perplexity';
324
+ mockBraveConfigKey = 'brave-fallback-key';
325
+ let capturedUrl = '';
326
+ globalThis.fetch = (async (url: string) => {
327
+ capturedUrl = url;
328
+ return new Response(JSON.stringify({ web: { results: [] } }), {
329
+ status: 200, headers: { 'content-type': 'application/json' },
330
+ });
331
+ }) as any;
332
+
333
+ const result = await execute({ query: 'fallback test' });
334
+ expect(result.isError).toBe(false);
335
+ expect(capturedUrl).toContain('brave');
336
+ });
337
+
338
+ test('falls back from brave to perplexity when brave has no key', async () => {
339
+ mockWebSearchProvider = 'brave';
340
+ mockPerplexityConfigKey = 'pplx-fallback-key';
341
+ let capturedUrl = '';
342
+ globalThis.fetch = (async (url: string, _init?: RequestInit) => {
343
+ capturedUrl = url;
344
+ return new Response(JSON.stringify({
345
+ choices: [{ message: { content: 'fallback result' } }],
346
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
347
+ }) as any;
348
+
349
+ const result = await execute({ query: 'fallback test' });
350
+ expect(result.isError).toBe(false);
351
+ expect(capturedUrl).toContain('perplexity');
352
+ });
353
+
354
+ test('maps anthropic-native to perplexity', async () => {
355
+ mockWebSearchProvider = 'anthropic-native';
356
+ mockPerplexityConfigKey = 'pplx-key';
357
+ let capturedUrl = '';
358
+ globalThis.fetch = (async (url: string) => {
359
+ capturedUrl = url;
360
+ return new Response(JSON.stringify({
361
+ choices: [{ message: { content: 'result' } }],
362
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
363
+ }) as any;
364
+
365
+ const result = await execute({ query: 'test' });
366
+ expect(result.isError).toBe(false);
367
+ expect(capturedUrl).toContain('perplexity');
368
+ });
369
+
370
+ // ---- Env var keys -------------------------------------------------------
371
+
372
+ test('uses PERPLEXITY_API_KEY env var when available', async () => {
373
+ const origEnv = process.env.PERPLEXITY_API_KEY;
374
+ process.env.PERPLEXITY_API_KEY = 'env-pplx-key';
375
+ try {
376
+ globalThis.fetch = (async (_url: string, init?: RequestInit) => {
377
+ const headers = new Headers(init?.headers);
378
+ expect(headers.get('authorization')).toBe('Bearer env-pplx-key');
379
+ return new Response(JSON.stringify({
380
+ choices: [{ message: { content: 'env key works' } }],
381
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
382
+ }) as any;
383
+
384
+ const result = await execute({ query: 'test' });
385
+ expect(result.isError).toBe(false);
386
+ } finally {
387
+ if (origEnv === undefined) {
388
+ delete process.env.PERPLEXITY_API_KEY;
389
+ } else {
390
+ process.env.PERPLEXITY_API_KEY = origEnv;
391
+ }
392
+ }
393
+ });
394
+
395
+ // ---- Network errors -----------------------------------------------------
396
+
397
+ test('handles fetch exceptions', async () => {
398
+ mockPerplexityConfigKey = 'pplx-key';
399
+ globalThis.fetch = (async () => {
400
+ throw new Error('Network error: connection refused');
401
+ }) as any;
402
+
403
+ const result = await execute({ query: 'test' });
404
+ expect(result.isError).toBe(true);
405
+ expect(result.content).toContain('Web search failed');
406
+ expect(result.content).toContain('connection refused');
407
+ });
408
+
409
+ // ---- Secure key precedence ----------------------------------------------
410
+
411
+ test('prefers secure key over config key for brave', async () => {
412
+ mockWebSearchProvider = 'brave';
413
+ mockBraveConfigKey = 'config-key';
414
+ mockBraveSecureKey = 'secure-key';
415
+ let capturedHeaders: Headers | null = null;
416
+ globalThis.fetch = (async (_url: string, init?: RequestInit) => {
417
+ capturedHeaders = new Headers(init?.headers);
418
+ return new Response(JSON.stringify({ web: { results: [] } }), {
419
+ status: 200, headers: { 'content-type': 'application/json' },
420
+ });
421
+ }) as any;
422
+
423
+ await execute({ query: 'test' });
424
+ // Brave uses X-Subscription-Token header with the secure key
425
+ expect(capturedHeaders!.get('x-subscription-token')).toBe('secure-key');
426
+ });
427
+ });
@@ -0,0 +1,248 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import {
3
+ sanitizeHeaders,
4
+ sanitizeUrl,
5
+ createSafeLogEntry,
6
+ stripQueryString,
7
+ buildDecisionTrace,
8
+ buildCredentialRefTrace,
9
+ } from '../logging.js';
10
+ import type { PolicyDecision } from '../types.js';
11
+ import type { CredentialInjectionTemplate } from '../../../credentials/policy-types.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // sanitizeHeaders
15
+ // ---------------------------------------------------------------------------
16
+
17
+ describe('sanitizeHeaders', () => {
18
+ test('redacts sensitive keys', () => {
19
+ const headers = {
20
+ 'Authorization': 'secret-value',
21
+ 'Content-Type': 'application/json',
22
+ 'X-API-Key': 'another-secret',
23
+ };
24
+ const result = sanitizeHeaders(headers, ['authorization', 'x-api-key']);
25
+ expect(result['Authorization']).toBe('[REDACTED]');
26
+ expect(result['Content-Type']).toBe('application/json');
27
+ expect(result['X-API-Key']).toBe('[REDACTED]');
28
+ });
29
+
30
+ test('case-insensitive matching', () => {
31
+ const headers = { 'authorization': 'bearer xyz' };
32
+ const result = sanitizeHeaders(headers, ['Authorization']);
33
+ expect(result['authorization']).toBe('[REDACTED]');
34
+ });
35
+
36
+ test('preserves non-sensitive headers', () => {
37
+ const headers = { 'Accept': 'text/html', 'Host': 'example.com' };
38
+ const result = sanitizeHeaders(headers, ['authorization']);
39
+ expect(result).toEqual(headers);
40
+ });
41
+
42
+ test('handles empty headers', () => {
43
+ const result = sanitizeHeaders({}, ['authorization']);
44
+ expect(result).toEqual({});
45
+ });
46
+
47
+ test('handles empty sensitive keys', () => {
48
+ const headers = { 'Authorization': 'bearer xyz' };
49
+ const result = sanitizeHeaders(headers, []);
50
+ expect(result['Authorization']).toBe('bearer xyz');
51
+ });
52
+ });
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // sanitizeUrl
56
+ // ---------------------------------------------------------------------------
57
+
58
+ describe('sanitizeUrl', () => {
59
+ test('redacts sensitive query params from absolute URL', () => {
60
+ const result = sanitizeUrl('https://api.example.com/v1?api_key=secret123&format=json', ['api_key']);
61
+ expect(result).toContain('api_key=%5BREDACTED%5D');
62
+ expect(result).toContain('format=json');
63
+ expect(result).not.toContain('secret123');
64
+ });
65
+
66
+ test('redacts sensitive query params from relative path', () => {
67
+ const result = sanitizeUrl('/v1/search?token=abc&q=hello', ['token']);
68
+ expect(result).toContain('token=%5BREDACTED%5D');
69
+ expect(result).toContain('q=hello');
70
+ expect(result).not.toContain('abc');
71
+ });
72
+
73
+ test('returns URL unchanged when no sensitive params', () => {
74
+ const url = 'https://api.example.com/v1?format=json';
75
+ expect(sanitizeUrl(url, ['api_key'])).toBe(url);
76
+ });
77
+
78
+ test('returns URL unchanged when sensitiveParams is empty', () => {
79
+ const url = 'https://api.example.com/v1?api_key=secret';
80
+ expect(sanitizeUrl(url, [])).toBe(url);
81
+ });
82
+
83
+ test('returns URL unchanged when no query string', () => {
84
+ expect(sanitizeUrl('https://api.example.com/v1', ['api_key']))
85
+ .toBe('https://api.example.com/v1');
86
+ });
87
+
88
+ test('case-insensitive param matching', () => {
89
+ const result = sanitizeUrl('https://api.example.com/v1?API_KEY=secret', ['api_key']);
90
+ expect(result).not.toContain('secret');
91
+ });
92
+
93
+ test('strips query string entirely for unparseable URLs', () => {
94
+ // Malformed URL that URL constructor can't parse
95
+ const result = sanitizeUrl('http://[invalid:url?key=secret', ['key']);
96
+ expect(result).not.toContain('secret');
97
+ });
98
+ });
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // createSafeLogEntry
102
+ // ---------------------------------------------------------------------------
103
+
104
+ describe('createSafeLogEntry', () => {
105
+ test('sanitizes both URL and headers', () => {
106
+ const req = {
107
+ method: 'GET',
108
+ url: '/api?token=secret',
109
+ headers: { 'Authorization': 'Bearer xyz', 'Accept': 'application/json' },
110
+ };
111
+ const result = createSafeLogEntry(req, ['authorization', 'token']);
112
+ expect(result.method).toBe('GET');
113
+ expect(result.url).not.toContain('secret');
114
+ expect(result.headers['Authorization']).toBe('[REDACTED]');
115
+ expect(result.headers['Accept']).toBe('application/json');
116
+ });
117
+ });
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // stripQueryString
121
+ // ---------------------------------------------------------------------------
122
+
123
+ describe('stripQueryString', () => {
124
+ test('strips query from path', () => {
125
+ expect(stripQueryString('/api/v1?key=value')).toBe('/api/v1');
126
+ });
127
+
128
+ test('returns path unchanged when no query', () => {
129
+ expect(stripQueryString('/api/v1')).toBe('/api/v1');
130
+ });
131
+
132
+ test('handles empty path', () => {
133
+ expect(stripQueryString('')).toBe('');
134
+ });
135
+
136
+ test('handles query-only', () => {
137
+ expect(stripQueryString('?key=value')).toBe('');
138
+ });
139
+ });
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // buildDecisionTrace
143
+ // ---------------------------------------------------------------------------
144
+
145
+ describe('buildDecisionTrace', () => {
146
+ test('matched decision', () => {
147
+ const decision: PolicyDecision = {
148
+ kind: 'matched',
149
+ credentialId: 'cred-1',
150
+ template: {
151
+ hostPattern: '*.example.com',
152
+ injectionType: 'header',
153
+ headerName: 'Authorization',
154
+ valuePrefix: 'Bearer ',
155
+ },
156
+ };
157
+ const trace = buildDecisionTrace('api.example.com', 443, '/api?key=secret', 'https', decision);
158
+ expect(trace.host).toBe('api.example.com');
159
+ expect(trace.port).toBe(443);
160
+ expect(trace.path).toBe('/api');
161
+ expect(trace.scheme).toBe('https');
162
+ expect(trace.decisionKind).toBe('matched');
163
+ expect(trace.candidateCount).toBe(1);
164
+ expect(trace.selectedPattern).toBe('*.example.com');
165
+ expect(trace.selectedCredentialId).toBe('cred-1');
166
+ });
167
+
168
+ test('ambiguous decision', () => {
169
+ const decision: PolicyDecision = {
170
+ kind: 'ambiguous',
171
+ candidates: [
172
+ { credentialId: 'cred-1', template: { hostPattern: '*.example.com', injectionType: 'header' } as CredentialInjectionTemplate },
173
+ { credentialId: 'cred-2', template: { hostPattern: '*.example.com', injectionType: 'header' } as CredentialInjectionTemplate },
174
+ ],
175
+ };
176
+ const trace = buildDecisionTrace('api.example.com', null, '/', 'https', decision);
177
+ expect(trace.decisionKind).toBe('ambiguous');
178
+ expect(trace.candidateCount).toBe(2);
179
+ expect(trace.selectedPattern).toBeNull();
180
+ expect(trace.selectedCredentialId).toBeNull();
181
+ });
182
+
183
+ test('missing decision', () => {
184
+ const decision: PolicyDecision = { kind: 'missing' };
185
+ const trace = buildDecisionTrace('unknown.com', null, '/', 'https', decision);
186
+ expect(trace.decisionKind).toBe('missing');
187
+ expect(trace.candidateCount).toBe(0);
188
+ });
189
+
190
+ test('unauthenticated decision', () => {
191
+ const decision: PolicyDecision = { kind: 'unauthenticated' };
192
+ const trace = buildDecisionTrace('example.com', null, '/', 'http', decision);
193
+ expect(trace.decisionKind).toBe('unauthenticated');
194
+ expect(trace.candidateCount).toBe(0);
195
+ });
196
+
197
+ test('ask_missing_credential decision', () => {
198
+ const decision: PolicyDecision = {
199
+ kind: 'ask_missing_credential',
200
+ target: { hostname: 'api.example.com', port: null, path: '/', scheme: 'https' },
201
+ matchingPatterns: ['*.example.com', 'api.example.com'],
202
+ };
203
+ const trace = buildDecisionTrace('api.example.com', null, '/', 'https', decision);
204
+ expect(trace.decisionKind).toBe('ask_missing_credential');
205
+ expect(trace.candidateCount).toBe(2);
206
+ });
207
+
208
+ test('ask_unauthenticated decision', () => {
209
+ const decision: PolicyDecision = {
210
+ kind: 'ask_unauthenticated',
211
+ target: { hostname: 'unknown.com', port: null, path: '/', scheme: 'https' },
212
+ };
213
+ const trace = buildDecisionTrace('unknown.com', null, '/', 'https', decision);
214
+ expect(trace.decisionKind).toBe('ask_unauthenticated');
215
+ expect(trace.candidateCount).toBe(0);
216
+ });
217
+
218
+ test('strips query string from path to avoid leaking secrets', () => {
219
+ const decision: PolicyDecision = { kind: 'unauthenticated' };
220
+ const trace = buildDecisionTrace('example.com', null, '/api?secret=abc', 'https', decision);
221
+ expect(trace.path).toBe('/api');
222
+ expect(trace.path).not.toContain('secret');
223
+ });
224
+ });
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // buildCredentialRefTrace
228
+ // ---------------------------------------------------------------------------
229
+
230
+ describe('buildCredentialRefTrace', () => {
231
+ test('builds trace with all fields', () => {
232
+ const trace = buildCredentialRefTrace(
233
+ ['my-api-key', 'unknown-ref'],
234
+ ['uuid-1'],
235
+ ['unknown-ref'],
236
+ );
237
+ expect(trace.rawRefs).toEqual(['my-api-key', 'unknown-ref']);
238
+ expect(trace.resolvedIds).toEqual(['uuid-1']);
239
+ expect(trace.unresolvedRefs).toEqual(['unknown-ref']);
240
+ });
241
+
242
+ test('handles empty arrays', () => {
243
+ const trace = buildCredentialRefTrace([], [], []);
244
+ expect(trace.rawRefs).toEqual([]);
245
+ expect(trace.resolvedIds).toEqual([]);
246
+ expect(trace.unresolvedRefs).toEqual([]);
247
+ });
248
+ });