vellum 0.2.12 → 0.2.14

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 (209) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +171 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +402 -5
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +271 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +28 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +96 -8
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +97 -0
  94. package/src/calls/elevenlabs-config.ts +31 -0
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +50 -6
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +114 -0
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +207 -19
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +26 -2
  116. package/src/config/schema.ts +178 -9
  117. package/src/config/types.ts +3 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/defaults.ts +11 -0
  160. package/src/permissions/prompter.ts +0 -4
  161. package/src/permissions/shell-identity.ts +227 -0
  162. package/src/permissions/trust-store.ts +76 -53
  163. package/src/permissions/types.ts +0 -19
  164. package/src/permissions/workspace-policy.ts +114 -0
  165. package/src/providers/retry.ts +12 -37
  166. package/src/runtime/assistant-event-hub.ts +41 -4
  167. package/src/runtime/channel-approval-parser.ts +60 -0
  168. package/src/runtime/channel-approval-types.ts +71 -0
  169. package/src/runtime/channel-approvals.ts +145 -0
  170. package/src/runtime/gateway-client.ts +16 -0
  171. package/src/runtime/http-server.ts +29 -9
  172. package/src/runtime/routes/call-routes.ts +52 -2
  173. package/src/runtime/routes/channel-routes.ts +296 -16
  174. package/src/runtime/routes/conversation-routes.ts +12 -5
  175. package/src/runtime/routes/events-routes.ts +97 -28
  176. package/src/runtime/routes/run-routes.ts +2 -7
  177. package/src/runtime/run-orchestrator.ts +0 -3
  178. package/src/schedule/recurrence-engine.ts +26 -2
  179. package/src/schedule/recurrence-types.ts +1 -1
  180. package/src/schedule/schedule-store.ts +12 -3
  181. package/src/security/secret-scanner.ts +7 -0
  182. package/src/tasks/ephemeral-permissions.ts +0 -2
  183. package/src/tasks/task-scheduler.ts +2 -1
  184. package/src/tools/calls/call-start.ts +8 -0
  185. package/src/tools/execution-target.ts +21 -0
  186. package/src/tools/execution-timeout.ts +49 -0
  187. package/src/tools/executor.ts +6 -135
  188. package/src/tools/network/web-search.ts +9 -32
  189. package/src/tools/policy-context.ts +29 -0
  190. package/src/tools/schedule/update.ts +8 -1
  191. package/src/tools/terminal/parser.ts +16 -18
  192. package/src/tools/types.ts +4 -11
  193. package/src/twitter/oauth-client.ts +102 -0
  194. package/src/twitter/router.ts +101 -0
  195. package/src/util/debounce.ts +88 -0
  196. package/src/util/network-info.ts +47 -0
  197. package/src/util/platform.ts +29 -4
  198. package/src/util/promise-guard.ts +37 -0
  199. package/src/util/retry.ts +98 -0
  200. package/src/util/truncate.ts +1 -1
  201. package/src/workspace/git-service.ts +129 -112
  202. package/src/tools/contacts/contact-merge.ts +0 -55
  203. package/src/tools/contacts/contact-search.ts +0 -58
  204. package/src/tools/contacts/contact-upsert.ts +0 -64
  205. package/src/tools/playbooks/index.ts +0 -4
  206. package/src/tools/playbooks/playbook-create.ts +0 -96
  207. package/src/tools/playbooks/playbook-delete.ts +0 -52
  208. package/src/tools/playbooks/playbook-list.ts +0 -74
  209. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -5,31 +5,12 @@ import { registerTool } from '../registry.js';
5
5
  import { getConfig } from '../../config/loader.js';
6
6
  import { getSecureKey } from '../../security/secure-keys.js';
7
7
  import { getLogger } from '../../util/logger.js';
8
+ import { getHttpRetryDelay, sleep, DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY_MS } from '../../util/retry.js';
8
9
 
9
10
  const log = getLogger('web-search');
10
11
 
11
12
  const BRAVE_API_URL = 'https://api.search.brave.com/res/v1/web/search';
12
13
  const PERPLEXITY_API_URL = 'https://api.perplexity.ai/chat/completions';
