@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
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
revokeMember,
|
|
53
53
|
upsertMember,
|
|
54
54
|
} from '../memory/ingress-member-store.js';
|
|
55
|
+
import { resolveActorTrust } from '../runtime/actor-trust-resolver.js';
|
|
55
56
|
import {
|
|
56
57
|
createOutboundSession,
|
|
57
58
|
validateAndConsumeChallenge,
|
|
@@ -143,6 +144,96 @@ describe('trusted contact verification → member activation', () => {
|
|
|
143
144
|
expect(member!.sourceChannel).toBe('telegram');
|
|
144
145
|
});
|
|
145
146
|
|
|
147
|
+
test('resolveActorTrust surfaces member displayName when sender displayName is missing', () => {
|
|
148
|
+
upsertMember({
|
|
149
|
+
assistantId: 'self',
|
|
150
|
+
sourceChannel: 'telegram',
|
|
151
|
+
externalUserId: 'requester-user-jeff',
|
|
152
|
+
externalChatId: 'requester-chat-jeff',
|
|
153
|
+
status: 'active',
|
|
154
|
+
policy: 'allow',
|
|
155
|
+
displayName: 'Jeff',
|
|
156
|
+
username: 'jeff_handle',
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const trust = resolveActorTrust({
|
|
160
|
+
assistantId: 'self',
|
|
161
|
+
sourceChannel: 'telegram',
|
|
162
|
+
externalChatId: 'requester-chat-jeff',
|
|
163
|
+
senderExternalUserId: 'requester-user-jeff',
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(trust.trustClass).toBe('trusted_contact');
|
|
167
|
+
expect(trust.actorMetadata.displayName).toBe('Jeff');
|
|
168
|
+
expect(trust.actorMetadata.senderDisplayName).toBeUndefined();
|
|
169
|
+
expect(trust.actorMetadata.memberDisplayName).toBe('Jeff');
|
|
170
|
+
expect(trust.actorMetadata.identifier).toBe('@jeff_handle');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('resolveActorTrust prioritizes member displayName over sender displayName', () => {
|
|
174
|
+
upsertMember({
|
|
175
|
+
assistantId: 'self',
|
|
176
|
+
sourceChannel: 'telegram',
|
|
177
|
+
externalUserId: 'requester-user-jeff-priority',
|
|
178
|
+
externalChatId: 'requester-chat-jeff-priority',
|
|
179
|
+
status: 'active',
|
|
180
|
+
policy: 'allow',
|
|
181
|
+
displayName: 'Jeff',
|
|
182
|
+
username: 'jeff_handle',
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const trust = resolveActorTrust({
|
|
186
|
+
assistantId: 'self',
|
|
187
|
+
sourceChannel: 'telegram',
|
|
188
|
+
externalChatId: 'requester-chat-jeff-priority',
|
|
189
|
+
senderExternalUserId: 'requester-user-jeff-priority',
|
|
190
|
+
senderUsername: 'jeffrey_telegram',
|
|
191
|
+
senderDisplayName: 'Jeffrey',
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(trust.trustClass).toBe('trusted_contact');
|
|
195
|
+
expect(trust.actorMetadata.displayName).toBe('Jeff');
|
|
196
|
+
expect(trust.actorMetadata.senderDisplayName).toBe('Jeffrey');
|
|
197
|
+
expect(trust.actorMetadata.memberDisplayName).toBe('Jeff');
|
|
198
|
+
expect(trust.actorMetadata.username).toBe('jeff_handle');
|
|
199
|
+
expect(trust.actorMetadata.identifier).toBe('@jeff_handle');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('resolveActorTrust falls back to sender metadata when member record matches chat but not sender (group chat)', () => {
|
|
203
|
+
// Simulate a group chat: member record exists for a different user who
|
|
204
|
+
// shares the same externalChatId (e.g., Telegram group).
|
|
205
|
+
upsertMember({
|
|
206
|
+
assistantId: 'self',
|
|
207
|
+
sourceChannel: 'telegram',
|
|
208
|
+
externalUserId: 'other-user-in-group',
|
|
209
|
+
externalChatId: 'shared-group-chat',
|
|
210
|
+
status: 'active',
|
|
211
|
+
policy: 'allow',
|
|
212
|
+
displayName: 'Other User',
|
|
213
|
+
username: 'other_handle',
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// A different sender sends a message in the same group chat
|
|
217
|
+
const trust = resolveActorTrust({
|
|
218
|
+
assistantId: 'self',
|
|
219
|
+
sourceChannel: 'telegram',
|
|
220
|
+
externalChatId: 'shared-group-chat',
|
|
221
|
+
senderExternalUserId: 'actual-sender-in-group',
|
|
222
|
+
senderUsername: 'actual_sender_handle',
|
|
223
|
+
senderDisplayName: 'Actual Sender',
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// The member record returned by findMember matched on chatId but belongs
|
|
227
|
+
// to a different user, so member metadata should NOT be used and trust
|
|
228
|
+
// should NOT be elevated to trusted_contact.
|
|
229
|
+
expect(trust.trustClass).toBe('unknown');
|
|
230
|
+
expect(trust.actorMetadata.displayName).toBe('Actual Sender');
|
|
231
|
+
expect(trust.actorMetadata.senderDisplayName).toBe('Actual Sender');
|
|
232
|
+
expect(trust.actorMetadata.memberDisplayName).toBeUndefined();
|
|
233
|
+
expect(trust.actorMetadata.username).toBe('actual_sender_handle');
|
|
234
|
+
expect(trust.actorMetadata.identifier).toBe('@actual_sender_handle');
|
|
235
|
+
});
|
|
236
|
+
|
|
146
237
|
test('post-verify message is accepted (ACL check passes)', () => {
|
|
147
238
|
// Create and verify a trusted contact
|
|
148
239
|
const session = createOutboundSession({
|
|
@@ -12,7 +12,9 @@ let rawConfigStore: Record<string, unknown> = {};
|
|
|
12
12
|
let mockIngressPublicBaseUrl: string | undefined = 'https://test.example.com';
|
|
13
13
|
|
|
14
14
|
mock.module('../config/loader.js', () => ({
|
|
15
|
-
getConfig: () => ({
|
|
15
|
+
getConfig: () => ({
|
|
16
|
+
ui: {},
|
|
17
|
+
}),
|
|
16
18
|
loadConfig: () => ({ ingress: { publicBaseUrl: mockIngressPublicBaseUrl } }),
|
|
17
19
|
loadRawConfig: () => ({ ...rawConfigStore }),
|
|
18
20
|
saveRawConfig: (cfg: Record<string, unknown>) => {
|
|
@@ -20,7 +20,9 @@ mock.module('../config/loader.js', () => ({
|
|
|
20
20
|
loadConfig: () => ({}),
|
|
21
21
|
saveConfig: () => {},
|
|
22
22
|
saveRawConfig: () => {},
|
|
23
|
-
getConfig: () => ({
|
|
23
|
+
getConfig: () => ({
|
|
24
|
+
ui: {},
|
|
25
|
+
}),
|
|
24
26
|
invalidateConfigCache: () => {},
|
|
25
27
|
getNestedValue: () => undefined,
|
|
26
28
|
setNestedValue: () => {},
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
const testDir = mkdtempSync(join(tmpdir(), 'voice-invite-redemption-test-'));
|
|
8
|
+
|
|
9
|
+
mock.module('../util/platform.js', () => ({
|
|
10
|
+
getDataDir: () => testDir,
|
|
11
|
+
isMacOS: () => process.platform === 'darwin',
|
|
12
|
+
isLinux: () => process.platform === 'linux',
|
|
13
|
+
isWindows: () => process.platform === 'win32',
|
|
14
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
15
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
16
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
17
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
18
|
+
ensureDataDir: () => {},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
mock.module('../util/logger.js', () => ({
|
|
22
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
23
|
+
get: () => () => {},
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
import { getSqlite, initializeDb, resetDb } from '../memory/db.js';
|
|
28
|
+
import { createInvite, revokeInvite } from '../memory/ingress-invite-store.js';
|
|
29
|
+
import { upsertMember } from '../memory/ingress-member-store.js';
|
|
30
|
+
import { redeemVoiceInviteCode } from '../runtime/invite-redemption-service.js';
|
|
31
|
+
import { generateVoiceCode, hashVoiceCode } from '../util/voice-code.js';
|
|
32
|
+
|
|
33
|
+
initializeDb();
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
resetDb();
|
|
37
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function resetTables() {
|
|
41
|
+
getSqlite().run('DELETE FROM assistant_ingress_members');
|
|
42
|
+
getSqlite().run('DELETE FROM assistant_ingress_invites');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// generateVoiceCode
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
describe('generateVoiceCode', () => {
|
|
50
|
+
test('generates a code with the default 6 digits', () => {
|
|
51
|
+
const code = generateVoiceCode();
|
|
52
|
+
expect(code.length).toBe(6);
|
|
53
|
+
expect(/^\d{6}$/.test(code)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('generates a code with the requested digit count', () => {
|
|
57
|
+
for (const digits of [4, 5, 6, 7, 8, 9, 10]) {
|
|
58
|
+
const code = generateVoiceCode(digits);
|
|
59
|
+
expect(code.length).toBe(digits);
|
|
60
|
+
expect(new RegExp(`^\\d{${digits}}$`).test(code)).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('throws for digit count below 4', () => {
|
|
65
|
+
expect(() => generateVoiceCode(3)).toThrow(/between 4 and 10/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('throws for digit count above 10', () => {
|
|
69
|
+
expect(() => generateVoiceCode(11)).toThrow(/between 4 and 10/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('produces different codes across multiple calls (randomness)', () => {
|
|
73
|
+
// Generate many codes and check that we don't get the same one every time.
|
|
74
|
+
// With 6 digits there are 900,000 possibilities, so getting 10 identical
|
|
75
|
+
// codes would be astronomically unlikely.
|
|
76
|
+
const codes = new Set<string>();
|
|
77
|
+
for (let i = 0; i < 10; i++) {
|
|
78
|
+
codes.add(generateVoiceCode());
|
|
79
|
+
}
|
|
80
|
+
// At least 2 distinct values in 10 tries
|
|
81
|
+
expect(codes.size).toBeGreaterThanOrEqual(2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('generated code is within the valid numeric range', () => {
|
|
85
|
+
for (let i = 0; i < 20; i++) {
|
|
86
|
+
const code = generateVoiceCode(6);
|
|
87
|
+
const num = parseInt(code, 10);
|
|
88
|
+
// 6 digits: range [100000, 999999]
|
|
89
|
+
expect(num).toBeGreaterThanOrEqual(100000);
|
|
90
|
+
expect(num).toBeLessThanOrEqual(999999);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// hashVoiceCode
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe('hashVoiceCode', () => {
|
|
100
|
+
test('produces a deterministic hash', () => {
|
|
101
|
+
const code = '123456';
|
|
102
|
+
const hash1 = hashVoiceCode(code);
|
|
103
|
+
const hash2 = hashVoiceCode(code);
|
|
104
|
+
expect(hash1).toBe(hash2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('produces a hex-encoded SHA-256 hash (64 chars)', () => {
|
|
108
|
+
const hash = hashVoiceCode('654321');
|
|
109
|
+
expect(hash.length).toBe(64);
|
|
110
|
+
expect(/^[0-9a-f]{64}$/.test(hash)).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('different codes produce different hashes', () => {
|
|
114
|
+
const hash1 = hashVoiceCode('111111');
|
|
115
|
+
const hash2 = hashVoiceCode('222222');
|
|
116
|
+
expect(hash1).not.toBe(hash2);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// redeemVoiceInviteCode
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe('redeemVoiceInviteCode', () => {
|
|
125
|
+
beforeEach(resetTables);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Helper: create a voice invite with a known code and return the
|
|
129
|
+
* invite record plus the plaintext code.
|
|
130
|
+
*/
|
|
131
|
+
function createVoiceInvite(opts: {
|
|
132
|
+
callerPhone?: string;
|
|
133
|
+
maxUses?: number;
|
|
134
|
+
expiresInMs?: number;
|
|
135
|
+
voiceCodeDigits?: number;
|
|
136
|
+
assistantId?: string;
|
|
137
|
+
} = {}) {
|
|
138
|
+
const digits = opts.voiceCodeDigits ?? 6;
|
|
139
|
+
const code = generateVoiceCode(digits);
|
|
140
|
+
const codeHash = hashVoiceCode(code);
|
|
141
|
+
|
|
142
|
+
const { invite } = createInvite({
|
|
143
|
+
assistantId: opts.assistantId ?? 'self',
|
|
144
|
+
sourceChannel: 'voice',
|
|
145
|
+
maxUses: opts.maxUses ?? 1,
|
|
146
|
+
expiresInMs: opts.expiresInMs,
|
|
147
|
+
expectedExternalUserId: opts.callerPhone ?? '+15551234567',
|
|
148
|
+
voiceCodeHash: codeHash,
|
|
149
|
+
voiceCodeDigits: digits,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return { invite, code };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
test('happy path: correct caller + correct code redeems successfully', () => {
|
|
156
|
+
const phone = '+15551234567';
|
|
157
|
+
const { code } = createVoiceInvite({ callerPhone: phone });
|
|
158
|
+
|
|
159
|
+
const result = redeemVoiceInviteCode({
|
|
160
|
+
callerExternalUserId: phone,
|
|
161
|
+
sourceChannel: 'voice',
|
|
162
|
+
code,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(result.ok).toBe(true);
|
|
166
|
+
expect(result).toMatchObject({
|
|
167
|
+
ok: true,
|
|
168
|
+
type: 'redeemed',
|
|
169
|
+
memberId: expect.any(String),
|
|
170
|
+
inviteId: expect.any(String),
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('wrong caller identity fails with generic error', () => {
|
|
175
|
+
const { code } = createVoiceInvite({ callerPhone: '+15551234567' });
|
|
176
|
+
|
|
177
|
+
const result = redeemVoiceInviteCode({
|
|
178
|
+
callerExternalUserId: '+19999999999',
|
|
179
|
+
sourceChannel: 'voice',
|
|
180
|
+
code,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('wrong code fails with generic error', () => {
|
|
187
|
+
createVoiceInvite({ callerPhone: '+15551234567' });
|
|
188
|
+
|
|
189
|
+
const result = redeemVoiceInviteCode({
|
|
190
|
+
callerExternalUserId: '+15551234567',
|
|
191
|
+
sourceChannel: 'voice',
|
|
192
|
+
code: '000000',
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('expired invite fails', () => {
|
|
199
|
+
const phone = '+15551234567';
|
|
200
|
+
const { code } = createVoiceInvite({ callerPhone: phone, expiresInMs: -1 });
|
|
201
|
+
|
|
202
|
+
const result = redeemVoiceInviteCode({
|
|
203
|
+
callerExternalUserId: phone,
|
|
204
|
+
sourceChannel: 'voice',
|
|
205
|
+
code,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('max uses exhausted fails', () => {
|
|
212
|
+
const phone = '+15551234567';
|
|
213
|
+
const { code } = createVoiceInvite({ callerPhone: phone, maxUses: 1 });
|
|
214
|
+
|
|
215
|
+
// First redemption succeeds
|
|
216
|
+
const first = redeemVoiceInviteCode({
|
|
217
|
+
callerExternalUserId: phone,
|
|
218
|
+
sourceChannel: 'voice',
|
|
219
|
+
code,
|
|
220
|
+
});
|
|
221
|
+
expect(first.ok).toBe(true);
|
|
222
|
+
|
|
223
|
+
// Second redemption fails — max uses exhausted
|
|
224
|
+
const second = redeemVoiceInviteCode({
|
|
225
|
+
callerExternalUserId: phone,
|
|
226
|
+
sourceChannel: 'voice',
|
|
227
|
+
code,
|
|
228
|
+
});
|
|
229
|
+
expect(second).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('revoked invite fails', () => {
|
|
233
|
+
const phone = '+15551234567';
|
|
234
|
+
const { invite, code } = createVoiceInvite({ callerPhone: phone });
|
|
235
|
+
|
|
236
|
+
revokeInvite(invite.id);
|
|
237
|
+
|
|
238
|
+
const result = redeemVoiceInviteCode({
|
|
239
|
+
callerExternalUserId: phone,
|
|
240
|
+
sourceChannel: 'voice',
|
|
241
|
+
code,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('voice-only invite cannot be redeemed if sourceChannel on invite is not voice', () => {
|
|
248
|
+
// Create a non-voice invite with voice code metadata to simulate a
|
|
249
|
+
// hypothetical misconfiguration. The redemption service filters by
|
|
250
|
+
// sourceChannel='voice', so non-voice invites are invisible.
|
|
251
|
+
const code = generateVoiceCode(6);
|
|
252
|
+
const codeHash = hashVoiceCode(code);
|
|
253
|
+
|
|
254
|
+
createInvite({
|
|
255
|
+
sourceChannel: 'telegram',
|
|
256
|
+
maxUses: 1,
|
|
257
|
+
expectedExternalUserId: '+15551234567',
|
|
258
|
+
voiceCodeHash: codeHash,
|
|
259
|
+
voiceCodeDigits: 6,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const result = redeemVoiceInviteCode({
|
|
263
|
+
callerExternalUserId: '+15551234567',
|
|
264
|
+
sourceChannel: 'voice',
|
|
265
|
+
code,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// findActiveVoiceInvites filters by sourceChannel='voice', so the
|
|
269
|
+
// telegram invite won't be found.
|
|
270
|
+
expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('already-member caller gets already_member outcome', () => {
|
|
274
|
+
const phone = '+15551234567';
|
|
275
|
+
const { code } = createVoiceInvite({ callerPhone: phone });
|
|
276
|
+
|
|
277
|
+
// Pre-create an active member for this phone on voice channel
|
|
278
|
+
upsertMember({
|
|
279
|
+
sourceChannel: 'voice',
|
|
280
|
+
externalUserId: phone,
|
|
281
|
+
status: 'active',
|
|
282
|
+
policy: 'allow',
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const result = redeemVoiceInviteCode({
|
|
286
|
+
callerExternalUserId: phone,
|
|
287
|
+
sourceChannel: 'voice',
|
|
288
|
+
code,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(result.ok).toBe(true);
|
|
292
|
+
expect(result).toMatchObject({
|
|
293
|
+
ok: true,
|
|
294
|
+
type: 'already_member',
|
|
295
|
+
memberId: expect.any(String),
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('blocked member gets generic failure to avoid leaking membership status', () => {
|
|
300
|
+
const phone = '+15551234567';
|
|
301
|
+
const { code } = createVoiceInvite({ callerPhone: phone });
|
|
302
|
+
|
|
303
|
+
upsertMember({
|
|
304
|
+
sourceChannel: 'voice',
|
|
305
|
+
externalUserId: phone,
|
|
306
|
+
status: 'blocked',
|
|
307
|
+
policy: 'deny',
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const result = redeemVoiceInviteCode({
|
|
311
|
+
callerExternalUserId: phone,
|
|
312
|
+
sourceChannel: 'voice',
|
|
313
|
+
code,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('empty callerExternalUserId fails', () => {
|
|
320
|
+
const result = redeemVoiceInviteCode({
|
|
321
|
+
callerExternalUserId: '',
|
|
322
|
+
sourceChannel: 'voice',
|
|
323
|
+
code: '123456',
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
});
|
|
@@ -53,6 +53,8 @@ mock.module('../util/logger.js', () => ({
|
|
|
53
53
|
|
|
54
54
|
mock.module('../config/loader.js', () => ({
|
|
55
55
|
getConfig: () => ({
|
|
56
|
+
ui: {},
|
|
57
|
+
|
|
56
58
|
provider: 'anthropic',
|
|
57
59
|
providerOrder: ['anthropic'],
|
|
58
60
|
apiKeys: { anthropic: 'test-key' },
|
|
@@ -266,7 +268,7 @@ describe('voice bridge confirmation handling (grant consumption via primitive)',
|
|
|
266
268
|
|
|
267
269
|
const guardianContext: GuardianRuntimeContext = {
|
|
268
270
|
sourceChannel: 'voice',
|
|
269
|
-
|
|
271
|
+
trustClass: 'trusted_contact',
|
|
270
272
|
requesterExternalUserId: 'caller-123',
|
|
271
273
|
};
|
|
272
274
|
|
|
@@ -307,7 +309,7 @@ describe('voice bridge confirmation handling (grant consumption via primitive)',
|
|
|
307
309
|
|
|
308
310
|
const guardianContext: GuardianRuntimeContext = {
|
|
309
311
|
sourceChannel: 'voice',
|
|
310
|
-
|
|
312
|
+
trustClass: 'trusted_contact',
|
|
311
313
|
requesterExternalUserId: 'caller-123',
|
|
312
314
|
};
|
|
313
315
|
|
|
@@ -343,7 +345,7 @@ describe('voice bridge confirmation handling (grant consumption via primitive)',
|
|
|
343
345
|
|
|
344
346
|
const guardianContext: GuardianRuntimeContext = {
|
|
345
347
|
sourceChannel: 'voice',
|
|
346
|
-
|
|
348
|
+
trustClass: 'trusted_contact',
|
|
347
349
|
};
|
|
348
350
|
|
|
349
351
|
await startVoiceTurn({
|
|
@@ -373,7 +375,7 @@ describe('voice bridge confirmation handling (grant consumption via primitive)',
|
|
|
373
375
|
|
|
374
376
|
const guardianContext: GuardianRuntimeContext = {
|
|
375
377
|
sourceChannel: 'voice',
|
|
376
|
-
|
|
378
|
+
trustClass: 'guardian',
|
|
377
379
|
};
|
|
378
380
|
|
|
379
381
|
await startVoiceTurn({
|
|
@@ -407,7 +409,7 @@ describe('voice bridge confirmation handling (grant consumption via primitive)',
|
|
|
407
409
|
|
|
408
410
|
const guardianContext: GuardianRuntimeContext = {
|
|
409
411
|
sourceChannel: 'voice',
|
|
410
|
-
|
|
412
|
+
trustClass: 'trusted_contact',
|
|
411
413
|
requesterExternalUserId: 'caller-123',
|
|
412
414
|
};
|
|
413
415
|
|
|
@@ -304,7 +304,7 @@ describe('voice-session-bridge', () => {
|
|
|
304
304
|
isInbound: true,
|
|
305
305
|
guardianContext: {
|
|
306
306
|
sourceChannel: 'voice',
|
|
307
|
-
|
|
307
|
+
trustClass: 'trusted_contact',
|
|
308
308
|
guardianExternalUserId: '+15550009999',
|
|
309
309
|
guardianChatId: '+15550009999',
|
|
310
310
|
requesterExternalUserId: '+15550002222',
|
|
@@ -340,7 +340,7 @@ describe('voice-session-bridge', () => {
|
|
|
340
340
|
isInbound: true,
|
|
341
341
|
guardianContext: {
|
|
342
342
|
sourceChannel: 'voice',
|
|
343
|
-
|
|
343
|
+
trustClass: 'unknown',
|
|
344
344
|
denialReason: 'no_binding',
|
|
345
345
|
},
|
|
346
346
|
onTextDelta: () => {},
|
|
@@ -374,7 +374,7 @@ describe('voice-session-bridge', () => {
|
|
|
374
374
|
isInbound: true,
|
|
375
375
|
guardianContext: {
|
|
376
376
|
sourceChannel: 'voice',
|
|
377
|
-
|
|
377
|
+
trustClass: 'guardian',
|
|
378
378
|
guardianExternalUserId: '+15550001111',
|
|
379
379
|
guardianChatId: '+15550001111',
|
|
380
380
|
},
|
|
@@ -407,7 +407,7 @@ describe('voice-session-bridge', () => {
|
|
|
407
407
|
|
|
408
408
|
const guardianCtx = {
|
|
409
409
|
sourceChannel: 'voice' as const,
|
|
410
|
-
|
|
410
|
+
trustClass: 'guardian' as const,
|
|
411
411
|
guardianExternalUserId: '+15550001111',
|
|
412
412
|
guardianChatId: '+15550001111',
|
|
413
413
|
};
|
|
@@ -450,7 +450,7 @@ describe('voice-session-bridge', () => {
|
|
|
450
450
|
isInbound: true,
|
|
451
451
|
guardianContext: {
|
|
452
452
|
sourceChannel: 'voice',
|
|
453
|
-
|
|
453
|
+
trustClass: 'trusted_contact',
|
|
454
454
|
},
|
|
455
455
|
onTextDelta: () => {},
|
|
456
456
|
onComplete: () => {},
|
|
@@ -499,7 +499,7 @@ describe('voice-session-bridge', () => {
|
|
|
499
499
|
isInbound: true,
|
|
500
500
|
guardianContext: {
|
|
501
501
|
sourceChannel: 'voice',
|
|
502
|
-
|
|
502
|
+
trustClass: 'trusted_contact',
|
|
503
503
|
},
|
|
504
504
|
onTextDelta: () => {},
|
|
505
505
|
onComplete: () => {},
|
|
@@ -574,7 +574,7 @@ describe('voice-session-bridge', () => {
|
|
|
574
574
|
isInbound: true,
|
|
575
575
|
guardianContext: {
|
|
576
576
|
sourceChannel: 'voice',
|
|
577
|
-
|
|
577
|
+
trustClass: 'trusted_contact',
|
|
578
578
|
guardianExternalUserId: '+15550009999',
|
|
579
579
|
guardianChatId: '+15550009999',
|
|
580
580
|
requesterExternalUserId: '+15550002222',
|
|
@@ -644,7 +644,7 @@ describe('voice-session-bridge', () => {
|
|
|
644
644
|
isInbound: true,
|
|
645
645
|
guardianContext: {
|
|
646
646
|
sourceChannel: 'voice',
|
|
647
|
-
|
|
647
|
+
trustClass: 'unknown',
|
|
648
648
|
denialReason: 'no_binding',
|
|
649
649
|
},
|
|
650
650
|
onTextDelta: () => {},
|
|
@@ -768,7 +768,7 @@ describe('voice-session-bridge', () => {
|
|
|
768
768
|
isInbound: true,
|
|
769
769
|
guardianContext: {
|
|
770
770
|
sourceChannel: 'voice',
|
|
771
|
-
|
|
771
|
+
trustClass: 'guardian',
|
|
772
772
|
guardianExternalUserId: '+15550001111',
|
|
773
773
|
guardianChatId: '+15550001111',
|
|
774
774
|
},
|
|
@@ -835,7 +835,7 @@ describe('voice-session-bridge', () => {
|
|
|
835
835
|
isInbound: true,
|
|
836
836
|
guardianContext: {
|
|
837
837
|
sourceChannel: 'voice',
|
|
838
|
-
|
|
838
|
+
trustClass: 'guardian',
|
|
839
839
|
guardianExternalUserId: '+15550001111',
|
|
840
840
|
guardianChatId: '+15550001111',
|
|
841
841
|
},
|
|
@@ -10,8 +10,12 @@ import { existsSync,mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
|
|
13
|
-
import { afterEach,beforeEach, describe, expect, test } from 'bun:test';
|
|
13
|
+
import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
14
14
|
|
|
15
|
+
import {
|
|
16
|
+
_resetEnrichmentService,
|
|
17
|
+
getEnrichmentService,
|
|
18
|
+
} from '../workspace/commit-message-enrichment-service.js';
|
|
15
19
|
import {
|
|
16
20
|
_resetGitServiceRegistry,
|
|
17
21
|
getWorkspaceGitService,
|
|
@@ -36,12 +40,19 @@ describe('Workspace git lifecycle (integration)', () => {
|
|
|
36
40
|
_resetHeartbeatState();
|
|
37
41
|
});
|
|
38
42
|
|
|
39
|
-
afterEach(() => {
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
try { await getEnrichmentService().shutdown(); } catch { /* ignore */ }
|
|
45
|
+
_resetEnrichmentService();
|
|
40
46
|
if (existsSync(testDir)) {
|
|
41
47
|
rmSync(testDir, { recursive: true, force: true });
|
|
42
48
|
}
|
|
43
49
|
});
|
|
44
50
|
|
|
51
|
+
afterAll(async () => {
|
|
52
|
+
try { await getEnrichmentService().shutdown(); } catch { /* ignore */ }
|
|
53
|
+
_resetEnrichmentService();
|
|
54
|
+
});
|
|
55
|
+
|
|
45
56
|
// Build a clean git env: strip all GIT_* env vars that CI runners
|
|
46
57
|
// inject, then set GIT_CEILING_DIRECTORIES to isolate test repos.
|
|
47
58
|
function gitEnv(cwd: string): Record<string, string> {
|