@vellumai/vellum-gateway 0.8.4 → 0.8.6

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.
Files changed (38) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/package.json +1 -1
  3. package/src/__tests__/auto-approve-conversation-thresholds.test.ts +14 -6
  4. package/src/__tests__/config.test.ts +49 -0
  5. package/src/__tests__/feature-flag-watcher-callback.test.ts +85 -0
  6. package/src/__tests__/feature-flags-route.test.ts +40 -1
  7. package/src/__tests__/guardian-init-lockfile.test.ts +18 -2
  8. package/src/__tests__/ipc-feature-flag-routes.test.ts +24 -0
  9. package/src/__tests__/remote-feature-flag-sync.test.ts +83 -5
  10. package/src/__tests__/route-schema-guard.test.ts +4 -0
  11. package/src/auth/guardian-bootstrap.ts +8 -0
  12. package/src/auth/ipc-route-policy.ts +5 -0
  13. package/src/config.ts +35 -1
  14. package/src/email/register-callback.ts +8 -0
  15. package/src/feature-flag-registry.json +33 -17
  16. package/src/feature-flag-remote-store.ts +7 -1
  17. package/src/feature-flag-resolver.ts +36 -0
  18. package/src/feature-flag-watcher.ts +8 -1
  19. package/src/handlers/handle-inbound.ts +5 -1
  20. package/src/http/middleware/cors.ts +14 -2
  21. package/src/http/routes/auth-token.ts +60 -0
  22. package/src/http/routes/auto-approve-thresholds.ts +10 -4
  23. package/src/http/routes/channel-verification-session-proxy.test.ts +2 -2
  24. package/src/http/routes/channel-verification-session-proxy.ts +19 -10
  25. package/src/http/routes/email-webhook.ts +10 -2
  26. package/src/http/routes/feature-flags.ts +10 -4
  27. package/src/http/routes/guardian-channel-create.test.ts +198 -0
  28. package/src/http/routes/guardian-channel-create.ts +137 -0
  29. package/src/http/routes/inbound-register.ts +27 -15
  30. package/src/http/routes/vellum-identity.ts +24 -0
  31. package/src/index.ts +35 -11
  32. package/src/ipc/feature-flag-handlers.ts +8 -3
  33. package/src/post-assistant-ready.ts +8 -4
  34. package/src/remote-feature-flag-sync.ts +10 -3
  35. package/src/risk/command-registry/commands/assistant.ts +16 -0
  36. package/src/runtime/client.ts +2 -0
  37. package/src/schema.ts +15 -11
  38. package/src/telegram/webhook-manager.ts +8 -0
package/ARCHITECTURE.md CHANGED
@@ -95,11 +95,11 @@ The gateway exposes a REST API for reading and mutating assistant feature flags.
95
95
  | GET | `/v1/feature-flags` | List all declared assistant feature flags from the defaults registry, merged with persisted values from the feature flag store. Returns `{ flags: FeatureFlagEntry[] }` where each entry has `key`, `enabled`, `defaultEnabled`, and `description`. |
96
96
  | PATCH | `/v1/feature-flags/:key` | Set a single assistant feature flag. Body: `{ "enabled": true\|false }`. Key must be a simple kebab-case flag key declared in the defaults registry. Writes to `~/.vellum/protected/feature-flags.json`. |
97
97
 
98
- **Unified registry:** All declared feature flags and their default values are defined in the unified registry at `meta/feature-flags/feature-flag-registry.json` (bundled copy at `gateway/src/feature-flag-registry.json`). The gateway loads this registry on startup via `gateway/src/feature-flag-defaults.ts`, filtering to `scope: "assistant"` flags. Labels come from the registry. The GET endpoint merges persisted overrides with registry defaults to produce the full flag list. The PATCH endpoint validates that the target flag key exists in the registry before accepting a write. Only declared keys are exposed by this API.
98
+ **Unified registry:** All declared feature flags and their default values are defined in the unified registry at `meta/feature-flags/feature-flag-registry.json` (bundled copy at `gateway/src/feature-flag-registry.json`). The gateway loads this registry on startup via `gateway/src/feature-flag-defaults.ts`, filtering to `scope: "assistant"` flags. Labels come from the registry. The GET endpoint merges persisted overrides with remote values and registry defaults to produce the full flag list. Once a remote snapshot exists, declared flags missing from that snapshot fail closed to `false`; this handles flags that are declared locally but unregistered on the platform. The PATCH endpoint validates that the target flag key exists in the registry before accepting a write. Only declared keys are exposed by this API.
99
99
 
