@vellumai/assistant 0.10.9 → 0.10.10-dev.202607162206.d08e98e

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 (456) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/Dockerfile +8 -0
  3. package/docs/activation-funnel-telemetry.md +13 -7
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/redacted-credential.test.ts +200 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/redacted-credential.ts +226 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/redacted-credential.test.ts +200 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/redacted-credential.ts +226 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/redacted-credential.test.ts +200 -0
  12. package/node_modules/@vellumai/service-contracts/src/redacted-credential.ts +226 -0
  13. package/openapi.yaml +530 -107
  14. package/package.json +1 -1
  15. package/scripts/generate-openapi.ts +8 -0
  16. package/src/__tests__/activation-early-marking.test.ts +6 -5
  17. package/src/__tests__/agent-loop-override-profile.test.ts +22 -25
  18. package/src/__tests__/agent-wake-override-profile.test.ts +21 -48
  19. package/src/__tests__/app-builder-tool-scripts.test.ts +0 -1
  20. package/src/__tests__/app-bundler.test.ts +4 -12
  21. package/src/__tests__/app-executors.test.ts +2 -49
  22. package/src/__tests__/app-routes-csp.test.ts +114 -146
  23. package/src/__tests__/auth-fallback-events-store.test.ts +8 -1
  24. package/src/__tests__/build-persisted-content.test.ts +96 -0
  25. package/src/__tests__/bundle-scanner.test.ts +27 -1
  26. package/src/__tests__/call-controller.test.ts +291 -0
  27. package/src/__tests__/call-site-routing-connection-auto-resolve.test.ts +165 -0
  28. package/src/__tests__/chat-credential-redaction.test.ts +1395 -0
  29. package/src/__tests__/chat-reveal-guard-priming.test.ts +791 -0
  30. package/src/__tests__/compaction.benchmark.test.ts +2 -1
  31. package/src/__tests__/compactor-image-manifest-trust.test.ts +50 -0
  32. package/src/__tests__/config-loader-backfill.test.ts +9 -4
  33. package/src/__tests__/config-schema-cmd.test.ts +10 -11
  34. package/src/__tests__/config-schema.test.ts +190 -257
  35. package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +208 -0
  36. package/src/__tests__/conversation-agent-loop-overflow.test.ts +5 -19
  37. package/src/__tests__/conversation-agent-loop.test.ts +22 -9
  38. package/src/__tests__/conversation-error.test.ts +31 -0
  39. package/src/__tests__/conversation-load-history-repair.test.ts +110 -0
  40. package/src/__tests__/conversation-process-callsite.test.ts +12 -19
  41. package/src/__tests__/conversation-routes-slash-commands.test.ts +7 -21
  42. package/src/__tests__/conversation-summarize-route.test.ts +36 -44
  43. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
  44. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +7 -7
  45. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +0 -1
  46. package/src/__tests__/conversation-tool-setup-attribution.test.ts +0 -1
  47. package/src/__tests__/conversation-usage.test.ts +4 -12
  48. package/src/__tests__/credential-routes.test.ts +250 -0
  49. package/src/__tests__/credential-security-invariants.test.ts +3 -0
  50. package/src/__tests__/db-migration-rollback.test.ts +22 -0
  51. package/src/__tests__/empty-response-hook.test.ts +186 -1
  52. package/src/__tests__/external-plugin-loader.test.ts +11 -5
  53. package/src/__tests__/heartbeat-service.test.ts +0 -28
  54. package/src/__tests__/host-shell-tool.test.ts +2 -0
  55. package/src/__tests__/inactive-tool-error-messages.test.ts +2 -2
  56. package/src/__tests__/inference-no-mode-boot-e2e.test.ts +52 -8
  57. package/src/__tests__/internal-telemetry-routes.test.ts +23 -4
  58. package/src/__tests__/invite-routes-http.test.ts +12 -16
  59. package/src/__tests__/list-all-apps.test.ts +0 -4
  60. package/src/__tests__/llm-context-resolution.test.ts +32 -56
  61. package/src/__tests__/llm-request-log-turn-query.test.ts +109 -0
  62. package/src/__tests__/llm-resolver-override-or-default.test.ts +3 -52
  63. package/src/__tests__/llm-resolver.test.ts +342 -602
  64. package/src/__tests__/llm-schema.test.ts +79 -37
  65. package/src/__tests__/max-tokens-continue-hook.test.ts +19 -0
  66. package/src/__tests__/media-stream-output.test.ts +259 -3
  67. package/src/__tests__/media-stream-server-integration.test.ts +22 -1
  68. package/src/__tests__/media-stream-stt-session.test.ts +47 -0
  69. package/src/__tests__/memory-jobs-worker-cleanup-cadence.test.ts +33 -0
  70. package/src/__tests__/memory-recall-log-store.test.ts +47 -13
  71. package/src/__tests__/mock-gateway-ipc.ts +46 -1
  72. package/src/__tests__/mtime-cache.test.ts +61 -0
  73. package/src/__tests__/navigate-settings-tab.test.ts +2 -0
  74. package/src/__tests__/normalize-onboarding.test.ts +33 -0
  75. package/src/__tests__/onboarding-persona-write.test.ts +26 -0
  76. package/src/__tests__/plugin-api-resolve-credential.test.ts +140 -0
  77. package/src/__tests__/plugin-app-serve-routes.test.ts +166 -14
  78. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +2 -0
  79. package/src/__tests__/post-turn-tool-result-truncation.test.ts +38 -0
  80. package/src/__tests__/provider-commit-message-generator.test.ts +27 -20
  81. package/src/__tests__/provider-connections-backfill.test.ts +138 -0
  82. package/src/__tests__/provider-platform-proxy-integration.test.ts +10 -31
  83. package/src/__tests__/provider-registry-ollama.test.ts +8 -18
  84. package/src/__tests__/provider-send-message-override-profile.test.ts +23 -23
  85. package/src/__tests__/provider-usage-tracking.test.ts +9 -19
  86. package/src/__tests__/prune-old-conversations-job.test.ts +12 -0
  87. package/src/__tests__/published-app-updater.test.ts +22 -16
  88. package/src/__tests__/registry.test.ts +5 -16
  89. package/src/__tests__/retry-openrouter-only-normalization.test.ts +22 -23
  90. package/src/__tests__/retry-thinking-adaptive-only.test.ts +43 -48
  91. package/src/__tests__/retry-thinking-tool-choice.test.ts +57 -66
  92. package/src/__tests__/retry-verbosity-normalization.test.ts +24 -23
  93. package/src/__tests__/reveal-success-registry.test.ts +123 -0
  94. package/src/__tests__/run-conversation-turn-persistence.test.ts +130 -0
  95. package/src/__tests__/secret-fixtures.ts +9 -0
  96. package/src/__tests__/server-history-render.test.ts +28 -0
  97. package/src/__tests__/skills.test.ts +9 -4
  98. package/src/__tests__/slack-share-routes.test.ts +0 -1
  99. package/src/__tests__/stt-stream-session.test.ts +6 -5
  100. package/src/__tests__/subagent-call-site-routing.test.ts +69 -95
  101. package/src/__tests__/subagent-disposal.test.ts +2 -0
  102. package/src/__tests__/subagent-fork-notifications.test.ts +2 -0
  103. package/src/__tests__/subagent-fork-spawn.test.ts +2 -0
  104. package/src/__tests__/subagent-manager-notify.test.ts +2 -0
  105. package/src/__tests__/subagent-role-registry.test.ts +37 -0
  106. package/src/__tests__/subagent-spawn-and-await.test.ts +1 -0
  107. package/src/__tests__/subagent-terminal-message.test.ts +50 -0
  108. package/src/__tests__/subagent-tool-gate-mode.test.ts +78 -5
  109. package/src/__tests__/surface-completion-nudge-hook.test.ts +19 -0
  110. package/src/__tests__/telemetry-routes.test.ts +99 -19
  111. package/src/__tests__/tool-audit.test.ts +34 -4
  112. package/src/__tests__/tool-executor-lifecycle-events.test.ts +1 -1
  113. package/src/__tests__/tool-profiler.test.ts +72 -1
  114. package/src/__tests__/tool-result-spool.test.ts +49 -4
  115. package/src/__tests__/tool-side-effects-slack-dm.test.ts +0 -1
  116. package/src/__tests__/ui-channel-variants.test.ts +108 -0
  117. package/src/__tests__/ui-shape-teaching.test.ts +255 -0
  118. package/src/__tests__/usage-attribution.test.ts +18 -41
  119. package/src/__tests__/user-plugin-loader.test.ts +4 -4
  120. package/src/__tests__/voice-config-update.test.ts +46 -0
  121. package/src/__tests__/voice-session-bridge.test.ts +216 -84
  122. package/src/__tests__/workspace-migration-131-drop-web-fetch-mode.test.ts +120 -0
  123. package/src/agent/loop.ts +4 -3
  124. package/src/api/events/open-conversation.test.ts +64 -0
  125. package/src/api/events/open-conversation.ts +33 -0
  126. package/src/api/index.ts +6 -0
  127. package/src/api/responses/conversation-message.ts +5 -0
  128. package/src/apps/app-store.ts +25 -35
  129. package/src/bundler/app-bundler.ts +34 -48
  130. package/src/bundler/app-compiler.ts +39 -4
  131. package/src/bundler/bundle-scanner.ts +13 -0
  132. package/src/bundler/manifest.ts +1 -1
  133. package/src/calls/__tests__/voice-session-bridge.test.ts +47 -0
  134. package/src/calls/call-constants.ts +5 -0
  135. package/src/calls/call-controller.ts +100 -32
  136. package/src/calls/call-transport.ts +9 -0
  137. package/src/calls/media-stream-output.ts +107 -4
  138. package/src/calls/media-stream-server.ts +29 -9
  139. package/src/calls/media-stream-stt-session.ts +7 -1
  140. package/src/calls/media-turn-detector.ts +11 -1
  141. package/src/calls/voice-session-bridge.ts +84 -63
  142. package/src/cli/commands/__tests__/inference-providers.test.ts +270 -33
  143. package/src/cli/commands/__tests__/notifications.test.ts +24 -3
  144. package/src/cli/commands/config.help.ts +6 -6
  145. package/src/cli/commands/credentials.help.ts +13 -0
  146. package/src/cli/commands/credentials.ts +6 -1
  147. package/src/cli/commands/email.help.ts +7 -0
  148. package/src/cli/commands/email.ts +35 -1
  149. package/src/cli/commands/inference-providers.ts +168 -107
  150. package/src/cli/commands/inference.help.ts +118 -46
  151. package/src/cli/commands/memory/index.help.ts +23 -0
  152. package/src/cli/commands/memory/nodes.ts +146 -0
  153. package/src/cli/commands/notifications.help.ts +10 -10
  154. package/src/cli/commands/oauth/connect-surface-guidance.test.ts +40 -0
  155. package/src/cli/commands/oauth/connect-surface-guidance.ts +54 -0
  156. package/src/cli/commands/oauth/connect.test.ts +126 -0
  157. package/src/cli/commands/oauth/connect.ts +29 -6
  158. package/src/cli/commands/oauth/index.help.ts +7 -1
  159. package/src/cli/commands/oauth/status.test.ts +69 -3
  160. package/src/cli/commands/oauth/status.ts +50 -12
  161. package/src/cli/commands/plugins.help.ts +13 -2
  162. package/src/cli/commands/plugins.ts +61 -7
  163. package/src/cli/commands/telemetry.help.ts +13 -0
  164. package/src/cli/commands/telemetry.ts +45 -5
  165. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +8 -2
  166. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +213 -9
  167. package/src/cli/lib/bundled-marketplace.json +93 -36
  168. package/src/cli/lib/inspect-plugin.ts +5 -1
  169. package/src/cli/lib/install-from-github.ts +68 -4
  170. package/src/cli/lib/plugin-catalog-local.ts +6 -2
  171. package/src/cli/lib/plugin-fingerprint.ts +3 -3
  172. package/src/cli/lib/upgrade-plugin.ts +236 -35
  173. package/src/config/__tests__/default-profile-catalog.test.ts +8 -12
  174. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +137 -0
  175. package/src/config/__tests__/profile-materialization.test.ts +1 -88
  176. package/src/config/bundled-skills/AGENTS.md +3 -30
  177. package/src/config/bundled-skills/app-builder/SKILL.md +5 -3
  178. package/src/config/bundled-skills/app-builder/TOOLS.json +23 -0
  179. package/src/config/bundled-skills/app-builder/tools/app-open.ts +32 -0
  180. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +1 -1
  181. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +7 -7
  182. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  183. package/src/config/bundled-skills/schedule/TOOLS.json +1 -1
  184. package/src/config/bundled-skills/settings/TOOLS.json +3 -1
  185. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +2 -0
  186. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +18 -10
  187. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  188. package/src/config/bundled-skills/subagent/TOOLS.json +1 -1
  189. package/src/config/call-site-defaults.ts +3 -3
  190. package/src/config/feature-flag-registry.json +24 -39
  191. package/src/config/llm-resolver.ts +88 -475
  192. package/src/config/profile-materialization.ts +11 -11
  193. package/src/config/schema.ts +93 -127
  194. package/src/config/schemas/__tests__/live-voice.test.ts +24 -6
  195. package/src/config/schemas/__tests__/stt.test.ts +31 -3
  196. package/src/config/schemas/live-voice.ts +10 -4
  197. package/src/config/schemas/llm.ts +52 -15
  198. package/src/config/schemas/memory-lifecycle.ts +1 -1
  199. package/src/config/schemas/memory-retrospective.ts +8 -0
  200. package/src/config/schemas/memory-v2.ts +2 -2
  201. package/src/config/schemas/memory-v3.ts +1 -1
  202. package/src/config/schemas/services.ts +6 -3
  203. package/src/config/schemas/stt.ts +38 -21
  204. package/src/config/schemas/tts.ts +13 -18
  205. package/src/config/skills.ts +31 -21
  206. package/src/context/compactor.ts +44 -7
  207. package/src/context/post-turn-tool-result-truncation.ts +4 -2
  208. package/src/context/tool-result-spool.ts +26 -5
  209. package/src/conversations/__tests__/message-consolidation.test.ts +48 -0
  210. package/src/conversations/message-consolidation.ts +22 -2
  211. package/src/daemon/__tests__/conversation-tool-setup.test.ts +5 -12
  212. package/src/daemon/app-source-watcher.ts +17 -23
  213. package/src/daemon/chat-credential-redaction.ts +1365 -0
  214. package/src/daemon/conversation-agent-loop-handlers.ts +627 -34
  215. package/src/daemon/conversation-agent-loop.ts +71 -31
  216. package/src/daemon/conversation-error.ts +36 -14
  217. package/src/daemon/conversation-process.ts +22 -0
  218. package/src/daemon/conversation-store.ts +35 -0
  219. package/src/daemon/conversation-surfaces.ts +25 -25
  220. package/src/daemon/conversation-tool-setup.ts +33 -7
  221. package/src/daemon/conversation.ts +55 -5
  222. package/src/daemon/handlers/shared.ts +33 -2
  223. package/src/daemon/lifecycle.ts +4 -4
  224. package/src/daemon/message-types/conversations.ts +6 -17
  225. package/src/daemon/providers-setup.ts +8 -0
  226. package/src/daemon/tool-setup-types.ts +7 -1
  227. package/src/daemon/wake-conversation-ops.ts +10 -1
  228. package/src/hooks/types.ts +9 -0
  229. package/src/ipc/__tests__/email-ipc.test.ts +90 -0
  230. package/src/ipc/gateway-client.test.ts +59 -0
  231. package/src/ipc/gateway-client.ts +70 -27
  232. package/src/live-voice/__tests__/live-voice-events.test.ts +14 -2
  233. package/src/live-voice/__tests__/live-voice-integration.test.ts +116 -2
  234. package/src/live-voice/__tests__/live-voice-vad.test.ts +804 -13
  235. package/src/live-voice/__tests__/protocol.test.ts +122 -0
  236. package/src/live-voice/live-voice-session.ts +443 -40
  237. package/src/live-voice/protocol.ts +143 -1
  238. package/src/monitoring/__tests__/plugin-source-watch.test.ts +3 -1
  239. package/src/monitoring/plugin-source-watch.ts +3 -62
  240. package/src/notifications/README.md +1 -1
  241. package/src/permissions/checker.ts +8 -4
  242. package/src/persistence/__tests__/db-init-migrations-ok.test.ts +26 -0
  243. package/src/persistence/conversation-crud.ts +104 -2
  244. package/src/persistence/db-init.ts +14 -3
  245. package/src/persistence/job-handlers/cleanup.ts +15 -7
  246. package/src/persistence/llm-request-log-store.ts +88 -52
  247. package/src/persistence/migrations/298-move-memory-jobs-to-memory-db.ts +7 -31
  248. package/src/persistence/migrations/305-drop-contact-acl-columns.ts +3 -2
  249. package/src/persistence/migrations/326-move-injection-events-to-memory-db.ts +8 -34
  250. package/src/persistence/migrations/336-move-memory-v2-activation-logs-to-memory-db.ts +90 -0
  251. package/src/persistence/migrations/337-move-memory-recall-logs-to-memory-db.ts +114 -0
  252. package/src/persistence/migrations/338-move-memory-v3-selections-to-memory-db.ts +84 -0
  253. package/src/persistence/migrations/339-move-activation-sessions-to-memory-db.ts +52 -0
  254. package/src/persistence/migrations/__tests__/run-migrations.test.ts +155 -0
  255. package/src/persistence/migrations/helpers/relocation.ts +44 -1
  256. package/src/persistence/migrations/run-migrations.ts +25 -1
  257. package/src/persistence/schema/infrastructure.ts +9 -0
  258. package/src/persistence/schema/memory-core.ts +3 -0
  259. package/src/persistence/schema/memory-injection.ts +2 -0
  260. package/src/persistence/steps.ts +36 -0
  261. package/src/platform/client.test.ts +1 -44
  262. package/src/platform/client.ts +10 -20
  263. package/src/platform/consent-cache.test.ts +89 -23
  264. package/src/platform/consent-cache.ts +71 -33
  265. package/src/plugin-api/constants.ts +12 -0
  266. package/src/plugin-api/conversation-turn.ts +37 -14
  267. package/src/plugin-api/index.ts +11 -1
  268. package/src/plugin-api/resolve-credential.ts +75 -0
  269. package/src/plugin-api/vision-support.test.ts +8 -19
  270. package/src/plugin-api/vision-support.ts +26 -17
  271. package/src/plugins/collect-source-versions.ts +77 -0
  272. package/src/plugins/defaults/compaction/compact.ts +6 -0
  273. package/src/plugins/defaults/compaction/window-manager.ts +9 -0
  274. package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +18 -24
  275. package/src/plugins/defaults/empty-response/hooks/user-prompt-submit.ts +38 -0
  276. package/src/plugins/defaults/empty-response/refusal-quarantine.ts +99 -0
  277. package/src/plugins/defaults/image-fallback/__tests__/caption-cache-persistence.test.ts +7 -3
  278. package/src/plugins/defaults/index.ts +8 -1
  279. package/src/plugins/defaults/max-tokens-continue/hooks/post-model-call.ts +4 -1
  280. package/src/plugins/defaults/memory/__tests__/activation-session-store.test.ts +48 -6
  281. package/src/plugins/defaults/memory/__tests__/db-memory-attach.test.ts +28 -22
  282. package/src/plugins/defaults/memory/__tests__/memory-log-stores-degraded.test.ts +148 -0
  283. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +80 -8
  284. package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +202 -0
  285. package/src/plugins/defaults/memory/__tests__/memory-v2-activation-log-store.test.ts +64 -25
  286. package/src/plugins/defaults/memory/__tests__/memory-v2-concept-frequency.test.ts +24 -11
  287. package/src/plugins/defaults/memory/__tests__/prompt-override.test.ts +70 -6
  288. package/src/plugins/defaults/memory/__tests__/table-relocation.test.ts +278 -0
  289. package/src/plugins/defaults/memory/activation-session-store.ts +27 -20
  290. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -9
  291. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +1 -8
  292. package/src/plugins/defaults/memory/graph/retriever.test.ts +6 -6
  293. package/src/plugins/defaults/memory/graph/store.ts +114 -0
  294. package/src/plugins/defaults/memory/graph/tool-handlers.ts +36 -1
  295. package/src/plugins/defaults/memory/graph/tools.ts +47 -13
  296. package/src/plugins/defaults/memory/graph-topology/build-memory-graph.ts +16 -35
  297. package/src/plugins/defaults/memory/jobs-worker.ts +10 -1
  298. package/src/plugins/defaults/memory/memory-db.ts +3 -2
  299. package/src/plugins/defaults/memory/memory-recall-log-store.ts +129 -66
  300. package/src/plugins/defaults/memory/memory-retrospective-constants.ts +8 -0
  301. package/src/plugins/defaults/memory/memory-retrospective-job.ts +11 -122
  302. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +216 -0
  303. package/src/plugins/defaults/memory/memory-v2-activation-log-store.ts +84 -46
  304. package/src/plugins/defaults/memory/memory-v2-concept-frequency.ts +42 -32
  305. package/src/plugins/defaults/memory/path-containment.ts +21 -0
  306. package/src/plugins/defaults/memory/prompt-override.ts +51 -13
  307. package/src/plugins/defaults/memory/tools.test.ts +34 -0
  308. package/src/plugins/defaults/memory/tools.ts +12 -2
  309. package/src/plugins/defaults/memory/v2/__tests__/harness-compare.test.ts +19 -15
  310. package/src/plugins/defaults/memory/v2/__tests__/harness-oracle.test.ts +24 -19
  311. package/src/plugins/defaults/memory/v2/__tests__/harness-replay-input.test.ts +19 -15
  312. package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +22 -2
  313. package/src/plugins/defaults/memory/v2/__tests__/prompts-consolidation.test.ts +5 -3
  314. package/src/plugins/defaults/memory/v2/harness/oracle.ts +59 -41
  315. package/src/plugins/defaults/memory/v2/harness/replay-input.ts +29 -25
  316. package/src/plugins/defaults/memory/v2/migration.ts +46 -16
  317. package/src/plugins/defaults/memory/v2/prompts/consolidation.ts +4 -0
  318. package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +17 -15
  319. package/src/plugins/defaults/memory/v3/__tests__/gate.test.ts +24 -22
  320. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +9 -5
  321. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +277 -10
  322. package/src/plugins/defaults/memory/v3/__tests__/selection-log-store.test.ts +57 -5
  323. package/src/plugins/defaults/memory/v3/__tests__/shadow-integration.test.ts +9 -7
  324. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +38 -7
  325. package/src/plugins/defaults/memory/v3/hot-set.test.ts +36 -18
  326. package/src/plugins/defaults/memory/v3/hot-set.ts +12 -14
  327. package/src/plugins/defaults/memory/v3/learned-edges.test.ts +47 -27
  328. package/src/plugins/defaults/memory/v3/learned-edges.ts +12 -14
  329. package/src/plugins/defaults/memory/v3/orchestrate.ts +139 -22
  330. package/src/plugins/defaults/memory/v3/prune.test.ts +10 -3
  331. package/src/plugins/defaults/memory/v3/prune.ts +17 -11
  332. package/src/plugins/defaults/memory/v3/selection-log-store.ts +23 -11
  333. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +70 -53
  334. package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +9 -6
  335. package/src/plugins/external-plugin-loader.ts +15 -15
  336. package/src/plugins/mtime-cache.ts +47 -0
  337. package/src/plugins/pipeline.ts +9 -1
  338. package/src/plugins/plugin-execution-context.ts +44 -0
  339. package/src/plugins/plugin-tree-walk.ts +31 -24
  340. package/src/plugins/source-fingerprint.ts +3 -4
  341. package/src/prompts/normalize-onboarding.ts +12 -0
  342. package/src/prompts/persona-resolver.ts +8 -0
  343. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +1 -1
  344. package/src/providers/__tests__/dispatch-connection-routing.test.ts +6 -9
  345. package/src/providers/__tests__/registry-native-web-search.test.ts +4 -10
  346. package/src/providers/__tests__/retry-callsite.test.ts +235 -215
  347. package/src/providers/__tests__/satellite-connection-routing.test.ts +8 -16
  348. package/src/providers/atlascloud/client.ts +10 -49
  349. package/src/providers/baseten/client.ts +43 -0
  350. package/src/providers/call-site-routing.ts +26 -13
  351. package/src/providers/connection-resolution.ts +9 -11
  352. package/src/providers/fetch-provider-catalog.ts +4 -2
  353. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +29 -4
  354. package/src/providers/inference/__tests__/connection-availability-keyless.test.ts +78 -0
  355. package/src/providers/inference/adapter-factory.ts +27 -6
  356. package/src/providers/inference/auth.ts +29 -1
  357. package/src/providers/inference/backfill.ts +61 -10
  358. package/src/providers/inference/connection-availability.ts +3 -2
  359. package/src/providers/inference/resolve-auth.ts +9 -1
  360. package/src/providers/model-catalog.ts +37 -0
  361. package/src/providers/openai/__tests__/api-error-normalization.test.ts +24 -2
  362. package/src/providers/openai/api-key-validation.ts +70 -0
  363. package/src/providers/retry.ts +2 -0
  364. package/src/providers/types.ts +11 -4
  365. package/src/providers/vellum-model-routing.test.ts +26 -0
  366. package/src/providers/vellum-model-routing.ts +30 -0
  367. package/src/providers/voice-error-copy.ts +47 -0
  368. package/src/runtime/agent-wake.ts +40 -24
  369. package/src/runtime/for-chat-mint-registry.ts +118 -0
  370. package/src/runtime/reveal-nonce.ts +49 -0
  371. package/src/runtime/reveal-success-registry.ts +306 -0
  372. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +88 -3
  373. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +161 -30
  374. package/src/runtime/routes/__tests__/migration-vellum-metadata-reconcile.test.ts +7 -0
  375. package/src/runtime/routes/__tests__/schedule-worker-routes.test.ts +15 -0
  376. package/src/runtime/routes/app-management-routes.ts +152 -45
  377. package/src/runtime/routes/app-routes.ts +16 -77
  378. package/src/runtime/routes/canned-message-complete.ts +16 -16
  379. package/src/runtime/routes/conversation-management-routes.ts +17 -14
  380. package/src/runtime/routes/conversation-query-routes.ts +54 -6
  381. package/src/runtime/routes/conversation-routes.ts +48 -2
  382. package/src/runtime/routes/credential-routes.ts +116 -3
  383. package/src/runtime/routes/email-routes.ts +17 -1
  384. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +33 -5
  385. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +6 -5
  386. package/src/runtime/routes/inference-provider-connection-routes.ts +77 -28
  387. package/src/runtime/routes/inference-send-routes.ts +1 -1
  388. package/src/runtime/routes/internal-telemetry-routes.ts +7 -6
  389. package/src/runtime/routes/plugins-routes.ts +37 -6
  390. package/src/runtime/routes/publish-routes.ts +15 -18
  391. package/src/runtime/routes/schedule-worker-routes.ts +9 -0
  392. package/src/runtime/routes/secret-routes.ts +10 -0
  393. package/src/runtime/routes/telemetry-routes.ts +149 -45
  394. package/src/schedule/__tests__/schedule-timezone.test.ts +101 -0
  395. package/src/schedule/__tests__/worker-watchdog.test.ts +209 -0
  396. package/src/schedule/schedule-store.ts +12 -1
  397. package/src/schedule/schedule-timezone.ts +63 -0
  398. package/src/schedule/scheduler.ts +100 -6
  399. package/src/schedule/worker-control.ts +19 -0
  400. package/src/security/auth-fallback-events-store.ts +7 -6
  401. package/src/security/secret-scanner.ts +26 -1
  402. package/src/services/published-app-updater.ts +6 -11
  403. package/src/stt/stt-stream-session.ts +36 -16
  404. package/src/subagent/manager.ts +34 -8
  405. package/src/telemetry/AGENTS.md +30 -1
  406. package/src/telemetry/__tests__/config-setting-snapshot.test.ts +18 -0
  407. package/src/telemetry/__tests__/outbox-test-harness.ts +5 -3
  408. package/src/telemetry/config-setting-snapshot.ts +44 -10
  409. package/src/telemetry/telemetry-event-sources.test.ts +124 -30
  410. package/src/telemetry/telemetry-event-sources.ts +137 -83
  411. package/src/telemetry/telemetry-events-outbox.test.ts +46 -1
  412. package/src/telemetry/telemetry-events-outbox.ts +60 -9
  413. package/src/telemetry/telemetry-wire-source.json +1 -1
  414. package/src/telemetry/telemetry-wire-validation.ts +39 -2
  415. package/src/telemetry/telemetry-wire.generated.ts +8 -0
  416. package/src/telemetry/tool-audit.ts +15 -9
  417. package/src/telemetry/tool-executed-events-store.test.ts +1 -1
  418. package/src/telemetry/turn-events-store.ts +20 -0
  419. package/src/telemetry/types.ts +45 -12
  420. package/src/telemetry/usage-telemetry-reporter.test.ts +294 -25
  421. package/src/telemetry/usage-telemetry-reporter.ts +117 -18
  422. package/src/telemetry/watchdog-direct-emit.test.ts +11 -3
  423. package/src/telemetry/watchdog-direct-emit.ts +11 -6
  424. package/src/tools/apps/executors.ts +11 -36
  425. package/src/tools/executor.ts +14 -1
  426. package/src/tools/host-terminal/host-shell.ts +6 -2
  427. package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +1 -1
  428. package/src/tools/network/__tests__/web-fetch-metadata.test.ts +25 -0
  429. package/src/tools/network/web-fetch.ts +6 -2
  430. package/src/tools/skills/sandbox-runner.ts +5 -2
  431. package/src/tools/subagent/spawn.ts +7 -11
  432. package/src/tools/terminal/shell.ts +5 -2
  433. package/src/tools/tool-manifest.ts +0 -2
  434. package/src/tools/tool-profiler.ts +37 -6
  435. package/src/tools/ui-surface/channel-variants.ts +101 -0
  436. package/src/tools/ui-surface/definitions.ts +23 -67
  437. package/src/tools/ui-surface/surface-shape-docs.ts +229 -0
  438. package/src/tts/__tests__/provider-adapters.test.ts +18 -0
  439. package/src/tts/provider-catalog.ts +2 -4
  440. package/src/tts/providers/deepgram-provider.ts +10 -1
  441. package/src/types/onboarding-context.ts +8 -0
  442. package/src/usage/attribution.ts +18 -112
  443. package/src/{config/bundled-skills/messaging/tools/gmail-mime-helpers.ts → util/mime-type.ts} +4 -1
  444. package/src/util/provider-error-patterns.ts +7 -1
  445. package/src/util/worker-process.ts +105 -0
  446. package/src/watcher/__tests__/telemetry.test.ts +17 -4
  447. package/src/watcher/telemetry.ts +5 -5
  448. package/src/workspace/migrations/131-drop-web-fetch-mode.ts +61 -0
  449. package/src/workspace/migrations/registry.ts +2 -0
  450. package/src/workspace/provider-commit-message-generator.ts +6 -4
  451. package/src/__tests__/app-open-proxy.test.ts +0 -67
  452. package/src/onboarding/onboarding-research-events-store.test.ts +0 -230
  453. package/src/onboarding/onboarding-research-events-store.ts +0 -104
  454. package/src/runtime/routes/assets/vellum-design-system.css +0 -2236
  455. package/src/tools/apps/definitions.ts +0 -73
  456. package/src/tools/apps/open-proxy.ts +0 -43