13
- const RATE_LIMIT_MAX_RETRIES = 3;
14
- const RATE_LIMIT_BASE_DELAY_MS = 1000;
15
-
16
- /**
17
- * Parse a Retry-After header value into milliseconds.
18
- * RFC 7231 allows either delta-seconds (e.g. "120") or an HTTP-date
19
- * (e.g. "Tue, 17 Feb 2026 12:00:00 GMT"). Returns undefined if unparseable.
20
- */
21
- function parseRetryAfterMs(value: string): number | undefined {
22
- const seconds = Number(value);
23
- if (!isNaN(seconds)) {
24
- return seconds * 1000;
25
- }
26
- // Try HTTP-date format — Date.parse handles RFC 2822 / IMF-fixdate
27
- const dateMs = Date.parse(value);
28
- if (!isNaN(dateMs)) {
29
- return Math.max(0, dateMs - Date.now());
30
- }
31
- return undefined;
32
- }
33
14
 
34
15
  type WebSearchProvider = 'perplexity' | 'brave';
35
16
 
@@ -148,7 +129,7 @@ async function executeBraveSearch(
148
129
 
149
130
  const url = `${BRAVE_API_URL}?${params.toString()}`;
150
131
 
151
- for (let attempt = 0; attempt <= RATE_LIMIT_MAX_RETRIES; attempt++) {
132
+ for (let attempt = 0; attempt <= DEFAULT_MAX_RETRIES; attempt++) {
152
133
  const response = await fetch(url, {
153
134
  headers: {
154
135
  'Accept': 'application/json',
@@ -169,12 +150,10 @@ async function executeBraveSearch(
169
150
  return { content: 'Error: Invalid or expired Brave Search API key', isError: true };
170
151
  }
171
152
 
172
- if (response.status === 429 && attempt < RATE_LIMIT_MAX_RETRIES) {
173
- const retryAfter = response.headers.get('retry-after');
174
- const parsed = retryAfter ? parseRetryAfterMs(retryAfter) : undefined;
175
- const delayMs = parsed ?? RATE_LIMIT_BASE_DELAY_MS * Math.pow(2, attempt);
153
+ if (response.status === 429 && attempt < DEFAULT_MAX_RETRIES) {
154
+ const delayMs = getHttpRetryDelay(response, attempt, DEFAULT_BASE_DELAY_MS);
176
155
  log.warn({ attempt: attempt + 1, delayMs }, 'Brave Search rate limited, retrying');
177
- await new Promise(resolve => setTimeout(resolve, delayMs));
156
+ await sleep(delayMs);
178
157
  continue;
179
158
  }
180
159
 
@@ -192,7 +171,7 @@ async function executePerplexitySearch(
192
171
  query: string,
193
172
  apiKey: string,
194
173
  ): Promise<ToolExecutionResult> {
195
- for (let attempt = 0; attempt <= RATE_LIMIT_MAX_RETRIES; attempt++) {
174
+ for (let attempt = 0; attempt <= DEFAULT_MAX_RETRIES; attempt++) {
196
175
  const response = await fetch(PERPLEXITY_API_URL, {
197
176
  method: 'POST',
198
177
  headers: {
@@ -218,12 +197,10 @@ async function executePerplexitySearch(
218
197
  return { content: 'Error: Invalid or expired Perplexity API key', isError: true };
219
198
  }
220
199
 
221
- if (response.status === 429 && attempt < RATE_LIMIT_MAX_RETRIES) {
222
- const retryAfter = response.headers.get('retry-after');
223
- const parsed = retryAfter ? parseRetryAfterMs(retryAfter) : undefined;
224
- const delayMs = parsed ?? RATE_LIMIT_BASE_DELAY_MS * Math.pow(2, attempt);
200
+ if (response.status === 429 && attempt < DEFAULT_MAX_RETRIES) {
201
+ const delayMs = getHttpRetryDelay(response, attempt, DEFAULT_BASE_DELAY_MS);
225
202
  log.warn({ attempt: attempt + 1, delayMs }, 'Perplexity rate limited, retrying');
226
- await new Promise(resolve => setTimeout(resolve, delayMs));
203
+ await sleep(delayMs);
227
204
  continue;
228
205
  }
229
206
 
@@ -0,0 +1,29 @@
1
+ import type { PolicyContext } from '../permissions/types.js';
2
+ import type { Tool, ToolContext } from './types.js';
3
+ import { getTaskRunRules } from '../tasks/ephemeral-permissions.js';
4
+
5
+ /**
6
+ * Build a PolicyContext from tool metadata and execution context.
7
+ * When executing within a task run, ephemeral permission rules are
8
+ * included so pre-approved tools are auto-allowed without prompting.
9
+ */
10
+ export function buildPolicyContext(tool: Tool, context?: ToolContext): PolicyContext | undefined {
11
+ const ephemeralRules = context?.taskRunId
12
+ ? getTaskRunRules(context.taskRunId)
13
+ : undefined;
14
+
15
+ if (tool.origin === 'skill') {
16
+ return {
17
+ executionTarget: tool.executionTarget,
18
+ ephemeralRules: ephemeralRules?.length ? ephemeralRules : undefined,
19
+ };
20
+ }
21
+
22
+ if (context?.taskRunId && ephemeralRules?.length) {
23
+ return {
24
+ ephemeralRules,
25
+ };
26
+ }
27
+
28
+ return undefined;
29
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ToolContext, ToolExecutionResult } from '../types.js';
2
2
  import { updateSchedule, formatLocalDate, describeCronExpression } from '../../schedule/schedule-store.js';
3
- import { normalizeScheduleSyntax, type ScheduleSyntax } from '../../schedule/recurrence-types.js';
3
+ import { normalizeScheduleSyntax, detectScheduleSyntax, type ScheduleSyntax } from '../../schedule/recurrence-types.js';
4
4
  import { validateRruleSetLines } from '../../schedule/recurrence-engine.js';
5
5
 
6
6
  export async function executeScheduleUpdate(
@@ -31,9 +31,16 @@ export async function executeScheduleUpdate(
31
31
  updates.expression = resolved.expression;
32
32
  } else if (input.expression !== undefined) {
33
33
  updates.expression = input.expression;
34
+ const detected = detectScheduleSyntax(input.expression as string);
35
+ if (detected) updates.syntax = detected;
34
36
  } else if (input.cron_expression !== undefined) {
35
37
  updates.cronExpression = input.cron_expression;
36
38
  }
39
+ // When only syntax is provided (no expression), normalizeScheduleSyntax returns null
40
+ // but we still need to persist the explicit syntax value.
41
+ if (input.syntax !== undefined && updates.syntax === undefined) {
42
+ updates.syntax = input.syntax;
43
+ }
37
44
  }
38
45
 
39
46
  if (Object.keys(updates).length === 0) {
@@ -3,6 +3,7 @@ import { readFileSync, existsSync } from 'node:fs';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { getLogger } from '../../util/logger.js';
5
5
  import { IntegrityError } from '../../util/errors.js';
6
+ import { PromiseGuard } from '../../util/promise-guard.js';
6
7
  import { Parser, Language, type Node as TSNode } from 'web-tree-sitter';
7
8
 
8
9
  const log = getLogger('shell-parser');
@@ -73,7 +74,7 @@ function verifyWasmChecksum(filePath: string, label: string): void {
73
74
  }
74
75
 
75
76
  let parserInstance: Parser | null = null;
76
- let initPromise: Promise<void> | null = null;
77
+ const initGuard = new PromiseGuard<void>();
77
78
 
78
79
  /**
79
80
  * Locate a WASM file from a dependency package.
@@ -105,27 +106,24 @@ function findWasmPath(pkg: string, file: string): string {
105
106
  async function ensureParser(): Promise<Parser> {
106
107
  if (parserInstance) return parserInstance;
107
108
 
108
- if (!initPromise) {
109
- initPromise = (async () => {
110
- const treeSitterWasm = findWasmPath('web-tree-sitter', 'web-tree-sitter.wasm');
111
- const bashWasmPath = findWasmPath('tree-sitter-bash', 'tree-sitter-bash.wasm');
109
+ await initGuard.run(async () => {
110
+ const treeSitterWasm = findWasmPath('web-tree-sitter', 'web-tree-sitter.wasm');
111
+ const bashWasmPath = findWasmPath('tree-sitter-bash', 'tree-sitter-bash.wasm');
112
112
 
113
- verifyWasmChecksum(treeSitterWasm, 'web-tree-sitter.wasm');
114
- verifyWasmChecksum(bashWasmPath, 'tree-sitter-bash.wasm');
113
+ verifyWasmChecksum(treeSitterWasm, 'web-tree-sitter.wasm');
114
+ verifyWasmChecksum(bashWasmPath, 'tree-sitter-bash.wasm');
115
115
 
116
- await Parser.init({
117
- locateFile: () => treeSitterWasm,
118
- });
116
+ await Parser.init({
117
+ locateFile: () => treeSitterWasm,
118
+ });
119
119
 
120
- const Bash = await Language.load(bashWasmPath);
121
- const parser = new Parser();
122
- parser.setLanguage(Bash);
123
- parserInstance = parser;
124
- log.info('Shell parser initialized (web-tree-sitter + bash, checksums verified)');
125
- })();
126
- }
120
+ const Bash = await Language.load(bashWasmPath);
121
+ const parser = new Parser();
122
+ parser.setLanguage(Bash);
123
+ parserInstance = parser;
124
+ log.info('Shell parser initialized (web-tree-sitter + bash, checksums verified)');
125
+ });
127
126
 
128
- await initPromise;
129
127
  return parserInstance!;
130
128
  }
131
129
 
@@ -1,4 +1,4 @@
1
- import type { RiskLevel, AllowlistOption, ScopeOption, ToolPrincipalKind } from '../permissions/types.js';
1
+ import type { RiskLevel, AllowlistOption, ScopeOption } from '../permissions/types.js';
2
2
  import type { ToolDefinition, ContentBlock } from '../providers/types.js';
3
3
  import type { SecretPromptResult } from '../permissions/secret-prompter.js';
4
4
 
@@ -12,12 +12,6 @@ interface ToolLifecycleEventBase {
12
12
  conversationId: string;
13
13
  requestId?: string;
14
14
  executionTarget?: ExecutionTarget;
15
- /** Security principal kind (e.g. 'core', 'skill', or 'task'). */
16
- principalKind?: ToolPrincipalKind;
17
- /** Security principal ID (skill ID when principalKind is 'skill'; task ID when 'task'). */
18
- principalId?: string;
19
- /** Content-hash of the principal's source at invocation time. */
20
- principalVersion?: string;
21
15
  }
22
16
 
23
17
  export interface ToolExecutionStartEvent extends ToolLifecycleEventBase {
@@ -109,16 +103,13 @@ export interface ToolContext {
109
103
  proxyToolResolver?: ProxyToolResolver;
110
104
  /** When set, only tools in this set may execute. Tools outside the set are blocked with an error. */
111
105
  allowedToolNames?: Set<string>;
112
- /** Principal that owns this tool invocation (set by executor from tool metadata). */
113
- principal?: { kind?: string; id?: string; version?: string };
114
106
  /** Request user confirmation for a sub-tool operation (used by claude_code tool). */
115
107
  requestConfirmation?: (req: {
116
108
  toolName: string;
117
109
  input: Record<string, unknown>;
118
110
  riskLevel: string;
119
111
  executionTarget?: ExecutionTarget;
120
- /** Principal that owns the parent tool invocation. */
121
- principal?: { kind?: string; id?: string; version?: string };
112
+ principal?: string;
122
113
  }) => Promise<{ decision: 'allow' | 'deny' }>;
123
114
  /** Prompt the user for a secret value via native SecureField UI. */
124
115
  requestSecret?: (params: {
@@ -141,6 +132,8 @@ export interface ToolContext {
141
132
  forcePromptSideEffects?: boolean;
142
133
  /** Approval callback for proxy policy decisions that require user confirmation. */
143
134
  proxyApprovalCallback?: import('./network/script-proxy/types.js').ProxyApprovalCallback;
135
+ /** Optional principal identifier propagated to sub-tool confirmation flows. */
136
+ principal?: string;
144
137
  }
145
138
 
146
139
  export interface DiffInfo {
@@ -0,0 +1,102 @@
1
+ /**
2
+ * OAuth-backed Twitter API client.
3
+ *
4
+ * Uses stored OAuth2 Bearer tokens (via the token manager) to execute
5
+ * Twitter API v2 operations directly, without requiring a browser session.
6
+ * Currently supports post and reply; all other operations fall back to the
7
+ * browser-based CDP client.
8
+ */
9
+
10
+ import { withValidToken } from '../security/token-manager.js';
11
+ import { getSecureKey } from '../security/secure-keys.js';
12
+
13
+ const TWITTER_API_BASE = 'https://api.x.com/2';
14
+ const SERVICE = 'integration:twitter';
15
+
16
+ /** Operations that the OAuth client can handle natively. */
17
+ const SUPPORTED_OPERATIONS = new Set(['post', 'reply']);
18
+
19
+ export interface OAuthPostResult {
20
+ tweetId: string;
21
+ text: string;
22
+ url?: string;
23
+ }
24
+
25
+ export interface OAuthOperationError {
26
+ message: string;
27
+ suggestFallback: boolean;
28
+ fallbackPath: 'browser';
29
+ operation: string;
30
+ }
31
+
32
+ export class UnsupportedOAuthOperationError extends Error {
33
+ public readonly suggestFallback = true;
34
+ public readonly fallbackPath = 'browser' as const;
35
+ public readonly operation: string;
36
+ constructor(operation: string) {
37
+ super(`The "${operation}" operation is not available via the OAuth API. Use the browser path instead.`);
38
+ this.name = 'UnsupportedOAuthOperationError';
39
+ this.operation = operation;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Post a tweet (or reply) using OAuth2 Bearer token authentication.
45
+ *
46
+ * The token manager handles refresh transparently — if the stored token
47
+ * is expired it will be refreshed before (or after a 401) calling the API.
48
+ */
49
+ export async function oauthPostTweet(
50
+ text: string,
51
+ opts?: { inReplyToTweetId?: string },
52
+ ): Promise<OAuthPostResult> {
53
+ return withValidToken(SERVICE, async (token) => {
54
+ const body: Record<string, unknown> = { text };
55
+ if (opts?.inReplyToTweetId) {
56
+ body.reply = { in_reply_to_tweet_id: opts.inReplyToTweetId };
57
+ }
58
+
59
+ const res = await fetch(`${TWITTER_API_BASE}/tweets`, {
60
+ method: 'POST',
61
+ headers: {
62
+ Authorization: `Bearer ${token}`,
63
+ 'Content-Type': 'application/json',
64
+ },
65
+ body: JSON.stringify(body),
66
+ });
67
+
68
+ if (!res.ok) {
69
+ const errorBody = await res.text().catch(() => '');
70
+ const err = new Error(
71
+ `Twitter API error (${res.status}): ${errorBody.slice(0, 500)}`,
72
+ );
73
+ // Attach status so the token manager's 401-retry logic can detect it.
74
+ (err as Error & { status: number }).status = res.status;
75
+ throw err;
76
+ }
77
+
78
+ const json = (await res.json()) as { data: { id: string; text: string } };
79
+ return {
80
+ tweetId: json.data.id,
81
+ text: json.data.text,
82
+ };
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Check whether OAuth credentials are available for the Twitter integration.
88
+ * Returns true if an access token has been stored (the token manager will
89
+ * handle refresh if it's expired).
90
+ */
91
+ export function oauthIsAvailable(): boolean {
92
+ return getSecureKey('credential:integration:twitter:access_token') !== undefined;
93
+ }
94
+
95
+ /**
96
+ * Check whether a given operation is supported via the OAuth API path.
97
+ * Only `post` and `reply` are currently supported; everything else
98
+ * (timeline, search, bookmarks, etc.) requires the browser path.
99
+ */
100
+ export function oauthSupportsOperation(operation: string): boolean {
101
+ return SUPPORTED_OPERATIONS.has(operation);
102
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Strategy router for Twitter operations.
3
+ * Selects OAuth or browser path based on persisted preference and capability.
4
+ */
5
+
6
+ import { loadRawConfig } from '../config/loader.js';
7
+ import { oauthPostTweet, oauthIsAvailable, oauthSupportsOperation } from './oauth-client.js';
8
+ import { postTweet as browserPostTweet, SessionExpiredError } from './client.js';
9
+ import type { PostTweetResult } from './client.js';
10
+
11
+ export type TwitterStrategy = 'oauth' | 'browser' | 'auto';
12
+
13
+ export interface RoutedResult<T> {
14
+ result: T;
15
+ pathUsed: TwitterStrategy;
16
+ }
17
+
18
+ export interface RoutedError {
19
+ message: string;
20
+ pathUsed: TwitterStrategy;
21
+ suggestAlternative?: TwitterStrategy;
22
+ alternativeSetupHint?: string;
23
+ }
24
+
25
+ function getPreferredStrategy(): TwitterStrategy {
26
+ try {
27
+ const raw = loadRawConfig();
28
+ const strategy = raw.twitterOperationStrategy as string | undefined;
29
+ if (strategy === 'oauth' || strategy === 'browser') return strategy;
30
+ } catch { /* fall through */ }
31
+ return 'auto';
32
+ }
33
+
34
+ export async function routedPostTweet(
35
+ text: string,
36
+ opts?: { inReplyToTweetId?: string },
37
+ ): Promise<RoutedResult<PostTweetResult>> {
38
+ const strategy = getPreferredStrategy();
39
+ const operation = opts?.inReplyToTweetId ? 'reply' : 'post';
40
+
41
+ if (strategy === 'oauth') {
42
+ // User explicitly wants OAuth
43
+ if (!oauthIsAvailable()) {
44
+ throw Object.assign(new Error('OAuth is not configured. Set up OAuth credentials in Settings, or switch to browser strategy: `vellum x strategy set browser`.'), {
45
+ pathUsed: 'oauth' as const,
46
+ suggestAlternative: 'browser' as const,
47
+ });
48
+ }
49
+ const result = await oauthPostTweet(text, opts);
50
+ return { result: { tweetId: result.tweetId, text: result.text, url: result.url ?? `https://x.com/i/status/${result.tweetId}` }, pathUsed: 'oauth' };
51
+ }
52
+
53
+ if (strategy === 'browser') {
54
+ // User explicitly wants browser
55
+ try {
56
+ const result = await browserPostTweet(text, opts);
57
+ return { result, pathUsed: 'browser' };
58
+ } catch (err) {
59
+ if (err instanceof SessionExpiredError) {
60
+ throw Object.assign(err, {
61
+ pathUsed: 'browser' as const,
62
+ suggestAlternative: 'oauth' as const,
63
+ });
64
+ }
65
+ throw err;
66
+ }
67
+ }
68
+
69
+ // auto strategy: try OAuth first if available and supported, fallback to browser
70
+ let oauthError: Error | undefined;
71
+ if (oauthIsAvailable() && oauthSupportsOperation(operation)) {
72
+ try {
73
+ const result = await oauthPostTweet(text, opts);
74
+ return { result: { tweetId: result.tweetId, text: result.text, url: result.url ?? `https://x.com/i/status/${result.tweetId}` }, pathUsed: 'oauth' };
75
+ } catch (err) {
76
+ oauthError = err instanceof Error ? err : new Error(String(err));
77
+ // Fall through to browser
78
+ }
79
+ }
80
+
81
+ // Fallback to browser
82
+ try {
83
+ const result = await browserPostTweet(text, opts);
84
+ return { result, pathUsed: 'browser' };
85
+ } catch (err) {
86
+ if (err instanceof SessionExpiredError) {
87
+ throw Object.assign(err, {
88
+ pathUsed: 'auto' as const,
89
+ oauthError: oauthError?.message,
90
+ });
91
+ }
92
+ if (oauthError) {
93
+ const browserError = err instanceof Error ? err : new Error(String(err));
94
+ throw Object.assign(browserError, {
95
+ pathUsed: 'auto' as const,
96
+ oauthError: oauthError.message,
97
+ });
98
+ }
99
+ throw err;
100
+ }
101
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Single-key debouncer. Delays execution until no new calls arrive
3
+ * within the specified delay period.
4
+ */
5
+ export class Debouncer {
6
+ private timer: ReturnType<typeof setTimeout> | null = null;
7
+
8
+ constructor(private readonly delayMs: number) {}
9
+
10
+ schedule(fn: () => void): void {
11
+ if (this.timer) clearTimeout(this.timer);
12
+ this.timer = setTimeout(() => {
13
+ this.timer = null;
14
+ fn();
15
+ }, this.delayMs);
16
+ }
17
+
18
+ cancel(): void {
19
+ if (this.timer) {
20
+ clearTimeout(this.timer);
21
+ this.timer = null;
22
+ }
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Multi-key debouncer. Each key gets its own independent timer.
28
+ * Includes an optional entry limit with eviction of oldest non-protected entries.
29
+ */
30
+ export class DebouncerMap {
31
+ private timers = new Map<string, ReturnType<typeof setTimeout>>();
32
+ private readonly defaultDelayMs: number;
33
+ private readonly maxEntries: number;
34
+ private readonly protectedKeyPrefix: string;
35
+
36
+ constructor(options: {
37
+ defaultDelayMs: number;
38
+ maxEntries?: number;
39
+ protectedKeyPrefix?: string;
40
+ }) {
41
+ this.defaultDelayMs = options.defaultDelayMs;
42
+ this.maxEntries = options.maxEntries ?? Infinity;
43
+ this.protectedKeyPrefix = options.protectedKeyPrefix ?? '';
44
+ }
45
+
46
+ schedule(key: string, fn: () => void, delayMs?: number): void {
47
+ const existing = this.timers.get(key);
48
+ if (existing) clearTimeout(existing);
49
+ const timer = setTimeout(() => {
50
+ this.timers.delete(key);
51
+ fn();
52
+ }, delayMs ?? this.defaultDelayMs);
53
+ this.timers.set(key, timer);
54
+ this.enforceLimit();
55
+ }
56
+
57
+ cancel(key: string): void {
58
+ const timer = this.timers.get(key);
59
+ if (timer) {
60
+ clearTimeout(timer);
61
+ this.timers.delete(key);
62
+ }
63
+ }
64
+
65
+ cancelAll(): void {
66
+ for (const timer of this.timers.values()) {
67
+ clearTimeout(timer);
68
+ }
69
+ this.timers.clear();
70
+ }
71
+
72
+ get size(): number {
73
+ return this.timers.size;
74
+ }
75
+
76
+ private enforceLimit(): void {
77
+ if (this.timers.size <= this.maxEntries) return;
78
+ const excess = this.timers.size - this.maxEntries;
79
+ let removed = 0;
80
+ for (const [key, timer] of this.timers) {
81
+ if (removed >= excess) break;
82
+ if (this.protectedKeyPrefix && key.startsWith(this.protectedKeyPrefix)) continue;
83
+ clearTimeout(timer);
84
+ this.timers.delete(key);
85
+ removed++;
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,47 @@
1
+ import { networkInterfaces } from 'node:os';
2
+
3
+ /**
4
+ * Returns the local IPv4 address most likely to be reachable from other
5
+ * devices on the same LAN.
6
+ *
7
+ * Priority order:
8
+ * 1. en0 (Wi-Fi on macOS)
9
+ * 2. en1 (secondary network on macOS)
10
+ * 3. First non-loopback IPv4 on any interface
11
+ *
12
+ * Skips link-local addresses (169.254.x.x) and IPv6.
13
+ * Returns null if no suitable address is found (e.g. no network).
14
+ */
15
+ export function getLocalIPv4(): string | null {
16
+ const ifaces = networkInterfaces();
17
+
18
+ // Priority interfaces in order
19
+ const priorityInterfaces = ['en0', 'en1'];
20
+
21
+ for (const ifName of priorityInterfaces) {
22
+ const addrs = ifaces[ifName];
23
+ if (!addrs) continue;
24
+ for (const addr of addrs) {
25
+ if (addr.family === 'IPv4' && !addr.internal && !isLinkLocal(addr.address)) {
26
+ return addr.address;
27
+ }
28
+ }
29
+ }
30
+
31
+ // Fallback: first non-loopback, non-link-local IPv4 on any interface
32
+ for (const [, addrs] of Object.entries(ifaces)) {
33
+ if (!addrs) continue;
34
+ for (const addr of addrs) {
35
+ if (addr.family === 'IPv4' && !addr.internal && !isLinkLocal(addr.address)) {
36
+ return addr.address;
37
+ }
38
+ }
39
+ }
40
+
41
+ return null;
42
+ }
43
+
44
+ /** Returns true for IPv4 link-local addresses (169.254.x.x). */
45
+ function isLinkLocal(address: string): boolean {
46
+ return address.startsWith('169.254.');
47
+ }
@@ -135,12 +135,37 @@ export function isTCPEnabled(): boolean {
135
135
 
136
136
  /**
137
137
  * Returns the hostname/address for the TCP listener.
138
- * Reads VELLUM_DAEMON_TCP_HOST env var; defaults to '127.0.0.1' (localhost only).
139
- * Set VELLUM_DAEMON_TCP_HOST=0.0.0.0 to accept connections from all interfaces
140
- * (required for real iOS device connections over a local network).
138
+ * Resolution order (first match wins):
139
+ * 1. VELLUM_DAEMON_TCP_HOST env var (explicit override)
140
+ * 2. If iOS pairing is enabled: '0.0.0.0' (LAN-accessible)
141
+ * 3. Default: '127.0.0.1' (localhost only)
141
142
  */
142
143
  export function getTCPHost(): string {
143
- return process.env.VELLUM_DAEMON_TCP_HOST?.trim() || '127.0.0.1';
144
+ const override = process.env.VELLUM_DAEMON_TCP_HOST?.trim();
145
+ if (override) return override;
146
+ if (isIOSPairingEnabled()) return '0.0.0.0';
147
+ return '127.0.0.1';
148
+ }
149
+
150
+ /**
151
+ * Returns whether iOS pairing mode is enabled.
152
+ * When enabled, the TCP listener binds to 0.0.0.0 (all interfaces)
153
+ * instead of 127.0.0.1 (localhost only), making the daemon reachable
154
+ * from iOS devices on the same local network.
155
+ *
156
+ * Resolution order (first match wins):
157
+ * 1. VELLUM_DAEMON_IOS_PAIRING env var ('true'/'1' → on, 'false'/'0' → off)
158
+ * 2. Presence of the flag file ~/.vellum/ios-pairing-enabled (exists → on)
159
+ * 3. Default: false
160
+ *
161
+ * This is separate from isTCPEnabled() — TCP can be enabled for localhost-only
162
+ * access without exposing the daemon to the LAN.
163
+ */
164
+ export function isIOSPairingEnabled(): boolean {
165
+ const override = process.env.VELLUM_DAEMON_IOS_PAIRING?.trim();
166
+ if (override === 'true' || override === '1') return true;
167
+ if (override === 'false' || override === '0') return false;
168
+ return existsSync(join(getRootDir(), 'ios-pairing-enabled'));
144
169
  }
145
170
 
146
171
  export function getHttpTokenPath(): string {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Guards against concurrent execution of an async factory.
3
+ * Multiple concurrent callers share the same in-flight promise.
4
+ * On failure, the guard resets so subsequent calls can retry.
5
+ */
6
+ export class PromiseGuard<T> {
7
+ private promise: Promise<T> | null = null;
8
+
9
+ /** Whether a promise is currently in-flight. */
10
+ get active(): boolean {
11
+ return this.promise !== null;
12
+ }
13
+
14
+ /**
15
+ * Execute the factory, deduplicating concurrent calls.
16
+ * If a call is already in-flight, returns the same promise.
17
+ * On failure, clears the cached promise to allow retry.
18
+ *
19
+ * @param factory - Creates the promise on first call.
20
+ * @param onError - Optional callback invoked when the factory rejects (before re-throwing).
21
+ */
22
+ run(factory: () => Promise<T>, onError?: (err: unknown) => void): Promise<T> {
23
+ if (this.promise) return this.promise;
24
+
25
+ this.promise = factory();
26
+ this.promise.catch((err) => {
27
+ this.promise = null;
28
+ onError?.(err);
29
+ });
30
+ return this.promise;
31
+ }
32
+
33
+ /** Reset the guard, allowing the next call to create a new promise. */
34
+ reset(): void {
35
+ this.promise = null;
36
+ }
37
+ }