@vellumai/vellum-gateway 0.8.5 → 0.8.7

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 (40) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/package.json +1 -1
  3. package/src/__tests__/feature-flag-resolver.test.ts +91 -0
  4. package/src/__tests__/feature-flags-route.test.ts +50 -50
  5. package/src/__tests__/guardian-init-lockfile.test.ts +18 -2
  6. package/src/__tests__/ipc-feature-flag-routes.test.ts +13 -13
  7. package/src/__tests__/ipc-slack-thread-routes.test.ts +47 -0
  8. package/src/__tests__/remote-feature-flag-sync.test.ts +117 -8
  9. package/src/__tests__/route-schema-guard.test.ts +2 -0
  10. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +222 -0
  11. package/src/__tests__/trust-rules-route-auth.test.ts +36 -0
  12. package/src/auth/guardian-bootstrap.ts +8 -0
  13. package/src/db/slack-store.ts +1 -1
  14. package/src/email/register-callback.ts +8 -0
  15. package/src/feature-flag-defaults.ts +2 -2
  16. package/src/feature-flag-registry.json +42 -26
  17. package/src/feature-flag-remote-store.ts +0 -4
  18. package/src/feature-flag-resolver.ts +36 -0
  19. package/src/http/middleware/cors.ts +4 -0
  20. package/src/http/routes/auth-token.ts +60 -0
  21. package/src/http/routes/channel-verification-session-proxy.test.ts +2 -2
  22. package/src/http/routes/channel-verification-session-proxy.ts +19 -10
  23. package/src/http/routes/feature-flags.ts +5 -8
  24. package/src/http/routes/ipc-runtime-proxy.test.ts +36 -24
  25. package/src/http/routes/ipc-runtime-proxy.ts +35 -9
  26. package/src/index.ts +69 -12
  27. package/src/ipc/feature-flag-handlers.ts +4 -8
  28. package/src/ipc/route-schema-cache.test.ts +196 -0
  29. package/src/ipc/route-schema-cache.ts +100 -24
  30. package/src/remote-feature-flag-sync.ts +47 -23
  31. package/src/risk/bash-risk-classifier.test.ts +2 -2
  32. package/src/risk/command-registry/commands/assistant.ts +17 -15
  33. package/src/risk/command-registry.test.ts +1 -1
  34. package/src/schema.ts +2 -2
  35. package/src/slack/socket-mode.ts +97 -0
  36. package/src/telegram/webhook-manager.ts +8 -0
  37. package/src/webhook-copy.ts +5 -0
  38. package/src/__tests__/ipc-route-policy-coverage.test.ts +0 -297
  39. package/src/__tests__/ipc-route-policy.test.ts +0 -160
  40. package/src/auth/ipc-route-policy.ts +0 -375
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 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.
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 explicit remote values and registry defaults to produce the full flag list. Declared flags missing from a remote snapshot fall back to their registry `defaultEnabled`; undeclared keys are rejected by writes and fail closed in direct resolver calls. 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> }`). 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.
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, explicit remote values win over registry defaults, and missing remote values fall back to registry defaults. 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.5",
3
+ "version": "0.8.7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,91 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { testSecurityDir } from "./test-preload.js";
5
+
6
+ const protectedDir = testSecurityDir;
7
+ const defaultsPath = join(protectedDir, "feature-flag-registry.json");
8
+
9
+ const TEST_REGISTRY = {
10
+ version: 1,
11
+ flags: [
12
+ {
13
+ id: "browser",
14
+ scope: "assistant",
15
+ key: "browser",
16
+ label: "Browser",
17
+ description: "Browser skill",
18
+ defaultEnabled: true,
19
+ },
20
+ {
21
+ id: "a2a-channel",
22
+ scope: "assistant",
23
+ key: "a2a-channel",
24
+ label: "A2A Channel",
25
+ description: "A2A channel integration",
26
+ defaultEnabled: false,
27
+ },
28
+ ],
29
+ };
30
+
31
+ beforeEach(() => {
32
+ mkdirSync(protectedDir, { recursive: true });
33
+ writeFileSync(defaultsPath, JSON.stringify(TEST_REGISTRY, null, 2));
34
+ _setRegistryCandidateOverrides([defaultsPath]);
35
+ resetFeatureFlagDefaultsCache();
36
+ clearFeatureFlagStoreCache();
37
+ clearRemoteFeatureFlagStoreCache();
38
+ });
39
+
40
+ afterEach(() => {
41
+ try {
42
+ rmSync(protectedDir, { recursive: true, force: true });
43
+ mkdirSync(protectedDir, { recursive: true });
44
+ } catch {
45
+ // best effort cleanup
46
+ }
47
+ _setRegistryCandidateOverrides(null);
48
+ resetFeatureFlagDefaultsCache();
49
+ clearFeatureFlagStoreCache();
50
+ clearRemoteFeatureFlagStoreCache();
51
+ });
52
+
53
+ const { isFeatureFlagEnabled } = await import("../feature-flag-resolver.js");
54
+ const { resetFeatureFlagDefaultsCache, _setRegistryCandidateOverrides } =
55
+ await import("../feature-flag-defaults.js");
56
+ const { clearFeatureFlagStoreCache, writeFeatureFlag } =
57
+ await import("../feature-flag-store.js");
58
+ const { clearRemoteFeatureFlagStoreCache, writeRemoteFeatureFlags } =
59
+ await import("../feature-flag-remote-store.js");
60
+
61
+ describe("isFeatureFlagEnabled", () => {
62
+ test("uses registry defaults when no override exists", () => {
63
+ expect(isFeatureFlagEnabled("browser")).toBe(true);
64
+ expect(isFeatureFlagEnabled("a2a-channel")).toBe(false);
65
+ });
66
+
67
+ test("uses persisted overrides for declared flags", () => {
68
+ writeFeatureFlag("browser", false);
69
+ writeFeatureFlag("a2a-channel", true);
70
+
71
+ expect(isFeatureFlagEnabled("browser")).toBe(false);
72
+ expect(isFeatureFlagEnabled("a2a-channel")).toBe(true);
73
+ });
74
+
75
+ test("uses explicit remote values for declared flags", () => {
76
+ writeRemoteFeatureFlags({
77
+ browser: false,
78
+ "a2a-channel": true,
79
+ });
80
+
81
+ expect(isFeatureFlagEnabled("browser")).toBe(false);
82
+ expect(isFeatureFlagEnabled("a2a-channel")).toBe(true);
83
+ });
84
+
85
+ test("ignores persisted and remote values for undeclared flags", () => {
86
+ writeFeatureFlag("unknown", true);
87
+ writeRemoteFeatureFlags({ unknown: true });
88
+
89
+ expect(isFeatureFlagEnabled("unknown")).toBe(false);
90
+ });
91
+ });
@@ -32,11 +32,11 @@ const TEST_REGISTRY = {
32
32
  defaultEnabled: true,
33
33
  },
34
34
  {
35
- id: "email-channel",
35
+ id: "a2a-channel",
36
36
  scope: "assistant",
37
- key: "email-channel",
38
- label: "Email Channel",
39
- description: "Email channel integration",
37
+ key: "a2a-channel",
38
+ label: "A2A Channel",
39
+ description: "A2A channel integration",
40
40
  defaultEnabled: false,
41
41
  },
42
42
  {
@@ -248,19 +248,19 @@ describe("GET /v1/feature-flags handler", () => {
248
248
  });
249
249
 
250
250
  test("remote values fill in when no local override exists", async () => {
251
- // Write a remote store with email-channel enabled (overriding registry default of false)
251
+ // Write a remote store with a2a-channel enabled (overriding registry default of false)
252
252
  writeFileSync(
253
253
  remoteFeatureFlagStorePath,
254
254
  JSON.stringify({
255
255
  version: 1,
256
256
  values: {
257
- "email-channel": true,
257
+ "a2a-channel": true,
258
258
  },
259
259
  }),
260
260
  );
261
261
  clearRemoteFeatureFlagStoreCache();
262
262
 
263
- // No local override for email-channel
263
+ // No local override for a2a-channel
264
264
  if (existsSync(featureFlagStorePath)) {
265
265
  rmSync(featureFlagStorePath);
266
266
  }
@@ -274,12 +274,12 @@ describe("GET /v1/feature-flags handler", () => {
274
274
  expect(res.status).toBe(200);
275
275
  const body = await res.json();
276
276
 
277
- const emailFlag = body.flags.find(
278
- (f: { key: string }) => f.key === "email-channel",
277
+ const a2aFlag = body.flags.find(
278
+ (f: { key: string }) => f.key === "a2a-channel",
279
279
  );
280
- expect(emailFlag).toBeDefined();
280
+ expect(a2aFlag).toBeDefined();
281
281
  // Remote value (true) overrides registry default (false)
282
- expect(emailFlag.enabled).toBe(true);
282
+ expect(a2aFlag.enabled).toBe(true);
283
283
  });
284
284
 
285
285
  test("local overrides take precedence over remote values", async () => {
@@ -289,7 +289,7 @@ describe("GET /v1/feature-flags handler", () => {
289
289
  JSON.stringify({
290
290
  version: 1,
291
291
  values: {
292
- "email-channel": true,
292
+ "a2a-channel": true,
293
293
  },
294
294
  }),
295
295
  );
@@ -301,7 +301,7 @@ describe("GET /v1/feature-flags handler", () => {
301
301
  JSON.stringify({
302
302
  version: 1,
303
303
  values: {
304
- "email-channel": false,
304
+ "a2a-channel": false,
305
305
  },
306
306
  }),
307
307
  );
@@ -315,38 +315,38 @@ describe("GET /v1/feature-flags handler", () => {
315
315
  expect(res.status).toBe(200);
316
316
  const body = await res.json();
317
317
 
318
- const emailFlag = body.flags.find(
319
- (f: { key: string }) => f.key === "email-channel",
318
+ const a2aFlag = body.flags.find(
319
+ (f: { key: string }) => f.key === "a2a-channel",
320
320
  );
321
- expect(emailFlag).toBeDefined();
321
+ expect(a2aFlag).toBeDefined();
322
322
  // Local override (false) takes precedence over remote (true)
323
- expect(emailFlag.enabled).toBe(false);
323
+ expect(a2aFlag.enabled).toBe(false);
324
324
  });
325
325
 
326
326
  test("reflects updated flags after remote sync writes new values (stale cache regression)", async () => {
327
327
  // Scenario: the remote poller (RemoteFeatureFlagSync) writes
328
- // email-channel: false, the gateway caches it, then a subsequent
329
- // poll writes email-channel: true. The GET handler should return
328
+ // a2a-channel: false, the gateway caches it, then a subsequent
329
+ // poll writes a2a-channel: true. The GET handler should return
330
330
  // the updated value because writeRemoteFeatureFlags() updates
331
331
  // both disk and the in-memory cache.
332
332
 
333
- // Step 1: First poll writes email-channel: false (simulated via
333
+ // Step 1: First poll writes a2a-channel: false (simulated via
334
334
  // writeRemoteFeatureFlags, which is what the poller calls internally).
335
- writeRemoteFeatureFlags({ "email-channel": false });
335
+ writeRemoteFeatureFlags({ "a2a-channel": false });
336
336
 
337
337
  const handler = createFeatureFlagsGetHandler();
338
338
  const res1 = await handler(
339
339
  new Request("http://gateway.test/v1/feature-flags"),
340
340
  );
341
341
  const body1 = await res1.json();
342
- const emailFlag1 = body1.flags.find(
343
- (f: { key: string }) => f.key === "email-channel",
342
+ const a2aFlag1 = body1.flags.find(
343
+ (f: { key: string }) => f.key === "a2a-channel",
344
344
  );
345
- expect(emailFlag1.enabled).toBe(false);
345
+ expect(a2aFlag1.enabled).toBe(false);
346
346
 
347
- // Step 2: Second poll writes email-channel: true — the poller
347
+ // Step 2: Second poll writes a2a-channel: true — the poller
348
348
  // calls writeRemoteFeatureFlags which updates file + cache.
349
- writeRemoteFeatureFlags({ "email-channel": true });
349
+ writeRemoteFeatureFlags({ "a2a-channel": true });
350
350
 
351
351
  // Step 3: The GET handler should immediately reflect the update
352
352
  // without needing a file-watcher round-trip.
@@ -354,10 +354,10 @@ describe("GET /v1/feature-flags handler", () => {
354
354
  new Request("http://gateway.test/v1/feature-flags"),
355
355
  );
356
356
  const body2 = await res2.json();
357
- const emailFlag2 = body2.flags.find(
358
- (f: { key: string }) => f.key === "email-channel",
357
+ const a2aFlag2 = body2.flags.find(
358
+ (f: { key: string }) => f.key === "a2a-channel",
359
359
  );
360
- expect(emailFlag2.enabled).toBe(true);
360
+ expect(a2aFlag2.enabled).toBe(true);
361
361
  });
362
362
 
363
363
  test("registry default used when neither local nor remote is set", async () => {
@@ -381,13 +381,13 @@ describe("GET /v1/feature-flags handler", () => {
381
381
  expect(res.status).toBe(200);
382
382
  const body = await res.json();
383
383
 
384
- // email-channel has defaultEnabled: false in registry
385
- const emailFlag = body.flags.find(
386
- (f: { key: string }) => f.key === "email-channel",
384
+ // a2a-channel has defaultEnabled: false in registry
385
+ const a2aFlag = body.flags.find(
386
+ (f: { key: string }) => f.key === "a2a-channel",
387
387
  );
388
- expect(emailFlag).toBeDefined();
389
- expect(emailFlag.enabled).toBe(false);
390
- expect(emailFlag.defaultEnabled).toBe(false);
388
+ expect(a2aFlag).toBeDefined();
389
+ expect(a2aFlag.enabled).toBe(false);
390
+ expect(a2aFlag.defaultEnabled).toBe(false);
391
391
 
392
392
  // browser has defaultEnabled: true in registry
393
393
  const browserFlag = body.flags.find(
@@ -398,7 +398,7 @@ 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 () => {
401
+ test("declared flags missing from a remote snapshot use their registry defaults", async () => {
402
402
  // No local override
403
403
  if (existsSync(featureFlagStorePath)) {
404
404
  rmSync(featureFlagStorePath);
@@ -411,7 +411,7 @@ describe("GET /v1/feature-flags handler", () => {
411
411
  remoteFeatureFlagStorePath,
412
412
  JSON.stringify({
413
413
  version: 1,
414
- values: { "email-channel": true },
414
+ values: { "a2a-channel": true },
415
415
  }),
416
416
  );
417
417
  clearRemoteFeatureFlagStoreCache();
@@ -424,16 +424,16 @@ describe("GET /v1/feature-flags handler", () => {
424
424
  expect(res.status).toBe(200);
425
425
  const body = await res.json();
426
426
 
427
- const emailFlag = body.flags.find(
428
- (f: { key: string }) => f.key === "email-channel",
427
+ const a2aFlag = body.flags.find(
428
+ (f: { key: string }) => f.key === "a2a-channel",
429
429
  );
430
- expect(emailFlag.enabled).toBe(true);
430
+ expect(a2aFlag.enabled).toBe(true);
431
431
 
432
432
  const browserFlag = body.flags.find(
433
433
  (f: { key: string }) => f.key === "browser",
434
434
  );
435
435
  expect(browserFlag).toBeDefined();
436
- expect(browserFlag.enabled).toBe(false);
436
+ expect(browserFlag.enabled).toBe(true);
437
437
  expect(browserFlag.defaultEnabled).toBe(true);
438
438
  });
439
439
 
@@ -498,7 +498,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
498
498
  JSON.stringify({
499
499
  version: 1,
500
500
  values: {
501
- "email-channel": true,
501
+ "a2a-channel": true,
502
502
  },
503
503
  }),
504
504
  );
@@ -517,7 +517,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
517
517
  // Both old and new values should be persisted
518
518
  clearFeatureFlagStoreCache();
519
519
  const persisted = readPersistedFeatureFlags();
520
- expect(persisted["email-channel"]).toBe(true);
520
+ expect(persisted["a2a-channel"]).toBe(true);
521
521
  expect(persisted["browser"]).toBe(true);
522
522
  });
523
523
 
@@ -527,12 +527,12 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
527
527
 
528
528
  const handler = createFeatureFlagsPatchHandler();
529
529
  const res = await handler(
530
- new Request("http://gateway.test/v1/feature-flags/email-channel", {
530
+ new Request("http://gateway.test/v1/feature-flags/a2a-channel", {
531
531
  method: "PATCH",
532
532
  headers: { "content-type": "application/json" },
533
533
  body: JSON.stringify({ enabled: true }),
534
534
  }),
535
- "email-channel",
535
+ "a2a-channel",
536
536
  );
537
537
 
538
538
  expect(res.status).toBe(200);
@@ -540,7 +540,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
540
540
 
541
541
  clearFeatureFlagStoreCache();
542
542
  const persisted = readPersistedFeatureFlags();
543
- expect(persisted["email-channel"]).toBe(true);
543
+ expect(persisted["a2a-channel"]).toBe(true);
544
544
  });
545
545
 
546
546
  // Validation tests
@@ -633,7 +633,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
633
633
  test("accepts valid declared kebab-case key formats", async () => {
634
634
  const handler = createFeatureFlagsPatchHandler();
635
635
 
636
- const validKeys = ["browser", "email-channel"];
636
+ const validKeys = ["browser", "a2a-channel"];
637
637
 
638
638
  for (const key of validKeys) {
639
639
  clearFeatureFlagStoreCache();
@@ -704,7 +704,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
704
704
  featureFlagStorePath,
705
705
  JSON.stringify({
706
706
  version: 1,
707
- values: { "email-channel": true },
707
+ values: { "a2a-channel": true },
708
708
  }),
709
709
  );
710
710
  clearFeatureFlagStoreCache();
@@ -723,7 +723,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
723
723
  const raw = readFileSync(featureFlagStorePath, "utf-8");
724
724
  const data = JSON.parse(raw);
725
725
  expect(data.version).toBe(1);
726
- expect(data.values["email-channel"]).toBe(true);
726
+ expect(data.values["a2a-channel"]).toBe(true);
727
727
  expect(data.values["browser"]).toBe(false);
728
728
 
729
729
  // Verify no temp files left behind
@@ -737,7 +737,7 @@ describe("PATCH /v1/feature-flags/:flagKey handler", () => {
737
737
  const handler = createFeatureFlagsPatchHandler();
738
738
 
739
739
  // Fire multiple concurrent PATCH requests at the same time
740
- const flagKeys = ["browser", "email-channel"];
740
+ const flagKeys = ["browser", "a2a-channel"];
741
741
 
742
742
  const results = await Promise.all(
743
743
  flagKeys.map((key) =>
@@ -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 () => {
@@ -30,11 +30,11 @@ const TEST_REGISTRY = {
30
30
  defaultEnabled: true,
31
31
  },
32
32
  {
33
- id: "email-channel",
33
+ id: "a2a-channel",
34
34
  scope: "assistant",
35
- key: "email-channel",
36
- label: "Email Channel",
37
- description: "Email channel integration",
35
+ key: "a2a-channel",
36
+ label: "A2A Channel",
37
+ description: "A2A channel integration",
38
38
  defaultEnabled: false,
39
39
  },
40
40
  {
@@ -167,9 +167,9 @@ describe("IPC feature flag routes", () => {
167
167
  expect(res.result).toBeDefined();
168
168
 
169
169
  const flags = res.result as Record<string, boolean>;
170
- // browser defaults to true, email-channel defaults to false
170
+ // browser defaults to true, a2a-channel defaults to false
171
171
  expect(flags["browser"]).toBe(true);
172
- expect(flags["email-channel"]).toBe(false);
172
+ expect(flags["a2a-channel"]).toBe(false);
173
173
  });
174
174
 
175
175
  test("get_feature_flags merges persisted values over defaults", async () => {
@@ -188,7 +188,7 @@ describe("IPC feature flag routes", () => {
188
188
  expect(res.error).toBeUndefined();
189
189
  const flags = res.result as Record<string, boolean>;
190
190
  expect(flags["browser"]).toBe(false); // overridden from default true
191
- expect(flags["email-channel"]).toBe(false); // still default
191
+ expect(flags["a2a-channel"]).toBe(false); // still default
192
192
  });
193
193
 
194
194
  test("get_feature_flags merges remote values when no local override", async () => {
@@ -196,7 +196,7 @@ describe("IPC feature flag routes", () => {
196
196
  remoteFeatureFlagStorePath,
197
197
  JSON.stringify({
198
198
  version: 1,
199
- values: { "email-channel": true },
199
+ values: { "a2a-channel": true },
200
200
  }),
201
201
  );
202
202
  clearRemoteFeatureFlagStoreCache();
@@ -212,15 +212,15 @@ describe("IPC feature flag routes", () => {
212
212
 
213
213
  expect(res.error).toBeUndefined();
214
214
  const flags = res.result as Record<string, boolean>;
215
- expect(flags["email-channel"]).toBe(true); // remote overrides default
215
+ expect(flags["a2a-channel"]).toBe(true); // remote overrides default
216
216
  });
217
217
 
218
- test("get_feature_flags disables declared flags missing from a remote snapshot", async () => {
218
+ test("get_feature_flags falls back to registry defaults for flags missing from a remote snapshot", async () => {
219
219
  writeFileSync(
220
220
  remoteFeatureFlagStorePath,
221
221
  JSON.stringify({
222
222
  version: 1,
223
- values: { "email-channel": true },
223
+ values: { "a2a-channel": true },
224
224
  }),
225
225
  );
226
226
  clearRemoteFeatureFlagStoreCache();
@@ -235,8 +235,8 @@ describe("IPC feature flag routes", () => {
235
235
 
236
236
  expect(res.error).toBeUndefined();
237
237
  const flags = res.result as Record<string, boolean>;
238
- expect(flags["email-channel"]).toBe(true);
239
- expect(flags["browser"]).toBe(false);
238
+ expect(flags["a2a-channel"]).toBe(true);
239
+ expect(flags["browser"]).toBe(true);
240
240
  });
241
241
 
242
242
  test("get_feature_flag returns value for a known flag", async () => {
@@ -77,10 +77,39 @@ function activeThreadRows(): Array<{ threadTs: string; channelId: string }> {
77
77
  return new SlackStore(getGatewayDb()).listActiveThreadsWithChannel();
78
78
  }
79
79
 
80
+ function rawActiveThreadRows(): Array<{
81
+ threadTs: string;
82
+ channelId: string | null;
83
+ }> {
84
+ const rawDb = (getGatewayDb() as unknown as { $client: unknown }).$client as {
85
+ prepare: (sql: string) => {
86
+ all: () => Array<{ thread_ts: string; channel_id: string | null }>;
87
+ };
88
+ };
89
+ return rawDb
90
+ .prepare("SELECT thread_ts, channel_id FROM slack_active_threads")
91
+ .all()
92
+ .map((row) => ({
93
+ threadTs: row.thread_ts,
94
+ channelId: row.channel_id,
95
+ }));
96
+ }
97
+
80
98
  function trackThread(): void {
81
99
  new SlackStore(getGatewayDb()).trackThread(THREAD_TS, CHANNEL_ID, 60_000);
82
100
  }
83
101
 
102
+ function trackLegacyThreadWithoutChannel(): void {
103
+ const rawDb = (getGatewayDb() as unknown as { $client: unknown }).$client as {
104
+ prepare: (sql: string) => { run: (...params: unknown[]) => void };
105
+ };
106
+ rawDb
107
+ .prepare(
108
+ "INSERT INTO slack_active_threads (thread_ts, channel_id, tracked_at, expires_at) VALUES (?, NULL, ?, ?)",
109
+ )
110
+ .run(THREAD_TS, Date.now(), Date.now() + 60_000);
111
+ }
112
+
84
113
  describe("IPC Slack thread routes", () => {
85
114
  let server: InstanceType<typeof GatewayIpcServer>;
86
115
  let client: Socket;
@@ -135,6 +164,24 @@ describe("IPC Slack thread routes", () => {
135
164
  ]);
136
165
  });
137
166
 
167
+ test("detach_slack_active_thread removes a legacy active thread without channel", async () => {
168
+ trackLegacyThreadWithoutChannel();
169
+
170
+ await startServerAndConnect();
171
+ const res = await sendRequest(client, "detach_slack_active_thread", {
172
+ channelId: CHANNEL_ID,
173
+ threadTs: THREAD_TS,
174
+ });
175
+
176
+ expect(res.error).toBeUndefined();
177
+ expect(res.result).toEqual({
178
+ detached: true,
179
+ channelId: CHANNEL_ID,
180
+ threadTs: THREAD_TS,
181
+ });
182
+ expect(rawActiveThreadRows()).toEqual([]);
183
+ });
184
+
138
185
  test("detach_slack_active_thread does not remove channel mismatches", async () => {
139
186
  trackThread();
140
187