@vellumai/assistant 0.4.9 → 0.4.11

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 (116) hide show
  1. package/ARCHITECTURE.md +24 -0
  2. package/Dockerfile +1 -1
  3. package/README.md +16 -9
  4. package/package.json +1 -1
  5. package/src/__tests__/account-registry.test.ts +1 -0
  6. package/src/__tests__/actor-token-service.test.ts +1 -0
  7. package/src/__tests__/app-builder-tool-scripts.test.ts +1 -0
  8. package/src/__tests__/asset-materialize-tool.test.ts +7 -0
  9. package/src/__tests__/asset-search-tool.test.ts +7 -0
  10. package/src/__tests__/browser-fill-credential.test.ts +1 -0
  11. package/src/__tests__/call-start-guardian-guard.test.ts +1 -0
  12. package/src/__tests__/channel-approval-routes.test.ts +29 -0
  13. package/src/__tests__/channel-guardian.test.ts +2143 -1546
  14. package/src/__tests__/channel-retry-sweep.test.ts +169 -14
  15. package/src/__tests__/claude-code-tool-profiles.test.ts +1 -0
  16. package/src/__tests__/computer-use-tools.test.ts +1 -0
  17. package/src/__tests__/contacts-tools.test.ts +1 -0
  18. package/src/__tests__/conversation-attention-telegram.test.ts +1 -0
  19. package/src/__tests__/credential-policy-validate.test.ts +97 -0
  20. package/src/__tests__/credential-security-e2e.test.ts +1 -0
  21. package/src/__tests__/credential-vault-unit.test.ts +1 -0
  22. package/src/__tests__/credential-vault.test.ts +1 -0
  23. package/src/__tests__/delete-managed-skill-tool.test.ts +1 -0
  24. package/src/__tests__/file-edit-tool.test.ts +1 -0
  25. package/src/__tests__/file-read-tool.test.ts +1 -0
  26. package/src/__tests__/file-write-tool.test.ts +1 -0
  27. package/src/__tests__/followup-tools.test.ts +1 -0
  28. package/src/__tests__/gateway-only-guard.test.ts +1 -1
  29. package/src/__tests__/guardian-control-plane-policy.test.ts +5 -4
  30. package/src/__tests__/guardian-grant-minting.test.ts +3 -0
  31. package/src/__tests__/guardian-principal-id-roundtrip.test.ts +4 -3
  32. package/src/__tests__/guardian-routing-state.test.ts +8 -0
  33. package/src/__tests__/headless-browser-interactions.test.ts +1 -0
  34. package/src/__tests__/headless-browser-navigate.test.ts +1 -0
  35. package/src/__tests__/headless-browser-read-tools.test.ts +1 -0
  36. package/src/__tests__/headless-browser-snapshot.test.ts +1 -0
  37. package/src/__tests__/host-file-edit-tool.test.ts +1 -0
  38. package/src/__tests__/host-file-read-tool.test.ts +1 -0
  39. package/src/__tests__/host-file-write-tool.test.ts +1 -0
  40. package/src/__tests__/host-shell-tool.test.ts +1 -0
  41. package/src/__tests__/lifecycle-docs-guard.test.ts +207 -0
  42. package/src/__tests__/managed-skill-lifecycle.test.ts +1 -0
  43. package/src/__tests__/media-reuse-story.e2e.test.ts +8 -0
  44. package/src/__tests__/messaging-send-tool.test.ts +1 -0
  45. package/src/__tests__/playbook-execution.test.ts +1 -0
  46. package/src/__tests__/playbook-tools.test.ts +1 -0
  47. package/src/__tests__/relay-server.test.ts +4 -0
  48. package/src/__tests__/scaffold-managed-skill-tool.test.ts +1 -0
  49. package/src/__tests__/schedule-tools.test.ts +1 -0
  50. package/src/__tests__/secret-onetime-send.test.ts +4 -0
  51. package/src/__tests__/secret-scanner-executor.test.ts +2 -0
  52. package/src/__tests__/send-notification-tool.test.ts +2 -0
  53. package/src/__tests__/shell-credential-ref.test.ts +1 -0
  54. package/src/__tests__/shell-tool-proxy-mode.test.ts +1 -0
  55. package/src/__tests__/skill-load-feature-flag.test.ts +1 -0
  56. package/src/__tests__/skill-load-tool.test.ts +1 -0
  57. package/src/__tests__/skill-script-runner-host.test.ts +1 -0
  58. package/src/__tests__/skill-script-runner-sandbox.test.ts +1 -0
  59. package/src/__tests__/skill-script-runner.test.ts +1 -0
  60. package/src/__tests__/skill-tool-factory.test.ts +1 -0
  61. package/src/__tests__/subagent-tools.test.ts +1 -1
  62. package/src/__tests__/swarm-recursion.test.ts +1 -0
  63. package/src/__tests__/swarm-session-integration.test.ts +1 -0
  64. package/src/__tests__/swarm-tool.test.ts +1 -0
  65. package/src/__tests__/task-management-tools.test.ts +1 -0
  66. package/src/__tests__/task-tools.test.ts +1 -0
  67. package/src/__tests__/terminal-tools.test.ts +1 -0
  68. package/src/__tests__/tool-approval-handler.test.ts +2 -2
  69. package/src/__tests__/tool-execution-abort-cleanup.test.ts +1 -0
  70. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -0
  71. package/src/__tests__/tool-executor-lifecycle-events.test.ts +2 -0
  72. package/src/__tests__/tool-executor-shell-integration.test.ts +1 -0
  73. package/src/__tests__/tool-executor.test.ts +1 -0
  74. package/src/__tests__/trust-context-guards.test.ts +218 -0
  75. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -0
  76. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +6 -0
  77. package/src/__tests__/trusted-contact-multichannel.test.ts +1 -0
  78. package/src/__tests__/trusted-contact-verification.test.ts +1 -0
  79. package/src/__tests__/view-image-tool.test.ts +1 -0
  80. package/src/calls/guardian-dispatch.ts +4 -4
  81. package/src/cli/mcp.ts +183 -3
  82. package/src/config/bundled-skills/agentmail/SKILL.md +4 -4
  83. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +1 -0
  84. package/src/config/bundled-skills/phone-calls/SKILL.md +17 -119
  85. package/src/config/system-prompt.ts +4 -2
  86. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  87. package/src/daemon/computer-use-session.ts +1 -0
  88. package/src/daemon/session-agent-loop.ts +1 -1
  89. package/src/daemon/session-memory.ts +2 -2
  90. package/src/daemon/session-runtime-assembly.ts +2 -2
  91. package/src/daemon/session-tool-setup.ts +1 -1
  92. package/src/mcp/client.ts +55 -6
  93. package/src/mcp/manager.ts +9 -0
  94. package/src/mcp/mcp-oauth-provider.ts +347 -0
  95. package/src/memory/channel-delivery-store.ts +1 -0
  96. package/src/memory/db-init.ts +4 -0
  97. package/src/memory/delivery-status.ts +43 -0
  98. package/src/memory/guardian-bindings.ts +3 -3
  99. package/src/memory/migrations/127-guardian-principal-id-not-null.ts +108 -0
  100. package/src/memory/migrations/index.ts +1 -0
  101. package/src/memory/migrations/registry.ts +6 -0
  102. package/src/memory/schema.ts +1 -1
  103. package/src/runtime/actor-trust-resolver.ts +13 -4
  104. package/src/runtime/channel-retry-sweep.ts +31 -14
  105. package/src/runtime/guardian-context-resolver.ts +25 -64
  106. package/src/runtime/guardian-outbound-actions.ts +399 -108
  107. package/src/runtime/guardian-vellum-migration.ts +1 -23
  108. package/src/runtime/guardian-verification-templates.ts +66 -30
  109. package/src/runtime/local-actor-identity.ts +4 -6
  110. package/src/runtime/middleware/actor-token.ts +2 -8
  111. package/src/runtime/routes/channel-route-shared.ts +0 -1
  112. package/src/runtime/routes/inbound-message-handler.ts +3 -4
  113. package/src/runtime/tool-grant-request-helper.ts +1 -1
  114. package/src/tools/credentials/policy-validate.ts +22 -0
  115. package/src/tools/guardian-control-plane-policy.ts +2 -2
  116. package/src/tools/types.ts +1 -1
