@vellumai/assistant 0.4.52 → 0.4.53

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 (205) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/docs/architecture/keychain-broker.md +6 -20
  3. package/docs/architecture/memory.md +3 -3
  4. package/package.json +1 -1
  5. package/src/__tests__/approval-cascade.test.ts +3 -1
  6. package/src/__tests__/approval-routes-http.test.ts +0 -1
  7. package/src/__tests__/asset-materialize-tool.test.ts +0 -1
  8. package/src/__tests__/asset-search-tool.test.ts +0 -1
  9. package/src/__tests__/assistant-events-sse-hardening.test.ts +0 -1
  10. package/src/__tests__/attachments-store.test.ts +0 -1
  11. package/src/__tests__/avatar-e2e.test.ts +6 -1
  12. package/src/__tests__/browser-fill-credential.test.ts +3 -0
  13. package/src/__tests__/btw-routes.test.ts +39 -0
  14. package/src/__tests__/call-controller.test.ts +0 -1
  15. package/src/__tests__/call-domain.test.ts +1 -0
  16. package/src/__tests__/call-routes-http.test.ts +1 -2
  17. package/src/__tests__/canonical-guardian-store.test.ts +33 -2
  18. package/src/__tests__/channel-readiness-service.test.ts +1 -0
  19. package/src/__tests__/claude-code-skill-regression.test.ts +6 -2
  20. package/src/__tests__/claude-code-tool-profiles.test.ts +7 -2
  21. package/src/__tests__/config-loader-backfill.test.ts +1 -2
  22. package/src/__tests__/config-schema.test.ts +6 -37
  23. package/src/__tests__/conversation-routes-slash-commands.test.ts +0 -1
  24. package/src/__tests__/credential-broker-server-use.test.ts +16 -16
  25. package/src/__tests__/credential-security-invariants.test.ts +14 -0
  26. package/src/__tests__/credential-vault-unit.test.ts +4 -4
  27. package/src/__tests__/error-handler-friendly-messages.test.ts +4 -5
  28. package/src/__tests__/gateway-only-enforcement.test.ts +0 -2
  29. package/src/__tests__/host-shell-tool.test.ts +0 -1
  30. package/src/__tests__/http-user-message-parity.test.ts +19 -0
  31. package/src/__tests__/list-messages-attachments.test.ts +0 -1
  32. package/src/__tests__/log-export-workspace.test.ts +233 -0
  33. package/src/__tests__/managed-proxy-context.test.ts +1 -1
  34. package/src/__tests__/managed-skill-lifecycle.test.ts +0 -1
  35. package/src/__tests__/media-generate-image.test.ts +7 -2
  36. package/src/__tests__/media-reuse-story.e2e.test.ts +0 -1
  37. package/src/__tests__/memory-regressions.test.ts +0 -1
  38. package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1
  39. package/src/__tests__/migration-export-http.test.ts +0 -1
  40. package/src/__tests__/migration-import-commit-http.test.ts +0 -1
  41. package/src/__tests__/migration-import-preflight-http.test.ts +0 -1
  42. package/src/__tests__/migration-validate-http.test.ts +0 -1
  43. package/src/__tests__/notification-schedule-dedup.test.ts +237 -0
  44. package/src/__tests__/oauth-cli.test.ts +1 -10
  45. package/src/__tests__/oauth-store.test.ts +3 -5
  46. package/src/__tests__/oauth2-gateway-transport.test.ts +5 -4
  47. package/src/__tests__/onboarding-starter-tasks.test.ts +1 -1
  48. package/src/__tests__/onboarding-template-contract.test.ts +1 -2
  49. package/src/__tests__/pricing.test.ts +0 -11
  50. package/src/__tests__/provider-commit-message-generator.test.ts +21 -14
  51. package/src/__tests__/provider-fail-open-selection.test.ts +9 -8
  52. package/src/__tests__/provider-managed-proxy-integration.test.ts +27 -24
  53. package/src/__tests__/provider-registry-ollama.test.ts +8 -2
  54. package/src/__tests__/recording-handler.test.ts +0 -1
  55. package/src/__tests__/relay-server.test.ts +0 -1
  56. package/src/__tests__/runtime-attachment-metadata.test.ts +0 -1
  57. package/src/__tests__/runtime-events-sse-parity.test.ts +0 -1
  58. package/src/__tests__/runtime-events-sse.test.ts +0 -1
  59. package/src/__tests__/secret-routes-managed-proxy.test.ts +0 -1
  60. package/src/__tests__/secret-scanner-executor.test.ts +0 -1
  61. package/src/__tests__/send-endpoint-busy.test.ts +0 -1
  62. package/src/__tests__/session-abort-tool-results.test.ts +3 -1
  63. package/src/__tests__/session-agent-loop-overflow.test.ts +1012 -838
  64. package/src/__tests__/session-agent-loop.test.ts +2 -2
  65. package/src/__tests__/session-confirmation-signals.test.ts +3 -1
  66. package/src/__tests__/session-error.test.ts +5 -4
  67. package/src/__tests__/session-history-web-search.test.ts +34 -9
  68. package/src/__tests__/session-pre-run-repair.test.ts +3 -1
  69. package/src/__tests__/session-provider-retry-repair.test.ts +31 -26
  70. package/src/__tests__/session-queue.test.ts +3 -1
  71. package/src/__tests__/session-runtime-assembly.test.ts +118 -0
  72. package/src/__tests__/session-slash-known.test.ts +31 -13
  73. package/src/__tests__/session-slash-queue.test.ts +3 -1
  74. package/src/__tests__/session-slash-unknown.test.ts +3 -1
  75. package/src/__tests__/session-workspace-cache-state.test.ts +3 -1
  76. package/src/__tests__/session-workspace-injection.test.ts +3 -1
  77. package/src/__tests__/session-workspace-tool-tracking.test.ts +3 -1
  78. package/src/__tests__/shell-tool-proxy-mode.test.ts +0 -1
  79. package/src/__tests__/skill-script-runner-sandbox.test.ts +0 -1
  80. package/src/__tests__/skillssh-registry.test.ts +21 -0
  81. package/src/__tests__/slack-share-routes.test.ts +1 -1
  82. package/src/__tests__/swarm-recursion.test.ts +5 -1
  83. package/src/__tests__/swarm-session-integration.test.ts +25 -14
  84. package/src/__tests__/swarm-tool.test.ts +5 -2
  85. package/src/__tests__/telegram-bot-username-resolution.test.ts +2 -4
  86. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1521 -0
  87. package/src/__tests__/tool-execution-abort-cleanup.test.ts +0 -1
  88. package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
  89. package/src/__tests__/tool-executor-shell-integration.test.ts +0 -1
  90. package/src/__tests__/tool-executor.test.ts +0 -1
  91. package/src/__tests__/trust-store.test.ts +5 -1
  92. package/src/__tests__/twilio-routes.test.ts +2 -2
  93. package/src/__tests__/verification-control-plane-policy.test.ts +0 -1
  94. package/src/__tests__/voice-quality.test.ts +2 -1
  95. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  96. package/src/__tests__/web-search.test.ts +1 -1
  97. package/src/agent/loop.ts +17 -1
  98. package/src/bundler/app-bundler.ts +40 -24
  99. package/src/calls/call-controller.ts +16 -0
  100. package/src/calls/relay-server.ts +29 -13
  101. package/src/calls/voice-control-protocol.ts +1 -0
  102. package/src/calls/voice-quality.ts +1 -1
  103. package/src/calls/voice-session-bridge.ts +9 -3
  104. package/src/channels/types.ts +16 -0
  105. package/src/cli/commands/bash.ts +173 -0
  106. package/src/cli/commands/doctor.ts +5 -23
  107. package/src/cli/commands/oauth/connections.ts +4 -2
  108. package/src/cli/commands/oauth/providers.ts +1 -13
  109. package/src/cli/program.ts +2 -0
  110. package/src/cli/reference.ts +1 -0
  111. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +2 -1
  112. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +3 -5
  113. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +2 -3
  114. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +1 -1
  115. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +5 -6
  116. package/src/config/feature-flag-registry.json +8 -0
  117. package/src/config/loader.ts +7 -135
  118. package/src/config/schema.ts +0 -6
  119. package/src/config/schemas/channels.ts +1 -0
  120. package/src/config/schemas/elevenlabs.ts +2 -2
  121. package/src/contacts/contact-store.ts +21 -25
  122. package/src/contacts/contacts-write.ts +6 -6
  123. package/src/contacts/types.ts +2 -0
  124. package/src/context/token-estimator.ts +35 -2
  125. package/src/context/window-manager.ts +16 -2
  126. package/src/daemon/config-watcher.ts +24 -6
  127. package/src/daemon/context-overflow-reducer.ts +13 -2
  128. package/src/daemon/handlers/config-ingress.ts +25 -8
  129. package/src/daemon/handlers/config-model.ts +21 -15
  130. package/src/daemon/handlers/config-telegram.ts +18 -6
  131. package/src/daemon/handlers/dictation.ts +0 -429
  132. package/src/daemon/handlers/skills.ts +1 -200
  133. package/src/daemon/lifecycle.ts +8 -5
  134. package/src/daemon/message-types/contacts.ts +2 -0
  135. package/src/daemon/message-types/integrations.ts +1 -0
  136. package/src/daemon/message-types/sessions.ts +2 -0
  137. package/src/daemon/parse-actual-tokens-from-error.test.ts +75 -0
  138. package/src/daemon/server.ts +23 -2
  139. package/src/daemon/session-agent-loop-handlers.ts +1 -1
  140. package/src/daemon/session-agent-loop.ts +27 -79
  141. package/src/daemon/session-error.ts +5 -4
  142. package/src/daemon/session-process.ts +17 -10
  143. package/src/daemon/session-runtime-assembly.ts +50 -0
  144. package/src/daemon/session-slash.ts +32 -20
  145. package/src/daemon/session.ts +1 -0
  146. package/src/events/domain-events.ts +1 -0
  147. package/src/media/app-icon-generator.ts +2 -1
  148. package/src/media/avatar-router.ts +3 -2
  149. package/src/memory/canonical-guardian-store.ts +25 -3
  150. package/src/memory/db-init.ts +12 -0
  151. package/src/memory/embedding-backend.ts +25 -16
  152. package/src/memory/migrations/158-channel-interaction-columns.ts +18 -0
  153. package/src/memory/migrations/159-drop-contact-interaction-columns.ts +16 -0
  154. package/src/memory/migrations/160-drop-loopback-port-column.ts +13 -0
  155. package/src/memory/migrations/index.ts +3 -0
  156. package/src/memory/retriever.test.ts +19 -12
  157. package/src/memory/schema/contacts.ts +2 -2
  158. package/src/memory/schema/oauth.ts +0 -1
  159. package/src/oauth/connect-orchestrator.ts +5 -3
  160. package/src/oauth/connect-types.ts +9 -2
  161. package/src/oauth/manual-token-connection.ts +9 -7
  162. package/src/oauth/oauth-store.ts +2 -8
  163. package/src/oauth/provider-behaviors.ts +10 -0
  164. package/src/oauth/seed-providers.ts +13 -5
  165. package/src/permissions/checker.ts +20 -1
  166. package/src/prompts/__tests__/build-cli-reference-section.test.ts +1 -1
  167. package/src/prompts/system-prompt.ts +2 -11
  168. package/src/prompts/templates/BOOTSTRAP.md +1 -3
  169. package/src/providers/anthropic/client.ts +16 -8
  170. package/src/providers/managed-proxy/constants.ts +1 -1
  171. package/src/providers/registry.ts +21 -15
  172. package/src/providers/types.ts +1 -1
  173. package/src/runtime/auth/route-policy.ts +4 -0
  174. package/src/runtime/channel-invite-transports/telegram.ts +12 -6
  175. package/src/runtime/channel-retry-sweep.ts +6 -0
  176. package/src/runtime/http-types.ts +1 -0
  177. package/src/runtime/middleware/error-handler.ts +1 -2
  178. package/src/runtime/routes/app-management-routes.ts +1 -0
  179. package/src/runtime/routes/btw-routes.ts +20 -1
  180. package/src/runtime/routes/conversation-routes.ts +32 -13
  181. package/src/runtime/routes/inbound-message-handler.ts +10 -2
  182. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  183. package/src/runtime/routes/inbound-stages/edit-intercept.ts +5 -5
  184. package/src/runtime/routes/integrations/slack/share.ts +5 -5
  185. package/src/runtime/routes/log-export-routes.ts +122 -10
  186. package/src/runtime/routes/session-query-routes.ts +3 -3
  187. package/src/runtime/routes/settings-routes.ts +53 -0
  188. package/src/runtime/routes/workspace-routes.ts +3 -0
  189. package/src/runtime/verification-templates.ts +1 -1
  190. package/src/security/oauth2.ts +4 -4
  191. package/src/security/secure-keys.ts +4 -4
  192. package/src/signals/bash.ts +157 -0
  193. package/src/skills/skillssh-registry.ts +6 -1
  194. package/src/swarm/backend-claude-code.ts +6 -6
  195. package/src/swarm/worker-backend.ts +1 -1
  196. package/src/swarm/worker-runner.ts +1 -1
  197. package/src/telegram/bot-username.ts +11 -0
  198. package/src/tools/claude-code/claude-code.ts +4 -4
  199. package/src/tools/credentials/broker.ts +7 -5
  200. package/src/tools/credentials/vault.ts +3 -2
  201. package/src/tools/network/__tests__/web-search.test.ts +18 -86
  202. package/src/tools/network/web-search.ts +9 -15
  203. package/src/util/platform.ts +7 -1
  204. package/src/util/pricing.ts +0 -1
  205. package/src/workspace/provider-commit-message-generator.ts +10 -6
