@vellumai/vellum-gateway 0.8.4 → 0.8.5
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/ARCHITECTURE.md +2 -2
- package/package.json +1 -1
- package/src/__tests__/auto-approve-conversation-thresholds.test.ts +14 -6
- package/src/__tests__/config.test.ts +49 -0
- package/src/__tests__/feature-flag-watcher-callback.test.ts +85 -0
- package/src/__tests__/feature-flags-route.test.ts +40 -1
- package/src/__tests__/ipc-feature-flag-routes.test.ts +24 -0
- package/src/__tests__/remote-feature-flag-sync.test.ts +75 -5
- package/src/__tests__/route-schema-guard.test.ts +2 -0
- package/src/auth/ipc-route-policy.ts +5 -0
- package/src/config.ts +35 -1
- package/src/feature-flag-registry.json +16 -0
- package/src/feature-flag-remote-store.ts +7 -1
- package/src/feature-flag-watcher.ts +8 -1
- package/src/handlers/handle-inbound.ts +5 -1
- package/src/http/middleware/cors.ts +10 -2
- package/src/http/routes/auto-approve-thresholds.ts +10 -4
- package/src/http/routes/email-webhook.ts +10 -2
- package/src/http/routes/feature-flags.ts +7 -3
- package/src/http/routes/guardian-channel-create.test.ts +198 -0
- package/src/http/routes/guardian-channel-create.ts +137 -0
- package/src/http/routes/inbound-register.ts +27 -15
- package/src/http/routes/vellum-identity.ts +24 -0
- package/src/index.ts +17 -11
- package/src/ipc/feature-flag-handlers.ts +8 -3
- package/src/post-assistant-ready.ts +8 -4
- package/src/remote-feature-flag-sync.ts +10 -3
- package/src/risk/command-registry/commands/assistant.ts +13 -0
- package/src/runtime/client.ts +2 -0
- package/src/schema.ts +13 -9
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> }`).
|
|
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
|
@@ -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
|
|
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(
|
|
74
|
+
expect(res.status).toBe(200);
|
|
69
75
|
|
|
70
76
|
const body = await res.json();
|
|
71
|
-
expect(body
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
@@ -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
|
-
// "
|
|
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");
|
|
@@ -459,7 +459,7 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
459
459
|
);
|
|
460
460
|
});
|
|
461
461
|
|
|
462
|
-
test("
|
|
462
|
+
test("normalizes remote false for GA flags (defaultEnabled: true in registry)", async () => {
|
|
463
463
|
// The platform sends false for all flags it knows about (blanket-deny).
|
|
464
464
|
// GA flags (defaultEnabled: true in the registry) should not be disabled
|
|
465
465
|
// by remote overrides — only local persisted overrides can do that.
|
|
@@ -468,7 +468,8 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
468
468
|
fetchMock = mock(async () =>
|
|
469
469
|
Response.json({
|
|
470
470
|
flags: {
|
|
471
|
-
// GA flag (defaultEnabled: true) — remote false should be
|
|
471
|
+
// GA flag (defaultEnabled: true) — remote false should be normalized
|
|
472
|
+
// to true so the missing-key fallback does not disable it.
|
|
472
473
|
"test-ga-flag": false,
|
|
473
474
|
// Gated flag (defaultEnabled: false) — remote false is kept
|
|
474
475
|
"email-channel": false,
|
|
@@ -488,8 +489,8 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
488
489
|
|
|
489
490
|
clearRemoteFeatureFlagStoreCache();
|
|
490
491
|
const cached = readRemoteFeatureFlags();
|
|
491
|
-
// test-ga-flag (GA, remote false) should be
|
|
492
|
-
expect(cached["test-ga-flag"]).
|
|
492
|
+
// test-ga-flag (GA, remote false) should be normalized to true
|
|
493
|
+
expect(cached["test-ga-flag"]).toBe(true);
|
|
493
494
|
// email-channel (gated, remote false) should be present
|
|
494
495
|
expect(cached["email-channel"]).toBe(false);
|
|
495
496
|
// test-ga-flag-true (unknown but true) should be present
|
|
@@ -498,6 +499,75 @@ describe("RemoteFeatureFlagSync", () => {
|
|
|
498
499
|
expect(cached["unknown-flag"]).toBe(false);
|
|
499
500
|
});
|
|
500
501
|
|
|
502
|
+
test("calls onChanged when remote flags change", async () => {
|
|
503
|
+
fetchMock = mock(async () =>
|
|
504
|
+
Response.json({
|
|
505
|
+
flags: { "new-flag": true },
|
|
506
|
+
}),
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
const onChanged = mock(() => {});
|
|
510
|
+
const sync = new RemoteFeatureFlagSync({
|
|
511
|
+
credentials: fakeCredentialCache(defaultCredentials()),
|
|
512
|
+
onChanged,
|
|
513
|
+
});
|
|
514
|
+
await sync.start();
|
|
515
|
+
sync.stop();
|
|
516
|
+
|
|
517
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
518
|
+
expect(onChanged).toHaveBeenCalledTimes(1);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("does not call onChanged when remote flags have not changed", async () => {
|
|
522
|
+
// First sync to seed the file
|
|
523
|
+
fetchMock = mock(async () =>
|
|
524
|
+
Response.json({
|
|
525
|
+
flags: { "stable-flag": true },
|
|
526
|
+
}),
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
const onChanged1 = mock(() => {});
|
|
530
|
+
const sync1 = new RemoteFeatureFlagSync({
|
|
531
|
+
credentials: fakeCredentialCache(defaultCredentials()),
|
|
532
|
+
onChanged: onChanged1,
|
|
533
|
+
});
|
|
534
|
+
await sync1.start();
|
|
535
|
+
sync1.stop();
|
|
536
|
+
expect(onChanged1).toHaveBeenCalledTimes(1);
|
|
537
|
+
|
|
538
|
+
// Second sync with same data — onChanged should NOT fire
|
|
539
|
+
fetchMock = mock(async () =>
|
|
540
|
+
Response.json({
|
|
541
|
+
flags: { "stable-flag": true },
|
|
542
|
+
}),
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
const onChanged2 = mock(() => {});
|
|
546
|
+
const sync2 = new RemoteFeatureFlagSync({
|
|
547
|
+
credentials: fakeCredentialCache(defaultCredentials()),
|
|
548
|
+
onChanged: onChanged2,
|
|
549
|
+
});
|
|
550
|
+
await sync2.start();
|
|
551
|
+
sync2.stop();
|
|
552
|
+
expect(onChanged2).not.toHaveBeenCalled();
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("does not call onChanged on fetch failure", async () => {
|
|
556
|
+
fetchMock = mock(
|
|
557
|
+
async () => new Response("Internal Server Error", { status: 500 }),
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
const onChanged = mock(() => {});
|
|
561
|
+
const sync = new RemoteFeatureFlagSync({
|
|
562
|
+
credentials: fakeCredentialCache(defaultCredentials()),
|
|
563
|
+
onChanged,
|
|
564
|
+
});
|
|
565
|
+
await sync.start();
|
|
566
|
+
sync.stop();
|
|
567
|
+
|
|
568
|
+
expect(onChanged).not.toHaveBeenCalled();
|
|
569
|
+
});
|
|
570
|
+
|
|
501
571
|
test("trims whitespace from credential values", async () => {
|
|
502
572
|
fetchMock = mock(async () => Response.json({ flags: {} }));
|
|
503
573
|
|
|
@@ -173,6 +173,8 @@ 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",
|
|
176
178
|
]);
|
|
177
179
|
|
|
178
180
|
// ── Schema paths that don't map to a discrete route definition ──
|
|
@@ -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
|
|
209
|
+
runtimeTimeoutMs,
|
|
176
210
|
shutdownDrainMs: 5000,
|
|
177
211
|
unmappedPolicy,
|
|
178
212
|
trustProxy: false,
|
|
@@ -393,6 +393,14 @@
|
|
|
393
393
|
"description": "Enable the Notifications tab in settings.",
|
|
394
394
|
"defaultEnabled": false
|
|
395
395
|
},
|
|
396
|
+
{
|
|
397
|
+
"id": "preview-channel",
|
|
398
|
+
"scope": "client",
|
|
399
|
+
"key": "preview-channel",
|
|
400
|
+
"label": "Preview Channel",
|
|
401
|
+
"description": "Enable user-facing Preview release channel controls in assistant settings.",
|
|
402
|
+
"defaultEnabled": false
|
|
403
|
+
},
|
|
396
404
|
{
|
|
397
405
|
"id": "rollback-enabled",
|
|
398
406
|
"scope": "assistant",
|
|
@@ -424,6 +432,14 @@
|
|
|
424
432
|
"label": "Velvet",
|
|
425
433
|
"description": "Enable the Velvet design theme.",
|
|
426
434
|
"defaultEnabled": false
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
"id": "memory-router-playground",
|
|
438
|
+
"scope": "client",
|
|
439
|
+
"key": "memory-router-playground",
|
|
440
|
+
"label": "Memory Router Playground",
|
|
441
|
+
"description": "Expose the developer-only Memory Router Playground tab in macOS Settings and the /assistant/memory-router-playground web page for dry-running v4 router config overrides against the live page index. Dev-only; default off.",
|
|
442
|
+
"defaultEnabled": false
|
|
427
443
|
}
|
|
428
444
|
]
|
|
429
445
|
}
|
|
@@ -43,6 +43,10 @@ export function getRemoteFeatureFlagStorePath(): string {
|
|
|
43
43
|
return join(getGatewaySecurityDir(), "feature-flags-remote.json");
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export function hasRemoteFeatureFlagSnapshot(): boolean {
|
|
47
|
+
return existsSync(getRemoteFeatureFlagStorePath());
|
|
48
|
+
}
|
|
49
|
+
|
|
46
50
|
// ---------------------------------------------------------------------------
|
|
47
51
|
// Disk I/O with caching
|
|
48
52
|
// ---------------------------------------------------------------------------
|
|
@@ -96,7 +100,9 @@ export function readRemoteFeatureFlags(): Record<string, boolean> {
|
|
|
96
100
|
* Persist remote feature flags to disk and update the in-memory cache.
|
|
97
101
|
* Returns `true` when the new values differ from the previous cache.
|
|
98
102
|
*/
|
|
99
|
-
export function writeRemoteFeatureFlags(
|
|
103
|
+
export function writeRemoteFeatureFlags(
|
|
104
|
+
values: Record<string, boolean>,
|
|
105
|
+
): boolean {
|
|
100
106
|
const path = getRemoteFeatureFlagStorePath();
|
|
101
107
|
const dir = dirname(path);
|
|
102
108
|
if (!existsSync(dir)) {
|
|
@@ -25,6 +25,10 @@ const log = getLogger("feature-flag-watcher");
|
|
|
25
25
|
|
|
26
26
|
const DEBOUNCE_MS = 500;
|
|
27
27
|
|
|
28
|
+
export interface FeatureFlagWatcherOptions {
|
|
29
|
+
onChanged?: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
28
32
|
export class FeatureFlagWatcher {
|
|
29
33
|
private watcher: FSWatcher | null = null;
|
|
30
34
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -32,10 +36,12 @@ export class FeatureFlagWatcher {
|
|
|
32
36
|
private remoteFlagFilename: string;
|
|
33
37
|
/** Accumulates which files changed during the debounce window. */
|
|
34
38
|
private pendingFilenames = new Set<string>();
|
|
39
|
+
private onChanged?: () => void;
|
|
35
40
|
|
|
36
|
-
constructor() {
|
|
41
|
+
constructor(options?: FeatureFlagWatcherOptions) {
|
|
37
42
|
this.localFlagFilename = basename(getFeatureFlagStorePath());
|
|
38
43
|
this.remoteFlagFilename = basename(getRemoteFeatureFlagStorePath());
|
|
44
|
+
this.onChanged = options?.onChanged;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
start(): void {
|
|
@@ -106,6 +112,7 @@ export class FeatureFlagWatcher {
|
|
|
106
112
|
{ filenames: [...filenames] },
|
|
107
113
|
"Feature flag cache invalidated due to file change",
|
|
108
114
|
);
|
|
115
|
+
this.onChanged?.();
|
|
109
116
|
}, DEBOUNCE_MS);
|
|
110
117
|
}
|
|
111
118
|
}
|
|
@@ -171,8 +171,12 @@ export async function handleInbound(
|
|
|
171
171
|
eventId: response.eventId,
|
|
172
172
|
duplicate: response.duplicate,
|
|
173
173
|
hasReply: !!response.assistantMessage,
|
|
174
|
+
denied: response.denied ?? false,
|
|
175
|
+
deniedReason: response.denied ? (response.reason ?? "unknown") : undefined,
|
|
174
176
|
},
|
|
175
|
-
|
|
177
|
+
response.denied
|
|
178
|
+
? "Inbound event denied by runtime"
|
|
179
|
+
: "Inbound event forwarded to runtime",
|
|
176
180
|
);
|
|
177
181
|
|
|
178
182
|
// ── Contact channel interaction tracking (dual-write) ──
|
|
@@ -19,10 +19,18 @@ const WEBVIEW_ORIGIN_RE = /^https:\/\/[a-z0-9-]+\.vellum\.local$/;
|
|
|
19
19
|
const ALLOWED_METHODS = "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS";
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Headers the webview bridge sends (auth + content type + org id
|
|
22
|
+
* Headers the webview bridge sends (auth + content type + org id + client
|
|
23
|
+
* identity for ATL-703 self-echo suppression).
|
|
24
|
+
*
|
|
25
|
+
* `X-Vellum-Client-Id` / `X-Vellum-Interface-Id` mirror the headers the
|
|
26
|
+
* Chrome extension already sends (see `extensionCorsHeaders` below) and the
|
|
27
|
+
* raw SSE streams attach via `getClientRegistrationHeaders()`. The web SPA's
|
|
28
|
+
* central HeyAPI interceptor (`apps/web/src/lib/api-interceptors.ts`)
|
|
29
|
+
* attaches them to every generated-client request so the daemon route
|
|
30
|
+
* handlers can echo the id back on `sync_changed`.
|
|
23
31
|
*/
|
|
24
32
|
const ALLOWED_HEADERS =
|
|
25
|
-
"Authorization, Content-Type, X-Session-Token, Vellum-Organization-Id, X-Trace-Id";
|
|
33
|
+
"Authorization, Content-Type, X-Session-Token, Vellum-Organization-Id, X-Trace-Id, X-Vellum-Client-Id, X-Vellum-Interface-Id";
|
|
26
34
|
|
|
27
35
|
/**
|
|
28
36
|
* Check whether the request `Origin` header matches a known Vellum Chrome
|
|
@@ -154,6 +154,15 @@ export function createGlobalThresholdPutHandler() {
|
|
|
154
154
|
// ---------------------------------------------------------------------------
|
|
155
155
|
// GET /v1/permissions/thresholds/conversations/:conversationId
|
|
156
156
|
// ---------------------------------------------------------------------------
|
|
157
|
+
//
|
|
158
|
+
// Returns 200 with `{ threshold: <value> }` when an override exists, and
|
|
159
|
+
// 200 with `{ threshold: null }` when no override exists for the given
|
|
160
|
+
// conversation. Treating "no override" as a normal 200 result (rather than
|
|
161
|
+
// 404) avoids surfacing a misleading network error in the browser console
|
|
162
|
+
// for what is the common case — most conversations have no override —
|
|
163
|
+
// and keeps the HTTP response shape consistent with the IPC contract,
|
|
164
|
+
// which already returns `null` for the same condition.
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
157
166
|
|
|
158
167
|
export function createConversationThresholdGetHandler() {
|
|
159
168
|
return async (_req: Request, params: string[]): Promise<Response> => {
|
|
@@ -176,10 +185,7 @@ export function createConversationThresholdGetHandler() {
|
|
|
176
185
|
.get();
|
|
177
186
|
|
|
178
187
|
if (!row) {
|
|
179
|
-
return Response.json(
|
|
180
|
-
{ error: "No override for this conversation" },
|
|
181
|
-
{ status: 404 },
|
|
182
|
-
);
|
|
188
|
+
return Response.json({ threshold: null });
|
|
183
189
|
}
|
|
184
190
|
|
|
185
191
|
return Response.json({ threshold: row.threshold });
|