@vellumai/assistant 0.3.2 → 0.3.3
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/README.md +82 -13
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +16 -0
- package/src/__tests__/app-git-history.test.ts +22 -27
- package/src/__tests__/app-git-service.test.ts +44 -78
- package/src/__tests__/channel-approval-routes.test.ts +930 -14
- package/src/__tests__/channel-approval.test.ts +2 -0
- package/src/__tests__/channel-delivery-store.test.ts +104 -1
- package/src/__tests__/channel-guardian.test.ts +184 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +5 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +87 -8
- package/src/__tests__/handlers-telegram-config.test.ts +82 -0
- package/src/__tests__/handlers-twilio-config.test.ts +665 -5
- package/src/__tests__/ingress-url-consistency.test.ts +64 -0
- package/src/__tests__/ipc-snapshot.test.ts +10 -0
- package/src/__tests__/run-orchestrator.test.ts +1 -1
- package/src/__tests__/session-process-bridge.test.ts +2 -0
- package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
- package/src/calls/twilio-config.ts +10 -1
- package/src/calls/twilio-rest.ts +70 -0
- package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
- package/src/config/bundled-skills/subagent/SKILL.md +4 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
- package/src/config/schema.ts +3 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +11 -4
- package/src/daemon/handlers/config.ts +168 -15
- package/src/daemon/handlers/sessions.ts +5 -3
- package/src/daemon/handlers/skills.ts +61 -17
- package/src/daemon/ipc-contract-inventory.json +4 -0
- package/src/daemon/ipc-contract.ts +10 -0
- package/src/daemon/session-agent-loop.ts +4 -0
- package/src/daemon/session-process.ts +20 -3
- package/src/daemon/session-slash.ts +50 -2
- package/src/daemon/session-surfaces.ts +17 -1
- package/src/inbound/public-ingress-urls.ts +20 -3
- package/src/index.ts +1 -23
- package/src/memory/app-git-service.ts +24 -0
- package/src/memory/app-store.ts +0 -21
- package/src/memory/channel-delivery-store.ts +74 -3
- package/src/memory/channel-guardian-store.ts +54 -26
- package/src/memory/conversation-key-store.ts +20 -0
- package/src/memory/conversation-store.ts +14 -2
- package/src/memory/db.ts +12 -0
- package/src/memory/schema.ts +5 -0
- package/src/runtime/http-server.ts +13 -5
- package/src/runtime/routes/channel-routes.ts +134 -43
- package/src/skills/clawhub.ts +6 -2
- package/src/subagent/manager.ts +4 -1
- package/src/subagent/types.ts +2 -0
- package/src/tools/skills/vellum-catalog.ts +45 -2
- package/src/tools/subagent/spawn.ts +2 -0
|
@@ -211,4 +211,68 @@ describe('Ingress URL consistency between assistant and gateway', () => {
|
|
|
211
211
|
|
|
212
212
|
expect(gatewayCanonical).toBe(callbackUrl);
|
|
213
213
|
});
|
|
214
|
+
|
|
215
|
+
// ── SMS-specific URL consistency ──────────────────────────────────
|
|
216
|
+
|
|
217
|
+
test('SMS webhook URL consistency: gateway signature URL matches configured ingress', () => {
|
|
218
|
+
const publicBase = 'https://sms-gateway.example.com';
|
|
219
|
+
const authToken = 'test-sms-auth-token';
|
|
220
|
+
|
|
221
|
+
// The gateway registers /webhooks/twilio/sms with Twilio. Twilio signs
|
|
222
|
+
// inbound SMS requests against the full public URL.
|
|
223
|
+
const smsWebhookUrl = `${publicBase}/webhooks/twilio/sms`;
|
|
224
|
+
|
|
225
|
+
const params = {
|
|
226
|
+
Body: 'hello',
|
|
227
|
+
From: '+15551234567',
|
|
228
|
+
To: '+15559876543',
|
|
229
|
+
MessageSid: 'SM123',
|
|
230
|
+
};
|
|
231
|
+
const twilioSignature = computeTwilioSignature(smsWebhookUrl, params, authToken);
|
|
232
|
+
|
|
233
|
+
// Gateway receives the request on its local address and reconstructs
|
|
234
|
+
// the canonical URL using the configured ingress base.
|
|
235
|
+
const localUrl = 'http://127.0.0.1:7830/webhooks/twilio/sms';
|
|
236
|
+
const canonicalUrl = reconstructGatewayCanonicalUrl(publicBase, localUrl);
|
|
237
|
+
|
|
238
|
+
expect(canonicalUrl).toBe(smsWebhookUrl);
|
|
239
|
+
|
|
240
|
+
const recomputed = computeTwilioSignature(canonicalUrl, params, authToken);
|
|
241
|
+
expect(recomputed).toBe(twilioSignature);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('SMS webhook signature fails when ingress URL is not configured (fail-visible)', () => {
|
|
245
|
+
const publicBase = 'https://sms-gateway.example.com';
|
|
246
|
+
const authToken = 'test-sms-auth-token';
|
|
247
|
+
|
|
248
|
+
const smsWebhookUrl = `${publicBase}/webhooks/twilio/sms`;
|
|
249
|
+
const params = { Body: 'test', From: '+15550001111', MessageSid: 'SM456' };
|
|
250
|
+
const twilioSignature = computeTwilioSignature(smsWebhookUrl, params, authToken);
|
|
251
|
+
|
|
252
|
+
// Without ingress config, the gateway uses the local URL — signature mismatch.
|
|
253
|
+
const localUrl = 'http://127.0.0.1:7830/webhooks/twilio/sms';
|
|
254
|
+
const canonicalWithout = reconstructGatewayCanonicalUrl(undefined, localUrl);
|
|
255
|
+
const recomputedWithout = computeTwilioSignature(canonicalWithout, params, authToken);
|
|
256
|
+
expect(recomputedWithout).not.toBe(twilioSignature);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('all Twilio webhook paths share the /webhooks/twilio/ prefix consistently', () => {
|
|
260
|
+
const config: IngressConfig = {
|
|
261
|
+
ingress: { publicBaseUrl: 'https://consistent.example.com' },
|
|
262
|
+
};
|
|
263
|
+
const base = getPublicBaseUrl(config);
|
|
264
|
+
|
|
265
|
+
// Document the path contract: all Twilio webhooks live under /webhooks/twilio/
|
|
266
|
+
const voiceUrl = getTwilioVoiceWebhookUrl(config, 'sess');
|
|
267
|
+
const statusUrl = getTwilioStatusCallbackUrl(config);
|
|
268
|
+
|
|
269
|
+
// Verify they all share the same base and prefix
|
|
270
|
+
expect(voiceUrl).toStartWith(`${base}/webhooks/twilio/`);
|
|
271
|
+
expect(statusUrl).toStartWith(`${base}/webhooks/twilio/`);
|
|
272
|
+
|
|
273
|
+
// SMS is currently handled at the gateway level (/webhooks/twilio/sms)
|
|
274
|
+
// but the path pattern is the same
|
|
275
|
+
const smsUrl = `${base}/webhooks/twilio/sms`;
|
|
276
|
+
expect(smsUrl).toStartWith(`${base}/webhooks/twilio/`);
|
|
277
|
+
});
|
|
214
278
|
});
|
|
@@ -378,6 +378,10 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
|
|
|
378
378
|
type: 'telegram_config',
|
|
379
379
|
action: 'get',
|
|
380
380
|
},
|
|
381
|
+
twilio_config: {
|
|
382
|
+
type: 'twilio_config',
|
|
383
|
+
action: 'get',
|
|
384
|
+
},
|
|
381
385
|
guardian_verification: {
|
|
382
386
|
type: 'guardian_verification',
|
|
383
387
|
action: 'create_challenge',
|
|
@@ -1218,6 +1222,12 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
|
|
|
1218
1222
|
connected: true,
|
|
1219
1223
|
hasWebhookSecret: true,
|
|
1220
1224
|
},
|
|
1225
|
+
twilio_config_response: {
|
|
1226
|
+
type: 'twilio_config_response',
|
|
1227
|
+
success: true,
|
|
1228
|
+
hasCredentials: true,
|
|
1229
|
+
phoneNumber: '+15551234567',
|
|
1230
|
+
},
|
|
1221
1231
|
guardian_verification_response: {
|
|
1222
1232
|
type: 'guardian_verification_response',
|
|
1223
1233
|
success: true,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterAll, mock
|
|
1
|
+
import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
|
|
2
2
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
@@ -33,6 +33,7 @@ mock.module('../config/loader.js', () => ({
|
|
|
33
33
|
provider: 'anthropic',
|
|
34
34
|
memory: { enabled: false },
|
|
35
35
|
calls: { enabled: false },
|
|
36
|
+
contextWindow: { maxInputTokens: 200000 },
|
|
36
37
|
}),
|
|
37
38
|
}));
|
|
38
39
|
|
|
@@ -75,6 +76,7 @@ function createMockSession(overrides?: Partial<ProcessSessionContext>): ProcessS
|
|
|
75
76
|
traceEmitter: {
|
|
76
77
|
emit: () => {},
|
|
77
78
|
} as unknown as ProcessSessionContext['traceEmitter'],
|
|
79
|
+
usageStats: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 },
|
|
78
80
|
persistUserMessage: mock((_content: string, _attachments: unknown[], _requestId?: string) => 'mock-msg-id'),
|
|
79
81
|
runAgentLoop: mock(async () => {}),
|
|
80
82
|
...overrides,
|
|
@@ -314,9 +314,9 @@ describe('tool_permission_simulate handler', () => {
|
|
|
314
314
|
|
|
315
315
|
const res = getResponse(sent);
|
|
316
316
|
expect(res.success).toBe(true);
|
|
317
|
-
// The sandbox-scoped rule should not match a host tool
|
|
317
|
+
// The sandbox-scoped allow rule should not match a host tool — falls
|
|
318
|
+
// through to the default ask rule instead.
|
|
318
319
|
expect(res.decision).toBe('prompt');
|
|
319
|
-
expect(res.matchedRuleId).toBeUndefined();
|
|
320
320
|
expect(res.executionTarget).toBe('host');
|
|
321
321
|
});
|
|
322
322
|
|
|
@@ -16,8 +16,17 @@ export interface TwilioConfig {
|
|
|
16
16
|
export function getTwilioConfig(): TwilioConfig {
|
|
17
17
|
const accountSid = getSecureKey('credential:twilio:account_sid');
|
|
18
18
|
const authToken = getSecureKey('credential:twilio:auth_token');
|
|
19
|
-
const phoneNumber = process.env.TWILIO_PHONE_NUMBER || getSecureKey('credential:twilio:phone_number') || '';
|
|
20
19
|
const config = loadConfig();
|
|
20
|
+
|
|
21
|
+
// Phone number resolution priority:
|
|
22
|
+
// 1. TWILIO_PHONE_NUMBER env var (explicit override)
|
|
23
|
+
// 2. config file sms.phoneNumber (primary storage)
|
|
24
|
+
// 3. credential:twilio:phone_number secure key (backward-compat fallback)
|
|
25
|
+
const phoneNumber =
|
|
26
|
+
process.env.TWILIO_PHONE_NUMBER ||
|
|
27
|
+
config.sms?.phoneNumber ||
|
|
28
|
+
getSecureKey('credential:twilio:phone_number') ||
|
|
29
|
+
'';
|
|
21
30
|
const webhookBaseUrl = getPublicBaseUrl(config);
|
|
22
31
|
|
|
23
32
|
// Always use the centralized relay URL derived from the public ingress base URL.
|
package/src/calls/twilio-rest.ts
CHANGED
|
@@ -154,3 +154,73 @@ export async function provisionPhoneNumber(
|
|
|
154
154
|
capabilities: { voice: data.capabilities.voice, sms: data.capabilities.sms },
|
|
155
155
|
};
|
|
156
156
|
}
|
|
157
|
+
|
|
158
|
+
export interface WebhookUrls {
|
|
159
|
+
voiceUrl: string;
|
|
160
|
+
statusCallbackUrl: string;
|
|
161
|
+
smsUrl: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Update the webhook URLs on a Twilio IncomingPhoneNumber.
|
|
166
|
+
*
|
|
167
|
+
* Configures voice webhook, voice status callback, and SMS webhook so
|
|
168
|
+
* that Twilio routes inbound calls and messages to the assistant's
|
|
169
|
+
* gateway endpoints.
|
|
170
|
+
*/
|
|
171
|
+
export async function updatePhoneNumberWebhooks(
|
|
172
|
+
accountSid: string,
|
|
173
|
+
authToken: string,
|
|
174
|
+
phoneNumber: string,
|
|
175
|
+
webhooks: WebhookUrls,
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
// First, find the SID for this phone number
|
|
178
|
+
const listRes = await fetch(
|
|
179
|
+
`${twilioBaseUrl(accountSid)}/IncomingPhoneNumbers.json?PhoneNumber=${encodeURIComponent(phoneNumber)}`,
|
|
180
|
+
{
|
|
181
|
+
method: 'GET',
|
|
182
|
+
headers: { Authorization: twilioAuthHeader(accountSid, authToken) },
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (!listRes.ok) {
|
|
187
|
+
const text = await listRes.text();
|
|
188
|
+
throw new Error(`Twilio API error ${listRes.status} looking up phone number: ${text}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const listData = (await listRes.json()) as {
|
|
192
|
+
incoming_phone_numbers: Array<{ sid: string; phone_number: string }>;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const match = listData.incoming_phone_numbers.find((n) => n.phone_number === phoneNumber);
|
|
196
|
+
if (!match) {
|
|
197
|
+
throw new Error(`Phone number ${phoneNumber} not found on Twilio account ${accountSid}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Update the phone number's webhook configuration
|
|
201
|
+
const body = new URLSearchParams({
|
|
202
|
+
VoiceUrl: webhooks.voiceUrl,
|
|
203
|
+
VoiceMethod: 'POST',
|
|
204
|
+
StatusCallback: webhooks.statusCallbackUrl,
|
|
205
|
+
StatusCallbackMethod: 'POST',
|
|
206
|
+
SmsUrl: webhooks.smsUrl,
|
|
207
|
+
SmsMethod: 'POST',
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const updateRes = await fetch(
|
|
211
|
+
`${twilioBaseUrl(accountSid)}/IncomingPhoneNumbers/${match.sid}.json`,
|
|
212
|
+
{
|
|
213
|
+
method: 'POST',
|
|
214
|
+
headers: {
|
|
215
|
+
Authorization: twilioAuthHeader(accountSid, authToken),
|
|
216
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
217
|
+
},
|
|
218
|
+
body: body.toString(),
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
if (!updateRes.ok) {
|
|
223
|
+
const text = await updateRes.text();
|
|
224
|
+
throw new Error(`Twilio API error ${updateRes.status} updating webhooks: ${text}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "Email Setup"
|
|
3
|
+
description: "Create the assistant's own email address via the Vellum hosted API (one-time setup)"
|
|
4
|
+
user-invocable: true
|
|
5
|
+
metadata: {"vellum": {"emoji": "📧"}}
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
You are setting up your own personal email address. This is a one-time operation — once you have an email, you do not need to run this again.
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
|
|
12
|
+
Only proceed if the user explicitly asks you to create or set up your email address. Do NOT proactively run this skill.
|
|
13
|
+
|
|
14
|
+
## Step 1: Check if Email Already Exists
|
|
15
|
+
|
|
16
|
+
Before doing anything, check whether you already have an email address configured:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
vellum email status
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This will return your email address, the callback URL that inbound email will hit, and the path to the inbox locally. If you already have an email address, tell the user your existing email address and stop — do NOT create another one.
|
|
23
|
+
|
|
24
|
+
## Step 2: Create Your Email
|
|
25
|
+
|
|
26
|
+
Call the Vellum hosted API to provision your email. Use `host_bash` to make the request:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
vellum email create <your-username>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For `<your-username>`, use your assistant name (lowercased, alphanumeric only). Check your identity from `IDENTITY.md` or `USER.md` to determine your name. If you don't have a name yet, ask the user what username they'd like for your email.
|
|
33
|
+
|
|
34
|
+
## Step 3: Confirm Setup
|
|
35
|
+
|
|
36
|
+
After the inbox is created successfully:
|
|
37
|
+
|
|
38
|
+
1. Parse the command output to extract your new email address.
|
|
39
|
+
2. Tell the user your new email address.
|
|
40
|
+
3. Store a note in your memory or `USER.md` that your email has been provisioned so you remember it in future conversations.
|
|
41
|
+
|
|
42
|
+
## Rules
|
|
43
|
+
|
|
44
|
+
- **One-time only.** If an inbox already exists (Step 1), do not create another. Inform the user of the existing address.
|
|
45
|
+
- **User-initiated only.** Never run this skill unless the user asks you to set up or create an email.
|
|
46
|
+
- **No custom domains.** Use the default provider domain. Do not attempt domain setup.
|
|
47
|
+
- **No API key prompting.** The email API key should already be configured. If the `vellum email` command fails with an API key error, tell the user the email integration is not yet configured and ask them to set it up.
|
|
48
|
+
|
|
49
|
+
## Troubleshooting
|
|
50
|
+
|
|
51
|
+
### API key not configured
|
|
52
|
+
If you get an error about a missing API key, the email provider has not been set up. Tell the user:
|
|
53
|
+
> "Email isn't configured yet. Please set up the email integration first."
|
|
54
|
+
|
|
55
|
+
### Inbox creation failed
|
|
56
|
+
If inbox creation returns an error (e.g. username taken), try a variation of the name (append a number or use a nickname) and retry once. If it still fails, report the error to the user.
|
|
@@ -18,6 +18,10 @@ Subagents follow this status flow: `pending` -> `running` -> `completed` / `fail
|
|
|
18
18
|
|
|
19
19
|
Only the parent session that spawned a subagent can interact with it (check status, send messages, abort, or read output).
|
|
20
20
|
|
|
21
|
+
## Silent Mode
|
|
22
|
+
|
|
23
|
+
Set `send_result_to_user: false` when spawning a subagent whose result is for internal processing only. The parent will still be notified on completion, but the notification will instruct it to read the result without presenting it to the user.
|
|
24
|
+
|
|
21
25
|
## Tips
|
|
22
26
|
|
|
23
27
|
- Do NOT poll `subagent_status` in a loop. You will be notified automatically when a subagent completes.
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"context": {
|
|
21
21
|
"type": "string",
|
|
22
22
|
"description": "Optional additional context to pass to the subagent"
|
|
23
|
+
},
|
|
24
|
+
"send_result_to_user": {
|
|
25
|
+
"type": "boolean",
|
|
26
|
+
"description": "Whether to present the subagent's result to the user when it completes. Defaults to true. Set to false for internal/silent processing."
|
|
23
27
|
}
|
|
24
28
|
},
|
|
25
29
|
"required": ["label", "objective"]
|
package/src/config/schema.ts
CHANGED
|
@@ -1066,6 +1066,9 @@ export const SmsConfigSchema = z.object({
|
|
|
1066
1066
|
phoneNumber: z
|
|
1067
1067
|
.string({ error: 'sms.phoneNumber must be a string' })
|
|
1068
1068
|
.default(''),
|
|
1069
|
+
assistantPhoneNumbers: z
|
|
1070
|
+
.record(z.string(), z.string({ error: 'sms.assistantPhoneNumbers values must be strings' }))
|
|
1071
|
+
.optional(),
|
|
1069
1072
|
});
|
|
1070
1073
|
|
|
1071
1074
|
const IngressBaseSchema = z.object({
|
|
@@ -82,7 +82,14 @@ If the user wants to buy a new number through Twilio, send:
|
|
|
82
82
|
- `areaCode` is optional — ask the user if they have a preferred area code
|
|
83
83
|
- `country` defaults to `"US"` — ask if they want a different country (ISO 3166-1 alpha-2)
|
|
84
84
|
|
|
85
|
-
The daemon provisions the number via the Twilio API
|
|
85
|
+
The daemon provisions the number via the Twilio API, automatically assigns it to the assistant (persisting to both secure storage and config), and configures Twilio webhooks (voice, status callback, SMS) if a public ingress URL is available. The response includes the new `phoneNumber`. No separate `assign_number` call is needed.
|
|
86
|
+
|
|
87
|
+
**Webhook auto-configuration:** When `ingress.publicBaseUrl` is configured, the daemon automatically sets the following webhooks on the Twilio phone number:
|
|
88
|
+
- Voice webhook: `{publicBaseUrl}/webhooks/twilio/voice`
|
|
89
|
+
- Voice status callback: `{publicBaseUrl}/webhooks/twilio/status`
|
|
90
|
+
- SMS webhook: `{publicBaseUrl}/webhooks/twilio/sms`
|
|
91
|
+
|
|
92
|
+
If ingress is not yet configured, webhook setup is skipped gracefully — the number is still assigned and usable once ingress is set up later.
|
|
86
93
|
|
|
87
94
|
**Trial account note:** Twilio trial accounts come with one free phone number. Check "Active Numbers" in the Twilio Console first before provisioning.
|
|
88
95
|
|
|
@@ -109,7 +116,7 @@ Then assign the chosen number:
|
|
|
109
116
|
}
|
|
110
117
|
```
|
|
111
118
|
|
|
112
|
-
The phone number must be in E.164 format.
|
|
119
|
+
The phone number must be in E.164 format. Like `provision_number`, `assign_number` also auto-configures Twilio webhooks when a public ingress URL is available.
|
|
113
120
|
|
|
114
121
|
### Option C: Manual Entry
|
|
115
122
|
|
|
@@ -146,13 +153,13 @@ If not configured, load and run the public-ingress skill:
|
|
|
146
153
|
skill_load skill=public-ingress
|
|
147
154
|
```
|
|
148
155
|
|
|
149
|
-
**Twilio webhook endpoints (
|
|
156
|
+
**Twilio webhook endpoints (auto-configured on provision/assign):**
|
|
150
157
|
- Voice webhook: `{publicBaseUrl}/webhooks/twilio/voice`
|
|
151
158
|
- Voice status callback: `{publicBaseUrl}/webhooks/twilio/status`
|
|
152
159
|
- ConversationRelay WebSocket: `{publicBaseUrl}/webhooks/twilio/relay` (wss://)
|
|
153
160
|
- SMS webhook: `{publicBaseUrl}/webhooks/twilio/sms`
|
|
154
161
|
|
|
155
|
-
|
|
162
|
+
Webhook URLs are automatically configured on the Twilio phone number when `provision_number` or `assign_number` is called with a valid ingress URL. No manual Twilio Console webhook configuration is needed.
|
|
156
163
|
|
|
157
164
|
## Step 5: Verify Setup
|
|
158
165
|
|
|
@@ -5,7 +5,7 @@ import { addRule, removeRule, updateRule, getAllRules, acceptStarterBundle } fro
|
|
|
5
5
|
import { classifyRisk, check, generateAllowlistOptions, generateScopeOptions } from '../../permissions/checker.js';
|
|
6
6
|
import { isSideEffectTool } from '../../tools/executor.js';
|
|
7
7
|
import { resolveExecutionTarget } from '../../tools/execution-target.js';
|
|
8
|
-
import { getAllTools
|
|
8
|
+
import { getAllTools } from '../../tools/registry.js';
|
|
9
9
|
import { listSchedules, updateSchedule, deleteSchedule, describeCronExpression } from '../../schedule/schedule-store.js';
|
|
10
10
|
import { listReminders, cancelReminder } from '../../tools/reminder/reminder-store.js';
|
|
11
11
|
import { getSecureKey, setSecureKey, deleteSecureKey } from '../../security/secure-keys.js';
|
|
@@ -37,7 +37,14 @@ import {
|
|
|
37
37
|
listIncomingPhoneNumbers,
|
|
38
38
|
searchAvailableNumbers,
|
|
39
39
|
provisionPhoneNumber,
|
|
40
|
+
updatePhoneNumberWebhooks,
|
|
40
41
|
} from '../../calls/twilio-rest.js';
|
|
42
|
+
import {
|
|
43
|
+
getTwilioVoiceWebhookUrl,
|
|
44
|
+
getTwilioStatusCallbackUrl,
|
|
45
|
+
getTwilioSmsWebhookUrl,
|
|
46
|
+
type IngressConfig,
|
|
47
|
+
} from '../../inbound/public-ingress-urls.js';
|
|
41
48
|
import { createVerificationChallenge, getGuardianBinding, revokeBinding as revokeGuardianBinding } from '../../runtime/channel-guardian-service.js';
|
|
42
49
|
import { log, CONFIG_RELOAD_DEBOUNCE_MS, defineHandlers, type HandlerContext } from './shared.js';
|
|
43
50
|
import { MODEL_TO_PROVIDER } from '../session-slash.js';
|
|
@@ -509,11 +516,46 @@ function triggerGatewayReconcile(ingressPublicBaseUrl: string | undefined): void
|
|
|
509
516
|
});
|
|
510
517
|
}
|
|
511
518
|
|
|
512
|
-
|
|
519
|
+
/**
|
|
520
|
+
* Best-effort Twilio webhook sync helper.
|
|
521
|
+
*
|
|
522
|
+
* Computes the voice, status-callback, and SMS webhook URLs from the current
|
|
523
|
+
* ingress config and pushes them to the Twilio IncomingPhoneNumber API.
|
|
524
|
+
*
|
|
525
|
+
* Returns `{ success, warning }`. When the update fails, `success` is false
|
|
526
|
+
* and `warning` contains a human-readable message. Callers should treat
|
|
527
|
+
* failure as non-fatal so that the primary operation (provision, assign,
|
|
528
|
+
* ingress save) still succeeds.
|
|
529
|
+
*/
|
|
530
|
+
async function syncTwilioWebhooks(
|
|
531
|
+
phoneNumber: string,
|
|
532
|
+
accountSid: string,
|
|
533
|
+
authToken: string,
|
|
534
|
+
ingressConfig: IngressConfig,
|
|
535
|
+
): Promise<{ success: boolean; warning?: string }> {
|
|
536
|
+
try {
|
|
537
|
+
const voiceUrl = getTwilioVoiceWebhookUrl(ingressConfig);
|
|
538
|
+
const statusCallbackUrl = getTwilioStatusCallbackUrl(ingressConfig);
|
|
539
|
+
const smsUrl = getTwilioSmsWebhookUrl(ingressConfig);
|
|
540
|
+
await updatePhoneNumberWebhooks(accountSid, authToken, phoneNumber, {
|
|
541
|
+
voiceUrl,
|
|
542
|
+
statusCallbackUrl,
|
|
543
|
+
smsUrl,
|
|
544
|
+
});
|
|
545
|
+
log.info({ phoneNumber }, 'Twilio webhooks configured successfully');
|
|
546
|
+
return { success: true };
|
|
547
|
+
} catch (err) {
|
|
548
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
549
|
+
log.warn({ err, phoneNumber }, `Webhook configuration skipped: ${message}`);
|
|
550
|
+
return { success: false, warning: `Webhook configuration skipped: ${message}` };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export async function handleIngressConfig(
|
|
513
555
|
msg: IngressConfigRequest,
|
|
514
556
|
socket: net.Socket,
|
|
515
557
|
ctx: HandlerContext,
|
|
516
|
-
): void {
|
|
558
|
+
): Promise<void> {
|
|
517
559
|
const localGatewayTarget = computeGatewayTarget();
|
|
518
560
|
try {
|
|
519
561
|
if (msg.action === 'get') {
|
|
@@ -584,6 +626,24 @@ export function handleIngressConfig(
|
|
|
584
626
|
// fallback branch above) rather than the raw `value` from the UI.
|
|
585
627
|
const effectiveUrl = isEnabled ? process.env.INGRESS_PUBLIC_BASE_URL : undefined;
|
|
586
628
|
triggerGatewayReconcile(effectiveUrl);
|
|
629
|
+
|
|
630
|
+
// Best-effort Twilio webhook reconciliation: when ingress is being
|
|
631
|
+
// enabled/updated and a Twilio number is assigned with valid credentials,
|
|
632
|
+
// push the new webhook URLs to Twilio so calls and SMS route correctly.
|
|
633
|
+
if (isEnabled && hasTwilioCredentials()) {
|
|
634
|
+
const currentConfig = loadRawConfig();
|
|
635
|
+
const smsConfig = (currentConfig?.sms ?? {}) as Record<string, unknown>;
|
|
636
|
+
const assignedNumber = (smsConfig.phoneNumber as string) ?? '';
|
|
637
|
+
if (assignedNumber) {
|
|
638
|
+
const acctSid = getSecureKey('credential:twilio:account_sid')!;
|
|
639
|
+
const acctToken = getSecureKey('credential:twilio:auth_token')!;
|
|
640
|
+
// Fire-and-forget: webhook sync failure must not block the ingress save
|
|
641
|
+
syncTwilioWebhooks(assignedNumber, acctSid, acctToken, currentConfig as IngressConfig)
|
|
642
|
+
.catch(() => {
|
|
643
|
+
// Already logged inside syncTwilioWebhooks
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
587
647
|
} else {
|
|
588
648
|
ctx.send(socket, { type: 'ingress_config_response', enabled: false, publicBaseUrl: '', localGatewayTarget, success: false, error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}` });
|
|
589
649
|
}
|
|
@@ -1109,7 +1169,15 @@ export async function handleTwilioConfig(
|
|
|
1109
1169
|
const hasCredentials = hasTwilioCredentials();
|
|
1110
1170
|
const raw = loadRawConfig();
|
|
1111
1171
|
const sms = (raw?.sms ?? {}) as Record<string, unknown>;
|
|
1112
|
-
|
|
1172
|
+
// When assistantId is provided, look up in assistantPhoneNumbers first,
|
|
1173
|
+
// fall back to the legacy phoneNumber field
|
|
1174
|
+
let phoneNumber: string;
|
|
1175
|
+
if (msg.assistantId) {
|
|
1176
|
+
const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
|
|
1177
|
+
phoneNumber = mapping[msg.assistantId] ?? (sms.phoneNumber as string) ?? '';
|
|
1178
|
+
} else {
|
|
1179
|
+
phoneNumber = (sms.phoneNumber as string) ?? '';
|
|
1180
|
+
}
|
|
1113
1181
|
ctx.send(socket, {
|
|
1114
1182
|
type: 'twilio_config_response',
|
|
1115
1183
|
success: true,
|
|
@@ -1192,9 +1260,12 @@ export async function handleTwilioConfig(
|
|
|
1192
1260
|
hasCredentials: true,
|
|
1193
1261
|
});
|
|
1194
1262
|
} else if (msg.action === 'clear_credentials') {
|
|
1263
|
+
// Only clear authentication credentials (Account SID and Auth Token).
|
|
1264
|
+
// Preserve the phone number in both config (sms.phoneNumber) and secure
|
|
1265
|
+
// key (credential:twilio:phone_number) so that re-entering credentials
|
|
1266
|
+
// resumes working without needing to reassign the number.
|
|
1195
1267
|
deleteSecureKey('credential:twilio:account_sid');
|
|
1196
1268
|
deleteSecureKey('credential:twilio:auth_token');
|
|
1197
|
-
deleteSecureKey('credential:twilio:phone_number');
|
|
1198
1269
|
deleteCredentialMetadata('twilio', 'account_sid');
|
|
1199
1270
|
deleteCredentialMetadata('twilio', 'auth_token');
|
|
1200
1271
|
|
|
@@ -1233,11 +1304,64 @@ export async function handleTwilioConfig(
|
|
|
1233
1304
|
// Purchase the first available number
|
|
1234
1305
|
const purchased = await provisionPhoneNumber(accountSid, authToken, available[0].phoneNumber);
|
|
1235
1306
|
|
|
1307
|
+
// Auto-assign: persist the purchased number in secure storage and config
|
|
1308
|
+
// (same persistence as assign_number for consistency)
|
|
1309
|
+
const phoneStored = setSecureKey('credential:twilio:phone_number', purchased.phoneNumber);
|
|
1310
|
+
if (!phoneStored) {
|
|
1311
|
+
ctx.send(socket, {
|
|
1312
|
+
type: 'twilio_config_response',
|
|
1313
|
+
success: false,
|
|
1314
|
+
hasCredentials: hasTwilioCredentials(),
|
|
1315
|
+
phoneNumber: purchased.phoneNumber,
|
|
1316
|
+
error: `Phone number ${purchased.phoneNumber} was purchased but could not be saved. Use assign_number to assign it manually.`,
|
|
1317
|
+
});
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const raw = loadRawConfig();
|
|
1322
|
+
const sms = (raw?.sms ?? {}) as Record<string, unknown>;
|
|
1323
|
+
// When assistantId is provided, only set the legacy global phoneNumber
|
|
1324
|
+
// if it's not already set — this prevents multi-assistant assignments
|
|
1325
|
+
// from clobbering each other's outbound SMS number.
|
|
1326
|
+
if (msg.assistantId) {
|
|
1327
|
+
if (!sms.phoneNumber) {
|
|
1328
|
+
sms.phoneNumber = purchased.phoneNumber;
|
|
1329
|
+
}
|
|
1330
|
+
} else {
|
|
1331
|
+
sms.phoneNumber = purchased.phoneNumber;
|
|
1332
|
+
}
|
|
1333
|
+
// When assistantId is provided, also persist into the per-assistant mapping
|
|
1334
|
+
if (msg.assistantId) {
|
|
1335
|
+
const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
|
|
1336
|
+
mapping[msg.assistantId] = purchased.phoneNumber;
|
|
1337
|
+
sms.assistantPhoneNumbers = mapping;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const wasSuppressed = ctx.suppressConfigReload;
|
|
1341
|
+
ctx.setSuppressConfigReload(true);
|
|
1342
|
+
try {
|
|
1343
|
+
saveRawConfig({ ...raw, sms });
|
|
1344
|
+
} catch (err) {
|
|
1345
|
+
ctx.setSuppressConfigReload(wasSuppressed);
|
|
1346
|
+
throw err;
|
|
1347
|
+
}
|
|
1348
|
+
ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
|
|
1349
|
+
|
|
1350
|
+
// Best-effort webhook configuration — non-fatal so the number is
|
|
1351
|
+
// still usable even if ingress isn't configured yet.
|
|
1352
|
+
const webhookResult = await syncTwilioWebhooks(
|
|
1353
|
+
purchased.phoneNumber,
|
|
1354
|
+
accountSid,
|
|
1355
|
+
authToken,
|
|
1356
|
+
loadRawConfig() as IngressConfig,
|
|
1357
|
+
);
|
|
1358
|
+
|
|
1236
1359
|
ctx.send(socket, {
|
|
1237
1360
|
type: 'twilio_config_response',
|
|
1238
1361
|
success: true,
|
|
1239
1362
|
hasCredentials: true,
|
|
1240
1363
|
phoneNumber: purchased.phoneNumber,
|
|
1364
|
+
warning: webhookResult.warning,
|
|
1241
1365
|
});
|
|
1242
1366
|
} else if (msg.action === 'assign_number') {
|
|
1243
1367
|
if (!msg.phoneNumber) {
|
|
@@ -1266,7 +1390,22 @@ export async function handleTwilioConfig(
|
|
|
1266
1390
|
// Also persist in assistant config (non-secret) for the UI
|
|
1267
1391
|
const raw = loadRawConfig();
|
|
1268
1392
|
const sms = (raw?.sms ?? {}) as Record<string, unknown>;
|
|
1269
|
-
|
|
1393
|
+
// When assistantId is provided, only set the legacy global phoneNumber
|
|
1394
|
+
// if it's not already set — this prevents multi-assistant assignments
|
|
1395
|
+
// from clobbering each other's outbound SMS number.
|
|
1396
|
+
if (msg.assistantId) {
|
|
1397
|
+
if (!sms.phoneNumber) {
|
|
1398
|
+
sms.phoneNumber = msg.phoneNumber;
|
|
1399
|
+
}
|
|
1400
|
+
} else {
|
|
1401
|
+
sms.phoneNumber = msg.phoneNumber;
|
|
1402
|
+
}
|
|
1403
|
+
// When assistantId is provided, also persist into the per-assistant mapping
|
|
1404
|
+
if (msg.assistantId) {
|
|
1405
|
+
const mapping = (sms.assistantPhoneNumbers as Record<string, string> | undefined) ?? {};
|
|
1406
|
+
mapping[msg.assistantId] = msg.phoneNumber;
|
|
1407
|
+
sms.assistantPhoneNumbers = mapping;
|
|
1408
|
+
}
|
|
1270
1409
|
|
|
1271
1410
|
const wasSuppressed = ctx.suppressConfigReload;
|
|
1272
1411
|
ctx.setSuppressConfigReload(true);
|
|
@@ -1278,11 +1417,26 @@ export async function handleTwilioConfig(
|
|
|
1278
1417
|
}
|
|
1279
1418
|
ctx.debounceTimers.schedule('__suppress_reset__', () => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
|
|
1280
1419
|
|
|
1420
|
+
// Best-effort webhook configuration when credentials are available
|
|
1421
|
+
let webhookWarning: string | undefined;
|
|
1422
|
+
if (hasTwilioCredentials()) {
|
|
1423
|
+
const acctSid = getSecureKey('credential:twilio:account_sid')!;
|
|
1424
|
+
const acctToken = getSecureKey('credential:twilio:auth_token')!;
|
|
1425
|
+
const webhookResult = await syncTwilioWebhooks(
|
|
1426
|
+
msg.phoneNumber,
|
|
1427
|
+
acctSid,
|
|
1428
|
+
acctToken,
|
|
1429
|
+
loadRawConfig() as IngressConfig,
|
|
1430
|
+
);
|
|
1431
|
+
webhookWarning = webhookResult.warning;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1281
1434
|
ctx.send(socket, {
|
|
1282
1435
|
type: 'twilio_config_response',
|
|
1283
1436
|
success: true,
|
|
1284
1437
|
hasCredentials: hasTwilioCredentials(),
|
|
1285
1438
|
phoneNumber: msg.phoneNumber,
|
|
1439
|
+
warning: webhookWarning,
|
|
1286
1440
|
});
|
|
1287
1441
|
} else if (msg.action === 'list_numbers') {
|
|
1288
1442
|
if (!hasTwilioCredentials()) {
|
|
@@ -1331,9 +1485,9 @@ export function handleGuardianVerification(
|
|
|
1331
1485
|
ctx: HandlerContext,
|
|
1332
1486
|
): void {
|
|
1333
1487
|
try {
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
1336
|
-
const assistantId = 'self';
|
|
1488
|
+
// Use the assistant ID from the request when available; fall back to
|
|
1489
|
+
// 'self' for backward compatibility with single-assistant mode.
|
|
1490
|
+
const assistantId = msg.assistantId ?? 'self';
|
|
1337
1491
|
const channel = msg.channel ?? 'telegram';
|
|
1338
1492
|
|
|
1339
1493
|
if (msg.action === 'create_challenge') {
|
|
@@ -1354,7 +1508,7 @@ export function handleGuardianVerification(
|
|
|
1354
1508
|
guardianExternalUserId: binding?.guardianExternalUserId,
|
|
1355
1509
|
});
|
|
1356
1510
|
} else if (msg.action === 'revoke') {
|
|
1357
|
-
|
|
1511
|
+
revokeGuardianBinding(assistantId, channel);
|
|
1358
1512
|
ctx.send(socket, {
|
|
1359
1513
|
type: 'guardian_verification_response',
|
|
1360
1514
|
success: true,
|
|
@@ -1411,11 +1565,10 @@ export async function handleToolPermissionSimulate(
|
|
|
1411
1565
|
|
|
1412
1566
|
const workingDir = msg.workingDir ?? process.cwd();
|
|
1413
1567
|
|
|
1414
|
-
//
|
|
1415
|
-
//
|
|
1416
|
-
const
|
|
1417
|
-
const
|
|
1418
|
-
const policyContext = executionTarget ? { executionTarget } : undefined;
|
|
1568
|
+
// Resolve execution target using manifest metadata or prefix heuristics.
|
|
1569
|
+
// resolveExecutionTarget handles unregistered tools via prefix fallback.
|
|
1570
|
+
const executionTarget = resolveExecutionTarget(msg.toolName);
|
|
1571
|
+
const policyContext = { executionTarget };
|
|
1419
1572
|
|
|
1420
1573
|
const riskLevel = await classifyRisk(msg.toolName, msg.input, workingDir);
|
|
1421
1574
|
const result = await check(msg.toolName, msg.input, workingDir, policyContext);
|
|
@@ -198,8 +198,9 @@ export function handleSecretResponse(
|
|
|
198
198
|
log.warn({ requestId: msg.requestId }, 'No session found with pending secret prompt for requestId');
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
export function handleSessionList(socket: net.Socket, ctx: HandlerContext): void {
|
|
202
|
-
const conversations = conversationStore.listConversations(
|
|
201
|
+
export function handleSessionList(socket: net.Socket, ctx: HandlerContext, offset = 0, limit = 50): void {
|
|
202
|
+
const conversations = conversationStore.listConversations(limit, false, offset);
|
|
203
|
+
const totalCount = conversationStore.countConversations();
|
|
203
204
|
const bindings = externalConversationStore.getBindingsForConversations(
|
|
204
205
|
conversations.map((c) => c.id),
|
|
205
206
|
);
|
|
@@ -223,6 +224,7 @@ export function handleSessionList(socket: net.Socket, ctx: HandlerContext): void
|
|
|
223
224
|
} : {}),
|
|
224
225
|
};
|
|
225
226
|
}),
|
|
227
|
+
hasMore: offset + conversations.length < totalCount,
|
|
226
228
|
});
|
|
227
229
|
}
|
|
228
230
|
|
|
@@ -541,7 +543,7 @@ export const sessionHandlers = defineHandlers({
|
|
|
541
543
|
user_message: handleUserMessage,
|
|
542
544
|
confirmation_response: handleConfirmationResponse,
|
|
543
545
|
secret_response: handleSecretResponse,
|
|
544
|
-
session_list: (
|
|
546
|
+
session_list: (msg, socket, ctx) => handleSessionList(socket, ctx, msg.offset ?? 0, msg.limit ?? 50),
|
|
545
547
|
session_create: handleSessionCreate,
|
|
546
548
|
sessions_clear: (_msg, socket, ctx) => handleSessionsClear(socket, ctx),
|
|
547
549
|
session_switch: handleSessionSwitch,
|