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
|
@@ -16,7 +16,6 @@ export function recordUsageEvent(input: UsageEventInput, pricing: PricingResult)
|
|
|
16
16
|
db.insert(llmUsageEvents).values({
|
|
17
17
|
id: event.id,
|
|
18
18
|
createdAt: event.createdAt,
|
|
19
|
-
assistantId: 'self',
|
|
20
19
|
conversationId: event.conversationId,
|
|
21
20
|
runId: event.runId,
|
|
22
21
|
requestId: event.requestId,
|
package/src/memory/runs-store.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runs track the lifecycle of an agent loop triggered by a user message:
|
|
5
5
|
* running → needs_confirmation → running → completed | failed
|
|
6
|
+
* running → needs_secret → running → completed | failed
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { eq, inArray } from 'drizzle-orm';
|
|
@@ -14,7 +15,7 @@ import { messageRuns } from './schema.js';
|
|
|
14
15
|
// Types
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
16
17
|
|
|
17
|
-
export type RunStatus = 'running' | 'needs_confirmation' | 'completed' | 'failed';
|
|
18
|
+
export type RunStatus = 'running' | 'needs_confirmation' | 'needs_secret' | 'completed' | 'failed';
|
|
18
19
|
|
|
19
20
|
export interface PendingConfirmation {
|
|
20
21
|
toolName: string;
|
|
@@ -34,13 +35,24 @@ export interface PendingConfirmation {
|
|
|
34
35
|
persistentDecisionsAllowed?: boolean;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
export interface PendingSecret {
|
|
39
|
+
requestId: string;
|
|
40
|
+
service: string;
|
|
41
|
+
field: string;
|
|
42
|
+
label: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
placeholder?: string;
|
|
45
|
+
purpose?: string;
|
|
46
|
+
allowOneTimeSend?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
export interface Run {
|
|
38
50
|
id: string;
|
|
39
|
-
assistantId: string;
|
|
40
51
|
conversationId: string;
|
|
41
52
|
messageId: string | null;
|
|
42
53
|
status: RunStatus;
|
|
43
54
|
pendingConfirmation: PendingConfirmation | null;
|
|
55
|
+
pendingSecret: PendingSecret | null;
|
|
44
56
|
inputTokens: number;
|
|
45
57
|
outputTokens: number;
|
|
46
58
|
estimatedCost: number;
|
|
@@ -64,13 +76,17 @@ function rowToRun(row: typeof messageRuns.$inferSelect): Run {
|
|
|
64
76
|
if (row.pendingConfirmation) {
|
|
65
77
|
try { pendingConfirmation = JSON.parse(row.pendingConfirmation); } catch { /* malformed */ }
|
|
66
78
|
}
|
|
79
|
+
let pendingSecret: PendingSecret | null = null;
|
|
80
|
+
if (row.pendingSecret) {
|
|
81
|
+
try { pendingSecret = JSON.parse(row.pendingSecret); } catch { /* malformed */ }
|
|
82
|
+
}
|
|
67
83
|
return {
|
|
68
84
|
id: row.id,
|
|
69
|
-
assistantId: row.assistantId,
|
|
70
85
|
conversationId: row.conversationId,
|
|
71
86
|
messageId: row.messageId,
|
|
72
87
|
status: row.status as RunStatus,
|
|
73
88
|
pendingConfirmation,
|
|
89
|
+
pendingSecret,
|
|
74
90
|
inputTokens: row.inputTokens,
|
|
75
91
|
outputTokens: row.outputTokens,
|
|
76
92
|
estimatedCost: row.estimatedCost,
|
|
@@ -94,11 +110,11 @@ export function createRun(
|
|
|
94
110
|
|
|
95
111
|
const row = {
|
|
96
112
|
id,
|
|
97
|
-
assistantId: 'self',
|
|
98
113
|
conversationId,
|
|
99
114
|
messageId: messageId ?? null,
|
|
100
115
|
status: 'running' as const,
|
|
101
116
|
pendingConfirmation: null,
|
|
117
|
+
pendingSecret: null,
|
|
102
118
|
inputTokens: 0,
|
|
103
119
|
outputTokens: 0,
|
|
104
120
|
estimatedCost: 0,
|
|
@@ -147,6 +163,35 @@ export function clearRunConfirmation(runId: string): void {
|
|
|
147
163
|
.run();
|
|
148
164
|
}
|
|
149
165
|
|
|
166
|
+
export function setRunSecret(
|
|
167
|
+
runId: string,
|
|
168
|
+
secret: PendingSecret,
|
|
169
|
+
): void {
|
|
170
|
+
const db = getDb();
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
db.update(messageRuns)
|
|
173
|
+
.set({
|
|
174
|
+
status: 'needs_secret',
|
|
175
|
+
pendingSecret: JSON.stringify(secret),
|
|
176
|
+
updatedAt: now,
|
|
177
|
+
})
|
|
178
|
+
.where(eq(messageRuns.id, runId))
|
|
179
|
+
.run();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function clearRunSecret(runId: string): void {
|
|
183
|
+
const db = getDb();
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
db.update(messageRuns)
|
|
186
|
+
.set({
|
|
187
|
+
status: 'running',
|
|
188
|
+
pendingSecret: null,
|
|
189
|
+
updatedAt: now,
|
|
190
|
+
})
|
|
191
|
+
.where(eq(messageRuns.id, runId))
|
|
192
|
+
.run();
|
|
193
|
+
}
|
|
194
|
+
|
|
150
195
|
export function completeRun(runId: string, usage?: RunUsage): void {
|
|
151
196
|
const db = getDb();
|
|
152
197
|
const now = Date.now();
|
|
@@ -180,13 +225,13 @@ export function failRun(runId: string, error: string): void {
|
|
|
180
225
|
/**
|
|
181
226
|
* Mark all non-terminal runs as failed.
|
|
182
227
|
* Called on startup to recover from daemon restarts that left runs
|
|
183
|
-
* in running/needs_confirmation with no in-memory state to resolve them.
|
|
228
|
+
* in running/needs_confirmation/needs_secret with no in-memory state to resolve them.
|
|
184
229
|
* Returns the number of rows affected.
|
|
185
230
|
*/
|
|
186
231
|
export function failOrphanedRuns(): number {
|
|
187
232
|
const db = getDb();
|
|
188
233
|
const now = Date.now();
|
|
189
|
-
const activeStatuses = ['running', 'needs_confirmation'];
|
|
234
|
+
const activeStatuses = ['running', 'needs_confirmation', 'needs_secret'];
|
|
190
235
|
|
|
191
236
|
// Count first so we can report how many were recovered.
|
|
192
237
|
const active = db.select({ id: messageRuns.id })
|
package/src/memory/schema.ts
CHANGED
|
@@ -148,7 +148,6 @@ export const memoryJobs = sqliteTable('memory_jobs', {
|
|
|
148
148
|
|
|
149
149
|
export const conversationKeys = sqliteTable('conversation_keys', {
|
|
150
150
|
id: text('id').primaryKey(),
|
|
151
|
-
assistantId: text('assistant_id').notNull(),
|
|
152
151
|
conversationKey: text('conversation_key').notNull(),
|
|
153
152
|
conversationId: text('conversation_id')
|
|
154
153
|
.notNull()
|
|
@@ -158,7 +157,6 @@ export const conversationKeys = sqliteTable('conversation_keys', {
|
|
|
158
157
|
|
|
159
158
|
export const attachments = sqliteTable('attachments', {
|
|
160
159
|
id: text('id').primaryKey(),
|
|
161
|
-
assistantId: text('assistant_id').notNull(),
|
|
162
160
|
originalFilename: text('original_filename').notNull(),
|
|
163
161
|
mimeType: text('mime_type').notNull(),
|
|
164
162
|
sizeBytes: integer('size_bytes').notNull(),
|
|
@@ -183,7 +181,6 @@ export const messageAttachments = sqliteTable('message_attachments', {
|
|
|
183
181
|
|
|
184
182
|
export const channelInboundEvents = sqliteTable('channel_inbound_events', {
|
|
185
183
|
id: text('id').primaryKey(),
|
|
186
|
-
assistantId: text('assistant_id').notNull(),
|
|
187
184
|
sourceChannel: text('source_channel').notNull(),
|
|
188
185
|
externalChatId: text('external_chat_id').notNull(),
|
|
189
186
|
externalMessageId: text('external_message_id').notNull(),
|
|
@@ -207,14 +204,14 @@ export const channelInboundEvents = sqliteTable('channel_inbound_events', {
|
|
|
207
204
|
|
|
208
205
|
export const messageRuns = sqliteTable('message_runs', {
|
|
209
206
|
id: text('id').primaryKey(),
|
|
210
|
-
assistantId: text('assistant_id').notNull(),
|
|
211
207
|
conversationId: text('conversation_id')
|
|
212
208
|
.notNull()
|
|
213
209
|
.references(() => conversations.id, { onDelete: 'cascade' }),
|
|
214
210
|
messageId: text('message_id')
|
|
215
211
|
.references(() => messages.id, { onDelete: 'cascade' }),
|
|
216
|
-
status: text('status').notNull().default('running'), // running | needs_confirmation | completed | failed
|
|
212
|
+
status: text('status').notNull().default('running'), // running | needs_confirmation | needs_secret | completed | failed
|
|
217
213
|
pendingConfirmation: text('pending_confirmation'), // JSON when status=needs_confirmation
|
|
214
|
+
pendingSecret: text('pending_secret'), // JSON when status=needs_secret
|
|
218
215
|
inputTokens: integer('input_tokens').notNull().default(0),
|
|
219
216
|
outputTokens: integer('output_tokens').notNull().default(0),
|
|
220
217
|
estimatedCost: real('estimated_cost').notNull().default(0),
|
|
@@ -523,7 +520,6 @@ export const llmRequestLogs = sqliteTable('llm_request_logs', {
|
|
|
523
520
|
export const llmUsageEvents = sqliteTable('llm_usage_events', {
|
|
524
521
|
id: text('id').primaryKey(),
|
|
525
522
|
createdAt: integer('created_at').notNull(),
|
|
526
|
-
assistantId: text('assistant_id'),
|
|
527
523
|
conversationId: text('conversation_id'),
|
|
528
524
|
runId: text('run_id'),
|
|
529
525
|
requestId: text('request_id'),
|
|
@@ -15,10 +15,16 @@ export interface ChannelReplyPayload {
|
|
|
15
15
|
export async function deliverChannelReply(
|
|
16
16
|
callbackUrl: string,
|
|
17
17
|
payload: ChannelReplyPayload,
|
|
18
|
+
bearerToken?: string,
|
|
18
19
|
): Promise<void> {
|
|
20
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
21
|
+
if (bearerToken) {
|
|
22
|
+
headers['Authorization'] = `Bearer ${bearerToken}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
const response = await fetch(callbackUrl, {
|
|
20
26
|
method: 'POST',
|
|
21
|
-
headers
|
|
27
|
+
headers,
|
|
22
28
|
body: JSON.stringify(payload),
|
|
23
29
|
signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
|
|
24
30
|
});
|
|
@@ -12,7 +12,7 @@ import { ConfigError, IngressBlockedError } from '../util/errors.js';
|
|
|
12
12
|
import { getLogger } from '../util/logger.js';
|
|
13
13
|
import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js';
|
|
14
14
|
import { loadConfig } from '../config/loader.js';
|
|
15
|
-
import {
|
|
15
|
+
import { getPublicBaseUrl } from '../inbound/public-ingress-urls.js';
|
|
16
16
|
import type { RunOrchestrator } from './run-orchestrator.js';
|
|
17
17
|
|
|
18
18
|
// Route handlers — grouped by domain
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
handleCreateRun,
|
|
31
31
|
handleGetRun,
|
|
32
32
|
handleRunDecision,
|
|
33
|
+
handleRunSecret,
|
|
33
34
|
handleAddTrustRule,
|
|
34
35
|
} from './routes/run-routes.js';
|
|
35
36
|
import {
|
|
@@ -65,6 +66,8 @@ import {
|
|
|
65
66
|
} from '../calls/twilio-routes.js';
|
|
66
67
|
import { RelayConnection, activeRelayConnections } from '../calls/relay-server.js';
|
|
67
68
|
import type { RelayWebSocketData } from '../calls/relay-server.js';
|
|
69
|
+
import { handleSubscribeAssistantEvents } from './routes/events-routes.js';
|
|
70
|
+
import { consumeCallback, consumeCallbackError } from '../security/oauth-callback-registry.js';
|
|
68
71
|
|
|
69
72
|
// Re-export shared types so existing consumers don't need to update imports
|
|
70
73
|
export type {
|
|
@@ -137,6 +140,103 @@ const GATEWAY_SUBPATH_MAP: Record<string, string> = {
|
|
|
137
140
|
'connect-action': 'connect-action',
|
|
138
141
|
};
|
|
139
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Direct Twilio webhook subpaths that are blocked in gateway_only mode.
|
|
145
|
+
* Internal forwarding endpoints (gateway→runtime) are unaffected.
|
|
146
|
+
*/
|
|
147
|
+
const GATEWAY_ONLY_BLOCKED_SUBPATHS = new Set(['voice-webhook', 'status', 'connect-action']);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Check if a request origin is from a private/internal network address.
|
|
151
|
+
* Extracts the hostname from the Origin header and validates it against
|
|
152
|
+
* isPrivateAddress(), consistent with the isPrivateNetworkPeer check.
|
|
153
|
+
*/
|
|
154
|
+
function isPrivateNetworkOrigin(req: Request): boolean {
|
|
155
|
+
const origin = req.headers.get('origin');
|
|
156
|
+
// No origin header (e.g., server-initiated or same-origin) — allow
|
|
157
|
+
if (!origin) return true;
|
|
158
|
+
try {
|
|
159
|
+
const url = new URL(origin);
|
|
160
|
+
const host = url.hostname;
|
|
161
|
+
if (host === 'localhost') return true;
|
|
162
|
+
// URL.hostname wraps IPv6 addresses in brackets (e.g. "[::1]") — strip them
|
|
163
|
+
const rawHost = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
|
164
|
+
return isPrivateAddress(rawHost);
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Check if a hostname is a loopback address.
|
|
172
|
+
*/
|
|
173
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
174
|
+
return hostname === '127.0.0.1' || hostname === '::1' || hostname === 'localhost';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Check if the actual peer/remote address of a connection is from a
|
|
179
|
+
* private/internal network. Uses Bun's server.requestIP() to get the
|
|
180
|
+
* real peer address, which cannot be spoofed unlike the Origin header.
|
|
181
|
+
*
|
|
182
|
+
* Accepts loopback, RFC 1918 private IPv4, link-local, and RFC 4193
|
|
183
|
+
* unique-local IPv6 — including their IPv4-mapped IPv6 forms. This
|
|
184
|
+
* supports container/pod deployments (e.g. Kubernetes sidecars) where
|
|
185
|
+
* gateway and runtime communicate over pod-internal private IPs.
|
|
186
|
+
*/
|
|
187
|
+
function isPrivateNetworkPeer(server: { requestIP(req: Request): { address: string; family: string; port: number } | null }, req: Request): boolean {
|
|
188
|
+
const ip = server.requestIP(req);
|
|
189
|
+
if (!ip) return false;
|
|
190
|
+
return isPrivateAddress(ip.address);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @internal Exported for testing.
|
|
195
|
+
*
|
|
196
|
+
* Determine whether an IP address string belongs to a private/internal
|
|
197
|
+
* network range:
|
|
198
|
+
* - Loopback: 127.0.0.0/8, ::1
|
|
199
|
+
* - RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
|
200
|
+
* - Link-local: 169.254.0.0/16
|
|
201
|
+
* - IPv6 unique local: fc00::/7 (fc00::–fdff::)
|
|
202
|
+
* - IPv4-mapped IPv6 variants of all of the above (::ffff:x.x.x.x)
|
|
203
|
+
*/
|
|
204
|
+
export function isPrivateAddress(addr: string): boolean {
|
|
205
|
+
// Handle IPv4-mapped IPv6 (e.g. ::ffff:10.0.0.1) — extract the IPv4 part
|
|
206
|
+
const v4Mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
|
|
207
|
+
const normalized = v4Mapped ? v4Mapped[1] : addr;
|
|
208
|
+
|
|
209
|
+
// IPv4 checks
|
|
210
|
+
if (normalized.includes('.')) {
|
|
211
|
+
const parts = normalized.split('.').map(Number);
|
|
212
|
+
if (parts.length !== 4 || parts.some(p => isNaN(p) || p < 0 || p > 255)) return false;
|
|
213
|
+
|
|
214
|
+
// Loopback: 127.0.0.0/8
|
|
215
|
+
if (parts[0] === 127) return true;
|
|
216
|
+
// 10.0.0.0/8
|
|
217
|
+
if (parts[0] === 10) return true;
|
|
218
|
+
// 172.16.0.0/12 (172.16.x.x – 172.31.x.x)
|
|
219
|
+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
220
|
+
// 192.168.0.0/16
|
|
221
|
+
if (parts[0] === 192 && parts[1] === 168) return true;
|
|
222
|
+
// Link-local: 169.254.0.0/16
|
|
223
|
+
if (parts[0] === 169 && parts[1] === 254) return true;
|
|
224
|
+
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// IPv6 checks
|
|
229
|
+
const lower = normalized.toLowerCase();
|
|
230
|
+
// Loopback
|
|
231
|
+
if (lower === '::1') return true;
|
|
232
|
+
// Unique local: fc00::/7 (fc00:: through fdff::)
|
|
233
|
+
if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
|
|
234
|
+
// Link-local: fe80::/10
|
|
235
|
+
if (lower.startsWith('fe80')) return true;
|
|
236
|
+
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
140
240
|
/**
|
|
141
241
|
* Validate a Twilio webhook request's X-Twilio-Signature header.
|
|
142
242
|
*
|
|
@@ -186,7 +286,7 @@ async function validateTwilioWebhook(
|
|
|
186
286
|
// used to compute the HMAC-SHA1 signature.
|
|
187
287
|
let publicBaseUrl: string | undefined;
|
|
188
288
|
try {
|
|
189
|
-
publicBaseUrl =
|
|
289
|
+
publicBaseUrl = getPublicBaseUrl(loadConfig());
|
|
190
290
|
} catch {
|
|
191
291
|
// No webhook base URL configured — fall back to using req.url as-is
|
|
192
292
|
}
|
|
@@ -246,6 +346,11 @@ export class RuntimeHttpServer {
|
|
|
246
346
|
this.interfacesDir = options.interfacesDir ?? null;
|
|
247
347
|
}
|
|
248
348
|
|
|
349
|
+
/** The port the server is actually listening on (resolved after start). */
|
|
350
|
+
get actualPort(): number {
|
|
351
|
+
return this.server?.port ?? this.port;
|
|
352
|
+
}
|
|
353
|
+
|
|
249
354
|
async start(): Promise<void> {
|
|
250
355
|
this.server = Bun.serve<RelayWebSocketData>({
|
|
251
356
|
port: this.port,
|
|
@@ -289,7 +394,20 @@ export class RuntimeHttpServer {
|
|
|
289
394
|
}, 30_000);
|
|
290
395
|
}
|
|
291
396
|
|
|
292
|
-
|
|
397
|
+
// Startup guard: log gateway-only mode warnings
|
|
398
|
+
try {
|
|
399
|
+
const config = loadConfig();
|
|
400
|
+
if (config.ingress.mode === 'gateway_only') {
|
|
401
|
+
log.info('Running in gateway-only ingress mode. Direct webhook routes disabled.');
|
|
402
|
+
if (!isLoopbackHost(this.hostname)) {
|
|
403
|
+
log.warn('gateway-only mode is enabled but RUNTIME_HTTP_HOST is not bound to loopback. This may expose the runtime to direct public access.');
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
} catch {
|
|
407
|
+
// Config loading may fail during startup — don't block server start
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
log.info({ port: this.actualPort, hostname: this.hostname, auth: !!this.bearerToken }, 'Runtime HTTP server listening');
|
|
293
411
|
}
|
|
294
412
|
|
|
295
413
|
async stop(): Promise<void> {
|
|
@@ -327,6 +445,18 @@ export class RuntimeHttpServer {
|
|
|
327
445
|
// WebSocket upgrade for ConversationRelay — before auth check because
|
|
328
446
|
// Twilio WebSocket connections don't use bearer tokens.
|
|
329
447
|
if (path.startsWith('/v1/calls/relay') && req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
448
|
+
// In gateway_only mode, only allow relay connections from private network peers.
|
|
449
|
+
// Primary check: actual peer address (cannot be spoofed) — accepts loopback
|
|
450
|
+
// and RFC 1918/4193 private addresses to support container deployments.
|
|
451
|
+
// Secondary check: Origin header (defense in depth).
|
|
452
|
+
const config = loadConfig();
|
|
453
|
+
if (config.ingress.mode === 'gateway_only' && (!isPrivateNetworkPeer(server, req) || !isPrivateNetworkOrigin(req))) {
|
|
454
|
+
return Response.json(
|
|
455
|
+
{ error: 'Direct relay access disabled in gateway-only mode', code: 'GATEWAY_ONLY' },
|
|
456
|
+
{ status: 403 },
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
330
460
|
const wsUrl = new URL(req.url);
|
|
331
461
|
const callSessionId = wsUrl.searchParams.get('callSessionId');
|
|
332
462
|
if (!callSessionId) {
|
|
@@ -356,6 +486,15 @@ export class RuntimeHttpServer {
|
|
|
356
486
|
if (resolvedTwilioSubpath && req.method === 'POST') {
|
|
357
487
|
const twilioSubpath = resolvedTwilioSubpath;
|
|
358
488
|
|
|
489
|
+
// In gateway_only mode, block direct Twilio webhook routes
|
|
490
|
+
const ingressConfig = loadConfig();
|
|
491
|
+
if (ingressConfig.ingress.mode === 'gateway_only' && GATEWAY_ONLY_BLOCKED_SUBPATHS.has(twilioSubpath)) {
|
|
492
|
+
return Response.json(
|
|
493
|
+
{ error: 'Direct webhook access disabled in gateway-only mode. Use the gateway.', code: 'GATEWAY_ONLY' },
|
|
494
|
+
{ status: 410 },
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
359
498
|
// Validate Twilio request signature before dispatching
|
|
360
499
|
const validation = await validateTwilioWebhook(req);
|
|
361
500
|
if (validation instanceof Response) return validation;
|
|
@@ -519,8 +658,8 @@ export class RuntimeHttpServer {
|
|
|
519
658
|
return await handleCreateRun(req, this.runOrchestrator);
|
|
520
659
|
}
|
|
521
660
|
|
|
522
|
-
// Match runs/:runId, runs/:runId/decision, runs/:runId/trust-rule
|
|
523
|
-
const runsMatch = endpoint.match(/^runs\/([^/]+)(\/decision|\/trust-rule)?$/);
|
|
661
|
+
// Match runs/:runId, runs/:runId/decision, runs/:runId/trust-rule, runs/:runId/secret
|
|
662
|
+
const runsMatch = endpoint.match(/^runs\/([^/]+)(\/decision|\/trust-rule|\/secret)?$/);
|
|
524
663
|
if (runsMatch) {
|
|
525
664
|
if (!this.runOrchestrator) {
|
|
526
665
|
return Response.json({ error: 'Run orchestration not configured' }, { status: 503 });
|
|
@@ -529,6 +668,9 @@ export class RuntimeHttpServer {
|
|
|
529
668
|
if (runsMatch[2] === '/decision' && req.method === 'POST') {
|
|
530
669
|
return await handleRunDecision(runId, req, this.runOrchestrator);
|
|
531
670
|
}
|
|
671
|
+
if (runsMatch[2] === '/secret' && req.method === 'POST') {
|
|
672
|
+
return await handleRunSecret(runId, req, this.runOrchestrator);
|
|
673
|
+
}
|
|
532
674
|
if (runsMatch[2] === '/trust-rule' && req.method === 'POST') {
|
|
533
675
|
const run = this.runOrchestrator.getRun(runId);
|
|
534
676
|
if (!run) {
|
|
@@ -551,7 +693,7 @@ export class RuntimeHttpServer {
|
|
|
551
693
|
}
|
|
552
694
|
|
|
553
695
|
if (endpoint === 'channels/inbound' && req.method === 'POST') {
|
|
554
|
-
return await handleChannelInbound(req, this.processMessage);
|
|
696
|
+
return await handleChannelInbound(req, this.processMessage, this.bearerToken);
|
|
555
697
|
}
|
|
556
698
|
|
|
557
699
|
if (endpoint === 'channels/delivery-ack' && req.method === 'POST') {
|
|
@@ -628,6 +770,31 @@ export class RuntimeHttpServer {
|
|
|
628
770
|
return await handleConnectAction(fakeReq);
|
|
629
771
|
}
|
|
630
772
|
|
|
773
|
+
if (endpoint === 'events' && req.method === 'GET') {
|
|
774
|
+
return handleSubscribeAssistantEvents(req, url);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ── Internal OAuth callback endpoint (gateway → runtime) ──
|
|
778
|
+
if (endpoint === 'internal/oauth/callback' && req.method === 'POST') {
|
|
779
|
+
const json = await req.json() as { state: string; code?: string; error?: string };
|
|
780
|
+
if (!json.state) {
|
|
781
|
+
return Response.json({ error: 'Missing state parameter' }, { status: 400 });
|
|
782
|
+
}
|
|
783
|
+
if (json.error) {
|
|
784
|
+
const consumed = consumeCallbackError(json.state, json.error);
|
|
785
|
+
return consumed
|
|
786
|
+
? Response.json({ ok: true })
|
|
787
|
+
: Response.json({ error: 'Unknown state' }, { status: 404 });
|
|
788
|
+
}
|
|
789
|
+
if (json.code) {
|
|
790
|
+
const consumed = consumeCallback(json.state, json.code);
|
|
791
|
+
return consumed
|
|
792
|
+
? Response.json({ ok: true })
|
|
793
|
+
: Response.json({ error: 'Unknown state' }, { status: 404 });
|
|
794
|
+
}
|
|
795
|
+
return Response.json({ error: 'Missing code or error parameter' }, { status: 400 });
|
|
796
|
+
}
|
|
797
|
+
|
|
631
798
|
return Response.json({ error: 'Not found', source: 'runtime' }, { status: 404 });
|
|
632
799
|
} catch (err) {
|
|
633
800
|
if (err instanceof IngressBlockedError) {
|
|
@@ -751,7 +918,7 @@ export class RuntimeHttpServer {
|
|
|
751
918
|
chatId: externalChatId,
|
|
752
919
|
text: rendered.text || undefined,
|
|
753
920
|
attachments: replyAttachments.length > 0 ? replyAttachments : undefined,
|
|
754
|
-
});
|
|
921
|
+
}, this.bearerToken);
|
|
755
922
|
}
|
|
756
923
|
break;
|
|
757
924
|
}
|
|
@@ -42,6 +42,7 @@ export async function handleDeleteConversation(req: Request): Promise<Response>
|
|
|
42
42
|
export async function handleChannelInbound(
|
|
43
43
|
req: Request,
|
|
44
44
|
processMessage?: MessageProcessor,
|
|
45
|
+
bearerToken?: string,
|
|
45
46
|
): Promise<Response> {
|
|
46
47
|
const body = await req.json() as {
|
|
47
48
|
sourceChannel?: string;
|
|
@@ -229,6 +230,7 @@ export async function handleChannelInbound(
|
|
|
229
230
|
metadataHints,
|
|
230
231
|
metadataUxBrief,
|
|
231
232
|
replyCallbackUrl,
|
|
233
|
+
bearerToken,
|
|
232
234
|
});
|
|
233
235
|
}
|
|
234
236
|
|
|
@@ -250,6 +252,7 @@ interface BackgroundProcessingParams {
|
|
|
250
252
|
metadataHints: string[];
|
|
251
253
|
metadataUxBrief?: string;
|
|
252
254
|
replyCallbackUrl?: string;
|
|
255
|
+
bearerToken?: string;
|
|
253
256
|
}
|
|
254
257
|
|
|
255
258
|
function processChannelMessageInBackground(params: BackgroundProcessingParams): void {
|
|
@@ -264,6 +267,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
|
|
|
264
267
|
metadataHints,
|
|
265
268
|
metadataUxBrief,
|
|
266
269
|
replyCallbackUrl,
|
|
270
|
+
bearerToken,
|
|
267
271
|
} = params;
|
|
268
272
|
|
|
269
273
|
(async () => {
|
|
@@ -285,7 +289,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
|
|
|
285
289
|
channelDeliveryStore.markProcessed(eventId);
|
|
286
290
|
|
|
287
291
|
if (replyCallbackUrl) {
|
|
288
|
-
await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl);
|
|
292
|
+
await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
|
|
289
293
|
}
|
|
290
294
|
} catch (err) {
|
|
291
295
|
log.error({ err, conversationId }, 'Background channel message processing failed');
|
|
@@ -298,6 +302,7 @@ async function deliverReplyViaCallback(
|
|
|
298
302
|
conversationId: string,
|
|
299
303
|
externalChatId: string,
|
|
300
304
|
callbackUrl: string,
|
|
305
|
+
bearerToken?: string,
|
|
301
306
|
): Promise<void> {
|
|
302
307
|
const msgs = conversationStore.getMessages(conversationId);
|
|
303
308
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
@@ -320,7 +325,7 @@ async function deliverReplyViaCallback(
|
|
|
320
325
|
chatId: externalChatId,
|
|
321
326
|
text: rendered.text || undefined,
|
|
322
327
|
attachments: replyAttachments.length > 0 ? replyAttachments : undefined,
|
|
323
|
-
});
|
|
328
|
+
}, bearerToken);
|
|
324
329
|
}
|
|
325
330
|
break;
|
|
326
331
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route handler for the assistant-events SSE endpoint.
|
|
3
|
+
*
|
|
4
|
+
* GET /v1/events?conversationKey=...
|
|
5
|
+
*
|
|
6
|
+
* Auth is enforced by RuntimeHttpServer before this handler is called.
|
|
7
|
+
* Subscribers receive all assistant events scoped to the given conversation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getOrCreateConversation } from '../../memory/conversation-key-store.js';
|
|
11
|
+
import { assistantEventHub } from '../assistant-event-hub.js';
|
|
12
|
+
import { formatSseFrame } from '../assistant-event.js';
|
|
13
|
+
import type { AssistantEventSubscription } from '../assistant-event-hub.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stream assistant events as Server-Sent Events for a specific conversation.
|
|
17
|
+
*
|
|
18
|
+
* Query params:
|
|
19
|
+
* conversationKey — required; scopes the stream to one conversation.
|
|
20
|
+
*/
|
|
21
|
+
export function handleSubscribeAssistantEvents(
|
|
22
|
+
req: Request,
|
|
23
|
+
url: URL,
|
|
24
|
+
): Response {
|
|
25
|
+
const conversationKey = url.searchParams.get('conversationKey');
|
|
26
|
+
if (!conversationKey) {
|
|
27
|
+
return Response.json({ error: 'conversationKey is required' }, { status: 400 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const mapping = getOrCreateConversation(conversationKey);
|
|
31
|
+
const encoder = new TextEncoder();
|
|
32
|
+
let sub: AssistantEventSubscription | null = null;
|
|
33
|
+
|
|
34
|
+
// Allow up to 16 queued frames before treating the consumer as stalled.
|
|
35
|
+
// This absorbs normal token-stream bursts without prematurely closing the
|
|
36
|
+
// connection, while still shedding genuinely slow clients.
|
|
37
|
+
const stream = new ReadableStream({
|
|
38
|
+
start(controller) {
|
|
39
|
+
// 'self' is the assistantId that RunOrchestrator assigns to all HTTP-run events
|
|
40
|
+
// (see buildAssistantEvent('self', ...) in run-orchestrator.ts). This endpoint
|
|
41
|
+
// is part of the HTTP runtime API, so only HTTP-run events are relevant here.
|
|
42
|
+
// IPC/daemon events use a different assistantId ('default') and reach desktop
|
|
43
|
+
// clients through a separate channel — they are intentionally excluded.
|
|
44
|
+
sub = assistantEventHub.subscribe(
|
|
45
|
+
{ assistantId: 'self', sessionId: mapping.conversationId },
|
|
46
|
+
(event) => {
|
|
47
|
+
try {
|
|
48
|
+
// Shed stalled consumers: desiredSize <= 0 means the 16-event buffer
|
|
49
|
+
// is full and the client isn't draining it.
|
|
50
|
+
if (controller.desiredSize !== null && controller.desiredSize <= 0) {
|
|
51
|
+
sub?.dispose();
|
|
52
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
controller.enqueue(encoder.encode(formatSseFrame(event)));
|
|
56
|
+
} catch {
|
|
57
|
+
sub?.dispose();
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
req.signal.addEventListener('abort', () => {
|
|
63
|
+
sub?.dispose();
|
|
64
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
65
|
+
}, { once: true });
|
|
66
|
+
},
|
|
67
|
+
cancel() {
|
|
68
|
+
sub?.dispose();
|
|
69
|
+
},
|
|
70
|
+
}, new CountQueuingStrategy({ highWaterMark: 16 }));
|
|
71
|
+
|
|
72
|
+
return new Response(stream, {
|
|
73
|
+
headers: {
|
|
74
|
+
'Content-Type': 'text/event-stream',
|
|
75
|
+
'Cache-Control': 'no-cache',
|
|
76
|
+
'Connection': 'keep-alive',
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -88,6 +88,7 @@ export function handleGetRun(
|
|
|
88
88
|
status: run.status,
|
|
89
89
|
messageId: run.messageId,
|
|
90
90
|
pendingConfirmation: run.pendingConfirmation,
|
|
91
|
+
pendingSecret: run.pendingSecret,
|
|
91
92
|
error: run.error,
|
|
92
93
|
createdAt: new Date(run.createdAt).toISOString(),
|
|
93
94
|
updatedAt: new Date(run.updatedAt).toISOString(),
|
|
@@ -217,3 +218,45 @@ export async function handleAddTrustRule(
|
|
|
217
218
|
return Response.json({ error: 'Failed to add trust rule' }, { status: 500 });
|
|
218
219
|
}
|
|
219
220
|
}
|
|
221
|
+
|
|
222
|
+
export async function handleRunSecret(
|
|
223
|
+
runId: string,
|
|
224
|
+
req: Request,
|
|
225
|
+
runOrchestrator: RunOrchestrator,
|
|
226
|
+
): Promise<Response> {
|
|
227
|
+
const run = runOrchestrator.getRun(runId);
|
|
228
|
+
if (!run) {
|
|
229
|
+
return Response.json({ error: 'Run not found' }, { status: 404 });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const body = await req.json() as {
|
|
233
|
+
value?: string;
|
|
234
|
+
delivery?: string;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const { value, delivery } = body;
|
|
238
|
+
|
|
239
|
+
if (delivery !== undefined && delivery !== 'store' && delivery !== 'transient_send') {
|
|
240
|
+
return Response.json(
|
|
241
|
+
{ error: 'delivery must be "store" or "transient_send"' },
|
|
242
|
+
{ status: 400 },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const result = runOrchestrator.submitSecret(
|
|
247
|
+
runId,
|
|
248
|
+
value,
|
|
249
|
+
delivery as 'store' | 'transient_send' | undefined,
|
|
250
|
+
);
|
|
251
|
+
if (result === 'run_not_found') {
|
|
252
|
+
return Response.json({ error: 'Run not found' }, { status: 404 });
|
|
253
|
+
}
|
|
254
|
+
if (result === 'no_pending_secret') {
|
|
255
|
+
return Response.json(
|
|
256
|
+
{ error: 'No secret pending for this run' },
|
|
257
|
+
{ status: 409 },
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return Response.json({ accepted: true });
|
|
262
|
+
}
|