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.
- package/bun.lock +4 -4
- package/package.json +4 -3
- package/src/__tests__/asset-materialize-tool.test.ts +2 -2
- package/src/__tests__/checker.test.ts +104 -0
- package/src/__tests__/config-schema.test.ts +0 -6
- package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +538 -0
- package/src/__tests__/ingress-url-consistency.test.ts +214 -0
- package/src/__tests__/ipc-snapshot.test.ts +17 -5
- package/src/__tests__/oauth-callback-registry.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +304 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
- package/src/__tests__/public-ingress-urls.test.ts +222 -0
- package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
- package/src/__tests__/runtime-events-sse.test.ts +162 -0
- package/src/__tests__/tool-executor.test.ts +88 -0
- package/src/__tests__/turn-commit.test.ts +64 -0
- package/src/__tests__/twilio-provider.test.ts +1 -1
- package/src/__tests__/twilio-routes.test.ts +4 -4
- package/src/__tests__/twitter-auth-handler.test.ts +87 -2
- package/src/calls/call-domain.ts +8 -6
- package/src/calls/twilio-config.ts +18 -3
- package/src/calls/twilio-routes.ts +10 -2
- package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
- package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
- package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
- package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
- package/src/config/defaults.ts +4 -1
- package/src/config/schema.ts +30 -6
- package/src/config/system-prompt.ts +1 -1
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
- package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
- package/src/daemon/computer-use-session.ts +2 -1
- package/src/daemon/handlers/config.ts +49 -17
- package/src/daemon/handlers/sessions.ts +2 -2
- package/src/daemon/handlers/shared.ts +1 -0
- package/src/daemon/handlers/subagents.ts +85 -2
- package/src/daemon/handlers/twitter-auth.ts +31 -2
- package/src/daemon/handlers/work-items.ts +1 -1
- package/src/daemon/ipc-contract-inventory.json +8 -4
- package/src/daemon/ipc-contract.ts +34 -15
- package/src/daemon/lifecycle.ts +9 -4
- package/src/daemon/server.ts +7 -0
- package/src/daemon/session-tool-setup.ts +8 -1
- package/src/inbound/public-ingress-urls.ts +112 -0
- package/src/memory/attachments-store.ts +0 -1
- package/src/memory/channel-delivery-store.ts +0 -1
- package/src/memory/conversation-key-store.ts +0 -1
- package/src/memory/db.ts +472 -148
- package/src/memory/llm-usage-store.ts +0 -1
- package/src/memory/runs-store.ts +51 -6
- package/src/memory/schema.ts +2 -6
- package/src/runtime/gateway-client.ts +7 -1
- package/src/runtime/http-server.ts +174 -7
- package/src/runtime/routes/channel-routes.ts +7 -2
- package/src/runtime/routes/events-routes.ts +79 -0
- package/src/runtime/routes/run-routes.ts +43 -0
- package/src/runtime/run-orchestrator.ts +64 -7
- package/src/security/oauth-callback-registry.ts +66 -0
- package/src/security/oauth2.ts +208 -58
- package/src/subagent/manager.ts +3 -1
- package/src/swarm/backend-claude-code.ts +1 -1
- package/src/tools/assets/search.ts +1 -36
- package/src/tools/claude-code/claude-code.ts +3 -3
- package/src/tools/tasks/work-item-list.ts +16 -2
- package/src/tools/tasks/work-item-run.ts +78 -0
- package/src/util/platform.ts +1 -1
- package/src/work-items/work-item-runner.ts +171 -0
- package/src/workspace/provider-commit-message-generator.ts +39 -23
- package/src/workspace/turn-commit.ts +6 -2
- package/src/__tests__/handlers-twilio-config.test.ts +0 -221
- package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
- 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 === '
|
|
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 === '
|
|
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('
|
|
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
|
-
|
|
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 (
|
|
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' };
|
package/src/calls/call-domain.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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:
|
|
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:
|
|
109
|
+
from: twilioConfig.phoneNumber,
|
|
108
110
|
to: phoneNumber,
|
|
109
|
-
webhookUrl:
|
|
110
|
-
statusCallbackUrl:
|
|
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 {
|
|
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 =
|
|
22
|
-
|
|
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
|
|
129
|
-
|
|
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.
|