@vellumai/vellum-gateway 0.4.54 → 0.4.56
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-watcher.test.ts +78 -27
- 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__/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 +13 -3
- package/src/credential-watcher.ts +8 -35
- package/src/feature-flag-registry.json +161 -25
- 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 +36 -3
- package/src/runtime/client.ts +59 -3
- package/src/schema.ts +53 -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 sessions during desktop session 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 sessions during session restoration (`
|
|
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 sessions during session 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: 50 * 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,
|
|
@@ -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";
|
|
@@ -114,39 +114,47 @@ function writeEncryptedStore(botToken: string, webhookSecret: string): void {
|
|
|
114
114
|
writeFileSync(storePath, JSON.stringify(store));
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
function metadataRecord(
|
|
118
|
+
credentialId: string,
|
|
119
|
+
service: string,
|
|
120
|
+
field: string,
|
|
121
|
+
): Record<string, unknown> {
|
|
122
|
+
return {
|
|
123
|
+
credentialId,
|
|
124
|
+
service,
|
|
125
|
+
field,
|
|
126
|
+
allowedTools: [],
|
|
127
|
+
allowedDomains: [],
|
|
128
|
+
createdAt: Date.now(),
|
|
129
|
+
updatedAt: Date.now(),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
117
133
|
/**
|
|
118
|
-
* Write credential metadata
|
|
119
|
-
*
|
|
134
|
+
* Write credential metadata using the same atomic rename pattern as the
|
|
135
|
+
* production metadata store.
|
|
120
136
|
*/
|
|
121
|
-
function writeCredentialMetadata(
|
|
137
|
+
function writeCredentialMetadata(
|
|
138
|
+
credentials: Record<string, unknown>[] = [
|
|
139
|
+
metadataRecord("test-bt", "telegram", "bot_token"),
|
|
140
|
+
metadataRecord("test-ws", "telegram", "webhook_secret"),
|
|
141
|
+
],
|
|
142
|
+
): void {
|
|
122
143
|
const dir = join(testDir, ".vellum", "workspace", "data", "credentials");
|
|
123
144
|
mkdirSync(dir, { recursive: true });
|
|
145
|
+
const metadataPath = join(dir, "metadata.json");
|
|
146
|
+
const tmpPath = join(
|
|
147
|
+
dir,
|
|
148
|
+
`.tmp-${cryptoRandomBytes(4).toString("hex")}-metadata.json`,
|
|
149
|
+
);
|
|
124
150
|
writeFileSync(
|
|
125
|
-
|
|
151
|
+
tmpPath,
|
|
126
152
|
JSON.stringify({
|
|
127
153
|
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
|
-
],
|
|
154
|
+
credentials,
|
|
148
155
|
}),
|
|
149
156
|
);
|
|
157
|
+
renameSync(tmpPath, metadataPath);
|
|
150
158
|
}
|
|
151
159
|
|
|
152
160
|
// ---------------------------------------------------------------------------
|
|
@@ -157,11 +165,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
157
165
|
const gatewayRoot = join(__dirname, "..", "..");
|
|
158
166
|
const gatewayEntry = join(gatewayRoot, "src", "index.ts");
|
|
159
167
|
|
|
160
|
-
const port = 49152 + Math.floor(Math.random() * 16383);
|
|
161
|
-
|
|
162
168
|
let gatewayProc: ChildProcess | null = null;
|
|
169
|
+
let port = 0;
|
|
163
170
|
|
|
164
171
|
async function startGateway(): Promise<void> {
|
|
172
|
+
port = 49152 + Math.floor(Math.random() * 16383);
|
|
165
173
|
gatewayProc = spawn("bun", ["run", gatewayEntry], {
|
|
166
174
|
env: {
|
|
167
175
|
...process.env,
|
|
@@ -238,4 +246,47 @@ describe("gateway telegram hot-reload (e2e)", () => {
|
|
|
238
246
|
});
|
|
239
247
|
expect(after.status).toBe(401);
|
|
240
248
|
}, 15_000);
|
|
249
|
+
|
|
250
|
+
test("gateway keeps reloading credentials after multiple atomic metadata rewrites when metadata.json already existed at startup", async () => {
|
|
251
|
+
mkdirSync(testDir, { recursive: true });
|
|
252
|
+
|
|
253
|
+
// Start in file-watch mode by creating metadata.json before boot, but
|
|
254
|
+
// omit Telegram entries so the integration is initially unconfigured.
|
|
255
|
+
writeCredentialMetadata([metadataRecord("baseline", "github", "token")]);
|
|
256
|
+
writeEncryptedStore("fake-bot-token:ABC123", "fake-webhook-secret");
|
|
257
|
+
|
|
258
|
+
await startGateway();
|
|
259
|
+
|
|
260
|
+
const base = `http://localhost:${port}`;
|
|
261
|
+
|
|
262
|
+
const before = await fetch(`${base}/webhooks/telegram`, {
|
|
263
|
+
method: "POST",
|
|
264
|
+
});
|
|
265
|
+
expect(before.status).toBe(503);
|
|
266
|
+
|
|
267
|
+
// First rewrite after startup stales a file-scoped fs.watch() subscription
|
|
268
|
+
// on macOS when metadata.json is atomically replaced.
|
|
269
|
+
writeCredentialMetadata([
|
|
270
|
+
metadataRecord("baseline", "github", "token"),
|
|
271
|
+
metadataRecord("other", "openai", "api_key"),
|
|
272
|
+
]);
|
|
273
|
+
|
|
274
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
275
|
+
|
|
276
|
+
// Second rewrite adds Telegram credentials. The gateway must still see
|
|
277
|
+
// this update without requiring a restart.
|
|
278
|
+
writeCredentialMetadata([
|
|
279
|
+
metadataRecord("baseline", "github", "token"),
|
|
280
|
+
metadataRecord("other", "openai", "api_key"),
|
|
281
|
+
metadataRecord("test-bt", "telegram", "bot_token"),
|
|
282
|
+
metadataRecord("test-ws", "telegram", "webhook_secret"),
|
|
283
|
+
]);
|
|
284
|
+
|
|
285
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
286
|
+
|
|
287
|
+
const after = await fetch(`${base}/webhooks/telegram`, {
|
|
288
|
+
method: "POST",
|
|
289
|
+
});
|
|
290
|
+
expect(after.status).toBe(401);
|
|
291
|
+
}, 15_000);
|
|
241
292
|
});
|
|
@@ -35,20 +35,12 @@ const TEST_REGISTRY = {
|
|
|
35
35
|
defaultEnabled: true,
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
|
-
id: "
|
|
38
|
+
id: "contacts",
|
|
39
39
|
scope: "assistant",
|
|
40
|
-
key: "feature_flags.
|
|
41
|
-
label: "
|
|
42
|
-
description: "
|
|
43
|
-
defaultEnabled:
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
id: "hatch-new-assistant",
|
|
47
|
-
scope: "assistant",
|
|
48
|
-
key: "feature_flags.hatch-new-assistant.enabled",
|
|
49
|
-
label: "Hatch New Assistant",
|
|
50
|
-
description: "Hatch new assistant",
|
|
51
|
-
defaultEnabled: true,
|
|
40
|
+
key: "feature_flags.contacts.enabled",
|
|
41
|
+
label: "Contacts",
|
|
42
|
+
description: "Contacts management",
|
|
43
|
+
defaultEnabled: false,
|
|
52
44
|
},
|
|
53
45
|
{
|
|
54
46
|
id: "user-hosted-enabled",
|
|
@@ -160,13 +152,6 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
160
152
|
);
|
|
161
153
|
expect(browserFlag2).toBeDefined();
|
|
162
154
|
expect(browserFlag2.label).toBe("Browser");
|
|
163
|
-
|
|
164
|
-
const hatchFlag = body.flags.find(
|
|
165
|
-
(f: { key: string }) =>
|
|
166
|
-
f.key === "feature_flags.hatch-new-assistant.enabled",
|
|
167
|
-
);
|
|
168
|
-
expect(hatchFlag).toBeDefined();
|
|
169
|
-
expect(hatchFlag.label).toBe("Hatch New Assistant");
|
|
170
155
|
});
|
|
171
156
|
|
|
172
157
|
test("does not include non-assistant-scope flags", async () => {
|
|
@@ -210,7 +195,6 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
210
195
|
JSON.stringify({
|
|
211
196
|
assistantFeatureFlagValues: {
|
|
212
197
|
"feature_flags.browser.enabled": false,
|
|
213
|
-
"feature_flags.guardian-verify-setup.enabled": false,
|
|
214
198
|
},
|
|
215
199
|
}),
|
|
216
200
|
);
|
|
@@ -229,13 +213,6 @@ describe("GET /v1/feature-flags handler", () => {
|
|
|
229
213
|
expect(browserFlag).toBeDefined();
|
|
230
214
|
expect(browserFlag.enabled).toBe(false); // overridden from default true
|
|
231
215
|
expect(browserFlag.defaultEnabled).toBe(true);
|
|
232
|
-
|
|
233
|
-
const guardianFlag = body.flags.find(
|
|
234
|
-
(f: { key: string }) =>
|
|
235
|
-
f.key === "feature_flags.guardian-verify-setup.enabled",
|
|
236
|
-
);
|
|
237
|
-
expect(guardianFlag).toBeDefined();
|
|
238
|
-
expect(guardianFlag.enabled).toBe(false); // overridden from default true
|
|
239
216
|
});
|
|
240
217
|
|
|
241
218
|
test("ignores non-boolean values in assistantFeatureFlagValues", async () => {
|
|
@@ -303,7 +280,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
303
280
|
twilio: { phoneNumber: "+1234567890" },
|
|
304
281
|
email: { address: "test@example.com" },
|
|
305
282
|
assistantFeatureFlagValues: {
|
|
306
|
-
"feature_flags.
|
|
283
|
+
"feature_flags.contacts.enabled": true,
|
|
307
284
|
},
|
|
308
285
|
}),
|
|
309
286
|
);
|
|
@@ -326,9 +303,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
326
303
|
expect(config.email).toEqual({ address: "test@example.com" });
|
|
327
304
|
// New section should have both old and new values
|
|
328
305
|
expect(
|
|
329
|
-
config.assistantFeatureFlagValues[
|
|
330
|
-
"feature_flags.guardian-verify-setup.enabled"
|
|
331
|
-
],
|
|
306
|
+
config.assistantFeatureFlagValues["feature_flags.contacts.enabled"],
|
|
332
307
|
).toBe(true);
|
|
333
308
|
expect(
|
|
334
309
|
config.assistantFeatureFlagValues["feature_flags.browser.enabled"],
|
|
@@ -383,7 +358,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
383
358
|
|
|
384
359
|
const oldFormatKeys = [
|
|
385
360
|
"skills.browser.enabled",
|
|
386
|
-
"skills.
|
|
361
|
+
"skills.contacts.enabled",
|
|
387
362
|
"skills.my-skill.enabled",
|
|
388
363
|
];
|
|
389
364
|
|
|
@@ -459,8 +434,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
459
434
|
|
|
460
435
|
const validKeys = [
|
|
461
436
|
"feature_flags.browser.enabled",
|
|
462
|
-
"feature_flags.
|
|
463
|
-
"feature_flags.hatch-new-assistant.enabled",
|
|
437
|
+
"feature_flags.contacts.enabled",
|
|
464
438
|
];
|
|
465
439
|
|
|
466
440
|
for (const key of validKeys) {
|
|
@@ -538,21 +512,21 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
538
512
|
// Write initial config
|
|
539
513
|
const initial = {
|
|
540
514
|
twilio: { phoneNumber: "+1234" },
|
|
541
|
-
assistantFeatureFlagValues: { "feature_flags.
|
|
515
|
+
assistantFeatureFlagValues: { "feature_flags.contacts.enabled": true },
|
|
542
516
|
};
|
|
543
517
|
writeFileSync(configPath, JSON.stringify(initial));
|
|
544
518
|
|
|
545
519
|
const handler = createFeatureFlagsPatchHandler();
|
|
546
520
|
await handler(
|
|
547
521
|
new Request(
|
|
548
|
-
"http://gateway.test/v1/feature-flags/feature_flags.
|
|
522
|
+
"http://gateway.test/v1/feature-flags/feature_flags.browser.enabled",
|
|
549
523
|
{
|
|
550
524
|
method: "PATCH",
|
|
551
525
|
headers: { "content-type": "application/json" },
|
|
552
526
|
body: JSON.stringify({ enabled: false }),
|
|
553
527
|
},
|
|
554
528
|
),
|
|
555
|
-
"feature_flags.
|
|
529
|
+
"feature_flags.browser.enabled",
|
|
556
530
|
);
|
|
557
531
|
|
|
558
532
|
// Verify the file is valid JSON and contains all expected data
|
|
@@ -560,12 +534,10 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
560
534
|
const config = JSON.parse(raw);
|
|
561
535
|
expect(config.twilio).toMatchObject({ phoneNumber: "+1234" });
|
|
562
536
|
expect(
|
|
563
|
-
config.assistantFeatureFlagValues["feature_flags.
|
|
537
|
+
config.assistantFeatureFlagValues["feature_flags.contacts.enabled"],
|
|
564
538
|
).toBe(true);
|
|
565
539
|
expect(
|
|
566
|
-
config.assistantFeatureFlagValues[
|
|
567
|
-
"feature_flags.guardian-verify-setup.enabled"
|
|
568
|
-
],
|
|
540
|
+
config.assistantFeatureFlagValues["feature_flags.browser.enabled"],
|
|
569
541
|
).toBe(false);
|
|
570
542
|
|
|
571
543
|
// Verify no temp files left behind
|
|
@@ -582,8 +554,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
|
|
|
582
554
|
// Fire multiple concurrent PATCH requests at the same time
|
|
583
555
|
const flagKeys = [
|
|
584
556
|
"feature_flags.browser.enabled",
|
|
585
|
-
"feature_flags.
|
|
586
|
-
"feature_flags.hatch-new-assistant.enabled",
|
|
557
|
+
"feature_flags.contacts.enabled",
|
|
587
558
|
];
|
|
588
559
|
|
|
589
560
|
const results = await Promise.all(
|