@vellumai/assistant 0.4.3 → 0.4.4
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/.env.example +3 -0
- package/ARCHITECTURE.md +40 -3
- package/README.md +43 -35
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +1 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
- package/src/__tests__/actor-token-service.test.ts +1099 -0
- package/src/__tests__/agent-loop.test.ts +51 -0
- package/src/__tests__/approval-routes-http.test.ts +2 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
- package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
- package/src/__tests__/call-controller.test.ts +49 -0
- package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
- package/src/__tests__/call-pointer-messages.test.ts +93 -3
- package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
- package/src/__tests__/callback-handoff-copy.test.ts +186 -0
- package/src/__tests__/channel-approval-routes.test.ts +133 -12
- package/src/__tests__/channel-guardian.test.ts +0 -87
- package/src/__tests__/channel-readiness-service.test.ts +10 -16
- package/src/__tests__/checker.test.ts +33 -12
- package/src/__tests__/config-schema.test.ts +4 -0
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
- package/src/__tests__/conversation-routes.test.ts +12 -3
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/daemon-server-session-init.test.ts +4 -0
- package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
- package/src/__tests__/guardian-dispatch.test.ts +8 -0
- package/src/__tests__/guardian-outbound-http.test.ts +4 -4
- package/src/__tests__/guardian-question-mode.test.ts +200 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
- package/src/__tests__/guardian-routing-state.test.ts +525 -0
- package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
- package/src/__tests__/handlers-telegram-config.test.ts +0 -83
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
- package/src/__tests__/headless-browser-navigate.test.ts +2 -0
- package/src/__tests__/ipc-snapshot.test.ts +18 -51
- package/src/__tests__/non-member-access-request.test.ts +131 -8
- package/src/__tests__/notification-decision-fallback.test.ts +129 -4
- package/src/__tests__/notification-decision-strategy.test.ts +62 -2
- package/src/__tests__/notification-guardian-path.test.ts +3 -0
- package/src/__tests__/recording-intent-handler.test.ts +1 -0
- package/src/__tests__/relay-server.test.ts +841 -39
- package/src/__tests__/send-endpoint-busy.test.ts +5 -0
- package/src/__tests__/session-agent-loop.test.ts +1 -0
- package/src/__tests__/session-confirmation-signals.test.ts +523 -0
- package/src/__tests__/session-init.benchmark.test.ts +0 -1
- package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
- package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
- package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
- package/src/__tests__/tool-executor.test.ts +21 -2
- package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
- package/src/__tests__/twilio-config.test.ts +2 -13
- package/src/agent/loop.ts +1 -1
- package/src/approvals/guardian-decision-primitive.ts +10 -2
- package/src/approvals/guardian-request-resolvers.ts +128 -9
- package/src/calls/call-constants.ts +21 -0
- package/src/calls/call-controller.ts +9 -2
- package/src/calls/call-domain.ts +28 -7
- package/src/calls/call-pointer-message-composer.ts +154 -0
- package/src/calls/call-pointer-messages.ts +106 -27
- package/src/calls/guardian-dispatch.ts +4 -2
- package/src/calls/relay-server.ts +424 -12
- package/src/calls/twilio-config.ts +4 -11
- package/src/calls/twilio-routes.ts +1 -1
- package/src/calls/types.ts +3 -1
- package/src/cli.ts +5 -4
- package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
- package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
- package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
- package/src/config/bundled-skills/messaging/SKILL.md +61 -12
- package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
- package/src/config/bundled-skills/twitter/SKILL.md +3 -3
- package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
- package/src/config/calls-schema.ts +24 -0
- package/src/config/env.ts +22 -0
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/schema.ts +2 -2
- package/src/config/skills.ts +11 -0
- package/src/config/system-prompt.ts +11 -1
- package/src/config/templates/SOUL.md +2 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
- package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
- package/src/daemon/call-pointer-generators.ts +59 -0
- package/src/daemon/computer-use-session.ts +2 -5
- package/src/daemon/handlers/apps.ts +76 -20
- package/src/daemon/handlers/config-channels.ts +5 -55
- package/src/daemon/handlers/config-inbox.ts +9 -3
- package/src/daemon/handlers/config-ingress.ts +28 -3
- package/src/daemon/handlers/config-telegram.ts +12 -0
- package/src/daemon/handlers/config.ts +2 -6
- package/src/daemon/handlers/pairing.ts +2 -0
- package/src/daemon/handlers/sessions.ts +48 -3
- package/src/daemon/handlers/shared.ts +17 -2
- package/src/daemon/ipc-contract/integrations.ts +1 -99
- package/src/daemon/ipc-contract/messages.ts +47 -1
- package/src/daemon/ipc-contract/notifications.ts +11 -0
- package/src/daemon/ipc-contract-inventory.json +2 -4
- package/src/daemon/lifecycle.ts +17 -0
- package/src/daemon/server.ts +14 -1
- package/src/daemon/session-agent-loop-handlers.ts +20 -0
- package/src/daemon/session-agent-loop.ts +22 -11
- package/src/daemon/session-lifecycle.ts +1 -1
- package/src/daemon/session-process.ts +11 -1
- package/src/daemon/session-runtime-assembly.ts +3 -0
- package/src/daemon/session-surfaces.ts +3 -2
- package/src/daemon/session.ts +88 -1
- package/src/daemon/tool-side-effects.ts +22 -0
- package/src/home-base/prebuilt/brain-graph.html +1483 -0
- package/src/home-base/prebuilt/index.html +40 -0
- package/src/inbound/platform-callback-registration.ts +157 -0
- package/src/memory/canonical-guardian-store.ts +1 -1
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/038-actor-token-records.ts +39 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/schema.ts +16 -0
- package/src/messaging/provider-types.ts +24 -0
- package/src/messaging/provider.ts +7 -0
- package/src/messaging/providers/gmail/adapter.ts +127 -0
- package/src/messaging/providers/sms/adapter.ts +40 -37
- package/src/notifications/adapters/macos.ts +45 -2
- package/src/notifications/broadcaster.ts +16 -0
- package/src/notifications/copy-composer.ts +39 -1
- package/src/notifications/decision-engine.ts +22 -9
- package/src/notifications/destination-resolver.ts +16 -2
- package/src/notifications/emit-signal.ts +16 -8
- package/src/notifications/guardian-question-mode.ts +419 -0
- package/src/notifications/signal.ts +14 -3
- package/src/permissions/checker.ts +13 -1
- package/src/permissions/prompter.ts +14 -0
- package/src/providers/anthropic/client.ts +20 -0
- package/src/providers/provider-send-message.ts +15 -3
- package/src/runtime/access-request-helper.ts +71 -1
- package/src/runtime/actor-token-service.ts +234 -0
- package/src/runtime/actor-token-store.ts +236 -0
- package/src/runtime/channel-approvals.ts +5 -3
- package/src/runtime/channel-readiness-service.ts +23 -64
- package/src/runtime/channel-readiness-types.ts +3 -4
- package/src/runtime/channel-retry-sweep.ts +4 -1
- package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/guardian-context-resolver.ts +82 -0
- package/src/runtime/guardian-outbound-actions.ts +0 -3
- package/src/runtime/guardian-reply-router.ts +67 -30
- package/src/runtime/guardian-vellum-migration.ts +57 -0
- package/src/runtime/http-server.ts +65 -12
- package/src/runtime/http-types.ts +13 -0
- package/src/runtime/invite-redemption-service.ts +8 -0
- package/src/runtime/local-actor-identity.ts +76 -0
- package/src/runtime/middleware/actor-token.ts +271 -0
- package/src/runtime/routes/approval-routes.ts +82 -7
- package/src/runtime/routes/brain-graph-routes.ts +222 -0
- package/src/runtime/routes/channel-readiness-routes.ts +71 -0
- package/src/runtime/routes/conversation-routes.ts +140 -52
- package/src/runtime/routes/events-routes.ts +20 -5
- package/src/runtime/routes/guardian-action-routes.ts +45 -3
- package/src/runtime/routes/guardian-approval-interception.ts +29 -0
- package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
- package/src/runtime/routes/inbound-message-handler.ts +143 -2
- package/src/runtime/routes/integration-routes.ts +7 -15
- package/src/runtime/routes/pairing-routes.ts +163 -0
- package/src/runtime/routes/twilio-routes.ts +934 -0
- package/src/runtime/tool-grant-request-helper.ts +3 -1
- package/src/security/oauth2.ts +27 -2
- package/src/security/token-manager.ts +46 -10
- package/src/tools/browser/browser-execution.ts +4 -3
- package/src/tools/browser/browser-handoff.ts +10 -18
- package/src/tools/browser/browser-manager.ts +80 -25
- package/src/tools/browser/browser-screencast.ts +35 -119
- package/src/tools/permission-checker.ts +15 -4
- package/src/tools/tool-approval-handler.ts +242 -18
- package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
- package/src/daemon/handlers/config-twilio.ts +0 -1082
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the trusted-contact pending-approval requester notification.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that:
|
|
5
|
+
* 1. Trusted contacts receive a one-shot "waiting for guardian approval" message
|
|
6
|
+
* 2. The message mentions the guardian by name when available
|
|
7
|
+
* 3. Messages are deduplicated by requestId (no repeated spam)
|
|
8
|
+
* 4. Guardian and unknown actors do NOT receive the notification
|
|
9
|
+
* 5. Delivery failures allow retry on next poll
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
17
|
+
|
|
18
|
+
const testDir = mkdtempSync(join(tmpdir(), 'tc-approval-notifier-test-'));
|
|
19
|
+
|
|
20
|
+
// ── Platform mock ──
|
|
21
|
+
mock.module('../util/platform.js', () => ({
|
|
22
|
+
getDataDir: () => testDir,
|
|
23
|
+
isMacOS: () => process.platform === 'darwin',
|
|
24
|
+
isLinux: () => process.platform === 'linux',
|
|
25
|
+
isWindows: () => process.platform === 'win32',
|
|
26
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
27
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
28
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
29
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
30
|
+
readHttpToken: () => 'test-token',
|
|
31
|
+
ensureDataDir: () => {},
|
|
32
|
+
migrateToDataLayout: () => {},
|
|
33
|
+
migrateToWorkspaceLayout: () => {},
|
|
34
|
+
normalizeAssistantId: (id: string) => id === 'self' || id === '' ? 'self' : id,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
// ── Logger mock ──
|
|
38
|
+
mock.module('../util/logger.js', () => ({
|
|
39
|
+
getLogger: () =>
|
|
40
|
+
new Proxy({} as Record<string, unknown>, {
|
|
41
|
+
get: () => () => {},
|
|
42
|
+
}),
|
|
43
|
+
isDebug: () => false,
|
|
44
|
+
truncateForLog: (value: string) => value,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
// ── Notification signal mock ──
|
|
48
|
+
mock.module('../notifications/emit-signal.js', () => ({
|
|
49
|
+
emitNotificationSignal: async () => ({
|
|
50
|
+
signalId: 'test-signal',
|
|
51
|
+
deduplicated: false,
|
|
52
|
+
dispatched: true,
|
|
53
|
+
reason: 'ok',
|
|
54
|
+
deliveryResults: [],
|
|
55
|
+
}),
|
|
56
|
+
registerBroadcastFn: () => {},
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
// ── Gateway client mock ──
|
|
60
|
+
// Track all deliverChannelReply calls for assertions
|
|
61
|
+
const deliveredReplies: Array<{
|
|
62
|
+
url: string;
|
|
63
|
+
payload: Record<string, unknown>;
|
|
64
|
+
bearerToken?: string;
|
|
65
|
+
}> = [];
|
|
66
|
+
let deliverShouldFail = false;
|
|
67
|
+
|
|
68
|
+
mock.module('../runtime/gateway-client.js', () => ({
|
|
69
|
+
deliverChannelReply: async (
|
|
70
|
+
url: string,
|
|
71
|
+
payload: Record<string, unknown>,
|
|
72
|
+
bearerToken?: string,
|
|
73
|
+
) => {
|
|
74
|
+
if (deliverShouldFail) {
|
|
75
|
+
throw new Error('Delivery failed');
|
|
76
|
+
}
|
|
77
|
+
deliveredReplies.push({ url, payload, bearerToken });
|
|
78
|
+
return { ok: true };
|
|
79
|
+
},
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
// ── Guardian binding mock ──
|
|
83
|
+
let mockGuardianBinding: Record<string, unknown> | null = null;
|
|
84
|
+
|
|
85
|
+
mock.module('../runtime/channel-guardian-service.js', () => ({
|
|
86
|
+
getGuardianBinding: () => mockGuardianBinding,
|
|
87
|
+
// Re-export stubs for other functions to prevent import errors
|
|
88
|
+
bindSessionIdentity: () => {},
|
|
89
|
+
createOutboundSession: () => ({}),
|
|
90
|
+
findActiveSession: () => null,
|
|
91
|
+
getGuardianBindingForChannel: () => null,
|
|
92
|
+
getPendingChallenge: () => null,
|
|
93
|
+
isGuardian: () => false,
|
|
94
|
+
resolveBootstrapToken: () => null,
|
|
95
|
+
updateSessionDelivery: () => {},
|
|
96
|
+
updateSessionStatus: () => {},
|
|
97
|
+
validateAndConsumeChallenge: () => ({ success: false, reason: 'no_challenge' }),
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
// ── Pending interactions mock ──
|
|
101
|
+
let mockPendingApprovals: Array<{
|
|
102
|
+
requestId: string;
|
|
103
|
+
toolName: string;
|
|
104
|
+
input: Record<string, unknown>;
|
|
105
|
+
riskLevel: string;
|
|
106
|
+
}> = [];
|
|
107
|
+
|
|
108
|
+
mock.module('../runtime/channel-approvals.js', () => ({
|
|
109
|
+
getApprovalInfoByConversation: () => mockPendingApprovals,
|
|
110
|
+
getChannelApprovalPrompt: () => null,
|
|
111
|
+
buildApprovalUIMetadata: () => ({}),
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
// ── Config env mock ──
|
|
115
|
+
mock.module('../config/env.js', () => ({
|
|
116
|
+
getGatewayInternalBaseUrl: () => 'http://localhost:3000',
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
// Import module under test AFTER mocks are set up
|
|
120
|
+
import type { ChannelId } from '../channels/types.js';
|
|
121
|
+
import type { GuardianContext } from '../runtime/guardian-context-resolver.js';
|
|
122
|
+
|
|
123
|
+
// We need to test the private functions by importing the module.
|
|
124
|
+
// Since startTrustedContactApprovalNotifier is not exported, we test it
|
|
125
|
+
// indirectly through handleChannelInbound via processChannelMessageInBackground.
|
|
126
|
+
//
|
|
127
|
+
// However, to test the notifier function in isolation, we extract the
|
|
128
|
+
// logic into a helper that we can call directly.
|
|
129
|
+
|
|
130
|
+
// For unit testing, we replicate the core logic here to verify behavior.
|
|
131
|
+
// The integration is tested by verifying deliverChannelReply calls.
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Helpers
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Simulates the core logic of the trusted-contact approval notifier.
|
|
139
|
+
* This mirrors the implementation in inbound-message-handler.ts.
|
|
140
|
+
*
|
|
141
|
+
* Uses a Map<requestId, conversationId> for deduplication so that cleanup
|
|
142
|
+
* is scoped to the owning conversation — concurrent pollers for different
|
|
143
|
+
* conversations will not evict each other's entries.
|
|
144
|
+
*/
|
|
145
|
+
async function simulateNotifierPoll(params: {
|
|
146
|
+
conversationId: string;
|
|
147
|
+
sourceChannel: ChannelId;
|
|
148
|
+
externalChatId: string;
|
|
149
|
+
guardianTrustClass: GuardianContext['trustClass'];
|
|
150
|
+
guardianExternalUserId?: string;
|
|
151
|
+
replyCallbackUrl: string;
|
|
152
|
+
bearerToken?: string;
|
|
153
|
+
assistantId?: string;
|
|
154
|
+
notifiedRequestIds: Map<string, string>;
|
|
155
|
+
}): Promise<boolean> {
|
|
156
|
+
const {
|
|
157
|
+
conversationId,
|
|
158
|
+
guardianTrustClass,
|
|
159
|
+
guardianExternalUserId,
|
|
160
|
+
notifiedRequestIds,
|
|
161
|
+
} = params;
|
|
162
|
+
|
|
163
|
+
// Gate check: only trusted contacts with guardian route
|
|
164
|
+
if (guardianTrustClass !== 'trusted_contact' || !guardianExternalUserId) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const { getApprovalInfoByConversation } = await import('../runtime/channel-approvals.js');
|
|
169
|
+
const { deliverChannelReply } = await import('../runtime/gateway-client.js');
|
|
170
|
+
const { getGuardianBinding } = await import('../runtime/channel-guardian-service.js');
|
|
171
|
+
|
|
172
|
+
const pending = getApprovalInfoByConversation(params.conversationId);
|
|
173
|
+
const info = pending[0];
|
|
174
|
+
|
|
175
|
+
// Clean up resolved requests — only for THIS conversation's entries.
|
|
176
|
+
const currentPendingIds = new Set(pending.map(p => p.requestId));
|
|
177
|
+
for (const [rid, cid] of notifiedRequestIds) {
|
|
178
|
+
if (cid === conversationId && !currentPendingIds.has(rid)) {
|
|
179
|
+
notifiedRequestIds.delete(rid);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!info || notifiedRequestIds.has(info.requestId)) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
notifiedRequestIds.set(info.requestId, conversationId);
|
|
188
|
+
|
|
189
|
+
// Resolve guardian name
|
|
190
|
+
let guardianName: string | undefined;
|
|
191
|
+
const binding = getGuardianBinding(params.assistantId ?? 'self', params.sourceChannel);
|
|
192
|
+
if (binding?.metadataJson) {
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(binding.metadataJson as string) as Record<string, unknown>;
|
|
195
|
+
if (typeof parsed.displayName === 'string' && parsed.displayName.trim().length > 0) {
|
|
196
|
+
guardianName = parsed.displayName.trim();
|
|
197
|
+
} else if (typeof parsed.username === 'string' && parsed.username.trim().length > 0) {
|
|
198
|
+
guardianName = `@${parsed.username.trim()}`;
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// ignore
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const waitingText = guardianName
|
|
206
|
+
? `Waiting for ${guardianName}'s approval...`
|
|
207
|
+
: 'Waiting for your guardian\'s approval...';
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
await deliverChannelReply(params.replyCallbackUrl, {
|
|
211
|
+
chatId: params.externalChatId,
|
|
212
|
+
text: waitingText,
|
|
213
|
+
assistantId: params.assistantId ?? 'self',
|
|
214
|
+
}, params.bearerToken);
|
|
215
|
+
return true;
|
|
216
|
+
} catch {
|
|
217
|
+
notifiedRequestIds.delete(info.requestId);
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ===========================================================================
|
|
223
|
+
// TESTS
|
|
224
|
+
// ===========================================================================
|
|
225
|
+
|
|
226
|
+
describe('trusted-contact pending-approval notifier', () => {
|
|
227
|
+
beforeEach(() => {
|
|
228
|
+
deliveredReplies.length = 0;
|
|
229
|
+
deliverShouldFail = false;
|
|
230
|
+
mockPendingApprovals = [];
|
|
231
|
+
mockGuardianBinding = null;
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
afterAll(() => {
|
|
235
|
+
try {
|
|
236
|
+
rmSync(testDir, { recursive: true });
|
|
237
|
+
} catch {
|
|
238
|
+
/* best effort */
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('sends waiting message to trusted contact when pending approval exists', async () => {
|
|
243
|
+
mockPendingApprovals = [{
|
|
244
|
+
requestId: 'req-1',
|
|
245
|
+
toolName: 'bash',
|
|
246
|
+
input: { command: 'ls' },
|
|
247
|
+
riskLevel: 'medium',
|
|
248
|
+
}];
|
|
249
|
+
|
|
250
|
+
mockGuardianBinding = {
|
|
251
|
+
id: 'binding-1',
|
|
252
|
+
metadataJson: JSON.stringify({ displayName: 'Mom' }),
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const notified = new Map<string, string>();
|
|
256
|
+
const sent = await simulateNotifierPoll({
|
|
257
|
+
conversationId: 'conv-1',
|
|
258
|
+
sourceChannel: 'telegram',
|
|
259
|
+
externalChatId: 'chat-123',
|
|
260
|
+
guardianTrustClass: 'trusted_contact',
|
|
261
|
+
guardianExternalUserId: 'guardian-1',
|
|
262
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
263
|
+
bearerToken: 'test-token',
|
|
264
|
+
assistantId: 'self',
|
|
265
|
+
notifiedRequestIds: notified,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
expect(sent).toBe(true);
|
|
269
|
+
expect(deliveredReplies).toHaveLength(1);
|
|
270
|
+
expect(deliveredReplies[0].payload.text).toBe("Waiting for Mom's approval...");
|
|
271
|
+
expect(deliveredReplies[0].payload.chatId).toBe('chat-123');
|
|
272
|
+
expect(notified.has('req-1')).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('uses username with @ prefix when display name is not available', async () => {
|
|
276
|
+
mockPendingApprovals = [{
|
|
277
|
+
requestId: 'req-2',
|
|
278
|
+
toolName: 'bash',
|
|
279
|
+
input: {},
|
|
280
|
+
riskLevel: 'medium',
|
|
281
|
+
}];
|
|
282
|
+
|
|
283
|
+
mockGuardianBinding = {
|
|
284
|
+
id: 'binding-1',
|
|
285
|
+
metadataJson: JSON.stringify({ username: 'guardian_user' }),
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const notified = new Map<string, string>();
|
|
289
|
+
await simulateNotifierPoll({
|
|
290
|
+
conversationId: 'conv-1',
|
|
291
|
+
sourceChannel: 'telegram',
|
|
292
|
+
externalChatId: 'chat-123',
|
|
293
|
+
guardianTrustClass: 'trusted_contact',
|
|
294
|
+
guardianExternalUserId: 'guardian-1',
|
|
295
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
296
|
+
notifiedRequestIds: notified,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
expect(deliveredReplies).toHaveLength(1);
|
|
300
|
+
expect(deliveredReplies[0].payload.text).toBe("Waiting for @guardian_user's approval...");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('uses generic phrasing when no guardian name is available', async () => {
|
|
304
|
+
mockPendingApprovals = [{
|
|
305
|
+
requestId: 'req-3',
|
|
306
|
+
toolName: 'bash',
|
|
307
|
+
input: {},
|
|
308
|
+
riskLevel: 'medium',
|
|
309
|
+
}];
|
|
310
|
+
|
|
311
|
+
// No binding metadata
|
|
312
|
+
mockGuardianBinding = {
|
|
313
|
+
id: 'binding-1',
|
|
314
|
+
metadataJson: null,
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const notified = new Map<string, string>();
|
|
318
|
+
await simulateNotifierPoll({
|
|
319
|
+
conversationId: 'conv-1',
|
|
320
|
+
sourceChannel: 'telegram',
|
|
321
|
+
externalChatId: 'chat-123',
|
|
322
|
+
guardianTrustClass: 'trusted_contact',
|
|
323
|
+
guardianExternalUserId: 'guardian-1',
|
|
324
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
325
|
+
notifiedRequestIds: notified,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
expect(deliveredReplies).toHaveLength(1);
|
|
329
|
+
expect(deliveredReplies[0].payload.text).toBe("Waiting for your guardian's approval...");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('uses generic phrasing when no guardian binding exists', async () => {
|
|
333
|
+
mockPendingApprovals = [{
|
|
334
|
+
requestId: 'req-4',
|
|
335
|
+
toolName: 'bash',
|
|
336
|
+
input: {},
|
|
337
|
+
riskLevel: 'medium',
|
|
338
|
+
}];
|
|
339
|
+
|
|
340
|
+
mockGuardianBinding = null;
|
|
341
|
+
|
|
342
|
+
const notified = new Map<string, string>();
|
|
343
|
+
await simulateNotifierPoll({
|
|
344
|
+
conversationId: 'conv-1',
|
|
345
|
+
sourceChannel: 'telegram',
|
|
346
|
+
externalChatId: 'chat-123',
|
|
347
|
+
guardianTrustClass: 'trusted_contact',
|
|
348
|
+
guardianExternalUserId: 'guardian-1',
|
|
349
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
350
|
+
notifiedRequestIds: notified,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(deliveredReplies).toHaveLength(1);
|
|
354
|
+
expect(deliveredReplies[0].payload.text).toBe("Waiting for your guardian's approval...");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('deduplicates by requestId — does not send twice for same request', async () => {
|
|
358
|
+
mockPendingApprovals = [{
|
|
359
|
+
requestId: 'req-5',
|
|
360
|
+
toolName: 'bash',
|
|
361
|
+
input: {},
|
|
362
|
+
riskLevel: 'medium',
|
|
363
|
+
}];
|
|
364
|
+
|
|
365
|
+
mockGuardianBinding = {
|
|
366
|
+
id: 'binding-1',
|
|
367
|
+
metadataJson: JSON.stringify({ displayName: 'Guardian' }),
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const notified = new Map<string, string>();
|
|
371
|
+
const baseParams = {
|
|
372
|
+
conversationId: 'conv-1',
|
|
373
|
+
sourceChannel: 'telegram' as ChannelId,
|
|
374
|
+
externalChatId: 'chat-123',
|
|
375
|
+
guardianTrustClass: 'trusted_contact' as const,
|
|
376
|
+
guardianExternalUserId: 'guardian-1',
|
|
377
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
378
|
+
notifiedRequestIds: notified,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// First poll: should send
|
|
382
|
+
const sent1 = await simulateNotifierPoll(baseParams);
|
|
383
|
+
expect(sent1).toBe(true);
|
|
384
|
+
expect(deliveredReplies).toHaveLength(1);
|
|
385
|
+
|
|
386
|
+
// Second poll: same requestId, should NOT send
|
|
387
|
+
const sent2 = await simulateNotifierPoll(baseParams);
|
|
388
|
+
expect(sent2).toBe(false);
|
|
389
|
+
expect(deliveredReplies).toHaveLength(1); // Still just 1
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('sends separate messages for different requestIds', async () => {
|
|
393
|
+
mockGuardianBinding = {
|
|
394
|
+
id: 'binding-1',
|
|
395
|
+
metadataJson: JSON.stringify({ displayName: 'Guardian' }),
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const notified = new Map<string, string>();
|
|
399
|
+
const baseParams = {
|
|
400
|
+
conversationId: 'conv-1',
|
|
401
|
+
sourceChannel: 'telegram' as ChannelId,
|
|
402
|
+
externalChatId: 'chat-123',
|
|
403
|
+
guardianTrustClass: 'trusted_contact' as const,
|
|
404
|
+
guardianExternalUserId: 'guardian-1',
|
|
405
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
406
|
+
notifiedRequestIds: notified,
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
// First request
|
|
410
|
+
mockPendingApprovals = [{
|
|
411
|
+
requestId: 'req-A',
|
|
412
|
+
toolName: 'bash',
|
|
413
|
+
input: {},
|
|
414
|
+
riskLevel: 'medium',
|
|
415
|
+
}];
|
|
416
|
+
await simulateNotifierPoll(baseParams);
|
|
417
|
+
expect(deliveredReplies).toHaveLength(1);
|
|
418
|
+
|
|
419
|
+
// Second request (different requestId)
|
|
420
|
+
mockPendingApprovals = [{
|
|
421
|
+
requestId: 'req-B',
|
|
422
|
+
toolName: 'read_file',
|
|
423
|
+
input: {},
|
|
424
|
+
riskLevel: 'low',
|
|
425
|
+
}];
|
|
426
|
+
await simulateNotifierPoll(baseParams);
|
|
427
|
+
expect(deliveredReplies).toHaveLength(2);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('concurrent pollers for different conversations do not evict each other', async () => {
|
|
431
|
+
mockGuardianBinding = {
|
|
432
|
+
id: 'binding-1',
|
|
433
|
+
metadataJson: JSON.stringify({ displayName: 'Guardian' }),
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// Shared dedupe map simulating the module-level global
|
|
437
|
+
const notified = new Map<string, string>();
|
|
438
|
+
|
|
439
|
+
// Conversation A gets a pending approval and notifies
|
|
440
|
+
mockPendingApprovals = [{
|
|
441
|
+
requestId: 'req-convA',
|
|
442
|
+
toolName: 'bash',
|
|
443
|
+
input: {},
|
|
444
|
+
riskLevel: 'medium',
|
|
445
|
+
}];
|
|
446
|
+
const sentA = await simulateNotifierPoll({
|
|
447
|
+
conversationId: 'conv-A',
|
|
448
|
+
sourceChannel: 'telegram',
|
|
449
|
+
externalChatId: 'chat-A',
|
|
450
|
+
guardianTrustClass: 'trusted_contact',
|
|
451
|
+
guardianExternalUserId: 'guardian-1',
|
|
452
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
453
|
+
notifiedRequestIds: notified,
|
|
454
|
+
});
|
|
455
|
+
expect(sentA).toBe(true);
|
|
456
|
+
expect(deliveredReplies).toHaveLength(1);
|
|
457
|
+
|
|
458
|
+
// Conversation B polls with no pending approvals — its cleanup must
|
|
459
|
+
// NOT evict conv-A's entry from the shared map.
|
|
460
|
+
mockPendingApprovals = [];
|
|
461
|
+
await simulateNotifierPoll({
|
|
462
|
+
conversationId: 'conv-B',
|
|
463
|
+
sourceChannel: 'telegram',
|
|
464
|
+
externalChatId: 'chat-B',
|
|
465
|
+
guardianTrustClass: 'trusted_contact',
|
|
466
|
+
guardianExternalUserId: 'guardian-1',
|
|
467
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
468
|
+
notifiedRequestIds: notified,
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// req-convA should still be in the notified map (not evicted by conv-B)
|
|
472
|
+
expect(notified.has('req-convA')).toBe(true);
|
|
473
|
+
|
|
474
|
+
// Re-poll conversation A with the same pending approval — should NOT
|
|
475
|
+
// re-send because the entry was preserved.
|
|
476
|
+
mockPendingApprovals = [{
|
|
477
|
+
requestId: 'req-convA',
|
|
478
|
+
toolName: 'bash',
|
|
479
|
+
input: {},
|
|
480
|
+
riskLevel: 'medium',
|
|
481
|
+
}];
|
|
482
|
+
const sentA2 = await simulateNotifierPoll({
|
|
483
|
+
conversationId: 'conv-A',
|
|
484
|
+
sourceChannel: 'telegram',
|
|
485
|
+
externalChatId: 'chat-A',
|
|
486
|
+
guardianTrustClass: 'trusted_contact',
|
|
487
|
+
guardianExternalUserId: 'guardian-1',
|
|
488
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
489
|
+
notifiedRequestIds: notified,
|
|
490
|
+
});
|
|
491
|
+
expect(sentA2).toBe(false);
|
|
492
|
+
expect(deliveredReplies).toHaveLength(1); // Still just 1 — no duplicate
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('does not activate for guardian actors', async () => {
|
|
496
|
+
mockPendingApprovals = [{
|
|
497
|
+
requestId: 'req-6',
|
|
498
|
+
toolName: 'bash',
|
|
499
|
+
input: {},
|
|
500
|
+
riskLevel: 'medium',
|
|
501
|
+
}];
|
|
502
|
+
|
|
503
|
+
const notified = new Map<string, string>();
|
|
504
|
+
const sent = await simulateNotifierPoll({
|
|
505
|
+
conversationId: 'conv-1',
|
|
506
|
+
sourceChannel: 'telegram',
|
|
507
|
+
externalChatId: 'chat-123',
|
|
508
|
+
guardianTrustClass: 'guardian',
|
|
509
|
+
guardianExternalUserId: 'guardian-1',
|
|
510
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
511
|
+
notifiedRequestIds: notified,
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
expect(sent).toBe(false);
|
|
515
|
+
expect(deliveredReplies).toHaveLength(0);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test('does not activate for unknown actors', async () => {
|
|
519
|
+
mockPendingApprovals = [{
|
|
520
|
+
requestId: 'req-7',
|
|
521
|
+
toolName: 'bash',
|
|
522
|
+
input: {},
|
|
523
|
+
riskLevel: 'medium',
|
|
524
|
+
}];
|
|
525
|
+
|
|
526
|
+
const notified = new Map<string, string>();
|
|
527
|
+
const sent = await simulateNotifierPoll({
|
|
528
|
+
conversationId: 'conv-1',
|
|
529
|
+
sourceChannel: 'telegram',
|
|
530
|
+
externalChatId: 'chat-123',
|
|
531
|
+
guardianTrustClass: 'unknown',
|
|
532
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
533
|
+
notifiedRequestIds: notified,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
expect(sent).toBe(false);
|
|
537
|
+
expect(deliveredReplies).toHaveLength(0);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test('does not activate for trusted contact without guardian identity', async () => {
|
|
541
|
+
mockPendingApprovals = [{
|
|
542
|
+
requestId: 'req-8',
|
|
543
|
+
toolName: 'bash',
|
|
544
|
+
input: {},
|
|
545
|
+
riskLevel: 'medium',
|
|
546
|
+
}];
|
|
547
|
+
|
|
548
|
+
const notified = new Map<string, string>();
|
|
549
|
+
const sent = await simulateNotifierPoll({
|
|
550
|
+
conversationId: 'conv-1',
|
|
551
|
+
sourceChannel: 'telegram',
|
|
552
|
+
externalChatId: 'chat-123',
|
|
553
|
+
guardianTrustClass: 'trusted_contact',
|
|
554
|
+
guardianExternalUserId: undefined,
|
|
555
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
556
|
+
notifiedRequestIds: notified,
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
expect(sent).toBe(false);
|
|
560
|
+
expect(deliveredReplies).toHaveLength(0);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test('retries delivery on failure — removes requestId from notified set', async () => {
|
|
564
|
+
mockPendingApprovals = [{
|
|
565
|
+
requestId: 'req-9',
|
|
566
|
+
toolName: 'bash',
|
|
567
|
+
input: {},
|
|
568
|
+
riskLevel: 'medium',
|
|
569
|
+
}];
|
|
570
|
+
|
|
571
|
+
mockGuardianBinding = {
|
|
572
|
+
id: 'binding-1',
|
|
573
|
+
metadataJson: JSON.stringify({ displayName: 'Guardian' }),
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const notified = new Map<string, string>();
|
|
577
|
+
const baseParams = {
|
|
578
|
+
conversationId: 'conv-1',
|
|
579
|
+
sourceChannel: 'telegram' as ChannelId,
|
|
580
|
+
externalChatId: 'chat-123',
|
|
581
|
+
guardianTrustClass: 'trusted_contact' as const,
|
|
582
|
+
guardianExternalUserId: 'guardian-1',
|
|
583
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
584
|
+
notifiedRequestIds: notified,
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
// First attempt: delivery fails
|
|
588
|
+
deliverShouldFail = true;
|
|
589
|
+
const sent1 = await simulateNotifierPoll(baseParams);
|
|
590
|
+
expect(sent1).toBe(false);
|
|
591
|
+
expect(notified.has('req-9')).toBe(false); // Removed for retry
|
|
592
|
+
|
|
593
|
+
// Second attempt: delivery succeeds
|
|
594
|
+
deliverShouldFail = false;
|
|
595
|
+
const sent2 = await simulateNotifierPoll(baseParams);
|
|
596
|
+
expect(sent2).toBe(true);
|
|
597
|
+
expect(deliveredReplies).toHaveLength(1);
|
|
598
|
+
expect(notified.has('req-9')).toBe(true);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test('does not send when no pending approvals exist', async () => {
|
|
602
|
+
mockPendingApprovals = [];
|
|
603
|
+
|
|
604
|
+
const notified = new Map<string, string>();
|
|
605
|
+
const sent = await simulateNotifierPoll({
|
|
606
|
+
conversationId: 'conv-1',
|
|
607
|
+
sourceChannel: 'telegram',
|
|
608
|
+
externalChatId: 'chat-123',
|
|
609
|
+
guardianTrustClass: 'trusted_contact',
|
|
610
|
+
guardianExternalUserId: 'guardian-1',
|
|
611
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
612
|
+
notifiedRequestIds: notified,
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
expect(sent).toBe(false);
|
|
616
|
+
expect(deliveredReplies).toHaveLength(0);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test('prefers displayName over username when both are present', async () => {
|
|
620
|
+
mockPendingApprovals = [{
|
|
621
|
+
requestId: 'req-10',
|
|
622
|
+
toolName: 'bash',
|
|
623
|
+
input: {},
|
|
624
|
+
riskLevel: 'medium',
|
|
625
|
+
}];
|
|
626
|
+
|
|
627
|
+
mockGuardianBinding = {
|
|
628
|
+
id: 'binding-1',
|
|
629
|
+
metadataJson: JSON.stringify({
|
|
630
|
+
displayName: 'Sarah',
|
|
631
|
+
username: 'sarah_bot',
|
|
632
|
+
}),
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const notified = new Map<string, string>();
|
|
636
|
+
await simulateNotifierPoll({
|
|
637
|
+
conversationId: 'conv-1',
|
|
638
|
+
sourceChannel: 'telegram',
|
|
639
|
+
externalChatId: 'chat-123',
|
|
640
|
+
guardianTrustClass: 'trusted_contact',
|
|
641
|
+
guardianExternalUserId: 'guardian-1',
|
|
642
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
643
|
+
notifiedRequestIds: notified,
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
expect(deliveredReplies).toHaveLength(1);
|
|
647
|
+
expect(deliveredReplies[0].payload.text).toBe("Waiting for Sarah's approval...");
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test('handles malformed metadataJson gracefully', async () => {
|
|
651
|
+
mockPendingApprovals = [{
|
|
652
|
+
requestId: 'req-11',
|
|
653
|
+
toolName: 'bash',
|
|
654
|
+
input: {},
|
|
655
|
+
riskLevel: 'medium',
|
|
656
|
+
}];
|
|
657
|
+
|
|
658
|
+
mockGuardianBinding = {
|
|
659
|
+
id: 'binding-1',
|
|
660
|
+
metadataJson: 'not-valid-json{{{',
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
const notified = new Map<string, string>();
|
|
664
|
+
await simulateNotifierPoll({
|
|
665
|
+
conversationId: 'conv-1',
|
|
666
|
+
sourceChannel: 'telegram',
|
|
667
|
+
externalChatId: 'chat-123',
|
|
668
|
+
guardianTrustClass: 'trusted_contact',
|
|
669
|
+
guardianExternalUserId: 'guardian-1',
|
|
670
|
+
replyCallbackUrl: 'http://localhost:3000/deliver/telegram',
|
|
671
|
+
notifiedRequestIds: notified,
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
expect(deliveredReplies).toHaveLength(1);
|
|
675
|
+
// Falls back to generic phrasing
|
|
676
|
+
expect(deliveredReplies[0].payload.text).toBe("Waiting for your guardian's approval...");
|
|
677
|
+
});
|
|
678
|
+
});
|