package/ARCHITECTURE.md CHANGED
@@ -641,7 +641,7 @@ The assistant feature-flag resolver (`src/config/assistant-feature-flags.ts`) is
641
641
  | **3. `skill_load` tool** | `executeSkillLoad()` in `tools/skills/load.ts` | If the model attempts to load a flagged-off skill by name, the tool returns an error: `"skill is currently unavailable (disabled by feature flag)"`. |
642
642
  | **4. Runtime tool projection** | `projectSkillTools()` in `daemon/session-skill-tools.ts` | Even if a skill was previously active in a session (has `<loaded_skill>` markers in history), the per-turn projection drops it when the flag is OFF. Already-registered tools are unregistered. |
643
643
  | **5. Included child skills** | `executeSkillLoad()` in `tools/skills/load.ts` | When a parent skill includes children via the `includes` directive, each child is independently checked against its feature flag. Flagged-off children are silently excluded from the loaded skill content. |
644
- | **6. Skill install gate** | `handleSkillsInstall()` in `daemon/handlers/skills.ts` | When a client requests skill installation, the handler checks the skill's feature flag before proceeding. If the flag is OFF, the install is rejected with an error. |
644
+ | **6. Skill install gate** | `installSkill()` in `daemon/handlers/skills.ts` | When a client requests skill installation, the function checks the skill's feature flag before proceeding. If the flag is OFF, the install is rejected with an error. |
645
645
 
