@vellumai/assistant 0.3.22 → 0.3.24
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/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -84
- package/src/__tests__/approval-primitive.test.ts +72 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +0 -4
- package/src/__tests__/host-shell-tool.test.ts +25 -0
- package/src/__tests__/ipc-snapshot.test.ts +0 -42
- package/src/__tests__/mcp-cli.test.ts +120 -3
- package/src/__tests__/skill-feature-flags-integration.test.ts +0 -4
- package/src/__tests__/terminal-tools.test.ts +19 -1
- package/src/__tests__/tool-approval-handler.test.ts +94 -5
- package/src/__tests__/tool-executor.test.ts +1 -1
- package/src/cli/mcp.ts +25 -0
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +13 -8
- package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
- package/src/config/bundled-skills/public-ingress/SKILL.md +6 -6
- package/src/config/bundled-skills/reminder/SKILL.md +7 -6
- package/src/config/bundled-skills/time-based-actions/SKILL.md +7 -6
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/schema.ts +10 -10
- package/src/config/system-prompt.ts +0 -72
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +7 -7
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +14 -6
- package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -10
- package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
- package/src/daemon/handlers/config.ts +0 -4
- package/src/daemon/handlers/navigate-settings.ts +0 -1
- package/src/daemon/ipc-contract-inventory.json +0 -10
- package/src/daemon/ipc-contract.ts +0 -4
- package/src/daemon/lifecycle.ts +14 -2
- package/src/daemon/session-process.ts +2 -2
- package/src/daemon/shutdown-handlers.ts +1 -1
- package/src/instrument.ts +15 -1
- package/src/mcp/client.ts +3 -3
- package/src/memory/conversation-crud.ts +26 -4
- package/src/memory/migrations/119-schema-indexes-and-columns.ts +46 -18
- package/src/permissions/checker.ts +4 -4
- package/src/runtime/routes/inbound-message-handler.ts +2 -2
- package/src/runtime/routes/ingress-routes.ts +7 -2
- package/src/tools/executor.ts +2 -2
- package/src/tools/host-terminal/host-shell.ts +4 -29
- package/src/tools/swarm/delegate.ts +3 -0
- package/src/tools/system/navigate-settings.ts +0 -1
- package/src/tools/terminal/safe-env.ts +9 -0
- package/src/tools/tool-approval-handler.ts +2 -33
- package/src/tools/tool-manifest.ts +33 -88
- package/src/daemon/handlers/config-parental.ts +0 -164
- package/src/daemon/ipc-contract/parental-control.ts +0 -109
- package/src/security/parental-control-store.ts +0 -184
package/package.json
CHANGED
|
@@ -1015,43 +1015,6 @@ exports[`IPC message snapshots ClientMessage types dictation_request serializes
|
|
|
1015
1015
|
}
|
|
1016
1016
|
`;
|
|
1017
1017
|
|
|
1018
|
-
exports[`IPC message snapshots ClientMessage types parental_control_get serializes to expected JSON 1`] = `
|
|
1019
|
-
{
|
|
1020
|
-
"type": "parental_control_get",
|
|
1021
|
-
}
|
|
1022
|
-
`;
|
|
1023
|
-
|
|
1024
|
-
exports[`IPC message snapshots ClientMessage types parental_control_verify_pin serializes to expected JSON 1`] = `
|
|
1025
|
-
{
|
|
1026
|
-
"pin": "123456",
|
|
1027
|
-
"type": "parental_control_verify_pin",
|
|
1028
|
-
}
|
|
1029
|
-
`;
|
|
1030
|
-
|
|
1031
|
-
exports[`IPC message snapshots ClientMessage types parental_control_set_pin serializes to expected JSON 1`] = `
|
|
1032
|
-
{
|
|
1033
|
-
"current_pin": "123456",
|
|
1034
|
-
"new_pin": "654321",
|
|
1035
|
-
"type": "parental_control_set_pin",
|
|
1036
|
-
}
|
|
1037
|
-
`;
|
|
1038
|
-
|
|
1039
|
-
exports[`IPC message snapshots ClientMessage types parental_control_update serializes to expected JSON 1`] = `
|
|
1040
|
-
{
|
|
1041
|
-
"blocked_tool_categories": [
|
|
1042
|
-
"shell",
|
|
1043
|
-
"network",
|
|
1044
|
-
],
|
|
1045
|
-
"content_restrictions": [
|
|
1046
|
-
"violence",
|
|
1047
|
-
"adult_content",
|
|
1048
|
-
],
|
|
1049
|
-
"enabled": true,
|
|
1050
|
-
"pin": "123456",
|
|
1051
|
-
"type": "parental_control_update",
|
|
1052
|
-
}
|
|
1053
|
-
`;
|
|
1054
|
-
|
|
1055
1018
|
exports[`IPC message snapshots ClientMessage types ingress_invite serializes to expected JSON 1`] = `
|
|
1056
1019
|
{
|
|
1057
1020
|
"action": "create",
|
|
@@ -2944,53 +2907,6 @@ exports[`IPC message snapshots ServerMessage types dictation_response serializes
|
|
|
2944
2907
|
}
|
|
2945
2908
|
`;
|
|
2946
2909
|
|
|
2947
|
-
exports[`IPC message snapshots ServerMessage types parental_control_get_response serializes to expected JSON 1`] = `
|
|
2948
|
-
{
|
|
2949
|
-
"blocked_tool_categories": [
|
|
2950
|
-
"shell",
|
|
2951
|
-
"network",
|
|
2952
|
-
],
|
|
2953
|
-
"content_restrictions": [
|
|
2954
|
-
"violence",
|
|
2955
|
-
"adult_content",
|
|
2956
|
-
],
|
|
2957
|
-
"enabled": true,
|
|
2958
|
-
"has_pin": true,
|
|
2959
|
-
"type": "parental_control_get_response",
|
|
2960
|
-
}
|
|
2961
|
-
`;
|
|
2962
|
-
|
|
2963
|
-
exports[`IPC message snapshots ServerMessage types parental_control_verify_pin_response serializes to expected JSON 1`] = `
|
|
2964
|
-
{
|
|
2965
|
-
"type": "parental_control_verify_pin_response",
|
|
2966
|
-
"verified": true,
|
|
2967
|
-
}
|
|
2968
|
-
`;
|
|
2969
|
-
|
|
2970
|
-
exports[`IPC message snapshots ServerMessage types parental_control_set_pin_response serializes to expected JSON 1`] = `
|
|
2971
|
-
{
|
|
2972
|
-
"success": true,
|
|
2973
|
-
"type": "parental_control_set_pin_response",
|
|
2974
|
-
}
|
|
2975
|
-
`;
|
|
2976
|
-
|
|
2977
|
-
exports[`IPC message snapshots ServerMessage types parental_control_update_response serializes to expected JSON 1`] = `
|
|
2978
|
-
{
|
|
2979
|
-
"blocked_tool_categories": [
|
|
2980
|
-
"shell",
|
|
2981
|
-
"network",
|
|
2982
|
-
],
|
|
2983
|
-
"content_restrictions": [
|
|
2984
|
-
"violence",
|
|
2985
|
-
"adult_content",
|
|
2986
|
-
],
|
|
2987
|
-
"enabled": true,
|
|
2988
|
-
"has_pin": true,
|
|
2989
|
-
"success": true,
|
|
2990
|
-
"type": "parental_control_update_response",
|
|
2991
|
-
}
|
|
2992
|
-
`;
|
|
2993
|
-
|
|
2994
2910
|
exports[`IPC message snapshots ServerMessage types ingress_invite_response serializes to expected JSON 1`] = `
|
|
2995
2911
|
{
|
|
2996
2912
|
"invite": {
|
|
@@ -537,4 +537,76 @@ describe('approval-primitive / consumeGrantForInvocation retry', () => {
|
|
|
537
537
|
expect(elapsed).toBeGreaterThanOrEqual(450);
|
|
538
538
|
expect(elapsed).toBeLessThan(1_500);
|
|
539
539
|
});
|
|
540
|
+
|
|
541
|
+
test('returns aborted when signal fires during retry polling', async () => {
|
|
542
|
+
const digest = computeToolApprovalDigest('shell', { command: 'aborted' });
|
|
543
|
+
const controller = new AbortController();
|
|
544
|
+
|
|
545
|
+
// Abort after 200ms — well before the 2s max wait
|
|
546
|
+
setTimeout(() => controller.abort(), 200);
|
|
547
|
+
|
|
548
|
+
const start = Date.now();
|
|
549
|
+
const result = await consumeGrantForInvocation(
|
|
550
|
+
{
|
|
551
|
+
toolName: 'shell',
|
|
552
|
+
inputDigest: digest,
|
|
553
|
+
consumingRequestId: 'consumer-aborted',
|
|
554
|
+
assistantId: 'self',
|
|
555
|
+
},
|
|
556
|
+
{ maxWaitMs: 2_000, intervalMs: 50, signal: controller.signal },
|
|
557
|
+
);
|
|
558
|
+
const elapsed = Date.now() - start;
|
|
559
|
+
|
|
560
|
+
expect(result.ok).toBe(false);
|
|
561
|
+
if (result.ok) return;
|
|
562
|
+
expect(result.reason).toBe('aborted');
|
|
563
|
+
// Should have exited shortly after the abort (200ms), not waited the full 2s
|
|
564
|
+
expect(elapsed).toBeLessThan(1_000);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test('returns aborted immediately when signal is already aborted', async () => {
|
|
568
|
+
const digest = computeToolApprovalDigest('shell', { command: 'pre-aborted' });
|
|
569
|
+
const controller = new AbortController();
|
|
570
|
+
controller.abort();
|
|
571
|
+
|
|
572
|
+
const start = Date.now();
|
|
573
|
+
const result = await consumeGrantForInvocation(
|
|
574
|
+
{
|
|
575
|
+
toolName: 'shell',
|
|
576
|
+
inputDigest: digest,
|
|
577
|
+
consumingRequestId: 'consumer-pre-aborted',
|
|
578
|
+
assistantId: 'self',
|
|
579
|
+
},
|
|
580
|
+
{ maxWaitMs: 2_000, intervalMs: 50, signal: controller.signal },
|
|
581
|
+
);
|
|
582
|
+
const elapsed = Date.now() - start;
|
|
583
|
+
|
|
584
|
+
expect(result.ok).toBe(false);
|
|
585
|
+
if (result.ok) return;
|
|
586
|
+
expect(result.reason).toBe('aborted');
|
|
587
|
+
// Should return nearly instantly since signal was already aborted
|
|
588
|
+
expect(elapsed).toBeLessThan(200);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test('skips retry entirely when maxWaitMs is 0', async () => {
|
|
592
|
+
const digest = computeToolApprovalDigest('shell', { command: 'no-retry' });
|
|
593
|
+
|
|
594
|
+
const start = Date.now();
|
|
595
|
+
const result = await consumeGrantForInvocation(
|
|
596
|
+
{
|
|
597
|
+
toolName: 'shell',
|
|
598
|
+
inputDigest: digest,
|
|
599
|
+
consumingRequestId: 'consumer-no-retry',
|
|
600
|
+
assistantId: 'self',
|
|
601
|
+
},
|
|
602
|
+
{ maxWaitMs: 0 },
|
|
603
|
+
);
|
|
604
|
+
const elapsed = Date.now() - start;
|
|
605
|
+
|
|
606
|
+
expect(result.ok).toBe(false);
|
|
607
|
+
if (result.ok) return;
|
|
608
|
+
expect(result.reason).toBe('no_match');
|
|
609
|
+
// Should return nearly instantly — no retry loop
|
|
610
|
+
expect(elapsed).toBeLessThan(100);
|
|
611
|
+
});
|
|
540
612
|
});
|
|
@@ -73,10 +73,6 @@ mock.module('../config/user-reference.js', () => ({
|
|
|
73
73
|
resolveUserReference: () => 'TestUser',
|
|
74
74
|
}));
|
|
75
75
|
|
|
76
|
-
mock.module('../security/parental-control-store.js', () => ({
|
|
77
|
-
getParentalControlSettings: () => ({ enabled: false, contentRestrictions: [], blockedToolCategories: [] }),
|
|
78
|
-
}));
|
|
79
|
-
|
|
80
76
|
mock.module('../tools/credentials/metadata-store.js', () => ({
|
|
81
77
|
listCredentialMetadata: () => [],
|
|
82
78
|
}));
|
|
@@ -424,6 +424,31 @@ describe('host_bash — environment setup', () => {
|
|
|
424
424
|
expect(result.content).toContain('HOME=');
|
|
425
425
|
expect(result.content.trim()).not.toBe('HOME=');
|
|
426
426
|
});
|
|
427
|
+
|
|
428
|
+
test('injects INTERNAL_GATEWAY_BASE_URL and GATEWAY_BASE_URL for host_bash commands', async () => {
|
|
429
|
+
const originalGatewayBase = process.env.GATEWAY_INTERNAL_BASE_URL;
|
|
430
|
+
const originalIngressBase = process.env.INGRESS_PUBLIC_BASE_URL;
|
|
431
|
+
process.env.GATEWAY_INTERNAL_BASE_URL = 'http://gateway.internal:9000/';
|
|
432
|
+
process.env.INGRESS_PUBLIC_BASE_URL = 'https://gw.example.com/';
|
|
433
|
+
try {
|
|
434
|
+
const result = await hostShellTool.execute({
|
|
435
|
+
command: 'echo "$INTERNAL_GATEWAY_BASE_URL|$GATEWAY_BASE_URL"',
|
|
436
|
+
}, makeContext());
|
|
437
|
+
expect(result.isError).toBe(false);
|
|
438
|
+
expect(result.content.trim()).toBe('http://gateway.internal:9000|https://gw.example.com');
|
|
439
|
+
} finally {
|
|
440
|
+
if (originalGatewayBase === undefined) {
|
|
441
|
+
delete process.env.GATEWAY_INTERNAL_BASE_URL;
|
|
442
|
+
} else {
|
|
443
|
+
process.env.GATEWAY_INTERNAL_BASE_URL = originalGatewayBase;
|
|
444
|
+
}
|
|
445
|
+
if (originalIngressBase === undefined) {
|
|
446
|
+
delete process.env.INGRESS_PUBLIC_BASE_URL;
|
|
447
|
+
} else {
|
|
448
|
+
process.env.INGRESS_PUBLIC_BASE_URL = originalIngressBase;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
});
|
|
427
452
|
});
|
|
428
453
|
|
|
429
454
|
// ---------------------------------------------------------------------------
|
|
@@ -630,25 +630,6 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
|
|
|
630
630
|
cursorInTextField: true,
|
|
631
631
|
},
|
|
632
632
|
},
|
|
633
|
-
parental_control_get: {
|
|
634
|
-
type: 'parental_control_get',
|
|
635
|
-
},
|
|
636
|
-
parental_control_verify_pin: {
|
|
637
|
-
type: 'parental_control_verify_pin',
|
|
638
|
-
pin: '123456',
|
|
639
|
-
},
|
|
640
|
-
parental_control_set_pin: {
|
|
641
|
-
type: 'parental_control_set_pin',
|
|
642
|
-
current_pin: '123456',
|
|
643
|
-
new_pin: '654321',
|
|
644
|
-
},
|
|
645
|
-
parental_control_update: {
|
|
646
|
-
type: 'parental_control_update',
|
|
647
|
-
pin: '123456',
|
|
648
|
-
enabled: true,
|
|
649
|
-
content_restrictions: ['violence', 'adult_content'],
|
|
650
|
-
blocked_tool_categories: ['shell', 'network'],
|
|
651
|
-
},
|
|
652
633
|
ingress_invite: {
|
|
653
634
|
type: 'ingress_invite',
|
|
654
635
|
action: 'create',
|
|
@@ -1889,29 +1870,6 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
|
|
|
1889
1870
|
mode: 'dictation',
|
|
1890
1871
|
actionPlan: undefined,
|
|
1891
1872
|
},
|
|
1892
|
-
parental_control_get_response: {
|
|
1893
|
-
type: 'parental_control_get_response',
|
|
1894
|
-
enabled: true,
|
|
1895
|
-
has_pin: true,
|
|
1896
|
-
content_restrictions: ['violence', 'adult_content'],
|
|
1897
|
-
blocked_tool_categories: ['shell', 'network'],
|
|
1898
|
-
},
|
|
1899
|
-
parental_control_verify_pin_response: {
|
|
1900
|
-
type: 'parental_control_verify_pin_response',
|
|
1901
|
-
verified: true,
|
|
1902
|
-
},
|
|
1903
|
-
parental_control_set_pin_response: {
|
|
1904
|
-
type: 'parental_control_set_pin_response',
|
|
1905
|
-
success: true,
|
|
1906
|
-
},
|
|
1907
|
-
parental_control_update_response: {
|
|
1908
|
-
type: 'parental_control_update_response',
|
|
1909
|
-
success: true,
|
|
1910
|
-
enabled: true,
|
|
1911
|
-
has_pin: true,
|
|
1912
|
-
content_restrictions: ['violence', 'adult_content'],
|
|
1913
|
-
blocked_tool_categories: ['shell', 'network'],
|
|
1914
|
-
},
|
|
1915
1873
|
ingress_invite_response: {
|
|
1916
1874
|
type: 'ingress_invite_response',
|
|
1917
1875
|
success: true,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
@@ -10,22 +10,35 @@ const CLI = join(import.meta.dir, '..', 'index.ts');
|
|
|
10
10
|
let testDataDir: string;
|
|
11
11
|
let configPath: string;
|
|
12
12
|
|
|
13
|
-
function
|
|
14
|
-
const result = spawnSync('bun', ['run', CLI, 'mcp',
|
|
13
|
+
function runMcp(subcommand: string, args: string[] = []): { stdout: string; stderr: string; exitCode: number } {
|
|
14
|
+
const result = spawnSync('bun', ['run', CLI, 'mcp', subcommand, ...args], {
|
|
15
15
|
encoding: 'utf-8',
|
|
16
16
|
timeout: 10_000,
|
|
17
17
|
env: { ...process.env, BASE_DATA_DIR: testDataDir },
|
|
18
18
|
});
|
|
19
19
|
return {
|
|
20
20
|
stdout: (result.stdout ?? '').toString(),
|
|
21
|
+
stderr: (result.stderr ?? '').toString(),
|
|
21
22
|
exitCode: result.status ?? 1,
|
|
22
23
|
};
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
function runMcpList(args: string[] = []) {
|
|
27
|
+
return runMcp('list', args);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runMcpAdd(name: string, args: string[]) {
|
|
31
|
+
return runMcp('add', [name, ...args]);
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
function writeConfig(config: Record<string, unknown>): void {
|
|
26
35
|
writeFileSync(configPath, JSON.stringify(config), 'utf-8');
|
|
27
36
|
}
|
|
28
37
|
|
|
38
|
+
function readConfig(): Record<string, unknown> {
|
|
39
|
+
return JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
describe('vellum mcp list', () => {
|
|
30
43
|
beforeAll(() => {
|
|
31
44
|
testDataDir = join(tmpdir(), `vellum-mcp-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
@@ -139,3 +152,107 @@ describe('vellum mcp list', () => {
|
|
|
139
152
|
expect(parsed).toEqual([]);
|
|
140
153
|
});
|
|
141
154
|
});
|
|
155
|
+
|
|
156
|
+
describe('vellum mcp add', () => {
|
|
157
|
+
beforeAll(() => {
|
|
158
|
+
testDataDir = join(tmpdir(), `vellum-mcp-add-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
159
|
+
const workspaceDir = join(testDataDir, '.vellum', 'workspace');
|
|
160
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
161
|
+
configPath = join(workspaceDir, 'config.json');
|
|
162
|
+
writeConfig({});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
afterAll(() => {
|
|
166
|
+
rmSync(testDataDir, { recursive: true, force: true });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
beforeEach(() => {
|
|
170
|
+
writeConfig({});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('adds a streamable-http server', () => {
|
|
174
|
+
const { stdout, exitCode } = runMcpAdd('test-http', [
|
|
175
|
+
'-t', 'streamable-http', '-u', 'https://example.com/mcp', '-r', 'medium',
|
|
176
|
+
]);
|
|
177
|
+
expect(exitCode).toBe(0);
|
|
178
|
+
expect(stdout).toContain('Added MCP server "test-http"');
|
|
179
|
+
|
|
180
|
+
const updated = readConfig();
|
|
181
|
+
const servers = (updated.mcp as Record<string, unknown> | undefined)?.servers as Record<string, unknown> | undefined;
|
|
182
|
+
const server = servers?.['test-http'] as Record<string, unknown>;
|
|
183
|
+
expect(server).toBeDefined();
|
|
184
|
+
expect((server.transport as Record<string, unknown>).type).toBe('streamable-http');
|
|
185
|
+
expect((server.transport as Record<string, unknown>).url).toBe('https://example.com/mcp');
|
|
186
|
+
expect(server.defaultRiskLevel).toBe('medium');
|
|
187
|
+
expect(server.enabled).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('adds a stdio server with args', () => {
|
|
191
|
+
const { stdout, exitCode } = runMcpAdd('test-stdio', [
|
|
192
|
+
'-t', 'stdio', '-c', 'npx', '-a', '-y', 'some-server', '-r', 'low',
|
|
193
|
+
]);
|
|
194
|
+
expect(exitCode).toBe(0);
|
|
195
|
+
expect(stdout).toContain('Added MCP server "test-stdio"');
|
|
196
|
+
|
|
197
|
+
const updated = readConfig();
|
|
198
|
+
const servers = (updated.mcp as Record<string, unknown> | undefined)?.servers as Record<string, unknown> | undefined;
|
|
199
|
+
const server = servers?.['test-stdio'] as Record<string, unknown>;
|
|
200
|
+
const transport = server.transport as Record<string, unknown>;
|
|
201
|
+
expect(transport.type).toBe('stdio');
|
|
202
|
+
expect(transport.command).toBe('npx');
|
|
203
|
+
expect(transport.args).toEqual(['-y', 'some-server']);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('adds server as disabled with --disabled flag', () => {
|
|
207
|
+
const { exitCode } = runMcpAdd('test-disabled', [
|
|
208
|
+
'-t', 'sse', '-u', 'https://example.com/sse', '--disabled',
|
|
209
|
+
]);
|
|
210
|
+
expect(exitCode).toBe(0);
|
|
211
|
+
|
|
212
|
+
const updated = readConfig();
|
|
213
|
+
const servers = (updated.mcp as Record<string, unknown> | undefined)?.servers as Record<string, unknown> | undefined;
|
|
214
|
+
const server = servers?.['test-disabled'] as Record<string, unknown>;
|
|
215
|
+
expect(server.enabled).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('rejects duplicate server name', () => {
|
|
219
|
+
writeConfig({
|
|
220
|
+
mcp: {
|
|
221
|
+
servers: {
|
|
222
|
+
existing: {
|
|
223
|
+
transport: { type: 'sse', url: 'https://example.com' },
|
|
224
|
+
enabled: true,
|
|
225
|
+
defaultRiskLevel: 'high',
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const { stderr } = runMcpAdd('existing', [
|
|
232
|
+
'-t', 'sse', '-u', 'https://other.com',
|
|
233
|
+
]);
|
|
234
|
+
expect(stderr).toContain('already exists');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('rejects stdio without --command', () => {
|
|
238
|
+
const { stderr } = runMcpAdd('bad-stdio', ['-t', 'stdio']);
|
|
239
|
+
expect(stderr).toContain('--command is required');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('rejects streamable-http without --url', () => {
|
|
243
|
+
const { stderr } = runMcpAdd('bad-http', ['-t', 'streamable-http']);
|
|
244
|
+
expect(stderr).toContain('--url is required');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('defaults risk to high', () => {
|
|
248
|
+
const { exitCode } = runMcpAdd('default-risk', [
|
|
249
|
+
'-t', 'sse', '-u', 'https://example.com/sse',
|
|
250
|
+
]);
|
|
251
|
+
expect(exitCode).toBe(0);
|
|
252
|
+
|
|
253
|
+
const updated = readConfig();
|
|
254
|
+
const servers = (updated.mcp as Record<string, unknown> | undefined)?.servers as Record<string, unknown> | undefined;
|
|
255
|
+
const server = servers?.['default-risk'] as Record<string, unknown>;
|
|
256
|
+
expect(server.defaultRiskLevel).toBe('high');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -68,10 +68,6 @@ mock.module('../config/user-reference.js', () => ({
|
|
|
68
68
|
resolveUserReference: () => 'TestUser',
|
|
69
69
|
}));
|
|
70
70
|
|
|
71
|
-
mock.module('../security/parental-control-store.js', () => ({
|
|
72
|
-
getParentalControlSettings: () => ({ enabled: false, contentRestrictions: [], blockedToolCategories: [] }),
|
|
73
|
-
}));
|
|
74
|
-
|
|
75
71
|
mock.module('../tools/credentials/metadata-store.js', () => ({
|
|
76
72
|
listCredentialMetadata: () => [],
|
|
77
73
|
}));
|
|
@@ -446,6 +446,23 @@ describe('buildSanitizedEnv', () => {
|
|
|
446
446
|
expect(env.LC_CTYPE).toBe('UTF-8');
|
|
447
447
|
});
|
|
448
448
|
|
|
449
|
+
test('injects INTERNAL_GATEWAY_BASE_URL from gateway config', () => {
|
|
450
|
+
process.env.GATEWAY_INTERNAL_BASE_URL = 'http://gateway.internal:9000/';
|
|
451
|
+
const env = buildSanitizedEnv();
|
|
452
|
+
expect(env.INTERNAL_GATEWAY_BASE_URL).toBe('http://gateway.internal:9000');
|
|
453
|
+
delete process.env.GATEWAY_INTERNAL_BASE_URL;
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test('injects GATEWAY_BASE_URL from public ingress when configured', () => {
|
|
457
|
+
process.env.GATEWAY_INTERNAL_BASE_URL = 'http://gateway.internal:9000/';
|
|
458
|
+
process.env.INGRESS_PUBLIC_BASE_URL = 'https://gw.example.com/';
|
|
459
|
+
const env = buildSanitizedEnv();
|
|
460
|
+
expect(env.INTERNAL_GATEWAY_BASE_URL).toBe('http://gateway.internal:9000');
|
|
461
|
+
expect(env.GATEWAY_BASE_URL).toBe('https://gw.example.com');
|
|
462
|
+
delete process.env.GATEWAY_INTERNAL_BASE_URL;
|
|
463
|
+
delete process.env.INGRESS_PUBLIC_BASE_URL;
|
|
464
|
+
});
|
|
465
|
+
|
|
449
466
|
test('result is a plain object with no prototype-inherited secrets', () => {
|
|
450
467
|
const env = buildSanitizedEnv();
|
|
451
468
|
const keys = Object.keys(env);
|
|
@@ -453,6 +470,8 @@ describe('buildSanitizedEnv', () => {
|
|
|
453
470
|
'PATH', 'HOME', 'TERM', 'LANG', 'EDITOR', 'SHELL', 'USER', 'TMPDIR',
|
|
454
471
|
'LC_ALL', 'LC_CTYPE', 'XDG_RUNTIME_DIR', 'DISPLAY', 'COLORTERM',
|
|
455
472
|
'TERM_PROGRAM', 'SSH_AUTH_SOCK', 'SSH_AGENT_PID', 'GPG_TTY', 'GNUPGHOME',
|
|
473
|
+
'INTERNAL_GATEWAY_BASE_URL',
|
|
474
|
+
'GATEWAY_BASE_URL',
|
|
456
475
|
];
|
|
457
476
|
for (const key of keys) {
|
|
458
477
|
expect(safeKeys).toContain(key);
|
|
@@ -684,4 +703,3 @@ describe('formatShellOutput', () => {
|
|
|
684
703
|
expect(result.content.length).toBeLessThan(60_000);
|
|
685
704
|
});
|
|
686
705
|
});
|
|
687
|
-
|
|
@@ -29,11 +29,6 @@ mock.module('../util/logger.js', () => ({
|
|
|
29
29
|
truncateForLog: (value: string) => value,
|
|
30
30
|
}));
|
|
31
31
|
|
|
32
|
-
// Mock parental controls — no tools blocked by default
|
|
33
|
-
mock.module('../security/parental-control-store.js', () => ({
|
|
34
|
-
isToolBlocked: () => false,
|
|
35
|
-
}));
|
|
36
|
-
|
|
37
32
|
// Mock guardian control-plane policy — not targeting control-plane by default
|
|
38
33
|
mock.module('../tools/guardian-control-plane-policy.js', () => ({
|
|
39
34
|
enforceGuardianOnlyPolicy: () => ({ denied: false }),
|
|
@@ -347,4 +342,98 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
|
|
|
347
342
|
|
|
348
343
|
expect(result.allowed).toBe(false);
|
|
349
344
|
});
|
|
345
|
+
|
|
346
|
+
test('non-voice channel denial is instant (no retry polling)', async () => {
|
|
347
|
+
const toolName = 'bash';
|
|
348
|
+
const input = { command: 'rm -rf /' };
|
|
349
|
+
|
|
350
|
+
// executionChannel defaults to undefined (non-voice)
|
|
351
|
+
const context = makeContext({
|
|
352
|
+
guardianActorRole: 'non-guardian',
|
|
353
|
+
executionChannel: 'telegram',
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const start = Date.now();
|
|
357
|
+
const result = await handler.checkPreExecutionGates(
|
|
358
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
359
|
+
);
|
|
360
|
+
const elapsed = Date.now() - start;
|
|
361
|
+
|
|
362
|
+
expect(result.allowed).toBe(false);
|
|
363
|
+
if (result.allowed) return;
|
|
364
|
+
expect(result.result.content).toContain('guardian approval');
|
|
365
|
+
// Non-voice denials should be nearly instant — no 10s retry polling
|
|
366
|
+
expect(elapsed).toBeLessThan(500);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('voice channel with delayed grant succeeds via retry polling', async () => {
|
|
370
|
+
const toolName = 'bash';
|
|
371
|
+
const input = { command: 'echo hello' };
|
|
372
|
+
const digest = computeToolApprovalDigest(toolName, input);
|
|
373
|
+
|
|
374
|
+
// Mint the grant after 300ms — the voice retry polling should find it
|
|
375
|
+
setTimeout(() => {
|
|
376
|
+
mintGrantFromDecision(
|
|
377
|
+
mintParams({
|
|
378
|
+
scopeMode: 'tool_signature',
|
|
379
|
+
toolName,
|
|
380
|
+
inputDigest: digest,
|
|
381
|
+
}),
|
|
382
|
+
);
|
|
383
|
+
}, 300);
|
|
384
|
+
|
|
385
|
+
const context = makeContext({
|
|
386
|
+
guardianActorRole: 'non-guardian',
|
|
387
|
+
executionChannel: 'voice',
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const start = Date.now();
|
|
391
|
+
const result = await handler.checkPreExecutionGates(
|
|
392
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
393
|
+
);
|
|
394
|
+
const elapsed = Date.now() - start;
|
|
395
|
+
|
|
396
|
+
expect(result.allowed).toBe(true);
|
|
397
|
+
// Should have taken at least ~300ms (the minting delay) but not the full 10s
|
|
398
|
+
expect(elapsed).toBeGreaterThanOrEqual(250);
|
|
399
|
+
expect(elapsed).toBeLessThan(5_000);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('voice channel abort returns Cancelled instead of guardian_approval_required', async () => {
|
|
403
|
+
const toolName = 'bash';
|
|
404
|
+
const input = { command: 'deploy --force' };
|
|
405
|
+
|
|
406
|
+
const controller = new AbortController();
|
|
407
|
+
// Abort after 200ms to simulate voice barge-in
|
|
408
|
+
setTimeout(() => controller.abort(), 200);
|
|
409
|
+
|
|
410
|
+
const context = makeContext({
|
|
411
|
+
guardianActorRole: 'non-guardian',
|
|
412
|
+
executionChannel: 'voice',
|
|
413
|
+
signal: controller.signal,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const start = Date.now();
|
|
417
|
+
const result = await handler.checkPreExecutionGates(
|
|
418
|
+
toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
|
|
419
|
+
);
|
|
420
|
+
const elapsed = Date.now() - start;
|
|
421
|
+
|
|
422
|
+
expect(result.allowed).toBe(false);
|
|
423
|
+
if (result.allowed) return;
|
|
424
|
+
// Should return 'Cancelled', not a guardian_approval_required message
|
|
425
|
+
expect(result.result.content).toBe('Cancelled');
|
|
426
|
+
expect(result.result.isError).toBe(true);
|
|
427
|
+
// Should exit promptly after the abort signal, not wait full 10s
|
|
428
|
+
expect(elapsed).toBeLessThan(2_000);
|
|
429
|
+
|
|
430
|
+
// The lifecycle event should be an error with 'Cancelled', not permission_denied
|
|
431
|
+
const errorEvents = events.filter((e) => e.type === 'error');
|
|
432
|
+
expect(errorEvents.length).toBeGreaterThanOrEqual(1);
|
|
433
|
+
const lastError = errorEvents[errorEvents.length - 1];
|
|
434
|
+
if (lastError.type === 'error') {
|
|
435
|
+
expect(lastError.errorMessage).toBe('Cancelled');
|
|
436
|
+
expect(lastError.isExpected).toBe(true);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
350
439
|
});
|
|
@@ -1844,7 +1844,7 @@ describe('buildSanitizedEnv — baseline: credential exclusion', () => {
|
|
|
1844
1844
|
'PATH', 'HOME', 'TERM', 'LANG', 'EDITOR', 'SHELL', 'USER',
|
|
1845
1845
|
'TMPDIR', 'LC_ALL', 'LC_CTYPE', 'XDG_RUNTIME_DIR', 'DISPLAY',
|
|
1846
1846
|
'COLORTERM', 'TERM_PROGRAM', 'SSH_AUTH_SOCK', 'SSH_AGENT_PID',
|
|
1847
|
-
'GPG_TTY', 'GNUPGHOME',
|
|
1847
|
+
'GPG_TTY', 'GNUPGHOME', 'INTERNAL_GATEWAY_BASE_URL', 'GATEWAY_BASE_URL',
|
|
1848
1848
|
];
|
|
1849
1849
|
|
|
1850
1850
|
const env = buildSanitizedEnv();
|
package/src/cli/mcp.ts
CHANGED
|
@@ -87,6 +87,7 @@ export function registerMcpCommand(program: Command): void {
|
|
|
87
87
|
|
|
88
88
|
if (servers[name]) {
|
|
89
89
|
log.error(`MCP server "${name}" already exists. Remove it first with: vellum mcp remove ${name}`);
|
|
90
|
+
process.exitCode = 1;
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
92
93
|
|
|
@@ -95,6 +96,7 @@ export function registerMcpCommand(program: Command): void {
|
|
|
95
96
|
case 'stdio':
|
|
96
97
|
if (!opts.command) {
|
|
97
98
|
log.error('--command is required for stdio transport');
|
|
99
|
+
process.exitCode = 1;
|
|
98
100
|
return;
|
|
99
101
|
}
|
|
100
102
|
transport = { type: 'stdio', command: opts.command, args: opts.args ?? [] };
|
|
@@ -103,17 +105,20 @@ export function registerMcpCommand(program: Command): void {
|
|
|
103
105
|
case 'streamable-http':
|
|
104
106
|
if (!opts.url) {
|
|
105
107
|
log.error(`--url is required for ${opts.transportType} transport`);
|
|
108
|
+
process.exitCode = 1;
|
|
106
109
|
return;
|
|
107
110
|
}
|
|
108
111
|
transport = { type: opts.transportType, url: opts.url };
|
|
109
112
|
break;
|
|
110
113
|
default:
|
|
111
114
|
log.error(`Unknown transport type: ${opts.transportType}. Must be stdio, sse, or streamable-http`);
|
|
115
|
+
process.exitCode = 1;
|
|
112
116
|
return;
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
if (!['low', 'medium', 'high'].includes(opts.risk)) {
|
|
116
120
|
log.error(`Invalid risk level: ${opts.risk}. Must be low, medium, or high`);
|
|
121
|
+
process.exitCode = 1;
|
|
117
122
|
return;
|
|
118
123
|
}
|
|
119
124
|
|
|
@@ -127,4 +132,24 @@ export function registerMcpCommand(program: Command): void {
|
|
|
127
132
|
log.info(`Added MCP server "${name}" (${opts.transportType})`);
|
|
128
133
|
log.info('Restart the daemon for changes to take effect: vellum daemon restart');
|
|
129
134
|
});
|
|
135
|
+
|
|
136
|
+
mcp
|
|
137
|
+
.command('remove <name>')
|
|
138
|
+
.description('Remove an MCP server configuration')
|
|
139
|
+
.action((name: string) => {
|
|
140
|
+
const raw = loadRawConfig();
|
|
141
|
+
const mcpConfig = raw.mcp as Record<string, unknown> | undefined;
|
|
142
|
+
const servers = mcpConfig?.servers as Record<string, unknown> | undefined;
|
|
143
|
+
|
|
144
|
+
if (!servers || !servers[name]) {
|
|
145
|
+
log.error(`MCP server "${name}" not found.`);
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
delete servers[name];
|
|
151
|
+
saveRawConfig(raw);
|
|
152
|
+
log.info(`Removed MCP server "${name}".`);
|
|
153
|
+
log.info('Restart the daemon for changes to take effect: vellum daemon restart');
|
|
154
|
+
});
|
|
130
155
|
}
|