vellum 0.2.7 → 0.2.9

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 (76) hide show
  1. package/bun.lock +4 -4
  2. package/package.json +4 -3
  3. package/src/__tests__/asset-materialize-tool.test.ts +2 -2
  4. package/src/__tests__/checker.test.ts +104 -0
  5. package/src/__tests__/config-schema.test.ts +0 -6
  6. package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
  7. package/src/__tests__/gateway-only-enforcement.test.ts +538 -0
  8. package/src/__tests__/ingress-url-consistency.test.ts +214 -0
  9. package/src/__tests__/ipc-snapshot.test.ts +17 -5
  10. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  11. package/src/__tests__/oauth2-gateway-transport.test.ts +304 -0
  12. package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
  13. package/src/__tests__/public-ingress-urls.test.ts +222 -0
  14. package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
  15. package/src/__tests__/runtime-events-sse.test.ts +162 -0
  16. package/src/__tests__/tool-executor.test.ts +88 -0
  17. package/src/__tests__/turn-commit.test.ts +64 -0
  18. package/src/__tests__/twilio-provider.test.ts +1 -1
  19. package/src/__tests__/twilio-routes.test.ts +4 -4
  20. package/src/__tests__/twitter-auth-handler.test.ts +87 -2
  21. package/src/calls/call-domain.ts +8 -6
  22. package/src/calls/twilio-config.ts +18 -3
  23. package/src/calls/twilio-routes.ts +10 -2
  24. package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
  25. package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
  26. package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
  27. package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
  28. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
  29. package/src/config/defaults.ts +4 -1
  30. package/src/config/schema.ts +30 -6
  31. package/src/config/system-prompt.ts +1 -1
  32. package/src/config/types.ts +1 -0
  33. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
  34. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
  35. package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
  36. package/src/daemon/computer-use-session.ts +2 -1
  37. package/src/daemon/handlers/config.ts +49 -17
  38. package/src/daemon/handlers/sessions.ts +2 -2
  39. package/src/daemon/handlers/shared.ts +1 -0
  40. package/src/daemon/handlers/subagents.ts +85 -2
  41. package/src/daemon/handlers/twitter-auth.ts +31 -2
  42. package/src/daemon/handlers/work-items.ts +1 -1
  43. package/src/daemon/ipc-contract-inventory.json +8 -4
  44. package/src/daemon/ipc-contract.ts +34 -15
  45. package/src/daemon/lifecycle.ts +9 -4
  46. package/src/daemon/server.ts +7 -0
  47. package/src/daemon/session-tool-setup.ts +8 -1
  48. package/src/inbound/public-ingress-urls.ts +112 -0
  49. package/src/memory/attachments-store.ts +0 -1
  50. package/src/memory/channel-delivery-store.ts +0 -1
  51. package/src/memory/conversation-key-store.ts +0 -1
  52. package/src/memory/db.ts +472 -148
  53. package/src/memory/llm-usage-store.ts +0 -1
  54. package/src/memory/runs-store.ts +51 -6
  55. package/src/memory/schema.ts +2 -6
  56. package/src/runtime/gateway-client.ts +7 -1
  57. package/src/runtime/http-server.ts +174 -7
  58. package/src/runtime/routes/channel-routes.ts +7 -2
  59. package/src/runtime/routes/events-routes.ts +79 -0
  60. package/src/runtime/routes/run-routes.ts +43 -0
  61. package/src/runtime/run-orchestrator.ts +64 -7
  62. package/src/security/oauth-callback-registry.ts +66 -0
  63. package/src/security/oauth2.ts +208 -58
  64. package/src/subagent/manager.ts +3 -1
  65. package/src/swarm/backend-claude-code.ts +1 -1
  66. package/src/tools/assets/search.ts +1 -36
  67. package/src/tools/claude-code/claude-code.ts +3 -3
  68. package/src/tools/tasks/work-item-list.ts +16 -2
  69. package/src/tools/tasks/work-item-run.ts +78 -0
  70. package/src/util/platform.ts +1 -1
  71. package/src/work-items/work-item-runner.ts +171 -0
  72. package/src/workspace/provider-commit-message-generator.ts +39 -23
  73. package/src/workspace/turn-commit.ts +6 -2
  74. package/src/__tests__/handlers-twilio-config.test.ts +0 -221
  75. package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
  76. package/src/calls/twilio-webhook-urls.ts +0 -50
