vellum 0.2.8 → 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 +2 -2
- package/package.json +3 -2
- 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 +91 -11
- package/src/__tests__/ingress-url-consistency.test.ts +214 -0
- package/src/__tests__/ipc-snapshot.test.ts +17 -16
- package/src/__tests__/oauth2-gateway-transport.test.ts +7 -1
- package/src/__tests__/public-ingress-urls.test.ts +50 -34
- package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
- package/src/__tests__/runtime-events-sse.test.ts +162 -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 +2 -3
- 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 +1 -2
- package/src/config/schema.ts +2 -6
- 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/handlers/config.ts +33 -50
- 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/ipc-contract-inventory.json +4 -4
- package/src/daemon/ipc-contract.ts +25 -21
- package/src/daemon/lifecycle.ts +9 -4
- package/src/daemon/server.ts +7 -0
- package/src/daemon/session-tool-setup.ts +1 -1
- package/src/inbound/public-ingress-urls.ts +36 -30
- package/src/memory/db.ts +132 -5
- package/src/memory/llm-usage-store.ts +0 -1
- package/src/memory/runs-store.ts +51 -3
- package/src/memory/schema.ts +2 -2
- package/src/runtime/gateway-client.ts +7 -1
- package/src/runtime/http-server.ts +95 -10
- 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 +10 -0
- package/src/security/oauth2.ts +41 -7
- package/src/subagent/manager.ts +3 -1
- 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/__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 -47
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized URL builders for all public-facing ingress endpoints.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* ingress.publicBaseUrl → calls.webhookBaseUrl → env TWILIO_WEBHOOK_BASE_URL
|
|
4
|
+
* ## Source-of-truth precedence
|
|
6
5
|
*
|
|
7
|
-
*
|
|
6
|
+
* The canonical public base URL is resolved through a two-level chain:
|
|
7
|
+
*
|
|
8
|
+
* 1. **User Settings** (`config.ingress.publicBaseUrl`) — set via the
|
|
9
|
+
* Settings UI or `config set ingress.publicBaseUrl`. This is the
|
|
10
|
+
* primary source of truth. When the assistant spawns or restarts
|
|
11
|
+
* the gateway, this value is forwarded as the `INGRESS_PUBLIC_BASE_URL`
|
|
12
|
+
* environment variable so both processes agree on the same URL.
|
|
13
|
+
*
|
|
14
|
+
* 2. **Environment variable** (`INGRESS_PUBLIC_BASE_URL`) — serves as a
|
|
15
|
+
* fallback for operational use (e.g. direct gateway-only deployments
|
|
16
|
+
* without the assistant, or CI overrides). When the assistant is
|
|
17
|
+
* managing the gateway, the env var is set automatically from (1).
|
|
18
|
+
*
|
|
19
|
+
* This chain ensures that:
|
|
20
|
+
* - The assistant's outbound callback URLs (Twilio webhooks, OAuth
|
|
21
|
+
* redirect URIs, etc.) match the gateway's inbound signature
|
|
22
|
+
* reconstruction URL.
|
|
23
|
+
* - Changing the URL in Settings propagates to the gateway on restart,
|
|
24
|
+
* eliminating Twilio signature mismatch risk.
|
|
25
|
+
*
|
|
26
|
+
* All public-facing ingress URL construction is centralized here.
|
|
8
27
|
*/
|
|
9
28
|
|
|
10
|
-
import { getLogger } from '../util/logger.js';
|
|
11
|
-
|
|
12
|
-
const log = getLogger('public-ingress-urls');
|
|
13
|
-
|
|
14
29
|
export interface IngressConfig {
|
|
15
30
|
ingress?: { publicBaseUrl?: string };
|
|
16
|
-
calls?: { webhookBaseUrl?: string };
|
|
17
31
|
}
|
|
18
32
|
|
|
19
33
|
/**
|
|
@@ -24,10 +38,8 @@ function normalizeUrl(url: string): string {
|
|
|
24
38
|
}
|
|
25
39
|
|
|
26
40
|
/**
|
|
27
|
-
* Resolve the canonical public base URL
|
|
28
|
-
*
|
|
29
|
-
* 2. calls.webhookBaseUrl (backward compat)
|
|
30
|
-
* 3. TWILIO_WEBHOOK_BASE_URL env var (legacy, deprecated)
|
|
41
|
+
* Resolve the canonical public base URL using the precedence chain
|
|
42
|
+
* documented at the top of this module.
|
|
31
43
|
*
|
|
32
44
|
* Throws if no source provides a non-empty value.
|
|
33
45
|
*/
|
|
@@ -38,28 +50,14 @@ export function getPublicBaseUrl(config: IngressConfig): string {
|
|
|
38
50
|
if (normalized) return normalized;
|
|
39
51
|
}
|
|
40
52
|
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
const normalized = normalizeUrl(
|
|
44
|
-
if (normalized) {
|
|
45
|
-
log.warn(
|
|
46
|
-
'Using calls.webhookBaseUrl as public base URL — set ingress.publicBaseUrl instead.',
|
|
47
|
-
);
|
|
48
|
-
return normalized;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const envValue = process.env.TWILIO_WEBHOOK_BASE_URL;
|
|
53
|
-
if (envValue) {
|
|
54
|
-
log.warn(
|
|
55
|
-
'TWILIO_WEBHOOK_BASE_URL env var is deprecated — set ingress.publicBaseUrl in config instead.',
|
|
56
|
-
);
|
|
57
|
-
const normalized = normalizeUrl(envValue);
|
|
53
|
+
const ingressEnvValue = process.env.INGRESS_PUBLIC_BASE_URL;
|
|
54
|
+
if (ingressEnvValue) {
|
|
55
|
+
const normalized = normalizeUrl(ingressEnvValue);
|
|
58
56
|
if (normalized) return normalized;
|
|
59
57
|
}
|
|
60
58
|
|
|
61
59
|
throw new Error(
|
|
62
|
-
'No public base URL configured. Set ingress.publicBaseUrl in config
|
|
60
|
+
'No public base URL configured. Set ingress.publicBaseUrl in config or INGRESS_PUBLIC_BASE_URL env var.',
|
|
63
61
|
);
|
|
64
62
|
}
|
|
65
63
|
|
|
@@ -104,3 +102,11 @@ export function getOAuthCallbackUrl(config: IngressConfig): string {
|
|
|
104
102
|
const base = getPublicBaseUrl(config);
|
|
105
103
|
return `${base}/webhooks/oauth/callback`;
|
|
106
104
|
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build the Telegram webhook URL.
|
|
108
|
+
*/
|
|
109
|
+
export function getTelegramWebhookUrl(config: IngressConfig): string {
|
|
110
|
+
const base = getPublicBaseUrl(config);
|
|
111
|
+
return `${base}/webhooks/telegram`;
|
|
112
|
+
}
|
package/src/memory/db.ts
CHANGED
|
@@ -241,6 +241,7 @@ export function initializeDb(): void {
|
|
|
241
241
|
message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
|
|
242
242
|
status TEXT NOT NULL DEFAULT 'running',
|
|
243
243
|
pending_confirmation TEXT,
|
|
244
|
+
pending_secret TEXT,
|
|
244
245
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
245
246
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
246
247
|
estimated_cost REAL NOT NULL DEFAULT 0,
|
|
@@ -250,6 +251,8 @@ export function initializeDb(): void {
|
|
|
250
251
|
)
|
|
251
252
|
`);
|
|
252
253
|
|
|
254
|
+
try { database.run(/*sql*/ `ALTER TABLE message_runs ADD COLUMN pending_secret TEXT`); } catch (e) { log.debug({ err: e }, 'ALTER TABLE message_runs ADD COLUMN pending_secret (likely already exists)'); }
|
|
255
|
+
|
|
253
256
|
database.run(/*sql*/ `
|
|
254
257
|
CREATE TABLE IF NOT EXISTS reminders (
|
|
255
258
|
id TEXT PRIMARY KEY,
|
|
@@ -418,7 +421,6 @@ export function initializeDb(): void {
|
|
|
418
421
|
CREATE TABLE IF NOT EXISTS llm_usage_events (
|
|
419
422
|
id TEXT PRIMARY KEY,
|
|
420
423
|
created_at INTEGER NOT NULL,
|
|
421
|
-
assistant_id TEXT,
|
|
422
424
|
conversation_id TEXT,
|
|
423
425
|
run_id TEXT,
|
|
424
426
|
request_id TEXT,
|
|
@@ -548,6 +550,7 @@ export function initializeDb(): void {
|
|
|
548
550
|
migrateMemoryItemsScopeSaltedFingerprints(database);
|
|
549
551
|
migrateAssistantIdToSelf(database);
|
|
550
552
|
migrateRemoveAssistantIdColumns(database);
|
|
553
|
+
migrateLlmUsageEventsDropAssistantId(database);
|
|
551
554
|
|
|
552
555
|
// Indexes for query performance on large datasets
|
|
553
556
|
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_request_logs_conv_created ON llm_request_logs(conversation_id, created_at)`);
|
|
@@ -610,7 +613,6 @@ export function initializeDb(): void {
|
|
|
610
613
|
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status)`);
|
|
611
614
|
|
|
612
615
|
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_created_at ON llm_usage_events(created_at)`);
|
|
613
|
-
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_assistant_id ON llm_usage_events(assistant_id)`);
|
|
614
616
|
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_provider ON llm_usage_events(provider)`);
|
|
615
617
|
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_model ON llm_usage_events(model)`);
|
|
616
618
|
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_actor ON llm_usage_events(actor)`);
|
|
@@ -1438,19 +1440,22 @@ function migrateAssistantIdToSelf(database: ReturnType<typeof drizzle<typeof sch
|
|
|
1438
1440
|
}
|
|
1439
1441
|
|
|
1440
1442
|
/**
|
|
1441
|
-
* One-shot migration: rebuild
|
|
1442
|
-
*
|
|
1443
|
-
* identity ("self").
|
|
1443
|
+
* One-shot migration: rebuild tables that previously stored assistant_id to remove
|
|
1444
|
+
* that column now that all rows are keyed to the implicit single-tenant identity ("self").
|
|
1444
1445
|
*
|
|
1445
1446
|
* Must run AFTER migrateAssistantIdToSelf (which normalises all values to "self")
|
|
1446
1447
|
* so there are no constraint violations when recreating the tables without the
|
|
1447
1448
|
* assistant_id dimension.
|
|
1448
1449
|
*
|
|
1450
|
+
* Each table section is guarded by a DDL check so this is safe on fresh installs
|
|
1451
|
+
* where the column was never created in the first place.
|
|
1452
|
+
*
|
|
1449
1453
|
* Tables rebuilt:
|
|
1450
1454
|
* - conversation_keys UNIQUE (conversation_key)
|
|
1451
1455
|
* - attachments no structural unique; content-dedup index updated
|
|
1452
1456
|
* - channel_inbound_events UNIQUE (source_channel, external_chat_id, external_message_id)
|
|
1453
1457
|
* - message_runs no unique constraint on assistant_id
|
|
1458
|
+
* - llm_usage_events nullable column with no constraint
|
|
1454
1459
|
*/
|
|
1455
1460
|
function migrateRemoveAssistantIdColumns(database: ReturnType<typeof drizzle<typeof schema>>): void {
|
|
1456
1461
|
const raw = (database as unknown as { $client: Database }).$client;
|
|
@@ -1588,6 +1593,128 @@ function migrateRemoveAssistantIdColumns(database: ReturnType<typeof drizzle<typ
|
|
|
1588
1593
|
raw.exec(/*sql*/ `ALTER TABLE message_runs_new RENAME TO message_runs`);
|
|
1589
1594
|
}
|
|
1590
1595
|
|
|
1596
|
+
// --- llm_usage_events ---
|
|
1597
|
+
const lueDdl = raw.query(
|
|
1598
|
+
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'llm_usage_events'`,
|
|
1599
|
+
).get() as { sql: string } | null;
|
|
1600
|
+
if (lueDdl?.sql.includes('assistant_id')) {
|
|
1601
|
+
raw.exec(/*sql*/ `
|
|
1602
|
+
CREATE TABLE llm_usage_events_new (
|
|
1603
|
+
id TEXT PRIMARY KEY,
|
|
1604
|
+
created_at INTEGER NOT NULL,
|
|
1605
|
+
conversation_id TEXT,
|
|
1606
|
+
run_id TEXT,
|
|
1607
|
+
request_id TEXT,
|
|
1608
|
+
actor TEXT NOT NULL,
|
|
1609
|
+
provider TEXT NOT NULL,
|
|
1610
|
+
model TEXT NOT NULL,
|
|
1611
|
+
input_tokens INTEGER NOT NULL,
|
|
1612
|
+
output_tokens INTEGER NOT NULL,
|
|
1613
|
+
cache_creation_input_tokens INTEGER,
|
|
1614
|
+
cache_read_input_tokens INTEGER,
|
|
1615
|
+
estimated_cost_usd REAL,
|
|
1616
|
+
pricing_status TEXT NOT NULL,
|
|
1617
|
+
metadata_json TEXT
|
|
1618
|
+
)
|
|
1619
|
+
`);
|
|
1620
|
+
raw.exec(/*sql*/ `
|
|
1621
|
+
INSERT INTO llm_usage_events_new (
|
|
1622
|
+
id, created_at, conversation_id, run_id, request_id, actor, provider, model,
|
|
1623
|
+
input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
|
|
1624
|
+
estimated_cost_usd, pricing_status, metadata_json
|
|
1625
|
+
)
|
|
1626
|
+
SELECT
|
|
1627
|
+
id, created_at, conversation_id, run_id, request_id, actor, provider, model,
|
|
1628
|
+
input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
|
|
1629
|
+
estimated_cost_usd, pricing_status, metadata_json
|
|
1630
|
+
FROM llm_usage_events
|
|
1631
|
+
`);
|
|
1632
|
+
raw.exec(/*sql*/ `DROP TABLE llm_usage_events`);
|
|
1633
|
+
raw.exec(/*sql*/ `ALTER TABLE llm_usage_events_new RENAME TO llm_usage_events`);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
raw.query(
|
|
1637
|
+
`INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
1638
|
+
).run(checkpointKey, Date.now());
|
|
1639
|
+
|
|
1640
|
+
raw.exec('COMMIT');
|
|
1641
|
+
} catch (e) {
|
|
1642
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
1643
|
+
throw e;
|
|
1644
|
+
} finally {
|
|
1645
|
+
raw.exec('PRAGMA foreign_keys = ON');
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* One-shot migration: rebuild llm_usage_events to drop the assistant_id column.
|
|
1651
|
+
*
|
|
1652
|
+
* This is a SEPARATE migration from migrateRemoveAssistantIdColumns so that installs
|
|
1653
|
+
* where the 4-table version of that migration already ran (checkpoint already set)
|
|
1654
|
+
* still get the llm_usage_events column removed. Without a separate checkpoint key,
|
|
1655
|
+
* those installs would skip the llm_usage_events rebuild entirely.
|
|
1656
|
+
*
|
|
1657
|
+
* Safe on fresh installs (DDL guard exits early) and idempotent via checkpoint.
|
|
1658
|
+
*/
|
|
1659
|
+
function migrateLlmUsageEventsDropAssistantId(database: ReturnType<typeof drizzle<typeof schema>>): void {
|
|
1660
|
+
const raw = (database as unknown as { $client: Database }).$client;
|
|
1661
|
+
const checkpointKey = 'migration_remove_assistant_id_lue_v1';
|
|
1662
|
+
const checkpoint = raw.query(
|
|
1663
|
+
`SELECT 1 FROM memory_checkpoints WHERE key = ?`,
|
|
1664
|
+
).get(checkpointKey);
|
|
1665
|
+
if (checkpoint) return;
|
|
1666
|
+
|
|
1667
|
+
// DDL guard: if the column was already removed (fresh install or migrateRemoveAssistantIdColumns
|
|
1668
|
+
// ran with the llm_usage_events block), just record the checkpoint and exit.
|
|
1669
|
+
const lueDdl = raw.query(
|
|
1670
|
+
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'llm_usage_events'`,
|
|
1671
|
+
).get() as { sql: string } | null;
|
|
1672
|
+
|
|
1673
|
+
if (!lueDdl?.sql.includes('assistant_id')) {
|
|
1674
|
+
raw.query(
|
|
1675
|
+
`INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
1676
|
+
).run(checkpointKey, Date.now());
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
raw.exec('PRAGMA foreign_keys = OFF');
|
|
1681
|
+
try {
|
|
1682
|
+
raw.exec('BEGIN');
|
|
1683
|
+
|
|
1684
|
+
raw.exec(/*sql*/ `
|
|
1685
|
+
CREATE TABLE llm_usage_events_new (
|
|
1686
|
+
id TEXT PRIMARY KEY,
|
|
1687
|
+
created_at INTEGER NOT NULL,
|
|
1688
|
+
conversation_id TEXT,
|
|
1689
|
+
run_id TEXT,
|
|
1690
|
+
request_id TEXT,
|
|
1691
|
+
actor TEXT NOT NULL,
|
|
1692
|
+
provider TEXT NOT NULL,
|
|
1693
|
+
model TEXT NOT NULL,
|
|
1694
|
+
input_tokens INTEGER NOT NULL,
|
|
1695
|
+
output_tokens INTEGER NOT NULL,
|
|
1696
|
+
cache_creation_input_tokens INTEGER,
|
|
1697
|
+
cache_read_input_tokens INTEGER,
|
|
1698
|
+
estimated_cost_usd REAL,
|
|
1699
|
+
pricing_status TEXT NOT NULL,
|
|
1700
|
+
metadata_json TEXT
|
|
1701
|
+
)
|
|
1702
|
+
`);
|
|
1703
|
+
raw.exec(/*sql*/ `
|
|
1704
|
+
INSERT INTO llm_usage_events_new (
|
|
1705
|
+
id, created_at, conversation_id, run_id, request_id, actor, provider, model,
|
|
1706
|
+
input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
|
|
1707
|
+
estimated_cost_usd, pricing_status, metadata_json
|
|
1708
|
+
)
|
|
1709
|
+
SELECT
|
|
1710
|
+
id, created_at, conversation_id, run_id, request_id, actor, provider, model,
|
|
1711
|
+
input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens,
|
|
1712
|
+
estimated_cost_usd, pricing_status, metadata_json
|
|
1713
|
+
FROM llm_usage_events
|
|
1714
|
+
`);
|
|
1715
|
+
raw.exec(/*sql*/ `DROP TABLE llm_usage_events`);
|
|
1716
|
+
raw.exec(/*sql*/ `ALTER TABLE llm_usage_events_new RENAME TO llm_usage_events`);
|
|
1717
|
+
|
|
1591
1718
|
raw.query(
|
|
1592
1719
|
`INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
1593
1720
|
).run(checkpointKey, Date.now());
|
|
@@ -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,12 +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
51
|
conversationId: string;
|
|
40
52
|
messageId: string | null;
|
|
41
53
|
status: RunStatus;
|
|
42
54
|
pendingConfirmation: PendingConfirmation | null;
|
|
55
|
+
pendingSecret: PendingSecret | null;
|
|
43
56
|
inputTokens: number;
|
|
44
57
|
outputTokens: number;
|
|
45
58
|
estimatedCost: number;
|
|
@@ -63,12 +76,17 @@ function rowToRun(row: typeof messageRuns.$inferSelect): Run {
|
|
|
63
76
|
if (row.pendingConfirmation) {
|
|
64
77
|
try { pendingConfirmation = JSON.parse(row.pendingConfirmation); } catch { /* malformed */ }
|
|
65
78
|
}
|
|
79
|
+
let pendingSecret: PendingSecret | null = null;
|
|
80
|
+
if (row.pendingSecret) {
|
|
81
|
+
try { pendingSecret = JSON.parse(row.pendingSecret); } catch { /* malformed */ }
|
|
82
|
+
}
|
|
66
83
|
return {
|
|
67
84
|
id: row.id,
|
|
68
85
|
conversationId: row.conversationId,
|
|
69
86
|
messageId: row.messageId,
|
|
70
87
|
status: row.status as RunStatus,
|
|
71
88
|
pendingConfirmation,
|
|
89
|
+
pendingSecret,
|
|
72
90
|
inputTokens: row.inputTokens,
|
|
73
91
|
outputTokens: row.outputTokens,
|
|
74
92
|
estimatedCost: row.estimatedCost,
|
|
@@ -96,6 +114,7 @@ export function createRun(
|
|
|
96
114
|
messageId: messageId ?? null,
|
|
97
115
|
status: 'running' as const,
|
|
98
116
|
pendingConfirmation: null,
|
|
117
|
+
pendingSecret: null,
|
|
99
118
|
inputTokens: 0,
|
|
100
119
|
outputTokens: 0,
|
|
101
120
|
estimatedCost: 0,
|
|
@@ -144,6 +163,35 @@ export function clearRunConfirmation(runId: string): void {
|
|
|
144
163
|
.run();
|
|
145
164
|
}
|
|
146
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
|
+
|
|
147
195
|
export function completeRun(runId: string, usage?: RunUsage): void {
|
|
148
196
|
const db = getDb();
|
|
149
197
|
const now = Date.now();
|
|
@@ -177,13 +225,13 @@ export function failRun(runId: string, error: string): void {
|
|
|
177
225
|
/**
|
|
178
226
|
* Mark all non-terminal runs as failed.
|
|
179
227
|
* Called on startup to recover from daemon restarts that left runs
|
|
180
|
-
* 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.
|
|
181
229
|
* Returns the number of rows affected.
|
|
182
230
|
*/
|
|
183
231
|
export function failOrphanedRuns(): number {
|
|
184
232
|
const db = getDb();
|
|
185
233
|
const now = Date.now();
|
|
186
|
-
const activeStatuses = ['running', 'needs_confirmation'];
|
|
234
|
+
const activeStatuses = ['running', 'needs_confirmation', 'needs_secret'];
|
|
187
235
|
|
|
188
236
|
// Count first so we can report how many were recovered.
|
|
189
237
|
const active = db.select({ id: messageRuns.id })
|
package/src/memory/schema.ts
CHANGED
|
@@ -209,8 +209,9 @@ export const messageRuns = sqliteTable('message_runs', {
|
|
|
209
209
|
.references(() => conversations.id, { onDelete: 'cascade' }),
|
|
210
210
|
messageId: text('message_id')
|
|
211
211
|
.references(() => messages.id, { onDelete: 'cascade' }),
|
|
212
|
-
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
|
|
213
213
|
pendingConfirmation: text('pending_confirmation'), // JSON when status=needs_confirmation
|
|
214
|
+
pendingSecret: text('pending_secret'), // JSON when status=needs_secret
|
|
214
215
|
inputTokens: integer('input_tokens').notNull().default(0),
|
|
215
216
|
outputTokens: integer('output_tokens').notNull().default(0),
|
|
216
217
|
estimatedCost: real('estimated_cost').notNull().default(0),
|
|
@@ -519,7 +520,6 @@ export const llmRequestLogs = sqliteTable('llm_request_logs', {
|
|
|
519
520
|
export const llmUsageEvents = sqliteTable('llm_usage_events', {
|
|
520
521
|
id: text('id').primaryKey(),
|
|
521
522
|
createdAt: integer('created_at').notNull(),
|
|
522
|
-
assistantId: text('assistant_id'),
|
|
523
523
|
conversationId: text('conversation_id'),
|
|
524
524
|
runId: text('run_id'),
|
|
525
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
|
});
|
|
@@ -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,7 @@ 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';
|
|
68
70
|
import { consumeCallback, consumeCallbackError } from '../security/oauth-callback-registry.js';
|
|
69
71
|
|
|
70
72
|
// Re-export shared types so existing consumers don't need to update imports
|
|
@@ -145,16 +147,21 @@ const GATEWAY_SUBPATH_MAP: Record<string, string> = {
|
|
|
145
147
|
const GATEWAY_ONLY_BLOCKED_SUBPATHS = new Set(['voice-webhook', 'status', 'connect-action']);
|
|
146
148
|
|
|
147
149
|
/**
|
|
148
|
-
* Check if a request origin is from
|
|
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.
|
|
149
153
|
*/
|
|
150
|
-
function
|
|
154
|
+
function isPrivateNetworkOrigin(req: Request): boolean {
|
|
151
155
|
const origin = req.headers.get('origin');
|
|
152
156
|
// No origin header (e.g., server-initiated or same-origin) — allow
|
|
153
157
|
if (!origin) return true;
|
|
154
158
|
try {
|
|
155
159
|
const url = new URL(origin);
|
|
156
160
|
const host = url.hostname;
|
|
157
|
-
|
|
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);
|
|
158
165
|
} catch {
|
|
159
166
|
return false;
|
|
160
167
|
}
|
|
@@ -167,6 +174,69 @@ function isLoopbackHost(hostname: string): boolean {
|
|
|
167
174
|
return hostname === '127.0.0.1' || hostname === '::1' || hostname === 'localhost';
|
|
168
175
|
}
|
|
169
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
|
+
|
|
170
240
|
/**
|
|
171
241
|
* Validate a Twilio webhook request's X-Twilio-Signature header.
|
|
172
242
|
*
|
|
@@ -276,6 +346,11 @@ export class RuntimeHttpServer {
|
|
|
276
346
|
this.interfacesDir = options.interfacesDir ?? null;
|
|
277
347
|
}
|
|
278
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
|
+
|
|
279
354
|
async start(): Promise<void> {
|
|
280
355
|
this.server = Bun.serve<RelayWebSocketData>({
|
|
281
356
|
port: this.port,
|
|
@@ -332,7 +407,7 @@ export class RuntimeHttpServer {
|
|
|
332
407
|
// Config loading may fail during startup — don't block server start
|
|
333
408
|
}
|
|
334
409
|
|
|
335
|
-
log.info({ port: this.
|
|
410
|
+
log.info({ port: this.actualPort, hostname: this.hostname, auth: !!this.bearerToken }, 'Runtime HTTP server listening');
|
|
336
411
|
}
|
|
337
412
|
|
|
338
413
|
async stop(): Promise<void> {
|
|
@@ -370,9 +445,12 @@ export class RuntimeHttpServer {
|
|
|
370
445
|
// WebSocket upgrade for ConversationRelay — before auth check because
|
|
371
446
|
// Twilio WebSocket connections don't use bearer tokens.
|
|
372
447
|
if (path.startsWith('/v1/calls/relay') && req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
373
|
-
// In gateway_only mode, only allow relay connections from
|
|
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).
|
|
374
452
|
const config = loadConfig();
|
|
375
|
-
if (config.ingress.mode === 'gateway_only' && !
|
|
453
|
+
if (config.ingress.mode === 'gateway_only' && (!isPrivateNetworkPeer(server, req) || !isPrivateNetworkOrigin(req))) {
|
|
376
454
|
return Response.json(
|
|
377
455
|
{ error: 'Direct relay access disabled in gateway-only mode', code: 'GATEWAY_ONLY' },
|
|
378
456
|
{ status: 403 },
|
|
@@ -580,8 +658,8 @@ export class RuntimeHttpServer {
|
|
|
580
658
|
return await handleCreateRun(req, this.runOrchestrator);
|
|
581
659
|
}
|
|
582
660
|
|
|
583
|
-
// Match runs/:runId, runs/:runId/decision, runs/:runId/trust-rule
|
|
584
|
-
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)?$/);
|
|
585
663
|
if (runsMatch) {
|
|
586
664
|
if (!this.runOrchestrator) {
|
|
587
665
|
return Response.json({ error: 'Run orchestration not configured' }, { status: 503 });
|
|
@@ -590,6 +668,9 @@ export class RuntimeHttpServer {
|
|
|
590
668
|
if (runsMatch[2] === '/decision' && req.method === 'POST') {
|
|
591
669
|
return await handleRunDecision(runId, req, this.runOrchestrator);
|
|
592
670
|
}
|
|
671
|
+
if (runsMatch[2] === '/secret' && req.method === 'POST') {
|
|
672
|
+
return await handleRunSecret(runId, req, this.runOrchestrator);
|
|
673
|
+
}
|
|
593
674
|
if (runsMatch[2] === '/trust-rule' && req.method === 'POST') {
|
|
594
675
|
const run = this.runOrchestrator.getRun(runId);
|
|
595
676
|
if (!run) {
|
|
@@ -612,7 +693,7 @@ export class RuntimeHttpServer {
|
|
|
612
693
|
}
|
|
613
694
|
|
|
614
695
|
if (endpoint === 'channels/inbound' && req.method === 'POST') {
|
|
615
|
-
return await handleChannelInbound(req, this.processMessage);
|
|
696
|
+
return await handleChannelInbound(req, this.processMessage, this.bearerToken);
|
|
616
697
|
}
|
|
617
698
|
|
|
618
699
|
if (endpoint === 'channels/delivery-ack' && req.method === 'POST') {
|
|
@@ -689,6 +770,10 @@ export class RuntimeHttpServer {
|
|
|
689
770
|
return await handleConnectAction(fakeReq);
|
|
690
771
|
}
|
|
691
772
|
|
|
773
|
+
if (endpoint === 'events' && req.method === 'GET') {
|
|
774
|
+
return handleSubscribeAssistantEvents(req, url);
|
|
775
|
+
}
|
|
776
|
+
|
|
692
777
|
// ── Internal OAuth callback endpoint (gateway → runtime) ──
|
|
693
778
|
if (endpoint === 'internal/oauth/callback' && req.method === 'POST') {
|
|
694
779
|
const json = await req.json() as { state: string; code?: string; error?: string };
|
|
@@ -833,7 +918,7 @@ export class RuntimeHttpServer {
|
|
|
833
918
|
chatId: externalChatId,
|
|
834
919
|
text: rendered.text || undefined,
|
|
835
920
|
attachments: replyAttachments.length > 0 ? replyAttachments : undefined,
|
|
836
|
-
});
|
|
921
|
+
}, this.bearerToken);
|
|
837
922
|
}
|
|
838
923
|
break;
|
|
839
924
|
}
|