@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
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Handle bash debug signals delivered via signal files from the CLI.
3
+ *
4
+ * Each invocation writes JSON to a unique `signals/bash.<requestId>` file.
5
+ * ConfigWatcher detects the new file and invokes {@link handleBashSignal},
6
+ * which reads the payload, spawns the command, and writes the result to
7
+ * `signals/bash.<requestId>.result` for the CLI to pick up.
8
+ *
9
+ * Per-request filenames avoid dropped commands when overlapping invocations
10
+ * race on the same signal file.
11
+ */
12
+
13
+ import { spawn } from "node:child_process";
14
+ import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ import { getLogger } from "../util/logger.js";
18
+ import { getWorkspaceDir } from "../util/platform.js";
19
+
20
+ const log = getLogger("signal:bash");
21
+
22
+ const DEFAULT_TIMEOUT_MS = 30_000;
23
+
24
+ interface BashSignalPayload {
25
+ requestId: string;
26
+ command: string;
27
+ timeoutMs?: number;
28
+ }
29
+
30
+ interface BashSignalResult {
31
+ requestId: string;
32
+ stdout: string;
33
+ stderr: string;
34
+ exitCode: number | null;
35
+ timedOut: boolean;
36
+ error?: string;
37
+ }
38
+
39
+ function writeResult(requestId: string, result: BashSignalResult): void {
40
+ try {
41
+ writeFileSync(
42
+ join(getWorkspaceDir(), "signals", `bash.${requestId}.result`),
43
+ JSON.stringify(result),
44
+ );
45
+ } catch (err) {
46
+ log.error({ err, requestId }, "Failed to write bash signal result");
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Read a `signals/bash.<requestId>` file, execute the command, and write
52
+ * the result to `signals/bash.<requestId>.result`. Called by ConfigWatcher
53
+ * when a matching signal file is created or modified.
54
+ */
55
+ export function handleBashSignal(filename: string): void {
56
+ const signalPath = join(getWorkspaceDir(), "signals", filename);
57
+ let raw: string;
58
+ try {
59
+ raw = readFileSync(signalPath, "utf-8");
60
+ } catch {
61
+ // File may already be deleted (e.g. re-trigger from our own unlinkSync).
62
+ return;
63
+ }
64
+
65
+ let payload: BashSignalPayload;
66
+ try {
67
+ payload = JSON.parse(raw) as BashSignalPayload;
68
+ } catch (err) {
69
+ log.error({ err, filename }, "Failed to parse bash signal file");
70
+ return;
71
+ }
72
+
73
+ try {
74
+ unlinkSync(signalPath);
75
+ } catch {
76
+ // Best-effort cleanup; the file may already be gone.
77
+ }
78
+
79
+ const { requestId, command, timeoutMs } = payload;
80
+
81
+ if (!requestId || typeof requestId !== "string") {
82
+ log.warn("Bash signal missing requestId");
83
+ return;
84
+ }
85
+ if (!command || typeof command !== "string") {
86
+ log.warn({ requestId }, "Bash signal missing command");
87
+ return;
88
+ }
89
+
90
+ const effectiveTimeout =
91
+ typeof timeoutMs === "number" && timeoutMs > 0
92
+ ? timeoutMs
93
+ : DEFAULT_TIMEOUT_MS;
94
+
95
+ log.info({ requestId, command }, "Executing bash signal command");
96
+
97
+ const stdoutChunks: Buffer[] = [];
98
+ const stderrChunks: Buffer[] = [];
99
+ let timedOut = false;
100
+ let resultWritten = false;
101
+
102
+ const child = spawn("bash", ["-c", command], {
103
+ cwd: getWorkspaceDir(),
104
+ stdio: ["ignore", "pipe", "pipe"],
105
+ });
106
+
107
+ const timer = setTimeout(() => {
108
+ timedOut = true;
109
+ child.kill("SIGKILL");
110
+ }, effectiveTimeout);
111
+
112
+ child.stdout.on("data", (data: Buffer) => {
113
+ stdoutChunks.push(data);
114
+ });
115
+
116
+ child.stderr.on("data", (data: Buffer) => {
117
+ stderrChunks.push(data);
118
+ });
119
+
120
+ child.on("close", (code) => {
121
+ clearTimeout(timer);
122
+ if (resultWritten) return;
123
+ resultWritten = true;
124
+
125
+ const stdout = Buffer.concat(stdoutChunks).toString();
126
+ const stderr = Buffer.concat(stderrChunks).toString();
127
+
128
+ log.info(
129
+ { requestId, exitCode: code, timedOut },
130
+ "Bash signal command completed",
131
+ );
132
+
133
+ writeResult(requestId, {
134
+ requestId,
135
+ stdout,
136
+ stderr,
137
+ exitCode: code,
138
+ timedOut,
139
+ });
140
+ });
141
+
142
+ child.on("error", (err) => {
143
+ clearTimeout(timer);
144
+ if (resultWritten) return;
145
+ resultWritten = true;
146
+
147
+ log.error({ err, requestId }, "Failed to spawn bash signal command");
148
+ writeResult(requestId, {
149
+ requestId,
150
+ stdout: "",
151
+ stderr: "",
152
+ exitCode: null,
153
+ timedOut: false,
154
+ error: err.message,
155
+ });
156
+ });
157
+ }
@@ -228,7 +228,12 @@ async function findSkillDirInTree(
228
228
  headers,
229
229
  signal: AbortSignal.timeout(15_000),
230
230
  });
231
- if (!response.ok) return null;
231
+ if (response.status === 404) return null;
232
+ if (!response.ok) {
233
+ throw new Error(
234
+ `GitHub API error while searching repo tree: HTTP ${response.status} ${response.statusText}`,
235
+ );
236
+ }
232
237
 
233
238
  const data = (await response.json()) as { tree: GitHubTreeEntry[] };
234
239
  const suffix = `${skillSlug}/SKILL.md`;
@@ -5,9 +5,9 @@
5
5
  * is testable and swappable independently of the tool adapter.
6
6
  */
7
7
 
8
- import { getConfig } from "../config/loader.js";
9
8
  import { resolveModelIntent } from "../providers/model-intents.js";
10
9
  import type { ModelIntent } from "../providers/types.js";
10
+ import { getSecureKeyAsync } from "../security/secure-keys.js";
11
11
  import { getLogger } from "../util/logger.js";
12
12
  import type {
13
13
  SwarmWorkerBackend,
@@ -28,9 +28,9 @@ export function createClaudeCodeBackend(): SwarmWorkerBackend {
28
28
  return {
29
29
  name: "claude_code",
30
30
 
31
- isAvailable(): boolean {
32
- const config = getConfig();
33
- const apiKey = config.apiKeys.anthropic ?? process.env.ANTHROPIC_API_KEY;
31
+ async isAvailable(): Promise<boolean> {
32
+ const apiKey =
33
+ (await getSecureKeyAsync("anthropic")) ?? process.env.ANTHROPIC_API_KEY;
34
34
  return !!apiKey;
35
35
  },
36
36
 
@@ -39,9 +39,9 @@ export function createClaudeCodeBackend(): SwarmWorkerBackend {
39
39
  const stderrLines: string[] = [];
40
40
  try {
41
41
  const { query } = await import("@anthropic-ai/claude-agent-sdk");
42
- const config = getConfig();
43
42
  const apiKey =
44
- config.apiKeys.anthropic ?? process.env.ANTHROPIC_API_KEY;
43
+ (await getSecureKeyAsync("anthropic")) ??
44
+ process.env.ANTHROPIC_API_KEY;
45
45
  if (!apiKey) {
46
46
  return {
47
47
  success: false,
@@ -125,7 +125,7 @@ export interface SwarmWorkerBackendInput {
125
125
  export interface SwarmWorkerBackend {
126
126
  readonly name: string;
127
127
  /** Check whether the backend is available (e.g. API key present). */
128
- isAvailable(): boolean;
128
+ isAvailable(): boolean | Promise<boolean>;
129
129
  /** Run a task with the given input. */
130
130
  runTask(input: SwarmWorkerBackendInput): Promise<SwarmWorkerBackendResult>;
131
131
  }
@@ -63,7 +63,7 @@ export async function runWorkerTask(
63
63
  emitStatus(onStatus, task.id, "queued");
64
64
 
65
65
  // Check backend availability
66
- if (!backend.isAvailable()) {
66
+ if (!(await backend.isAvailable())) {
67
67
  emitStatus(onStatus, task.id, "failed");
68
68
  return {
69
69
  taskId: task.id,
@@ -1,5 +1,16 @@
1
1
  import { getConfig } from "../config/loader.js";
2
2
 
3
+ /**
4
+ * Read the Telegram bot ID from config.
5
+ */
6
+ export function getTelegramBotId(): string | undefined {
7
+ const value = getConfig().telegram.botId;
8
+ if (value.trim().length > 0) {
9
+ return value.trim();
10
+ }
11
+ return undefined;
12
+ }
13
+
3
14
  /**
4
15
  * Read the Telegram bot username from config.
5
16
  */
@@ -2,9 +2,9 @@ import {
2
2
  getCCCommand,
3
3
  loadCCCommandTemplate,
4
4
  } from "../../commands/cc-command-registry.js";
5
- import { getConfig } from "../../config/loader.js";
6
5
  import { RiskLevel } from "../../permissions/types.js";
7
6
  import type { ToolDefinition } from "../../providers/types.js";
7
+ import { getSecureKeyAsync } from "../../security/secure-keys.js";
8
8
  import type { WorkerProfile } from "../../swarm/worker-backend.js";
9
9
  import { getProfilePolicy } from "../../swarm/worker-backend.js";
10
10
  import { getLogger } from "../../util/logger.js";
@@ -203,12 +203,12 @@ export const claudeCodeTool: Tool = {
203
203
  const profilePolicy = getProfilePolicy(profileName);
204
204
 
205
205
  // Validate API key
206
- const config = getConfig();
207
- const apiKey = config.apiKeys.anthropic ?? process.env.ANTHROPIC_API_KEY;
206
+ const apiKey =
207
+ (await getSecureKeyAsync("anthropic")) ?? process.env.ANTHROPIC_API_KEY;
208
208
  if (!apiKey) {
209
209
  return {
210
210
  content:
211
- "Error: No Anthropic API key configured. Set it via config or ANTHROPIC_API_KEY environment variable.",
211
+ "Error: No Anthropic API key configured. Set it via `keys set anthropic <key>` or configure it from the Settings page under API Keys.",
212
212
  isError: true,
213
213
  };
214
214
  }
@@ -1,7 +1,7 @@
1
1
  import { v4 as uuid } from "uuid";
2
2
 
3
3
  import { credentialKey } from "../../security/credential-key.js";
4
- import { getSecureKey } from "../../security/secure-keys.js";
4
+ import { getSecureKeyAsync } from "../../security/secure-keys.js";
5
5
  import { getLogger } from "../../util/logger.js";
6
6
  import type {
7
7
  AuthorizeRequest,
@@ -217,7 +217,7 @@ export class CredentialBroker {
217
217
  // Deletion is deferred until after a successful fill so the value survives
218
218
  // transient failures (e.g. stale element, page navigation, Playwright timeout).
219
219
  const transient = this.transientValues.get(storageKey);
220
- const value = transient?.value ?? getSecureKey(storageKey);
220
+ const value = transient?.value ?? (await getSecureKeyAsync(storageKey));
221
221
  if (!value) {
222
222
  return {
223
223
  success: false,
@@ -302,7 +302,7 @@ export class CredentialBroker {
302
302
 
303
303
  const storageKey = credentialKey(request.service, request.field);
304
304
  const transient = this.transientValues.get(storageKey);
305
- const value = transient?.value ?? getSecureKey(storageKey);
305
+ const value = transient?.value ?? (await getSecureKeyAsync(storageKey));
306
306
  if (!value) {
307
307
  return {
308
308
  success: false,
@@ -344,7 +344,9 @@ export class CredentialBroker {
344
344
  * never included in the result — the proxy reads it separately via
345
345
  * the secure key backend at injection time.
346
346
  */
347
- serverUseById(request: ServerUseByIdRequest): ServerUseByIdResult {
347
+ async serverUseById(
348
+ request: ServerUseByIdRequest,
349
+ ): Promise<ServerUseByIdResult> {
348
350
  const resolved = resolveById(request.credentialId);
349
351
  if (!resolved) {
350
352
  return {
@@ -383,7 +385,7 @@ export class CredentialBroker {
383
385
 
384
386
  // Fail-closed: verify the secret value actually exists in secure storage.
385
387
  // Without this, downstream proxy code would attempt unauthenticated requests.
386
- const value = getSecureKey(resolved.storageKey);
388
+ const value = await getSecureKeyAsync(resolved.storageKey);
387
389
  if (!value) {
388
390
  return {
389
391
  success: false,
@@ -900,8 +900,9 @@ class CredentialStoreTool implements Tool {
900
900
  | "loopback"
901
901
  | "gateway"
902
902
  | null) ?? "gateway";
903
- if (transport === "loopback" && descProviderRow.loopbackPort) {
904
- redirectUri = `http://127.0.0.1:${descProviderRow.loopbackPort}/oauth/callback`;
903
+ const loopbackPort = descBehavior?.loopbackPort;
904
+ if (transport === "loopback" && loopbackPort) {
905
+ redirectUri = `http://localhost:${loopbackPort}/oauth/callback`;
905
906
  } else if (transport === "loopback") {
906
907
  redirectUri =
907
908
  "(automatic — no redirect URI needed, uses random localhost port)";
@@ -2,8 +2,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  // Mutable mock state — set per test
4
4
  let mockWebSearchProvider: string | undefined = "perplexity";
5
- let mockBraveConfigKey: string | undefined;
6
- let mockPerplexityConfigKey: string | undefined;
7
5
  let mockBraveSecureKey: string | undefined;
8
6
  let mockPerplexitySecureKey: string | undefined;
9
7
 
@@ -19,12 +17,11 @@ mock.module("../../registry.js", () => ({
19
17
  mock.module("../../../config/loader.js", () => ({
20
18
  getConfig: () => ({
21
19
  webSearchProvider: mockWebSearchProvider,
22
- apiKeys: { brave: mockBraveConfigKey, perplexity: mockPerplexityConfigKey },
23
20
  }),
24
21
  }));
25
22
 
26
23
  mock.module("../../../security/secure-keys.js", () => ({
27
- getSecureKey: (provider: string) => {
24
+ getSecureKeyAsync: async (provider: string) => {
28
25
  if (provider === "brave") return mockBraveSecureKey;
29
26
  if (provider === "perplexity") return mockPerplexitySecureKey;
30
27
  return undefined;
@@ -47,33 +44,16 @@ await import("../web-search.js");
47
44
 
48
45
  describe("web_search tool", () => {
49
46
  let originalFetch: typeof globalThis.fetch;
50
- let savedBraveKey: string | undefined;
51
- let savedPerplexityKey: string | undefined;
52
47
 
53
48
  beforeEach(() => {
54
49
  originalFetch = globalThis.fetch;
55
50
  mockWebSearchProvider = "perplexity";
56
- mockBraveConfigKey = undefined;
57
- mockPerplexityConfigKey = undefined;
58
51
  mockBraveSecureKey = undefined;
59
52
  mockPerplexitySecureKey = undefined;
60
-
61
- // Isolate from host env so getApiKey() doesn't short-circuit on real keys
62
- savedBraveKey = process.env.BRAVE_API_KEY;
63
- savedPerplexityKey = process.env.PERPLEXITY_API_KEY;
64
- delete process.env.BRAVE_API_KEY;
65
- delete process.env.PERPLEXITY_API_KEY;
66
53
  });
67
54
 
68
55
  afterEach(() => {
69
56
  globalThis.fetch = originalFetch;
70
-
71
- if (savedBraveKey !== undefined) process.env.BRAVE_API_KEY = savedBraveKey;
72
- else delete process.env.BRAVE_API_KEY;
73
-
74
- if (savedPerplexityKey !== undefined)
75
- process.env.PERPLEXITY_API_KEY = savedPerplexityKey;
76
- else delete process.env.PERPLEXITY_API_KEY;
77
57
  });
78
58
 
79
59
  function execute(input: Record<string, unknown>) {
@@ -105,7 +85,7 @@ describe("web_search tool", () => {
105
85
  // ---- Perplexity provider ------------------------------------------------
106
86
 
107
87
  test("executes Perplexity search successfully", async () => {
108
- mockPerplexityConfigKey = "pplx-test-key";
88
+ mockPerplexitySecureKey = "pplx-test-key";
109
89
  globalThis.fetch = (async (_url: string, _init?: RequestInit) => {
110
90
  return new Response(
111
91
  JSON.stringify({
@@ -126,7 +106,7 @@ describe("web_search tool", () => {
126
106
  });
127
107
 
128
108
  test("Perplexity sends correct request format", async () => {
129
- mockPerplexityConfigKey = "pplx-test-key";
109
+ mockPerplexitySecureKey = "pplx-test-key";
130
110
  let capturedUrl = "";
131
111
  let capturedBody: any = null;
132
112
  let capturedHeaders: any = null;
@@ -150,7 +130,7 @@ describe("web_search tool", () => {
150
130
  });
151
131
 
152
132
  test("Perplexity returns no results message when response is empty", async () => {
153
- mockPerplexityConfigKey = "pplx-test-key";
133
+ mockPerplexitySecureKey = "pplx-test-key";
154
134
  globalThis.fetch = (async () => {
155
135
  return new Response(JSON.stringify({ choices: [] }), {
156
136
  status: 200,
@@ -164,7 +144,7 @@ describe("web_search tool", () => {
164
144
  });
165
145
 
166
146
  test("Perplexity handles 401/403 auth errors", async () => {
167
- mockPerplexityConfigKey = "bad-key";
147
+ mockPerplexitySecureKey = "bad-key";
168
148
  globalThis.fetch = (async () => {
169
149
  return new Response("Unauthorized", { status: 401 });
170
150
  }) as any;
@@ -175,7 +155,7 @@ describe("web_search tool", () => {
175
155
  });
176
156
 
177
157
  test("Perplexity handles 429 rate limit after max retries", async () => {
178
- mockPerplexityConfigKey = "pplx-key";
158
+ mockPerplexitySecureKey = "pplx-key";
179
159
  let callCount = 0;
180
160
  globalThis.fetch = (async () => {
181
161
  callCount++;
@@ -193,7 +173,7 @@ describe("web_search tool", () => {
193
173
  });
194
174
 
195
175
  test("Perplexity handles generic server error", async () => {
196
- mockPerplexityConfigKey = "pplx-key";
176
+ mockPerplexitySecureKey = "pplx-key";
197
177
  globalThis.fetch = (async () => {
198
178
  return new Response("Internal Server Error", { status: 500 });
199
179
  }) as any;
@@ -207,7 +187,7 @@ describe("web_search tool", () => {
207
187
 
208
188
  test("executes Brave search successfully", async () => {
209
189
  mockWebSearchProvider = "brave";
210
- mockBraveConfigKey = "brave-test-key";
190
+ mockBraveSecureKey = "brave-test-key";
211
191
  globalThis.fetch = (async (_url: string) => {
212
192
  return new Response(
213
193
  JSON.stringify({
@@ -243,7 +223,7 @@ describe("web_search tool", () => {
243
223
 
244
224
  test("Brave sends correct query parameters", async () => {
245
225
  mockWebSearchProvider = "brave";
246
- mockBraveConfigKey = "brave-key";
226
+ mockBraveSecureKey = "brave-key";
247
227
  let capturedUrl = "";
248
228
  globalThis.fetch = (async (url: string) => {
249
229
  capturedUrl = url;
@@ -268,7 +248,7 @@ describe("web_search tool", () => {
268
248
 
269
249
  test("Brave clamps count and offset", async () => {
270
250
  mockWebSearchProvider = "brave";
271
- mockBraveConfigKey = "brave-key";
251
+ mockBraveSecureKey = "brave-key";
272
252
  let capturedUrl = "";
273
253
  globalThis.fetch = (async (url: string) => {
274
254
  capturedUrl = url;
@@ -286,7 +266,7 @@ describe("web_search tool", () => {
286
266
 
287
267
  test("Brave skips invalid freshness values", async () => {
288
268
  mockWebSearchProvider = "brave";
289
- mockBraveConfigKey = "brave-key";
269
+ mockBraveSecureKey = "brave-key";
290
270
  let capturedUrl = "";
291
271
  globalThis.fetch = (async (url: string) => {
292
272
  capturedUrl = url;
@@ -303,7 +283,7 @@ describe("web_search tool", () => {
303
283
 
304
284
  test("Brave handles empty results", async () => {
305
285
  mockWebSearchProvider = "brave";
306
- mockBraveConfigKey = "brave-key";
286
+ mockBraveSecureKey = "brave-key";
307
287
  globalThis.fetch = (async () => {
308
288
  return new Response(JSON.stringify({ web: { results: [] } }), {
309
289
  status: 200,
@@ -318,7 +298,7 @@ describe("web_search tool", () => {
318
298
 
319
299
  test("Brave handles 401 auth error", async () => {
320
300
  mockWebSearchProvider = "brave";
321
- mockBraveConfigKey = "bad-key";
301
+ mockBraveSecureKey = "bad-key";
322
302
  globalThis.fetch = (async () => {
323
303
  return new Response("Forbidden", { status: 403 });
324
304
  }) as any;
@@ -330,7 +310,7 @@ describe("web_search tool", () => {
330
310
 
331
311
  test("Brave handles 429 rate limit with Retry-After header", async () => {
332
312
  mockWebSearchProvider = "brave";
333
- mockBraveConfigKey = "brave-key";
313
+ mockBraveSecureKey = "brave-key";
334
314
  let callCount = 0;
335
315
  globalThis.fetch = (async () => {
336
316
  callCount++;
@@ -369,7 +349,7 @@ describe("web_search tool", () => {
369
349
 
370
350
  test("falls back from perplexity to brave when perplexity has no key", async () => {
371
351
  mockWebSearchProvider = "perplexity";
372
- mockBraveConfigKey = "brave-fallback-key";
352
+ mockBraveSecureKey = "brave-fallback-key";
373
353
  let capturedUrl = "";
374
354
  globalThis.fetch = (async (url: string) => {
375
355
  capturedUrl = url;
@@ -386,7 +366,7 @@ describe("web_search tool", () => {
386
366
 
387
367
  test("falls back from brave to perplexity when brave has no key", async () => {
388
368
  mockWebSearchProvider = "brave";
389
- mockPerplexityConfigKey = "pplx-fallback-key";
369
+ mockPerplexitySecureKey = "pplx-fallback-key";
390
370
  let capturedUrl = "";
391
371
  globalThis.fetch = (async (url: string, _init?: RequestInit) => {
392
372
  capturedUrl = url;
@@ -405,7 +385,7 @@ describe("web_search tool", () => {
405
385
 
406
386
  test("maps anthropic-native to perplexity", async () => {
407
387
  mockWebSearchProvider = "anthropic-native";
408
- mockPerplexityConfigKey = "pplx-key";
388
+ mockPerplexitySecureKey = "pplx-key";
409
389
  let capturedUrl = "";
410
390
  globalThis.fetch = (async (url: string) => {
411
391
  capturedUrl = url;
@@ -422,38 +402,10 @@ describe("web_search tool", () => {
422
402
  expect(capturedUrl).toContain("perplexity");
423
403
  });
424
404
 
425
- // ---- Env var keys -------------------------------------------------------
426
-
427
- test("uses PERPLEXITY_API_KEY env var when available", async () => {
428
- const origEnv = process.env.PERPLEXITY_API_KEY;
429
- process.env.PERPLEXITY_API_KEY = "env-pplx-key";
430
- try {
431
- globalThis.fetch = (async (_url: string, init?: RequestInit) => {
432
- const headers = new Headers(init?.headers);
433
- expect(headers.get("authorization")).toBe("Bearer env-pplx-key");
434
- return new Response(
435
- JSON.stringify({
436
- choices: [{ message: { content: "env key works" } }],
437
- }),
438
- { status: 200, headers: { "content-type": "application/json" } },
439
- );
440
- }) as any;
441
-
442
- const result = await execute({ query: "test" });
443
- expect(result.isError).toBe(false);
444
- } finally {
445
- if (origEnv === undefined) {
446
- delete process.env.PERPLEXITY_API_KEY;
447
- } else {
448
- process.env.PERPLEXITY_API_KEY = origEnv;
449
- }
450
- }
451
- });
452
-
453
405
  // ---- Network errors -----------------------------------------------------
454
406
 
455
407
  test("handles fetch exceptions", async () => {
456
- mockPerplexityConfigKey = "pplx-key";
408
+ mockPerplexitySecureKey = "pplx-key";
457
409
  globalThis.fetch = (async () => {
458
410
  throw new Error("Network error: connection refused");
459
411
  }) as any;
@@ -463,24 +415,4 @@ describe("web_search tool", () => {
463
415
  expect(result.content).toContain("Web search failed");
464
416
  expect(result.content).toContain("connection refused");
465
417
  });
466
-
467
- // ---- Secure key precedence ----------------------------------------------
468
-
469
- test("prefers secure key over config key for brave", async () => {
470
- mockWebSearchProvider = "brave";
471
- mockBraveConfigKey = "config-key";
472
- mockBraveSecureKey = "secure-key";
473
- let capturedHeaders: Headers | null = null;
474
- globalThis.fetch = (async (_url: string, init?: RequestInit) => {
475
- capturedHeaders = new Headers(init?.headers);
476
- return new Response(JSON.stringify({ web: { results: [] } }), {
477
- status: 200,
478
- headers: { "content-type": "application/json" },
479
- });
480
- }) as any;
481
-
482
- await execute({ query: "test" });
483
- // Brave uses X-Subscription-Token header with the secure key
484
- expect(capturedHeaders!.get("x-subscription-token")).toBe("secure-key");
485
- });
486
418
  });
@@ -1,7 +1,7 @@
1
1
  import { getConfig } from "../../config/loader.js";
2
2
  import { RiskLevel } from "../../permissions/types.js";
3
3
  import type { ToolDefinition } from "../../providers/types.js";
4
- import { getSecureKey } from "../../security/secure-keys.js";
4
+ import { getSecureKeyAsync } from "../../security/secure-keys.js";
5
5
  import { getLogger } from "../../util/logger.js";
6
6
  import {
7
7
  DEFAULT_BASE_DELAY_MS,
@@ -50,21 +50,15 @@ function getWebSearchProvider(): WebSearchProvider {
50
50
  return configured as WebSearchProvider;
51
51
  }
52
52
 
53
- function getApiKey(provider: WebSearchProvider): string | undefined {
53
+ async function getApiKey(
54
+ provider: WebSearchProvider,
55
+ ): Promise<string | undefined> {
54
56
  if (provider === "brave") {
55
- if (process.env.BRAVE_API_KEY) return process.env.BRAVE_API_KEY;
56
- const secureKey = getSecureKey("brave");
57
- if (secureKey) return secureKey;
58
- const config = getConfig();
59
- return config.apiKeys.brave;
57
+ return (await getSecureKeyAsync("brave")) ?? undefined;
60
58
  }
61
59
 
62
60
  // Perplexity
63
- if (process.env.PERPLEXITY_API_KEY) return process.env.PERPLEXITY_API_KEY;
64
- const secureKey = getSecureKey("perplexity");
65
- if (secureKey) return secureKey;
66
- const config = getConfig();
67
- return config.apiKeys.perplexity;
61
+ return (await getSecureKeyAsync("perplexity")) ?? undefined;
68
62
  }
69
63
 
70
64
  function formatBraveResults(
@@ -321,13 +315,13 @@ class WebSearchTool implements Tool {
321
315
  }
322
316
 
323
317
  let provider = getWebSearchProvider();
324
- let apiKey = getApiKey(provider);
318
+ let apiKey = await getApiKey(provider);
325
319
 
326
320
  // Fallback: if the configured provider has no key, try the other provider
327
321
  if (!apiKey) {
328
322
  const fallback: WebSearchProvider =
329
323
  provider === "perplexity" ? "brave" : "perplexity";
330
- const fallbackKey = getApiKey(fallback);
324
+ const fallbackKey = await getApiKey(fallback);
331
325
  if (fallbackKey) {
332
326
  log.info(
333
327
  { from: provider, to: fallback },
@@ -338,7 +332,7 @@ class WebSearchTool implements Tool {
338
332
  } else {
339
333
  return {
340
334
  content:
341
- "Error: No web search API key configured. Set a PERPLEXITY_API_KEY or BRAVE_API_KEY environment variable, or configure it from the Settings page under API Keys.",
335
+ "Error: No web search API key configured. Set it via `keys set perplexity <key>` or `keys set brave <key>`, or configure it from the Settings page under API Keys.",
342
336
  isError: true,
343
337
  };
344
338
  }
@@ -328,7 +328,13 @@ export function getHooksDir(): string {
328
328
  // These will become the canonical paths after workspace migration.
329
329
  // Currently not used by call-sites; wired in later PRs.
330
330
 
331
- /** Returns ~/.vellum/workspace — the workspace root for user-facing state. */
331
+ /**
332
+ * Returns ~/.vellum/workspace — the workspace root for user-facing state.
333
+ *
334
+ * WARNING: The entire workspace directory is included in diagnostic log exports
335
+ * ("Send logs to Vellum"). Do not store secrets, API keys, or sensitive
336
+ * credentials here — use the credential store or ~/.vellum/protected/ instead.
337
+ */
332
338
  export function getWorkspaceDir(): string {
333
339
  return join(getRootDir(), "workspace");
334
340
  }
@@ -20,7 +20,6 @@ const PROVIDER_PRICING: Record<string, Record<string, ModelPricing>> = {
20
20
  anthropic: {
21
21
  "claude-opus-4-6": { inputPer1M: 5, outputPer1M: 25 },
22
22
  "claude-opus-4": { inputPer1M: 15, outputPer1M: 75 },
23
- "claude-opus-4-6-fast": { inputPer1M: 30, outputPer1M: 150 },
24
23
  "claude-sonnet-4": { inputPer1M: 3, outputPer1M: 15 },
25
24
  "claude-haiku-4": { inputPer1M: 0.8, outputPer1M: 4 },
26
25
  },