@@ -0,0 +1,162 @@
1
+ /**
2
+ * HTTP-layer integration tests for GET /v1/events (SSE assistant-events endpoint).
3
+ *
4
+ * Tests:
5
+ * - 401 unauthorized (missing bearer token)
6
+ * - 400 when conversationKey is absent
7
+ * - Happy path: stream receives a published AssistantEvent
8
+ */
9
+ import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
10
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'runtime-events-sse-test-')));
15
+
16
+ mock.module('../util/platform.js', () => ({
17
+ getRootDir: () => testDir,
18
+ getDataDir: () => testDir,
19
+ isMacOS: () => process.platform === 'darwin',
20
+ isLinux: () => process.platform === 'linux',
21
+ isWindows: () => process.platform === 'win32',
22
+ getSocketPath: () => join(testDir, 'test.sock'),
23
+ getPidPath: () => join(testDir, 'test.pid'),
24
+ getDbPath: () => join(testDir, 'test.db'),
25
+ getLogPath: () => join(testDir, 'test.log'),
26
+ ensureDataDir: () => {},
27
+ }));
28
+
29
+ mock.module('../util/logger.js', () => ({
30
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
31
+ get: () => () => {},
32
+ }),
33
+ }));
34
+
35
+ mock.module('../config/loader.js', () => ({
36
+ getConfig: () => ({
37
+ model: 'test',
38
+ provider: 'test',
39
+ apiKeys: {},
40
+ memory: { enabled: false },
41
+ rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
42
+ secretDetection: { enabled: false },
43
+ }),
44
+ }));
45
+
46
+ import { initializeDb, getDb, resetDb } from '../memory/db.js';
47
+ import { RuntimeHttpServer } from '../runtime/http-server.js';
48
+ import { assistantEventHub } from '../runtime/assistant-event-hub.js';
49
+ import { buildAssistantEvent } from '../runtime/assistant-event.js';
50
+ import { getOrCreateConversation } from '../memory/conversation-key-store.js';
51
+
52
+ initializeDb();
53
+
54
+ const TEST_TOKEN = 'test-bearer-token-events-sse';
55
+ const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` };
56
+
57
+ describe('SSE assistant-events endpoint', () => {
58
+ let server: RuntimeHttpServer;
59
+ let port: number;
60
+
61
+ beforeEach(() => {
62
+ const db = getDb();
63
+ db.run('DELETE FROM conversation_keys');
64
+ db.run('DELETE FROM conversations');
65
+ });
66
+
67
+ afterAll(() => {
68
+ resetDb();
69
+ try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
70
+ });
71
+
72
+ async function startServer(): Promise<void> {
73
+ port = 19500 + Math.floor(Math.random() * 500);
74
+ server = new RuntimeHttpServer({ port, bearerToken: TEST_TOKEN });
75
+ await server.start();
76
+ }
77
+
78
+ async function stopServer(): Promise<void> {
79
+ await server?.stop();
80
+ }
81
+
82
+ function eventsUrl(params = ''): string {
83
+ return `http://127.0.0.1:${port}/v1/events${params ? `?${params}` : ''}`;
84
+ }
85
+
86
+ // ── Auth ──────────────────────────────────────────────────────────────────
87
+
88
+ test('401 when bearer token is missing', async () => {
89
+ await startServer();
90
+
91
+ const res = await fetch(eventsUrl('conversationKey=test-noauth'));
92
+ expect(res.status).toBe(401);
93
+ // Consume body to prevent resource leak
94
+ await res.body?.cancel();
95
+
96
+ await stopServer();
97
+ });
98
+
99
+ test('401 when bearer token is wrong', async () => {
100
+ await startServer();
101
+
102
+ const res = await fetch(eventsUrl('conversationKey=test-badauth'), {
103
+ headers: { Authorization: 'Bearer wrong-token' },
104
+ });
105
+ expect(res.status).toBe(401);
106
+ await res.body?.cancel();
107
+
108
+ await stopServer();
109
+ });
110
+
111
+ // ── Validation ────────────────────────────────────────────────────────────
112
+
113
+ test('400 when conversationKey is missing', async () => {
114
+ await startServer();
115
+
116
+ const res = await fetch(eventsUrl(), { headers: AUTH_HEADERS });
117
+ expect(res.status).toBe(400);
118
+ const body = await res.json() as { error: string };
119
+ expect(body.error).toContain('conversationKey');
120
+
121
+ await stopServer();
122
+ });
123
+
124
+ // ── Happy path ────────────────────────────────────────────────────────────
125
+
126
+ test('stream receives a published assistant event', async () => {
127
+ // Test the handler directly (bypassing HTTP) to avoid chunked-transfer
128
+ // buffering in Bun's loopback SSE implementation. The HTTP auth and
129
+ // routing are already covered by other test files; here we focus on the
130
+ // SSE subscription logic and frame delivery.
131
+ const { conversationId } = getOrCreateConversation('sse-happy-path');
132
+
133
+ const ac = new AbortController();
134
+ const req = new Request(
135
+ 'http://localhost/v1/events?conversationKey=sse-happy-path',
136
+ { signal: ac.signal },
137
+ );
138
+
139
+ const { handleSubscribeAssistantEvents } = await import('../runtime/routes/events-routes.js');
140
+ const response = handleSubscribeAssistantEvents(req, new URL(req.url));
141
+
142
+ expect(response.status).toBe(200);
143
+ expect(response.headers.get('content-type')).toContain('text/event-stream');
144
+
145
+ // start() is called synchronously during ReadableStream construction, so the
146
+ // hub subscription is already registered before we publish.
147
+ const event = buildAssistantEvent('self', { type: 'pong' }, conversationId);
148
+ await assistantEventHub.publish(event);
149
+
150
+ // Read the first frame directly from the response body stream.
151
+ const reader = response.body!.getReader();
152
+ const { value, done } = await reader.read();
153
+ ac.abort();
154
+
155
+ expect(done).toBe(false);
156
+ const frame = new TextDecoder().decode(value);
157
+ expect(frame).toContain('event: assistant_event');
158
+ expect(frame).toContain(`"assistantId":"self"`);
159
+ expect(frame).toContain(`"sessionId":"${conversationId}"`);
160
+ expect(frame).toContain('"type":"pong"');
161
+ });
162
+ });
@@ -1965,3 +1965,91 @@ describe('buildSanitizedEnv — baseline: credential exclusion', () => {
1965
1965
  }
1966
1966
  });