646
646
  All six enforcement points derive the flag key via `skillFlagKey(skill)` — which returns `undefined` for ungated skills, short-circuiting the check — and then call `isAssistantFeatureFlagEnabled(flagKey, config)` for consistency.
647
647
 
@@ -657,7 +657,7 @@ All six enforcement points derive the flag key via `skillFlagKey(skill)` — whi
657
657
  | `src/tools/skills/load.ts` | `executeSkillLoad()` — enforcement points 3 and 5 |
658
658
  | `src/daemon/session-skill-tools.ts` | `projectSkillTools()` — enforcement point 4 |
659
659
  | `src/config/schema.ts` | `assistantFeatureFlagValues` field definition in `AssistantConfig` (Zod schema) |
660
- | `src/daemon/handlers/skills.ts` | `handleSkillsList()` — uses `resolveSkillStates()` for client responses; `handleSkillsInstall()` — enforcement point 6 |
660
+ | `src/daemon/handlers/skills.ts` | `listSkills()` — uses `resolveSkillStates()` for client responses; `installSkill()` — enforcement point 6 |
661
661
  | `meta/feature-flags/feature-flag-registry.json` | Unified feature flag registry (repo root) — all declared flags with scope, label, default values, and descriptions |
662
662
  | `src/config/feature-flag-registry.json` | Bundled copy of the unified registry for compiled binary resolution |
663
663
 
@@ -179,30 +179,16 @@ The gateway reads credentials via async `readCredential()` which tries the broke
179
179
 
180
180
  ### Known sync exceptions
181
181
 
182
- The migration from sync to async secure-key functions is **incremental**. The following call sites still use the deprecated sync variants. They are grouped by category and tracked for future conversion.
182
+ The migration from sync to async secure-key functions is complete for all call sites except provider initialization paths that require synchronous access. The following call sites still use the deprecated sync variants.
183
183
 
184
- #### Startup / top-level config (must remain sync)
184
+ #### Provider initialization (must remain sync)
185
185
 
186
186
  These call sites run in synchronous initialization contexts where async I/O is not feasible:
187
187
 
