@vellumai/assistant 0.10.4-dev.202607012136.5ef7865 → 0.10.4-dev.202607012234.a9be056

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 (33) hide show
  1. package/openapi.yaml +80 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/context-search-conversations-source.test.ts +7 -9
  4. package/src/__tests__/non-member-access-request.test.ts +224 -1
  5. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  6. package/src/__tests__/slack-inbound-verification.test.ts +36 -0
  7. package/src/__tests__/trusted-contact-multichannel.test.ts +4 -0
  8. package/src/approvals/guardian-request-resolvers.ts +21 -1
  9. package/src/calls/relay-server.ts +10 -0
  10. package/src/cli/lib/__tests__/toggle-plugin.test.ts +7 -0
  11. package/src/cli/lib/toggle-plugin.ts +21 -2
  12. package/src/config/assistant-feature-flags.ts +0 -22
  13. package/src/config/feature-flag-registry.json +0 -8
  14. package/src/contacts/__tests__/member-write-relay.test.ts +57 -1
  15. package/src/contacts/member-write-relay.ts +41 -0
  16. package/src/daemon/disk-pressure-policy.ts +2 -1
  17. package/src/daemon/message-provenance.ts +12 -14
  18. package/src/daemon/message-types/sync.ts +1 -0
  19. package/src/persistence/__tests__/conversation-queries-search.test.ts +18 -27
  20. package/src/persistence/conversation-queries.ts +14 -18
  21. package/src/persistence/conversation-search-lexical.ts +1 -1
  22. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +2 -3
  23. package/src/runtime/access-request-helper.ts +71 -2
  24. package/src/runtime/routes/__tests__/plugins-routes.test.ts +360 -3
  25. package/src/runtime/routes/app-management-routes.ts +4 -7
  26. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +98 -0
  27. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +50 -4
  28. package/src/runtime/routes/inbound-stages/background-dispatch.ts +2 -3
  29. package/src/runtime/routes/plugins-routes.ts +149 -1
  30. package/src/runtime/sync/resource-sync-events.ts +15 -0
  31. package/src/runtime/trust-class.test.ts +141 -0
  32. package/src/runtime/trust-class.ts +88 -0
  33. package/src/config/__tests__/messages-search-backend.test.ts +0 -77
package/openapi.yaml CHANGED
@@ -21119,6 +21119,11 @@ paths:
21119
21119
  name:
21120
21120
  type: string
21121
21121
  description: Display name. Equal to `id` today.
21122
+ enabled:
21123
+ type: boolean
21124
+ description:
21125
+ Whether the plugin is active in this workspace. `false` when a `.disabled` sentinel is present under its
21126
+ directory; `true` otherwise.
21122
21127
  description:
21123
21128
  anyOf:
21124
21129
  - type: string
@@ -21147,6 +21152,7 @@ paths:
21147
21152
  required:
21148
21153
  - id
21149
21154
  - name
21155
+ - enabled
21150
21156
  - description
21151
21157
  - version
21152
21158
  additionalProperties: false
@@ -21430,6 +21436,80 @@ paths:
21430
21436
  description: The install recorded no commit or no fingerprint to re-materialize and verify a baseline from.
21431
21437
  "503":
21432
21438
  description: The plugin source (GitHub) was temporarily unavailable; the diff is retryable.
