@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.
- package/ARCHITECTURE.md +24 -0
- package/Dockerfile +1 -1
- package/README.md +16 -9
- package/package.json +1 -1
- package/src/__tests__/account-registry.test.ts +1 -0
- package/src/__tests__/actor-token-service.test.ts +1 -0
- package/src/__tests__/app-builder-tool-scripts.test.ts +1 -0
- package/src/__tests__/asset-materialize-tool.test.ts +7 -0
- package/src/__tests__/asset-search-tool.test.ts +7 -0
- package/src/__tests__/browser-fill-credential.test.ts +1 -0
- package/src/__tests__/call-start-guardian-guard.test.ts +1 -0
- package/src/__tests__/channel-approval-routes.test.ts +29 -0
- package/src/__tests__/channel-guardian.test.ts +2143 -1546
- package/src/__tests__/channel-retry-sweep.test.ts +169 -14
- package/src/__tests__/claude-code-tool-profiles.test.ts +1 -0
- package/src/__tests__/computer-use-tools.test.ts +1 -0
- package/src/__tests__/contacts-tools.test.ts +1 -0
- package/src/__tests__/conversation-attention-telegram.test.ts +1 -0
- package/src/__tests__/credential-policy-validate.test.ts +97 -0
- package/src/__tests__/credential-security-e2e.test.ts +1 -0
- package/src/__tests__/credential-vault-unit.test.ts +1 -0
- package/src/__tests__/credential-vault.test.ts +1 -0
- package/src/__tests__/delete-managed-skill-tool.test.ts +1 -0
- package/src/__tests__/file-edit-tool.test.ts +1 -0
- package/src/__tests__/file-read-tool.test.ts +1 -0
- package/src/__tests__/file-write-tool.test.ts +1 -0
- package/src/__tests__/followup-tools.test.ts +1 -0
- package/src/__tests__/gateway-only-guard.test.ts +1 -1
- package/src/__tests__/guardian-control-plane-policy.test.ts +5 -4
- package/src/__tests__/guardian-grant-minting.test.ts +3 -0
- package/src/__tests__/guardian-principal-id-roundtrip.test.ts +4 -3
- package/src/__tests__/guardian-routing-state.test.ts +8 -0
- package/src/__tests__/headless-browser-interactions.test.ts +1 -0
- package/src/__tests__/headless-browser-navigate.test.ts +1 -0
- package/src/__tests__/headless-browser-read-tools.test.ts +1 -0
- package/src/__tests__/headless-browser-snapshot.test.ts +1 -0
- package/src/__tests__/host-file-edit-tool.test.ts +1 -0
- package/src/__tests__/host-file-read-tool.test.ts +1 -0
- package/src/__tests__/host-file-write-tool.test.ts +1 -0
- package/src/__tests__/host-shell-tool.test.ts +1 -0
- package/src/__tests__/lifecycle-docs-guard.test.ts +207 -0
- package/src/__tests__/managed-skill-lifecycle.test.ts +1 -0
- package/src/__tests__/media-reuse-story.e2e.test.ts +8 -0
- package/src/__tests__/messaging-send-tool.test.ts +1 -0
- package/src/__tests__/playbook-execution.test.ts +1 -0
- package/src/__tests__/playbook-tools.test.ts +1 -0
- package/src/__tests__/relay-server.test.ts +4 -0
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +1 -0
- package/src/__tests__/schedule-tools.test.ts +1 -0
- package/src/__tests__/secret-onetime-send.test.ts +4 -0
- package/src/__tests__/secret-scanner-executor.test.ts +2 -0
- package/src/__tests__/send-notification-tool.test.ts +2 -0
- package/src/__tests__/shell-credential-ref.test.ts +1 -0
- package/src/__tests__/shell-tool-proxy-mode.test.ts +1 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +1 -0
- package/src/__tests__/skill-load-tool.test.ts +1 -0
- package/src/__tests__/skill-script-runner-host.test.ts +1 -0
- package/src/__tests__/skill-script-runner-sandbox.test.ts +1 -0
- package/src/__tests__/skill-script-runner.test.ts +1 -0
- package/src/__tests__/skill-tool-factory.test.ts +1 -0
- package/src/__tests__/subagent-tools.test.ts +1 -1
- package/src/__tests__/swarm-recursion.test.ts +1 -0
- package/src/__tests__/swarm-session-integration.test.ts +1 -0
- package/src/__tests__/swarm-tool.test.ts +1 -0
- package/src/__tests__/task-management-tools.test.ts +1 -0
- package/src/__tests__/task-tools.test.ts +1 -0
- package/src/__tests__/terminal-tools.test.ts +1 -0
- package/src/__tests__/tool-approval-handler.test.ts +2 -2
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +1 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -0
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +2 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +1 -0
- package/src/__tests__/tool-executor.test.ts +1 -0
- package/src/__tests__/trust-context-guards.test.ts +218 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +6 -0
- package/src/__tests__/trusted-contact-multichannel.test.ts +1 -0
- package/src/__tests__/trusted-contact-verification.test.ts +1 -0
- package/src/__tests__/view-image-tool.test.ts +1 -0
- package/src/calls/guardian-dispatch.ts +4 -4
- package/src/cli/mcp.ts +183 -3
- package/src/config/bundled-skills/agentmail/SKILL.md +4 -4
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +1 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +17 -119
- package/src/config/system-prompt.ts +4 -2
- package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/computer-use-session.ts +1 -0
- package/src/daemon/session-agent-loop.ts +1 -1
- package/src/daemon/session-memory.ts +2 -2
- package/src/daemon/session-runtime-assembly.ts +2 -2
- package/src/daemon/session-tool-setup.ts +1 -1
- package/src/mcp/client.ts +55 -6
- package/src/mcp/manager.ts +9 -0
- package/src/mcp/mcp-oauth-provider.ts +347 -0
- package/src/memory/channel-delivery-store.ts +1 -0
- package/src/memory/db-init.ts +4 -0
- package/src/memory/delivery-status.ts +43 -0
- package/src/memory/guardian-bindings.ts +3 -3
- package/src/memory/migrations/127-guardian-principal-id-not-null.ts +108 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +6 -0
- package/src/memory/schema.ts +1 -1
- package/src/runtime/actor-trust-resolver.ts +13 -4
- package/src/runtime/channel-retry-sweep.ts +31 -14
- package/src/runtime/guardian-context-resolver.ts +25 -64
- package/src/runtime/guardian-outbound-actions.ts +399 -108
- package/src/runtime/guardian-vellum-migration.ts +1 -23
- package/src/runtime/guardian-verification-templates.ts +66 -30
- package/src/runtime/local-actor-identity.ts +4 -6
- package/src/runtime/middleware/actor-token.ts +2 -8
- package/src/runtime/routes/channel-route-shared.ts +0 -1
- package/src/runtime/routes/inbound-message-handler.ts +3 -4
- package/src/runtime/tool-grant-request-helper.ts +1 -1
- package/src/tools/credentials/policy-validate.ts +22 -0
- package/src/tools/guardian-control-plane-policy.ts +2 -2
- 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
|
});
|
|
@@ -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
|
|
98
|
+
let guardianPrincipalId = vellumBinding?.guardianPrincipalId;
|
|
99
99
|
|
|
100
|
-
// Self-heal: if the vellum binding is missing
|
|
101
|
-
//
|
|
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
|
|
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
|
-
|
|
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 `
|
|
11
|
+
Run all commands via `bash`. Do NOT attempt to install, build, or locate the CLI — just execute it directly.
|
|
12
12
|
|
|
13
|
-
Example: `
|
|
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 `
|
|
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: `["
|
|
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
|
---
|