@vellumai/assistant 0.3.21 → 0.3.23
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__/host-shell-tool.test.ts +25 -0
- package/src/__tests__/mcp-cli.test.ts +258 -0
- package/src/__tests__/terminal-tools.test.ts +19 -1
- package/src/__tests__/tool-executor.test.ts +1 -1
- package/src/cli/mcp.ts +79 -2
- 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/feature-flag-registry.json +8 -0
- package/src/config/schema.ts +10 -10
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +7 -7
- 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/lifecycle.ts +14 -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/tools/host-terminal/host-shell.ts +4 -29
- package/src/tools/swarm/delegate.ts +3 -0
- package/src/tools/terminal/safe-env.ts +9 -0
- package/src/tools/tool-manifest.ts +33 -88
package/package.json
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
const CLI = join(import.meta.dir, '..', 'index.ts');
|
|
9
|
+
|
|
10
|
+
let testDataDir: string;
|
|
11
|
+
let configPath: string;
|
|
12
|
+
|
|
13
|
+
function runMcp(subcommand: string, args: string[] = []): { stdout: string; stderr: string; exitCode: number } {
|
|
14
|
+
const result = spawnSync('bun', ['run', CLI, 'mcp', subcommand, ...args], {
|
|
15
|
+
encoding: 'utf-8',
|
|
16
|
+
timeout: 10_000,
|
|
17
|
+
env: { ...process.env, BASE_DATA_DIR: testDataDir },
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
stdout: (result.stdout ?? '').toString(),
|
|
21
|
+
stderr: (result.stderr ?? '').toString(),
|
|
22
|
+
exitCode: result.status ?? 1,
|
|
23
|
+
};
|
|
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
|
+
|
|
34
|
+
function writeConfig(config: Record<string, unknown>): void {
|
|
35
|
+
writeFileSync(configPath, JSON.stringify(config), 'utf-8');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readConfig(): Record<string, unknown> {
|
|
39
|
+
return JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('vellum mcp list', () => {
|
|
43
|
+
beforeAll(() => {
|
|
44
|
+
testDataDir = join(tmpdir(), `vellum-mcp-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
45
|
+
const workspaceDir = join(testDataDir, '.vellum', 'workspace');
|
|
46
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
47
|
+
configPath = join(workspaceDir, 'config.json');
|
|
48
|
+
writeConfig({});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
rmSync(testDataDir, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
writeConfig({});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('shows message when no MCP servers configured', () => {
|
|
60
|
+
const { stdout, exitCode } = runMcpList();
|
|
61
|
+
expect(exitCode).toBe(0);
|
|
62
|
+
expect(stdout).toContain('No MCP servers configured');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('lists configured servers', () => {
|
|
66
|
+
writeConfig({
|
|
67
|
+
mcp: {
|
|
68
|
+
servers: {
|
|
69
|
+
'test-server': {
|
|
70
|
+
transport: { type: 'streamable-http', url: 'https://example.com/mcp' },
|
|
71
|
+
enabled: true,
|
|
72
|
+
defaultRiskLevel: 'medium',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const { stdout, exitCode } = runMcpList();
|
|
79
|
+
expect(exitCode).toBe(0);
|
|
80
|
+
expect(stdout).toContain('1 MCP server(s) configured');
|
|
81
|
+
expect(stdout).toContain('test-server');
|
|
82
|
+
expect(stdout).toContain('streamable-http');
|
|
83
|
+
expect(stdout).toContain('https://example.com/mcp');
|
|
84
|
+
expect(stdout).toContain('medium');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('shows disabled status', () => {
|
|
88
|
+
writeConfig({
|
|
89
|
+
mcp: {
|
|
90
|
+
servers: {
|
|
91
|
+
'disabled-server': {
|
|
92
|
+
transport: { type: 'sse', url: 'https://example.com/sse' },
|
|
93
|
+
enabled: false,
|
|
94
|
+
defaultRiskLevel: 'high',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const { stdout, exitCode } = runMcpList();
|
|
101
|
+
expect(exitCode).toBe(0);
|
|
102
|
+
expect(stdout).toContain('disabled');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('shows stdio command info', () => {
|
|
106
|
+
writeConfig({
|
|
107
|
+
mcp: {
|
|
108
|
+
servers: {
|
|
109
|
+
'stdio-server': {
|
|
110
|
+
transport: { type: 'stdio', command: 'npx', args: ['-y', 'some-mcp-server'] },
|
|
111
|
+
enabled: true,
|
|
112
|
+
defaultRiskLevel: 'low',
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const { stdout, exitCode } = runMcpList();
|
|
119
|
+
expect(exitCode).toBe(0);
|
|
120
|
+
expect(stdout).toContain('stdio-server');
|
|
121
|
+
expect(stdout).toContain('stdio');
|
|
122
|
+
expect(stdout).toContain('npx -y some-mcp-server');
|
|
123
|
+
expect(stdout).toContain('low');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('--json outputs valid JSON', () => {
|
|
127
|
+
writeConfig({
|
|
128
|
+
mcp: {
|
|
129
|
+
servers: {
|
|
130
|
+
'json-server': {
|
|
131
|
+
transport: { type: 'streamable-http', url: 'https://example.com/mcp' },
|
|
132
|
+
enabled: true,
|
|
133
|
+
defaultRiskLevel: 'high',
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const { stdout, exitCode } = runMcpList(['--json']);
|
|
140
|
+
expect(exitCode).toBe(0);
|
|
141
|
+
const parsed = JSON.parse(stdout);
|
|
142
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
143
|
+
expect(parsed).toHaveLength(1);
|
|
144
|
+
expect(parsed[0].id).toBe('json-server');
|
|
145
|
+
expect(parsed[0].transport.url).toBe('https://example.com/mcp');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('--json outputs empty array when no servers', () => {
|
|
149
|
+
const { stdout, exitCode } = runMcpList(['--json']);
|
|
150
|
+
expect(exitCode).toBe(0);
|
|
151
|
+
const parsed = JSON.parse(stdout);
|
|
152
|
+
expect(parsed).toEqual([]);
|
|
153
|
+
});
|
|
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
|
+
});
|
|
@@ -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
|
-
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
2
|
|
|
3
|
-
import { loadRawConfig } from '../config/loader.js';
|
|
3
|
+
import { loadRawConfig, saveRawConfig } from '../config/loader.js';
|
|
4
4
|
import type { McpConfig, McpServerConfig } from '../config/mcp-schema.js';
|
|
5
5
|
import { getCliLogger } from '../util/logger.js';
|
|
6
6
|
|
|
@@ -29,13 +29,19 @@ export function registerMcpCommand(program: Command): void {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
if (opts.json) {
|
|
32
|
-
const result = entries
|
|
32
|
+
const result = entries
|
|
33
|
+
.filter(([, config]) => config && typeof config === 'object')
|
|
34
|
+
.map(([id, config]) => ({ id, ...config }));
|
|
33
35
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
34
36
|
return;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
log.info(`${entries.length} MCP server(s) configured:\n`);
|
|
38
40
|
for (const [id, cfg] of entries) {
|
|
41
|
+
if (!cfg || typeof cfg !== 'object') {
|
|
42
|
+
log.info(` ${id} (invalid config — skipped)\n`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
39
45
|
const enabled = cfg.enabled !== false;
|
|
40
46
|
const transport = cfg.transport;
|
|
41
47
|
const risk = cfg.defaultRiskLevel ?? 'high';
|
|
@@ -55,4 +61,75 @@ export function registerMcpCommand(program: Command): void {
|
|
|
55
61
|
log.info('');
|
|
56
62
|
}
|
|
57
63
|
});
|
|
64
|
+
|
|
65
|
+
mcp
|
|
66
|
+
.command('add <name>')
|
|
67
|
+
.description('Add an MCP server configuration')
|
|
68
|
+
.requiredOption('-t, --transport-type <type>', 'Transport type: stdio, sse, or streamable-http')
|
|
69
|
+
.option('-u, --url <url>', 'Server URL (for sse/streamable-http)')
|
|
70
|
+
.option('-c, --command <cmd>', 'Command to run (for stdio)')
|
|
71
|
+
.option('-a, --args <args...>', 'Command arguments (for stdio)')
|
|
72
|
+
.option('-r, --risk <level>', 'Default risk level: low, medium, or high', 'high')
|
|
73
|
+
.option('--disabled', 'Add as disabled')
|
|
74
|
+
.action((name: string, opts: {
|
|
75
|
+
transportType: string;
|
|
76
|
+
url?: string;
|
|
77
|
+
command?: string;
|
|
78
|
+
args?: string[];
|
|
79
|
+
risk: string;
|
|
80
|
+
disabled?: boolean;
|
|
81
|
+
}) => {
|
|
82
|
+
const raw = loadRawConfig();
|
|
83
|
+
if (!raw.mcp) raw.mcp = { servers: {} };
|
|
84
|
+
const mcpConfig = raw.mcp as Record<string, unknown>;
|
|
85
|
+
if (!mcpConfig.servers) mcpConfig.servers = {};
|
|
86
|
+
const servers = mcpConfig.servers as Record<string, unknown>;
|
|
87
|
+
|
|
88
|
+
if (servers[name]) {
|
|
89
|
+
log.error(`MCP server "${name}" already exists. Remove it first with: vellum mcp remove ${name}`);
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let transport: Record<string, unknown>;
|
|
95
|
+
switch (opts.transportType) {
|
|
96
|
+
case 'stdio':
|
|
97
|
+
if (!opts.command) {
|
|
98
|
+
log.error('--command is required for stdio transport');
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
transport = { type: 'stdio', command: opts.command, args: opts.args ?? [] };
|
|
103
|
+
break;
|
|
104
|
+
case 'sse':
|
|
105
|
+
case 'streamable-http':
|
|
106
|
+
if (!opts.url) {
|
|
107
|
+
log.error(`--url is required for ${opts.transportType} transport`);
|
|
108
|
+
process.exitCode = 1;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
transport = { type: opts.transportType, url: opts.url };
|
|
112
|
+
break;
|
|
113
|
+
default:
|
|
114
|
+
log.error(`Unknown transport type: ${opts.transportType}. Must be stdio, sse, or streamable-http`);
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!['low', 'medium', 'high'].includes(opts.risk)) {
|
|
120
|
+
log.error(`Invalid risk level: ${opts.risk}. Must be low, medium, or high`);
|
|
121
|
+
process.exitCode = 1;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
servers[name] = {
|
|
126
|
+
transport,
|
|
127
|
+
enabled: !opts.disabled,
|
|
128
|
+
defaultRiskLevel: opts.risk,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
saveRawConfig(raw);
|
|
132
|
+
log.info(`Added MCP server "${name}" (${opts.transportType})`);
|
|
133
|
+
log.info('Restart the daemon for changes to take effect: vellum daemon restart');
|
|
134
|
+
});
|
|
58
135
|
}
|
|
@@ -621,7 +621,7 @@ Run the **public-ingress** skill to set up ngrok and configure `ingress.publicBa
|
|
|
621
621
|
|
|
622
622
|
### Call connects but no audio / one-way audio
|
|
623
623
|
- The ConversationRelay WebSocket may not be connecting. Check that `ingress.publicBaseUrl` is correct and the tunnel is active
|
|
624
|
-
- Verify the gateway is running
|
|
624
|
+
- Verify the gateway is running at `$GATEWAY_BASE_URL`
|
|
625
625
|
|
|
626
626
|
### "Number not eligible for caller identity"
|
|
627
627
|
The user's phone number is not owned by or verified with the Twilio account. The number must be either purchased through Twilio or added as a verified caller ID at https://console.twilio.com/us1/develop/phone-numbers/manage/verified.
|
|
@@ -24,7 +24,7 @@ First, check whether ingress is already configured:
|
|
|
24
24
|
vellum config get ingress.publicBaseUrl
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Use the injected `INTERNAL_GATEWAY_BASE_URL` as the local gateway target before proceeding.
|
|
28
28
|
|
|
29
29
|
If `ingress.publicBaseUrl` is already set and the tunnel is running (check via `curl -s http://127.0.0.1:4040/api/tunnels`), tell the user the current status and ask if they want to reconfigure or if this is sufficient.
|
|
30
30
|
|
|
@@ -109,10 +109,10 @@ pkill -f ngrok || true
|
|
|
109
109
|
sleep 1
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
Start ngrok in the background, tunneling to the local gateway:
|
|
112
|
+
Start ngrok in the background, tunneling to the local gateway target:
|
|
113
113
|
|
|
114
114
|
```bash
|
|
115
|
-
nohup ngrok http
|
|
115
|
+
nohup ngrok http "$INTERNAL_GATEWAY_BASE_URL" --log=stdout > /tmp/ngrok.log 2>&1 &
|
|
116
116
|
echo $! > /tmp/ngrok.pid
|
|
117
117
|
```
|
|
118
118
|
|
|
@@ -169,14 +169,14 @@ vellum config get ingress.enabled
|
|
|
169
169
|
Summarize the setup:
|
|
170
170
|
|
|
171
171
|
- **Public URL:** `<the-url>` (this is your `ingress.publicBaseUrl`)
|
|
172
|
-
- **Local gateway:** `
|
|
172
|
+
- **Local gateway target:** `$INTERNAL_GATEWAY_BASE_URL`
|
|
173
173
|
- **ngrok dashboard:** http://127.0.0.1:4040
|
|
174
174
|
|
|
175
175
|
Provide useful follow-up commands:
|
|
176
176
|
|
|
177
177
|
- **Check tunnel status:** `curl -s http://127.0.0.1:4040/api/tunnels | python3 -c "import sys,json; [print(t['public_url']) for t in json.load(sys.stdin)['tunnels']]"`
|
|
178
178
|
- **View ngrok logs:** `cat /tmp/ngrok.log`
|
|
179
|
-
- **Restart tunnel:** `pkill -f ngrok; sleep 1; nohup ngrok http
|
|
179
|
+
- **Restart tunnel:** `pkill -f ngrok; sleep 1; nohup ngrok http "$INTERNAL_GATEWAY_BASE_URL" --log=stdout > /tmp/ngrok.log 2>&1 &`
|
|
180
180
|
- **Stop tunnel:** `pkill -f ngrok`
|
|
181
181
|
- **Rotate URL:** Stop and restart ngrok (free tier assigns a new URL each time; update `ingress.publicBaseUrl` afterward)
|
|
182
182
|
|
|
@@ -194,7 +194,7 @@ Sign in to https://dashboard.ngrok.com, copy a fresh token from the "Your Authto
|
|
|
194
194
|
The ngrok process may not be running. Check with `ps aux | grep ngrok`. If not running, start it per Step 4. If running but 4040 is unresponsive, check `/tmp/ngrok.log` for errors.
|
|
195
195
|
|
|
196
196
|
### Gateway not reachable on local target
|
|
197
|
-
Ensure the Vellum gateway is running on
|
|
197
|
+
Ensure the Vellum gateway is running on `$INTERNAL_GATEWAY_BASE_URL`. Check with `curl -s "$INTERNAL_GATEWAY_BASE_URL/healthz"`. If not running, start the assistant daemon first.
|
|
198
198
|
|
|
199
199
|
### "Too many connections" or tunnel limit errors
|
|
200
200
|
ngrok's free tier allows one tunnel at a time. Stop any other ngrok tunnels before starting a new one.
|
|
@@ -41,6 +41,14 @@
|
|
|
41
41
|
"description": "Enable X (Twitter) skill section in the system prompt",
|
|
42
42
|
"defaultEnabled": true
|
|
43
43
|
},
|
|
44
|
+
{
|
|
45
|
+
"id": "collect-usage-data",
|
|
46
|
+
"scope": "assistant",
|
|
47
|
+
"key": "feature_flags.collect-usage-data.enabled",
|
|
48
|
+
"label": "Collect usage data",
|
|
49
|
+
"description": "Send crash reports and error diagnostics to help improve the app",
|
|
50
|
+
"defaultEnabled": true
|
|
51
|
+
},
|
|
44
52
|
{
|
|
45
53
|
"id": "user-hosted-enabled",
|
|
46
54
|
"scope": "macos",
|
package/src/config/schema.ts
CHANGED
|
@@ -68,6 +68,16 @@ export {
|
|
|
68
68
|
TimeoutConfigSchema,
|
|
69
69
|
UiConfigSchema,
|
|
70
70
|
} from './core-schema.js';
|
|
71
|
+
export type {
|
|
72
|
+
McpConfig,
|
|
73
|
+
McpServerConfig,
|
|
74
|
+
McpTransport,
|
|
75
|
+
} from './mcp-schema.js';
|
|
76
|
+
export {
|
|
77
|
+
McpConfigSchema,
|
|
78
|
+
McpServerConfigSchema,
|
|
79
|
+
McpTransportSchema,
|
|
80
|
+
} from './mcp-schema.js';
|
|
71
81
|
export type {
|
|
72
82
|
MemoryCleanupConfig,
|
|
73
83
|
MemoryConfig,
|
|
@@ -114,16 +124,6 @@ export type {
|
|
|
114
124
|
export {
|
|
115
125
|
SandboxConfigSchema,
|
|
116
126
|
} from './sandbox-schema.js';
|
|
117
|
-
export type {
|
|
118
|
-
McpConfig,
|
|
119
|
-
McpServerConfig,
|
|
120
|
-
McpTransport,
|
|
121
|
-
} from './mcp-schema.js';
|
|
122
|
-
export {
|
|
123
|
-
McpConfigSchema,
|
|
124
|
-
McpServerConfigSchema,
|
|
125
|
-
McpTransportSchema,
|
|
126
|
-
} from './mcp-schema.js';
|
|
127
127
|
export type {
|
|
128
128
|
RemotePolicyConfig,
|
|
129
129
|
RemoteProviderConfig,
|
|
@@ -9,10 +9,10 @@ You are helping your user set up guardian verification for a messaging channel (
|
|
|
9
9
|
|
|
10
10
|
## Prerequisites
|
|
11
11
|
|
|
12
|
-
-
|
|
12
|
+
- Use the injected `INTERNAL_GATEWAY_BASE_URL` for gateway API calls.
|
|
13
13
|
- Never call the daemon runtime port directly; always call the gateway URL.
|
|
14
14
|
- The bearer token is stored at `~/.vellum/http-token`. Read it with: `TOKEN=$(cat ~/.vellum/http-token)`.
|
|
15
|
-
- Run shell commands for this skill with `host_bash` (not sandbox `bash`) so host auth/token and
|
|
15
|
+
- Run shell commands for this skill with `host_bash` (not sandbox `bash`) so host auth/token and gateway routing are reliable.
|
|
16
16
|
- Keep narration minimal: execute required calls first, then provide a concise status update. Do not narrate internal install/check/load chatter unless something fails.
|
|
17
17
|
|
|
18
18
|
## Step 1: Confirm Channel
|
|
@@ -40,7 +40,7 @@ Execute the outbound start request:
|
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
42
|
TOKEN=$(cat ~/.vellum/http-token)
|
|
43
|
-
curl -s -X POST
|
|
43
|
+
curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/outbound/start" \
|
|
44
44
|
-H "Content-Type: application/json" \
|
|
45
45
|
-H "Authorization: Bearer $TOKEN" \
|
|
46
46
|
-d '{"channel": "<channel>", "destination": "<destination>"}'
|
|
@@ -78,7 +78,7 @@ If the user says they did not receive the code or asks to resend:
|
|
|
78
78
|
|
|
79
79
|
```bash
|
|
80
80
|
TOKEN=$(cat ~/.vellum/http-token)
|
|
81
|
-
curl -s -X POST
|
|
81
|
+
curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/outbound/resend" \
|
|
82
82
|
-H "Content-Type: application/json" \
|
|
83
83
|
-H "Authorization: Bearer $TOKEN" \
|
|
84
84
|
-d '{"channel": "<channel>"}'
|
|
@@ -108,7 +108,7 @@ If the user wants to cancel the verification:
|
|
|
108
108
|
|
|
109
109
|
```bash
|
|
110
110
|
TOKEN=$(cat ~/.vellum/http-token)
|
|
111
|
-
curl -s -X POST
|
|
111
|
+
curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/outbound/cancel" \
|
|
112
112
|
-H "Content-Type: application/json" \
|
|
113
113
|
-H "Authorization: Bearer $TOKEN" \
|
|
114
114
|
-d '{"channel": "<channel>"}'
|
|
@@ -127,7 +127,7 @@ For **voice** verification only: after telling the user their code and instructi
|
|
|
127
127
|
|
|
128
128
|
```bash
|
|
129
129
|
TOKEN=$(cat ~/.vellum/http-token)
|
|
130
|
-
curl -s
|
|
130
|
+
curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/status?channel=voice" \
|
|
131
131
|
-H "Authorization: Bearer $TOKEN"
|
|
132
132
|
```
|
|
133
133
|
|
|
@@ -154,7 +154,7 @@ After the user reports entering the code, verify the binding was created:
|
|
|
154
154
|
|
|
155
155
|
```bash
|
|
156
156
|
TOKEN=$(cat ~/.vellum/http-token)
|
|
157
|
-
curl -s
|
|
157
|
+
curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/status?channel=<channel>" \
|
|
158
158
|
-H "Authorization: Bearer $TOKEN"
|
|
159
159
|
```
|
|
160
160
|
|
|
@@ -12,7 +12,7 @@ You are helping your user connect a Telegram bot to the Vellum Assistant gateway
|
|
|
12
12
|
|
|
13
13
|
Before beginning setup, verify these conditions are met:
|
|
14
14
|
|
|
15
|
-
1. **Gateway API base URL is set and reachable:** Use the
|
|
15
|
+
1. **Gateway API base URL is set and reachable:** Use the injected `INTERNAL_GATEWAY_BASE_URL`, then run `curl -sf "$INTERNAL_GATEWAY_BASE_URL/healthz"` — it should return gateway health JSON (for example `{"status":"ok"}`). If it fails, tell the user to start the daemon with `vellum daemon start` and wait for it to become healthy before continuing.
|
|
16
16
|
2. **Public ingress URL is configured.** The gateway webhook URL is derived from `${ingress.publicBaseUrl}/webhooks/telegram`. If the ingress URL is not configured, load and execute the **public-ingress** skill first (`skill_load` with `skill: "public-ingress"`) to set up an ngrok tunnel and persist the URL before continuing.
|
|
17
17
|
3. **Use gateway control-plane routes only.** Telegram setup/config actions in this skill must call gateway endpoints under `/v1/integrations/telegram/*` — never call the daemon runtime port directly.
|
|
18
18
|
|
|
@@ -37,7 +37,7 @@ The token is collected securely via a system-level prompt and is never exposed i
|
|
|
37
37
|
After the token is collected, call the composite setup endpoint which validates the token, stores credentials, and registers bot commands in a single request:
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
|
-
curl -sf -X POST "$
|
|
40
|
+
curl -sf -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/telegram/setup" \
|
|
41
41
|
-H "Authorization: Bearer $(cat ~/.vellum/http-token)" \
|
|
42
42
|
-H "Content-Type: application/json" \
|
|
43
43
|
-d '{}'
|
|
@@ -98,7 +98,7 @@ If routing is misconfigured, inbound Telegram messages will be rejected and the
|
|
|
98
98
|
Before reporting success, confirm the guardian binding was actually created. Check the guardian binding status:
|
|
99
99
|
|
|
100
100
|
```bash
|
|
101
|
-
curl -sf "$
|
|
101
|
+
curl -sf "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/status?channel=telegram" \
|
|
102
102
|
-H "Authorization: Bearer $(cat ~/.vellum/http-token)"
|
|
103
103
|
```
|
|
104
104
|
|
|
@@ -117,7 +117,7 @@ Summarize what was done:
|
|
|
117
117
|
- Guardian identity: {verified | not configured}
|
|
118
118
|
- Guardian verification status: {verified via outbound flow | skipped}
|
|
119
119
|
- Routing configuration validated
|
|
120
|
-
- To re-check guardian status later, use: `curl -sf "$
|
|
120
|
+
- To re-check guardian status later, use: `curl -sf "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/status?channel=telegram" -H "Authorization: Bearer $(cat ~/.vellum/http-token)"`
|
|
121
121
|
|
|
122
122
|
The gateway automatically detects credentials from the vault, reconciles the Telegram webhook registration, and begins accepting Telegram webhooks shortly. In single-assistant mode, routing is automatically configured — no manual environment variable configuration or webhook registration is needed. If the webhook secret changes later, the gateway's credential watcher will automatically re-register the webhook. If the ingress URL changes (e.g., tunnel restart), the assistant daemon triggers an immediate internal reconcile so the webhook re-registers automatically without a gateway restart.
|
|
123
123
|
|
|
@@ -9,7 +9,7 @@ You are helping your user manage trusted contacts and invite links for the Vellu
|
|
|
9
9
|
|
|
10
10
|
## Prerequisites
|
|
11
11
|
|
|
12
|
-
-
|
|
12
|
+
- Use the injected `INTERNAL_GATEWAY_BASE_URL` for gateway API calls.
|
|
13
13
|
- Use gateway control-plane routes only: this skill calls `/v1/ingress/*` and `/v1/integrations/telegram/config` on the gateway, never the daemon runtime port directly.
|
|
14
14
|
- The bearer token is stored at `~/.vellum/http-token`. Read it with: `TOKEN=$(cat ~/.vellum/http-token)`.
|
|
15
15
|
|
|
@@ -29,7 +29,7 @@ Use this to show the user who currently has access, or to look up a specific con
|
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
31
|
TOKEN=$(cat ~/.vellum/http-token)
|
|
32
|
-
curl -s
|
|
32
|
+
curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/members" \
|
|
33
33
|
-H "Authorization: Bearer $TOKEN"
|
|
34
34
|
```
|
|
35
35
|
|
|
@@ -40,7 +40,7 @@ Optional query parameters for filtering:
|
|
|
40
40
|
|
|
41
41
|
Example with filters:
|
|
42
42
|
```bash
|
|
43
|
-
curl -s "
|
|
43
|
+
curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/members?sourceChannel=telegram&status=active" \
|
|
44
44
|
-H "Authorization: Bearer $TOKEN"
|
|
45
45
|
```
|
|
46
46
|
|
|
@@ -65,7 +65,7 @@ Ask the user: *"I'll add [name/identifier] on [channel] as an allowed contact. S
|
|
|
65
65
|
|
|
66
66
|
```bash
|
|
67
67
|
TOKEN=$(cat ~/.vellum/http-token)
|
|
68
|
-
curl -s -X POST
|
|
68
|
+
curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/members" \
|
|
69
69
|
-H "Content-Type: application/json" \
|
|
70
70
|
-H "Authorization: Bearer $TOKEN" \
|
|
71
71
|
-d '{
|
|
@@ -97,7 +97,7 @@ First, list members to find the member's `id`, then revoke:
|
|
|
97
97
|
|
|
98
98
|
```bash
|
|
99
99
|
TOKEN=$(cat ~/.vellum/http-token)
|
|
100
|
-
curl -s -X DELETE "
|
|
100
|
+
curl -s -X DELETE "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/members/<member_id>" \
|
|
101
101
|
-H "Authorization: Bearer $TOKEN" \
|
|
102
102
|
-H "Content-Type: application/json" \
|
|
103
103
|
-d '{"reason": "<optional reason>"}'
|
|
@@ -113,7 +113,7 @@ Ask the user: *"I'll block [name/identifier]. They will be permanently denied fr
|
|
|
113
113
|
|
|
114
114
|
```bash
|
|
115
115
|
TOKEN=$(cat ~/.vellum/http-token)
|
|
116
|
-
curl -s -X POST "
|
|
116
|
+
curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/members/<member_id>/block" \
|
|
117
117
|
-H "Content-Type: application/json" \
|
|
118
118
|
-H "Authorization: Bearer $TOKEN" \
|
|
119
119
|
-d '{"reason": "<optional reason>"}'
|
|
@@ -125,7 +125,7 @@ Use this when the guardian wants to invite someone to message the assistant on T
|
|
|
125
125
|
|
|
126
126
|
```bash
|
|
127
127
|
TOKEN=$(cat ~/.vellum/http-token)
|
|
128
|
-
curl -s -X POST
|
|
128
|
+
curl -s -X POST "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites" \
|
|
129
129
|
-H "Content-Type: application/json" \
|
|
130
130
|
-H "Authorization: Bearer $TOKEN" \
|
|
131
131
|
-d '{
|
|
@@ -146,7 +146,7 @@ The response contains `{ ok: true, invite: { id, token, ... } }`. The `token` fi
|
|
|
146
146
|
|
|
147
147
|
```bash
|
|
148
148
|
TOKEN=$(cat ~/.vellum/http-token)
|
|
149
|
-
curl -s
|
|
149
|
+
curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/telegram/config" \
|
|
150
150
|
-H "Authorization: Bearer $TOKEN"
|
|
151
151
|
```
|
|
152
152
|
|
|
@@ -174,7 +174,7 @@ Use this to show the guardian their active (and optionally all) invite links.
|
|
|
174
174
|
|
|
175
175
|
```bash
|
|
176
176
|
TOKEN=$(cat ~/.vellum/http-token)
|
|
177
|
-
curl -s "
|
|
177
|
+
curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites?sourceChannel=telegram" \
|
|
178
178
|
-H "Authorization: Bearer $TOKEN"
|
|
179
179
|
```
|
|
180
180
|
|
|
@@ -205,7 +205,7 @@ First, list invites to find the invite's `id`, then revoke:
|
|
|
205
205
|
|
|
206
206
|
```bash
|
|
207
207
|
TOKEN=$(cat ~/.vellum/http-token)
|
|
208
|
-
curl -s -X DELETE "
|
|
208
|
+
curl -s -X DELETE "$INTERNAL_GATEWAY_BASE_URL/v1/ingress/invites/<invite_id>" \
|
|
209
209
|
-H "Authorization: Bearer $TOKEN"
|
|
210
210
|
```
|
|
211
211
|
|
|
@@ -228,10 +228,10 @@ To re-check guardian status later, query the channel(s) that were verified:
|
|
|
228
228
|
```bash
|
|
229
229
|
TOKEN=$(cat ~/.vellum/http-token)
|
|
230
230
|
# Check SMS guardian status
|
|
231
|
-
curl -s
|
|
231
|
+
curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/status?channel=sms" \
|
|
232
232
|
-H "Authorization: Bearer $TOKEN"
|
|
233
233
|
# Check voice guardian status
|
|
234
|
-
curl -s
|
|
234
|
+
curl -s "$INTERNAL_GATEWAY_BASE_URL/v1/integrations/guardian/status?channel=voice" \
|
|
235
235
|
-H "Authorization: Bearer $TOKEN"
|
|
236
236
|
```
|
|
237
237
|
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { reconcileCallsOnStartup } from '../calls/call-recovery.js';
|
|
|
8
8
|
import { setRelayBroadcast } from '../calls/relay-server.js';
|
|
9
9
|
import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js';
|
|
10
10
|
import { setVoiceBridgeDeps } from '../calls/voice-session-bridge.js';
|
|
11
|
+
import { isAssistantFeatureFlagEnabled } from '../config/assistant-feature-flags.js';
|
|
11
12
|
import {
|
|
12
13
|
getQdrantUrlEnv,
|
|
13
14
|
getRuntimeHttpHost,
|
|
@@ -21,8 +22,9 @@ import { syncUpdateBulletinOnStartup } from '../config/update-bulletin.js';
|
|
|
21
22
|
import { HeartbeatService } from '../heartbeat/heartbeat-service.js';
|
|
22
23
|
import { getHookManager } from '../hooks/manager.js';
|
|
23
24
|
import { installTemplates } from '../hooks/templates.js';
|
|
24
|
-
import { initSentry } from '../instrument.js';
|
|
25
|
+
import { closeSentry, initSentry } from '../instrument.js';
|
|
25
26
|
import { initLogfire } from '../logfire.js';
|
|
27
|
+
import { getMcpServerManager } from '../mcp/manager.js';
|
|
26
28
|
import * as attachmentsStore from '../memory/attachments-store.js';
|
|
27
29
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
28
30
|
import { initializeDb } from '../memory/db.js';
|
|
@@ -54,7 +56,6 @@ import { createGuardianActionCopyGenerator, createGuardianFollowUpConversationGe
|
|
|
54
56
|
import { initPairingHandlers } from './handlers/pairing.js';
|
|
55
57
|
import { installCliLaunchers } from './install-cli-launchers.js';
|
|
56
58
|
import type { ServerMessage } from './ipc-protocol.js';
|
|
57
|
-
import { getMcpServerManager } from '../mcp/manager.js';
|
|
58
59
|
import { initializeProvidersAndTools, registerMessagingProviders,registerWatcherProviders } from './providers-setup.js';
|
|
59
60
|
import { seedInterfaceFiles } from './seed-files.js';
|
|
60
61
|
import { DaemonServer } from './server.js';
|
|
@@ -96,7 +97,11 @@ export async function runDaemon(): Promise<void> {
|
|
|
96
97
|
let socketCreated = false;
|
|
97
98
|
|
|
98
99
|
try {
|
|
100
|
+
// Initialize crash reporting eagerly so early startup failures are
|
|
101
|
+
// captured. After config loads we check the opt-out flag and call
|
|
102
|
+
// closeSentry() if the user has disabled it.
|
|
99
103
|
initSentry();
|
|
104
|
+
|
|
100
105
|
await initLogfire();
|
|
101
106
|
|
|
102
107
|
// Migration order matters: first move legacy flat files into the data dir
|
|
@@ -173,6 +178,13 @@ export async function runDaemon(): Promise<void> {
|
|
|
173
178
|
initLogger({ dir: config.logFile.dir, retentionDays: config.logFile.retentionDays });
|
|
174
179
|
}
|
|
175
180
|
|
|
181
|
+
// If the user has opted out of crash reporting, stop Sentry from capturing
|
|
182
|
+
// future events. Early-startup crashes before this point are still captured.
|
|
183
|
+
const collectUsageData = isAssistantFeatureFlagEnabled('feature_flags.collect-usage-data.enabled', config);
|
|
184
|
+
if (!collectUsageData) {
|
|
185
|
+
await closeSentry();
|
|
186
|
+
}
|
|
187
|
+
|
|
176
188
|
await initializeProvidersAndTools(config);
|
|
177
189
|
|
|
178
190
|
// Start the IPC socket BEFORE Qdrant so that clients can connect
|
|
@@ -2,8 +2,8 @@ import * as Sentry from '@sentry/node';
|
|
|
2
2
|
|
|
3
3
|
import type { HeartbeatService } from '../heartbeat/heartbeat-service.js';
|
|
4
4
|
import type { HookManager } from '../hooks/manager.js';
|
|
5
|
-
import { getSqlite, resetDb } from '../memory/db.js';
|
|
6
5
|
import type { McpServerManager } from '../mcp/manager.js';
|
|
6
|
+
import { getSqlite, resetDb } from '../memory/db.js';
|
|
7
7
|
import type { QdrantManager } from '../memory/qdrant-manager.js';
|
|
8
8
|
import type { RuntimeHttpServer } from '../runtime/http-server.js';
|
|
9
9
|
import { browserManager } from '../tools/browser/browser-manager.js';
|
package/src/instrument.ts
CHANGED
|
@@ -32,7 +32,12 @@ function redactObject(obj: unknown): unknown {
|
|
|
32
32
|
return obj;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Call after dotenv has loaded so SENTRY_DSN is available.
|
|
37
|
+
* Always initializes Sentry to capture early startup crashes. If the user
|
|
38
|
+
* later opts out via the "collect-usage-data" feature flag, call closeSentry()
|
|
39
|
+
* after config is loaded to stop future event capturing.
|
|
40
|
+
*/
|
|
36
41
|
export function initSentry(): void {
|
|
37
42
|
Sentry.init({
|
|
38
43
|
dsn: getSentryDsn(),
|
|
@@ -60,3 +65,12 @@ export function initSentry(): void {
|
|
|
60
65
|
},
|
|
61
66
|
});
|
|
62
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Stop capturing future Sentry events. Called after config loads when the
|
|
71
|
+
* user has opted out of crash reporting so that early-startup crashes are
|
|
72
|
+
* still captured but subsequent events are suppressed.
|
|
73
|
+
*/
|
|
74
|
+
export async function closeSentry(): Promise<void> {
|
|
75
|
+
await Sentry.close();
|
|
76
|
+
}
|
package/src/mcp/client.ts
CHANGED
|
@@ -50,7 +50,7 @@ export class McpClient {
|
|
|
50
50
|
} catch (err) {
|
|
51
51
|
// Clean up the transport on failure (e.g., kill spawned stdio process)
|
|
52
52
|
try { await this.client.close(); } catch { /* ignore cleanup errors */ }
|
|
53
|
-
this.transport =
|
|
53
|
+
this.transport = null;
|
|
54
54
|
throw err;
|
|
55
55
|
}
|
|
56
56
|
this.connected = true;
|
|
@@ -85,7 +85,7 @@ export class McpClient {
|
|
|
85
85
|
const isError = result.isError === true;
|
|
86
86
|
|
|
87
87
|
// Handle structuredContent if present
|
|
88
|
-
if (result.structuredContent !== undefined
|
|
88
|
+
if (result.structuredContent !== undefined) {
|
|
89
89
|
return {
|
|
90
90
|
content: JSON.stringify(result.structuredContent),
|
|
91
91
|
isError,
|
|
@@ -96,7 +96,7 @@ export class McpClient {
|
|
|
96
96
|
const textParts: string[] = [];
|
|
97
97
|
if (Array.isArray(result.content)) {
|
|
98
98
|
for (const block of result.content) {
|
|
99
|
-
if (typeof block === 'object' && block !==
|
|
99
|
+
if (typeof block === 'object' && block !== undefined && 'type' in block) {
|
|
100
100
|
if (block.type === 'text' && 'text' in block) {
|
|
101
101
|
textParts.push(String(block.text));
|
|
102
102
|
} else if (block.type === 'resource' && 'resource' in block) {
|
|
@@ -156,7 +156,27 @@ export function createConversation(titleOrOpts?: string | { title?: string; thre
|
|
|
156
156
|
source,
|
|
157
157
|
memoryScopeId,
|
|
158
158
|
};
|
|
159
|
-
|
|
159
|
+
|
|
160
|
+
// Retry on SQLITE_BUSY and SQLITE_IOERR — transient disk I/O errors or WAL
|
|
161
|
+
// contention can cause the first attempt to fail even under normal load.
|
|
162
|
+
const MAX_RETRIES = 3;
|
|
163
|
+
for (let attempt = 0; ; attempt++) {
|
|
164
|
+
try {
|
|
165
|
+
db.insert(conversations).values(conversation).run();
|
|
166
|
+
break;
|
|
167
|
+
} catch (err) {
|
|
168
|
+
const code = (err as { code?: string }).code ?? '';
|
|
169
|
+
if (attempt < MAX_RETRIES && (code.startsWith('SQLITE_BUSY') || code.startsWith('SQLITE_IOERR'))) {
|
|
170
|
+
log.warn({ attempt, conversationId: id, code }, 'createConversation: transient SQLite error, retrying');
|
|
171
|
+
// Synchronous sleep — createConversation is synchronous and the
|
|
172
|
+
// retry window is short (50-150ms), so Bun.sleepSync is appropriate.
|
|
173
|
+
Bun.sleepSync(50 * (attempt + 1));
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
160
180
|
return conversation;
|
|
161
181
|
}
|
|
162
182
|
|
|
@@ -213,7 +233,8 @@ export async function addMessage(conversationId: string, role: string, content:
|
|
|
213
233
|
? metadata.userMessageChannel
|
|
214
234
|
: null;
|
|
215
235
|
// Wrap insert + updatedAt bump in a transaction so they're atomic.
|
|
216
|
-
// Retry on SQLITE_BUSY
|
|
236
|
+
// Retry on SQLITE_BUSY* and SQLITE_IOERR* — covers WAL contention variants
|
|
237
|
+
// (SQLITE_BUSY_SNAPSHOT, SQLITE_BUSY_RECOVERY) and transient disk I/O errors.
|
|
217
238
|
// Timestamp is recomputed each attempt so a late retry doesn't persist a stale updatedAt.
|
|
218
239
|
const MAX_RETRIES = 3;
|
|
219
240
|
let now!: number;
|
|
@@ -243,8 +264,9 @@ export async function addMessage(conversationId: string, role: string, content:
|
|
|
243
264
|
});
|
|
244
265
|
break;
|
|
245
266
|
} catch (err) {
|
|
246
|
-
|
|
247
|
-
|
|
267
|
+
const errCode = (err as { code?: string }).code ?? '';
|
|
268
|
+
if (attempt < MAX_RETRIES && (errCode.startsWith('SQLITE_BUSY') || errCode.startsWith('SQLITE_IOERR'))) {
|
|
269
|
+
log.warn({ attempt, conversationId, code: errCode }, 'addMessage: transient SQLite error, retrying');
|
|
248
270
|
await Bun.sleep(50 * (attempt + 1));
|
|
249
271
|
continue;
|
|
250
272
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DrizzleDb } from '../db-connection.js';
|
|
2
|
+
import { getSqliteFrom } from '../db-connection.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Add indexes, a column, and a unique constraint for schema improvements:
|
|
@@ -15,23 +16,50 @@ export function migrateSchemaIndexesAndColumns(database: DrizzleDb): void {
|
|
|
15
16
|
database.run(/*sql*/ `ALTER TABLE memory_jobs ADD COLUMN started_at INTEGER`);
|
|
16
17
|
} catch { /* already exists */ }
|
|
17
18
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
ORDER BY updated_at DESC
|
|
29
|
-
) AS rn
|
|
30
|
-
FROM notification_deliveries
|
|
31
|
-
)
|
|
32
|
-
WHERE rn = 1
|
|
33
|
-
)
|
|
34
|
-
`);
|
|
19
|
+
// Ensure notification_decision_id column exists on notification_deliveries.
|
|
20
|
+
// Migration 114 (createNotificationTables) should have created this column,
|
|
21
|
+
// but on databases where 114 crashed mid-run the column may be absent. Rather
|
|
22
|
+
// than silently skipping the dedup+index step (leaving the schema incompatible
|
|
23
|
+
// with runtime code that writes notificationDecisionId), we add the column
|
|
24
|
+
// here if it is missing, then proceed unconditionally.
|
|
25
|
+
const raw = getSqliteFrom(database);
|
|
26
|
+
const notifDdl = raw.query(
|
|
27
|
+
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'notification_deliveries'`,
|
|
28
|
+
).get() as { sql: string } | null;
|
|
35
29
|
|
|
36
|
-
|
|
30
|
+
if (notifDdl && !notifDdl.sql.includes('notification_decision_id')) {
|
|
31
|
+
// ADD COLUMN cannot carry NOT NULL without a default in SQLite, so we add
|
|
32
|
+
// it as nullable TEXT. Existing rows get NULL, which is valid until the
|
|
33
|
+
// runtime backfills or replaces them. The unique index below is created
|
|
34
|
+
// with WHERE NOT NULL to tolerate the transition period.
|
|
35
|
+
try {
|
|
36
|
+
database.run(/*sql*/ `ALTER TABLE notification_deliveries ADD COLUMN notification_decision_id TEXT`);
|
|
37
|
+
} catch { /* column was added concurrently — safe to continue */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (notifDdl) {
|
|
41
|
+
// Deduplicate before creating the unique index — the prior schema allowed
|
|
42
|
+
// multiple rows per (notification_decision_id, channel) via the wider
|
|
43
|
+
// (decision_id, channel, destination, attempt) unique index. Keep the
|
|
44
|
+
// row with the latest updated_at for each group.
|
|
45
|
+
try {
|
|
46
|
+
database.run(/*sql*/ `
|
|
47
|
+
DELETE FROM notification_deliveries
|
|
48
|
+
WHERE id NOT IN (
|
|
49
|
+
SELECT id FROM (
|
|
50
|
+
SELECT id, ROW_NUMBER() OVER (
|
|
51
|
+
PARTITION BY notification_decision_id, channel
|
|
52
|
+
ORDER BY updated_at DESC
|
|
53
|
+
) AS rn
|
|
54
|
+
FROM notification_deliveries
|
|
55
|
+
)
|
|
56
|
+
WHERE rn = 1
|
|
57
|
+
)
|
|
58
|
+
`);
|
|
59
|
+
} catch { /* deduplication failed — unique index creation below may fail too, which is non-fatal */ }
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_deliveries_decision_channel ON notification_deliveries(notification_decision_id, channel) WHERE notification_decision_id IS NOT NULL`);
|
|
63
|
+
} catch { /* index already exists or constraint violation — safe to continue */ }
|
|
64
|
+
}
|
|
37
65
|
}
|
|
@@ -9,38 +9,13 @@ import type { ToolDefinition } from '../../providers/types.js';
|
|
|
9
9
|
import { redactSecrets } from '../../security/secret-scanner.js';
|
|
10
10
|
import { getLogger } from '../../util/logger.js';
|
|
11
11
|
import { formatShellOutput } from '../shared/shell-output.js';
|
|
12
|
+
import { buildSanitizedEnv } from '../terminal/safe-env.js';
|
|
12
13
|
import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
|
|
13
14
|
|
|
14
15
|
const log = getLogger('host-shell-tool');
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
'HOME',
|
|
19
|
-
'TERM',
|
|
20
|
-
'LANG',
|
|
21
|
-
'EDITOR',
|
|
22
|
-
'SHELL',
|
|
23
|
-
'USER',
|
|
24
|
-
'TMPDIR',
|
|
25
|
-
'LC_ALL',
|
|
26
|
-
'LC_CTYPE',
|
|
27
|
-
'XDG_RUNTIME_DIR',
|
|
28
|
-
'DISPLAY',
|
|
29
|
-
'COLORTERM',
|
|
30
|
-
'TERM_PROGRAM',
|
|
31
|
-
'SSH_AUTH_SOCK',
|
|
32
|
-
'SSH_AGENT_PID',
|
|
33
|
-
'GPG_TTY',
|
|
34
|
-
'GNUPGHOME',
|
|
35
|
-
] as const;
|
|
36
|
-
|
|
37
|
-
function buildSanitizedEnv(): Record<string, string> {
|
|
38
|
-
const env: Record<string, string> = {};
|
|
39
|
-
for (const key of SAFE_ENV_VARS) {
|
|
40
|
-
if (process.env[key] != null) {
|
|
41
|
-
env[key] = process.env[key]!;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
17
|
+
function buildHostShellEnv(): Record<string, string> {
|
|
18
|
+
const env = buildSanitizedEnv();
|
|
44
19
|
// Ensure ~/.local/bin and ~/.bun/bin are in PATH so `vellum` and `bun` are
|
|
45
20
|
// always reachable, even when the daemon is launched from a macOS app
|
|
46
21
|
// bundle that inherits a minimal PATH.
|
|
@@ -125,7 +100,7 @@ class HostShellTool implements Tool {
|
|
|
125
100
|
|
|
126
101
|
const child = spawn('bash', ['-c', '--', command], {
|
|
127
102
|
cwd: workingDir,
|
|
128
|
-
env:
|
|
103
|
+
env: buildHostShellEnv(),
|
|
129
104
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
130
105
|
});
|
|
131
106
|
|
|
@@ -8,6 +8,7 @@ import { executeSwarm } from '../../swarm/orchestrator.js';
|
|
|
8
8
|
import { generatePlan } from '../../swarm/router-planner.js';
|
|
9
9
|
import { getLogger } from '../../util/logger.js';
|
|
10
10
|
import { truncate } from '../../util/truncate.js';
|
|
11
|
+
import { registerTool } from '../registry.js';
|
|
11
12
|
import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
|
|
12
13
|
|
|
13
14
|
const log = getLogger('swarm-delegate');
|
|
@@ -178,6 +179,8 @@ export const swarmDelegateTool: Tool = {
|
|
|
178
179
|
},
|
|
179
180
|
};
|
|
180
181
|
|
|
182
|
+
registerTool(swarmDelegateTool);
|
|
183
|
+
|
|
181
184
|
/** Clear all active sessions — only for testing. */
|
|
182
185
|
export function _resetSwarmActive(): void {
|
|
183
186
|
activeSessions.clear();
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Shared by the sandbox bash tool and skill sandbox runner.
|
|
7
7
|
*/
|
|
8
|
+
import { getGatewayInternalBaseUrl, getIngressPublicBaseUrl } from '../../config/env.js';
|
|
9
|
+
|
|
8
10
|
const SAFE_ENV_VARS = [
|
|
9
11
|
'PATH',
|
|
10
12
|
'HOME',
|
|
@@ -33,5 +35,12 @@ export function buildSanitizedEnv(): Record<string, string> {
|
|
|
33
35
|
env[key] = process.env[key]!;
|
|
34
36
|
}
|
|
35
37
|
}
|
|
38
|
+
// Always inject an internal gateway base for local control-plane/API calls.
|
|
39
|
+
const internalGatewayBase = getGatewayInternalBaseUrl();
|
|
40
|
+
env.INTERNAL_GATEWAY_BASE_URL = internalGatewayBase;
|
|
41
|
+
// Inject a public gateway base when ingress is configured; otherwise fall
|
|
42
|
+
// back to the internal base so commands remain functional in local-only mode.
|
|
43
|
+
const publicGatewayBase = getIngressPublicBaseUrl()?.replace(/\/+$/, '');
|
|
44
|
+
env.GATEWAY_BASE_URL = publicGatewayBase || internalGatewayBase;
|
|
36
45
|
return env;
|
|
37
46
|
}
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* so adding/removing tools only requires editing this manifest.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { RiskLevel } from '../permissions/types.js';
|
|
10
9
|
import { accountManageTool } from './credentials/account-registry.js';
|
|
11
10
|
import { credentialStoreTool } from './credentials/vault.js';
|
|
12
11
|
import { memorySaveTool, memorySearchTool, memoryUpdateTool } from './memory/register.js';
|
|
@@ -20,22 +19,34 @@ import type { Tool } from './types.js';
|
|
|
20
19
|
import { screenWatchTool } from './watch/screen-watch.js';
|
|
21
20
|
|
|
22
21
|
// ── Eager side-effect modules ───────────────────────────────────────
|
|
23
|
-
//
|
|
22
|
+
// These static imports trigger top-level `registerTool()` side effects.
|
|
23
|
+
//
|
|
24
|
+
// IMPORTANT: These MUST be static imports (not dynamic `await import()`).
|
|
25
|
+
// When the daemon is compiled with `bun --compile`, dynamic imports with
|
|
26
|
+
// relative string literals resolve against the virtual `/$bunfs/root/`
|
|
27
|
+
// filesystem root rather than the module's own directory, causing
|
|
28
|
+
// "Cannot find module './filesystem/read.js'" crashes in production builds.
|
|
29
|
+
// Static imports are resolved at bundle time and are always safe.
|
|
30
|
+
import './assets/materialize.js';
|
|
31
|
+
import './assets/search.js';
|
|
32
|
+
import './filesystem/edit.js';
|
|
33
|
+
import './filesystem/read.js';
|
|
34
|
+
import './filesystem/view-image.js';
|
|
35
|
+
import './filesystem/write.js';
|
|
36
|
+
import './network/web-fetch.js';
|
|
37
|
+
import './network/web-search.js';
|
|
38
|
+
import './skills/delete-managed.js';
|
|
39
|
+
import './skills/load.js';
|
|
40
|
+
import './skills/scaffold-managed.js';
|
|
41
|
+
import './swarm/delegate.js';
|
|
42
|
+
import './system/request-permission.js';
|
|
43
|
+
import './system/version.js';
|
|
44
|
+
import './terminal/shell.js';
|
|
24
45
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
await import('./network/web-search.js');
|
|
30
|
-
await import('./network/web-fetch.js');
|
|
31
|
-
await import('./skills/load.js');
|
|
32
|
-
await import('./skills/scaffold-managed.js');
|
|
33
|
-
await import('./skills/delete-managed.js');
|
|
34
|
-
await import('./system/request-permission.js');
|
|
35
|
-
await import('./assets/search.js');
|
|
36
|
-
await import('./assets/materialize.js');
|
|
37
|
-
await import('./filesystem/view-image.js');
|
|
38
|
-
await import('./system/version.js');
|
|
46
|
+
// loadEagerModules is a no-op now that all eager registrations happen via
|
|
47
|
+
// static imports above. Kept for API compatibility with registry.ts callers.
|
|
48
|
+
export function loadEagerModules(): Promise<void> {
|
|
49
|
+
return Promise.resolve();
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
// Tool names registered by the eager modules above. Listed explicitly so
|
|
@@ -43,6 +54,7 @@ export async function loadEagerModules(): Promise<void> {
|
|
|
43
54
|
// already in the registry before init ran (e.g. when a test file imports
|
|
44
55
|
// an eager module at the top level).
|
|
45
56
|
export const eagerModuleToolNames: string[] = [
|
|
57
|
+
'bash',
|
|
46
58
|
'file_read',
|
|
47
59
|
'file_write',
|
|
48
60
|
'file_edit',
|
|
@@ -54,6 +66,7 @@ export const eagerModuleToolNames: string[] = [
|
|
|
54
66
|
'request_system_permission',
|
|
55
67
|
'asset_search',
|
|
56
68
|
'asset_materialize',
|
|
69
|
+
'swarm_delegate',
|
|
57
70
|
'view_image',
|
|
58
71
|
'version',
|
|
59
72
|
];
|
|
@@ -78,76 +91,8 @@ export const explicitTools: Tool[] = [
|
|
|
78
91
|
|
|
79
92
|
// ── Lazy tool descriptors ───────────────────────────────────────────
|
|
80
93
|
// Tools that defer module loading until first invocation.
|
|
94
|
+
// bash and swarm_delegate were previously lazy but are now eagerly registered
|
|
95
|
+
// via side-effect imports above, preserving their full definitions (including
|
|
96
|
+
// the `reason` field on bash) and fixing bun --compile module-not-found crashes.
|
|
81
97
|
|
|
82
|
-
export const lazyTools: LazyToolDescriptor[] = [
|
|
83
|
-
{
|
|
84
|
-
name: 'bash',
|
|
85
|
-
description: 'Execute a shell command on the local machine',
|
|
86
|
-
category: 'terminal',
|
|
87
|
-
defaultRiskLevel: RiskLevel.Medium,
|
|
88
|
-
definition: {
|
|
89
|
-
name: 'bash',
|
|
90
|
-
description: 'Execute a shell command on the local machine',
|
|
91
|
-
input_schema: {
|
|
92
|
-
type: 'object',
|
|
93
|
-
properties: {
|
|
94
|
-
command: {
|
|
95
|
-
type: 'string',
|
|
96
|
-
description: 'The shell command to execute',
|
|
97
|
-
},
|
|
98
|
-
timeout_seconds: {
|
|
99
|
-
type: 'number',
|
|
100
|
-
description: 'Optional timeout in seconds. Defaults to the configured default (120s). Cannot exceed the configured maximum.',
|
|
101
|
-
},
|
|
102
|
-
network_mode: {
|
|
103
|
-
type: 'string',
|
|
104
|
-
enum: ['off', 'proxied'],
|
|
105
|
-
description: 'Network access mode for the command. "off" (default) blocks network access; "proxied" routes traffic through the credential proxy.',
|
|
106
|
-
},
|
|
107
|
-
credential_ids: {
|
|
108
|
-
type: 'array',
|
|
109
|
-
items: { type: 'string' },
|
|
110
|
-
description: 'Optional list of credential IDs to inject via the proxy when network_mode is "proxied".',
|
|
111
|
-
},
|
|
112
|
-
},
|
|
113
|
-
required: ['command'],
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
loader: async () => {
|
|
117
|
-
const mod = await import('./terminal/shell.js');
|
|
118
|
-
return mod.shellTool;
|
|
119
|
-
},
|
|
120
|
-
},
|
|
121
|
-
{
|
|
122
|
-
name: 'swarm_delegate',
|
|
123
|
-
description: 'Decompose a complex task into parallel specialist subtasks and execute them concurrently.',
|
|
124
|
-
category: 'orchestration',
|
|
125
|
-
defaultRiskLevel: RiskLevel.Medium,
|
|
126
|
-
definition: {
|
|
127
|
-
name: 'swarm_delegate',
|
|
128
|
-
description: 'Decompose a complex task into parallel specialist subtasks and execute them concurrently. Use this for multi-part tasks that benefit from parallel research, coding, and review.',
|
|
129
|
-
input_schema: {
|
|
130
|
-
type: 'object',
|
|
131
|
-
properties: {
|
|
132
|
-
objective: {
|
|
133
|
-
type: 'string',
|
|
134
|
-
description: 'The complex task to decompose and execute in parallel',
|
|
135
|
-
},
|
|
136
|
-
context: {
|
|
137
|
-
type: 'string',
|
|
138
|
-
description: 'Optional additional context about the task or codebase',
|
|
139
|
-
},
|
|
140
|
-
max_workers: {
|
|
141
|
-
type: 'number',
|
|
142
|
-
description: 'Maximum concurrent workers (1-6, default from config)',
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
|
-
required: ['objective'],
|
|
146
|
-
},
|
|
147
|
-
},
|
|
148
|
-
loader: async () => {
|
|
149
|
-
const mod = await import('./swarm/delegate.js');
|
|
150
|
-
return mod.swarmDelegateTool;
|
|
151
|
-
},
|
|
152
|
-
},
|
|
153
|
-
];
|
|
98
|
+
export const lazyTools: LazyToolDescriptor[] = [];
|