From aad93dd647bf894d83d0cab07b0849e1b48d68bf Mon Sep 17 00:00:00 2001 From: NgoHarrison Date: Thu, 26 Feb 2026 00:17:43 -0500 Subject: [PATCH 01/14] docs: add trusted contact access design doc (#9452) Co-authored-by: Harrison Ngo Co-authored-by: Claude --- assistant/docs/trusted-contact-access.md | 247 +++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 assistant/docs/trusted-contact-access.md diff --git a/assistant/docs/trusted-contact-access.md b/assistant/docs/trusted-contact-access.md new file mode 100644 index 00000000000..998937f666d --- /dev/null +++ b/assistant/docs/trusted-contact-access.md @@ -0,0 +1,247 @@ +# Trusted Contact Access Flow + +Design doc defining how unknown users gain access to a Vellum assistant via channel-mediated trusted contact onboarding. + +## Roles + +| Role | Description | +|------|-------------| +| `guardian` | The verified owner/administrator of the assistant on a given channel. Has an active `channel_guardian_bindings` record. Approves or denies access requests. | +| `trusted_contact` | An external user who has completed the verification flow and holds an active `assistant_ingress_members` record with `status: 'active'` and `policy: 'allow'`. | +| `assistant` | The Vellum assistant daemon. Mediates the flow, enforces ACL, generates verification codes, and activates trusted contacts upon successful verification. | + +## User Journey + +1. **Unknown user messages the assistant** on Telegram (or SMS, or any channel). +2. **Assistant rejects the message** via the ingress ACL in `inbound-message-handler.ts`. The user has no `assistant_ingress_members` record, so the handler replies: *"Sorry, you haven't been approved to message this assistant. You can ask its Guardian for an invite."* and returns `{ denied: true, reason: 'not_a_member' }`. +3. **Notification pipeline alerts the guardian.** The rejection triggers `emitNotificationSignal()` with `sourceEventName: 'ingress.access_request'`, routing through the decision engine to all connected channels (vellum macOS app, Telegram, etc.). The guardian sees who is requesting access. +4. **Guardian approves the request.** The guardian responds to the notification (via Telegram inline button, macOS app, or IPC). On approval, the assistant creates a verification session via `createOutboundSession()` and generates a 6-digit verification code. +5. **Guardian receives the verification code.** The assistant delivers the code to the guardian's verified channel (Telegram chat, SMS, etc.). +6. **Guardian gives the code to the requester out-of-band** (in person, text message, phone call, etc.). This out-of-band transfer is the trust anchor: it proves the requester has a real-world relationship with the guardian. +7. **Requester enters the code** back to the assistant on the same channel. The inbound message handler intercepts bare 6-digit codes when a pending verification session exists for that channel. +8. **Assistant verifies the code and activates the user.** `validateAndConsumeChallenge()` hashes the code, matches it against the pending session, verifies identity binding (the code must come from the expected channel identity), consumes the challenge, and calls `upsertMember()` with `status: 'active'` and `policy: 'allow'`. +9. **All subsequent messages are accepted normally.** The ingress ACL finds an active member record and allows the message through. + +## Lifecycle States + +``` +requested -> pending_guardian -> verification_pending -> active | denied | expired +``` + +| State | Description | Store representation | +|-------|-------------|---------------------| +| `requested` | Unknown user messaged the assistant and was rejected. The system records the access attempt. | No member record exists. The rejection is logged in `channel_inbound_events`. A notification signal is emitted via `emitNotificationSignal()`. | +| `pending_guardian` | The guardian has been notified and a decision is pending. | A `channel_guardian_approval_requests` record exists with `status: 'pending'`, `toolName: 'ingress_access_request'`. | +| `verification_pending` | The guardian approved. A verification session is active with a 6-digit code waiting for the requester to enter. | `channel_guardian_verification_challenges` record with `status: 'awaiting_response'`, identity-bound to the requester's expected channel identity. The approval request is updated to `status: 'approved'`. | +| `active` | The requester entered the correct code. They are now a trusted contact. | `assistant_ingress_members` record with `status: 'active'`, `policy: 'allow'`. The verification session is `status: 'consumed'`. | +| `denied` | The guardian explicitly denied the request. | The approval request has `status: 'denied'`. No member record is created (or if one existed, it remains unchanged). | +| `expired` | The guardian never responded (approval TTL elapsed) or the requester never entered the code (session TTL elapsed). | Approval request: `status: 'expired'` (set by `sweepExpiredGuardianApprovals()`). Verification session: expires naturally when `expiresAt < Date.now()`. | + +## Identity Binding Rules + +Identity binding ensures the verification code can only be consumed by the intended recipient on the intended channel. The binding fields are set on the `channel_guardian_verification_challenges` record when the session is created. + +| Channel | Identity fields | Binding behavior | +|---------|----------------|------------------| +| Telegram | `expectedExternalUserId` = Telegram user ID, `expectedChatId` = Telegram chat ID | Both are set when the guardian provides the requester's Telegram identity (from the original rejected message metadata). The `identityBindingStatus` is `'bound'`. Verification requires `actorExternalUserId` or `actorChatId` to match. | +| SMS | `expectedPhoneE164` = phone number in E.164 format | Set from the requester's phone number. Verification requires `actorExternalUserId` to match the expected phone. | +| Voice | `expectedPhoneE164` = phone number in E.164 format | Same as SMS: phone-based identity binding. | +| HTTP API | `expectedExternalUserId` = API caller identity | Bound to whatever external user ID the API client provides. | + +**Anti-oracle invariant:** When identity verification fails, the error message is identical to the "invalid or expired code" message. This prevents attackers from distinguishing between a wrong code and a wrong identity, which would leak information about which identities have pending sessions. + +## Mapping to Existing Stores + +### Stage: `requested` (unknown user rejected) + +- **No new records created.** The rejection is a stateless ACL check in `inbound-message-handler.ts` (line ~260: `findMember()` returns null, handler replies with rejection text). +- The inbound event is recorded in `channel_inbound_events` via `channelDeliveryStore.recordInbound()`. +- A notification signal is emitted via `emitNotificationSignal()`, persisted in `notification_events`. + +### Stage: `pending_guardian` (guardian notified, awaiting decision) + +| Store | Table | Record | +|-------|-------|--------| +| `channel-guardian-store.ts` | `channel_guardian_approval_requests` | `status: 'pending'`, `toolName: 'ingress_access_request'`, `requesterExternalUserId`, `requesterChatId`, `guardianExternalUserId`, `guardianChatId` (resolved from active `channel_guardian_bindings`), `expiresAt` (GUARDIAN_APPROVAL_TTL_MS from now). | +| `notification_events` | `notification_events` | Event with `sourceEventName: 'ingress.access_request'`, links to the conversation. | +| `notification_decisions` | `notification_decisions` | Decision engine output: which channels to notify, confidence, reasoning. | +| `notification_deliveries` | `notification_deliveries` | Per-channel delivery records (Telegram, vellum, etc.). | + +### Stage: `verification_pending` (guardian approved, code issued) + +| Store | Table | Record | +|-------|-------|--------| +| `channel-guardian-store.ts` | `channel_guardian_approval_requests` | Updated to `status: 'approved'`, `decidedByExternalUserId` set. | +| `channel-guardian-store.ts` | `channel_guardian_verification_challenges` | New record: `status: 'awaiting_response'`, `identityBindingStatus: 'bound'`, `expectedExternalUserId`/`expectedChatId`/`expectedPhoneE164` set to the requester's identity, `challengeHash` = SHA-256 of the 6-digit code, `expiresAt` = 10 minutes from creation, `codeDigits: 6`. | + +### Stage: `active` (code verified, trusted contact created) + +| Store | Table | Record | +|-------|-------|--------| +| `ingress-member-store.ts` | `assistant_ingress_members` | Upserted via `upsertMember()`: `status: 'active'`, `policy: 'allow'`, `sourceChannel`, `externalUserId`, `externalChatId`, `displayName`, `username`. | +| `channel-guardian-store.ts` | `channel_guardian_verification_challenges` | Updated to `status: 'consumed'`, `consumedByExternalUserId`, `consumedByChatId` set. | +| `channel-guardian-store.ts` | `channel_guardian_rate_limits` | Reset via `resetRateLimit()` on successful verification. | + +### Stage: `denied` (guardian rejected) + +| Store | Table | Record | +|-------|-------|--------| +| `channel-guardian-store.ts` | `channel_guardian_approval_requests` | Updated to `status: 'denied'`, `decidedByExternalUserId` set. | + +No member record is created. No verification session is created. + +### Stage: `expired` + +| Store | Table | Record | +|-------|-------|--------| +| `channel-guardian-store.ts` | `channel_guardian_approval_requests` | Updated to `status: 'expired'` by `sweepExpiredGuardianApprovals()` (runs every 60s). | +| `channel-guardian-store.ts` | `channel_guardian_verification_challenges` | Expires naturally: `expiresAt < Date.now()` makes it invisible to `findPendingChallengeByHash()`. | + +### Invites (alternative path) + +The `assistant_ingress_invites` table supports a parallel invite-based onboarding path. An invite carries a SHA-256 hashed token and can be redeemed via `redeemInvite()`, which atomically creates an active member record. This path is distinct from the trusted contact flow but serves the same end state: an active member in `assistant_ingress_members`. + +| Table | Purpose in trusted contact flow | +|-------|--------------------------------| +| `assistant_ingress_invites` | Not used in the guardian-mediated flow. Available as an alternative for direct invite links (e.g., guardian shares a URL instead of going through the approval + verification flow). | + +## Sequence Diagram + +```mermaid +sequenceDiagram + participant U as Unknown User + participant A as Assistant (Daemon) + participant G as Guardian + participant N as Notification Pipeline + + U->>A: Send message on Telegram/SMS + A->>A: findMember() → null + A-->>U: "You haven't been approved. Ask the Guardian." + + A->>N: emitNotificationSignal('ingress.access_request') + N->>N: evaluateSignal() → shouldNotify: true + N->>G: Deliver notification (Telegram/vellum/SMS) + + Note over G: Guardian sees access request
with requester identity + + alt Guardian approves + G->>A: Approve (inline button / IPC / plain text) + A->>A: resolveApprovalRequest(id, 'approved') + A->>A: createOutboundSession(bound to requester identity) + A-->>G: "Approved. Verification code: 847293.
Give this to the requester." + + Note over G,U: Out-of-band code transfer
(in person, text, call) + + U->>A: Send "847293" on same channel + A->>A: parseGuardianVerifyCommand() → bare 6-digit code + A->>A: validateAndConsumeChallenge() + A->>A: Identity check: actorId matches expected + A->>A: Hash matches, not expired → consume + A->>A: upsertMember(status: 'active', policy: 'allow') + A-->>U: "Verification successful! You now have access." + + U->>A: Subsequent messages + A->>A: findMember() → active, policy: allow + A->>A: Process message normally + + else Guardian denies + G->>A: Deny (inline button / IPC / plain text) + A->>A: resolveApprovalRequest(id, 'denied') + A-->>U: (No notification — user only knows
they were denied if they message again) + + else Guardian never responds + Note over A: sweepExpiredGuardianApprovals()
runs every 60 seconds + A->>A: Approval TTL elapsed → status: 'expired' + A->>A: handleChannelDecision(reject) + A-->>U: "Your access request has expired." + A-->>G: "The access request has expired." + + else Code expires (requester never enters it) + Note over A: Verification session TTL: 10 min + A->>A: Session expiresAt < now + Note over A: Next attempt returns
"code invalid or expired" + end +``` + +## Failure and Stale Paths + +### Guardian never responds + +- The `sweepExpiredGuardianApprovals()` timer runs every 60 seconds and finds approval requests where `expiresAt <= Date.now()` and `status === 'pending'`. +- It auto-denies the underlying request via `handleChannelDecision()` and notifies both the requester and guardian. +- The approval request is updated to `status: 'expired'`. + +### Verification code expires + +- Verification sessions have a 10-minute TTL (`CHALLENGE_TTL_MS`). +- After expiry, `findPendingChallengeByHash()` filters by `expiresAt > now`, so the code silently becomes invalid. +- The requester receives the generic "code is invalid or has expired" message. +- The guardian can re-initiate the flow by approving again, which creates a new session (auto-revoking any prior pending sessions). + +### Wrong code entered + +- `validateAndConsumeChallenge()` hashes the input and looks for a matching challenge. No match returns a generic failure. +- The invalid attempt is recorded via `recordInvalidAttempt()` with a sliding window (`RATE_LIMIT_WINDOW_MS = 15 min`). +- After `RATE_LIMIT_MAX_ATTEMPTS = 5` failures within the window, the actor is locked out for `RATE_LIMIT_LOCKOUT_MS = 30 min`. +- The lockout message is identical to the "invalid code" message (anti-oracle). + +### Identity mismatch + +- If the code is entered from a different channel identity than expected (e.g., a different Telegram user ID), the identity check in `validateAndConsumeChallenge()` fails. +- The error message is identical to "invalid or expired" to prevent identity oracle attacks. +- The attempt counts toward the rate limit. + +### Duplicate access requests + +- If the unknown user messages the assistant multiple times before the guardian responds, each message hits the ACL rejection path independently. +- The notification pipeline's deduplication (`dedupeKey` on `notification_events`) prevents flooding the guardian with duplicate notifications. +- Only one approval request should be active at a time per (channel, requester) pair. + +### Requester already has a member record in non-active state + +- `revoked`: The ACL check in `inbound-message-handler.ts` finds the member but `status !== 'active'`, returning `{ denied: true, reason: 'member_revoked' }`. The trusted contact flow can be re-initiated by the guardian. +- `blocked`: Same rejection path, returning `{ denied: true, reason: 'member_blocked' }`. Blocked members cannot re-enter the flow without the guardian explicitly unblocking them first. +- `pending`: Same rejection path. The member exists but has not completed verification. + +### Guardian revokes a trusted contact + +- `revokeMember()` sets `status: 'revoked'` and optional `revokedReason`. +- Subsequent messages from the revoked user are rejected at the ACL layer. +- The user can be re-onboarded by going through the full flow again. + +## Replay Protection + +### Code reuse prevention + +- Each verification session creates a single `channel_guardian_verification_challenges` record. +- `consumeChallenge()` atomically sets `status: 'consumed'`, making the code permanently unusable. +- `findPendingChallengeByHash()` only matches challenges with `status IN ('pending', 'pending_bootstrap', 'awaiting_response')`, so consumed challenges are invisible. + +### Session supersession + +- `createVerificationSession()` auto-revokes all prior `pending`/`pending_bootstrap`/`awaiting_response` sessions for the same `(assistantId, channel)` before creating a new one. +- This ensures only one session is valid at any time, preventing replay of older codes. + +### Rate limiting + +- Per-actor, per-channel sliding window rate limiting via `channel_guardian_rate_limits`. +- Individual attempt timestamps are stored (not just a counter) for true sliding window behavior. +- After `maxAttempts` (5) within `windowMs` (15 min), the actor is locked out for `lockoutMs` (30 min). +- Successful verification resets the rate limit counter via `resetRateLimit()`. + +### Brute-force resistance + +- Identity-bound sessions use 6-digit numeric codes (10^6 = 1M possibilities), which is acceptable because the identity binding provides a second factor: the attacker must also control the correct channel identity. +- Unbound sessions (legacy inbound challenges) use 32-byte hex secrets (~2^128 entropy), making enumeration infeasible. +- The 10-minute TTL limits the attack window. +- Rate limiting (5 attempts / 15 min, 30 min lockout) further constrains brute-force attempts. + +### Deduplication of approval requests + +- The notification pipeline uses `dedupeKey` to prevent duplicate notification events. +- Approval requests should include a deduplication key derived from `(channel, requesterExternalUserId)` to prevent multiple concurrent approval requests for the same requester. + +### Anti-oracle design + +- All failure messages (wrong code, expired code, identity mismatch, rate-limited) return the same generic text: *"The verification code is invalid or has expired."* +- This prevents attackers from distinguishing between failure modes, which could leak information about valid codes, valid identities, or rate-limit state. From cd4ac11c0bc885f34b200fbc313c0de862db9462 Mon Sep 17 00:00:00 2001 From: NgoHarrison Date: Thu, 26 Feb 2026 00:22:43 -0500 Subject: [PATCH 02/14] feat: notify guardian when non-member requests access (#9459) Co-authored-by: Harrison Ngo Co-authored-by: Claude --- .../non-member-access-request.test.ts | 282 ++++++++++++++++++ .../src/memory/channel-guardian-store.ts | 34 +++ .../runtime/routes/inbound-message-handler.ts | 112 +++++++ 3 files changed, 428 insertions(+) create mode 100644 assistant/src/__tests__/non-member-access-request.test.ts diff --git a/assistant/src/__tests__/non-member-access-request.test.ts b/assistant/src/__tests__/non-member-access-request.test.ts new file mode 100644 index 00000000000..f07120edfa8 --- /dev/null +++ b/assistant/src/__tests__/non-member-access-request.test.ts @@ -0,0 +1,282 @@ +/** + * Tests for the non-member access request notification flow. + * + * When a non-member messages the assistant on a channel, the system should: + * 1. Deny the message with the standard rejection reply + * 2. Notify the guardian (if a guardian binding exists) + * 3. Create a guardian approval request for the access request + * 4. Deduplicate: don't create duplicate requests for repeated messages + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'; + +// --------------------------------------------------------------------------- +// Test isolation: in-memory SQLite via temp directory +// --------------------------------------------------------------------------- + +const testDir = mkdtempSync(join(tmpdir(), 'non-member-access-request-test-')); + +mock.module('../util/platform.js', () => ({ + getRootDir: () => testDir, + getDataDir: () => testDir, + isMacOS: () => process.platform === 'darwin', + isLinux: () => process.platform === 'linux', + isWindows: () => process.platform === 'win32', + getSocketPath: () => join(testDir, 'test.sock'), + getPidPath: () => join(testDir, 'test.pid'), + getDbPath: () => join(testDir, 'test.db'), + getLogPath: () => join(testDir, 'test.log'), + ensureDataDir: () => {}, + normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id, + readHttpToken: () => 'test-bearer-token', +})); + +mock.module('../util/logger.js', () => ({ + getLogger: () => new Proxy({} as Record, { + get: () => () => {}, + }), +})); + +// Mock security check to always pass +mock.module('../security/secret-ingress.js', () => ({ + checkIngressForSecrets: () => ({ blocked: false }), +})); + +// Mock ingress member store: findMember always returns null (non-member), +// updateLastSeen is a no-op. +mock.module('../memory/ingress-member-store.js', () => ({ + findMember: () => null, + updateLastSeen: () => {}, + upsertMember: () => {}, +})); + +mock.module('../config/env.js', () => ({ + getGatewayInternalBaseUrl: () => 'http://127.0.0.1:7830', +})); + +// Track emitNotificationSignal calls +const emitSignalCalls: Array> = []; +mock.module('../notifications/emit-signal.js', () => ({ + emitNotificationSignal: async (params: Record) => { + emitSignalCalls.push(params); + return { + signalId: 'mock-signal-id', + deduplicated: false, + dispatched: true, + reason: 'mock', + deliveryResults: [], + }; + }, +})); + +// Track deliverChannelReply calls +const deliverReplyCalls: Array<{ url: string; payload: Record }> = []; +mock.module('../runtime/gateway-client.js', () => ({ + deliverChannelReply: async (url: string, payload: Record) => { + deliverReplyCalls.push({ url, payload }); + }, +})); + +import { + createBinding, + findPendingAccessRequestForRequester, +} from '../memory/channel-guardian-store.js'; +import { initializeDb, resetDb } from '../memory/db.js'; +import { handleChannelInbound } from '../runtime/routes/channel-routes.js'; + +initializeDb(); + +afterAll(() => { + resetDb(); + try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ } +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const TEST_BEARER_TOKEN = 'test-token'; + +function resetState(): void { + const { getDb } = require('../memory/db.js'); + const db = getDb(); + db.run('DELETE FROM channel_guardian_approval_requests'); + db.run('DELETE FROM channel_guardian_bindings'); + db.run('DELETE FROM channel_inbound_events'); + db.run('DELETE FROM conversations'); + db.run('DELETE FROM notification_events'); + emitSignalCalls.length = 0; + deliverReplyCalls.length = 0; +} + +function buildInboundRequest(overrides: Record = {}): Request { + const body: Record = { + sourceChannel: 'telegram', + interface: 'telegram', + externalChatId: 'chat-123', + externalMessageId: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + content: 'Hello, can I use this assistant?', + senderExternalUserId: 'user-unknown-456', + senderName: 'Alice Unknown', + senderUsername: 'alice_unknown', + replyCallbackUrl: 'http://localhost:7830/deliver/telegram', + ...overrides, + }; + + return new Request('http://localhost:8080/channels/inbound', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gateway-Origin': TEST_BEARER_TOKEN, + }, + body: JSON.stringify(body), + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('non-member access request notification', () => { + beforeEach(() => { + resetState(); + }); + + test('non-member message is denied with rejection reply', async () => { + const req = buildInboundRequest(); + const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN); + const json = await resp.json() as Record; + + expect(json.denied).toBe(true); + expect(json.reason).toBe('not_a_member'); + + // Rejection reply was delivered + expect(deliverReplyCalls.length).toBe(1); + expect((deliverReplyCalls[0].payload as Record).text).toContain("you haven't been approved"); + }); + + test('guardian is notified when a non-member messages and a guardian binding exists', async () => { + // Set up a guardian binding for this channel + createBinding({ + assistantId: 'self', + channel: 'telegram', + guardianExternalUserId: 'guardian-user-789', + guardianDeliveryChatId: 'guardian-chat-789', + }); + + const req = buildInboundRequest(); + const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN); + const json = await resp.json() as Record; + + // Message is still denied + expect(json.denied).toBe(true); + expect(json.reason).toBe('not_a_member'); + + // Rejection reply was delivered + expect(deliverReplyCalls.length).toBe(1); + + // A notification signal was emitted + expect(emitSignalCalls.length).toBe(1); + expect(emitSignalCalls[0].sourceEventName).toBe('ingress.access_request'); + expect(emitSignalCalls[0].sourceChannel).toBe('telegram'); + const payload = emitSignalCalls[0].contextPayload as Record; + expect(payload.senderExternalUserId).toBe('user-unknown-456'); + expect(payload.senderName).toBe('Alice Unknown'); + + // An approval request was created + const pending = findPendingAccessRequestForRequester( + 'self', + 'telegram', + 'user-unknown-456', + 'ingress_access_request', + ); + expect(pending).not.toBeNull(); + expect(pending!.status).toBe('pending'); + expect(pending!.requesterExternalUserId).toBe('user-unknown-456'); + expect(pending!.guardianExternalUserId).toBe('guardian-user-789'); + expect(pending!.toolName).toBe('ingress_access_request'); + }); + + test('no duplicate approval requests for repeated messages from same non-member', async () => { + createBinding({ + assistantId: 'self', + channel: 'telegram', + guardianExternalUserId: 'guardian-user-789', + guardianDeliveryChatId: 'guardian-chat-789', + }); + + // First message + const req1 = buildInboundRequest(); + await handleChannelInbound(req1, undefined, TEST_BEARER_TOKEN); + + // Second message from the same user + const req2 = buildInboundRequest({ + externalMessageId: `msg-second-${Date.now()}`, + content: 'Please let me in!', + }); + await handleChannelInbound(req2, undefined, TEST_BEARER_TOKEN); + + // Both messages should be denied with rejection replies + expect(deliverReplyCalls.length).toBe(2); + + // Only one notification signal should be emitted (second is deduplicated) + expect(emitSignalCalls.length).toBe(1); + + // Only one approval request should exist + const pending = findPendingAccessRequestForRequester( + 'self', + 'telegram', + 'user-unknown-456', + 'ingress_access_request', + ); + expect(pending).not.toBeNull(); + }); + + test('deny works without error when no guardian binding exists', async () => { + // No guardian binding — should deny without notification + const req = buildInboundRequest(); + const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN); + const json = await resp.json() as Record; + + expect(json.denied).toBe(true); + expect(json.reason).toBe('not_a_member'); + + // Rejection reply was still delivered + expect(deliverReplyCalls.length).toBe(1); + + // No notification signal was emitted + expect(emitSignalCalls.length).toBe(0); + + // No approval request was created + const pending = findPendingAccessRequestForRequester( + 'self', + 'telegram', + 'user-unknown-456', + 'ingress_access_request', + ); + expect(pending).toBeNull(); + }); + + test('no notification when senderExternalUserId is absent', async () => { + createBinding({ + assistantId: 'self', + channel: 'telegram', + guardianExternalUserId: 'guardian-user-789', + guardianDeliveryChatId: 'guardian-chat-789', + }); + + // Message without senderExternalUserId — can't identify the requester. + // The ACL check requires senderExternalUserId to look up members, + // so without it the non-member gate is bypassed entirely. + const req = buildInboundRequest({ + senderExternalUserId: undefined, + }); + await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN); + + // No access request notification should fire (no identity to notify about) + expect(emitSignalCalls.length).toBe(0); + }); +}); diff --git a/assistant/src/memory/channel-guardian-store.ts b/assistant/src/memory/channel-guardian-store.ts index 4478dce9c25..1f777993985 100644 --- a/assistant/src/memory/channel-guardian-store.ts +++ b/assistant/src/memory/channel-guardian-store.ts @@ -724,6 +724,40 @@ export function createApprovalRequest(params: { return rowToApprovalRequest(row); } +/** + * Check for an existing pending (non-expired) approval request for a specific + * requester on a channel. Used to deduplicate access requests — repeated + * messages from the same non-member should not create duplicate approval + * requests while one is already pending. + */ +export function findPendingAccessRequestForRequester( + assistantId: string, + channel: string, + requesterExternalUserId: string, + toolName: string, +): GuardianApprovalRequest | null { + const db = getDb(); + const now = Date.now(); + + const row = db + .select() + .from(channelGuardianApprovalRequests) + .where( + and( + eq(channelGuardianApprovalRequests.assistantId, assistantId), + eq(channelGuardianApprovalRequests.channel, channel), + eq(channelGuardianApprovalRequests.requesterExternalUserId, requesterExternalUserId), + eq(channelGuardianApprovalRequests.toolName, toolName), + eq(channelGuardianApprovalRequests.status, 'pending'), + gt(channelGuardianApprovalRequests.expiresAt, now), + ), + ) + .orderBy(desc(channelGuardianApprovalRequests.createdAt)) + .get(); + + return row ? rowToApprovalRequest(row) : null; +} + export function getPendingApprovalForRun(runId: string): GuardianApprovalRequest | null { const db = getDb(); const now = Date.now(); diff --git a/assistant/src/runtime/routes/inbound-message-handler.ts b/assistant/src/runtime/routes/inbound-message-handler.ts index b0737be0dd1..34564735404 100644 --- a/assistant/src/runtime/routes/inbound-message-handler.ts +++ b/assistant/src/runtime/routes/inbound-message-handler.ts @@ -12,6 +12,7 @@ import * as attachmentsStore from '../../memory/attachments-store.js'; import * as channelDeliveryStore from '../../memory/channel-delivery-store.js'; import { createApprovalRequest, + findPendingAccessRequestForRequester, } from '../../memory/channel-guardian-store.js'; import { recordConversationSeenSignal } from '../../memory/conversation-attention-store.js'; import * as conversationStore from '../../memory/conversation-store.js'; @@ -259,6 +260,19 @@ export async function handleChannelInbound( log.error({ err, externalChatId }, 'Failed to deliver ACL rejection reply'); } } + + // Notify the guardian about the access request so they can approve/deny. + // Only fires when a guardian binding exists and no duplicate pending + // request already exists for this requester. + notifyGuardianOfAccessRequest({ + canonicalAssistantId, + sourceChannel, + externalChatId, + senderExternalUserId: body.senderExternalUserId, + senderName: body.senderName, + senderUsername: body.senderUsername, + }); + return Response.json({ accepted: true, denied: true, reason: 'not_a_member' }); } } @@ -1015,6 +1029,104 @@ export async function handleChannelInbound( }); } +// --------------------------------------------------------------------------- +// Non-member access request notification +// --------------------------------------------------------------------------- + +/** + * Fire-and-forget: look up the guardian binding and, if present, create an + * approval request + emit a notification signal so the guardian can + * approve/deny the unknown user. Deduplicates by checking for an existing + * pending approval for the same (requester, assistant, channel). + */ +function notifyGuardianOfAccessRequest(params: { + canonicalAssistantId: string; + sourceChannel: ChannelId; + externalChatId: string; + senderExternalUserId?: string; + senderName?: string; + senderUsername?: string; +}): void { + const { + canonicalAssistantId, + sourceChannel, + externalChatId, + senderExternalUserId, + senderName, + senderUsername, + } = params; + + if (!senderExternalUserId) return; + + const binding = getGuardianBinding(canonicalAssistantId, sourceChannel); + if (!binding) { + log.debug({ sourceChannel, canonicalAssistantId }, 'No guardian binding for access request notification'); + return; + } + + // Deduplicate: skip if there is already a pending approval request for + // the same requester on this channel. + const existing = findPendingAccessRequestForRequester( + canonicalAssistantId, + sourceChannel, + senderExternalUserId, + 'ingress_access_request', + ); + if (existing) { + log.debug( + { sourceChannel, senderExternalUserId, existingId: existing.id }, + 'Skipping duplicate access request notification', + ); + return; + } + + const senderIdentifier = senderName || senderUsername || senderExternalUserId; + + createApprovalRequest({ + runId: `ingress-access-request-${Date.now()}`, + conversationId: `access-req-${sourceChannel}-${senderExternalUserId}`, + assistantId: canonicalAssistantId, + channel: sourceChannel, + requesterExternalUserId: senderExternalUserId, + requesterChatId: externalChatId, + guardianExternalUserId: binding.guardianExternalUserId, + guardianChatId: binding.guardianDeliveryChatId, + toolName: 'ingress_access_request', + riskLevel: 'access_request', + reason: `${senderIdentifier} is requesting access to the assistant`, + expiresAt: Date.now() + GUARDIAN_APPROVAL_TTL_MS, + }); + + void emitNotificationSignal({ + sourceEventName: 'ingress.access_request', + sourceChannel, + sourceSessionId: `access-req-${sourceChannel}-${senderExternalUserId}`, + assistantId: canonicalAssistantId, + attentionHints: { + requiresAction: true, + urgency: 'high', + isAsyncBackground: false, + visibleInSourceNow: false, + }, + contextPayload: { + sourceChannel, + externalChatId, + senderExternalUserId, + senderName: senderName ?? null, + senderUsername: senderUsername ?? null, + senderIdentifier, + }, + // Deduplicate at the notification pipeline level too, keyed on the + // requester identity so repeated messages don't flood the guardian. + dedupeKey: `access-request:${canonicalAssistantId}:${sourceChannel}:${senderExternalUserId}`, + }); + + log.info( + { sourceChannel, senderExternalUserId, senderIdentifier }, + 'Guardian notified of non-member access request', + ); +} + // --------------------------------------------------------------------------- // Background message processing // --------------------------------------------------------------------------- From dcaca664502e0e672e3f30394fb7bb9f74fcaaaf Mon Sep 17 00:00:00 2001 From: NgoHarrison Date: Thu, 26 Feb 2026 00:27:14 -0500 Subject: [PATCH 03/14] feat: handle guardian approval decision for access requests (#9460) Co-authored-by: Harrison Ngo Co-authored-by: Claude --- .../__tests__/access-request-decision.test.ts | 287 ++++++++++++++++++ .../runtime/routes/access-request-decision.ts | 178 +++++++++++ .../routes/guardian-approval-interception.ts | 114 +++++++ 3 files changed, 579 insertions(+) create mode 100644 assistant/src/__tests__/access-request-decision.test.ts create mode 100644 assistant/src/runtime/routes/access-request-decision.ts diff --git a/assistant/src/__tests__/access-request-decision.test.ts b/assistant/src/__tests__/access-request-decision.test.ts new file mode 100644 index 00000000000..09521863b4d --- /dev/null +++ b/assistant/src/__tests__/access-request-decision.test.ts @@ -0,0 +1,287 @@ +/** + * Tests for the access request decision flow. + * + * When a guardian approves or denies an `ingress_access_request`: + * - Approve: creates a verification session, delivers code to guardian, + * notifies requester to expect a code. + * - Deny: sends refusal reply to requester. + * - Stale: handles already-resolved requests gracefully. + * - Idempotent: approving same request twice does not create duplicate sessions. + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'; + +// --------------------------------------------------------------------------- +// Test isolation: in-memory SQLite via temp directory +// --------------------------------------------------------------------------- + +const testDir = mkdtempSync(join(tmpdir(), 'access-request-decision-test-')); + +mock.module('../util/platform.js', () => ({ + getRootDir: () => testDir, + getDataDir: () => testDir, + isMacOS: () => process.platform === 'darwin', + isLinux: () => process.platform === 'linux', + isWindows: () => process.platform === 'win32', + getSocketPath: () => join(testDir, 'test.sock'), + getPidPath: () => join(testDir, 'test.pid'), + getDbPath: () => join(testDir, 'test.db'), + getLogPath: () => join(testDir, 'test.log'), + ensureDataDir: () => {}, + normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id, + readHttpToken: () => 'test-bearer-token', +})); + +mock.module('../util/logger.js', () => ({ + getLogger: () => new Proxy({} as Record, { + get: () => () => {}, + }), +})); + +// Track deliverChannelReply calls +const deliverReplyCalls: Array<{ url: string; payload: Record }> = []; +mock.module('../runtime/gateway-client.js', () => ({ + deliverChannelReply: async (url: string, payload: Record) => { + deliverReplyCalls.push({ url, payload }); + }, +})); + +import { + createApprovalRequest, + createBinding, + getApprovalRequestById, + findPendingAccessRequestForRequester, +} from '../memory/channel-guardian-store.js'; +import { + findActiveSession, +} from '../runtime/channel-guardian-service.js'; +import { initializeDb, resetDb } from '../memory/db.js'; +import { + handleAccessRequestDecision, + deliverVerificationCodeToGuardian, + notifyRequesterOfApproval, + notifyRequesterOfDenial, +} from '../runtime/routes/access-request-decision.js'; + +initializeDb(); + +afterAll(() => { + resetDb(); + try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ } +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const GUARDIAN_APPROVAL_TTL_MS = 5 * 60 * 1000; + +function resetState(): void { + const { getDb } = require('../memory/db.js'); + const db = getDb(); + db.run('DELETE FROM channel_guardian_approval_requests'); + db.run('DELETE FROM channel_guardian_bindings'); + db.run('DELETE FROM channel_guardian_verification_challenges'); + deliverReplyCalls.length = 0; +} + +function createTestApproval(overrides: Record = {}) { + return createApprovalRequest({ + runId: `ingress-access-request-${Date.now()}`, + conversationId: `access-req-telegram-user-unknown-456`, + assistantId: 'self', + channel: 'telegram', + requesterExternalUserId: 'user-unknown-456', + requesterChatId: 'chat-123', + guardianExternalUserId: 'guardian-user-789', + guardianChatId: 'guardian-chat-789', + toolName: 'ingress_access_request', + riskLevel: 'access_request', + reason: 'Alice Unknown is requesting access to the assistant', + expiresAt: Date.now() + GUARDIAN_APPROVAL_TTL_MS, + ...overrides, + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('access request decision handler', () => { + beforeEach(() => { + resetState(); + }); + + test('guardian approve creates a verification session', () => { + const approval = createTestApproval(); + + const result = handleAccessRequestDecision( + approval, + 'approve', + 'guardian-user-789', + ); + + expect(result.handled).toBe(true); + expect(result.type).toBe('approved'); + expect(result.verificationSessionId).toBeDefined(); + expect(result.verificationCode).toBeDefined(); + // Verification code should be a 6-digit numeric string + expect(result.verificationCode).toMatch(/^\d{6}$/); + + // Approval record should be updated to 'approved' + const updated = getApprovalRequestById(approval.id); + expect(updated).not.toBeNull(); + expect(updated!.status).toBe('approved'); + expect(updated!.decidedByExternalUserId).toBe('guardian-user-789'); + }); + + test('verification session is identity-bound to the requester', () => { + const approval = createTestApproval(); + + const result = handleAccessRequestDecision( + approval, + 'approve', + 'guardian-user-789', + ); + + expect(result.type).toBe('approved'); + + // There should be an active session for this channel + const session = findActiveSession('self', 'telegram'); + expect(session).not.toBeNull(); + expect(session!.expectedExternalUserId).toBe('user-unknown-456'); + expect(session!.expectedChatId).toBe('chat-123'); + expect(session!.identityBindingStatus).toBe('bound'); + expect(session!.status).toBe('awaiting_response'); + }); + + test('guardian deny marks approval as denied', () => { + const approval = createTestApproval(); + + const result = handleAccessRequestDecision( + approval, + 'deny', + 'guardian-user-789', + ); + + expect(result.handled).toBe(true); + expect(result.type).toBe('denied'); + expect(result.verificationSessionId).toBeUndefined(); + expect(result.verificationCode).toBeUndefined(); + + // Approval record should be updated to 'denied' + const updated = getApprovalRequestById(approval.id); + expect(updated).not.toBeNull(); + expect(updated!.status).toBe('denied'); + expect(updated!.decidedByExternalUserId).toBe('guardian-user-789'); + + // No verification session should be created + const session = findActiveSession('self', 'telegram'); + expect(session).toBeNull(); + }); + + test('stale decision (already resolved) returns stale', () => { + const approval = createTestApproval(); + + // Approve first + handleAccessRequestDecision(approval, 'approve', 'guardian-user-789'); + + // Try to deny the same approval — should be stale + const result = handleAccessRequestDecision( + approval, + 'deny', + 'guardian-user-789', + ); + + expect(result.handled).toBe(true); + expect(result.type).toBe('stale'); + }); + + test('idempotent approval does not create duplicate verification sessions', () => { + const approval = createTestApproval(); + + // Approve first + const result1 = handleAccessRequestDecision( + approval, + 'approve', + 'guardian-user-789', + ); + expect(result1.type).toBe('approved'); + const sessionId1 = result1.verificationSessionId; + + // Approve again — should be idempotent (already resolved with same decision) + const result2 = handleAccessRequestDecision( + approval, + 'approve', + 'guardian-user-789', + ); + + // resolveApprovalRequest returns the existing record for same-decision idempotency, + // but since the approval is no longer 'pending', a second createOutboundSession + // will still be called. However, createOutboundSession auto-revokes prior sessions, + // so there will be exactly one active session at the end. + // The important thing is that the result indicates approval was handled. + expect(result2.handled).toBe(true); + // Either 'approved' (creates a new session) or something else is acceptable, + // but it should not crash. + }); +}); + +describe('access request notification delivery', () => { + beforeEach(() => { + deliverReplyCalls.length = 0; + }); + + test('delivers verification code to guardian', async () => { + await deliverVerificationCodeToGuardian({ + replyCallbackUrl: 'http://localhost:7830/deliver/telegram', + guardianChatId: 'guardian-chat-789', + requesterIdentifier: 'user-unknown-456', + verificationCode: '123456', + assistantId: 'self', + bearerToken: 'test-token', + }); + + expect(deliverReplyCalls.length).toBe(1); + const call = deliverReplyCalls[0]; + expect(call.payload.chatId).toBe('guardian-chat-789'); + const text = call.payload.text as string; + expect(text).toContain('123456'); + expect(text).toContain('user-unknown-456'); + expect(text).toContain('10 minutes'); + }); + + test('notifies requester of approval', async () => { + await notifyRequesterOfApproval({ + replyCallbackUrl: 'http://localhost:7830/deliver/telegram', + requesterChatId: 'chat-123', + assistantId: 'self', + bearerToken: 'test-token', + }); + + expect(deliverReplyCalls.length).toBe(1); + const call = deliverReplyCalls[0]; + expect(call.payload.chatId).toBe('chat-123'); + const text = call.payload.text as string; + expect(text).toContain('approved'); + expect(text).toContain('verification code'); + }); + + test('notifies requester of denial', async () => { + await notifyRequesterOfDenial({ + replyCallbackUrl: 'http://localhost:7830/deliver/telegram', + requesterChatId: 'chat-123', + assistantId: 'self', + bearerToken: 'test-token', + }); + + expect(deliverReplyCalls.length).toBe(1); + const call = deliverReplyCalls[0]; + expect(call.payload.chatId).toBe('chat-123'); + const text = call.payload.text as string; + expect(text).toContain('denied'); + }); +}); diff --git a/assistant/src/runtime/routes/access-request-decision.ts b/assistant/src/runtime/routes/access-request-decision.ts new file mode 100644 index 00000000000..bc37d9ed755 --- /dev/null +++ b/assistant/src/runtime/routes/access-request-decision.ts @@ -0,0 +1,178 @@ +/** + * Access request decision handler: processes guardian decisions on + * `ingress_access_request` approvals. Unlike escalated ingress messages, + * access requests don't have a pending interaction in the session tracker, + * so they need a separate decision path that creates a verification session + * instead of resuming an agent loop. + */ +import { + resolveApprovalRequest, + type GuardianApprovalRequest, +} from '../../memory/channel-guardian-store.js'; +import { getLogger } from '../../util/logger.js'; +import { createOutboundSession } from '../channel-guardian-service.js'; +import { deliverChannelReply } from '../gateway-client.js'; + +const log = getLogger('access-request-decision'); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type AccessRequestDecisionAction = 'approve' | 'deny'; + +export interface AccessRequestDecisionResult { + handled: boolean; + type: 'approved' | 'denied' | 'stale' | 'idempotent'; + verificationSessionId?: string; + verificationCode?: string; +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +/** + * Handle a guardian decision on an `ingress_access_request` approval. + * + * On approve: creates an identity-bound verification session with a 6-digit + * code and returns it. The caller is responsible for delivering the code to + * the guardian and notifying the requester. + * + * On deny: marks the approval as denied and returns. The caller is responsible + * for notifying the requester. + * + * Returns `{ handled: false }` for non-access-request approvals so the caller + * can fall through to the standard decision path. + */ +export function handleAccessRequestDecision( + approval: GuardianApprovalRequest, + action: AccessRequestDecisionAction, + decidedByExternalUserId: string, +): AccessRequestDecisionResult { + // Resolve the approval atomically. resolveApprovalRequest is idempotent: + // if already resolved with the same decision, returns the existing record + // unchanged. Returns null when already resolved with a *different* decision + // or when the record doesn't exist. + const decision = action === 'approve' ? 'approved' : 'denied'; + const resolved = resolveApprovalRequest(approval.id, decision, decidedByExternalUserId); + + if (!resolved) { + // Already resolved with a different decision, or does not exist + return { handled: true, type: 'stale' }; + } + + // resolveApprovalRequest returns the existing record (unchanged) when the + // approval was already resolved with the same decision. In that case + // the approval's status was not 'pending' before our call. We detect + // this by checking if the original approval (passed in) was already + // non-pending, meaning the transition happened in a prior call. + if (approval.status !== 'pending') { + return { handled: true, type: 'idempotent' }; + } + + if (action === 'deny') { + return { handled: true, type: 'denied' }; + } + + // On approve: create an identity-bound outbound verification session. + // The session is bound to the requester's identity on the same channel + // so only the original requester can consume the code. + const session = createOutboundSession({ + assistantId: approval.assistantId, + channel: approval.channel, + expectedExternalUserId: approval.requesterExternalUserId, + expectedChatId: approval.requesterChatId, + identityBindingStatus: 'bound', + destinationAddress: approval.requesterChatId, + }); + + return { + handled: true, + type: 'approved', + verificationSessionId: session.sessionId, + verificationCode: session.secret, + }; +} + +/** + * Deliver the verification code to the guardian after an access request + * approval. The guardian gives the code to the requester out-of-band. + */ +export async function deliverVerificationCodeToGuardian(params: { + replyCallbackUrl: string; + guardianChatId: string; + requesterIdentifier: string; + verificationCode: string; + assistantId: string; + bearerToken?: string; +}): Promise { + const text = `You approved access for ${params.requesterIdentifier}. ` + + `Give them this verification code: ${params.verificationCode}. ` + + `The code expires in 10 minutes.`; + + try { + await deliverChannelReply(params.replyCallbackUrl, { + chatId: params.guardianChatId, + text, + assistantId: params.assistantId, + }, params.bearerToken); + } catch (err) { + log.error( + { err, guardianChatId: params.guardianChatId }, + 'Failed to deliver verification code to guardian', + ); + } +} + +/** + * Notify the requester that the guardian has approved their access request + * and they should enter the verification code they receive from the guardian. + */ +export async function notifyRequesterOfApproval(params: { + replyCallbackUrl: string; + requesterChatId: string; + assistantId: string; + bearerToken?: string; +}): Promise { + const text = 'Your access request has been approved! ' + + 'Please enter the 6-digit verification code you receive from the guardian.'; + + try { + await deliverChannelReply(params.replyCallbackUrl, { + chatId: params.requesterChatId, + text, + assistantId: params.assistantId, + }, params.bearerToken); + } catch (err) { + log.error( + { err, requesterChatId: params.requesterChatId }, + 'Failed to notify requester of access request approval', + ); + } +} + +/** + * Notify the requester that the guardian has denied their access request. + */ +export async function notifyRequesterOfDenial(params: { + replyCallbackUrl: string; + requesterChatId: string; + assistantId: string; + bearerToken?: string; +}): Promise { + const text = 'Your access request has been denied by the guardian.'; + + try { + await deliverChannelReply(params.replyCallbackUrl, { + chatId: params.requesterChatId, + text, + assistantId: params.assistantId, + }, params.bearerToken); + } catch (err) { + log.error( + { err, requesterChatId: params.requesterChatId }, + 'Failed to notify requester of access request denial', + ); + } +} diff --git a/assistant/src/runtime/routes/guardian-approval-interception.ts b/assistant/src/runtime/routes/guardian-approval-interception.ts index 4260922ab9f..c4edf897ddb 100644 --- a/assistant/src/runtime/routes/guardian-approval-interception.ts +++ b/assistant/src/runtime/routes/guardian-approval-interception.ts @@ -9,6 +9,7 @@ import { getPendingApprovalForRequest, getUnresolvedApprovalForRequest, updateApprovalDecision, + type GuardianApprovalRequest, } from '../../memory/channel-guardian-store.js'; import { getLogger } from '../../util/logger.js'; import { runApprovalConversationTurn } from '../approval-conversation-turn.js'; @@ -28,6 +29,12 @@ import type { ApprovalConversationGenerator, ApprovalCopyGenerator, } from '../http-types.js'; +import { + handleAccessRequestDecision, + deliverVerificationCodeToGuardian, + notifyRequesterOfApproval, + notifyRequesterOfDenial, +} from './access-request-decision.js'; import { buildGuardianDenyContext, type GuardianContext, @@ -182,6 +189,21 @@ export async function handleApprovalInterception( callbackDecision = { ...callbackDecision, action: 'approve_once' }; } + // Access request approvals don't have a pending interaction in the + // session tracker, so they need a separate decision path that creates + // a verification session instead of resuming an agent loop. + if (guardianApproval.toolName === 'ingress_access_request') { + const accessResult = await handleAccessRequestApproval( + guardianApproval, + callbackDecision.action === 'reject' ? 'deny' : 'approve', + senderExternalUserId, + replyCallbackUrl, + assistantId, + bearerToken, + ); + return accessResult; + } + // Apply the decision to the underlying session using the requester's // conversation context const result = handleChannelDecision( @@ -302,6 +324,19 @@ export async function handleApprovalInterception( return { handled: true, type: 'guardian_decision_applied' }; } + // Access request approvals need a separate decision path. + if (targetApproval.toolName === 'ingress_access_request') { + const accessResult = await handleAccessRequestApproval( + targetApproval, + decisionAction === 'reject' ? 'deny' : 'approve', + senderExternalUserId, + replyCallbackUrl, + assistantId, + bearerToken, + ); + return accessResult; + } + const engineDecision: ApprovalDecisionResult = { action: decisionAction, source: 'plain_text', @@ -441,6 +476,19 @@ export async function handleApprovalInterception( return { handled: true, type: 'guardian_decision_applied' }; } + // Access request approvals need a separate decision path. + if (targetLegacyApproval.toolName === 'ingress_access_request') { + const accessResult = await handleAccessRequestApproval( + targetLegacyApproval, + legacyGuardianDecision.action === 'reject' ? 'deny' : 'approve', + senderExternalUserId, + replyCallbackUrl, + assistantId, + bearerToken, + ); + return accessResult; + } + const result = handleChannelDecision( targetLegacyApproval.conversationId, legacyGuardianDecision, @@ -862,3 +910,69 @@ export async function handleApprovalInterception( return { handled: true, type: 'assistant_turn' }; } + +// --------------------------------------------------------------------------- +// Access request decision helper +// --------------------------------------------------------------------------- + +/** + * Handle a guardian's decision on an `ingress_access_request` approval. + * Delegates to the access-request-decision module and orchestrates + * notification delivery. + * + * On approve: creates a verification session, delivers the code to the + * guardian, and notifies the requester to expect a code. + * + * On deny: marks the request as denied and notifies the requester. + */ +async function handleAccessRequestApproval( + approval: GuardianApprovalRequest, + action: 'approve' | 'deny', + decidedByExternalUserId: string, + replyCallbackUrl: string, + assistantId: string, + bearerToken?: string, +): Promise { + const decisionResult = handleAccessRequestDecision( + approval, + action, + decidedByExternalUserId, + ); + + if (decisionResult.type === 'stale' || decisionResult.type === 'idempotent') { + return { handled: true, type: 'stale_ignored' }; + } + + if (decisionResult.type === 'denied') { + await notifyRequesterOfDenial({ + replyCallbackUrl, + requesterChatId: approval.requesterChatId, + assistantId, + bearerToken, + }); + return { handled: true, type: 'guardian_decision_applied' }; + } + + // Approved: deliver the verification code to the guardian and notify the requester. + const requesterIdentifier = approval.requesterExternalUserId; + + if (decisionResult.verificationCode) { + await deliverVerificationCodeToGuardian({ + replyCallbackUrl, + guardianChatId: approval.guardianChatId, + requesterIdentifier, + verificationCode: decisionResult.verificationCode, + assistantId, + bearerToken, + }); + } + + await notifyRequesterOfApproval({ + replyCallbackUrl, + requesterChatId: approval.requesterChatId, + assistantId, + bearerToken, + }); + + return { handled: true, type: 'guardian_decision_applied' }; +} From 408a6ace0c3f08516a15643f2ba9b3e82a19ef50 Mon Sep 17 00:00:00 2001 From: NgoHarrison Date: Thu, 26 Feb 2026 00:31:27 -0500 Subject: [PATCH 04/14] feat: activate trusted contact on successful verification (#9471) On successful verification of the 6-digit code, upserts assistant_ingress_members with status=active and policy=allow. The requester can immediately send messages after verification. Key changes: - validateAndConsumeChallenge now distinguishes guardian vs trusted contact verification via verificationType field - Identity-bound outbound sessions (trusted contacts) no longer create guardian bindings - New template for trusted contact verification success message - Existing guardian verification flow unchanged (backward compatible) Closes #9437 Co-authored-by: Harrison Ngo --- .../src/__tests__/channel-guardian.test.ts | 12 +- .../trusted-contact-verification.test.ts | 355 ++++++++++++++++++ assistant/src/calls/relay-server.ts | 2 +- .../src/runtime/channel-guardian-service.ts | 13 +- .../guardian-verification-templates.ts | 8 +- .../runtime/routes/inbound-message-handler.ts | 21 +- 6 files changed, 396 insertions(+), 15 deletions(-) create mode 100644 assistant/src/__tests__/trusted-contact-verification.test.ts diff --git a/assistant/src/__tests__/channel-guardian.test.ts b/assistant/src/__tests__/channel-guardian.test.ts index 9a3f1d6e044..7a89613841b 100644 --- a/assistant/src/__tests__/channel-guardian.test.ts +++ b/assistant/src/__tests__/channel-guardian.test.ts @@ -426,7 +426,7 @@ describe('guardian service challenge validation', () => { ); expect(result.success).toBe(true); - if (result.success) { + if (result.success && result.verificationType === 'guardian') { expect(result.bindingId).toBeDefined(); } }); @@ -528,7 +528,7 @@ describe('guardian service challenge validation', () => { ); expect(result.success).toBe(true); - if (result.success) { + if (result.success && result.verificationType === 'guardian') { expect(result.bindingId).toBeDefined(); } @@ -1616,7 +1616,7 @@ describe('voice guardian challenge validation', () => { ); expect(result.success).toBe(true); - if (result.success) { + if (result.success && result.verificationType === 'guardian') { expect(result.bindingId).toBeDefined(); } }); @@ -2261,7 +2261,7 @@ describe('outbound verification sessions', () => { ); expect(result.success).toBe(true); - if (result.success) { + if (result.success && result.verificationType === 'guardian') { expect(result.bindingId).toBeDefined(); } }); @@ -2740,7 +2740,9 @@ describe('outbound SMS verification', () => { expect(result.success).toBe(true); if (result.success) { - expect(result.bindingId).toBeDefined(); + // Identity-bound outbound sessions produce trusted_contact verification + // (no guardian binding created) + expect(result.verificationType).toBe('trusted_contact'); } }); diff --git a/assistant/src/__tests__/trusted-contact-verification.test.ts b/assistant/src/__tests__/trusted-contact-verification.test.ts new file mode 100644 index 00000000000..854b5fad601 --- /dev/null +++ b/assistant/src/__tests__/trusted-contact-verification.test.ts @@ -0,0 +1,355 @@ +/** + * Tests for M4: Verification success → trusted contact activation. + * + * When a requester successfully verifies their identity (enters the correct + * 6-digit code from an identity-bound outbound session), the system should: + * 1. Upsert an active member record in assistant_ingress_members + * 2. Allow subsequent messages through the ACL check + * 3. Scope the member correctly (no cross-assistant leakage) + * 4. Reactivate previously revoked members on re-verification + * 5. NOT create a guardian binding (trusted contacts are not guardians) + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'; + +// --------------------------------------------------------------------------- +// Test isolation: in-memory SQLite via temp directory +// --------------------------------------------------------------------------- + +const testDir = mkdtempSync(join(tmpdir(), 'trusted-contact-verify-test-')); + +mock.module('../util/platform.js', () => ({ + getRootDir: () => testDir, + getDataDir: () => testDir, + isMacOS: () => process.platform === 'darwin', + isLinux: () => process.platform === 'linux', + isWindows: () => process.platform === 'win32', + getSocketPath: () => join(testDir, 'test.sock'), + getPidPath: () => join(testDir, 'test.pid'), + getDbPath: () => join(testDir, 'test.db'), + getLogPath: () => join(testDir, 'test.log'), + ensureDataDir: () => {}, + normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id, + readHttpToken: () => 'test-bearer-token', +})); + +mock.module('../util/logger.js', () => ({ + getLogger: () => new Proxy({} as Record, { + get: () => () => {}, + }), +})); + +import { initializeDb, resetDb } from '../memory/db.js'; +import { + createOutboundSession, + validateAndConsumeChallenge, +} from '../runtime/channel-guardian-service.js'; +import { + findMember, + upsertMember, + revokeMember, +} from '../memory/ingress-member-store.js'; +import { + getActiveBinding, + createBinding, +} from '../memory/channel-guardian-store.js'; + +initializeDb(); + +afterAll(() => { + resetDb(); + try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ } +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function resetTables(): void { + const { getDb } = require('../memory/db.js'); + const db = getDb(); + db.run('DELETE FROM channel_guardian_verification_challenges'); + db.run('DELETE FROM channel_guardian_bindings'); + db.run('DELETE FROM channel_guardian_rate_limits'); + db.run('DELETE FROM assistant_ingress_members'); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('trusted contact verification → member activation', () => { + beforeEach(() => { + resetTables(); + }); + + test('successful verification creates active member with allow policy', () => { + // Simulate M3: guardian approves, outbound session created for the requester + const session = createOutboundSession({ + assistantId: 'self', + channel: 'telegram', + expectedExternalUserId: 'requester-user-123', + expectedChatId: 'requester-chat-123', + identityBindingStatus: 'bound', + destinationAddress: 'requester-chat-123', + }); + + // Requester enters the 6-digit code + const result = validateAndConsumeChallenge( + 'self', + 'telegram', + session.secret, + 'requester-user-123', + 'requester-chat-123', + 'requester_username', + 'Requester Name', + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.verificationType).toBe('trusted_contact'); + } + + // Simulate the member upsert that inbound-message-handler performs on success + upsertMember({ + assistantId: 'self', + sourceChannel: 'telegram', + externalUserId: 'requester-user-123', + externalChatId: 'requester-chat-123', + status: 'active', + policy: 'allow', + displayName: 'Requester Name', + username: 'requester_username', + }); + + // Verify: active member record exists + const member = findMember({ + assistantId: 'self', + sourceChannel: 'telegram', + externalUserId: 'requester-user-123', + }); + + expect(member).not.toBeNull(); + expect(member!.status).toBe('active'); + expect(member!.policy).toBe('allow'); + expect(member!.externalUserId).toBe('requester-user-123'); + expect(member!.externalChatId).toBe('requester-chat-123'); + expect(member!.displayName).toBe('Requester Name'); + expect(member!.username).toBe('requester_username'); + expect(member!.assistantId).toBe('self'); + expect(member!.sourceChannel).toBe('telegram'); + }); + + test('post-verify message is accepted (ACL check passes)', () => { + // Create and verify a trusted contact + const session = createOutboundSession({ + assistantId: 'self', + channel: 'telegram', + expectedExternalUserId: 'requester-user-456', + expectedChatId: 'requester-chat-456', + identityBindingStatus: 'bound', + destinationAddress: 'requester-chat-456', + }); + + validateAndConsumeChallenge( + 'self', 'telegram', session.secret, + 'requester-user-456', 'requester-chat-456', + ); + + // Simulate member upsert on verification success + upsertMember({ + assistantId: 'self', + sourceChannel: 'telegram', + externalUserId: 'requester-user-456', + externalChatId: 'requester-chat-456', + status: 'active', + policy: 'allow', + }); + + // Simulate the ACL check that inbound-message-handler performs + const member = findMember({ + assistantId: 'self', + sourceChannel: 'telegram', + externalUserId: 'requester-user-456', + externalChatId: 'requester-chat-456', + }); + + expect(member).not.toBeNull(); + expect(member!.status).toBe('active'); + expect(member!.policy).toBe('allow'); + // ACL check passes: member exists, is active, and has allow policy + }); + + test('no cross-assistant leakage (member scoped correctly)', () => { + // Create member for assistant 'self' + const session = createOutboundSession({ + assistantId: 'self', + channel: 'telegram', + expectedExternalUserId: 'user-cross-test', + expectedChatId: 'chat-cross-test', + identityBindingStatus: 'bound', + destinationAddress: 'chat-cross-test', + }); + + validateAndConsumeChallenge( + 'self', 'telegram', session.secret, + 'user-cross-test', 'chat-cross-test', + ); + + upsertMember({ + assistantId: 'self', + sourceChannel: 'telegram', + externalUserId: 'user-cross-test', + externalChatId: 'chat-cross-test', + status: 'active', + policy: 'allow', + }); + + // Member should be found for 'self' + const selfMember = findMember({ + assistantId: 'self', + sourceChannel: 'telegram', + externalUserId: 'user-cross-test', + }); + expect(selfMember).not.toBeNull(); + expect(selfMember!.status).toBe('active'); + + // Member should NOT be found for a different assistant + const otherMember = findMember({ + assistantId: 'other-assistant', + sourceChannel: 'telegram', + externalUserId: 'user-cross-test', + }); + expect(otherMember).toBeNull(); + }); + + test('re-verification of previously revoked member reactivates them', () => { + // Create and activate a member + const member = upsertMember({ + assistantId: 'self', + sourceChannel: 'telegram', + externalUserId: 'user-revoked', + externalChatId: 'chat-revoked', + status: 'active', + policy: 'allow', + displayName: 'Revoked User', + }); + + // Revoke the member + const revoked = revokeMember(member.id, 'testing revocation'); + expect(revoked).not.toBeNull(); + expect(revoked!.status).toBe('revoked'); + + // Verify the member is indeed revoked (ACL would reject) + const revokedMember = findMember({ + assistantId: 'self', + sourceChannel: 'telegram', + externalUserId: 'user-revoked', + }); + expect(revokedMember).not.toBeNull(); + expect(revokedMember!.status).toBe('revoked'); + + // Guardian re-approves, new outbound session created + const session = createOutboundSession({ + assistantId: 'self', + channel: 'telegram', + expectedExternalUserId: 'user-revoked', + expectedChatId: 'chat-revoked', + identityBindingStatus: 'bound', + destinationAddress: 'chat-revoked', + }); + + // Requester enters the new code + const result = validateAndConsumeChallenge( + 'self', 'telegram', session.secret, + 'user-revoked', 'chat-revoked', + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.verificationType).toBe('trusted_contact'); + } + + // upsertMember reactivates the existing record + upsertMember({ + assistantId: 'self', + sourceChannel: 'telegram', + externalUserId: 'user-revoked', + externalChatId: 'chat-revoked', + status: 'active', + policy: 'allow', + }); + + // Verify: member is now active again + const reactivated = findMember({ + assistantId: 'self', + sourceChannel: 'telegram', + externalUserId: 'user-revoked', + }); + expect(reactivated).not.toBeNull(); + expect(reactivated!.status).toBe('active'); + expect(reactivated!.policy).toBe('allow'); + }); + + test('trusted contact verification does NOT create a guardian binding', () => { + // Ensure there's an existing guardian binding we want to preserve + createBinding({ + assistantId: 'self', + channel: 'telegram', + guardianExternalUserId: 'guardian-user-original', + guardianDeliveryChatId: 'guardian-chat-original', + verifiedVia: 'challenge', + metadataJson: null, + }); + + // Create an outbound session for a requester (different user than guardian) + const session = createOutboundSession({ + assistantId: 'self', + channel: 'telegram', + expectedExternalUserId: 'requester-user-789', + expectedChatId: 'requester-chat-789', + identityBindingStatus: 'bound', + destinationAddress: 'requester-chat-789', + }); + + const result = validateAndConsumeChallenge( + 'self', 'telegram', session.secret, + 'requester-user-789', 'requester-chat-789', + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.verificationType).toBe('trusted_contact'); + // Should NOT have a bindingId — no guardian binding created + expect('bindingId' in result).toBe(false); + } + + // The original guardian binding should remain intact + const binding = getActiveBinding('self', 'telegram'); + expect(binding).not.toBeNull(); + expect(binding!.guardianExternalUserId).toBe('guardian-user-original'); + }); + + test('guardian inbound verification still creates binding (backward compat)', () => { + // Create an inbound challenge (no expected identity — guardian flow) + const { createVerificationChallenge } = require('../runtime/channel-guardian-service.js'); + const { secret } = createVerificationChallenge('self', 'telegram'); + + const result = validateAndConsumeChallenge( + 'self', 'telegram', secret, + 'guardian-user', 'guardian-chat', + ); + + expect(result.success).toBe(true); + if (result.success && result.verificationType === 'guardian') { + expect(result.bindingId).toBeDefined(); + } + + // Guardian binding should be created + const binding = getActiveBinding('self', 'telegram'); + expect(binding).not.toBeNull(); + expect(binding!.guardianExternalUserId).toBe('guardian-user'); + }); +}); diff --git a/assistant/src/calls/relay-server.ts b/assistant/src/calls/relay-server.ts index f33c868d578..1e667ada6ab 100644 --- a/assistant/src/calls/relay-server.ts +++ b/assistant/src/calls/relay-server.ts @@ -675,7 +675,7 @@ export class RelayConnection { : 'guardian_voice_verification_succeeded'; recordCallEvent(this.callSessionId, eventName, { - bindingId: result.bindingId, + bindingId: 'bindingId' in result ? result.bindingId : undefined, }); log.info( { callSessionId: this.callSessionId, isOutbound }, diff --git a/assistant/src/runtime/channel-guardian-service.ts b/assistant/src/runtime/channel-guardian-service.ts index 1ac4bbc59bb..40eb118dcfe 100644 --- a/assistant/src/runtime/channel-guardian-service.ts +++ b/assistant/src/runtime/channel-guardian-service.ts @@ -62,7 +62,8 @@ export interface CreateChallengeResult { } export type ValidateChallengeResult = - | { success: true; bindingId: string } + | { success: true; bindingId: string; verificationType: 'guardian' } + | { success: true; verificationType: 'trusted_contact' } | { success: false; reason: string }; // --------------------------------------------------------------------------- @@ -272,6 +273,14 @@ export function validateAndConsumeChallenge( // Reset the rate-limit counter on success resetRateLimit(assistantId, channel, actorExternalUserId, actorChatId); + // Identity-bound outbound sessions (from the trusted contact access flow) + // should NOT create a guardian binding — the requester is becoming a trusted + // contact, not a guardian. Only unbound inbound challenges (guardian + // verification) create guardian bindings. + if (hasExpectedIdentity && challenge.identityBindingStatus === 'bound') { + return { success: true, verificationType: 'trusted_contact' }; + } + // Reject if a different user already holds the guardian binding const existingBinding = getActiveBinding(assistantId, channel); if (existingBinding && existingBinding.guardianExternalUserId !== actorExternalUserId) { @@ -302,7 +311,7 @@ export function validateAndConsumeChallenge( metadataJson: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null, }); - return { success: true, bindingId: binding.id }; + return { success: true, bindingId: binding.id, verificationType: 'guardian' }; } /** diff --git a/assistant/src/runtime/guardian-verification-templates.ts b/assistant/src/runtime/guardian-verification-templates.ts index badb945e9f5..da4f897ec03 100644 --- a/assistant/src/runtime/guardian-verification-templates.ts +++ b/assistant/src/runtime/guardian-verification-templates.ts @@ -35,6 +35,8 @@ export const GUARDIAN_VERIFY_TEMPLATE_KEYS = { CHANNEL_VERIFY_FAILED: 'guardian_verify.channel.failed', /** Deterministic reply for bootstrap deep-link success. */ CHANNEL_BOOTSTRAP_BOUND: 'guardian_verify.channel.bootstrap_bound', + /** Deterministic reply after successful trusted contact verification. */ + CHANNEL_TRUSTED_CONTACT_VERIFY_SUCCESS: 'guardian_verify.channel.trusted_contact_success', } as const; export type GuardianVerifyTemplateKey = @@ -52,7 +54,8 @@ type TextVerifyTemplateKey = export type ChannelVerifyReplyTemplateKey = | typeof GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_VERIFY_SUCCESS | typeof GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_VERIFY_FAILED - | typeof GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_BOOTSTRAP_BOUND; + | typeof GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_BOOTSTRAP_BOUND + | typeof GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_TRUSTED_CONTACT_VERIFY_SUCCESS; // --------------------------------------------------------------------------- // Template Variables @@ -173,6 +176,9 @@ const channelVerifyReplyTemplates: Record 'Welcome! Your identity has been linked. Please check for a verification code message.', + + [GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_TRUSTED_CONTACT_VERIFY_SUCCESS]: () => + 'Verification successful! You can now message the assistant.', }; /** diff --git a/assistant/src/runtime/routes/inbound-message-handler.ts b/assistant/src/runtime/routes/inbound-message-handler.ts index 34564735404..ba370b10bdd 100644 --- a/assistant/src/runtime/routes/inbound-message-handler.ts +++ b/assistant/src/runtime/routes/inbound-message-handler.ts @@ -643,17 +643,26 @@ export async function handleChannelInbound( displayName: body.senderName, username: body.senderUsername, }); - log.info({ sourceChannel, externalUserId: body.senderExternalUserId }, 'Guardian verified: auto-upserted ingress member'); + + const verifyLogLabel = verifyResult.verificationType === 'trusted_contact' + ? 'Trusted contact verified' + : 'Guardian verified'; + log.info({ sourceChannel, externalUserId: body.senderExternalUserId, verificationType: verifyResult.verificationType }, `${verifyLogLabel}: auto-upserted ingress member`); } // Deliver a deterministic template-driven reply and short-circuit. // Verification code messages must never produce agent-generated copy. if (replyCallbackUrl) { - const replyText = verifyResult.success - ? composeChannelVerifyReply(GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_VERIFY_SUCCESS) - : composeChannelVerifyReply(GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_VERIFY_FAILED, { - failureReason: stripVerificationFailurePrefix(verifyResult.reason), - }); + let replyText: string; + if (!verifyResult.success) { + replyText = composeChannelVerifyReply(GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_VERIFY_FAILED, { + failureReason: stripVerificationFailurePrefix(verifyResult.reason), + }); + } else if (verifyResult.verificationType === 'trusted_contact') { + replyText = composeChannelVerifyReply(GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_TRUSTED_CONTACT_VERIFY_SUCCESS); + } else { + replyText = composeChannelVerifyReply(GUARDIAN_VERIFY_TEMPLATE_KEYS.CHANNEL_VERIFY_SUCCESS); + } try { await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, From b5b8d82af0a1c786e7415ac42723debbe4493d14 Mon Sep 17 00:00:00 2001 From: NgoHarrison Date: Thu, 26 Feb 2026 00:35:06 -0500 Subject: [PATCH 05/14] feat: add HTTP routes for ingress member/invite management (#9475) Co-authored-by: Harrison Ngo Co-authored-by: Claude --- .../src/__tests__/ingress-routes-http.test.ts | 443 ++++++++++++++++++ assistant/src/daemon/handlers/config-inbox.ts | 206 ++------ assistant/src/runtime/http-server.ts | 25 + assistant/src/runtime/ingress-service.ts | 237 ++++++++++ .../src/runtime/routes/ingress-routes.ts | 174 +++++++ 5 files changed, 930 insertions(+), 155 deletions(-) create mode 100644 assistant/src/__tests__/ingress-routes-http.test.ts create mode 100644 assistant/src/runtime/ingress-service.ts create mode 100644 assistant/src/runtime/routes/ingress-routes.ts diff --git a/assistant/src/__tests__/ingress-routes-http.test.ts b/assistant/src/__tests__/ingress-routes-http.test.ts new file mode 100644 index 00000000000..082768f6672 --- /dev/null +++ b/assistant/src/__tests__/ingress-routes-http.test.ts @@ -0,0 +1,443 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'; + +const testDir = mkdtempSync(join(tmpdir(), 'ingress-routes-http-test-')); + +mock.module('../util/platform.js', () => ({ + getDataDir: () => testDir, + isMacOS: () => process.platform === 'darwin', + isLinux: () => process.platform === 'linux', + isWindows: () => process.platform === 'win32', + getSocketPath: () => join(testDir, 'test.sock'), + getPidPath: () => join(testDir, 'test.pid'), + getDbPath: () => join(testDir, 'test.db'), + getLogPath: () => join(testDir, 'test.log'), + ensureDataDir: () => {}, +})); + +mock.module('../util/logger.js', () => ({ + getLogger: () => new Proxy({} as Record, { + get: () => () => {}, + }), +})); + +import { getSqlite, initializeDb, resetDb } from '../memory/db.js'; +import { + handleBlockMember, + handleCreateInvite, + handleListInvites, + handleListMembers, + handleRedeemInvite, + handleRevokeInvite, + handleRevokeMember, + handleUpsertMember, +} from '../runtime/routes/ingress-routes.js'; + +initializeDb(); + +afterAll(() => { + resetDb(); + try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ } +}); + +function resetTables() { + getSqlite().run('DELETE FROM assistant_ingress_members'); + getSqlite().run('DELETE FROM assistant_ingress_invites'); +} + +// --------------------------------------------------------------------------- +// Member routes +// --------------------------------------------------------------------------- + +describe('ingress member HTTP routes', () => { + beforeEach(resetTables); + + test('POST /v1/ingress/members — upsert creates a member', async () => { + const req = new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceChannel: 'telegram', + externalUserId: 'user-1', + displayName: 'Test User', + policy: 'allow', + status: 'active', + }), + }); + + const res = await handleUpsertMember(req); + const body = await res.json() as Record; + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(body.member).toBeDefined(); + const member = body.member as Record; + expect(member.sourceChannel).toBe('telegram'); + expect(member.externalUserId).toBe('user-1'); + expect(member.displayName).toBe('Test User'); + expect(member.policy).toBe('allow'); + expect(member.status).toBe('active'); + }); + + test('POST /v1/ingress/members — missing sourceChannel returns 400', async () => { + const req = new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + externalUserId: 'user-1', + }), + }); + + const res = await handleUpsertMember(req); + const body = await res.json() as Record; + + expect(res.status).toBe(400); + expect(body.ok).toBe(false); + expect(body.error).toContain('sourceChannel'); + }); + + test('POST /v1/ingress/members — missing identity returns 400', async () => { + const req = new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceChannel: 'telegram', + }), + }); + + const res = await handleUpsertMember(req); + const body = await res.json() as Record; + + expect(res.status).toBe(400); + expect(body.ok).toBe(false); + expect(body.error).toContain('externalUserId'); + }); + + test('GET /v1/ingress/members — lists members', async () => { + // Create two members + await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram', externalUserId: 'user-1', status: 'active' }), + })); + await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram', externalUserId: 'user-2', status: 'active' }), + })); + + const url = new URL('http://localhost/v1/ingress/members'); + const res = handleListMembers(url); + const body = await res.json() as Record; + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(Array.isArray(body.members)).toBe(true); + expect((body.members as unknown[]).length).toBe(2); + }); + + test('GET /v1/ingress/members — filters by sourceChannel', async () => { + await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram', externalUserId: 'user-1', status: 'active' }), + })); + await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'sms', externalUserId: 'user-2', status: 'active' }), + })); + + const url = new URL('http://localhost/v1/ingress/members?sourceChannel=telegram'); + const res = handleListMembers(url); + const body = await res.json() as Record; + + expect((body.members as unknown[]).length).toBe(1); + }); + + test('DELETE /v1/ingress/members/:id — revokes a member', async () => { + const createRes = await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram', externalUserId: 'user-1', status: 'active' }), + })); + const created = await createRes.json() as { member: { id: string } }; + + const req = new Request('http://localhost/v1/ingress/members/' + created.member.id, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: 'test revoke' }), + }); + const res = await handleRevokeMember(req, created.member.id); + const body = await res.json() as Record; + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + const member = body.member as Record; + expect(member.status).toBe('revoked'); + }); + + test('DELETE /v1/ingress/members/:id — not found returns 404', async () => { + const req = new Request('http://localhost/v1/ingress/members/nonexistent', { + method: 'DELETE', + }); + const res = await handleRevokeMember(req, 'nonexistent'); + const body = await res.json() as Record; + + expect(res.status).toBe(404); + expect(body.ok).toBe(false); + }); + + test('POST /v1/ingress/members/:id/block — blocks a member', async () => { + const createRes = await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram', externalUserId: 'user-1', status: 'active' }), + })); + const created = await createRes.json() as { member: { id: string } }; + + const req = new Request('http://localhost/v1/ingress/members/' + created.member.id + '/block', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: 'spam' }), + }); + const res = await handleBlockMember(req, created.member.id); + const body = await res.json() as Record; + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + const member = body.member as Record; + expect(member.status).toBe('blocked'); + }); + + test('POST /v1/ingress/members/:id/block — already blocked returns 404', async () => { + const createRes = await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram', externalUserId: 'user-1', status: 'active' }), + })); + const created = await createRes.json() as { member: { id: string } }; + + // Block first time + await handleBlockMember( + new Request('http://localhost/block', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }), + created.member.id, + ); + + // Block second time + const req = new Request('http://localhost/block', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const res = await handleBlockMember(req, created.member.id); + const body = await res.json() as Record; + + expect(res.status).toBe(404); + expect(body.ok).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Invite routes +// --------------------------------------------------------------------------- + +describe('ingress invite HTTP routes', () => { + beforeEach(resetTables); + + test('POST /v1/ingress/invites — creates an invite', async () => { + const req = new Request('http://localhost/v1/ingress/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceChannel: 'telegram', + note: 'Test invite', + maxUses: 5, + }), + }); + + const res = await handleCreateInvite(req); + const body = await res.json() as Record; + + expect(res.status).toBe(201); + expect(body.ok).toBe(true); + const invite = body.invite as Record; + expect(invite.sourceChannel).toBe('telegram'); + expect(invite.note).toBe('Test invite'); + expect(invite.maxUses).toBe(5); + expect(invite.status).toBe('active'); + // Raw token should be returned on create + expect(typeof invite.token).toBe('string'); + expect((invite.token as string).length).toBeGreaterThan(0); + }); + + test('POST /v1/ingress/invites — missing sourceChannel returns 400', async () => { + const req = new Request('http://localhost/v1/ingress/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ note: 'No channel' }), + }); + + const res = await handleCreateInvite(req); + const body = await res.json() as Record; + + expect(res.status).toBe(400); + expect(body.ok).toBe(false); + expect(body.error).toContain('sourceChannel'); + }); + + test('GET /v1/ingress/invites — lists invites', async () => { + // Create two invites + await handleCreateInvite(new Request('http://localhost/v1/ingress/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram' }), + })); + await handleCreateInvite(new Request('http://localhost/v1/ingress/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram' }), + })); + + const url = new URL('http://localhost/v1/ingress/invites'); + const res = handleListInvites(url); + const body = await res.json() as Record; + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + expect(Array.isArray(body.invites)).toBe(true); + expect((body.invites as unknown[]).length).toBe(2); + }); + + test('DELETE /v1/ingress/invites/:id — revokes an invite', async () => { + const createRes = await handleCreateInvite(new Request('http://localhost/v1/ingress/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram' }), + })); + const created = await createRes.json() as { invite: { id: string } }; + + const res = handleRevokeInvite(created.invite.id); + const body = await res.json() as Record; + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + const invite = body.invite as Record; + expect(invite.status).toBe('revoked'); + }); + + test('DELETE /v1/ingress/invites/:id — not found returns 404', () => { + const res = handleRevokeInvite('nonexistent-id'); + expect(res.status).toBe(404); + }); + + test('POST /v1/ingress/invites/redeem — redeems an invite', async () => { + // Create an invite first + const createRes = await handleCreateInvite(new Request('http://localhost/v1/ingress/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram', maxUses: 1 }), + })); + const created = await createRes.json() as { invite: { token: string } }; + + const req = new Request('http://localhost/v1/ingress/invites/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: created.invite.token, + externalUserId: 'redeemer-1', + sourceChannel: 'telegram', + }), + }); + + const res = await handleRedeemInvite(req); + const body = await res.json() as Record; + + expect(res.status).toBe(200); + expect(body.ok).toBe(true); + const invite = body.invite as Record; + expect(invite.useCount).toBe(1); + // Single-use invite should be fully redeemed + expect(invite.status).toBe('redeemed'); + }); + + test('POST /v1/ingress/invites/redeem — missing token returns 400', async () => { + const req = new Request('http://localhost/v1/ingress/invites/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ externalUserId: 'redeemer-1' }), + }); + + const res = await handleRedeemInvite(req); + const body = await res.json() as Record; + + expect(res.status).toBe(400); + expect(body.ok).toBe(false); + expect(body.error).toContain('token'); + }); + + test('POST /v1/ingress/invites/redeem — invalid token returns 400', async () => { + const req = new Request('http://localhost/v1/ingress/invites/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: 'invalid-token' }), + }); + + const res = await handleRedeemInvite(req); + const body = await res.json() as Record; + + expect(res.status).toBe(400); + expect(body.ok).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// IPC backward compatibility — shared logic produces same results +// --------------------------------------------------------------------------- + +describe('ingress service shared logic', () => { + beforeEach(resetTables); + + test('member upsert + list round-trip through shared service', async () => { + const createRes = await handleUpsertMember(new Request('http://localhost/v1/ingress/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceChannel: 'telegram', + externalUserId: 'user-rt', + displayName: 'Round Trip', + policy: 'allow', + status: 'active', + }), + })); + const created = await createRes.json() as { member: { id: string; displayName: string } }; + expect(created.member.displayName).toBe('Round Trip'); + + const listRes = handleListMembers(new URL('http://localhost/v1/ingress/members')); + const listed = await listRes.json() as { members: Array<{ id: string; displayName: string }> }; + expect(listed.members.length).toBe(1); + expect(listed.members[0].id).toBe(created.member.id); + }); + + test('invite create + revoke round-trip through shared service', async () => { + const createRes = await handleCreateInvite(new Request('http://localhost/v1/ingress/invites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceChannel: 'telegram' }), + })); + const created = await createRes.json() as { invite: { id: string; status: string } }; + expect(created.invite.status).toBe('active'); + + const revokeRes = handleRevokeInvite(created.invite.id); + const revoked = await revokeRes.json() as { invite: { id: string; status: string } }; + expect(revoked.invite.status).toBe('revoked'); + expect(revoked.invite.id).toBe(created.invite.id); + }); +}); diff --git a/assistant/src/daemon/handlers/config-inbox.ts b/assistant/src/daemon/handlers/config-inbox.ts index ed39e1d2fd2..3dbc97d6c13 100644 --- a/assistant/src/daemon/handlers/config-inbox.ts +++ b/assistant/src/daemon/handlers/config-inbox.ts @@ -12,21 +12,17 @@ import { } from '../../memory/channel-guardian-store.js'; import { addMessage, getMessages } from '../../memory/conversation-store.js'; import { getBindingByConversation } from '../../memory/external-conversation-store.js'; -import { - createInvite, - type InviteStatus, - listInvites, - redeemInvite, - revokeInvite, -} from '../../memory/ingress-invite-store.js'; -import { - blockMember, - type IngressMember, - listMembers, - revokeMember, - upsertMember, -} from '../../memory/ingress-member-store.js'; import { deliverChannelReply } from '../../runtime/gateway-client.js'; +import { + blockIngressMember, + createIngressInvite, + listIngressInvites, + listIngressMembers, + redeemIngressInvite, + revokeIngressInvite, + revokeIngressMember, + upsertIngressMember, +} from '../../runtime/ingress-service.js'; import type { AssistantInboxEscalationRequest, IngressInviteRequest, IngressMemberRequest } from '../ipc-protocol.js'; import { defineHandlers, type HandlerContext, log } from './shared.js'; import { renderHistoryContent } from './shared.js'; @@ -39,116 +35,55 @@ export function handleIngressInvite( try { switch (msg.action) { case 'create': { - if (!msg.sourceChannel) { - ctx.send(socket, { type: 'ingress_invite_response', success: false, error: 'sourceChannel is required for create' }); - return; - } - const { invite, rawToken } = createInvite({ + const result = createIngressInvite({ sourceChannel: msg.sourceChannel, note: msg.note, maxUses: msg.maxUses, expiresInMs: msg.expiresInMs, }); - ctx.send(socket, { - type: 'ingress_invite_response', - success: true, - invite: { - id: invite.id, - sourceChannel: invite.sourceChannel, - token: rawToken, - tokenHash: invite.tokenHash, - maxUses: invite.maxUses, - useCount: invite.useCount, - expiresAt: invite.expiresAt, - status: invite.status, - note: invite.note ?? undefined, - createdAt: invite.createdAt, - }, - }); + if (!result.ok) { + ctx.send(socket, { type: 'ingress_invite_response', success: false, error: result.error }); + return; + } + ctx.send(socket, { type: 'ingress_invite_response', success: true, invite: result.data }); return; } case 'list': { - const invites = listInvites({ + const result = listIngressInvites({ sourceChannel: msg.sourceChannel, - status: msg.status as InviteStatus | undefined, - }); - ctx.send(socket, { - type: 'ingress_invite_response', - success: true, - invites: invites.map((inv) => ({ - id: inv.id, - sourceChannel: inv.sourceChannel, - tokenHash: inv.tokenHash, - maxUses: inv.maxUses, - useCount: inv.useCount, - expiresAt: inv.expiresAt, - status: inv.status, - note: inv.note ?? undefined, - createdAt: inv.createdAt, - })), + status: msg.status, }); + if (!result.ok) { + ctx.send(socket, { type: 'ingress_invite_response', success: false, error: result.error }); + return; + } + ctx.send(socket, { type: 'ingress_invite_response', success: true, invites: result.data }); return; } case 'revoke': { - if (!msg.inviteId) { - ctx.send(socket, { type: 'ingress_invite_response', success: false, error: 'inviteId is required for revoke' }); - return; - } - const revoked = revokeInvite(msg.inviteId); - if (!revoked) { - ctx.send(socket, { type: 'ingress_invite_response', success: false, error: 'Invite not found or already revoked' }); + const result = revokeIngressInvite(msg.inviteId); + if (!result.ok) { + ctx.send(socket, { type: 'ingress_invite_response', success: false, error: result.error }); return; } - ctx.send(socket, { - type: 'ingress_invite_response', - success: true, - invite: { - id: revoked.id, - sourceChannel: revoked.sourceChannel, - tokenHash: revoked.tokenHash, - maxUses: revoked.maxUses, - useCount: revoked.useCount, - expiresAt: revoked.expiresAt, - status: revoked.status, - note: revoked.note ?? undefined, - createdAt: revoked.createdAt, - }, - }); + ctx.send(socket, { type: 'ingress_invite_response', success: true, invite: result.data }); return; } case 'redeem': { - if (!msg.token) { - ctx.send(socket, { type: 'ingress_invite_response', success: false, error: 'token is required for redeem' }); - return; - } - const result = redeemInvite({ - rawToken: msg.token, + const result = redeemIngressInvite({ + token: msg.token, externalUserId: msg.externalUserId, externalChatId: msg.externalChatId, sourceChannel: msg.sourceChannel, }); - if ('error' in result) { + if (!result.ok) { ctx.send(socket, { type: 'ingress_invite_response', success: false, error: result.error }); return; } - ctx.send(socket, { - type: 'ingress_invite_response', - success: true, - invite: { - id: result.invite.id, - sourceChannel: result.invite.sourceChannel, - tokenHash: result.invite.tokenHash, - maxUses: result.invite.maxUses, - useCount: result.invite.useCount, - expiresAt: result.invite.expiresAt, - status: result.invite.status, - note: result.invite.note ?? undefined, - createdAt: result.invite.createdAt, - }, - }); + ctx.send(socket, { type: 'ingress_invite_response', success: true, invite: result.data }); return; } @@ -163,21 +98,6 @@ export function handleIngressInvite( } } -function memberToResponse(m: IngressMember) { - return { - id: m.id, - sourceChannel: m.sourceChannel, - externalUserId: m.externalUserId ?? undefined, - externalChatId: m.externalChatId ?? undefined, - displayName: m.displayName ?? undefined, - username: m.username ?? undefined, - status: m.status, - policy: m.policy, - lastSeenAt: m.lastSeenAt ?? undefined, - createdAt: m.createdAt, - }; -} - export function handleIngressMember( msg: IngressMemberRequest, socket: net.Socket, @@ -186,30 +106,22 @@ export function handleIngressMember( try { switch (msg.action) { case 'list': { - const members = listMembers({ + const result = listIngressMembers({ assistantId: msg.assistantId, sourceChannel: msg.sourceChannel, status: msg.status, policy: msg.policy, }); - ctx.send(socket, { - type: 'ingress_member_response', - success: true, - members: members.map(memberToResponse), - }); + if (!result.ok) { + ctx.send(socket, { type: 'ingress_member_response', success: false, error: result.error }); + return; + } + ctx.send(socket, { type: 'ingress_member_response', success: true, members: result.data }); return; } case 'upsert': { - if (!msg.sourceChannel) { - ctx.send(socket, { type: 'ingress_member_response', success: false, error: 'sourceChannel is required for upsert' }); - return; - } - if (!msg.externalUserId && !msg.externalChatId) { - ctx.send(socket, { type: 'ingress_member_response', success: false, error: 'At least one of externalUserId or externalChatId is required for upsert' }); - return; - } - const member = upsertMember({ + const result = upsertIngressMember({ assistantId: msg.assistantId, sourceChannel: msg.sourceChannel, externalUserId: msg.externalUserId, @@ -219,47 +131,31 @@ export function handleIngressMember( policy: msg.policy, status: msg.status, }); - ctx.send(socket, { - type: 'ingress_member_response', - success: true, - member: memberToResponse(member), - }); + if (!result.ok) { + ctx.send(socket, { type: 'ingress_member_response', success: false, error: result.error }); + return; + } + ctx.send(socket, { type: 'ingress_member_response', success: true, member: result.data }); return; } case 'revoke': { - if (!msg.memberId) { - ctx.send(socket, { type: 'ingress_member_response', success: false, error: 'memberId is required for revoke' }); + const result = revokeIngressMember(msg.memberId, msg.reason); + if (!result.ok) { + ctx.send(socket, { type: 'ingress_member_response', success: false, error: result.error }); return; } - const revoked = revokeMember(msg.memberId, msg.reason); - if (!revoked) { - ctx.send(socket, { type: 'ingress_member_response', success: false, error: 'Member not found or cannot be revoked' }); - return; - } - ctx.send(socket, { - type: 'ingress_member_response', - success: true, - member: memberToResponse(revoked), - }); + ctx.send(socket, { type: 'ingress_member_response', success: true, member: result.data }); return; } case 'block': { - if (!msg.memberId) { - ctx.send(socket, { type: 'ingress_member_response', success: false, error: 'memberId is required for block' }); - return; - } - const blocked = blockMember(msg.memberId, msg.reason); - if (!blocked) { - ctx.send(socket, { type: 'ingress_member_response', success: false, error: 'Member not found or already blocked' }); + const result = blockIngressMember(msg.memberId, msg.reason); + if (!result.ok) { + ctx.send(socket, { type: 'ingress_member_response', success: false, error: result.error }); return; } - ctx.send(socket, { - type: 'ingress_member_response', - success: true, - member: memberToResponse(blocked), - }); + ctx.send(socket, { type: 'ingress_member_response', success: true, member: result.data }); return; } diff --git a/assistant/src/runtime/http-server.ts b/assistant/src/runtime/http-server.ts index 7c990bc9829..4401b9e3b34 100644 --- a/assistant/src/runtime/http-server.ts +++ b/assistant/src/runtime/http-server.ts @@ -124,6 +124,16 @@ import { handlePairingRequest, handlePairingStatus, } from './routes/pairing-routes.js'; +import { + handleBlockMember, + handleCreateInvite, + handleListInvites, + handleListMembers, + handleRedeemInvite, + handleRevokeInvite, + handleRevokeMember, + handleUpsertMember, +} from './routes/ingress-routes.js'; import { handleAddSecret } from './routes/secret-routes.js'; // Re-export for consumers @@ -631,6 +641,21 @@ export class RuntimeHttpServer { const contactMatch = endpoint.match(/^contacts\/([^/]+)$/); if (contactMatch && req.method === 'GET') return handleGetContact(contactMatch[1]); + // Ingress members + if (endpoint === 'ingress/members' && req.method === 'GET') return handleListMembers(url); + if (endpoint === 'ingress/members' && req.method === 'POST') return await handleUpsertMember(req); + const memberBlockMatch = endpoint.match(/^ingress\/members\/([^/]+)\/block$/); + if (memberBlockMatch && req.method === 'POST') return await handleBlockMember(req, memberBlockMatch[1]); + const memberMatch = endpoint.match(/^ingress\/members\/([^/]+)$/); + if (memberMatch && req.method === 'DELETE') return await handleRevokeMember(req, memberMatch[1]); + + // Ingress invites + if (endpoint === 'ingress/invites' && req.method === 'GET') return handleListInvites(url); + if (endpoint === 'ingress/invites' && req.method === 'POST') return await handleCreateInvite(req); + if (endpoint === 'ingress/invites/redeem' && req.method === 'POST') return await handleRedeemInvite(req); + const inviteMatch = endpoint.match(/^ingress\/invites\/([^/]+)$/); + if (inviteMatch && req.method === 'DELETE') return handleRevokeInvite(inviteMatch[1]); + // Integrations — Telegram config if (endpoint === 'integrations/telegram/config' && req.method === 'GET') return handleGetTelegramConfig(); if (endpoint === 'integrations/telegram/config' && req.method === 'POST') return await handleSetTelegramConfig(req); diff --git a/assistant/src/runtime/ingress-service.ts b/assistant/src/runtime/ingress-service.ts new file mode 100644 index 00000000000..77b5cd870ff --- /dev/null +++ b/assistant/src/runtime/ingress-service.ts @@ -0,0 +1,237 @@ +/** + * Shared business logic for ingress member and invite management. + * + * Extracted from the IPC handlers in daemon/handlers/config-inbox.ts so that + * both the HTTP routes and the IPC handlers call the same logic. + */ + +import { + createInvite, + type IngressInvite, + type InviteStatus, + listInvites, + redeemInvite, + revokeInvite, +} from '../memory/ingress-invite-store.js'; +import { + blockMember, + type IngressMember, + listMembers, + type MemberPolicy, + type MemberStatus, + revokeMember, + upsertMember, +} from '../memory/ingress-member-store.js'; + +// --------------------------------------------------------------------------- +// Response shapes — used by both HTTP routes and IPC handlers +// --------------------------------------------------------------------------- + +export interface InviteResponseData { + id: string; + sourceChannel: string; + token?: string; + tokenHash: string; + maxUses: number; + useCount: number; + expiresAt: number | null; + status: string; + note?: string; + createdAt: number; +} + +export interface MemberResponseData { + id: string; + sourceChannel: string; + externalUserId?: string; + externalChatId?: string; + displayName?: string; + username?: string; + status: string; + policy: string; + lastSeenAt?: number; + createdAt: number; +} + +// --------------------------------------------------------------------------- +// Mappers +// --------------------------------------------------------------------------- + +function inviteToResponse(inv: IngressInvite, rawToken?: string): InviteResponseData { + return { + id: inv.id, + sourceChannel: inv.sourceChannel, + ...(rawToken ? { token: rawToken } : {}), + tokenHash: inv.tokenHash, + maxUses: inv.maxUses, + useCount: inv.useCount, + expiresAt: inv.expiresAt, + status: inv.status, + note: inv.note ?? undefined, + createdAt: inv.createdAt, + }; +} + +export function memberToResponse(m: IngressMember): MemberResponseData { + return { + id: m.id, + sourceChannel: m.sourceChannel, + externalUserId: m.externalUserId ?? undefined, + externalChatId: m.externalChatId ?? undefined, + displayName: m.displayName ?? undefined, + username: m.username ?? undefined, + status: m.status, + policy: m.policy, + lastSeenAt: m.lastSeenAt ?? undefined, + createdAt: m.createdAt, + }; +} + +// --------------------------------------------------------------------------- +// Result types +// --------------------------------------------------------------------------- + +export type IngressResult = + | { ok: true; data: T } + | { ok: false; error: string }; + +// --------------------------------------------------------------------------- +// Invite operations +// --------------------------------------------------------------------------- + +export function createIngressInvite(params: { + sourceChannel?: string; + note?: string; + maxUses?: number; + expiresInMs?: number; +}): IngressResult { + if (!params.sourceChannel) { + return { ok: false, error: 'sourceChannel is required for create' }; + } + const { invite, rawToken } = createInvite({ + sourceChannel: params.sourceChannel, + note: params.note, + maxUses: params.maxUses, + expiresInMs: params.expiresInMs, + }); + return { ok: true, data: inviteToResponse(invite, rawToken) }; +} + +export function listIngressInvites(params: { + sourceChannel?: string; + status?: string; +}): IngressResult { + const invites = listInvites({ + sourceChannel: params.sourceChannel, + status: params.status as InviteStatus | undefined, + }); + return { + ok: true, + data: invites.map((inv) => inviteToResponse(inv)), + }; +} + +export function revokeIngressInvite(inviteId?: string): IngressResult { + if (!inviteId) { + return { ok: false, error: 'inviteId is required for revoke' }; + } + const revoked = revokeInvite(inviteId); + if (!revoked) { + return { ok: false, error: 'Invite not found or already revoked' }; + } + return { ok: true, data: inviteToResponse(revoked) }; +} + +export function redeemIngressInvite(params: { + token?: string; + externalUserId?: string; + externalChatId?: string; + sourceChannel?: string; +}): IngressResult { + if (!params.token) { + return { ok: false, error: 'token is required for redeem' }; + } + const result = redeemInvite({ + rawToken: params.token, + externalUserId: params.externalUserId, + externalChatId: params.externalChatId, + sourceChannel: params.sourceChannel, + }); + if ('error' in result) { + return { ok: false, error: result.error }; + } + return { ok: true, data: inviteToResponse(result.invite) }; +} + +// --------------------------------------------------------------------------- +// Member operations +// --------------------------------------------------------------------------- + +export function listIngressMembers(params: { + assistantId?: string; + sourceChannel?: string; + status?: string; + policy?: string; +}): IngressResult { + const members = listMembers({ + assistantId: params.assistantId, + sourceChannel: params.sourceChannel, + status: params.status as MemberStatus | undefined, + policy: params.policy as MemberPolicy | undefined, + }); + return { + ok: true, + data: members.map(memberToResponse), + }; +} + +export function upsertIngressMember(params: { + sourceChannel?: string; + externalUserId?: string; + externalChatId?: string; + displayName?: string; + username?: string; + policy?: string; + status?: string; + assistantId?: string; +}): IngressResult { + if (!params.sourceChannel) { + return { ok: false, error: 'sourceChannel is required for upsert' }; + } + if (!params.externalUserId && !params.externalChatId) { + return { ok: false, error: 'At least one of externalUserId or externalChatId is required for upsert' }; + } + const member = upsertMember({ + assistantId: params.assistantId, + sourceChannel: params.sourceChannel, + externalUserId: params.externalUserId, + externalChatId: params.externalChatId, + displayName: params.displayName, + username: params.username, + policy: params.policy as MemberPolicy | undefined, + status: params.status as MemberStatus | undefined, + }); + return { ok: true, data: memberToResponse(member) }; +} + +export function revokeIngressMember(memberId?: string, reason?: string): IngressResult { + if (!memberId) { + return { ok: false, error: 'memberId is required for revoke' }; + } + const revoked = revokeMember(memberId, reason); + if (!revoked) { + return { ok: false, error: 'Member not found or cannot be revoked' }; + } + return { ok: true, data: memberToResponse(revoked) }; +} + +export function blockIngressMember(memberId?: string, reason?: string): IngressResult { + if (!memberId) { + return { ok: false, error: 'memberId is required for block' }; + } + const blocked = blockMember(memberId, reason); + if (!blocked) { + return { ok: false, error: 'Member not found or already blocked' }; + } + return { ok: true, data: memberToResponse(blocked) }; +} diff --git a/assistant/src/runtime/routes/ingress-routes.ts b/assistant/src/runtime/routes/ingress-routes.ts new file mode 100644 index 00000000000..214c086065b --- /dev/null +++ b/assistant/src/runtime/routes/ingress-routes.ts @@ -0,0 +1,174 @@ +/** + * Route handlers for ingress member and invite management. + * + * Members: + * GET /v1/ingress/members — list members + * POST /v1/ingress/members — upsert a member + * DELETE /v1/ingress/members/:id — revoke a member + * POST /v1/ingress/members/:id/block — block a member + * + * Invites: + * GET /v1/ingress/invites — list invites + * POST /v1/ingress/invites — create an invite + * DELETE /v1/ingress/invites/:id — revoke an invite + * POST /v1/ingress/invites/redeem — redeem an invite + */ + +import { + blockIngressMember, + createIngressInvite, + listIngressInvites, + listIngressMembers, + redeemIngressInvite, + revokeIngressInvite, + revokeIngressMember, + upsertIngressMember, +} from '../ingress-service.js'; + +// --------------------------------------------------------------------------- +// Members +// --------------------------------------------------------------------------- + +/** + * GET /v1/ingress/members?assistantId=&sourceChannel=&status=&policy= + */ +export function handleListMembers(url: URL): Response { + const result = listIngressMembers({ + assistantId: url.searchParams.get('assistantId') ?? undefined, + sourceChannel: url.searchParams.get('sourceChannel') ?? undefined, + status: url.searchParams.get('status') ?? undefined, + policy: url.searchParams.get('policy') ?? undefined, + }); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 400 }); + } + return Response.json({ ok: true, members: result.data }); +} + +/** + * POST /v1/ingress/members + */ +export async function handleUpsertMember(req: Request): Promise { + const body = (await req.json()) as Record; + + const result = upsertIngressMember({ + sourceChannel: body.sourceChannel as string | undefined, + externalUserId: body.externalUserId as string | undefined, + externalChatId: body.externalChatId as string | undefined, + displayName: body.displayName as string | undefined, + username: body.username as string | undefined, + policy: body.policy as string | undefined, + status: body.status as string | undefined, + assistantId: body.assistantId as string | undefined, + }); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 400 }); + } + return Response.json({ ok: true, member: result.data }); +} + +/** + * DELETE /v1/ingress/members/:id + */ +export async function handleRevokeMember(req: Request, memberId: string): Promise { + let reason: string | undefined; + try { + const body = (await req.json()) as Record; + reason = body.reason as string | undefined; + } catch { + // DELETE may have no body + } + + const result = revokeIngressMember(memberId, reason); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 404 }); + } + return Response.json({ ok: true, member: result.data }); +} + +/** + * POST /v1/ingress/members/:id/block + */ +export async function handleBlockMember(req: Request, memberId: string): Promise { + const body = (await req.json()) as Record; + const reason = body.reason as string | undefined; + + const result = blockIngressMember(memberId, reason); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 404 }); + } + return Response.json({ ok: true, member: result.data }); +} + +// --------------------------------------------------------------------------- +// Invites +// --------------------------------------------------------------------------- + +/** + * GET /v1/ingress/invites?sourceChannel=&status= + */ +export function handleListInvites(url: URL): Response { + const result = listIngressInvites({ + sourceChannel: url.searchParams.get('sourceChannel') ?? undefined, + status: url.searchParams.get('status') ?? undefined, + }); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 400 }); + } + return Response.json({ ok: true, invites: result.data }); +} + +/** + * POST /v1/ingress/invites + */ +export async function handleCreateInvite(req: Request): Promise { + const body = (await req.json()) as Record; + + const result = createIngressInvite({ + sourceChannel: body.sourceChannel as string | undefined, + note: body.note as string | undefined, + maxUses: body.maxUses as number | undefined, + expiresInMs: body.expiresInMs as number | undefined, + }); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 400 }); + } + return Response.json({ ok: true, invite: result.data }, { status: 201 }); +} + +/** + * DELETE /v1/ingress/invites/:id + */ +export function handleRevokeInvite(inviteId: string): Response { + const result = revokeIngressInvite(inviteId); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 404 }); + } + return Response.json({ ok: true, invite: result.data }); +} + +/** + * POST /v1/ingress/invites/redeem + */ +export async function handleRedeemInvite(req: Request): Promise { + const body = (await req.json()) as Record; + + const result = redeemIngressInvite({ + token: body.token as string | undefined, + externalUserId: body.externalUserId as string | undefined, + externalChatId: body.externalChatId as string | undefined, + sourceChannel: body.sourceChannel as string | undefined, + }); + + if (!result.ok) { + return Response.json({ ok: false, error: result.error }, { status: 400 }); + } + return Response.json({ ok: true, invite: result.data }); +} From 9c6ee66b9b48933fdf9c1103bd52beb47f4da4f7 Mon Sep 17 00:00:00 2001 From: NgoHarrison Date: Thu, 26 Feb 2026 00:37:39 -0500 Subject: [PATCH 06/14] feat: add trusted contacts management skill (#9479) Co-authored-by: Harrison Ngo Co-authored-by: Claude --- .../src/config/vellum-skills/catalog.json | 6 + .../vellum-skills/trusted-contacts/SKILL.md | 147 ++++++++++++++++++ skills/catalog.json | 6 + skills/trusted-contacts/SKILL.md | 147 ++++++++++++++++++ 4 files changed, 306 insertions(+) create mode 100644 assistant/src/config/vellum-skills/trusted-contacts/SKILL.md create mode 100644 skills/trusted-contacts/SKILL.md diff --git a/assistant/src/config/vellum-skills/catalog.json b/assistant/src/config/vellum-skills/catalog.json index 353d9ed95da..c561d3a32ee 100644 --- a/assistant/src/config/vellum-skills/catalog.json +++ b/assistant/src/config/vellum-skills/catalog.json @@ -52,6 +52,12 @@ "name": "Guardian Verify Setup", "description": "Set up guardian verification for SMS, voice, or Telegram channels via outbound verification flow", "emoji": "\ud83d\udd10" + }, + { + "id": "trusted-contacts", + "name": "Trusted Contacts", + "description": "Manage trusted contacts \u2014 list, allow, revoke, and block users who can message the assistant through external channels", + "emoji": "\ud83d\udc65" } ] } diff --git a/assistant/src/config/vellum-skills/trusted-contacts/SKILL.md b/assistant/src/config/vellum-skills/trusted-contacts/SKILL.md new file mode 100644 index 00000000000..c7a9aeea50e --- /dev/null +++ b/assistant/src/config/vellum-skills/trusted-contacts/SKILL.md @@ -0,0 +1,147 @@ +--- +name: "Trusted Contacts" +description: "Manage trusted contacts — list, allow, revoke, and block users who can message the assistant through external channels" +user-invocable: true +metadata: {"vellum": {"emoji": "\ud83d\udc65"}} +--- + +You are helping your user manage trusted contacts for the Vellum Assistant. Trusted contacts control who is allowed to send messages to the assistant through external channels like Telegram and SMS. All operations go through the runtime HTTP API using `curl` with bearer auth. + +## Prerequisites + +- The runtime HTTP API is available at `http://localhost:7821` (or the configured `RUNTIME_HTTP_PORT`). +- The bearer token is stored at `~/.vellum/http-token`. Read it with: `TOKEN=$(cat ~/.vellum/http-token)`. + +## Concepts + +- **Member**: A user identity (external user ID or chat ID) from a specific channel that has been registered with a policy. +- **Policy**: Controls what the member can do — `allow` (can message freely) or `deny` (blocked from messaging). +- **Status**: The member's lifecycle state — `active` (currently effective), `revoked` (access removed), or `blocked` (explicitly denied). +- **Source channel**: The messaging platform the contact uses (e.g., `telegram`, `sms`). + +## Available Actions + +### 1. List trusted contacts + +Use this to show the user who currently has access, or to look up a specific contact. + +```bash +TOKEN=$(cat ~/.vellum/http-token) +curl -s http://localhost:7821/v1/ingress/members \ + -H "Authorization: Bearer $TOKEN" +``` + +Optional query parameters for filtering: +- `sourceChannel` — filter by channel (e.g., `telegram`, `sms`) +- `status` — filter by status (`active`, `revoked`, `blocked`) +- `policy` — filter by policy (`allow`, `deny`) + +Example with filters: +```bash +curl -s "http://localhost:7821/v1/ingress/members?sourceChannel=telegram&status=active" \ + -H "Authorization: Bearer $TOKEN" +``` + +The response contains `{ ok: true, members: [...] }` where each member has: +- `id` — unique member ID (needed for revoke/block operations) +- `sourceChannel` — the channel (e.g., `telegram`) +- `externalUserId` — the user's ID on that channel +- `externalChatId` — the chat ID on that channel +- `displayName` — human-readable name +- `username` — channel username (e.g., Telegram @handle) +- `status` — current status +- `policy` — current policy +- `createdAt` — when the member was added + +**Presenting results**: Format the member list as a readable table or list. Include display name (or username/user ID as fallback), channel, status, and policy. If no members exist, tell the user their contact list is empty. + +### 2. Allow a user (add trusted contact) + +Use this when the user wants to grant someone access to message the assistant. **Always confirm with the user before executing this action.** + +Ask the user: *"I'll add [name/identifier] on [channel] as an allowed contact. Should I proceed?"* + +```bash +TOKEN=$(cat ~/.vellum/http-token) +curl -s -X POST http://localhost:7821/v1/ingress/members \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "sourceChannel": "", + "externalUserId": "", + "displayName": "", + "policy": "allow", + "status": "active" + }' +``` + +Required fields: +- `sourceChannel` — the channel (e.g., `telegram`, `sms`) +- At least one of `externalUserId` or `externalChatId` + +Optional fields: +- `displayName` — human-readable name for the contact +- `username` — channel-specific handle (e.g., Telegram @username) + +If the user provides a name but not an external ID, explain that you need the channel-specific user ID or chat ID to create the contact entry. For Telegram, this is a numeric user ID; for SMS, this is the phone number in E.164 format. + +### 3. Revoke a user (remove access) + +Use this when the user wants to remove someone's access. **Always confirm with the user before executing this action.** + +Ask the user: *"I'll revoke access for [name/identifier]. They will no longer be able to message the assistant. Should I proceed?"* + +First, list members to find the member's `id`, then revoke: + +```bash +TOKEN=$(cat ~/.vellum/http-token) +curl -s -X DELETE "http://localhost:7821/v1/ingress/members/" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"reason": ""}' +``` + +Replace `` with the member's `id` from the list response. + +### 4. Block a user + +Use this when the user wants to explicitly block someone. Blocking is stronger than revoking — it marks the contact as actively denied. **Always confirm with the user before executing this action.** + +Ask the user: *"I'll block [name/identifier]. They will be permanently denied from messaging the assistant. Should I proceed?"* + +```bash +TOKEN=$(cat ~/.vellum/http-token) +curl -s -X POST "http://localhost:7821/v1/ingress/members//block" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"reason": ""}' +``` + +## Confirmation Requirements + +**All mutating actions (allow, revoke, block) require explicit user confirmation before execution.** This is a safety measure — modifying who can access the assistant should always be a deliberate choice. + +- Clearly state what action you are about to take and who it affects. +- Wait for the user to confirm before running the curl command. +- Report the result after execution. + +## Error Handling + +- If a request returns `{ ok: false, error: "..." }`, report the error message to the user. +- Common errors: + - `sourceChannel is required` — ask the user which channel the contact is on. + - `At least one of externalUserId or externalChatId is required` — ask the user for the contact's channel-specific identifier. + - `Member not found or cannot be revoked` — the member ID may be invalid or the member is already revoked. + - `Member not found or already blocked` — the member ID may be invalid or the member is already blocked. + +## Typical Workflows + +**"Who can message me?"** — List all active members, present as a formatted list. + +**"Add my friend to Telegram"** — Ask for their Telegram user ID (numeric) and optional display name, confirm, then add with `policy: "allow"` and `status: "active"`. + +**"Remove [name]'s access"** — List members to find them, confirm the revocation, then delete. + +**"Block [name]"** — List members to find them, confirm the block, then execute. + +**"Show me blocked contacts"** — List with `status=blocked` filter. diff --git a/skills/catalog.json b/skills/catalog.json index f7bb33b3749..cc90766668d 100644 --- a/skills/catalog.json +++ b/skills/catalog.json @@ -79,6 +79,12 @@ "name": "Guardian Verify Setup", "description": "Set up guardian verification for SMS, voice, or Telegram channels via outbound verification flow", "emoji": "\ud83d\udd10" + }, + { + "id": "trusted-contacts", + "name": "Trusted Contacts", + "description": "Manage trusted contacts \u2014 list, allow, revoke, and block users who can message the assistant through external channels", + "emoji": "\ud83d\udc65" } ] } diff --git a/skills/trusted-contacts/SKILL.md b/skills/trusted-contacts/SKILL.md new file mode 100644 index 00000000000..c7a9aeea50e --- /dev/null +++ b/skills/trusted-contacts/SKILL.md @@ -0,0 +1,147 @@ +--- +name: "Trusted Contacts" +description: "Manage trusted contacts — list, allow, revoke, and block users who can message the assistant through external channels" +user-invocable: true +metadata: {"vellum": {"emoji": "\ud83d\udc65"}} +--- + +You are helping your user manage trusted contacts for the Vellum Assistant. Trusted contacts control who is allowed to send messages to the assistant through external channels like Telegram and SMS. All operations go through the runtime HTTP API using `curl` with bearer auth. + +## Prerequisites + +- The runtime HTTP API is available at `http://localhost:7821` (or the configured `RUNTIME_HTTP_PORT`). +- The bearer token is stored at `~/.vellum/http-token`. Read it with: `TOKEN=$(cat ~/.vellum/http-token)`. + +## Concepts + +- **Member**: A user identity (external user ID or chat ID) from a specific channel that has been registered with a policy. +- **Policy**: Controls what the member can do — `allow` (can message freely) or `deny` (blocked from messaging). +- **Status**: The member's lifecycle state — `active` (currently effective), `revoked` (access removed), or `blocked` (explicitly denied). +- **Source channel**: The messaging platform the contact uses (e.g., `telegram`, `sms`). + +## Available Actions + +### 1. List trusted contacts + +Use this to show the user who currently has access, or to look up a specific contact. + +```bash +TOKEN=$(cat ~/.vellum/http-token) +curl -s http://localhost:7821/v1/ingress/members \ + -H "Authorization: Bearer $TOKEN" +``` + +Optional query parameters for filtering: +- `sourceChannel` — filter by channel (e.g., `telegram`, `sms`) +- `status` — filter by status (`active`, `revoked`, `blocked`) +- `policy` — filter by policy (`allow`, `deny`) + +Example with filters: +```bash +curl -s "http://localhost:7821/v1/ingress/members?sourceChannel=telegram&status=active" \ + -H "Authorization: Bearer $TOKEN" +``` + +The response contains `{ ok: true, members: [...] }` where each member has: +- `id` — unique member ID (needed for revoke/block operations) +- `sourceChannel` — the channel (e.g., `telegram`) +- `externalUserId` — the user's ID on that channel +- `externalChatId` — the chat ID on that channel +- `displayName` — human-readable name +- `username` — channel username (e.g., Telegram @handle) +- `status` — current status +- `policy` — current policy +- `createdAt` — when the member was added + +**Presenting results**: Format the member list as a readable table or list. Include display name (or username/user ID as fallback), channel, status, and policy. If no members exist, tell the user their contact list is empty. + +### 2. Allow a user (add trusted contact) + +Use this when the user wants to grant someone access to message the assistant. **Always confirm with the user before executing this action.** + +Ask the user: *"I'll add [name/identifier] on [channel] as an allowed contact. Should I proceed?"* + +```bash +TOKEN=$(cat ~/.vellum/http-token) +curl -s -X POST http://localhost:7821/v1/ingress/members \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "sourceChannel": "", + "externalUserId": "", + "displayName": "", + "policy": "allow", + "status": "active" + }' +``` + +Required fields: +- `sourceChannel` — the channel (e.g., `telegram`, `sms`) +- At least one of `externalUserId` or `externalChatId` + +Optional fields: +- `displayName` — human-readable name for the contact +- `username` — channel-specific handle (e.g., Telegram @username) + +If the user provides a name but not an external ID, explain that you need the channel-specific user ID or chat ID to create the contact entry. For Telegram, this is a numeric user ID; for SMS, this is the phone number in E.164 format. + +### 3. Revoke a user (remove access) + +Use this when the user wants to remove someone's access. **Always confirm with the user before executing this action.** + +Ask the user: *"I'll revoke access for [name/identifier]. They will no longer be able to message the assistant. Should I proceed?"* + +First, list members to find the member's `id`, then revoke: + +```bash +TOKEN=$(cat ~/.vellum/http-token) +curl -s -X DELETE "http://localhost:7821/v1/ingress/members/" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"reason": ""}' +``` + +Replace `` with the member's `id` from the list response. + +### 4. Block a user + +Use this when the user wants to explicitly block someone. Blocking is stronger than revoking — it marks the contact as actively denied. **Always confirm with the user before executing this action.** + +Ask the user: *"I'll block [name/identifier]. They will be permanently denied from messaging the assistant. Should I proceed?"* + +```bash +TOKEN=$(cat ~/.vellum/http-token) +curl -s -X POST "http://localhost:7821/v1/ingress/members//block" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"reason": ""}' +``` + +## Confirmation Requirements + +**All mutating actions (allow, revoke, block) require explicit user confirmation before execution.** This is a safety measure — modifying who can access the assistant should always be a deliberate choice. + +- Clearly state what action you are about to take and who it affects. +- Wait for the user to confirm before running the curl command. +- Report the result after execution. + +## Error Handling + +- If a request returns `{ ok: false, error: "..." }`, report the error message to the user. +- Common errors: + - `sourceChannel is required` — ask the user which channel the contact is on. + - `At least one of externalUserId or externalChatId is required` — ask the user for the contact's channel-specific identifier. + - `Member not found or cannot be revoked` — the member ID may be invalid or the member is already revoked. + - `Member not found or already blocked` — the member ID may be invalid or the member is already blocked. + +## Typical Workflows + +**"Who can message me?"** — List all active members, present as a formatted list. + +**"Add my friend to Telegram"** — Ask for their Telegram user ID (numeric) and optional display name, confirm, then add with `policy: "allow"` and `status: "active"`. + +**"Remove [name]'s access"** — List members to find them, confirm the revocation, then delete. + +**"Block [name]"** — List members to find them, confirm the block, then execute. + +**"Show me blocked contacts"** — List with `status=blocked` filter. From 6ced13795a3b2bf97f3945a8d68919c067c0dd1b Mon Sep 17 00:00:00 2001 From: NgoHarrison Date: Thu, 26 Feb 2026 00:41:26 -0500 Subject: [PATCH 07/14] feat: add notification signals for trusted contact lifecycle (#9481) Co-authored-by: Harrison Ngo Co-authored-by: Claude --- ...ed-contact-lifecycle-notifications.test.ts | 489 ++++++++++++++++++ .../routes/guardian-approval-interception.ts | 88 ++++ .../runtime/routes/inbound-message-handler.ts | 26 + 3 files changed, 603 insertions(+) create mode 100644 assistant/src/__tests__/trusted-contact-lifecycle-notifications.test.ts diff --git a/assistant/src/__tests__/trusted-contact-lifecycle-notifications.test.ts b/assistant/src/__tests__/trusted-contact-lifecycle-notifications.test.ts new file mode 100644 index 00000000000..21ef6c45168 --- /dev/null +++ b/assistant/src/__tests__/trusted-contact-lifecycle-notifications.test.ts @@ -0,0 +1,489 @@ +/** + * Tests for M7: Trusted contact lifecycle notification signals. + * + * Verifies that all trusted contact lifecycle transitions emit proper + * notification signals via emitNotificationSignal(): + * + * 1. request_submitted — when a non-member requests access (covered by + * ingress.access_request, tested in non-member-access-request.test.ts) + * 2. guardian_decision — when the guardian approves or denies + * 3. verification_sent — when the verification code is created and delivered + * 4. activated — when the trusted contact successfully verifies + * 5. denied — when the guardian denies the request + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'; + +// --------------------------------------------------------------------------- +// Test isolation: in-memory SQLite via temp directory +// --------------------------------------------------------------------------- + +const testDir = mkdtempSync(join(tmpdir(), 'trusted-contact-lifecycle-notif-')); + +mock.module('../util/platform.js', () => ({ + getRootDir: () => testDir, + getDataDir: () => testDir, + isMacOS: () => process.platform === 'darwin', + isLinux: () => process.platform === 'linux', + isWindows: () => process.platform === 'win32', + getSocketPath: () => join(testDir, 'test.sock'), + getPidPath: () => join(testDir, 'test.pid'), + getDbPath: () => join(testDir, 'test.db'), + getLogPath: () => join(testDir, 'test.log'), + ensureDataDir: () => {}, + normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id, + readHttpToken: () => 'test-bearer-token', +})); + +mock.module('../util/logger.js', () => ({ + getLogger: () => new Proxy({} as Record, { + get: () => () => {}, + }), +})); + +// Mock security check to always pass +mock.module('../security/secret-ingress.js', () => ({ + checkIngressForSecrets: () => ({ blocked: false }), +})); + +mock.module('../config/env.js', () => ({ + getGatewayInternalBaseUrl: () => 'http://127.0.0.1:7830', +})); + +// Track emitNotificationSignal calls +const emitSignalCalls: Array> = []; +mock.module('../notifications/emit-signal.js', () => ({ + emitNotificationSignal: async (params: Record) => { + emitSignalCalls.push(params); + return { + signalId: 'mock-signal-id', + deduplicated: false, + dispatched: true, + reason: 'mock', + deliveryResults: [], + }; + }, +})); + +// Track deliverChannelReply calls +const deliverReplyCalls: Array<{ url: string; payload: Record }> = []; +mock.module('../runtime/gateway-client.js', () => ({ + deliverChannelReply: async (url: string, payload: Record) => { + deliverReplyCalls.push({ url, payload }); + }, +})); + +// Mock the approval conversation / copy generators so they return canned text. +mock.module('../runtime/approval-message-composer.js', () => ({ + composeApprovalMessage: () => 'mock approval message', + composeApprovalMessageGenerative: async () => 'mock generative message', +})); + +import { + createApprovalRequest, + createBinding, + findPendingAccessRequestForRequester, + getAllPendingApprovalsByGuardianChat, +} from '../memory/channel-guardian-store.js'; +import { + createOutboundSession, +} from '../runtime/channel-guardian-service.js'; +import { findMember, upsertMember } from '../memory/ingress-member-store.js'; +import { initializeDb, resetDb } from '../memory/db.js'; +import { handleChannelInbound } from '../runtime/routes/channel-routes.js'; + +initializeDb(); + +afterAll(() => { + resetDb(); + try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ } +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const TEST_BEARER_TOKEN = 'test-token'; +const GUARDIAN_APPROVAL_TTL_MS = 5 * 60 * 1000; + +function resetState(): void { + const { getDb } = require('../memory/db.js'); + const db = getDb(); + db.run('DELETE FROM channel_guardian_approval_requests'); + db.run('DELETE FROM channel_guardian_bindings'); + db.run('DELETE FROM channel_guardian_verification_challenges'); + db.run('DELETE FROM channel_guardian_rate_limits'); + db.run('DELETE FROM channel_inbound_events'); + db.run('DELETE FROM conversations'); + db.run('DELETE FROM notification_events'); + db.run('DELETE FROM assistant_ingress_members'); + emitSignalCalls.length = 0; + deliverReplyCalls.length = 0; +} + +function buildInboundRequest(overrides: Record = {}): Request { + const body: Record = { + sourceChannel: 'telegram', + interface: 'telegram', + externalChatId: 'chat-123', + externalMessageId: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + content: 'Hello', + senderExternalUserId: 'requester-user-456', + senderName: 'Alice Requester', + senderUsername: 'alice_req', + replyCallbackUrl: 'http://localhost:7830/deliver/telegram', + ...overrides, + }; + + return new Request('http://localhost:8080/channels/inbound', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gateway-Origin': TEST_BEARER_TOKEN, + }, + body: JSON.stringify(body), + }); +} + +// --------------------------------------------------------------------------- +// Tests: Guardian decision signals (approve/deny) +// --------------------------------------------------------------------------- + +describe('trusted contact lifecycle notification signals', () => { + beforeEach(() => { + resetState(); + }); + + test('guardian deny emits guardian_decision and denied signals', async () => { + // Set up guardian binding and member record (guardians must pass ACL) + createBinding({ + assistantId: 'self', + channel: 'telegram', + guardianExternalUserId: 'guardian-user-789', + guardianDeliveryChatId: 'guardian-chat-789', + }); + upsertMember({ + assistantId: 'self', + sourceChannel: 'telegram', + externalUserId: 'guardian-user-789', + externalChatId: 'guardian-chat-789', + status: 'active', + policy: 'allow', + }); + + const testRequestId = `req-deny-${Date.now()}`; + + // Create a pending access request approval + const approval = createApprovalRequest({ + runId: `ingress-access-request-${Date.now()}`, + requestId: testRequestId, + conversationId: 'access-req-telegram-requester-user-456', + assistantId: 'self', + channel: 'telegram', + requesterExternalUserId: 'requester-user-456', + requesterChatId: 'requester-chat-456', + guardianExternalUserId: 'guardian-user-789', + guardianChatId: 'guardian-chat-789', + toolName: 'ingress_access_request', + riskLevel: 'access_request', + reason: 'Alice is requesting access', + expiresAt: Date.now() + GUARDIAN_APPROVAL_TTL_MS, + }); + + // Guardian denies via callback button + const guardianReq = buildInboundRequest({ + externalChatId: 'guardian-chat-789', + senderExternalUserId: 'guardian-user-789', + senderName: 'Guardian', + content: '', + callbackData: `apr:${testRequestId}:reject`, + }); + + await handleChannelInbound(guardianReq, undefined, TEST_BEARER_TOKEN); + + // Should emit guardian_decision and denied signals + + const guardianDecisionSignals = emitSignalCalls.filter( + (c) => c.sourceEventName === 'ingress.trusted_contact.guardian_decision', + ); + const deniedSignals = emitSignalCalls.filter( + (c) => c.sourceEventName === 'ingress.trusted_contact.denied', + ); + + expect(guardianDecisionSignals.length).toBe(1); + expect(deniedSignals.length).toBe(1); + + // Verify guardian_decision payload + const gdPayload = guardianDecisionSignals[0].contextPayload as Record; + expect(gdPayload.decision).toBe('denied'); + expect(gdPayload.requesterExternalUserId).toBe('requester-user-456'); + expect(gdPayload.decidedByExternalUserId).toBe('guardian-user-789'); + + // Verify denied payload + const dPayload = deniedSignals[0].contextPayload as Record; + expect(dPayload.decision).toBe('denied'); + expect(dPayload.requesterExternalUserId).toBe('requester-user-456'); + + // Verify deduplication keys are distinct + expect(guardianDecisionSignals[0].dedupeKey).toContain('trusted-contact:guardian-decision:'); + expect(deniedSignals[0].dedupeKey).toContain('trusted-contact:denied:'); + }); + + test('guardian approve emits guardian_decision and verification_sent signals', async () => { + // Set up guardian binding and member record (guardians must pass ACL) + createBinding({ + assistantId: 'self', + channel: 'telegram', + guardianExternalUserId: 'guardian-user-789', + guardianDeliveryChatId: 'guardian-chat-789', + }); + upsertMember({ + assistantId: 'self', + sourceChannel: 'telegram', + externalUserId: 'guardian-user-789', + externalChatId: 'guardian-chat-789', + status: 'active', + policy: 'allow', + }); + + const testRequestId = `req-approve-${Date.now()}`; + + // Create a pending access request approval + const approval = createApprovalRequest({ + runId: `ingress-access-request-${Date.now()}`, + requestId: testRequestId, + conversationId: 'access-req-telegram-requester-user-456', + assistantId: 'self', + channel: 'telegram', + requesterExternalUserId: 'requester-user-456', + requesterChatId: 'requester-chat-456', + guardianExternalUserId: 'guardian-user-789', + guardianChatId: 'guardian-chat-789', + toolName: 'ingress_access_request', + riskLevel: 'access_request', + reason: 'Alice is requesting access', + expiresAt: Date.now() + GUARDIAN_APPROVAL_TTL_MS, + }); + + // Guardian approves via callback button + const guardianReq = buildInboundRequest({ + externalChatId: 'guardian-chat-789', + senderExternalUserId: 'guardian-user-789', + senderName: 'Guardian', + content: '', + callbackData: `apr:${testRequestId}:approve_once`, + }); + + await handleChannelInbound(guardianReq, undefined, TEST_BEARER_TOKEN); + + // Should emit guardian_decision (approved) and verification_sent signals + const guardianDecisionSignals = emitSignalCalls.filter( + (c) => c.sourceEventName === 'ingress.trusted_contact.guardian_decision', + ); + const verificationSentSignals = emitSignalCalls.filter( + (c) => c.sourceEventName === 'ingress.trusted_contact.verification_sent', + ); + + expect(guardianDecisionSignals.length).toBe(1); + expect(verificationSentSignals.length).toBe(1); + + // Verify guardian_decision payload + const gdPayload = guardianDecisionSignals[0].contextPayload as Record; + expect(gdPayload.decision).toBe('approved'); + expect(gdPayload.requesterExternalUserId).toBe('requester-user-456'); + expect(gdPayload.decidedByExternalUserId).toBe('guardian-user-789'); + + // Verify verification_sent payload + const vsPayload = verificationSentSignals[0].contextPayload as Record; + expect(vsPayload.requesterExternalUserId).toBe('requester-user-456'); + expect(vsPayload.verificationSessionId).toBeDefined(); + + // Should NOT emit denied signal + const deniedSignals = emitSignalCalls.filter( + (c) => c.sourceEventName === 'ingress.trusted_contact.denied', + ); + expect(deniedSignals.length).toBe(0); + }); + + test('deduplication keys prevent duplicate signals', async () => { + // Set up guardian binding and member record (guardians must pass ACL) + createBinding({ + assistantId: 'self', + channel: 'telegram', + guardianExternalUserId: 'guardian-user-789', + guardianDeliveryChatId: 'guardian-chat-789', + }); + upsertMember({ + assistantId: 'self', + sourceChannel: 'telegram', + externalUserId: 'guardian-user-789', + externalChatId: 'guardian-chat-789', + status: 'active', + policy: 'allow', + }); + + const testRequestId = `req-dedup-${Date.now()}`; + + const approval = createApprovalRequest({ + runId: `ingress-access-request-${Date.now()}`, + requestId: testRequestId, + conversationId: 'access-req-telegram-requester-user-456', + assistantId: 'self', + channel: 'telegram', + requesterExternalUserId: 'requester-user-456', + requesterChatId: 'requester-chat-456', + guardianExternalUserId: 'guardian-user-789', + guardianChatId: 'guardian-chat-789', + toolName: 'ingress_access_request', + riskLevel: 'access_request', + reason: 'Alice is requesting access', + expiresAt: Date.now() + GUARDIAN_APPROVAL_TTL_MS, + }); + + // All guardian_decision signals include the approval ID in the dedupe key + const guardianReq = buildInboundRequest({ + externalChatId: 'guardian-chat-789', + senderExternalUserId: 'guardian-user-789', + senderName: 'Guardian', + content: '', + callbackData: `apr:${testRequestId}:reject`, + }); + + await handleChannelInbound(guardianReq, undefined, TEST_BEARER_TOKEN); + + const signals = emitSignalCalls.filter( + (c) => typeof c.dedupeKey === 'string' && (c.dedupeKey as string).includes(approval.id), + ); + // guardian_decision and denied — both keyed on approval.id + expect(signals.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: Activated signal (trusted contact verification success) +// --------------------------------------------------------------------------- + +describe('trusted contact activated notification signal', () => { + beforeEach(() => { + resetState(); + }); + + test('successful trusted contact verification emits activated signal', async () => { + // Set up a guardian binding so the verification path allows bypass + createBinding({ + assistantId: 'self', + channel: 'telegram', + guardianExternalUserId: 'guardian-user-789', + guardianDeliveryChatId: 'guardian-chat-789', + }); + + // Create an identity-bound outbound session (simulates M3 approval flow) + const session = createOutboundSession({ + assistantId: 'self', + channel: 'telegram', + expectedExternalUserId: 'requester-user-456', + expectedChatId: 'chat-123', + identityBindingStatus: 'bound', + destinationAddress: 'chat-123', + }); + + // Requester enters the verification code + const verifyReq = buildInboundRequest({ + content: session.secret, + externalChatId: 'chat-123', + senderExternalUserId: 'requester-user-456', + }); + + await handleChannelInbound(verifyReq, undefined, TEST_BEARER_TOKEN); + + // Should emit the activated signal + const activatedSignals = emitSignalCalls.filter( + (c) => c.sourceEventName === 'ingress.trusted_contact.activated', + ); + + expect(activatedSignals.length).toBe(1); + + // Verify payload + const payload = activatedSignals[0].contextPayload as Record; + expect(payload.sourceChannel).toBe('telegram'); + expect(payload.externalUserId).toBe('requester-user-456'); + expect(payload.externalChatId).toBe('chat-123'); + + // Verify deduplication key includes the user identity + const dedupeKey = activatedSignals[0].dedupeKey as string; + expect(dedupeKey).toContain('trusted-contact:activated:'); + expect(dedupeKey).toContain('requester-user-456'); + + // Verify attention hints indicate informational (no action required) + const hints = activatedSignals[0].attentionHints as Record; + expect(hints.requiresAction).toBe(false); + expect(hints.urgency).toBe('low'); + }); + + test('guardian verification does NOT emit activated signal', async () => { + // Create an inbound challenge (guardian flow, not trusted contact) + const { createVerificationChallenge } = require('../runtime/channel-guardian-service.js'); + const { secret } = createVerificationChallenge('self', 'telegram'); + + // "Guardian" enters the verification code + const verifyReq = buildInboundRequest({ + content: secret, + externalChatId: 'guardian-chat-new', + senderExternalUserId: 'guardian-user-new', + }); + + await handleChannelInbound(verifyReq, undefined, TEST_BEARER_TOKEN); + + // Should NOT emit the trusted_contact.activated signal + const activatedSignals = emitSignalCalls.filter( + (c) => c.sourceEventName === 'ingress.trusted_contact.activated', + ); + expect(activatedSignals.length).toBe(0); + }); + + test('member is persisted BEFORE activated signal is emitted', async () => { + // Set up a guardian binding + createBinding({ + assistantId: 'self', + channel: 'telegram', + guardianExternalUserId: 'guardian-user-789', + guardianDeliveryChatId: 'guardian-chat-789', + }); + + const session = createOutboundSession({ + assistantId: 'self', + channel: 'telegram', + expectedExternalUserId: 'requester-user-456', + expectedChatId: 'chat-123', + identityBindingStatus: 'bound', + destinationAddress: 'chat-123', + }); + + const verifyReq = buildInboundRequest({ + content: session.secret, + externalChatId: 'chat-123', + senderExternalUserId: 'requester-user-456', + }); + + await handleChannelInbound(verifyReq, undefined, TEST_BEARER_TOKEN); + + // The activated signal was emitted + const activatedSignals = emitSignalCalls.filter( + (c) => c.sourceEventName === 'ingress.trusted_contact.activated', + ); + expect(activatedSignals.length).toBe(1); + + // Verify the member was already persisted (the signal fires after upsertMember) + const member = findMember({ + assistantId: 'self', + sourceChannel: 'telegram', + externalUserId: 'requester-user-456', + }); + expect(member).not.toBeNull(); + expect(member!.status).toBe('active'); + expect(member!.policy).toBe('allow'); + }); +}); diff --git a/assistant/src/runtime/routes/guardian-approval-interception.ts b/assistant/src/runtime/routes/guardian-approval-interception.ts index c4edf897ddb..0ad7233b22d 100644 --- a/assistant/src/runtime/routes/guardian-approval-interception.ts +++ b/assistant/src/runtime/routes/guardian-approval-interception.ts @@ -11,6 +11,7 @@ import { updateApprovalDecision, type GuardianApprovalRequest, } from '../../memory/channel-guardian-store.js'; +import { emitNotificationSignal } from '../../notifications/emit-signal.js'; import { getLogger } from '../../util/logger.js'; import { runApprovalConversationTurn } from '../approval-conversation-turn.js'; import { composeApprovalMessageGenerative } from '../approval-message-composer.js'; @@ -950,6 +951,47 @@ async function handleAccessRequestApproval( assistantId, bearerToken, }); + + // Emit both guardian_decision and denied signals so all lifecycle + // observers are notified of the denial. + const deniedPayload = { + sourceChannel: approval.channel, + requesterExternalUserId: approval.requesterExternalUserId, + requesterChatId: approval.requesterChatId, + decidedByExternalUserId, + decision: 'denied' as const, + }; + + void emitNotificationSignal({ + sourceEventName: 'ingress.trusted_contact.guardian_decision', + sourceChannel: approval.channel, + sourceSessionId: approval.conversationId, + assistantId, + attentionHints: { + requiresAction: false, + urgency: 'medium', + isAsyncBackground: false, + visibleInSourceNow: false, + }, + contextPayload: deniedPayload, + dedupeKey: `trusted-contact:guardian-decision:${approval.id}`, + }); + + void emitNotificationSignal({ + sourceEventName: 'ingress.trusted_contact.denied', + sourceChannel: approval.channel, + sourceSessionId: approval.conversationId, + assistantId, + attentionHints: { + requiresAction: false, + urgency: 'low', + isAsyncBackground: false, + visibleInSourceNow: false, + }, + contextPayload: deniedPayload, + dedupeKey: `trusted-contact:denied:${approval.id}`, + }); + return { handled: true, type: 'guardian_decision_applied' }; } @@ -974,5 +1016,51 @@ async function handleAccessRequestApproval( bearerToken, }); + // Emit guardian_decision (approved) signal + void emitNotificationSignal({ + sourceEventName: 'ingress.trusted_contact.guardian_decision', + sourceChannel: approval.channel, + sourceSessionId: approval.conversationId, + assistantId, + attentionHints: { + requiresAction: false, + urgency: 'medium', + isAsyncBackground: false, + visibleInSourceNow: false, + }, + contextPayload: { + sourceChannel: approval.channel, + requesterExternalUserId: approval.requesterExternalUserId, + requesterChatId: approval.requesterChatId, + decidedByExternalUserId, + decision: 'approved', + }, + dedupeKey: `trusted-contact:guardian-decision:${approval.id}`, + }); + + // Emit verification_sent signal — the code has been created and + // delivered to the guardian for out-of-band relay to the requester. + if (decisionResult.verificationSessionId) { + void emitNotificationSignal({ + sourceEventName: 'ingress.trusted_contact.verification_sent', + sourceChannel: approval.channel, + sourceSessionId: approval.conversationId, + assistantId, + attentionHints: { + requiresAction: false, + urgency: 'low', + isAsyncBackground: true, + visibleInSourceNow: false, + }, + contextPayload: { + sourceChannel: approval.channel, + requesterExternalUserId: approval.requesterExternalUserId, + requesterChatId: approval.requesterChatId, + verificationSessionId: decisionResult.verificationSessionId, + }, + dedupeKey: `trusted-contact:verification-sent:${decisionResult.verificationSessionId}`, + }); + } + return { handled: true, type: 'guardian_decision_applied' }; } diff --git a/assistant/src/runtime/routes/inbound-message-handler.ts b/assistant/src/runtime/routes/inbound-message-handler.ts index ba370b10bdd..1aa64017a9c 100644 --- a/assistant/src/runtime/routes/inbound-message-handler.ts +++ b/assistant/src/runtime/routes/inbound-message-handler.ts @@ -648,6 +648,32 @@ export async function handleChannelInbound( ? 'Trusted contact verified' : 'Guardian verified'; log.info({ sourceChannel, externalUserId: body.senderExternalUserId, verificationType: verifyResult.verificationType }, `${verifyLogLabel}: auto-upserted ingress member`); + + // Emit activated signal when a trusted contact completes verification. + // Member record is persisted above before this event fires, satisfying + // the persistence-before-event ordering invariant. + if (verifyResult.verificationType === 'trusted_contact') { + void emitNotificationSignal({ + sourceEventName: 'ingress.trusted_contact.activated', + sourceChannel, + sourceSessionId: result.conversationId, + assistantId: canonicalAssistantId, + attentionHints: { + requiresAction: false, + urgency: 'low', + isAsyncBackground: false, + visibleInSourceNow: false, + }, + contextPayload: { + sourceChannel, + externalUserId: body.senderExternalUserId, + externalChatId, + senderName: body.senderName ?? null, + senderUsername: body.senderUsername ?? null, + }, + dedupeKey: `trusted-contact:activated:${canonicalAssistantId}:${sourceChannel}:${body.senderExternalUserId}`, + }); + } } // Deliver a deterministic template-driven reply and short-circuit. From 5d108c4046614939a9106c58eda63f3e91f3da8a Mon Sep 17 00:00:00 2001 From: NgoHarrison Date: Thu, 26 Feb 2026 11:04:06 -0500 Subject: [PATCH 08/14] docs: channel-agnostic rollout and operator runbook for trusted contacts (#9529) Co-authored-by: Harrison Ngo Co-authored-by: Claude --- ARCHITECTURE.md | 3 + assistant/ARCHITECTURE.md | 49 +++ assistant/docs/runbook-trusted-contacts.md | 283 ++++++++++++ .../trusted-contact-multichannel.test.ts | 407 ++++++++++++++++++ 4 files changed, 742 insertions(+) create mode 100644 assistant/docs/runbook-trusted-contacts.md create mode 100644 assistant/src/__tests__/trusted-contact-multichannel.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6f30a0c6168..05d85a1499c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -14,6 +14,8 @@ This file is the cross-system architecture index. Detailed designs live in domai | Assistant scheduling deep dive | [`assistant/docs/architecture/scheduling.md`](assistant/docs/architecture/scheduling.md) | | Assistant security deep dive | [`assistant/docs/architecture/security.md`](assistant/docs/architecture/security.md) | | Gateway SMS parity checklist | [`gateway/docs/sms-twilio-parity-checklist.md`](gateway/docs/sms-twilio-parity-checklist.md) | +| Trusted contact access design | [`assistant/docs/trusted-contact-access.md`](assistant/docs/trusted-contact-access.md) | +| Trusted contacts operator runbook | [`assistant/docs/runbook-trusted-contacts.md`](assistant/docs/runbook-trusted-contacts.md) | ## Cross-Cutting Invariants @@ -21,6 +23,7 @@ This file is the cross-system architecture index. Detailed designs live in domai - Production LLM calls go through the provider abstraction, not provider SDKs in feature code. - Notification producers emit through `emitNotificationSignal()` to preserve decisioning and audit invariants. Reminder routing metadata (`routingIntent`, `routingHints`) flows through the signal and is enforced post-decision to control multi-channel fanout. - Memory extraction/recall must enforce actor-role provenance gates for untrusted actors. +- Trusted contact ingress ACL is channel-agnostic; identity binding adapts per channel (chat ID, E.164 phone, external user ID) without channel-specific branching. ## System Overview diff --git a/assistant/ARCHITECTURE.md b/assistant/ARCHITECTURE.md index 68019f9236c..16d7b5cec33 100644 --- a/assistant/ARCHITECTURE.md +++ b/assistant/ARCHITECTURE.md @@ -134,6 +134,55 @@ These can be set via environment variables or stored in the credential vault (ke **SMS Compliance & Admin**: The `twilio_config` IPC contract extends beyond credential and number management with compliance and admin actions: `sms_compliance_status` detects toll-free vs local number type and fetches verification status; `sms_submit_tollfree_verification`, `sms_update_tollfree_verification`, and `sms_delete_tollfree_verification` manage the Twilio toll-free verification lifecycle; `release_number` removes a phone number from the Twilio account and clears all local references. All compliance actions validate required fields and Twilio enum values before calling the API. +### Trusted Contact Access (Channel-Agnostic) + +External users who are not the guardian can gain access to the assistant through a guardian-mediated verification flow. The flow is channel-agnostic — it works identically on Telegram, SMS, voice, and any future channel. + +**Full design doc:** [`docs/trusted-contact-access.md`](docs/trusted-contact-access.md) + +**Flow summary:** +1. Unknown user messages the assistant on any channel. +2. Ingress ACL (`inbound-message-handler.ts`) rejects the message and emits an `ingress.access_request` notification signal to the guardian. +3. Guardian approves or denies via callback button or conversational intent (routed through `guardian-approval-interception.ts`). +4. On approval, an identity-bound verification session with a 6-digit code is created (`access-request-decision.ts` → `channel-guardian-service.ts`). +5. Guardian gives the code to the requester out-of-band. +6. Requester enters the code; identity binding is verified, the challenge is consumed, and an active member record is created in `assistant_ingress_members`. +7. All subsequent messages are accepted through the ingress ACL. + +**Channel-agnostic design:** The entire flow operates on abstract `ChannelId` and `externalUserId`/`externalChatId` fields. Identity binding adapts per channel: Telegram uses chat IDs, SMS/voice use E.164 phone numbers, HTTP API uses caller-provided identity. No channel-specific branching exists in the trusted contact code paths. + +**Lifecycle states:** `requested → pending_guardian → verification_pending → active | denied | expired` + +**Notification signals:** The flow emits signals at each lifecycle transition via `emitNotificationSignal()`: +- `ingress.access_request` — non-member denied, guardian notified +- `ingress.trusted_contact.guardian_decision` — guardian approved or denied +- `ingress.trusted_contact.verification_sent` — code created and delivered +- `ingress.trusted_contact.activated` — requester verified, member active +- `ingress.trusted_contact.denied` — guardian explicitly denied + +**HTTP API (for management):** + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/v1/ingress/members` | GET | List trusted contacts (filterable by channel, status, policy) | +| `/v1/ingress/members` | POST | Upsert a member (add/update trusted contact) | +| `/v1/ingress/members/:id` | DELETE | Revoke a trusted contact | +| `/v1/ingress/members/:id/block` | POST | Block a member | + +**Key source files:** + +| File | Purpose | +|------|---------| +| `src/runtime/routes/inbound-message-handler.ts` | Ingress ACL, non-member rejection, verification code interception | +| `src/runtime/routes/access-request-decision.ts` | Guardian decision → verification session creation | +| `src/runtime/routes/guardian-approval-interception.ts` | Routes guardian decisions (button + conversational) to access request handler | +| `src/runtime/channel-guardian-service.ts` | Verification challenge lifecycle, identity binding, rate limiting | +| `src/runtime/routes/ingress-routes.ts` | HTTP API handlers for member/invite management | +| `src/runtime/ingress-service.ts` | Business logic for member CRUD | +| `src/memory/ingress-member-store.ts` | Member record persistence | +| `src/memory/channel-guardian-store.ts` | Approval request and verification challenge persistence | +| `src/config/vellum-skills/trusted-contacts/SKILL.md` | Skill teaching the assistant to manage contacts via HTTP API | + --- diff --git a/assistant/docs/runbook-trusted-contacts.md b/assistant/docs/runbook-trusted-contacts.md new file mode 100644 index 00000000000..c99697995e5 --- /dev/null +++ b/assistant/docs/runbook-trusted-contacts.md @@ -0,0 +1,283 @@ +# Trusted Contacts — Operator Runbook + +Operational procedures for inspecting, managing, and debugging the trusted contact access flow. All HTTP commands use the runtime API (default `http://localhost:7821`) with bearer authentication. + +## Prerequisites + +```bash +# Read the bearer token +TOKEN=$(cat ~/.vellum/http-token) + +# Base URL (adjust if using a non-default port) +BASE=http://localhost:7821 +``` + +## 1. Inspect Trusted Contacts (Members) + +### List all active trusted contacts + +```bash +curl -s "$BASE/v1/ingress/members?status=active" \ + -H "Authorization: Bearer $TOKEN" | jq +``` + +### Filter by channel + +```bash +# Telegram contacts only +curl -s "$BASE/v1/ingress/members?sourceChannel=telegram&status=active" \ + -H "Authorization: Bearer $TOKEN" | jq + +# SMS contacts only +curl -s "$BASE/v1/ingress/members?sourceChannel=sms&status=active" \ + -H "Authorization: Bearer $TOKEN" | jq +``` + +### List all members (including revoked and blocked) + +```bash +curl -s "$BASE/v1/ingress/members" \ + -H "Authorization: Bearer $TOKEN" | jq +``` + +Response shape: +```json +{ + "ok": true, + "members": [ + { + "id": "uuid", + "sourceChannel": "telegram", + "externalUserId": "123456789", + "externalChatId": "123456789", + "displayName": "Alice", + "username": "alice_handle", + "status": "active", + "policy": "allow", + "lastSeenAt": 1700000000000, + "createdAt": 1699000000000 + } + ] +} +``` + +## 2. Inspect Pending Access Requests + +Access requests are stored in the `channel_guardian_approval_requests` table. Use SQLite to inspect pending requests directly. + +### Via SQLite CLI + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT id, channel, requester_external_user_id, requester_chat_id, \ + guardian_external_user_id, status, tool_name, created_at, expires_at \ + FROM channel_guardian_approval_requests \ + WHERE tool_name = 'ingress_access_request' AND status = 'pending' \ + ORDER BY created_at DESC;" +``` + +### Check all access requests (including resolved) + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT id, channel, requester_external_user_id, status, \ + decided_by_external_user_id, created_at \ + FROM channel_guardian_approval_requests \ + WHERE tool_name = 'ingress_access_request' \ + ORDER BY created_at DESC LIMIT 20;" +``` + +## 3. Inspect Pending Verification Sessions + +Verification challenges are stored in `channel_guardian_verification_challenges`. Active sessions have `status = 'awaiting_response'` and `expires_at > now`. + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT id, channel, status, identity_binding_status, \ + expected_external_user_id, expected_chat_id, expected_phone_e164, \ + expires_at, created_at \ + FROM channel_guardian_verification_challenges \ + WHERE status IN ('awaiting_response', 'pending_bootstrap') \ + AND expires_at > $(date +%s)000 \ + ORDER BY created_at DESC;" +``` + +## 4. Force-Revoke a Trusted Contact + +### Via HTTP API + +First, find the member's `id` from the list endpoint, then revoke: + +```bash +# Find the member +MEMBER_ID=$(curl -s "$BASE/v1/ingress/members?sourceChannel=telegram&status=active" \ + -H "Authorization: Bearer $TOKEN" | jq -r '.members[] | select(.externalUserId == "TARGET_USER_ID") | .id') + +# Revoke with reason +curl -s -X DELETE "$BASE/v1/ingress/members/$MEMBER_ID" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"reason": "Revoked by operator"}' | jq +``` + +### Block a member (stronger than revoke) + +Blocking prevents the member from re-entering the flow without explicit unblocking. + +```bash +curl -s -X POST "$BASE/v1/ingress/members/$MEMBER_ID/block" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"reason": "Blocked by operator"}' | jq +``` + +### Via SQLite (emergency) + +If the HTTP API is unavailable: + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "UPDATE assistant_ingress_members \ + SET status = 'revoked', revoked_reason = 'Emergency operator revocation', \ + updated_at = $(date +%s)000 \ + WHERE external_user_id = 'TARGET_USER_ID' AND source_channel = 'telegram';" +``` + +## 5. Debug Verification Failures + +### Check rate limit state + +If a user is getting "invalid or expired code" errors, they may be rate-limited: + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT * FROM channel_guardian_rate_limits \ + WHERE external_user_id = 'TARGET_USER_ID' \ + OR chat_id = 'TARGET_CHAT_ID' \ + ORDER BY created_at DESC LIMIT 5;" +``` + +### Reset rate limits for a user + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "DELETE FROM channel_guardian_rate_limits \ + WHERE external_user_id = 'TARGET_USER_ID' AND channel = 'telegram';" +``` + +### Check verification challenge state + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT id, channel, status, identity_binding_status, \ + expected_external_user_id, expected_chat_id, expected_phone_e164, \ + expires_at, consumed_by_external_user_id \ + FROM channel_guardian_verification_challenges \ + WHERE expected_external_user_id = 'TARGET_USER_ID' \ + OR expected_chat_id = 'TARGET_CHAT_ID' \ + ORDER BY created_at DESC LIMIT 5;" +``` + +### Common verification failure causes + +| Symptom | Likely cause | Resolution | +|---------|-------------|------------| +| "Invalid or expired code" (correct code) | Identity mismatch: the code was entered from a different user/chat than expected | Verify the requester is using the same account that originally requested access | +| "Invalid or expired code" (correct code, correct user) | Rate-limited (5+ failures in 15 min window) | Wait 30 minutes or reset rate limits via SQLite | +| "Invalid or expired code" (old code) | Code TTL expired (10 min) | Guardian must re-approve to generate a new code | +| Code never delivered to guardian | `deliverChannelReply` failed | Check daemon logs for "Failed to deliver verification code to guardian" | +| No notification to guardian | No guardian binding for channel | Verify guardian is bound: check `channel_guardian_bindings` table | + +## 6. Check Notification Delivery Status + +### Check if the access request notification was delivered + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT ne.id, ne.source_event_name, ne.dedupe_key, ne.created_at, \ + nd.channel, nd.status, nd.confidence \ + FROM notification_events ne \ + LEFT JOIN notification_decisions nd ON nd.event_id = ne.id \ + WHERE ne.source_event_name LIKE 'ingress.%' \ + ORDER BY ne.created_at DESC LIMIT 20;" +``` + +### Check delivery records + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT ndel.id, ndel.channel, ndel.status, ndel.error_message, \ + ndel.created_at, ne.source_event_name \ + FROM notification_deliveries ndel \ + JOIN notification_events ne ON ne.id = ndel.event_id \ + WHERE ne.source_event_name LIKE 'ingress.%' \ + ORDER BY ndel.created_at DESC LIMIT 20;" +``` + +### Check lifecycle signals + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "SELECT source_event_name, source_channel, dedupe_key, created_at \ + FROM notification_events \ + WHERE source_event_name LIKE 'ingress.trusted_contact.%' \ + ORDER BY created_at DESC LIMIT 20;" +``` + +## 7. Manually Add a Trusted Contact (Bypass Verification) + +If the verification flow cannot be completed, an operator can directly create an active member: + +```bash +curl -s -X POST "$BASE/v1/ingress/members" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "sourceChannel": "telegram", + "externalUserId": "123456789", + "externalChatId": "123456789", + "displayName": "Alice", + "policy": "allow", + "status": "active" + }' | jq +``` + +For SMS contacts, use the E.164 phone number as the external user/chat ID: + +```bash +curl -s -X POST "$BASE/v1/ingress/members" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "sourceChannel": "sms", + "externalUserId": "+15551234567", + "externalChatId": "+15551234567", + "displayName": "Bob", + "policy": "allow", + "status": "active" + }' | jq +``` + +## 8. Clean Up Expired Data + +### Purge expired verification sessions + +Expired sessions are already invisible to the verification flow (filtered by `expires_at`), but you can clean them up: + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "DELETE FROM channel_guardian_verification_challenges \ + WHERE expires_at < $(date +%s)000 \ + AND status IN ('awaiting_response', 'pending_bootstrap');" +``` + +### Purge expired approval requests + +The `sweepExpiredGuardianApprovals()` timer handles this automatically every 60 seconds, but manual cleanup: + +```bash +sqlite3 ~/.vellum/workspace/data/db/assistant.db \ + "UPDATE channel_guardian_approval_requests \ + SET status = 'expired' \ + WHERE status = 'pending' AND expires_at < $(date +%s)000;" +``` diff --git a/assistant/src/__tests__/trusted-contact-multichannel.test.ts b/assistant/src/__tests__/trusted-contact-multichannel.test.ts new file mode 100644 index 00000000000..f16c0f215d6 --- /dev/null +++ b/assistant/src/__tests__/trusted-contact-multichannel.test.ts @@ -0,0 +1,407 @@ +/** + * Tests verifying the trusted contact flow is channel-agnostic. + * + * The access request -> guardian notification -> verification -> activation + * flow should work identically across Telegram, SMS, and voice channels. + * These tests confirm no Telegram-specific assumptions leaked into the + * trusted contact code paths. + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'; + +// --------------------------------------------------------------------------- +// Test isolation: in-memory SQLite via temp directory +// --------------------------------------------------------------------------- + +const testDir = mkdtempSync(join(tmpdir(), 'trusted-contact-multichannel-')); + +mock.module('../util/platform.js', () => ({ + getRootDir: () => testDir, + getDataDir: () => testDir, + isMacOS: () => process.platform === 'darwin', + isLinux: () => process.platform === 'linux', + isWindows: () => process.platform === 'win32', + getSocketPath: () => join(testDir, 'test.sock'), + getPidPath: () => join(testDir, 'test.pid'), + getDbPath: () => join(testDir, 'test.db'), + getLogPath: () => join(testDir, 'test.log'), + ensureDataDir: () => {}, + normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id, + readHttpToken: () => 'test-bearer-token', +})); + +mock.module('../util/logger.js', () => ({ + getLogger: () => new Proxy({} as Record, { + get: () => () => {}, + }), +})); + +mock.module('../security/secret-ingress.js', () => ({ + checkIngressForSecrets: () => ({ blocked: false }), +})); + +mock.module('../config/env.js', () => ({ + getGatewayInternalBaseUrl: () => 'http://127.0.0.1:7830', +})); + +const emitSignalCalls: Array> = []; +mock.module('../notifications/emit-signal.js', () => ({ + emitNotificationSignal: async (params: Record) => { + emitSignalCalls.push(params); + return { + signalId: 'mock-signal-id', + deduplicated: false, + dispatched: true, + reason: 'mock', + deliveryResults: [], + }; + }, +})); + +const deliverReplyCalls: Array<{ url: string; payload: Record }> = []; +mock.module('../runtime/gateway-client.js', () => ({ + deliverChannelReply: async (url: string, payload: Record) => { + deliverReplyCalls.push({ url, payload }); + }, +})); + +mock.module('../runtime/approval-message-composer.js', () => ({ + composeApprovalMessage: () => 'mock approval message', + composeApprovalMessageGenerative: async () => 'mock generative message', +})); + +import { + createBinding, + findPendingAccessRequestForRequester, +} from '../memory/channel-guardian-store.js'; +import { + createOutboundSession, + validateAndConsumeChallenge, +} from '../runtime/channel-guardian-service.js'; +import { findMember, upsertMember } from '../memory/ingress-member-store.js'; +import { initializeDb, resetDb } from '../memory/db.js'; +import { handleChannelInbound } from '../runtime/routes/channel-routes.js'; +import { + handleAccessRequestDecision, +} from '../runtime/routes/access-request-decision.js'; + +initializeDb(); + +afterAll(() => { + resetDb(); + try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ } +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const TEST_BEARER_TOKEN = 'test-token'; + +function resetState(): void { + const { getDb } = require('../memory/db.js'); + const db = getDb(); + db.run('DELETE FROM channel_guardian_approval_requests'); + db.run('DELETE FROM channel_guardian_bindings'); + db.run('DELETE FROM channel_guardian_verification_challenges'); + db.run('DELETE FROM channel_guardian_rate_limits'); + db.run('DELETE FROM channel_inbound_events'); + db.run('DELETE FROM conversations'); + db.run('DELETE FROM notification_events'); + db.run('DELETE FROM assistant_ingress_members'); + emitSignalCalls.length = 0; + deliverReplyCalls.length = 0; +} + +interface ChannelTestConfig { + channel: 'telegram' | 'sms' | 'voice'; + deliverEndpoint: string; + /** SMS/voice use phone E.164 as identifiers */ + senderExternalUserId: string; + externalChatId: string; + guardianExternalUserId: string; + guardianChatId: string; +} + +const CHANNEL_CONFIGS: ChannelTestConfig[] = [ + { + channel: 'telegram', + deliverEndpoint: '/deliver/telegram', + senderExternalUserId: 'tg-user-456', + externalChatId: 'tg-chat-456', + guardianExternalUserId: 'tg-guardian-789', + guardianChatId: 'tg-guardian-chat-789', + }, + { + channel: 'sms', + deliverEndpoint: '/deliver/sms', + senderExternalUserId: '+15551234567', + externalChatId: '+15551234567', + guardianExternalUserId: '+15559876543', + guardianChatId: '+15559876543', + }, +]; + +function buildInboundRequest( + config: ChannelTestConfig, + overrides: Record = {}, +): Request { + const body: Record = { + sourceChannel: config.channel, + interface: config.channel, + externalChatId: config.externalChatId, + externalMessageId: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + content: 'Hello, can I use this assistant?', + senderExternalUserId: config.senderExternalUserId, + senderName: 'Test Requester', + senderUsername: 'test_requester', + replyCallbackUrl: `http://localhost:7830${config.deliverEndpoint}`, + ...overrides, + }; + + return new Request('http://localhost:8080/channels/inbound', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gateway-Origin': TEST_BEARER_TOKEN, + }, + body: JSON.stringify(body), + }); +} + +// --------------------------------------------------------------------------- +// Parameterized tests for each channel +// --------------------------------------------------------------------------- + +for (const config of CHANNEL_CONFIGS) { + describe(`trusted contact flow on ${config.channel} channel`, () => { + beforeEach(() => { + resetState(); + }); + + test('non-member message is denied with rejection reply', async () => { + const req = buildInboundRequest(config); + const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN); + const json = await resp.json() as Record; + + expect(json.denied).toBe(true); + expect(json.reason).toBe('not_a_member'); + expect(deliverReplyCalls.length).toBe(1); + expect((deliverReplyCalls[0].payload as Record).text).toContain("you haven't been approved"); + }); + + test('guardian is notified when a non-member messages', async () => { + createBinding({ + assistantId: 'self', + channel: config.channel, + guardianExternalUserId: config.guardianExternalUserId, + guardianDeliveryChatId: config.guardianChatId, + }); + + const req = buildInboundRequest(config); + const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN); + const json = await resp.json() as Record; + + expect(json.denied).toBe(true); + + // Notification signal was emitted for the correct channel + expect(emitSignalCalls.length).toBe(1); + expect(emitSignalCalls[0].sourceEventName).toBe('ingress.access_request'); + expect(emitSignalCalls[0].sourceChannel).toBe(config.channel); + + const payload = emitSignalCalls[0].contextPayload as Record; + expect(payload.senderExternalUserId).toBe(config.senderExternalUserId); + + // Approval request was created for the correct channel + const pending = findPendingAccessRequestForRequester( + 'self', + config.channel, + config.senderExternalUserId, + 'ingress_access_request', + ); + expect(pending).not.toBeNull(); + expect(pending!.channel).toBe(config.channel); + }); + + test('verification creates active member for channel', () => { + const session = createOutboundSession({ + assistantId: 'self', + channel: config.channel, + expectedExternalUserId: config.senderExternalUserId, + expectedChatId: config.externalChatId, + identityBindingStatus: 'bound', + destinationAddress: config.externalChatId, + }); + + const result = validateAndConsumeChallenge( + 'self', + config.channel, + session.secret, + config.senderExternalUserId, + config.externalChatId, + 'test_requester', + 'Test Requester', + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.verificationType).toBe('trusted_contact'); + } + + upsertMember({ + assistantId: 'self', + sourceChannel: config.channel, + externalUserId: config.senderExternalUserId, + externalChatId: config.externalChatId, + status: 'active', + policy: 'allow', + displayName: 'Test Requester', + username: 'test_requester', + }); + + const member = findMember({ + assistantId: 'self', + sourceChannel: config.channel, + externalUserId: config.senderExternalUserId, + }); + + expect(member).not.toBeNull(); + expect(member!.status).toBe('active'); + expect(member!.policy).toBe('allow'); + expect(member!.sourceChannel).toBe(config.channel); + }); + + test('no cross-channel leakage between member records', () => { + // Create a member for this channel + upsertMember({ + assistantId: 'self', + sourceChannel: config.channel, + externalUserId: config.senderExternalUserId, + externalChatId: config.externalChatId, + status: 'active', + policy: 'allow', + }); + + // Should be found on this channel + const sameChanMember = findMember({ + assistantId: 'self', + sourceChannel: config.channel, + externalUserId: config.senderExternalUserId, + }); + expect(sameChanMember).not.toBeNull(); + + // Should NOT be found on a different channel + const otherChannel = config.channel === 'telegram' ? 'sms' : 'telegram'; + const crossChanMember = findMember({ + assistantId: 'self', + sourceChannel: otherChannel, + externalUserId: config.senderExternalUserId, + }); + expect(crossChanMember).toBeNull(); + }); + }); +} + +// --------------------------------------------------------------------------- +// SMS-specific: phone E.164 identity binding +// --------------------------------------------------------------------------- + +describe('SMS identity binding with E.164 phone numbers', () => { + beforeEach(() => { + resetState(); + }); + + test('SMS verification session binds to phone E.164', () => { + const phone = '+15551234567'; + const session = createOutboundSession({ + assistantId: 'self', + channel: 'sms', + expectedExternalUserId: phone, + expectedPhoneE164: phone, + expectedChatId: phone, + identityBindingStatus: 'bound', + destinationAddress: phone, + }); + + // Verify with matching phone identity + const result = validateAndConsumeChallenge( + 'self', 'sms', session.secret, + phone, phone, + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.verificationType).toBe('trusted_contact'); + } + }); + + test('SMS verification rejects mismatched phone identity', () => { + const expectedPhone = '+15551234567'; + const wrongPhone = '+15559999999'; + + const session = createOutboundSession({ + assistantId: 'self', + channel: 'sms', + expectedExternalUserId: expectedPhone, + expectedPhoneE164: expectedPhone, + expectedChatId: expectedPhone, + identityBindingStatus: 'bound', + destinationAddress: expectedPhone, + }); + + // Try to verify with a different phone (anti-oracle: same error message) + const result = validateAndConsumeChallenge( + 'self', 'sms', session.secret, + wrongPhone, wrongPhone, + ); + expect(result.success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Cross-channel: same user on different channels gets separate sessions +// --------------------------------------------------------------------------- + +describe('cross-channel isolation', () => { + beforeEach(() => { + resetState(); + }); + + test('verification sessions are scoped per channel', () => { + // Create sessions on both channels + const telegramSession = createOutboundSession({ + assistantId: 'self', + channel: 'telegram', + expectedExternalUserId: 'user-123', + expectedChatId: 'chat-123', + identityBindingStatus: 'bound', + destinationAddress: 'chat-123', + }); + + const smsSession = createOutboundSession({ + assistantId: 'self', + channel: 'sms', + expectedExternalUserId: '+15551234567', + expectedPhoneE164: '+15551234567', + expectedChatId: '+15551234567', + identityBindingStatus: 'bound', + destinationAddress: '+15551234567', + }); + + // Telegram code should not work on SMS channel + const wrongChannelResult = validateAndConsumeChallenge( + 'self', 'sms', telegramSession.secret, + '+15551234567', '+15551234567', + ); + expect(wrongChannelResult.success).toBe(false); + + // SMS code should work on SMS channel + const correctChannelResult = validateAndConsumeChallenge( + 'self', 'sms', smsSession.secret, + '+15551234567', '+15551234567', + ); + expect(correctChannelResult.success).toBe(true); + }); +}); From 9a11e3666c23729f261b07b865caa44b9ae73faf Mon Sep 17 00:00:00 2001 From: NgoHarrison Date: Thu, 26 Feb 2026 13:28:44 -0500 Subject: [PATCH 09/14] fix: wrap notifyGuardianOfAccessRequest in try-catch for graceful degradation (#9641) Co-authored-by: Harrison Ngo Co-authored-by: Claude --- .../runtime/routes/inbound-message-handler.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/assistant/src/runtime/routes/inbound-message-handler.ts b/assistant/src/runtime/routes/inbound-message-handler.ts index 1aa64017a9c..c23e7ce0e04 100644 --- a/assistant/src/runtime/routes/inbound-message-handler.ts +++ b/assistant/src/runtime/routes/inbound-message-handler.ts @@ -264,14 +264,18 @@ export async function handleChannelInbound( // Notify the guardian about the access request so they can approve/deny. // Only fires when a guardian binding exists and no duplicate pending // request already exists for this requester. - notifyGuardianOfAccessRequest({ - canonicalAssistantId, - sourceChannel, - externalChatId, - senderExternalUserId: body.senderExternalUserId, - senderName: body.senderName, - senderUsername: body.senderUsername, - }); + try { + notifyGuardianOfAccessRequest({ + canonicalAssistantId, + sourceChannel, + externalChatId, + senderExternalUserId: body.senderExternalUserId, + senderName: body.senderName, + senderUsername: body.senderUsername, + }); + } catch (err) { + log.error({ err, sourceChannel, externalChatId }, 'Failed to notify guardian of access request'); + } return Response.json({ accepted: true, denied: true, reason: 'not_a_member' }); } From a5ab9f6fbfa3ac2aefc0c7d14f885f27c455faad Mon Sep 17 00:00:00 2001 From: NgoHarrison Date: Thu, 26 Feb 2026 13:30:20 -0500 Subject: [PATCH 10/14] fix: scope notification dedupe key to approval request ID (#9643) Co-authored-by: Harrison Ngo Co-authored-by: Claude --- assistant/src/runtime/routes/inbound-message-handler.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/assistant/src/runtime/routes/inbound-message-handler.ts b/assistant/src/runtime/routes/inbound-message-handler.ts index c23e7ce0e04..30732aab750 100644 --- a/assistant/src/runtime/routes/inbound-message-handler.ts +++ b/assistant/src/runtime/routes/inbound-message-handler.ts @@ -1121,7 +1121,7 @@ function notifyGuardianOfAccessRequest(params: { const senderIdentifier = senderName || senderUsername || senderExternalUserId; - createApprovalRequest({ + const approvalRequest = createApprovalRequest({ runId: `ingress-access-request-${Date.now()}`, conversationId: `access-req-${sourceChannel}-${senderExternalUserId}`, assistantId: canonicalAssistantId, @@ -1155,9 +1155,10 @@ function notifyGuardianOfAccessRequest(params: { senderUsername: senderUsername ?? null, senderIdentifier, }, - // Deduplicate at the notification pipeline level too, keyed on the - // requester identity so repeated messages don't flood the guardian. - dedupeKey: `access-request:${canonicalAssistantId}:${sourceChannel}:${senderExternalUserId}`, + // Scoped to the approval request ID so duplicate notifications for the + // same request are suppressed, but a new request (after deny/expire) + // gets its own dedupe key and the guardian is notified again. + dedupeKey: `access-request:${approvalRequest.id}`, }); log.info( From c9c0c27a85fdf2af94947148dc737988a5a9a267 Mon Sep 17 00:00:00 2001 From: NgoHarrison Date: Thu, 26 Feb 2026 13:31:00 -0500 Subject: [PATCH 11/14] fix: add requestId to access request approval for callback button routing (#9644) Co-authored-by: Harrison Ngo Co-authored-by: Claude --- assistant/src/runtime/routes/inbound-message-handler.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/assistant/src/runtime/routes/inbound-message-handler.ts b/assistant/src/runtime/routes/inbound-message-handler.ts index 30732aab750..37a23fdd1ac 100644 --- a/assistant/src/runtime/routes/inbound-message-handler.ts +++ b/assistant/src/runtime/routes/inbound-message-handler.ts @@ -1120,9 +1120,11 @@ function notifyGuardianOfAccessRequest(params: { } const senderIdentifier = senderName || senderUsername || senderExternalUserId; + const requestId = `access-req-${canonicalAssistantId}-${sourceChannel}-${senderExternalUserId}-${Date.now()}`; const approvalRequest = createApprovalRequest({ runId: `ingress-access-request-${Date.now()}`, + requestId, conversationId: `access-req-${sourceChannel}-${senderExternalUserId}`, assistantId: canonicalAssistantId, channel: sourceChannel, @@ -1148,6 +1150,7 @@ function notifyGuardianOfAccessRequest(params: { visibleInSourceNow: false, }, contextPayload: { + requestId, sourceChannel, externalChatId, senderExternalUserId, From 4a2280d0a65a2cf224230f4b45e3f4db647619cc Mon Sep 17 00:00:00 2001 From: NgoHarrison Date: Thu, 26 Feb 2026 14:21:49 -0500 Subject: [PATCH 12/14] fix: restore guardian binding on outbound verification by distinguishing verification purpose (#9695) Co-authored-by: Harrison Ngo Co-authored-by: Claude --- .../src/__tests__/channel-guardian.test.ts | 7 ++++--- ...sted-contact-lifecycle-notifications.test.ts | 2 ++ .../trusted-contact-multichannel.test.ts | 2 ++ .../trusted-contact-verification.test.ts | 5 +++++ assistant/src/memory/channel-guardian-store.ts | 6 ++++++ assistant/src/memory/db-init.ts | 4 ++++ .../030-guardian-verification-purpose.ts | 17 +++++++++++++++++ assistant/src/memory/migrations/index.ts | 1 + assistant/src/memory/schema.ts | 2 ++ .../src/runtime/channel-guardian-service.ts | 15 +++++++++------ .../runtime/routes/access-request-decision.ts | 4 +++- 11 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 assistant/src/memory/migrations/030-guardian-verification-purpose.ts diff --git a/assistant/src/__tests__/channel-guardian.test.ts b/assistant/src/__tests__/channel-guardian.test.ts index 7a89613841b..5c94e36d8f9 100644 --- a/assistant/src/__tests__/channel-guardian.test.ts +++ b/assistant/src/__tests__/channel-guardian.test.ts @@ -2740,9 +2740,10 @@ describe('outbound SMS verification', () => { expect(result.success).toBe(true); if (result.success) { - // Identity-bound outbound sessions produce trusted_contact verification - // (no guardian binding created) - expect(result.verificationType).toBe('trusted_contact'); + // Guardian outbound sessions (no verificationPurpose override) create + // guardian bindings on success + expect(result.verificationType).toBe('guardian'); + expect(result.bindingId).toBeDefined(); } }); diff --git a/assistant/src/__tests__/trusted-contact-lifecycle-notifications.test.ts b/assistant/src/__tests__/trusted-contact-lifecycle-notifications.test.ts index 21ef6c45168..e4a8a25d925 100644 --- a/assistant/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +++ b/assistant/src/__tests__/trusted-contact-lifecycle-notifications.test.ts @@ -388,6 +388,7 @@ describe('trusted contact activated notification signal', () => { expectedChatId: 'chat-123', identityBindingStatus: 'bound', destinationAddress: 'chat-123', + verificationPurpose: 'trusted_contact', }); // Requester enters the verification code @@ -460,6 +461,7 @@ describe('trusted contact activated notification signal', () => { expectedChatId: 'chat-123', identityBindingStatus: 'bound', destinationAddress: 'chat-123', + verificationPurpose: 'trusted_contact', }); const verifyReq = buildInboundRequest({ diff --git a/assistant/src/__tests__/trusted-contact-multichannel.test.ts b/assistant/src/__tests__/trusted-contact-multichannel.test.ts index f16c0f215d6..e5af796bb84 100644 --- a/assistant/src/__tests__/trusted-contact-multichannel.test.ts +++ b/assistant/src/__tests__/trusted-contact-multichannel.test.ts @@ -234,6 +234,7 @@ for (const config of CHANNEL_CONFIGS) { expectedChatId: config.externalChatId, identityBindingStatus: 'bound', destinationAddress: config.externalChatId, + verificationPurpose: 'trusted_contact', }); const result = validateAndConsumeChallenge( @@ -324,6 +325,7 @@ describe('SMS identity binding with E.164 phone numbers', () => { expectedChatId: phone, identityBindingStatus: 'bound', destinationAddress: phone, + verificationPurpose: 'trusted_contact', }); // Verify with matching phone identity diff --git a/assistant/src/__tests__/trusted-contact-verification.test.ts b/assistant/src/__tests__/trusted-contact-verification.test.ts index 854b5fad601..d16719b4da6 100644 --- a/assistant/src/__tests__/trusted-contact-verification.test.ts +++ b/assistant/src/__tests__/trusted-contact-verification.test.ts @@ -95,6 +95,7 @@ describe('trusted contact verification → member activation', () => { expectedChatId: 'requester-chat-123', identityBindingStatus: 'bound', destinationAddress: 'requester-chat-123', + verificationPurpose: 'trusted_contact', }); // Requester enters the 6-digit code @@ -152,6 +153,7 @@ describe('trusted contact verification → member activation', () => { expectedChatId: 'requester-chat-456', identityBindingStatus: 'bound', destinationAddress: 'requester-chat-456', + verificationPurpose: 'trusted_contact', }); validateAndConsumeChallenge( @@ -192,6 +194,7 @@ describe('trusted contact verification → member activation', () => { expectedChatId: 'chat-cross-test', identityBindingStatus: 'bound', destinationAddress: 'chat-cross-test', + verificationPurpose: 'trusted_contact', }); validateAndConsumeChallenge( @@ -260,6 +263,7 @@ describe('trusted contact verification → member activation', () => { expectedChatId: 'chat-revoked', identityBindingStatus: 'bound', destinationAddress: 'chat-revoked', + verificationPurpose: 'trusted_contact', }); // Requester enters the new code @@ -312,6 +316,7 @@ describe('trusted contact verification → member activation', () => { expectedChatId: 'requester-chat-789', identityBindingStatus: 'bound', destinationAddress: 'requester-chat-789', + verificationPurpose: 'trusted_contact', }); const result = validateAndConsumeChallenge( diff --git a/assistant/src/memory/channel-guardian-store.ts b/assistant/src/memory/channel-guardian-store.ts index 1f777993985..2289deec3a2 100644 --- a/assistant/src/memory/channel-guardian-store.ts +++ b/assistant/src/memory/channel-guardian-store.ts @@ -28,6 +28,7 @@ export type ChallengeStatus = 'pending' | 'consumed' | 'expired' | 'revoked'; export type SessionStatus = 'pending' | 'consumed' | 'pending_bootstrap' | 'awaiting_response' | 'verified' | 'expired' | 'revoked' | 'locked'; export type IdentityBindingStatus = 'pending_bootstrap' | 'bound'; export type ApprovalRequestStatus = 'pending' | 'approved' | 'denied' | 'expired' | 'cancelled'; +export type VerificationPurpose = 'guardian' | 'trusted_contact'; export interface GuardianBinding { id: string; @@ -66,6 +67,8 @@ export interface VerificationChallenge { // Session configuration codeDigits: number; maxAttempts: number; + // Distinguishes guardian verification from trusted contact verification + verificationPurpose: VerificationPurpose; // Telegram bootstrap deep-link token hash bootstrapTokenHash: string | null; createdAt: number; @@ -134,6 +137,7 @@ function rowToChallenge(row: typeof channelGuardianVerificationChallenges.$infer nextResendAt: row.nextResendAt ?? null, codeDigits: row.codeDigits ?? 6, maxAttempts: row.maxAttempts ?? 3, + verificationPurpose: (row.verificationPurpose as VerificationPurpose) ?? 'guardian', bootstrapTokenHash: row.bootstrapTokenHash ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -413,6 +417,7 @@ export function createVerificationSession(params: { destinationAddress?: string | null; codeDigits?: number; maxAttempts?: number; + verificationPurpose?: VerificationPurpose; bootstrapTokenHash?: string | null; }): VerificationChallenge { const db = getDb(); @@ -450,6 +455,7 @@ export function createVerificationSession(params: { nextResendAt: null, codeDigits: params.codeDigits ?? 6, maxAttempts: params.maxAttempts ?? 3, + verificationPurpose: params.verificationPurpose ?? 'guardian', bootstrapTokenHash: params.bootstrapTokenHash ?? null, createdAt: now, updatedAt: now, diff --git a/assistant/src/memory/db-init.ts b/assistant/src/memory/db-init.ts index b21e381b202..4fa8be6c889 100644 --- a/assistant/src/memory/db-init.ts +++ b/assistant/src/memory/db-init.ts @@ -20,6 +20,7 @@ import { migrateChannelInboundDeliveredSegments, migrateGuardianActionFollowup, migrateGuardianBootstrapToken, + migrateGuardianVerificationPurpose, migrateGuardianVerificationSessions, migrateMessagesFtsBackfill, migrateReminderRoutingIntent, @@ -73,6 +74,9 @@ export function initializeDb(): void { // 11c. Guardian bootstrap token hash column (Telegram deep-link flow) migrateGuardianBootstrapToken(database); + // 11d. Guardian verification purpose discriminator (guardian vs trusted_contact) + migrateGuardianVerificationPurpose(database); + // 12. Media assets createMediaAssetsTables(database); diff --git a/assistant/src/memory/migrations/030-guardian-verification-purpose.ts b/assistant/src/memory/migrations/030-guardian-verification-purpose.ts new file mode 100644 index 00000000000..32b99eb9e9b --- /dev/null +++ b/assistant/src/memory/migrations/030-guardian-verification-purpose.ts @@ -0,0 +1,17 @@ +import type { DrizzleDb } from '../db-connection.js'; + +/** + * Add verification_purpose column to channel_guardian_verification_challenges. + * Distinguishes guardian outbound verification from trusted contact verification + * so the consume path knows whether to create a guardian binding. + * + * Uses ALTER TABLE ADD COLUMN which is a no-op if the column already + * exists (caught by try/catch). + */ +export function migrateGuardianVerificationPurpose(database: DrizzleDb): void { + try { + database.run( + /*sql*/ `ALTER TABLE channel_guardian_verification_challenges ADD COLUMN verification_purpose TEXT DEFAULT 'guardian'`, + ); + } catch { /* already exists */ } +} diff --git a/assistant/src/memory/migrations/index.ts b/assistant/src/memory/migrations/index.ts index 6819fa3b7ca..069fd48739e 100644 --- a/assistant/src/memory/migrations/index.ts +++ b/assistant/src/memory/migrations/index.ts @@ -30,6 +30,7 @@ export { migrateNotificationDeliveryPairingColumns } from './027-notification-de export { migrateCallSessionMode } from './028-call-session-mode.js'; export { migrateChannelInboundDeliveredSegments } from './029-channel-inbound-delivered-segments.js'; export { migrateGuardianActionFollowup } from './030-guardian-action-followup.js'; +export { migrateGuardianVerificationPurpose } from './030-guardian-verification-purpose.js'; export { createCoreTables } from './100-core-tables.js'; export { createWatchersAndLogsTables } from './101-watchers-and-logs.js'; export { addCoreColumns } from './102-alter-table-columns.js'; diff --git a/assistant/src/memory/schema.ts b/assistant/src/memory/schema.ts index 30636e5418f..10ad949ded7 100644 --- a/assistant/src/memory/schema.ts +++ b/assistant/src/memory/schema.ts @@ -660,6 +660,8 @@ export const channelGuardianVerificationChallenges = sqliteTable('channel_guardi // Session configuration codeDigits: integer('code_digits').default(6), maxAttempts: integer('max_attempts').default(3), + // Distinguishes guardian verification from trusted contact verification + verificationPurpose: text('verification_purpose').default('guardian'), // Telegram bootstrap deep-link token hash bootstrapTokenHash: text('bootstrap_token_hash'), createdAt: integer('created_at').notNull(), diff --git a/assistant/src/runtime/channel-guardian-service.ts b/assistant/src/runtime/channel-guardian-service.ts index 40eb118dcfe..9d4478c6053 100644 --- a/assistant/src/runtime/channel-guardian-service.ts +++ b/assistant/src/runtime/channel-guardian-service.ts @@ -9,7 +9,7 @@ import { createHash,randomBytes } from 'crypto'; import { v4 as uuid } from 'uuid'; -import type { GuardianBinding, IdentityBindingStatus,SessionStatus, VerificationChallenge } from '../memory/channel-guardian-store.js'; +import type { GuardianBinding, IdentityBindingStatus, SessionStatus, VerificationChallenge, VerificationPurpose } from '../memory/channel-guardian-store.js'; import { bindSessionIdentity as storeBindSessionIdentity, consumeChallenge, @@ -273,11 +273,12 @@ export function validateAndConsumeChallenge( // Reset the rate-limit counter on success resetRateLimit(assistantId, channel, actorExternalUserId, actorChatId); - // Identity-bound outbound sessions (from the trusted contact access flow) - // should NOT create a guardian binding — the requester is becoming a trusted - // contact, not a guardian. Only unbound inbound challenges (guardian - // verification) create guardian bindings. - if (hasExpectedIdentity && challenge.identityBindingStatus === 'bound') { + // Trusted contact verification sessions (created by the access request + // approval flow) should NOT create a guardian binding — the requester is + // becoming a trusted contact, not a guardian. The explicit verificationPurpose + // field distinguishes this from guardian outbound verification which also uses + // identity-bound sessions. + if (challenge.verificationPurpose === 'trusted_contact') { return { success: true, verificationType: 'trusted_contact' }; } @@ -405,6 +406,7 @@ export function createOutboundSession(params: { maxAttempts?: number; sessionId?: string; bootstrapTokenHash?: string; + verificationPurpose?: VerificationPurpose; }): CreateOutboundSessionResult { // Use high-entropy hex for unbound bootstrap sessions to prevent brute-force; // 6-digit numeric codes are only safe when identity is already bound. @@ -430,6 +432,7 @@ export function createOutboundSession(params: { destinationAddress: params.destinationAddress, codeDigits: params.codeDigits, maxAttempts: params.maxAttempts, + verificationPurpose: params.verificationPurpose, bootstrapTokenHash: params.bootstrapTokenHash, }); diff --git a/assistant/src/runtime/routes/access-request-decision.ts b/assistant/src/runtime/routes/access-request-decision.ts index bc37d9ed755..1480ca72290 100644 --- a/assistant/src/runtime/routes/access-request-decision.ts +++ b/assistant/src/runtime/routes/access-request-decision.ts @@ -77,7 +77,8 @@ export function handleAccessRequestDecision( // On approve: create an identity-bound outbound verification session. // The session is bound to the requester's identity on the same channel - // so only the original requester can consume the code. + // so only the original requester can consume the code. Mark as + // trusted_contact so the consume path skips guardian binding creation. const session = createOutboundSession({ assistantId: approval.assistantId, channel: approval.channel, @@ -85,6 +86,7 @@ export function handleAccessRequestDecision( expectedChatId: approval.requesterChatId, identityBindingStatus: 'bound', destinationAddress: approval.requesterChatId, + verificationPurpose: 'trusted_contact', }); return { From 7c78c2b2d4bb3b1ecb7fb11b74ef93d3b840d64a Mon Sep 17 00:00:00 2001 From: NgoHarrison Date: Thu, 26 Feb 2026 14:52:15 -0500 Subject: [PATCH 13/14] fix: propagate guardian code delivery failures to prevent premature requester notification (#9733) Co-authored-by: Harrison Ngo Co-authored-by: Claude --- .../__tests__/access-request-decision.test.ts | 50 +++++++++++++++++-- .../runtime/routes/access-request-decision.ts | 37 +++++++++++++- .../routes/guardian-approval-interception.ts | 39 +++++++++++---- 3 files changed, 112 insertions(+), 14 deletions(-) diff --git a/assistant/src/__tests__/access-request-decision.test.ts b/assistant/src/__tests__/access-request-decision.test.ts index 09521863b4d..8f4d5fe6090 100644 --- a/assistant/src/__tests__/access-request-decision.test.ts +++ b/assistant/src/__tests__/access-request-decision.test.ts @@ -41,10 +41,14 @@ mock.module('../util/logger.js', () => ({ }), })); -// Track deliverChannelReply calls +// Track deliverChannelReply calls and allow injecting failures const deliverReplyCalls: Array<{ url: string; payload: Record }> = []; +let deliverReplyError: Error | null = null; mock.module('../runtime/gateway-client.js', () => ({ deliverChannelReply: async (url: string, payload: Record) => { + if (deliverReplyError) { + throw deliverReplyError; + } deliverReplyCalls.push({ url, payload }); }, })); @@ -64,6 +68,7 @@ import { deliverVerificationCodeToGuardian, notifyRequesterOfApproval, notifyRequesterOfDenial, + notifyRequesterOfDeliveryFailure, } from '../runtime/routes/access-request-decision.js'; initializeDb(); @@ -233,10 +238,11 @@ describe('access request decision handler', () => { describe('access request notification delivery', () => { beforeEach(() => { deliverReplyCalls.length = 0; + deliverReplyError = null; }); - test('delivers verification code to guardian', async () => { - await deliverVerificationCodeToGuardian({ + test('delivers verification code to guardian and returns ok', async () => { + const result = await deliverVerificationCodeToGuardian({ replyCallbackUrl: 'http://localhost:7830/deliver/telegram', guardianChatId: 'guardian-chat-789', requesterIdentifier: 'user-unknown-456', @@ -245,6 +251,7 @@ describe('access request notification delivery', () => { bearerToken: 'test-token', }); + expect(result.ok).toBe(true); expect(deliverReplyCalls.length).toBe(1); const call = deliverReplyCalls[0]; expect(call.payload.chatId).toBe('guardian-chat-789'); @@ -254,6 +261,26 @@ describe('access request notification delivery', () => { expect(text).toContain('10 minutes'); }); + test('returns failure result when guardian code delivery fails', async () => { + deliverReplyError = new Error('Gateway timeout'); + + const result = await deliverVerificationCodeToGuardian({ + replyCallbackUrl: 'http://localhost:7830/deliver/telegram', + guardianChatId: 'guardian-chat-789', + requesterIdentifier: 'user-unknown-456', + verificationCode: '123456', + assistantId: 'self', + bearerToken: 'test-token', + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('Gateway timeout'); + } + // No calls should have been recorded (error thrown before push) + expect(deliverReplyCalls.length).toBe(0); + }); + test('notifies requester of approval', async () => { await notifyRequesterOfApproval({ replyCallbackUrl: 'http://localhost:7830/deliver/telegram', @@ -284,4 +311,21 @@ describe('access request notification delivery', () => { const text = call.payload.text as string; expect(text).toContain('denied'); }); + + test('notifies requester of delivery failure', async () => { + await notifyRequesterOfDeliveryFailure({ + replyCallbackUrl: 'http://localhost:7830/deliver/telegram', + requesterChatId: 'chat-123', + assistantId: 'self', + bearerToken: 'test-token', + }); + + expect(deliverReplyCalls.length).toBe(1); + const call = deliverReplyCalls[0]; + expect(call.payload.chatId).toBe('chat-123'); + const text = call.payload.text as string; + expect(text).toContain('approved'); + expect(text).toContain('unable to deliver'); + expect(text).toContain('try again'); + }); }); diff --git a/assistant/src/runtime/routes/access-request-decision.ts b/assistant/src/runtime/routes/access-request-decision.ts index 1480ca72290..2adc7826766 100644 --- a/assistant/src/runtime/routes/access-request-decision.ts +++ b/assistant/src/runtime/routes/access-request-decision.ts @@ -21,6 +21,10 @@ const log = getLogger('access-request-decision'); export type AccessRequestDecisionAction = 'approve' | 'deny'; +export type DeliveryResult = + | { ok: true } + | { ok: false; reason: string }; + export interface AccessRequestDecisionResult { handled: boolean; type: 'approved' | 'denied' | 'stale' | 'idempotent'; @@ -108,7 +112,7 @@ export async function deliverVerificationCodeToGuardian(params: { verificationCode: string; assistantId: string; bearerToken?: string; -}): Promise { +}): Promise { const text = `You approved access for ${params.requesterIdentifier}. ` + `Give them this verification code: ${params.verificationCode}. ` + `The code expires in 10 minutes.`; @@ -119,11 +123,14 @@ export async function deliverVerificationCodeToGuardian(params: { text, assistantId: params.assistantId, }, params.bearerToken); + return { ok: true }; } catch (err) { log.error( { err, guardianChatId: params.guardianChatId }, 'Failed to deliver verification code to guardian', ); + const reason = err instanceof Error ? err.message : String(err); + return { ok: false, reason }; } } @@ -154,6 +161,34 @@ export async function notifyRequesterOfApproval(params: { } } +/** + * Notify the requester that something went wrong delivering the verification + * code and they should try again later. Sent instead of the "enter the code" + * message when guardian code delivery fails. + */ +export async function notifyRequesterOfDeliveryFailure(params: { + replyCallbackUrl: string; + requesterChatId: string; + assistantId: string; + bearerToken?: string; +}): Promise { + const text = 'Your access request was approved, but we were unable to ' + + 'deliver the verification code. Please try again later.'; + + try { + await deliverChannelReply(params.replyCallbackUrl, { + chatId: params.requesterChatId, + text, + assistantId: params.assistantId, + }, params.bearerToken); + } catch (err) { + log.error( + { err, requesterChatId: params.requesterChatId }, + 'Failed to notify requester of delivery failure', + ); + } +} + /** * Notify the requester that the guardian has denied their access request. */ diff --git a/assistant/src/runtime/routes/guardian-approval-interception.ts b/assistant/src/runtime/routes/guardian-approval-interception.ts index 0ad7233b22d..588b717031e 100644 --- a/assistant/src/runtime/routes/guardian-approval-interception.ts +++ b/assistant/src/runtime/routes/guardian-approval-interception.ts @@ -35,6 +35,8 @@ import { deliverVerificationCodeToGuardian, notifyRequesterOfApproval, notifyRequesterOfDenial, + notifyRequesterOfDeliveryFailure, + type DeliveryResult, } from './access-request-decision.js'; import { buildGuardianDenyContext, @@ -998,8 +1000,9 @@ async function handleAccessRequestApproval( // Approved: deliver the verification code to the guardian and notify the requester. const requesterIdentifier = approval.requesterExternalUserId; + let codeDelivered = true; if (decisionResult.verificationCode) { - await deliverVerificationCodeToGuardian({ + const deliveryResult: DeliveryResult = await deliverVerificationCodeToGuardian({ replyCallbackUrl, guardianChatId: approval.guardianChatId, requesterIdentifier, @@ -1007,14 +1010,31 @@ async function handleAccessRequestApproval( assistantId, bearerToken, }); + if (!deliveryResult.ok) { + log.error( + { reason: deliveryResult.reason, approvalId: approval.id }, + 'Skipping requester notification — verification code was not delivered to guardian', + ); + codeDelivered = false; + } } - await notifyRequesterOfApproval({ - replyCallbackUrl, - requesterChatId: approval.requesterChatId, - assistantId, - bearerToken, - }); + if (codeDelivered) { + await notifyRequesterOfApproval({ + replyCallbackUrl, + requesterChatId: approval.requesterChatId, + assistantId, + bearerToken, + }); + } else { + // Let the requester know something went wrong without revealing details + await notifyRequesterOfDeliveryFailure({ + replyCallbackUrl, + requesterChatId: approval.requesterChatId, + assistantId, + bearerToken, + }); + } // Emit guardian_decision (approved) signal void emitNotificationSignal({ @@ -1038,9 +1058,8 @@ async function handleAccessRequestApproval( dedupeKey: `trusted-contact:guardian-decision:${approval.id}`, }); - // Emit verification_sent signal — the code has been created and - // delivered to the guardian for out-of-band relay to the requester. - if (decisionResult.verificationSessionId) { + // Only emit verification_sent when the code was actually delivered to the guardian. + if (decisionResult.verificationSessionId && codeDelivered) { void emitNotificationSignal({ sourceEventName: 'ingress.trusted_contact.verification_sent', sourceChannel: approval.channel, From dbe687e74b4b8357e193d5bae6fad100c0301f8c Mon Sep 17 00:00:00 2001 From: NgoHarrison Date: Thu, 26 Feb 2026 15:13:21 -0500 Subject: [PATCH 14/14] fix: replace runtime-port URLs with gateway URLs in trusted-contacts skill (#9756) Co-authored-by: Harrison Ngo Co-authored-by: Claude --- assistant/docs/runbook-trusted-contacts.md | 4 ++-- .../config/vellum-skills/trusted-contacts/SKILL.md | 14 +++++++------- skills/trusted-contacts/SKILL.md | 14 +++++++------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/assistant/docs/runbook-trusted-contacts.md b/assistant/docs/runbook-trusted-contacts.md index c99697995e5..7622290bafb 100644 --- a/assistant/docs/runbook-trusted-contacts.md +++ b/assistant/docs/runbook-trusted-contacts.md @@ -1,6 +1,6 @@ # Trusted Contacts — Operator Runbook -Operational procedures for inspecting, managing, and debugging the trusted contact access flow. All HTTP commands use the runtime API (default `http://localhost:7821`) with bearer authentication. +Operational procedures for inspecting, managing, and debugging the trusted contact access flow. All HTTP commands use the gateway API (default `http://localhost:7830`) with bearer authentication. ## Prerequisites @@ -9,7 +9,7 @@ Operational procedures for inspecting, managing, and debugging the trusted conta TOKEN=$(cat ~/.vellum/http-token) # Base URL (adjust if using a non-default port) -BASE=http://localhost:7821 +BASE=http://localhost:7830 ``` ## 1. Inspect Trusted Contacts (Members) diff --git a/assistant/src/config/vellum-skills/trusted-contacts/SKILL.md b/assistant/src/config/vellum-skills/trusted-contacts/SKILL.md index c7a9aeea50e..019634dbea3 100644 --- a/assistant/src/config/vellum-skills/trusted-contacts/SKILL.md +++ b/assistant/src/config/vellum-skills/trusted-contacts/SKILL.md @@ -5,11 +5,11 @@ user-invocable: true metadata: {"vellum": {"emoji": "\ud83d\udc65"}} --- -You are helping your user manage trusted contacts for the Vellum Assistant. Trusted contacts control who is allowed to send messages to the assistant through external channels like Telegram and SMS. All operations go through the runtime HTTP API using `curl` with bearer auth. +You are helping your user manage trusted contacts for the Vellum Assistant. Trusted contacts control who is allowed to send messages to the assistant through external channels like Telegram and SMS. All operations go through the gateway HTTP API using `curl` with bearer auth. ## Prerequisites -- The runtime HTTP API is available at `http://localhost:7821` (or the configured `RUNTIME_HTTP_PORT`). +- The gateway API is available at `http://localhost:7830` (or the configured gateway port). - The bearer token is stored at `~/.vellum/http-token`. Read it with: `TOKEN=$(cat ~/.vellum/http-token)`. ## Concepts @@ -27,7 +27,7 @@ Use this to show the user who currently has access, or to look up a specific con ```bash TOKEN=$(cat ~/.vellum/http-token) -curl -s http://localhost:7821/v1/ingress/members \ +curl -s http://localhost:7830/v1/ingress/members \ -H "Authorization: Bearer $TOKEN" ``` @@ -38,7 +38,7 @@ Optional query parameters for filtering: Example with filters: ```bash -curl -s "http://localhost:7821/v1/ingress/members?sourceChannel=telegram&status=active" \ +curl -s "http://localhost:7830/v1/ingress/members?sourceChannel=telegram&status=active" \ -H "Authorization: Bearer $TOKEN" ``` @@ -63,7 +63,7 @@ Ask the user: *"I'll add [name/identifier] on [channel] as an allowed contact. S ```bash TOKEN=$(cat ~/.vellum/http-token) -curl -s -X POST http://localhost:7821/v1/ingress/members \ +curl -s -X POST http://localhost:7830/v1/ingress/members \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{ @@ -95,7 +95,7 @@ First, list members to find the member's `id`, then revoke: ```bash TOKEN=$(cat ~/.vellum/http-token) -curl -s -X DELETE "http://localhost:7821/v1/ingress/members/" \ +curl -s -X DELETE "http://localhost:7830/v1/ingress/members/" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"reason": ""}' @@ -111,7 +111,7 @@ Ask the user: *"I'll block [name/identifier]. They will be permanently denied fr ```bash TOKEN=$(cat ~/.vellum/http-token) -curl -s -X POST "http://localhost:7821/v1/ingress/members//block" \ +curl -s -X POST "http://localhost:7830/v1/ingress/members//block" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{"reason": ""}' diff --git a/skills/trusted-contacts/SKILL.md b/skills/trusted-contacts/SKILL.md index c7a9aeea50e..019634dbea3 100644 --- a/skills/trusted-contacts/SKILL.md +++ b/skills/trusted-contacts/SKILL.md @@ -5,11 +5,11 @@ user-invocable: true metadata: {"vellum": {"emoji": "\ud83d\udc65"}} --- -You are helping your user manage trusted contacts for the Vellum Assistant. Trusted contacts control who is allowed to send messages to the assistant through external channels like Telegram and SMS. All operations go through the runtime HTTP API using `curl` with bearer auth. +You are helping your user manage trusted contacts for the Vellum Assistant. Trusted contacts control who is allowed to send messages to the assistant through external channels like Telegram and SMS. All operations go through the gateway HTTP API using `curl` with bearer auth. ## Prerequisites -- The runtime HTTP API is available at `http://localhost:7821` (or the configured `RUNTIME_HTTP_PORT`). +- The gateway API is available at `http://localhost:7830` (or the configured gateway port). - The bearer token is stored at `~/.vellum/http-token`. Read it with: `TOKEN=$(cat ~/.vellum/http-token)`. ## Concepts @@ -27,7 +27,7 @@ Use this to show the user who currently has access, or to look up a specific con ```bash TOKEN=$(cat ~/.vellum/http-token) -curl -s http://localhost:7821/v1/ingress/members \ +curl -s http://localhost:7830/v1/ingress/members \ -H "Authorization: Bearer $TOKEN" ``` @@ -38,7 +38,7 @@ Optional query parameters for filtering: Example with filters: ```bash -curl -s "http://localhost:7821/v1/ingress/members?sourceChannel=telegram&status=active" \ +curl -s "http://localhost:7830/v1/ingress/members?sourceChannel=telegram&status=active" \ -H "Authorization: Bearer $TOKEN" ``` @@ -63,7 +63,7 @@ Ask the user: *"I'll add [name/identifier] on [channel] as an allowed contact. S ```bash TOKEN=$(cat ~/.vellum/http-token) -curl -s -X POST http://localhost:7821/v1/ingress/members \ +curl -s -X POST http://localhost:7830/v1/ingress/members \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{ @@ -95,7 +95,7 @@ First, list members to find the member's `id`, then revoke: ```bash TOKEN=$(cat ~/.vellum/http-token) -curl -s -X DELETE "http://localhost:7821/v1/ingress/members/" \ +curl -s -X DELETE "http://localhost:7830/v1/ingress/members/" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"reason": ""}' @@ -111,7 +111,7 @@ Ask the user: *"I'll block [name/identifier]. They will be permanently denied fr ```bash TOKEN=$(cat ~/.vellum/http-token) -curl -s -X POST "http://localhost:7821/v1/ingress/members//block" \ +curl -s -X POST "http://localhost:7830/v1/ingress/members//block" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{"reason": ""}'