1967
1967
  });
1968
+
1969
+ // ---------------------------------------------------------------------------
1970
+ // Persistent-allow lifecycle: roundtrip and auto-allow on subsequent invocation
1971
+ // ---------------------------------------------------------------------------
1972
+
1973
+ describe('ToolExecutor persistent-allow lifecycle', () => {
1974
+ beforeEach(() => {
1975
+ fakeToolResult = { content: 'ok', isError: false };
1976
+ lastCheckArgs = undefined;
1977
+ getToolOverride = undefined;
1978
+ checkResultOverride = undefined;
1979
+ checkFnOverride = undefined;
1980
+ if (addRuleSpy) { addRuleSpy.mockRestore(); addRuleSpy = undefined; }
1981
+ });
1982
+
1983
+ function setupAddRuleSpy() {
1984
+ addRuleSpy = spyOn(trustStore, 'addRule').mockImplementation(
1985
+ (tool: string, pattern: string, scope: string, decision = 'allow', priority = 100, options?: any) => {
1986
+ return { id: 'spy-rule-id', tool, pattern, scope, decision, priority, createdAt: Date.now(), ...options } as any;
1987
+ },
1988
+ );
1989
+ return addRuleSpy;
1990
+ }
1991
+
1992
+ test('persistent-allow roundtrip: always_allow saves rule and allows tool', async () => {
1993
+ // Simulate check() returning 'prompt' so the executor asks the user
1994
+ checkResultOverride = { decision: 'prompt', reason: 'Medium risk: requires approval' };
1995
+ const spy = setupAddRuleSpy();
1996
+
1997
+ // User responds with always_allow, selecting a pattern and scope
1998
+ const prompter = makePrompterWithDecision('always_allow', 'git *', '/tmp/project');
1999
+ const executor = new ToolExecutor(prompter);
2000
+ const result = await executor.execute('bash', { command: 'git status' }, makeContext());
2001
+
2002
+ // The tool should have been allowed to proceed
2003
+ expect(result.isError).toBe(false);
2004
+ expect(result.content).toBe('ok');
2005
+
2006
+ // addRule should have been called with the correct arguments
2007
+ expect(spy).toHaveBeenCalledTimes(1);
2008
+ const [tool, pattern, scope, decision] = spy.mock.calls[0];
2009
+ expect(tool).toBe('bash');
2010
+ expect(pattern).toBe('git *');
2011
+ expect(scope).toBe('/tmp/project');
2012
+ expect(decision).toBe('allow');
2013
+ });
2014
+
2015
+ test('auto-allow on subsequent invocation: matching rule skips prompt', async () => {
2016
+ // Simulate a previously saved rule by making check() return 'allow'
2017
+ // with a matched rule (as findHighestPriorityRule would).
2018
+ checkResultOverride = { decision: 'allow', reason: 'Matched trust rule: git *' };
2019
+
2020
+ let promptCalled = false;
2021
+ const trackingPrompter = {
2022
+ prompt: async () => { promptCalled = true; return { decision: 'allow' as const }; },
2023
+ resolveConfirmation: () => {},
2024
+ updateSender: () => {},
2025
+ dispose: () => {},
2026
+ } as unknown as PermissionPrompter;
2027
+
2028
+ const executor = new ToolExecutor(trackingPrompter);
2029
+ const result = await executor.execute('bash', { command: 'git status' }, makeContext());
2030
+
2031
+ // The tool should be auto-allowed
2032
+ expect(result.isError).toBe(false);
2033
+ expect(result.content).toBe('ok');
2034
+
2035
+ // The prompter should NOT have been called — the rule auto-allowed
2036
+ expect(promptCalled).toBe(false);
2037
+ });
2038
+
2039
+ test('always_allow with everywhere scope saves rule and allows tool', async () => {
2040
+ checkResultOverride = { decision: 'prompt', reason: 'Medium risk: requires approval' };
2041
+ const spy = setupAddRuleSpy();
2042
+
2043
+ const prompter = makePrompterWithDecision('always_allow', 'file_write:*', 'everywhere');
2044
+ const executor = new ToolExecutor(prompter);
2045
+ const result = await executor.execute('file_write', { path: '/tmp/test.txt', content: 'hello' }, makeContext());
2046
+
2047
+ expect(result.isError).toBe(false);
2048
+ expect(spy).toHaveBeenCalledTimes(1);
2049
+ const [tool, pattern, scope, decision] = spy.mock.calls[0];
2050
+ expect(tool).toBe('file_write');
2051
+ expect(pattern).toBe('file_write:*');
2052
+ expect(scope).toBe('everywhere');
2053
+ expect(decision).toBe('allow');
2054
+ });
2055
+ });
@@ -487,4 +487,68 @@ describe('LLM commit message integration', () => {
487
487
 
488
488
  expect(fullMessage).toContain('Turn:');
489
489
  });
