@vellumai/assistant 0.9.0 → 0.9.1-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/ARCHITECTURE.md +18 -34
  2. package/bun.lock +7 -8
  3. package/docs/activation-funnel-telemetry.md +4 -4
  4. package/docs/architecture/security.md +29 -28
  5. package/docs/stt-provider-onboarding.md +3 -5
  6. package/docs/workflows-testing.md +13 -44
  7. package/docs/workflows.md +3 -5
  8. package/node_modules/@vellumai/ces-client/src/__tests__/ces-client.test.ts +47 -0
  9. package/node_modules/@vellumai/ces-client/src/rpc-client.ts +28 -5
  10. package/node_modules/@vellumai/environments/src/seeds.ts +2 -5
  11. package/node_modules/@vellumai/gateway-client/src/index.ts +17 -6
  12. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +119 -0
  13. package/node_modules/@vellumai/gateway-client/src/types.ts +15 -84
  14. package/openapi.yaml +135 -59
  15. package/package.json +2 -1
  16. package/scripts/sync-llm-catalog.ts +6 -15
  17. package/scripts/sync-web-search-catalog.ts +3 -11
  18. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +14 -26
  19. package/src/__tests__/agent-loop-compaction-strip.test.ts +240 -0
  20. package/src/__tests__/agent-loop-output-hooks.test.ts +69 -0
  21. package/src/__tests__/agent-loop-override-profile.test.ts +25 -0
  22. package/src/__tests__/always-loaded-tools-guard.test.ts +2 -3
  23. package/src/__tests__/app-dir-path-guard.test.ts +0 -1
  24. package/src/__tests__/assistant-feature-flag-guard.test.ts +1 -4
  25. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +0 -2
  26. package/src/__tests__/avatar-identity-sync.test.ts +2 -27
  27. package/src/__tests__/btw-routes.test.ts +6 -8
  28. package/src/__tests__/checker.test.ts +0 -3
  29. package/src/__tests__/config-loader-backfill.test.ts +103 -6
  30. package/src/__tests__/config-watcher.test.ts +0 -18
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +22 -0
  32. package/src/__tests__/credential-broker.test.ts +449 -1
  33. package/src/__tests__/credential-execution-tools.test.ts +0 -1
  34. package/src/__tests__/credential-prompt-route.test.ts +3 -4
  35. package/src/__tests__/credential-routes.test.ts +360 -0
  36. package/src/__tests__/credential-security-invariants.test.ts +2 -13
  37. package/src/__tests__/dynamic-page-surface.test.ts +101 -1
  38. package/src/__tests__/fixtures/credential-security-fixtures.ts +2 -33
  39. package/src/__tests__/gateway-only-guard.test.ts +3 -7
  40. package/src/__tests__/identity-routes.test.ts +0 -189
  41. package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
  42. package/src/__tests__/invite-redemption-service.test.ts +4 -4
  43. package/src/__tests__/llm-callsite-catalog.test.ts +5 -6
  44. package/src/__tests__/llm-catalog-parity.test.ts +0 -22
  45. package/src/__tests__/llm-resolver.test.ts +49 -24
  46. package/src/__tests__/oauth-provider-seed-logos.test.ts +4 -6
  47. package/src/__tests__/onboarding-persona-write.test.ts +1 -1
  48. package/src/__tests__/persona-resolver.test.ts +11 -14
  49. package/src/__tests__/plugin-api-model-profiles.test.ts +178 -0
  50. package/src/__tests__/registry.test.ts +2 -7
  51. package/src/__tests__/schedule-routes-workflow-validation.test.ts +1 -10
  52. package/src/__tests__/schedule-routes.test.ts +0 -30
  53. package/src/__tests__/schedule-tools.test.ts +2 -18
  54. package/src/__tests__/skill-execute-input.test.ts +46 -1
  55. package/src/__tests__/skill-runtime-path.test.ts +2 -3
  56. package/src/__tests__/subagent-tools.test.ts +116 -0
  57. package/src/__tests__/surface-completion-nudge-hook.test.ts +367 -0
  58. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -29
  59. package/src/__tests__/token-manager.test.ts +519 -0
  60. package/src/__tests__/tool-executor.test.ts +0 -79
  61. package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
  62. package/src/__tests__/trusted-contact-verification.test.ts +6 -6
  63. package/src/__tests__/voice-invite-redemption.test.ts +2 -2
  64. package/src/__tests__/web-search-catalog-parity.test.ts +6 -25
  65. package/src/__tests__/workspace-greetings.test.ts +152 -0
  66. package/src/agent/loop.ts +25 -5
  67. package/src/api/README.md +6 -6
  68. package/src/api/responses/conversation-message.ts +2 -4
  69. package/src/api/responses/home.ts +0 -4
  70. package/src/approvals/guardian-request-resolvers.ts +2 -2
  71. package/src/calls/relay-access-wait.ts +1 -1
  72. package/src/calls/voice-session-bridge.ts +2 -2
  73. package/src/cli/commands/plugins.ts +143 -2
  74. package/src/cli/lib/__tests__/diff-plugin.test.ts +443 -0
  75. package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +313 -0
  76. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +253 -2
  77. package/src/cli/lib/diff-plugin.ts +346 -0
  78. package/src/cli/lib/install-from-github.ts +105 -17
  79. package/src/cli/lib/merge-plugin-tree.ts +228 -0
  80. package/src/cli/lib/plugin-fingerprint.ts +14 -0
  81. package/src/cli/lib/upgrade-plugin.ts +270 -10
  82. package/src/cli/program.ts +0 -2
  83. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  84. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  85. package/src/config/bundled-skills/workflows/SKILL.md +0 -1
  86. package/src/config/bundled-tool-registry.ts +2 -7
  87. package/src/config/call-site-defaults.ts +12 -2
  88. package/src/config/feature-flag-registry.json +1 -17
  89. package/src/config/inference-profile-validation.ts +26 -0
  90. package/src/config/loader.ts +4 -0
  91. package/src/config/profile-order.ts +28 -0
  92. package/src/config/schemas/elevenlabs.ts +0 -1
  93. package/src/config/schemas/platform.ts +0 -8
  94. package/src/config/seed-inference-profiles.ts +25 -20
  95. package/src/contacts/contact-store.ts +87 -96
  96. package/src/contacts/contacts-write.ts +5 -21
  97. package/src/context/compactor.ts +2 -2
  98. package/src/credential-execution/process-manager.ts +55 -14
  99. package/src/credential-execution/prompted-credential.ts +2 -3
  100. package/src/daemon/config-watcher.ts +0 -4
  101. package/src/daemon/conversation-agent-loop.ts +15 -4
  102. package/src/daemon/conversation-slash.ts +2 -23
  103. package/src/daemon/conversation-tool-setup.ts +11 -3
  104. package/src/daemon/conversation.ts +2 -0
  105. package/src/daemon/handlers/config-channels.ts +20 -16
  106. package/src/daemon/handlers/config-slack-channel.ts +2 -3
  107. package/src/daemon/lifecycle.ts +0 -7
  108. package/src/daemon/message-types/conversations.ts +3 -3
  109. package/src/daemon/message-types/sync.ts +0 -1
  110. package/src/daemon/orphan-reaper.test.ts +0 -19
  111. package/src/daemon/orphan-reaper.ts +2 -24
  112. package/src/daemon/server.ts +0 -10
  113. package/src/home/relationship-state.ts +2 -4
  114. package/src/memory/__tests__/memory-retrospective-job.test.ts +195 -401
  115. package/src/memory/bookmark-crud.ts +1 -2
  116. package/src/memory/db-init.ts +7 -17
  117. package/src/memory/embedding-backend.ts +23 -0
  118. package/src/memory/embedding-billing-breaker.ts +96 -0
  119. package/src/memory/jobs-store.ts +25 -13
  120. package/src/memory/jobs-worker.ts +52 -0
  121. package/src/memory/memory-retrospective-constants.ts +4 -4
  122. package/src/memory/memory-retrospective-job.ts +19 -227
  123. package/src/memory/migrations/291-contact-channels-renormalize-addresses.ts +62 -0
  124. package/src/memory/migrations/__tests__/291-contact-channels-renormalize-addresses.test.ts +311 -0
  125. package/src/memory/migrations/__tests__/run-migrations.test.ts +52 -0
  126. package/src/memory/migrations/index.ts +1 -0
  127. package/src/memory/migrations/run-migrations.ts +41 -0
  128. package/src/memory/migrations/validate-migration-state.ts +1 -1
  129. package/src/memory/schema/contacts.ts +0 -4
  130. package/src/messaging/providers/slack/adapter.ts +1 -1
  131. package/src/notifications/adapters/shared.ts +29 -0
  132. package/src/notifications/adapters/slack.ts +5 -32
  133. package/src/notifications/adapters/telegram.ts +2 -20
  134. package/src/notifications/broadcaster.ts +10 -1
  135. package/src/notifications/home-feed-side-effect.ts +4 -3
  136. package/src/notifications/notification-utils.ts +17 -19
  137. package/src/notifications/types.ts +7 -0
  138. package/src/oauth/AGENTS.md +5 -24
  139. package/src/plugin-api/constants.ts +1 -1
  140. package/src/plugin-api/index.ts +6 -1
  141. package/src/plugin-api/model-profiles.ts +33 -0
  142. package/src/plugin-api/types.ts +50 -2
  143. package/src/plugins/defaults/index.ts +25 -0
  144. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +54 -2
  145. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +107 -7
  146. package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +276 -0
  147. package/src/plugins/defaults/surface-completion-nudge/hooks/stop.ts +22 -0
  148. package/src/plugins/defaults/surface-completion-nudge/nudge-state-store.ts +46 -0
  149. package/src/plugins/defaults/surface-completion-nudge/package.json +14 -0
  150. package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +1 -1
  151. package/src/prompts/persona-resolver.ts +2 -2
  152. package/src/runtime/AGENTS.md +0 -1
  153. package/src/runtime/actor-trust-resolver.ts +12 -44
  154. package/src/runtime/btw-sidechain.ts +3 -6
  155. package/src/runtime/channel-approval-types.ts +18 -45
  156. package/src/runtime/channel-invite-transports/telegram.ts +4 -4
  157. package/src/runtime/channel-verification-service.ts +4 -3
  158. package/src/runtime/invite-redemption-service.ts +3 -3
  159. package/src/runtime/routes/__tests__/plugins-routes.test.ts +218 -1
  160. package/src/runtime/routes/app-routes.ts +1 -1
  161. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +2 -2
  162. package/src/runtime/routes/assets/vellum-design-system.css +1959 -0
  163. package/src/runtime/routes/btw-routes.ts +1 -27
  164. package/src/runtime/routes/conversation-compaction-routes.ts +1 -1
  165. package/src/runtime/routes/conversation-routes.ts +2 -2
  166. package/src/runtime/routes/credential-routes.ts +40 -16
  167. package/src/runtime/routes/empty-state-greeting-cache.ts +1 -2
  168. package/src/runtime/routes/identity-routes.ts +1 -296
  169. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +1 -1
  170. package/src/runtime/routes/plugins-routes.ts +171 -5
  171. package/src/runtime/routes/schedule-routes.ts +0 -22
  172. package/src/runtime/routes/workflow-routes.test.ts +4 -43
  173. package/src/runtime/routes/workflow-routes.ts +0 -28
  174. package/src/runtime/routes/workspace-greetings.ts +55 -0
  175. package/src/runtime/sync/resource-sync-events.ts +1 -11
  176. package/src/schedule/inference-profile.ts +2 -14
  177. package/src/subagent/manager.ts +6 -0
  178. package/src/subagent/types.ts +6 -0
  179. package/src/tools/AGENTS.md +3 -3
  180. package/src/tools/browser/browser-execution.ts +1 -1
  181. package/src/tools/network/web-search-error.ts +1 -1
  182. package/src/tools/permission-checker.ts +1 -1
  183. package/src/tools/schedule/create.ts +3 -9
  184. package/src/tools/schedule/update.ts +2 -10
  185. package/src/tools/side-effects.ts +2 -17
  186. package/src/tools/skills/execute.ts +34 -0
  187. package/src/tools/subagent/spawn.ts +34 -9
  188. package/src/tools/tool-approval-handler.ts +1 -3
  189. package/src/tools/tool-manifest.ts +0 -2
  190. package/src/tools/ui-surface/definitions.ts +39 -1
  191. package/src/tools/workflows/run-workflow.test.ts +8 -18
  192. package/src/util/platform.ts +2 -2
  193. package/src/workflows/capabilities.ts +2 -3
  194. package/src/workflows/run-manager.test.ts +0 -25
  195. package/src/workflows/run-manager.ts +2 -24
  196. package/src/__tests__/app-control-no-global-cgevent.test.ts +0 -98
  197. package/src/__tests__/credential-security-e2e.test.ts +0 -362
  198. package/src/__tests__/credential-vault-unit.test.ts +0 -1528
  199. package/src/__tests__/credential-vault.test.ts +0 -1706
  200. package/src/__tests__/identity-intro-cache.test.ts +0 -315
  201. package/src/__tests__/secret-onetime-send.test.ts +0 -182
  202. package/src/cli/commands/__tests__/task.test.ts +0 -914
  203. package/src/cli/commands/task.ts +0 -771
  204. package/src/config/bundled-skills/personal-page/SKILL.md +0 -57
  205. package/src/config/bundled-skills/personal-page/TOOLS.json +0 -27
  206. package/src/config/bundled-skills/personal-page/tools/app-refresh.ts +0 -17
  207. package/src/config/preloaded-apps/personal-page/src/components/About.tsx +0 -22
  208. package/src/config/preloaded-apps/personal-page/src/components/App.tsx +0 -16
  209. package/src/config/preloaded-apps/personal-page/src/components/Features.tsx +0 -77
  210. package/src/config/preloaded-apps/personal-page/src/components/Hero.tsx +0 -57
  211. package/src/config/preloaded-apps/personal-page/src/components/Pending.tsx +0 -28
  212. package/src/config/preloaded-apps/personal-page/src/components/animations.tsx +0 -234
  213. package/src/config/preloaded-apps/personal-page/src/components/icons.tsx +0 -48
  214. package/src/config/preloaded-apps/personal-page/src/components/media.ts +0 -16
  215. package/src/config/preloaded-apps/personal-page/src/index.html +0 -20
  216. package/src/config/preloaded-apps/personal-page/src/main.tsx +0 -7
  217. package/src/config/preloaded-apps/personal-page/src/profile-data.ts +0 -82
  218. package/src/config/preloaded-apps/personal-page/src/styles.css +0 -759
  219. package/src/memory/__tests__/preloaded-apps.test.ts +0 -85
  220. package/src/memory/preloaded-apps.ts +0 -116
  221. package/src/runtime/routes/identity-intro-cache.ts +0 -172
  222. package/src/tools/credentials/vault.ts +0 -712