188
- | File | Sync functions used | Reason |
189
- | -------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
190
- | `assistant/src/config/loader.ts` | `getSecureKey`, `setSecureKey`, `deleteSecureKey` | Config loading runs synchronously at startup before the event loop is available. The broker socket may not be ready yet, and converting to async would require rearchitecting the entire config initialization chain. |
191
- | `assistant/src/providers/managed-proxy/context.ts` | `getSecureKey` | Provider context initialization is synchronous. The managed proxy context must resolve credentials before the first request can be processed, and the initialization path does not support awaiting. |
192
-
193
- #### Tracked for future conversion
194
-
195
- These call sites use sync `getSecureKey` in contexts that could potentially support async, but conversion has been deferred to avoid scope creep during the initial broker rollout:
196
-
197
- | File | Sync functions used | Reason deferred |
198
- | ------------------------------------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------- |
199
- | `assistant/src/tools/credentials/broker.ts` | `getSecureKey` | Credential broker read paths are called synchronously from multiple consumers; converting requires async propagation. |
200
- | `assistant/src/security/token-manager.ts` | `getSecureKey` | Token resolution is called in sync context during connection token lookup; async would require refactoring callers. |
201
- | `assistant/src/runtime/routes/integrations/slack/share.ts` | `getSecureKey` | Slack share route reads OAuth token synchronously; converting requires making the route handler async-aware. |
202
- | `assistant/src/runtime/channel-invite-transports/telegram.ts` | `getSecureKey` | Telegram transport reads bot token synchronously; converting requires async transport initialization. |
203
- | `assistant/src/tools/network/web-search.ts` | `getSecureKey` | Web search tool reads API keys synchronously; converting requires async tool execution path. |
204
- | `assistant/src/cli/commands/doctor.ts` | `getSecureKey` | Doctor diagnostic command reads provider keys synchronously; converting requires async CLI command execution. |
205
- | `assistant/src/cli/commands/oauth/connections.ts` | `getSecureKey` | OAuth connections CLI reads client secrets synchronously during display; converting requires async command handler. |
188
+ | File | Sync functions used | Reason |
189
+ | -------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
190
+ | `assistant/src/providers/managed-proxy/context.ts` | `getSecureKey` | Provider context initialization is synchronous. The managed proxy context must resolve credentials before the first request can be processed, and the initialization path does not support awaiting. |
191
+ | `assistant/src/providers/registry.ts` | `getSecureKey` | Provider registry initialization is synchronous. API keys must be resolved at provider construction time, and the call chain from config watcher through to provider init is fully synchronous. |
206
192
 
207
193
  Any new sync usage requires explicit justification and should be documented here.
208
194
 
@@ -293,8 +293,8 @@ The embedding backend is selected based on `memory.embeddings.provider` config:
293
293
 
294
294
  - `auto` (default): Tries local → OpenAI → Gemini → Ollama, using the first available.
295
295
  - `local`: ONNX-based local model (bge-small-en-v1.5). Lazy-loaded to avoid crashing in compiled binaries where onnxruntime-node is unavailable.
296
- - `openai`: OpenAI text-embedding-3-small. Requires `apiKeys.openai`.
297
- - `gemini`: Gemini gemini-embedding-001. Requires `apiKeys.gemini`. Only backend supporting multimodal embeddings (images, audio, video).
296
+ - `openai`: OpenAI text-embedding-3-small. Requires an OpenAI API key in secure storage.
297
+ - `gemini`: Gemini gemini-embedding-001. Requires a Gemini API key in secure storage. Only backend supporting multimodal embeddings (images, audio, video).
298
298
  - `ollama`: Ollama nomic-embed-text. Requires Ollama to be configured.
299
299
 
300
300
  An in-memory LRU vector cache (32 MB cap, keyed by `sha256(provider + model + content)`) avoids redundant embedding calls for identical content. Sparse embeddings are generated in-process (no external calls).
@@ -570,7 +570,7 @@ graph TB
570
570
 
571
571
  **Commit message LLM fallback chain**: The generator runs a sequence of pre-flight checks before calling the LLM. Each check that fails produces a machine-readable `llmFallbackReason` in the structured log output and immediately returns a deterministic message. The checks, in order:
572
572
  1. `disabled` — `commitMessageLLM.enabled` is `false` or `useConfiguredProvider` is `false`
573
- 2. `missing_provider_api_key` — the configured provider's API key is not set in `config.apiKeys` (skipped for keyless providers like Ollama that run without an API key)
573
+ 2. `missing_provider_api_key` — the configured provider's API key is not found in secure storage (skipped for keyless providers like Ollama that run without an API key)
574
574
  3. `breaker_open` — the generator's internal circuit breaker is open after consecutive LLM failures (exponential backoff)
575
575
  4. `insufficient_budget` — the remaining turn budget (`deadlineMs - Date.now()`) is below `minRemainingTurnBudgetMs`
576
576
  5. `missing_fast_model` — no fast model could be resolved for the configured provider (see below); the provider is **not** called
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.4.52",
3
+ "version": "0.4.53",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -79,7 +79,6 @@ mock.module("../config/loader.js", () => ({
79
79
  },
80
80
  rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
81
81
  timeouts: { permissionTimeoutSec: 300 },
82
- apiKeys: {},
83
82
  skills: { entries: {}, allowBundled: true },
84
83
  permissions: { mode: "workspace" },
85
84
  }),