490
+
491
+ test('changed files from preStatus are passed to generator', async () => {
492
+ let capturedContext: { changedFiles: string[] } | undefined;
493
+ let capturedOptions: { changedFiles: string[] } | undefined;
494
+
495
+ const llmResult: GenerateCommitMessageResult = {
496
+ message: 'feat: captured context test',
497
+ source: 'llm',
498
+ };
499
+
500
+ mock.module('../workspace/provider-commit-message-generator.js', () => ({
501
+ getCommitMessageGenerator: () => ({
502
+ generateCommitMessage: async (ctx: { changedFiles: string[] }, opts: { changedFiles: string[] }) => {
503
+ capturedContext = ctx;
504
+ capturedOptions = opts;
505
+ return llmResult;
506
+ },
507
+ }),
508
+ }));
509
+
510
+ const { commitTurnChanges: commit } = await import('../workspace/turn-commit.js');
511
+
512
+ const service = new WorkspaceGitService(testDir);
513
+ await service.ensureInitialized();
514
+
515
+ writeFileSync(join(testDir, 'alpha.ts'), 'export const a = 1;');
516
+ writeFileSync(join(testDir, 'beta.ts'), 'export const b = 2;');
517
+
518
+ await commit(testDir, 'sess_files', 1);
519
+
520
+ // The generator should have received the actual file list, not empty arrays
521
+ expect(capturedContext).toBeDefined();
522
+ expect(capturedContext!.changedFiles.length).toBeGreaterThan(0);
523
+ expect(capturedContext!.changedFiles).toContain('alpha.ts');
524
+ expect(capturedContext!.changedFiles).toContain('beta.ts');
525
+
526
+ expect(capturedOptions).toBeDefined();
527
+ expect(capturedOptions!.changedFiles.length).toBeGreaterThan(0);
528
+ expect(capturedOptions!.changedFiles).toContain('alpha.ts');
529
+ expect(capturedOptions!.changedFiles).toContain('beta.ts');
530
+ });
531
+
532
+ test('clean workspace skips LLM generator call', async () => {
533
+ let generatorCalled = false;
534
+
535
+ mock.module('../workspace/provider-commit-message-generator.js', () => ({
536
+ getCommitMessageGenerator: () => ({
537
+ generateCommitMessage: async () => {
538
+ generatorCalled = true;
539
+ return { message: 'should not be called', source: 'llm' as const };
540
+ },
541
+ }),
542
+ }));
543
+
544
+ const { commitTurnChanges: commit } = await import('../workspace/turn-commit.js');
545
+
546
+ const service = new WorkspaceGitService(testDir);
547
+ await service.ensureInitialized();
548
+
549
+ // No file changes — workspace is clean
550
+ await commit(testDir, 'sess_clean', 1);
551
+
552
+ expect(generatorCalled).toBe(false);
553
+ });
490
554
  });
