diff --git a/assistant/ARCHITECTURE.md b/assistant/ARCHITECTURE.md index 21fe9575c2f..2ed27f10e01 100644 --- a/assistant/ARCHITECTURE.md +++ b/assistant/ARCHITECTURE.md @@ -21,7 +21,6 @@ This document owns assistant-runtime architecture details. The repo-level archit - Runtime channel runs pass this as `guardianContext`, and session runtime assembly injects `` into provider-facing prompts. - Voice calls mirror the same prompt contract: `CallController` receives guardian context on setup and refreshes it immediately after successful voice challenge verification, so the first post-verification turn is grounded as `actor_role: guardian`. - Voice-specific behavior (DTMF/speech verification flow, relay state machine) remains voice-local; only actor-role resolution is shared. -- Outbound guardian verification supports two code-submission paths: (1) the user receives a code via SMS/Telegram/voice call and enters it in the macOS Settings UI (`submit_outbound_code` IPC action), or (2) the user replies in-channel with `/guardian_verify ` (backward-compatible fallback). Outbound voice calls speak the code and instruct entering it in the app (no DTMF entry required). ### SMS Channel (Twilio) diff --git a/assistant/src/__tests__/channel-guardian.test.ts b/assistant/src/__tests__/channel-guardian.test.ts index a5246312454..ad0dffea13a 100644 --- a/assistant/src/__tests__/channel-guardian.test.ts +++ b/assistant/src/__tests__/channel-guardian.test.ts @@ -2772,15 +2772,16 @@ describe('outbound SMS verification', () => { GUARDIAN_VERIFY_TEMPLATE_KEYS.CHALLENGE_REQUEST, { code: '123456', expiresInMinutes: 10 }, ); - expect(challengeSms).toContain('123456'); + // Code should NOT appear in the message — user sees it in the app + expect(challengeSms).not.toContain('123456'); expect(challengeSms).toContain('10 minutes'); - expect(challengeSms).toContain('verification code'); + expect(challengeSms).toContain('Vellum assistant app'); const resendSms = composeVerificationSms( GUARDIAN_VERIFY_TEMPLATE_KEYS.RESEND, { code: 'ABCDEF', expiresInMinutes: 5 }, ); - expect(resendSms).toContain('ABCDEF'); + expect(resendSms).not.toContain('ABCDEF'); expect(resendSms).toContain('5 minutes'); expect(resendSms).toContain('resent'); @@ -2876,7 +2877,8 @@ describe('outbound SMS verification', () => { { code: '999999', expiresInMinutes: 10, assistantName: 'MyBot' }, ); expect(sms).toContain('[MyBot]'); - expect(sms).toContain('999999'); + // Code should NOT appear in the message + expect(sms).not.toContain('999999'); }); test('cancel_outbound returns error when no active session', () => { @@ -3273,12 +3275,15 @@ describe('outbound Telegram verification', () => { expect(revoked).toBeNull(); }); - test('telegram template includes /guardian_verify command', () => { + test('telegram template includes /guardian_verify instruction without code', () => { const msg = composeVerificationTelegram( GUARDIAN_VERIFY_TEMPLATE_KEYS.TELEGRAM_CHALLENGE_REQUEST, { code: 'abc123', expiresInMinutes: 10 }, ); - expect(msg).toContain('/guardian_verify abc123'); + // Should mention /guardian_verify but NOT include the actual code + expect(msg).toContain('/guardian_verify'); + expect(msg).not.toContain('abc123'); + expect(msg).toContain('Vellum assistant app'); }); test('telegram resend template includes (resent) suffix', () => { @@ -3286,7 +3291,8 @@ describe('outbound Telegram verification', () => { GUARDIAN_VERIFY_TEMPLATE_KEYS.TELEGRAM_RESEND, { code: 'xyz789', expiresInMinutes: 5 }, ); - expect(msg).toContain('/guardian_verify xyz789'); + expect(msg).toContain('/guardian_verify'); + expect(msg).not.toContain('xyz789'); expect(msg).toContain('(resent)'); }); @@ -3296,7 +3302,8 @@ describe('outbound Telegram verification', () => { { code: '999999', expiresInMinutes: 10, assistantName: 'MyBot' }, ); expect(msg).toContain('[MyBot]'); - expect(msg).toContain('999999'); + // Code should NOT appear in the message + expect(msg).not.toContain('999999'); }); test('start_outbound for telegram with missing destination fails', () => { @@ -3760,280 +3767,3 @@ describe('M1–M4 hardening coverage', () => { expect(voiceResult.secret).toMatch(/^\d{6}$/); }); }); - -// ═══════════════════════════════════════════════════════════════════════════ -// Submit Outbound Code (IPC handler) -// ═══════════════════════════════════════════════════════════════════════════ - -describe('submit_outbound_code via IPC handler', () => { - beforeEach(() => { - resetTables(); - }); - - test('submit_outbound_code success for SMS binds guardian', () => { - // Create an outbound session for SMS - const session = createOutboundSession({ - assistantId: 'self', - channel: 'sms', - expectedPhoneE164: '+15551234567', - expectedExternalUserId: '+15551234567', - destinationAddress: '+15551234567', - codeDigits: 6, - }); - - const { ctx, lastResponse } = createMockCtx(); - const msg: GuardianVerificationRequest = { - type: 'guardian_verification', - action: 'submit_outbound_code', - channel: 'sms', - assistantId: 'self', - sessionId: session.sessionId, - verificationCode: session.secret, - }; - - handleGuardianVerification(msg, mockSocket, ctx); - - const resp = lastResponse(); - expect(resp).not.toBeNull(); - expect(resp!.success).toBe(true); - expect(resp!.bound).toBe(true); - expect(resp!.channel).toBe('sms'); - - // Verify binding was created - const binding = getActiveBinding('self', 'sms'); - expect(binding).not.toBeNull(); - expect(binding!.guardianExternalUserId).toBe('+15551234567'); - }); - - test('submit_outbound_code success for Telegram binds guardian', () => { - const session = createOutboundSession({ - assistantId: 'self', - channel: 'telegram', - expectedChatId: 'chat-123', - expectedExternalUserId: 'user-123', - destinationAddress: 'chat-123', - }); - - const { ctx, lastResponse } = createMockCtx(); - const msg: GuardianVerificationRequest = { - type: 'guardian_verification', - action: 'submit_outbound_code', - channel: 'telegram', - assistantId: 'self', - sessionId: session.sessionId, - verificationCode: session.secret, - }; - - handleGuardianVerification(msg, mockSocket, ctx); - - const resp = lastResponse(); - expect(resp).not.toBeNull(); - expect(resp!.success).toBe(true); - expect(resp!.bound).toBe(true); - - const binding = getActiveBinding('self', 'telegram'); - expect(binding).not.toBeNull(); - expect(binding!.guardianExternalUserId).toBe('user-123'); - expect(binding!.guardianDeliveryChatId).toBe('chat-123'); - }); - - test('submit_outbound_code success for voice binds guardian', () => { - const session = createOutboundSession({ - assistantId: 'self', - channel: 'voice', - expectedPhoneE164: '+15559876543', - expectedExternalUserId: '+15559876543', - destinationAddress: '+15559876543', - codeDigits: 6, - }); - - const { ctx, lastResponse } = createMockCtx(); - const msg: GuardianVerificationRequest = { - type: 'guardian_verification', - action: 'submit_outbound_code', - channel: 'voice', - assistantId: 'self', - verificationCode: session.secret, - }; - - handleGuardianVerification(msg, mockSocket, ctx); - - const resp = lastResponse(); - expect(resp).not.toBeNull(); - expect(resp!.success).toBe(true); - expect(resp!.bound).toBe(true); - - const binding = getActiveBinding('self', 'voice'); - expect(binding).not.toBeNull(); - expect(binding!.guardianExternalUserId).toBe('+15559876543'); - }); - - test('submit_outbound_code invalid code returns error and keeps session', () => { - const session = createOutboundSession({ - assistantId: 'self', - channel: 'sms', - expectedPhoneE164: '+15551234567', - expectedExternalUserId: '+15551234567', - destinationAddress: '+15551234567', - codeDigits: 6, - }); - - const { ctx, lastResponse } = createMockCtx(); - const msg: GuardianVerificationRequest = { - type: 'guardian_verification', - action: 'submit_outbound_code', - channel: 'sms', - assistantId: 'self', - sessionId: session.sessionId, - verificationCode: '000000', - }; - - handleGuardianVerification(msg, mockSocket, ctx); - - const resp = lastResponse(); - expect(resp).not.toBeNull(); - expect(resp!.success).toBe(false); - expect(resp!.error).toBe('invalid_code'); - - // Session should still be active for retry - const activeSession = serviceFindActiveSession('self', 'sms'); - expect(activeSession).not.toBeNull(); - - // No binding should be created - const binding = getActiveBinding('self', 'sms'); - expect(binding).toBeNull(); - }); - - test('submit_outbound_code no active session returns error', () => { - const { ctx, lastResponse } = createMockCtx(); - const msg: GuardianVerificationRequest = { - type: 'guardian_verification', - action: 'submit_outbound_code', - channel: 'sms', - assistantId: 'self', - verificationCode: '123456', - }; - - handleGuardianVerification(msg, mockSocket, ctx); - - const resp = lastResponse(); - expect(resp).not.toBeNull(); - expect(resp!.success).toBe(false); - expect(resp!.error).toBe('no_active_session'); - }); - - test('submit_outbound_code rejects pending_bootstrap session', () => { - createOutboundSession({ - assistantId: 'self', - channel: 'telegram', - expectedExternalUserId: 'user-123', - identityBindingStatus: 'pending_bootstrap', - }); - - const { ctx, lastResponse } = createMockCtx(); - const msg: GuardianVerificationRequest = { - type: 'guardian_verification', - action: 'submit_outbound_code', - channel: 'telegram', - assistantId: 'self', - verificationCode: '123456', - }; - - handleGuardianVerification(msg, mockSocket, ctx); - - const resp = lastResponse(); - expect(resp).not.toBeNull(); - expect(resp!.success).toBe(false); - expect(resp!.error).toBe('pending_bootstrap'); - }); - - test('submit_outbound_code rejects mismatched sessionId', () => { - createOutboundSession({ - assistantId: 'self', - channel: 'sms', - expectedPhoneE164: '+15551234567', - expectedExternalUserId: '+15551234567', - destinationAddress: '+15551234567', - codeDigits: 6, - }); - - const { ctx, lastResponse } = createMockCtx(); - const msg: GuardianVerificationRequest = { - type: 'guardian_verification', - action: 'submit_outbound_code', - channel: 'sms', - assistantId: 'self', - sessionId: 'wrong-session-id', - verificationCode: '123456', - }; - - handleGuardianVerification(msg, mockSocket, ctx); - - const resp = lastResponse(); - expect(resp).not.toBeNull(); - expect(resp!.success).toBe(false); - expect(resp!.error).toBe('session_mismatch'); - }); - - test('submit_outbound_code rejects missing code', () => { - createOutboundSession({ - assistantId: 'self', - channel: 'sms', - expectedPhoneE164: '+15551234567', - expectedExternalUserId: '+15551234567', - destinationAddress: '+15551234567', - codeDigits: 6, - }); - - const { ctx, lastResponse } = createMockCtx(); - const msg: GuardianVerificationRequest = { - type: 'guardian_verification', - action: 'submit_outbound_code', - channel: 'sms', - assistantId: 'self', - }; - - handleGuardianVerification(msg, mockSocket, ctx); - - const resp = lastResponse(); - expect(resp).not.toBeNull(); - expect(resp!.success).toBe(false); - expect(resp!.error).toBe('missing_code'); - }); - - test('submit_outbound_code rejects already_bound channel', () => { - // Create existing binding - createBinding({ - assistantId: 'self', - channel: 'sms', - guardianExternalUserId: '+15559999999', - guardianDeliveryChatId: '+15559999999', - }); - - // Create outbound session - const session = createOutboundSession({ - assistantId: 'self', - channel: 'sms', - expectedPhoneE164: '+15551234567', - expectedExternalUserId: '+15551234567', - destinationAddress: '+15551234567', - codeDigits: 6, - }); - - const { ctx, lastResponse } = createMockCtx(); - const msg: GuardianVerificationRequest = { - type: 'guardian_verification', - action: 'submit_outbound_code', - channel: 'sms', - assistantId: 'self', - verificationCode: session.secret, - }; - - handleGuardianVerification(msg, mockSocket, ctx); - - const resp = lastResponse(); - expect(resp).not.toBeNull(); - expect(resp!.success).toBe(false); - expect(resp!.error).toBe('already_bound'); - }); -}); diff --git a/assistant/src/calls/call-domain.ts b/assistant/src/calls/call-domain.ts index a4c7a1bf1cb..11f16bc19d5 100644 --- a/assistant/src/calls/call-domain.ts +++ b/assistant/src/calls/call-domain.ts @@ -572,8 +572,6 @@ export type StartGuardianVerificationCallInput = { phoneNumber: string; guardianVerificationSessionId: string; assistantId?: string; - /** Plaintext verification code to speak during outbound call. */ - guardianVerificationSecret?: string; }; export type StartGuardianVerificationCallResult = @@ -590,7 +588,7 @@ export type StartGuardianVerificationCallResult = export async function startGuardianVerificationCall( input: StartGuardianVerificationCallInput, ): Promise { - const { phoneNumber, guardianVerificationSessionId, assistantId = 'self', guardianVerificationSecret } = input; + const { phoneNumber, guardianVerificationSessionId, assistantId = 'self' } = input; if (!phoneNumber || !E164_REGEX.test(phoneNumber)) { return { ok: false, error: 'phone_number must be in E.164 format', status: 400 }; @@ -635,7 +633,6 @@ export async function startGuardianVerificationCall( statusCallbackUrl, customParams: { guardianVerificationSessionId, - ...(guardianVerificationSecret ? { guardianVerificationSecret } : {}), }, }); diff --git a/assistant/src/calls/relay-server.ts b/assistant/src/calls/relay-server.ts index fa631245423..b85e02f82de 100644 --- a/assistant/src/calls/relay-server.ts +++ b/assistant/src/calls/relay-server.ts @@ -420,11 +420,10 @@ export class RelayConnection { const persistedMode = session?.callMode; const persistedGvSessionId = session?.guardianVerificationSessionId; const customParamGvSessionId = msg.customParameters?.guardianVerificationSessionId; - const customParamGvSecret = msg.customParameters?.guardianVerificationSecret; const guardianVerificationSessionId = persistedGvSessionId ?? customParamGvSessionId; if (persistedMode === 'guardian_verification' && guardianVerificationSessionId) { - this.startOutboundGuardianVerification(assistantId, guardianVerificationSessionId, msg.to, customParamGvSecret); + this.startOutboundGuardianVerification(assistantId, guardianVerificationSessionId, msg.to); return; } @@ -434,7 +433,7 @@ export class RelayConnection { { callSessionId: this.callSessionId, guardianVerificationSessionId: customParamGvSessionId }, 'Guardian verification detected via setup custom parameter (no persisted call_mode) — entering verification path', ); - this.startOutboundGuardianVerification(assistantId, customParamGvSessionId, msg.to, customParamGvSecret); + this.startOutboundGuardianVerification(assistantId, customParamGvSessionId, msg.to); return; } @@ -555,53 +554,34 @@ export class RelayConnection { assistantId: string, guardianVerificationSessionId: string, toNumber: string, - secret?: string, ): void { this.guardianVerificationActive = true; this.outboundGuardianVerificationSessionId = guardianVerificationSessionId; this.guardianChallengeAssistantId = assistantId; // For outbound guardian calls, the "to" number is the guardian's phone this.guardianVerificationFromNumber = toNumber; + this.connectionState = 'verification_pending'; + this.verificationAttempts = 0; + this.verificationMaxAttempts = 3; this.verificationCodeLength = 6; + this.dtmfBuffer = ''; recordCallEvent(this.callSessionId, 'outbound_guardian_voice_verification_started', { assistantId, guardianVerificationSessionId, + maxAttempts: this.verificationMaxAttempts, }); - if (secret) { - // Outbound flow: speak the code and instruct the user to enter it in the app, then end the call. - this.connectionState = 'connected'; - this.guardianVerificationActive = false; - - const codeText = composeVerificationVoice( - GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_OUTBOUND_CODE, - { codeDigits: this.verificationCodeLength, code: secret }, - ); - this.sendTextToken(codeText, true); - - log.info( - { callSessionId: this.callSessionId, assistantId, guardianVerificationSessionId }, - 'Outbound guardian voice verification: code spoken, verification via UI', - ); - } else { - // Legacy fallback: prompt for DTMF/speech code entry - this.connectionState = 'verification_pending'; - this.verificationAttempts = 0; - this.verificationMaxAttempts = 3; - this.dtmfBuffer = ''; - - const introText = composeVerificationVoice( - GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_CALL_INTRO, - { codeDigits: this.verificationCodeLength }, - ); - this.sendTextToken(introText, true); + const introText = composeVerificationVoice( + GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_CALL_INTRO, + { codeDigits: this.verificationCodeLength }, + ); + this.sendTextToken(introText, true); - log.info( - { callSessionId: this.callSessionId, assistantId, guardianVerificationSessionId }, - 'Outbound guardian voice verification started (DTMF mode)', - ); - } + log.info( + { callSessionId: this.callSessionId, assistantId, guardianVerificationSessionId }, + 'Outbound guardian voice verification started', + ); } /** diff --git a/assistant/src/daemon/handlers/config-channels.ts b/assistant/src/daemon/handlers/config-channels.ts index 316b3191303..31af6aac358 100644 --- a/assistant/src/daemon/handlers/config-channels.ts +++ b/assistant/src/daemon/handlers/config-channels.ts @@ -1,4 +1,4 @@ -import { randomBytes, createHash, timingSafeEqual } from 'node:crypto'; +import { randomBytes, createHash } from 'node:crypto'; import * as net from 'node:net'; import * as externalConversationStore from '../../memory/external-conversation-store.js'; @@ -14,7 +14,6 @@ import { updateSessionStatus, updateSessionDelivery, } from '../../runtime/channel-guardian-service.js'; -import { createBinding } from '../../memory/channel-guardian-store.js'; import { createReadinessService, type ChannelReadinessService } from '../../runtime/channel-readiness-service.js'; import { composeVerificationSms, @@ -240,8 +239,6 @@ export function handleGuardianVerification( handleResendOutbound(msg, socket, ctx, assistantId, channel); } else if (msg.action === 'cancel_outbound') { handleCancelOutbound(socket, ctx, assistantId, channel); - } else if (msg.action === 'submit_outbound_code') { - handleSubmitOutboundCode(msg, socket, ctx, assistantId, channel); } else { ctx.send(socket, { type: 'guardian_verification_response', @@ -605,9 +602,8 @@ function handleStartOutboundVoice( updateSessionDelivery(sessionResult.sessionId, now, sendCount, nextResendAt); - // Initiate the outbound Twilio call (fire-and-forget), passing the secret - // so the relay can speak it to the guardian. - initiateGuardianVoiceCall(destination, sessionResult.sessionId, assistantId, sessionResult.secret); + // Initiate the outbound Twilio call (fire-and-forget) + initiateGuardianVoiceCall(destination, sessionResult.sessionId, assistantId); ctx.send(socket, { type: 'guardian_verification_response', @@ -759,7 +755,7 @@ function handleResendOutbound( const nextResendAt = now + RESEND_COOLDOWN_MS; updateSessionDelivery(newSession.sessionId, now, newSendCount, nextResendAt); - initiateGuardianVoiceCall(destination, newSession.sessionId, assistantId, newSession.secret); + initiateGuardianVoiceCall(destination, newSession.sessionId, assistantId); ctx.send(socket, { type: 'guardian_verification_response', @@ -834,137 +830,6 @@ function handleCancelOutbound( }); } -// --------------------------------------------------------------------------- -// Submit outbound code handler -// --------------------------------------------------------------------------- - -function handleSubmitOutboundCode( - msg: GuardianVerificationRequest, - socket: net.Socket, - ctx: HandlerContext, - assistantId: string, - channel: ChannelId, -): void { - const supportedChannels: ChannelId[] = ['sms', 'telegram', 'voice']; - if (!supportedChannels.includes(channel)) { - ctx.send(socket, { - type: 'guardian_verification_response', - success: false, - error: 'unsupported_channel', - message: `Channel "${channel}" is not supported for outbound code submission.`, - channel, - }); - return; - } - - const code = msg.verificationCode; - if (!code || code.trim().length === 0) { - ctx.send(socket, { - type: 'guardian_verification_response', - success: false, - error: 'missing_code', - message: 'A verification code is required.', - channel, - }); - return; - } - - const session = findActiveSession(assistantId, channel); - if (!session) { - ctx.send(socket, { - type: 'guardian_verification_response', - success: false, - error: 'no_active_session', - message: 'No active outbound verification session found.', - channel, - }); - return; - } - - if (session.identityBindingStatus === 'pending_bootstrap') { - ctx.send(socket, { - type: 'guardian_verification_response', - success: false, - error: 'pending_bootstrap', - message: 'Cannot submit code: waiting for bootstrap deep-link activation.', - channel, - }); - return; - } - - if (msg.sessionId && msg.sessionId !== session.id) { - ctx.send(socket, { - type: 'guardian_verification_response', - success: false, - error: 'session_mismatch', - message: 'The session ID does not match the active session.', - channel, - }); - return; - } - - // Check for existing active binding (code submission doesn't support rebind) - const existingBinding = getGuardianBinding(assistantId, channel); - if (existingBinding) { - ctx.send(socket, { - type: 'guardian_verification_response', - success: false, - error: 'already_bound', - message: 'A guardian is already bound for this channel. Revoke it first.', - channel, - }); - return; - } - - // Constant-time compare: hash the submitted code and compare to the session's challengeHash - const submittedHash = createHash('sha256').update(code.trim()).digest('hex'); - const expectedHash = session.challengeHash; - - const submittedBuf = Buffer.from(submittedHash, 'hex'); - const expectedBuf = Buffer.from(expectedHash, 'hex'); - - if (submittedBuf.length !== expectedBuf.length || !timingSafeEqual(submittedBuf, expectedBuf)) { - ctx.send(socket, { - type: 'guardian_verification_response', - success: false, - error: 'invalid_code', - message: 'The verification code is incorrect. Please try again.', - channel, - }); - return; - } - - // Code matches — bind the guardian using the session's expected identity - const guardianExternalUserId = - session.expectedExternalUserId ?? session.expectedPhoneE164 ?? session.expectedChatId ?? ''; - const guardianDeliveryChatId = - session.expectedChatId ?? session.expectedPhoneE164 ?? session.expectedExternalUserId ?? ''; - - // Revoke any existing binding before creating new one - revokeGuardianBinding(assistantId, channel); - - createBinding({ - assistantId, - channel, - guardianExternalUserId, - guardianDeliveryChatId, - verifiedVia: 'outbound_code', - }); - - // Mark session as consumed - updateSessionStatus(session.id, 'consumed', { - consumedByExternalUserId: guardianExternalUserId, - consumedByChatId: guardianDeliveryChatId, - }); - - ctx.send(socket, { - type: 'guardian_verification_response', - success: true, - bound: true, - channel, - }); -} - // --------------------------------------------------------------------------- // SMS delivery helper // --------------------------------------------------------------------------- @@ -1050,7 +915,6 @@ function initiateGuardianVoiceCall( phoneNumber: string, guardianVerificationSessionId: string, assistantId: string, - secret?: string, ): void { (async () => { try { @@ -1058,7 +922,6 @@ function initiateGuardianVoiceCall( phoneNumber, guardianVerificationSessionId, assistantId, - guardianVerificationSecret: secret, }); if (result.ok) { log.info({ phoneNumber, guardianVerificationSessionId, callSid: result.callSid }, 'Guardian verification call initiated'); diff --git a/assistant/src/daemon/ipc-contract/integrations.ts b/assistant/src/daemon/ipc-contract/integrations.ts index 59669a0ee22..18a59aa07f7 100644 --- a/assistant/src/daemon/ipc-contract/integrations.ts +++ b/assistant/src/daemon/ipc-contract/integrations.ts @@ -84,15 +84,13 @@ export interface ChannelReadinessRequest { export interface GuardianVerificationRequest { type: 'guardian_verification'; - action: 'create_challenge' | 'status' | 'revoke' | 'start_outbound' | 'resend_outbound' | 'cancel_outbound' | 'submit_outbound_code'; + action: 'create_challenge' | 'status' | 'revoke' | 'start_outbound' | 'resend_outbound' | 'cancel_outbound'; channel?: ChannelId; // Defaults to 'telegram' sessionId?: string; assistantId?: string; // Defaults to 'self' rebind?: boolean; // When true, allows creating a challenge even if a binding already exists /** E.164 phone number for SMS/voice, Telegram handle/chat-id. Used by outbound actions. */ destination?: string; - /** Verification code entered by the user in the Settings UI. Used by submit_outbound_code action. */ - verificationCode?: string; } export interface TwitterAuthStartRequest { diff --git a/assistant/src/runtime/guardian-verification-templates.ts b/assistant/src/runtime/guardian-verification-templates.ts index 1b60aa4bd39..9b60f91b938 100644 --- a/assistant/src/runtime/guardian-verification-templates.ts +++ b/assistant/src/runtime/guardian-verification-templates.ts @@ -21,10 +21,8 @@ export const GUARDIAN_VERIFY_TEMPLATE_KEYS = { TELEGRAM_CHALLENGE_REQUEST: 'guardian_verify.telegram.challenge_request', /** Resend Telegram message with verification code. */ TELEGRAM_RESEND: 'guardian_verify.telegram.resend', - /** Inbound voice call intro prompt: asks guardian to enter verification code via keypad. */ + /** Outbound voice call intro prompt: asks guardian to enter verification code via keypad. */ VOICE_CALL_INTRO: 'guardian_verify.voice.call_intro', - /** Outbound voice call prompt: speaks the verification code and instructs entering it in the app. */ - VOICE_OUTBOUND_CODE: 'guardian_verify.voice.outbound_code', /** Voice retry prompt after an incorrect code entry. */ VOICE_RETRY: 'guardian_verify.voice.retry', /** Voice success prompt after successful verification. */ @@ -69,8 +67,6 @@ export interface GuardianVerifyTemplateVars { export interface GuardianVerifyVoiceTemplateVars { /** Number of digits in the verification code. */ codeDigits: number; - /** The actual verification code (used for outbound voice calls that speak the code). */ - code?: string; } export interface ChannelVerifyReplyVars { @@ -85,12 +81,12 @@ export interface ChannelVerifyReplyVars { const templates: Record string> = { [GUARDIAN_VERIFY_TEMPLATE_KEYS.CHALLENGE_REQUEST]: (vars) => { const prefix = vars.assistantName ? `[${vars.assistantName}] ` : ''; - return `${prefix}Your verification code is: ${vars.code}. It expires in ${vars.expiresInMinutes} minutes. Enter this code in the app to verify. You can also reply with this code or use /guardian_verify ${vars.code}`; + return `${prefix}Guardian verification requested. Reply with the code shown in your Vellum assistant app to verify. It expires in ${vars.expiresInMinutes} minutes.`; }, [GUARDIAN_VERIFY_TEMPLATE_KEYS.RESEND]: (vars) => { const prefix = vars.assistantName ? `[${vars.assistantName}] ` : ''; - return `${prefix}Your verification code is: ${vars.code}. It expires in ${vars.expiresInMinutes} minutes. Enter this code in the app to verify. You can also reply with this code or use /guardian_verify ${vars.code} (resent)`; + return `${prefix}Guardian verification requested. Reply with the code shown in your Vellum assistant app to verify. It expires in ${vars.expiresInMinutes} minutes. (resent)`; }, [GUARDIAN_VERIFY_TEMPLATE_KEYS.ALREADY_VERIFIED]: (_vars) => { @@ -100,12 +96,12 @@ const templates: Record { const prefix = vars.assistantName ? `[${vars.assistantName}] ` : ''; - return `${prefix}Your verification code is: ${vars.code}. Enter this code in the app to verify. You can also reply with: /guardian_verify ${vars.code}`; + return `${prefix}Guardian verification requested. Reply with /guardian_verify followed by the code shown in your Vellum assistant app. It expires in ${vars.expiresInMinutes} minutes.`; }, [GUARDIAN_VERIFY_TEMPLATE_KEYS.TELEGRAM_RESEND]: (vars) => { const prefix = vars.assistantName ? `[${vars.assistantName}] ` : ''; - return `${prefix}Your verification code is: ${vars.code}. (resent)\nEnter this code in the app to verify. You can also reply with: /guardian_verify ${vars.code}`; + return `${prefix}Guardian verification requested. Reply with /guardian_verify followed by the code shown in your Vellum assistant app. It expires in ${vars.expiresInMinutes} minutes. (resent)`; }, }; @@ -139,7 +135,6 @@ export function composeVerificationTelegram( type VoiceTemplateKey = | typeof GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_CALL_INTRO - | typeof GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_OUTBOUND_CODE | typeof GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_RETRY | typeof GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_SUCCESS | typeof GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_FAILURE; @@ -148,11 +143,6 @@ const voiceTemplates: Record `You are receiving a verification call. Please enter your ${vars.codeDigits}-digit verification code using your keypad.`, - [GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_OUTBOUND_CODE]: (vars) => { - const spaced = vars.code ? vars.code.split('').join(' ') : ''; - return `You are receiving a verification call. Your verification code is: ${spaced}. Again, your code is: ${spaced}. Please enter this code in the app to complete verification. Goodbye.`; - }, - [GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_RETRY]: (_vars) => 'That code was incorrect. Please try again.', diff --git a/clients/macos/vellum-assistant/Features/Settings/SettingsConnectTab.swift b/clients/macos/vellum-assistant/Features/Settings/SettingsConnectTab.swift index fc4c700abd4..bb4041d4a81 100644 --- a/clients/macos/vellum-assistant/Features/Settings/SettingsConnectTab.swift +++ b/clients/macos/vellum-assistant/Features/Settings/SettingsConnectTab.swift @@ -53,8 +53,8 @@ struct SettingsConnectTab: View { // Outbound guardian verification destination input (keyed by channel) @State private var guardianDestinationText: [String: String] = [:] - // Outbound verification code entry (keyed by channel) - @State private var outboundCodeEntryText: [String: String] = [:] + // Outbound verification code copy state (tracks which channel's code was just copied) + @State private var outboundCodeCopiedChannel: String? // Countdown timer for outbound verification expiry (ref-counted so // closing one channel row doesn't stop the timer for remaining rows) @@ -1332,19 +1332,7 @@ struct SettingsConnectTab: View { bootstrapUrl: String?, outboundCode: String? ) -> some View { - let submitError: String? = { - switch channel { - case "telegram": return store.telegramOutboundSubmitError - case "sms": return store.smsOutboundSubmitError - case "voice": return store.voiceOutboundSubmitError - default: return nil - } - }() - let isPendingBootstrap = bootstrapUrl != nil - let codeBinding = Binding( - get: { outboundCodeEntryText[channel] ?? "" }, - set: { outboundCodeEntryText[channel] = $0 } - ) + let isCodeCopied = outboundCodeCopiedChannel == channel VStack(alignment: .leading, spacing: VSpacing.sm) { HStack(spacing: VSpacing.sm) { @@ -1359,31 +1347,53 @@ struct SettingsConnectTab: View { } VStack(alignment: .leading, spacing: VSpacing.xs) { - // Code entry field + Verify button - if !isPendingBootstrap { - Text("Enter the code you received:") + // Verification code display + if let outboundCode { + Text("Your verification code:") .font(VFont.caption) .foregroundColor(VColor.textMuted) HStack(spacing: VSpacing.sm) { - TextField("Verification code", text: codeBinding) + Text(outboundCode) .font(VFont.mono) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 200) + .foregroundColor(VColor.textPrimary) + .textSelection(.enabled) + .lineLimit(1) - VButton(label: "Verify", style: .primary) { - let code = codeBinding.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !code.isEmpty else { return } - store.submitOutboundGuardianCode(channel: channel, code: code) - } - .disabled(codeBinding.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } + Spacer() - if let submitError { - Text(submitError) - .font(VFont.caption) - .foregroundColor(VColor.error) + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(outboundCode, forType: .string) + outboundCodeCopiedChannel = channel + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + if outboundCodeCopiedChannel == channel { + outboundCodeCopiedChannel = nil + } + } + } label: { + HStack(spacing: VSpacing.xs) { + Image(systemName: isCodeCopied ? "checkmark" : "doc.on.doc") + .font(.system(size: 12, weight: .medium)) + Text(isCodeCopied ? "Copied" : "Copy") + .font(VFont.caption) + } + .foregroundColor(isCodeCopied ? VColor.success : VColor.textSecondary) + .frame(height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Copy verification code") + .help("Copy code") } + .padding(VSpacing.md) + .background(VColor.surface) + .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) + .overlay( + RoundedRectangle(cornerRadius: VRadius.md) + .stroke(VColor.surfaceBorder.opacity(0.5), lineWidth: 1) + ) } // Countdown to expiry diff --git a/clients/macos/vellum-assistant/Features/Settings/SettingsStore.swift b/clients/macos/vellum-assistant/Features/Settings/SettingsStore.swift index f97287c2a63..149b3e48818 100644 --- a/clients/macos/vellum-assistant/Features/Settings/SettingsStore.swift +++ b/clients/macos/vellum-assistant/Features/Settings/SettingsStore.swift @@ -138,7 +138,6 @@ public final class SettingsStore: ObservableObject { @Published var telegramOutboundSendCount: Int = 0 @Published var telegramBootstrapUrl: String? @Published var telegramOutboundCode: String? - @Published var telegramOutboundSubmitError: String? // MARK: - Outbound Guardian Session State (SMS) @@ -147,7 +146,6 @@ public final class SettingsStore: ObservableObject { @Published var smsOutboundNextResendAt: Date? @Published var smsOutboundSendCount: Int = 0 @Published var smsOutboundCode: String? - @Published var smsOutboundSubmitError: String? // MARK: - Outbound Guardian Session State (Voice) @@ -156,7 +154,6 @@ public final class SettingsStore: ObservableObject { @Published var voiceOutboundNextResendAt: Date? @Published var voiceOutboundSendCount: Int = 0 @Published var voiceOutboundCode: String? - @Published var voiceOutboundSubmitError: String? // MARK: - Email Integration State @@ -579,7 +576,6 @@ public final class SettingsStore: ObservableObject { } // Handle outbound verification session state - let submitErrorCodes: Set = ["invalid_code", "missing_code", "session_mismatch", "pending_bootstrap"] if response.success { if response.verificationSessionId != nil { self.applyOutboundResponseState(channel: channel, response: response) @@ -590,15 +586,6 @@ public final class SettingsStore: ObservableObject { self.clearOutboundState(for: channel) self.stopGuardianStatusPolling(for: channel) } - } else if let errorCode = response.error, submitErrorCodes.contains(errorCode) { - // Submit-specific error: surface as inline error and keep session active for retry - let errorMessage = Self.reflectedString(response, key: "message") ?? "Verification failed. Please try again." - switch channel { - case "telegram": self.telegramOutboundSubmitError = errorMessage - case "sms": self.smsOutboundSubmitError = errorMessage - case "voice": self.voiceOutboundSubmitError = errorMessage - default: break - } } else { self.stopGuardianStatusPolling(for: channel) } @@ -1227,44 +1214,6 @@ public final class SettingsStore: ObservableObject { } } - func submitOutboundGuardianCode(channel: String, code: String) { - // Clear any prior submit error - switch channel { - case "telegram": telegramOutboundSubmitError = nil - case "sms": smsOutboundSubmitError = nil - case "voice": voiceOutboundSubmitError = nil - default: return - } - - let sessionId: String? = { - switch channel { - case "telegram": return telegramOutboundSessionId - case "sms": return smsOutboundSessionId - case "voice": return voiceOutboundSessionId - default: return nil - } - }() - - do { - try daemonClient?.sendGuardianVerification( - action: "submit_outbound_code", - channel: channel, - sessionId: sessionId, - assistantId: guardianAssistantScope, - verificationCode: code - ) - } catch { - log.error("Failed to submit outbound \(channel) guardian code: \(error)") - let errorMsg = "Failed to submit code. Please try again." - switch channel { - case "telegram": telegramOutboundSubmitError = errorMsg - case "sms": smsOutboundSubmitError = errorMsg - case "voice": voiceOutboundSubmitError = errorMsg - default: break - } - } - } - private func clearOutboundState(for channel: String) { switch channel { case "telegram": @@ -1274,21 +1223,18 @@ public final class SettingsStore: ObservableObject { telegramOutboundSendCount = 0 telegramBootstrapUrl = nil telegramOutboundCode = nil - telegramOutboundSubmitError = nil case "sms": smsOutboundSessionId = nil smsOutboundExpiresAt = nil smsOutboundNextResendAt = nil smsOutboundSendCount = 0 smsOutboundCode = nil - smsOutboundSubmitError = nil case "voice": voiceOutboundSessionId = nil voiceOutboundExpiresAt = nil voiceOutboundNextResendAt = nil voiceOutboundSendCount = 0 voiceOutboundCode = nil - voiceOutboundSubmitError = nil default: break } diff --git a/clients/macos/vellum-assistantTests/SettingsStoreChannelVerificationTests.swift b/clients/macos/vellum-assistantTests/SettingsStoreChannelVerificationTests.swift index 6b38490f111..0f4d1df41e6 100644 --- a/clients/macos/vellum-assistantTests/SettingsStoreChannelVerificationTests.swift +++ b/clients/macos/vellum-assistantTests/SettingsStoreChannelVerificationTests.swift @@ -1024,110 +1024,4 @@ final class SettingsStoreChannelVerificationTests: XCTestCase { XCTAssertNil(store.voiceOutboundNextResendAt) XCTAssertEqual(store.voiceOutboundSendCount, 0) } - - // MARK: - Submit Outbound Code - - func testSubmitOutboundCodeSendsCorrectIPCPayload() { - // Set up outbound session state - store.smsOutboundSessionId = "sess-submit-1" - - let guardianMessagesBefore = sentMessages.compactMap { $0 as? GuardianVerificationRequestMessage } - let submitCountBefore = guardianMessagesBefore.filter { $0.action == "submit_outbound_code" }.count - - store.submitOutboundGuardianCode(channel: "sms", code: "123456") - - let guardianMessages = sentMessages.compactMap { $0 as? GuardianVerificationRequestMessage } - let submitMessages = guardianMessages.filter { $0.action == "submit_outbound_code" } - XCTAssertEqual(submitMessages.count, submitCountBefore + 1) - let msg = submitMessages.last! - XCTAssertEqual(msg.channel, "sms") - XCTAssertEqual(msg.sessionId, "sess-submit-1") - XCTAssertEqual(msg.verificationCode, "123456") - XCTAssertEqual(msg.assistantId, testAssistantId) - } - - func testFailedSubmitKeepsOutboundSessionAndSurfacesError() { - // Set up outbound session state - store.smsOutboundSessionId = "sess-submit-2" - store.smsOutboundExpiresAt = Date().addingTimeInterval(600) - store.smsOutboundSendCount = 1 - - // Simulate error response for invalid code - daemonClient.onGuardianVerificationResponse?(GuardianVerificationResponseMessage( - type: "guardian_verification_response", - success: false, - channel: "sms", - error: "invalid_code", - message: "The verification code is incorrect. Please try again." - )) - - let predicate = NSPredicate { _, _ in self.store.smsOutboundSubmitError != nil } - let expectation = XCTNSPredicateExpectation(predicate: predicate, object: nil) - wait(for: [expectation], timeout: 2.0) - - // Session should still be active - XCTAssertEqual(store.smsOutboundSessionId, "sess-submit-2") - XCTAssertNotNil(store.smsOutboundExpiresAt) - XCTAssertEqual(store.smsOutboundSendCount, 1) - - // Error should be surfaced - XCTAssertNotNil(store.smsOutboundSubmitError) - XCTAssertTrue(store.smsOutboundSubmitError!.contains("incorrect")) - } - - func testSuccessfulSubmitClearsOutboundStateAndMarksVerified() { - // Set up outbound session state - store.smsOutboundSessionId = "sess-submit-3" - store.smsOutboundExpiresAt = Date().addingTimeInterval(600) - store.smsOutboundSendCount = 1 - store.smsGuardianVerificationInProgress = true - - // Simulate success response with bound: true - daemonClient.onGuardianVerificationResponse?(GuardianVerificationResponseMessage( - type: "guardian_verification_response", - success: true, - bound: true, - guardianExternalUserId: "+15551234567", - channel: "sms", - assistantId: "self" - )) - - let predicate = NSPredicate { _, _ in self.store.smsGuardianVerified } - let expectation = XCTNSPredicateExpectation(predicate: predicate, object: nil) - wait(for: [expectation], timeout: 2.0) - - // Outbound state should be cleared - XCTAssertNil(store.smsOutboundSessionId) - XCTAssertNil(store.smsOutboundExpiresAt) - XCTAssertNil(store.smsOutboundSubmitError) - - // Guardian should be verified - XCTAssertTrue(store.smsGuardianVerified) - XCTAssertEqual(store.smsGuardianIdentity, "+15551234567") - } - - func testPendingBootstrapDoesNotEnableVerifySubmit() { - // Set up Telegram outbound session in pending_bootstrap state - store.telegramOutboundSessionId = "tg-sess-bootstrap" - store.telegramOutboundExpiresAt = Date().addingTimeInterval(600) - store.telegramBootstrapUrl = "https://t.me/MyBot?start=verify_abc" - - // Simulate status response with pending_bootstrap - daemonClient.onGuardianVerificationResponse?(GuardianVerificationResponseMessage( - type: "guardian_verification_response", - success: true, - channel: "telegram", - verificationSessionId: "tg-sess-bootstrap", - pendingBootstrap: true - )) - - let predicate = NSPredicate { _, _ in self.store.telegramOutboundSessionId == "tg-sess-bootstrap" } - let expectation = XCTNSPredicateExpectation(predicate: predicate, object: nil) - wait(for: [expectation], timeout: 2.0) - - // Bootstrap URL should still be present (UI uses this to show bootstrap link instead of code entry) - XCTAssertNotNil(store.telegramBootstrapUrl) - // No submit error should be present - XCTAssertNil(store.telegramOutboundSubmitError) - } } diff --git a/clients/shared/IPC/DaemonClient.swift b/clients/shared/IPC/DaemonClient.swift index fbe38ca3341..6a3cd425220 100644 --- a/clients/shared/IPC/DaemonClient.swift +++ b/clients/shared/IPC/DaemonClient.swift @@ -1070,8 +1070,7 @@ public final class DaemonClient: ObservableObject, DaemonClientProtocol { sessionId: String? = nil, assistantId: String? = nil, rebind: Bool? = nil, - destination: String? = nil, - verificationCode: String? = nil + destination: String? = nil ) throws { try send(GuardianVerificationRequestMessage( action: action, @@ -1079,8 +1078,7 @@ public final class DaemonClient: ObservableObject, DaemonClientProtocol { sessionId: sessionId, assistantId: assistantId, rebind: rebind, - destination: destination, - verificationCode: verificationCode + destination: destination )) } diff --git a/clients/shared/IPC/Generated/IPCContractGenerated.swift b/clients/shared/IPC/Generated/IPCContractGenerated.swift index 2255df06c1d..a571daa87a2 100644 --- a/clients/shared/IPC/Generated/IPCContractGenerated.swift +++ b/clients/shared/IPC/Generated/IPCContractGenerated.swift @@ -1717,10 +1717,8 @@ public struct IPCGuardianVerificationRequest: Codable, Sendable { public let rebind: Bool? /// E.164 phone number for SMS/voice, Telegram handle/chat-id. Used by outbound actions. public let destination: String? - /// Verification code entered by the user in the Settings UI. Used by submit_outbound_code action. - public let verificationCode: String? - public init(type: String, action: String, channel: String? = nil, sessionId: String? = nil, assistantId: String? = nil, rebind: Bool? = nil, destination: String? = nil, verificationCode: String? = nil) { + public init(type: String, action: String, channel: String? = nil, sessionId: String? = nil, assistantId: String? = nil, rebind: Bool? = nil, destination: String? = nil) { self.type = type self.action = action self.channel = channel @@ -1728,7 +1726,6 @@ public struct IPCGuardianVerificationRequest: Codable, Sendable { self.assistantId = assistantId self.rebind = rebind self.destination = destination - self.verificationCode = verificationCode } } diff --git a/clients/shared/IPC/IPCMessages.swift b/clients/shared/IPC/IPCMessages.swift index 772eb3bcd4c..4df12cd1295 100644 --- a/clients/shared/IPC/IPCMessages.swift +++ b/clients/shared/IPC/IPCMessages.swift @@ -2009,8 +2009,7 @@ extension IPCGuardianVerificationRequest { sessionId: String? = nil, assistantId: String? = nil, rebind: Bool? = nil, - destination: String? = nil, - verificationCode: String? = nil + destination: String? = nil ) { self.init( type: "guardian_verification", @@ -2019,8 +2018,7 @@ extension IPCGuardianVerificationRequest { sessionId: sessionId, assistantId: assistantId, rebind: rebind, - destination: destination, - verificationCode: verificationCode + destination: destination ) } }