@vellumai/assistant 0.3.18 → 0.3.20
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 +155 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/docs/architecture/integrations.md +7 -11
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -0
- package/src/__tests__/approval-primitive.test.ts +540 -0
- package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
- package/src/__tests__/call-controller.test.ts +605 -104
- package/src/__tests__/channel-invite-transport.test.ts +264 -0
- package/src/__tests__/checker.test.ts +60 -0
- package/src/__tests__/cli.test.ts +42 -1
- package/src/__tests__/config-schema.test.ts +11 -127
- package/src/__tests__/config-watcher.test.ts +0 -8
- package/src/__tests__/daemon-lifecycle.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +8 -2
- package/src/__tests__/diff.test.ts +22 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +779 -0
- package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
- package/src/__tests__/guardian-dispatch.test.ts +185 -1
- package/src/__tests__/guardian-grant-minting.test.ts +532 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
- package/src/__tests__/invite-redemption-service.test.ts +306 -0
- package/src/__tests__/ipc-snapshot.test.ts +58 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -0
- package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
- package/src/__tests__/sandbox-host-parity.test.ts +6 -13
- package/src/__tests__/scoped-approval-grants.test.ts +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
- package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
- package/src/__tests__/session-load-history-repair.test.ts +169 -2
- package/src/__tests__/session-runtime-assembly.test.ts +33 -5
- package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
- package/src/__tests__/skill-feature-flags.test.ts +188 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
- package/src/__tests__/skill-mirror-parity.test.ts +1 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
- package/src/__tests__/system-prompt.test.ts +1 -1
- package/src/__tests__/terminal-sandbox.test.ts +142 -9
- package/src/__tests__/terminal-tools.test.ts +2 -93
- package/src/__tests__/thread-seed-composer.test.ts +18 -0
- package/src/__tests__/tool-approval-handler.test.ts +350 -0
- package/src/__tests__/trust-store.test.ts +2 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
- package/src/agent/loop.ts +36 -1
- package/src/approvals/approval-primitive.ts +381 -0
- package/src/approvals/guardian-decision-primitive.ts +191 -0
- package/src/calls/call-controller.ts +276 -212
- package/src/calls/call-domain.ts +56 -6
- package/src/calls/guardian-dispatch.ts +56 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +59 -4
- package/src/cli/core-commands.ts +0 -4
- package/src/cli.ts +76 -34
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
- package/src/config/assistant-feature-flags.ts +162 -0
- package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
- package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
- package/src/config/bundled-skills/notifications/SKILL.md +18 -0
- package/src/config/bundled-skills/reminder/SKILL.md +49 -2
- package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
- package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
- package/src/config/core-schema.ts +1 -1
- package/src/config/env-registry.ts +10 -0
- package/src/config/feature-flag-registry.json +61 -0
- package/src/config/loader.ts +22 -1
- package/src/config/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +12 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +26 -0
- package/src/config/skills.ts +9 -0
- package/src/config/system-prompt.ts +110 -46
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +19 -1
- package/src/config/vellum-skills/catalog.json +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
- package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/config-watcher.ts +0 -1
- package/src/daemon/daemon-control.ts +1 -1
- package/src/daemon/guardian-invite-intent.ts +124 -0
- package/src/daemon/handlers/avatar.ts +68 -0
- package/src/daemon/handlers/browser.ts +2 -2
- package/src/daemon/handlers/config-channels.ts +18 -0
- package/src/daemon/handlers/guardian-actions.ts +120 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/sessions.ts +19 -0
- package/src/daemon/handlers/shared.ts +3 -1
- package/src/daemon/handlers/skills.ts +45 -2
- package/src/daemon/install-cli-launchers.ts +58 -13
- package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
- package/src/daemon/ipc-contract/sessions.ts +8 -2
- package/src/daemon/ipc-contract/settings.ts +25 -2
- package/src/daemon/ipc-contract/skills.ts +1 -0
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +6 -2
- package/src/daemon/main.ts +1 -0
- package/src/daemon/server.ts +1 -0
- package/src/daemon/session-lifecycle.ts +52 -7
- package/src/daemon/session-memory.ts +45 -0
- package/src/daemon/session-process.ts +260 -422
- package/src/daemon/session-runtime-assembly.ts +12 -0
- package/src/daemon/session-skill-tools.ts +14 -1
- package/src/daemon/session-tool-setup.ts +5 -0
- package/src/daemon/session.ts +11 -0
- package/src/daemon/tool-side-effects.ts +35 -9
- package/src/index.ts +0 -2
- package/src/memory/conversation-display-order-migration.ts +44 -0
- package/src/memory/conversation-queries.ts +2 -0
- package/src/memory/conversation-store.ts +91 -0
- package/src/memory/db-init.ts +13 -1
- package/src/memory/embedding-local.ts +22 -8
- package/src/memory/guardian-action-store.ts +133 -2
- package/src/memory/guardian-verification.ts +1 -1
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
- package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/schema.ts +35 -1
- package/src/memory/scoped-approval-grants.ts +518 -0
- package/src/messaging/providers/slack/client.ts +12 -0
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/notifications/decision-engine.ts +49 -12
- package/src/notifications/emit-signal.ts +7 -0
- package/src/notifications/signal.ts +7 -0
- package/src/notifications/thread-seed-composer.ts +2 -1
- package/src/permissions/checker.ts +27 -0
- package/src/runtime/channel-approval-types.ts +16 -6
- package/src/runtime/channel-approvals.ts +19 -15
- package/src/runtime/channel-invite-transport.ts +85 -0
- package/src/runtime/channel-invite-transports/telegram.ts +105 -0
- package/src/runtime/guardian-action-grant-minter.ts +154 -0
- package/src/runtime/guardian-action-message-composer.ts +30 -0
- package/src/runtime/guardian-decision-types.ts +91 -0
- package/src/runtime/http-server.ts +23 -1
- package/src/runtime/ingress-service.ts +22 -0
- package/src/runtime/invite-redemption-service.ts +181 -0
- package/src/runtime/invite-redemption-templates.ts +39 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/guardian-action-routes.ts +206 -0
- package/src/runtime/routes/guardian-approval-interception.ts +66 -74
- package/src/runtime/routes/inbound-message-handler.ts +568 -409
- package/src/runtime/routes/pairing-routes.ts +4 -0
- package/src/security/encrypted-store.ts +31 -17
- package/src/security/keychain.ts +176 -2
- package/src/security/secure-keys.ts +97 -0
- package/src/security/tool-approval-digest.ts +67 -0
- package/src/skills/remote-skill-policy.ts +131 -0
- package/src/tools/browser/browser-execution.ts +2 -2
- package/src/tools/browser/browser-manager.ts +46 -32
- package/src/tools/browser/browser-screencast.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -1
- package/src/tools/executor.ts +22 -17
- package/src/tools/network/script-proxy/session-manager.ts +1 -5
- package/src/tools/skills/load.ts +22 -8
- package/src/tools/system/avatar-generator.ts +119 -0
- package/src/tools/system/navigate-settings.ts +65 -0
- package/src/tools/system/open-system-settings.ts +75 -0
- package/src/tools/system/voice-config.ts +121 -32
- package/src/tools/terminal/backends/native.ts +40 -19
- package/src/tools/terminal/backends/types.ts +3 -3
- package/src/tools/terminal/parser.ts +1 -1
- package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
- package/src/tools/terminal/sandbox.ts +1 -12
- package/src/tools/terminal/shell.ts +3 -31
- package/src/tools/tool-approval-handler.ts +141 -3
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +6 -0
- package/src/util/diff.ts +36 -13
- package/Dockerfile.sandbox +0 -5
- package/src/__tests__/doordash-client.test.ts +0 -187
- package/src/__tests__/doordash-session.test.ts +0 -154
- package/src/__tests__/signup-e2e.test.ts +0 -354
- package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
- package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
- package/src/cli/doordash.ts +0 -1057
- package/src/config/bundled-skills/doordash/SKILL.md +0 -163
- package/src/config/templates/LOOKS.md +0 -25
- package/src/doordash/cart-queries.ts +0 -787
- package/src/doordash/client.ts +0 -1016
- package/src/doordash/order-queries.ts +0 -85
- package/src/doordash/queries.ts +0 -13
- package/src/doordash/query-extractor.ts +0 -94
- package/src/doordash/search-queries.ts +0 -203
- package/src/doordash/session.ts +0 -84
- package/src/doordash/store-queries.ts +0 -246
- package/src/doordash/types.ts +0 -367
- package/src/tools/terminal/backends/docker.ts +0 -379
package/src/calls/call-domain.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { getTwilioStatusCallbackUrl,getTwilioVoiceWebhookUrl } from '../inbound/
|
|
|
13
13
|
import { getOrCreateConversation } from '../memory/conversation-key-store.js';
|
|
14
14
|
import { queueGenerateConversationTitle } from '../memory/conversation-title-service.js';
|
|
15
15
|
import { upsertBinding } from '../memory/external-conversation-store.js';
|
|
16
|
+
import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
|
|
16
17
|
import { isGuardian } from '../runtime/channel-guardian-service.js';
|
|
17
18
|
import { getSecureKey } from '../security/secure-keys.js';
|
|
18
19
|
import { getLogger } from '../util/logger.js';
|
|
@@ -71,6 +72,8 @@ export type CancelCallInput = {
|
|
|
71
72
|
export type AnswerCallInput = {
|
|
72
73
|
callSessionId: string;
|
|
73
74
|
answer: string;
|
|
75
|
+
/** When provided, the answer is matched to this specific pending question/consultation. */
|
|
76
|
+
pendingQuestionId?: string;
|
|
74
77
|
};
|
|
75
78
|
|
|
76
79
|
export type RelayInstructionInput = {
|
|
@@ -489,6 +492,17 @@ export async function cancelCall(input: CancelCallInput): Promise<{ ok: true; se
|
|
|
489
492
|
// Expire any pending questions so they don't linger
|
|
490
493
|
expirePendingQuestions(callSessionId);
|
|
491
494
|
|
|
495
|
+
// Revoke any scoped approval grants bound to this call session.
|
|
496
|
+
// Revoke by both callSessionId and conversationId because the
|
|
497
|
+
// guardian-approval-interception minting path sets callSessionId: null
|
|
498
|
+
// but always sets conversationId.
|
|
499
|
+
try {
|
|
500
|
+
revokeScopedApprovalGrantsForContext({ callSessionId });
|
|
501
|
+
revokeScopedApprovalGrantsForContext({ conversationId: session.conversationId });
|
|
502
|
+
} catch (err) {
|
|
503
|
+
log.warn({ err, callSessionId }, 'Failed to revoke scoped grants on call cancel');
|
|
504
|
+
}
|
|
505
|
+
|
|
492
506
|
// Re-check final status: a concurrent transition (e.g. Twilio callback) may have
|
|
493
507
|
// moved the session to a terminal state before our update, causing it to be skipped.
|
|
494
508
|
const updated = getCallSession(callSessionId);
|
|
@@ -504,30 +518,66 @@ export async function cancelCall(input: CancelCallInput): Promise<{ ok: true; se
|
|
|
504
518
|
|
|
505
519
|
/**
|
|
506
520
|
* Answer a pending question for an active call.
|
|
521
|
+
*
|
|
522
|
+
* When `pendingQuestionId` is provided, the answer is matched to that specific
|
|
523
|
+
* pending question/consultation rather than relying on transient controller
|
|
524
|
+
* state. This allows answers to arrive while the call is active and not paused,
|
|
525
|
+
* as long as the referenced question is still pending.
|
|
507
526
|
*/
|
|
508
527
|
export async function answerCall(input: AnswerCallInput): Promise<{ ok: true; questionId: string } | CallError> {
|
|
509
|
-
const { callSessionId, answer } = input;
|
|
528
|
+
const { callSessionId, answer, pendingQuestionId } = input;
|
|
510
529
|
|
|
511
530
|
if (!answer || typeof answer !== 'string') {
|
|
512
531
|
return { ok: false, error: 'Missing answer', status: 400 };
|
|
513
532
|
}
|
|
514
533
|
|
|
534
|
+
const controller = getCallController(callSessionId);
|
|
535
|
+
if (!controller) {
|
|
536
|
+
log.warn({ callSessionId }, 'answerCall: no active controller for call session');
|
|
537
|
+
return { ok: false, error: 'No active controller for this call', status: 409 };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// When a specific question is targeted, validate it matches the active
|
|
541
|
+
// consultation. This prevents stale or duplicate answers from being
|
|
542
|
+
// applied to the wrong consultation.
|
|
543
|
+
if (pendingQuestionId) {
|
|
544
|
+
const activeQuestionId = controller.getPendingConsultationQuestionId();
|
|
545
|
+
if (!activeQuestionId) {
|
|
546
|
+
log.warn(
|
|
547
|
+
{ callSessionId, pendingQuestionId },
|
|
548
|
+
'answerCall: pendingQuestionId provided but no consultation is active',
|
|
549
|
+
);
|
|
550
|
+
return { ok: false, error: 'Referenced question is no longer pending', status: 409 };
|
|
551
|
+
}
|
|
552
|
+
if (activeQuestionId !== pendingQuestionId) {
|
|
553
|
+
log.warn(
|
|
554
|
+
{ callSessionId, pendingQuestionId, activeQuestionId },
|
|
555
|
+
'answerCall: pendingQuestionId does not match active consultation',
|
|
556
|
+
);
|
|
557
|
+
return { ok: false, error: 'Referenced question is stale — a newer consultation has superseded it', status: 409 };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Look up the pending question in the store for record-keeping
|
|
515
562
|
const question = getPendingQuestion(callSessionId);
|
|
516
563
|
if (!question) {
|
|
517
564
|
return { ok: false, error: 'No pending question found', status: 404 };
|
|
518
565
|
}
|
|
519
566
|
|
|
520
|
-
|
|
521
|
-
if (
|
|
522
|
-
log.warn(
|
|
523
|
-
|
|
567
|
+
// When pendingQuestionId is given, double-check it matches the store record
|
|
568
|
+
if (pendingQuestionId && question.id !== pendingQuestionId) {
|
|
569
|
+
log.warn(
|
|
570
|
+
{ callSessionId, pendingQuestionId, storeQuestionId: question.id },
|
|
571
|
+
'answerCall: store pending question does not match requested pendingQuestionId',
|
|
572
|
+
);
|
|
573
|
+
return { ok: false, error: 'Referenced question is stale', status: 409 };
|
|
524
574
|
}
|
|
525
575
|
|
|
526
576
|
const accepted = await controller.handleUserAnswer(answer);
|
|
527
577
|
if (!accepted) {
|
|
528
578
|
log.warn(
|
|
529
579
|
{ callSessionId },
|
|
530
|
-
'answerCall: controller rejected the answer (
|
|
580
|
+
'answerCall: controller rejected the answer (no pending consultation)',
|
|
531
581
|
);
|
|
532
582
|
return { ok: false, error: 'Controller is not waiting for an answer', status: 409 };
|
|
533
583
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
countPendingRequestsByCallSessionId,
|
|
13
13
|
createGuardianActionDelivery,
|
|
14
14
|
createGuardianActionRequest,
|
|
15
|
+
getGuardianConversationIdForCallSession,
|
|
15
16
|
updateDeliveryStatus,
|
|
16
17
|
} from '../memory/guardian-action-store.js';
|
|
17
18
|
import { emitNotificationSignal } from '../notifications/emit-signal.js';
|
|
@@ -22,11 +23,20 @@ import type { CallPendingQuestion } from './types.js';
|
|
|
22
23
|
|
|
23
24
|
const log = getLogger('guardian-dispatch');
|
|
24
25
|
|
|
26
|
+
// Per-callSessionId serialization lock. Ensures that concurrent dispatches for
|
|
27
|
+
// the same call session are serialized so the second dispatch always sees the
|
|
28
|
+
// delivery row (and thus the guardian conversation ID) persisted by the first.
|
|
29
|
+
const pendingDispatches = new Map<string, Promise<void>>();
|
|
30
|
+
|
|
25
31
|
export interface GuardianDispatchParams {
|
|
26
32
|
callSessionId: string;
|
|
27
33
|
conversationId: string;
|
|
28
34
|
assistantId: string;
|
|
29
35
|
pendingQuestion: CallPendingQuestion;
|
|
36
|
+
/** Tool identity for tool-approval requests (absent for informational ASK_GUARDIAN). */
|
|
37
|
+
toolName?: string;
|
|
38
|
+
/** Canonical SHA-256 digest of tool input for tool-approval requests. */
|
|
39
|
+
inputDigest?: string;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
function applyDeliveryStatus(deliveryId: string, result: NotificationDeliveryResult): void {
|
|
@@ -43,11 +53,38 @@ function applyDeliveryStatus(deliveryId: string, result: NotificationDeliveryRes
|
|
|
43
53
|
* Fire-and-forget: errors are logged but do not propagate.
|
|
44
54
|
*/
|
|
45
55
|
export async function dispatchGuardianQuestion(params: GuardianDispatchParams): Promise<void> {
|
|
56
|
+
const { callSessionId } = params;
|
|
57
|
+
|
|
58
|
+
// Serialize concurrent dispatches for the same call session so the second
|
|
59
|
+
// dispatch always sees the guardian conversation ID persisted by the first.
|
|
60
|
+
const preceding = pendingDispatches.get(callSessionId);
|
|
61
|
+
const current = (preceding ?? Promise.resolve()).then(() =>
|
|
62
|
+
dispatchGuardianQuestionInner(params),
|
|
63
|
+
);
|
|
64
|
+
// Store a suppressed-error variant so the chain never rejects, and keep
|
|
65
|
+
// a stable reference for the cleanup identity check below.
|
|
66
|
+
const suppressed = current.catch(() => {});
|
|
67
|
+
pendingDispatches.set(callSessionId, suppressed);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await current;
|
|
71
|
+
} finally {
|
|
72
|
+
// Clean up the map entry only if it still points to our promise, to avoid
|
|
73
|
+
// removing a later dispatch's entry.
|
|
74
|
+
if (pendingDispatches.get(callSessionId) === suppressed) {
|
|
75
|
+
pendingDispatches.delete(callSessionId);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Promise<void> {
|
|
46
81
|
const {
|
|
47
82
|
callSessionId,
|
|
48
83
|
conversationId,
|
|
49
84
|
assistantId,
|
|
50
85
|
pendingQuestion,
|
|
86
|
+
toolName,
|
|
87
|
+
inputDigest,
|
|
51
88
|
} = params;
|
|
52
89
|
|
|
53
90
|
try {
|
|
@@ -63,6 +100,8 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
|
|
|
63
100
|
pendingQuestionId: pendingQuestion.id,
|
|
64
101
|
questionText: pendingQuestion.questionText,
|
|
65
102
|
expiresAt,
|
|
103
|
+
toolName,
|
|
104
|
+
inputDigest,
|
|
66
105
|
});
|
|
67
106
|
|
|
68
107
|
log.info(
|
|
@@ -76,6 +115,22 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
|
|
|
76
115
|
// in the same call session.
|
|
77
116
|
const activeGuardianRequestCount = countPendingRequestsByCallSessionId(callSessionId);
|
|
78
117
|
|
|
118
|
+
// Look up the vellum conversation used for the first guardian question
|
|
119
|
+
// in this call session. When found, pass it as an affinity hint so the
|
|
120
|
+
// notification pipeline deterministically routes to the same conversation
|
|
121
|
+
// instead of letting the LLM choose a different thread.
|
|
122
|
+
const existingGuardianConversationId = getGuardianConversationIdForCallSession(callSessionId);
|
|
123
|
+
const conversationAffinityHint = existingGuardianConversationId
|
|
124
|
+
? { vellum: existingGuardianConversationId }
|
|
125
|
+
: undefined;
|
|
126
|
+
|
|
127
|
+
if (existingGuardianConversationId) {
|
|
128
|
+
log.info(
|
|
129
|
+
{ callSessionId, existingGuardianConversationId },
|
|
130
|
+
'Found existing guardian conversation for call session — enforcing thread affinity',
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
79
134
|
// Route through the canonical notification pipeline. The paired vellum
|
|
80
135
|
// conversation from this pipeline is the canonical guardian thread.
|
|
81
136
|
let vellumDeliveryId: string | null = null;
|
|
@@ -99,6 +154,7 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
|
|
|
99
154
|
pendingQuestionId: pendingQuestion.id,
|
|
100
155
|
activeGuardianRequestCount,
|
|
101
156
|
},
|
|
157
|
+
conversationAffinityHint,
|
|
102
158
|
dedupeKey: `guardian:${request.id}`,
|
|
103
159
|
onThreadCreated: (info) => {
|
|
104
160
|
if (info.sourceEventName !== 'guardian.question' || vellumDeliveryId) return;
|
|
@@ -12,6 +12,7 @@ import type { ServerWebSocket } from 'bun';
|
|
|
12
12
|
|
|
13
13
|
import { getConfig } from '../config/loader.js';
|
|
14
14
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
15
|
+
import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
|
|
15
16
|
import {
|
|
16
17
|
getPendingChallenge,
|
|
17
18
|
validateAndConsumeChallenge,
|
|
@@ -351,6 +352,18 @@ export class RelayConnection {
|
|
|
351
352
|
}
|
|
352
353
|
|
|
353
354
|
expirePendingQuestions(this.callSessionId);
|
|
355
|
+
|
|
356
|
+
// Revoke any scoped approval grants bound to this call session.
|
|
357
|
+
// Revoke by both callSessionId and conversationId because the
|
|
358
|
+
// guardian-approval-interception minting path sets callSessionId: null
|
|
359
|
+
// but always sets conversationId.
|
|
360
|
+
try {
|
|
361
|
+
revokeScopedApprovalGrantsForContext({ callSessionId: this.callSessionId });
|
|
362
|
+
revokeScopedApprovalGrantsForContext({ conversationId: session.conversationId });
|
|
363
|
+
} catch (err) {
|
|
364
|
+
log.warn({ err, callSessionId: this.callSessionId }, 'Failed to revoke scoped grants on transport close');
|
|
365
|
+
}
|
|
366
|
+
|
|
354
367
|
persistCallCompletionMessage(session.conversationId, this.callSessionId).catch((err) => {
|
|
355
368
|
log.error({ err, conversationId: session.conversationId, callSessionId: this.callSessionId }, 'Failed to persist call completion message');
|
|
356
369
|
});
|
package/src/calls/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type CallStatus = 'initiated' | 'ringing' | 'in_progress' | 'waiting_on_user' | 'completed' | 'failed' | 'cancelled';
|
|
2
|
-
export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed' | 'callee_verification_started' | 'callee_verification_succeeded' | 'callee_verification_failed' | 'guardian_voice_verification_started' | 'guardian_voice_verification_succeeded' | 'guardian_voice_verification_failed' | 'outbound_guardian_voice_verification_started' | 'outbound_guardian_voice_verification_succeeded' | 'outbound_guardian_voice_verification_failed' | 'guardian_consultation_timed_out' | 'guardian_unavailable_skipped';
|
|
2
|
+
export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed' | 'callee_verification_started' | 'callee_verification_succeeded' | 'callee_verification_failed' | 'guardian_voice_verification_started' | 'guardian_voice_verification_succeeded' | 'guardian_voice_verification_failed' | 'outbound_guardian_voice_verification_started' | 'outbound_guardian_voice_verification_succeeded' | 'outbound_guardian_voice_verification_failed' | 'guardian_consultation_timed_out' | 'guardian_unavailable_skipped' | 'guardian_consult_deferred' | 'guardian_consult_coalesced';
|
|
3
3
|
export type PendingQuestionStatus = 'pending' | 'answered' | 'expired' | 'cancelled';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* dependencies at startup via `setVoiceBridgeDeps()`.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { consumeGrantForInvocation } from '../approvals/approval-primitive.js';
|
|
13
14
|
import type { ChannelId } from '../channels/types.js';
|
|
14
15
|
import { getConfig } from '../config/loader.js';
|
|
15
16
|
import type { ServerMessage } from '../daemon/ipc-protocol.js';
|
|
@@ -19,6 +20,7 @@ import { resolveChannelCapabilities } from '../daemon/session-runtime-assembly.j
|
|
|
19
20
|
import { buildAssistantEvent } from '../runtime/assistant-event.js';
|
|
20
21
|
import { assistantEventHub } from '../runtime/assistant-event-hub.js';
|
|
21
22
|
import { checkIngressForSecrets } from '../security/secret-ingress.js';
|
|
23
|
+
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
22
24
|
import { IngressBlockedError } from '../util/errors.js';
|
|
23
25
|
import { getLogger } from '../util/logger.js';
|
|
24
26
|
|
|
@@ -81,6 +83,8 @@ export interface VoiceRunEventSink {
|
|
|
81
83
|
export interface VoiceTurnOptions {
|
|
82
84
|
/** The conversation ID for this voice call's session. */
|
|
83
85
|
conversationId: string;
|
|
86
|
+
/** The call session ID for scoped grant matching. */
|
|
87
|
+
callSessionId?: string;
|
|
84
88
|
/** The transcribed caller utterance or synthetic marker. */
|
|
85
89
|
content: string;
|
|
86
90
|
/** Assistant scope for multi-assistant channels. */
|
|
@@ -147,7 +151,12 @@ function buildVoiceCallControlPrompt(opts: {
|
|
|
147
151
|
'1. Be concise — keep responses to 1-3 sentences. Phone conversations should be brief and natural.',
|
|
148
152
|
...(opts.isCallerGuardian
|
|
149
153
|
? ['2. You are speaking directly with your guardian (your user). Do NOT use [ASK_GUARDIAN:]. If you need permission, information, or confirmation, ask them directly in the conversation. They can answer you right now.']
|
|
150
|
-
: [
|
|
154
|
+
: [[
|
|
155
|
+
'2. You can consult your guardian in two ways:',
|
|
156
|
+
' - For general questions or information: [ASK_GUARDIAN: your question here]',
|
|
157
|
+
' - For tool/action permission requests: [ASK_GUARDIAN_APPROVAL: {"question":"Describe what you need permission for","toolName":"the_tool_name","input":{...tool input object...}}]',
|
|
158
|
+
' Use ASK_GUARDIAN_APPROVAL when you need permission to execute a specific tool or action. Use ASK_GUARDIAN for everything else (general questions, advice, information). When you use either marker, add a natural hold message like "Let me check on that for you."',
|
|
159
|
+
].join('\n')]
|
|
151
160
|
),
|
|
152
161
|
);
|
|
153
162
|
|
|
@@ -194,7 +203,7 @@ function buildVoiceCallControlPrompt(opts: {
|
|
|
194
203
|
|
|
195
204
|
lines.push(
|
|
196
205
|
'9. After the opening greeting turn, treat the Task field as background context only — do not re-execute its instructions on subsequent turns.',
|
|
197
|
-
'10. Do not make up information. If you are unsure, use [ASK_GUARDIAN: your question] to consult your guardian.',
|
|
206
|
+
'10. Do not make up information. If you are unsure, use [ASK_GUARDIAN: your question] to consult your guardian. For tool permission requests, use [ASK_GUARDIAN_APPROVAL: {"question":"...","toolName":"...","input":{...}}].',
|
|
198
207
|
'</voice_call_control>',
|
|
199
208
|
);
|
|
200
209
|
|
|
@@ -298,6 +307,7 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
|
|
|
298
307
|
strictSideEffects,
|
|
299
308
|
};
|
|
300
309
|
session.setAssistantId(opts.assistantId ?? 'self');
|
|
310
|
+
session.callSessionId = opts.callSessionId;
|
|
301
311
|
session.setGuardianContext(opts.guardianContext ?? null);
|
|
302
312
|
session.setCommandIntent(null);
|
|
303
313
|
session.setTurnChannelContext({
|
|
@@ -336,12 +346,56 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
|
|
|
336
346
|
const autoDeny = !isGuardian;
|
|
337
347
|
const autoAllow = isGuardian;
|
|
338
348
|
let lastError: string | null = null;
|
|
339
|
-
session.updateClient((msg: ServerMessage) => {
|
|
349
|
+
session.updateClient(async (msg: ServerMessage) => {
|
|
340
350
|
if (msg.type === 'confirmation_request') {
|
|
341
351
|
if (autoDeny) {
|
|
352
|
+
// Non-guardian voice callers have no interactive approval UI.
|
|
353
|
+
// The pre-exec gate (tool-approval-handler.ts) handles grant
|
|
354
|
+
// consumption with retry for tool execution confirmations, but
|
|
355
|
+
// some confirmation_request events originate from proxy/network
|
|
356
|
+
// paths (e.g. PermissionPrompter in createProxyApprovalCallback)
|
|
357
|
+
// that bypass the pre-exec gate. We do a single sync lookup here
|
|
358
|
+
// (maxWaitMs: 0) since the primary retry path is in the pre-exec
|
|
359
|
+
// gate; this secondary path just needs a quick check.
|
|
360
|
+
try {
|
|
361
|
+
const inputDigest = computeToolApprovalDigest(msg.toolName, msg.input);
|
|
362
|
+
const consumeResult = await consumeGrantForInvocation({
|
|
363
|
+
requestId: msg.requestId,
|
|
364
|
+
toolName: msg.toolName,
|
|
365
|
+
inputDigest,
|
|
366
|
+
consumingRequestId: msg.requestId,
|
|
367
|
+
assistantId: opts.assistantId ?? 'self',
|
|
368
|
+
executionChannel: 'voice',
|
|
369
|
+
conversationId: opts.conversationId,
|
|
370
|
+
callSessionId: opts.callSessionId,
|
|
371
|
+
requesterExternalUserId: opts.guardianContext?.requesterExternalUserId,
|
|
372
|
+
}, { maxWaitMs: 0 });
|
|
373
|
+
|
|
374
|
+
if (consumeResult.ok) {
|
|
375
|
+
log.info(
|
|
376
|
+
{ turnId, toolName: msg.toolName, grantId: consumeResult.grant.id },
|
|
377
|
+
'Consumed scoped grant — allowing non-guardian voice confirmation',
|
|
378
|
+
);
|
|
379
|
+
session.handleConfirmationResponse(
|
|
380
|
+
msg.requestId,
|
|
381
|
+
'allow',
|
|
382
|
+
undefined,
|
|
383
|
+
undefined,
|
|
384
|
+
`Permission approved for "${msg.toolName}": guardian pre-approved via scoped grant.`,
|
|
385
|
+
);
|
|
386
|
+
publishToHub(msg);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
} catch (err) {
|
|
390
|
+
log.error(
|
|
391
|
+
{ err, turnId, toolName: msg.toolName },
|
|
392
|
+
'Error consuming grant in voice confirmation handler — falling through to deny',
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
342
396
|
log.info(
|
|
343
397
|
{ turnId, toolName: msg.toolName },
|
|
344
|
-
'Auto-denying confirmation request for voice turn (
|
|
398
|
+
'Auto-denying confirmation request for non-guardian voice turn (no matching scoped grant)',
|
|
345
399
|
);
|
|
346
400
|
session.handleConfirmationResponse(
|
|
347
401
|
msg.requestId,
|
|
@@ -390,6 +444,7 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
|
|
|
390
444
|
session.setCommandIntent(null);
|
|
391
445
|
session.setAssistantId('self');
|
|
392
446
|
session.setVoiceCallControlPrompt(null);
|
|
447
|
+
session.callSessionId = undefined;
|
|
393
448
|
// Reset the session's client callback to a no-op so the stale
|
|
394
449
|
// closure doesn't intercept events from future turns on the same session.
|
|
395
450
|
session.updateClient(() => {}, true);
|
package/src/cli/core-commands.ts
CHANGED
|
@@ -649,11 +649,7 @@ export function registerDoctorCommand(program: Command): void {
|
|
|
649
649
|
const { runSandboxDiagnostics } = await import('../tools/terminal/sandbox-diagnostics.js');
|
|
650
650
|
const sandbox = runSandboxDiagnostics();
|
|
651
651
|
log.info(`\n Sandbox: ${sandbox.config.enabled ? 'enabled' : 'disabled'}`);
|
|
652
|
-
log.info(` Backend: ${sandbox.config.backend}`);
|
|
653
652
|
log.info(` Reason: ${sandbox.activeBackendReason}`);
|
|
654
|
-
if (sandbox.config.backend === 'docker') {
|
|
655
|
-
log.info(` Image: ${sandbox.config.dockerImage}`);
|
|
656
|
-
}
|
|
657
653
|
log.info('');
|
|
658
654
|
for (const check of sandbox.checks) {
|
|
659
655
|
if (check.ok) {
|
package/src/cli.ts
CHANGED
|
@@ -43,6 +43,72 @@ export function sanitizeUrlForDisplay(rawUrl: unknown): string {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function stringifyConfirmationInputValue(value: unknown): string {
|
|
47
|
+
if (typeof value === 'string') return value;
|
|
48
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
49
|
+
if (value == null) return 'null';
|
|
50
|
+
try {
|
|
51
|
+
return JSON.stringify(value);
|
|
52
|
+
} catch {
|
|
53
|
+
return String(value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function formatConfirmationInputLines(input: Record<string, unknown>): string[] {
|
|
58
|
+
const lines: string[] = [];
|
|
59
|
+
for (const key of Object.keys(input).sort()) {
|
|
60
|
+
const rawValue = input[key];
|
|
61
|
+
const value = key.toLowerCase().includes('url') && typeof rawValue === 'string'
|
|
62
|
+
? sanitizeUrlForDisplay(rawValue)
|
|
63
|
+
: rawValue;
|
|
64
|
+
const rendered = stringifyConfirmationInputValue(value);
|
|
65
|
+
const renderedLines = rendered.split('\n');
|
|
66
|
+
if (renderedLines.length === 0) {
|
|
67
|
+
lines.push(`${key}:`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
lines.push(`${key}: ${renderedLines[0]}`);
|
|
71
|
+
for (const continuation of renderedLines.slice(1)) {
|
|
72
|
+
lines.push(` ${continuation}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function formatConfirmationCommandPreview(req: Pick<ConfirmationRequest, 'toolName' | 'input'>): string {
|
|
79
|
+
if (req.toolName === 'bash' || req.toolName === 'host_bash') {
|
|
80
|
+
return String(req.input.command ?? '');
|
|
81
|
+
}
|
|
82
|
+
if (req.toolName === 'file_read' || req.toolName === 'host_file_read') {
|
|
83
|
+
return `read ${req.input.path ?? ''}`;
|
|
84
|
+
}
|
|
85
|
+
if (req.toolName === 'file_write' || req.toolName === 'host_file_write') {
|
|
86
|
+
return `write ${req.input.path ?? ''}`;
|
|
87
|
+
}
|
|
88
|
+
if (req.toolName === 'file_edit' || req.toolName === 'host_file_edit') {
|
|
89
|
+
return `edit ${req.input.path ?? ''}`;
|
|
90
|
+
}
|
|
91
|
+
if (req.toolName === 'web_fetch') {
|
|
92
|
+
return `fetch ${sanitizeUrlForDisplay(req.input.url ?? '')}`;
|
|
93
|
+
}
|
|
94
|
+
if (req.toolName === 'browser_navigate') {
|
|
95
|
+
return `navigate ${sanitizeUrlForDisplay(req.input.url ?? '')}`;
|
|
96
|
+
}
|
|
97
|
+
if (req.toolName === 'browser_close') {
|
|
98
|
+
return req.input.close_all_pages ? 'close all browser pages' : 'close browser page';
|
|
99
|
+
}
|
|
100
|
+
if (req.toolName === 'browser_click') {
|
|
101
|
+
return `click ${req.input.element_id ?? req.input.selector ?? ''}`;
|
|
102
|
+
}
|
|
103
|
+
if (req.toolName === 'browser_type') {
|
|
104
|
+
return `type into ${req.input.element_id ?? req.input.selector ?? ''}`;
|
|
105
|
+
}
|
|
106
|
+
if (req.toolName === 'browser_press_key') {
|
|
107
|
+
return `press "${req.input.key ?? ''}"`;
|
|
108
|
+
}
|
|
109
|
+
return req.toolName;
|
|
110
|
+
}
|
|
111
|
+
|
|
46
112
|
|
|
47
113
|
export async function startCli(): Promise<void> {
|
|
48
114
|
const socketPath = getSocketPath();
|
|
@@ -138,48 +204,24 @@ export async function startCli(): Promise<void> {
|
|
|
138
204
|
return false;
|
|
139
205
|
}
|
|
140
206
|
|
|
141
|
-
function formatCommandPreview(req: ConfirmationRequest): string {
|
|
142
|
-
if (req.toolName === 'bash') {
|
|
143
|
-
return String(req.input.command ?? '');
|
|
144
|
-
}
|
|
145
|
-
if (req.toolName === 'file_read') {
|
|
146
|
-
return `read ${req.input.path ?? ''}`;
|
|
147
|
-
}
|
|
148
|
-
if (req.toolName === 'file_write') {
|
|
149
|
-
return `write ${req.input.path ?? ''}`;
|
|
150
|
-
}
|
|
151
|
-
if (req.toolName === 'web_fetch') {
|
|
152
|
-
return `fetch ${sanitizeUrlForDisplay(req.input.url ?? '')}`;
|
|
153
|
-
}
|
|
154
|
-
if (req.toolName === 'browser_navigate') {
|
|
155
|
-
return `navigate ${sanitizeUrlForDisplay(req.input.url ?? '')}`;
|
|
156
|
-
}
|
|
157
|
-
if (req.toolName === 'browser_close') {
|
|
158
|
-
return req.input.close_all_pages ? 'close all browser pages' : 'close browser page';
|
|
159
|
-
}
|
|
160
|
-
if (req.toolName === 'browser_click') {
|
|
161
|
-
return `click ${req.input.element_id ?? req.input.selector ?? ''}`;
|
|
162
|
-
}
|
|
163
|
-
if (req.toolName === 'browser_type') {
|
|
164
|
-
return `type into ${req.input.element_id ?? req.input.selector ?? ''}`;
|
|
165
|
-
}
|
|
166
|
-
if (req.toolName === 'browser_press_key') {
|
|
167
|
-
return `press "${req.input.key ?? ''}"`;
|
|
168
|
-
}
|
|
169
|
-
return `${req.toolName}: ${truncate(JSON.stringify(req.input), 80)}`;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
207
|
function renderConfirmationPrompt(req: ConfirmationRequest): void {
|
|
173
|
-
const preview =
|
|
208
|
+
const preview = formatConfirmationCommandPreview(req);
|
|
209
|
+
const inputLines = formatConfirmationInputLines(req.input);
|
|
174
210
|
process.stdout.write('\n');
|
|
175
211
|
process.stdout.write(`\u250C ${req.toolName}: ${preview}\n`);
|
|
176
212
|
process.stdout.write(`\u2502 Risk: ${req.riskLevel}${req.sandboxed ? ' [sandboxed]' : ''}\n`);
|
|
177
213
|
if (req.executionTarget) {
|
|
178
214
|
process.stdout.write(`\u2502 Target: ${req.executionTarget}\n`);
|
|
179
215
|
}
|
|
216
|
+
if (inputLines.length > 0) {
|
|
217
|
+
process.stdout.write(`\u2502\n`);
|
|
218
|
+
for (const line of inputLines) {
|
|
219
|
+
process.stdout.write(`\u2502 ${line}\n`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
180
222
|
if (req.diff) {
|
|
181
223
|
const diffOutput = req.diff.isNewFile
|
|
182
|
-
? formatNewFileDiff(req.diff.newContent, req.diff.filePath)
|
|
224
|
+
? formatNewFileDiff(req.diff.newContent, req.diff.filePath, null)
|
|
183
225
|
: formatDiff(req.diff.oldContent, req.diff.newContent, req.diff.filePath);
|
|
184
226
|
if (diffOutput) {
|
|
185
227
|
process.stdout.write(`\u2502\n`);
|
|
@@ -506,7 +548,7 @@ export async function startCli(): Promise<void> {
|
|
|
506
548
|
toolStreaming = false;
|
|
507
549
|
if (msg.diff) {
|
|
508
550
|
const diffOutput = msg.diff.isNewFile
|
|
509
|
-
? formatNewFileDiff(msg.diff.newContent, msg.diff.filePath)
|
|
551
|
+
? formatNewFileDiff(msg.diff.newContent, msg.diff.filePath, null)
|
|
510
552
|
: formatDiff(msg.diff.oldContent, msg.diff.newContent, msg.diff.filePath);
|
|
511
553
|
if (diffOutput) {
|
|
512
554
|
process.stdout.write(diffOutput);
|