21439
+ /v1/plugins/{name}/disable:
21440
+ post:
21441
+ operationId: plugins_by_name_disable_post
21442
+ summary: Disable a plugin
21443
+ description:
21444
+ Disable a plugin in this workspace by dropping a `.disabled` sentinel, mirroring the CLI's `assistant
21445
+ plugins disable <name>`. The change is honored live at read time by every tool / injector / hook gate — no
21446
+ restart required. Broadcasts a `sync_changed` invalidation carrying the `plugins:list` tag so other clients
21447
+ refetch `GET /v1/plugins`. An already-disabled plugin returns 409; a user plugin with no directory returns 404
21448
+ (default plugins are stubbed on demand via the `default-` prefix); a malformed name returns 400.
21449
+ tags:
21450
+ - plugins
21451
+ parameters:
21452
+ - name: name
21453
+ in: path
21454
+ required: true
21455
+ schema:
21456
+ type: string
21457
+ responses:
21458
+ "200":
21459
+ description: Successful response
21460
+ content:
21461
+ application/json:
21462
+ schema:
21463
+ type: object
21464
+ properties:
21465
+ ok:
21466
+ type: boolean
21467
+ required:
21468
+ - ok
21469
+ additionalProperties: false
21470
+ "400":
21471
+ description: The plugin name failed validation (not kebab-case alphanumerics).
21472
+ "404":
21473
+ description: No plugin directory exists with the given name (user plugins must already be installed).
21474
+ "409":
21475
+ description: The plugin is already disabled.
21476
+ /v1/plugins/{name}/enable:
21477
+ post:
21478
+ operationId: plugins_by_name_enable_post
21479
+ summary: Enable a plugin
21480
+ description:
21481
+ Enable a plugin in this workspace by removing its `.disabled` sentinel, mirroring the CLI's `assistant
21482
+ plugins enable <name>`. The change is honored live at read time by every tool / injector / hook gate — no
21483
+ restart required. Broadcasts a `sync_changed` invalidation carrying the `plugins:list` tag so other clients
21484
+ refetch `GET /v1/plugins`. An already-enabled plugin returns 409; a name with no plugin directory returns 404
21485
+ (prefix a default plugin with `default-`); a malformed name returns 400.
21486
+ tags:
21487
+ - plugins
21488
+ parameters:
21489
+ - name: name
21490
+ in: path
21491
+ required: true
21492
+ schema:
21493
+ type: string
21494
+ responses:
21495
+ "200":
21496
+ description: Successful response
21497
+ content:
21498
+ application/json:
21499
+ schema:
21500
+ type: object
21501
+ properties:
21502
+ ok:
21503
+ type: boolean
21504
+ required:
21505
+ - ok
21506
+ additionalProperties: false
21507
+ "400":
21508
+ description: The plugin name failed validation (not kebab-case alphanumerics).
21509
+ "404":
21510
+ description: No plugin directory exists with the given name.
21511
+ "409":
21512
+ description: The plugin is already enabled.
21433
21513
  /v1/plugins/{name}/inspect:
21434
21514
  get:
21435
21515
  operationId: plugins_by_name_inspect_get
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.4-dev.202607012136.5ef7865",
3
+ "version": "0.10.4-dev.202607012234.a9be056",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -2,13 +2,12 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  import { writeSlackMetadata } from "../messaging/providers/slack/message-metadata.js";
4
4
  import type { MessageLexicalSearchResult } from "../persistence/embeddings/messages-lexical-index.js";
5
- import { setOverridesForTesting } from "./feature-flag-test-helpers.js";
6
5
 
7
6
  // Mutable stand-in for the Qdrant lexical candidate helper. The default
8
7
  // throws so any qdrant-path test that forgets to set an implementation fails
9
8
  // loudly rather than silently exercising an empty candidate set. FTS-path
10
- // tests never reach it (the flag defaults to fts5), so its value is irrelevant
11
- // there.
9
+ // tests never reach it (fts5 is used until the backfill completes), so its
10
+ // value is irrelevant there.
12
11
  let lexicalMockImpl: (
13
12
  query: string,
14
13
  limit: number,
@@ -407,14 +406,13 @@ describe("searchConversationSource with the qdrant backend", () => {
407
406
  getDb().run("DELETE FROM conversations");
408
407
  lexicalCalls = [];
409
408
  suppressIndexing = false;
410
- // These tests exercise the populated-index (post-backfill) qdrant path, so
411
- // mark the backfill complete. The completion gate is covered by its own test.
409
+ // The qdrant backend is selected once the index is populated: not suppressed
410
+ // + backfill complete. These tests exercise that post-backfill path, so mark
411
+ // the backfill complete. The completion gate is covered by its own test.
412
412
  setMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY, "1");
413
- setOverridesForTesting({ "messages-search-backend": true });
414
413
  });
415
414
 
416
415
  afterEach(() => {
417
- setOverridesForTesting({});
418
416
  suppressIndexing = false;
419
417
  deleteMemoryCheckpoint(LEXICAL_BACKFILL_COMPLETE_KEY);
420
418
  lexicalMockImpl = () => {
@@ -424,7 +422,7 @@ describe("searchConversationSource with the qdrant backend", () => {
424
422
  };
425
423
  });
426
424
 
427
- test("falls back to FTS when memory indexing is suppressed, even with the qdrant flag on", async () => {
425
+ test("falls back to FTS when memory indexing is suppressed", async () => {
428
426
  const match = await seedConversation({
429
427
  title: "Suppressed indexing notes",
430
428
  content: "The suppressedtoken decision is recorded here.",
@@ -451,7 +449,7 @@ describe("searchConversationSource with the qdrant backend", () => {
451
449
  ]);
452
450
  });
453
451
 
454
- test("falls back to FTS until the backfill completion checkpoint is set, even with the qdrant flag on", async () => {
452
+ test("falls back to FTS until the backfill completion checkpoint is set", async () => {
455
453
  const match = await seedConversation({
456
454
  title: "Pre-backfill notes",
457
455
  content: "The prebackfilltoken decision is recorded here.",
@@ -94,12 +94,17 @@ function seedGatewayGuardian(
94
94
  }
95
95
 
96
96
  import {
97
+ createCanonicalGuardianRequest,
97
98
  listCanonicalGuardianDeliveries,
98
99
  listCanonicalGuardianRequests,
100
+ resolveCanonicalGuardianRequest,
99
101
  } from "../contacts/canonical-guardian-store.js";
100
102
  import { getDb } from "../persistence/db-connection.js";
101
103
  import { initializeDb } from "../persistence/db-init.js";
102
- import { notifyGuardianOfAccessRequest } from "../runtime/access-request-helper.js";
104
+ import {
105
+ isAccessRequestDenied,
106
+ notifyGuardianOfAccessRequest,
107
+ } from "../runtime/access-request-helper.js";
103
108
  import { handleChannelInbound } from "./helpers/channel-test-adapter.js";
104
109
  import { createGuardianBinding } from "./helpers/create-guardian-binding.js";
105
110
 
@@ -304,6 +309,74 @@ describe("non-member access request notification", () => {
304
309
  expect(pending.length).toBe(1);
305
310
  });
306
311
 
312
+ // After the guardian denies an access request, subsequent DMs from the same
313
+ // sender do not re-prompt the guardian. Drives the real inbound path for both
314
+ // messages so the deny-dedup matches the exact assistant-scoped
315
+ // conversationId the notify path derives — a hand-crafted fixture could mask
316
+ // a mismatch.
317
+ test("a denied sender's subsequent DM does not re-prompt the guardian", async () => {
318
+ seedGatewayGuardian({
319
+ channelType: "telegram",
320
+ address: "guardian-user-789",
321
+ externalChatId: "guardian-chat-789",
322
+ principalId: anchorPrincipalId,
323
+ });
324
+ createGuardianBinding({
325
+ channel: "telegram",
326
+ guardianExternalUserId: "guardian-user-789",
327
+ guardianDeliveryChatId: "guardian-chat-789",
328
+ guardianPrincipalId: anchorPrincipalId,
329
+ verifiedVia: "test",
330
+ });
331
+
332
+ // 1) First DM from the unknown sender → guardian is prompted once.
333
+ await handleChannelInbound(
334
+ buildInboundRequest(),
335
+ undefined,
336
+ TEST_BEARER_TOKEN,
337
+ );
338
+ expect(emitSignalCalls.length).toBe(1);
339
+
340
+ const created = listCanonicalGuardianRequests({
341
+ status: "pending",
342
+ requesterExternalUserId: "user-unknown-456",
343
+ sourceChannel: "telegram",
344
+ kind: "access_request",
345
+ });
346
+ expect(created.length).toBe(1);
347
+
348
+ // 2) Guardian denies — resolve the *real* request (same CAS transition the
349
+ // deny primitive performs), preserving its conversationId.
350
+ const denied = resolveCanonicalGuardianRequest(created[0].id, "pending", {
351
+ status: "denied",
352
+ decidedByExternalUserId: "guardian-user-789",
353
+ });
354
+ expect(denied?.status).toBe("denied");
355
+
356
+ // 3) Same sender DMs again → still denied, but NO new guardian prompt.
357
+ const resp2 = await handleChannelInbound(
358
+ buildInboundRequest({
359
+ externalMessageId: `msg-after-deny-${Date.now()}`,
360
+ }),
361
+ undefined,
362
+ TEST_BEARER_TOKEN,
363
+ );
364
+ const json2 = (await resp2.json()) as Record<string, unknown>;
365
+ expect(json2.denied).toBe(true);
366
+
367
+ // No additional access-request signal was emitted (still just the first).
368
+ expect(emitSignalCalls.length).toBe(1);
369
+
370
+ // And no fresh pending request was created for the denied sender.
371
+ const pendingAfter = listCanonicalGuardianRequests({
372
+ status: "pending",
373
+ requesterExternalUserId: "user-unknown-456",
374
+ sourceChannel: "telegram",
375
+ kind: "access_request",
376
+ });
377
+ expect(pendingAfter.length).toBe(0);
378
+ });
379
+
307
380
  test("access request is created with self-healed principal even without same-channel guardian binding", async () => {
308
381
  // No guardian binding on any channel — self-heal creates a vellum binding
309
382
  // so the access_request (now decisionable) has a guardianPrincipalId.
@@ -826,4 +899,154 @@ describe("access-request-helper unit tests", () => {
826
899
  expect(telegram!.destinationChatId).toBe("guardian-chat-456");
827
900
  expect(telegram!.status).toBe("sent");
828
901
  });
902
+
903
+ test("notifyGuardianOfAccessRequest is suppressed after a prior deny for the same sender", async () => {
904
+ // Simulate a previously-denied access request for this sender on this
905
+ // channel/assistant. The conversationId must match the assistant-scoped
906
+ // key the helper derives: access-req-<assistantId>-<channel>-<actor>.
907
+ createCanonicalGuardianRequest({
908
+ id: `denied-${Date.now()}`,
909
+ kind: "access_request",
910
+ sourceType: "channel",
911
+ sourceChannel: "telegram",
912
+ conversationId: "access-req-self-telegram-denied-user",
913
+ requesterExternalUserId: "denied-user",
914
+ guardianPrincipalId: anchorPrincipalId,
915
+ toolName: "ingress_access_request",
916
+ status: "denied",
917
+ });
918
+
919
+ const result = await notifyGuardianOfAccessRequest({
920
+ canonicalAssistantId: "self",
921
+ sourceChannel: "telegram",
922
+ conversationExternalId: "chat-denied",
923
+ actorExternalId: "denied-user",
924
+ actorDisplayName: "Denied User",
925
+ });
926
+
927
+ // Suppressed: no new prompt, no signal, no new pending request.
928
+ expect(result.notified).toBe(false);
929
+ if (!result.notified) {
930
+ expect(result.reason).toBe("already_denied");
931
+ }
932
+ expect(emitSignalCalls.length).toBe(0);
933
+
934
+ const pending = listCanonicalGuardianRequests({
935
+ status: "pending",
936
+ requesterExternalUserId: "denied-user",
937
+ kind: "access_request",
938
+ });
939
+ expect(pending.length).toBe(0);
940
+ });
941
+
942
+ test("a prior deny for one sender does not suppress prompts for a different sender", async () => {
943
+ createCanonicalGuardianRequest({
944
+ id: `denied-other-${Date.now()}`,
945
+ kind: "access_request",
946
+ sourceType: "channel",
947
+ sourceChannel: "telegram",
948
+ conversationId: "access-req-self-telegram-denied-user",
949
+ requesterExternalUserId: "denied-user",
950
+ guardianPrincipalId: anchorPrincipalId,
951
+ toolName: "ingress_access_request",
952
+ status: "denied",
953
+ });
954
+
955
+ // A different sender still gets a fresh prompt.
956
+ const result = await notifyGuardianOfAccessRequest({
957
+ canonicalAssistantId: "self",
958
+ sourceChannel: "telegram",
959
+ conversationExternalId: "chat-fresh",
960
+ actorExternalId: "fresh-user",
961
+ actorDisplayName: "Fresh User",
962
+ });
963
+
964
+ expect(result.notified).toBe(true);
965
+ expect(emitSignalCalls.length).toBe(1);
966
+
967
+ const pending = listCanonicalGuardianRequests({
968
+ status: "pending",
969
+ requesterExternalUserId: "fresh-user",
970
+ kind: "access_request",
971
+ });
972
+ expect(pending.length).toBe(1);
973
+ });
974
+
975
+ test("a denied request on a different channel does not suppress a new channel's prompt", async () => {
976
+ // Denied on telegram; the same actor id messaging on slack is a distinct
977
+ // (channel-scoped) context and still surfaces to the guardian.
978
+ createCanonicalGuardianRequest({
979
+ id: `denied-tg-${Date.now()}`,
980
+ kind: "access_request",
981
+ sourceType: "channel",
982
+ sourceChannel: "telegram",
983
+ conversationId: "access-req-self-telegram-cross-user",
984
+ requesterExternalUserId: "cross-user",
985
+ guardianPrincipalId: anchorPrincipalId,
986
+ toolName: "ingress_access_request",
987
+ status: "denied",
988
+ });
989
+
990
+ const result = await notifyGuardianOfAccessRequest({
991
+ canonicalAssistantId: "self",
992
+ sourceChannel: "slack",
993
+ conversationExternalId: "C-cross",
994
+ actorExternalId: "cross-user",
995
+ actorDisplayName: "Cross User",
996
+ });
997
+
998
+ expect(result.notified).toBe(true);
999
+ const pending = listCanonicalGuardianRequests({
1000
+ status: "pending",
1001
+ requesterExternalUserId: "cross-user",
1002
+ sourceChannel: "slack",
1003
+ kind: "access_request",
1004
+ });
1005
+ expect(pending.length).toBe(1);
1006
+ });
1007
+
1008
+ test("isAccessRequestDenied is true only for the denied (assistant, channel, sender)", () => {
1009
+ const key = {
1010
+ canonicalAssistantId: "self",
1011
+ sourceChannel: "telegram",
1012
+ actorExternalId: "denied-user",
1013
+ };
1014
+ expect(isAccessRequestDenied(key)).toBe(false);
1015
+
1016
+ createCanonicalGuardianRequest({
1017
+ id: `denied-${Date.now()}`,
1018
+ kind: "access_request",
1019
+ sourceType: "channel",
1020
+ sourceChannel: "telegram",
1021
+ conversationId: "access-req-self-telegram-denied-user",
1022
+ requesterExternalUserId: "denied-user",
1023
+ guardianPrincipalId: anchorPrincipalId,
1024
+ toolName: "ingress_access_request",
1025
+ status: "denied",
1026
+ });
1027
+
1028
+ expect(isAccessRequestDenied(key)).toBe(true);
1029
+ // Scoped: a different channel or sender is not treated as denied.
1030
+ expect(isAccessRequestDenied({ ...key, sourceChannel: "slack" })).toBe(
1031
+ false,
1032
+ );
1033
+ expect(isAccessRequestDenied({ ...key, actorExternalId: "other" })).toBe(
1034
+ false,
1035
+ );
1036
+ // A still-pending request is not a terminal deny.
1037
+ createCanonicalGuardianRequest({
1038
+ id: `pending-${Date.now()}`,
1039
+ kind: "access_request",
1040
+ sourceType: "channel",
1041
+ sourceChannel: "telegram",
1042
+ conversationId: "access-req-self-telegram-pending-user",
1043
+ requesterExternalUserId: "pending-user",
1044
+ guardianPrincipalId: anchorPrincipalId,
1045
+ toolName: "ingress_access_request",
1046
+ status: "pending",
1047
+ });
1048
+ expect(
1049
+ isAccessRequestDenied({ ...key, actorExternalId: "pending-user" }),
1050
+ ).toBe(false);
1051
+ });
829
1052
  });
@@ -78,7 +78,6 @@ const BASELINE: Record<string, readonly string[]> = {
78
78
  "../../../util/logger.js",
79
79
  ],
80
80
  memory: [
81
- "../../../../../config/assistant-feature-flags.js",
82
81
  "../../../../../config/types.js",
83
82
  "../../../../../messaging/providers/slack/message-metadata.js",
84
83
  "../../../../../persistence/auto-analysis-constants.js",
@@ -84,6 +84,7 @@ function seedGatewayGuardian(
84
84
  });
85
85
  }
86
86
 
87
+ import { createCanonicalGuardianRequest } from "../contacts/canonical-guardian-store.js";
87
88
  import { getDb } from "../persistence/db-connection.js";
88
89
  import { initializeDb } from "../persistence/db-init.js";
89
90
  import { findActiveSession } from "../runtime/channel-verification-service.js";
@@ -186,6 +187,41 @@ describe("Slack inbound trusted contact verification", () => {
186
187
  ).toContain("I don't recognize you yet");
187
188
  });
188
189
 
190
+ test("a terminally-denied Slack sender gets no challenge and no guardian re-prompt", async () => {
191
+ // Guardian previously denied this sender (terminal). Seed the denied
192
+ // canonical request under the assistant-scoped conversationId the ingress
193
+ // path derives for (self, slack, U0123UNKNOWN).
194
+ createCanonicalGuardianRequest({
195
+ id: `denied-${Date.now()}`,
196
+ kind: "access_request",
197
+ sourceType: "channel",
198
+ sourceChannel: "slack",
199
+ conversationId: "access-req-self-slack-U0123UNKNOWN",
200
+ requesterExternalUserId: "U0123UNKNOWN",
201
+ guardianPrincipalId: "guardian-principal",
202
+ toolName: "ingress_access_request",
203
+ status: "denied",
204
+ });
205
+
206
+ const resp = await handleChannelInbound(
207
+ buildSlackInboundRequest(),
208
+ undefined,
209
+ TEST_BEARER_TOKEN,
210
+ );
211
+ const json = (await resp.json()) as Record<string, unknown>;
212
+
213
+ // Still denied — but not via a fresh, unusable verification challenge.
214
+ expect(json.denied).toBe(true);
215
+ expect(json.reason).not.toBe("verification_challenge_sent");
216
+ expect(json.verificationSessionId).toBeUndefined();
217
+
218
+ // No verification session was minted for the denied sender.
219
+ expect(findActiveSession("slack")).toBeNull();
220
+
221
+ // And the guardian was not re-notified.
222
+ expect(emitSignalCalls.length).toBe(0);
223
+ });
224
+
189
225
  test("verification session is identity-bound to the Slack user", async () => {
190
226
  const req = buildSlackInboundRequest();
191
227
  await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
@@ -52,6 +52,10 @@ mock.module("../runtime/access-request-helper.js", () => ({
52
52
  requestId: `mock-req-${Date.now()}`,
53
53
  };
54
54
  },
55
+ // acl-enforcement imports this alongside notifyGuardianOfAccessRequest; stub
56
+ // it so the terminal-deny check never falls through to the real DB-backed
57
+ // helper in this mocked suite.
58
+ isAccessRequestDenied: () => false,
55
59
  }));
56
60
 
57
61
  const deliverReplyCalls: Array<{
@@ -17,7 +17,10 @@ import {
17
17
  getCanonicalGuardianRequest,
18
18
  } from "../contacts/canonical-guardian-store.js";
19
19
  import { findContactChannel } from "../contacts/contact-store.js";
20
- import { activateMemberChannel } from "../contacts/member-write-relay.js";
20
+ import {
21
+ activateMemberChannel,
22
+ seedUnverifiedMemberChannel,
23
+ } from "../contacts/member-write-relay.js";
21
24
  import { findConversation } from "../daemon/conversation-registry.js";
22
25
  import { emitNotificationSignal } from "../notifications/emit-signal.js";
23
26
  import {
@@ -499,6 +502,23 @@ const accessRequestResolver: GuardianRequestResolver = {
499
502
  "Access request resolver: deny",
500
503
  );
501
504
 
505
+ // Persist the denied sender as an unverified_contact (gateway-first).
506
+ // Denial is a terminal decision: the sender becomes a known, unverified
507
+ // contact so future inbound resolves as unverified_contact rather than
508
+ // re-triggering discovery. Paired with the denied-request suppression in
509
+ // notifyGuardianOfAccessRequest, this stops the prompt from re-firing on
510
+ // every subsequent DM. The guardian can still verify them later. Skipped
511
+ // for desktop-origin (vellum) requests, which carry no channel identity.
512
+ if (requesterExternalUserId && channel !== "vellum") {
513
+ await seedUnverifiedMemberChannel({
514
+ sourceChannel: channel,
515
+ externalUserId: requesterExternalUserId,
516
+ ...(requesterDisplayName
517
+ ? { displayName: requesterDisplayName }
518
+ : {}),
519
+ });
520
+ }
521
+
502
522
  // Deliver denial notification and lifecycle signals when channel context is available
503
523
  if (channelDeliveryContext) {
504
524
  try {
@@ -1450,6 +1450,16 @@ export class RelayConnection {
1450
1450
  },
1451
1451
  "Guardian notified of voice access request with caller name",
1452
1452
  );
1453
+ } else if (accessResult.reason === "already_denied") {
1454
+ // The guardian already denied this caller; they are intentionally not
1455
+ // re-notified. Deliver the denial copy rather than the "I'll let them
1456
+ // know" timeout copy, which would falsely promise a notification.
1457
+ log.info(
1458
+ { callSessionId: this.callSessionId },
1459
+ "Voice caller previously denied — suppressing re-notification, delivering denial",
1460
+ );
1461
+ void this.handleAccessRequestDenied();
1462
+ return;
1453
1463
  } else {
1454
1464
  log.warn(
1455
1465
  { callSessionId: this.callSessionId },
@@ -132,6 +132,13 @@ describe("enablePlugin", () => {
132
132
  );
133
133
  });
134
134
 
135
+ it("throws PluginDirectoryNotFoundError for non-default plugin with no directory", () => {
136
+ // A missing user plugin must 404, not surface as an already-enabled no-op.
137
+ expect(() => enablePlugin("nonexistent-plugin")).toThrow(
138
+ PluginDirectoryNotFoundError,
139
+ );
140
+ });
141
+
135
142
  it("throws InvalidPluginNameError for path traversal attempts", () => {
136
143
  expect(() => enablePlugin("../state")).toThrow(InvalidPluginNameError);
137
144
  expect(() => enablePlugin("foo/bar")).toThrow(InvalidPluginNameError);
@@ -16,7 +16,14 @@
16
16
  * it was the sentinel).
17
17
  */
18
18
 
19
- import { existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
19
+ import {
20
+ existsSync,
21
+ mkdirSync,
22
+ readdirSync,
23
+ rmSync,
24
+ unlinkSync,
25
+ writeFileSync,
26
+ } from "node:fs";
20
27
  import { join } from "node:path";
21
28
 
22
29
  import { getWorkspacePluginsDir } from "../../util/platform.js";
@@ -111,7 +118,11 @@ export function disablePlugin(name: string): TogglePluginResult {
111
118
  }
112
119
 
113
120
  // Write empty sentinel file — content is irrelevant, existence is the signal.
114
- Bun.write(sentinelPath, "");
121
+ // Synchronous (like `enablePlugin`'s `unlinkSync`) so the sentinel is durable
122
+ // before the caller returns, and a write failure throws synchronously into the
123
+ // caller's try/catch instead of an unawaited `Bun.write` promise that could
124
+ // resolve/reject after the response.
125
+ writeFileSync(sentinelPath, "");
115
126
  return { name: validated, action: "disable", sentinelPath };
116
127
  }
117
128
 
@@ -126,6 +137,14 @@ export function enablePlugin(name: string): TogglePluginResult {
126
137
  const pluginDir = join(pluginsDir, validated);
127
138
  const sentinelPath = join(pluginDir, DISABLED_FILE);
128
139
 
140
+ // A missing user plugin is a 404, not a no-op: without this, a typo/deleted
141
+ // name has no sentinel and would fall through to "already enabled" (409),
142
+ // indistinguishable from a genuinely-enabled plugin. Default plugins have no
143
+ // directory when enabled, so they skip this check (their no-op stays 409).
144
+ if (!validated.startsWith("default-") && !existsSync(pluginDir)) {
145
+ throw new PluginDirectoryNotFoundError(validated);
146
+ }
147
+
129
148
  if (!existsSync(sentinelPath)) {
130
149
  throw new PluginAlreadyInStateException(validated, "enable");
131
150
  }
@@ -326,25 +326,3 @@ export function isAssistantFeatureFlagEnabled(
326
326
  ): boolean {
327
327
  return !!getAssistantFeatureFlagValue(key, config);
328
328
  }
329
-
330
- /**
331
- * Backends that resolve lexical message-content search.
332
- * fts5 — SQLite FTS5 full-text index (default)
333
- * qdrant — BM25-style sparse Qdrant lexical index (messages_lexical)
334
- */
335
- export type MessagesSearchBackend = "fts5" | "qdrant";
336
-
337
- /**
338
- * Resolve the active messages search backend from the boolean
339
- * `messages-search-backend` flag. Disabled (default) ⇒ `fts5`; enabled
340
- * (boolean `true`) ⇒ `qdrant`. The raw comparison also treats a stray
341
- * string override of `"qdrant"` as enabled; every other value (`false`,
342
- * `"fts5"`, any other string, undefined) falls back to `fts5` so the safe
343
- * default always wins for an unexpected override.
344
- */
345
- export function getMessagesSearchBackend(
346
- config: AssistantConfig,
347
- ): MessagesSearchBackend {
348
- const raw = getAssistantFeatureFlagValue("messages-search-backend", config);
349
- return raw === "qdrant" || raw === true ? "qdrant" : "fts5";
350
- }
@@ -417,14 +417,6 @@
417
417
  "label": "New-thread suggestions library",
418
418
  "description": "Replace the empty-state conversation-starter chips with the scrollable, categorized suggestions library and detail drawer (currently mocked data).",
419
419
  "defaultEnabled": false
420
- },
421
- {
422
- "id": "messages-search-backend",
423
- "scope": "assistant",
424
- "key": "messages-search-backend",
425
- "label": "Messages Search Backend",
426
- "description": "When disabled (default), message content search uses SQLite FTS5. When enabled, it uses the sparse Qdrant lexical index.",
427
- "defaultEnabled": false
428
420
  }
429
421
  ]
430
422
  }