100
100
  **Flag key format:** The canonical key format is simple kebab-case (e.g., `browser`, `ces-tools`). Only keys matching this pattern and declared in the registry are accepted by the PATCH endpoint; other patterns are rejected with 400. All writes use the canonical format and are stored in the protected feature flag store (`~/.vellum/protected/feature-flags.json`).
101
101
 
102
- **Storage:** Flag overrides are persisted in `~/.vellum/protected/feature-flags.json` (local) or `GATEWAY_SECURITY_DIR/feature-flags.json` (Docker). The store uses a versioned JSON format (`{ version: 1, values: Record<string, boolean> }`). The GET endpoint reads from the feature flag store and merges with registry defaults. The gateway writes atomically (temp file + rename, 0o600 permissions). The daemon's config watcher monitors the protected directory and hot-reloads changes, so flag mutations take effect on the next session or tool resolution without a restart.
102
+ **Storage:** Flag overrides are persisted in `~/.vellum/protected/feature-flags.json` (local) or `GATEWAY_SECURITY_DIR/feature-flags.json` (Docker). The store uses a versioned JSON format (`{ version: 1, values: Record<string, boolean> }`). Remote platform snapshots are persisted separately in `feature-flags-remote.json`; local overrides win over remote values, remote values win over registry defaults, and missing remote values fail closed when a remote snapshot exists. The gateway writes atomically (temp file + rename, 0o600 permissions). The daemon's config watcher monitors the protected directory and hot-reloads flag changes, so flag mutations take effect on the next session or tool resolution without a restart.
103
103
 
104
104
  **Token separation (authentication boundary):**