@@ -33,7 +33,7 @@ let mockAuthToken: string | undefined = 'test-auth-token-secret';
33
33
 
34
34
  mock.module('../security/secure-keys.js', () => ({
35
35
  getSecureKey: (account: string) => {
36
- if (account === 'twilio_auth_token') return mockAuthToken;
36
+ if (account === 'credential:twilio:auth_token') return mockAuthToken;
37
37
  return undefined;
38
38
  },
39
39
  }));
@@ -52,7 +52,7 @@ let mockAuthToken: string | undefined = 'test-auth-token-for-webhooks';
52
52
 
53
53
  mock.module('../security/secure-keys.js', () => ({
54
54
  getSecureKey: (account: string) => {
55
- if (account === 'twilio_auth_token') return mockAuthToken;
55
+ if (account === 'credential:twilio:auth_token') return mockAuthToken;
56
56
  return undefined;
57
57
  },
58
58
  }));
@@ -72,7 +72,7 @@ mock.module('../calls/twilio-provider.js', () => {
72
72
  readonly name = 'twilio';
73
73
 
74
74
  static getAuthToken(): string | null {
75
- return getSecureKey('twilio_auth_token') ?? null;
75
+ return getSecureKey('credential:twilio:auth_token') ?? null;
76
76
  }
77
77
 
78
78
  static verifyWebhookSignature(
@@ -205,9 +205,9 @@ describe('twilio webhook routes', () => {
205
205
  });
206
206
 
207
207
  async function startServer(): Promise<void> {
208
- port = 20000 + Math.floor(Math.random() * 1000);
209
- server = new RuntimeHttpServer({ port, bearerToken: TEST_TOKEN });
208
+ server = new RuntimeHttpServer({ port: 0, bearerToken: TEST_TOKEN });
210
209
  await server.start();
210
+ port = server.actualPort;
211
211
  }
212
212
 
213
213
  async function stopServer(): Promise<void> {
@@ -8,10 +8,11 @@ const testDir = mkdtempSync(join(tmpdir(), 'handlers-twitter-auth-test-'));
8
8
 
9
9
  // Track loadRawConfig / saveRawConfig calls
10
10
  let rawConfigStore: Record<string, unknown> = {};
11
+ let mockIngressPublicBaseUrl: string | undefined = 'https://test.example.com';
11
12
 
12
13
  mock.module('../config/loader.js', () => ({
13
14
  getConfig: () => ({}),
14
- loadConfig: () => ({}),
15
+ loadConfig: () => ({ ingress: { publicBaseUrl: mockIngressPublicBaseUrl } }),
15
16
  loadRawConfig: () => ({ ...rawConfigStore }),
16
17
  saveRawConfig: (cfg: Record<string, unknown>) => {
17
18
  rawConfigStore = { ...cfg };
@@ -20,6 +21,19 @@ mock.module('../config/loader.js', () => ({
20
21
  invalidateConfigCache: () => {},
21
22
  }));
22
23
 
24
+ mock.module('../inbound/public-ingress-urls.js', () => ({
25
+ getPublicBaseUrl: (config: { ingress?: { publicBaseUrl?: string } }) => {
26
+ const url = config?.ingress?.publicBaseUrl;
27
+ if (url) return url;
28
+ throw new Error('No public base URL configured.');
29
+ },
30
+ getOAuthCallbackUrl: (config: { ingress?: { publicBaseUrl?: string } }) => {
31
+ const url = config?.ingress?.publicBaseUrl;
32
+ if (!url) throw new Error('No public base URL configured.');
33
+ return `${url}/webhooks/oauth/callback`;
34
+ },
35
+ }));
36
+
23
37
  mock.module('../util/platform.js', () => ({
24
38
  getRootDir: () => testDir,
25
39
  getDataDir: () => testDir,
@@ -77,9 +91,15 @@ mock.module('../security/secure-keys.js', () => ({
77
91
  // Mock OAuth2 flow
78
92
  let oauthFlowResult: unknown = null;
79
93
  let oauthFlowError: Error | null = null;
94
+ let lastOAuthFlowOptions: Record<string, unknown> | undefined;
80
95
 
81
96
  mock.module('../security/oauth2.js', () => ({
82
- startOAuth2Flow: async (_config: unknown, callbacks: { openUrl: (url: string) => void }) => {
97
+ startOAuth2Flow: async (
98
+ _config: unknown,
99
+ callbacks: { openUrl: (url: string) => void },
100
+ options?: Record<string, unknown>,
101
+ ) => {
102
+ lastOAuthFlowOptions = options;
83
103
  // Trigger the openUrl callback so tests can verify the open_url message is sent
84
104
  callbacks.openUrl('https://twitter.com/i/oauth2/authorize?test=1');
85
105
  if (oauthFlowError) throw oauthFlowError;
@@ -163,6 +183,8 @@ describe('Twitter auth handler', () => {
163
183
  oauthFlowResult = null;
164
184
  oauthFlowError = null;
165
185
  lastUpsertPolicy = undefined;
186
+ lastOAuthFlowOptions = undefined;
187
+ mockIngressPublicBaseUrl = 'https://test.example.com';
166
188
  // Mock fetch for Twitter API
167
189
  globalThis.fetch = (async (_url: string | URL | Request) => {
168
190
  return mockFetchResponse;
@@ -267,6 +289,69 @@ describe('Twitter auth handler', () => {
267
289
  expect(meta!.accountInfo).toBe('@testuser');
268
290
  });
269
291
 
292
+ test('passes callbackTransport: gateway to startOAuth2Flow', async () => {
293
+ rawConfigStore = { twitterIntegrationMode: 'local_byo' };
294
+ secureKeyStore['credential:integration:twitter:oauth_client_id'] = 'test-client-id';
295
+
296
+ oauthFlowResult = {
297
+ tokens: {
298
+ accessToken: 'mock-access-token',
299
+ refreshToken: 'mock-refresh-token',
300
+ expiresIn: 7200,
301
+ scope: 'tweet.read users.read offline.access',
302
+ tokenType: 'bearer',
303
+ },
304
+ grantedScopes: ['tweet.read', 'users.read', 'offline.access'],
305
+ rawTokenResponse: {},
306
+ };
307
+
308
+ const msg: TwitterAuthStartRequest = { type: 'twitter_auth_start' };
309
+ const { ctx, sent } = createTestContext();
310
+ await handleMessage(msg, {} as net.Socket, ctx);
311
+
312
+ await new Promise((r) => setTimeout(r, 50));
313
+
314
+ // Verify startOAuth2Flow was called with gateway transport
315
+ expect(lastOAuthFlowOptions).toBeDefined();
316
+ expect(lastOAuthFlowOptions!.callbackTransport).toBe('gateway');
317
+ });
318
+
319
+ test('fails fast with actionable error when no ingress URL is configured', async () => {
320
+ rawConfigStore = { twitterIntegrationMode: 'local_byo' };
321
+ secureKeyStore['credential:integration:twitter:oauth_client_id'] = 'test-client-id';
322
+ mockIngressPublicBaseUrl = undefined;
323
+
324
+ oauthFlowResult = {
325
+ tokens: { accessToken: 'should-not-reach', refreshToken: undefined },
326
+ grantedScopes: [],
327
+ rawTokenResponse: {},
328
+ };
329
+
330
+ const msg: TwitterAuthStartRequest = { type: 'twitter_auth_start' };
331
+ const { ctx, sent } = createTestContext();
332
+ await handleMessage(msg, {} as net.Socket, ctx);
333
+
334
+ await new Promise((r) => setTimeout(r, 50));
335
+
336
+ // Should NOT have sent open_url — the flow should fail before reaching OAuth
337
+ const openUrlMsg = sent.find((m) => m.type === 'open_url');
338
+ expect(openUrlMsg).toBeUndefined();
339
+
340
+ const result = sent.find((m) => m.type === 'twitter_auth_result') as {
341
+ type: string;
342
+ success: boolean;
343
+ error?: string;
344
+ };
345
+ expect(result).toBeDefined();
346
+ expect(result.success).toBe(false);
347
+ expect(result.error).toContain('ingress.publicBaseUrl');
348
+ expect(result.error).toContain('INGRESS_PUBLIC_BASE_URL');
349
+ expect(result.error).toContain('/webhooks/oauth/callback');
350
+
351
+ // startOAuth2Flow should not have been called
352
+ expect(lastOAuthFlowOptions).toBeUndefined();
353
+ });
354
+
270
355
  describe('auth hardening', () => {
271
356
  test('OAuth cancel path returns sanitized failure', async () => {
272
357
  rawConfigStore = { twitterIntegrationMode: 'local_byo' };
@@ -20,7 +20,8 @@ import { getCallOrchestrator, unregisterCallOrchestrator } from './call-state.js
20
20
  import { activeRelayConnections } from './relay-server.js';
21
21
  import { TwilioConversationRelayProvider } from './twilio-provider.js';
22
22
  import { getTwilioConfig } from './twilio-config.js';
23
- import { buildTwilioVoiceWebhookUrl, buildTwilioStatusCallbackUrl } from './twilio-webhook-urls.js';
23
+ import { getTwilioVoiceWebhookUrl, getTwilioStatusCallbackUrl } from '../inbound/public-ingress-urls.js';
24
+ import { loadConfig } from '../config/loader.js';
24
25
  import type { CallSession } from './types.js';
25
26
 
26
27
  const log = getLogger('call-domain');
@@ -89,13 +90,14 @@ export async function startCall(input: StartCallInput): Promise<StartCallResult
89
90
  let sessionId: string | null = null;
90
91
 
91
92
  try {
92
- const config = getTwilioConfig();
93
+ const twilioConfig = getTwilioConfig();
94
+ const ingressConfig = loadConfig();
93
95
  const provider = new TwilioConversationRelayProvider();
94
96
 
95
97
  const session = createCallSession({
96
98
  conversationId,
97
99
  provider: 'twilio',
98
- fromNumber: config.phoneNumber,
100
+ fromNumber: twilioConfig.phoneNumber,
99
101
  toNumber: phoneNumber,
100
102
  task: callContext ? `${task}\n\nContext: ${callContext}` : task,
101
103
  });
@@ -104,10 +106,10 @@ export async function startCall(input: StartCallInput): Promise<StartCallResult
104
106
  log.info({ callSessionId: session.id, to: phoneNumber, task }, 'Initiating outbound call');
105
107
 
106
108
  const { callSid } = await provider.initiateCall({
107
- from: config.phoneNumber,
109
+ from: twilioConfig.phoneNumber,
108
110
  to: phoneNumber,
109
- webhookUrl: buildTwilioVoiceWebhookUrl(config.webhookBaseUrl, session.id),
110
- statusCallbackUrl: buildTwilioStatusCallbackUrl(config.webhookBaseUrl),
111
+ webhookUrl: getTwilioVoiceWebhookUrl(ingressConfig, session.id),
112
+ statusCallbackUrl: getTwilioStatusCallbackUrl(ingressConfig),
111
113
  });
112
114
 
113
115
  updateCallSession(session.id, { providerCallSid: callSid });
@@ -1,7 +1,7 @@
1
1
  import { getSecureKey } from '../security/secure-keys.js';
2
2
  import { getLogger } from '../util/logger.js';
3
3
  import { loadConfig } from '../config/loader.js';
4
- import { getWebhookBaseUrl } from './twilio-webhook-urls.js';
4
+ import { getPublicBaseUrl, getTwilioRelayUrl } from '../inbound/public-ingress-urls.js';
5
5
 
6
6
  const log = getLogger('twilio-config');
7
7
 
@@ -18,8 +18,23 @@ export function getTwilioConfig(): TwilioConfig {
18
18
  const authToken = getSecureKey('credential:twilio:auth_token');
19
19
  const phoneNumber = process.env.TWILIO_PHONE_NUMBER || getSecureKey('credential:twilio:phone_number') || '';
20
20
  const config = loadConfig();
21
- const webhookBaseUrl = getWebhookBaseUrl(config);
22
- const wssBaseUrl = process.env.TWILIO_WSS_BASE_URL || '';
21
+ const webhookBaseUrl = getPublicBaseUrl(config);
22
+
23
+ // In gateway_only mode, ignore TWILIO_WSS_BASE_URL and always use the
24
+ // centralized relay URL derived from the public ingress base URL.
25
+ let wssBaseUrl: string;
26
+ if (config.ingress.mode === 'gateway_only') {
27
+ if (process.env.TWILIO_WSS_BASE_URL) {
28
+ log.warn('TWILIO_WSS_BASE_URL env var is ignored in gateway-only mode. Relay URL is derived from ingress.publicBaseUrl.');
29
+ }
30
+ try {
31
+ wssBaseUrl = getTwilioRelayUrl(config);
32
+ } catch {
33
+ wssBaseUrl = '';
34
+ }
35
+ } else {
36
+ wssBaseUrl = process.env.TWILIO_WSS_BASE_URL || '';
37
+ }
23
38
 
24
39
  if (!accountSid || !authToken) {
25
40
  throw new Error('Twilio credentials not configured. Set credential:twilio:account_sid and credential:twilio:auth_token via the credential_store tool.');
@@ -22,6 +22,8 @@ import type { CallStatus } from './types.js';
22
22
  import { logDeadLetterEvent } from './call-recovery.js';
23
23
  import { isTerminalState } from './call-state-machine.js';
24
24
  import { getTwilioConfig } from './twilio-config.js';
25
+ import { loadConfig } from '../config/loader.js';
26
+ import { getTwilioRelayUrl } from '../inbound/public-ingress-urls.js';
25
27
 
26
28
  const log = getLogger('twilio-routes');
27
29
 
@@ -125,8 +127,14 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
125
127
  log.info({ callSessionId, callSid }, 'Stored CallSid from voice webhook');
126
128
  }
127
129
 
128
- const config = getTwilioConfig();
129
- const relayUrl = resolveRelayUrl(config.wssBaseUrl, config.webhookBaseUrl);
130
+ const twilioConfig = getTwilioConfig();
131
+ let relayUrl: string;
132
+ try {
133
+ relayUrl = getTwilioRelayUrl(loadConfig());
134
+ } catch {
135
+ // Fallback to legacy resolution when ingress is not configured
136
+ relayUrl = resolveRelayUrl(twilioConfig.wssBaseUrl, twilioConfig.webhookBaseUrl);
137
+ }
130
138
  const welcomeGreeting = process.env.CALL_WELCOME_GREETING ?? 'Hello, how can I help you today?';
131
139
 
132
140
  const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting);
@@ -251,6 +251,31 @@
251
251
  },
252
252
  "executor": "tools/task-list-remove.ts",
253
253
  "execution_target": "host"
254
+ },
255
+ {
256
+ "name": "task_queue_run",
257
+ "description": "Run a task from the Task Queue in the background. Use this when the user says \"run this task\", \"execute this task\", \"start this task\", or wants to kick off a queued work item. The task runs asynchronously — the user can continue chatting while it executes. Required tool permissions are auto-approved since the user is explicitly requesting execution.",
258
+ "category": "tasks",
259
+ "risk": "medium",
260
+ "input_schema": {
261
+ "type": "object",
262
+ "properties": {
263
+ "work_item_id": {
264
+ "type": "string",
265
+ "description": "Direct work item ID (most precise selector)"
266
+ },
267
+ "task_name": {
268
+ "type": "string",
269
+ "description": "Task name/title to search for (case-insensitive substring match)"
270
+ },
271
+ "title": {
272
+ "type": "string",
273
+ "description": "Work item title to search for (case-insensitive substring match)"
274
+ }
275
+ }
276
+ },
277
+ "executor": "tools/task-queue-run.ts",
278
+ "execution_target": "host"
254
279
  }
255
280
  ]
256
281
  }
@@ -0,0 +1,9 @@
1
+ import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
2
+ import { executeTaskQueueRun } from '../../../../tools/tasks/work-item-run.js';
3
+
4
+ export async function run(
5
+ input: Record<string, unknown>,
6
+ context: ToolContext,
7
+ ): Promise<ToolExecutionResult> {
8
+ return executeTaskQueueRun(input, context);
9
+ }
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: "Transcribe"
3
+ description: "Transcribe audio and video files using Whisper (cloud API or local)"
4
+ metadata: {"vellum": {"emoji": "🎙️"}}
5
+ ---
6
+
7
+ Transcribe audio and video files using OpenAI's Whisper model — either via the cloud API or locally via whisper.cpp.
8
+
9
+ ## Choosing a Mode
10
+
11
+ Before transcribing, **ask the user which mode they prefer** if they haven't specified:
12
+
13
+ 1. **`api`** — Uses the OpenAI Whisper API. Fast, accurate, no setup needed. Requires an OpenAI API key (check if one is already configured). Audio is sent to OpenAI's servers. Costs ~$0.006/min.
14
+ 2. **`local`** — Uses whisper.cpp installed via Homebrew. Free, private, runs entirely on-device. Requires a one-time `brew install whisper-cpp`. Slightly slower but no data leaves the machine.
15
+
16
+ If the user says "cloud", "API", or "online" → use `api`.
17
+ If the user says "local", "offline", "private", or "on-device" → use `local`.
18
+
19
+ ## Usage Notes
20
+
21
+ - The tool accepts either a `file_path` (absolute path to a local file) or an `attachment_id` (for uploaded attachments). Prefer `file_path` when the user references a file on disk.
22
+ - Supported formats: any video (mp4, mov, etc.) or audio (mp3, wav, m4a, etc.) file.
23
+ - For video files, audio is automatically extracted via ffmpeg before transcription.
24
+ - The API mode has a 25MB per-request limit — large files are automatically split into chunks.
25
+ - Local mode requires whisper.cpp (`brew install whisper-cpp`). The model is downloaded automatically on first use.