@@ -203,6 +202,9 @@ mock.module("../memory/llm-usage-store.js", () => ({
203
202
  mock.module("../agent/loop.js", () => ({
204
203
  AgentLoop: class {
205
204
  constructor() {}
205
+ getToolTokenBudget() {
206
+ return 0;
207
+ }
206
208
  async run(
207
209
  _messages: Message[],
208
210
  _onEvent: (event: AgentEvent) => void,
@@ -41,7 +41,6 @@ mock.module("../config/loader.js", () => ({
41
41
 
42
42
  model: "test",
43
43
  provider: "test",
44
- apiKeys: {},
45
44
  memory: { enabled: false },
46
45
  rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
47
46
  secretDetection: { enabled: false },
@@ -33,7 +33,6 @@ mock.module("../config/loader.js", () => ({
33
33
 
34
34
  model: "test",
35
35
  provider: "test",
36
- apiKeys: {},
37
36
  memory: { enabled: false },
38
37
  rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
39
38
  }),
@@ -32,7 +32,6 @@ mock.module("../config/loader.js", () => ({
32
32
 
33
33
  model: "test",
34
34
  provider: "test",
35
- apiKeys: {},
36
35
  memory: { enabled: false },
37
36
  rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
38
37
  }),
@@ -40,7 +40,6 @@ mock.module("../config/loader.js", () => ({
40
40
 
41
41
  model: "test",
42
42
  provider: "test",
43
- apiKeys: {},
44
43
  memory: { enabled: false },
45
44
  rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
46
45
  secretDetection: { enabled: false },
@@ -30,7 +30,6 @@ mock.module("../config/loader.js", () => ({
30
30
 
31
31
  model: "test",
32
32
  provider: "test",
33
- apiKeys: {},
34
33
  memory: { enabled: false },
35
34
  rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
36
35
  }),
@@ -27,11 +27,16 @@ const geminiGenerateContentFn = mock(async () => geminiGenerateContentResult);
27
27
 
28
28
  mock.module("../config/loader.js", () => ({
29
29
  getConfig: () => ({
30
- apiKeys: { gemini: mockGeminiKey },
31
30
  imageGenModel: "gemini-2.5-flash-image",
32
31
  }),
33
32
  }));
34
33
 
34
+ mock.module("../security/secure-keys.js", () => ({
35
+ getSecureKeyAsync: async (name: string) =>
36
+ name === "gemini" ? mockGeminiKey : null,
37
+ getSecureKey: () => null,
38
+ }));
39
+
35
40
  mock.module("../util/platform.js", () => ({
36
41
  getWorkspaceDir: () => mockWorkspaceDir,
37
42
  }));
@@ -59,8 +59,11 @@ let mockGetCredentialMetadata: ReturnType<typeof mock>;
59
59
 
60
60
  mock.module("../security/secure-keys.js", () => ({
61
61
  getSecureKey: (...args: unknown[]) => mockGetSecureKey(...args),
62
+ getSecureKeyAsync: async (...args: unknown[]) => mockGetSecureKey(...args),
62
63
  setSecureKey: () => true,
64
+ setSecureKeyAsync: async () => true,
63
65
  deleteSecureKey: () => "deleted",
66
+ deleteSecureKeyAsync: async () => "deleted",
64
67
  listSecureKeys: () => [],
65
68
  getBackendType: () => "encrypted",
66
69
  _resetBackend: () => {},
@@ -54,6 +54,17 @@ mock.module("../daemon/session-tool-setup.js", () => ({
54
54
  buildToolDefinitions: () => MOCK_TOOLS,
55
55
  }));
56
56
 
57
+ const mockCheckIngressForSecrets = mock((content: string) => ({
58
+ blocked: false,
59
+ userNotice: "",
60
+ detectedTypes: [] as string[],
61
+ normalizedContent: content,
62
+ }));
63
+
64
+ mock.module("../security/secret-ingress.js", () => ({
65
+ checkIngressForSecrets: mockCheckIngressForSecrets,
66
+ }));
67
+
57
68
  // ---------------------------------------------------------------------------
58
69
  // Imports (after mocks)
59
70
  // ---------------------------------------------------------------------------
@@ -218,6 +229,34 @@ describe("POST /v1/btw", () => {
218
229
  expect(body.error.message).toContain("content");
219
230
  });
220
231
 
232
+ test("returns 422 when content includes a blocked secret", async () => {
233
+ mockCheckIngressForSecrets.mockReturnValueOnce({
234
+ blocked: true,
235
+ userNotice: "Secret detected",
236
+ detectedTypes: ["api_key"],
237
+ normalizedContent: "sk-test-123",
238
+ });
239
+
240
+ const provider = makeMockProvider();
241
+ const session = makeMockSession(provider);
242
+ const res = await callHandler(
243
+ { conversationKey: "key", content: "sk-test-123" },
244
+ { sendMessageDeps: makeSendMessageDeps(session) },
245
+ );
246
+
247
+ expect(res.status).toBe(422);
248
+ const body = (await res.json()) as {
249
+ accepted: boolean;
250
+ error: string;
251
+ message: string;
252
+ detectedTypes: string[];
253
+ };
254
+ expect(body.accepted).toBe(false);
255
+ expect(body.error).toBe("secret_blocked");
256
+ expect(body.detectedTypes).toEqual(["api_key"]);
257
+ expect(provider.sendMessage).not.toHaveBeenCalled();
258
+ });
259
+
221
260
  // -- Service unavailability (503) --
222
261
 
223
262
  test("returns 503 when sendMessageDeps is unavailable", async () => {
@@ -43,7 +43,6 @@ mock.module("../config/loader.js", () => {
43
43
 
44
44
  provider: "anthropic",
45
45
  providerOrder: ["anthropic"],
46
- apiKeys: { anthropic: "test-key" },
47
46
  calls: {
48
47
  enabled: true,
49
48
  provider: "twilio",
@@ -115,6 +115,7 @@ mock.module("../calls/twilio-provider.js", () => ({
115
115
 
116
116
  mock.module("../security/secure-keys.js", () => ({
117
117
  getSecureKey: () => null,
118
+ getSecureKeyAsync: async () => null,
118
119
  }));
119
120
 
120
121
  mock.module("../config/loader.js", () => ({
@@ -53,7 +53,6 @@ mock.module("../config/loader.js", () => ({
53
53
 
54
54
  model: "test",
55
55
  provider: "test",
56
- apiKeys: {},
57
56
  memory: { enabled: false },
58
57
  rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
59
58
  secretDetection: { enabled: false },
@@ -62,7 +61,6 @@ mock.module("../config/loader.js", () => ({
62
61
  loadConfig: () => ({
63
62
  model: "test",
64
63
  provider: "test",
65
- apiKeys: {},
66
64
  memory: { enabled: false },
67
65
  rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
68
66
  secretDetection: { enabled: false },
@@ -106,6 +104,7 @@ mock.module("../calls/twilio-config.js", () => ({
106
104
  // Mock secure keys
107
105
  mock.module("../security/secure-keys.js", () => ({
108
106
  getSecureKey: () => null,
107
+ getSecureKeyAsync: async () => null,
109
108
  }));
110
109
 
111
110
  mock.module("../calls/voice-ingress-preflight.js", () => ({
@@ -761,7 +761,7 @@ describe("canonical-guardian-store", () => {
761
761
 
762
762
  // ── expireAllPendingCanonicalRequests ───────────────────────────────
763
763
 
764
- test("expireAllPendingCanonicalRequests transitions all pending to expired", () => {
764
+ test("expireAllPendingCanonicalRequests transitions interaction-bound pending to expired", () => {
765
765
  const req1 = createCanonicalGuardianRequest({
766
766
  kind: "tool_approval",
767
767
  sourceType: "desktop",
@@ -770,7 +770,7 @@ describe("canonical-guardian-store", () => {
770
770
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
771
771
  });
772
772
  const req2 = createCanonicalGuardianRequest({
773
- kind: "tool_approval",
773
+ kind: "pending_question",
774
774
  sourceType: "channel",
775
775
  conversationId: "conv-bulk-2",
776
776
  guardianPrincipalId: TEST_PRINCIPAL,
@@ -784,6 +784,37 @@ describe("canonical-guardian-store", () => {
784
784
  expect(getCanonicalGuardianRequest(req2.id)!.status).toBe("expired");
785
785
  });
786
786
 
787
+ test("expireAllPendingCanonicalRequests does not expire persistent kinds (access_request, tool_grant_request)", () => {
788
+ const accessReq = createCanonicalGuardianRequest({
789
+ kind: "access_request",
790
+ sourceType: "channel",
791
+ conversationId: "conv-bulk-persist-1",
792
+ guardianPrincipalId: TEST_PRINCIPAL,
793
+ });
794
+ const grantReq = createCanonicalGuardianRequest({
795
+ kind: "tool_grant_request",
796
+ sourceType: "channel",
797
+ conversationId: "conv-bulk-persist-2",
798
+ guardianPrincipalId: TEST_PRINCIPAL,
799
+ });
800
+ // Also create an interaction-bound request to verify selective expiry
801
+ const toolApproval = createCanonicalGuardianRequest({
802
+ kind: "tool_approval",
803
+ sourceType: "desktop",
804
+ conversationId: "conv-bulk-persist-3",
805
+ guardianPrincipalId: TEST_PRINCIPAL,
806
+ });
807
+
808
+ const count = expireAllPendingCanonicalRequests();
809
+ expect(count).toBe(1); // Only tool_approval expired
810
+
811
+ expect(getCanonicalGuardianRequest(accessReq.id)!.status).toBe("pending");
812
+ expect(getCanonicalGuardianRequest(grantReq.id)!.status).toBe("pending");
813
+ expect(getCanonicalGuardianRequest(toolApproval.id)!.status).toBe(
814
+ "expired",
815
+ );
816
+ });
817
+
787
818
  test("expireAllPendingCanonicalRequests does not affect already-resolved requests", () => {
788
819
  const approved = createCanonicalGuardianRequest({
789
820
  kind: "tool_approval",
@@ -49,6 +49,7 @@ mock.module("../email/service.js", () => ({
49
49
 
50
50
  mock.module("../security/secure-keys.js", () => ({
51
51
  getSecureKey: (key: string) => mockSecureKeys[key] ?? null,
52
+ getSecureKeyAsync: async (key: string) => mockSecureKeys[key] ?? null,
52
53
  }));
53
54
 
54
55
  mock.module("../runtime/channel-invite-transports/whatsapp.js", () => ({
@@ -29,11 +29,15 @@ mock.module("../util/logger.js", () => ({
29
29
  mock.module("../config/loader.js", () => ({
30
30
  getConfig: () => ({
31
31
  ui: {},
32
-
33
- apiKeys: { anthropic: "test-key" },
34
32
  }),
35
33
  }));
36
34
 
35
+ mock.module("../security/secure-keys.js", () => ({
36
+ getSecureKeyAsync: async (name: string) =>
37
+ name === "anthropic" ? "fake-anthropic-key" : null,
38
+ getSecureKey: () => null,
39
+ }));
40
+
37
41
  // ---------------------------------------------------------------------------
38
42
  // Imports (after mocks)
39
43
  // ---------------------------------------------------------------------------
@@ -33,11 +33,16 @@ mock.module("../util/logger.js", () => ({
33
33
  mock.module("../config/loader.js", () => ({
34
34
  getConfig: () => ({
35
35
  ui: {},
36
-
37
- apiKeys: { anthropic: "test-key" },
38
36
  }),
39
37
  }));
40
38
 
39
+ // Mock secure-keys — provide a fake Anthropic API key
40
+ mock.module("../security/secure-keys.js", () => ({
41
+ getSecureKeyAsync: async (name: string) =>
42
+ name === "anthropic" ? "fake-anthropic-key" : null,
43
+ getSecureKey: () => null,
44
+ }));
45
+
41
46
  import { claudeCodeTool } from "../tools/claude-code/claude-code.js";
42
47
  import type { ToolContext } from "../tools/types.js";
43
48
 
@@ -278,13 +278,12 @@ describe("config loader backfill", () => {
278
278
  expect(contentAfter).toBe(contentBefore);
279
279
  });
280
280
 
281
- test("does not write apiKeys or dataDir during backfill", () => {
281
+ test("does not write dataDir during backfill", () => {
282
282
  writeConfig({ provider: "anthropic" });
283
283
 
284
284
  loadConfig();
285
285
 
286
286
  const raw = readConfig();
287
- expect(raw.apiKeys).toBeUndefined();
288
287
  expect(raw.dataDir).toBeUndefined();
289
288
  });
290
289
 
@@ -67,7 +67,10 @@ import {
67
67
  resolveVoiceQualityProfile,
68
68
  } from "../calls/voice-quality.js";
69
69
  import { invalidateConfigCache, loadConfig } from "../config/loader.js";
70
- import { AssistantConfigSchema } from "../config/schema.js";
70
+ import {
71
+ AssistantConfigSchema,
72
+ DEFAULT_ELEVENLABS_VOICE_ID,
73
+ } from "../config/schema.js";
71
74
  import { _setStorePath } from "../security/encrypted-store.js";
72
75
  import { _setBackend } from "../security/secure-keys.js";
73
76
 
@@ -89,7 +92,6 @@ describe("AssistantConfigSchema", () => {
89
92
  expect(result.provider).toBe("anthropic");
90
93
  expect(result.model).toBe("claude-opus-4-6");
91
94
  expect(result.maxTokens).toBe(16000);
92
- expect(result.apiKeys).toEqual({});
93
95
  expect(result.thinking).toEqual({
94
96
  enabled: false,
95
97
  streamThinking: false,
@@ -137,7 +139,6 @@ describe("AssistantConfigSchema", () => {
137
139
  provider: "openai",
138
140
  model: "gpt-4",
139
141
  maxTokens: 4096,
140
- apiKeys: { openai: "sk-test" },
141
142
  thinking: { enabled: true },
142
143
  timeouts: {
143
144
  shellDefaultTimeoutSec: 30,
@@ -358,13 +359,6 @@ describe("AssistantConfigSchema", () => {
358
359
  expect(result.success).toBe(false);
359
360
  });
360
361
 
361
- test("rejects non-string apiKeys values", () => {
362
- const result = AssistantConfigSchema.safeParse({
363
- apiKeys: { anthropic: 123 },
364
- });
365
- expect(result.success).toBe(false);
366
- });
367
-
368
362
  test("accepts partial nested objects with defaults", () => {
369
363
  const result = AssistantConfigSchema.parse({
370
364
  timeouts: { shellDefaultTimeoutSec: 30 },
@@ -906,10 +900,10 @@ describe("resolveVoiceQualityProfile", () => {
906
900
  expect(profile.voice).toBe("test-voice-id");
907
901
  });
908
902
 
909
- test("defaults to Rachel voice ID when elevenlabs.voiceId is not set", () => {
903
+ test("defaults to Amelia voice ID when elevenlabs.voiceId is not set", () => {
910
904
  const config = AssistantConfigSchema.parse({});
911
905
  const profile = resolveVoiceQualityProfile(config);
912
- expect(profile.voice).toBe("21m00Tcm4TlvDq8ikWAM");
906
+ expect(profile.voice).toBe(DEFAULT_ELEVENLABS_VOICE_ID);
913
907
  });
914
908
 
915
909
  test("applies voice tuning params from elevenlabs config", () => {
@@ -1165,31 +1159,6 @@ describe("loadConfig with schema validation", () => {
1165
1159
  expect(config.permissions.mode).toBe("workspace");
1166
1160
  });
1167
1161
 
1168
- test("does not mutate default apiKeys when fallback config is overridden by env keys", () => {
1169
- const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY;
1170
- try {
1171
- const testKey = ["test", "in", "memory", "default", "leak"].join("-");
1172
- process.env.ANTHROPIC_API_KEY = testKey;
1173
- writeConfig("this is not a config object");
1174
-
1175
- const configWithEnv = loadConfig();
1176
- expect(configWithEnv.apiKeys.anthropic).toBe(testKey);
1177
-
1178
- invalidateConfigCache();
1179
- delete process.env.ANTHROPIC_API_KEY;
1180
- writeConfig("still not a config object");
1181
-
1182
- const configWithoutEnv = loadConfig();
1183
- expect(configWithoutEnv.apiKeys.anthropic).toBeUndefined();
1184
- } finally {
1185
- if (originalAnthropicApiKey !== undefined) {
1186
- process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey;
1187
- } else {
1188
- delete process.env.ANTHROPIC_API_KEY;
1189
- }
1190
- }
1191
- });
1192
-
1193
1162
  // ── Calls config (loader integration) ──────────────────────────────
1194
1163
 
1195
1164
  test("loads calls config from file", () => {
@@ -30,7 +30,6 @@ mock.module("../config/loader.js", () => ({
30
30
  ui: {},
31
31
  model: "claude-opus-4-6",
32
32
  provider: "anthropic",
33
- apiKeys: { anthropic: "test-key" },
34
33
  memory: { enabled: false },
35
34
  rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
36
35
  secretDetection: { enabled: false },
@@ -371,7 +371,7 @@ describe("CredentialBroker.serverUse", () => {
371
371
  expect(r2.result).toBe("triggered");
372
372
  });
373
373
 
374
- test("different services with same field name are independent (serverUseById)", () => {
374
+ test("different services with same field name are independent (serverUseById)", async () => {
375
375
  const meta1 = upsertCredentialMetadata("github", "api_token", {
376
376
  allowedTools: ["github_api"],
377
377
  });
@@ -382,7 +382,7 @@ describe("CredentialBroker.serverUse", () => {
382
382
  setSecureKey(credentialKey("gitlab", "api_token"), "gl_tok");
383
383
 
384
384
  // github credential should not serve gitlab tool
385
- const r1 = broker.serverUseById({
385
+ const r1 = await broker.serverUseById({
386
386
  credentialId: meta1.credentialId,
387
387
  requestingTool: "gitlab_api",
388
388
  });
@@ -447,7 +447,7 @@ describe("CredentialBroker.serverUseById", () => {
447
447
  rmSync(TEST_DIR, { recursive: true, force: true });
448
448
  });
449
449
 
450
- test("returns metadata and injection templates for valid credential", () => {
450
+ test("returns metadata and injection templates for valid credential", async () => {
451
451
  const meta = upsertCredentialMetadata("fal", "api_key", {
452
452
  allowedTools: ["media_proxy"],
453
453
  injectionTemplates: [
@@ -461,7 +461,7 @@ describe("CredentialBroker.serverUseById", () => {
461
461
  });
462
462
  setSecureKey(credentialKey("fal", "api_key"), "fal-secret-key");
463
463
 
464
- const result = broker.serverUseById({
464
+ const result = await broker.serverUseById({
465
465
  credentialId: meta.credentialId,
466
466
  requestingTool: "media_proxy",
467
467
  });
@@ -480,13 +480,13 @@ describe("CredentialBroker.serverUseById", () => {
480
480
  expect(serialized).not.toContain("fal-secret-key");
481
481
  });
482
482
 
483
- test("denies when requesting tool is not in allowed list", () => {
483
+ test("denies when requesting tool is not in allowed list", async () => {
484
484
  const meta = upsertCredentialMetadata("fal", "api_key", {
485
485
  allowedTools: ["media_proxy"],
486
486
  });
487
487
  setSecureKey(credentialKey("fal", "api_key"), "fal-secret-key");
488
488
 
489
- const result = broker.serverUseById({
489
+ const result = await broker.serverUseById({
490
490
  credentialId: meta.credentialId,
491
491
  requestingTool: "unauthorized_tool",
492
492
  });
@@ -498,8 +498,8 @@ describe("CredentialBroker.serverUseById", () => {
498
498
  expect(result.reason).toContain("media_proxy");
499
499
  });
500
500
 
501
- test("returns not found for unknown credential ID", () => {
502
- const result = broker.serverUseById({
501
+ test("returns not found for unknown credential ID", async () => {
502
+ const result = await broker.serverUseById({
503
503
  credentialId: "nonexistent-id",
504
504
  requestingTool: "media_proxy",
505
505
  });
@@ -510,14 +510,14 @@ describe("CredentialBroker.serverUseById", () => {
510
510
  expect(result.reason).toContain("nonexistent-id");
511
511
  });
512
512
 
513
- test("denies when credential has domain restrictions", () => {
513
+ test("denies when credential has domain restrictions", async () => {
514
514
  const meta = upsertCredentialMetadata("github", "oauth_token", {
515
515
  allowedTools: ["media_proxy"],
516
516
  allowedDomains: ["github.com"],
517
517
  });
518
518
  setSecureKey(credentialKey("github", "oauth_token"), "gho_test");
519
519
 
520
- const result = broker.serverUseById({
520
+ const result = await broker.serverUseById({
521
521
  credentialId: meta.credentialId,
522
522
  requestingTool: "media_proxy",
523
523
  });
@@ -528,13 +528,13 @@ describe("CredentialBroker.serverUseById", () => {
528
528
  expect(result.reason).toContain("cannot be used server-side");
529
529
  });
530
530
 
531
- test("returns empty injection templates when credential has none", () => {
531
+ test("returns empty injection templates when credential has none", async () => {
532
532
  const meta = upsertCredentialMetadata("vercel", "api_token", {
533
533
  allowedTools: ["media_proxy"],
534
534
  });
535
535
  setSecureKey(credentialKey("vercel", "api_token"), "test-vercel-token");
536
536
 
537
- const result = broker.serverUseById({
537
+ const result = await broker.serverUseById({
538
538
  credentialId: meta.credentialId,
539
539
  requestingTool: "media_proxy",
540
540
  });
@@ -544,13 +544,13 @@ describe("CredentialBroker.serverUseById", () => {
544
544
  expect(result.injectionTemplates).toEqual([]);
545
545
  });
546
546
 
547
- test("denies with empty allowedTools and suggests updating credential", () => {
547
+ test("denies with empty allowedTools and suggests updating credential", async () => {
548
548
  const meta = upsertCredentialMetadata("stripe", "secret_key", {
549
549
  allowedTools: [],
550
550
  });
551
551
  setSecureKey(credentialKey("stripe", "secret_key"), "sk_test_xyz");
552
552
 
553
- const result = broker.serverUseById({
553
+ const result = await broker.serverUseById({
554
554
  credentialId: meta.credentialId,
555
555
  requestingTool: "media_proxy",
556
556
  });
@@ -561,7 +561,7 @@ describe("CredentialBroker.serverUseById", () => {
561
561
  expect(result.reason).toContain("credential_store");
562
562
  });
563
563
 
564
- test("denies when metadata exists but no stored secret value", () => {
564
+ test("denies when metadata exists but no stored secret value", async () => {
565
565
  const meta = upsertCredentialMetadata("fal", "api_key", {
566
566
  allowedTools: ["media_proxy"],
567
567
  injectionTemplates: [
@@ -575,7 +575,7 @@ describe("CredentialBroker.serverUseById", () => {
575
575
  });
576
576
  // No setSecureKey — metadata exists but value doesn't
577
577
 
578
- const result = broker.serverUseById({
578
+ const result = await broker.serverUseById({
579
579
  credentialId: meta.credentialId,
580
580
  requestingTool: "media_proxy",
581
581
  });