@vellumai/assistant 0.3.28 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +33 -3
- package/bun.lock +4 -1
- package/docs/trusted-contact-access.md +9 -2
- package/package.json +6 -3
- package/scripts/ipc/generate-swift.ts +3 -3
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
- package/src/__tests__/agent-loop-thinking.test.ts +1 -1
- package/src/__tests__/approval-routes-http.test.ts +13 -5
- package/src/__tests__/asset-materialize-tool.test.ts +2 -0
- package/src/__tests__/asset-search-tool.test.ts +2 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
- package/src/__tests__/attachments-store.test.ts +2 -0
- package/src/__tests__/browser-skill-endstate.test.ts +3 -3
- package/src/__tests__/call-controller.test.ts +30 -29
- package/src/__tests__/call-routes-http.test.ts +34 -32
- package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
- package/src/__tests__/channel-invite-transport.test.ts +6 -6
- package/src/__tests__/channel-reply-delivery.test.ts +19 -0
- package/src/__tests__/channel-retry-sweep.test.ts +130 -0
- package/src/__tests__/clarification-resolver.test.ts +2 -0
- package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
- package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
- package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
- package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
- package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
- package/src/__tests__/config-schema.test.ts +5 -5
- package/src/__tests__/config-watcher.test.ts +3 -1
- package/src/__tests__/connection-policy.test.ts +14 -5
- package/src/__tests__/contacts-tools.test.ts +3 -1
- package/src/__tests__/contradiction-checker.test.ts +2 -0
- package/src/__tests__/conversation-pairing.test.ts +10 -0
- package/src/__tests__/conversation-routes.test.ts +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +16 -6
- package/src/__tests__/credential-vault-unit.test.ts +2 -2
- package/src/__tests__/credential-vault.test.ts +5 -4
- package/src/__tests__/daemon-lifecycle.test.ts +9 -0
- package/src/__tests__/daemon-server-session-init.test.ts +27 -0
- package/src/__tests__/elevenlabs-config.test.ts +2 -0
- package/src/__tests__/encrypted-store.test.ts +10 -5
- package/src/__tests__/followup-tools.test.ts +3 -1
- package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
- package/src/__tests__/gmail-integration.test.ts +0 -1
- package/src/__tests__/guardian-control-plane-policy.test.ts +25 -21
- package/src/__tests__/guardian-dispatch.test.ts +2 -0
- package/src/__tests__/guardian-grant-minting.test.ts +68 -1
- package/src/__tests__/guardian-outbound-http.test.ts +12 -9
- package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
- package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
- package/src/__tests__/handlers-slack-config.test.ts +3 -1
- package/src/__tests__/handlers-telegram-config.test.ts +3 -1
- package/src/__tests__/handlers-twilio-config.test.ts +3 -1
- package/src/__tests__/handlers-twitter-config.test.ts +3 -1
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
- package/src/__tests__/heartbeat-service.test.ts +20 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
- package/src/__tests__/ingress-reconcile.test.ts +3 -1
- package/src/__tests__/ingress-routes-http.test.ts +231 -4
- package/src/__tests__/intent-routing.test.ts +2 -0
- package/src/__tests__/ipc-snapshot.test.ts +13 -0
- package/src/__tests__/media-generate-image.test.ts +21 -0
- package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
- package/src/__tests__/memory-regressions.test.ts +20 -20
- package/src/__tests__/non-member-access-request.test.ts +183 -9
- package/src/__tests__/notification-decision-fallback.test.ts +2 -0
- package/src/__tests__/notification-decision-strategy.test.ts +61 -0
- package/src/__tests__/notification-guardian-path.test.ts +2 -0
- package/src/__tests__/oauth-connect-handler.test.ts +3 -1
- package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
- package/src/__tests__/pairing-routes.test.ts +171 -0
- package/src/__tests__/playbook-execution.test.ts +3 -1
- package/src/__tests__/playbook-tools.test.ts +3 -1
- package/src/__tests__/provider-error-scenarios.test.ts +59 -8
- package/src/__tests__/proxy-approval-callback.test.ts +2 -0
- package/src/__tests__/recording-handler.test.ts +11 -0
- package/src/__tests__/recording-intent-handler.test.ts +15 -0
- package/src/__tests__/recording-state-machine.test.ts +13 -2
- package/src/__tests__/registry.test.ts +7 -3
- package/src/__tests__/relay-server.test.ts +148 -28
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
- package/src/__tests__/runtime-events-sse.test.ts +4 -2
- package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
- package/src/__tests__/schedule-tools.test.ts +3 -1
- package/src/__tests__/send-endpoint-busy.test.ts +288 -0
- package/src/__tests__/session-abort-tool-results.test.ts +23 -0
- package/src/__tests__/session-agent-loop.test.ts +16 -0
- package/src/__tests__/session-conflict-gate.test.ts +21 -0
- package/src/__tests__/session-load-history-repair.test.ts +27 -17
- package/src/__tests__/session-pre-run-repair.test.ts +23 -0
- package/src/__tests__/session-profile-injection.test.ts +21 -0
- package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
- package/src/__tests__/session-queue.test.ts +23 -0
- package/src/__tests__/session-runtime-assembly.test.ts +50 -12
- package/src/__tests__/session-skill-tools.test.ts +27 -5
- package/src/__tests__/session-slash-known.test.ts +23 -0
- package/src/__tests__/session-slash-queue.test.ts +23 -0
- package/src/__tests__/session-slash-unknown.test.ts +23 -0
- package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
- package/src/__tests__/session-workspace-injection.test.ts +21 -0
- package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
- package/src/__tests__/shell-credential-ref.test.ts +2 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
- package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
- package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
- package/src/__tests__/skills.test.ts +8 -4
- package/src/__tests__/slack-channel-config.test.ts +3 -1
- package/src/__tests__/subagent-tools.test.ts +19 -0
- package/src/__tests__/swarm-recursion.test.ts +2 -0
- package/src/__tests__/swarm-session-integration.test.ts +2 -0
- package/src/__tests__/swarm-tool.test.ts +2 -0
- package/src/__tests__/system-prompt.test.ts +3 -1
- package/src/__tests__/task-compiler.test.ts +3 -1
- package/src/__tests__/task-management-tools.test.ts +3 -1
- package/src/__tests__/task-tools.test.ts +3 -1
- package/src/__tests__/terminal-sandbox.test.ts +13 -12
- package/src/__tests__/terminal-tools.test.ts +2 -0
- package/src/__tests__/tool-approval-handler.test.ts +15 -15
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
- package/src/__tests__/tool-grant-request-escalation.test.ts +7 -7
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
- package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
- package/src/__tests__/trusted-contact-verification.test.ts +91 -0
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
- package/src/__tests__/twitter-auth-handler.test.ts +3 -1
- package/src/__tests__/twitter-cli-routing.test.ts +3 -1
- package/src/__tests__/view-image-tool.test.ts +3 -1
- package/src/__tests__/voice-invite-redemption.test.ts +329 -0
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
- package/src/__tests__/voice-session-bridge.test.ts +10 -10
- package/src/__tests__/work-item-output.test.ts +3 -1
- package/src/__tests__/workspace-lifecycle.test.ts +13 -2
- package/src/calls/call-controller.ts +26 -23
- package/src/calls/guardian-action-sweep.ts +10 -2
- package/src/calls/relay-server.ts +216 -27
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +3 -3
- package/src/cli.ts +12 -0
- package/src/config/agent-schema.ts +14 -3
- package/src/config/calls-schema.ts +6 -6
- package/src/config/core-schema.ts +3 -3
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/mcp-schema.ts +1 -1
- package/src/config/memory-schema.ts +27 -19
- package/src/config/schema.ts +21 -21
- package/src/config/skills-schema.ts +7 -7
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +139 -16
- package/src/daemon/handlers/config-inbox.ts +4 -4
- package/src/daemon/handlers/sessions.ts +148 -4
- package/src/daemon/ipc-contract/messages.ts +16 -0
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +19 -0
- package/src/daemon/pairing-store.ts +86 -3
- package/src/daemon/response-tier.ts +6 -5
- package/src/daemon/session-agent-loop.ts +5 -5
- package/src/daemon/session-lifecycle.ts +25 -17
- package/src/daemon/session-memory.ts +2 -2
- package/src/daemon/session-process.ts +1 -20
- package/src/daemon/session-runtime-assembly.ts +28 -22
- package/src/daemon/session-tool-setup.ts +2 -2
- package/src/daemon/session.ts +3 -3
- package/src/memory/canonical-guardian-store.ts +63 -1
- package/src/memory/channel-guardian-store.ts +1 -0
- package/src/memory/conversation-crud.ts +7 -7
- package/src/memory/db-init.ts +4 -0
- package/src/memory/embedding-local.ts +257 -39
- package/src/memory/embedding-runtime-manager.ts +471 -0
- package/src/memory/guardian-bindings.ts +25 -1
- package/src/memory/indexer.ts +3 -3
- package/src/memory/ingress-invite-store.ts +45 -0
- package/src/memory/job-handlers/backfill.ts +16 -9
- package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/qdrant-client.ts +31 -22
- package/src/memory/schema.ts +4 -0
- package/src/notifications/copy-composer.ts +15 -0
- package/src/runtime/access-request-helper.ts +43 -7
- package/src/runtime/actor-trust-resolver.ts +46 -50
- package/src/runtime/channel-invite-transports/voice.ts +58 -0
- package/src/runtime/channel-retry-sweep.ts +18 -6
- package/src/runtime/guardian-context-resolver.ts +38 -96
- package/src/runtime/guardian-reply-router.ts +31 -1
- package/src/runtime/ingress-service.ts +80 -3
- package/src/runtime/invite-redemption-service.ts +141 -2
- package/src/runtime/routes/channel-route-shared.ts +1 -1
- package/src/runtime/routes/channel-routes.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +166 -2
- package/src/runtime/routes/guardian-approval-interception.ts +17 -6
- package/src/runtime/routes/inbound-message-handler.ts +41 -10
- package/src/runtime/routes/ingress-routes.ts +52 -4
- package/src/runtime/routes/pairing-routes.ts +3 -0
- package/src/tools/guardian-control-plane-policy.ts +2 -2
- package/src/tools/reminder/reminder-store.ts +10 -14
- package/src/tools/tool-approval-handler.ts +11 -11
- package/src/tools/types.ts +2 -2
- package/src/util/logger.ts +20 -8
- package/src/util/platform.ts +10 -0
- package/src/util/voice-code.ts +29 -0
- package/src/daemon/guardian-invite-intent.ts +0 -124
|
@@ -85,6 +85,12 @@ export interface GuardianReplyResult {
|
|
|
85
85
|
requestId?: string;
|
|
86
86
|
/** Detailed result from the canonical decision primitive (when a decision was attempted). */
|
|
87
87
|
canonicalResult?: CanonicalDecisionResult;
|
|
88
|
+
/**
|
|
89
|
+
* When true, the caller should skip legacy approval interception for this
|
|
90
|
+
* message. Set by the invite handoff bypass so that "open invite flow"
|
|
91
|
+
* reaches the assistant even when other legacy guardian approvals are pending.
|
|
92
|
+
*/
|
|
93
|
+
skipApprovalInterception?: boolean;
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
// ---------------------------------------------------------------------------
|
|
@@ -319,7 +325,30 @@ export async function routeGuardianReply(
|
|
|
319
325
|
}
|
|
320
326
|
}
|
|
321
327
|
|
|
322
|
-
// ── 2.5.
|
|
328
|
+
// ── 2.5. Invite handoff bypass for access requests ──
|
|
329
|
+
// When the guardian sends "open invite flow" and there is at least one
|
|
330
|
+
// pending access_request, return not_consumed so the message falls through
|
|
331
|
+
// to the normal assistant turn and can invoke the Trusted Contacts skill.
|
|
332
|
+
if (messageText.length > 0 && pendingRequests.length > 0) {
|
|
333
|
+
const normalized = messageText.trim().toLowerCase().replace(/[.!?]+$/g, '');
|
|
334
|
+
if (normalized === 'open invite flow') {
|
|
335
|
+
const hasAccessRequest = pendingRequests.some(r => r.kind === 'access_request');
|
|
336
|
+
if (hasAccessRequest) {
|
|
337
|
+
log.info(
|
|
338
|
+
{ event: 'router_invite_handoff', pendingCount: pendingRequests.length },
|
|
339
|
+
'Guardian sent "open invite flow" with pending access_request — passing through to assistant',
|
|
340
|
+
);
|
|
341
|
+
return {
|
|
342
|
+
consumed: false,
|
|
343
|
+
decisionApplied: false,
|
|
344
|
+
type: 'not_consumed' as const,
|
|
345
|
+
skipApprovalInterception: true,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── 2.6. Deterministic plain-text decisions for known pending targets ──
|
|
323
352
|
// Desktop sessions intentionally do not enable NL classification; when the
|
|
324
353
|
// caller has exactly one known pending request and sends an explicit
|
|
325
354
|
// approve/reject phrase ("approve", "yes", "reject", "no"), apply the
|
|
@@ -551,6 +580,7 @@ const EXPLICIT_APPROVE_PHRASES: ReadonlySet<string> = new Set([
|
|
|
551
580
|
'yes',
|
|
552
581
|
'y',
|
|
553
582
|
'allow',
|
|
583
|
+
'go for it',
|
|
554
584
|
'go ahead',
|
|
555
585
|
'proceed',
|
|
556
586
|
'do it',
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* both the HTTP routes and the IPC handlers call the same logic.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { isChannelId } from '../channels/types.js';
|
|
8
9
|
import {
|
|
9
10
|
createInvite,
|
|
10
11
|
type IngressInvite,
|
|
@@ -22,11 +23,18 @@ import {
|
|
|
22
23
|
revokeMember,
|
|
23
24
|
upsertMember,
|
|
24
25
|
} from '../memory/ingress-member-store.js';
|
|
26
|
+
import { isValidE164 } from '../util/phone.js';
|
|
27
|
+
import { generateVoiceCode, hashVoiceCode } from '../util/voice-code.js';
|
|
28
|
+
import { getTransport } from './channel-invite-transport.js';
|
|
25
29
|
import {
|
|
26
30
|
type InviteRedemptionOutcome,
|
|
27
31
|
redeemInvite as redeemInviteTyped,
|
|
32
|
+
redeemVoiceInviteCode as redeemVoiceInviteCodeTyped,
|
|
33
|
+
type VoiceRedemptionOutcome,
|
|
28
34
|
} from './invite-redemption-service.js';
|
|
29
35
|
|
|
36
|
+
import './channel-invite-transports/telegram.js';
|
|
37
|
+
|
|
30
38
|
// ---------------------------------------------------------------------------
|
|
31
39
|
// Response shapes — used by both HTTP routes and IPC handlers
|
|
32
40
|
// ---------------------------------------------------------------------------
|
|
@@ -35,12 +43,20 @@ export interface InviteResponseData {
|
|
|
35
43
|
id: string;
|
|
36
44
|
sourceChannel: string;
|
|
37
45
|
token?: string;
|
|
46
|
+
share?: {
|
|
47
|
+
url: string;
|
|
48
|
+
displayText: string;
|
|
49
|
+
};
|
|
38
50
|
tokenHash: string;
|
|
39
51
|
maxUses: number;
|
|
40
52
|
useCount: number;
|
|
41
53
|
expiresAt: number | null;
|
|
42
54
|
status: string;
|
|
43
55
|
note?: string;
|
|
56
|
+
// Voice invite fields (present only for voice invites)
|
|
57
|
+
expectedExternalUserId?: string;
|
|
58
|
+
voiceCode?: string;
|
|
59
|
+
voiceCodeDigits?: number;
|
|
44
60
|
createdAt: number;
|
|
45
61
|
}
|
|
46
62
|
|
|
@@ -61,17 +77,39 @@ export interface MemberResponseData {
|
|
|
61
77
|
// Mappers
|
|
62
78
|
// ---------------------------------------------------------------------------
|
|
63
79
|
|
|
64
|
-
function
|
|
80
|
+
function buildSharePayload(sourceChannel: string, rawToken?: string): InviteResponseData['share'] | undefined {
|
|
81
|
+
if (!rawToken || !isChannelId(sourceChannel)) return undefined;
|
|
82
|
+
const transport = getTransport(sourceChannel);
|
|
83
|
+
if (!transport?.buildShareableInvite) return undefined;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
return transport.buildShareableInvite({
|
|
87
|
+
rawToken,
|
|
88
|
+
sourceChannel,
|
|
89
|
+
});
|
|
90
|
+
} catch {
|
|
91
|
+
// Missing channel-specific config (e.g. Telegram bot username) should
|
|
92
|
+
// not fail invite creation — callers can still use the raw token.
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function inviteToResponse(inv: IngressInvite, opts?: { rawToken?: string; voiceCode?: string }): InviteResponseData {
|
|
98
|
+
const share = buildSharePayload(inv.sourceChannel, opts?.rawToken);
|
|
65
99
|
return {
|
|
66
100
|
id: inv.id,
|
|
67
101
|
sourceChannel: inv.sourceChannel,
|
|
68
|
-
...(rawToken ? { token: rawToken } : {}),
|
|
102
|
+
...(opts?.rawToken ? { token: opts.rawToken } : {}),
|
|
103
|
+
...(share ? { share } : {}),
|
|
69
104
|
tokenHash: inv.tokenHash,
|
|
70
105
|
maxUses: inv.maxUses,
|
|
71
106
|
useCount: inv.useCount,
|
|
72
107
|
expiresAt: inv.expiresAt,
|
|
73
108
|
status: inv.status,
|
|
74
109
|
note: inv.note ?? undefined,
|
|
110
|
+
...(inv.expectedExternalUserId ? { expectedExternalUserId: inv.expectedExternalUserId } : {}),
|
|
111
|
+
...(opts?.voiceCode ? { voiceCode: opts.voiceCode } : {}),
|
|
112
|
+
...(inv.voiceCodeDigits != null ? { voiceCodeDigits: inv.voiceCodeDigits } : {}),
|
|
75
113
|
createdAt: inv.createdAt,
|
|
76
114
|
};
|
|
77
115
|
}
|
|
@@ -108,17 +146,46 @@ export function createIngressInvite(params: {
|
|
|
108
146
|
note?: string;
|
|
109
147
|
maxUses?: number;
|
|
110
148
|
expiresInMs?: number;
|
|
149
|
+
// Voice invite parameters
|
|
150
|
+
expectedExternalUserId?: string;
|
|
151
|
+
voiceCodeDigits?: number;
|
|
111
152
|
}): IngressResult<InviteResponseData> {
|
|
112
153
|
if (!params.sourceChannel) {
|
|
113
154
|
return { ok: false, error: 'sourceChannel is required for create' };
|
|
114
155
|
}
|
|
156
|
+
|
|
157
|
+
// For voice invites: generate a one-time numeric code, hash it, and pass
|
|
158
|
+
// the hash to the store. The plaintext code is included in the response
|
|
159
|
+
// exactly once and never stored.
|
|
160
|
+
let voiceCode: string | undefined;
|
|
161
|
+
let voiceCodeHash: string | undefined;
|
|
162
|
+
const isVoice = params.sourceChannel === 'voice';
|
|
163
|
+
|
|
164
|
+
if (isVoice) {
|
|
165
|
+
if (!params.expectedExternalUserId) {
|
|
166
|
+
return { ok: false, error: 'expectedExternalUserId is required for voice invites' };
|
|
167
|
+
}
|
|
168
|
+
if (!isValidE164(params.expectedExternalUserId)) {
|
|
169
|
+
return { ok: false, error: 'expectedExternalUserId must be in E.164 format (e.g., +15551234567)' };
|
|
170
|
+
}
|
|
171
|
+
voiceCode = generateVoiceCode(6);
|
|
172
|
+
voiceCodeHash = hashVoiceCode(voiceCode);
|
|
173
|
+
}
|
|
174
|
+
|
|
115
175
|
const { invite, rawToken } = createInvite({
|
|
116
176
|
sourceChannel: params.sourceChannel,
|
|
117
177
|
note: params.note,
|
|
118
178
|
maxUses: params.maxUses,
|
|
119
179
|
expiresInMs: params.expiresInMs,
|
|
180
|
+
...(isVoice ? {
|
|
181
|
+
expectedExternalUserId: params.expectedExternalUserId,
|
|
182
|
+
voiceCodeHash,
|
|
183
|
+
voiceCodeDigits: 6,
|
|
184
|
+
} : {}),
|
|
120
185
|
});
|
|
121
|
-
|
|
186
|
+
// Voice invites must not expose the token — callers must redeem via the
|
|
187
|
+
// identity-bound voice code flow, not the generic token redemption path.
|
|
188
|
+
return { ok: true, data: inviteToResponse(invite, { rawToken: isVoice ? undefined : rawToken, voiceCode }) };
|
|
122
189
|
}
|
|
123
190
|
|
|
124
191
|
export function listIngressInvites(params: {
|
|
@@ -172,6 +239,7 @@ export function redeemIngressInvite(params: {
|
|
|
172
239
|
// ---------------------------------------------------------------------------
|
|
173
240
|
|
|
174
241
|
export { type InviteRedemptionOutcome } from './invite-redemption-service.js';
|
|
242
|
+
export { type VoiceRedemptionOutcome } from './invite-redemption-service.js';
|
|
175
243
|
|
|
176
244
|
export function redeemIngressInviteTyped(params: {
|
|
177
245
|
rawToken: string;
|
|
@@ -185,6 +253,15 @@ export function redeemIngressInviteTyped(params: {
|
|
|
185
253
|
return redeemInviteTyped(params);
|
|
186
254
|
}
|
|
187
255
|
|
|
256
|
+
export function redeemVoiceInviteCode(params: {
|
|
257
|
+
assistantId?: string;
|
|
258
|
+
callerExternalUserId: string;
|
|
259
|
+
sourceChannel: 'voice';
|
|
260
|
+
code: string;
|
|
261
|
+
}): VoiceRedemptionOutcome {
|
|
262
|
+
return redeemVoiceInviteCodeTyped(params);
|
|
263
|
+
}
|
|
264
|
+
|
|
188
265
|
// ---------------------------------------------------------------------------
|
|
189
266
|
// Member operations
|
|
190
267
|
// ---------------------------------------------------------------------------
|
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
* persisted, or returned in the outcome.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { ChannelId } from '../channels/types.js';
|
|
10
11
|
import { getSqlite } from '../memory/db.js';
|
|
11
|
-
import { findByTokenHash, hashToken, markInviteExpired, recordInviteUse,redeemInvite as storeRedeemInvite } from '../memory/ingress-invite-store.js';
|
|
12
|
+
import { findActiveVoiceInvites,findByTokenHash, hashToken, markInviteExpired, recordInviteUse, redeemInvite as storeRedeemInvite } from '../memory/ingress-invite-store.js';
|
|
12
13
|
import { findMember, upsertMember } from '../memory/ingress-member-store.js';
|
|
14
|
+
import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
|
|
15
|
+
import { hashVoiceCode } from '../util/voice-code.js';
|
|
13
16
|
|
|
14
17
|
// ---------------------------------------------------------------------------
|
|
15
18
|
// Outcome type
|
|
@@ -20,6 +23,13 @@ export type InviteRedemptionOutcome =
|
|
|
20
23
|
| { ok: true; type: 'already_member'; memberId: string }
|
|
21
24
|
| { ok: false; reason: 'invalid_token' | 'expired' | 'revoked' | 'max_uses_reached' | 'channel_mismatch' | 'missing_identity' };
|
|
22
25
|
|
|
26
|
+
// Generic failure reasons for voice redemption — intentionally vague to avoid
|
|
27
|
+
// leaking information about which invites exist or which identity is bound.
|
|
28
|
+
export type VoiceRedemptionOutcome =
|
|
29
|
+
| { ok: true; type: 'redeemed'; memberId: string; inviteId: string }
|
|
30
|
+
| { ok: true; type: 'already_member'; memberId: string }
|
|
31
|
+
| { ok: false; reason: 'invalid_or_expired' };
|
|
32
|
+
|
|
23
33
|
// ---------------------------------------------------------------------------
|
|
24
34
|
// Error-string to typed-reason mapping
|
|
25
35
|
// ---------------------------------------------------------------------------
|
|
@@ -111,6 +121,12 @@ export function redeemInvite(params: {
|
|
|
111
121
|
// Sentinel error used to trigger a transaction rollback when the invite
|
|
112
122
|
// was concurrently revoked/expired between pre-validation and write time.
|
|
113
123
|
const STALE_INVITE = Symbol('stale_invite');
|
|
124
|
+
const canonicalMemberId = existingMember.externalUserId ? canonicalizeInboundIdentity(sourceChannel as ChannelId, existingMember.externalUserId) : null;
|
|
125
|
+
const canonicalCallerId = externalUserId ? canonicalizeInboundIdentity(sourceChannel as ChannelId, externalUserId) : null;
|
|
126
|
+
const memberMatchesSender = !!(canonicalMemberId && canonicalCallerId && canonicalMemberId === canonicalCallerId);
|
|
127
|
+
const preservedDisplayName = memberMatchesSender && existingMember.displayName?.trim().length
|
|
128
|
+
? existingMember.displayName
|
|
129
|
+
: displayName;
|
|
114
130
|
|
|
115
131
|
let reactivated: ReturnType<typeof upsertMember> | undefined;
|
|
116
132
|
try {
|
|
@@ -120,7 +136,8 @@ export function redeemInvite(params: {
|
|
|
120
136
|
sourceChannel,
|
|
121
137
|
externalUserId,
|
|
122
138
|
externalChatId,
|
|
123
|
-
|
|
139
|
+
// Reactivation should not overwrite a guardian-managed nickname.
|
|
140
|
+
displayName: preservedDisplayName,
|
|
124
141
|
username,
|
|
125
142
|
status: 'active',
|
|
126
143
|
policy: 'allow',
|
|
@@ -179,3 +196,125 @@ export function redeemInvite(params: {
|
|
|
179
196
|
inviteId: result.invite.id,
|
|
180
197
|
};
|
|
181
198
|
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// redeemVoiceInviteCode
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Redeem a voice invite code for a caller identified by their E.164 phone number.
|
|
206
|
+
*
|
|
207
|
+
* Unlike token-based redemption, voice redemption:
|
|
208
|
+
* 1. Filters only active voice invites bound to the caller's identity
|
|
209
|
+
* (expectedExternalUserId must match callerExternalUserId).
|
|
210
|
+
* 2. Validates the short numeric code by hashing it and comparing to the
|
|
211
|
+
* stored voiceCodeHash.
|
|
212
|
+
* 3. Enforces expiry and use limits.
|
|
213
|
+
* 4. On success: upserts/reactivates a member with status 'active', policy 'allow'.
|
|
214
|
+
* 5. Consumes one invite use atomically (increment useCount).
|
|
215
|
+
*
|
|
216
|
+
* Failure responses are intentionally generic ("invalid_or_expired") to prevent
|
|
217
|
+
* oracle attacks that could reveal which invites exist or which phone numbers
|
|
218
|
+
* are bound.
|
|
219
|
+
*/
|
|
220
|
+
export function redeemVoiceInviteCode(params: {
|
|
221
|
+
assistantId?: string;
|
|
222
|
+
callerExternalUserId: string;
|
|
223
|
+
sourceChannel: 'voice';
|
|
224
|
+
code: string;
|
|
225
|
+
}): VoiceRedemptionOutcome {
|
|
226
|
+
const { assistantId = 'self', callerExternalUserId, code } = params;
|
|
227
|
+
|
|
228
|
+
if (!callerExternalUserId) {
|
|
229
|
+
return { ok: false, reason: 'invalid_or_expired' };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Find all active voice invites bound to the caller's phone number
|
|
233
|
+
const candidates = findActiveVoiceInvites({
|
|
234
|
+
assistantId,
|
|
235
|
+
expectedExternalUserId: callerExternalUserId,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (candidates.length === 0) {
|
|
239
|
+
return { ok: false, reason: 'invalid_or_expired' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const codeHash = hashVoiceCode(code);
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
|
|
245
|
+
// Search for a matching invite: code hash match, not expired, uses remaining
|
|
246
|
+
const invite = candidates.find((inv) => {
|
|
247
|
+
if (inv.voiceCodeHash !== codeHash) return false;
|
|
248
|
+
if (inv.expiresAt <= now) return false;
|
|
249
|
+
if (inv.useCount >= inv.maxUses) return false;
|
|
250
|
+
return true;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (!invite) {
|
|
254
|
+
// Mark any expired candidates while we're here
|
|
255
|
+
for (const inv of candidates) {
|
|
256
|
+
if (inv.expiresAt <= now && inv.status === 'active') {
|
|
257
|
+
markInviteExpired(inv.id);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { ok: false, reason: 'invalid_or_expired' };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Channel enforcement: voice invites can only be redeemed on the voice channel
|
|
264
|
+
if (invite.sourceChannel !== 'voice') {
|
|
265
|
+
return { ok: false, reason: 'invalid_or_expired' };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Check for existing membership
|
|
269
|
+
const existingMember = findMember({
|
|
270
|
+
assistantId: invite.assistantId,
|
|
271
|
+
sourceChannel: 'voice',
|
|
272
|
+
externalUserId: callerExternalUserId,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (existingMember && existingMember.status === 'active') {
|
|
276
|
+
return { ok: true, type: 'already_member', memberId: existingMember.id };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Blocked members cannot bypass the guardian's explicit block
|
|
280
|
+
if (existingMember && existingMember.status === 'blocked') {
|
|
281
|
+
return { ok: false, reason: 'invalid_or_expired' };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Atomic redemption: upsert member + consume invite use in a transaction
|
|
285
|
+
const STALE_INVITE = Symbol('stale_invite');
|
|
286
|
+
let memberId: string | undefined;
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
getSqlite().transaction(() => {
|
|
290
|
+
const member = upsertMember({
|
|
291
|
+
assistantId: invite.assistantId,
|
|
292
|
+
sourceChannel: 'voice',
|
|
293
|
+
externalUserId: callerExternalUserId,
|
|
294
|
+
status: 'active',
|
|
295
|
+
policy: 'allow',
|
|
296
|
+
inviteId: invite.id,
|
|
297
|
+
});
|
|
298
|
+
memberId = member.id;
|
|
299
|
+
|
|
300
|
+
const recorded = recordInviteUse({
|
|
301
|
+
inviteId: invite.id,
|
|
302
|
+
externalUserId: callerExternalUserId,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
if (!recorded) throw STALE_INVITE;
|
|
306
|
+
}).immediate();
|
|
307
|
+
} catch (err) {
|
|
308
|
+
if (err === STALE_INVITE) {
|
|
309
|
+
return { ok: false, reason: 'invalid_or_expired' };
|
|
310
|
+
}
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
ok: true,
|
|
316
|
+
type: 'redeemed',
|
|
317
|
+
memberId: memberId!,
|
|
318
|
+
inviteId: invite.id,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
ApprovalUIMetadata,
|
|
12
12
|
} from '../channel-approval-types.js';
|
|
13
13
|
import type { DenialReason } from '../guardian-context-resolver.js';
|
|
14
|
-
export type {
|
|
14
|
+
export type { ActorTrustClass, DenialReason, GuardianContext } from '../guardian-context-resolver.js';
|
|
15
15
|
export { toGuardianRuntimeContext } from '../guardian-context-resolver.js';
|
|
16
16
|
|
|
17
17
|
/** Canonicalize assistantId for channel ingress paths. */
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
5
5
|
import { join, relative } from 'node:path';
|
|
6
6
|
|
|
7
|
+
import { createAssistantMessage, createUserMessage } from '../../agent/message-types.js';
|
|
7
8
|
import { CHANNEL_IDS, INTERFACE_IDS, parseChannelId, parseInterfaceId } from '../../channels/types.js';
|
|
8
9
|
import { mergeToolResults,renderHistoryContent } from '../../daemon/handlers.js';
|
|
9
10
|
import type { ServerMessage } from '../../daemon/ipc-protocol.js';
|
|
@@ -11,6 +12,8 @@ import * as attachmentsStore from '../../memory/attachments-store.js';
|
|
|
11
12
|
import {
|
|
12
13
|
createCanonicalGuardianRequest,
|
|
13
14
|
generateCanonicalRequestCode,
|
|
15
|
+
listCanonicalGuardianRequests,
|
|
16
|
+
listPendingCanonicalGuardianRequestsByDestinationConversation,
|
|
14
17
|
} from '../../memory/canonical-guardian-store.js';
|
|
15
18
|
import {
|
|
16
19
|
getConversationByKey,
|
|
@@ -21,6 +24,7 @@ import { getConfiguredProvider } from '../../providers/provider-send-message.js'
|
|
|
21
24
|
import type { Provider } from '../../providers/types.js';
|
|
22
25
|
import { getLogger } from '../../util/logger.js';
|
|
23
26
|
import { buildAssistantEvent } from '../assistant-event.js';
|
|
27
|
+
import { routeGuardianReply } from '../guardian-reply-router.js';
|
|
24
28
|
import { httpError } from '../http-errors.js';
|
|
25
29
|
import type {
|
|
26
30
|
MessageProcessor,
|
|
@@ -35,6 +39,143 @@ const log = getLogger('conversation-routes');
|
|
|
35
39
|
|
|
36
40
|
const SUGGESTION_CACHE_MAX = 100;
|
|
37
41
|
|
|
42
|
+
function collectLivePendingConfirmationRequestIds(
|
|
43
|
+
conversationId: string,
|
|
44
|
+
sourceChannel: string,
|
|
45
|
+
session: import('../../daemon/session.js').Session,
|
|
46
|
+
): string[] {
|
|
47
|
+
const pendingInteractionRequestIds = pendingInteractions
|
|
48
|
+
.getByConversation(conversationId)
|
|
49
|
+
.filter(
|
|
50
|
+
(interaction) =>
|
|
51
|
+
interaction.kind === 'confirmation'
|
|
52
|
+
&& interaction.session === session
|
|
53
|
+
&& session.hasPendingConfirmation(interaction.requestId),
|
|
54
|
+
)
|
|
55
|
+
.map((interaction) => interaction.requestId);
|
|
56
|
+
|
|
57
|
+
// Query both by destination conversation (via deliveries table) and by
|
|
58
|
+
// source conversation (direct field). For desktop/HTTP sessions these
|
|
59
|
+
// often overlap, but the Set dedup below handles that.
|
|
60
|
+
const pendingCanonicalRequestIds = [
|
|
61
|
+
...listPendingCanonicalGuardianRequestsByDestinationConversation(conversationId, sourceChannel)
|
|
62
|
+
.filter((request) => request.kind === 'tool_approval')
|
|
63
|
+
.map((request) => request.id),
|
|
64
|
+
...listCanonicalGuardianRequests({
|
|
65
|
+
status: 'pending',
|
|
66
|
+
conversationId,
|
|
67
|
+
kind: 'tool_approval',
|
|
68
|
+
}).map((request) => request.id),
|
|
69
|
+
].filter((requestId) => session.hasPendingConfirmation(requestId));
|
|
70
|
+
|
|
71
|
+
return Array.from(new Set([
|
|
72
|
+
...pendingInteractionRequestIds,
|
|
73
|
+
...pendingCanonicalRequestIds,
|
|
74
|
+
]));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function tryConsumeInlineApprovalReply(params: {
|
|
78
|
+
conversationId: string;
|
|
79
|
+
sourceChannel: string;
|
|
80
|
+
sourceInterface: string;
|
|
81
|
+
content: string;
|
|
82
|
+
attachments: Array<{
|
|
83
|
+
id: string;
|
|
84
|
+
filename: string;
|
|
85
|
+
mimeType: string;
|
|
86
|
+
data: string;
|
|
87
|
+
}>;
|
|
88
|
+
session: import('../../daemon/session.js').Session;
|
|
89
|
+
onEvent: (msg: ServerMessage) => void;
|
|
90
|
+
}): Promise<{ consumed: boolean; messageId?: string }> {
|
|
91
|
+
const {
|
|
92
|
+
conversationId,
|
|
93
|
+
sourceChannel,
|
|
94
|
+
sourceInterface,
|
|
95
|
+
content,
|
|
96
|
+
attachments,
|
|
97
|
+
session,
|
|
98
|
+
onEvent,
|
|
99
|
+
} = params;
|
|
100
|
+
const trimmedContent = content.trim();
|
|
101
|
+
|
|
102
|
+
// Only consume inline replies when there are no queued turns, matching
|
|
103
|
+
// the IPC path guard. With queued messages, "approve"/"no" should be
|
|
104
|
+
// processed in queue order rather than treated as a confirmation reply.
|
|
105
|
+
if (
|
|
106
|
+
!session.hasAnyPendingConfirmation()
|
|
107
|
+
|| session.getQueueDepth() > 0
|
|
108
|
+
|| trimmedContent.length === 0
|
|
109
|
+
) {
|
|
110
|
+
return { consumed: false };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const pendingRequestIds = collectLivePendingConfirmationRequestIds(conversationId, sourceChannel, session);
|
|
114
|
+
if (pendingRequestIds.length === 0) {
|
|
115
|
+
return { consumed: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const routerResult = await routeGuardianReply({
|
|
119
|
+
messageText: trimmedContent,
|
|
120
|
+
channel: sourceChannel,
|
|
121
|
+
actor: {
|
|
122
|
+
externalUserId: undefined,
|
|
123
|
+
channel: sourceChannel,
|
|
124
|
+
isTrusted: true,
|
|
125
|
+
},
|
|
126
|
+
conversationId,
|
|
127
|
+
pendingRequestIds,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (!routerResult.consumed || routerResult.type === 'nl_keep_pending') {
|
|
131
|
+
return { consumed: false };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Decision has been applied — transcript persistence is best-effort.
|
|
135
|
+
// If DB writes fail, we still return consumed: true so the approval text
|
|
136
|
+
// is not re-processed as a new user turn.
|
|
137
|
+
let messageId: string | undefined;
|
|
138
|
+
try {
|
|
139
|
+
const channelMeta = {
|
|
140
|
+
userMessageChannel: sourceChannel,
|
|
141
|
+
assistantMessageChannel: sourceChannel,
|
|
142
|
+
userMessageInterface: sourceInterface,
|
|
143
|
+
assistantMessageInterface: sourceInterface,
|
|
144
|
+
provenanceActorRole: 'guardian' as const,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const userMessage = createUserMessage(content, attachments);
|
|
148
|
+
const persistedUser = await conversationStore.addMessage(
|
|
149
|
+
conversationId,
|
|
150
|
+
'user',
|
|
151
|
+
JSON.stringify(userMessage.content),
|
|
152
|
+
channelMeta,
|
|
153
|
+
);
|
|
154
|
+
messageId = persistedUser.id;
|
|
155
|
+
|
|
156
|
+
const replyText = (routerResult.replyText?.trim())
|
|
157
|
+
|| (routerResult.decisionApplied ? 'Decision applied.' : 'Request already resolved.');
|
|
158
|
+
const assistantMessage = createAssistantMessage(replyText);
|
|
159
|
+
await conversationStore.addMessage(
|
|
160
|
+
conversationId,
|
|
161
|
+
'assistant',
|
|
162
|
+
JSON.stringify(assistantMessage.content),
|
|
163
|
+
channelMeta,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Avoid mutating in-memory history / emitting stream deltas while a run is active.
|
|
167
|
+
if (!session.isProcessing()) {
|
|
168
|
+
session.getMessages().push(userMessage, assistantMessage);
|
|
169
|
+
onEvent({ type: 'assistant_text_delta', text: replyText, sessionId: conversationId });
|
|
170
|
+
onEvent({ type: 'message_complete', sessionId: conversationId });
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
log.warn({ err, conversationId }, 'Failed to persist inline approval transcript entries');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { consumed: true, messageId };
|
|
177
|
+
}
|
|
178
|
+
|
|
38
179
|
function getInterfaceFilesWithMtimes(interfacesDir: string | null): Array<{ path: string; mtimeMs: number }> {
|
|
39
180
|
if (!interfacesDir || !existsSync(interfacesDir)) return [];
|
|
40
181
|
const results: Array<{ path: string; mtimeMs: number }> = [];
|
|
@@ -283,13 +424,36 @@ export async function handleSendMessage(
|
|
|
283
424
|
const session = await smDeps.getOrCreateSession(mapping.conversationId);
|
|
284
425
|
// HTTP API is a trusted local ingress (same as IPC) — set guardian context
|
|
285
426
|
// so that memory extraction is not silently disabled by unverified provenance.
|
|
286
|
-
session.setGuardianContext({
|
|
427
|
+
session.setGuardianContext({ trustClass: 'guardian', sourceChannel: sourceChannel ?? 'http' });
|
|
287
428
|
const onEvent = makeHubPublisher(smDeps, mapping.conversationId, session);
|
|
288
429
|
|
|
289
430
|
const attachments = hasAttachments
|
|
290
431
|
? smDeps.resolveAttachments(attachmentIds)
|
|
291
432
|
: [];
|
|
292
433
|
|
|
434
|
+
// Try to consume the message as an inline approval/rejection reply.
|
|
435
|
+
// On failure, degrade to the existing queue/auto-deny path rather than
|
|
436
|
+
// surfacing a 500 — mirrors the IPC handler's catch-and-fallback.
|
|
437
|
+
try {
|
|
438
|
+
const inlineReplyResult = await tryConsumeInlineApprovalReply({
|
|
439
|
+
conversationId: mapping.conversationId,
|
|
440
|
+
sourceChannel,
|
|
441
|
+
sourceInterface,
|
|
442
|
+
content: content ?? '',
|
|
443
|
+
attachments,
|
|
444
|
+
session,
|
|
445
|
+
onEvent,
|
|
446
|
+
});
|
|
447
|
+
if (inlineReplyResult.consumed) {
|
|
448
|
+
return Response.json(
|
|
449
|
+
{ accepted: true, ...(inlineReplyResult.messageId ? { messageId: inlineReplyResult.messageId } : {}) },
|
|
450
|
+
{ status: 202 },
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
} catch (err) {
|
|
454
|
+
log.warn({ err, conversationId: mapping.conversationId }, 'Inline approval consumption failed, falling through to normal send path');
|
|
455
|
+
}
|
|
456
|
+
|
|
293
457
|
if (session.isProcessing()) {
|
|
294
458
|
// If a tool confirmation is pending, auto-deny it so the agent
|
|
295
459
|
// can finish the current turn and process this queued message.
|
|
@@ -353,7 +517,7 @@ export async function handleSendMessage(
|
|
|
353
517
|
mapping.conversationId,
|
|
354
518
|
content ?? '',
|
|
355
519
|
hasAttachments ? attachmentIds : undefined,
|
|
356
|
-
{ guardianContext: {
|
|
520
|
+
{ guardianContext: { trustClass: 'guardian', sourceChannel } },
|
|
357
521
|
sourceChannel,
|
|
358
522
|
sourceInterface,
|
|
359
523
|
);
|
|
@@ -100,7 +100,7 @@ export async function handleApprovalInterception(
|
|
|
100
100
|
// request targeting this chat, the message might be a decision on behalf
|
|
101
101
|
// of a non-guardian requester.
|
|
102
102
|
if (
|
|
103
|
-
guardianCtx.
|
|
103
|
+
guardianCtx.trustClass === 'guardian' &&
|
|
104
104
|
senderExternalUserId
|
|
105
105
|
) {
|
|
106
106
|
// Callback/button path: deterministic and takes priority.
|
|
@@ -161,7 +161,7 @@ export async function handleApprovalInterception(
|
|
|
161
161
|
if (guardianApproval) {
|
|
162
162
|
// Validate that the sender is the specific guardian who was assigned
|
|
163
163
|
// this approval request. This is a defense-in-depth check — the
|
|
164
|
-
//
|
|
164
|
+
// trustClass check above already verifies the sender is a guardian,
|
|
165
165
|
// but this catches edge cases like binding rotation between request
|
|
166
166
|
// creation and decision.
|
|
167
167
|
if (senderExternalUserId !== guardianApproval.guardianExternalUserId) {
|
|
@@ -548,9 +548,15 @@ export async function handleApprovalInterception(
|
|
|
548
548
|
const pendingPrompt = getChannelApprovalPrompt(conversationId);
|
|
549
549
|
if (!pendingPrompt) return { handled: false };
|
|
550
550
|
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
|
|
551
|
+
// Legacy unverified-channel equivalent:
|
|
552
|
+
// unknown trust + explicit denial reason (`no_identity` / `no_binding`).
|
|
553
|
+
// Unknown without a denial reason means identity-known, non-member sender
|
|
554
|
+
// in a shared channel; that case must not force-reject someone else's request.
|
|
555
|
+
const isLegacyUnverifiedSender = guardianCtx.trustClass === 'unknown' && !!guardianCtx.denialReason;
|
|
556
|
+
|
|
557
|
+
// When the sender is from a legacy-unverified channel actor, auto-deny any
|
|
558
|
+
// pending confirmation and block self-approval.
|
|
559
|
+
if (isLegacyUnverifiedSender) {
|
|
554
560
|
const pending = getApprovalInfoByConversation(conversationId);
|
|
555
561
|
if (pending.length > 0) {
|
|
556
562
|
handleChannelDecision(
|
|
@@ -569,7 +575,12 @@ export async function handleApprovalInterception(
|
|
|
569
575
|
// When the sender is a non-guardian and there's a pending guardian approval
|
|
570
576
|
// for this conversation's request, block self-approval. The non-guardian must
|
|
571
577
|
// wait for the guardian to decide.
|
|
572
|
-
|
|
578
|
+
//
|
|
579
|
+
// Include identity-known, non-member senders (`unknown` without denialReason)
|
|
580
|
+
// so shared-channel participants can't approve/deny someone else's pending request.
|
|
581
|
+
const isIdentityKnownNonGuardian = guardianCtx.trustClass === 'trusted_contact'
|
|
582
|
+
|| (guardianCtx.trustClass === 'unknown' && !guardianCtx.denialReason);
|
|
583
|
+
if (isIdentityKnownNonGuardian) {
|
|
573
584
|
const pending = getApprovalInfoByConversation(conversationId);
|
|
574
585
|
if (pending.length > 0) {
|
|
575
586
|
const guardianApprovalForRequest = getPendingApprovalForRequest(pending[0].requestId);
|