@vellumai/assistant 0.3.19 → 0.3.21

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.
Files changed (199) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/bun.lock +139 -2
  5. package/docs/architecture/integrations.md +7 -11
  6. package/package.json +2 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +439 -108
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/cli.test.ts +42 -1
  15. package/src/__tests__/config-schema.test.ts +11 -127
  16. package/src/__tests__/config-watcher.test.ts +0 -8
  17. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  18. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  19. package/src/__tests__/diff.test.ts +22 -0
  20. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  21. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  22. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  23. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  24. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  25. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  26. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  27. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  28. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  29. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  30. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  31. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  32. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  33. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  34. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  35. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  36. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  37. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  39. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  40. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  41. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  42. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  43. package/src/__tests__/system-prompt.test.ts +1 -1
  44. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  45. package/src/__tests__/terminal-tools.test.ts +2 -93
  46. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  47. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  48. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  49. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  50. package/src/agent/loop.ts +36 -1
  51. package/src/approvals/approval-primitive.ts +381 -0
  52. package/src/approvals/guardian-decision-primitive.ts +191 -0
  53. package/src/calls/call-controller.ts +252 -209
  54. package/src/calls/call-domain.ts +44 -6
  55. package/src/calls/guardian-dispatch.ts +48 -0
  56. package/src/calls/types.ts +1 -1
  57. package/src/calls/voice-session-bridge.ts +46 -30
  58. package/src/cli/core-commands.ts +0 -4
  59. package/src/cli/mcp.ts +58 -0
  60. package/src/cli.ts +76 -34
  61. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  62. package/src/config/assistant-feature-flags.ts +162 -0
  63. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  64. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  65. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  66. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  67. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  68. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  69. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  70. package/src/config/core-schema.ts +1 -1
  71. package/src/config/env-registry.ts +10 -0
  72. package/src/config/feature-flag-registry.json +61 -0
  73. package/src/config/loader.ts +22 -1
  74. package/src/config/mcp-schema.ts +46 -0
  75. package/src/config/sandbox-schema.ts +0 -39
  76. package/src/config/schema.ts +18 -2
  77. package/src/config/skill-state.ts +34 -0
  78. package/src/config/skills-schema.ts +0 -1
  79. package/src/config/skills.ts +9 -0
  80. package/src/config/system-prompt.ts +110 -46
  81. package/src/config/templates/SOUL.md +1 -1
  82. package/src/config/types.ts +19 -1
  83. package/src/config/vellum-skills/catalog.json +1 -1
  84. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  85. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  86. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
  87. package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
  88. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  89. package/src/daemon/config-watcher.ts +0 -1
  90. package/src/daemon/daemon-control.ts +1 -1
  91. package/src/daemon/guardian-invite-intent.ts +124 -0
  92. package/src/daemon/handlers/avatar.ts +68 -0
  93. package/src/daemon/handlers/browser.ts +2 -2
  94. package/src/daemon/handlers/guardian-actions.ts +120 -0
  95. package/src/daemon/handlers/index.ts +4 -0
  96. package/src/daemon/handlers/sessions.ts +19 -0
  97. package/src/daemon/handlers/shared.ts +3 -1
  98. package/src/daemon/install-cli-launchers.ts +58 -13
  99. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  100. package/src/daemon/ipc-contract/sessions.ts +8 -2
  101. package/src/daemon/ipc-contract/settings.ts +25 -2
  102. package/src/daemon/ipc-contract-inventory.json +10 -0
  103. package/src/daemon/ipc-contract.ts +4 -0
  104. package/src/daemon/lifecycle.ts +14 -2
  105. package/src/daemon/main.ts +1 -0
  106. package/src/daemon/providers-setup.ts +26 -1
  107. package/src/daemon/server.ts +1 -0
  108. package/src/daemon/session-lifecycle.ts +52 -7
  109. package/src/daemon/session-memory.ts +45 -0
  110. package/src/daemon/session-process.ts +258 -432
  111. package/src/daemon/session-runtime-assembly.ts +12 -0
  112. package/src/daemon/session-skill-tools.ts +14 -1
  113. package/src/daemon/session-tool-setup.ts +5 -0
  114. package/src/daemon/session.ts +11 -0
  115. package/src/daemon/shutdown-handlers.ts +11 -0
  116. package/src/daemon/tool-side-effects.ts +35 -9
  117. package/src/index.ts +2 -2
  118. package/src/mcp/client.ts +152 -0
  119. package/src/mcp/manager.ts +139 -0
  120. package/src/memory/conversation-display-order-migration.ts +44 -0
  121. package/src/memory/conversation-queries.ts +2 -0
  122. package/src/memory/conversation-store.ts +91 -0
  123. package/src/memory/db-init.ts +5 -1
  124. package/src/memory/embedding-local.ts +13 -8
  125. package/src/memory/guardian-action-store.ts +125 -2
  126. package/src/memory/ingress-invite-store.ts +95 -1
  127. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  128. package/src/memory/migrations/index.ts +2 -1
  129. package/src/memory/schema.ts +5 -1
  130. package/src/memory/scoped-approval-grants.ts +14 -5
  131. package/src/messaging/providers/slack/client.ts +12 -0
  132. package/src/messaging/providers/slack/types.ts +5 -0
  133. package/src/notifications/decision-engine.ts +49 -12
  134. package/src/notifications/emit-signal.ts +7 -0
  135. package/src/notifications/signal.ts +7 -0
  136. package/src/notifications/thread-seed-composer.ts +2 -1
  137. package/src/runtime/channel-approval-types.ts +16 -6
  138. package/src/runtime/channel-approvals.ts +19 -15
  139. package/src/runtime/channel-invite-transport.ts +85 -0
  140. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  141. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  142. package/src/runtime/guardian-action-message-composer.ts +30 -0
  143. package/src/runtime/guardian-decision-types.ts +91 -0
  144. package/src/runtime/http-server.ts +23 -1
  145. package/src/runtime/ingress-service.ts +22 -0
  146. package/src/runtime/invite-redemption-service.ts +181 -0
  147. package/src/runtime/invite-redemption-templates.ts +39 -0
  148. package/src/runtime/routes/call-routes.ts +2 -1
  149. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  150. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  151. package/src/runtime/routes/identity-routes.ts +73 -0
  152. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  153. package/src/runtime/routes/pairing-routes.ts +4 -0
  154. package/src/security/encrypted-store.ts +31 -17
  155. package/src/security/keychain.ts +176 -2
  156. package/src/security/secure-keys.ts +97 -0
  157. package/src/security/tool-approval-digest.ts +1 -1
  158. package/src/tools/browser/browser-execution.ts +2 -2
  159. package/src/tools/browser/browser-manager.ts +46 -32
  160. package/src/tools/browser/browser-screencast.ts +2 -2
  161. package/src/tools/calls/call-start.ts +1 -1
  162. package/src/tools/executor.ts +22 -17
  163. package/src/tools/mcp/mcp-tool-factory.ts +100 -0
  164. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  165. package/src/tools/registry.ts +64 -1
  166. package/src/tools/skills/load.ts +22 -8
  167. package/src/tools/system/avatar-generator.ts +119 -0
  168. package/src/tools/system/navigate-settings.ts +65 -0
  169. package/src/tools/system/open-system-settings.ts +75 -0
  170. package/src/tools/system/voice-config.ts +121 -32
  171. package/src/tools/terminal/backends/native.ts +40 -19
  172. package/src/tools/terminal/backends/types.ts +3 -3
  173. package/src/tools/terminal/parser.ts +1 -1
  174. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  175. package/src/tools/terminal/sandbox.ts +1 -12
  176. package/src/tools/terminal/shell.ts +3 -31
  177. package/src/tools/tool-approval-handler.ts +141 -3
  178. package/src/tools/tool-manifest.ts +6 -0
  179. package/src/tools/types.ts +10 -2
  180. package/src/util/diff.ts +36 -13
  181. package/Dockerfile.sandbox +0 -5
  182. package/src/__tests__/doordash-client.test.ts +0 -187
  183. package/src/__tests__/doordash-session.test.ts +0 -154
  184. package/src/__tests__/signup-e2e.test.ts +0 -354
  185. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  186. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  187. package/src/cli/doordash.ts +0 -1057
  188. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  189. package/src/config/templates/LOOKS.md +0 -25
  190. package/src/doordash/cart-queries.ts +0 -787
  191. package/src/doordash/client.ts +0 -1016
  192. package/src/doordash/order-queries.ts +0 -85
  193. package/src/doordash/queries.ts +0 -13
  194. package/src/doordash/query-extractor.ts +0 -94
  195. package/src/doordash/search-queries.ts +0 -203
  196. package/src/doordash/session.ts +0 -84
  197. package/src/doordash/store-queries.ts +0 -246
  198. package/src/doordash/types.ts +0 -367
  199. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -72,6 +72,8 @@ export type CancelCallInput = {
72
72
  export type AnswerCallInput = {
73
73
  callSessionId: string;
74
74
  answer: string;
75
+ /** When provided, the answer is matched to this specific pending question/consultation. */
76
+ pendingQuestionId?: string;
75
77
  };
76
78
 
77
79
  export type RelayInstructionInput = {
@@ -516,30 +518,66 @@ export async function cancelCall(input: CancelCallInput): Promise<{ ok: true; se
516
518
 
517
519
  /**
518
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.
519
526
  */
520
527
  export async function answerCall(input: AnswerCallInput): Promise<{ ok: true; questionId: string } | CallError> {
521
- const { callSessionId, answer } = input;
528
+ const { callSessionId, answer, pendingQuestionId } = input;
522
529
 
523
530
  if (!answer || typeof answer !== 'string') {
524
531
  return { ok: false, error: 'Missing answer', status: 400 };
525
532
  }
526
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
527
562
  const question = getPendingQuestion(callSessionId);
528
563
  if (!question) {
529
564
  return { ok: false, error: 'No pending question found', status: 404 };
530
565
  }
531
566
 
532
- const controller = getCallController(callSessionId);
533
- if (!controller) {
534
- log.warn({ callSessionId }, 'answerCall: no active controller for call session');
535
- return { ok: false, error: 'No active controller for this call', status: 409 };
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 };
536
574
  }
537
575
 
538
576
  const accepted = await controller.handleUserAnswer(answer);
539
577
  if (!accepted) {
540
578
  log.warn(
541
579
  { callSessionId },
542
- 'answerCall: controller rejected the answer (not in waiting_on_user state)',
580
+ 'answerCall: controller rejected the answer (no pending consultation)',
543
581
  );
544
582
  return { ok: false, error: 'Controller is not waiting for an answer', status: 409 };
545
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,6 +23,11 @@ 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;
@@ -47,6 +53,31 @@ function applyDeliveryStatus(deliveryId: string, result: NotificationDeliveryRes
47
53
  * Fire-and-forget: errors are logged but do not propagate.
48
54
  */
49
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> {
50
81
  const {
51
82
  callSessionId,
52
83
  conversationId,
@@ -84,6 +115,22 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
84
115
  // in the same call session.
85
116
  const activeGuardianRequestCount = countPendingRequestsByCallSessionId(callSessionId);
86
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
+
87
134
  // Route through the canonical notification pipeline. The paired vellum
88
135
  // conversation from this pipeline is the canonical guardian thread.
89
136
  let vellumDeliveryId: string | null = null;
@@ -107,6 +154,7 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
107
154
  pendingQuestionId: pendingQuestion.id,
108
155
  activeGuardianRequestCount,
109
156
  },
157
+ conversationAffinityHint,
110
158
  dedupeKey: `guardian:${request.id}`,
111
159
  onThreadCreated: (info) => {
112
160
  if (info.sourceEventName !== 'guardian.question' || vellumDeliveryId) return;
@@ -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';
@@ -18,7 +19,6 @@ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.
18
19
  import { resolveChannelCapabilities } from '../daemon/session-runtime-assembly.js';
19
20
  import { buildAssistantEvent } from '../runtime/assistant-event.js';
20
21
  import { assistantEventHub } from '../runtime/assistant-event-hub.js';
21
- import { consumeScopedApprovalGrantByToolSignature } from '../memory/scoped-approval-grants.js';
22
22
  import { checkIngressForSecrets } from '../security/secret-ingress.js';
23
23
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
24
24
  import { IngressBlockedError } from '../util/errors.js';
@@ -307,6 +307,7 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
307
307
  strictSideEffects,
308
308
  };
309
309
  session.setAssistantId(opts.assistantId ?? 'self');
310
+ session.callSessionId = opts.callSessionId;
310
311
  session.setGuardianContext(opts.guardianContext ?? null);
311
312
  session.setCommandIntent(null);
312
313
  session.setTurnChannelContext({
@@ -345,42 +346,56 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
345
346
  const autoDeny = !isGuardian;
346
347
  const autoAllow = isGuardian;
347
348
  let lastError: string | null = null;
348
- session.updateClient((msg: ServerMessage) => {
349
+ session.updateClient(async (msg: ServerMessage) => {
349
350
  if (msg.type === 'confirmation_request') {
350
351
  if (autoDeny) {
351
- // Before auto-denying, check if a guardian from another channel
352
- // has pre-approved this exact tool invocation via a scoped grant.
353
- const inputDigest = computeToolApprovalDigest(msg.toolName, msg.input);
354
- const consumeResult = consumeScopedApprovalGrantByToolSignature({
355
- toolName: msg.toolName,
356
- inputDigest,
357
- consumingRequestId: msg.requestId,
358
- assistantId: opts.assistantId,
359
- executionChannel: 'voice',
360
- conversationId: opts.conversationId,
361
- callSessionId: opts.callSessionId,
362
- requesterExternalUserId: opts.guardianContext?.requesterExternalUserId,
363
- });
364
-
365
- if (consumeResult.ok) {
366
- log.info(
367
- { turnId, toolName: msg.toolName, grantId: consumeResult.grant?.id },
368
- 'Consumed scoped grant — allowing non-guardian voice confirmation',
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',
369
393
  );
370
- session.handleConfirmationResponse(
371
- msg.requestId,
372
- 'allow',
373
- undefined,
374
- undefined,
375
- `Permission approved for "${msg.toolName}": guardian pre-approved via scoped grant.`,
376
- );
377
- publishToHub(msg);
378
- return;
379
394
  }
380
395
 
381
396
  log.info(
382
397
  { turnId, toolName: msg.toolName },
383
- 'Auto-denying confirmation request for voice turn (no matching scoped grant)',
398
+ 'Auto-denying confirmation request for non-guardian voice turn (no matching scoped grant)',
384
399
  );
385
400
  session.handleConfirmationResponse(
386
401
  msg.requestId,
@@ -429,6 +444,7 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
429
444
  session.setCommandIntent(null);
430
445
  session.setAssistantId('self');
431
446
  session.setVoiceCallControlPrompt(null);
447
+ session.callSessionId = undefined;
432
448
  // Reset the session's client callback to a no-op so the stale
433
449
  // closure doesn't intercept events from future turns on the same session.
434
450
  session.updateClient(() => {}, true);
@@ -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/mcp.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { Command } from 'commander';
2
+
3
+ import { loadRawConfig } from '../config/loader.js';
4
+ import type { McpConfig, McpServerConfig } from '../config/mcp-schema.js';
5
+ import { getCliLogger } from '../util/logger.js';
6
+
7
+ const log = getCliLogger('cli');
8
+
9
+ export function registerMcpCommand(program: Command): void {
10
+ const mcp = program.command('mcp').description('Manage MCP (Model Context Protocol) servers');
11
+
12
+ mcp
13
+ .command('list')
14
+ .description('List configured MCP servers and their status')
15
+ .option('--json', 'Output as JSON')
16
+ .action((opts: { json?: boolean }) => {
17
+ const raw = loadRawConfig();
18
+ const mcpConfig = raw.mcp as Partial<McpConfig> | undefined;
19
+ const servers = mcpConfig?.servers ?? {};
20
+ const entries = Object.entries(servers) as [string, McpServerConfig][];
21
+
22
+ if (entries.length === 0) {
23
+ if (opts.json) {
24
+ process.stdout.write(JSON.stringify([], null, 2) + '\n');
25
+ } else {
26
+ log.info('No MCP servers configured.');
27
+ }
28
+ return;
29
+ }
30
+
31
+ if (opts.json) {
32
+ const result = entries.map(([id, config]) => ({ id, ...config }));
33
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
34
+ return;
35
+ }
36
+
37
+ log.info(`${entries.length} MCP server(s) configured:\n`);
38
+ for (const [id, cfg] of entries) {
39
+ const enabled = cfg.enabled !== false;
40
+ const transport = cfg.transport;
41
+ const risk = cfg.defaultRiskLevel ?? 'high';
42
+ const status = enabled ? '✓ enabled' : '✗ disabled';
43
+
44
+ log.info(` ${id}`);
45
+ log.info(` Status: ${status}`);
46
+ log.info(` Transport: ${transport?.type ?? 'unknown'}`);
47
+ if (transport?.type === 'stdio') {
48
+ log.info(` Command: ${transport.command} ${(transport.args ?? []).join(' ')}`);
49
+ } else if (transport && 'url' in transport) {
50
+ log.info(` URL: ${transport.url}`);
51
+ }
52
+ log.info(` Risk: ${risk}`);
53
+ if (cfg.allowedTools) log.info(` Allowed: ${cfg.allowedTools.join(', ')}`);
54
+ if (cfg.blockedTools) log.info(` Blocked: ${cfg.blockedTools.join(', ')}`);
55
+ log.info('');
56
+ }
57
+ });
58
+ }
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 = formatCommandPreview(req);
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);