@@ -0,0 +1,1395 @@
1
+ /**
2
+ * Tests for the chat-persist sentinel redaction path (LUM-2768).
3
+ *
4
+ * Covers the three stages independently: reveal-command parsing (pure
5
+ * string work), candidate resolution (mocked credential store), and the
6
+ * byte-match sentinel substitution. The failure-direction invariant is the
7
+ * core assertion set: anything short of an exact plaintext match must
8
+ * degrade to the plain (non-revealable) sentinel, never a mislabeled
9
+ * enriched one.
10
+ */
11
+ import { describe, expect, mock, test } from "bun:test";
12
+
13
+ const SECRET_STORE: Record<string, string | null> = {};
14
+
15
+ mock.module("../security/secure-keys.js", () => ({
16
+ getSecureKeyAsync: async (key: string) => SECRET_STORE[key] ?? null,
17
+ }));
18
+
19
+ mock.module("../tools/credentials/metadata-store.js", () => ({
20
+ getCredentialMetadataById: (id: string) =>
21
+ id === "11111111-2222-4333-8444-555555555555"
22
+ ? { service: "openai", field: "api_key" }
23
+ : undefined,
24
+ }));
25
+
26
+ // ── Imports (after mocks) ────────────────────────────────────────────────
27
+ import { neutralizeRedactedSentinels } from "@vellumai/service-contracts/redacted-credential";
28
+
29
+ import {
30
+ buildForChatSentinel,
31
+ buildLiveRevealGuardEntries,
32
+ collectRevealRefsFromCommand,
33
+ drainCandidateGuardedChunk,
34
+ drainSentinelGuardedText,
35
+ filterRefsByRevealProof,
36
+ guardForChatSentinels,
37
+ redactCandidateValuesLegacy,
38
+ redactSecretsForChat,
39
+ remintAuthoritiesFromCandidates,
40
+ resolveProvenRevealCandidates,
41
+ resolveRefIdentities,
42
+ resolveRevealCandidates,
43
+ swapLiveRevealValues,
44
+ } from "../daemon/chat-credential-redaction.js";
45
+ import {
46
+ currentForChatMintWatermark,
47
+ forChatMintsSince,
48
+ recordForChatMint,
49
+ resetForChatMintRegistryForTest,
50
+ } from "../runtime/for-chat-mint-registry.js";
51
+ import {
52
+ _resetRevealSuccessRegistryForTest,
53
+ openRevealProofWindow,
54
+ recordRevealSuccess,
55
+ } from "../runtime/reveal-success-registry.js";
56
+ import { redactSecrets } from "../security/secret-scanner.js";
57
+ import {
58
+ OPENAI_PROJECT_KEY_REDACTION_MARKER,
59
+ SYNTHETIC_OPENAI_PROJECT_KEY,
60
+ } from "./secret-fixtures.js";
61
+
62
+ const UUID = "11111111-2222-4333-8444-555555555555";
63
+
64
+ describe("collectRevealRefsFromCommand", () => {
65
+ test("parses --service/--field flags", () => {
66
+ expect(
67
+ collectRevealRefsFromCommand(
68
+ "assistant credentials reveal --service openai --field api_key",
69
+ ),
70
+ ).toEqual([{ service: "openai", field: "api_key" }]);
71
+ });
72
+
73
+ test("parses = and quoted flag values", () => {
74
+ expect(
75
+ collectRevealRefsFromCommand(
76
+ `assistant credentials reveal --service="github-app" --field 'pem'`,
77
+ ),
78
+ ).toEqual([{ service: "github-app", field: "pem" }]);
79
+ });
80
+
81
+ test("parses a positional UUID", () => {
82
+ expect(
83
+ collectRevealRefsFromCommand(`assistant credentials reveal ${UUID}`),
84
+ ).toEqual([{ id: UUID }]);
85
+ });
86
+
87
+ test("splits compound commands so flags cannot bleed across invocations", () => {
88
+ expect(
89
+ collectRevealRefsFromCommand(
90
+ "assistant credentials reveal --service a --field b && assistant credentials reveal --service c --field d",
91
+ ),
92
+ ).toEqual([
93
+ { service: "a", field: "b" },
94
+ { service: "c", field: "d" },
95
+ ]);
96
+ });
97
+
98
+ test("splits on a single & background separator", () => {
99
+ expect(
100
+ collectRevealRefsFromCommand(
101
+ "assistant credentials reveal --service a --field b & assistant credentials reveal --service c --field d",
102
+ ),
103
+ ).toEqual([
104
+ { service: "a", field: "b" },
105
+ { service: "c", field: "d" },
106
+ ]);
107
+ });
108
+
109
+ test("does not treat && as two single-& separators", () => {
110
+ // The two-char operator must win at its position; a spurious empty split
111
+ // between the ampersands would drop the second invocation's flags.
112
+ expect(
113
+ collectRevealRefsFromCommand(
114
+ "assistant credentials reveal --service a --field b&&assistant credentials reveal --service c --field d",
115
+ ),
116
+ ).toEqual([
117
+ { service: "a", field: "b" },
118
+ { service: "c", field: "d" },
119
+ ]);
120
+ });
121
+
122
+ test("a quoted separator inside a flag value does not split the invocation", () => {
123
+ // Service/field identifiers are arbitrary strings; a shell separator
124
+ // inside quotes is part of the value. Cutting there would orphan the
125
+ // flags across two broken segments and stage nothing — and an
126
+ // unstageable reveal's opaque output could stream or persist raw.
127
+ expect(
128
+ collectRevealRefsFromCommand(
129
+ "assistant credentials reveal --service 'R&D' --field token",
130
+ ),
131
+ ).toEqual([{ service: "R&D", field: "token" }]);
132
+ });
133
+
134
+ test("quoted separators cannot swallow a following invocation", () => {
135
+ expect(
136
+ collectRevealRefsFromCommand(
137
+ 'assistant credentials reveal --service "a&&b;c" --field x && assistant credentials reveal --service d --field e',
138
+ ),
139
+ ).toEqual([
140
+ { service: "a&&b;c", field: "x" },
141
+ { service: "d", field: "e" },
142
+ ]);
143
+ });
144
+
145
+ test("unescapes backslash-escaped characters in unquoted flag values", () => {
146
+ // The shell strips escaping before the CLI sees argv, so the reveal
147
+ // route records proof under the UNESCAPED identity — the parse must
148
+ // agree or the staged ref can never match its proof.
149
+ expect(
150
+ collectRevealRefsFromCommand(
151
+ "assistant credentials reveal --service foo\\ bar --field token",
152
+ ),
153
+ ).toEqual([{ service: "foo bar", field: "token" }]);
154
+ expect(
155
+ collectRevealRefsFromCommand(
156
+ "assistant credentials reveal --service a\\&b --field x",
157
+ ),
158
+ ).toEqual([{ service: "a&b", field: "x" }]);
159
+ });
160
+
161
+ test("unescapes POSIX-escapable characters inside double-quoted values", () => {
162
+ expect(
163
+ collectRevealRefsFromCommand(
164
+ 'assistant credentials reveal --service "a\\"b" --field x',
165
+ ),
166
+ ).toEqual([{ service: 'a"b', field: "x" }]);
167
+ });
168
+
169
+ test("stages every reveal invocation within one segment", () => {
170
+ // Command substitution nests several reveals in ONE segment; each must
171
+ // stage its own ref — parsing only the first flag pair would leave the
172
+ // later identities unstaged even though the route proves them.
173
+ expect(
174
+ collectRevealRefsFromCommand(
175
+ 'echo "$(assistant credentials reveal --service a --field token)" "$(assistant credentials reveal --service b --field token)"',
176
+ ),
177
+ ).toEqual([
178
+ { service: "a", field: "token" },
179
+ { service: "b", field: "token" },
180
+ ]);
181
+ });
182
+
183
+ test("a substitution's closing paren does not glue onto the parsed value", () => {
184
+ expect(
185
+ collectRevealRefsFromCommand(
186
+ 'KEY="$(assistant credentials reveal --service openai --field api_key)"',
187
+ ),
188
+ ).toEqual([{ service: "openai", field: "api_key" }]);
189
+ });
190
+
191
+ test("stages two UUID reveals nested in one segment", () => {
192
+ const other = "99999999-9999-4999-8999-999999999999";
193
+ expect(
194
+ collectRevealRefsFromCommand(
195
+ `echo "$(assistant credentials reveal ${UUID})" "$(assistant credentials reveal ${other})"`,
196
+ ),
197
+ ).toEqual([{ id: UUID }, { id: other }]);
198
+ });
199
+
200
+ test("ignores commands without a reveal invocation", () => {
201
+ expect(collectRevealRefsFromCommand("echo hello")).toEqual([]);
202
+ expect(collectRevealRefsFromCommand("assistant credentials list")).toEqual(
203
+ [],
204
+ );
205
+ });
206
+
207
+ test("unparseable invocation yields no ref (fails safe)", () => {
208
+ expect(
209
+ collectRevealRefsFromCommand("assistant credentials reveal --service x"),
210
+ ).toEqual([]);
211
+ });
212
+ });
213
+
214
+ describe("resolveRevealCandidates", () => {
215
+ test("resolves service/field refs via the scoped store read", async () => {
216
+ SECRET_STORE["credential/openai/api_key"] = SYNTHETIC_OPENAI_PROJECT_KEY;
217
+ const out = await resolveRevealCandidates([
218
+ { service: "openai", field: "api_key" },
219
+ ]);
220
+ expect(out).toEqual([
221
+ {
222
+ service: "openai",
223
+ field: "api_key",
224
+ value: SYNTHETIC_OPENAI_PROJECT_KEY,
225
+ },
226
+ ]);
227
+ });
228
+
229
+ test("resolves a UUID ref through metadata and dedupes with the flag ref", async () => {
230
+ SECRET_STORE["credential/openai/api_key"] = SYNTHETIC_OPENAI_PROJECT_KEY;
231
+ const out = await resolveRevealCandidates([
232
+ { id: UUID },
233
+ { service: "openai", field: "api_key" },
234
+ ]);
235
+ expect(out).toHaveLength(1);
236
+ });
237
+
238
+ test("drops unknown ids and missing secrets", async () => {
239
+ delete SECRET_STORE["credential/gone/api_key"];
240
+ const out = await resolveRevealCandidates([
241
+ { id: "99999999-9999-4999-8999-999999999999" },
242
+ { service: "gone", field: "api_key" },
243
+ ]);
244
+ expect(out).toEqual([]);
245
+ });
246
+
247
+ test("keeps both values when the same credential was rotated and re-revealed", async () => {
248
+ // reveal v1 → `credentials set` → reveal v2 inside one turn: the route
249
+ // served BOTH plaintexts to the tool's stdout, so both must survive as
250
+ // candidates. Deduping on identity alone would drop one and let it
251
+ // stream/persist raw whenever the scanner cannot classify it.
252
+ const out = await resolveRevealCandidates([
253
+ {
254
+ service: "openai",
255
+ field: "api_key",
256
+ provenValue: "hunter2-rotated-alpha",
257
+ },
258
+ {
259
+ service: "openai",
260
+ field: "api_key",
261
+ provenValue: "hunter2-rotated-beta",
262
+ },
263
+ ]);
264
+ expect(out).toEqual([
265
+ { service: "openai", field: "api_key", value: "hunter2-rotated-alpha" },
266
+ { service: "openai", field: "api_key", value: "hunter2-rotated-beta" },
267
+ ]);
268
+ });
269
+
270
+ test("still dedupes proven refs that agree on identity AND value", async () => {
271
+ const out = await resolveRevealCandidates([
272
+ { service: "openai", field: "api_key", provenValue: "hunter2-same" },
273
+ { service: "openai", field: "api_key", provenValue: "hunter2-same" },
274
+ ]);
275
+ expect(out).toHaveLength(1);
276
+ });
277
+
278
+ test("a value with JSON-escapable bytes also yields its escaped encoding", async () => {
279
+ // `credentials reveal --json` prints the value through JSON.stringify,
280
+ // so newlines/quotes/backslashes reach stdout ESCAPED — a representation
281
+ // the raw exact-match list can never find. Both encodings must be
282
+ // candidates.
283
+ const out = await resolveRevealCandidates([
284
+ { service: "github-app", field: "pem", provenValue: "line1\nline2" },
285
+ ]);
286
+ expect(out).toEqual([
287
+ { service: "github-app", field: "pem", value: "line1\nline2" },
288
+ { service: "github-app", field: "pem", value: "line1\\nline2" },
289
+ ]);
290
+ });
291
+
292
+ test("drops values below the exact-match length floor", async () => {
293
+ // The fallback global-replaces every occurrence of a candidate's bytes
294
+ // for the rest of the turn; a trivial value like `ok` would shred
295
+ // unrelated text into markers, and below the floor the value carries
296
+ // no meaningful entropy to protect anyway.
297
+ const out = await resolveRevealCandidates([
298
+ { service: "svc", field: "f", provenValue: "ok" },
299
+ { service: "svc", field: "g", provenValue: "12345" },
300
+ { service: "svc", field: "h", provenValue: "123456" },
301
+ ]);
302
+ expect(out).toEqual([{ service: "svc", field: "h", value: "123456" }]);
303
+ });
304
+
305
+ test("a value with no escapable bytes yields a single candidate", async () => {
306
+ const out = await resolveRevealCandidates([
307
+ { service: "svc", field: "f", provenValue: "hunter2-plain" },
308
+ ]);
309
+ expect(out).toHaveLength(1);
310
+ });
311
+ });
312
+
313
+ describe("resolveRefIdentities (eager staging lookup)", () => {
314
+ test("resolves a UUID ref to service/field and keeps the id", () => {
315
+ expect(resolveRefIdentities([{ id: UUID }])).toEqual([
316
+ { id: UUID, service: "openai", field: "api_key" },
317
+ ]);
318
+ });
319
+
320
+ test("passes an unknown id through for the proof-time fallback", () => {
321
+ const ref = { id: "99999999-9999-4999-8999-999999999999" };
322
+ expect(resolveRefIdentities([ref])).toEqual([ref]);
323
+ });
324
+ });
325
+
326
+ describe("filterRefsByRevealProof identity handling", () => {
327
+ test("a pre-resolved id ref stays proven after its metadata disappears", () => {
328
+ // Staging resolved the id to service/field while the metadata still
329
+ // existed; the credential was then removed before the tool result. The
330
+ // carried identity must satisfy the proof — a metadata re-lookup would
331
+ // fail and silently drop the ref for a value the tool already printed.
332
+ _resetRevealSuccessRegistryForTest();
333
+ openRevealProofWindow();
334
+ recordRevealSuccess(
335
+ "gone-service",
336
+ "api_key",
337
+ "hunter2-removed",
338
+ "nonce-test",
339
+ );
340
+ const proven = filterRefsByRevealProof(
341
+ [
342
+ {
343
+ id: "99999999-9999-4999-8999-999999999999",
344
+ service: "gone-service",
345
+ field: "api_key",
346
+ },
347
+ ],
348
+ 0,
349
+ "nonce-test",
350
+ );
351
+ expect(proven).toEqual([
352
+ {
353
+ service: "gone-service",
354
+ field: "api_key",
355
+ provenValue: "hunter2-removed",
356
+ },
357
+ ]);
358
+ _resetRevealSuccessRegistryForTest();
359
+ });
360
+ });
361
+
362
+ describe("drainCandidateGuardedChunk (live tool output guard)", () => {
363
+ const candidates = [{ service: "svc", field: "f", value: "hunter2-opaque" }];
364
+
365
+ test("redacts a complete occurrence within one chunk", () => {
366
+ const out = drainCandidateGuardedChunk(
367
+ "value: hunter2-opaque\n",
368
+ candidates,
369
+ );
370
+ expect(out.emitText).toBe('value: <redacted type="Credential" />\n');
371
+ expect(out.bufferedRemainder).toBe("");
372
+ });
373
+
374
+ test("holds a trailing partial occurrence, then redacts on completion", () => {
375
+ const first = drainCandidateGuardedChunk("out: hunter2-op", candidates);
376
+ expect(first.emitText).toBe("out: ");
377
+ expect(first.bufferedRemainder).toBe("hunter2-op");
378
+ const second = drainCandidateGuardedChunk(
379
+ first.bufferedRemainder + "aque done",
380
+ candidates,
381
+ );
382
+ expect(second.emitText).toBe('<redacted type="Credential" /> done');
383
+ expect(second.bufferedRemainder).toBe("");
384
+ });
385
+
386
+ test("passes clean chunks through whole", () => {
387
+ const out = drainCandidateGuardedChunk("plain output\n", candidates);
388
+ expect(out.emitText).toBe("plain output\n");
389
+ expect(out.bufferedRemainder).toBe("");
390
+ });
391
+
392
+ test("resolveProvenRevealCandidates skips refs without a proven value", () => {
393
+ expect(
394
+ resolveProvenRevealCandidates([
395
+ { service: "svc", field: "f" },
396
+ { service: "svc", field: "f", provenValue: "hunter2-opaque" },
397
+ ]),
398
+ ).toEqual(candidates);
399
+ });
400
+ });
401
+
402
+ describe("redactSecretsForChat", () => {
403
+ const candidates = [
404
+ {
405
+ service: "openai",
406
+ field: "api_key",
407
+ value: SYNTHETIC_OPENAI_PROJECT_KEY,
408
+ },
409
+ ];
410
+
411
+ test("exact byte match produces the enriched (revealable) sentinel", () => {
412
+ const out = redactSecretsForChat(
413
+ `key: ${SYNTHETIC_OPENAI_PROJECT_KEY}\n`,
414
+ candidates,
415
+ );
416
+ expect(out).toBe(
417
+ "key: \u3014redacted:OpenAI Project Key:openai:api_key\u3015\n",
418
+ );
419
+ expect(out).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
420
+ });
421
+
422
+ test("colon-qualified candidate service stays revealable via encoding", () => {
423
+ // Vault keys rewritten by migration 018-rekey-compound-credential-keys
424
+ // carry colon-qualified services (`integration:google`). The sentinel
425
+ // must encode the delimiter instead of downgrading the proven match to
426
+ // the non-revealable shape.
427
+ const out = redactSecretsForChat(`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`, [
428
+ {
429
+ service: "integration:google",
430
+ field: "api_key",
431
+ value: SYNTHETIC_OPENAI_PROJECT_KEY,
432
+ },
433
+ ]);
434
+ expect(out).toBe(
435
+ "key: \u3014redacted:OpenAI Project Key:integration%3Agoogle:api_key\u3015",
436
+ );
437
+ expect(out).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
438
+ });
439
+
440
+ test("no candidate match produces the plain sentinel — never a guess", () => {
441
+ const out = redactSecretsForChat(`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`, [
442
+ { service: "openai", field: "api_key", value: "different-value" },
443
+ ]);
444
+ expect(out).toBe("key: \u3014redacted:OpenAI Project Key\u3015");
445
+ });
446
+
447
+ test("empty candidate list still redacts, as plain sentinels", () => {
448
+ const out = redactSecretsForChat(SYNTHETIC_OPENAI_PROJECT_KEY, []);
449
+ expect(out).toBe("\u3014redacted:OpenAI Project Key\u3015");
450
+ });
451
+
452
+ test("a full PEM candidate is redacted whole, not just its header", () => {
453
+ // The scanner's `Private Key` pattern matches ONLY the
454
+ // `-----BEGIN … PRIVATE KEY-----` header, so a scanner-first pass would
455
+ // replace the header and leave the base64 body raw — and the full-value
456
+ // fallback would then miss because the intact value no longer exists.
457
+ // Candidate protection must run before the scanner so the whole key
458
+ // becomes one sentinel and no body bytes survive.
459
+ const pem =
460
+ "-----BEGIN RSA PRIVATE KEY-----\n" +
461
+ "MIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf9Cnzj4p4WGeKLs1Pt8Q\n" +
462
+ "uKUpRKfFLfRYC9AIKjbJTWit+CqvjfRasdf0123456789abcdefGHIJKLMNOPQR\n" +
463
+ "-----END RSA PRIVATE KEY-----";
464
+ const out = redactSecretsForChat(`here:\n${pem}\ndone`, [
465
+ { service: "github-app", field: "pem", value: pem },
466
+ ]);
467
+ expect(out).not.toContain("MIIBOgIBAAJBAKj34GkxFhD90");
468
+ expect(out).not.toContain("PRIVATE KEY");
469
+ expect(out).toBe(
470
+ "here:\n\u3014redacted:Private Key:github-app:pem\u3015\ndone",
471
+ );
472
+ });
473
+
474
+ test("duplicate plaintext across two identities degrades to the plain sentinel", () => {
475
+ // A byte match proves the value, not the vault identity: when two
476
+ // revealed credentials share the same plaintext, minting either chip
477
+ // would mislabel the span (and reveal the wrong value later if that
478
+ // credential rotates). The ambiguity must fail safe.
479
+ const out = redactSecretsForChat(`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`, [
480
+ {
481
+ service: "openai",
482
+ field: "api_key",
483
+ value: SYNTHETIC_OPENAI_PROJECT_KEY,
484
+ },
485
+ {
486
+ service: "litellm",
487
+ field: "api_key",
488
+ value: SYNTHETIC_OPENAI_PROJECT_KEY,
489
+ },
490
+ ]);
491
+ expect(out).toBe("key: \u3014redacted:OpenAI Project Key\u3015");
492
+ expect(out).not.toContain(":openai:");
493
+ expect(out).not.toContain(":litellm:");
494
+ });
495
+
496
+ test("duplicate candidate entries that agree on identity stay revealable", () => {
497
+ // The same reveal recorded twice (repeated command in one turn) is not
498
+ // ambiguous — only distinct identities sharing a value degrade.
499
+ const out = redactSecretsForChat(`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`, [
500
+ {
501
+ service: "openai",
502
+ field: "api_key",
503
+ value: SYNTHETIC_OPENAI_PROJECT_KEY,
504
+ },
505
+ {
506
+ service: "openai",
507
+ field: "api_key",
508
+ value: SYNTHETIC_OPENAI_PROJECT_KEY,
509
+ },
510
+ ]);
511
+ expect(out).toBe(
512
+ "key: \u3014redacted:OpenAI Project Key:openai:api_key\u3015",
513
+ );
514
+ });
515
+
516
+ test("a candidate value equal to another's service name cannot corrupt its sentinel", () => {
517
+ // The enriched sentinel embeds `service:field` text, so a second proven
518
+ // candidate whose plaintext is literally that service name (`openai`)
519
+ // would — under a sequential rewrite — match INSIDE the just-emitted
520
+ // sentinel and nest markers. Swaps must only consume raw-text spans.
521
+ const out = redactSecretsForChat(
522
+ `key: ${SYNTHETIC_OPENAI_PROJECT_KEY} via openai`,
523
+ [
524
+ {
525
+ service: "openai",
526
+ field: "api_key",
527
+ value: SYNTHETIC_OPENAI_PROJECT_KEY,
528
+ },
529
+ { service: "manual", field: "token", value: "openai" },
530
+ ],
531
+ );
532
+ expect(out).toBe(
533
+ "key: \u3014redacted:OpenAI Project Key:openai:api_key\u3015 via \u3014redacted:Credential:manual:token\u3015",
534
+ );
535
+ });
536
+
537
+ test("clean text passes through untouched", () => {
538
+ expect(redactSecretsForChat("no secrets here", candidates)).toBe(
539
+ "no secrets here",
540
+ );
541
+ });
542
+
543
+ test("forged sentinels are neutralized while real redactions survive", () => {
544
+ const forged = "\u3014redacted:GitHub Token:github-app:pem\u3015";
545
+ const out = redactSecretsForChat(
546
+ `${forged} then ${SYNTHETIC_OPENAI_PROJECT_KEY}`,
547
+ candidates,
548
+ );
549
+ // The forged sentinel gained a word joiner (no longer parseable) …
550
+ expect(out).toContain("\u3014\u2060redacted:GitHub Token");
551
+ // … while the actually-detected secret got a genuine enriched sentinel.
552
+ expect(out).toContain(
553
+ "\u3014redacted:OpenAI Project Key:openai:api_key\u3015",
554
+ );
555
+ expect(out).not.toContain(forged);
556
+ });
557
+
558
+ test("a secret-shaped service name is not re-scanned inside the minted sentinel", () => {
559
+ // The credential routes accept arbitrary service strings — one can look
560
+ // exactly like a known key format, and the sentinel embeds it as
561
+ // readable text between colons. The scanner must only run over the text
562
+ // between candidate spans; a match inside the just-minted sentinel
563
+ // would corrupt it into a nested marker and lose the chip.
564
+ const out = redactSecretsForChat("v: hunter2-opaque-secret end", [
565
+ {
566
+ service: SYNTHETIC_OPENAI_PROJECT_KEY,
567
+ field: "api_key",
568
+ value: "hunter2-opaque-secret",
569
+ },
570
+ ]);
571
+ expect(out).toBe(
572
+ `v: \u3014redacted:Credential:${SYNTHETIC_OPENAI_PROJECT_KEY}:api_key\u3015 end`,
573
+ );
574
+ });
575
+
576
+ test("a longer scanner match outranks a candidate substring inside it", () => {
577
+ // A proven manual value can be a strict substring of a DIFFERENT
578
+ // scanner-detectable secret. Carving the candidate's bytes out of the
579
+ // enclosing key would leave the key's suffix raw — the scanner must
580
+ // redact the whole key instead.
581
+ const candidates = [{ service: "svc", field: "f", value: "sk-pro" }];
582
+ const out = redactSecretsForChat(
583
+ `key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`,
584
+ candidates,
585
+ );
586
+ expect(out).toBe("key: \u3014redacted:OpenAI Project Key\u3015");
587
+ // A standalone occurrence still gets the exact-match fallback.
588
+ expect(redactSecretsForChat("v: sk-pro end", candidates)).toBe(
589
+ "v: \u3014redacted:Credential:svc:f\u3015 end",
590
+ );
591
+ });
592
+
593
+ test("a candidate value containing the sentinel trigger is still redacted whole", () => {
594
+ // Manual credential values are arbitrary strings — one may embed the
595
+ // sentinel trigger itself. Neutralizing the whole text BEFORE the
596
+ // exact-match pass would mutate the value's occurrence and the protect
597
+ // pass would miss it, so candidate spans resolve on the raw bytes and
598
+ // neutralization applies only to the text between them.
599
+ const value = "weird\u3014redacted:inner\u3015token";
600
+ const out = redactSecretsForChat(`v: ${value} end`, [
601
+ { service: "svc", field: "f", value },
602
+ ]);
603
+ expect(out).toBe("v: \u3014redacted:Credential:svc:f\u3015 end");
604
+ expect(out).not.toContain("weird");
605
+ });
606
+ });
607
+
608
+ describe("drainSentinelGuardedText (live-stream forgery guard)", () => {
609
+ test("neutralizes a complete forged sentinel within one chunk", () => {
610
+ const out = drainSentinelGuardedText(
611
+ "key: \u3014redacted:GitHub Token:github-app:pem\u3015 done",
612
+ );
613
+ expect(out.emitText).toBe(
614
+ "key: \u3014\u2060redacted:GitHub Token:github-app:pem\u3015 done",
615
+ );
616
+ expect(out.bufferedRemainder).toBe("");
617
+ });
618
+
619
+ test("holds back a trigger split across chunks, then neutralizes on completion", () => {
620
+ const first = drainSentinelGuardedText("look \u3014redac");
621
+ expect(first.emitText).toBe("look ");
622
+ expect(first.bufferedRemainder).toBe("\u3014redac");
623
+ const second = drainSentinelGuardedText(
624
+ first.bufferedRemainder + "ted:GitHub Token:github-app:pem\u3015",
625
+ );
626
+ expect(second.emitText).toBe(
627
+ "\u3014\u2060redacted:GitHub Token:github-app:pem\u3015",
628
+ );
629
+ expect(second.bufferedRemainder).toBe("");
630
+ });
631
+
632
+ test("releases a held prefix that never completes into a trigger", () => {
633
+ const first = drainSentinelGuardedText("open \u3014red");
634
+ expect(first.bufferedRemainder).toBe("\u3014red");
635
+ const second = drainSentinelGuardedText(
636
+ first.bufferedRemainder + " herring",
637
+ );
638
+ expect(second.emitText).toBe("\u3014red herring");
639
+ expect(second.bufferedRemainder).toBe("");
640
+ });
641
+
642
+ test("plain text passes through whole", () => {
643
+ const out = drainSentinelGuardedText("nothing suspicious here");
644
+ expect(out.emitText).toBe("nothing suspicious here");
645
+ expect(out.consumedRaw).toBe("nothing suspicious here");
646
+ expect(out.bufferedRemainder).toBe("");
647
+ });
648
+ });
649
+
650
+ describe("live reveal swap (stream plaintext hold-back)", () => {
651
+ const CANDIDATE = {
652
+ service: "openai",
653
+ field: "api_key",
654
+ value: SYNTHETIC_OPENAI_PROJECT_KEY,
655
+ };
656
+ const ENTRIES = buildLiveRevealGuardEntries([CANDIDATE]);
657
+ const SENTINEL = redactSecretsForChat(SYNTHETIC_OPENAI_PROJECT_KEY, [
658
+ CANDIDATE,
659
+ ]);
660
+
661
+ test("buildLiveRevealGuardEntries pairs a detectable value with its enriched sentinel", () => {
662
+ expect(ENTRIES).toEqual([
663
+ { value: SYNTHETIC_OPENAI_PROJECT_KEY, replacement: SENTINEL },
664
+ ]);
665
+ expect(SENTINEL).toContain(":openai:api_key\u3015");
666
+ });
667
+
668
+ test("buildLiveRevealGuardEntries covers a value the scanner cannot classify bare", () => {
669
+ // Several scanner patterns only match WITH context
670
+ // (`password=<value>` and friends), so a bare-undetectable candidate
671
+ // must still get an entry — dropping it let the plaintext cross the
672
+ // live stream raw while final persistence redacted the contextual
673
+ // occurrence. The replacement falls back to a generic-typed enriched
674
+ // sentinel built from the candidate identity.
675
+ expect(
676
+ buildLiveRevealGuardEntries([
677
+ { service: "svc", field: "f", value: "not a real secret shape" },
678
+ ]),
679
+ ).toEqual([
680
+ {
681
+ value: "not a real secret shape",
682
+ replacement: "\u3014redacted:Credential:svc:f\u3015",
683
+ },
684
+ ]);
685
+ });
686
+
687
+ test("persist redacts an unclassifiable candidate value via the exact-match fallback", () => {
688
+ // The live guard swaps a scanner-unclassifiable value,
689
+ // so persistence MUST redact it too — otherwise the SSE transcript
690
+ // hides the secret while the stored row keeps the raw plaintext and a
691
+ // refresh or history fetch exposes it. The persisted bytes must equal
692
+ // the streamed replacement exactly.
693
+ const candidates = [
694
+ { service: "svc", field: "f", value: "not a real secret shape" },
695
+ ];
696
+ const persisted = redactSecretsForChat(
697
+ "your token is not a real secret shape — keep it safe",
698
+ candidates,
699
+ );
700
+ expect(persisted).toBe(
701
+ "your token is \u3014redacted:Credential:svc:f\u3015 — keep it safe",
702
+ );
703
+ expect(persisted).not.toContain("not a real secret shape");
704
+ // Byte-identity with the live guard entry.
705
+ const [entry] = buildLiveRevealGuardEntries(candidates);
706
+ expect(entry!.replacement).toBe("\u3014redacted:Credential:svc:f\u3015");
707
+ });
708
+
709
+ test("redactCandidateValuesLegacy covers unclassifiable values on legacy-marker surfaces", () => {
710
+ // The reveal command's own stdout persists into the
711
+ // tool_result row via the legacy `<redacted type/>` path (the tool
712
+ // detail panel renders no chips). An opaque/manual value with no
713
+ // scanner-recognizable shape must still be redacted there — the
714
+ // stored tool result must not retain a value every other surface
715
+ // hides.
716
+ const candidates = [
717
+ { service: "svc", field: "f", value: "hunter2-opaque" },
718
+ ];
719
+ expect(
720
+ redactCandidateValuesLegacy("stdout: hunter2-opaque\n", candidates),
721
+ ).toBe('stdout: <redacted type="Credential" />\n');
722
+ // No candidates → byte-identical passthrough (legacy mode unchanged).
723
+ expect(redactCandidateValuesLegacy("stdout: hunter2-opaque\n", [])).toBe(
724
+ "stdout: hunter2-opaque\n",
725
+ );
726
+ });
727
+
728
+ test("redactCandidateValuesLegacy redacts a full PEM candidate whole", () => {
729
+ // Same PEM hazard on the legacy surface: the scanner recognizes only the
730
+ // header, so candidate protection must run before it or the key body
731
+ // persists raw in the tool-result row.
732
+ const pem =
733
+ "-----BEGIN RSA PRIVATE KEY-----\n" +
734
+ "MIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf9Cnzj4p4WGeKLs1Pt8Q\n" +
735
+ "-----END RSA PRIVATE KEY-----";
736
+ const out = redactCandidateValuesLegacy(`pem:\n${pem}\n`, [
737
+ { service: "github-app", field: "pem", value: pem },
738
+ ]);
739
+ expect(out).not.toContain("MIIBOgIBAAJBAKj34GkxFhD90");
740
+ expect(out).not.toContain("PRIVATE KEY");
741
+ expect(out).toBe('pem:\n<redacted type="Credential" />\n');
742
+ });
743
+
744
+ test("redactCandidateValuesLegacy does not depend on the sentinel flag's marker format", () => {
745
+ // The fallback protects legacy mode too — keeping a
746
+ // route-proven plaintext out of persisted rows is independent of
747
+ // which marker format the client renders. The output is the legacy
748
+ // marker, valid on flag-off surfaces.
749
+ const out = redactCandidateValuesLegacy("value: opaque-manual-secret", [
750
+ { service: "svc", field: "f", value: "opaque-manual-secret" },
751
+ ]);
752
+ expect(out).toBe('value: <redacted type="Credential" />');
753
+ expect(out).not.toContain("\u3014");
754
+ });
755
+
756
+ test("redactCandidateValuesLegacy covers the JSON-escaped form printed by reveal --json", async () => {
757
+ // `reveal --json` stdout carries JSON.stringify({ok, value}) — a value
758
+ // with newlines or quotes appears escaped, bytes the raw value can
759
+ // never match. The resolved candidate list carries both encodings, so
760
+ // the escaped body must be redacted too (the scanner alone would only
761
+ // catch a recognizable header).
762
+ const value = 'multi\nline"secret\\body';
763
+ const candidates = await resolveRevealCandidates([
764
+ { service: "svc", field: "f", provenValue: value },
765
+ ]);
766
+ const stdout = JSON.stringify({ ok: true, value });
767
+ const out = redactCandidateValuesLegacy(stdout, candidates);
768
+ expect(out).toContain('<redacted type="Credential" />');
769
+ expect(out).not.toContain("multi");
770
+ expect(out).not.toContain("secret");
771
+ });
772
+
773
+ test("redactCandidateValuesLegacy lets the scanner redact an enclosing secret whole", () => {
774
+ const out = redactCandidateValuesLegacy(
775
+ `key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`,
776
+ [{ service: "svc", field: "f", value: "sk-pro" }],
777
+ );
778
+ expect(out).toBe(`key: ${OPENAI_PROJECT_KEY_REDACTION_MARKER}`);
779
+ });
780
+
781
+ test("redactCandidateValuesLegacy covers a value containing the sentinel trigger", () => {
782
+ // Same raw-bytes-first ordering as the sentinel path: neutralizing the
783
+ // whole text before the exact match would mutate the value's occurrence
784
+ // and the opaque secret would persist almost intact.
785
+ const value = "weird\u3014redacted:inner\u3015token";
786
+ const out = redactCandidateValuesLegacy(`v: ${value} end`, [
787
+ { service: "svc", field: "f", value },
788
+ ]);
789
+ expect(out).toBe('v: <redacted type="Credential" /> end');
790
+ expect(out).not.toContain("weird");
791
+ });
792
+
793
+ test("redactCandidateValuesLegacy keeps emitted markers intact when a value appears inside them", () => {
794
+ // The legacy marker's own text contains `redacted` \u2014 a candidate whose
795
+ // plaintext is that word must not rewrite inside a marker just emitted
796
+ // for another candidate.
797
+ const out = redactCandidateValuesLegacy("value: hunter2-opaque-secret", [
798
+ { service: "svc", field: "f", value: "hunter2-opaque-secret" },
799
+ { service: "other", field: "g", value: "redacted" },
800
+ ]);
801
+ expect(out).toBe('value: <redacted type="Credential" />');
802
+ });
803
+
804
+ test("the persist fallback applies the duplicate-identity degrade rule", () => {
805
+ const candidates = [
806
+ { service: "svc", field: "f", value: "shared plain value" },
807
+ { service: "other", field: "g", value: "shared plain value" },
808
+ ];
809
+ expect(redactSecretsForChat("echo shared plain value", candidates)).toBe(
810
+ "echo \u3014redacted:Credential\u3015",
811
+ );
812
+ });
813
+
814
+ test("a bare-unclassifiable duplicate plaintext degrades to the plain generic sentinel", () => {
815
+ // The unique-identity degrade rule applies to fallback entries too:
816
+ // two identities sharing an unclassifiable value must not mint an
817
+ // identity-carrying chip for either.
818
+ expect(
819
+ buildLiveRevealGuardEntries([
820
+ { service: "svc", field: "f", value: "shared plain value" },
821
+ { service: "other", field: "g", value: "shared plain value" },
822
+ ]),
823
+ ).toEqual([
824
+ {
825
+ value: "shared plain value",
826
+ replacement: "\u3014redacted:Credential\u3015",
827
+ },
828
+ ]);
829
+ });
830
+
831
+ test("buildLiveRevealGuardEntries degrades a duplicate plaintext to the plain sentinel, matching persist", () => {
832
+ // Two identities sharing one value: the persist seam degrades that span
833
+ // to the plain type-only sentinel, so the live swap must emit the same
834
+ // bytes — one deduped entry whose replacement carries no identity.
835
+ const entries = buildLiveRevealGuardEntries([
836
+ {
837
+ service: "openai",
838
+ field: "api_key",
839
+ value: SYNTHETIC_OPENAI_PROJECT_KEY,
840
+ },
841
+ {
842
+ service: "litellm",
843
+ field: "api_key",
844
+ value: SYNTHETIC_OPENAI_PROJECT_KEY,
845
+ },
846
+ ]);
847
+ expect(entries).toEqual([
848
+ {
849
+ value: SYNTHETIC_OPENAI_PROJECT_KEY,
850
+ replacement: "\u3014redacted:OpenAI Project Key\u3015",
851
+ },
852
+ ]);
853
+ });
854
+
855
+ test("swaps a complete echoed value within one chunk", () => {
856
+ const raw = `Here it is: ${SYNTHETIC_OPENAI_PROJECT_KEY} — rotate it.`;
857
+ const out = drainSentinelGuardedText(raw, ENTRIES);
858
+ expect(out.emitText).toBe(`Here it is: ${SENTINEL} — rotate it.`);
859
+ expect(out.emitText).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
860
+ expect(out.consumedRaw).toBe(raw);
861
+ expect(out.bufferedRemainder).toBe("");
862
+ });
863
+
864
+ test("holds back a value split across chunks, then swaps on completion", () => {
865
+ const head = SYNTHETIC_OPENAI_PROJECT_KEY.slice(0, 12);
866
+ const tail = SYNTHETIC_OPENAI_PROJECT_KEY.slice(12);
867
+ const first = drainSentinelGuardedText(`token: ${head}`, ENTRIES);
868
+ expect(first.emitText).toBe("token: ");
869
+ expect(first.bufferedRemainder).toBe(head);
870
+ const second = drainSentinelGuardedText(
871
+ first.bufferedRemainder + tail + " end",
872
+ ENTRIES,
873
+ );
874
+ expect(second.emitText).toBe(`${SENTINEL} end`);
875
+ expect(second.emitText).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
876
+ expect(second.bufferedRemainder).toBe("");
877
+ });
878
+
879
+ test("releases a held value prefix that never completes", () => {
880
+ const head = SYNTHETIC_OPENAI_PROJECT_KEY.slice(0, 12);
881
+ const first = drainSentinelGuardedText(`prefix ${head}`, ENTRIES);
882
+ expect(first.bufferedRemainder).toBe(head);
883
+ const second = drainSentinelGuardedText(
884
+ first.bufferedRemainder + "-not-the-secret",
885
+ ENTRIES,
886
+ );
887
+ expect(second.emitText).toBe(`${head}-not-the-secret`);
888
+ expect(second.bufferedRemainder).toBe("");
889
+ });
890
+
891
+ test("consumedRaw carries the plaintext for persist-side re-redaction", () => {
892
+ const raw = `value ${SYNTHETIC_OPENAI_PROJECT_KEY}.`;
893
+ const out = drainSentinelGuardedText(raw, ENTRIES);
894
+ // The mirror path re-redacts consumedRaw at persist; verify the round
895
+ // trip lands on the same sentinel the live stream emitted.
896
+ expect(redactSecretsForChat(out.consumedRaw, [CANDIDATE])).toBe(
897
+ out.emitText,
898
+ );
899
+ });
900
+
901
+ test("swaps a self-overlapping value chunked at the overlap boundary", () => {
902
+ // A value whose proper prefix is also its suffix (`abcabc`), chunked
903
+ // exactly at the overlap (`abc` + `abc`). The hold must consume the
904
+ // complete occurrence instead of re-holding the trailing repeat —
905
+ // otherwise the first half is emitted as raw plaintext and the swap
906
+ // never sees the full value.
907
+ const entries = [{ value: "abcabc", replacement: "[SWAPPED]" }];
908
+ const first = drainSentinelGuardedText("abc", entries);
909
+ expect(first.emitText).toBe("");
910
+ expect(first.bufferedRemainder).toBe("abc");
911
+ const second = drainSentinelGuardedText(
912
+ first.bufferedRemainder + "abc",
913
+ entries,
914
+ );
915
+ expect(second.emitText).toBe("[SWAPPED]");
916
+ expect(second.consumedRaw).toBe("abcabc");
917
+ expect(second.bufferedRemainder).toBe("");
918
+ });
919
+
920
+ test("holds only the partial repeat after a complete self-overlapping occurrence", () => {
921
+ const entries = [{ value: "abcabc", replacement: "[SWAPPED]" }];
922
+ const out = drainSentinelGuardedText("abcabcabc", entries);
923
+ expect(out.emitText).toBe("[SWAPPED]");
924
+ expect(out.consumedRaw).toBe("abcabc");
925
+ expect(out.bufferedRemainder).toBe("abc");
926
+ });
927
+
928
+ test("holds a completed value whole while a higher-priority match could claim its tail", () => {
929
+ // Candidate A ends with a proper prefix of longer
930
+ // (higher-priority) candidate B (`…-sk` / `sk-…`). At a chunk boundary
931
+ // right after a complete A the outcome is genuinely ambiguous — the
932
+ // next bytes decide whether the full-text swap consumes A or a B
933
+ // starting inside A's tail — so A must be held WHOLE. Splitting A to
934
+ // hold only B's prefix leaks A's head raw; committing A immediately
935
+ // leaks B's suffix raw when B completes.
936
+ const entries = [
937
+ { value: "xx-sk", replacement: "[A]" },
938
+ { value: "sk-yyyy", replacement: "[B]" },
939
+ ];
940
+ const first = drainSentinelGuardedText("token xx-sk", entries);
941
+ expect(first.emitText).toBe("token ");
942
+ expect(first.consumedRaw).toBe("token ");
943
+ expect(first.bufferedRemainder).toBe("xx-sk");
944
+
945
+ // Continuation 1: B completes inside A's tail — matches the unchunked
946
+ // swap of `xx-sk-yyyy` (B wins, A's occurrence is blocked by overlap).
947
+ const bWins = drainSentinelGuardedText(
948
+ first.bufferedRemainder + "-yyyy ok",
949
+ entries,
950
+ );
951
+ expect(bWins.emitText).toBe("xx-[B] ok");
952
+ expect(bWins.consumedRaw).toBe("xx-sk-yyyy ok");
953
+ expect(bWins.bufferedRemainder).toBe("");
954
+
955
+ // Continuation 2: the threat dissolves — A swaps whole.
956
+ const aWins = drainSentinelGuardedText(
957
+ first.bufferedRemainder + " ok",
958
+ entries,
959
+ );
960
+ expect(aWins.emitText).toBe("[A] ok");
961
+ expect(aWins.consumedRaw).toBe("xx-sk ok");
962
+ expect(aWins.bufferedRemainder).toBe("");
963
+ });
964
+
965
+ test("equal-length overlapping entries stay chunk-independent", () => {
966
+ // `aba` outranks `bab` (equal length, earlier in the
967
+ // sorted order — same tie-break as `swapLiveRevealValues`). Chunks
968
+ // `bab` + `a` must produce the same output as the unchunked swap of
969
+ // `baba` (`b` + swapped `aba`), not commit the completed `bab` and
970
+ // emit a raw trailing `a`.
971
+ const entries = [
972
+ { value: "aba", replacement: "[ABA]" },
973
+ { value: "bab", replacement: "[BAB]" },
974
+ ];
975
+ const first = drainSentinelGuardedText("bab", entries);
976
+ expect(first.emitText).toBe("");
977
+ expect(first.consumedRaw).toBe("");
978
+ expect(first.bufferedRemainder).toBe("bab");
979
+ const second = drainSentinelGuardedText(
980
+ first.bufferedRemainder + "a",
981
+ entries,
982
+ );
983
+ expect(second.emitText).toBe("b[ABA]");
984
+ expect(second.consumedRaw).toBe("baba");
985
+ expect(second.bufferedRemainder).toBe("");
986
+ });
987
+
988
+ test("commits an equal-length occurrence once no higher-priority prefix survives", () => {
989
+ // Same entries, but the bytes after the completed `bab` rule out any
990
+ // pending `aba` — the occurrence commits whole with no held tail.
991
+ const entries = [
992
+ { value: "aba", replacement: "[ABA]" },
993
+ { value: "bab", replacement: "[BAB]" },
994
+ ];
995
+ const out = drainSentinelGuardedText("bab end", entries);
996
+ expect(out.emitText).toBe("[BAB] end");
997
+ expect(out.consumedRaw).toBe("bab end");
998
+ expect(out.bufferedRemainder).toBe("");
999
+ });
1000
+
1001
+ test("still holds another entry's prefix when it follows a completed value", () => {
1002
+ // Bytes AFTER the consumed occurrence are fair game: `sk` following a
1003
+ // complete A is genuinely ambiguous (B may be starting) and is held,
1004
+ // while A itself swaps whole.
1005
+ const entries = [
1006
+ { value: "xx-sk", replacement: "[A]" },
1007
+ { value: "sk-yyyy", replacement: "[B]" },
1008
+ ];
1009
+ const first = drainSentinelGuardedText("token xx-sk sk", entries);
1010
+ expect(first.emitText).toBe("token [A] ");
1011
+ expect(first.consumedRaw).toBe("token xx-sk ");
1012
+ expect(first.bufferedRemainder).toBe("sk");
1013
+ const second = drainSentinelGuardedText(
1014
+ first.bufferedRemainder + "-yyyy done",
1015
+ entries,
1016
+ );
1017
+ expect(second.emitText).toBe("[B] done");
1018
+ expect(second.consumedRaw).toBe("sk-yyyy done");
1019
+ expect(second.bufferedRemainder).toBe("");
1020
+ });
1021
+
1022
+ test("hold-back skips occurrences with the swap's greedy left-to-right semantics", () => {
1023
+ // `abab` with value `aba`: split/join consumes the occurrence at 0 and
1024
+ // does NOT match the overlapping occurrence at 2, so the guard must not
1025
+ // hold the trailing `b` (not a prefix) — live emit and persist-time
1026
+ // redaction stay byte-identical.
1027
+ const entries = [{ value: "aba", replacement: "[X]" }];
1028
+ const out = drainSentinelGuardedText("abab", entries);
1029
+ expect(out.emitText).toBe("[X]b");
1030
+ expect(out.consumedRaw).toBe("abab");
1031
+ expect(out.bufferedRemainder).toBe("");
1032
+ });
1033
+
1034
+ test("swaps the longest matching value when one candidate prefixes another", () => {
1035
+ // Insertion order must not matter: with the shorter entry first, the
1036
+ // longer echoed value would otherwise get the shorter chip plus its
1037
+ // unmatched suffix emitted raw — inconsistent with persist-time
1038
+ // redaction, which redacts the whole longer span.
1039
+ const entries = [
1040
+ { value: "sk-abc", replacement: "[SHORT]" },
1041
+ { value: "sk-abcdef", replacement: "[LONG]" },
1042
+ ];
1043
+ expect(swapLiveRevealValues("key: sk-abcdef end", entries)).toBe(
1044
+ "key: [LONG] end",
1045
+ );
1046
+ expect(swapLiveRevealValues("a sk-abc b sk-abcdef c", entries)).toBe(
1047
+ "a [SHORT] b [LONG] c",
1048
+ );
1049
+ });
1050
+
1051
+ test("swapLiveRevealValues replaces multiple occurrences", () => {
1052
+ const text = `a ${SYNTHETIC_OPENAI_PROJECT_KEY} b ${SYNTHETIC_OPENAI_PROJECT_KEY} c`;
1053
+ expect(swapLiveRevealValues(text, ENTRIES)).toBe(
1054
+ `a ${SENTINEL} b ${SENTINEL} c`,
1055
+ );
1056
+ });
1057
+
1058
+ test("the live guard swaps a value containing the sentinel trigger", () => {
1059
+ // The emit transform resolves candidate spans and forgery
1060
+ // neutralization together on the raw bytes — sequencing neutralization
1061
+ // first would mutate this value's occurrence and stream it raw.
1062
+ const value = "weird\u3014redacted:inner\u3015token";
1063
+ const candidates = [{ service: "svc", field: "f", value }];
1064
+ const entries = buildLiveRevealGuardEntries(candidates);
1065
+ const out = drainSentinelGuardedText(`v: ${value} end`, entries);
1066
+ expect(out.emitText).toBe("v: \u3014redacted:Credential:svc:f\u3015 end");
1067
+ expect(out.bufferedRemainder).toBe("");
1068
+ });
1069
+
1070
+ test("swapLiveRevealValues never rewrites inside an earlier entry's replacement", () => {
1071
+ // A replacement sentinel is not inert text — it embeds service/field
1072
+ // segments. When another entry's plaintext equals one of those segments
1073
+ // (a credential whose value is literally `openai`), a sequential
1074
+ // split/join would corrupt the just-emitted sentinel into nested
1075
+ // markers; the swap must only consume raw-text spans.
1076
+ const entries = [
1077
+ {
1078
+ value: "sk-long-secret-0123456789",
1079
+ replacement: "\u3014redacted:Credential:openai:api_key\u3015",
1080
+ },
1081
+ {
1082
+ value: "openai",
1083
+ replacement: "\u3014redacted:Credential:manual:token\u3015",
1084
+ },
1085
+ ];
1086
+ expect(
1087
+ swapLiveRevealValues(
1088
+ "sk-long-secret-0123456789 spoke to openai",
1089
+ entries,
1090
+ ),
1091
+ ).toBe(
1092
+ "\u3014redacted:Credential:openai:api_key\u3015 spoke to \u3014redacted:Credential:manual:token\u3015",
1093
+ );
1094
+ });
1095
+ });
1096
+
1097
+ describe("persisted text rider", () => {
1098
+ test("buildPersistedAssistantContent stamps _redactionVersion on text blocks", async () => {
1099
+ const { buildPersistedAssistantContent } =
1100
+ await import("../daemon/conversation-agent-loop-handlers.js");
1101
+ const [block] = buildPersistedAssistantContent(
1102
+ [{ type: "text", text: "hello" }],
1103
+ [],
1104
+ );
1105
+ expect((block as { _redactionVersion?: number })._redactionVersion).toBe(2);
1106
+ });
1107
+ });
1108
+
1109
+ describe("legacy marker invariant", () => {
1110
+ test("redactSecrets output is byte-unchanged by this feature", () => {
1111
+ expect(redactSecrets(`key: ${SYNTHETIC_OPENAI_PROJECT_KEY}`)).toBe(
1112
+ `key: ${OPENAI_PROJECT_KEY_REDACTION_MARKER}`,
1113
+ );
1114
+ });
1115
+ });
1116
+
1117
+ describe("--for-chat (daemon-minted sentinel channel)", () => {
1118
+ const FOR_CHAT_CANDIDATE = {
1119
+ service: "openai",
1120
+ field: "api_key",
1121
+ value: SYNTHETIC_OPENAI_PROJECT_KEY,
1122
+ };
1123
+ const CANONICAL = buildForChatSentinel(FOR_CHAT_CANDIDATE);
1124
+ const MINT = {
1125
+ service: "openai",
1126
+ field: "api_key",
1127
+ sentinel: CANONICAL,
1128
+ };
1129
+
1130
+ test("collectRevealRefsFromCommand carries no --for-chat signal — executed mints are the only authority", () => {
1131
+ // A parse of the requested command must never authorize re-minting: a
1132
+ // segment that merely QUOTES a reveal invocation (echo '… --for-chat …')
1133
+ // parses identically to one that runs it. The refs exist only to scope
1134
+ // the plaintext-swap candidate fetch; the re-mint allowlist comes from
1135
+ // the route-recorded mint registry and from candidates PROVEN by the
1136
+ // reveal-success registry.
1137
+ expect(
1138
+ collectRevealRefsFromCommand(
1139
+ "assistant credentials reveal --for-chat --service openai --field api_key",
1140
+ ),
1141
+ ).toEqual([{ service: "openai", field: "api_key" }]);
1142
+ expect(
1143
+ collectRevealRefsFromCommand(
1144
+ `assistant credentials reveal ${UUID} --for-chat`,
1145
+ ),
1146
+ ).toEqual([{ id: UUID }]);
1147
+ });
1148
+
1149
+ test("buildForChatSentinel stamps the scanner's type label", () => {
1150
+ expect(CANONICAL).toBe(
1151
+ "\u3014redacted:OpenAI Project Key:openai:api_key\u3015",
1152
+ );
1153
+ });
1154
+
1155
+ test("buildForChatSentinel falls back to a generic label for unscannable values", () => {
1156
+ expect(
1157
+ buildForChatSentinel({
1158
+ service: "svc",
1159
+ field: "f",
1160
+ value: "plain-value-no-pattern",
1161
+ }),
1162
+ ).toBe("\u3014redacted:Credential:svc:f\u3015");
1163
+ });
1164
+
1165
+ test("guardForChatSentinels re-mints an identity match, even with a tampered type label", () => {
1166
+ const echoed = `Your key: ${CANONICAL} — click to reveal.`;
1167
+ expect(guardForChatSentinels(echoed, [MINT])).toBe(echoed);
1168
+ // A hand-altered type label is canonicalized away, not trusted — the
1169
+ // replacement is the route's original mint.
1170
+ const tampered = "see \u3014redacted:GitHub Token:openai:api_key\u3015";
1171
+ expect(guardForChatSentinels(tampered, [MINT])).toBe(`see ${CANONICAL}`);
1172
+ });
1173
+
1174
+ test("guardForChatSentinels neutralizes unknown identities and plain shapes", () => {
1175
+ const unknown = "\u3014redacted:API Key:github-app:pem\u3015";
1176
+ const plain = "\u3014redacted:API Key\u3015";
1177
+ const out = guardForChatSentinels(`${unknown} ${plain}`, [MINT]);
1178
+ expect(out).toBe(
1179
+ "\u3014\u2060redacted:API Key:github-app:pem\u3015 \u3014\u2060redacted:API Key\u3015",
1180
+ );
1181
+ });
1182
+
1183
+ test("guardForChatSentinels equals plain neutralization with no recorded mints", () => {
1184
+ const text = `x ${CANONICAL} y \u3014redac partial`;
1185
+ expect(guardForChatSentinels(text, [])).toBe(
1186
+ neutralizeRedactedSentinels(text),
1187
+ );
1188
+ });
1189
+
1190
+ test("redactSecretsForChat preserves the daemon-minted sentinel and still redacts plaintext", () => {
1191
+ const text = `chip ${CANONICAL} and raw ${SYNTHETIC_OPENAI_PROJECT_KEY}`;
1192
+ const out = redactSecretsForChat(text, [FOR_CHAT_CANDIDATE], [MINT]);
1193
+ expect(out).toBe(`chip ${CANONICAL} and raw ${CANONICAL}`);
1194
+ expect(out).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
1195
+ });
1196
+
1197
+ test("a secret embedded inside a forged sentinel is still scanner-redacted", () => {
1198
+ // The forged span's identity matches no authority, so its bytes run
1199
+ // the full neutralize+scan pipeline — an attacker cannot smuggle a
1200
+ // real key through persistence by dressing it as a sentinel.
1201
+ const forged = `\u3014redacted:x:svc:${SYNTHETIC_OPENAI_PROJECT_KEY}\u3015`;
1202
+ const out = redactSecretsForChat(forged, [], [MINT]);
1203
+ expect(out).not.toContain(SYNTHETIC_OPENAI_PROJECT_KEY);
1204
+ });
1205
+
1206
+ test("a candidate list alone is not re-mint authority (quoted command grants nothing)", () => {
1207
+ // The quoted-command scenario: the identity IS a reveal candidate
1208
+ // (staging parsed the echoed text), but the route never executed the
1209
+ // reveal, so no proof exists, handlers derive no authorities, and the
1210
+ // echoed sentinel is neutralized like any other forgery.
1211
+ const out = redactSecretsForChat(`chip ${CANONICAL}`, [FOR_CHAT_CANDIDATE]);
1212
+ expect(out).toBe(`chip ${neutralizeRedactedSentinels(CANONICAL)}`);
1213
+ });
1214
+
1215
+ test("stream guard holds an unclosed trigger open until the sentinel completes, then re-mints", () => {
1216
+ const head = CANONICAL.slice(0, 20); // past the trigger, before the close
1217
+ const tail = CANONICAL.slice(20);
1218
+ const first = drainSentinelGuardedText(`token: ${head}`, [], [MINT]);
1219
+ expect(first.emitText).toBe("token: ");
1220
+ expect(first.bufferedRemainder).toBe(head);
1221
+ const second = drainSentinelGuardedText(
1222
+ first.bufferedRemainder + tail + " done",
1223
+ [],
1224
+ [MINT],
1225
+ );
1226
+ expect(second.emitText).toBe(`${CANONICAL} done`);
1227
+ expect(second.bufferedRemainder).toBe("");
1228
+ });
1229
+
1230
+ test("stream guard still eagerly neutralizes complete triggers without recorded mints", () => {
1231
+ const out = drainSentinelGuardedText("x \u3014redacted:oops not closed");
1232
+ expect(out.emitText).toBe("x \u3014\u2060redacted:oops not closed");
1233
+ expect(out.bufferedRemainder).toBe("");
1234
+ });
1235
+
1236
+ test("stream guard releases (neutralized) an unclosed trigger past the hold cap", () => {
1237
+ const forged = "\u3014redacted:" + "a".repeat(600);
1238
+ const out = drainSentinelGuardedText(`pre ${forged}`, [], [MINT]);
1239
+ expect(out.emitText).toContain("\u3014\u2060redacted:");
1240
+ expect(out.bufferedRemainder).toBe("");
1241
+ });
1242
+ });
1243
+
1244
+ describe("plain-reveal re-mint authorities (retyped sentinel after a proven reveal)", () => {
1245
+ const CANDIDATE = {
1246
+ service: "test",
1247
+ field: "qa_token",
1248
+ value: "hunter2-manual-token-value",
1249
+ };
1250
+ const CANONICAL = buildForChatSentinel(CANDIDATE);
1251
+ const AUTHORITIES = remintAuthoritiesFromCandidates([CANDIDATE]);
1252
+
1253
+ test("remintAuthoritiesFromCandidates builds one canonical authority per identity", () => {
1254
+ // Encoding-variant candidates share an identity and must not produce
1255
+ // duplicate authorities; the sentinel is minted from the first
1256
+ // candidate's own metadata, never from any retyped span.
1257
+ const variants = [
1258
+ CANDIDATE,
1259
+ { ...CANDIDATE, value: "hunter2-manual\\ntoken" },
1260
+ ];
1261
+ expect(remintAuthoritiesFromCandidates(variants)).toEqual([
1262
+ {
1263
+ service: "test",
1264
+ field: "qa_token",
1265
+ sentinel: CANONICAL,
1266
+ },
1267
+ ]);
1268
+ });
1269
+
1270
+ test("persist re-mints a model-retyped sentinel whose identity was proven this turn", () => {
1271
+ // The LUM-2768 repro: history shows the model its own redacted reply;
1272
+ // asked to "show it again", it retypes the sentinel instead of echoing
1273
+ // the plaintext. With the identity proven this turn, the retyped span
1274
+ // is restored to the canonical mint instead of degrading to
1275
+ // neutralized glyph text.
1276
+ const retyped = `here it is again: ${CANONICAL}`;
1277
+ expect(redactSecretsForChat(retyped, [CANDIDATE], AUTHORITIES)).toBe(
1278
+ retyped,
1279
+ );
1280
+ });
1281
+
1282
+ test("a tampered label on a proven identity canonicalizes instead of neutralizing", () => {
1283
+ const tampered = "\u3014redacted:GitHub Token:test:qa_token\u3015";
1284
+ expect(redactSecretsForChat(tampered, [CANDIDATE], AUTHORITIES)).toBe(
1285
+ CANONICAL,
1286
+ );
1287
+ });
1288
+
1289
+ test("live guard re-mints the retyped sentinel so the wire matches persistence", () => {
1290
+ const out = drainSentinelGuardedText(
1291
+ `again: ${CANONICAL}!`,
1292
+ [],
1293
+ AUTHORITIES,
1294
+ );
1295
+ expect(out.emitText).toBe(`again: ${CANONICAL}!`);
1296
+ expect(out.bufferedRemainder).toBe("");
1297
+ });
1298
+
1299
+ test("an unproven identity still neutralizes even with other authorities present", () => {
1300
+ const other = "\u3014redacted:Credential:prod:api_key\u3015";
1301
+ expect(redactSecretsForChat(other, [CANDIDATE], AUTHORITIES)).toBe(
1302
+ neutralizeRedactedSentinels(other),
1303
+ );
1304
+ });
1305
+ });
1306
+
1307
+ describe("for-chat mint registry", () => {
1308
+ test("returns only mints recorded after the captured watermark", () => {
1309
+ resetForChatMintRegistryForTest();
1310
+ recordForChatMint({
1311
+ service: "old",
1312
+ field: "f",
1313
+ sentinel: "s0",
1314
+ nonce: "n1",
1315
+ });
1316
+ const watermark = currentForChatMintWatermark();
1317
+ expect(forChatMintsSince(watermark)).toEqual([]);
1318
+ recordForChatMint({
1319
+ service: "openai",
1320
+ field: "api_key",
1321
+ sentinel: "s1",
1322
+ nonce: "n1",
1323
+ });
1324
+ expect(forChatMintsSince(watermark)).toEqual([
1325
+ { service: "openai", field: "api_key", sentinel: "s1", nonce: "n1" },
1326
+ ]);
1327
+ // A turn that started before the first mint sees both identities.
1328
+ expect(forChatMintsSince(0)).toHaveLength(2);
1329
+ resetForChatMintRegistryForTest();
1330
+ });
1331
+
1332
+ test("records carry the executing conversation's nonce for consumer-side binding", () => {
1333
+ // The registry stores the secret nonce the executing tool shell
1334
+ // forwarded; the agent loop accepts only records matching its own
1335
+ // conversation's nonce AND an identity its run staged, so a concurrent
1336
+ // conversation's executed reveal never authorizes another that merely
1337
+ // names the identity.
1338
+ resetForChatMintRegistryForTest();
1339
+ recordForChatMint({
1340
+ service: "openai",
1341
+ field: "api_key",
1342
+ sentinel: "s1",
1343
+ nonce: "n1",
1344
+ });
1345
+ expect(forChatMintsSince(0)).toEqual([
1346
+ { service: "openai", field: "api_key", sentinel: "s1", nonce: "n1" },
1347
+ ]);
1348
+ resetForChatMintRegistryForTest();
1349
+ });
1350
+
1351
+ test("dedupes per nonce+identity with the latest sentinel winning", () => {
1352
+ resetForChatMintRegistryForTest();
1353
+ recordForChatMint({
1354
+ service: "svc",
1355
+ field: "f",
1356
+ sentinel: "first",
1357
+ nonce: "n1",
1358
+ });
1359
+ recordForChatMint({
1360
+ service: "svc",
1361
+ field: "f",
1362
+ sentinel: "second",
1363
+ nonce: "n1",
1364
+ });
1365
+ expect(forChatMintsSince(0)).toEqual([
1366
+ { service: "svc", field: "f", sentinel: "second", nonce: "n1" },
1367
+ ]);
1368
+ resetForChatMintRegistryForTest();
1369
+ });
1370
+
1371
+ test("concurrent same-credential reveals from different conversations both survive", () => {
1372
+ // The dedupe key includes the nonce: consumers filter by THEIR nonce
1373
+ // after this call, so an identity-only dedupe would let conversation
1374
+ // B's later reveal of the same credential clobber conversation A's
1375
+ // legitimate mint and neutralize A's echoed sentinel.
1376
+ resetForChatMintRegistryForTest();
1377
+ recordForChatMint({
1378
+ service: "svc",
1379
+ field: "f",
1380
+ sentinel: "s-a",
1381
+ nonce: "nonce-a",
1382
+ });
1383
+ recordForChatMint({
1384
+ service: "svc",
1385
+ field: "f",
1386
+ sentinel: "s-b",
1387
+ nonce: "nonce-b",
1388
+ });
1389
+ const mints = forChatMintsSince(0);
1390
+ expect(mints).toHaveLength(2);
1391
+ expect(mints.find((m) => m.nonce === "nonce-a")?.sentinel).toBe("s-a");
1392
+ expect(mints.find((m) => m.nonce === "nonce-b")?.sentinel).toBe("s-b");
1393
+ resetForChatMintRegistryForTest();
1394
+ });
1395
+ });