105
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -60,15 +60,21 @@ function makeDelete(conversationId: string): [Request, string[]] {
60
60
  // ---------------------------------------------------------------------------
61
61
 
62
62
  describe("GET /v1/permissions/thresholds/conversations/:conversationId", () => {
63
- test("returns 404 for nonexistent conversation", async () => {
63
+ test("returns 200 with threshold:null when no override exists", async () => {
64
+ // "No override" is the common case — every conversation reads this
65
+ // endpoint to decide whether to apply a per-conversation threshold,
66
+ // and only a small fraction have one configured. Returning 200 with
67
+ // `{ threshold: null }` (rather than 404) avoids surfacing a
68
+ // misleading network error in the browser console for the default
69
+ // case and matches the IPC contract, which also returns null.
64
70
  const handler = createConversationThresholdGetHandler();
65
71
  const [req, params] = makeGet("conv-xyz");
66
72
 
67
73
  const res = await handler(req, params);
68
- expect(res.status).toBe(404);
74
+ expect(res.status).toBe(200);
69
75
 
70
76
  const body = await res.json();
71
- expect(body.error).toBe("No override for this conversation");
77
+ expect(body).toEqual({ threshold: null });
72
78
  });
73
79
 
74
80
  test("returns threshold after PUT creates it", async () => {
@@ -160,7 +166,7 @@ describe("PUT /v1/permissions/thresholds/conversations/:conversationId", () => {
160
166
  });
161
167
 
162
168
  describe("DELETE /v1/permissions/thresholds/conversations/:conversationId", () => {
163
- test("removes existing override, subsequent GET returns 404", async () => {
169
+ test("removes existing override, subsequent GET returns threshold:null", async () => {
164
170
  const putHandler = createConversationThresholdPutHandler();
165
171
  const getHandler = createConversationThresholdGetHandler();
166
172
  const deleteHandler = createConversationThresholdDeleteHandler();
@@ -174,10 +180,12 @@ describe("DELETE /v1/permissions/thresholds/conversations/:conversationId", () =
174
180
  const delRes = await deleteHandler(delReq, delParams);
175
181
  expect(delRes.status).toBe(204);
176
182
 
177
- // Verify gone
183
+ // Verify gone — GET now reports the absence as a normal 200 result.
178
184
  const [getReq, getParams] = makeGet("conv-del");
179
185
  const getRes = await getHandler(getReq, getParams);
180
- expect(getRes.status).toBe(404);
186
+ expect(getRes.status).toBe(200);
187
+ const body = await getRes.json();
188
+ expect(body).toEqual({ threshold: null });
181
189
  });
182
190
 
183
191
  test("returns 204 on nonexistent conversation (idempotent)", async () => {
@@ -1,4 +1,9 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
1
4
  import { describe, test, expect } from "bun:test";
5
+
6
+ import { testWorkspaceDir } from "./test-preload.js";
2
7
  import { loadConfig } from "../config.js";
3
8
 
4
9
  describe("config: hardcoded defaults", () => {
@@ -61,6 +66,50 @@ describe("config: hardcoded defaults", () => {
61
66
  }
62
67
  });
63
68
 
69
+ test("runtimeTimeoutMs is configurable via env var", () => {
70
+ const saved = process.env.RUNTIME_TIMEOUT_MS;
71
+ process.env.RUNTIME_TIMEOUT_MS = "300000";
72
+ try {
73
+ const config = loadConfig();
74
+ expect(config.runtimeTimeoutMs).toBe(300000);
75
+ } finally {
76
+ if (saved !== undefined) process.env.RUNTIME_TIMEOUT_MS = saved;
77
+ else delete process.env.RUNTIME_TIMEOUT_MS;
78
+ }
79
+ });
80
+
81
+ test("runtimeTimeoutMs rejects invalid env var", () => {
82
+ const saved = process.env.RUNTIME_TIMEOUT_MS;
83
+ process.env.RUNTIME_TIMEOUT_MS = "0";
84
+ try {
85
+ expect(() => loadConfig()).toThrow(
86
+ "RUNTIME_TIMEOUT_MS must be a positive integer",
87
+ );
88
+ } finally {
89
+ if (saved !== undefined) process.env.RUNTIME_TIMEOUT_MS = saved;
90
+ else delete process.env.RUNTIME_TIMEOUT_MS;
91
+ }
92
+ });
93
+
94
+ test("runtimeTimeoutMs rejects non-numeric workspace config values", () => {
95
+ const saved = process.env.RUNTIME_TIMEOUT_MS;
96
+ delete process.env.RUNTIME_TIMEOUT_MS;
97
+ writeFileSync(
98
+ join(testWorkspaceDir, "config.json"),
99
+ JSON.stringify({ gateway: { runtimeTimeoutMs: true } }),
100
+ );
101
+
102
+ try {
103
+ expect(() => loadConfig()).toThrow(
104
+ "gateway.runtimeTimeoutMs must be a positive integer",
105
+ );
106
+ } finally {
107
+ if (saved !== undefined) process.env.RUNTIME_TIMEOUT_MS = saved;
108
+ else delete process.env.RUNTIME_TIMEOUT_MS;
109
+ writeFileSync(join(testWorkspaceDir, "config.json"), "{}");
110
+ }
111
+ });
112
+
64
113
  test("gatewayInternalBaseUrl derives from port", () => {
65
114
  const saved = process.env.GATEWAY_PORT;
66
115
  process.env.GATEWAY_PORT = "8080";
@@ -0,0 +1,85 @@
1
+ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // Each test gets a fresh, uniquely-named flag directory. Reusing one path
7
+ // across tests (rm + recreate) leaves fs.watch bound to a stale inode, so
8
+ // file events on the recreated directory are silently dropped.
9
+ let flagDir = "";
10
+
11
+ mock.module("../feature-flag-store.js", () => ({
12
+ clearFeatureFlagStoreCache: mock(() => {}),
13
+ getFeatureFlagStorePath: () => join(flagDir, "feature-flags.json"),
14
+ }));
15
+
16
+ mock.module("../feature-flag-remote-store.js", () => ({
17
+ refreshRemoteFeatureFlagStoreCache: mock(() => {}),
18
+ getRemoteFeatureFlagStorePath: () =>
19
+ join(flagDir, "remote-feature-flags.json"),
20
+ }));
21
+
22
+ const { FeatureFlagWatcher } = await import("../feature-flag-watcher.js");
23
+
24
+ describe("FeatureFlagWatcher onChanged callback", () => {
25
+ beforeEach(() => {
26
+ flagDir = mkdtempSync(join(tmpdir(), "ff-watcher-test-"));
27
+ mkdirSync(flagDir, { recursive: true });
28
+ writeFileSync(join(flagDir, "feature-flags.json"), "{}");
29
+ });
30
+
31
+ afterEach(() => {
32
+ try {
33
+ rmSync(flagDir, { recursive: true, force: true });
34
+ } catch {
35
+ // best effort
36
+ }
37
+ });
38
+
39
+ test("calls onChanged after debounce fires on local flag file change", async () => {
40
+ const onChanged = mock(() => {});
41
+ const watcher = new FeatureFlagWatcher({ onChanged });
42
+ watcher.start();
43
+
44
+ writeFileSync(
45
+ join(flagDir, "feature-flags.json"),
46
+ JSON.stringify({ test: true }),
47
+ );
48
+
49
+ await new Promise((r) => setTimeout(r, 700));
50
+
51
+ expect(onChanged).toHaveBeenCalledTimes(1);
52
+ watcher.stop();
53
+ });
54
+
55
+ test("calls onChanged after debounce fires on remote flag file change", async () => {
56
+ const onChanged = mock(() => {});
57
+ const watcher = new FeatureFlagWatcher({ onChanged });
58
+ watcher.start();
59
+
60
+ writeFileSync(
61
+ join(flagDir, "remote-feature-flags.json"),
62
+ JSON.stringify({ remote: true }),
63
+ );
64
+
65
+ await new Promise((r) => setTimeout(r, 700));
66
+
67
+ expect(onChanged).toHaveBeenCalledTimes(1);
68
+ watcher.stop();
69
+ });
70
+
71
+ test("does not call onChanged when no callback is provided", async () => {
72
+ const watcher = new FeatureFlagWatcher();
73
+ watcher.start();
74
+
75
+ writeFileSync(
76
+ join(flagDir, "feature-flags.json"),
77
+ JSON.stringify({ test: true }),
78
+ );
79
+
80
+ await new Promise((r) => setTimeout(r, 700));
81
+
82
+ // No assertion needed — just verify no error is thrown
83
+ watcher.stop();
84
+ });
85
+ });
@@ -324,7 +324,7 @@ describe("GET /v1/feature-flags handler", () => {
324
324
  });
325
325
 
326
326
  test("reflects updated flags after remote sync writes new values (stale cache regression)", async () => {
327
- // Scenario: the LD poller (RemoteFeatureFlagSync) writes
327
+ // Scenario: the remote poller (RemoteFeatureFlagSync) writes
328
328
  // email-channel: false, the gateway caches it, then a subsequent
329
329
  // poll writes email-channel: true. The GET handler should return
330
330
  // the updated value because writeRemoteFeatureFlags() updates
@@ -398,6 +398,45 @@ describe("GET /v1/feature-flags handler", () => {
398
398
  expect(browserFlag.defaultEnabled).toBe(true);
399
399
  });
400
400
 
401
+ test("declared flags missing from a remote snapshot default to disabled", async () => {
402
+ // No local override
403
+ if (existsSync(featureFlagStorePath)) {
404
+ rmSync(featureFlagStorePath);
405
+ }
406
+ clearFeatureFlagStoreCache();
407
+
408
+ // Remote snapshot exists, but browser is absent as it would be when the
409
+ // platform has no LaunchDarkly value for that key.
410
+ writeFileSync(
411
+ remoteFeatureFlagStorePath,
412
+ JSON.stringify({
413
+ version: 1,
414
+ values: { "email-channel": true },
415
+ }),
416
+ );
417
+ clearRemoteFeatureFlagStoreCache();
418
+
419
+ const handler = createFeatureFlagsGetHandler();
420
+ const res = await handler(
421
+ new Request("http://gateway.test/v1/feature-flags"),
422
+ );
423
+
424
+ expect(res.status).toBe(200);
425
+ const body = await res.json();
426
+
427
+ const emailFlag = body.flags.find(
428
+ (f: { key: string }) => f.key === "email-channel",
429
+ );
430
+ expect(emailFlag.enabled).toBe(true);
431
+
432
+ const browserFlag = body.flags.find(
433
+ (f: { key: string }) => f.key === "browser",
434
+ );
435
+ expect(browserFlag).toBeDefined();
436
+ expect(browserFlag.enabled).toBe(false);
437
+ expect(browserFlag.defaultEnabled).toBe(true);
438
+ });
439
+
401
440
  test("returns flags when invoked via assistants path without trailing slash", async () => {
402
441
  // The macOS client sends GET /v1/assistants/<id>/feature-flags (no trailing slash).
403
442
  // The gateway route regex must accept this path.
@@ -526,14 +526,30 @@ describe("guardian/reset-bootstrap", () => {
526
526
  expect(body.error).toBe("Loopback-only endpoint");
527
527
  });
528
528
 
529
- test("rejects in Docker mode (bootstrap secret set)", async () => {
529
+ test("rejects without valid bootstrap secret when secrets are configured", async () => {
530
530
  process.env.GUARDIAN_BOOTSTRAP_SECRET = "some-secret";
531
531
  const handler = createChannelVerificationSessionProxyHandler(makeConfig());
532
532
 
533
533
  const res = await handler.handleResetBootstrap("127.0.0.1");
534
534
  expect(res.status).toBe(403);
535
535
  const body = await res.json();
536
- expect(body.error).toBe("Reset not available in containerized mode");
536
+ expect(body.error).toBe("Invalid bootstrap secret");
537
+ });
538
+
539
+ test("allows reset with valid bootstrap secret", async () => {
540
+ process.env.GUARDIAN_BOOTSTRAP_SECRET = "some-secret";
541
+ writeFileSync(lockPath(), new Date().toISOString(), { mode: 0o600 });
542
+ writeFileSync(consumedPath(), JSON.stringify([0]) + "\n", { mode: 0o600 });
543
+ const handler = createChannelVerificationSessionProxyHandler(makeConfig());
544
+
545
+ const req = new Request("http://localhost:7830/v1/guardian/reset-bootstrap", {
546
+ method: "POST",
547
+ headers: { "x-bootstrap-secret": "some-secret" },
548
+ });
549
+ const res = await handler.handleResetBootstrap("127.0.0.1", req);
550
+ expect(res.status).toBe(200);
551
+ expect(lockFileExists()).toBe(false);
552
+ expect(consumedSecrets()).toEqual([]);
537
553
  });
538
554
 
539
555
  test("resets in-flight flag so init can proceed", async () => {
@@ -215,6 +215,30 @@ describe("IPC feature flag routes", () => {
215
215
  expect(flags["email-channel"]).toBe(true); // remote overrides default
216
216
  });
217
217
 
218
+ test("get_feature_flags disables declared flags missing from a remote snapshot", async () => {
219
+ writeFileSync(
220
+ remoteFeatureFlagStorePath,
221
+ JSON.stringify({
222
+ version: 1,
223
+ values: { "email-channel": true },
224
+ }),
225
+ );
226
+ clearRemoteFeatureFlagStoreCache();
227
+
228
+ if (existsSync(featureFlagStorePath)) {
229
+ rmSync(featureFlagStorePath);
230
+ }
231
+ clearFeatureFlagStoreCache();
232
+
233
+ await startServerAndConnect();
234
+ const res = await sendRequest(client, "get_feature_flags");
235
+
236
+ expect(res.error).toBeUndefined();
237
+ const flags = res.result as Record<string, boolean>;
238
+ expect(flags["email-channel"]).toBe(true);
239
+ expect(flags["browser"]).toBe(false);
240
+ });
241
+
218
242
  test("get_feature_flag returns value for a known flag", async () => {
219
243
  await startServerAndConnect();
220
244
  const res = await sendRequest(client, "get_feature_flag", {
@@ -38,7 +38,7 @@ const { resetFeatureFlagDefaultsCache, _setRegistryCandidateOverrides } =
38
38
 
39
39
  // ---------------------------------------------------------------------------
40
40
  // Test-local registry with a GA flag (defaultEnabled: true) for the
41
- // "ignores remote false for GA flags" test. Written to an isolated temp path
41
+ // "normalizes remote false for GA flags" test. Written to an isolated temp path
42
42
  // so we never touch the committed registry file.
43
43
  // ---------------------------------------------------------------------------
44
44
  const testRegistryPath = join(protectedDir, "feature-flag-registry.json");
@@ -62,6 +62,14 @@ const TEST_REGISTRY = {
62
62
  description: "Email channel integration",
63
63
  defaultEnabled: false,
64
64
  },
65
+ {
66
+ id: "platform-features-in-local-mode",
67
+ scope: "assistant",
68
+ key: "platform-features-in-local-mode",
69
+ label: "Platform Features in Local Mode",
70
+ description: "Gate platform API calls in local mode",
71
+ defaultEnabled: true,
72
+ },
65
73
  ],
66
74
  };
67
75
 
@@ -459,7 +467,7 @@ describe("RemoteFeatureFlagSync", () => {
459
467
  );
460
468
  });
461
469
 
462
- test("ignores remote false for GA flags (defaultEnabled: true in registry)", async () => {
470
+ test("normalizes remote false for GA flags (defaultEnabled: true in registry)", async () => {
463
471
  // The platform sends false for all flags it knows about (blanket-deny).
464
472
  // GA flags (defaultEnabled: true in the registry) should not be disabled
465
473
  // by remote overrides — only local persisted overrides can do that.
@@ -468,7 +476,8 @@ describe("RemoteFeatureFlagSync", () => {
468
476
  fetchMock = mock(async () =>
469
477
  Response.json({
470
478
  flags: {
471
- // GA flag (defaultEnabled: true) — remote false should be dropped
479
+ // GA flag (defaultEnabled: true) — remote false should be normalized
480
+ // to true so the missing-key fallback does not disable it.
472
481
  "test-ga-flag": false,
473
482
  // Gated flag (defaultEnabled: false) — remote false is kept
474
483
  "email-channel": false,
@@ -488,8 +497,8 @@ describe("RemoteFeatureFlagSync", () => {
488
497
 
489
498
  clearRemoteFeatureFlagStoreCache();
490
499
  const cached = readRemoteFeatureFlags();
491
- // test-ga-flag (GA, remote false) should be absent
492
- expect(cached["test-ga-flag"]).toBeUndefined();
500
+ // test-ga-flag (GA, remote false) should be normalized to true
501
+ expect(cached["test-ga-flag"]).toBe(true);
493
502
  // email-channel (gated, remote false) should be present
494
503
  expect(cached["email-channel"]).toBe(false);
495
504
  // test-ga-flag-true (unknown but true) should be present
@@ -498,6 +507,75 @@ describe("RemoteFeatureFlagSync", () => {
498
507
  expect(cached["unknown-flag"]).toBe(false);
499
508
  });
500
509
 
510
+ test("calls onChanged when remote flags change", async () => {
511
+ fetchMock = mock(async () =>
512
+ Response.json({
513
+ flags: { "new-flag": true },
514
+ }),
515
+ );
516
+
517
+ const onChanged = mock(() => {});
518
+ const sync = new RemoteFeatureFlagSync({
519
+ credentials: fakeCredentialCache(defaultCredentials()),
520
+ onChanged,
521
+ });
522
+ await sync.start();
523
+ sync.stop();
524
+
525
+ expect(fetchMock).toHaveBeenCalledTimes(1);
526
+ expect(onChanged).toHaveBeenCalledTimes(1);
527
+ });
528
+
529
+ test("does not call onChanged when remote flags have not changed", async () => {
530
+ // First sync to seed the file
531
+ fetchMock = mock(async () =>
532
+ Response.json({
533
+ flags: { "stable-flag": true },
534
+ }),
535
+ );
536
+
537
+ const onChanged1 = mock(() => {});
538
+ const sync1 = new RemoteFeatureFlagSync({
539
+ credentials: fakeCredentialCache(defaultCredentials()),
540
+ onChanged: onChanged1,
541
+ });
542
+ await sync1.start();
543
+ sync1.stop();
544
+ expect(onChanged1).toHaveBeenCalledTimes(1);
545
+
546
+ // Second sync with same data — onChanged should NOT fire
547
+ fetchMock = mock(async () =>
548
+ Response.json({
549
+ flags: { "stable-flag": true },
550
+ }),
551
+ );
552
+
553
+ const onChanged2 = mock(() => {});
554
+ const sync2 = new RemoteFeatureFlagSync({
555
+ credentials: fakeCredentialCache(defaultCredentials()),
556
+ onChanged: onChanged2,
557
+ });
558
+ await sync2.start();
559
+ sync2.stop();
560
+ expect(onChanged2).not.toHaveBeenCalled();
561
+ });
562
+
563
+ test("does not call onChanged on fetch failure", async () => {
564
+ fetchMock = mock(
565
+ async () => new Response("Internal Server Error", { status: 500 }),
566
+ );
567
+
568
+ const onChanged = mock(() => {});
569
+ const sync = new RemoteFeatureFlagSync({
570
+ credentials: fakeCredentialCache(defaultCredentials()),
571
+ onChanged,
572
+ });
573
+ await sync.start();
574
+ sync.stop();
575
+
576
+ expect(onChanged).not.toHaveBeenCalled();
577
+ });
578
+
501
579
  test("trims whitespace from credential values", async () => {
502
580
  fetchMock = mock(async () => Response.json({ flags: {} }));
503
581
 
@@ -173,6 +173,10 @@ const EXCLUDED_FROM_SCHEMA = new Set([
173
173
  "/v1/pair",
174
174
  // A2A agent card discovery — read-only, unauthenticated per spec
175
175
  "/.well-known/agent-card.json",
176
+ // Internal-only: reachable only via vembda's trusted gateway-query proxy
177
+ "/v1/contacts/guardian/channel",
178
+ // BFF token auth — loopback-only, not part of the public gateway API
179
+ "/auth/token",
176
180
  ]);
177
181
 
178
182
  // ── Schema paths that don't map to a discrete route definition ──
@@ -24,6 +24,7 @@ import {
24
24
  } from "../db/schema.js";
25
25
  import { readCredential } from "../credential-reader.js";
26
26
  import { credentialKey } from "../credential-key.js";
27
+ import { arePlatformFeaturesEnabled } from "../feature-flag-resolver.js";
27
28
  import { getLogger } from "../logger.js";
28
29
 
29
30
  import { CURRENT_POLICY_EPOCH } from "./policy.js";
@@ -544,6 +545,13 @@ function mintRefreshToken(
544
545
  * callers fall back to a generated principal ID in that case.
545
546
  */
546
547
  async function fetchPlatformOwnerDisplayName(): Promise<string | null> {
548
+ if (!arePlatformFeaturesEnabled()) {
549
+ log.debug(
550
+ "platform-features-in-local-mode is disabled — skipping platform owner display name fetch",
551
+ );
552
+ return null;
553
+ }
554
+
547
555
  const isPlatform =
548
556
  process.env.IS_PLATFORM?.trim().toLowerCase() === "true" ||
549
557
  process.env.IS_PLATFORM?.trim() === "1";
@@ -315,6 +315,11 @@ const POLICY_TABLE: PolicyEntry[] = [
315
315
  ["settings_client_put", ["settings.write"]],
316
316
  ["settings_voice_put", ["settings.write"]],
317
317
 
318
+ // Plugins
319
+ ["plugins_list", ["settings.read"]],
320
+ ["plugins_search", ["settings.read"]],
321
+ ["plugins_uninstall", ["settings.write"]],
322
+
318
323
  // Skills
319
324
  ["checkSkillUpdates", ["settings.write"]],
320
325
  ["configureSkill", ["settings.write"]],
package/src/config.ts CHANGED
@@ -77,6 +77,32 @@ function parseRoutingEntries(raw: unknown): RoutingEntry[] {
77
77
  return entries;
78
78
  }
79
79
 
80
+ function parsePositiveInteger(
81
+ value: unknown,
82
+ name: string,
83
+ ): number | undefined {
84
+ if (value === undefined || value === null) return undefined;
85
+
86
+ let parsed: number;
87
+ if (typeof value === "number") {
88
+ parsed = value;
89
+ } else if (typeof value === "string") {
90
+ const trimmed = value.trim();
91
+ if (trimmed === "") return undefined;
92
+ if (!/^\d+$/.test(trimmed)) {
93
+ throw new Error(`${name} must be a positive integer`);
94
+ }
95
+ parsed = Number(trimmed);
96
+ } else {
97
+ throw new Error(`${name} must be a positive integer`);
98
+ }
99
+
100
+ if (!Number.isInteger(parsed) || parsed <= 0) {
101
+ throw new Error(`${name} must be a positive integer`);
102
+ }
103
+ return parsed;
104
+ }
105
+
80
106
  export function loadConfig(): GatewayConfig {
81
107
  const portRaw = process.env.GATEWAY_PORT || "7830";
82
108
  const port = Number(portRaw);
@@ -119,6 +145,13 @@ export function loadConfig(): GatewayConfig {
119
145
  (typeof gw.defaultAssistantId === "string" && gw.defaultAssistantId
120
146
  ? gw.defaultAssistantId
121
147
  : undefined);
148
+ const runtimeTimeoutMs =
149
+ parsePositiveInteger(
150
+ process.env.RUNTIME_TIMEOUT_MS,
151
+ "RUNTIME_TIMEOUT_MS",
152
+ ) ??
153
+ parsePositiveInteger(gw.runtimeTimeoutMs, "gateway.runtimeTimeoutMs") ??
154
+ 30000;
122
155
  let routingEntries: RoutingEntry[] = [];
123
156
  if (process.env.ROUTING_ENTRIES) {
124
157
  try {
@@ -147,6 +180,7 @@ export function loadConfig(): GatewayConfig {
147
180
  hasVelayBaseUrl: !!velayBaseUrl,
148
181
  port,
149
182
  runtimeProxyRequireAuth,
183
+ runtimeTimeoutMs,
150
184
  trustProxy: false,
151
185
  },
152
186
  "Configuration loaded",
@@ -172,7 +206,7 @@ export function loadConfig(): GatewayConfig {
172
206
  runtimeInitialBackoffMs: 500,
173
207
  runtimeMaxRetries: 2,
174
208
  runtimeProxyRequireAuth,
175
- runtimeTimeoutMs: 30000,
209
+ runtimeTimeoutMs,
176
210
  shutdownDrainMs: 5000,
177
211
  unmappedPolicy,
178
212
  trustProxy: false,
@@ -2,6 +2,7 @@ import type { ConfigFileCache } from "../config-file-cache.js";
2
2
  import type { CredentialCache } from "../credential-cache.js";
3
3
  import { credentialKey } from "../credential-key.js";
4
4
  import { fetchImpl } from "../fetch.js";
5
+ import { arePlatformFeaturesEnabled } from "../feature-flag-resolver.js";
5
6
  import { getLogger } from "../logger.js";
6
7
 
7
8
  const log = getLogger("email-callback");
@@ -37,6 +38,13 @@ export async function registerEmailCallbackRoute(caches?: {
37
38
  credentials?: CredentialCache;
38
39
  configFile?: ConfigFileCache;
39
40
  }): Promise<string | undefined> {
41
+ if (!arePlatformFeaturesEnabled()) {
42
+ log.debug(
43
+ "platform-features-in-local-mode is disabled — skipping email callback registration",
44
+ );
45
+ return undefined;
46
+ }
47
+
40
48
  const [platformBaseUrlRaw, assistantApiKeyRaw, assistantIdRaw] =
41
49
  caches?.credentials
42
50
  ? await Promise.all([