@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.
- package/openapi.yaml +80 -0
- package/package.json +1 -1
- package/src/__tests__/context-search-conversations-source.test.ts +7 -9
- package/src/__tests__/non-member-access-request.test.ts +224 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
- package/src/__tests__/slack-inbound-verification.test.ts +36 -0
- package/src/__tests__/trusted-contact-multichannel.test.ts +4 -0
- package/src/approvals/guardian-request-resolvers.ts +21 -1
- package/src/calls/relay-server.ts +10 -0
- package/src/cli/lib/__tests__/toggle-plugin.test.ts +7 -0
- package/src/cli/lib/toggle-plugin.ts +21 -2
- package/src/config/assistant-feature-flags.ts +0 -22
- package/src/config/feature-flag-registry.json +0 -8
- package/src/contacts/__tests__/member-write-relay.test.ts +57 -1
- package/src/contacts/member-write-relay.ts +41 -0
- package/src/daemon/disk-pressure-policy.ts +2 -1
- package/src/daemon/message-provenance.ts +12 -14
- package/src/daemon/message-types/sync.ts +1 -0
- package/src/persistence/__tests__/conversation-queries-search.test.ts +18 -27
- package/src/persistence/conversation-queries.ts +14 -18
- package/src/persistence/conversation-search-lexical.ts +1 -1
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +2 -3
- package/src/runtime/access-request-helper.ts +71 -2
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +360 -3
- package/src/runtime/routes/app-management-routes.ts +4 -7
- package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +98 -0
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +50 -4
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +2 -3
- package/src/runtime/routes/plugins-routes.ts +149 -1
- package/src/runtime/sync/resource-sync-events.ts +15 -0
- package/src/runtime/trust-class.test.ts +141 -0
- package/src/runtime/trust-class.ts +88 -0
- 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
|
@@ -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 (
|
|
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
|
-
//
|
|
411
|
-
//
|
|
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
|
|
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
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|