@vellumai/assistant 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +4 -4
- package/README.md +6 -6
- package/bun.lock +6 -2
- package/docs/architecture/memory.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/actor-token-service.test.ts +5 -2
- package/src/__tests__/assistant-feature-flags-integration.test.ts +1 -0
- package/src/__tests__/call-controller.test.ts +78 -0
- package/src/__tests__/call-domain.test.ts +148 -10
- package/src/__tests__/call-pointer-message-composer.test.ts +39 -49
- package/src/__tests__/call-pointer-messages.test.ts +105 -43
- package/src/__tests__/canonical-guardian-store.test.ts +44 -10
- package/src/__tests__/channel-approval-routes.test.ts +67 -65
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +1 -0
- package/src/__tests__/conversation-attention-telegram.test.ts +2 -2
- package/src/__tests__/deterministic-verification-control-plane.test.ts +6 -6
- package/src/__tests__/guardian-actions-endpoint.test.ts +7 -6
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +57 -12
- package/src/__tests__/guardian-grant-minting.test.ts +24 -24
- package/src/__tests__/guardian-principal-id-roundtrip.test.ts +205 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +64 -25
- package/src/__tests__/guardian-routing-state.test.ts +4 -4
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +2 -2
- package/src/__tests__/inbound-invite-redemption.test.ts +8 -8
- package/src/__tests__/memory-retrieval.benchmark.test.ts +22 -47
- package/src/__tests__/no-is-trusted-guard.test.ts +77 -0
- package/src/__tests__/non-member-access-request.test.ts +50 -47
- package/src/__tests__/relay-server.test.ts +71 -0
- package/src/__tests__/send-endpoint-busy.test.ts +6 -0
- package/src/__tests__/session-tool-setup-tools-disabled.test.ts +155 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +1 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +66 -2
- package/src/__tests__/system-prompt.test.ts +1 -0
- package/src/__tests__/tool-approval-handler.test.ts +1 -1
- package/src/__tests__/tool-grant-request-escalation.test.ts +9 -2
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +8 -1
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +22 -22
- package/src/__tests__/trusted-contact-multichannel.test.ts +4 -4
- package/src/__tests__/trusted-contact-verification.test.ts +10 -10
- package/src/approvals/guardian-decision-primitive.ts +29 -25
- package/src/approvals/guardian-request-resolvers.ts +9 -5
- package/src/calls/call-pointer-message-composer.ts +27 -85
- package/src/calls/call-pointer-messages.ts +54 -21
- package/src/calls/guardian-dispatch.ts +30 -0
- package/src/calls/relay-server.ts +13 -13
- package/src/config/system-prompt.ts +10 -3
- package/src/config/templates/BOOTSTRAP.md +6 -5
- package/src/config/templates/USER.md +1 -0
- package/src/config/user-reference.ts +44 -0
- package/src/daemon/handlers/guardian-actions.ts +5 -2
- package/src/daemon/handlers/sessions.ts +8 -3
- package/src/daemon/lifecycle.ts +109 -3
- package/src/daemon/server.ts +32 -24
- package/src/daemon/session-agent-loop.ts +4 -3
- package/src/daemon/session-lifecycle.ts +1 -9
- package/src/daemon/session-process.ts +2 -2
- package/src/daemon/session-runtime-assembly.ts +2 -0
- package/src/daemon/session-tool-setup.ts +10 -0
- package/src/daemon/session.ts +1 -0
- package/src/memory/canonical-guardian-store.ts +40 -0
- package/src/memory/conversation-crud.ts +26 -0
- package/src/memory/conversation-store.ts +1 -0
- package/src/memory/db-init.ts +8 -0
- package/src/memory/guardian-bindings.ts +4 -0
- package/src/memory/job-handlers/backfill.ts +2 -9
- package/src/memory/migrations/125-guardian-principal-id-columns.ts +19 -0
- package/src/memory/migrations/126-backfill-guardian-principal-id.ts +210 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema.ts +3 -0
- package/src/notifications/copy-composer.ts +2 -2
- package/src/runtime/access-request-helper.ts +43 -28
- package/src/runtime/actor-trust-resolver.ts +19 -14
- package/src/runtime/channel-guardian-service.ts +6 -0
- package/src/runtime/guardian-context-resolver.ts +6 -2
- package/src/runtime/guardian-reply-router.ts +33 -16
- package/src/runtime/guardian-vellum-migration.ts +29 -5
- package/src/runtime/http-types.ts +0 -13
- package/src/runtime/local-actor-identity.ts +19 -13
- package/src/runtime/middleware/actor-token.ts +2 -2
- package/src/runtime/routes/channel-delivery-routes.ts +5 -5
- package/src/runtime/routes/conversation-routes.ts +45 -35
- package/src/runtime/routes/guardian-action-routes.ts +7 -1
- package/src/runtime/routes/guardian-approval-interception.ts +52 -52
- package/src/runtime/routes/guardian-bootstrap-routes.ts +1 -0
- package/src/runtime/routes/inbound-conversation.ts +7 -7
- package/src/runtime/routes/inbound-message-handler.ts +105 -94
- package/src/runtime/tool-grant-request-helper.ts +1 -0
- package/src/util/logger.ts +10 -0
- package/src/daemon/call-pointer-generators.ts +0 -59
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { type DrizzleDb, getSqliteFrom } from '../db-connection.js';
|
|
2
|
+
import { withCrashRecovery } from './validate-migration-state.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Backfill guardianPrincipalId for existing channel_guardian_bindings and
|
|
6
|
+
* canonical_guardian_requests rows.
|
|
7
|
+
*
|
|
8
|
+
* Strategy:
|
|
9
|
+
*
|
|
10
|
+
* 1. Derive the assistant's canonical principal from the active 'vellum'
|
|
11
|
+
* binding's guardianExternalUserId. This is the stable identity used for
|
|
12
|
+
* all guardian decisions from the desktop client.
|
|
13
|
+
*
|
|
14
|
+
* 2. Backfill channel_guardian_bindings:
|
|
15
|
+
* a. For the vellum binding: set guardianPrincipalId = guardianExternalUserId
|
|
16
|
+
* (the vellum external user ID IS the canonical principal).
|
|
17
|
+
* b. For non-vellum bindings: set guardianPrincipalId to the vellum
|
|
18
|
+
* binding's principal (unifying all channels onto one canonical
|
|
19
|
+
* principal). Falls back to guardianExternalUserId if no vellum
|
|
20
|
+
* binding exists.
|
|
21
|
+
*
|
|
22
|
+
* 3. Backfill canonical_guardian_requests (pending only):
|
|
23
|
+
* a. If the request has a guardianExternalUserId that maps to an active
|
|
24
|
+
* binding, use that binding's guardianPrincipalId (now backfilled).
|
|
25
|
+
* b. For desktop-originated requests (sourceType = 'desktop' or
|
|
26
|
+
* sourceChannel = 'vellum') that lack guardianExternalUserId,
|
|
27
|
+
* use the assistant principal derived in step 1.
|
|
28
|
+
* c. Pending requests that cannot be deterministically bound are expired
|
|
29
|
+
* (including access_request rows, which are now principal-bound).
|
|
30
|
+
*
|
|
31
|
+
* 4. Idempotent: uses checkpoint key + only updates rows with NULL
|
|
32
|
+
* guardianPrincipalId.
|
|
33
|
+
*/
|
|
34
|
+
export function migrateBackfillGuardianPrincipalId(database: DrizzleDb): void {
|
|
35
|
+
withCrashRecovery(database, 'migration_backfill_guardian_principal_id_v3', () => {
|
|
36
|
+
const raw = getSqliteFrom(database);
|
|
37
|
+
|
|
38
|
+
// Guard: tables must exist
|
|
39
|
+
const bindingsTableExists = raw.query(
|
|
40
|
+
`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'channel_guardian_bindings'`,
|
|
41
|
+
).get();
|
|
42
|
+
if (!bindingsTableExists) return;
|
|
43
|
+
|
|
44
|
+
const requestsTableExists = raw.query(
|
|
45
|
+
`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'canonical_guardian_requests'`,
|
|
46
|
+
).get();
|
|
47
|
+
|
|
48
|
+
// Guard: guardian_principal_id column must exist on bindings table
|
|
49
|
+
const bindingColExists = raw.query(
|
|
50
|
+
`SELECT 1 FROM pragma_table_info('channel_guardian_bindings') WHERE name = 'guardian_principal_id'`,
|
|
51
|
+
).get();
|
|
52
|
+
if (!bindingColExists) return;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
raw.exec('BEGIN');
|
|
56
|
+
|
|
57
|
+
// ── Step 1a: Backfill vellum binding first ─────────────────────
|
|
58
|
+
// The vellum binding's external user ID IS the canonical principal.
|
|
59
|
+
raw.exec(/*sql*/ `
|
|
60
|
+
UPDATE channel_guardian_bindings
|
|
61
|
+
SET guardian_principal_id = guardian_external_user_id,
|
|
62
|
+
updated_at = ${Date.now()}
|
|
63
|
+
WHERE status = 'active'
|
|
64
|
+
AND channel = 'vellum'
|
|
65
|
+
AND guardian_external_user_id IS NOT NULL
|
|
66
|
+
AND guardian_principal_id IS NULL
|
|
67
|
+
`);
|
|
68
|
+
|
|
69
|
+
// ── Step 1b: Derive canonical principal from vellum binding ──
|
|
70
|
+
const vellumRow = raw.query(
|
|
71
|
+
`SELECT guardian_principal_id FROM channel_guardian_bindings
|
|
72
|
+
WHERE assistant_id = 'self' AND channel = 'vellum' AND status = 'active'
|
|
73
|
+
AND guardian_principal_id IS NOT NULL LIMIT 1`,
|
|
74
|
+
).get() as { guardian_principal_id: string } | null;
|
|
75
|
+
|
|
76
|
+
if (vellumRow) {
|
|
77
|
+
// Unify non-vellum bindings onto the canonical principal
|
|
78
|
+
raw.query(
|
|
79
|
+
`UPDATE channel_guardian_bindings
|
|
80
|
+
SET guardian_principal_id = ?,
|
|
81
|
+
updated_at = ${Date.now()}
|
|
82
|
+
WHERE status = 'active'
|
|
83
|
+
AND channel != 'vellum'
|
|
84
|
+
AND guardian_external_user_id IS NOT NULL
|
|
85
|
+
AND guardian_principal_id IS NULL`,
|
|
86
|
+
).run(vellumRow.guardian_principal_id);
|
|
87
|
+
} else {
|
|
88
|
+
// No vellum binding — fallback to channel-specific principal
|
|
89
|
+
raw.exec(/*sql*/ `
|
|
90
|
+
UPDATE channel_guardian_bindings
|
|
91
|
+
SET guardian_principal_id = guardian_external_user_id,
|
|
92
|
+
updated_at = ${Date.now()}
|
|
93
|
+
WHERE status = 'active'
|
|
94
|
+
AND guardian_external_user_id IS NOT NULL
|
|
95
|
+
AND guardian_principal_id IS NULL
|
|
96
|
+
`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Step 2: Derive assistant principal from vellum binding ─────
|
|
100
|
+
// The vellum binding's guardianExternalUserId is the canonical
|
|
101
|
+
// assistant principal used for desktop-originated requests.
|
|
102
|
+
const vellumBinding = raw.query(
|
|
103
|
+
`SELECT guardian_external_user_id, guardian_principal_id FROM channel_guardian_bindings WHERE assistant_id = 'self' AND channel = 'vellum' AND status = 'active' LIMIT 1`,
|
|
104
|
+
).get() as { guardian_external_user_id: string; guardian_principal_id: string | null } | null;
|
|
105
|
+
|
|
106
|
+
// Use the (now-backfilled) principal from the vellum binding
|
|
107
|
+
const assistantPrincipal = vellumBinding?.guardian_principal_id ?? vellumBinding?.guardian_external_user_id ?? null;
|
|
108
|
+
|
|
109
|
+
// ── Step 3: Backfill canonical_guardian_requests ────────────────
|
|
110
|
+
if (requestsTableExists) {
|
|
111
|
+
const requestColExists = raw.query(
|
|
112
|
+
`SELECT 1 FROM pragma_table_info('canonical_guardian_requests') WHERE name = 'guardian_principal_id'`,
|
|
113
|
+
).get();
|
|
114
|
+
|
|
115
|
+
if (requestColExists) {
|
|
116
|
+
const now = new Date().toISOString();
|
|
117
|
+
|
|
118
|
+
// 3a. Pending requests with a guardianExternalUserId that maps
|
|
119
|
+
// to an active binding — use the binding's principal.
|
|
120
|
+
// Includes all kinds; binding is always useful when available.
|
|
121
|
+
const pendingWithGuardian = raw.query(
|
|
122
|
+
`SELECT r.id, r.guardian_external_user_id, r.kind
|
|
123
|
+
FROM canonical_guardian_requests r
|
|
124
|
+
WHERE r.status = 'pending'
|
|
125
|
+
AND r.guardian_principal_id IS NULL
|
|
126
|
+
AND r.guardian_external_user_id IS NOT NULL`,
|
|
127
|
+
).all() as Array<{ id: string; guardian_external_user_id: string; kind: string }>;
|
|
128
|
+
|
|
129
|
+
// Build a lookup of guardianExternalUserId -> principalId from
|
|
130
|
+
// active bindings (all already backfilled in step 1).
|
|
131
|
+
const activeBindings = raw.query(
|
|
132
|
+
`SELECT guardian_external_user_id, guardian_principal_id
|
|
133
|
+
FROM channel_guardian_bindings
|
|
134
|
+
WHERE assistant_id = 'self' AND status = 'active' AND guardian_principal_id IS NOT NULL`,
|
|
135
|
+
).all() as Array<{ guardian_external_user_id: string; guardian_principal_id: string }>;
|
|
136
|
+
|
|
137
|
+
const externalToP = new Map<string, string>();
|
|
138
|
+
for (const b of activeBindings) {
|
|
139
|
+
externalToP.set(b.guardian_external_user_id, b.guardian_principal_id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const updateStmt = raw.prepare(
|
|
143
|
+
`UPDATE canonical_guardian_requests SET guardian_principal_id = ?, updated_at = ? WHERE id = ?`,
|
|
144
|
+
);
|
|
145
|
+
const expireStmt = raw.prepare(
|
|
146
|
+
`UPDATE canonical_guardian_requests SET status = 'expired', updated_at = ? WHERE id = ?`,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const unboundRequestIds: string[] = [];
|
|
150
|
+
|
|
151
|
+
for (const req of pendingWithGuardian) {
|
|
152
|
+
const principal = externalToP.get(req.guardian_external_user_id);
|
|
153
|
+
if (principal) {
|
|
154
|
+
updateStmt.run(principal, now, req.id);
|
|
155
|
+
} else {
|
|
156
|
+
// Cannot deterministically map — will expire below.
|
|
157
|
+
unboundRequestIds.push(req.id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 3b. Desktop-originated pending requests missing guardian info
|
|
162
|
+
// entirely — bind to the assistant principal. Only applies to
|
|
163
|
+
// requests that also lack a guardian external user ID; requests
|
|
164
|
+
// that carry an external ID but failed step 3a mapping should be
|
|
165
|
+
// expired, not reassigned.
|
|
166
|
+
if (assistantPrincipal) {
|
|
167
|
+
raw.query(
|
|
168
|
+
`UPDATE canonical_guardian_requests
|
|
169
|
+
SET guardian_principal_id = ?, updated_at = ?
|
|
170
|
+
WHERE status = 'pending'
|
|
171
|
+
AND guardian_principal_id IS NULL
|
|
172
|
+
AND guardian_external_user_id IS NULL
|
|
173
|
+
AND (source_type = 'desktop' OR source_channel = 'vellum')`,
|
|
174
|
+
).run(assistantPrincipal, now);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 3c. Expire remaining pending requests that still have no
|
|
178
|
+
// guardian_principal_id. These requests can never be approved
|
|
179
|
+
// in the principal-based system, so they must be expired
|
|
180
|
+
// proactively. This includes access_request rows which are
|
|
181
|
+
// now decisionable and principal-bound.
|
|
182
|
+
const stillUnbound = raw.query(
|
|
183
|
+
`SELECT id FROM canonical_guardian_requests
|
|
184
|
+
WHERE guardian_principal_id IS NULL
|
|
185
|
+
AND status = 'pending'`,
|
|
186
|
+
).all() as Array<{ id: string }>;
|
|
187
|
+
|
|
188
|
+
for (const req of stillUnbound) {
|
|
189
|
+
expireStmt.run(now, req.id);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Also expire requests identified in 3a that had no binding match.
|
|
193
|
+
for (const id of unboundRequestIds) {
|
|
194
|
+
const check = raw.query(
|
|
195
|
+
`SELECT guardian_principal_id FROM canonical_guardian_requests WHERE id = ? AND status = 'pending'`,
|
|
196
|
+
).get(id) as { guardian_principal_id: string | null } | null;
|
|
197
|
+
if (check && !check.guardian_principal_id) {
|
|
198
|
+
expireStmt.run(now, id);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
raw.exec('COMMIT');
|
|
205
|
+
} catch (e) {
|
|
206
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
207
|
+
throw e;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
@@ -65,6 +65,8 @@ export { createCanonicalGuardianTables } from './121-canonical-guardian-requests
|
|
|
65
65
|
export { migrateCanonicalGuardianRequesterChatId } from './122-canonical-guardian-requester-chat-id.js';
|
|
66
66
|
export { migrateCanonicalGuardianDeliveriesDestinationIndex } from './123-canonical-guardian-deliveries-destination-index.js';
|
|
67
67
|
export { migrateVoiceInviteDisplayMetadata } from './124-voice-invite-display-metadata.js';
|
|
68
|
+
export { migrateGuardianPrincipalIdColumns } from './125-guardian-principal-id-columns.js';
|
|
69
|
+
export { migrateBackfillGuardianPrincipalId } from './126-backfill-guardian-principal-id.js';
|
|
68
70
|
export {
|
|
69
71
|
MIGRATION_REGISTRY,
|
|
70
72
|
type MigrationRegistryEntry,
|
|
@@ -95,6 +95,11 @@ export const MIGRATION_REGISTRY: MigrationRegistryEntry[] = [
|
|
|
95
95
|
version: 14,
|
|
96
96
|
description: 'Normalize phone-like identity fields to E.164 format across guardian bindings, verification challenges, canonical requests, ingress members, and rate limits',
|
|
97
97
|
},
|
|
98
|
+
{
|
|
99
|
+
key: 'migration_backfill_guardian_principal_id_v2',
|
|
100
|
+
version: 15,
|
|
101
|
+
description: 'Backfill guardianPrincipalId for existing channel_guardian_bindings and canonical_guardian_requests rows, expire unresolvable pending requests',
|
|
102
|
+
},
|
|
98
103
|
];
|
|
99
104
|
|
|
100
105
|
export interface MigrationValidationResult {
|
package/src/memory/schema.ts
CHANGED
|
@@ -637,6 +637,7 @@ export const channelGuardianBindings = sqliteTable('channel_guardian_bindings',
|
|
|
637
637
|
channel: text('channel').notNull(),
|
|
638
638
|
guardianExternalUserId: text('guardian_external_user_id').notNull(),
|
|
639
639
|
guardianDeliveryChatId: text('guardian_delivery_chat_id').notNull(),
|
|
640
|
+
guardianPrincipalId: text('guardian_principal_id'),
|
|
640
641
|
status: text('status').notNull().default('active'),
|
|
641
642
|
verifiedAt: integer('verified_at').notNull(),
|
|
642
643
|
verifiedVia: text('verified_via').notNull().default('challenge'),
|
|
@@ -887,6 +888,7 @@ export const canonicalGuardianRequests = sqliteTable('canonical_guardian_request
|
|
|
887
888
|
requesterExternalUserId: text('requester_external_user_id'),
|
|
888
889
|
requesterChatId: text('requester_chat_id'),
|
|
889
890
|
guardianExternalUserId: text('guardian_external_user_id'),
|
|
891
|
+
guardianPrincipalId: text('guardian_principal_id'),
|
|
890
892
|
callSessionId: text('call_session_id'),
|
|
891
893
|
pendingQuestionId: text('pending_question_id'),
|
|
892
894
|
questionText: text('question_text'),
|
|
@@ -896,6 +898,7 @@ export const canonicalGuardianRequests = sqliteTable('canonical_guardian_request
|
|
|
896
898
|
status: text('status').notNull().default('pending'),
|
|
897
899
|
answerText: text('answer_text'),
|
|
898
900
|
decidedByExternalUserId: text('decided_by_external_user_id'),
|
|
901
|
+
decidedByPrincipalId: text('decided_by_principal_id'),
|
|
899
902
|
followupState: text('followup_state'),
|
|
900
903
|
expiresAt: text('expires_at'),
|
|
901
904
|
createdAt: text('created_at').notNull(),
|
|
@@ -64,7 +64,7 @@ const TEMPLATES: Record<string, CopyTemplate> = {
|
|
|
64
64
|
const requester = str(payload.senderIdentifier, 'Someone');
|
|
65
65
|
const requestCode = nonEmpty(typeof payload.requestCode === 'string' ? payload.requestCode : undefined);
|
|
66
66
|
const sourceChannel = typeof payload.sourceChannel === 'string' ? payload.sourceChannel : undefined;
|
|
67
|
-
const callerName = nonEmpty(typeof payload.
|
|
67
|
+
const callerName = nonEmpty(typeof payload.actorDisplayName === 'string' ? payload.actorDisplayName : undefined);
|
|
68
68
|
const previousMemberStatus = typeof payload.previousMemberStatus === 'string'
|
|
69
69
|
? payload.previousMemberStatus
|
|
70
70
|
: undefined;
|
|
@@ -72,7 +72,7 @@ const TEMPLATES: Record<string, CopyTemplate> = {
|
|
|
72
72
|
|
|
73
73
|
// Voice-originated access requests include caller name context
|
|
74
74
|
if (sourceChannel === 'voice' && callerName) {
|
|
75
|
-
lines.push(`${callerName} (${str(payload.
|
|
75
|
+
lines.push(`${callerName} (${str(payload.actorExternalId, requester)}) is calling and requesting access to the assistant.`);
|
|
76
76
|
} else {
|
|
77
77
|
lines.push(`${requester} is requesting access to the assistant.`);
|
|
78
78
|
}
|
|
@@ -26,6 +26,7 @@ import { emitNotificationSignal } from '../notifications/emit-signal.js';
|
|
|
26
26
|
import type { NotificationDeliveryResult } from '../notifications/types.js';
|
|
27
27
|
import { getLogger } from '../util/logger.js';
|
|
28
28
|
import { getGuardianBinding } from './channel-guardian-service.js';
|
|
29
|
+
import { ensureVellumGuardianBinding } from './guardian-vellum-migration.js';
|
|
29
30
|
import { GUARDIAN_APPROVAL_TTL_MS } from './routes/channel-route-shared.js';
|
|
30
31
|
|
|
31
32
|
const log = getLogger('access-request-helper');
|
|
@@ -45,10 +46,10 @@ function applyDeliveryStatus(deliveryId: string, result: NotificationDeliveryRes
|
|
|
45
46
|
export interface AccessRequestParams {
|
|
46
47
|
canonicalAssistantId: string;
|
|
47
48
|
sourceChannel: ChannelId;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
conversationExternalId: string;
|
|
50
|
+
actorExternalId?: string;
|
|
51
|
+
actorDisplayName?: string;
|
|
52
|
+
actorUsername?: string;
|
|
52
53
|
previousMemberStatus?: MemberStatus;
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -80,14 +81,14 @@ export function notifyGuardianOfAccessRequest(
|
|
|
80
81
|
const {
|
|
81
82
|
canonicalAssistantId,
|
|
82
83
|
sourceChannel,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
conversationExternalId,
|
|
85
|
+
actorExternalId,
|
|
86
|
+
actorDisplayName,
|
|
87
|
+
actorUsername,
|
|
87
88
|
previousMemberStatus,
|
|
88
89
|
} = params;
|
|
89
90
|
|
|
90
|
-
if (!
|
|
91
|
+
if (!actorExternalId) {
|
|
91
92
|
return { notified: false, reason: 'no_sender_id' };
|
|
92
93
|
}
|
|
93
94
|
|
|
@@ -97,33 +98,46 @@ export function notifyGuardianOfAccessRequest(
|
|
|
97
98
|
// 3. null (no guardian identity — notification pipeline uses trusted channels)
|
|
98
99
|
const sourceBinding = getGuardianBinding(canonicalAssistantId, sourceChannel);
|
|
99
100
|
let guardianExternalUserId: string | null = null;
|
|
101
|
+
let guardianPrincipalId: string | null = null;
|
|
100
102
|
let guardianBindingChannel: string | null = null;
|
|
101
103
|
|
|
102
104
|
if (sourceBinding) {
|
|
103
105
|
guardianExternalUserId = sourceBinding.guardianExternalUserId;
|
|
106
|
+
guardianPrincipalId = sourceBinding.guardianPrincipalId;
|
|
104
107
|
guardianBindingChannel = sourceBinding.channel;
|
|
105
108
|
} else {
|
|
106
109
|
const allBindings = listActiveBindingsByAssistant(canonicalAssistantId);
|
|
107
110
|
if (allBindings.length > 0) {
|
|
108
111
|
guardianExternalUserId = allBindings[0].guardianExternalUserId;
|
|
112
|
+
guardianPrincipalId = allBindings[0].guardianPrincipalId;
|
|
109
113
|
guardianBindingChannel = allBindings[0].channel;
|
|
110
114
|
log.debug(
|
|
111
115
|
{ sourceChannel, fallbackChannel: guardianBindingChannel, canonicalAssistantId },
|
|
112
116
|
'Using cross-channel guardian binding fallback for access request',
|
|
113
117
|
);
|
|
114
|
-
} else {
|
|
115
|
-
log.debug(
|
|
116
|
-
{ sourceChannel, canonicalAssistantId },
|
|
117
|
-
'No guardian binding for access request — proceeding without guardian identity',
|
|
118
|
-
);
|
|
119
118
|
}
|
|
120
119
|
}
|
|
121
120
|
|
|
121
|
+
// Self-heal: access_request is now decisionable and requires a principal.
|
|
122
|
+
// If no binding was found (or the binding lacks a principal), bootstrap the
|
|
123
|
+
// vellum binding so the request can be properly attributed.
|
|
124
|
+
if (!guardianPrincipalId) {
|
|
125
|
+
log.info(
|
|
126
|
+
{ sourceChannel, canonicalAssistantId },
|
|
127
|
+
'No guardian principal for access request — self-healing vellum binding',
|
|
128
|
+
);
|
|
129
|
+
const healedPrincipalId = ensureVellumGuardianBinding(canonicalAssistantId);
|
|
130
|
+
const vellumBinding = getGuardianBinding(canonicalAssistantId, 'vellum');
|
|
131
|
+
guardianExternalUserId = vellumBinding?.guardianExternalUserId ?? guardianExternalUserId;
|
|
132
|
+
guardianPrincipalId = vellumBinding?.guardianPrincipalId ?? healedPrincipalId;
|
|
133
|
+
guardianBindingChannel = guardianBindingChannel ?? 'vellum';
|
|
134
|
+
}
|
|
135
|
+
|
|
122
136
|
// The conversationId is assistant-scoped so the dedupe query below only
|
|
123
137
|
// matches requests for the same assistant. Without this, a pending request
|
|
124
138
|
// from assistant A could be returned for assistant B, allowing the caller
|
|
125
139
|
// to piggyback on A's guardian approval.
|
|
126
|
-
const conversationId = `access-req-${canonicalAssistantId}-${sourceChannel}-${
|
|
140
|
+
const conversationId = `access-req-${canonicalAssistantId}-${sourceChannel}-${actorExternalId}`;
|
|
127
141
|
|
|
128
142
|
// Deduplicate: skip creation if there is already a pending canonical request
|
|
129
143
|
// for the same requester on this channel *and* assistant. Still return
|
|
@@ -131,21 +145,21 @@ export function notifyGuardianOfAccessRequest(
|
|
|
131
145
|
// was already notified.
|
|
132
146
|
const existingCanonical = listCanonicalGuardianRequests({
|
|
133
147
|
status: 'pending',
|
|
134
|
-
requesterExternalUserId:
|
|
148
|
+
requesterExternalUserId: actorExternalId,
|
|
135
149
|
sourceChannel,
|
|
136
150
|
kind: 'access_request',
|
|
137
151
|
conversationId,
|
|
138
152
|
});
|
|
139
153
|
if (existingCanonical.length > 0) {
|
|
140
154
|
log.debug(
|
|
141
|
-
{ sourceChannel,
|
|
155
|
+
{ sourceChannel, actorExternalId, existingId: existingCanonical[0].id },
|
|
142
156
|
'Skipping duplicate access request notification',
|
|
143
157
|
);
|
|
144
158
|
return { notified: true, created: false, requestId: existingCanonical[0].id };
|
|
145
159
|
}
|
|
146
160
|
|
|
147
|
-
const senderIdentifier =
|
|
148
|
-
const requestId = `access-req-${canonicalAssistantId}-${sourceChannel}-${
|
|
161
|
+
const senderIdentifier = actorDisplayName || actorUsername || actorExternalId;
|
|
162
|
+
const requestId = `access-req-${canonicalAssistantId}-${sourceChannel}-${actorExternalId}-${Date.now()}`;
|
|
149
163
|
|
|
150
164
|
const canonicalRequest = createCanonicalGuardianRequest({
|
|
151
165
|
id: requestId,
|
|
@@ -153,9 +167,10 @@ export function notifyGuardianOfAccessRequest(
|
|
|
153
167
|
sourceType: 'channel',
|
|
154
168
|
sourceChannel,
|
|
155
169
|
conversationId,
|
|
156
|
-
requesterExternalUserId:
|
|
157
|
-
requesterChatId:
|
|
170
|
+
requesterExternalUserId: actorExternalId,
|
|
171
|
+
requesterChatId: conversationExternalId,
|
|
158
172
|
guardianExternalUserId: guardianExternalUserId ?? undefined,
|
|
173
|
+
guardianPrincipalId: guardianPrincipalId ?? undefined,
|
|
159
174
|
toolName: 'ingress_access_request',
|
|
160
175
|
questionText: `${senderIdentifier} is requesting access to the assistant`,
|
|
161
176
|
expiresAt: new Date(Date.now() + GUARDIAN_APPROVAL_TTL_MS).toISOString(),
|
|
@@ -165,7 +180,7 @@ export function notifyGuardianOfAccessRequest(
|
|
|
165
180
|
void emitNotificationSignal({
|
|
166
181
|
sourceEventName: 'ingress.access_request',
|
|
167
182
|
sourceChannel,
|
|
168
|
-
sourceSessionId: `access-req-${sourceChannel}-${
|
|
183
|
+
sourceSessionId: `access-req-${sourceChannel}-${actorExternalId}`,
|
|
169
184
|
assistantId: canonicalAssistantId,
|
|
170
185
|
attentionHints: {
|
|
171
186
|
requiresAction: true,
|
|
@@ -177,10 +192,10 @@ export function notifyGuardianOfAccessRequest(
|
|
|
177
192
|
requestId,
|
|
178
193
|
requestCode: canonicalRequest.requestCode,
|
|
179
194
|
sourceChannel,
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
195
|
+
conversationExternalId,
|
|
196
|
+
actorExternalId,
|
|
197
|
+
actorDisplayName: actorDisplayName ?? null,
|
|
198
|
+
actorUsername: actorUsername ?? null,
|
|
184
199
|
senderIdentifier,
|
|
185
200
|
guardianBindingChannel,
|
|
186
201
|
previousMemberStatus: previousMemberStatus ?? null,
|
|
@@ -237,13 +252,13 @@ export function notifyGuardianOfAccessRequest(
|
|
|
237
252
|
})
|
|
238
253
|
.catch((err) => {
|
|
239
254
|
log.error(
|
|
240
|
-
{ err, requestId: canonicalRequest.id, sourceChannel,
|
|
255
|
+
{ err, requestId: canonicalRequest.id, sourceChannel, actorExternalId },
|
|
241
256
|
'Failed to persist access request delivery rows from notification pipeline',
|
|
242
257
|
);
|
|
243
258
|
});
|
|
244
259
|
|
|
245
260
|
log.info(
|
|
246
|
-
{ sourceChannel,
|
|
261
|
+
{ sourceChannel, actorExternalId, senderIdentifier, guardianBindingChannel },
|
|
247
262
|
'Guardian notified of access request',
|
|
248
263
|
);
|
|
249
264
|
|
|
@@ -35,6 +35,8 @@ export interface ActorTrustContext {
|
|
|
35
35
|
guardianExternalUserId: string;
|
|
36
36
|
guardianDeliveryChatId: string | null;
|
|
37
37
|
} | null;
|
|
38
|
+
/** Canonical principal ID from the guardian binding. Nullable for backward compatibility — M5 will make this required. */
|
|
39
|
+
guardianPrincipalId?: string | null;
|
|
38
40
|
/** Ingress member record, if any, for this sender. */
|
|
39
41
|
memberRecord: IngressMember | null;
|
|
40
42
|
/** Trust classification. */
|
|
@@ -56,10 +58,10 @@ export interface ActorTrustContext {
|
|
|
56
58
|
export interface ResolveActorTrustInput {
|
|
57
59
|
assistantId: string;
|
|
58
60
|
sourceChannel: ChannelId;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
conversationExternalId: string;
|
|
62
|
+
actorExternalId?: string;
|
|
63
|
+
actorUsername?: string;
|
|
64
|
+
actorDisplayName?: string;
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
// ---------------------------------------------------------------------------
|
|
@@ -78,16 +80,16 @@ export interface ResolveActorTrustInput {
|
|
|
78
80
|
export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustContext {
|
|
79
81
|
const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
|
|
80
82
|
|
|
81
|
-
const rawUserId = typeof input.
|
|
82
|
-
? input.
|
|
83
|
+
const rawUserId = typeof input.actorExternalId === 'string' && input.actorExternalId.trim().length > 0
|
|
84
|
+
? input.actorExternalId.trim()
|
|
83
85
|
: undefined;
|
|
84
86
|
|
|
85
|
-
const senderUsername = typeof input.
|
|
86
|
-
? input.
|
|
87
|
+
const senderUsername = typeof input.actorUsername === 'string' && input.actorUsername.trim().length > 0
|
|
88
|
+
? input.actorUsername.trim()
|
|
87
89
|
: undefined;
|
|
88
90
|
|
|
89
|
-
const senderDisplayName = typeof input.
|
|
90
|
-
? input.
|
|
91
|
+
const senderDisplayName = typeof input.actorDisplayName === 'string' && input.actorDisplayName.trim().length > 0
|
|
92
|
+
? input.actorDisplayName.trim()
|
|
91
93
|
: undefined;
|
|
92
94
|
|
|
93
95
|
// Canonical identity: normalize phone-like channels to E.164.
|
|
@@ -102,6 +104,7 @@ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustCont
|
|
|
102
104
|
return {
|
|
103
105
|
canonicalSenderId: null,
|
|
104
106
|
guardianBindingMatch: null,
|
|
107
|
+
guardianPrincipalId: undefined,
|
|
105
108
|
memberRecord: null,
|
|
106
109
|
trustClass: 'unknown',
|
|
107
110
|
actorMetadata: {
|
|
@@ -137,7 +140,7 @@ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustCont
|
|
|
137
140
|
assistantId,
|
|
138
141
|
sourceChannel: input.sourceChannel,
|
|
139
142
|
externalUserId: canonicalSenderId,
|
|
140
|
-
externalChatId: input.
|
|
143
|
+
externalChatId: input.conversationExternalId,
|
|
141
144
|
});
|
|
142
145
|
|
|
143
146
|
// In group chats, findMember may match on externalChatId and return a
|
|
@@ -181,6 +184,7 @@ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustCont
|
|
|
181
184
|
return {
|
|
182
185
|
canonicalSenderId,
|
|
183
186
|
guardianBindingMatch,
|
|
187
|
+
guardianPrincipalId: binding?.guardianPrincipalId ?? undefined,
|
|
184
188
|
memberRecord,
|
|
185
189
|
trustClass,
|
|
186
190
|
actorMetadata: {
|
|
@@ -202,20 +206,21 @@ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustCont
|
|
|
202
206
|
*/
|
|
203
207
|
export function toGuardianRuntimeContextFromTrust(
|
|
204
208
|
ctx: ActorTrustContext,
|
|
205
|
-
|
|
209
|
+
conversationExternalId: string,
|
|
206
210
|
): GuardianRuntimeContext {
|
|
207
211
|
return {
|
|
208
212
|
sourceChannel: ctx.actorMetadata.channel,
|
|
209
213
|
trustClass: ctx.trustClass,
|
|
210
214
|
guardianChatId: ctx.guardianBindingMatch?.guardianDeliveryChatId ??
|
|
211
|
-
(ctx.trustClass === 'guardian' ?
|
|
215
|
+
(ctx.trustClass === 'guardian' ? conversationExternalId : undefined),
|
|
212
216
|
guardianExternalUserId: ctx.guardianBindingMatch?.guardianExternalUserId,
|
|
217
|
+
guardianPrincipalId: ctx.guardianPrincipalId,
|
|
213
218
|
requesterIdentifier: ctx.actorMetadata.identifier,
|
|
214
219
|
requesterDisplayName: ctx.actorMetadata.displayName,
|
|
215
220
|
requesterSenderDisplayName: ctx.actorMetadata.senderDisplayName,
|
|
216
221
|
requesterMemberDisplayName: ctx.actorMetadata.memberDisplayName,
|
|
217
222
|
requesterExternalUserId: ctx.canonicalSenderId ?? undefined,
|
|
218
|
-
requesterChatId:
|
|
223
|
+
requesterChatId: conversationExternalId,
|
|
219
224
|
denialReason: ctx.denialReason,
|
|
220
225
|
};
|
|
221
226
|
}
|
|
@@ -310,12 +310,18 @@ export function validateAndConsumeChallenge(
|
|
|
310
310
|
metadata.displayName = actorDisplayName.trim();
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
// Unify all channel bindings onto the canonical (vellum) principal so that
|
|
314
|
+
// cross-channel authorization reduces to strict principal equality.
|
|
315
|
+
const vellumBinding = getActiveBinding(assistantId, 'vellum');
|
|
316
|
+
const canonicalPrincipal = vellumBinding?.guardianPrincipalId ?? actorExternalUserId;
|
|
317
|
+
|
|
313
318
|
// Create the new guardian binding
|
|
314
319
|
const binding = createBinding({
|
|
315
320
|
assistantId,
|
|
316
321
|
channel,
|
|
317
322
|
guardianExternalUserId: actorExternalUserId,
|
|
318
323
|
guardianDeliveryChatId: actorChatId,
|
|
324
|
+
guardianPrincipalId: canonicalPrincipal,
|
|
319
325
|
verifiedVia: 'challenge',
|
|
320
326
|
metadataJson: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null,
|
|
321
327
|
});
|
|
@@ -24,6 +24,8 @@ export interface GuardianContext {
|
|
|
24
24
|
trustClass: ActorTrustClass;
|
|
25
25
|
guardianChatId?: string;
|
|
26
26
|
guardianExternalUserId?: string;
|
|
27
|
+
/** Canonical principal ID from the guardian binding. Nullable for backward compatibility — M5 will make this required. */
|
|
28
|
+
guardianPrincipalId?: string | null;
|
|
27
29
|
requesterIdentifier?: string;
|
|
28
30
|
requesterDisplayName?: string;
|
|
29
31
|
requesterSenderDisplayName?: string;
|
|
@@ -48,14 +50,15 @@ export function resolveGuardianContext(input: ResolveGuardianContextInput): Guar
|
|
|
48
50
|
return {
|
|
49
51
|
trustClass: trust.trustClass,
|
|
50
52
|
guardianChatId: trust.guardianBindingMatch?.guardianDeliveryChatId ??
|
|
51
|
-
(trust.trustClass === 'guardian' ? input.
|
|
53
|
+
(trust.trustClass === 'guardian' ? input.conversationExternalId : undefined),
|
|
52
54
|
guardianExternalUserId: canonicalGuardianExternalUserId,
|
|
55
|
+
guardianPrincipalId: trust.guardianPrincipalId,
|
|
53
56
|
requesterIdentifier: trust.actorMetadata.identifier,
|
|
54
57
|
requesterDisplayName: trust.actorMetadata.displayName,
|
|
55
58
|
requesterSenderDisplayName: trust.actorMetadata.senderDisplayName,
|
|
56
59
|
requesterMemberDisplayName: trust.actorMetadata.memberDisplayName,
|
|
57
60
|
requesterExternalUserId: trust.canonicalSenderId ?? undefined,
|
|
58
|
-
requesterChatId: input.
|
|
61
|
+
requesterChatId: input.conversationExternalId,
|
|
59
62
|
memberStatus: trust.memberRecord?.status ?? undefined,
|
|
60
63
|
memberPolicy: trust.memberRecord?.policy ?? undefined,
|
|
61
64
|
denialReason: trust.denialReason,
|
|
@@ -150,6 +153,7 @@ export function toGuardianRuntimeContext(sourceChannel: ChannelId, ctx: Guardian
|
|
|
150
153
|
trustClass: ctx.trustClass,
|
|
151
154
|
guardianChatId: ctx.guardianChatId,
|
|
152
155
|
guardianExternalUserId: ctx.guardianExternalUserId,
|
|
156
|
+
guardianPrincipalId: ctx.guardianPrincipalId,
|
|
153
157
|
requesterIdentifier: ctx.requesterIdentifier,
|
|
154
158
|
requesterDisplayName: ctx.requesterDisplayName,
|
|
155
159
|
requesterSenderDisplayName: ctx.requesterSenderDisplayName,
|