@@ -417,6 +417,24 @@ describe("loadConfig startup behavior", () => {
417
417
  expect(raw.memory?.jobs?.workerConcurrency).toBe(4);
418
418
  });
419
419
 
420
+ test("strips daemon.reapOrphanedSubprocesses from existing user configs", () => {
421
+ // `daemon.reapOrphanedSubprocesses` is a deprecated opt-in flag: the
422
+ // orphan-subprocess reaper runs by default whenever the daemon is PID 1 on
423
+ // Linux. Existing configs that have it written to disk should load cleanly
424
+ // with the field silently stripped.
425
+ writeConfig({
426
+ provider: "anthropic",
427
+ daemon: { reapOrphanedSubprocesses: true, standaloneRecording: false },
428
+ });
429
+
430
+ loadConfig();
431
+
432
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
433
+ expect(raw.daemon?.reapOrphanedSubprocesses).toBeUndefined();
434
+ // Sibling fields under daemon are preserved
435
+ expect(raw.daemon?.standaloneRecording).toBe(false);
436
+ });
437
+
420
438
  test("still writes a default config on first launch when file is absent", () => {
421
439
  // Discoverability: when no config.json exists, write one populated with
422
440
  // all schema defaults so users can see and edit available options.
@@ -674,6 +692,70 @@ describe("loadConfig startup behavior", () => {
674
692
  expect(raw.llm.activeProfile).toBe("balanced");
675
693
  });
676
694
 
695
+ test("on-platform managed profiles reconcile to the code template on every boot", () => {
696
+ // Headline behavior: on-platform installs now reconcile managed profile
697
+ // content from the code template on every boot (same as off-platform), so
698
+ // model/config updates ship in a release without a workspace migration.
699
+ process.env.IS_PLATFORM = "true";
700
+
701
+ writeConfig({
702
+ llm: {
703
+ profiles: {
704
+ balanced: {
705
+ source: "managed",
706
+ provider: "anthropic",
707
+ model: "old-model-from-previous-release",
708
+ maxTokens: 1,
709
+ provider_connection: "anthropic-managed",
710
+ },
711
+ },
712
+ activeProfile: "balanced",
713
+ },
714
+ });
715
+
716
+ // Non-hatch boot (no overlay). Content is refreshed from the template.
717
+ mergeDefaultConfigAndSeedInferenceProfiles();
718
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
719
+
720
+ expect(raw.llm.profiles.balanced.model).toBe("claude-sonnet-4-6");
721
+ expect(raw.llm.profiles.balanced.maxTokens).toBe(16000);
722
+ expect(raw.llm.profiles.balanced.provider_connection).toBe(
723
+ "anthropic-managed",
724
+ );
725
+ expect(raw.llm.activeProfile).toBe("balanced");
726
+ });
727
+
728
+ test("on-platform reseed preserves user-edited label and status on managed profiles", () => {
729
+ // The only two fields a user may override on a managed profile — label and
730
+ // status — survive the on-platform reconcile, exactly as off-platform.
731
+ process.env.IS_PLATFORM = "true";
732
+
733
+ writeConfig({
734
+ llm: {
735
+ profiles: {
736
+ balanced: {
737
+ source: "managed",
738
+ provider: "anthropic",
739
+ model: "old-model-from-previous-release",
740
+ provider_connection: "anthropic-managed",
741
+ label: "My Default",
742
+ status: "disabled",
743
+ },
744
+ },
745
+ activeProfile: "balanced",
746
+ },
747
+ });
748
+
749
+ mergeDefaultConfigAndSeedInferenceProfiles();
750
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
751
+
752
+ // Content refreshes from the template...
753
+ expect(raw.llm.profiles.balanced.model).toBe("claude-sonnet-4-6");
754
+ // ...but the user's label and status overrides are preserved.
755
+ expect(raw.llm.profiles.balanced.label).toBe("My Default");
756
+ expect(raw.llm.profiles.balanced.status).toBe("disabled");
757
+ });
758
+
677
759
  test("off-platform reseed preserves user-edited label on managed profiles (Codex P1 on PR #30362)", () => {
678
760
  // Simulate a user who renamed the managed "balanced" profile via
679
761
  // PUT /v1/config/llm/profiles/balanced { label: "My Default" }.
@@ -770,7 +852,13 @@ describe("loadConfig startup behavior", () => {
770
852
  expect("status" in raw.llm.profiles.balanced).toBe(false);
771
853
  });
772
854
 
773
- test("platform-provided profile fragments are not polluted by managed seeds", () => {
855
+ test("platform overlay fragment wins its hatch boot, then content reconciles to the code template (label preserved)", () => {
856
+ // The overlay is authoritative for the boot it is supplied: its `balanced`
857
+ // fragment lands verbatim and is never polluted by template fields it omits
858
+ // (no maxTokens/thinking leak in). On the next boot — overlay archived —
859
+ // managed profile *content* reconciles to the code template, since the
860
+ // templates are the single source of truth for content. Only the
861
+ // user/overlay-set `label` survives the reconcile.
774
862
  process.env.IS_PLATFORM = "true";
775
863
 
776
864
  writeConfig({
@@ -824,6 +912,7 @@ describe("loadConfig startup behavior", () => {
824
912
  const config = loadConfig();
825
913
  const mainAgentConfig = resolveCallSiteConfig("mainAgent", config.llm);
826
914
 
915
+ // Hatch boot: overlay fragment is preserved verbatim (preserveProfileNames).
827
916
  expect(config.llm.activeProfile).toBe("balanced");
828
917
  expect(config.llm.profiles.balanced).toEqual({
829
918
  source: "managed",
@@ -844,16 +933,24 @@ describe("loadConfig startup behavior", () => {
844
933
  expect(raw.llm.profiles.balanced.maxTokens).toBeUndefined();
845
934
  expect(raw.llm.profiles.balanced.thinking).toBeUndefined();
846
935
 
936
+ // Next boot, no overlay: content reconciles to the anthropic-managed code
937
+ // template; only the overlay-set label is carried across.
847
938
  mergeDefaultConfigAndSeedInferenceProfiles();
848
939
 
849
940
  const afterRestart = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
850
941
  expect(afterRestart.llm.activeProfile).toBe("balanced");
851
- expect(afterRestart.llm.profiles.balanced).toEqual({
852
- source: "managed",
853
- provider: "openai",
854
- model: "gpt-5.4",
855
- label: "Platform Balanced",
942
+ expect(afterRestart.llm.profiles.balanced.provider).toBe("anthropic");
943
+ expect(afterRestart.llm.profiles.balanced.provider_connection).toBe(
944
+ "anthropic-managed",
945
+ );
946
+ expect(afterRestart.llm.profiles.balanced.model).toBe("claude-sonnet-4-6");
947
+ expect(afterRestart.llm.profiles.balanced.maxTokens).toBe(16000);
948
+ expect(afterRestart.llm.profiles.balanced.thinking).toEqual({
949
+ enabled: true,
950
+ streamThinking: true,
856
951
  });
952
+ // The user/overlay-set label is the one field that survives the reconcile.
953
+ expect(afterRestart.llm.profiles.balanced.label).toBe("Platform Balanced");
857
954
  });
858
955
 
859
956
  test("quarantines corrupt config before merging hatch overlay", () => {
@@ -231,24 +231,6 @@ describe("ConfigWatcher workspace file handlers", () => {
231
231
  expect(evictCallCount).toBe(1);
232
232
  });
233
233
 
234
- test("SOUL.md change triggers identity intro refetch notification", async () => {
235
- let introCallCount = 0;
236
- watcher.start(
237
- onConversationEvict,
238
- undefined,
239
- undefined,
240
- undefined,
241
- undefined,
242
- undefined,
243
- () => {
244
- introCallCount += 1;
245
- },
246
- );
247
- simulateFileChange(WORKSPACE_DIR, "SOUL.md");
248
- await new Promise((r) => setTimeout(r, WAIT_MS));
249
- expect(introCallCount).toBe(1);
250
- });
251
-
252
234
  test("IDENTITY.md change triggers onConversationEvict", async () => {
253
235
  watcher.start(onConversationEvict);
254
236
  simulateFileChange(WORKSPACE_DIR, "IDENTITY.md");
@@ -366,6 +366,7 @@ import { runAgentLoopImpl } from "../daemon/conversation-agent-loop.js";
366
366
  interface CapturedAgentLoopRun {
367
367
  callSite: LLMCallSite | undefined;
368
368
  overrideProfile: string | undefined;
369
+ forceOverrideProfile: boolean | undefined;
369
370
  resolvedOverrideProfile: string | undefined;
370
371
  resolvedMaxInputTokens: number | undefined;
371
372
  }
@@ -383,6 +384,7 @@ function makeCtx(
383
384
  captured.push({
384
385
  callSite: options.callSite,
385
386
  overrideProfile: options.overrideProfile,
387
+ forceOverrideProfile: options.forceOverrideProfile,
386
388
  resolvedOverrideProfile: options.resolveOverrideProfile?.(),
387
389
  resolvedMaxInputTokens: options.resolveContextWindow?.().maxInputTokens,
388
390
  });
@@ -620,6 +622,26 @@ describe("runAgentLoopImpl — per-conversation inferenceProfile", () => {
620
622
  expect(captured.length).toBeGreaterThan(0);
621
623
  for (const call of captured) {
622
624
  expect(call.overrideProfile).toBe("fast");
625
+ expect(call.forceOverrideProfile).toBeUndefined();
626
+ }
627
+ });
628
+
629
+ test("explicit options.forceOverrideProfile is passed to AgentLoop.run", async () => {
630
+ const captured: CapturedAgentLoopRun[] = [];
631
+ const ctx = makeCtx(captured, {
632
+ conversationType: "background",
633
+ inferenceProfile: null,
634
+ });
635
+
636
+ await runAgentLoopImpl(ctx, "hello", "msg-1", () => {}, {
637
+ overrideProfile: "fast",
638
+ forceOverrideProfile: true,
639
+ });
640
+
641
+ expect(captured.length).toBeGreaterThan(0);
642
+ for (const call of captured) {
643
+ expect(call.overrideProfile).toBe("fast");
644
+ expect(call.forceOverrideProfile).toBe(true);
623
645
  }
624
646
  });
625
647
 
@@ -1,14 +1,24 @@
1
- import { mkdtempSync, rmSync } from "node:fs";
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
5
6
 
6
7
  import { credentialKey } from "../security/credential-key.js";
8
+ import { _resetBackend, setSecureKeyAsync } from "../security/secure-keys.js";
7
9
  import { CredentialBroker } from "../tools/credentials/broker.js";
8
10
  import {
9
11
  _setMetadataPath,
10
12
  upsertCredentialMetadata,
11
13
  } from "../tools/credentials/metadata-store.js";
14
+ import { BROWSER_FILL_CAPABILITY } from "../tools/credentials/tool-policy.js";
15
+ import { setStorePathForTesting } from "./encrypted-store-test-helpers.js";
16
+
17
+ const TEST_DIR = join(
18
+ tmpdir(),
19
+ `vellum-broker-test-${randomBytes(4).toString("hex")}`,
20
+ );
21
+ const STORE_PATH = join(TEST_DIR, "keys.enc");
12
22
 
13
23
  describe("CredentialBroker", () => {
14
24
  let broker: CredentialBroker;
@@ -222,3 +232,441 @@ describe("CredentialBroker", () => {
222
232
  });
223
233
  });
224
234
  });
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Behavior that reads stored secret values, exercised against the encrypted
238
+ // store backend (transient injection, multi-template serverUseById, revokeAll,
239
+ // and canonical/legacy capability-key resolution).
240
+ // ---------------------------------------------------------------------------
241
+
242
+ describe("CredentialBroker (encrypted store backend)", () => {
243
+ let broker: CredentialBroker;
244
+
245
+ beforeEach(() => {
246
+ if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
247
+ mkdirSync(TEST_DIR, { recursive: true });
248
+ setStorePathForTesting(STORE_PATH);
249
+ _resetBackend();
250
+ _setMetadataPath(join(TEST_DIR, "metadata.json"));
251
+ broker = new CredentialBroker();
252
+ });
253
+
254
+ afterEach(() => {
255
+ _setMetadataPath(null);
256
+ setStorePathForTesting(null);
257
+ _resetBackend();
258
+ rmSync(TEST_DIR, { recursive: true, force: true });
259
+ });
260
+
261
+ describe("transient credentials", () => {
262
+ test("consume returns transient value and deletes it", () => {
263
+ // GIVEN a credential with metadata and an injected one-time transient value
264
+ upsertCredentialMetadata("svc", "key", { allowedTools: ["tool1"] });
265
+ broker.injectTransient("svc", "key", "one-time-secret");
266
+
267
+ // WHEN the credential is authorized and consumed
268
+ const auth = broker.authorize({
269
+ service: "svc",
270
+ field: "key",
271
+ toolName: "tool1",
272
+ });
273
+ expect(auth.authorized).toBe(true);
274
+ if (!auth.authorized) return;
275
+ const result = broker.consume(auth.token.tokenId);
276
+
277
+ // THEN the transient value is returned alongside the storage key
278
+ expect(result.success).toBe(true);
279
+ expect(result.value).toBe("one-time-secret");
280
+ expect(result.storageKey).toBe(credentialKey("svc", "key"));
281
+
282
+ // AND a subsequent authorize+consume no longer has the transient value
283
+ const auth2 = broker.authorize({
284
+ service: "svc",
285
+ field: "key",
286
+ toolName: "tool1",
287
+ });
288
+ expect(auth2.authorized).toBe(true);
289
+ if (!auth2.authorized) return;
290
+ const result2 = broker.consume(auth2.token.tokenId);
291
+ expect(result2.success).toBe(true);
292
+ expect(result2.value).toBeUndefined();
293
+ });
294
+
295
+ test("browserFill uses transient value when available", async () => {
296
+ // GIVEN a credential with a transient value and no stored value
297
+ upsertCredentialMetadata("github", "token", {
298
+ allowedTools: ["browser_fill_credential"],
299
+ });
300
+ broker.injectTransient("github", "token", "transient-ghp-123");
301
+
302
+ // WHEN browserFill runs
303
+ let filledValue: string | undefined;
304
+ const result = await broker.browserFill({
305
+ service: "github",
306
+ field: "token",
307
+ toolName: "browser_fill_credential",
308
+ fill: async (v) => {
309
+ filledValue = v;
310
+ },
311
+ });
312
+
313
+ // THEN the transient value is filled
314
+ expect(result.success).toBe(true);
315
+ expect(filledValue).toBe("transient-ghp-123");
316
+ });
317
+
318
+ test("browserFill consumes transient value — second fill falls back to stored", async () => {
319
+ // GIVEN a credential with both a stored value and a transient value
320
+ upsertCredentialMetadata("github", "token", {
321
+ allowedTools: ["browser_fill_credential"],
322
+ });
323
+ await setSecureKeyAsync(credentialKey("github", "token"), "stored-value");
324
+ broker.injectTransient("github", "token", "transient-value");
325
+
326
+ // WHEN browserFill runs twice
327
+ let filled1: string | undefined;
328
+ await broker.browserFill({
329
+ service: "github",
330
+ field: "token",
331
+ toolName: "browser_fill_credential",
332
+ fill: async (v) => {
333
+ filled1 = v;
334
+ },
335
+ });
336
+ let filled2: string | undefined;
337
+ await broker.browserFill({
338
+ service: "github",
339
+ field: "token",
340
+ toolName: "browser_fill_credential",
341
+ fill: async (v) => {
342
+ filled2 = v;
343
+ },
344
+ });
345
+
346
+ // THEN the first fill uses the transient and the second falls back to stored
347
+ expect(filled1).toBe("transient-value");
348
+ expect(filled2).toBe("stored-value");
349
+ });
350
+
351
+ test("browserFill preserves transient value on fill failure", async () => {
352
+ // GIVEN a credential with a transient value
353
+ upsertCredentialMetadata("github", "token", {
354
+ allowedTools: ["browser_fill_credential"],
355
+ });
356
+ broker.injectTransient("github", "token", "transient-preserved");
357
+
358
+ // WHEN the first fill throws
359
+ const result1 = await broker.browserFill({
360
+ service: "github",
361
+ field: "token",
362
+ toolName: "browser_fill_credential",
363
+ fill: async () => {
364
+ throw new Error("Playwright timeout");
365
+ },
366
+ });
367
+ expect(result1.success).toBe(false);
368
+ let filled: string | undefined;
369
+ const result2 = await broker.browserFill({
370
+ service: "github",
371
+ field: "token",
372
+ toolName: "browser_fill_credential",
373
+ fill: async (v) => {
374
+ filled = v;
375
+ },
376
+ });
377
+
378
+ // THEN the transient value survives for the second fill
379
+ expect(result2.success).toBe(true);
380
+ expect(filled).toBe("transient-preserved");
381
+ });
382
+
383
+ test("serverUse uses transient value when available", async () => {
384
+ // GIVEN a credential with a transient value
385
+ upsertCredentialMetadata("vercel", "api_token", {
386
+ allowedTools: ["deploy"],
387
+ });
388
+ broker.injectTransient("vercel", "api_token", "transient-vercel-tok");
389
+
390
+ // WHEN serverUse runs
391
+ const result = await broker.serverUse({
392
+ service: "vercel",
393
+ field: "api_token",
394
+ toolName: "deploy",
395
+ execute: async (v) => {
396
+ expect(v).toBe("transient-vercel-tok");
397
+ return "deployed";
398
+ },
399
+ });
400
+
401
+ // THEN the transient value is used for execution
402
+ expect(result.success).toBe(true);
403
+ expect(result.result).toBe("deployed");
404
+ });
405
+
406
+ test("serverUse consumes transient — subsequent call has no value without stored key", async () => {
407
+ // GIVEN a credential with only a transient value (no stored value)
408
+ upsertCredentialMetadata("vercel", "api_token", {
409
+ allowedTools: ["deploy"],
410
+ });
411
+ broker.injectTransient("vercel", "api_token", "transient-only");
412
+
413
+ // WHEN serverUse runs twice
414
+ await broker.serverUse({
415
+ service: "vercel",
416
+ field: "api_token",
417
+ toolName: "deploy",
418
+ execute: async () => "ok",
419
+ });
420
+ const result = await broker.serverUse({
421
+ service: "vercel",
422
+ field: "api_token",
423
+ toolName: "deploy",
424
+ execute: async () => {
425
+ throw new Error("should not be called");
426
+ },
427
+ });
428
+
429
+ // THEN the second call fails because no stored value remains
430
+ expect(result.success).toBe(false);
431
+ expect(result.reason).toContain("no stored value");
432
+ });
433
+
434
+ test("injectTransient replaces previous transient for same key", () => {
435
+ // GIVEN two transient values injected for the same credential
436
+ upsertCredentialMetadata("svc", "key", { allowedTools: ["t"] });
437
+ broker.injectTransient("svc", "key", "first");
438
+ broker.injectTransient("svc", "key", "second");
439
+
440
+ // WHEN the credential is authorized and consumed
441
+ const auth = broker.authorize({
442
+ service: "svc",
443
+ field: "key",
444
+ toolName: "t",
445
+ });
446
+ if (!auth.authorized) return;
447
+ const result = broker.consume(auth.token.tokenId);
448
+
449
+ // THEN the most recent transient value wins
450
+ expect(result.value).toBe("second");
451
+ });
452
+
453
+ test("transient value for one credential does not affect another", () => {
454
+ // GIVEN a transient value injected for svcA only
455
+ upsertCredentialMetadata("svcA", "key", { allowedTools: ["t"] });
456
+ upsertCredentialMetadata("svcB", "key", { allowedTools: ["t"] });
457
+ broker.injectTransient("svcA", "key", "val-a");
458
+
459
+ // WHEN both credentials are authorized and consumed
460
+ const authB = broker.authorize({
461
+ service: "svcB",
462
+ field: "key",
463
+ toolName: "t",
464
+ });
465
+ if (!authB.authorized) return;
466
+ const resultB = broker.consume(authB.token.tokenId);
467
+ const authA = broker.authorize({
468
+ service: "svcA",
469
+ field: "key",
470
+ toolName: "t",
471
+ });
472
+ if (!authA.authorized) return;
473
+ const resultA = broker.consume(authA.token.tokenId);
474
+
475
+ // THEN only svcA carries the transient value
476
+ expect(resultB.success).toBe(true);
477
+ expect(resultB.value).toBeUndefined();
478
+ expect(resultA.value).toBe("val-a");
479
+ });
480
+ });
481
+
482
+ describe("serverUseById edge cases", () => {
483
+ test("serverUseById with multiple injection templates returns all", async () => {
484
+ // GIVEN a credential with two injection templates and a stored value
485
+ const meta = upsertCredentialMetadata("multi", "api_key", {
486
+ allowedTools: ["proxy"],
487
+ injectionTemplates: [
488
+ {
489
+ hostPattern: "*.fal.ai",
490
+ injectionType: "header",
491
+ headerName: "Authorization",
492
+ valuePrefix: "Key ",
493
+ },
494
+ {
495
+ hostPattern: "gateway.fal.ai",
496
+ injectionType: "header",
497
+ headerName: "X-Fal-Key",
498
+ },
499
+ ],
500
+ });
501
+ await setSecureKeyAsync(
502
+ credentialKey("multi", "api_key"),
503
+ "multi-secret",
504
+ );
505
+
506
+ // WHEN serverUseById resolves the credential by id
507
+ const result = await broker.serverUseById({
508
+ credentialId: meta.credentialId,
509
+ requestingTool: "proxy",
510
+ });
511
+
512
+ // THEN both injection templates are returned without leaking the secret
513
+ expect(result.success).toBe(true);
514
+ if (!result.success) return;
515
+ expect(result.injectionTemplates).toHaveLength(2);
516
+ expect(result.injectionTemplates[0].hostPattern).toBe("*.fal.ai");
517
+ expect(result.injectionTemplates[1].hostPattern).toBe("gateway.fal.ai");
518
+ expect(JSON.stringify(result)).not.toContain("multi-secret");
519
+ });
520
+ });
521
+
522
+ describe("revokeAll", () => {
523
+ test("revokeAll clears all tokens and subsequent consume fails", () => {
524
+ // GIVEN two active tokens for a credential
525
+ upsertCredentialMetadata("svc", "key", { allowedTools: ["t1", "t2"] });
526
+ const a1 = broker.authorize({
527
+ service: "svc",
528
+ field: "key",
529
+ toolName: "t1",
530
+ });
531
+ const a2 = broker.authorize({
532
+ service: "svc",
533
+ field: "key",
534
+ toolName: "t2",
535
+ });
536
+ expect(broker.activeTokenCount).toBe(2);
537
+
538
+ // WHEN revokeAll is called
539
+ broker.revokeAll();
540
+
541
+ // THEN the token count is zero and consuming either token fails
542
+ expect(broker.activeTokenCount).toBe(0);
543
+ if (a1.authorized) {
544
+ expect(broker.consume(a1.token.tokenId).success).toBe(false);
545
+ }
546
+ if (a2.authorized) {
547
+ expect(broker.consume(a2.token.tokenId).success).toBe(false);
548
+ }
549
+ });
550
+
551
+ test("revokeAll on empty broker is a no-op", () => {
552
+ // GIVEN a broker with no active tokens
553
+ expect(broker.activeTokenCount).toBe(0);
554
+
555
+ // WHEN revokeAll is called
556
+ broker.revokeAll();
557
+
558
+ // THEN the token count remains zero
559
+ expect(broker.activeTokenCount).toBe(0);
560
+ });
561
+ });
562
+
563
+ describe("canonical capability key", () => {
564
+ test("authorize succeeds with canonical key when metadata has canonical key", () => {
565
+ // GIVEN metadata allowing the canonical browser-fill capability key
566
+ upsertCredentialMetadata("github", "token", {
567
+ allowedTools: [BROWSER_FILL_CAPABILITY],
568
+ });
569
+
570
+ // WHEN authorizing with the canonical key
571
+ const result = broker.authorize({
572
+ service: "github",
573
+ field: "token",
574
+ toolName: BROWSER_FILL_CAPABILITY,
575
+ });
576
+
577
+ // THEN authorization succeeds
578
+ expect(result.authorized).toBe(true);
579
+ });
580
+
581
+ test("authorize succeeds with canonical key when metadata has legacy alias", () => {
582
+ // GIVEN metadata stored under the legacy browser_fill_credential alias
583
+ upsertCredentialMetadata("github", "token", {
584
+ allowedTools: ["browser_fill_credential"],
585
+ });
586
+
587
+ // WHEN authorizing with the canonical key
588
+ const result = broker.authorize({
589
+ service: "github",
590
+ field: "token",
591
+ toolName: BROWSER_FILL_CAPABILITY,
592
+ });
593
+
594
+ // THEN authorization succeeds
595
+ expect(result.authorized).toBe(true);
596
+ });
597
+
598
+ test("authorize succeeds with legacy alias when metadata has canonical key", () => {
599
+ // GIVEN metadata allowing the canonical capability key
600
+ upsertCredentialMetadata("github", "token", {
601
+ allowedTools: [BROWSER_FILL_CAPABILITY],
602
+ });
603
+
604
+ // WHEN authorizing with the legacy alias
605
+ const result = broker.authorize({
606
+ service: "github",
607
+ field: "token",
608
+ toolName: "browser_fill_credential",
609
+ });
610
+
611
+ // THEN authorization succeeds
612
+ expect(result.authorized).toBe(true);
613
+ });
614
+
615
+ test("serverUse with canonical key works when metadata has legacy alias", async () => {
616
+ // GIVEN metadata stored under the legacy alias with a stored value
617
+ upsertCredentialMetadata("vercel", "api_token", {
618
+ allowedTools: ["browser_fill_credential"],
619
+ });
620
+ await setSecureKeyAsync(
621
+ credentialKey("vercel", "api_token"),
622
+ "vercel-tok",
623
+ );
624
+
625
+ // WHEN serverUse runs with the canonical key
626
+ const result = await broker.serverUse({
627
+ service: "vercel",
628
+ field: "api_token",
629
+ toolName: BROWSER_FILL_CAPABILITY,
630
+ execute: async (v) => v,
631
+ });
632
+
633
+ // THEN execution succeeds with the stored value
634
+ expect(result.success).toBe(true);
635
+ expect(result.result).toBe("vercel-tok");
636
+ });
637
+
638
+ test("non-aliased tool names are unaffected by alias resolution", () => {
639
+ // GIVEN metadata allowing a non-aliased custom tool
640
+ upsertCredentialMetadata("svc", "key", {
641
+ allowedTools: ["custom_tool"],
642
+ });
643
+
644
+ // WHEN authorizing with that custom tool
645
+ const result = broker.authorize({
646
+ service: "svc",
647
+ field: "key",
648
+ toolName: "custom_tool",
649
+ });
650
+
651
+ // THEN authorization succeeds
652
+ expect(result.authorized).toBe(true);
653
+ });
654
+
655
+ test("non-aliased tool denied when only canonical key is allowed", () => {
656
+ // GIVEN metadata allowing only the canonical capability key
657
+ upsertCredentialMetadata("svc", "key", {
658
+ allowedTools: [BROWSER_FILL_CAPABILITY],
659
+ });
660
+
661
+ // WHEN authorizing with an unrelated tool
662
+ const result = broker.authorize({
663
+ service: "svc",
664
+ field: "key",
665
+ toolName: "unrelated_tool",
666
+ });
667
+
668
+ // THEN authorization is denied
669
+ expect(result.authorized).toBe(false);
670
+ });
671
+ });
672
+ });
@@ -136,7 +136,6 @@ describe("secure tool installation is separate from credential grants", () => {
136
136
  test("manage_secure_command_tool does not share tool name with grant tools", () => {
137
137
  // The tool name must not collide with existing credential grant tools
138
138
  const grantToolNames = [
139
- "credential_store",
140
139
  "credential_grant",
141
140
  "credential_revoke",
142
141
  "credential_list",