@vellumai/vellum-gateway 0.4.55 → 0.4.57
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/AGENTS.md +1 -1
- package/ARCHITECTURE.md +8 -8
- package/Dockerfile +1 -1
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/__tests__/browser-relay-websocket.test.ts +6 -1
- package/src/__tests__/channel-verification-session-proxy.test.ts +6 -1
- package/src/__tests__/config-file-cache.test.ts +1 -1
- package/src/__tests__/config.test.ts +6 -1
- package/src/__tests__/contacts-control-plane-proxy.test.ts +6 -1
- package/src/__tests__/credential-reader.test.ts +107 -14
- package/src/__tests__/credential-watcher.test.ts +138 -28
- package/src/__tests__/feature-flags-route.test.ts +15 -44
- package/src/__tests__/guardian-init-lockfile.test.ts +199 -0
- package/src/__tests__/load-guards.test.ts +6 -1
- package/src/__tests__/oauth-callback.test.ts +6 -1
- package/src/__tests__/resolve-assistant.test.ts +6 -1
- package/src/__tests__/runtime-client.test.ts +45 -1
- package/src/__tests__/runtime-health-proxy.test.ts +6 -1
- package/src/__tests__/runtime-proxy-auth.test.ts +6 -1
- package/src/__tests__/runtime-proxy.test.ts +6 -1
- package/src/__tests__/slack-control-plane-proxy.test.ts +6 -1
- package/src/__tests__/slack-deliver-ratelimit.test.ts +6 -1
- package/src/__tests__/slack-deliver.test.ts +14 -2
- package/src/__tests__/slack-display-name.test.ts +6 -1
- package/src/__tests__/slack-normalize.test.ts +6 -1
- package/src/__tests__/slack-reaction-normalize.test.ts +6 -1
- package/src/__tests__/sleep-wake-detector.test.ts +83 -0
- package/src/__tests__/telegram-control-plane-proxy.test.ts +6 -1
- package/src/__tests__/telegram-deliver-auth.test.ts +6 -1
- package/src/__tests__/telegram-send-attachments.test.ts +22 -3
- package/src/__tests__/telegram-webhook-handler.test.ts +41 -1
- package/src/__tests__/twilio-relay-websocket.test.ts +6 -1
- package/src/__tests__/twilio-webhooks.test.ts +6 -1
- package/src/__tests__/whatsapp-download.test.ts +6 -1
- package/src/__tests__/whatsapp-webhook.test.ts +6 -1
- package/src/auth/subject.ts +7 -7
- package/src/auth/token-exchange.ts +1 -1
- package/src/auth/types.ts +1 -1
- package/src/channels/inbound-event.ts +1 -1
- package/src/config-file-cache.ts +1 -1
- package/src/config.ts +16 -4
- package/src/credential-reader.ts +55 -11
- package/src/credential-watcher.ts +36 -47
- package/src/feature-flag-registry.json +185 -33
- package/src/http/middleware/auth.ts +17 -2
- package/src/http/router.ts +11 -3
- package/src/http/routes/channel-verification-session-proxy.ts +52 -2
- package/src/http/routes/config-file-utils.ts +73 -0
- package/src/http/routes/contacts-control-plane-route-match.ts +9 -1
- package/src/http/routes/feature-flags.ts +6 -62
- package/src/http/routes/privacy-config.ts +101 -0
- package/src/http/routes/slack-deliver.ts +6 -2
- package/src/http/routes/telegram-deliver.test.ts +6 -1
- package/src/http/routes/telegram-webhook.test.ts +6 -1
- package/src/http/routes/telegram-webhook.ts +6 -2
- package/src/http/routes/twilio-voice-webhook.test.ts +6 -1
- package/src/http/routes/whatsapp-deliver.test.ts +6 -1
- package/src/http/routes/whatsapp-webhook.test.ts +6 -1
- package/src/http/routes/whatsapp-webhook.ts +6 -2
- package/src/index.ts +129 -3
- package/src/runtime/client.ts +59 -3
- package/src/schema.ts +248 -0
- package/src/slack/socket-mode.ts +110 -16
- package/src/sleep-wake-detector.ts +44 -0
- package/src/telegram/send.test.ts +6 -1
- package/src/telegram/send.ts +8 -2
- package/src/whatsapp/download.ts +5 -2
- package/src/whatsapp/send.ts +8 -2
- package/workspace/config.json +1 -3
package/AGENTS.md
CHANGED
|
@@ -35,7 +35,7 @@ All assistant API requests from clients, CLI, skills, and user-facing tooling **
|
|
|
35
35
|
|
|
36
36
|
Gateway inbound events use a channel-discriminated union model (`GatewayInboundEvent`) with explicit identity fields:
|
|
37
37
|
|
|
38
|
-
- **`conversationExternalId`**: Delivery/
|
|
38
|
+
- **`conversationExternalId`**: Delivery/conversation address (e.g., Telegram chat ID, phone number). Used for conversation binding and message routing. **Not** used for trust classification.
|
|
39
39
|
- **`actorExternalId`**: Sender identity (e.g., Telegram user ID, WhatsApp phone number). Used for trust classification, guardian binding, and ACL enforcement. **Required** for all public channel ingress.
|
|
40
40
|
- **"conversation"** is canonical vocabulary for delivery addresses. "thread" is reserved for provider-specific fields (Slack `thread_ts`, email thread IDs).
|
|
41
41
|
- **"actor"** is canonical vocabulary for sender identity.
|
package/ARCHITECTURE.md
CHANGED
|
@@ -215,13 +215,13 @@ Channel readiness endpoints are exposed directly by the gateway and forwarded to
|
|
|
215
215
|
|
|
216
216
|
### Channel Binding Lifecycle (Lane Separation)
|
|
217
217
|
|
|
218
|
-
Each channel (desktop, Telegram, etc.) operates in its own **lane**: conversations created by an external channel are never displayed in the desktop
|
|
218
|
+
Each channel (desktop, Telegram, etc.) operates in its own **lane**: conversations created by an external channel are never displayed in the desktop conversation list, and desktop conversations are never exposed to external channels. The `channelBinding` metadata on a conversation is used solely for routing inbound/outbound messages within that lane and for filtering conversations during desktop conversation restoration.
|
|
219
219
|
|
|
220
220
|
Channel bindings follow a three-phase lifecycle:
|
|
221
221
|
|
|
222
222
|
1. **Bind** — An inbound message from an external channel (e.g., Telegram chat) arrives at the gateway, which normalizes it and forwards it to the runtime's `/v1/channels/inbound` endpoint. The runtime creates or reuses a conversation, establishing the channel binding (`sourceChannel` metadata on the conversation).
|
|
223
223
|
|
|
224
|
-
2. **Route** — Subsequent messages on the same external chat are routed to the same conversation via the channel binding. Replies from the assistant are delivered back through the gateway's `/deliver/telegram` endpoint. The desktop client filters out channel-bound
|
|
224
|
+
2. **Route** — Subsequent messages on the same external chat are routed to the same conversation via the channel binding. Replies from the assistant are delivered back through the gateway's `/deliver/telegram` endpoint. The desktop client filters out channel-bound conversations during conversation restoration (`ConversationRestorer`) so they never appear in the desktop conversation list.
|
|
225
225
|
|
|
226
226
|
3. **Rebind** — If a message arrives on an external chat whose conversation was previously deleted, the channel inbound handler treats it as a new conversation and establishes a fresh binding. The external chat ID is reused, but the conversation is new.
|
|
227
227
|
|
|
@@ -741,7 +741,7 @@ sequenceDiagram
|
|
|
741
741
|
alt ASK_GUARDIAN pattern detected
|
|
742
742
|
Ctrl->>CallStore: createPendingQuestion()
|
|
743
743
|
Ctrl->>GuardianDispatch: dispatchGuardianQuestion()
|
|
744
|
-
GuardianDispatch->>Mac:
|
|
744
|
+
GuardianDispatch->>Mac: notification_conversation_created SSE
|
|
745
745
|
GuardianDispatch->>TG: POST /deliver/{channel}
|
|
746
746
|
Note over Mac,TG: First channel to respond wins
|
|
747
747
|
Mac/TG->>Routes: guardian answer
|
|
@@ -923,9 +923,9 @@ When the LLM emits `[ASK_GUARDIAN: question]` during a voice call, the controlle
|
|
|
923
923
|
1. **Request creation**: A `guardian_action_request` row is created with a unique 6-character hex request code, the question text, a `pending` status, and an expiry timestamp.
|
|
924
924
|
|
|
925
925
|
2. **Delivery fan-out via notification pipeline**: The guardian dispatch calls `emitNotificationSignal()` and uses the same notification decision + broadcaster path as every other producer.
|
|
926
|
-
- **Vellum**: Conversation pairing happens in the notification broadcaster. The resulting `
|
|
926
|
+
- **Vellum**: Conversation pairing happens in the notification broadcaster. The resulting `notification_conversation_created` event surfaces the conversation in the desktop UI.
|
|
927
927
|
- **Telegram**: Delivery is handled by channel adapters selected by the notification decision and guarded by configured bindings.
|
|
928
|
-
- Guardian dispatch records `guardian_action_deliveries` from pipeline delivery results. It also uses the per-dispatch `
|
|
928
|
+
- Guardian dispatch records `guardian_action_deliveries` from pipeline delivery results. It also uses the per-dispatch `onConversationCreated` callback so vellum delivery rows are created as soon as conversation pairing occurs (without waiting for slower channels).
|
|
929
929
|
|
|
930
930
|
3. **Answer resolution**: The first channel to respond wins. Answer resolution uses an atomic `WHERE status = 'pending'` check on the `guardian_action_requests` table -- only the first writer succeeds in transitioning the request to `answered` status. The winning answer text and responding channel are recorded on the request row.
|
|
931
931
|
|
|
@@ -939,7 +939,7 @@ When the LLM emits `[ASK_GUARDIAN: question]` during a voice call, the controlle
|
|
|
939
939
|
|
|
940
940
|
#### macOS Notification + Deep-Link Flow
|
|
941
941
|
|
|
942
|
-
When a guardian question is dispatched while the macOS app is backgrounded, the Swift client posts a native `UNUserNotificationCenter` notification from the generic `notification_intent` payload (`NOTIFICATION_INTENT` category). The deep-link metadata includes the paired conversation ID, so tapping the notification routes directly to the guardian
|
|
942
|
+
When a guardian question is dispatched while the macOS app is backgrounded, the Swift client posts a native `UNUserNotificationCenter` notification from the generic `notification_intent` payload (`NOTIFICATION_INTENT` category). The deep-link metadata includes the paired conversation ID, so tapping the notification routes directly to the guardian conversation.
|
|
943
943
|
|
|
944
944
|
### SQLite Tables
|
|
945
945
|
|
|
@@ -994,8 +994,8 @@ This makes ingress URL updates smoother in local tunnel workflows because Twilio
|
|
|
994
994
|
| POST | `/v1/internal/twilio/voice-webhook` | Internal voice webhook used by gateway; accepts JSON `{ params, originalUrl, assistantId? }`, creates inbound session or returns TwiML |
|
|
995
995
|
| GET | `/v1/calls/:callSessionId` | Get call status, including any pending question |
|
|
996
996
|
| POST | `/v1/calls/:callSessionId/cancel` | Cancel an active call |
|
|
997
|
-
| POST | `/v1/calls/:callSessionId/answer` | Answer a pending question via HTTP (alternative to in-
|
|
998
|
-
| POST | `/v1/calls/:callSessionId/instruction` | Relay a steering instruction to an active call's controller (alternative to in-
|
|
997
|
+
| POST | `/v1/calls/:callSessionId/answer` | Answer a pending question via HTTP (alternative to in-conversation bridge) |
|
|
998
|
+
| POST | `/v1/calls/:callSessionId/instruction` | Relay a steering instruction to an active call's controller (alternative to in-conversation bridge) |
|
|
999
999
|
| POST | `/v1/internal/twilio/status` | Internal status callback used by gateway; accepts JSON `{ params }` |
|
|
1000
1000
|
| POST | `/v1/internal/twilio/connect-action` | Internal connect action callback used by gateway; accepts JSON `{ params }` |
|
|
1001
1001
|
| WS | `/v1/calls/relay` | ConversationRelay WebSocket (bidirectional: prompt/interrupt/dtmf from Twilio, text tokens/end to Twilio) |
|
package/Dockerfile
CHANGED
|
@@ -18,7 +18,7 @@ FROM debian:trixie-slim@sha256:1d3c811171a08a5adaa4a163fbafd96b61b87aa871bbc7aa1
|
|
|
18
18
|
|
|
19
19
|
WORKDIR /app
|
|
20
20
|
|
|
21
|
-
RUN apt-get update && apt-get install -y \
|
|
21
|
+
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
|
22
22
|
ca-certificates \
|
|
23
23
|
&& rm -rf /var/lib/apt/lists/*
|
|
24
24
|
|
package/README.md
CHANGED
|
@@ -26,11 +26,11 @@ bun run dev
|
|
|
26
26
|
|
|
27
27
|
## Configuration
|
|
28
28
|
|
|
29
|
-
| Variable | Required | Default | Description
|
|
30
|
-
| ------------------------- | -------- | ------- |
|
|
31
|
-
| `TELEGRAM_BOT_TOKEN` | No | — | Bot token from @BotFather (Telegram disabled when unset). When not set as an env var, the gateway reads from the assistant's secure credential store: keychain broker first (UDS to the assistant
|
|
32
|
-
| `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as `TELEGRAM_BOT_TOKEN`.
|
|
33
|
-
| `GATEWAY_PORT` | No | `7830` | Port for the gateway HTTP server
|
|
29
|
+
| Variable | Required | Default | Description |
|
|
30
|
+
| ------------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
31
|
+
| `TELEGRAM_BOT_TOKEN` | No | — | Bot token from @BotFather (Telegram disabled when unset). When not set as an env var, the gateway reads from the assistant's secure credential store: keychain broker first (UDS to the assistant process), then the encrypted file store (`~/.vellum/protected/keys.enc`). When the broker is unavailable (assistant not running or non-macOS), the encrypted store is used directly. |
|
|
32
|
+
| `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as `TELEGRAM_BOT_TOKEN`. |
|
|
33
|
+
| `GATEWAY_PORT` | No | `7830` | Port for the gateway HTTP server |
|
|
34
34
|
|
|
35
35
|
Most gateway behavior is now configured via hardcoded defaults or workspace config (`~/.vellum/workspace/config.json`) rather than environment variables. Channel operational settings (Telegram API base URL, timeouts, deliver auth bypass flags, runtime base URL, routing, proxy settings, attachment limits, shutdown drain) are managed via `workspace/config.json` through `ConfigFileCache`. See the channel-specific sections in `ARCHITECTURE.md` for details.
|
|
36
36
|
|
|
@@ -185,7 +185,7 @@ Use this flow when you are changing files under `gateway/` and need to validate
|
|
|
185
185
|
```bash
|
|
186
186
|
# Terminal 1: restart assistant runtime HTTP server
|
|
187
187
|
cd assistant
|
|
188
|
-
bun run
|
|
188
|
+
bun run assistant:restart:http
|
|
189
189
|
|
|
190
190
|
# Terminal 2: run gateway from local source with runtime proxy enabled
|
|
191
191
|
cd gateway
|
package/package.json
CHANGED
|
@@ -41,7 +41,12 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
41
41
|
runtimeInitialBackoffMs: 500,
|
|
42
42
|
maxWebhookPayloadBytes: 1048576,
|
|
43
43
|
logFile: { dir: undefined, retentionDays: 30 },
|
|
44
|
-
maxAttachmentBytes:
|
|
44
|
+
maxAttachmentBytes: {
|
|
45
|
+
telegram: 50 * 1024 * 1024,
|
|
46
|
+
slack: 100 * 1024 * 1024,
|
|
47
|
+
whatsapp: 16 * 1024 * 1024,
|
|
48
|
+
default: 50 * 1024 * 1024,
|
|
49
|
+
},
|
|
45
50
|
maxAttachmentConcurrency: 3,
|
|
46
51
|
gatewayInternalBaseUrl: "http://127.0.0.1:7830",
|
|
47
52
|
trustProxy: false,
|
|
@@ -35,7 +35,12 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
35
35
|
runtimeInitialBackoffMs: 500,
|
|
36
36
|
maxWebhookPayloadBytes: 1048576,
|
|
37
37
|
logFile: { dir: undefined, retentionDays: 30 },
|
|
38
|
-
maxAttachmentBytes:
|
|
38
|
+
maxAttachmentBytes: {
|
|
39
|
+
telegram: 50 * 1024 * 1024,
|
|
40
|
+
slack: 100 * 1024 * 1024,
|
|
41
|
+
whatsapp: 16 * 1024 * 1024,
|
|
42
|
+
default: 50 * 1024 * 1024,
|
|
43
|
+
},
|
|
39
44
|
maxAttachmentConcurrency: 3,
|
|
40
45
|
gatewayInternalBaseUrl: "http://127.0.0.1:7830",
|
|
41
46
|
trustProxy: false,
|
|
@@ -44,7 +44,7 @@ describe("ConfigFileCache: getString", () => {
|
|
|
44
44
|
expect(cache.getString("email", "address")).toBe("a@b.com");
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
test("returns undefined for empty string", () => {
|
|
47
|
+
test("returns undefined for empty string value", () => {
|
|
48
48
|
writeConfig({ email: { address: "" } });
|
|
49
49
|
const cache = new ConfigFileCache();
|
|
50
50
|
expect(cache.getString("email", "address")).toBeUndefined();
|
|
@@ -9,7 +9,12 @@ describe("config: hardcoded defaults", () => {
|
|
|
9
9
|
expect(config.runtimeMaxRetries).toBe(2);
|
|
10
10
|
expect(config.runtimeInitialBackoffMs).toBe(500);
|
|
11
11
|
expect(config.maxWebhookPayloadBytes).toBe(1024 * 1024);
|
|
12
|
-
expect(config.maxAttachmentBytes).
|
|
12
|
+
expect(config.maxAttachmentBytes).toEqual({
|
|
13
|
+
telegram: 20 * 1024 * 1024,
|
|
14
|
+
slack: 100 * 1024 * 1024,
|
|
15
|
+
whatsapp: 16 * 1024 * 1024,
|
|
16
|
+
default: 100 * 1024 * 1024,
|
|
17
|
+
});
|
|
13
18
|
expect(config.maxAttachmentConcurrency).toBe(3);
|
|
14
19
|
expect(config.runtimeProxyEnabled).toBe(false);
|
|
15
20
|
expect(config.runtimeProxyRequireAuth).toBe(true);
|
|
@@ -35,7 +35,12 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
35
35
|
runtimeInitialBackoffMs: 500,
|
|
36
36
|
maxWebhookPayloadBytes: 1048576,
|
|
37
37
|
logFile: { dir: undefined, retentionDays: 30 },
|
|
38
|
-
maxAttachmentBytes:
|
|
38
|
+
maxAttachmentBytes: {
|
|
39
|
+
telegram: 50 * 1024 * 1024,
|
|
40
|
+
slack: 100 * 1024 * 1024,
|
|
41
|
+
whatsapp: 16 * 1024 * 1024,
|
|
42
|
+
default: 50 * 1024 * 1024,
|
|
43
|
+
},
|
|
39
44
|
maxAttachmentConcurrency: 3,
|
|
40
45
|
gatewayInternalBaseUrl: "http://127.0.0.1:7830",
|
|
41
46
|
trustProxy: false,
|
|
@@ -78,24 +78,16 @@ function getMachineEntropy(): string {
|
|
|
78
78
|
return parts.join(":");
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const salt = randomBytes(16);
|
|
86
|
-
const key = pbkdf2Sync(
|
|
87
|
-
getMachineEntropy(),
|
|
88
|
-
salt,
|
|
89
|
-
PBKDF2_ITERATIONS,
|
|
90
|
-
KEY_LENGTH,
|
|
91
|
-
"sha512",
|
|
92
|
-
);
|
|
81
|
+
function encryptEntries(
|
|
82
|
+
entries: Record<string, string>,
|
|
83
|
+
key: Buffer,
|
|
84
|
+
): Record<string, { iv: string; tag: string; data: string }> {
|
|
93
85
|
const encryptedEntries: Record<
|
|
94
86
|
string,
|
|
95
87
|
{ iv: string; tag: string; data: string }
|
|
96
88
|
> = {};
|
|
97
89
|
for (const [account, value] of Object.entries(entries)) {
|
|
98
|
-
const iv = randomBytes(
|
|
90
|
+
const iv = randomBytes(16);
|
|
99
91
|
const cipher = createCipheriv(ALGORITHM, key, iv, {
|
|
100
92
|
authTagLength: AUTH_TAG_LENGTH,
|
|
101
93
|
});
|
|
@@ -110,15 +102,48 @@ function writeEncryptedStore(entries: Record<string, string>): void {
|
|
|
110
102
|
data: encrypted.toString("hex"),
|
|
111
103
|
};
|
|
112
104
|
}
|
|
105
|
+
return encryptedEntries;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function writeEncryptedStore(entries: Record<string, string>): void {
|
|
109
|
+
const storePath = join(testDir, ".vellum", "protected", "keys.enc");
|
|
110
|
+
mkdirSync(join(testDir, ".vellum", "protected"), { recursive: true });
|
|
111
|
+
|
|
112
|
+
const salt = randomBytes(16);
|
|
113
|
+
const key = pbkdf2Sync(
|
|
114
|
+
getMachineEntropy(),
|
|
115
|
+
salt,
|
|
116
|
+
PBKDF2_ITERATIONS,
|
|
117
|
+
KEY_LENGTH,
|
|
118
|
+
"sha512",
|
|
119
|
+
);
|
|
113
120
|
|
|
114
121
|
const store = {
|
|
115
122
|
version: 1,
|
|
116
123
|
salt: salt.toString("hex"),
|
|
117
|
-
entries:
|
|
124
|
+
entries: encryptEntries(entries, key),
|
|
118
125
|
};
|
|
119
126
|
writeFileSync(storePath, JSON.stringify(store));
|
|
120
127
|
}
|
|
121
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Write a v2 encrypted store with a random store.key file.
|
|
131
|
+
* The store.key is used directly as the AES-256-GCM key (no PBKDF2).
|
|
132
|
+
*/
|
|
133
|
+
function writeEncryptedStoreV2(entries: Record<string, string>): void {
|
|
134
|
+
const protectedDir = join(testDir, ".vellum", "protected");
|
|
135
|
+
mkdirSync(protectedDir, { recursive: true });
|
|
136
|
+
|
|
137
|
+
const storeKey = randomBytes(KEY_LENGTH);
|
|
138
|
+
writeFileSync(join(protectedDir, "store.key"), storeKey);
|
|
139
|
+
|
|
140
|
+
const store = {
|
|
141
|
+
version: 2,
|
|
142
|
+
entries: encryptEntries(entries, storeKey),
|
|
143
|
+
};
|
|
144
|
+
writeFileSync(join(protectedDir, "keys.enc"), JSON.stringify(store));
|
|
145
|
+
}
|
|
146
|
+
|
|
122
147
|
// ---------------------------------------------------------------------------
|
|
123
148
|
// Broker test helpers — mock UDS server
|
|
124
149
|
// ---------------------------------------------------------------------------
|
|
@@ -264,6 +289,74 @@ describe("readTelegramCredentials", () => {
|
|
|
264
289
|
});
|
|
265
290
|
});
|
|
266
291
|
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Tests: v2 encrypted store (store.key)
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
describe("v2 encrypted store with store.key", () => {
|
|
297
|
+
test("reads credential from v2 store when store.key exists", async () => {
|
|
298
|
+
writeEncryptedStoreV2({
|
|
299
|
+
[credentialKey("test", "key")]: "v2-secret-value",
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const result = await readCredential(credentialKey("test", "key"));
|
|
303
|
+
expect(result).toBe("v2-secret-value");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("returns undefined for v2 store when store.key is missing", async () => {
|
|
307
|
+
// Write a v2 store but without the store.key file
|
|
308
|
+
const protectedDir = join(testDir, ".vellum", "protected");
|
|
309
|
+
mkdirSync(protectedDir, { recursive: true });
|
|
310
|
+
|
|
311
|
+
const storeKey = randomBytes(KEY_LENGTH);
|
|
312
|
+
const store = {
|
|
313
|
+
version: 2,
|
|
314
|
+
entries: encryptEntries(
|
|
315
|
+
{ [credentialKey("test", "key")]: "v2-secret-value" },
|
|
316
|
+
storeKey,
|
|
317
|
+
),
|
|
318
|
+
};
|
|
319
|
+
writeFileSync(join(protectedDir, "keys.enc"), JSON.stringify(store));
|
|
320
|
+
// Deliberately do NOT write store.key
|
|
321
|
+
|
|
322
|
+
const result = await readCredential(credentialKey("test", "key"));
|
|
323
|
+
expect(result).toBeUndefined();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("returns Telegram credentials from v2 store", async () => {
|
|
327
|
+
writeMetadata([
|
|
328
|
+
{ service: "telegram", field: "bot_token" },
|
|
329
|
+
{ service: "telegram", field: "webhook_secret" },
|
|
330
|
+
]);
|
|
331
|
+
|
|
332
|
+
writeEncryptedStoreV2({
|
|
333
|
+
[credentialKey("telegram", "bot_token")]: "v2-bot-token",
|
|
334
|
+
[credentialKey("telegram", "webhook_secret")]: "v2-webhook-secret",
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const result = await readTelegramCredentials();
|
|
338
|
+
expect(result).toEqual({
|
|
339
|
+
botToken: "v2-bot-token",
|
|
340
|
+
webhookSecret: "v2-webhook-secret",
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Tests: v1 encrypted store backward compatibility
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
describe("v1 encrypted store backward compatibility", () => {
|
|
350
|
+
test("v1 store continues to work with entropy-based key derivation", async () => {
|
|
351
|
+
writeEncryptedStore({
|
|
352
|
+
[credentialKey("test", "key")]: "v1-secret-value",
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const result = await readCredential(credentialKey("test", "key"));
|
|
356
|
+
expect(result).toBe("v1-secret-value");
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
267
360
|
// ---------------------------------------------------------------------------
|
|
268
361
|
// Tests: broker credential reading
|
|
269
362
|
// ---------------------------------------------------------------------------
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
pbkdf2Sync,
|
|
15
15
|
randomBytes as cryptoRandomBytes,
|
|
16
16
|
} from "node:crypto";
|
|
17
|
-
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
17
|
+
import { mkdirSync, renameSync, writeFileSync, rmSync } from "node:fs";
|
|
18
18
|
import { hostname, tmpdir, userInfo } from "node:os";
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
@@ -68,7 +68,7 @@ function encrypt(
|
|
|
68
68
|
value: string,
|
|
69
69
|
key: Buffer,
|
|
70
70
|
): { iv: string; tag: string; data: string } {
|
|
71
|
-
const iv = cryptoRandomBytes(
|
|
71
|
+
const iv = cryptoRandomBytes(16);
|
|
72
72
|
const cipher = createCipheriv(ALGORITHM, key, iv, {
|
|
73
73
|
authTagLength: AUTH_TAG_LENGTH,
|
|
74
74
|
});
|
|
@@ -115,38 +115,71 @@ function writeEncryptedStore(botToken: string, webhookSecret: string): void {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
|
-
* Write
|
|
119
|
-
*
|
|
118
|
+
* Write Telegram credentials into a v2 encrypted store using a random
|
|
119
|
+
* store.key file (no PBKDF2 derivation).
|
|
120
120
|
*/
|
|
121
|
-
function
|
|
121
|
+
function writeEncryptedStoreV2(
|
|
122
|
+
botToken: string,
|
|
123
|
+
webhookSecret: string,
|
|
124
|
+
): void {
|
|
125
|
+
const protectedDir = join(testDir, ".vellum", "protected");
|
|
126
|
+
mkdirSync(protectedDir, { recursive: true });
|
|
127
|
+
|
|
128
|
+
const storeKey = cryptoRandomBytes(KEY_LENGTH);
|
|
129
|
+
writeFileSync(join(protectedDir, "store.key"), storeKey);
|
|
130
|
+
|
|
131
|
+
const store = {
|
|
132
|
+
version: 2,
|
|
133
|
+
entries: {
|
|
134
|
+
"credential/telegram/bot_token": encrypt(botToken, storeKey),
|
|
135
|
+
"credential/telegram/webhook_secret": encrypt(webhookSecret, storeKey),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
writeFileSync(join(protectedDir, "keys.enc"), JSON.stringify(store));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function metadataRecord(
|
|
143
|
+
credentialId: string,
|
|
144
|
+
service: string,
|
|
145
|
+
field: string,
|
|
146
|
+
): Record<string, unknown> {
|
|
147
|
+
return {
|
|
148
|
+
credentialId,
|
|
149
|
+
service,
|
|
150
|
+
field,
|
|
151
|
+
allowedTools: [],
|
|
152
|
+
allowedDomains: [],
|
|
153
|
+
createdAt: Date.now(),
|
|
154
|
+
updatedAt: Date.now(),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Write credential metadata using the same atomic rename pattern as the
|
|
160
|
+
* production metadata store.
|
|
161
|
+
*/
|
|
162
|
+
function writeCredentialMetadata(
|
|
163
|
+
credentials: Record<string, unknown>[] = [
|
|
164
|
+
metadataRecord("test-bt", "telegram", "bot_token"),
|
|
165
|
+
metadataRecord("test-ws", "telegram", "webhook_secret"),
|
|
166
|
+
],
|
|
167
|
+
): void {
|
|
122
168
|
const dir = join(testDir, ".vellum", "workspace", "data", "credentials");
|
|
123
169
|
mkdirSync(dir, { recursive: true });
|
|
170
|
+
const metadataPath = join(dir, "metadata.json");
|
|
171
|
+
const tmpPath = join(
|
|
172
|
+
dir,
|
|
173
|
+
`.tmp-${cryptoRandomBytes(4).toString("hex")}-metadata.json`,
|
|
174
|
+
);
|
|
124
175
|
writeFileSync(
|
|
125
|
-
|
|
176
|
+
tmpPath,
|
|
126
177
|
JSON.stringify({
|
|
127
178
|
version: 2,
|
|
128
|
-
credentials
|
|
129
|
-
{
|
|
130
|
-
credentialId: "test-bt",
|
|
131
|
-
service: "telegram",
|
|
132
|
-
field: "bot_token",
|
|
133
|
-
allowedTools: [],
|
|
134
|
-
allowedDomains: [],
|
|
135
|
-
createdAt: Date.now(),
|
|
136
|
-
updatedAt: Date.now(),
|
|
137
|
-
},
|
|
138
|
-
{
|
|
139
|
-
credentialId: "test-ws",
|
|
140
|
-
service: "telegram",
|
|
141
|
-
field: "webhook_secret",
|
|
142
|
-
allowedTools: [],
|
|
143
|
-
allowedDomains: [],
|
|
144
|
-
createdAt: Date.now(),
|
|
145
|
-
updatedAt: Date.now(),
|
|
146
|
-
},
|
|
147
|
-
],
|
|
179
|
+
credentials,
|
|
148
180
|
}),
|
|
149
181
|
);
|
|
182
|
+
renameSync(tmpPath, metadataPath);
|
|
150
183
|
}
|
|
151
184
|
|
|
152
185
|
// ---------------------------------------------------------------------------
|
|
@@ -157,11 +190,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
157
190
|
const gatewayRoot = join(__dirname, "..", "..");
|
|
158
191
|
const gatewayEntry = join(gatewayRoot, "src", "index.ts");
|
|
159
192
|
|
|
160
|
-
const port = 49152 + Math.floor(Math.random() * 16383);
|
|
161
|
-
|
|
162
193
|
let gatewayProc: ChildProcess | null = null;
|
|
194
|
+
let port = 0;
|
|
163
195
|
|
|
164
196
|
async function startGateway(): Promise<void> {
|
|
197
|
+
port = 49152 + Math.floor(Math.random() * 16383);
|
|
165
198
|
gatewayProc = spawn("bun", ["run", gatewayEntry], {
|
|
166
199
|
env: {
|
|
167
200
|
...process.env,
|
|
@@ -238,4 +271,81 @@ describe("gateway telegram hot-reload (e2e)", () => {
|
|
|
238
271
|
});
|
|
239
272
|
expect(after.status).toBe(401);
|
|
240
273
|
}, 15_000);
|
|
274
|
+
|
|
275
|
+
test("gateway keeps reloading credentials after multiple atomic metadata rewrites when metadata.json already existed at startup", async () => {
|
|
276
|
+
mkdirSync(testDir, { recursive: true });
|
|
277
|
+
|
|
278
|
+
// Start in file-watch mode by creating metadata.json before boot, but
|
|
279
|
+
// omit Telegram entries so the integration is initially unconfigured.
|
|
280
|
+
writeCredentialMetadata([metadataRecord("baseline", "github", "token")]);
|
|
281
|
+
writeEncryptedStore("fake-bot-token:ABC123", "fake-webhook-secret");
|
|
282
|
+
|
|
283
|
+
await startGateway();
|
|
284
|
+
|
|
285
|
+
const base = `http://localhost:${port}`;
|
|
286
|
+
|
|
287
|
+
const before = await fetch(`${base}/webhooks/telegram`, {
|
|
288
|
+
method: "POST",
|
|
289
|
+
});
|
|
290
|
+
expect(before.status).toBe(503);
|
|
291
|
+
|
|
292
|
+
// First rewrite after startup stales a file-scoped fs.watch() subscription
|
|
293
|
+
// on macOS when metadata.json is atomically replaced.
|
|
294
|
+
writeCredentialMetadata([
|
|
295
|
+
metadataRecord("baseline", "github", "token"),
|
|
296
|
+
metadataRecord("other", "openai", "api_key"),
|
|
297
|
+
]);
|
|
298
|
+
|
|
299
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
300
|
+
|
|
301
|
+
// Second rewrite adds Telegram credentials. The gateway must still see
|
|
302
|
+
// this update without requiring a restart.
|
|
303
|
+
writeCredentialMetadata([
|
|
304
|
+
metadataRecord("baseline", "github", "token"),
|
|
305
|
+
metadataRecord("other", "openai", "api_key"),
|
|
306
|
+
metadataRecord("test-bt", "telegram", "bot_token"),
|
|
307
|
+
metadataRecord("test-ws", "telegram", "webhook_secret"),
|
|
308
|
+
]);
|
|
309
|
+
|
|
310
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
311
|
+
|
|
312
|
+
const after = await fetch(`${base}/webhooks/telegram`, {
|
|
313
|
+
method: "POST",
|
|
314
|
+
});
|
|
315
|
+
expect(after.status).toBe(401);
|
|
316
|
+
}, 15_000);
|
|
317
|
+
|
|
318
|
+
test("gateway hot-reloads v2 encrypted store credentials written after startup", async () => {
|
|
319
|
+
// --- Setup: no credentials directory exists (fresh hatch) ---
|
|
320
|
+
mkdirSync(testDir, { recursive: true });
|
|
321
|
+
|
|
322
|
+
// Start the real gateway process
|
|
323
|
+
await startGateway();
|
|
324
|
+
|
|
325
|
+
const base = `http://localhost:${port}`;
|
|
326
|
+
|
|
327
|
+
// --- Step 1: confirm Telegram is NOT configured ---
|
|
328
|
+
const before = await fetch(`${base}/webhooks/telegram`, {
|
|
329
|
+
method: "POST",
|
|
330
|
+
});
|
|
331
|
+
expect(before.status).toBe(503);
|
|
332
|
+
const beforeBody = (await before.json()) as { error: string };
|
|
333
|
+
expect(beforeBody.error).toBe("Telegram integration not configured");
|
|
334
|
+
|
|
335
|
+
// --- Step 2: simulate daemon writing v2 credentials ---
|
|
336
|
+
writeEncryptedStoreV2("fake-v2-bot-token:XYZ", "fake-v2-webhook-secret");
|
|
337
|
+
writeCredentialMetadata();
|
|
338
|
+
|
|
339
|
+
// Wait for credential watcher debounce (500ms) + generous margin
|
|
340
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
341
|
+
|
|
342
|
+
// --- Step 3: query again — gateway should now recognize Telegram is configured.
|
|
343
|
+
// We expect 401 (webhook secret verification failed) rather than 503
|
|
344
|
+
// (not configured). Getting past the 503 gate proves the gateway
|
|
345
|
+
// hot-reloaded the v2 credentials from the credential store.
|
|
346
|
+
const after = await fetch(`${base}/webhooks/telegram`, {
|
|
347
|
+
method: "POST",
|
|
348
|
+
});
|
|
349
|
+
expect(after.status).toBe(401);
|
|
350
|
+
}, 15_000);
|
|
241
351
|
});
|