@vellumai/assistant 0.3.27 → 0.4.0
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 +81 -4
- package/Dockerfile +2 -2
- package/bun.lock +4 -1
- package/docs/trusted-contact-access.md +9 -2
- package/package.json +6 -3
- package/scripts/ipc/generate-swift.ts +9 -5
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
- package/src/__tests__/agent-loop-thinking.test.ts +1 -1
- package/src/__tests__/agent-loop.test.ts +119 -0
- 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__/bundled-asset.test.ts +107 -0
- 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__/canonical-guardian-store.test.ts +636 -0
- package/src/__tests__/channel-approval-routes.test.ts +174 -1
- 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__/emit-signal-routing-intent.test.ts +43 -1
- 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-actions-endpoint.test.ts +205 -345
- package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
- package/src/__tests__/guardian-dispatch.test.ts +21 -19
- 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 +1092 -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__/mcp-cli.test.ts +77 -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 +212 -36
- package/src/__tests__/notification-decision-fallback.test.ts +63 -3
- package/src/__tests__/notification-decision-strategy.test.ts +78 -0
- package/src/__tests__/notification-guardian-path.test.ts +15 -15
- 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__/onboarding-template-contract.test.ts +116 -21
- 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__/secret-scanner-executor.test.ts +59 -0
- package/src/__tests__/secret-scanner.test.ts +8 -0
- package/src/__tests__/send-endpoint-busy.test.ts +4 -0
- package/src/__tests__/sensitive-output-placeholders.test.ts +208 -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 +126 -59
- 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 +497 -0
- 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/agent/loop.ts +46 -3
- package/src/approvals/guardian-decision-primitive.ts +285 -0
- package/src/approvals/guardian-request-resolvers.ts +539 -0
- package/src/calls/call-controller.ts +26 -23
- package/src/calls/guardian-action-sweep.ts +10 -2
- package/src/calls/guardian-dispatch.ts +46 -40
- package/src/calls/relay-server.ts +358 -24
- 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/system-prompt.ts +2 -1
- package/src/config/templates/BOOTSTRAP.md +47 -31
- package/src/config/templates/USER.md +5 -0
- package/src/config/update-bulletin-template-path.ts +4 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
- package/src/daemon/handlers/config-inbox.ts +4 -4
- package/src/daemon/handlers/guardian-actions.ts +45 -66
- package/src/daemon/handlers/sessions.ts +148 -4
- package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
- package/src/daemon/ipc-contract/messages.ts +16 -0
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +22 -16
- package/src/daemon/pairing-store.ts +86 -3
- package/src/daemon/server.ts +18 -0
- package/src/daemon/session-agent-loop-handlers.ts +5 -4
- package/src/daemon/session-agent-loop.ts +33 -6
- package/src/daemon/session-lifecycle.ts +25 -17
- package/src/daemon/session-memory.ts +2 -2
- package/src/daemon/session-process.ts +68 -326
- package/src/daemon/session-runtime-assembly.ts +119 -25
- package/src/daemon/session-tool-setup.ts +3 -2
- package/src/daemon/session.ts +4 -3
- package/src/home-base/prebuilt/seed.ts +2 -1
- package/src/hooks/templates.ts +2 -1
- package/src/memory/canonical-guardian-store.ts +586 -0
- package/src/memory/channel-guardian-store.ts +2 -0
- package/src/memory/conversation-crud.ts +7 -7
- package/src/memory/db-init.ts +20 -0
- package/src/memory/embedding-local.ts +257 -39
- package/src/memory/embedding-runtime-manager.ts +471 -0
- package/src/memory/guardian-action-store.ts +7 -60
- package/src/memory/guardian-approvals.ts +9 -4
- 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/036-normalize-phone-identities.ts +289 -0
- package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
- package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
- package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
- package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
- package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
- package/src/memory/migrations/index.ts +5 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/qdrant-client.ts +31 -22
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +56 -0
- package/src/notifications/copy-composer.ts +31 -4
- package/src/notifications/decision-engine.ts +57 -0
- package/src/permissions/defaults.ts +2 -0
- package/src/runtime/access-request-helper.ts +173 -0
- package/src/runtime/actor-trust-resolver.ts +221 -0
- package/src/runtime/channel-guardian-service.ts +12 -4
- 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 -71
- package/src/runtime/guardian-decision-types.ts +6 -0
- package/src/runtime/guardian-reply-router.ts +717 -0
- package/src/runtime/http-server.ts +8 -0
- package/src/runtime/ingress-service.ts +80 -3
- package/src/runtime/invite-redemption-service.ts +141 -2
- package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
- 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 +20 -2
- package/src/runtime/routes/guardian-action-routes.ts +100 -109
- package/src/runtime/routes/guardian-approval-interception.ts +17 -6
- package/src/runtime/routes/inbound-message-handler.ts +205 -529
- package/src/runtime/routes/ingress-routes.ts +52 -4
- package/src/runtime/routes/pairing-routes.ts +3 -0
- package/src/runtime/tool-grant-request-helper.ts +195 -0
- package/src/tools/executor.ts +13 -1
- package/src/tools/guardian-control-plane-policy.ts +2 -2
- package/src/tools/sensitive-output-placeholders.ts +203 -0
- package/src/tools/tool-approval-handler.ts +53 -10
- package/src/tools/types.ts +13 -2
- package/src/util/bundled-asset.ts +31 -0
- package/src/util/canonicalize-identity.ts +52 -0
- 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
|
@@ -93,6 +93,10 @@ import {
|
|
|
93
93
|
handleInstructionCall,
|
|
94
94
|
handleStartCall,
|
|
95
95
|
} from './routes/call-routes.js';
|
|
96
|
+
import {
|
|
97
|
+
startCanonicalGuardianExpirySweep,
|
|
98
|
+
stopCanonicalGuardianExpirySweep,
|
|
99
|
+
} from './routes/canonical-guardian-expiry-sweep.js';
|
|
96
100
|
import { canonicalChannelAssistantId } from './routes/channel-route-shared.js';
|
|
97
101
|
import {
|
|
98
102
|
handleChannelDeliveryAck,
|
|
@@ -341,6 +345,9 @@ export class RuntimeHttpServer {
|
|
|
341
345
|
startGuardianActionSweep(getGatewayInternalBaseUrl(), this.bearerToken, this.guardianActionCopyGenerator);
|
|
342
346
|
log.info('Guardian action expiry sweep started');
|
|
343
347
|
|
|
348
|
+
startCanonicalGuardianExpirySweep();
|
|
349
|
+
log.info('Canonical guardian request expiry sweep started');
|
|
350
|
+
|
|
344
351
|
log.info('Running in gateway-only ingress mode. Direct webhook routes disabled.');
|
|
345
352
|
if (!isLoopbackHost(this.hostname)) {
|
|
346
353
|
log.warn('RUNTIME_HTTP_HOST is not bound to loopback. This may expose the runtime to direct public access.');
|
|
@@ -361,6 +368,7 @@ export class RuntimeHttpServer {
|
|
|
361
368
|
this.pairingStore.stop();
|
|
362
369
|
stopGuardianExpirySweep();
|
|
363
370
|
stopGuardianActionSweep();
|
|
371
|
+
stopCanonicalGuardianExpirySweep();
|
|
364
372
|
if (this.retrySweepTimer) {
|
|
365
373
|
clearInterval(this.retrySweepTimer);
|
|
366
374
|
this.retrySweepTimer = null;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical guardian request expiry sweep.
|
|
3
|
+
*
|
|
4
|
+
* Periodically scans the `canonical_guardian_requests` table for pending
|
|
5
|
+
* requests whose `expiresAt` timestamp has passed and transitions them to
|
|
6
|
+
* the `expired` status. This ensures that stale requests are cleaned up
|
|
7
|
+
* even when no follow-up traffic arrives from either the guardian or the
|
|
8
|
+
* requester.
|
|
9
|
+
*
|
|
10
|
+
* Complements the existing sweeps:
|
|
11
|
+
* - `calls/guardian-action-sweep.ts` — voice call guardian action expiry
|
|
12
|
+
* - `runtime/routes/guardian-expiry-sweep.ts` — channel guardian approval expiry
|
|
13
|
+
*
|
|
14
|
+
* Unlike those sweeps, this one operates on the unified canonical domain
|
|
15
|
+
* (`canonical_guardian_requests`) and does not need to auto-deny pending
|
|
16
|
+
* interactions or deliver channel notices — the canonical request status
|
|
17
|
+
* transition is the single source of truth, and consumers (resolvers,
|
|
18
|
+
* clients polling prompts) observe the expired status directly.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
listCanonicalGuardianRequests,
|
|
23
|
+
resolveCanonicalGuardianRequest,
|
|
24
|
+
} from '../../memory/canonical-guardian-store.js';
|
|
25
|
+
import { getLogger } from '../../util/logger.js';
|
|
26
|
+
|
|
27
|
+
const log = getLogger('canonical-guardian-expiry-sweep');
|
|
28
|
+
|
|
29
|
+
/** Interval at which the expiry sweep runs (60 seconds). */
|
|
30
|
+
const SWEEP_INTERVAL_MS = 60_000;
|
|
31
|
+
|
|
32
|
+
/** Timer handle for the sweep so it can be stopped in tests and shutdown. */
|
|
33
|
+
let sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
34
|
+
|
|
35
|
+
/** Guard against overlapping sweeps. */
|
|
36
|
+
let sweepInProgress = false;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sweep all pending canonical guardian requests that have expired.
|
|
40
|
+
*
|
|
41
|
+
* Uses CAS resolution (`resolveCanonicalGuardianRequest`) so that a
|
|
42
|
+
* concurrent decision that wins the race is never overwritten by the
|
|
43
|
+
* sweep. Returns the count of requests transitioned to expired.
|
|
44
|
+
*/
|
|
45
|
+
export function sweepExpiredCanonicalGuardianRequests(): number {
|
|
46
|
+
const pending = listCanonicalGuardianRequests({ status: 'pending' });
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
let expiredCount = 0;
|
|
49
|
+
|
|
50
|
+
for (const request of pending) {
|
|
51
|
+
if (!request.expiresAt) continue;
|
|
52
|
+
|
|
53
|
+
const expiresAtMs = new Date(request.expiresAt).getTime();
|
|
54
|
+
if (expiresAtMs >= now) continue;
|
|
55
|
+
|
|
56
|
+
// CAS resolve: only transition from 'pending' to 'expired'.
|
|
57
|
+
// If someone resolved it between our read and this write, the CAS
|
|
58
|
+
// fails harmlessly (returns null) and we skip the request.
|
|
59
|
+
const resolved = resolveCanonicalGuardianRequest(request.id, 'pending', {
|
|
60
|
+
status: 'expired',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (resolved) {
|
|
64
|
+
expiredCount++;
|
|
65
|
+
log.info(
|
|
66
|
+
{
|
|
67
|
+
event: 'canonical_request_expired',
|
|
68
|
+
requestId: request.id,
|
|
69
|
+
kind: request.kind,
|
|
70
|
+
expiresAt: request.expiresAt,
|
|
71
|
+
},
|
|
72
|
+
'Expired canonical guardian request via sweep',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (expiredCount > 0) {
|
|
78
|
+
log.info(
|
|
79
|
+
{ event: 'canonical_expiry_sweep_complete', expiredCount },
|
|
80
|
+
`Canonical guardian expiry sweep: expired ${expiredCount} request(s)`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return expiredCount;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Start the periodic canonical guardian expiry sweep. Idempotent — calling
|
|
89
|
+
* it multiple times reuses the same timer.
|
|
90
|
+
*/
|
|
91
|
+
export function startCanonicalGuardianExpirySweep(): void {
|
|
92
|
+
if (sweepTimer) return;
|
|
93
|
+
sweepTimer = setInterval(() => {
|
|
94
|
+
if (sweepInProgress) return;
|
|
95
|
+
sweepInProgress = true;
|
|
96
|
+
try {
|
|
97
|
+
sweepExpiredCanonicalGuardianRequests();
|
|
98
|
+
} catch (err) {
|
|
99
|
+
log.error({ err }, 'Canonical guardian expiry sweep failed');
|
|
100
|
+
} finally {
|
|
101
|
+
sweepInProgress = false;
|
|
102
|
+
}
|
|
103
|
+
}, SWEEP_INTERVAL_MS);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Stop the periodic canonical guardian expiry sweep. Used in tests and
|
|
108
|
+
* shutdown.
|
|
109
|
+
*/
|
|
110
|
+
export function stopCanonicalGuardianExpirySweep(): void {
|
|
111
|
+
if (sweepTimer) {
|
|
112
|
+
clearInterval(sweepTimer);
|
|
113
|
+
sweepTimer = null;
|
|
114
|
+
}
|
|
115
|
+
sweepInProgress = false;
|
|
116
|
+
}
|
|
@@ -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. */
|
|
@@ -8,6 +8,10 @@ import { CHANNEL_IDS, INTERFACE_IDS, parseChannelId, parseInterfaceId } from '..
|
|
|
8
8
|
import { mergeToolResults,renderHistoryContent } from '../../daemon/handlers.js';
|
|
9
9
|
import type { ServerMessage } from '../../daemon/ipc-protocol.js';
|
|
10
10
|
import * as attachmentsStore from '../../memory/attachments-store.js';
|
|
11
|
+
import {
|
|
12
|
+
createCanonicalGuardianRequest,
|
|
13
|
+
generateCanonicalRequestCode,
|
|
14
|
+
} from '../../memory/canonical-guardian-store.js';
|
|
11
15
|
import {
|
|
12
16
|
getConversationByKey,
|
|
13
17
|
getOrCreateConversation,
|
|
@@ -171,6 +175,20 @@ function makeHubPublisher(
|
|
|
171
175
|
persistentDecisionsAllowed: msg.persistentDecisionsAllowed,
|
|
172
176
|
},
|
|
173
177
|
});
|
|
178
|
+
|
|
179
|
+
// Create a canonical guardian request so IPC/HTTP handlers can find it
|
|
180
|
+
// via applyCanonicalGuardianDecision.
|
|
181
|
+
createCanonicalGuardianRequest({
|
|
182
|
+
id: msg.requestId,
|
|
183
|
+
kind: 'tool_approval',
|
|
184
|
+
sourceType: 'desktop',
|
|
185
|
+
sourceChannel: 'vellum',
|
|
186
|
+
conversationId,
|
|
187
|
+
toolName: msg.toolName,
|
|
188
|
+
status: 'pending',
|
|
189
|
+
requestCode: generateCanonicalRequestCode(),
|
|
190
|
+
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
191
|
+
});
|
|
174
192
|
} else if (msg.type === 'secret_request') {
|
|
175
193
|
pendingInteractions.register(msg.requestId, {
|
|
176
194
|
session,
|
|
@@ -265,7 +283,7 @@ export async function handleSendMessage(
|
|
|
265
283
|
const session = await smDeps.getOrCreateSession(mapping.conversationId);
|
|
266
284
|
// HTTP API is a trusted local ingress (same as IPC) — set guardian context
|
|
267
285
|
// so that memory extraction is not silently disabled by unverified provenance.
|
|
268
|
-
session.setGuardianContext({
|
|
286
|
+
session.setGuardianContext({ trustClass: 'guardian', sourceChannel: sourceChannel ?? 'http' });
|
|
269
287
|
const onEvent = makeHubPublisher(smDeps, mapping.conversationId, session);
|
|
270
288
|
|
|
271
289
|
const attachments = hasAttachments
|
|
@@ -335,7 +353,7 @@ export async function handleSendMessage(
|
|
|
335
353
|
mapping.conversationId,
|
|
336
354
|
content ?? '',
|
|
337
355
|
hasAttachments ? attachmentIds : undefined,
|
|
338
|
-
{ guardianContext: {
|
|
356
|
+
{ guardianContext: { trustClass: 'guardian', sourceChannel } },
|
|
339
357
|
sourceChannel,
|
|
340
358
|
sourceInterface,
|
|
341
359
|
);
|