@@ -0,0 +1,218 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { describe, expect, it } from 'bun:test';
5
+
6
+ /**
7
+ * Guard tests for the canonical trust-context model.
8
+ *
9
+ * These tests prevent reintroduction of removed compatibility patterns
10
+ * by scanning source files for type invariants:
11
+ *
12
+ * (a) guardianPrincipalId in GuardianRuntimeContext must be `?: string`
13
+ * (optional string), NOT `string | null`.
14
+ * (b) guardianTrustClass in ToolContext must be a required field (no `?`).
15
+ * (c) The channel retry sweep parser must not reference `actorRole`.
16
+ * (d) guardianPrincipalId in GuardianBinding must be `string` (non-null,
17
+ * non-optional).
18
+ */
19
+
20
+ const srcDir = join(import.meta.dir, '..');
21
+
22
+ describe('trust-context guards', () => {
23
+ // -----------------------------------------------------------------------
24
+ // (a) No `string | null` for guardianPrincipalId in runtime types
25
+ // -----------------------------------------------------------------------
26
+
27
+ it('guardianPrincipalId is not typed as string | null in GuardianRuntimeContext', () => {
28
+ const source = readFileSync(
29
+ join(srcDir, 'daemon', 'session-runtime-assembly.ts'),
30
+ 'utf-8',
31
+ );
32
+
33
+ // Extract the GuardianRuntimeContext interface block
34
+ const ifaceStart = source.indexOf('export interface GuardianRuntimeContext');
35
+ expect(ifaceStart).toBeGreaterThan(-1);
36
+
37
+ const blockStart = source.indexOf('{', ifaceStart);
38
+ let braceDepth = 0;
39
+ let blockEnd = blockStart;
40
+ for (let i = blockStart; i < source.length; i++) {
41
+ if (source[i] === '{') braceDepth++;
42
+ if (source[i] === '}') braceDepth--;
43
+ if (braceDepth === 0) {
44
+ blockEnd = i + 1;
45
+ break;
46
+ }
47
+ }
48
+ const block = source.slice(blockStart, blockEnd);
49
+
50
+ // guardianPrincipalId should NOT be typed as `string | null`
51
+ const principalLine = block.split('\n').find((l) =>
52
+ l.includes('guardianPrincipalId'),
53
+ );
54
+ expect(
55
+ principalLine,
56
+ 'Expected to find guardianPrincipalId in GuardianRuntimeContext',
57
+ ).toBeDefined();
58
+
59
+ expect(
60
+ principalLine!.includes('string | null') || principalLine!.includes('null | string'),
61
+ 'guardianPrincipalId must not be typed as nullable in GuardianRuntimeContext. ' +
62
+ 'Use `guardianPrincipalId?: string` (optional, non-nullable) instead. ' +
63
+ `Found: "${principalLine!.trim()}"`,
64
+ ).toBe(false);
65
+
66
+ // The field must remain optional (has `?`) — channels where no guardian
67
+ // principal exists should be able to omit it.
68
+ expect(
69
+ /guardianPrincipalId\s*\?/.test(principalLine!),
70
+ 'guardianPrincipalId must remain optional (`?:`) in GuardianRuntimeContext. ' +
71
+ 'Channels without a guardian principal need to omit this field. ' +
72
+ `Found: "${principalLine!.trim()}"`,
73
+ ).toBe(true);
74
+ });
75
+
76
+ // -----------------------------------------------------------------------
77
+ // (b) guardianTrustClass is required in ToolContext
78
+ // -----------------------------------------------------------------------
79
+
80
+ it('guardianTrustClass is a required field in ToolContext', () => {
81
+ const source = readFileSync(
82
+ join(srcDir, 'tools', 'types.ts'),
83
+ 'utf-8',
84
+ );
85
+
86
+ // Extract the ToolContext interface block
87
+ const ifaceStart = source.indexOf('export interface ToolContext');
88
+ expect(ifaceStart).toBeGreaterThan(-1);
89
+
90
+ const blockStart = source.indexOf('{', ifaceStart);
91
+ let braceDepth = 0;
92
+ let blockEnd = blockStart;
93
+ for (let i = blockStart; i < source.length; i++) {
94
+ if (source[i] === '{') braceDepth++;
95
+ if (source[i] === '}') braceDepth--;
96
+ if (braceDepth === 0) {
97
+ blockEnd = i + 1;
98
+ break;
99
+ }
100
+ }
101
+ const block = source.slice(blockStart, blockEnd);
102
+
103
+ const trustLine = block.split('\n').find((l) =>
104
+ l.includes('guardianTrustClass'),
105
+ );
106
+ expect(
107
+ trustLine,
108
+ 'Expected to find guardianTrustClass in ToolContext',
109
+ ).toBeDefined();
110
+
111
+ // The field must NOT have a `?` before the colon — it must be required.
112
+ expect(
113
+ /guardianTrustClass\s*\?/.test(trustLine!),
114
+ 'guardianTrustClass must be a required field in ToolContext (no `?`). ' +
115
+ 'Explicit trust gates must not be optional — every tool execution ' +
116
+ `must carry a trust classification. Found: "${trustLine!.trim()}"`,
117
+ ).toBe(false);
118
+ });
119
+
120
+ // -----------------------------------------------------------------------
121
+ // (c) No actorRole fallback in channel retry sweep parser
122
+ // -----------------------------------------------------------------------
123
+
124
+ it('channel retry sweep parser does not reference actorRole', () => {
125
+ const source = readFileSync(
126
+ join(srcDir, 'runtime', 'channel-retry-sweep.ts'),
127
+ 'utf-8',
128
+ );
129
+
130
+ // The parseGuardianRuntimeContext function must use strict trustClass
131
+ // parsing only — no legacy actorRole fallback.
132
+ const parserStart = source.indexOf('function parseGuardianRuntimeContext');
133
+ expect(parserStart).toBeGreaterThan(-1);
134
+
135
+ // Find the end of the function (next function-level declaration or EOF)
136
+ const parserBody = source.slice(parserStart);
137
+ const nextFn = parserBody.indexOf('\nexport ', 1);
138
+ const parserSource = nextFn > 0 ? parserBody.slice(0, nextFn) : parserBody;
139
+
140
+ expect(
141
+ parserSource.includes('actorRole'),
142
+ 'parseGuardianRuntimeContext must not reference `actorRole`. ' +
143
+ 'The retry sweep uses strict `trustClass` parsing — no legacy actorRole fallback.',
144
+ ).toBe(false);
145
+ });
146
+
147
+ // -----------------------------------------------------------------------
148
+ // (d) Retry sweep never passes undefined guardianContext to processMessage
149
+ // -----------------------------------------------------------------------
150
+
151
+ it('retry sweep always provides an explicit guardianContext (never undefined)', () => {
152
+ const source = readFileSync(
153
+ join(srcDir, 'runtime', 'channel-retry-sweep.ts'),
154
+ 'utf-8',
155
+ );
156
+
157
+ // The sweep must synthesize a trust context when guardianCtx is absent,
158
+ // so `guardianContext` should never be conditionally undefined at the
159
+ // processMessage callsite. Look for the pattern that ensures this:
160
+ // a `const guardianContext: GuardianRuntimeContext = parsedGuardianContext ?? {`
161
+ // fallback that synthesizes trustClass: 'unknown'.
162
+ expect(
163
+ source.includes("trustClass: 'unknown'"),
164
+ 'The retry sweep must synthesize an explicit `trustClass: \'unknown\'` context ' +
165
+ 'when guardianCtx is absent from stored payloads. This prevents downstream ' +
166
+ 'defaults from granting implicit guardian trust on replay.',
167
+ ).toBe(true);
168
+ });
169
+
170
+ // -----------------------------------------------------------------------
171
+ // (e) guardianPrincipalId is non-null in GuardianBinding
172
+ // -----------------------------------------------------------------------
173
+
174
+ it('guardianPrincipalId is typed as string (non-null) in GuardianBinding', () => {
175
+ const source = readFileSync(
176
+ join(srcDir, 'memory', 'guardian-bindings.ts'),
177
+ 'utf-8',
178
+ );
179
+
180
+ // Extract the GuardianBinding interface block
181
+ const ifaceStart = source.indexOf('export interface GuardianBinding');
182
+ expect(ifaceStart).toBeGreaterThan(-1);
183
+
184
+ const blockStart = source.indexOf('{', ifaceStart);
185
+ let braceDepth = 0;
186
+ let blockEnd = blockStart;
187
+ for (let i = blockStart; i < source.length; i++) {
188
+ if (source[i] === '{') braceDepth++;
189
+ if (source[i] === '}') braceDepth--;
190
+ if (braceDepth === 0) {
191
+ blockEnd = i + 1;
192
+ break;
193
+ }
194
+ }
195
+ const block = source.slice(blockStart, blockEnd);
196
+
197
+ const principalLine = block.split('\n').find((l) =>
198
+ l.includes('guardianPrincipalId'),
199
+ );
200
+ expect(
201
+ principalLine,
202
+ 'Expected to find guardianPrincipalId in GuardianBinding',
203
+ ).toBeDefined();
204
+
205
+ // Must be `guardianPrincipalId: string` — not optional, not nullable
206
+ expect(
207
+ principalLine!.includes('string | null') || principalLine!.includes('null | string'),
208
+ 'guardianPrincipalId must not be typed as nullable in GuardianBinding. ' +
209
+ `Found: "${principalLine!.trim()}"`,
210
+ ).toBe(false);
211
+
212
+ expect(
213
+ /guardianPrincipalId\s*\?/.test(principalLine!),
214
+ 'guardianPrincipalId must not be optional in GuardianBinding. ' +
215
+ `Found: "${principalLine!.trim()}"`,
216
+ ).toBe(false);
217
+ });
218
+ });
@@ -316,6 +316,7 @@ describe('(a) target flow: trusted-contact inline guardian approval end-to-end',
316
316
  test('complete flow: routing state allows interactive + bridge notifies guardian + tool resumes', async () => {
317
317
  // Step 1: Verify routing state allows interactive turns for trusted contacts
318
318
  const guardianCtx: GuardianContext = {
319
+ sourceChannel: 'telegram',
319
320
  trustClass: 'trusted_contact',
320
321
  guardianExternalUserId: 'guardian-1',
321
322
  guardianChatId: 'guardian-chat-1',
@@ -463,6 +464,7 @@ describe('(c) no-binding flow: trusted contact fails fast without guardian bindi
463
464
 
464
465
  test('routing state blocks prompt waiting when no guardian binding exists', () => {
465
466
  const ctx: GuardianContext = {
467
+ sourceChannel: 'telegram',
466
468
  trustClass: 'trusted_contact',
467
469
  // No guardianExternalUserId — mirrors no binding
468
470
  };
@@ -592,10 +594,12 @@ describe('(d) unknown actor flow: fail-closed with no interactive approval', ()
592
594
 
593
595
  test('unknown actors have promptWaitingAllowed=false regardless of guardian route', () => {
594
596
  const withRoute: GuardianContext = {
597
+ sourceChannel: 'telegram',
595
598
  trustClass: 'unknown',
596
599
  guardianExternalUserId: 'guardian-1',
597
600
  };
598
601
  const withoutRoute: GuardianContext = {
602
+ sourceChannel: 'telegram',
599
603
  trustClass: 'unknown',
600
604
  };
601
605
 
@@ -961,6 +965,7 @@ describe('cross-milestone integration checks', () => {
961
965
  test('M1+M4: routing state interactivity drives inline wait eligibility', async () => {
962
966
  // With guardian binding: interactive + inline wait allowed
963
967
  const withBinding: GuardianContext = {
968
+ sourceChannel: 'telegram',
964
969
  trustClass: 'trusted_contact',
965
970
  guardianExternalUserId: 'guardian-1',
966
971
  };
@@ -968,6 +973,7 @@ describe('cross-milestone integration checks', () => {
968
973
 
969
974
  // Without guardian binding: not interactive + inline wait should not enter dead-end
970
975
  const withoutBinding: GuardianContext = {
976
+ sourceChannel: 'telegram',
971
977
  trustClass: 'trusted_contact',
972
978
  };
973
979
  expect(resolveRoutingState(withoutBinding).promptWaitingAllowed).toBe(false);
@@ -161,6 +161,7 @@ describe('trusted contact lifecycle notification signals', () => {
161
161
  channel: 'telegram',
162
162
  guardianExternalUserId: 'guardian-user-789',
163
163
  guardianDeliveryChatId: 'guardian-chat-789',
164
+ guardianPrincipalId: 'guardian-user-789',
164
165
  });
165
166
  upsertMember({
166
167
  assistantId: 'self',
@@ -236,6 +237,7 @@ describe('trusted contact lifecycle notification signals', () => {
236
237
  channel: 'telegram',
237
238
  guardianExternalUserId: 'guardian-user-789',
238
239
  guardianDeliveryChatId: 'guardian-chat-789',
240
+ guardianPrincipalId: 'guardian-user-789',
239
241
  });
240
242
  upsertMember({
241
243
  assistantId: 'self',
@@ -310,6 +312,7 @@ describe('trusted contact lifecycle notification signals', () => {
310
312
  channel: 'telegram',
311
313
  guardianExternalUserId: 'guardian-user-789',
312
314
  guardianDeliveryChatId: 'guardian-chat-789',
315
+ guardianPrincipalId: 'guardian-user-789',
313
316
  });
314
317
  upsertMember({
315
318
  assistantId: 'self',
@@ -373,6 +376,7 @@ describe('trusted contact activated notification signal', () => {
373
376
  channel: 'telegram',
374
377
  guardianExternalUserId: 'guardian-user-789',
375
378
  guardianDeliveryChatId: 'guardian-chat-789',
379
+ guardianPrincipalId: 'guardian-user-789',
376
380
  });
377
381
 
378
382
  // Create an identity-bound outbound session (simulates M3 approval flow)
@@ -425,6 +429,7 @@ describe('trusted contact activated notification signal', () => {
425
429
  channel: 'telegram',
426
430
  guardianExternalUserId: 'guardian-user-789',
427
431
  guardianDeliveryChatId: 'guardian-chat-789',
432
+ guardianPrincipalId: 'guardian-user-789',
428
433
  });
429
434
 
430
435
  upsertMember({
@@ -506,6 +511,7 @@ describe('trusted contact activated notification signal', () => {
506
511
  channel: 'telegram',
507
512
  guardianExternalUserId: 'guardian-user-789',
508
513
  guardianDeliveryChatId: 'guardian-chat-789',
514
+ guardianPrincipalId: 'guardian-user-789',
509
515
  });
510
516
 
511
517
  const session = createOutboundSession({
@@ -210,6 +210,7 @@ for (const config of CHANNEL_CONFIGS) {
210
210
  channel: config.channel,
211
211
  guardianExternalUserId: config.guardianExternalUserId,
212
212
  guardianDeliveryChatId: config.guardianChatId,
213
+ guardianPrincipalId: config.guardianExternalUserId,
213
214
  });
214
215
 
215
216
  const req = buildInboundRequest(config);
@@ -393,6 +393,7 @@ describe('trusted contact verification → member activation', () => {
393
393
  channel: 'telegram',
394
394
  guardianExternalUserId: 'guardian-user-original',
395
395
  guardianDeliveryChatId: 'guardian-chat-original',
396
+ guardianPrincipalId: 'guardian-user-original',
396
397
  verifiedVia: 'challenge',
397
398
  metadataJson: null,
398
399
  });
@@ -50,6 +50,7 @@ function makeContext(workingDir: string = testDir): ToolContext {
50
50
  workingDir,
51
51
  sessionId: 'test-session',
52
52
  conversationId: 'test-conversation',
53
+ guardianTrustClass: 'guardian',
53
54
  };
54
55
  }
55
56
 
@@ -95,14 +95,14 @@ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Pr
95
95
  // (the canonical assistant-level binding) so the request is attributed to
96
96
  // the assistant's guardian principal.
97
97
  let vellumBinding = getActiveBinding(assistantId, 'vellum');
98
- let guardianPrincipalId = vellumBinding?.guardianPrincipalId ?? undefined;
98
+ let guardianPrincipalId = vellumBinding?.guardianPrincipalId;
99
99
 
100
- // Self-heal: if the vellum binding is missing or lacks a principal,
101
- // bootstrap it so the pending_question request can be attributed.
100
+ // Self-heal: if the vellum binding is missing, bootstrap it so
101
+ // the pending_question request can be attributed.
102
102
  if (!guardianPrincipalId) {
103
103
  log.info(
104
104
  { callSessionId, assistantId, hadBinding: !!vellumBinding },
105
- 'Vellum binding missing or lacks principal — self-healing for voice dispatch',
105
+ 'Vellum binding missing — self-healing for voice dispatch',
106
106
  );
107
107
  const healedPrincipalId = ensureVellumGuardianBinding(assistantId);
108
108
  vellumBinding = getActiveBinding(assistantId, 'vellum');
package/src/cli/mcp.ts CHANGED
@@ -1,11 +1,46 @@
1
+ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
2
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
3
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
4
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
1
5
  import type { Command } from 'commander';
2
6
 
3
7
  import { loadRawConfig, saveRawConfig } from '../config/loader.js';
4
8
  import type { McpConfig, McpServerConfig } from '../config/mcp-schema.js';
9
+ import { McpClient } from '../mcp/client.js';
10
+ import { deleteMcpOAuthCredentials, McpOAuthProvider } from '../mcp/mcp-oauth-provider.js';
5
11
  import { getCliLogger } from '../util/logger.js';
6
12
 
7
13
  const log = getCliLogger('cli');
8
14
 
15
+ const HEALTH_CHECK_TIMEOUT_MS = 10_000;
16
+
17
+ async function checkServerHealth(serverId: string, config: McpServerConfig): Promise<string> {
18
+ const client = new McpClient(serverId, { quiet: true });
19
+ try {
20
+ await Promise.race([
21
+ client.connect(config.transport),
22
+ new Promise<never>((_, reject) => {
23
+ const t = setTimeout(() => reject(new Error('timeout')), HEALTH_CHECK_TIMEOUT_MS);
24
+ if (typeof t === 'object' && 'unref' in t) t.unref();
25
+ }),
26
+ ]);
27
+
28
+ if (!client.isConnected) {
29
+ return '! Needs authentication';
30
+ }
31
+
32
+ await client.disconnect();
33
+ return '\u2713 Connected';
34
+ } catch (err) {
35
+ try { await client.disconnect(); } catch { /* ignore */ }
36
+ const message = err instanceof Error ? err.message : String(err);
37
+ if (message.includes('timeout')) {
38
+ return '\u2717 Timed out';
39
+ }
40
+ return `\u2717 Error: ${message}`;
41
+ }
42
+ }
43
+
9
44
  export function registerMcpCommand(program: Command): void {
10
45
  const mcp = program.command('mcp').description('Manage MCP (Model Context Protocol) servers');
11
46
 
@@ -13,7 +48,7 @@ export function registerMcpCommand(program: Command): void {
13
48
  .command('list')
14
49
  .description('List configured MCP servers and their status')
15
50
  .option('--json', 'Output as JSON')
16
- .action((opts: { json?: boolean }) => {
51
+ .action(async (opts: { json?: boolean }) => {
17
52
  const raw = loadRawConfig();
18
53
  const mcpConfig = raw.mcp as Partial<McpConfig> | undefined;
19
54
  const servers = mcpConfig?.servers ?? {};
@@ -37,6 +72,8 @@ export function registerMcpCommand(program: Command): void {
37
72
  }
38
73
 
39
74
  log.info(`${entries.length} MCP server(s) configured:\n`);
75
+
76
+ let didHealthCheck = false;
40
77
  for (const [id, cfg] of entries) {
41
78
  if (!cfg || typeof cfg !== 'object') {
42
79
  log.info(` ${id} (invalid config — skipped)\n`);
@@ -45,7 +82,14 @@ export function registerMcpCommand(program: Command): void {
45
82
  const enabled = cfg.enabled !== false;
46
83
  const transport = cfg.transport;
47
84
  const risk = cfg.defaultRiskLevel ?? 'high';
48
- const status = enabled ? '✓ enabled' : '✗ disabled';
85
+
86
+ let status: string;
87
+ if (!enabled) {
88
+ status = '✗ disabled';
89
+ } else {
90
+ status = await checkServerHealth(id, cfg);
91
+ didHealthCheck = true;
92
+ }
49
93
 
50
94
  log.info(` ${id}`);
51
95
  log.info(` Status: ${status}`);
@@ -60,6 +104,9 @@ export function registerMcpCommand(program: Command): void {
60
104
  if (cfg.blockedTools) log.info(` Blocked: ${cfg.blockedTools.join(', ')}`);
61
105
  log.info('');
62
106
  }
107
+
108
+ // Health checks may leave MCP transports alive — force exit
109
+ if (didHealthCheck) process.exit(0);
63
110
  });
64
111
 
65
112
  mcp
@@ -133,10 +180,132 @@ export function registerMcpCommand(program: Command): void {
133
180
  log.info('Restart the daemon for changes to take effect: vellum daemon restart');
134
181
  });
135
182
 
183
+ mcp
184
+ .command('auth <name>')
185
+ .description('Authenticate with an MCP server via OAuth')
186
+ .action(async (name: string) => {
187
+ const raw = loadRawConfig();
188
+ const mcpConfig = raw.mcp as Partial<McpConfig> | undefined;
189
+ const servers = mcpConfig?.servers ?? {};
190
+ const serverConfig = (servers as Record<string, McpServerConfig>)[name];
191
+
192
+ if (!serverConfig) {
193
+ log.error(`MCP server "${name}" not found. Add it first with: vellum mcp add`);
194
+ process.exitCode = 1;
195
+ return;
196
+ }
197
+
198
+ const transport = serverConfig.transport;
199
+ if (transport.type !== 'sse' && transport.type !== 'streamable-http') {
200
+ log.error(`OAuth is only supported for sse/streamable-http transports (server "${name}" uses ${transport.type})`);
201
+ process.exitCode = 1;
202
+ return;
203
+ }
204
+
205
+ // Validate URL early so we fail fast before starting the callback server
206
+ let serverUrl: URL;
207
+ try {
208
+ serverUrl = new URL(transport.url);
209
+ } catch {
210
+ log.error(`Invalid URL for MCP server "${name}": ${transport.url}`);
211
+ process.exitCode = 1;
212
+ return;
213
+ }
214
+
215
+ const provider = new McpOAuthProvider(name, transport.url, /* interactive */ true);
216
+ // Clear stale client_info and discovery — the callback server uses a random port,
217
+ // so any previously cached client_info has a mismatched redirect_uri.
218
+ // Preserve tokens so they survive if this auth attempt fails.
219
+ await provider.invalidateCredentials('client');
220
+ await provider.invalidateCredentials('discovery');
221
+ const { codePromise } = await provider.startCallbackServer();
222
+
223
+ const OAUTH_TIMEOUT_MS = 150_000; // 2.5 min for browser interaction
224
+ const TransportClass = transport.type === 'sse' ? SSEClientTransport : StreamableHTTPClientTransport;
225
+ const mcpTransport = new TransportClass(
226
+ serverUrl,
227
+ {
228
+ authProvider: provider,
229
+ requestInit: transport.headers ? { headers: transport.headers } : undefined,
230
+ },
231
+ );
232
+
233
+ const client = new Client({ name: 'vellum-assistant', version: '1.0.0' });
234
+
235
+ try {
236
+ // Try connecting — if tokens are already cached, this succeeds immediately
237
+ await client.connect(mcpTransport);
238
+ provider.stopCallbackServer();
239
+ await client.close();
240
+ log.info(`Server "${name}" is already authenticated.`);
241
+ return;
242
+ } catch (err) {
243
+ if (!(err instanceof UnauthorizedError)) {
244
+ provider.stopCallbackServer();
245
+ try { await client.close(); } catch { /* ignore */ }
246
+ log.error(`Failed to connect to "${name}": ${err}`);
247
+ process.exitCode = 1;
248
+ return;
249
+ }
250
+ }
251
+
252
+ // UnauthorizedError — browser was opened by redirectToAuthorization().
253
+ // Wait for the user to complete the OAuth flow.
254
+ log.info('Waiting for authorization in browser... (press Ctrl+C to cancel)');
255
+
256
+ let code: string;
257
+ let oauthTimer: ReturnType<typeof setTimeout> | undefined;
258
+ try {
259
+ code = await Promise.race([
260
+ codePromise,
261
+ new Promise<never>((_, reject) => {
262
+ oauthTimer = setTimeout(() => reject(new Error('OAuth authorization timed out after 2.5 minutes')), OAUTH_TIMEOUT_MS);
263
+ if (typeof oauthTimer === 'object' && 'unref' in oauthTimer) oauthTimer.unref();
264
+ }),
265
+ ]);
266
+ clearTimeout(oauthTimer);
267
+ } catch (err) {
268
+ clearTimeout(oauthTimer);
269
+ provider.stopCallbackServer();
270
+ try { await client.close(); } catch { /* ignore */ }
271
+ const message = err instanceof Error ? err.message : String(err);
272
+ if (message.includes('denied') || message.includes('cancelled')) {
273
+ log.error(`Authorization cancelled for "${name}".`);
274
+ } else if (message.includes('timed out')) {
275
+ log.error(`Authorization timed out for "${name}". Try again with: vellum mcp auth ${name}`);
276
+ } else {
277
+ log.error(`Authorization failed for "${name}": ${message}`);
278
+ }
279
+ process.exitCode = 1;
280
+ return;
281
+ }
282
+
283
+ log.info('Authorization received. Exchanging token...');
284
+
285
+ // Exchange auth code for tokens
286
+ try {
287
+ await mcpTransport.finishAuth(code);
288
+ } catch (err) {
289
+ provider.stopCallbackServer();
290
+ try { await client.close(); } catch { /* ignore */ }
291
+ log.error(`Token exchange failed for "${name}": ${err}`);
292
+ process.exitCode = 1;
293
+ return;
294
+ }
295
+
296
+ // Clean up transport/client so the process can exit
297
+ try { await client.close(); } catch { /* ignore */ }
298
+ provider.stopCallbackServer();
299
+
300
+ log.info(`Authentication successful for "${name}".`);
301
+ log.info('Restart the daemon for changes to take effect: vellum daemon restart');
302
+ process.exit(0);
303
+ });
304
+
136
305
  mcp
137
306
  .command('remove <name>')
138
307
  .description('Remove an MCP server configuration')
139
- .action((name: string) => {
308
+ .action(async (name: string) => {
140
309
  const raw = loadRawConfig();
141
310
  const mcpConfig = raw.mcp as Record<string, unknown> | undefined;
142
311
  const servers = mcpConfig?.servers as Record<string, unknown> | undefined;
@@ -147,6 +316,17 @@ export function registerMcpCommand(program: Command): void {
147
316
  return;
148
317
  }
149
318
 
319
+ // Best-effort cleanup of any OAuth credentials stored for this server
320
+ const serverConfig = servers[name] as Record<string, unknown>;
321
+ const transport = serverConfig?.transport as Record<string, unknown> | undefined;
322
+ if (transport?.type === 'sse' || transport?.type === 'streamable-http') {
323
+ try {
324
+ await deleteMcpOAuthCredentials(name);
325
+ } catch {
326
+ // Ignore — credentials may not exist
327
+ }
328
+ }
329
+
150
330
  delete servers[name];
151
331
  saveRawConfig(raw);
152
332
  log.info(`Removed MCP server "${name}".`);
@@ -8,9 +8,9 @@ metadata: {"vellum": {"emoji": "📬"}}
8
8
  ## How to run
9
9
 
10
10
  `vellum` is your own CLI binary — it is already installed and available on the PATH.
11
- Run all commands via `host_bash`. Do NOT attempt to install, build, or locate the CLI — just execute it directly.
11
+ Run all commands via `bash`. Do NOT attempt to install, build, or locate the CLI — just execute it directly.
12
12
 
13
- Example: `host_bash("vellum email status --json")`
13
+ Example: `bash("vellum email status --json")`
14
14
 
15
15
  Never use browser/computer-use unless user explicitly approves fallback.
16
16
 
@@ -20,7 +20,7 @@ This skill manages the **assistant's own** AgentMail address (`@agentmail.to`)
20
20
 
21
21
  ## Rules
22
22
 
23
- - Always run `vellum email` commands via `host_bash` and parse JSON output.
23
+ - Always run `vellum email` commands via `bash` and parse JSON output.
24
24
  - Always do `vellum email status --json` preflight first.
25
25
  - Prefer `draft create` before any send — never bypass draft flow.
26
26
  - Require explicit user confirmation before `draft approve-send --confirm`.
@@ -38,7 +38,7 @@ Use `credential_store` with:
38
38
  - label: `AgentMail API Key`
39
39
  - description: `Get your API key from console.agentmail.to`
40
40
  - placeholder: `am_us_...`
41
- - allowed_tools: `["host_bash"]`
41
+ - allowed_tools: `["bash"]`
42
42
  - usage_description: `AgentMail email operations via vellum CLI`
43
43
 
44
44
  After the credential is stored, retry `vellum email status --json` to confirm it works.
@@ -2,6 +2,7 @@
2
2
  name: "Google OAuth Setup"
3
3
  description: "Set up Google Cloud OAuth credentials for Gmail and Calendar using browser automation"
4
4
  user-invocable: true
5
+ credential-setup-for: "gmail"
5
6
  includes: ["browser", "public-ingress"]
6
7
  metadata: {"vellum": {"emoji": "\ud83d\udd11"}}
7
8
  ---